Skip to content

Translation management models

These models are responsible for managing the translation of objects.

graph TD
A[TranslationSource] --> B[TranslatableObject]
C[Translation] --> A
D[TranslationLog] --> A
C --> E[wagtail.Locale]
A --> E
D --> E
A --> F[django.ContentType]
B --> F

style E stroke-dasharray: 5 5
style F stroke-dasharray: 5 5

TranslatableObject

Bases: Model

A TranslatableObject represents a set of instances of a translatable model that are all translations of each another.

In Wagtail, objects are considered translations of each other when they are of the same content type and have the same translation_key value.

Attributes:

Name Type Description
translation_key UUIDField

The translation_key that value that is used by the instances.

content_type ForeignKey to ContentType

Link to the base Django content type representing the model that the instances use. Note that this field refers to the model that has the locale and translation_key fields and not the specific type.

Source code in wagtail_localize/models.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
class TranslatableObject(models.Model):
    """
    A TranslatableObject represents a set of instances of a translatable model
    that are all translations of each another.

    In Wagtail, objects are considered translations of each other when they are
    of the same content type and have the same `translation_key` value.

    Attributes:
        translation_key (UUIDField): The translation_key that value that is used by the instances.
        content_type (ForeignKey to ContentType): Link to the base Django content type representing the model that the
            instances use. Note that this field refers to the model that has the ``locale`` and ``translation_key``
            fields and not the specific type.
    """

    translation_key = models.UUIDField(primary_key=True)
    content_type = models.ForeignKey(
        ContentType, on_delete=models.CASCADE, related_name="+"
    )

    objects = TranslatableObjectManager()

    class Meta:
        unique_together = [("content_type", "translation_key")]

    def __str__(self):
        return f"TranslatableObject: {self.translation_key}, {self.content_type_id}"

    def has_translation(self, locale):
        """
        Returns True if there is an instance of this object in the given Locale.

        Args:
            locale (Locale | int): Either a Locale object or an ID of a Locale.

        Returns:
            bool: True if there is an instance of this object in the given locale.
        """
        return self.content_type.get_all_objects_for_this_type(
            translation_key=self.translation_key, locale_id=pk(locale)
        ).exists()

    def get_instance(self, locale):
        """
        Returns a model instance for this object in the given locale.

        Args:
            locale (Locale | int): Either a Locale object or an ID of a Locale.

        Returns:
            Model: The model instance.

        Raises:
            Model.DoesNotExist: If there is not an instance of this object in the given locale.
        """
        return self.content_type.get_object_for_this_type(
            translation_key=self.translation_key, locale_id=pk(locale)
        )

    def get_instance_or_none(self, locale):
        """
        Returns a model instance for this object in the given locale.

        Args:
            locale (Locale | int): Either a Locale object or an ID of a Locale.

        Returns:
            Model: The model instance if one exists.
            None: If the model doesn't exist.
        """
        try:
            return self.get_instance(locale)
        except self.content_type.model_class().DoesNotExist:
            pass

get_instance(locale)

Returns a model instance for this object in the given locale.

Parameters:

Name Type Description Default
locale Locale | int

Either a Locale object or an ID of a Locale.

required

Returns:

Name Type Description
Model

The model instance.

Raises:

Type Description
DoesNotExist

If there is not an instance of this object in the given locale.

Source code in wagtail_localize/models.py
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
def get_instance(self, locale):
    """
    Returns a model instance for this object in the given locale.

    Args:
        locale (Locale | int): Either a Locale object or an ID of a Locale.

    Returns:
        Model: The model instance.

    Raises:
        Model.DoesNotExist: If there is not an instance of this object in the given locale.
    """
    return self.content_type.get_object_for_this_type(
        translation_key=self.translation_key, locale_id=pk(locale)
    )

get_instance_or_none(locale)

Returns a model instance for this object in the given locale.

Parameters:

Name Type Description Default
locale Locale | int

Either a Locale object or an ID of a Locale.

required

Returns:

Name Type Description
Model

The model instance if one exists.

None

If the model doesn't exist.

Source code in wagtail_localize/models.py
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
def get_instance_or_none(self, locale):
    """
    Returns a model instance for this object in the given locale.

    Args:
        locale (Locale | int): Either a Locale object or an ID of a Locale.

    Returns:
        Model: The model instance if one exists.
        None: If the model doesn't exist.
    """
    try:
        return self.get_instance(locale)
    except self.content_type.model_class().DoesNotExist:
        pass

has_translation(locale)

Returns True if there is an instance of this object in the given Locale.

Parameters:

Name Type Description Default
locale Locale | int

Either a Locale object or an ID of a Locale.

required

Returns:

Name Type Description
bool

True if there is an instance of this object in the given locale.

Source code in wagtail_localize/models.py
189
190
191
192
193
194
195
196
197
198
199
200
201
def has_translation(self, locale):
    """
    Returns True if there is an instance of this object in the given Locale.

    Args:
        locale (Locale | int): Either a Locale object or an ID of a Locale.

    Returns:
        bool: True if there is an instance of this object in the given locale.
    """
    return self.content_type.get_all_objects_for_this_type(
        translation_key=self.translation_key, locale_id=pk(locale)
    ).exists()

TranslationSource

Bases: Model

Frozen source content that is to be translated.

This is like a page revision, except it can be created for any model and it's only created/updated when a user submits something for translation.

Attributes:

Name Type Description
object ForeignKey to TranslatableObject

The object that this is a source for

specific_content_type ForeignKey to ContentType

The specific content type that this was extracted from. Note that TranslatableObject.content_type doesn't store the most specific content type, but this does.

locale ForeignKey to Locale

The Locale of the instance that this source content was extracted from.

object_repr TextField

A string representing the name of the source object. Used in the UI.

content_json TextField with JSON contents

The serialized source content. Note that this is serialzed in the same way that Wagtail serializes page revisions.

created_at DateTimeField

The date/time at which the content was first extracted from this source.

last_updated_at DateTimeField

The date/time at which the content was last extracted from this source.

