Explorar el Código

Initial internationalisation models

Karl Hobley hace 4 años
padre
commit
db92a94506

+ 61 - 0
wagtail/core/compat.py

@@ -1,6 +1,14 @@
+import functools
+
 from django.conf import settings
+from django.conf.locale import LANG_INFO
 from django.core.exceptions import ImproperlyConfigured
 
+from django.core.signals import setting_changed
+from django.dispatch import receiver
+from django.utils.translation import check_for_language
+
+
 # A setting that can be used in foreign key declarations
 AUTH_USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', 'auth.User')
 # Two additional settings that are useful in South migrations when
@@ -10,3 +18,56 @@ try:
 except ValueError:
     raise ImproperlyConfigured("AUTH_USER_MODEL must be of the form"
                                " 'app_label.model_name'")
+
+
+@functools.lru_cache()
+def get_languages():
+    """
+    Cache of settings.LANGUAGES in a dictionary for easy lookups by key.
+    """
+    # TODO: Add support for WAGTAIL_LANGUAGES
+    return dict(settings.LANGUAGES)
+
+
+# Added in Django 2.1
+@functools.lru_cache(maxsize=1000)
+def get_supported_language_variant(lang_code, strict=False):
+    """
+    Return the language code that's listed in supported languages, possibly
+    selecting a more generic variant. Raise LookupError if nothing is found.
+    If `strict` is False (the default), look for a country-specific variant
+    when neither the language code nor its generic variant is found.
+    lru_cache should have a maxsize to prevent from memory exhaustion attacks,
+    as the provided language codes are taken from the HTTP request. See also
+    <https://www.djangoproject.com/weblog/2007/oct/26/security-fix/>.
+    """
+    if lang_code:
+        # If 'fr-ca' is not supported, try special fallback or language-only 'fr'.
+        possible_lang_codes = [lang_code]
+        try:
+            possible_lang_codes.extend(LANG_INFO[lang_code]["fallback"])
+        except KeyError:
+            pass
+        generic_lang_code = lang_code.split("-")[0]
+        possible_lang_codes.append(generic_lang_code)
+        supported_lang_codes = get_languages()
+
+        for code in possible_lang_codes:
+            if code in supported_lang_codes and check_for_language(code):
+                return code
+        if not strict:
+            # if fr-fr is not supported, try fr-ca.
+            for supported_code in supported_lang_codes:
+                if supported_code.startswith(generic_lang_code + "-"):
+                    return supported_code
+    raise LookupError(lang_code)
+
+
+@receiver(setting_changed)
+def reset_cache(**kwargs):
+    """
+    Clear cache when global LANGUAGES/LANGUAGE_CODE settings are changed
+    """
+    if kwargs["setting"] in ("LANGUAGES", "LANGUAGE_CODE"):
+        get_languages.cache_clear()
+        get_supported_language_variant.cache_clear()

+ 23 - 0
wagtail/core/migrations/0053_locale_model.py

@@ -0,0 +1,23 @@
+# Generated by Django 2.2.10 on 2020-07-13 10:13
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wagtailcore', '0052_pagelogentry'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Locale',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('language_code', models.CharField(max_length=100, unique=True)),
+            ],
+            options={
+                'ordering': ['language_code'],
+            },
+        ),
+    ]

+ 25 - 0
wagtail/core/migrations/0054_initial_locale.py

@@ -0,0 +1,25 @@
+# Generated by Django 2.2.10 on 2020-07-13 10:13
+
+from django.conf import settings
+from django.db import migrations
+
+from wagtail.core.compat import get_supported_language_variant
+
+
+def initial_locale(apps, schema_editor):
+    Locale = apps.get_model("wagtailcore.Locale")
+
+    Locale.objects.create(
+        language_code=get_supported_language_variant(settings.LANGUAGE_CODE),
+    )
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wagtailcore', '0053_locale_model'),
+    ]
+
+    operations = [
+        migrations.RunPython(initial_locale, migrations.RunPython.noop),
+    ]

+ 28 - 0
wagtail/core/migrations/0055_page_locale_fields.py

@@ -0,0 +1,28 @@
+# Generated by Django 2.2.10 on 2020-07-13 10:13
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wagtailcore', '0054_initial_locale'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='page',
+            name='translation_key',
+            field=models.UUIDField(editable=False, null=True),
+        ),
+        migrations.AddField(
+            model_name='page',
+            name='locale',
+            field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='wagtailcore.Locale'),
+        ),
+        migrations.AlterUniqueTogether(
+            name='page',
+            unique_together={('translation_key', 'locale')},
+        ),
+    ]

+ 16 - 0
wagtail/core/migrations/0056_page_locale_fields_populate.py

@@ -0,0 +1,16 @@
+# Generated by Django 2.2.10 on 2020-07-13 10:13
+
+from django.db import migrations
+
+from wagtail.core.models import BootstrapTranslatableModel
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wagtailcore', '0055_page_locale_fields'),
+    ]
+
+    operations = [
+        BootstrapTranslatableModel('wagtailcore.Page'),
+    ]

+ 25 - 0
wagtail/core/migrations/0057_page_locale_fields_notnull.py

@@ -0,0 +1,25 @@
+# Generated by Django 2.2.10 on 2020-07-13 10:17
+
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wagtailcore', '0056_page_locale_fields_populate'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='page',
+            name='locale',
+            field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='wagtailcore.Locale'),
+        ),
+        migrations.AlterField(
+            model_name='page',
+            name='translation_key',
+            field=models.UUIDField(default=uuid.uuid4, editable=False),
+        ),
+    ]

+ 345 - 4
wagtail/core/models.py

@@ -1,9 +1,11 @@
 import json
 import logging
+import uuid
 from io import StringIO
 from urllib.parse import urlparse
 
 from django import forms
+from django.apps import apps
 from django.conf import settings
 from django.contrib.auth.models import Group, Permission
 from django.contrib.contenttypes.models import ContentType
@@ -12,15 +14,17 @@ from django.core.cache import cache
 from django.core.exceptions import PermissionDenied, ValidationError
 from django.core.handlers.base import BaseHandler
 from django.core.handlers.wsgi import WSGIRequest
-from django.db import models, transaction
+from django.db import migrations, models, transaction
 from django.db.models import Q, Value
 from django.db.models.expressions import OuterRef, Subquery
 from django.db.models.functions import Concat, Lower, Substr
