Browse Source

Un-revert #6220

This reverts commit 3467e57de946df5f9f62b173b1ce327ab729dcc5.
Matt Westcott 4 years ago
parent
commit
f2f4503f4f
35 changed files with 1895 additions and 94 deletions
  1. 6 0
      docs/releases/2.11.rst
  2. 1 1
      wagtail/admin/rich_text/converters/editor_html.py
  3. 1 1
      wagtail/admin/templates/wagtailadmin/pages/listing/_page_title_explore.html
  4. 49 44
      wagtail/admin/templatetags/wagtailuserbar.py
  5. 15 1
      wagtail/admin/tests/pages/test_create_page.py
  6. 1 0
      wagtail/admin/views/pages/create.py
  7. 26 0
      wagtail/contrib/frontend_cache/tests.py
  8. 6 1
      wagtail/contrib/frontend_cache/utils.py
  9. 29 1
      wagtail/contrib/sitemaps/tests.py
  10. 1 0
      wagtail/core/compat.py
  11. 23 0
      wagtail/core/migrations/0053_locale_model.py
  12. 25 0
      wagtail/core/migrations/0054_initial_locale.py
  13. 28 0
      wagtail/core/migrations/0055_page_locale_fields.py
  14. 16 0
      wagtail/core/migrations/0056_page_locale_fields_populate.py
  15. 25 0
      wagtail/core/migrations/0057_page_locale_fields_notnull.py
  16. 438 19
      wagtail/core/models.py
  17. 26 0
      wagtail/core/query.py
  18. 1 1
      wagtail/core/rich_text/pages.py
  19. 54 0
      wagtail/core/tests/test_locale_model.py
  20. 366 10
      wagtail/core/tests/test_page_model.py
  21. 32 1
      wagtail/core/tests/test_page_permissions.py
  22. 57 1
      wagtail/core/tests/test_page_queryset.py
  23. 47 1
      wagtail/core/tests/test_rich_text.py
  24. 150 0
      wagtail/core/tests/test_translatablemixin.py
  25. 138 3
      wagtail/core/tests/test_utils.py
  26. 2 2
      wagtail/core/tests/tests.py
  27. 104 0
      wagtail/core/utils.py
  28. 1 1
      wagtail/core/views.py
  29. 0 0
      wagtail/tests/i18n/__init__.py
  30. 79 0
      wagtail/tests/i18n/migrations/0001_initial.py
  31. 0 0
      wagtail/tests/i18n/migrations/__init__.py
  32. 32 0
      wagtail/tests/i18n/models.py
  33. 7 0
      wagtail/tests/settings.py
  34. 91 0
      wagtail/tests/testapp/migrations/0055_eventpage_childobject_i18n.py
  35. 18 6
      wagtail/tests/testapp/models.py

+ 6 - 0
docs/releases/2.11.rst

@@ -55,3 +55,9 @@ you run the following command in each environment:
 Previously, collections were stored in the order in which they were created - and then sorted by name where displayed in the CMS. Collections are now ordered by treebeard path by default, so the above command must be run to retain alphabetical ordering.
 
 Failure to do this won't affect your current colllections, but may affect your ability to add new ones.
+
+
+``Site.get_site_root_paths`` now returns language code
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+In previous releases, ``Site.get_site_root_paths`` returned a list of ``(site_id, root_path, root_url)`` tuples. To support the new internationalisation model, this has now been changed to a list of named tuples with the fields: ``site_id``, ``root_path``, ``root_url`` and ``language_code``. Existing code that handled this as a 3-tuple should be updated accordingly.

+ 1 - 1
wagtail/admin/rich_text/converters/editor_html.py

@@ -174,6 +174,6 @@ class PageLinkHandler:
             if parent_page:
                 attrs += 'data-parent-id="%d" ' % parent_page.id
 
-            return '<a %shref="%s">' % (attrs, escape(page.specific.url))
+            return '<a %shref="%s">' % (attrs, escape(page.localized.specific.url))
         except Page.DoesNotExist:
             return "<a>"

+ 1 - 1
wagtail/admin/templates/wagtailadmin/pages/listing/_page_title_explore.html

