1import uuid
2
3from django.apps import apps
4from django.conf import settings
5from django.core import checks
6from django.db import migrations, models, transaction
7from django.db.models.signals import pre_save
8from django.dispatch import receiver
9from django.utils import translation
10from django.utils.encoding import force_str
11from modelcluster.fields import ParentalKey
12
13from wagtail.core.signals import pre_validate_delete
14from wagtail.core.utils import get_content_languages, get_supported_content_language_variant
15
16from .copying import _copy
17
18
19def pk(obj):
20    if isinstance(obj, models.Model):
21        return obj.pk
22    else:
23        return obj
24
25
26class LocaleManager(models.Manager):
27    def get_for_language(self, language_code):
28        """
29        Gets a Locale from a language code.
30        """
31        return self.get(language_code=get_supported_content_language_variant(language_code))
32
33
34class Locale(models.Model):
35    #: The language code that represents this locale
36    #:
37    #: The language code can either be a language code on its own (such as ``en``, ``fr``),
38    #: or it can include a region code (such as ``en-gb``, ``fr-fr``).
39    language_code = models.CharField(max_length=100, unique=True)
40
41    # Objects excludes any Locales that have been removed from LANGUAGES, This effectively disables them
42    # The Locale management UI needs to be able to see these so we provide a separate manager `all_objects`
43    objects = LocaleManager()
44    all_objects = models.Manager()
45
46    class Meta:
47        ordering = [
48            "language_code",
49        ]
50
51    @classmethod
52    def get_default(cls):
53        """
54        Returns the default Locale based on the site's LANGUAGE_CODE setting
55        """
56        return cls.objects.get_for_language(settings.LANGUAGE_CODE)
57
58    @classmethod
59    def get_active(cls):
60        """
61        Returns the Locale that corresponds to the currently activated language in Django.
62        """
63        try:
64            return cls.objects.get_for_language(translation.get_language())
65        except (cls.DoesNotExist, LookupError):
66            return cls.get_default()
67
68    @transaction.atomic
69    def delete(self, *args, **kwargs):
70        # Provide a signal like pre_delete, but sent before on_delete validation.
71        # This allows us to use the signal to fix up references to the locale to be deleted
72        # that would otherwise fail validation.
73        # Workaround for https://code.djangoproject.com/ticket/6870
74        pre_validate_delete.send(sender=Locale, instance=self)
75        return super().delete(*args, **kwargs)
76
77    def language_code_is_valid(self):
78        return self.language_code in get_content_languages()
79
80    def get_display_name(self):
81        return get_content_languages().get(self.language_code)
82
83    def __str__(self):
84        return force_str(self.get_display_name() or self.language_code)
85
86
87class TranslatableMixin(models.Model):
88    translation_key = models.UUIDField(default=uuid.uuid4, editable=False)
89    locale = models.ForeignKey(Locale, on_delete=models.PROTECT, related_name="+", editable=False)
90
91    class Meta:
92        abstract = True
93        unique_together = [("translation_key", "locale")]
94
95    @classmethod
96    def check(cls, **kwargs):
97        errors = super(TranslatableMixin, cls).check(**kwargs)
98        is_translation_model = cls.get_translation_model() is cls
99
100        # Raise error if subclass has removed the unique_together constraint
101        # No need to check this on multi-table-inheritance children though as it only needs to be applied to
102        # the table that has the translation_key/locale fields
103        if is_translation_model and ("translation_key", "locale") not in cls._meta.unique_together:
104            errors.append(
105                checks.Error(
106                    "{0}.{1} is missing a unique_together constraint for the translation key and locale fields"
107                    .format(cls._meta.app_label, cls.__name__),
108                    hint="Add ('translation_key', 'locale') to {}.Meta.unique_together".format(cls.__name__),
109                    obj=cls,
110                    id='wagtailcore.E003',
111                )
112            )
113
114        return errors
115
116    @property
117    def localized(self):
118        """
119        Finds the translation in the current active language.
120
121        If there is no translation in the active language, self is returned.
122        """
123        try:
124            locale = Locale.get_active()
125        except (LookupError, Locale.DoesNotExist):
126            return self
127
128        if locale.id == self.locale_id:
129            return self
130
131        return self.get_translation_or_none(locale) or self
132
133    def get_translations(self, inclusive=False):
134        """
135        Returns a queryset containing the translations of this instance.
136        """
137        translations = self.__class__.objects.filter(
138            translation_key=self.translation_key
139        )
140
141        if inclusive is False:
142            translations = translations.exclude(id=self.id)
143
144        return translations
145
146    def get_translation(self, locale):
147        """
148        Finds the translation in the specified locale.
149
150        If there is no translation in that locale, this raises a ``model.DoesNotExist`` exception.
151        """
152        return self.get_translations(inclusive=True).get(locale_id=pk(locale))
153
154    def get_translation_or_none(self, locale):
155        """
156        Finds the translation in the specified locale.
157
158        If there is no translation in that locale, this returns None.
159        """
160        try:
161            return self.get_translation(locale)
162        except self.__class__.DoesNotExist:
163            return None
164
165    def has_translation(self, locale):
166        """
167        Returns True if a translation exists in the specified locale.
168        """
169        return self.get_translations(inclusive=True).filter(locale_id=pk(locale)).exists()
170
171    def copy_for_translation(self, locale):
172        """
173        Creates a copy of this instance with the specified locale.
174
175        Note that the copy is initially unsaved.
176        """
177        translated, child_object_map = _copy(self)
178        translated.locale = locale
179
180        # Update locale on any translatable child objects as well
181        # Note: If this is not a subclass of ClusterableModel, child_object_map will always be '{}'
182        for (child_relation, old_pk), child_object in child_object_map.items():
183            if isinstance(child_object, TranslatableMixin):
184                child_object.locale = locale
185
186        return translated
187
188    def get_default_locale(self):
189        """
190        Finds the default locale to use for this object.
191
192        This will be called just before the initial save.
193        """
194        # Check if the object has any parental keys to another translatable model
195        # If so, take the locale from the object referenced in that parental key
196        parental_keys = [
197            field
198            for field in self._meta.get_fields()
199            if isinstance(field, ParentalKey)
200            and issubclass(field.related_model, TranslatableMixin)
201        ]
202
203        if parental_keys:
204            parent_id = parental_keys[0].value_from_object(self)
205            return (
206                parental_keys[0]
207                .related_model.objects.defer().select_related("locale")
208                .get(id=parent_id)
209                .locale
210            )
211
212        return Locale.get_default()
213
214    @classmethod
215    def get_translation_model(cls):
216        """
217        Returns this model's "Translation model".
218
219        The "Translation model" is the model that has the ``locale`` and
220        ``translation_key`` fields.
221        Typically this would be the current model, but it may be a
222        super-class if multi-table inheritance is in use (as is the case
223        for ``wagtailcore.Page``).
224        """
225        return cls._meta.get_field("locale").model
226
227
228def bootstrap_translatable_model(model, locale):
229    """
230    This function populates the "translation_key", and "locale" fields on model instances that were created
231    before wagtail-localize was added to the site.
232
233    This can be called from a data migration, or instead you could use the "boostrap_translatable_models"
234    management command.
235    """
236    for instance in (
237        model.objects.filter(translation_key__isnull=True).defer().iterator()
238    ):
239        instance.translation_key = uuid.uuid4()
240        instance.locale = locale
241        instance.save(update_fields=["translation_key", "locale"])
242
243
244class BootstrapTranslatableModel(migrations.RunPython):
245    def __init__(self, model_string, language_code=None):
246        if language_code is None:
247            language_code = get_supported_content_language_variant(settings.LANGUAGE_CODE)
248
249        def forwards(apps, schema_editor):
250            model = apps.get_model(model_string)
251            Locale = apps.get_model("wagtailcore.Locale")
252
253            locale = Locale.objects.get(language_code=language_code)
254            bootstrap_translatable_model(model, locale)
255
256        def backwards(apps, schema_editor):
257            pass
258
259        super().__init__(forwards, backwards)
260
261
262class BootstrapTranslatableMixin(TranslatableMixin):
263    """
264    A version of TranslatableMixin without uniqueness constraints.
265
266    This is to make it easy to transition existing models to being translatable.
267
268    The process is as follows:
269     - Add BootstrapTranslatableMixin to the model
270     - Run makemigrations
271     - Create a data migration for each app, then use the BootstrapTranslatableModel operation in
272       wagtail.core.models on each model in that app
273     - Change BootstrapTranslatableMixin to TranslatableMixin
274     - Run makemigrations again
275     - Migrate!
276    """
277    translation_key = models.UUIDField(null=True, editable=False)
278    locale = models.ForeignKey(
279        Locale, on_delete=models.PROTECT, null=True, related_name="+", editable=False
280    )
281
282    @classmethod
283    def check(cls, **kwargs):
284        # skip the check in TranslatableMixin that enforces the unique-together constraint
285        return super(TranslatableMixin, cls).check(**kwargs)
286
287    class Meta:
288        abstract = True
289
290
291def get_translatable_models(include_subclasses=False):
292    """
293    Returns a list of all concrete models that inherit from TranslatableMixin.
294    By default, this only includes models that are direct children of TranslatableMixin,
295    to get all models, set the include_subclasses attribute to True.
296    """
297    translatable_models = [
298        model
299        for model in apps.get_models()
300        if issubclass(model, TranslatableMixin) and not model._meta.abstract
301    ]
302
303    if include_subclasses is False:
304        # Exclude models that inherit from another translatable model
305        root_translatable_models = set()
306
307        for model in translatable_models:
308            root_translatable_models.add(model.get_translation_model())
309
310        translatable_models = [
311            model for model in translatable_models if model in root_translatable_models
312        ]
313
314    return translatable_models
315
316
317@receiver(pre_save)
318def set_locale_on_new_instance(sender, instance, **kwargs):
319    if not isinstance(instance, TranslatableMixin):
320        return
321
322    if instance.locale_id is not None:
323        return
324
325    # If this is a fixture load, use the global default Locale
326    # as the page tree is probably in an flux
327    if kwargs["raw"]:
328        instance.locale = Locale.get_default()
329        return
330
331    instance.locale = instance.get_default_locale()
332