+from django.db.models.signals import pre_save
+from django.dispatch import receiver
 from django.http import Http404
 from django.http.request import split_domain_port
 from django.template.response import TemplateResponse
 from django.urls import NoReverseMatch, reverse
-from django.utils import timezone
+from django.utils import timezone, translation
 from django.utils.cache import patch_cache_control
 from django.utils.encoding import force_str
 from django.utils.functional import cached_property
@@ -45,6 +49,9 @@ from wagtail.core.url_routing import RouteResult
 from wagtail.core.utils import WAGTAIL_APPEND_SLASH, camelcase_to_underscore, resolve_model_string
 from wagtail.search import index
 
+from .compat import get_languages, get_supported_language_variant
+from .utils import find_available_slug
+
 logger = logging.getLogger('wagtail.core')
 
 PAGE_TEMPLATE_VAR = 'page'
@@ -273,6 +280,249 @@ class Site(models.Model):
         return result
 
 
+def pk(obj):
+    if isinstance(obj, models.Model):
+        return obj.pk
+    else:
+        return obj
+
+
+class LocaleManager(models.Manager):
+    def get_queryset(self):
+        # Exclude any locales that have an invalid language code
+        return super().get_queryset().filter(language_code__in=get_languages().keys())
+
+    def get_for_language(self, language_code):
+        """
+        Gets a Locale from a language code.
+        """
+        return self.get(language_code=get_supported_language_variant(language_code))
+
+
+class Locale(models.Model):
+    language_code = models.CharField(max_length=100, unique=True)
+
+    # Objects excludes any Locales that have been removed from LANGUAGES, This effectively disables them
+    # The Locale management UI needs to be able to see these so we provide a separate manager `all_objects`
+    objects = LocaleManager()
+    all_objects = models.Manager()
+
+    class Meta:
+        ordering = [
+            "language_code",
+        ]
+
+    @classmethod
+    def get_default(cls):
+        """
+        Returns the default Locale based on the site's LOCALE_CODE setting
+        """
+        return cls.objects.get_for_language(settings.LANGUAGE_CODE)
+
+    @classmethod
+    def get_active(cls):
+        """
+        Returns the Locale that corresponds to the currently activated language in Django.
+        """
+        try:
+            return cls.objects.get_for_language(translation.get_language())
+        except cls.DoesNotExist:
+            return cls.get_default()
+
+    def get_display_name(self):
+        return get_languages().get(self.language_code)
+
+    def __str__(self):
+        return self.get_display_name() or self.language_code
+
+
+class TranslatableMixin(models.Model):
+    translation_key = models.UUIDField(default=uuid.uuid4, editable=False)
+    locale = models.ForeignKey(Locale, on_delete=models.PROTECT, related_name="+", editable=False)
+
+    class Meta:
+        abstract = True
+        unique_together = [("translation_key", "locale")]
+
+    def get_translations(self, inclusive=False):
+        translations = self.__class__.objects.filter(
+            translation_key=self.translation_key
+        )
+
+        if inclusive is False:
+            translations = translations.exclude(id=self.id)
+
+        return translations
+
+    def get_translation(self, locale):
+        return self.get_translations(inclusive=True).get(locale_id=pk(locale))
+
+    def get_translation_or_none(self, locale):
+        try:
+            return self.get_translation(locale)
+        except self.__class__.DoesNotExist:
+            return None
+
+    def has_translation(self, locale):
+        return self.get_translations(inclusive=True).filter(locale_id=pk(locale)).exists()
+
+    def copy_for_translation(self, locale):
+        """
+        Copies this instance for the specified locale.
+        """
+        translated = self.__class__.objects.get(id=self.id)
+        translated.id = None
+        translated.locale = locale
+
+        return translated
+
+    def get_default_locale(self):
+        """
+        Finds the default locale to use for this object.
+
+        This will be called just before the initial save.
+        """
+        # Check if the object has any parental keys to another translatable model
+        # If so, take the locale from the object referenced in that parental key
+        parental_keys = [
+            field
+            for field in self._meta.get_fields()
+            if isinstance(field, ParentalKey)
+            and issubclass(field.related_model, TranslatableMixin)
+        ]
+
+        if parental_keys:
+            parent_id = parental_keys[0].value_from_object(self)
+            return (
+                parental_keys[0]
+                .related_model.objects.defer().select_related("locale")
+                .get(id=parent_id)
+                .locale
+            )
+
+        return Locale.get_default()
+
+    @classmethod
+    def get_translation_model(self):
+        """
+        Gets the model which manages the translations for this model.
+        (The model that has the "translation_key" and "locale" fields)
+        Most of the time this would be the current model, but some sites
+        may have intermediate concrete models between wagtailcore.Page and
+        the specfic page model.
+        """
+        return self._meta.get_field("locale").model
+
+
+def bootstrap_translatable_model(model, locale):
+    """
+    This function populates the "translation_key", and "locale" fields on model instances that were created
+    before wagtail-localize was added to the site.
+
+    This can be called from a data migration, or instead you could use the "boostrap_translatable_models"
+    management command.
+    """
+    for instance in (
+        model.objects.filter(translation_key__isnull=True).defer().iterator()
+    ):
+        instance.translation_key = uuid.uuid4()
+        instance.locale = locale
+        instance.save(update_fields=["translation_key", "locale"])
+
+
+class BootstrapTranslatableModel(migrations.RunPython):
+    def __init__(self, model_string, language_code=None):
+        if language_code is None:
+            language_code = get_supported_language_variant(settings.LANGUAGE_CODE)
+
+        def forwards(apps, schema_editor):
+            model = apps.get_model(model_string)
+            Locale = apps.get_model("wagtailcore.Locale")
+
+            locale = Locale.objects.get(language_code=language_code)
+            bootstrap_translatable_model(model, locale)
+
+        def backwards(apps, schema_editor):
+            pass
+
+        super().__init__(forwards, backwards)
+
+
+class ParentNotTranslatedError(Exception):
+    """
+    Raised when a call to Page.copy_for_translation is made but the
+    parent page is not translated and copy_parents is False.
+    """
+    pass
+
+
+class BootstrapTranslatableMixin(TranslatableMixin):
+    """
+    A version of TranslatableMixin without uniqueness constraints.
+
+    This is to make it easy to transition existing models to being translatable.
+
+    The process is as follows:
+     - Add BootstrapTranslatableMixin to the model
+     - Run makemigrations
+     - Create a data migration for each app, then use the BootstrapTranslatableModel operation in
+       wagtail.core.models on each model in that app
+     - Change BootstrapTranslatableMixin to TranslatableMixin
+     - Run makemigrations again
+     - Migrate!
+    """
+    translation_key = models.UUIDField(null=True, editable=False)
+    locale = models.ForeignKey(
+        Locale, on_delete=models.PROTECT, null=True, related_name="+", editable=False
+    )
+
+    class Meta:
+        abstract = True
+
+
+def get_translatable_models(include_subclasses=False):
+    """
+    Returns a list of all concrete models that inherit from TranslatableMixin.
+    By default, this only includes models that are direct children of TranslatableMixin,
+    to get all models, set the include_subclasses attribute to True.
+    """
+    translatable_models = [
+        model
+        for model in apps.get_models()
+        if issubclass(model, TranslatableMixin) and not model._meta.abstract
+    ]
+
+    if include_subclasses is False:
+        # Exclude models that inherit from another translatable model
+        root_translatable_models = set()
+
+        for model in translatable_models:
+            root_translatable_models.add(model.get_translation_model())
+
+        translatable_models = [
+            model for model in translatable_models if model in root_translatable_models
+        ]
+
+    return translatable_models
+
+
+@receiver(pre_save)
+def set_locale_on_new_instance(sender, instance, **kwargs):
+    if not isinstance(instance, TranslatableMixin):
+        return
+
+    if instance.locale_id is not None:
+        return
+
+    # If this is a fixture load, use the global default Locale
+    # as the page tree is probably in an flux
+    if kwargs["raw"]:
+        instance.locale = Locale.get_default()
+        return
+
+    instance.locale = instance.get_default_locale()
+
+
 PAGE_MODEL_CLASSES = []
 
 