@@ -3,7 +3,7 @@
 {# The title field for a page in the page listing, when in 'explore' mode #}
 
 <div class="title-wrapper">
-    {% if page.sites_rooted_here.exists %}
+    {% if page.is_site_root %}
         {% if perms.wagtailcore.add_site or perms.wagtailcore.change_site or perms.wagtailcore.delete_site %}
             <a href="{% url 'wagtailsites:index' %}" class="icon icon-site" title="{% trans 'Sites menu' %}"></a>
         {% endif %}

+ 49 - 44
wagtail/admin/templatetags/wagtailuserbar.py

@@ -1,11 +1,13 @@
 from django import template
 from django.template.loader import render_to_string
+from django.utils import translation
 
 from wagtail.admin.userbar import (
     AddPageItem, AdminItem, ApproveModerationEditPageItem, EditPageItem, ExplorePageItem,
     RejectModerationEditPageItem)
 from wagtail.core import hooks
 from wagtail.core.models import PAGE_TEMPLATE_VAR, Page, PageRevision
+from wagtail.users.models import UserProfile
 
 
 register = template.Library()
@@ -42,48 +44,51 @@ def wagtailuserbar(context, position='bottom-right'):
     if not user.has_perm('wagtailadmin.access_admin'):
         return ''
 
-    page = get_page_instance(context)
-
-    try:
-        revision_id = request.revision_id
-    except AttributeError:
-        revision_id = None
-
-    if page and page.id:
-        if revision_id:
-            items = [
-                AdminItem(),
-                ExplorePageItem(PageRevision.objects.get(id=revision_id).page),
-                EditPageItem(PageRevision.objects.get(id=revision_id).page),
-                ApproveModerationEditPageItem(PageRevision.objects.get(id=revision_id)),
-                RejectModerationEditPageItem(PageRevision.objects.get(id=revision_id)),
-            ]
+    # Render the userbar using the user's preferred admin language
+    userprofile = UserProfile.get_for_user(user)
+    with translation.override(userprofile.get_preferred_language()):
+        page = get_page_instance(context)
+
+        try:
+            revision_id = request.revision_id
+        except AttributeError:
+            revision_id = None
+
+        if page and page.id:
+            if revision_id:
+                items = [
+                    AdminItem(),
+                    ExplorePageItem(PageRevision.objects.get(id=revision_id).page),
+                    EditPageItem(PageRevision.objects.get(id=revision_id).page),
+                    ApproveModerationEditPageItem(PageRevision.objects.get(id=revision_id)),
+                    RejectModerationEditPageItem(PageRevision.objects.get(id=revision_id)),
+                ]
+            else:
+                # Not a revision
+                items = [
+                    AdminItem(),
+                    ExplorePageItem(Page.objects.get(id=page.id)),
+                    EditPageItem(Page.objects.get(id=page.id)),
+                    AddPageItem(Page.objects.get(id=page.id)),
+                ]
         else:
-            # Not a revision
-            items = [
-                AdminItem(),
-                ExplorePageItem(Page.objects.get(id=page.id)),
-                EditPageItem(Page.objects.get(id=page.id)),
-                AddPageItem(Page.objects.get(id=page.id)),
-            ]
-    else:
-        # Not a page.
-        items = [AdminItem()]
-
-    for fn in hooks.get_hooks('construct_wagtail_userbar'):
-        fn(request, items)
-
-    # Render the items
-    rendered_items = [item.render(request) for item in items]
-
-    # Remove any unrendered items
-    rendered_items = [item for item in rendered_items if item]
-
-    # Render the userbar items
-    return render_to_string('wagtailadmin/userbar/base.html', {
-        'request': request,
-        'items': rendered_items,
-        'position': position,
-        'page': page,
-        'revision_id': revision_id
-    })
+            # Not a page.
+            items = [AdminItem()]
+
+        for fn in hooks.get_hooks('construct_wagtail_userbar'):
+            fn(request, items)
+
+        # Render the items
+        rendered_items = [item.render(request) for item in items]
+
+        # Remove any unrendered items
+        rendered_items = [item for item in rendered_items if item]
+
+        # Render the userbar items
+        return render_to_string('wagtailadmin/userbar/base.html', {
+            'request': request,
+            'items': rendered_items,
+            'position': position,
+            'page': page,
+            'revision_id': revision_id
+        })

+ 15 - 1
wagtail/admin/tests/pages/test_create_page.py

@@ -10,7 +10,7 @@ from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 
 from wagtail.admin.tests.pages.timestamps import submittable_timestamp
-from wagtail.core.models import GroupPagePermission, Page, PageRevision
+from wagtail.core.models import GroupPagePermission, Locale, Page, PageRevision
 from wagtail.core.signals import page_published
 from wagtail.tests.testapp.models import (
     BusinessChild, BusinessIndex, BusinessSubIndex, DefaultStreamPage, PersonPage,
@@ -730,6 +730,20 @@ class TestPageCreation(TestCase, WagtailTestUtils):
         response = self.client.get(reverse('wagtailadmin_pages:add', args=('tests', 'simplepage', self.root_page.id)))
         self.assertNotContains(response, '<button type="submit" name="action-submit" value="Submit for moderation" class="button">Submit for moderation</button>')
 
+    def test_create_sets_locale_to_parent_locale(self):
+        # We need to make sure the page's locale it set to the parent in the create view so that any customisations
+        # for that language will take effect.
+        fr_locale = Locale.objects.create(language_code="fr")
+        fr_homepage = self.root_page.add_child(instance=Page(
+            title="Home",
+            slug="home-fr",
+            locale=fr_locale,
+        ))
+
+        response = self.client.get(reverse('wagtailadmin_pages:add', args=('tests', 'simplepage', fr_homepage.id)))
+
+        self.assertEqual(response.context['page'].locale, fr_locale)
+
 
 class TestPerRequestEditHandler(TestCase, WagtailTestUtils):
     fixtures = ['test.json']

+ 1 - 0
wagtail/admin/views/pages/create.py

@@ -75,6 +75,7 @@ class CreateView(TemplateResponseMixin, ContextMixin, HookResponseMixin, View):
             return response
 
         self.page = self.page_class(owner=self.request.user)
+        self.page.locale = self.parent_page.locale
         self.edit_handler = self.page_class.get_edit_handler()
         self.edit_handler = self.edit_handler.bind_to(request=self.request, instance=self.page)
         self.form_class = self.edit_handler.get_form_class()

+ 26 - 0
wagtail/contrib/frontend_cache/tests.py

@@ -315,6 +315,32 @@ class TestCachePurgingSignals(TestCase):
             'http://localhost/pt-br/events/past/',
         ])
 
+    @override_settings(ROOT_URLCONF='wagtail.tests.urls_multilang',
+                       LANGUAGE_CODE='en',
+                       WAGTAIL_I18N_ENABLED=True,
+                       WAGTAIL_CONTENT_LANGUAGES=[('en', 'English'), ('fr', 'French')])
+    def test_purge_on_publish_with_i18n_enabled(self):
+        PURGED_URLS[:] = []  # reset PURGED_URLS to the empty list
+        page = EventIndex.objects.get(url_path='/home/events/')
+        page.save_revision().publish()
+
+        self.assertEqual(PURGED_URLS, [
+            'http://localhost/en/events/',
+            'http://localhost/en/events/past/',
+            'http://localhost/fr/events/',
+            'http://localhost/fr/events/past/',
+        ])
+
+    @override_settings(ROOT_URLCONF='wagtail.tests.urls_multilang',
+                       LANGUAGE_CODE='en',
+                       WAGTAIL_CONTENT_LANGUAGES=[('en', 'English'), ('fr', 'French')])
+    def test_purge_on_publish_without_i18n_enabled(self):
+        # It should ignore WAGTAIL_CONTENT_LANGUAGES as WAGTAIL_I18N_ENABLED isn't set
+        PURGED_URLS[:] = []  # reset PURGED_URLS to the empty list
+        page = EventIndex.objects.get(url_path='/home/events/')
+        page.save_revision().publish()
+        self.assertEqual(PURGED_URLS, ['http://localhost/en/events/', 'http://localhost/en/events/past/'])
+
 
 class TestPurgeBatchClass(TestCase):
     # Tests the .add_*() methods on PurgeBatch. The .purge() method is tested

+ 6 - 1
wagtail/contrib/frontend_cache/utils.py

@@ -6,6 +6,8 @@ from django.conf import settings
 from django.core.exceptions import ImproperlyConfigured
 from django.utils.module_loading import import_string
 
+from wagtail.core.utils import get_content_languages
+
 logger = logging.getLogger('wagtail.frontendcache')
 
 
