summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorDaniel Izquierdo <daniel@makeleaps.com>2016-10-10 16:23:35 +0900
committerMariusz Felisiak <felisiak.mariusz@gmail.com>2019-11-19 10:55:05 +0100
commit89abecc75d7eadab681f29be5237972c6c2f997b (patch)
tree0f0f5bcf8480308dec2899536ae9ce11b3bb555a /tests
parent4e1d809aa570c0e0736587672607f9d6c22e42c9 (diff)
Fixed #27272 -- Added an on_delete RESTRICT handler to allow cascading deletions while protecting direct ones.
Diffstat (limited to 'tests')
-rw-r--r--tests/admin_views/admin.py5
-rw-r--r--tests/admin_views/models.py8
-rw-r--r--tests/admin_views/tests.py31
-rw-r--r--tests/delete/models.py57
-rw-r--r--tests/delete/tests.py88
5 files changed, 180 insertions, 9 deletions
diff --git a/tests/admin_views/admin.py b/tests/admin_views/admin.py
index 4f39381783..6a11d5b9bb 100644
--- a/tests/admin_views/admin.py
+++ b/tests/admin_views/admin.py
@@ -41,8 +41,8 @@ from .models import (
ReferencedByGenRel, ReferencedByInline, ReferencedByParent,
RelatedPrepopulated, RelatedWithUUIDPKModel, Report, Reservation,
Restaurant, RowLevelChangePermissionModel, Section, ShortMessage, Simple,
- Sketch, State, Story, StumpJoke, Subscriber, SuperVillain, Telegram, Thing,
- Topping, UnchangeableObject, UndeletableObject, UnorderedObject,
+ Sketch, Song, State, Story, StumpJoke, Subscriber, SuperVillain, Telegram,
+ Thing, Topping, UnchangeableObject, UndeletableObject, UnorderedObject,
UserMessenger, UserProxy, Villain, Vodcast, Whatsit, Widget, Worker,
WorkHour,
)
@@ -1069,6 +1069,7 @@ site.register(ReadOnlyPizza, ReadOnlyPizzaAdmin)
site.register(ReadablePizza)
site.register(Topping, ToppingAdmin)
site.register(Album, AlbumAdmin)
+site.register(Song)
site.register(Question, QuestionAdmin)
site.register(Answer, AnswerAdmin, date_hierarchy='question__posted')
site.register(Answer2, date_hierarchy='question__expires')
diff --git a/tests/admin_views/models.py b/tests/admin_views/models.py
index a519f7395d..16dd58bcd3 100644
--- a/tests/admin_views/models.py
+++ b/tests/admin_views/models.py
@@ -604,6 +604,14 @@ class Album(models.Model):
title = models.CharField(max_length=30)
+class Song(models.Model):
+ name = models.CharField(max_length=20)
+ album = models.ForeignKey(Album, on_delete=models.RESTRICT)
+
+ def __str__(self):
+ return self.name
+
+
class Employee(Person):
code = models.CharField(max_length=20)
diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py
index 9709bcaf88..e833c44f95 100644
--- a/tests/admin_views/tests.py
+++ b/tests/admin_views/tests.py
@@ -38,7 +38,7 @@ from . import customadmin
from .admin import CityAdmin, site, site2
from .models import (
Actor, AdminOrderedAdminMethod, AdminOrderedCallable, AdminOrderedField,
- AdminOrderedModelMethod, Answer, Answer2, Article, BarAccount, Book,
+ AdminOrderedModelMethod, Album, Answer, Answer2, Article, BarAccount, Book,
Bookmark, Category, Chapter, ChapterXtra1, ChapterXtra2, Character, Child,
Choice, City, Collector, Color, ComplexSortedPerson, CoverLetter,
CustomArticle, CyclicOne, CyclicTwo, DooHickey, Employee, EmptyModel,
@@ -50,7 +50,7 @@ from .models import (
PrePopulatedPost, Promo, Question, ReadablePizza, ReadOnlyPizza,
Recommendation, Recommender, RelatedPrepopulated, RelatedWithUUIDPKModel,
Report, Restaurant, RowLevelChangePermissionModel, SecretHideout, Section,
- ShortMessage, Simple, State, Story, SuperSecretHideout, SuperVillain,
+ ShortMessage, Simple, Song, State, Story, SuperSecretHideout, SuperVillain,
Telegram, TitleTranslation, Topping, UnchangeableObject, UndeletableObject,
UnorderedObject, UserProxy, Villain, Vodcast, Whatsit, Widget, Worker,
WorkHour,
@@ -2603,6 +2603,33 @@ class AdminViewDeletedObjectsTest(TestCase):
self.assertEqual(Question.objects.count(), 1)
self.assertContains(response, "would require deleting the following protected related objects")
+ def test_restricted(self):
+ album = Album.objects.create(title='Amaryllis')
+ song = Song.objects.create(album=album, name='Unity')
+ response = self.client.get(reverse('admin:admin_views_album_delete', args=(album.pk,)))
+ self.assertContains(
+ response,
+ 'would require deleting the following protected related objects',
+ )
+ self.assertContains(
+ response,
+ '<li>Song: <a href="%s">Unity</a></li>'
+ % reverse('admin:admin_views_song_change', args=(song.pk,))
+ )
+
+ def test_post_delete_restricted(self):
+ album = Album.objects.create(title='Amaryllis')
+ Song.objects.create(album=album, name='Unity')
+ response = self.client.post(
+ reverse('admin:admin_views_album_delete', args=(album.pk,)),
+ {'post': 'yes'},
+ )
+ self.assertEqual(Album.objects.count(), 1)
+ self.assertContains(
+ response,
+ 'would require deleting the following protected related objects',
+ )
+
def test_not_registered(self):
should_contain = """<li>Secret hideout: underground bunker"""
response = self.client.get(reverse('admin:admin_views_villain_delete', args=(self.v1.pk,)))
diff --git a/tests/delete/models.py b/tests/delete/models.py
index e2ddce0588..7a38ecc777 100644
--- a/tests/delete/models.py
+++ b/tests/delete/models.py
@@ -1,8 +1,17 @@
+from django.contrib.contenttypes.fields import (
+ GenericForeignKey, GenericRelation,
+)
+from django.contrib.contenttypes.models import ContentType
from django.db import models
+class P(models.Model):
+ pass
+
+
class R(models.Model):
is_default = models.BooleanField(default=False)
+ p = models.ForeignKey(P, models.CASCADE, null=True)
def __str__(self):
return "%s" % self.pk
@@ -46,10 +55,12 @@ class A(models.Model):
)
cascade = models.ForeignKey(R, models.CASCADE, related_name='cascade_set')
cascade_nullable = models.ForeignKey(R, models.CASCADE, null=True, related_name='cascade_nullable_set')
- protect = models.ForeignKey(R, models.PROTECT, null=True)
+ protect = models.ForeignKey(R, models.PROTECT, null=True, related_name='protect_set')
+ restrict = models.ForeignKey(R, models.RESTRICT, null=True, related_name='restrict_set')
donothing = models.ForeignKey(R, models.DO_NOTHING, null=True, related_name='donothing_set')
child = models.ForeignKey(RChild, models.CASCADE, related_name="child")
child_setnull = models.ForeignKey(RChild, models.SET_NULL, null=True, related_name="child_setnull")
+ cascade_p = models.ForeignKey(P, models.CASCADE, related_name='cascade_p_set', null=True)
# A OneToOneField is just a ForeignKey unique=True, so we don't duplicate
# all the tests; just one smoke test to ensure on_delete works for it as
@@ -61,7 +72,7 @@ def create_a(name):
a = A(name=name)
for name in ('auto', 'auto_nullable', 'setvalue', 'setnull', 'setdefault',
'setdefault_none', 'cascade', 'cascade_nullable', 'protect',
- 'donothing', 'o2o_setnull'):
+ 'restrict', 'donothing', 'o2o_setnull'):
r = R.objects.create()
setattr(a, name, r)
a.child = RChild.objects.create()
@@ -147,3 +158,45 @@ class SecondReferrer(models.Model):
other_referrer = models.ForeignKey(
Referrer, models.CASCADE, to_field='unique_field', related_name='+'
)
+
+
+class DeleteTop(models.Model):
+ b1 = GenericRelation('GenericB1')
+ b2 = GenericRelation('GenericB2')
+
+
+class B1(models.Model):
+ delete_top = models.ForeignKey(DeleteTop, models.CASCADE)
+
+
+class B2(models.Model):
+ delete_top = models.ForeignKey(DeleteTop, models.CASCADE)
+
+
+class DeleteBottom(models.Model):
+ b1 = models.ForeignKey(B1, models.RESTRICT)
+ b2 = models.ForeignKey(B2, models.CASCADE)
+
+
+class GenericB1(models.Model):
+ content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
+ object_id = models.PositiveIntegerField()
+ generic_delete_top = GenericForeignKey('content_type', 'object_id')
+
+
+class GenericB2(models.Model):
+ content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
+ object_id = models.PositiveIntegerField()
+ generic_delete_top = GenericForeignKey('content_type', 'object_id')
+ generic_delete_bottom = GenericRelation('GenericDeleteBottom')
+
+
+class GenericDeleteBottom(models.Model):
+ generic_b1 = models.ForeignKey(GenericB1, models.RESTRICT)
+ content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
+ object_id = models.PositiveIntegerField()
+ generic_b2 = GenericForeignKey()
+
+
+class GenericDeleteBottomParent(models.Model):
+ generic_delete_bottom = models.ForeignKey(GenericDeleteBottom, on_delete=models.CASCADE)
diff --git a/tests/delete/tests.py b/tests/delete/tests.py
index d61fdb40c1..b93bdd5265 100644
--- a/tests/delete/tests.py
+++ b/tests/delete/tests.py
@@ -1,13 +1,14 @@
from math import ceil
from django.db import IntegrityError, connection, models
-from django.db.models.deletion import Collector
+from django.db.models.deletion import Collector, RestrictedError
from django.db.models.sql.constants import GET_ITERATOR_CHUNK_SIZE
from django.test import TestCase, skipIfDBFeature, skipUnlessDBFeature
from .models import (
- MR, A, Avatar, Base, Child, HiddenUser, HiddenUserProfile, M, M2MFrom,
- M2MTo, MRNull, Origin, Parent, R, RChild, RChildChild, Referrer, S, T,
+ B1, B2, MR, A, Avatar, Base, Child, DeleteBottom, DeleteTop, GenericB1,
+ GenericB2, GenericDeleteBottom, HiddenUser, HiddenUserProfile, M, M2MFrom,
+ M2MTo, MRNull, Origin, P, Parent, R, RChild, RChildChild, Referrer, S, T,
User, create_a, get_default_r,
)
@@ -146,6 +147,87 @@ class OnDeleteTests(TestCase):
a = A.objects.get(pk=a.pk)
self.assertIsNone(a.o2o_setnull)
+ def test_restrict(self):
+ a = create_a('restrict')
+ msg = (
+ "Cannot delete some instances of model 'R' because they are "
+ "referenced through a restricted foreign key: 'A.restrict'."
+ )
+ with self.assertRaisesMessage(RestrictedError, msg):
+ a.restrict.delete()
+
+ def test_restrict_path_cascade_indirect(self):
+ a = create_a('restrict')
+ a.restrict.p = P.objects.create()
+ a.restrict.save()
+ msg = (
+ "Cannot delete some instances of model 'R' because they are "
+ "referenced through a restricted foreign key: 'A.restrict'."
+ )
+ with self.assertRaisesMessage(RestrictedError, msg):
+ a.restrict.p.delete()
+ # Object referenced also with CASCADE relationship can be deleted.
+ a.cascade.p = a.restrict.p
+ a.cascade.save()
+ a.restrict.p.delete()
+ self.assertFalse(A.objects.filter(name='restrict').exists())
+ self.assertFalse(R.objects.filter(pk=a.restrict_id).exists())
+
+ def test_restrict_path_cascade_direct(self):
+ a = create_a('restrict')
+ a.restrict.p = P.objects.create()
+ a.restrict.save()
+ a.cascade_p = a.restrict.p
+ a.save()
+ a.restrict.p.delete()
+ self.assertFalse(A.objects.filter(name='restrict').exists())
+ self.assertFalse(R.objects.filter(pk=a.restrict_id).exists())
+
+ def test_restrict_path_cascade_indirect_diamond(self):
+ delete_top = DeleteTop.objects.create()
+ b1 = B1.objects.create(delete_top=delete_top)
+ b2 = B2.objects.create(delete_top=delete_top)
+ DeleteBottom.objects.create(b1=b1, b2=b2)
+ msg = (
+ "Cannot delete some instances of model 'B1' because they are "
+ "referenced through a restricted foreign key: 'DeleteBottom.b1'."
+ )
+ with self.assertRaisesMessage(RestrictedError, msg):
+ b1.delete()
+ self.assertTrue(DeleteTop.objects.exists())
+ self.assertTrue(B1.objects.exists())
+ self.assertTrue(B2.objects.exists())
+ self.assertTrue(DeleteBottom.objects.exists())
+ # Object referenced also with CASCADE relationship can be deleted.
+ delete_top.delete()
+ self.assertFalse(DeleteTop.objects.exists())
+ self.assertFalse(B1.objects.exists())
+ self.assertFalse(B2.objects.exists())
+ self.assertFalse(DeleteBottom.objects.exists())
+
+ def test_restrict_gfk_no_fast_delete(self):
+ delete_top = DeleteTop.objects.create()
+ generic_b1 = GenericB1.objects.create(generic_delete_top=delete_top)
+ generic_b2 = GenericB2.objects.create(generic_delete_top=delete_top)
+ GenericDeleteBottom.objects.create(generic_b1=generic_b1, generic_b2=generic_b2)
+ msg = (
+ "Cannot delete some instances of model 'GenericB1' because they "
+ "are referenced through a restricted foreign key: "
+ "'GenericDeleteBottom.generic_b1'."
+ )
+ with self.assertRaisesMessage(RestrictedError, msg):
+ generic_b1.delete()
+ self.assertTrue(DeleteTop.objects.exists())
+ self.assertTrue(GenericB1.objects.exists())
+ self.assertTrue(GenericB2.objects.exists())
+ self.assertTrue(GenericDeleteBottom.objects.exists())
+ # Object referenced also with CASCADE relationship can be deleted.
+ delete_top.delete()
+ self.assertFalse(DeleteTop.objects.exists())
+ self.assertFalse(GenericB1.objects.exists())
+ self.assertFalse(GenericB2.objects.exists())
+ self.assertFalse(GenericDeleteBottom.objects.exists())
+
class DeletionTests(TestCase):