486 lines
16 KiB
Python
486 lines
16 KiB
Python
import uuid
|
|
|
|
from django.apps import apps
|
|
from django.conf import settings
|
|
from django.core import checks
|
|
from django.db import migrations, models, transaction
|
|
from django.db.models.signals import pre_save
|
|
from django.dispatch import receiver
|
|
from django.utils import translation
|
|
from django.utils.encoding import force_str
|
|
from django.utils.translation import gettext_lazy as _
|
|
from modelcluster.fields import ParentalKey
|
|
|
|
from wagtail.actions.copy_for_translation import CopyForTranslationAction
|
|
from wagtail.coreutils import (
|
|
get_content_languages,
|
|
get_supported_content_language_variant,
|
|
)
|
|
from wagtail.signals import pre_validate_delete
|
|
|
|
|
|
def pk(obj):
|
|
if isinstance(obj, models.Model):
|
|
return obj.pk
|
|
else:
|
|
return obj
|
|
|
|
|
|
class LocaleManager(models.Manager):
|
|
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):
|
|
#: The language code that represents this locale
|
|
#:
|
|
#: The language code can either be a language code on its own (such as ``en``, ``fr``),
|
|
#: or it can include a region code (such as ``en-gb``, ``fr-fr``).
|
|
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 ``LANGUAGE_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, LookupError):
|
|
return cls.get_default()
|
|
|
|
@transaction.atomic
|
|
def delete(self, *args, **kwargs):
|
|
# Provide a signal like pre_delete, but sent before on_delete validation.
|
|
# This allows us to use the signal to fix up references to the locale to be deleted
|
|
# that would otherwise fail validation.
|
|
# Workaround for https://code.djangoproject.com/ticket/6870
|
|
pre_validate_delete.send(sender=Locale, instance=self)
|
|
return super().delete(*args, **kwargs)
|
|
|
|
def language_code_is_valid(self):
|
|
return self.language_code in get_content_languages()
|
|
|
|
def get_display_name(self) -> str:
|
|
try:
|
|
return get_content_languages()[self.language_code]
|
|
except KeyError:
|
|
pass
|
|
try:
|
|
return self.language_name
|
|
except KeyError:
|
|
pass
|
|
|
|
return self.language_code
|
|
|
|
def __str__(self):
|
|
return force_str(self.get_display_name())
|
|
|
|
def _get_language_info(self) -> dict[str, str]:
|
|
return translation.get_language_info(self.language_code)
|
|
|
|
@property
|
|
def language_info(self):
|
|
return translation.get_language_info(self.language_code)
|
|
|
|
@property
|
|
def language_name(self):
|
|
"""
|
|
Uses data from ``django.conf.locale`` to return the language name in
|
|
English. For example, if the object's ``language_code`` were ``"fr"``,
|
|
the return value would be ``"French"``.
|
|
|
|
Raises ``KeyError`` if ``django.conf.locale`` has no information
|
|
for the object's ``language_code`` value.
|
|
"""
|
|
return self.language_info["name"]
|
|
|
|
@property
|
|
def language_name_local(self):
|
|
"""
|
|
Uses data from ``django.conf.locale`` to return the language name in
|
|
the language itself. For example, if the ``language_code`` were
|
|
``"fr"`` (French), the return value would be ``"français"``.
|
|
|
|
Raises ``KeyError`` if ``django.conf.locale`` has no information
|
|
for the object's ``language_code`` value.
|
|
"""
|
|
return self.language_info["name_local"]
|
|
|
|
@property
|
|
def language_name_localized(self):
|
|
"""
|
|
Uses data from ``django.conf.locale`` to return the language name in
|
|
the currently active language. For example, if ``language_code`` were
|
|
``"fr"`` (French), and the active language were ``"da"`` (Danish), the
|
|
return value would be ``"Fransk"``.
|
|
|
|
Raises ``KeyError`` if ``django.conf.locale`` has no information
|
|
for the object's ``language_code`` value.
|
|
|
|
"""
|
|
return translation.gettext(self.language_name)
|
|
|
|
@property
|
|
def is_bidi(self) -> bool:
|
|
"""
|
|
Returns a boolean indicating whether the language is bi-directional.
|
|
"""
|
|
return self.language_code in settings.LANGUAGES_BIDI
|
|
|
|
@property
|
|
def is_default(self) -> bool:
|
|
"""
|
|
Returns a boolean indicating whether this object is the default locale.
|
|
"""
|
|
try:
|
|
return self.language_code == get_supported_content_language_variant(
|
|
settings.LANGUAGE_CODE
|
|
)
|
|
except LookupError:
|
|
return False
|
|
|
|
@property
|
|
def is_active(self) -> bool:
|
|
"""
|
|
Returns a boolean indicating whether this object is the currently active locale.
|
|
"""
|
|
try:
|
|
return self.language_code == get_supported_content_language_variant(
|
|
translation.get_language()
|
|
)
|
|
except LookupError:
|
|
return self.is_default
|
|
|
|
|
|
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,
|
|
verbose_name=_("locale"),
|
|
)
|
|
locale.wagtail_reference_index_ignore = True
|
|
|
|
class Meta:
|
|
abstract = True
|
|
unique_together = [("translation_key", "locale")]
|
|
|
|
@classmethod
|
|
def check(cls, **kwargs):
|
|
errors = super().check(**kwargs)
|
|
# No need to check on multi-table-inheritance children as it only needs to be applied to
|
|
# the table that has the translation_key/locale fields
|
|
is_translation_model = cls.get_translation_model() is cls
|
|
if not is_translation_model:
|
|
return errors
|
|
|
|
unique_constraint_fields = ("translation_key", "locale")
|
|
|
|
has_unique_constraint = any(
|
|
isinstance(constraint, models.UniqueConstraint)
|
|
and set(constraint.fields) == set(unique_constraint_fields)
|
|
for constraint in cls._meta.constraints
|
|
)
|
|
|
|
has_unique_together = unique_constraint_fields in cls._meta.unique_together
|
|
|
|
# Raise error if subclass has removed constraints
|
|
if not (has_unique_constraint or has_unique_together):
|
|
errors.append(
|
|
checks.Error(
|
|
"%s is missing a UniqueConstraint for the fields: %s."
|
|
% (cls._meta.label, unique_constraint_fields),
|
|
hint=(
|
|
"Add models.UniqueConstraint(fields=%s, "
|
|
"name='unique_translation_key_locale_%s_%s') to %s.Meta.constraints."
|
|
% (
|
|
unique_constraint_fields,
|
|
cls._meta.app_label,
|
|
cls._meta.model_name,
|
|
cls.__name__,
|
|
)
|
|
),
|
|
obj=cls,
|
|
id="wagtailcore.E003",
|
|
)
|
|
)
|
|
|
|
# Raise error if subclass has both UniqueConstraint and unique_together
|
|
if has_unique_constraint and has_unique_together:
|
|
errors.append(
|
|
checks.Error(
|
|
"%s should not have both UniqueConstraint and unique_together for: %s."
|
|
% (cls._meta.label, unique_constraint_fields),
|
|
hint="Remove unique_together in favor of UniqueConstraint.",
|
|
obj=cls,
|
|
id="wagtailcore.E003",
|
|
)
|
|
)
|
|
|
|
return errors
|
|
|
|
@property
|
|
def localized(self):
|
|
"""
|
|
Finds the translation in the current active language.
|
|
|
|
If there is no translation in the active language, self is returned.
|
|
|
|
Note: This will not return the translation if it is in draft.
|
|
If you want to include drafts, use the ``.localized_draft`` attribute instead.
|
|
"""
|
|
from wagtail.models import DraftStateMixin
|
|
|
|
localized = self.localized_draft
|
|
if isinstance(self, DraftStateMixin) and not localized.live:
|
|
return self
|
|
|
|
return localized
|
|
|
|
@property
|
|
def localized_draft(self):
|
|
"""
|
|
Finds the translation in the current active language.
|
|
|
|
If there is no translation in the active language, self is returned.
|
|
|
|
Note: This will return translations that are in draft. If you want to exclude
|
|
these, use the ``.localized`` attribute.
|
|
"""
|
|
if not getattr(settings, "WAGTAIL_I18N_ENABLED", False):
|
|
return self
|
|
|
|
try:
|
|
locale = Locale.get_active()
|
|
except (LookupError, Locale.DoesNotExist):
|
|
return self
|
|
|
|
if locale.id == self.locale_id:
|
|
return self
|
|
|
|
return self.get_translation_or_none(locale) or self
|
|
|
|
def get_translations(self, inclusive=False):
|
|
"""
|
|
Returns a queryset containing the translations of this instance.
|
|
"""
|
|
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):
|
|
"""
|
|
Finds the translation in the specified locale.
|
|
|
|
If there is no translation in that locale, this raises a ``model.DoesNotExist`` exception.
|
|
"""
|
|
return self.get_translations(inclusive=True).get(locale_id=pk(locale))
|
|
|
|
def get_translation_or_none(self, locale):
|
|
"""
|
|
Finds the translation in the specified locale.
|
|
|
|
If there is no translation in that locale, this returns ``None``.
|
|
"""
|
|
try:
|
|
return self.get_translation(locale)
|
|
except self.__class__.DoesNotExist:
|
|
return None
|
|
|
|
def has_translation(self, locale):
|
|
"""
|
|
Returns True if a translation exists in the specified locale.
|
|
"""
|
|
return (
|
|
self.get_translations(inclusive=True).filter(locale_id=pk(locale)).exists()
|
|
)
|
|
|
|
def copy_for_translation(self, locale, exclude_fields=None):
|
|
"""
|
|
Creates a copy of this instance with the specified locale.
|
|
|
|
Note that the copy is initially unsaved.
|
|
"""
|
|
return CopyForTranslationAction(
|
|
self,
|
|
locale,
|
|
exclude_fields=exclude_fields,
|
|
).execute()
|
|
|
|
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(cls):
|
|
"""
|
|
Returns this model's "Translation model".
|
|
|
|
The "Translation model" is the model that has the ``locale`` and
|
|
``translation_key`` fields.
|
|
Typically this would be the current model, but it may be a
|
|
super-class if multi-table inheritance is in use (as is the case
|
|
for ``wagtailcore.Page``).
|
|
"""
|
|
return cls._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 "bootstrap_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 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.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
|
|
)
|
|
|
|
@classmethod
|
|
def check(cls, **kwargs):
|
|
# skip the check in TranslatableMixin that enforces the unique-together constraint
|
|
return super(TranslatableMixin, cls).check(**kwargs)
|
|
|
|
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 flux
|
|
if kwargs["raw"]:
|
|
instance.locale = Locale.get_default()
|
|
return
|
|
|
|
instance.locale = instance.get_default_locale()
|