481 lines
18 KiB
Python
481 lines
18 KiB
Python
|
|
from io import StringIO
|
||
|
|
|
||
|
|
from django.contrib.contenttypes.models import ContentType
|
||
|
|
from django.core import management
|
||
|
|
from django.test import TestCase
|
||
|
|
from django.utils.functional import SimpleLazyObject
|
||
|
|
|
||
|
|
from wagtail.blocks import StreamValue, StructValue
|
||
|
|
from wagtail.documents import get_document_model
|
||
|
|
from wagtail.documents.tests.utils import get_test_document_file
|
||
|
|
from wagtail.images import get_image_model
|
||
|
|
from wagtail.images.tests.utils import get_test_image_file
|
||
|
|
from wagtail.models import Page, ReferenceIndex
|
||
|
|
from wagtail.rich_text import RichText
|
||
|
|
from wagtail.test.testapp.models import (
|
||
|
|
Advert,
|
||
|
|
AdvertWithCustomUUIDPrimaryKey,
|
||
|
|
EventPage,
|
||
|
|
EventPageCarouselItem,
|
||
|
|
EventPageRelatedLink,
|
||
|
|
GenericSnippetNoFieldIndexPage,
|
||
|
|
GenericSnippetNoIndexPage,
|
||
|
|
GenericSnippetPage,
|
||
|
|
ModelWithNullableParentalKey,
|
||
|
|
VariousOnDeleteModel,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestCreateOrUpdateForObject(TestCase):
|
||
|
|
def setUp(self):
|
||
|
|
image_model = get_image_model()
|
||
|
|
self.image_content_type = ContentType.objects.get_for_model(image_model)
|
||
|
|
|
||
|
|
self.test_feed_image = image_model.objects.create(
|
||
|
|
title="Test feed image",
|
||
|
|
file=get_test_image_file(),
|
||
|
|
)
|
||
|
|
self.test_image_1 = image_model.objects.create(
|
||
|
|
title="Test image 1",
|
||
|
|
file=get_test_image_file(),
|
||
|
|
)
|
||
|
|
self.test_image_2 = image_model.objects.create(
|
||
|
|
title="Test image 2",
|
||
|
|
file=get_test_image_file(),
|
||
|
|
)
|
||
|
|
|
||
|
|
# Add event page
|
||
|
|
self.event_page = EventPage(
|
||
|
|
title="Event page",
|
||
|
|
slug="event-page",
|
||
|
|
location="the moon",
|
||
|
|
audience="public",
|
||
|
|
cost="free",
|
||
|
|
date_from="2001-01-01",
|
||
|
|
feed_image=self.test_feed_image,
|
||
|
|
)
|
||
|
|
self.event_page.carousel_items = [
|
||
|
|
EventPageCarouselItem(
|
||
|
|
caption="1234567", image=self.test_image_1, sort_order=1
|
||
|
|
),
|
||
|
|
EventPageCarouselItem(
|
||
|
|
caption="7654321", image=self.test_image_2, sort_order=2
|
||
|
|
),
|
||
|
|
EventPageCarouselItem(
|
||
|
|
caption="abcdefg", image=self.test_image_1, sort_order=3
|
||
|
|
),
|
||
|
|
]
|
||
|
|
self.root_page = Page.objects.get(id=2)
|
||
|
|
self.root_page.add_child(instance=self.event_page)
|
||
|
|
|
||
|
|
self.expected_references = {
|
||
|
|
(
|
||
|
|
self.image_content_type.id,
|
||
|
|
str(self.test_feed_image.pk),
|
||
|
|
"feed_image",
|
||
|
|
"feed_image",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
self.image_content_type.id,
|
||
|
|
str(self.test_image_1.pk),
|
||
|
|
"carousel_items.item.image",
|
||
|
|
f"carousel_items.{self.event_page.carousel_items.get(sort_order=1).id}.image",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
self.image_content_type.id,
|
||
|
|
str(self.test_image_2.pk),
|
||
|
|
"carousel_items.item.image",
|
||
|
|
f"carousel_items.{self.event_page.carousel_items.get(sort_order=2).id}.image",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
self.image_content_type.id,
|
||
|
|
str(self.test_image_1.pk),
|
||
|
|
"carousel_items.item.image",
|
||
|
|
f"carousel_items.{self.event_page.carousel_items.get(sort_order=3).id}.image",
|
||
|
|
),
|
||
|
|
}
|
||
|
|
|
||
|
|
def test(self):
|
||
|
|
self.assertSetEqual(
|
||
|
|
set(
|
||
|
|
ReferenceIndex.get_references_for_object(self.event_page).values_list(
|
||
|
|
"to_content_type", "to_object_id", "model_path", "content_path"
|
||
|
|
)
|
||
|
|
),
|
||
|
|
self.expected_references,
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_update(self):
|
||
|
|
reference_to_keep = ReferenceIndex.objects.get(
|
||
|
|
base_content_type=ReferenceIndex._get_base_content_type(self.event_page),
|
||
|
|
content_type=ContentType.objects.get_for_model(self.event_page),
|
||
|
|
content_path="feed_image",
|
||
|
|
)
|
||
|
|
reference_to_remove = ReferenceIndex.objects.create(
|
||
|
|
base_content_type=ReferenceIndex._get_base_content_type(self.event_page),
|
||
|
|
content_type=ContentType.objects.get_for_model(self.event_page),
|
||
|
|
object_id=self.event_page.pk,
|
||
|
|
to_content_type=self.image_content_type,
|
||
|
|
to_object_id=self.test_image_1.pk,
|
||
|
|
model_path="hero_image", # Field doesn't exist
|
||
|
|
content_path="hero_image",
|
||
|
|
content_path_hash=ReferenceIndex._get_content_path_hash("hero_image"),
|
||
|
|
)
|
||
|
|
|
||
|
|
ReferenceIndex.create_or_update_for_object(self.event_page)
|
||
|
|
|
||
|
|
# Check that the record for the reference to be kept has been preserved/reused
|
||
|
|
self.assertTrue(ReferenceIndex.objects.filter(id=reference_to_keep.id).exists())
|
||
|
|
|
||
|
|
# Check that the record for the reference to be removed has been deleted
|
||
|
|
self.assertFalse(
|
||
|
|
ReferenceIndex.objects.filter(id=reference_to_remove.id).exists()
|
||
|
|
)
|
||
|
|
|
||
|
|
# Check that the current stored references are correct
|
||
|
|
self.assertSetEqual(
|
||
|
|
set(
|
||
|
|
ReferenceIndex.get_references_for_object(self.event_page).values_list(
|
||
|
|
"to_content_type", "to_object_id", "model_path", "content_path"
|
||
|
|
)
|
||
|
|
),
|
||
|
|
{
|
||
|
|
(
|
||
|
|
self.image_content_type.id,
|
||
|
|
str(self.test_feed_image.pk),
|
||
|
|
"feed_image",
|
||
|
|
"feed_image",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
self.image_content_type.id,
|
||
|
|
str(self.test_image_1.pk),
|
||
|
|
"carousel_items.item.image",
|
||
|
|
f"carousel_items.{self.event_page.carousel_items.get(sort_order=1).id}.image",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
self.image_content_type.id,
|
||
|
|
str(self.test_image_2.pk),
|
||
|
|
"carousel_items.item.image",
|
||
|
|
f"carousel_items.{self.event_page.carousel_items.get(sort_order=2).id}.image",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
self.image_content_type.id,
|
||
|
|
str(self.test_image_1.pk),
|
||
|
|
"carousel_items.item.image",
|
||
|
|
f"carousel_items.{self.event_page.carousel_items.get(sort_order=3).id}.image",
|
||
|
|
),
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_saving_base_model_does_not_remove_references(self):
|
||
|
|
page = Page.objects.get(pk=self.event_page.pk)
|
||
|
|
page.save()
|
||
|
|
self.assertSetEqual(
|
||
|
|
set(
|
||
|
|
ReferenceIndex.get_references_for_object(self.event_page).values_list(
|
||
|
|
"to_content_type", "to_object_id", "model_path", "content_path"
|
||
|
|
)
|
||
|
|
),
|
||
|
|
self.expected_references,
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_null_parental_key(self):
|
||
|
|
obj = ModelWithNullableParentalKey(
|
||
|
|
content="""<p><a linktype="page" id="%d">event page</a></p>"""
|
||
|
|
% self.event_page.id
|
||
|
|
)
|
||
|
|
obj.save()
|
||
|
|
|
||
|
|
# Models with a ParentalKey are not considered indexable - references are recorded against the parent model
|
||
|
|
# instead. Since the ParentalKey is null here, no reference will be recorded.
|
||
|
|
refs = ReferenceIndex.get_references_to(self.event_page)
|
||
|
|
self.assertEqual(refs.count(), 0)
|
||
|
|
|
||
|
|
def test_lazy_parental_key(self):
|
||
|
|
event_page_related_link = EventPageRelatedLink()
|
||
|
|
# The parent model is a lazy object
|
||
|
|
event_page_related_link.page = SimpleLazyObject(lambda: self.event_page)
|
||
|
|
event_page_related_link.link_page = self.root_page
|
||
|
|
event_page_related_link.save()
|
||
|
|
refs = ReferenceIndex.get_references_to(self.root_page)
|
||
|
|
self.assertEqual(refs.count(), 1)
|
||
|
|
|
||
|
|
def test_generic_foreign_key(self):
|
||
|
|
page1 = GenericSnippetPage(
|
||
|
|
title="generic snippet page", snippet_content_object=self.event_page
|
||
|
|
)
|
||
|
|
self.root_page.add_child(instance=page1)
|
||
|
|
page2 = GenericSnippetPage(
|
||
|
|
title="generic snippet page", snippet_content_object=None
|
||
|
|
)
|
||
|
|
self.root_page.add_child(instance=page2)
|
||
|
|
|
||
|
|
refs = ReferenceIndex.get_references_to(self.event_page)
|
||
|
|
self.assertEqual(refs.count(), 1)
|
||
|
|
|
||
|
|
def test_model_index_ignore_generic_foreign_key(self):
|
||
|
|
page1 = GenericSnippetNoIndexPage(
|
||
|
|
title="generic snippet page", snippet_content_object=self.event_page
|
||
|
|
)
|
||
|
|
self.root_page.add_child(instance=page1)
|
||
|
|
page2 = GenericSnippetNoIndexPage(
|
||
|
|
title="generic snippet page", snippet_content_object=None
|
||
|
|
)
|
||
|
|
self.root_page.add_child(instance=page2)
|
||
|
|
|
||
|
|
# There should be no references
|
||
|
|
refs = ReferenceIndex.get_references_to(self.event_page)
|
||
|
|
self.assertEqual(refs.count(), 0)
|
||
|
|
|
||
|
|
def test_model_field_index_ignore_generic_foreign_key(self):
|
||
|
|
content_type = ContentType.objects.get_for_model(self.event_page)
|
||
|
|
page1 = GenericSnippetNoFieldIndexPage(
|
||
|
|
title="generic snippet page", snippet_content_type_nonindexed=content_type
|
||
|
|
)
|
||
|
|
self.root_page.add_child(instance=page1)
|
||
|
|
page2 = GenericSnippetNoFieldIndexPage(
|
||
|
|
title="generic snippet page", snippet_content_type_nonindexed=None
|
||
|
|
)
|
||
|
|
self.root_page.add_child(instance=page2)
|
||
|
|
|
||
|
|
# There should be no references
|
||
|
|
refs = ReferenceIndex.get_references_to(content_type)
|
||
|
|
self.assertEqual(refs.count(), 0)
|
||
|
|
|
||
|
|
def test_rebuild_references_index_no_verbosity(self):
|
||
|
|
stdout = StringIO()
|
||
|
|
management.call_command(
|
||
|
|
"rebuild_references_index",
|
||
|
|
verbosity=0,
|
||
|
|
stdout=stdout,
|
||
|
|
)
|
||
|
|
self.assertFalse(stdout.getvalue())
|
||
|
|
|
||
|
|
def test_show_references_index(self):
|
||
|
|
stdout = StringIO()
|
||
|
|
management.call_command(
|
||
|
|
"show_references_index",
|
||
|
|
stdout=stdout,
|
||
|
|
)
|
||
|
|
self.assertIn(" 3 wagtail.images.models.Image", stdout.getvalue())
|
||
|
|
self.assertIn(" 4 wagtail.test.testapp.models.EventPage", stdout.getvalue())
|
||
|
|
|
||
|
|
|
||
|
|
class TestDescribeOnDelete(TestCase):
|
||
|
|
fixtures = ["test.json"]
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def setUpTestData(cls):
|
||
|
|
management.call_command("rebuild_references_index", stdout=StringIO())
|
||
|
|
|
||
|
|
def setUp(self):
|
||
|
|
field = VariousOnDeleteModel._meta.get_field("stream_field")
|
||
|
|
advertisement_content = field.stream_block.child_blocks["advertisement_content"]
|
||
|
|
captioned_advert = advertisement_content.child_blocks["captioned_advert"]
|
||
|
|
|
||
|
|
self.advert = Advert.objects.create(text="An advertisement")
|
||
|
|
self.advert_uuid = AdvertWithCustomUUIDPrimaryKey.objects.create(
|
||
|
|
text="A UUID advertisement"
|
||
|
|
)
|
||
|
|
|
||
|
|
self.page = EventPage.objects.first()
|
||
|
|
page_link = f'<p>Link to <a id="{self.page.id}" linktype="page">a page</a></p>'
|
||
|
|
|
||
|
|
self.image = get_image_model().objects.create(
|
||
|
|
title="My image",
|
||
|
|
file=get_test_image_file(),
|
||
|
|
)
|
||
|
|
|
||
|
|
self.document = get_document_model().objects.create(
|
||
|
|
title="My document",
|
||
|
|
file=get_test_document_file(),
|
||
|
|
)
|
||
|
|
|
||
|
|
# Each case is a tuple of (
|
||
|
|
# VariousOnDeleteModel init kwargs,
|
||
|
|
# referred object,
|
||
|
|
# expected field description,
|
||
|
|
# expected on delete description,
|
||
|
|
# )
|
||
|
|
self.cases = [
|
||
|
|
# References from ForeignKey
|
||
|
|
(
|
||
|
|
{"text": "on_delete=CASCADE", "on_delete_cascade": self.advert},
|
||
|
|
self.advert,
|
||
|
|
"On delete cascade",
|
||
|
|
"the various on delete model will also be deleted",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
{"text": "on_delete=PROTECT", "on_delete_protect": self.advert},
|
||
|
|
self.advert,
|
||
|
|
"On delete protect",
|
||
|
|
"prevents deletion",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
{"text": "on_delete=RESTRICT", "on_delete_restrict": self.advert},
|
||
|
|
self.advert,
|
||
|
|
"On delete restrict",
|
||
|
|
"may prevent deletion",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
{"text": "on_delete=SET_NULL", "on_delete_set_null": self.advert},
|
||
|
|
self.advert,
|
||
|
|
"On delete set null",
|
||
|
|
"will unset the reference",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
{"text": "on_delete=SET_DEFAULT", "on_delete_set_default": self.advert},
|
||
|
|
self.advert,
|
||
|
|
"On delete set default",
|
||
|
|
"will be set to the default various on delete model",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
{"text": "on_delete=SET", "on_delete_set": self.advert},
|
||
|
|
self.advert,
|
||
|
|
"On delete set",
|
||
|
|
"will be set to a various on delete model specified by the system",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
{"text": "on_delete=DO_NOTHING", "on_delete_do_nothing": self.advert},
|
||
|
|
self.advert,
|
||
|
|
"On delete do nothing",
|
||
|
|
"will do nothing",
|
||
|
|
),
|
||
|
|
# References from GenericForeignKey
|
||
|
|
(
|
||
|
|
{"text": "GenericForeignKey", "content_object": self.advert_uuid},
|
||
|
|
self.advert_uuid,
|
||
|
|
"Content object",
|
||
|
|
"will unset the reference",
|
||
|
|
),
|
||
|
|
# References from RichTextField
|
||
|
|
(
|
||
|
|
{"text": "RichTextField model field", "rich_text": page_link},
|
||
|
|
self.page,
|
||
|
|
"Rich text",
|
||
|
|
"will unset the reference",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
{
|
||
|
|
"text": "deep RichTextBlock",
|
||
|
|
"stream_field": StreamValue(
|
||
|
|
field.stream_block,
|
||
|
|
[
|
||
|
|
(
|
||
|
|
"advertisement_content",
|
||
|
|
StreamValue(
|
||
|
|
advertisement_content,
|
||
|
|
[
|
||
|
|
(
|
||
|
|
"rich_text",
|
||
|
|
RichText(page_link),
|
||
|
|
)
|
||
|
|
],
|
||
|
|
),
|
||
|
|
)
|
||
|
|
],
|
||
|
|
),
|
||
|
|
},
|
||
|
|
self.page,
|
||
|
|
"Stream field → Advertisement content → Rich text",
|
||
|
|
"will unset the reference",
|
||
|
|
),
|
||
|
|
# References from StreamField
|
||
|
|
(
|
||
|
|
{
|
||
|
|
"text": "deep SnippetChooserBlock",
|
||
|
|
"stream_field": StreamValue(
|
||
|
|
field.stream_block,
|
||
|
|
[
|
||
|
|
(
|
||
|
|
"advertisement_content",
|
||
|
|
StreamValue(
|
||
|
|
advertisement_content,
|
||
|
|
[
|
||
|
|
(
|
||
|
|
"captioned_advert",
|
||
|
|
StructValue(
|
||
|
|
captioned_advert,
|
||
|
|
[
|
||
|
|
("advert", self.advert),
|
||
|
|
("caption", "Deep text"),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
)
|
||
|
|
],
|
||
|
|
),
|
||
|
|
)
|
||
|
|
],
|
||
|
|
),
|
||
|
|
},
|
||
|
|
self.advert,
|
||
|
|
"Stream field → Advertisement content → Captioned advert",
|
||
|
|
"will unset the reference",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
{
|
||
|
|
"text": "ImageChooserBlock",
|
||
|
|
"stream_field": StreamValue(
|
||
|
|
field.stream_block, [("image", self.image)]
|
||
|
|
),
|
||
|
|
},
|
||
|
|
self.image,
|
||
|
|
"Stream field → Image",
|
||
|
|
"will unset the reference",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
{
|
||
|
|
"text": "DocumentChooserBlock",
|
||
|
|
"stream_field": StreamValue(
|
||
|
|
field.stream_block, [("document", self.document)]
|
||
|
|
),
|
||
|
|
},
|
||
|
|
self.document,
|
||
|
|
"Stream field → Document",
|
||
|
|
"will unset the reference",
|
||
|
|
),
|
||
|
|
]
|
||
|
|
|
||
|
|
def test_describe_source_field_and_on_delete(self):
|
||
|
|
for (
|
||
|
|
init_kwargs,
|
||
|
|
referred_object,
|
||
|
|
field_description,
|
||
|
|
on_delete_description,
|
||
|
|
) in self.cases:
|
||
|
|
with self.subTest(test=init_kwargs["text"]):
|
||
|
|
# Explicitly pass None to this field so that it is not set to
|
||
|
|
# the default value for test cases other than the SET_DEFAULT case
|
||
|
|
if "on_delete_set_default" not in init_kwargs:
|
||
|
|
init_kwargs["on_delete_set_default"] = None
|
||
|
|
|
||
|
|
obj = VariousOnDeleteModel.objects.create(**init_kwargs)
|
||
|
|
usage = ReferenceIndex.get_references_to(
|
||
|
|
referred_object
|
||
|
|
).group_by_source_object()
|
||
|
|
referrer, references = usage[0]
|
||
|
|
reference = references[0]
|
||
|
|
|
||
|
|
self.assertIs(usage.is_protected, "on_delete_protect" in init_kwargs)
|
||
|
|
self.assertEqual(usage.count(), 1)
|
||
|
|
self.assertEqual(referrer, obj)
|
||
|
|
self.assertEqual(len(references), 1)
|
||
|
|
self.assertEqual(reference.describe_source_field(), field_description)
|
||
|
|
self.assertEqual(reference.describe_on_delete(), on_delete_description)
|
||
|
|
obj.delete()
|
||
|
|
|
||
|
|
def test_describe_source_field_and_on_delete_parental_key(self):
|
||
|
|
# The test fixtures contain two references to the advert:
|
||
|
|
# 1. One advert placement on the home page
|
||
|
|
# 2. One advert placement on the Christmas page
|
||
|
|
advert = Advert.objects.first()
|
||
|
|
usage = ReferenceIndex.get_references_to(advert).group_by_source_object()
|
||
|
|
self.assertEqual(usage.count(), 2)
|
||
|
|
for _, references in usage:
|
||
|
|
reference = references[0]
|
||
|
|
self.assertEqual(reference.describe_source_field(), "Advert")
|
||
|
|
self.assertEqual(
|
||
|
|
reference.describe_on_delete(),
|
||
|
|
"the advert placement will also be deleted",
|
||
|
|
)
|