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