@@ -63,7 +65,10 @@ def purge_urls_from_cache(urls, backend_settings=None, backends=None):
     # Convert each url to urls one for each managed language (WAGTAILFRONTENDCACHE_LANGUAGES setting).
     # The managed languages are common to all the defined backends.
     # This depends on settings.USE_I18N
-    languages = getattr(settings, 'WAGTAILFRONTENDCACHE_LANGUAGES', [])
+    # If WAGTAIL_I18N_ENABLED is True, this defaults to WAGTAIL_CONTENT_LANGUAGES
+    wagtail_i18n_enabled = getattr(settings, 'WAGTAIL_I18N_ENABLED', False)
+    content_languages = get_content_languages() if wagtail_i18n_enabled else {}
+    languages = getattr(settings, 'WAGTAILFRONTENDCACHE_LANGUAGES', list(content_languages.keys()))
     if settings.USE_I18N and languages:
         langs_regex = "^/(%s)/" % "|".join(languages)
         new_urls = []

+ 29 - 1
wagtail/contrib/sitemaps/tests.py

@@ -4,7 +4,7 @@ import pytz
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.sites.shortcuts import get_current_site
-from django.test import RequestFactory, TestCase
+from django.test import RequestFactory, TestCase, override_settings
 from django.utils import timezone
 
 from wagtail.core.models import Page, PageViewRestriction, Site
@@ -113,6 +113,34 @@ class TestSitemapGenerator(TestCase):
         self.assertIn('http://localhost/', urls)  # Homepage
         self.assertIn('http://localhost/hello-world/', urls)  # Child page
 
+    @override_settings(WAGTAIL_I18N_ENABLED=True)
+    def test_get_urls_without_request_with_i18n(self):
+        request, django_site = self.get_request_and_django_site('/sitemap.xml')
+        req_protocol = request.scheme
+
+        sitemap = Sitemap()
+        with self.assertNumQueries(19):
+            urls = [url['location'] for url in sitemap.get_urls(1, django_site, req_protocol)]
+
+        self.assertIn('http://localhost/', urls)  # Homepage
+        self.assertIn('http://localhost/hello-world/', urls)  # Child page
+
+    @override_settings(WAGTAIL_I18N_ENABLED=True)
+    def test_get_urls_with_request_site_cache_with_i18n(self):
+        request, django_site = self.get_request_and_django_site('/sitemap.xml')
+        req_protocol = request.scheme
+
+        sitemap = Sitemap(request)
+
+        # pre-seed find_for_request cache, so that it's not counted towards the query count
+        Site.find_for_request(request)
+
+        with self.assertNumQueries(16):
+            urls = [url['location'] for url in sitemap.get_urls(1, django_site, req_protocol)]
+
+        self.assertIn('http://localhost/', urls)  # Homepage
+        self.assertIn('http://localhost/hello-world/', urls)  # Child page
+
     def test_get_urls_uses_specific(self):
         request, django_site = self.get_request_and_django_site('/sitemap.xml')
         req_protocol = request.scheme

+ 1 - 0
wagtail/core/compat.py

@@ -1,6 +1,7 @@
 from django.conf import settings
 from django.core.exceptions import ImproperlyConfigured
 
+
 # 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

+ 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.utils import get_supported_content_language_variant
+
+
+def initial_locale(apps, schema_editor):
+    Locale = apps.get_model("wagtailcore.Locale")
+
+    Locale.objects.create(
+        language_code=get_supported_content_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),
+        ),
+    ]

+ 438 - 19
wagtail/core/models.py

@@ -1,9 +1,12 @@
 import json
 import logging
+import uuid
+from collections import namedtuple
 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 +15,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 +50,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 .utils import (
+    find_available_slug, get_content_languages, get_supported_content_language_variant)
+
 logger = logging.getLogger('wagtail.core')
 
 PAGE_TEMPLATE_VAR = 'page'
@@ -136,6 +144,9 @@ class SiteManager(models.Manager):
         return self.get(hostname=hostname, port=port)
 
 
+SiteRootPath = namedtuple('SiteRootPath', 'site_id root_path root_url language_code')
+
+
 class Site(models.Model):
     hostname = models.CharField(verbose_name=_('hostname'), max_length=255, db_index=True)
     port = models.IntegerField(
@@ -257,22 +268,309 @@ class Site(models.Model):
     @staticmethod
     def get_site_root_paths():
         """
-        Return a list of (id, root_path, root_url) tuples, most specific path
+        Return a list of `SiteRootPath` instances, most specific path
         first - used to translate url_paths into actual URLs with hostnames
+
+        Each root path is an instance of the `SiteRootPath` named tuple,
+        and have the following attributes:
+
+         - `site_id` - The ID of the Site record
+         - `root_path` - The internal URL path of the site's home page (for example '/home/')
+         - `root_url` - The scheme/domain name of the site (for example 'https://www.example.com/')
+         - `language_code` - The language code of the site (for example 'en')
         """
         result = cache.get('wagtail_site_root_paths')
 
         if result is None:
-            result = [
-                (site.id, site.root_page.url_path, site.root_url)
-                for site in Site.objects.select_related('root_page').order_by(
-                    '-root_page__url_path', '-is_default_site', 'hostname')
-            ]
+            result = []
+
+            for site in Site.objects.select_related('root_page', 'root_page__locale').order_by('-root_page__url_path', '-is_default_site', 'hostname'):
+                if getattr(settings, 'WAGTAIL_I18N_ENABLED', False):
+                    result.extend([
+                        SiteRootPath(site.id, root_page.url_path, site.root_url, root_page.locale.language_code)
+                        for root_page in site.root_page.get_translations(inclusive=True).select_related('locale')
+                    ])
+                else:
+                    result.append(SiteRootPath(site.id, site.root_page.url_path, site.root_url, site.root_page.locale.language_code))
+
             cache.set('wagtail_site_root_paths', result, 3600)
 
         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_content_languages().keys())