@@ -325,7 +575,7 @@ class PageBase(models.base.ModelBase):
             PAGE_MODEL_CLASSES.append(cls)
 
 
-class AbstractPage(TreebeardPathFixMixin, MP_Node):
+class AbstractPage(TranslatableMixin, TreebeardPathFixMixin, MP_Node):
     """
     Abstract superclass for Page. According to Django's inheritance rules, managers set on
     abstract models are inherited by subclasses, but managers set on concrete models that are extended
@@ -458,6 +708,8 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
         index.FilterField('first_published_at'),
         index.FilterField('last_published_at'),
         index.FilterField('latest_revision_created_at'),
+        index.FilterField('locale'),
+        index.FilterField('translation_key'),
     ]
 
     # Do not allow plain Page instances to be created through the Wagtail admin
@@ -539,6 +791,22 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
 
         return candidate_slug
 
+    def get_default_locale(self):
+        """
+        Finds the default locale to use for this page.
+
+        This will be called just before the initial save.
+        """
+        parent = self.get_parent()
+        if parent is not None:
+            return (
+                parent.specific_class.objects.defer().select_related("locale")
+                .get(id=parent.id)
+                .locale
+            )
+
+        return super().get_default_locale()
+
     def full_clean(self, *args, **kwargs):
         # Apply fixups that need to happen before per-field validation occurs
 
@@ -554,6 +822,10 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
         if not self.draft_title:
             self.draft_title = self.title
 
+        # Set the locale
+        if self.locale_id is None:
+            self.locale = self.get_default_locale()
+
         super().full_clean(*args, **kwargs)
 
     def clean(self):
@@ -1256,6 +1528,12 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
         Checks if this page instance can be moved to be a subpage of a parent
         page instance.
         """
+        # Prevent pages from being moved to different language sections
+        # The only page that can have multi-lingual children is the root page
+        parent_is_root = parent.depth == 1
+        if not parent_is_root and parent.locale_id != self.locale_id:
+            return False
+
         return self.can_exist_under(parent)
 
     @classmethod
@@ -1372,7 +1650,7 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
         logger.info("Page moved: \"%s\" id=%d path=%s", self.title, self.id, new_url_path)
 
     def copy(self, recursive=False, to=None, update_attrs=None, copy_revisions=True, keep_live=True, user=None,
-             process_child_object=None, exclude_fields=None, log_action='wagtail.copy'):
+             process_child_object=None, exclude_fields=None, log_action='wagtail.copy', reset_translation_key=True):
         """
         Copies a given page
         :param log_action flag for logging the action. Pass None to skip logging.
@@ -1394,6 +1672,10 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
         if user:
             base_update_attrs['owner'] = user
 
+        # When we're not copying for translation, we should give the translation_key a new value
+        if reset_translation_key:
+            base_update_attrs['translation_key'] = uuid.uuid4()
+
         if update_attrs:
             base_update_attrs.update(update_attrs)
 
@@ -1404,6 +1686,10 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
             if process_child_object:
                 process_child_object(specific_self, page_copy, child_relation, child_object)
 
+            # When we're not copying for translation, we should give the translation_key a new value for each child object as well
+            if reset_translation_key and isinstance(child_object, TranslatableMixin):
+                child_object.translation_key = uuid.uuid4()
+
         # Save the new page
         if to:
             if recursive and (to == self or to.is_descendant_of(self)):
@@ -1510,6 +1796,58 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
 
     copy.alters_data = True
 
+    @transaction.atomic
+    def copy_for_translation(self, locale, copy_parents=False, exclude_fields=None):
+        """
+        Copies this page for the specified locale.
+        """
+        # Find the translated version of the parent page to create the new page under
+        parent = self.get_parent().specific
+        slug = self.slug
+
+        if not parent.is_root():
+            try:
+                translated_parent = parent.get_translation(locale)
+            except parent.__class__.DoesNotExist:
+                if not copy_parents:
+                    raise ParentNotTranslatedError
+
+                translated_parent = parent.copy_for_translation(
+                    locale, copy_parents=True
+                )
+        else:
+            # Don't duplicate the root page for translation. Create new locale as a sibling
+            translated_parent = parent
+
+            # Append language code to slug as the new page
+            # will be created in the same section as the existing one
+            slug += "-" + locale.language_code
+
+        # Find available slug for new page
+        slug = find_available_slug(translated_parent, slug)
+
+        # Update locale on translatable child objects as well
+        def process_child_object(
+            original_page, page_copy, child_relation, child_object
+        ):
+            if isinstance(child_object, TranslatableMixin):
+                child_object.locale = locale
+
+        return self.copy(
+            to=translated_parent,
+            update_attrs={
+                "locale": locale,
+                "slug": slug,
+            },
+            copy_revisions=False,
+            keep_live=False,
+            reset_translation_key=False,
+            process_child_object=process_child_object,
+            exclude_fields=exclude_fields,
+        )
+
+    copy_for_translation.alters_data = True
+
     def permissions_for_user(self, user):
         """
         Return a PagePermissionsTester object defining what actions the user can perform on this page
