Repository: wagtail/wagtail

Issue:
versioned_static problems with ManifestStaticFilesStorage
### Issue Summary

Feels like a continuation of #5632.

Before Wagtail 2.7 it was possible to run commands such as `./manage.py makemigrations  --dry-run --check` *before* running collectstatic, which can be useful for testing missing migrations in CI. However since 2.7 it results in a `ValueError: Missing staticfiles manifest entry` error.

There's a few places with rich text editor plugins where versioned_static is being called when other code is imported, instead of when `.media` is being called on a form or widget. This probably shouldn't happen.

### Steps to Reproduce

```shell
pip install wagtail==2.7
wagtail start versionedstaticdemo
cd versionedstaticdemo
echo "SECRET_KEY='secret'" > versionedstaticdemo/settings/local.py
cat > home/models.py <<EOF
from django.db import models

from wagtail.admin.edit_handlers import FieldPanel
from wagtail.core.models import Page
from wagtail.core.fields import RichTextField


class HomePage(Page):
    rich_text = RichTextField()

    content_panels = Page.content_panels + [
        FieldPanel("rich_text", classname="full"),
    ]
EOF
./manage.py makemigrations --settings=versionedstaticdemo.settings.production --dry-run --check
```

Results in:

```
ValueError: Missing staticfiles manifest entry for 'wagtailadmin/js/draftail.js'
```

### Technical details

* Python version: 3.7 (any)
* Django version: 2.2 (any)
* Wagtail version: 2.7+ / master


Discussion:
squash-labs[bot]: <h2>Manage this branch in <a href="https://app.squash.io/accounts/">Squash</a></h2>Test this branch here: <a href='https://tomkinsversioned-static-lznic.squash.io' target='_blank'>https://tomkinsversioned-static-lznic.squash.io</a><br/>
frmdstryr: Related #5644, appears that was not fully complete.

tomkins: My original intention was to avoid tweaking `versioned_static` as that felt like the right thing, so the main focus was to get anything which used it into a media method to avoid being called too early.

However we could just change that function and leave the rest alone. It's a much smaller PR - and feels a lot less invasive.

I'm happy to bring back the original code and address some more of the original feedback if you prefer.
tomkins: Hopefully the third attempt should be a bit better:

- `versioned_static` will now immediately return the exact string back if it's an absolute URL already - which matches Django's behaviour.
- Updated `DraftailRichTextArea` a bit more, as plugin media can't be merged during `__init__` - combining media together result in the manifest being checked.
- Added `versioned_static` support to draftail features.
- Added `cached_property` to both editors media methods.



Patch:
diff --git a/README.md b/README.md
index 5bae740662..05855702e4 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,8 @@ Wagtail is used by NASA, Google, Oxfam, the NHS, Mozilla, MIT, the Red Cross, Sa
 
 ### Compatibility
 