+
+    def get_for_language(self, language_code):
+        """
+        Gets a Locale from a language code.
+        """
+        return self.get(language_code=get_supported_content_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_content_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")]
+
+    @classmethod
+    def check(cls, **kwargs):
+        errors = super(TranslatableMixin, cls).check(**kwargs)
+        is_translation_model = cls.get_translation_model() is cls
+
+        # Raise error if subclass has removed the unique_together constraint
+        # No need to check this on multi-table-inheritance children though as it only needs to be applied to
+        # the table that has the translation_key/locale fields
+        if is_translation_model and ("translation_key", "locale") not in cls._meta.unique_together:
+            errors.append(
+                checks.Error(
+                    "{0}.{1} is missing a unique_together constraint for the translation key and locale fields"
+                    .format(cls._meta.app_label, cls.__name__),
+                    hint="Add ('translation_key', 'locale') to {}.Meta.unique_together".format(cls.__name__),
+                    obj=cls,
+                    id='wagtailcore.E003',
+                )
+            )
+
+        return errors
+
+    @property
+    def localized(self):
+        locale = Locale.get_active()
+
+        if locale.id == self.locale_id:
+            return self
+
+        return self.get_translation_or_none(locale) or self
+
+    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_content_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 +623,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 +756,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 +839,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 +870,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):
@@ -561,6 +881,14 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
         if not Page._slug_is_available(self.slug, self.get_parent(), self):
             raise ValidationError({'slug': _("This slug is already in use")})
 
+    def is_site_root(self):
+        """
+        Returns True if this page is the root of any site.
+
+        This includes translations of site root pages as well.
+        """
+        return Site.objects.filter(root_page__translation_key=self.translation_key).exists()
+
     @transaction.atomic
     # ensure that changes are only committed when we have updated all descendant URL paths, to preserve consistency
     def save(self, clean=True, user=None, log_action=False, **kwargs):
@@ -609,7 +937,7 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
             self._update_descendant_url_paths(old_url_path, new_url_path)
 
         # Check if this is a root page of any sites and clear the 'wagtail_site_root_paths' key if so
-        if Site.objects.filter(root_page=self).exists():
+        if self.is_site_root():
             cache.delete('wagtail_site_root_paths')
 
         # Log
@@ -798,6 +1126,23 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
         """
         return ContentType.objects.get_for_id(self.content_type_id)
 
+    @property
+    def localized_draft(self):
+        locale = Locale.get_active()
+
+        if locale.id == self.locale_id:
+            return self
+
+        return self.get_translation_or_none(locale) or self
+
+    @property
+    def localized(self):
+        localized = self.localized_draft
+        if not localized.live:
+            return self
+
+        return localized
+
     def route(self, request, path_components):
         if path_components:
             # request is for a child of this page
@@ -1017,29 +1362,30 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
         """
 
         possible_sites = [
-            (pk, path, url)
-            for pk, path, url in self._get_site_root_paths(request)
+            (pk, path, url, language_code)
+            for pk, path, url, language_code in self._get_site_root_paths(request)
             if self.url_path.startswith(path)
         ]
 
         if not possible_sites:
             return None
 
-        site_id, root_path, root_url = possible_sites[0]
+        site_id, root_path, root_url, language_code = possible_sites[0]
 
         site = Site.find_for_request(request)
         if site:
-            for site_id, root_path, root_url in possible_sites:
+            for site_id, root_path, root_url, language_code in possible_sites:
                 if site_id == site.pk:
                     break
             else:
-                site_id, root_path, root_url = possible_sites[0]
+                site_id, root_path, root_url, language_code = possible_sites[0]
 
         # The page may not be routable because wagtail_serve is not registered
         # This may be the case if Wagtail is used headless
         try:
-            page_path = reverse(
-                'wagtail_serve', args=(self.url_path[len(root_path):],))
+            with translation.override(language_code):
+                page_path = reverse(
+                    'wagtail_serve', args=(self.url_path[len(root_path):],))
         except NoReverseMatch:
             return (site_id, None, None)
 
@@ -1093,7 +1439,11 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
 
         site_id, root_url, page_path = url_parts
 
-        if (current_site is not None and site_id == current_site.id) or len(self._get_site_root_paths(request)) == 1:
+        # Get number of unique sites in root paths
+        # Note: there may be more root paths to sites if there are multiple languages
+        num_sites = len(set(root_path[0] for root_path in self._get_site_root_paths(request)))
+
+        if (current_site is not None and site_id == current_site.id) or num_sites == 1:
             # the site matches OR we're only running a single site, so a local URL is sufficient
             return page_path
         else:
@@ -1256,6 +1606,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 +1728,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 +1750,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 +1764,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 +1874,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 +2229,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 +2279,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):

+ 26 - 0
wagtail/core/query.py

@@ -366,6 +366,32 @@ class PageQuerySet(SearchableQuerySetMixin, TreeQuerySet):
         """
         return self.descendant_of(site.root_page, inclusive=True)
 
+    def translation_of_q(self, page, inclusive):
+        q = Q(translation_key=page.translation_key)
+
+        if not inclusive:
+            q &= ~Q(pk=page.pk)
+
+        return q
+
+    def translation_of(self, page, inclusive=False):
+        """
+        This filters the QuerySet to only contain pages that are translations of the specified page.
+
+        If inclusive is True, the page itself is returned.
+        """
+        return self.filter(self.translation_of_q(page, inclusive))
+
+    def not_translation_of(self, page, inclusive=False):
+        """
+        This filters the QuerySet to only contain pages that are not translations of the specified page.
+
+        Note, this will include the page itself as the page is technically not a translation of itself.
+        If inclusive is True, we consider the page to be a translation of itself so this excludes the page
+        from the results.
+        """
+        return self.exclude(self.translation_of_q(page, inclusive))
+
 
 def specific_iterator(qs, defer=False):
     """

+ 1 - 1
wagtail/core/rich_text/pages.py

@@ -19,6 +19,6 @@ class PageLinkHandler(LinkHandler):
     def expand_db_attributes(cls, attrs):
         try:
             page = cls.get_instance(attrs)
-            return '<a href="%s">' % escape(page.specific.url)
+            return '<a href="%s">' % escape(page.localized.specific.url)
         except Page.DoesNotExist:
             return "<a>"

+ 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")

+ 366 - 10
wagtail/core/tests/test_page_model.py

@@ -12,17 +12,18 @@ from django.http import Http404, HttpRequest
 from django.test import Client, TestCase
 from django.test.client import RequestFactory
 from django.test.utils import override_settings
-from django.utils import timezone
+from django.utils import timezone, translation
 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
 
 
@@ -433,6 +434,160 @@ class TestRouting(TestCase):
             self.assertEqual(christmas_page.get_url(request=request), '/events/christmas/')
 
 