@@ -1813,6 +2151,8 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
         obj.locked_at = self.locked_at
         obj.latest_revision_created_at = self.latest_revision_created_at
         obj.first_published_at = self.first_published_at
+        obj.translation_key = self.translation_key
+        obj.locale = self.locale
 
         return obj
 
@@ -1861,6 +2201,7 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
     class Meta:
         verbose_name = _('page')
         verbose_name_plural = _('pages')
+        unique_together = [("translation_key", "locale")]
 
 
 class Orderable(models.Model):

+ 54 - 0
wagtail/core/tests/test_locale_model.py

@@ -0,0 +1,54 @@
+from django.conf import settings
+from django.test import TestCase, override_settings
+from django.utils import translation
+
+from wagtail.core.models import Locale, Page
+from wagtail.tests.i18n.models import TestPage
+
+
+def make_test_page(**kwargs):
+    root_page = Page.objects.get(id=1)
+    kwargs.setdefault("title", "Test page")
+    return root_page.add_child(instance=TestPage(**kwargs))
+
+
+class TestLocaleModel(TestCase):
+    def setUp(self):
+        language_codes = dict(settings.LANGUAGES).keys()
+
+        for language_code in language_codes:
+            Locale.objects.get_or_create(language_code=language_code)
+
+    def test_default(self):
+        locale = Locale.get_default()
+        self.assertEqual(locale.language_code, "en")
+
+    @override_settings(LANGUAGE_CODE="fr-ca")
+    def test_default_doesnt_have_to_be_english(self):
+        locale = Locale.get_default()
+        self.assertEqual(locale.language_code, "fr")
+
+    def test_get_active_default(self):
+        self.assertEqual(Locale.get_active().language_code, "en")
+
+    def test_get_active_overridden(self):
+        with translation.override("fr"):
+            self.assertEqual(Locale.get_active().language_code, "fr")
+
+    def test_get_display_name(self):
+        locale = Locale.objects.get(language_code="en")
+        self.assertEqual(locale.get_display_name(), "English")
+
+    def test_get_display_name_for_unconfigured_langauge(self):
+        # This language is not in LANGUAGES so it should just return the language code
+        locale = Locale.objects.create(language_code="foo")
+        self.assertIsNone(locale.get_display_name())
+
+    def test_str(self):
+        locale = Locale.objects.get(language_code="en")
+        self.assertEqual(str(locale), "English")
+
+    def test_str_for_unconfigured_langauge(self):
+        # This language is not in LANGUAGES so it should just return the language code
+        locale = Locale.objects.create(language_code="foo")
+        self.assertEqual(str(locale), "foo")

+ 181 - 9
wagtail/core/tests/test_page_model.py

@@ -15,14 +15,15 @@ from django.test.utils import override_settings
 from django.utils import timezone
 from freezegun import freeze_time
 
-from wagtail.core.models import Page, PageManager, Site, get_page_models
+from wagtail.core.models import (
+    Locale, Page, PageManager, ParentNotTranslatedError, Site, get_page_models, get_translatable_models)
 from wagtail.tests.testapp.models import (
     AbstractPage, Advert, AlwaysShowInMenusPage, BlogCategory, BlogCategoryBlogPage, BusinessChild,
     BusinessIndex, BusinessNowherePage, BusinessSubIndex, CustomManager, CustomManagerPage,
-    CustomPageQuerySet, EventCategory, EventIndex, EventPage, GenericSnippetPage, ManyToManyBlogPage,
-    MTIBasePage, MTIChildPage, MyCustomPage, OneToOnePage, PageWithExcludedCopyField, SimpleChildPage,
-    SimplePage, SimpleParentPage, SingleEventPage, SingletonPage, StandardIndex, StreamPage,
-    TaggedPage)
+    CustomPageQuerySet, EventCategory, EventIndex, EventPage, EventPageSpeaker, GenericSnippetPage,
+    ManyToManyBlogPage, MTIBasePage, MTIChildPage, MyCustomPage, OneToOnePage,
+    PageWithExcludedCopyField, SimpleChildPage, SimplePage, SimpleParentPage, SingleEventPage,
+    SingletonPage, StandardIndex, StreamPage, TaggedPage)
 from wagtail.tests.utils import WagtailTestUtils
 
 
@@ -1255,14 +1256,13 @@ class TestCopyPage(TestCase):
         self.assertNotEqual(page.id, new_page.id)
 
     def test_copy_page_with_additional_excluded_fields(self):
-
         homepage = Page.objects.get(url_path='/home/')
-        page = PageWithExcludedCopyField(
+        page = homepage.add_child(instance=PageWithExcludedCopyField(
             title='Discovery',
             slug='disco',
             content='NCC-1031',
-            special_field='Context is for Kings')
-        new_page = page.copy(to=homepage)
+            special_field='Context is for Kings'))
+        new_page = page.copy(to=homepage, update_attrs={'slug': 'disco-2'})
 
         self.assertEqual(page.title, new_page.title)
         self.assertNotEqual(page.id, new_page.id)
@@ -1331,6 +1331,121 @@ class TestCopyPage(TestCase):
             EventPage.exclude_fields_in_copy = []
 
 
