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