+@override_settings(ROOT_URLCONF='wagtail.tests.urls_multilang',
+                   LANGUAGE_CODE='en',
+                   WAGTAIL_I18N_ENABLED=True,
+                   WAGTAIL_CONTENT_LANGUAGES=[('en', "English"), ('fr', "French")])
+class TestRoutingWithI18N(TestRouting):
+    # This inherits from TestRouting so contains all the same test cases
+    # Only the test cases that behave differently under internationalisation are overridden here
+
+    def test_urls(self):
+        default_site = Site.objects.get(is_default_site=True)
+        homepage = Page.objects.get(url_path='/home/')
+        christmas_page = Page.objects.get(url_path='/home/events/christmas/')
+
+        # Basic installation only has one site configured, so page.url will return local URLs
+        self.assertEqual(
+            homepage.get_url_parts(),
+            (default_site.id, 'http://localhost', '/en/')
+        )
+        self.assertEqual(homepage.full_url, 'http://localhost/en/')
+        self.assertEqual(homepage.url, '/en/')
+        self.assertEqual(homepage.relative_url(default_site), '/en/')
+        self.assertEqual(homepage.get_site(), default_site)
+
+        self.assertEqual(
+            christmas_page.get_url_parts(),
+            (default_site.id, 'http://localhost', '/en/events/christmas/')
+        )
+        self.assertEqual(christmas_page.full_url, 'http://localhost/en/events/christmas/')
+        self.assertEqual(christmas_page.url, '/en/events/christmas/')
+        self.assertEqual(christmas_page.relative_url(default_site), '/en/events/christmas/')
+        self.assertEqual(christmas_page.get_site(), default_site)
+
+    def test_urls_with_translation_activated(self):
+        # This should have no effect as the URL is determined from the page's locale
+        # and not the active locale
+        with translation.override("fr"):
+            self.test_urls()
+
+    def test_urls_with_different_language_tree(self):
+        default_site = Site.objects.get(is_default_site=True)
+        homepage = Page.objects.get(url_path='/home/')
+        christmas_page = Page.objects.get(url_path='/home/events/christmas/')
+
+        fr_locale = Locale.objects.create(language_code="fr")
+        fr_homepage = homepage.copy_for_translation(fr_locale)
+        fr_christmas_page = christmas_page.copy_for_translation(fr_locale, copy_parents=True)
+        fr_christmas_page.slug = 'noel'
+        fr_christmas_page.save(update_fields=['slug'])
+
+        # Basic installation only has one site configured, so page.url will return local URLs
+        self.assertEqual(
+            fr_homepage.get_url_parts(),
+            (default_site.id, 'http://localhost', '/fr/')
+        )
+        self.assertEqual(fr_homepage.full_url, 'http://localhost/fr/')
+        self.assertEqual(fr_homepage.url, '/fr/')
+        self.assertEqual(fr_homepage.relative_url(default_site), '/fr/')
+        self.assertEqual(fr_homepage.get_site(), default_site)
+
+        self.assertEqual(
+            fr_christmas_page.get_url_parts(),
+            (default_site.id, 'http://localhost', '/fr/events/noel/')
+        )
+        self.assertEqual(fr_christmas_page.full_url, 'http://localhost/fr/events/noel/')
+        self.assertEqual(fr_christmas_page.url, '/fr/events/noel/')
+        self.assertEqual(fr_christmas_page.relative_url(default_site), '/fr/events/noel/')
+        self.assertEqual(fr_christmas_page.get_site(), default_site)
+
+    @override_settings(ALLOWED_HOSTS=['localhost', 'testserver', 'events.example.com', 'second-events.example.com'])
+    def test_urls_with_multiple_sites(self):
+        events_page = Page.objects.get(url_path='/home/events/')
+        events_site = Site.objects.create(hostname='events.example.com', root_page=events_page)
+
+        # An underscore is not valid according to RFC 1034/1035
+        # and will raise a DisallowedHost Exception
+        second_events_site = Site.objects.create(
+            hostname='second-events.example.com', root_page=events_page)
+
+        default_site = Site.objects.get(is_default_site=True)
+        homepage = Page.objects.get(url_path='/home/')
+        christmas_page = Page.objects.get(url_path='/home/events/christmas/')
+
+        # with multiple sites, page.url will return full URLs to ensure that
+        # they work across sites
+        self.assertEqual(
+            homepage.get_url_parts(),
+            (default_site.id, 'http://localhost', '/en/')
+        )
+        self.assertEqual(homepage.full_url, 'http://localhost/en/')
+        self.assertEqual(homepage.url, 'http://localhost/en/')
+        self.assertEqual(homepage.relative_url(default_site), '/en/')
+        self.assertEqual(homepage.relative_url(events_site), 'http://localhost/en/')
+        self.assertEqual(homepage.get_site(), default_site)
+
+        self.assertEqual(
+            christmas_page.get_url_parts(),
+            (events_site.id, 'http://events.example.com', '/en/christmas/')
+        )
+        self.assertEqual(christmas_page.full_url, 'http://events.example.com/en/christmas/')
+        self.assertEqual(christmas_page.url, 'http://events.example.com/en/christmas/')
+        self.assertEqual(christmas_page.relative_url(default_site), 'http://events.example.com/en/christmas/')
+        self.assertEqual(christmas_page.relative_url(events_site), '/en/christmas/')
+        self.assertEqual(christmas_page.get_site(), events_site)
+
+        request = HttpRequest()
+        request.META['HTTP_HOST'] = events_site.hostname
+        request.META['SERVER_PORT'] = events_site.port
+
+        self.assertEqual(
+            christmas_page.get_url_parts(request=request),
+            (events_site.id, 'http://events.example.com', '/en/christmas/')
+        )
+
+        request2 = HttpRequest()
+        request2.META['HTTP_HOST'] = second_events_site.hostname
+        request2.META['SERVER_PORT'] = second_events_site.port
+        self.assertEqual(
+            christmas_page.get_url_parts(request=request2),
+            (second_events_site.id, 'http://second-events.example.com', '/en/christmas/')
+        )
+
+    # Override CACHES so we don't generate any cache-related SQL queries (tests use DatabaseCache
+    # otherwise) and so cache.get will always return None.
+    @override_settings(CACHES={'default': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}})
+    @override_settings(ALLOWED_HOSTS=['dummy'])
+    def test_request_scope_site_root_paths_cache(self):
+        homepage = Page.objects.get(url_path='/home/')
+        christmas_page = EventPage.objects.get(url_path='/home/events/christmas/')
+
+        # without a request, get_url should only issue 2 SQL queries
+        with self.assertNumQueries(2):
+            self.assertEqual(homepage.get_url(), '/en/')
+        # subsequent calls with the same page should generate no SQL queries
+        with self.assertNumQueries(0):
+            self.assertEqual(homepage.get_url(), '/en/')
+        # subsequent calls with a different page will still generate 2 SQL queries
+        with self.assertNumQueries(2):
+            self.assertEqual(christmas_page.get_url(), '/en/events/christmas/')
+
+        # with a request, the first call to get_url should issue 1 SQL query
+        request = HttpRequest()
+        request.META['HTTP_HOST'] = "dummy"
+        request.META['SERVER_PORT'] = "8888"
+        # first call with "balnk" request issues a extra query for the Site.find_for_request() call
+        with self.assertNumQueries(3):
+            self.assertEqual(homepage.get_url(request=request), '/en/')
+        # subsequent calls should issue no SQL queries
+        with self.assertNumQueries(0):
+            self.assertEqual(homepage.get_url(request=request), '/en/')
+        # even if called on a different page
+        with self.assertNumQueries(0):
+            self.assertEqual(christmas_page.get_url(request=request), '/en/events/christmas/')
+
+
 class TestServeView(TestCase):
     fixtures = ['test.json']
 