+class TestCopyForTranslation(TestCase):
+    fixtures = ['test.json']
+
+    def setUp(self):
+        self.en_homepage = Page.objects.get(url_path='/home/').specific
+        self.en_eventindex = EventIndex.objects.get(url_path='/home/events/')
+        self.en_eventpage = EventPage.objects.get(url_path='/home/events/christmas/')
+        self.root_page = self.en_homepage.get_parent()
+        self.fr_locale = Locale.objects.create(language_code="fr")
+
+    def test_copy_homepage(self):
+        fr_homepage = self.en_homepage.copy_for_translation(self.fr_locale)
+
+        self.assertNotEqual(self.en_homepage.id, fr_homepage.id)
+        self.assertEqual(fr_homepage.locale, self.fr_locale)
+        self.assertEqual(fr_homepage.translation_key, self.en_homepage.translation_key)
+
+        # At the top level, the langauge code should be appended to the slug
+        self.assertEqual(fr_homepage.slug, "home-fr")
+
+        # Translation must be in draft
+        self.assertFalse(fr_homepage.live)
+        self.assertTrue(fr_homepage.has_unpublished_changes)
+
+    def test_copy_homepage_slug_exists(self):
+        # This test is the same as test_copy_homepage, but we will create another page with
+        # the slug "home-fr" before translating. copy_for_translation should pick a different slug
+        self.root_page.add_child(instance=SimplePage(title="Old french homepage", slug="home-fr", content="Test content"))
+
+        fr_homepage = self.en_homepage.copy_for_translation(self.fr_locale)
+        self.assertEqual(fr_homepage.slug, "home-fr-1")
+
+    def test_copy_childpage(self):
+        # Create translated homepage manually
+        fr_homepage = self.root_page.add_child(instance=Page(
+            title="french homepage",
+            slug="home-fr",
+            locale=self.fr_locale,
+            translation_key=self.en_homepage.translation_key
+        ))
+
+        fr_eventindex = self.en_eventindex.copy_for_translation(self.fr_locale)
+
+        self.assertNotEqual(self.en_eventindex.id, fr_eventindex.id)
+        self.assertEqual(fr_eventindex.locale, self.fr_locale)
+        self.assertEqual(fr_eventindex.translation_key, self.en_eventindex.translation_key)
+
+        # Check that the fr event index was created under the fr homepage
+        self.assertEqual(fr_eventindex.get_parent(), fr_homepage)
+
+        # The slug should be the same when copying to another tree
+        self.assertEqual(self.en_eventindex.slug, fr_eventindex.slug)
+
+    def test_copy_childpage_without_parent(self):
+        # This test is the same as test_copy_childpage but we won't create the parent page first
+
+        with self.assertRaises(ParentNotTranslatedError):
+            self.en_eventindex.copy_for_translation(self.fr_locale)
+
+    def test_copy_childpage_with_copy_parents(self):
+        # This time we will set copy_parents
+        fr_eventindex = self.en_eventindex.copy_for_translation(self.fr_locale, copy_parents=True)
+
+        self.assertNotEqual(self.en_eventindex.id, fr_eventindex.id)
+        self.assertEqual(fr_eventindex.locale, self.fr_locale)
+        self.assertEqual(fr_eventindex.translation_key, self.en_eventindex.translation_key)
+        self.assertEqual(self.en_eventindex.slug, fr_eventindex.slug)
+
+        # This should create the homepage as well
+        fr_homepage = fr_eventindex.get_parent()
+
+        self.assertNotEqual(self.en_homepage.id, fr_homepage.id)
+        self.assertEqual(fr_homepage.locale, self.fr_locale)
+        self.assertEqual(fr_homepage.translation_key, self.en_homepage.translation_key)
+        self.assertEqual(fr_homepage.slug, "home-fr")
+
+    def test_copy_page_with_translatable_child_objects(self):
+        # Create translated homepage and event index manually
+        fr_homepage = self.root_page.add_child(instance=Page(
+            title="french homepage",
+            slug="home-fr",
+            locale=self.fr_locale,
+            translation_key=self.en_homepage.translation_key
+        ))
+
+        fr_homepage.add_child(instance=EventIndex(
+            title="Events",
+            slug="events",
+            locale=self.fr_locale,
+            translation_key=self.en_eventindex.translation_key
+        ))
+
+        # Add an award to the speaker
+        # TODO: Nested child objects not supported by page copy
+        en_speaker = self.en_eventpage.speakers.get()
+        # en_award = EventPageSpeakerAward.objects.create(
+        #     speaker=en_speaker,
+        #     name="Golden Globe"
+        # )
+
+        fr_eventpage = self.en_eventpage.copy_for_translation(self.fr_locale)
+
+        # Check that the speakers and awards were copied for translation properly
+        fr_speaker = fr_eventpage.speakers.get()
+        self.assertEqual(fr_speaker.locale, self.fr_locale)
+        self.assertEqual(fr_speaker.translation_key, en_speaker.translation_key)
+        self.assertEqual(list(fr_speaker.get_translations()), [en_speaker])
+
+        # TODO: Nested child objects not supported by page copy
+        # fr_award = fr_speaker.awards.get()
+        # self.assertEqual(ffr_award.locale, self.fr_locale)
+        # self.assertEqual(ffr_award.translation_key, en_award.translation_key)
+        # self.assertEqual(list(fr_award.get_translations()), [en_award])
+
+
 class TestSubpageTypeBusinessRules(TestCase, WagtailTestUtils):
     def test_allowed_subpage_models(self):
         # SimplePage does not define any restrictions on subpage types
@@ -1896,3 +2011,60 @@ class TestCachedContentType(TestCase):
         self.assertEqual(
             result, ContentType.objects.get(id=self.page.content_type_id)
         )
