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