@@ -1255,14 +1410,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 +1485,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 +2165,90 @@ 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)
+
+
+class TestLocalized(TestCase):
+    fixtures = ['test.json']
+
+    def setUp(self):
+        self.fr_locale = Locale.objects.create(language_code="fr")
+        self.event_page = Page.objects.get(url_path='/home/events/christmas/')
+        self.fr_event_page = self.event_page.copy_for_translation(self.fr_locale, copy_parents=True)
+        self.fr_event_page.title = 'Noël'
+        self.fr_event_page.save(update_fields=['title'])
+        self.fr_event_page.save_revision().publish()
+
+    def test_localized_same_language(self):
+        self.assertEqual(self.event_page.localized, self.event_page)
+        self.assertEqual(self.event_page.localized_draft, self.event_page)
+
+    def test_localized_different_language(self):
+        with translation.override("fr"):
+            self.assertEqual(self.event_page.localized, self.fr_event_page.page_ptr)
+            self.assertEqual(self.event_page.localized_draft, self.fr_event_page.page_ptr)
+
+    def test_localized_different_language_unpublished(self):
+        # We shouldn't autolocalize if the translation is unpublished
+        self.fr_event_page.unpublish()
+        self.fr_event_page.save()
+
+        with translation.override("fr"):
+            self.assertEqual(self.event_page.localized, self.event_page)
+            self.assertEqual(self.event_page.localized_draft, self.fr_event_page.page_ptr)

+ 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(email='superuser@example.com')
+        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/')

+ 57 - 1
wagtail/core/tests/test_page_queryset.py

@@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.db.models import Count, Q
 from django.test import TestCase
 
-from wagtail.core.models import Page, PageViewRestriction, Site
+from wagtail.core.models import Locale, Page, PageViewRestriction, Site
 from wagtail.core.signals import page_unpublished
 from wagtail.search.query import MATCH_ALL
 from wagtail.tests.testapp.models import EventPage, SimplePage, SingleEventPage, StreamPage
@@ -418,6 +418,62 @@ class TestPageQuerySet(TestCase):
         with self.assertRaises(AttributeError):
             Page.objects.delete()
 
+    def test_translation_of(self):
+        en_homepage = Page.objects.get(url_path='/home/')
+
+        # Create a translation of the homepage
+        fr_locale = Locale.objects.create(language_code="fr")
+        root_page = Page.objects.get(depth=1)
+        fr_homepage = root_page.add_child(instance=Page(
+            title="French homepage",
+            slug="home-fr",
+            locale=fr_locale,
+            translation_key=en_homepage.translation_key,
+        ))
+
+        with self.assertNumQueries(1):
+            translations = Page.objects.translation_of(en_homepage)
+            self.assertListEqual(list(translations), [fr_homepage])
+
+        # Now test with inclusive
+        with self.assertNumQueries(1):
+            translations = Page.objects.translation_of(en_homepage, inclusive=True).order_by('id')
+            self.assertListEqual(list(translations), [en_homepage, fr_homepage])
+
+    def test_not_translation_of(self):
+        en_homepage = Page.objects.get(url_path='/home/')
+
+        # Create a translation of the homepage
+        fr_locale = Locale.objects.create(language_code="fr")
+        root_page = Page.objects.get(depth=1)
+        fr_homepage = root_page.add_child(instance=Page(
+            title="French homepage",
+            slug="home-fr",
+            locale=fr_locale,
+            translation_key=en_homepage.translation_key,
+        ))
+
+        with self.assertNumQueries(1):
+            translations = list(Page.objects.not_translation_of(en_homepage))
+
+        # Check that every single page is in the queryset, except for fr_homepage
+        for page in Page.objects.all():
+            if page in [fr_homepage]:
+                self.assertNotIn(page, translations)
+            else:
+                self.assertIn(page, translations)
+
+        # Test with inclusive
+        with self.assertNumQueries(1):
+            translations = list(Page.objects.not_translation_of(en_homepage, inclusive=True))
+
+        # Check that every single page is in the queryset, except for fr_homepage and en_homepage
+        for page in Page.objects.all():
+            if page in [en_homepage, fr_homepage]:
+                self.assertNotIn(page, translations)
+            else:
+                self.assertIn(page, translations)
+
 
 class TestPageQueryInSite(TestCase):
     fixtures = ['test.json']

+ 47 - 1
wagtail/core/tests/test_rich_text.py

@@ -1,7 +1,9 @@
 from unittest.mock import patch
 
-from django.test import TestCase
+from django.test import TestCase, override_settings
+from django.utils import translation
 
+from wagtail.core.models import Locale, Page
 from wagtail.core.rich_text import RichText, expand_db_html
 from wagtail.core.rich_text.feature_registry import FeatureRegistry
 from wagtail.core.rich_text.pages import PageLinkHandler
@@ -12,6 +14,10 @@ from wagtail.tests.testapp.models import EventPage
 class TestPageLinktypeHandler(TestCase):
     fixtures = ['test.json']
 
+    def test_expand_db_attributes(self):
+        result = PageLinkHandler.expand_db_attributes({'id': Page.objects.get(url_path='/home/events/christmas/').id})
+        self.assertEqual(result, '<a href="/events/christmas/">')
+
     def test_expand_db_attributes_page_does_not_exist(self):
         result = PageLinkHandler.expand_db_attributes({'id': 0})
         self.assertEqual(result, '<a>')