+_(If you are reading this on GitHub, the details here may not be indicative of the current released version - please see [Compatible Django / Python versions](https://docs.wagtail.io/en/stable/releases/upgrading.html#compatible-django-python-versions) in the Wagtail documentation.)_
+
 Wagtail supports:
 
 * Django 2.1.x, 2.2.x and 3.0.x
diff --git a/wagtail/admin/rich_text/editors/draftail/__init__.py b/wagtail/admin/rich_text/editors/draftail/__init__.py
index 5b50ca5c88..44240e77fc 100644
--- a/wagtail/admin/rich_text/editors/draftail/__init__.py
+++ b/wagtail/admin/rich_text/editors/draftail/__init__.py
@@ -1,6 +1,7 @@
 import json
 
 from django.forms import Media, widgets
+from django.utils.functional import cached_property
 
 from wagtail.admin.edit_handlers import RichTextFieldPanel
 from wagtail.admin.rich_text.converters.contentstate import ContentstateConverter
@@ -23,12 +24,7 @@ class DraftailRichTextArea(widgets.HiddenInput):
         # but we don't currently recognise any options from there (other than 'features', which is passed here as a separate kwarg)
         kwargs.pop('options', None)
         self.options = {}
-
-        self._media = Media(js=[
-            versioned_static('wagtailadmin/js/draftail.js'),
-        ], css={
-            'all': [versioned_static('wagtailadmin/css/panels/draftail.css')]
-        })
+        self.plugins = []
 
         self.features = kwargs.pop('features', None)
         if self.features is None:
@@ -38,7 +34,7 @@ class DraftailRichTextArea(widgets.HiddenInput):
             plugin = feature_registry.get_editor_plugin('draftail', feature)
             if plugin:
                 plugin.construct_options(self.options)
-                self._media += plugin.media
+                self.plugins.append(plugin)
 
         self.converter = ContentstateConverter(self.features)
 
@@ -71,6 +67,15 @@ class DraftailRichTextArea(widgets.HiddenInput):
             return None
         return self.converter.to_database_format(original_value)
 
-    @property
+    @cached_property
     def media(self):
-        return self._media
+        media = Media(js=[
+            versioned_static('wagtailadmin/js/draftail.js'),
+        ], css={
+            'all': [versioned_static('wagtailadmin/css/panels/draftail.css')]
+        })
+
+        for plugin in self.plugins:
+            media += plugin.media
+
+        return media
diff --git a/wagtail/admin/rich_text/editors/draftail/features.py b/wagtail/admin/rich_text/editors/draftail/features.py
index 01c7ab2ab9..c2bdbcb033 100644
--- a/wagtail/admin/rich_text/editors/draftail/features.py
+++ b/wagtail/admin/rich_text/editors/draftail/features.py
@@ -1,5 +1,7 @@
 from django.forms import Media
 
+from wagtail.admin.staticfiles import versioned_static
+
 # Feature objects: these are mapped to feature identifiers within the rich text
 # feature registry (wagtail.core.rich_text.features). Each one implements
 # a `construct_options` method which modifies an options dict as appropriate to
@@ -12,12 +14,17 @@ from django.forms import Media
 
 class Feature:
     def __init__(self, js=None, css=None):
-        self.js = js
-        self.css = css
+        self.js = js or []
+        self.css = css or {}
 
     @property
     def media(self):
-        return Media(js=self.js, css=self.css)
+        js = [versioned_static(js_file) for js_file in self.js]
+        css = {}
+        for media_type, css_files in self.css.items():
+            css[media_type] = [versioned_static(css_file) for css_file in css_files]
+
+        return Media(js=js, css=css)
 
 
 class BooleanFeature(Feature):
diff --git a/wagtail/admin/rich_text/editors/hallo.py b/wagtail/admin/rich_text/editors/hallo.py
index c6d4225876..1c3b7f3815 100644
--- a/wagtail/admin/rich_text/editors/hallo.py
+++ b/wagtail/admin/rich_text/editors/hallo.py
@@ -2,6 +2,7 @@ import json
 from collections import OrderedDict
 
 from django.forms import Media, widgets
+from django.utils.functional import cached_property
 
 from wagtail.admin.edit_handlers import RichTextFieldPanel
 from wagtail.admin.rich_text.converters.editor_html import EditorHTMLConverter
@@ -13,8 +14,8 @@ class HalloPlugin:
     def __init__(self, **kwargs):
         self.name = kwargs.get('name', None)
         self.options = kwargs.get('options', {})
-        self.js = kwargs.get('js', None)
-        self.css = kwargs.get('css', None)
+        self.js = kwargs.get('js', [])
+        self.css = kwargs.get('css', {})
         self.order = kwargs.get('order', 100)
 
     def construct_plugins_list(self, plugins):
@@ -23,7 +24,12 @@ class HalloPlugin:
 
     @property
     def media(self):
-        return Media(js=self.js, css=self.css)
+        js = [versioned_static(js_file) for js_file in self.js]
+        css = {}
+        for media_type, css_files in self.css.items():
+            css[media_type] = [versioned_static(css_file) for css_file in css_files]
+
+        return Media(js=js, css=css)
 
 
 class HalloFormatPlugin(HalloPlugin):
@@ -142,7 +148,7 @@ class HalloRichTextArea(widgets.Textarea):
             return None
         return self.converter.to_database_format(original_value)
 
-    @property
+    @cached_property
     def media(self):
         media = Media(js=[
             versioned_static('wagtailadmin/js/vendor/hallo.js'),
diff --git a/wagtail/admin/staticfiles.py b/wagtail/admin/staticfiles.py
index 00091135e1..13fc8bf6e3 100644
--- a/wagtail/admin/staticfiles.py
+++ b/wagtail/admin/staticfiles.py
@@ -41,6 +41,10 @@ def versioned_static(path):
     Wrapper for Django's static file finder to append a cache-busting query parameter
     that updates on each Wagtail version
     """
+    # An absolute path is returned unchanged (either a full URL, or processed already)
+    if path.startswith(('http://', 'https://', '/')):
+        return path
+
     base_url = static(path)
 
     # if URL already contains a querystring, don't add our own, to avoid interfering
diff --git a/wagtail/admin/wagtail_hooks.py b/wagtail/admin/wagtail_hooks.py
index 9f82866231..817fbc48c8 100644
--- a/wagtail/admin/wagtail_hooks.py
+++ b/wagtail/admin/wagtail_hooks.py
@@ -18,7 +18,6 @@ from wagtail.admin.rich_text.converters.html_to_contentstate import (
     BlockElementHandler, ExternalLinkElementHandler, HorizontalRuleHandler,
     InlineStyleElementHandler, ListElementHandler, ListItemElementHandler, PageLinkElementHandler)
 from wagtail.admin.search import SearchArea
-from wagtail.admin.staticfiles import versioned_static
 from wagtail.admin.views.account import email_management_enabled, password_management_enabled
 from wagtail.admin.viewsets import viewsets
 from wagtail.admin.widgets import Button, ButtonWithDropdownFromHook, PageListingButton
@@ -280,7 +279,7 @@ def register_core_features(features):
         'hallo', 'hr',
         HalloPlugin(
             name='hallohr',
-            js=[versioned_static('wagtailadmin/js/hallo-plugins/hallo-hr.js')],
+            js=['wagtailadmin/js/hallo-plugins/hallo-hr.js'],
             order=45,
         )
     )
@@ -293,8 +292,8 @@ def register_core_features(features):
         HalloPlugin(
             name='hallowagtaillink',
             js=[
-                versioned_static('wagtailadmin/js/page-chooser-modal.js'),
-                versioned_static('wagtailadmin/js/hallo-plugins/hallo-wagtaillink.js'),
+                'wagtailadmin/js/page-chooser-modal.js',
+                'wagtailadmin/js/hallo-plugins/hallo-wagtaillink.js',
             ],
         )
     )
@@ -542,7 +541,7 @@ def register_core_features(features):
                 'href': "^(http:|https:|undefined$)",
             }
         }, js=[
-            versioned_static('wagtailadmin/js/page-chooser-modal.js'),
+            'wagtailadmin/js/page-chooser-modal.js',
         ])
     )
     features.register_converter_rule('contentstate', 'link', {
diff --git a/wagtail/documents/wagtail_hooks.py b/wagtail/documents/wagtail_hooks.py
index 14d576d302..89cd5c6fc1 100644
--- a/wagtail/documents/wagtail_hooks.py
+++ b/wagtail/documents/wagtail_hooks.py
@@ -12,7 +12,6 @@ from wagtail.admin.navigation import get_site_for_user
 from wagtail.admin.rich_text import HalloPlugin
 from wagtail.admin.search import SearchArea
 from wagtail.admin.site_summary import SummaryItem
-from wagtail.admin.staticfiles import versioned_static
 from wagtail.core import hooks
 from wagtail.core.models import BaseViewRestriction
 from wagtail.core.wagtail_hooks import require_wagtail_login
@@ -76,8 +75,8 @@ def register_document_feature(features):
         HalloPlugin(
             name='hallowagtaildoclink',
             js=[
-                versioned_static('wagtaildocs/js/document-chooser-modal.js'),
-                versioned_static('wagtaildocs/js/hallo-plugins/hallo-wagtaildoclink.js'),
+                'wagtaildocs/js/document-chooser-modal.js',
+                'wagtaildocs/js/hallo-plugins/hallo-wagtaildoclink.js',
             ],
         )
     )
@@ -86,7 +85,7 @@ def register_document_feature(features):
             'type': 'DOCUMENT',
             'icon': 'doc-full',
             'description': ugettext('Document'),
-        }, js=[versioned_static('wagtaildocs/js/document-chooser-modal.js')])
+        }, js=['wagtaildocs/js/document-chooser-modal.js'])
     )
 
     features.register_converter_rule(
diff --git a/wagtail/embeds/wagtail_hooks.py b/wagtail/embeds/wagtail_hooks.py
index 03cff1523a..40922fe14d 100644
--- a/wagtail/embeds/wagtail_hooks.py
+++ b/wagtail/embeds/wagtail_hooks.py
@@ -5,7 +5,6 @@ from django.utils.translation import ugettext as _
 
 import wagtail.admin.rich_text.editors.draftail.features as draftail_features
 from wagtail.admin.rich_text import HalloPlugin
-from wagtail.admin.staticfiles import versioned_static
 from wagtail.core import hooks
 from wagtail.embeds import urls
 from wagtail.embeds.rich_text import MediaEmbedHandler
@@ -43,8 +42,8 @@ def register_embed_feature(features):
         HalloPlugin(
             name='hallowagtailembeds',
             js=[
-                versioned_static('wagtailembeds/js/embed-chooser-modal.js'),
-                versioned_static('wagtailembeds/js/hallo-plugins/hallo-wagtailembeds.js'),
+                'wagtailembeds/js/embed-chooser-modal.js',
+                'wagtailembeds/js/hallo-plugins/hallo-wagtailembeds.js',
             ],
         )
     )
@@ -59,7 +58,7 @@ def register_embed_feature(features):
             'type': 'EMBED',
             'icon': 'media',
             'description': _('Embed'),
-        }, js=[versioned_static('wagtailembeds/js/embed-chooser-modal.js')])
+        }, js=['wagtailembeds/js/embed-chooser-modal.js'])
     )
 
     # define how to convert between contentstate's representation of embeds and
diff --git a/wagtail/images/wagtail_hooks.py b/wagtail/images/wagtail_hooks.py
index 967b1e1444..fcf107b986 100644
--- a/wagtail/images/wagtail_hooks.py
+++ b/wagtail/images/wagtail_hooks.py
@@ -10,7 +10,6 @@ from wagtail.admin.navigation import get_site_for_user
 from wagtail.admin.rich_text import HalloPlugin
 from wagtail.admin.search import SearchArea
 from wagtail.admin.site_summary import SummaryItem
-from wagtail.admin.staticfiles import versioned_static
 from wagtail.core import hooks
 from wagtail.images import admin_urls, get_image_model, image_operations
 from wagtail.images.api.admin.views import ImagesAdminAPIViewSet
@@ -71,8 +70,8 @@ def register_image_feature(features):
         HalloPlugin(
             name='hallowagtailimage',
             js=[
-                versioned_static('wagtailimages/js/image-chooser-modal.js'),
-                versioned_static('wagtailimages/js/hallo-plugins/hallo-wagtailimage.js'),
+                'wagtailimages/js/image-chooser-modal.js',
+                'wagtailimages/js/hallo-plugins/hallo-wagtailimage.js',
             ],
         )
     )
@@ -95,7 +94,7 @@ def register_image_feature(features):
                 'id': True,
             }
         }, js=[
-            versioned_static('wagtailimages/js/image-chooser-modal.js'),
+            'wagtailimages/js/image-chooser-modal.js',
         ])
     )
 


Test patch:
diff --git a/wagtail/admin/tests/pages/test_create_page.py b/wagtail/admin/tests/pages/test_create_page.py
index 3ce7b3d737..d2777d1d6c 100644
--- a/wagtail/admin/tests/pages/test_create_page.py
+++ b/wagtail/admin/tests/pages/test_create_page.py
@@ -14,8 +14,8 @@ from wagtail.admin.tests.pages.timestamps import submittable_timestamp
 from wagtail.core.models import GroupPagePermission, Page, PageRevision
 from wagtail.core.signals import page_published
 from wagtail.tests.testapp.models import (
-    BusinessChild, BusinessIndex, BusinessSubIndex, DefaultStreamPage, SimplePage,
-    SingletonPage, SingletonPageViaMaxCount, StandardChild, StandardIndex)
+    BusinessChild, BusinessIndex, BusinessSubIndex, DefaultStreamPage, PersonPage,
+    SimplePage, SingletonPage, SingletonPageViaMaxCount, StandardChild, StandardIndex)
 from wagtail.tests.utils import WagtailTestUtils
 
 
@@ -895,3 +895,32 @@ class TestIssue2994(TestCase, WagtailTestUtils):
         new_page = DefaultStreamPage.objects.get(slug='issue-2994-test')
         self.assertEqual(1, len(new_page.body))
         self.assertEqual('hello world', new_page.body[0].value)
+
+
+class TestInlinePanelWithTags(TestCase, WagtailTestUtils):
+    # https://github.com/wagtail/wagtail/issues/5414#issuecomment-567080707
+
+    def setUp(self):
+        self.root_page = Page.objects.get(id=2)
+        self.user = self.login()
+
+    def test_create(self):
+        post_data = {
+            'title': 'Mr Benn',
+            'slug': 'mr-benn',
+            'first_name': 'William',
+            'last_name': 'Benn',
+            'addresses-TOTAL_FORMS': 1,
+            'addresses-INITIAL_FORMS': 0,
+            'addresses-MIN_NUM_FORMS': 0,
+            'addresses-MAX_NUM_FORMS': 1000,
+            'addresses-0-address': "52 Festive Road, London",
+            'addresses-0-tags': "shopkeeper, bowler-hat",
+            'action-publish': "Publish",
+        }
+        response = self.client.post(
+            reverse('wagtailadmin_pages:add', args=('tests', 'personpage', self.root_page.id)), post_data
+        )
+        self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
+        new_page = PersonPage.objects.get(slug='mr-benn')
+        self.assertEqual(new_page.addresses.first().tags.count(), 2)
diff --git a/wagtail/admin/tests/test_rich_text.py b/wagtail/admin/tests/test_rich_text.py
index 635aa931a0..5ea01cc872 100644
--- a/wagtail/admin/tests/test_rich_text.py
+++ b/wagtail/admin/tests/test_rich_text.py
@@ -7,6 +7,8 @@ from django.urls import reverse
 from wagtail.admin.rich_text import (
     DraftailRichTextArea, HalloRichTextArea, get_rich_text_editor_widget)
 from wagtail.admin.rich_text.converters.editor_html import PageLinkHandler
+from wagtail.admin.rich_text.editors.draftail.features import Feature
+from wagtail.admin.rich_text.editors.hallo import HalloPlugin
 from wagtail.core.blocks import RichTextBlock
 from wagtail.core.models import Page, get_page_models
 from wagtail.core.rich_text import features as feature_registry
@@ -756,3 +758,23 @@ class TestWidgetNotHidden(SimpleTestCase):
             HalloRichTextArea().is_hidden,
             False,
         )
+
+
+class TestDraftailFeature(SimpleTestCase):
+    def test_versioned_static_media(self):
+        feature = Feature(js=['wagtailadmin/js/example/feature.js'], css={
+            'all': ['wagtailadmin/css/example/feature.css'],
+        })
+        media_html = str(feature.media)
+        self.assertRegex(media_html, r'feature.js\?v=(\w+)')
+        self.assertRegex(media_html, r'feature.css\?v=(\w+)')
+
+
+class TestHalloPlugin(SimpleTestCase):
+    def test_versioned_static_media(self):
+        plugin = HalloPlugin(js=['wagtailadmin/js/vendor/hallo.js'], css={
+            'all': ['wagtailadmin/css/panels/hallo.css'],
+        })
+        media_html = str(plugin.media)
+        self.assertRegex(media_html, r'hallo.js\?v=(\w+)')
+        self.assertRegex(media_html, r'hallo.css\?v=(\w+)')
diff --git a/wagtail/admin/tests/test_templatetags.py b/wagtail/admin/tests/test_templatetags.py
index cf33eb45ac..efcf316410 100644
--- a/wagtail/admin/tests/test_templatetags.py
+++ b/wagtail/admin/tests/test_templatetags.py
@@ -1,3 +1,5 @@
+from unittest import mock
+
 from django.contrib.auth import get_user_model
 from django.test import TestCase
 from django.test.utils import override_settings
@@ -66,3 +68,18 @@ class TestVersionedStatic(TestCase):
     def test_versioned_static(self):
         result = versioned_static('wagtailadmin/js/core.js')
         self.assertRegex(result, r'^/static/wagtailadmin/js/core.js\?v=(\w+)$')
+
+    @mock.patch('wagtail.admin.staticfiles.static')
+    def test_versioned_static_version_string(self, mock_static):
+        mock_static.return_value = '/static/wagtailadmin/js/core.js?v=123'
+        result = versioned_static('wagtailadmin/js/core.js')
+        self.assertEqual(result, '/static/wagtailadmin/js/core.js?v=123')
+        mock_static.assert_called_once_with('wagtailadmin/js/core.js')
+
+    def test_versioned_static_absolute_path(self):
+        result = versioned_static('/static/wagtailadmin/js/core.js')
+        self.assertEqual(result, '/static/wagtailadmin/js/core.js')
+
+    def test_versioned_static_url(self):
+        result = versioned_static('http://example.org/static/wagtailadmin/js/core.js')
+        self.assertEqual(result, 'http://example.org/static/wagtailadmin/js/core.js')
diff --git a/wagtail/tests/testapp/migrations/0046_personpage.py b/wagtail/tests/testapp/migrations/0046_personpage.py
new file mode 100644
index 0000000000..82b43c0be9
--- /dev/null
+++ b/wagtail/tests/testapp/migrations/0046_personpage.py
@@ -0,0 +1,66 @@
+# Generated by Django 2.2.6 on 2020-01-07 14:24
+
+from django.db import migrations, models
+import django.db.models.deletion
+import modelcluster.contrib.taggit
+import modelcluster.fields
+import wagtail.search.index
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wagtailcore', '0042_index_on_pagerevision_approved_go_live_at'),
+        ('taggit', '0003_taggeditem_add_unique_index'),
+        ('tests', '0045_add_formsubmission_verbose_name_plural'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Address',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
+                ('address', models.CharField(max_length=255, verbose_name='Address')),
+            ],
+            options={
+                'verbose_name': 'Address',
+                'verbose_name_plural': 'Addresses',
+            },
+            bases=(wagtail.search.index.Indexed, models.Model),
+        ),
+        migrations.CreateModel(
+            name='PersonPage',
+            fields=[
+                ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')),
+                ('first_name', models.CharField(max_length=255, verbose_name='First Name')),
+                ('last_name', models.CharField(max_length=255, verbose_name='Last Name')),
+            ],
+            options={
+                'verbose_name': 'Person',
+                'verbose_name_plural': 'Persons',
+            },
+            bases=('wagtailcore.page',),
+        ),
+        migrations.CreateModel(
+            name='AddressTag',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('content_object', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='tagged_items', to='tests.Address')),
+                ('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tests_addresstag_items', to='taggit.Tag')),
+            ],
+            options={
+                'abstract': False,
+            },
+        ),
+        migrations.AddField(
+            model_name='address',
+            name='person',
+            field=modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='addresses', to='tests.PersonPage', verbose_name='Person'),
+        ),
+        migrations.AddField(
+            model_name='address',
+            name='tags',
+            field=modelcluster.contrib.taggit.ClusterTaggableManager(blank=True, help_text='A comma-separated list of tags.', through='tests.AddressTag', to='taggit.Tag', verbose_name='Tags'),
+        ),
+    ]
diff --git a/wagtail/tests/testapp/models.py b/wagtail/tests/testapp/models.py
index 0f04665732..93bdcb1032 100644
--- a/wagtail/tests/testapp/models.py
+++ b/wagtail/tests/testapp/models.py
@@ -1355,3 +1355,59 @@ class SimpleChildPage(Page):
     parent_page_types = ['tests.SimpleParentPage', Page]
 
     max_count_per_parent = 1
+
+
+class PersonPage(Page):
+    first_name = models.CharField(
+        max_length=255,
+        verbose_name='First Name',
+    )
+    last_name = models.CharField(
+        max_length=255,
+        verbose_name='Last Name',
+    )
+
+    content_panels = Page.content_panels + [
+        MultiFieldPanel([
+            FieldPanel('first_name'),
+            FieldPanel('last_name'),
+        ], 'Person'),
+        InlinePanel('addresses', label='Address'),
+    ]
+
+    class Meta:
+        verbose_name = 'Person'
+        verbose_name_plural = 'Persons'
+
+
+class Address(index.Indexed, ClusterableModel, Orderable):
+    address = models.CharField(
+        max_length=255,
+        verbose_name='Address',
+    )
+    tags = ClusterTaggableManager(
+        through='tests.AddressTag',
+        blank=True,
+    )
+    person = ParentalKey(
+        to='tests.PersonPage',
+        related_name='addresses',
+        verbose_name='Person'
+    )
+
+    panels = [
+        FieldPanel('address'),
+        FieldPanel('tags'),
+    ]
+
+    class Meta:
+        verbose_name = 'Address'
+        verbose_name_plural = 'Addresses'
+
+
+class AddressTag(TaggedItemBase):
+    content_object = ParentalKey(
+        to='tests.Address',
+        on_delete=models.CASCADE,
+        related_name='tagged_items'
+    )