+
+
+class TestGetTranslatableModels(TestCase):
+    def test_get_translatable_models(self):
+        translatable_models = get_translatable_models()
+
+        # Only root translatable models should be included by default
+        self.assertNotIn(EventPage, translatable_models)
+
+        self.assertIn(Page, translatable_models)
+        self.assertIn(EventPageSpeaker, translatable_models)
+        self.assertNotIn(Site, translatable_models)
+        self.assertNotIn(Advert, translatable_models)
+
+    def test_get_translatable_models_include_subclasses(self):
+        translatable_models = get_translatable_models(include_subclasses=True)
+
+        self.assertIn(EventPage, translatable_models)
+
+        self.assertIn(Page, translatable_models)
+        self.assertIn(EventPageSpeaker, translatable_models)
+        self.assertNotIn(Site, translatable_models)
+        self.assertNotIn(Advert, translatable_models)
+
+
+class TestDefaultLocale(TestCase):
+    def setUp(self):
+        self.root_page = Page.objects.first()
+
+    def test_default_locale(self):
+        page = self.root_page.add_child(
+            instance=SimplePage(title="Test1", slug="test1", content="test")
+        )
+
+        self.assertEqual(page.locale, self.root_page.locale)
+
+    def test_override_default_locale(self):
+        fr_locale = Locale.objects.create(language_code="fr")
+
+        page = self.root_page.add_child(
+            instance=SimplePage(title="Test1", slug="test1", content="test", locale=fr_locale)
+        )
+
+        self.assertEqual(page.locale, fr_locale)
+
+    def test_always_defaults_to_parent_locale(self):
+        fr_locale = Locale.objects.create(language_code="fr")
+
+        fr_page = self.root_page.add_child(
+            instance=SimplePage(title="Test1", slug="test1", content="test", locale=fr_locale)
+        )
+
+        page = fr_page.add_child(
+            instance=SimplePage(title="Test1", slug="test1", content="test")
+        )
+
+        self.assertEqual(page.locale, fr_locale)

+ 32 - 1
wagtail/core/tests/test_page_permissions.py

@@ -6,7 +6,7 @@ from django.test import Client, TestCase, override_settings
 from django.utils import timezone
 
 from wagtail.core.models import (
-    GroupApprovalTask, GroupPagePermission, Page, UserPagePermissionsProxy, Workflow, WorkflowTask)
+    GroupApprovalTask, GroupPagePermission, Locale, Page, UserPagePermissionsProxy, Workflow, WorkflowTask)
 from wagtail.tests.testapp.models import (
     BusinessSubIndex, EventIndex, EventPage, SingletonPageViaMaxCount)
 
@@ -307,6 +307,37 @@ class TestPagePermission(TestCase):
         # cannot move because the parent_page_types rule of BusinessSubIndex forbids EventPage as a parent
         self.assertFalse(board_meetings_perms.can_move_to(unpublished_event_page))
 
+    def test_cant_move_pages_between_locales(self):
+        user = get_user_model().objects.get(username='superuser')
+        homepage = Page.objects.get(url_path='/home/').specific
+        root = Page.objects.get(url_path='/').specific
+
+        fr_locale = Locale.objects.create(language_code="fr")
+        fr_page = root.add_child(instance=Page(
+            title="French page",
+            slug="french-page",
+            locale=fr_locale,
+        ))
+
+        fr_homepage = root.add_child(instance=Page(
+            title="French homepage",
+            slug="french-homepage",
+            locale=fr_locale,
+        ))
+
+        french_page_perms = fr_page.permissions_for_user(user)
+
+        # fr_page can be moved into fr_homepage but not homepage
+        self.assertFalse(french_page_perms.can_move_to(homepage))
+        self.assertTrue(french_page_perms.can_move_to(fr_homepage))
+
+        # All pages can be moved to the root, regardless what language they are
+        self.assertTrue(french_page_perms.can_move_to(root))
+
+        events_index = Page.objects.get(url_path='/home/events/')
+        events_index_perms = events_index.permissions_for_user(user)
+        self.assertTrue(events_index_perms.can_move_to(root))
+
     def test_editable_pages_for_user_with_add_permission(self):
         event_editor = get_user_model().objects.get(email='eventeditor@example.com')
         homepage = Page.objects.get(url_path='/home/')

+ 109 - 0
wagtail/core/tests/test_translatablemixin.py

@@ -0,0 +1,109 @@
+from unittest.mock import patch
+
+from django.conf import settings
+from django.test import TestCase
+
+from wagtail.core.models import Locale
+
+from wagtail.tests.i18n.models import InheritedTestModel, TestModel
+
+
+def make_test_instance(model=None, **kwargs):
+    if model is None:
+        model = TestModel
+
+    return model.objects.create(**kwargs)
+
+
+class TestTranslatableMixin(TestCase):
+    def setUp(self):
+        language_codes = dict(settings.LANGUAGES).keys()
+
+        for language_code in language_codes:
+            Locale.objects.get_or_create(language_code=language_code)
+
+        # create the locales
+        self.locale = Locale.objects.get(language_code="en")
+        self.another_locale = Locale.objects.get(language_code="fr")
+
+        # add the main model
+        self.main_instance = make_test_instance(
+            locale=self.locale, title="Main Model"
+        )
+
+        # add a translated model
+        self.translated_model = make_test_instance(
+            locale=self.another_locale,
+            translation_key=self.main_instance.translation_key,
+            title="Translated Model",
+        )
+
+        # add a random model that shouldn't show up anywhere
+        make_test_instance()
+
+    def test_get_translations_inclusive_false(self):
+        self.assertSequenceEqual(
+            list(self.main_instance.get_translations()), [self.translated_model]
+        )
+
+    def test_get_translations_inclusive_true(self):
+        self.assertEqual(
+            list(self.main_instance.get_translations(inclusive=True)),
+            [self.main_instance, self.translated_model],
+        )
+
+    def test_get_translation(self):
+        self.assertEqual(
+            self.main_instance.get_translation(self.locale), self.main_instance
+        )
+
+    def test_get_translation_using_locale_id(self):
+        self.assertEqual(
+            self.main_instance.get_translation(self.locale.id), self.main_instance
+        )
+
+    def test_get_translation_or_none_return_translation(self):
+        with patch.object(
+            self.main_instance, "get_translation"
+        ) as mock_get_translation:
+            mock_get_translation.return_value = self.translated_model
+            self.assertEqual(
+                self.main_instance.get_translation_or_none(self.another_locale),
+                self.translated_model,
+            )
+
+    def test_get_translation_or_none_return_none(self):
+        self.translated_model.delete()
+        with patch.object(
+            self.main_instance, "get_translation"
+        ) as mock_get_translation:
+            mock_get_translation.side_effect = self.main_instance.DoesNotExist
+            self.assertEqual(
+                self.main_instance.get_translation_or_none(self.another_locale), None
+            )
+
+    def test_has_translation_when_exists(self):
+        self.assertTrue(self.main_instance.has_translation(self.locale))
+
+    def test_has_translation_when_exists_using_locale_id(self):
+        self.assertTrue(self.main_instance.has_translation(self.locale.id))
+
+    def test_has_translation_when_none_exists(self):
+        self.translated_model.delete()
+        self.assertFalse(self.main_instance.has_translation(self.another_locale))
+
+    def test_copy_for_translation(self):
+        self.translated_model.delete()
+        copy = self.main_instance.copy_for_translation(locale=self.another_locale)
+
+        self.assertNotEqual(copy, self.main_instance)
+        self.assertEqual(copy.translation_key, self.main_instance.translation_key)
+        self.assertEqual(copy.locale, self.another_locale)
+        self.assertEqual("Main Model", copy.title)
+
+    def test_get_translation_model(self):
+        self.assertEqual(self.main_instance.get_translation_model(), TestModel)
+
+        # test with a model that inherits from `TestModel`
+        inherited_model = make_test_instance(model=InheritedTestModel)
+        self.assertEqual(inherited_model.get_translation_model(), TestModel)