@@ -21,6 +27,46 @@ class TestPageLinktypeHandler(TestCase):
         self.assertEqual(result, '<a href="None">')
 
 
+@override_settings(
+    WAGTAIL_I18N_ENABLED=True,
+    WAGTAIL_CONTENT_LANGUAGES=[
+        ('en', 'English'),
+        ('fr', 'French'),
+    ],
+    ROOT_URLCONF='wagtail.tests.urls_multilang'
+)
+class TestPageLinktypeHandlerWithI18N(TestCase):
+    fixtures = ['test.json']
+
+    def setUp(self):
+        self.fr_locale = Locale.objects.create(language_code="fr")
+        self.event_page = Page.objects.get(url_path='/home/events/christmas/')
+        self.fr_event_page = self.event_page.copy_for_translation(self.fr_locale, copy_parents=True)
+        self.fr_event_page.slug = 'noel'
+        self.fr_event_page.save(update_fields=['slug'])
+        self.fr_event_page.save_revision().publish()
+
+    def test_expand_db_attributes(self):
+        result = PageLinkHandler.expand_db_attributes({'id': self.event_page.id})
+        self.assertEqual(result, '<a href="/en/events/christmas/">')
+
+    def test_expand_db_attributes_autolocalizes(self):
+        # Even though it's linked to the english page in rich text.
+        # The link should be to the local language version if it's available
+        with translation.override("fr"):
+            result = PageLinkHandler.expand_db_attributes({'id': self.event_page.id})
+            self.assertEqual(result, '<a href="/fr/events/noel/">')
+
+    def test_expand_db_attributes_doesnt_autolocalize_unpublished_page(self):
+        # We shouldn't autolocalize if the translation is unpublished
+        self.fr_event_page.unpublish()
+        self.fr_event_page.save()
+
+        with translation.override("fr"):
+            result = PageLinkHandler.expand_db_attributes({'id': self.event_page.id})
+            self.assertEqual(result, '<a href="/en/events/christmas/">')
+
+
 class TestExtractAttrs(TestCase):
     def test_extract_attr(self):
         html = '<a foo="bar" baz="quux">snowman</a>'

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

@@ -0,0 +1,150 @@
+from unittest.mock import patch
+
+from django.conf import settings
+from django.core import checks
+from django.test import TestCase
+
+from wagtail.core.models import Locale, TranslatableMixin
+
+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)
+
+
+class TestLocalized(TestCase):
+    def setUp(self):
+        self.en_locale = Locale.objects.get()
+        self.fr_locale = Locale.objects.create(language_code="fr")
+
+        self.en_instance = make_test_instance(
+            locale=self.en_locale, title="Main Model"
+        )
+        self.fr_instance = make_test_instance(
+            locale=self.fr_locale, translation_key=self.en_instance.translation_key, title="Main Model"
+        )
+
+    def test_localized_same_language(self):
+        # Shouldn't run an extra query if the instances locale matches the active language
+        # FIXME: Cache active locale record so this is zero
+        with self.assertNumQueries(1):
+            instance = self.en_instance.localized
+
+        self.assertEqual(instance, self.en_instance)
+
+    def test_localized_different_language(self):
+        with self.assertNumQueries(2):
+            instance = self.fr_instance.localized
+
+        self.assertEqual(instance, self.en_instance)
+
+
+class TestSystemChecks(TestCase):
+    def test_raises_error_if_unique_together_constraint_missing(self):
+        class TranslatableModel(TranslatableMixin):
+            class Meta:
+                unique_together = []
+
+        errors = TranslatableModel.check()
+
+        self.assertEqual(len(errors), 1)
+        self.assertIsInstance(errors[0], checks.Error)
+        self.assertEqual(errors[0].id, 'wagtailcore.E003')

+ 138 - 3
wagtail/core/tests/test_utils.py

@@ -1,10 +1,12 @@
 # -*- coding: utf-8 -*
-from django.test import TestCase
+from django.core.exceptions import ImproperlyConfigured
+from django.test import TestCase, override_settings
 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,
+    get_content_languages, get_supported_content_language_variant, safe_snake_case, string_to_ascii)
 
 
 class TestCamelCaseToUnderscore(TestCase):
@@ -116,3 +118,136 @@ 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")
+
+
+@override_settings(
+    USE_I18N=True,
+    WAGTAIL_I18N_ENABLED=True,
+    LANGUAGES=[
+        ('en', 'English'),
+        ('de', 'German'),
+        ('de-at', 'Austrian German'),
+        ('pt-br', 'Portuguese (Brazil)'),
+    ],
+    WAGTAIL_CONTENT_LANGUAGES=[
+        ('en', 'English'),
+        ('de', 'German'),
+        ('de-at', 'Austrian German'),
+        ('pt-br', 'Portuguese (Brazil)'),
+    ],
+)
+class TestGetContentLanguages(TestCase):
+    def test_get_content_languages(self):
+        self.assertEqual(get_content_languages(), {
+            'de': 'German',
+            'de-at': 'Austrian German',
+            'en': 'English',
+            'pt-br': 'Portuguese (Brazil)'
+        })
+
+    @override_settings(
+        WAGTAIL_CONTENT_LANGUAGES=[
+            ('en', 'English'),
+            ('de', 'German'),
+        ],
+    )
+    def test_can_be_different_to_django_languages(self):
+        self.assertEqual(get_content_languages(), {
+            'de': 'German',
+            'en': 'English',
+        })
+
+    @override_settings(
+        WAGTAIL_CONTENT_LANGUAGES=[
+            ('en', 'English'),
+            ('de', 'German'),
+            ('zh', 'Chinese'),
+        ],
+    )
+    def test_must_be_subset_of_django_languages(self):
+        with self.assertRaises(ImproperlyConfigured) as e:
+            get_content_languages()
+
+        self.assertEqual(e.exception.args, ("The language zh is specified in WAGTAIL_CONTENT_LANGUAGES but not LANGUAGES. WAGTAIL_CONTENT_LANGUAGES must be a subset of LANGUAGES.", ))
+
+
+@override_settings(
+    USE_I18N=True,
+    WAGTAIL_I18N_ENABLED=True,
+    LANGUAGES=[
+        ('en', 'English'),
+        ('de', 'German'),
+        ('de-at', 'Austrian German'),
+        ('pt-br', 'Portuguese (Brazil)'),
+    ],
+    WAGTAIL_CONTENT_LANGUAGES=[
+        ('en', 'English'),
+        ('de', 'German'),
+        ('de-at', 'Austrian German'),
+        ('pt-br', 'Portuguese (Brazil)'),
+    ],
+)
+class TestGetSupportedContentLanguageVariant(TestCase):
+    # From: https://github.com/django/django/blob/9e57b1efb5205bd94462e9de35254ec5ea6eb04e/tests/i18n/tests.py#L1481
+    def test_get_supported_content_language_variant(self):
+        g = get_supported_content_language_variant
+        self.assertEqual(g('en'), 'en')
+        self.assertEqual(g('en-gb'), 'en')
+        self.assertEqual(g('de'), 'de')
+        self.assertEqual(g('de-at'), 'de-at')
+        self.assertEqual(g('de-ch'), 'de')
+        self.assertEqual(g('pt-br'), 'pt-br')
+        self.assertEqual(g('pt'), 'pt-br')
+        self.assertEqual(g('pt-pt'), 'pt-br')
+        with self.assertRaises(LookupError):
+            g('pt', strict=True)
+        with self.assertRaises(LookupError):
+            g('pt-pt', strict=True)
+        with self.assertRaises(LookupError):
+            g('xyz')
+        with self.assertRaises(LookupError):
+            g('xy-zz')
+
+    @override_settings(WAGTAIL_CONTENT_LANGUAGES=[
+        ('en', 'English'),
+        ('de', 'German'),
+    ])
+    def test_uses_wagtail_content_languages(self):
+        # be sure it's not using Django's LANGUAGES
+        g = get_supported_content_language_variant
+        self.assertEqual(g('en'), 'en')
+        self.assertEqual(g('en-gb'), 'en')
+        self.assertEqual(g('de'), 'de')
+        self.assertEqual(g('de-at'), 'de')
+        self.assertEqual(g('de-ch'), 'de')
+        with self.assertRaises(LookupError):
+            g('pt-br')
+        with self.assertRaises(LookupError):
+            g('pt')
+        with self.assertRaises(LookupError):
+            g('pt-pt')
+        with self.assertRaises(LookupError):
+            g('xyz')
+        with self.assertRaises(LookupError):
+            g('xy-zz')

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