Source code in wagtail_localize/models.py
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
class TranslationSource(models.Model):
    """
    Frozen source content that is to be translated.

    This is like a page revision, except it can be created for any model and it's only created/updated when a user
    submits something for translation.

    Attributes:
        object (ForeignKey to TranslatableObject): The object that this is a source for
        specific_content_type (ForeignKey to ContentType): The specific content type that this was extracted from.
            Note that `TranslatableObject.content_type` doesn't store the most specific content type, but this
            does.
        locale (ForeignKey to Locale): The Locale of the instance that this source content was extracted from.
        object_repr (TextField): A string representing the name of the source object. Used in the UI.
        content_json (TextField with JSON contents): The serialized source content. Note that this is serialzed in the
            same way that Wagtail serializes page revisions.
        created_at (DateTimeField): The date/time at which the content was first extracted from this source.
        last_updated_at (DateTimeField): The date/time at which the content was last extracted from this source.
    """

    object = models.ForeignKey(
        TranslatableObject, on_delete=models.CASCADE, related_name="sources"
    )
    specific_content_type = models.ForeignKey(
        ContentType, on_delete=models.CASCADE, related_name="+"
    )
    locale = models.ForeignKey("wagtailcore.Locale", on_delete=models.CASCADE)
    object_repr = models.TextField(max_length=200)
    content_json = models.TextField()
    # The name of the last migration to be applied to the app that contains the specific_content_type model
    # This is used to provide a warning to a user when they are editing a translation that was submitted with
    # an older schema
    # Can be blank if the app has no migrations or the TranslationSource was submitted with an old version
    # of Wagtail Localize
    schema_version = models.CharField(max_length=255, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    last_updated_at = models.DateTimeField()

    objects = TranslationSourceQuerySet.as_manager()

    class Meta:
        unique_together = [
            ("object", "locale"),
        ]

    def __str__(self):
        return f"TranslationSource: {self.object_id}, {self.specific_content_type_id}, {self.locale}"

    @classmethod
    def get_or_create_from_instance(cls, instance):
        """
        Creates or gets a TranslationSource for the given instance.

        This extracts the content from the given instance. Then stores it in a new TranslationSource instance if one
        doesn't already exist. If one does already exist, it returns the existing TranslationSource without changing
        it.

        Args:
            instance (Model that inherits TranslatableMixin): A Translatable model instance to find a TranslationSource
                instance for.

        Returns:
            tuple[TranslationSource, boolean]: A two-tuple, the first component is the TranslationSource object, and
                the second component is a boolean that is True if the TranslationSource was created.
        """
        # Make sure we're using the specific version of pages
        if isinstance(instance, Page):
            instance = instance.specific

        object, created = TranslatableObject.objects.get_or_create_from_instance(
            instance
        )

        try:
            return (
                TranslationSource.objects.get(
                    object_id=object.translation_key, locale_id=instance.locale_id
                ),
                False,
            )
        except TranslationSource.DoesNotExist:
            pass

        if isinstance(instance, ClusterableModel):
            content_json = instance.to_json()
        else:
            serializable_data = get_serializable_data_for_fields(instance)
            content_json = json.dumps(serializable_data, cls=DjangoJSONEncoder)

        source, created = cls.objects.update_or_create(
            object=object,
            locale=instance.locale,
            # You can't update the content type of a source. So if this happens,
            # it'll try and create a new source and crash (can't have more than
            # one source per object/locale)
            specific_content_type=ContentType.objects.get_for_model(instance.__class__),
            defaults={
                "locale": instance.locale,
                "object_repr": str(instance)[:200],
                "content_json": content_json,
                "schema_version": get_schema_version(instance._meta.app_label),
                "last_updated_at": timezone.now(),
            },
        )
        source.refresh_segments()
        return source, created

    @classmethod
    def update_or_create_from_instance(cls, instance):
        """
        Creates or updates a TranslationSource for the given instance.

        This extracts the content from the given instance. Then stores it in a new TranslationSource instance if one
        doesn't already exist. If one does already exist, it updates the existing TranslationSource.

        Args:
            instance (Model that inherits TranslatableMixin): A Translatable model instance to extract source content
                from.

        Returns:
            tuple[TranslationSource, boolean]: A two-tuple, the first component is the TranslationSource object, and
                the second component is a boolean that is True if the TranslationSource was created.
        """
        # Make sure we're using the specific version of pages
        if isinstance(instance, Page):
            instance = instance.specific

        object, created = TranslatableObject.objects.get_or_create_from_instance(
            instance
        )

        if isinstance(instance, ClusterableModel):
            content_json = instance.to_json()
        else:
            serializable_data = get_serializable_data_for_fields(instance)
            content_json = json.dumps(serializable_data, cls=DjangoJSONEncoder)

        # Check if the instance has changed since the previous version
        source = TranslationSource.objects.filter(
            object_id=object.translation_key, locale_id=instance.locale_id
        ).first()

        # Check if the instance has changed at all since the previous version
        if source:
            if json.loads(content_json) == json.loads(source.content_json):
                return source, False

        source, created = cls.objects.update_or_create(
            object=object,
            locale=instance.locale,
            # You can't update the content type of a source. So if this happens,
            # it'll try and create a new source and crash (can't have more than
            # one source per object/locale)
            specific_content_type=ContentType.objects.get_for_model(instance.__class__),
            defaults={
                "locale": instance.locale,
                "object_repr": str(instance)[:200],
                "content_json": content_json,
                "schema_version": get_schema_version(instance._meta.app_label),
                "last_updated_at": timezone.now(),
            },
        )
        source.refresh_segments()
        return source, created

    @transaction.atomic
    def update_from_db(self):
        """
        Retrieves the source instance from the database and updates this TranslationSource
        with its current contents.

        Raises:
            Model.DoesNotExist: If the source instance has been deleted.
        """
        instance = self.get_source_instance()

        if isinstance(instance, ClusterableModel):
            self.content_json = instance.to_json()
        else:
            serializable_data = get_serializable_data_for_fields(instance)
            self.content_json = json.dumps(serializable_data, cls=DjangoJSONEncoder)

        self.schema_version = get_schema_version(instance._meta.app_label)
        self.object_repr = str(instance)[:200]
        self.last_updated_at = timezone.now()

        self.save(
            update_fields=[
                "content_json",
                "schema_version",
                "object_repr",
                "last_updated_at",
            ]
        )
        self.refresh_segments()

    def get_source_instance(self):
        """
        This gets the live version of instance that the source data was extracted from.

        This is different to source.object.get_instance(source.locale) as the instance
        returned by this methid will have the same model that the content was extracted
        from. The model returned by `object.get_instance` might be more generic since
        that model only records the model that the TranslatableMixin was applied to but
        that model might have child models.

        Returns:
            Model: The model instance that this TranslationSource was created from.

        Raises:
            Model.DoesNotExist: If the source instance has been deleted.
        """
        return self.specific_content_type.get_object_for_this_type(
            translation_key=self.object_id, locale_id=self.locale_id
        )

    def get_source_instance_edit_url(self):
        """
        Returns the URL to edit the source instance.
        """
        return get_edit_url(self.get_source_instance())

    def get_translated_instance(self, locale):
        return self.specific_content_type.get_object_for_this_type(
            translation_key=self.object_id, locale_id=pk(locale)
        )

    def as_instance(self):
        """
        Builds an instance of the object with the content of this source.

        Returns:
            Model: A model instance that has the content of this TranslationSource.

        Raises:
            SourceDeletedError: if the source instance has been deleted.
        """
        try:
            instance = self.get_source_instance()
        except models.ObjectDoesNotExist as err:
            raise SourceDeletedError from err

        if isinstance(instance, Page):
            # see https://github.com/wagtail/wagtail/pull/8024
            content_json = json.loads(self.content_json)
            return instance.with_content_json(content_json)

        elif isinstance(instance, ClusterableModel):
            new_instance = instance.__class__.from_json(self.content_json)

        else:
            new_instance = model_from_serializable_data(
                instance.__class__, json.loads(self.content_json)
            )

        new_instance.pk = instance.pk
        new_instance.locale = instance.locale
        new_instance.translation_key = instance.translation_key

        return new_instance

    @transaction.atomic
    def refresh_segments(self):
        """
        Updates the *Segment models to reflect the latest version of the source.

        This is called by `from_instance` so you don't usually need to call this manually.
        """
        seen_string_segment_ids = []
        seen_template_segment_ids = []
        seen_related_object_segment_ids = []
        seen_overridable_segment_ids = []

        instance = self.as_instance()
        for segment in extract_segments(instance):
            if isinstance(segment, TemplateSegmentValue):
                segment_obj = TemplateSegment.from_value(self, segment)
                seen_template_segment_ids.append(segment_obj.id)
            elif isinstance(segment, RelatedObjectSegmentValue):
                segment_obj = RelatedObjectSegment.from_value(self, segment)
                seen_related_object_segment_ids.append(segment_obj.id)
            elif isinstance(segment, OverridableSegmentValue):
                segment_obj = OverridableSegment.from_value(self, segment)
                seen_overridable_segment_ids.append(segment_obj.id)
            else:
                segment_obj = StringSegment.from_value(self, self.locale, segment)
                seen_string_segment_ids.append(segment_obj.id)

            # Make sure the segment's field_path is pre-populated
            segment_obj.context.get_field_path(instance)

        # Delete any segments that weren't mentioned
        self.stringsegment_set.exclude(id__in=seen_string_segment_ids).delete()
        self.templatesegment_set.exclude(id__in=seen_template_segment_ids).delete()
        self.relatedobjectsegment_set.exclude(
            id__in=seen_related_object_segment_ids
        ).delete()
        self.overridablesegment_set.exclude(
            id__in=seen_overridable_segment_ids
        ).delete()

    def export_po(self):
        """
        Exports all translatable strings from this source.

        Note that because there is no target locale, all `msgstr` fields will be blank.

        Returns:
            polib.POFile: A POFile object containing the source translatable strings.
        """
        # Get messages
        messages = []

        for string_segment in (
            StringSegment.objects.filter(source=self)
            .order_by("order")
            .select_related("context", "string")
        ):
            messages.append((string_segment.string.data, string_segment.context.path))

        # Build a PO file
        po = polib.POFile(wrapwidth=200)
        po.metadata = {
            "POT-Creation-Date": str(timezone.now()),
            "MIME-Version": "1.0",
            "Content-Type": "text/plain; charset=utf-8",
        }

        for text, context in messages:
            po.append(
                polib.POEntry(
                    msgid=text,
                    msgctxt=context,
                    msgstr="",
                )
            )

        return po

    def _get_segments_for_translation(self, locale, fallback=False):
        """
        Returns a list of segments that can be passed into "ingest_segments" to translate an object.
        """
        string_segments = (
            StringSegment.objects.filter(source=self)
            .annotate_translation(locale)
            .select_related("context", "string")
        )

        template_segments = (
            TemplateSegment.objects.filter(source=self)
            .select_related("template")
            .select_related("context")
        )

        related_object_segments = (
            RelatedObjectSegment.objects.filter(source=self)
            .select_related("object")
            .select_related("context")
        )

        overridable_segments = (
            OverridableSegment.objects.filter(source=self)
            .annotate_override_json(locale)
            .filter(override_json__isnull=False)
            .select_related("context")
        )

        segments = []

        for string_segment in string_segments:
            if string_segment.translation:
                string = StringValue(string_segment.translation)
            elif fallback:
                string = StringValue(string_segment.string.data)
            else:
                raise MissingTranslationError(string_segment, locale)

            segment_value = StringSegmentValue(
                string_segment.context.path,
                string,
                attrs=json.loads(string_segment.attrs),
            ).with_order(string_segment.order)

            segments.append(segment_value)

        for template_segment in template_segments:
            template = template_segment.template
            segment_value = TemplateSegmentValue(
                template_segment.context.path,
                template.template_format,
                template.template,
                template.string_count,
                order=template_segment.order,
            )
            segments.append(segment_value)

        for related_object_segment in related_object_segments:
            if related_object_segment.object.has_translation(locale):
                segment_value = RelatedObjectSegmentValue(
                    related_object_segment.context.path,
                    related_object_segment.object.content_type,
                    related_object_segment.object.translation_key,
                    order=related_object_segment.order,
                )
                segments.append(segment_value)

            elif fallback:
                # Skip this segment, this will reuse what is already in the database
                continue
            else:
                raise MissingRelatedObjectError(related_object_segment, locale)

        for overridable_segment in overridable_segments:
            segment_value = OverridableSegmentValue(
                overridable_segment.context.path,
                json.loads(overridable_segment.override_json),
                order=overridable_segment.order,
            )
            segments.append(segment_value)

        return segments

    def create_or_update_translation(
        self, locale, user=None, publish=True, copy_parent_pages=False, fallback=False
    ):
        """
        Creates/updates a translation of the object into the specified locale
        based on the content of this source and the translated strings
        currently in translation memory.

        Args:
            locale (Locale): The target locale to generate the translation for.
            user (User, optional): The user who is carrying out this operation. For logging purposes
            publish (boolean, optional): Set this to False to save a draft of the translation. Pages only.
            copy_parent_pages (boolean, optional): Set this to True to make copies of the parent pages if they are not
                yet translated.
            fallback (boolean, optional): Set this to True to fallback to source strings/related objects if they are
                not yet translated. By default, this will raise an error if anything is missing.

        Raises:
            SourceDeletedError: if the source object has been deleted.
            CannotSaveDraftError: if the `publish` parameter was set to `False` when translating a non-page object.
            MissingTranslationError: if a translation is missing and `fallback `is not `True`.
            MissingRelatedObjectError: if a related object is not translated and `fallback `is not `True`.

        Returns:
            Model: The translated instance.
        """
        original = self.as_instance()
        created = False

        # Only pages can be saved as draft
        # To-Do: add support for models using DraftStateMixin
        if not publish and not isinstance(original, Page):
            raise CannotSaveDraftError

        try:
            translation = self.get_translated_instance(locale)
        except models.ObjectDoesNotExist:
            if isinstance(original, Page):
                translation = original.copy_for_translation(
                    locale, copy_parents=copy_parent_pages
                )
            else:
                translation = original.copy_for_translation(locale)

            created = True

        copy_synchronised_fields(original, translation)

        segments = self._get_segments_for_translation(locale, fallback=fallback)

        try:
            with transaction.atomic():
                # Ingest all translated segments
                ingest_segments(original, translation, self.locale, locale, segments)

                if isinstance(translation, Page):
                    # If the page is an alias, convert it into a regular page
                    if translation.alias_of_id:
                        translation.alias_of_id = None
                        translation.save(update_fields=["alias_of_id"], clean=False)

                        # Create initial revision
                        revision = translation.save_revision(
                            user=user, changed=False, clean=False
                        )

                        # Log the alias conversion
                        PageLogEntry.objects.log_action(
                            instance=translation,
                            revision=revision,
                            action="wagtail.convert_alias",
                            user=user,
                            data={
                                "page": {
                                    "id": translation.id,
                                    "title": translation.get_admin_display_title(),
                                },
                            },
                        )

                    # Make sure the slug is valid
                    translation.slug = find_available_slug(
                        translation.get_parent(),
                        slugify(translation.slug),
                        ignore_page_id=translation.id,
                    )
                    translation.save()

                    # Create a new revision
                    page_revision = translation.save_revision(user=user)

                    self.sync_view_restrictions(original, translation)

                    if publish:
                        transaction.on_commit(page_revision.publish)

                else:
                    # Note: we don't need to run full_clean for Pages as Wagtail does that in Page.save()
                    translation.full_clean()

                    translation.save()
                    page_revision = None

        except ValidationError as e:
            # If the validation error's field matches the context of a translation,
            # set that error message on that translation.
            # TODO (someday): Add support for errors raised from streamfield
            for field_name, errors in e.error_dict.items():
                try:
                    context = TranslationContext.objects.get(
                        object=self.object, path=field_name
                    )

                except TranslationContext.DoesNotExist:
                    # TODO (someday): How would we handle validation errors for non-translatable fields?
                    continue

                # Check for string translation
                try:
                    string_translation = StringTranslation.objects.get(
                        translation_of_id__in=StringSegment.objects.filter(
                            source=self
                        ).values_list("string_id", flat=True),
                        context=context,
                        locale=locale,
                    )

                    string_translation.set_field_error(errors)

                except StringTranslation.DoesNotExist:
                    pass

                # Check for segment override
                try:
                    segment_override = SegmentOverride.objects.get(
                        context=context,
                        locale=locale,
                    )

                    segment_override.set_field_error(errors)

                except SegmentOverride.DoesNotExist:
                    pass

            raise

        # Log that the translation was made
        TranslationLog.objects.create(
            source=self, locale=locale, page_revision=page_revision
        )

        return translation, created

    def get_ephemeral_translated_instance(self, locale, fallback=False):
        """
        Returns an instance with the translations added which is not intended to be saved.

        This is used for previewing pages with draft translations applied.

        Args:
            locale (Locale): The target locale to generate the ephemeral translation for.
            fallback (boolean): Set this to True to fallback to source strings/related objects if they are not yet
                translated. By default, this will raise an error if anything is missing.

        Raises:
            SourceDeletedError: if the source object has been deleted.
            MissingTranslationError: if a translation is missing and `fallback `is not `True`.
            MissingRelatedObjectError: if a related object is not translated and `fallback `is not `True`.

        Returns:
            Model: The translated instance with unsaved changes.
        """
        original = self.as_instance()
        translation = self.get_translated_instance(locale)

        copy_synchronised_fields(original, translation)

        segments = self._get_segments_for_translation(locale, fallback=fallback)

        # Ingest all translated segments
        ingest_segments(original, translation, self.locale, locale, segments)

        return translation

    def schema_out_of_date(self):
        """
        Returns True if the app that contains the model this source was generated from
        has been updated since the source was last updated.
        """
        if not self.schema_version:
            return False

        current_schema_version = get_schema_version(
            self.specific_content_type.app_label
        )
        return self.schema_version != current_schema_version

    def sync_view_restrictions(self, original, translation_page):
        """
        Synchronizes view restriction object for the translated page

        Args:
            original (Page|Snippet): The original instance.
            translation_page (Page|Snippet): The translated instance.
        """
        if not isinstance(original, Page) or not isinstance(translation_page, Page):
            raise NoViewRestrictionsError

        if original.view_restrictions.exists():
            original_restriction = original.view_restrictions.first()
            if not translation_page.view_restrictions.exists():
                view_restriction, child_object_map = _copy(
                    original_restriction,
                    exclude_fields=["id"],
                    update_attrs={"page": translation_page},
                )
                view_restriction.save()
            else:
                # if both exist, sync them
                translation_restriction = translation_page.view_restrictions.first()
                should_save = False
                if (
                    translation_restriction.restriction_type
                    != original_restriction.restriction_type
                ):
                    translation_restriction.restriction_type = (
                        original_restriction.restriction_type
                    )
                    should_save = True
                if translation_restriction.password != original_restriction.password:
                    translation_restriction.password = original_restriction.password
                    should_save = True
                if list(
                    original_restriction.groups.values_list("pk", flat=True)
                ) != list(translation_restriction.groups.values_list("pk", flat=True)):
                    translation_restriction.groups.set(
                        original_restriction.groups.all()
                    )

                if should_save:
                    translation_restriction.save()

        elif translation_page.view_restrictions.exists():
            # the original no longer has the restriction, so drop it
            translation_page.view_restrictions.all().delete()

    def update_target_view_restrictions(self, locale):
        """
        Creates a corresponding view restriction object for the translated page for the given locale

        Args:
            locale (Locale): The target locale
        """
        original = self.as_instance()

        # Only update restrictions for pages
        if not isinstance(original, Page):
            return

        try:
            translation_page = self.get_translated_instance(locale)
        except Page.DoesNotExist:
            return

        self.sync_view_restrictions(original, translation_page)

_get_segments_for_translation(locale, fallback=False)

Returns a list of segments that can be passed into "ingest_segments" to translate an object.

Source code in wagtail_localize/models.py
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
def _get_segments_for_translation(self, locale, fallback=False):
    """
    Returns a list of segments that can be passed into "ingest_segments" to translate an object.
    """
    string_segments = (
        StringSegment.objects.filter(source=self)
        .annotate_translation(locale)
        .select_related("context", "string")
    )

    template_segments = (
        TemplateSegment.objects.filter(source=self)
        .select_related("template")
        .select_related("context")
    )

    related_object_segments = (
        RelatedObjectSegment.objects.filter(source=self)
        .select_related("object")
        .select_related("context")
    )

    overridable_segments = (
        OverridableSegment.objects.filter(source=self)
        .annotate_override_json(locale)
        .filter(override_json__isnull=False)
        .select_related("context")
    )

    segments = []

    for string_segment in string_segments:
        if string_segment.translation:
            string = StringValue(string_segment.translation)
        elif fallback:
            string = StringValue(string_segment.string.data)
        else:
            raise MissingTranslationError(string_segment, locale)

        segment_value = StringSegmentValue(
            string_segment.context.path,
            string,
            attrs=json.loads(string_segment.attrs),
        ).with_order(string_segment.order)

        segments.append(segment_value)

    for template_segment in template_segments:
        template = template_segment.template
        segment_value = TemplateSegmentValue(
            template_segment.context.path,
            template.template_format,
            template.template,
            template.string_count,
            order=template_segment.order,
        )
        segments.append(segment_value)

    for related_object_segment in related_object_segments:
        if related_object_segment.object.has_translation(locale):
            segment_value = RelatedObjectSegmentValue(
                related_object_segment.context.path,
                related_object_segment.object.content_type,
                related_object_segment.object.translation_key,
                order=related_object_segment.order,
            )
            segments.append(segment_value)

        elif fallback:
            # Skip this segment, this will reuse what is already in the database
            continue
        else:
            raise MissingRelatedObjectError(related_object_segment, locale)

    for overridable_segment in overridable_segments:
        segment_value = OverridableSegmentValue(
            overridable_segment.context.path,
            json.loads(overridable_segment.override_json),
            order=overridable_segment.order,
        )
        segments.append(segment_value)

    return segments

as_instance()

Builds an instance of the object with the content of this source.

Returns:

Name Type Description
Model

A model instance that has the content of this TranslationSource.

Raises:

Type Description
SourceDeletedError

if the source instance has been deleted.

Source code in wagtail_localize/models.py
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
def as_instance(self):
    """
    Builds an instance of the object with the content of this source.

    Returns:
        Model: A model instance that has the content of this TranslationSource.

    Raises:
        SourceDeletedError: if the source instance has been deleted.
    """
    try:
        instance = self.get_source_instance()
    except models.ObjectDoesNotExist as err:
        raise SourceDeletedError from err

    if isinstance(instance, Page):
        # see https://github.com/wagtail/wagtail/pull/8024
        content_json = json.loads(self.content_json)
        return instance.with_content_json(content_json)

    elif isinstance(instance, ClusterableModel):
        new_instance = instance.__class__.from_json(self.content_json)

    else:
        new_instance = model_from_serializable_data(
            instance.__class__, json.loads(self.content_json)
        )

    new_instance.pk = instance.pk
    new_instance.locale = instance.locale
    new_instance.translation_key = instance.translation_key

    return new_instance

create_or_update_translation(locale, user=None, publish=True, copy_parent_pages=False, fallback=False)

Creates/updates a translation of the object into the specified locale based on the content of this source and the translated strings currently in translation memory.

Parameters:

Name Type Description Default
locale Locale

The target locale to generate the translation for.

required
user User

The user who is carrying out this operation. For logging purposes

None
publish boolean

Set this to False to save a draft of the translation. Pages only.

True
copy_parent_pages boolean

Set this to True to make copies of the parent pages if they are not yet translated.

False
fallback boolean

Set this to True to fallback to source strings/related objects if they are not yet translated. By default, this will raise an error if anything is missing.

False

Raises:

Type Description
SourceDeletedError

if the source object has been deleted.

CannotSaveDraftError

if the publish parameter was set to False when translating a non-page object.

MissingTranslationError

if a translation is missing and fallbackis not True.

MissingRelatedObjectError

if a related object is not translated and fallbackis not True.

Returns:

Name Type Description
Model

The translated instance.

Source code in wagtail_localize/models.py
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
def create_or_update_translation(
    self, locale, user=None, publish=True, copy_parent_pages=False, fallback=False
):
    """
    Creates/updates a translation of the object into the specified locale
    based on the content of this source and the translated strings
    currently in translation memory.

    Args:
        locale (Locale): The target locale to generate the translation for.
        user (User, optional): The user who is carrying out this operation. For logging purposes
        publish (boolean, optional): Set this to False to save a draft of the translation. Pages only.
        copy_parent_pages (boolean, optional): Set this to True to make copies of the parent pages if they are not
            yet translated.
        fallback (boolean, optional): Set this to True to fallback to source strings/related objects if they are
            not yet translated. By default, this will raise an error if anything is missing.

    Raises:
        SourceDeletedError: if the source object has been deleted.
        CannotSaveDraftError: if the `publish` parameter was set to `False` when translating a non-page object.
        MissingTranslationError: if a translation is missing and `fallback `is not `True`.
        MissingRelatedObjectError: if a related object is not translated and `fallback `is not `True`.

    Returns:
        Model: The translated instance.
    """
    original = self.as_instance()
    created = False

    # Only pages can be saved as draft
    # To-Do: add support for models using DraftStateMixin
    if not publish and not isinstance(original, Page):
        raise CannotSaveDraftError

    try:
        translation = self.get_translated_instance(locale)
    except models.ObjectDoesNotExist:
        if isinstance(original, Page):
            translation = original.copy_for_translation(
                locale, copy_parents=copy_parent_pages
            )
        else:
            translation = original.copy_for_translation(locale)

        created = True

    copy_synchronised_fields(original, translation)

    segments = self._get_segments_for_translation(locale, fallback=fallback)

    try:
        with transaction.atomic():
            # Ingest all translated segments
            ingest_segments(original, translation, self.locale, locale, segments)

            if isinstance(translation, Page):
                # If the page is an alias, convert it into a regular page
                if translation.alias_of_id:
                    translation.alias_of_id = None
                    translation.save(update_fields=["alias_of_id"], clean=False)

                    # Create initial revision
                    revision = translation.save_revision(
                        user=user, changed=False, clean=False
                    )

                    # Log the alias conversion
                    PageLogEntry.objects.log_action(
                        instance=translation,
                        revision=revision,
                        action="wagtail.convert_alias",
                        user=user,
                        data={
                            "page": {
                                "id": translation.id,
                                "title": translation.get_admin_display_title(),
                            },
                        },
                    )

                # Make sure the slug is valid
                translation.slug = find_available_slug(
                    translation.get_parent(),
                    slugify(translation.slug),
                    ignore_page_id=translation.id,
                )
                translation.save()

                # Create a new revision
                page_revision = translation.save_revision(user=user)

                self.sync_view_restrictions(original, translation)

                if publish:
                    transaction.on_commit(page_revision.publish)

            else:
                # Note: we don't need to run full_clean for Pages as Wagtail does that in Page.save()
                translation.full_clean()

                translation.save()
                page_revision = None

    except ValidationError as e:
        # If the validation error's field matches the context of a translation,
        # set that error message on that translation.
        # TODO (someday): Add support for errors raised from streamfield
        for field_name, errors in e.error_dict.items():
            try:
                context = TranslationContext.objects.get(
                    object=self.object, path=field_name
                )

            except TranslationContext.DoesNotExist:
                # TODO (someday): How would we handle validation errors for non-translatable fields?
                continue

            # Check for string translation
            try:
                string_translation = StringTranslation.objects.get(
                    translation_of_id__in=StringSegment.objects.filter(
                        source=self
                    ).values_list("string_id", flat=True),
                    context=context,
                    locale=locale,
                )

                string_translation.set_field_error(errors)

            except StringTranslation.DoesNotExist:
                pass

            # Check for segment override
            try:
                segment_override = SegmentOverride.objects.get(
                    context=context,
                    locale=locale,
                )

                segment_override.set_field_error(errors)

            except SegmentOverride.DoesNotExist:
                pass

        raise

    # Log that the translation was made
    TranslationLog.objects.create(
        source=self, locale=locale, page_revision=page_revision
    )

    return translation, created

export_po()

Exports all translatable strings from this source.

Note that because there is no target locale, all msgstr fields will be blank.

Returns:

Type Description

polib.POFile: A POFile object containing the source translatable strings.

Source code in wagtail_localize/models.py
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
def export_po(self):
    """
    Exports all translatable strings from this source.

    Note that because there is no target locale, all `msgstr` fields will be blank.

    Returns:
        polib.POFile: A POFile object containing the source translatable strings.
    """
    # Get messages
    messages = []

    for string_segment in (
        StringSegment.objects.filter(source=self)
        .order_by("order")
        .select_related("context", "string")
    ):
        messages.append((string_segment.string.data, string_segment.context.path))

    # Build a PO file
    po = polib.POFile(wrapwidth=200)
    po.metadata = {
        "POT-Creation-Date": str(timezone.now()),
        "MIME-Version": "1.0",
        "Content-Type": "text/plain; charset=utf-8",
    }

    for text, context in messages:
        po.append(
            polib.POEntry(
                msgid=text,
                msgctxt=context,
                msgstr="",
            )
        )

    return po

get_ephemeral_translated_instance(locale, fallback=False)

Returns an instance with the translations added which is not intended to be saved.

This is used for previewing pages with draft translations applied.

Parameters:

Name Type Description Default
locale Locale

The target locale to generate the ephemeral translation for.

required
fallback boolean

Set this to True to fallback to source strings/related objects if they are not yet translated. By default, this will raise an error if anything is missing.

False

Raises:

Type Description
SourceDeletedError

if the source object has been deleted.

MissingTranslationError

if a translation is missing and fallbackis not True.

MissingRelatedObjectError

if a related object is not translated and fallbackis not True.

Returns:

Name Type Description
Model

The translated instance with unsaved changes.

Source code in wagtail_localize/models.py
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
def get_ephemeral_translated_instance(self, locale, fallback=False):
    """
    Returns an instance with the translations added which is not intended to be saved.

    This is used for previewing pages with draft translations applied.

    Args:
        locale (Locale): The target locale to generate the ephemeral translation for.
        fallback (boolean): Set this to True to fallback to source strings/related objects if they are not yet
            translated. By default, this will raise an error if anything is missing.

    Raises:
        SourceDeletedError: if the source object has been deleted.
        MissingTranslationError: if a translation is missing and `fallback `is not `True`.
        MissingRelatedObjectError: if a related object is not translated and `fallback `is not `True`.

    Returns:
        Model: The translated instance with unsaved changes.
    """
    original = self.as_instance()
    translation = self.get_translated_instance(locale)

    copy_synchronised_fields(original, translation)

    segments = self._get_segments_for_translation(locale, fallback=fallback)

    # Ingest all translated segments
    ingest_segments(original, translation, self.locale, locale, segments)

    return translation

get_or_create_from_instance(instance) classmethod

Creates or gets a TranslationSource for the given instance.

This extracts the content from the given instance. Then stores it in a new TranslationSource instance if one doesn't already exist. If one does already exist, it returns the existing TranslationSource without changing it.

Parameters:

Name Type Description Default
instance Model that inherits TranslatableMixin

A Translatable model instance to find a TranslationSource instance for.

required

Returns:

Type Description

tuple[TranslationSource, boolean]: A two-tuple, the first component is the TranslationSource object, and the second component is a boolean that is True if the TranslationSource was created.

Source code in wagtail_localize/models.py
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
@classmethod
def get_or_create_from_instance(cls, instance):
    """
    Creates or gets a TranslationSource for the given instance.

    This extracts the content from the given instance. Then stores it in a new TranslationSource instance if one
    doesn't already exist. If one does already exist, it returns the existing TranslationSource without changing
    it.

    Args:
        instance (Model that inherits TranslatableMixin): A Translatable model instance to find a TranslationSource
            instance for.

    Returns:
        tuple[TranslationSource, boolean]: A two-tuple, the first component is the TranslationSource object, and
            the second component is a boolean that is True if the TranslationSource was created.
    """
    # Make sure we're using the specific version of pages
    if isinstance(instance, Page):
        instance = instance.specific

    object, created = TranslatableObject.objects.get_or_create_from_instance(
        instance
    )

    try:
        return (
            TranslationSource.objects.get(
                object_id=object.translation_key, locale_id=instance.locale_id
            ),
            False,
        )
    except TranslationSource.DoesNotExist:
        pass

    if isinstance(instance, ClusterableModel):
        content_json = instance.to_json()
    else:
        serializable_data = get_serializable_data_for_fields(instance)
        content_json = json.dumps(serializable_data, cls=DjangoJSONEncoder)

    source, created = cls.objects.update_or_create(
        object=object,
        locale=instance.locale,
        # You can't update the content type of a source. So if this happens,
        # it'll try and create a new source and crash (can't have more than
        # one source per object/locale)
        specific_content_type=ContentType.objects.get_for_model(instance.__class__),
        defaults={
            "locale": instance.locale,
            "object_repr": str(instance)[:200],
            "content_json": content_json,
            "schema_version": get_schema_version(instance._meta.app_label),
            "last_updated_at": timezone.now(),
        },
    )
    source.refresh_segments()
    return source, created

get_source_instance()

This gets the live version of instance that the source data was extracted from.

This is different to source.object.get_instance(source.locale) as the instance returned by this methid will have the same model that the content was extracted from. The model returned by object.get_instance might be more generic since that model only records the model that the TranslatableMixin was applied to but that model might have child models.

Returns:

Name Type Description
Model

The model instance that this TranslationSource was created from.

Raises:

Type Description
DoesNotExist

If the source instance has been deleted.

Source code in wagtail_localize/models.py
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
def get_source_instance(self):
    """
    This gets the live version of instance that the source data was extracted from.

    This is different to source.object.get_instance(source.locale) as the instance
    returned by this methid will have the same model that the content was extracted
    from. The model returned by `object.get_instance` might be more generic since
    that model only records the model that the TranslatableMixin was applied to but
    that model might have child models.

    Returns:
        Model: The model instance that this TranslationSource was created from.

    Raises:
        Model.DoesNotExist: If the source instance has been deleted.
    """
    return self.specific_content_type.get_object_for_this_type(
        translation_key=self.object_id, locale_id=self.locale_id
    )

get_source_instance_edit_url()

Returns the URL to edit the source instance.

Source code in wagtail_localize/models.py
504
505
506
507
508
def get_source_instance_edit_url(self):
    """
    Returns the URL to edit the source instance.
    """
    return get_edit_url(self.get_source_instance())

refresh_segments()

Updates the *Segment models to reflect the latest version of the source.

This is called by from_instance so you don't usually need to call this manually.

Source code in wagtail_localize/models.py
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
@transaction.atomic
def refresh_segments(self):
    """
    Updates the *Segment models to reflect the latest version of the source.

    This is called by `from_instance` so you don't usually need to call this manually.
    """
    seen_string_segment_ids = []
    seen_template_segment_ids = []
    seen_related_object_segment_ids = []
    seen_overridable_segment_ids = []

    instance = self.as_instance()
    for segment in extract_segments(instance):
        if isinstance(segment, TemplateSegmentValue):
            segment_obj = TemplateSegment.from_value(self, segment)
            seen_template_segment_ids.append(segment_obj.id)
        elif isinstance(segment, RelatedObjectSegmentValue):
            segment_obj = RelatedObjectSegment.from_value(self, segment)
            seen_related_object_segment_ids.append(segment_obj.id)
        elif isinstance(segment, OverridableSegmentValue):
            segment_obj = OverridableSegment.from_value(self, segment)
            seen_overridable_segment_ids.append(segment_obj.id)
        else:
            segment_obj = StringSegment.from_value(self, self.locale, segment)
            seen_string_segment_ids.append(segment_obj.id)

        # Make sure the segment's field_path is pre-populated
        segment_obj.context.get_field_path(instance)

    # Delete any segments that weren't mentioned
    self.stringsegment_set.exclude(id__in=seen_string_segment_ids).delete()
    self.templatesegment_set.exclude(id__in=seen_template_segment_ids).delete()
    self.relatedobjectsegment_set.exclude(
        id__in=seen_related_object_segment_ids
    ).delete()
    self.overridablesegment_set.exclude(
        id__in=seen_overridable_segment_ids
    ).delete()

schema_out_of_date()

Returns True if the app that contains the model this source was generated from has been updated since the source was last updated.

Source code in wagtail_localize/models.py
895
896
897
898
899
900
901
902
903
904
905
906
def schema_out_of_date(self):
    """
    Returns True if the app that contains the model this source was generated from
    has been updated since the source was last updated.
    """
    if not self.schema_version:
        return False

    current_schema_version = get_schema_version(
        self.specific_content_type.app_label
    )
    return self.schema_version != current_schema_version

sync_view_restrictions(original, translation_page)

Synchronizes view restriction object for the translated page

Parameters:

Name Type Description Default
original Page | Snippet

The original instance.

required
translation_page Page | Snippet

The translated instance.

required
Source code in wagtail_localize/models.py
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
def sync_view_restrictions(self, original, translation_page):
    """
    Synchronizes view restriction object for the translated page

    Args:
        original (Page|Snippet): The original instance.
        translation_page (Page|Snippet): The translated instance.
    """
    if not isinstance(original, Page) or not isinstance(translation_page, Page):
        raise NoViewRestrictionsError

    if original.view_restrictions.exists():
        original_restriction = original.view_restrictions.first()
        if not translation_page.view_restrictions.exists():
            view_restriction, child_object_map = _copy(
                original_restriction,
                exclude_fields=["id"],
                update_attrs={"page": translation_page},
            )
            view_restriction.save()
        else:
            # if both exist, sync them
            translation_restriction = translation_page.view_restrictions.first()
            should_save = False
            if (
                translation_restriction.restriction_type
                != original_restriction.restriction_type
            ):
                translation_restriction.restriction_type = (
                    original_restriction.restriction_type
                )
                should_save = True
            if translation_restriction.password != original_restriction.password:
                translation_restriction.password = original_restriction.password
                should_save = True
            if list(
                original_restriction.groups.values_list("pk", flat=True)
            ) != list(translation_restriction.groups.values_list("pk", flat=True)):
                translation_restriction.groups.set(
                    original_restriction.groups.all()
                )

            if should_save:
                translation_restriction.save()

    elif translation_page.view_restrictions.exists():
        # the original no longer has the restriction, so drop it
        translation_page.view_restrictions.all().delete()

update_from_db()

Retrieves the source instance from the database and updates this TranslationSource with its current contents.

Raises:

Type Description
DoesNotExist

If the source instance has been deleted.

Source code in wagtail_localize/models.py
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
@transaction.atomic
def update_from_db(self):
    """
    Retrieves the source instance from the database and updates this TranslationSource
    with its current contents.

    Raises:
        Model.DoesNotExist: If the source instance has been deleted.
    """
    instance = self.get_source_instance()

    if isinstance(instance, ClusterableModel):
        self.content_json = instance.to_json()
    else:
        serializable_data = get_serializable_data_for_fields(instance)
        self.content_json = json.dumps(serializable_data, cls=DjangoJSONEncoder)

    self.schema_version = get_schema_version(instance._meta.app_label)
    self.object_repr = str(instance)[:200]
    self.last_updated_at = timezone.now()

    self.save(
        update_fields=[
            "content_json",
            "schema_version",
            "object_repr",
            "last_updated_at",
        ]
    )
    self.refresh_segments()

update_or_create_from_instance(instance) classmethod

Creates or updates a TranslationSource for the given instance.

This extracts the content from the given instance. Then stores it in a new TranslationSource instance if one doesn't already exist. If one does already exist, it updates the existing TranslationSource.

Parameters:

Name Type Description Default
instance Model that inherits TranslatableMixin

A Translatable model instance to extract source content from.

required

Returns:

Type Description

tuple[TranslationSource, boolean]: A two-tuple, the first component is the TranslationSource object, and the second component is a boolean that is True if the TranslationSource was created.

Source code in wagtail_localize/models.py
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
@classmethod
def update_or_create_from_instance(cls, instance):
    """
    Creates or updates a TranslationSource for the given instance.

    This extracts the content from the given instance. Then stores it in a new TranslationSource instance if one
    doesn't already exist. If one does already exist, it updates the existing TranslationSource.

    Args:
        instance (Model that inherits TranslatableMixin): A Translatable model instance to extract source content
            from.

    Returns:
        tuple[TranslationSource, boolean]: A two-tuple, the first component is the TranslationSource object, and
            the second component is a boolean that is True if the TranslationSource was created.
    """
    # Make sure we're using the specific version of pages
    if isinstance(instance, Page):
        instance = instance.specific

    object, created = TranslatableObject.objects.get_or_create_from_instance(
        instance
    )

    if isinstance(instance, ClusterableModel):
        content_json = instance.to_json()
    else:
        serializable_data = get_serializable_data_for_fields(instance)
        content_json = json.dumps(serializable_data, cls=DjangoJSONEncoder)

    # Check if the instance has changed since the previous version
    source = TranslationSource.objects.filter(
        object_id=object.translation_key, locale_id=instance.locale_id
    ).first()

    # Check if the instance has changed at all since the previous version
    if source:
        if json.loads(content_json) == json.loads(source.content_json):
            return source, False

    source, created = cls.objects.update_or_create(
        object=object,
        locale=instance.locale,
        # You can't update the content type of a source. So if this happens,
        # it'll try and create a new source and crash (can't have more than
        # one source per object/locale)
        specific_content_type=ContentType.objects.get_for_model(instance.__class__),
        defaults={
            "locale": instance.locale,
            "object_repr": str(instance)[:200],
            "content_json": content_json,
            "schema_version": get_schema_version(instance._meta.app_label),
            "last_updated_at": timezone.now(),
        },
    )
    source.refresh_segments()
    return source, created

update_target_view_restrictions(locale)

Creates a corresponding view restriction object for the translated page for the given locale

Parameters:

Name Type Description Default
locale Locale

The target locale

required
Source code in wagtail_localize/models.py
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
def update_target_view_restrictions(self, locale):
    """
    Creates a corresponding view restriction object for the translated page for the given locale

    Args:
        locale (Locale): The target locale
    """
    original = self.as_instance()

    # Only update restrictions for pages
    if not isinstance(original, Page):
        return

    try:
        translation_page = self.get_translated_instance(locale)
    except Page.DoesNotExist:
        return

    self.sync_view_restrictions(original, translation_page)

Translation

Bases: Model

Manages the translation of an object into a locale.

An instance of this model is created whenever an object is submitted for translation into a new language.

They can be disabled at any time, and are deleted or disabled automatically if either the source or destination object is deleted.

If the translation of a page is disabled, the page editor of the translation would return to the normal Wagtail editor.

Attributes:

Name Type Description
uuid UUIDField

A unique ID for this translation used for referencing it from external systems.

source ForeignKey to TranslationSource

The source that is being translated.

target_locale ForeignKey to Locale

The Locale that the source is being translated into.

created_at DateTimeField

The date/time the translation was started.

translations_last_updated_at DateTimeField

The date/time of when a translated string was last updated.

destination_last_updated_at DateTimeField

The date/time of when the destination object was last updated.

enabled boolean

Whether this translation is enabled or not.

Source code in wagtail_localize/models.py
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
class Translation(models.Model):
    """
    Manages the translation of an object into a locale.

    An instance of this model is created whenever an object is submitted for translation into a new language.

    They can be disabled at any time, and are deleted or disabled automatically if either the source or
    destination object is deleted.

    If the translation of a page is disabled, the page editor of the translation would return to the normal Wagtail
    editor.

    Attributes:
        uuid (UUIDField): A unique ID for this translation used for referencing it from external systems.
        source (ForeignKey to TranslationSource): The source that is being translated.
        target_locale (ForeignKey to Locale): The Locale that the source is being translated into.
        created_at (DateTimeField): The date/time the translation was started.
        translations_last_updated_at (DateTimeField): The date/time of when a translated string was last updated.
        destination_last_updated_at (DateTimeField): The date/time of when the destination object was last updated.
        enabled (boolean): Whether this translation is enabled or not.
    """

    uuid = models.UUIDField(unique=True, default=uuid.uuid4)

    source = models.ForeignKey(
        TranslationSource, on_delete=models.CASCADE, related_name="translations"
    )
    target_locale = models.ForeignKey(
        "wagtailcore.Locale",
        on_delete=models.CASCADE,
        related_name="translations",
    )

    created_at = models.DateTimeField(auto_now_add=True)
    translations_last_updated_at = models.DateTimeField(null=True)
    destination_last_updated_at = models.DateTimeField(null=True)
    enabled = models.BooleanField(default=True)

    class Meta:
        unique_together = [
            ("source", "target_locale"),
        ]

    def __str__(self):
        return f"Translation: {self.uuid}, {self.source_id}, {self.target_locale_id}, (enabled: {self.enabled})"

    def get_target_instance(self):
        """
        Fetches the translated instance from the database.

        Raises:
            Model.DoesNotExist: if the translation does not exist.

        Returns:
            Model: The translated instance.
        """
        return self.source.get_translated_instance(self.target_locale)

    def get_target_instance_edit_url(self):
        """
        Returns the URL to edit the target instance.

        Raises:
            Model.DoesNotExist: if the translation does not exist.

        Returns:
            str: The URL of the edit view of the target instance.
        """
        return get_edit_url(self.get_target_instance())

    def get_progress(self):
        """
        Gets the current translation progress.

        Returns
            tuple[int, int]: A two-tuple of integers. First integer is the total number of string segments to be translated.
                The second integer is the number of string segments that have been translated so far.
        """
        # Get QuerySet of Segments that need to be translated
        required_segments = StringSegment.objects.filter(source_id=self.source_id)

        # Annotate each Segment with a flag that indicates whether the segment is translated
        # into the locale
        required_segments = required_segments.annotate(
            is_translated=Exists(
                StringTranslation.objects.filter(
                    translation_of_id=OuterRef("string_id"),
                    context_id=OuterRef("context_id"),
                    locale_id=self.target_locale_id,
                    has_error=False,
                )
            )
        )

        # Count the total number of segments and the number of translated segments
        aggs = required_segments.annotate(
            is_translated_i=Case(
                When(is_translated=True, then=Value(1)),
                default=Value(0),
                output_field=IntegerField(),
            )
        ).aggregate(
            total_segments=Count("pk"), translated_segments=Sum("is_translated_i")
        )

        return aggs["total_segments"], aggs["translated_segments"]

    def get_status_display(self):
        """
        Returns a string to describe the current status of this translation to a user.

        Returns:
            str: The status of this translation
        """
        total_segments, translated_segments = self.get_progress()
        if total_segments == translated_segments:
            return _("Up to date")
        else:
            return _("Waiting for translations")

    def export_po(self):
        """
        Exports all translatable strings with any translations that have already been made.

        Returns:
            polib.POFile: A POFile object containing the source translatable strings and any translations.
        """
        # Get messages
        messages = []

        string_segments = (
            StringSegment.objects.filter(source=self.source)
            .order_by("order")
            .select_related("context", "string")
            .annotate_translation(self.target_locale, include_errors=True)
        )

        for string_segment in string_segments:
            messages.append(
                (
                    string_segment.string.data,
                    string_segment.context.path,
                    string_segment.translation,
                )
            )

        # Build a PO file
        po = polib.POFile(wrapwidth=200)
        po.metadata = {
            "POT-Creation-Date": str(timezone.now()),
            "MIME-Version": "1.0",
            "Content-Type": "text/plain; charset=utf-8",
            "X-WagtailLocalize-TranslationID": str(self.uuid),
        }

        for text, context, translation in messages:
            po.append(
                polib.POEntry(
                    msgid=text,
                    msgctxt=context,
                    msgstr=translation or "",
                )
            )

        # Add any obsolete segments that have translations for future reference
        # We find this by looking for obsolete contexts and annotate the latest
        # translation for each one. Contexts that were never translated are
        # excluded
        for translation in (
            StringTranslation.objects.filter(
                context__object_id=self.source.object_id, locale=self.target_locale
            )
            .exclude(
                translation_of_id__in=StringSegment.objects.filter(
                    source=self.source
                ).values_list("string_id", flat=True)
            )
            .select_related("translation_of", "context")
            .iterator()
        ):
            po.append(
                polib.POEntry(
                    msgid=translation.translation_of.data,
                    msgstr=translation.data or "",
                    msgctxt=translation.context.path,
                    obsolete=True,
                )
            )

        return po

    @transaction.atomic
    def import_po(
        self, po, delete=False, user=None, translation_type="manual", tool_name=""
    ):
        """
        Imports all translatable strings with any translations that have already been made.

        Args:
            po (polib.POFile): A POFile object containing the source translatable strings and any translations.
            delete (boolean, optional): Set to True to delete any translations that do not appear in the PO file.
            user (User, optional): The user who is performing this operation. Used for logging purposes.
            translation_type ('manual' or 'machine', optional): Whether the translationw as performed by a human or machine. Defaults to 'manual'.
            tool_name (string, optional): The name of the tool that was used to perform the translation. Defaults to ''.

        Returns:
            list[POImportWarning]: A list of POImportWarning objects representing any non-fatal issues that were
            encountered while importing the PO file.
        """
        seen_translation_ids = set()
        warnings = []

        if "X-WagtailLocalize-TranslationID" in po.metadata and po.metadata[
            "X-WagtailLocalize-TranslationID"
        ] != str(self.uuid):
            return []

        for index, entry in enumerate(po):
            try:
                # Filter by hash instead to avoid case sensitivity issues
                # https://github.com/wagtail/wagtail-localize/issues/758
                string = String.objects.get(
                    locale_id=self.source.locale_id,
                    data_hash=String._get_data_hash(entry.msgid),
                )
                context = TranslationContext.objects.get(
                    object_id=self.source.object_id, path=entry.msgctxt
                )

                # Ignore blank strings
                if not entry.msgstr:
                    continue

                # Ignore if the string doesn't appear in this context, and if there is not an obsolete StringTranslation
                if (
                    not StringSegment.objects.filter(
                        string=string, context=context
                    ).exists()
                    and not StringTranslation.objects.filter(
                        translation_of=string, context=context
                    ).exists()
                ):
                    warnings.append(
                        StringNotUsedInContext(index, entry.msgid, entry.msgctxt)
                    )
                    continue

                string_translation, created = string.translations.get_or_create(
                    locale_id=self.target_locale_id,
                    context=context,
                    defaults={
                        "data": entry.msgstr,
                        "updated_at": timezone.now(),
                        "translation_type": translation_type,
                        "tool_name": tool_name,
                        "last_translated_by": user,
                        "has_error": False,
                        "field_error": "",
                    },
                )

                seen_translation_ids.add(string_translation.id)

                if not created:
                    # Update the string_translation only if it has changed
                    if string_translation.data != entry.msgstr:
                        string_translation.data = entry.msgstr
                        string_translation.translation_type = translation_type
                        string_translation.tool_name = tool_name
                        string_translation.last_translated_by = user
                        string_translation.updated_at = timezone.now()
                        string_translation.has_error = False  # reset the error flag.
                        string_translation.save()

            except TranslationContext.DoesNotExist:
                warnings.append(UnknownContext(index, entry.msgctxt))

            except String.DoesNotExist:
                warnings.append(UnknownString(index, entry.msgid))

        # Delete any translations that weren't mentioned
        if delete:
            StringTranslation.objects.filter(
                context__object_id=self.source.object_id, locale=self.target_locale
            ).exclude(id__in=seen_translation_ids).delete()

        return warnings

    def save_target(self, user=None, publish=True):
        """
        Saves the target page/snippet using the current translations.

        Args:
            user (User, optional): The user that is performing this action. Used for logging purposes.
            publish (boolean, optional): Set this to False to save a draft of the translation. Pages only.

        Raises:
            SourceDeletedError: if the source object has been deleted.
            CannotSaveDraftError: if the `publish` parameter was set to `False` when translating a non-page object.
            MissingTranslationError: if a translation is missing and `fallback `is not `True`.
            MissingRelatedObjectError: if a related object is not translated and `fallback `is not `True`.

        Returns:
            Model: The translated instance.
        """
        self.source.create_or_update_translation(
            self.target_locale,
            user=user,
            publish=publish,
            fallback=True,
            copy_parent_pages=True,
        )

export_po()

Exports all translatable strings with any translations that have already been made.

Returns:

Type Description

polib.POFile: A POFile object containing the source translatable strings and any translations.

Source code in wagtail_localize/models.py
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
def export_po(self):
    """
    Exports all translatable strings with any translations that have already been made.

    Returns:
        polib.POFile: A POFile object containing the source translatable strings and any translations.
    """
    # Get messages
    messages = []

    string_segments = (
        StringSegment.objects.filter(source=self.source)
        .order_by("order")
        .select_related("context", "string")
        .annotate_translation(self.target_locale, include_errors=True)
    )

    for string_segment in string_segments:
        messages.append(
            (
                string_segment.string.data,
                string_segment.context.path,
                string_segment.translation,
            )
        )

    # Build a PO file
    po = polib.POFile(wrapwidth=200)
    po.metadata = {
        "POT-Creation-Date": str(timezone.now()),
        "MIME-Version": "1.0",
        "Content-Type": "text/plain; charset=utf-8",
        "X-WagtailLocalize-TranslationID": str(self.uuid),
    }

    for text, context, translation in messages:
        po.append(
            polib.POEntry(
                msgid=text,
                msgctxt=context,
                msgstr=translation or "",
            )
        )

    # Add any obsolete segments that have translations for future reference
    # We find this by looking for obsolete contexts and annotate the latest
    # translation for each one. Contexts that were never translated are
    # excluded
    for translation in (
        StringTranslation.objects.filter(
            context__object_id=self.source.object_id, locale=self.target_locale
        )
        .exclude(
            translation_of_id__in=StringSegment.objects.filter(
                source=self.source
            ).values_list("string_id", flat=True)
        )
        .select_related("translation_of", "context")
        .iterator()
    ):
        po.append(
            polib.POEntry(
                msgid=translation.translation_of.data,
                msgstr=translation.data or "",
                msgctxt=translation.context.path,
                obsolete=True,
            )
        )

    return po

get_progress()

Gets the current translation progress.

Returns tuple[int, int]: A two-tuple of integers. First integer is the total number of string segments to be translated. The second integer is the number of string segments that have been translated so far.

Source code in wagtail_localize/models.py
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
def get_progress(self):
    """
    Gets the current translation progress.

    Returns
        tuple[int, int]: A two-tuple of integers. First integer is the total number of string segments to be translated.
            The second integer is the number of string segments that have been translated so far.
    """
    # Get QuerySet of Segments that need to be translated
    required_segments = StringSegment.objects.filter(source_id=self.source_id)

    # Annotate each Segment with a flag that indicates whether the segment is translated
    # into the locale
    required_segments = required_segments.annotate(
        is_translated=Exists(
            StringTranslation.objects.filter(
                translation_of_id=OuterRef("string_id"),
                context_id=OuterRef("context_id"),
                locale_id=self.target_locale_id,
                has_error=False,
            )
        )
    )

    # Count the total number of segments and the number of translated segments
    aggs = required_segments.annotate(
        is_translated_i=Case(
            When(is_translated=True, then=Value(1)),
            default=Value(0),
            output_field=IntegerField(),
        )
    ).aggregate(
        total_segments=Count("pk"), translated_segments=Sum("is_translated_i")
    )

    return aggs["total_segments"], aggs["translated_segments"]

get_status_display()

Returns a string to describe the current status of this translation to a user.

Returns:

Name Type Description
str

The status of this translation

Source code in wagtail_localize/models.py
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
def get_status_display(self):
    """
    Returns a string to describe the current status of this translation to a user.

    Returns:
        str: The status of this translation
    """
    total_segments, translated_segments = self.get_progress()
    if total_segments == translated_segments:
        return _("Up to date")
    else:
        return _("Waiting for translations")

get_target_instance()

Fetches the translated instance from the database.

Raises:

Type Description
DoesNotExist

if the translation does not exist.

Returns:

Name Type Description
Model

The translated instance.

Source code in wagtail_localize/models.py
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
def get_target_instance(self):
    """
    Fetches the translated instance from the database.

    Raises:
        Model.DoesNotExist: if the translation does not exist.

    Returns:
        Model: The translated instance.
    """
    return self.source.get_translated_instance(self.target_locale)

get_target_instance_edit_url()

Returns the URL to edit the target instance.

Raises:

Type Description
DoesNotExist

if the translation does not exist.

Returns:

Name Type Description
str

The URL of the edit view of the target instance.

Source code in wagtail_localize/models.py
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
def get_target_instance_edit_url(self):
    """
    Returns the URL to edit the target instance.

    Raises:
        Model.DoesNotExist: if the translation does not exist.

    Returns:
        str: The URL of the edit view of the target instance.
    """
    return get_edit_url(self.get_target_instance())

import_po(po, delete=False, user=None, translation_type='manual', tool_name='')

Imports all translatable strings with any translations that have already been made.

Parameters:

Name Type Description Default
po POFile

A POFile object containing the source translatable strings and any translations.

required
delete boolean

Set to True to delete any translations that do not appear in the PO file.

False
user User

The user who is performing this operation. Used for logging purposes.

None
translation_type manual or machine

Whether the translationw as performed by a human or machine. Defaults to 'manual'.

'manual'
tool_name string

The name of the tool that was used to perform the translation. Defaults to ''.

''

Returns:

Type Description

list[POImportWarning]: A list of POImportWarning objects representing any non-fatal issues that were

encountered while importing the PO file.

Source code in wagtail_localize/models.py
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
@transaction.atomic
def import_po(
    self, po, delete=False, user=None, translation_type="manual", tool_name=""
):
    """
    Imports all translatable strings with any translations that have already been made.

    Args:
        po (polib.POFile): A POFile object containing the source translatable strings and any translations.
        delete (boolean, optional): Set to True to delete any translations that do not appear in the PO file.
        user (User, optional): The user who is performing this operation. Used for logging purposes.
        translation_type ('manual' or 'machine', optional): Whether the translationw as performed by a human or machine. Defaults to 'manual'.
        tool_name (string, optional): The name of the tool that was used to perform the translation. Defaults to ''.

    Returns:
        list[POImportWarning]: A list of POImportWarning objects representing any non-fatal issues that were
        encountered while importing the PO file.
    """
    seen_translation_ids = set()
    warnings = []

    if "X-WagtailLocalize-TranslationID" in po.metadata and po.metadata[
        "X-WagtailLocalize-TranslationID"
    ] != str(self.uuid):
        return []

    for index, entry in enumerate(po):
        try:
            # Filter by hash instead to avoid case sensitivity issues
            # https://github.com/wagtail/wagtail-localize/issues/758
            string = String.objects.get(
                locale_id=self.source.locale_id,
                data_hash=String._get_data_hash(entry.msgid),
            )
            context = TranslationContext.objects.get(
                object_id=self.source.object_id, path=entry.msgctxt
            )

            # Ignore blank strings
            if not entry.msgstr:
                continue

            # Ignore if the string doesn't appear in this context, and if there is not an obsolete StringTranslation
            if (
                not StringSegment.objects.filter(
                    string=string, context=context
                ).exists()
                and not StringTranslation.objects.filter(
                    translation_of=string, context=context
                ).exists()
            ):
                warnings.append(
                    StringNotUsedInContext(index, entry.msgid, entry.msgctxt)
                )
                continue

            string_translation, created = string.translations.get_or_create(
                locale_id=self.target_locale_id,
                context=context,
                defaults={
                    "data": entry.msgstr,
                    "updated_at": timezone.now(),
                    "translation_type": translation_type,
                    "tool_name": tool_name,
                    "last_translated_by": user,
                    "has_error": False,
                    "field_error": "",
                },
            )

            seen_translation_ids.add(string_translation.id)

            if not created:
                # Update the string_translation only if it has changed
                if string_translation.data != entry.msgstr:
                    string_translation.data = entry.msgstr
                    string_translation.translation_type = translation_type
                    string_translation.tool_name = tool_name
                    string_translation.last_translated_by = user
                    string_translation.updated_at = timezone.now()
                    string_translation.has_error = False  # reset the error flag.
                    string_translation.save()

        except TranslationContext.DoesNotExist:
            warnings.append(UnknownContext(index, entry.msgctxt))

        except String.DoesNotExist:
            warnings.append(UnknownString(index, entry.msgid))

    # Delete any translations that weren't mentioned
    if delete:
        StringTranslation.objects.filter(
            context__object_id=self.source.object_id, locale=self.target_locale
        ).exclude(id__in=seen_translation_ids).delete()

    return warnings

save_target(user=None, publish=True)

Saves the target page/snippet using the current translations.

Parameters:

Name Type Description Default
user User

The user that is performing this action. Used for logging purposes.

None
publish boolean

Set this to False to save a draft of the translation. Pages only.

True

Raises:

Type Description
SourceDeletedError

if the source object has been deleted.

CannotSaveDraftError

if the publish parameter was set to False when translating a non-page object.

MissingTranslationError

if a translation is missing and fallbackis not True.

MissingRelatedObjectError

if a related object is not translated and fallbackis not True.

Returns:

Name Type Description
Model

The translated instance.

Source code in wagtail_localize/models.py
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
def save_target(self, user=None, publish=True):
    """
    Saves the target page/snippet using the current translations.

    Args:
        user (User, optional): The user that is performing this action. Used for logging purposes.
        publish (boolean, optional): Set this to False to save a draft of the translation. Pages only.

    Raises:
        SourceDeletedError: if the source object has been deleted.
        CannotSaveDraftError: if the `publish` parameter was set to `False` when translating a non-page object.
        MissingTranslationError: if a translation is missing and `fallback `is not `True`.
        MissingRelatedObjectError: if a related object is not translated and `fallback `is not `True`.

    Returns:
        Model: The translated instance.
    """
    self.source.create_or_update_translation(
        self.target_locale,
        user=user,
        publish=publish,
        fallback=True,
        copy_parent_pages=True,
    )

TranslationLog

Bases: Model

Keeps Track of when translations are created/updated.

Attributes:

Name Type Description
source ForeignKey to TranslationSource

The source that was used for translation.

locale ForeignKey to Locale

The Locale that the source was translated into.

created_at DateTimeField

The date/time the translation was done.

page_revision ForeignKey to PageRevision

If the translation was of a page, this links to the PageRevision that was created

Source code in wagtail_localize/models.py
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
class TranslationLog(models.Model):
    """
    Keeps Track of when translations are created/updated.

    Attributes:
        source (ForeignKey to TranslationSource): The source that was used for translation.
        locale (ForeignKey to Locale): The Locale that the source was translated into.
        created_at (DateTimeField): The date/time the translation was done.
        page_revision (ForeignKey to PageRevision): If the translation was of a page, this links to the PageRevision
            that was created
    """

    source = models.ForeignKey(
        TranslationSource, on_delete=models.CASCADE, related_name="translation_logs"
    )
    locale = models.ForeignKey(
        "wagtailcore.Locale",
        on_delete=models.CASCADE,
        related_name="translation_logs",
    )
    created_at = models.DateTimeField(auto_now_add=True)
    page_revision = models.ForeignKey(
        "wagtailcore.Revision",
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="+",
    )

    def __str__(self):
        return f"TranslationLog: {self.source_id}, {self.locale_id}, {self.page_revision_id} "

    def get_instance(self):
        """
        Gets the instance of the translated object, if it still exists.

        Raises:
            Model.DoesNotExist: if the translated object no longer exists.

        Returns:
            The translated object.
        """
        return self.source.object.get_instance(self.locale)

get_instance()

Gets the instance of the translated object, if it still exists.

Raises:

Type Description
DoesNotExist

if the translated object no longer exists.

Returns:

Type Description

The translated object.

Source code in wagtail_localize/models.py
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
def get_instance(self):
    """
    Gets the instance of the translated object, if it still exists.

    Raises:
        Model.DoesNotExist: if the translated object no longer exists.

    Returns:
        The translated object.
    """
    return self.source.object.get_instance(self.locale)