+ 24 - 2
wagtail/core/tests/test_utils.py

@@ -2,9 +2,10 @@
 from django.test import TestCase
 from django.utils.text import slugify
 
+from wagtail.core.models import Page
 from wagtail.core.utils import (
-    accepts_kwarg, camelcase_to_underscore, cautious_slugify,
-    safe_snake_case, string_to_ascii)
+    accepts_kwarg, camelcase_to_underscore, cautious_slugify, find_available_slug, safe_snake_case,
+    string_to_ascii)
 
 
 class TestCamelCaseToUnderscore(TestCase):
@@ -116,3 +117,24 @@ class TestAcceptsKwarg(TestCase):
         self.assertFalse(accepts_kwarg(func_without_banana, 'banana'))
         self.assertTrue(accepts_kwarg(func_with_banana, 'banana'))
         self.assertTrue(accepts_kwarg(func_with_kwargs, 'banana'))
+
+
+class TestFindAvailableSlug(TestCase):
+    def setUp(self):
+        self.root_page = Page.objects.get(depth=1)
+        self.home_page = Page.objects.get(depth=2)
+
+        self.root_page.add_child(instance=Page(title="Second homepage", slug="home-1"))
+
+    def test_find_available_slug(self):
+        with self.assertNumQueries(1):
+            slug = find_available_slug(self.root_page, "unique-slug")
+
+        self.assertEqual(slug, "unique-slug")
+
+    def test_find_available_slug_already_used(self):
+        # Even though the first two slugs are already used, this still requires only one query to find a unique one
+        with self.assertNumQueries(1):
+            slug = find_available_slug(self.root_page, "home")
+
+        self.assertEqual(slug, "home-2")

+ 27 - 0
wagtail/core/utils.py

@@ -168,3 +168,30 @@ class InvokeViaAttributeShortcut:
     def __getattr__(self, name):
         method = getattr(self.obj, self.method_name)
         return method(name)
+
+
+def find_available_slug(parent, requested_slug):
+    """
+    Finds an available slug within the specified parent.
+
+    If the requested slug is not available, this adds a number on the end, for example:
+
+     - 'requested-slug'
+     - 'requested-slug-1'
+     - 'requested-slug-2'
+
+    And so on, until an available slug is found.
+    """
+    existing_slugs = set(
+        parent.get_children()
+        .filter(slug__startswith=requested_slug)
+        .values_list("slug", flat=True)
+    )
+    slug = requested_slug
+    number = 1
+
+    while slug in existing_slugs:
+        slug = requested_slug + "-" + str(number)
+        number += 1
+
+    return slug

+ 0 - 0
wagtail/tests/i18n/__init__.py


+ 79 - 0
wagtail/tests/i18n/migrations/0001_initial.py

@@ -0,0 +1,79 @@
+# Generated by Django 2.2.10 on 2020-07-13 15:59
+
+from django.db import migrations, models
+import django.db.models.deletion
+import modelcluster.fields
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('wagtailcore', '0057_page_locale_fields_notnull'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='TestModel',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('translation_key', models.UUIDField(default=uuid.uuid4, editable=False)),
+                ('title', models.CharField(max_length=255)),
+                ('locale', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='wagtailcore.Locale')),
+            ],
+            options={
+                'abstract': False,
+                'unique_together': {('translation_key', 'locale')},
+            },
+        ),
+        migrations.CreateModel(
+            name='TestPage',
+            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')),
+            ],
+            options={
+                'abstract': False,
+            },
+            bases=('wagtailcore.page',),
+        ),
+        migrations.CreateModel(
+            name='InheritedTestModel',
+            fields=[
+                ('testmodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='i18n.TestModel')),
+            ],
+            bases=('i18n.testmodel',),
+        ),
+        migrations.CreateModel(
+            name='TestNonParentalChildObject',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('translation_key', models.UUIDField(default=uuid.uuid4, editable=False)),
+                ('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
+                ('field', models.TextField()),
+                ('locale', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='wagtailcore.Locale')),
+                ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='test_nonparentalchildobjects', to='i18n.TestPage')),
+            ],
+            options={
+                'abstract': False,
+                'unique_together': {('translation_key', 'locale')},
+            },
+        ),
+        migrations.CreateModel(
+            name='TestChildObject',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('translation_key', models.UUIDField(default=uuid.uuid4, editable=False)),
+                ('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
+                ('field', models.TextField()),
+                ('locale', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='wagtailcore.Locale')),
+                ('page', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='test_childobjects', to='i18n.TestPage')),
+            ],
+            options={
+                'ordering': ['sort_order'],
+                'abstract': False,
+                'unique_together': {('translation_key', 'locale')},
+            },
+        ),
+    ]

+ 0 - 0
wagtail/tests/i18n/migrations/__init__.py


+ 32 - 0
wagtail/tests/i18n/models.py

@@ -0,0 +1,32 @@
+from django.db import models
+from modelcluster.fields import ParentalKey
+
+from wagtail.core.models import Orderable, Page, TranslatableMixin
+
+
+class TestPage(Page):
+    pass
+
+
+class TestModel(TranslatableMixin):
+    title = models.CharField(max_length=255)
+
+
+class InheritedTestModel(TestModel):
+    class Meta:
+        unique_together = None
+
+
+class TestChildObject(TranslatableMixin, Orderable):
+    page = ParentalKey(TestPage, related_name="test_childobjects")
+    field = models.TextField()
+
+    class Meta(TranslatableMixin.Meta, Orderable.Meta):
+        pass
+
+
+class TestNonParentalChildObject(TranslatableMixin, Orderable):
+    page = models.ForeignKey(
+        TestPage, on_delete=models.CASCADE, related_name="test_nonparentalchildobjects"
+    )
+    field = models.TextField()