@@ -6,7 +6,7 @@ from django.test.utils import override_settings
 from django.urls.exceptions import NoReverseMatch
 from django.utils.safestring import SafeString
 
-from wagtail.core.models import Page, Site
+from wagtail.core.models import Page, Site, SiteRootPath
 from wagtail.core.templatetags.wagtailcore_tags import richtext, slugurl
 from wagtail.core.utils import resolve_model_string
 from wagtail.tests.testapp.models import SimplePage
@@ -156,7 +156,7 @@ class TestSiteRootPathsCache(TestCase):
         _ = homepage.url  # noqa
 
         # Check that the cache has been set correctly
-        self.assertEqual(cache.get('wagtail_site_root_paths'), [(1, '/home/', 'http://localhost')])
+        self.assertEqual(cache.get('wagtail_site_root_paths'), [SiteRootPath(site_id=1, root_path='/home/', root_url='http://localhost', language_code='en')])
 
     def test_cache_clears_when_site_saved(self):
         """

+ 104 - 0
wagtail/core/utils.py

@@ -1,3 +1,4 @@
+import functools
 import inspect
 import re
 import unicodedata
@@ -5,9 +6,14 @@ from anyascii import anyascii
 
 from django.apps import apps
 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.db.models import Model
+from django.dispatch import receiver
 from django.utils.encoding import force_str
 from django.utils.text import slugify
+from django.utils.translation import check_for_language
 
 WAGTAIL_APPEND_SLASH = getattr(settings, 'WAGTAIL_APPEND_SLASH', True)
 
@@ -168,3 +174,101 @@ 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
+
+
+@functools.lru_cache()
+def get_content_languages():
+    """
+    Cache of settings.WAGTAIL_CONTENT_LANGUAGES in a dictionary for easy lookups by key.
+    """
+    content_languages = getattr(settings, 'WAGTAIL_CONTENT_LANGUAGES', None)
+    languages = dict(settings.LANGUAGES)
+
+    if content_languages is None:
+        # Default to a single language based on LANGUAGE_CODE
+        content_languages = [
+            (settings.LANGUAGE_CODE, languages[settings.LANGUAGE_CODE]),
+        ]
+
+    # Check that each content language is in LANGUAGES
+    for language_code, name in content_languages:
+        if language_code not in languages:
+            raise ImproperlyConfigured(
+                "The language {} is specified in WAGTAIL_CONTENT_LANGUAGES but not LANGUAGES. "
+                "WAGTAIL_CONTENT_LANGUAGES must be a subset of LANGUAGES.".format(language_code)
+            )
+
+    return dict(content_languages)
+
+
+@functools.lru_cache(maxsize=1000)
+def get_supported_content_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/>.
+
+    This is equvilant to Django's `django.utils.translation.get_supported_content_language_variant`
+    but reads the `WAGTAIL_CONTENT_LANGUAGES` setting instead.
+    """
+    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_content_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 WAGTAIL_CONTENT_LANGUAGES/LANGUAGES/LANGUAGE_CODE settings are changed
+    """
+    if kwargs["setting"] in ("WAGTAIL_CONTENT_LANGUAGES", "LANGUAGES", "LANGUAGE_CODE"):
+        get_content_languages.cache_clear()
+        get_supported_content_language_variant.cache_clear()

+ 1 - 1
wagtail/core/views.py

@@ -14,7 +14,7 @@ def serve(request, path):
         raise Http404
 
     path_components = [component for component in path.split('/') if component]
-    page, args, kwargs = site.root_page.specific.route(request, path_components)
+    page, args, kwargs = site.root_page.localized.specific.route(request, path_components)
 
     for fn in hooks.get_hooks('before_serve_page'):
         result = fn(page, request, args, kwargs)

+ 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()

+ 7 - 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',
@@ -227,6 +228,12 @@ WAGTAILADMIN_RICH_TEXT_EDITORS = {
 }
 
 
+WAGTAIL_CONTENT_LANGUAGES = [
+    ("en", "English"),
+    ("fr", "French"),
+]
+
+
 # Set a non-standard DEFAULT_AUTHENTICATION_CLASSES value, to verify that the
 # admin API still works with session-based auth regardless of this setting
 # (see https://github.com/wagtail/wagtail/issues/5585)

+ 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):