+ 1 - 0
wagtail/tests/settings.py

@@ -111,6 +111,7 @@ INSTALLED_APPS = [
     'wagtail.tests.routablepage',
     'wagtail.tests.search',
     'wagtail.tests.modeladmintest',
+    'wagtail.tests.i18n',
     'wagtail.contrib.styleguide',
     'wagtail.contrib.routable_page',
     'wagtail.contrib.frontend_cache',

+ 91 - 0
wagtail/tests/testapp/migrations/0055_eventpage_childobject_i18n.py

@@ -0,0 +1,91 @@
+# Generated by Django 3.0.8 on 2020-07-31 12:18
+
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wagtailcore', '0057_page_locale_fields_notnull'),
+        ('tests', '0054_simpletask'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='eventcategory',
+            name='locale',
+            field=models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='wagtailcore.Locale'),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='eventcategory',
+            name='translation_key',
+            field=models.UUIDField(default=uuid.uuid4, editable=False),
+        ),
+        migrations.AddField(
+            model_name='eventpagecarouselitem',
+            name='locale',
+            field=models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='wagtailcore.Locale'),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='eventpagecarouselitem',
+            name='translation_key',
+            field=models.UUIDField(default=uuid.uuid4, editable=False),
+        ),
+        migrations.AddField(
+            model_name='eventpagerelatedlink',
+            name='locale',
+            field=models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='wagtailcore.Locale'),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='eventpagerelatedlink',
+            name='translation_key',
+            field=models.UUIDField(default=uuid.uuid4, editable=False),
+        ),
+        migrations.AddField(
+            model_name='eventpagespeaker',
+            name='locale',
+            field=models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='wagtailcore.Locale'),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='eventpagespeaker',
+            name='translation_key',
+            field=models.UUIDField(default=uuid.uuid4, editable=False),
+        ),
+        migrations.AddField(
+            model_name='eventpagespeakeraward',
+            name='locale',
+            field=models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='wagtailcore.Locale'),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='eventpagespeakeraward',
+            name='translation_key',
+            field=models.UUIDField(default=uuid.uuid4, editable=False),
+        ),
+        migrations.AlterUniqueTogether(
+            name='eventcategory',
+            unique_together={('translation_key', 'locale')},
+        ),
+        migrations.AlterUniqueTogether(
+            name='eventpagecarouselitem',
+            unique_together={('translation_key', 'locale')},
+        ),
+        migrations.AlterUniqueTogether(
+            name='eventpagerelatedlink',
+            unique_together={('translation_key', 'locale')},
+        ),
+        migrations.AlterUniqueTogether(
+            name='eventpagespeaker',
+            unique_together={('translation_key', 'locale')},
+        ),
+        migrations.AlterUniqueTogether(
+            name='eventpagespeakeraward',
+            unique_together={('translation_key', 'locale')},
+        ),
+    ]

+ 18 - 6
wagtail/tests/testapp/models.py

@@ -33,7 +33,7 @@ from wagtail.contrib.sitemaps import Sitemap
 from wagtail.contrib.table_block.blocks import TableBlock
 from wagtail.core.blocks import CharBlock, RawHTMLBlock, RichTextBlock, StructBlock
 from wagtail.core.fields import RichTextField, StreamField
-from wagtail.core.models import Orderable, Page, PageManager, PageQuerySet, Task
+from wagtail.core.models import Orderable, Page, PageManager, PageQuerySet, Task, TranslatableMixin
 from wagtail.documents.edit_handlers import DocumentChooserPanel
 from wagtail.documents.models import AbstractDocument, Document
 from wagtail.images.blocks import ImageChooserBlock
@@ -192,15 +192,21 @@ FilePage.content_panels = [
 
 # Event page
 
-class EventPageCarouselItem(Orderable, CarouselItem):
+class EventPageCarouselItem(TranslatableMixin, Orderable, CarouselItem):
     page = ParentalKey('tests.EventPage', related_name='carousel_items', on_delete=models.CASCADE)
 
+    class Meta(TranslatableMixin.Meta, Orderable.Meta):
+        pass
 
-class EventPageRelatedLink(Orderable, RelatedLink):
+
+class EventPageRelatedLink(TranslatableMixin, Orderable, RelatedLink):
     page = ParentalKey('tests.EventPage', related_name='related_links', on_delete=models.CASCADE)
 
+    class Meta(TranslatableMixin.Meta, Orderable.Meta):
+        pass
+
 
-class EventPageSpeakerAward(Orderable, models.Model):
+class EventPageSpeakerAward(TranslatableMixin, Orderable, models.Model):
     speaker = ParentalKey('tests.EventPageSpeaker', related_name='awards', on_delete=models.CASCADE)
     name = models.CharField("Award name", max_length=255)
     date_awarded = models.DateField(null=True, blank=True)
@@ -210,8 +216,11 @@ class EventPageSpeakerAward(Orderable, models.Model):
         FieldPanel('date_awarded'),
     ]
 
+    class Meta(TranslatableMixin.Meta, Orderable.Meta):
+        pass
 
-class EventPageSpeaker(Orderable, LinkFields, ClusterableModel):
+
+class EventPageSpeaker(TranslatableMixin, Orderable, LinkFields, ClusterableModel):
     page = ParentalKey('tests.EventPage', related_name='speakers', related_query_name='speaker', on_delete=models.CASCADE)
     first_name = models.CharField("Name", max_length=255, blank=True)
     last_name = models.CharField("Surname", max_length=255, blank=True)
@@ -235,8 +244,11 @@ class EventPageSpeaker(Orderable, LinkFields, ClusterableModel):
         InlinePanel('awards', label="Awards"),
     ]
 
+    class Meta(TranslatableMixin.Meta, Orderable.Meta):
+        pass
+
 
-class EventCategory(models.Model):
+class EventCategory(TranslatableMixin, models.Model):
     name = models.CharField("Name", max_length=255)
 
     def __str__(self):