From 4f77d8d4d34df80c0cc5b361cfb9e2f75b80eca5 Mon Sep 17 00:00:00 2001 From: Coen van der Kamp Date: Thu, 8 Aug 2024 15:14:30 +0200 Subject: [PATCH 01/10] Improve settings, remove warnings --- tests/testproject/settings.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/testproject/settings.py b/tests/testproject/settings.py index 12e1f33..30b2685 100644 --- a/tests/testproject/settings.py +++ b/tests/testproject/settings.py @@ -167,8 +167,6 @@ "django.contrib.staticfiles.finders.AppDirectoriesFinder", ] -STATICFILES_DIRS = [os.path.join(PROJECT_DIR, "static")] - STATIC_ROOT = os.path.join(BASE_DIR, "test-static") STATIC_URL = "/static/" @@ -178,3 +176,4 @@ # Wagtail settings WAGTAIL_SITE_NAME = "Wagtail Translate test site" +WAGTAILADMIN_BASE_URL = "http://127.0.0.1:8000" From 20dffd1d133f6e0b98c85da911f78eacdd3e50ec Mon Sep 17 00:00:00 2001 From: Coen van der Kamp Date: Thu, 8 Aug 2024 15:16:02 +0200 Subject: [PATCH 02/10] Ignore env --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b0192ef..38817b7 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ __pycache__/ /node_modules /test-static /test-media -.python-version +/.python-version +/env/ From dbf7da0d0c64f01163c8768deaf179e953a1697a Mon Sep 17 00:00:00 2001 From: Coen van der Kamp Date: Thu, 8 Aug 2024 15:21:40 +0200 Subject: [PATCH 03/10] Add tests, improve translation of blocks, add translate related objects --- src/wagtail_translate/fields.py | 63 +---- src/wagtail_translate/translators/base.py | 26 +- tests/test_field_types.py | 24 ++ tests/test_streamfield_types.py | 293 +++++++++++++++++++++- 4 files changed, 333 insertions(+), 73 deletions(-) diff --git a/src/wagtail_translate/fields.py b/src/wagtail_translate/fields.py index d5d9ce0..b7b0322 100644 --- a/src/wagtail_translate/fields.py +++ b/src/wagtail_translate/fields.py @@ -7,14 +7,13 @@ def get_translatable_fields(model): """ - Derives a list of translatable fields from the given model class. + Derives a list of translatable fields (strings) from the given model class. Arguments: model (Model class): The model class to derive translatable fields from. Returns: - list[TranslatableField or SynchronizedField]: - A list of TranslatableField and SynchronizedFields that were derived from the model. + list: A list of stings, containing the names of translatable fields. """ # Set translatable_fields on the model to override the default behaviour. @@ -63,7 +62,7 @@ def get_translatable_fields(model): continue # Ignore choice fields - # These are usually used for enums and should not be translated. + # These are usually enums and should not be translated. if isinstance(field, models.CharField) and field.choices: continue @@ -94,65 +93,11 @@ def get_translatable_fields(model): continue # Foreign keys to translatable models should be translated. - # With the exception of pages that are special because we can localize them at runtime easily. - # TODO: Perhaps we need a special type for pages where it links to the translation if availabe, - # but falls back to the source if it isn't translated yet? - # Note: This exact same decision was made for page chooser blocks in segments/extract.py if issubclass(field.related_model, TranslatableMixin) and not issubclass( field.related_model, Page ): - continue - # TODO, implement related Translatable support - # translatable_fields.append(field) + translatable_fields.append(field) else: continue - # TODO - # # Fields that support extracting segments are translatable - # elif hasattr(field, "get_translatable_segments"): - # translatable_fields.append(TranslatableField(field)) - # - # else: - # # Everything else is synchronised - # translatable_fields.append(SynchronizedField(field)) - - # Add child relations for clusterable models - # if issubclass(model, ClusterableModel): - # for child_relation in get_all_child_relations(model): - # # Ignore comments - # if ( - # issubclass(model, Page) - # and child_relation.name == COMMENTS_RELATION_NAME - # ): - # continue - # - # if issubclass(child_relation.related_model, TranslatableMixin): - # translatable_fields.append(child_relation.name) - # else: - # continue - - # Combine with any overrides defined on the model - # override_translatable_fields = getattr(model, "override_translatable_fields", []) - # - # if override_translatable_fields: - # override_translatable_fields = { - # field.field_name: field for field in override_translatable_fields - # } - # - # combined_translatable_fields = [] - # for field in translatable_fields: - # if field.field_name in override_translatable_fields: - # combined_translatable_fields.append( - # override_translatable_fields.pop(field.field_name) - # ) - # else: - # combined_translatable_fields.append(field) - # - # if override_translatable_fields: - # combined_translatable_fields.extend(override_translatable_fields.values()) - # - # return combined_translatable_fields - # - # else: - return translatable_fields diff --git a/src/wagtail_translate/translators/base.py b/src/wagtail_translate/translators/base.py index 50e6721..a1a51a5 100644 --- a/src/wagtail_translate/translators/base.py +++ b/src/wagtail_translate/translators/base.py @@ -1,6 +1,8 @@ from bs4 import BeautifulSoup, NavigableString +from django.db.models import ForeignKey from wagtail import blocks from wagtail.fields import RichTextField, StreamField +from wagtail.models import TranslatableMixin from wagtail.rich_text import RichText from ..fields import get_translatable_fields @@ -158,6 +160,24 @@ def translate_list_block(self, item): self.translate_block(block_item) item.value[idx] = block_item.value + def translate_related_object(self, item): + """Translate related object, + + Select the translated object if it exists, + otherwise keep the current object. + """ + if not isinstance(item, TranslatableMixin): + return item + + if ( + translation := item.get_translations() + .filter(locale__language_code=self.target_language_code) + .first() + ): + return translation + + return item + def translate_block(self, item) -> None: """ Translate block, @@ -180,9 +200,7 @@ def translate_block(self, item) -> None: elif isinstance(item.block, blocks.BlockQuoteBlock): item.value = self.translate(item.value) elif isinstance(item.block, blocks.ChooserBlock): - ... # TODO, implement - elif isinstance(item.block, blocks.PageChooserBlock): - ... # TODO, implement + item.value = self.translate_related_object(item.value) # And to recurse, we need to handle iterables. elif isinstance(item.block, blocks.StructBlock): @@ -248,6 +266,8 @@ def translate_obj(self, source_obj, target_obj): translation = self.translate_html(src) elif isinstance(field, StreamField): translation = self.translate_blocks(src) + elif isinstance(field, ForeignKey): + translation = self.translate_related_object(src) else: translation = self.translate(src) setattr(target_obj, field.name, translation) diff --git a/tests/test_field_types.py b/tests/test_field_types.py index 7938a40..3452fa7 100644 --- a/tests/test_field_types.py +++ b/tests/test_field_types.py @@ -4,6 +4,7 @@ from wagtail.fields import RichTextField from tests.factories import BlogPostPageFactory, LocaleFactory +from tests.testapp.models import BlogCategory, BlogPostPage pytestmark = pytest.mark.django_db @@ -23,3 +24,26 @@ def test_translate_rich_text_field(): field = translation._meta.get_field("intro") assert isinstance(field, RichTextField) assert translation.intro == "

Uryyb jbeyq

" + + +def test_translate_fk_snippet(): + locale_en = LocaleFactory(language_code="en") # the default language + locale_fr = LocaleFactory(language_code="fr") + category = BlogCategory.objects.create(name="One Two Three") + page = BlogPostPageFactory(title="One", category=category) + translated_page = page.copy_for_translation(locale_fr) + + # Since there is NO translation, the original snippet is used. + assert translated_page.category == category + assert translated_page.category.locale == locale_en + + # Create a translation for the target page, the translated page is used. + translated_page.delete() + translated_snippet = category.copy_for_translation(locale_fr) + + assert BlogPostPage.objects.count() == 1 + assert BlogCategory.objects.count() == 2 + + translated_page = page.copy_for_translation(locale_fr) + assert translated_page.category == translated_snippet + assert translated_page.category.locale == locale_fr diff --git a/tests/test_streamfield_types.py b/tests/test_streamfield_types.py index 471a1f6..c0348b7 100644 --- a/tests/test_streamfield_types.py +++ b/tests/test_streamfield_types.py @@ -2,10 +2,11 @@ import pytest -from wagtail import blocks from wagtail.fields import StreamField +from wagtail_factories import DocumentFactory -from tests.factories import BlogPostPageFactory, LocaleFactory +from tests.factories import BlogPostPageFactory, ImageFactory, LocaleFactory +from tests.testapp.models import BlogCategory pytestmark = pytest.mark.django_db @@ -20,13 +21,11 @@ def test_body_field_is_streamfield(): def test_streamfield_translate_char_block(): page = BlogPostPageFactory( body=[ - {"type": "heading", "value": "Hello heading", "id": str(uuid.uuid4())}, + {"type": "heading", "value": "One Two Three", "id": str(uuid.uuid4())}, ] ) translation = page.copy_for_translation(LocaleFactory()) - field = translation._meta.get_field("body") - assert isinstance(field.stream_block.child_blocks["heading"], blocks.CharBlock) - assert translation.body[0].value == "Uryyb urnqvat" + assert translation.body[0].value == "Bar Gjb Guerr" def test_streamfield_translate_rich_text_block(): @@ -34,14 +33,286 @@ def test_streamfield_translate_rich_text_block(): body=[ { "type": "paragraph", - "value": "

Hello richtext

", + "value": "

One Two

", "id": str(uuid.uuid4()), }, ] ) translation = page.copy_for_translation(LocaleFactory()) - field = translation._meta.get_field("body") - assert isinstance( - field.stream_block.child_blocks["paragraph"], blocks.RichTextBlock + assert str(translation.body[0].value).strip() == "

Bar Gjb

" + + +def test_streamfield_stream_block(): + page = BlogPostPageFactory( + body=[ + { + "type": "stream", + "value": [ + { + "type": "paragraph", + "value": "One", + "id": str(uuid.uuid4()), + }, + { + "type": "paragraph", + "value": "Two", + "id": str(uuid.uuid4()), + }, + { + "type": "paragraph", + "value": "Three", + "id": str(uuid.uuid4()), + }, + ], + "id": str(uuid.uuid4()), + }, + ] + ) + translation = page.copy_for_translation(LocaleFactory()) + assert str(translation.body[0].value) == "\n".join( + [ + """
Bar
""", + """
Gjb
""", + """
Guerr
""", + ] + ) + + +def test_streamfield_stream_nested_block(): + page = BlogPostPageFactory( + body=[ + { + "type": "stream_nested", + "value": [ + { + "type": "stream", + "value": [ + { + "type": "paragraph", + "value": "One", + "id": str(uuid.uuid4()), + }, + { + "type": "paragraph", + "value": "Two", + "id": str(uuid.uuid4()), + }, + { + "type": "paragraph", + "value": "Three", + "id": str(uuid.uuid4()), + }, + ], + "id": str(uuid.uuid4()), + } + ], + "id": str(uuid.uuid4()), + } + ] + ) + translation = page.copy_for_translation(LocaleFactory()) + assert str(translation.body[0].value) == "".join( + [ + """
""", + "\n".join( + [ + """
Bar
""", + """
Gjb
""", + """
Guerr
""", + ] + ), + """
""", + ] ) - assert str(translation.body[0].value) == "

Uryyb evpugrkg

" + + +def test_streamfield_list_block(): + page = BlogPostPageFactory( + body=[ + { + "type": "list", + "value": [ + {"type": "item", "value": "One", "id": str(uuid.uuid4())}, + {"type": "item", "value": "Two", "id": str(uuid.uuid4())}, + {"type": "item", "value": "Three", "id": str(uuid.uuid4())}, + ], + "id": str(uuid.uuid4()), + } + ] + ) + translation = page.copy_for_translation(LocaleFactory()) + assert list(translation.body[0].value) == [ + "Bar", + "Gjb", + "Guerr", + ] + + +def test_streamfield_list_nested_block(): + page = BlogPostPageFactory( + body=[ + { + "type": "list_nested", + "value": [ + { + "type": "item", + "value": [ + { + "type": "item", + "value": "One 1", + "id": str(uuid.uuid4()), + }, + { + "type": "item", + "value": "Two 1", + "id": str(uuid.uuid4()), + }, + { + "type": "item", + "value": "Three 1", + "id": str(uuid.uuid4()), + }, + ], + "id": str(uuid.uuid4()), + }, + { + "type": "item", + "value": [ + { + "type": "item", + "value": "One 2", + "id": str(uuid.uuid4()), + }, + { + "type": "item", + "value": "Two 2", + "id": str(uuid.uuid4()), + }, + { + "type": "item", + "value": "Three 2", + "id": str(uuid.uuid4()), + }, + ], + "id": str(uuid.uuid4()), + }, + ], + "id": str(uuid.uuid4()), + } + ] + ) + translation = page.copy_for_translation(LocaleFactory()) + assert [list(item) for item in translation.body[0].value] == [ + [ + "Bar 1", + "Gjb 1", + "Guerr 1", + ], + [ + "Bar 2", + "Gjb 2", + "Guerr 2", + ], + ] + + +def test_streamfield_image_struct_block(): + image = ImageFactory() + page = BlogPostPageFactory( + body=[ + { + "type": "image_struct", + "value": {"image": image.pk, "caption": "One Two Three"}, + "id": str(uuid.uuid4()), + } + ] + ) + translation = page.copy_for_translation(LocaleFactory()) + assert translation.body[0].value["image"] == image + assert translation.body[0].value["caption"] == "Bar Gjb Guerr" + + +def test_streamfield_raw_block(): + page = BlogPostPageFactory( + body=[ + { + "type": "raw", + "value": '

Three

', + "id": str(uuid.uuid4()), + } + ] + ) + translation = page.copy_for_translation(LocaleFactory()) + # Only stings, title and alt attributes are translated + assert translation.body[0].value == '

Guerr

' + + +def test_streamfield_blockquote_block(): + page = BlogPostPageFactory( + body=[ + {"type": "block_quote", "value": "One Two Three", "id": str(uuid.uuid4())} + ] + ) + translation = page.copy_for_translation(LocaleFactory()) + assert translation.body[0].value == "Bar Gjb Guerr" + + +def test_streamfield_page_block(): + locale_en = LocaleFactory(language_code="en") # the default language + locale_fr = LocaleFactory(language_code="fr") + target_page = BlogPostPageFactory() + page = BlogPostPageFactory( + body=[{"type": "page", "value": target_page.pk, "id": str(uuid.uuid4())}] + ) + translation = page.copy_for_translation(locale_fr) + + # Since there is NO translation, the original target page is used. + assert translation.body[0].value.locale == locale_en + assert translation.body[0].value.specific == target_page + + # Create a translation for the target page, the translated page is used. + translation.delete() + target_page_translation = target_page.copy_for_translation(locale_fr) + target_page_translation.save_revision().publish() + translation = page.copy_for_translation(locale_fr) + assert translation.body[0].value.locale == locale_fr + assert translation.body[0].value.specific == target_page_translation + + +def test_streamfield_document_block(): + document = DocumentFactory() + page = BlogPostPageFactory( + body=[{"type": "document", "value": document.pk, "id": str(uuid.uuid4())}] + ) + translation = page.copy_for_translation(LocaleFactory()) + assert translation.body[0].value == document + + +def test_streamfield_image_chooser_block(): + image = ImageFactory() + page = BlogPostPageFactory( + body=[{"type": "image_chooser", "value": image.pk, "id": str(uuid.uuid4())}] + ) + translation = page.copy_for_translation(LocaleFactory()) + assert translation.body[0].value == image + + +def test_streamfield_snippet_block(): + locale_en = LocaleFactory(language_code="en") # the default language + locale_fr = LocaleFactory(language_code="fr") + snippet = BlogCategory.objects.create(name="One Two Three") + page = BlogPostPageFactory( + body=[{"type": "snippet", "value": snippet.pk, "id": str(uuid.uuid4())}] + ) + translation = page.copy_for_translation(locale_fr) + + # Since there is NO translation, the original snippet is used. + assert translation.body[0].value == snippet + assert translation.body[0].value.locale == locale_en + + # Create a translation for the target page, the translated page is used. + translation.delete() + snippet_translation = snippet.copy_for_translation(locale_fr) + translation = page.copy_for_translation(locale_fr) + assert translation.body[0].value == snippet_translation + assert translation.body[0].value.locale == locale_fr From 6a25d220a176abb9bbb92c3de8f4929d1b22a91f Mon Sep 17 00:00:00 2001 From: Coen van der Kamp Date: Wed, 21 Aug 2024 16:16:19 +0200 Subject: [PATCH 04/10] Add patch for **model** copy_for_translation_done --- src/wagtail_translate/apps.py | 18 +++-- src/wagtail_translate/monkeypatch_model.py | 44 ++++++++++++ .../{monkeypatches.py => monkeypatch_page.py} | 0 tests/test_app.py | 27 ++++++-- tests/testapp/migrations/0001_initial.py | 68 ++++++++++--------- tests/testapp/models.py | 26 ++++--- 6 files changed, 132 insertions(+), 51 deletions(-) create mode 100644 src/wagtail_translate/monkeypatch_model.py rename src/wagtail_translate/{monkeypatches.py => monkeypatch_page.py} (100%) diff --git a/src/wagtail_translate/apps.py b/src/wagtail_translate/apps.py index 984deee..5cc93f3 100644 --- a/src/wagtail_translate/apps.py +++ b/src/wagtail_translate/apps.py @@ -3,19 +3,29 @@ from django.apps import AppConfig -def patch_needed(version=wagtail.VERSION) -> bool: +def page_patch_needed(version=wagtail.VERSION) -> bool: """ - Wagtail 6.2 introduces the `copy_for_translation_done` signal. + Wagtail 6.2 introduces the `copy_for_translation_done` signal for **pages**. Older Wagtail versions need to be patched. """ return version[0] < 6 or (version[0] == 6 and version[1] < 2) +def model_patch_needed(version=wagtail.VERSION) -> bool: + """ + Wagtail 6.3 introduces the `copy_for_translation_done` signal for **models**. + Older Wagtail versions need to be patched. + """ + return version[0] < 6 or (version[0] == 6 and version[1] < 3) + + class WagtailTranslateAppConfig(AppConfig): label = "wagtail_translate" name = "wagtail_translate" verbose_name = "Wagtail Translate" def ready(self): - if patch_needed(): - from . import monkeypatches # noqa + if page_patch_needed(): + from . import monkeypatch_page # noqa + if model_patch_needed(): + from . import monkeypatch_model # noqa diff --git a/src/wagtail_translate/monkeypatch_model.py b/src/wagtail_translate/monkeypatch_model.py new file mode 100644 index 0000000..bf5a06e --- /dev/null +++ b/src/wagtail_translate/monkeypatch_model.py @@ -0,0 +1,44 @@ +""" +Wagtail 6.3 introduces the copy_for_translation_done signal for models (snippets). +Wagtail Translate will patch older versions of Wagtail. + +The `CopyForTranslationAction.execute` method is patched. +The new method sends the `copy_for_translation_done` signal. +""" + +import logging + +from wagtail.actions.copy_for_translation import CopyForTranslationAction + + +logger = logging.getLogger(__name__) + + +def new_execute(self, skip_permission_checks=False): + self.check(skip_permission_checks=skip_permission_checks) + + translated_object = self._copy_for_translation( + self.object, self.locale, self.exclude_fields + ) + + # Depending on the Wagtail version, + # the signal may be defined in Wagtail or in Wagtail Translate. + try: + from wagtail.signals import copy_for_translation_done + except ImportError: + from wagtail_translate.signals import copy_for_translation_done + + # Send signal + copy_for_translation_done.send( + sender=self.__class__, + source_obj=self.object, + target_obj=translated_object, + ) + + return translated_object + + +logger.warning( + "Monkeypatching wagtail.actions.copy_for_translation.CopyForTranslationAction.execute, send copy_for_translation_done signal" +) +CopyForTranslationAction.execute = new_execute diff --git a/src/wagtail_translate/monkeypatches.py b/src/wagtail_translate/monkeypatch_page.py similarity index 100% rename from src/wagtail_translate/monkeypatches.py rename to src/wagtail_translate/monkeypatch_page.py diff --git a/tests/test_app.py b/tests/test_app.py index 1ca9b28..2a48236 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,6 +1,6 @@ import pytest -from wagtail_translate.apps import patch_needed +from wagtail_translate.apps import model_patch_needed, page_patch_needed @pytest.mark.parametrize( @@ -11,10 +11,29 @@ ((5, 3, 0), True), ((6, 1, 0), True), ((6, 1, 9), True), - ((6, 2, 0), False), # 6.2 introduces the `copy_for_translation_done` signal. + # 6.2 introduces the `copy_for_translation_done` signal. + ((6, 2, 0), False), ((6, 2, 1), False), ((6, 3, 0), False), ], ) -def test_patch_needed(version, expected): - assert patch_needed(version) is expected +def test_page_patch_needed(version, expected): + assert page_patch_needed(version) is expected + + +@pytest.mark.parametrize( + "version, expected", + [ + ((5, 1, 0), True), + ((5, 2, 0), True), + ((5, 3, 0), True), + ((6, 1, 0), True), + ((6, 1, 9), True), + ((6, 2, 0), True), + ((6, 2, 1), True), + # 6.3 introduces the `copy_for_translation_done` signal to models (snippets). + ((6, 3, 0), False), + ], +) +def test_model_patch_needed(version, expected): + assert model_patch_needed(version) is expected diff --git a/tests/testapp/migrations/0001_initial.py b/tests/testapp/migrations/0001_initial.py index ca89f31..3539ced 100644 --- a/tests/testapp/migrations/0001_initial.py +++ b/tests/testapp/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.6 on 2024-06-12 20:03 +# Generated by Django 4.2.15 on 2024-08-21 13:45 import uuid @@ -18,34 +18,46 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("wagtailcore", "0089_log_entry_data_json_null_to_object"), ("wagtailimages", "0025_alter_image_file_alter_rendition_file"), + ("wagtailcore", "0089_log_entry_data_json_null_to_object"), ] operations = [ migrations.CreateModel( - name="BlogIndexPage", + name="BlogCategory", fields=[ ( - "page_ptr", - models.OneToOneField( + "id", + models.BigAutoField( auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, primary_key=True, serialize=False, - to="wagtailcore.page", + verbose_name="ID", + ), + ), + ( + "translation_key", + models.UUIDField(default=uuid.uuid4, editable=False), + ), + ("name", models.CharField(max_length=255)), + ( + "locale", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="wagtailcore.locale", ), ), - ("introduction", models.TextField(blank=True)), ], options={ + "verbose_name_plural": "Blog Categories", "abstract": False, + "unique_together": {("translation_key", "locale")}, }, - bases=("wagtailcore.page",), ), migrations.CreateModel( - name="HomePage", + name="BlogIndexPage", fields=[ ( "page_ptr", @@ -58,6 +70,7 @@ class Migration(migrations.Migration): to="wagtailcore.page", ), ), + ("introduction", models.TextField(blank=True)), ], options={ "abstract": False, @@ -65,36 +78,24 @@ class Migration(migrations.Migration): bases=("wagtailcore.page",), ), migrations.CreateModel( - name="BlogCategory", + name="HomePage", fields=[ ( - "id", - models.BigAutoField( + "page_ptr", + models.OneToOneField( auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, primary_key=True, serialize=False, - verbose_name="ID", - ), - ), - ( - "translation_key", - models.UUIDField(default=uuid.uuid4, editable=False), - ), - ("name", models.CharField(max_length=255)), - ( - "locale", - models.ForeignKey( - editable=False, - on_delete=django.db.models.deletion.PROTECT, - related_name="+", - to="wagtailcore.locale", + to="wagtailcore.page", ), ), ], options={ "abstract": False, - "unique_together": {("translation_key", "locale")}, }, + bases=("wagtailcore.page",), ), migrations.CreateModel( name="BlogPostPage", @@ -180,7 +181,7 @@ class Migration(migrations.Migration): ), ), ("raw", wagtail.blocks.RawHTMLBlock()), - ("blockquoteblock", wagtail.blocks.BlockQuoteBlock()), + ("block_quote", wagtail.blocks.BlockQuoteBlock()), ("page", wagtail.blocks.PageChooserBlock()), ( "document", @@ -196,12 +197,14 @@ class Migration(migrations.Migration): tests.testapp.models.BlogCategory ), ), - ] + ], + use_json_field=True, ), ), ( "category", models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="blog_posts", @@ -211,6 +214,7 @@ class Migration(migrations.Migration): ( "image", models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="wagtailimages.image", diff --git a/tests/testapp/models.py b/tests/testapp/models.py index a172c5c..d87b80f 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -36,8 +36,18 @@ class BlogCategory(TranslatableMixin): def __str__(self): return self.name + class Meta(TranslatableMixin.Meta): + verbose_name_plural = "Blog Categories" + class BlogPostPage(Page): + category = models.ForeignKey( + BlogCategory, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="blog_posts", + ) intro = RichTextField(blank=True) publication_date = models.DateField(null=True, blank=True) image = models.ForeignKey( @@ -68,11 +78,12 @@ class BlogPostPage(Page): [("paragraph", blocks.CharBlock()), ("image", ImageChooserBlock())] ), ), - # Not sure why anyone would want to nest StructBlocks, but it is possible. - # ("struct_nested", blocks.StructBlock([("struct", blocks.StructBlock([("paragraph", blocks.CharBlock()), ("image", ImageChooserBlock())]))])), + # TODO: Add support for nested StructBlocks. + # Not sure why anyone would want to nest StructBlocks, but it is possible. + # ("struct_nested", blocks.StructBlock([("struct", blocks.StructBlock([("paragraph", blocks.CharBlock()), ("image", ImageChooserBlock())]))])), ("image_struct", ImageBlock()), ("raw", blocks.RawHTMLBlock()), - ("blockquoteblock", blocks.BlockQuoteBlock()), + ("block_quote", blocks.BlockQuoteBlock()), ("page", blocks.PageChooserBlock()), ("document", DocumentChooserBlock()), ("image_chooser", ImageChooserBlock()), @@ -80,18 +91,11 @@ class BlogPostPage(Page): ], use_json_field=True, ) - category = models.ForeignKey( - BlogCategory, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="blog_posts", - ) content_panels = Page.content_panels + [ + FieldPanel("category"), FieldPanel("intro"), FieldPanel("publication_date"), FieldPanel("image"), FieldPanel("body"), - FieldPanel("category"), ] From fb252134908429c05d96171bb7ff2b46f3e700f6 Mon Sep 17 00:00:00 2001 From: Coen van der Kamp Date: Wed, 21 Aug 2024 16:18:37 +0200 Subject: [PATCH 05/10] Use .env file to store secrets, move WT settings to the bottom --- .env-example | 6 ++++++ .gitignore | 3 ++- pyproject.toml | 2 ++ tests/testproject/settings.py | 16 +++++++++++++--- 4 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 .env-example diff --git a/.env-example b/.env-example new file mode 100644 index 0000000..c60c7bd --- /dev/null +++ b/.env-example @@ -0,0 +1,6 @@ +# https://pypi.org/project/python-dotenv/ + +# Rename this file to .env and fill in the values. +# The .env is not onder version control. Keeping your secrets safe. + +WAGTAIL_TRANSLATE_DEEPL_KEY= diff --git a/.gitignore b/.gitignore index 38817b7..a692d92 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ __pycache__/ /.tox /.venv /venv +/env +/.env /.vscode /site /test_wagtail_translate.db @@ -15,4 +17,3 @@ __pycache__/ /test-static /test-media /.python-version -/env/ diff --git a/pyproject.toml b/pyproject.toml index 002d2ca..f54c58c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,9 +33,11 @@ dependencies = [ "Django>=4.2", "Wagtail>=5.2" ] + [project.optional-dependencies] testing = [ "dj-database-url==2.1.0", + "python-dotenv", "pre-commit==3.4.0", "pytest==8.1.1", "pytest-cov==5.0.0", diff --git a/tests/testproject/settings.py b/tests/testproject/settings.py index 30b2685..9b274ab 100644 --- a/tests/testproject/settings.py +++ b/tests/testproject/settings.py @@ -12,6 +12,12 @@ import dj_database_url +from dotenv import load_dotenv + + +# Load ENV variables from ~/.env +load_dotenv() + # Build paths inside the project like this: os.path.join(PROJECT_DIR, ...) PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -63,8 +69,6 @@ "django.contrib.sitemaps", ] -WAGTAIL_TRANSLATE_TRANSLATOR = "wagtail_translate.translators.rot13.ROT13Translator" - MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", @@ -174,6 +178,12 @@ # Wagtail settings - WAGTAIL_SITE_NAME = "Wagtail Translate test site" WAGTAILADMIN_BASE_URL = "http://127.0.0.1:8000" + +# Wagtail Translate +WAGTAIL_TRANSLATE_TRANSLATOR = "wagtail_translate.translators.rot13.ROT13Translator" + +# Or with DeepL: +# WAGTAIL_TRANSLATE_TRANSLATOR = "wagtail_translate.translators.deepl.DeepLTranslator" +# WAGTAIL_TRANSLATE_DEEPL_KEY = os.environ.get("WAGTAIL_TRANSLATE_DEEPL_KEY", "") From 098a56189f89f956f46af48899b4b12f14db4166 Mon Sep 17 00:00:00 2001 From: Coen van der Kamp Date: Wed, 21 Aug 2024 16:19:36 +0200 Subject: [PATCH 06/10] Assume https on URL fields --- tests/testproject/settings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/testproject/settings.py b/tests/testproject/settings.py index 9b274ab..5b9df9a 100644 --- a/tests/testproject/settings.py +++ b/tests/testproject/settings.py @@ -176,6 +176,8 @@ MEDIA_ROOT = os.path.join(BASE_DIR, "test-media") +# Django transitional setting +FORMS_URLFIELD_ASSUME_HTTPS = True # Wagtail settings WAGTAIL_SITE_NAME = "Wagtail Translate test site" From 9f986e6a91b9562b8c13c74a8d1ea744a7566a0d Mon Sep 17 00:00:00 2001 From: Coen van der Kamp Date: Wed, 21 Aug 2024 16:20:27 +0200 Subject: [PATCH 07/10] Serve media in development --- tests/testproject/urls.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/testproject/urls.py b/tests/testproject/urls.py index f56286e..262701c 100644 --- a/tests/testproject/urls.py +++ b/tests/testproject/urls.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.conf.urls.i18n import i18n_patterns from django.contrib import admin from django.urls import include, path @@ -12,6 +13,12 @@ path("documents/", include(wagtaildocs_urls)), ] +if settings.DEBUG: + from django.conf import settings + from django.conf.urls.static import static + + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + urlpatterns += i18n_patterns( path("", include(wagtail_urls)), ) From 1928c0bd56e0592e471cea5fda5e300ed9960f65 Mon Sep 17 00:00:00 2001 From: Coen van der Kamp Date: Wed, 21 Aug 2024 16:22:20 +0200 Subject: [PATCH 08/10] Add explanation docs --- README.md | 9 ++- docs/customise_wagtail_translate.md | 2 +- docs/explanation.md | 87 +++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 docs/explanation.md diff --git a/README.md b/README.md index 0dc06df..f8d6528 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,13 @@ # Wagtail Translate -**Wagtail Translate** adds machine translations to your Wagtail site, with built-in support for [DeepL](https://www.deepl.com) and the flexibility to integrate other translation services. It automatically detects when a page is copied to a new locale and initiates the translation process. +**Wagtail Translate** adds machine translations to your Wagtail site, with built-in support for [DeepL](https://www.deepl.com) and the flexibility to integrate other translation services. +It automatically detects when a page is copied to a new locale and initiates the translation process. -For multilingual websites, the recommended packages are [Wagtail Localize](https://wagtail-localize.org/) or [Wagtail Simple Translation](https://docs.wagtail.org/en/stable/reference/contrib/simple_translation.html). Wagtail Localize offers advanced features that may be excessive for many projects, while Wagtail Simple Translation only copies pages to new locales, requiring manual translation. +[Wagtail Localize](https://wagtail-localize.org/) and [Wagtail Simple Translation](https://docs.wagtail.org/en/stable/reference/contrib/simple_translation.html) are the go-to solutions for multi-language Wagtail projects. +Wagtail Localize offers advanced features that may be excessive for many projects, while Wagtail Simple Translation only copies pages to new locales, requiring manual translation. -**Wagtail Translate** adds machine translations to Wagtail and works in combination with Simple Translation, offering the ideal solution for projects seeking a simple interface with powerful translation support. +**Wagtail Translate** adds machine translations to Wagtail. +It works in combination with Simple Translation, offering the ideal solution for projects seeking a simple interface with powerful translation support. [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) [![PyPI version](https://badge.fury.io/py/wagtail-translate.svg)](https://badge.fury.io/py/wagtail-translate) diff --git a/docs/customise_wagtail_translate.md b/docs/customise_wagtail_translate.md index e7ce100..b320e4b 100644 --- a/docs/customise_wagtail_translate.md +++ b/docs/customise_wagtail_translate.md @@ -1,4 +1,4 @@ -## Customise Wagtail Translate +## How to customise Wagtail Translate Multi-language projects often have specific requirements for the translation process. This page provides examples of how to customize Wagtail Translate to fit your project's needs. diff --git a/docs/explanation.md b/docs/explanation.md new file mode 100644 index 0000000..1f8e7b8 --- /dev/null +++ b/docs/explanation.md @@ -0,0 +1,87 @@ +# Explanation + +This document outlines the concepts behind Wagtail Translate and explains the motivations for its design choices. + +## Idea and positioning + +[Wagtail Localize](https://wagtail-localize.org/) and [Wagtail Simple Translation](https://docs.wagtail.org/en/stable/reference/contrib/simple_translation.html) are the go-to solutions for multilingual Wagtail projects. +Wagtail Localize offers advanced features that may be excessive for many projects, while Wagtail Simple Translation only copies pages to new locales, requiring manual translation. + +**Wagtail Translate** adds machine translations to Wagtail. It works with Simple Translation, providing an ideal solution for projects seeking a straightforward interface with robust translation support. + +Your content editors will appreciate the simplicity of Wagtail Translate. đŸ¥³ + +## Features + +- Automatic translation +- Integration with DeepL +- Highly customizable + +## Architecture + +Wagtail Translate operates at the model level and does not include a user interface. While using Wagtail Translate in combination with Wagtail Simple Translation is the obvious choice, it is not a strict requirement. + +Wagtail Translate listens for Wagtail's `copy_for_translation_done` signal to initiate the machine translation. +Wagtail Translate is indifferent on who sends the signal. This could be Simple Translation, other products, or custom code. + +## Customizing default behaviors + +Wagtail Translate is designed to be flexible, allowing you to tailor its behavior to your needs. + +- Provide your own translation service +- Define your own signal handler +- Customize the translation process + +See the [Customization](customization.md) document for more information. + +## Background workers + +Wagtail Translate uses external services like DeepL to provide translations. These services can take time, fail to respond, or be offline. + +By default, Wagtail Translate handles translations synchronously, meaning they block the current thread, and the user must wait for all translations to complete. This can negatively impact performance and user experience. + +Django's [background workers](https://www.djangoproject.com/weblog/2024/may/29/django-enhancement-proposal-14-background-workers/) are an accepted proposal and are in development. Once available, they will offer a unified way to offload tasks. Wagtail Translate is expected to support them. For now, if you need translations offloaded to a background task, you can customize the default behaviors and implement this yourself. + +See the [Customization](customization.md) document for more information. + +## Fields to translate + +Wagtail Translate introspects the model definition to identify the fields that need translation. You can control which fields are translated by implementing a `get_translatable_fields` method on your model. + +## HTML + +Wagtail has rich text and raw fields, which may contain HTML. Translating HTML is challenging because the structure (tags and attributes) must remain unchanged, while the text between tags should be translated. + +Wagtail Translate deconstructs HTML and translates the text parts one by one, then reconstructs the HTML with the translated text. However, this process can lead to a lack of context for the translation service. For example: + +```html +

The black cat

+``` +Translating word-for-word into French yields: +```html +

Le noir chat

+``` +But it should be translated as: +```html +

Le chat noir

+``` + +Word order can vary across languages, and words may have different meanings depending on the context. For instance, the word "bow" can refer to a weapon for shooting arrows or a gesture of respect. In French, these are translated as "arc" and "révérence," respectively. If translation service has too little context, there is a risk of incorrect translation. + +Wagtail Translate preserves the HTML structure to prevent broken HTML, as translating tags and attributes could lead to invalid markup. + +In practice, most HTML translates well, but if you encounter odd translations, this may be the cause. A workaround might be removing the styling from the text before translation and re-apply the styling after. + +You can alter the HTML translation behavior by providing a custom translation class and overriding the `BaseTranslator.translate_html` method. + +## Translation of related objects + +For related objects (foreign keys) Wagtail Translate checks if the object is translatable, and if the translations exist. If the translation exists, it is used; otherwise, the original object is used. + +This approach prevents deep traversal as related objects can have related objects of their own. It also accommodates intentional cross-language references. For example, a multilingual site might have blogs exclusively in English, requiring links to the English posts regardless of the referencing page's locale. + +Since Wagtail Translate can't forsee the intended behaviour, it uses the simplest approach: use if the translation exists, otherwise use the original object. This means that sometimes the content editor needs to step in and translate the related object, and select that related object. + +To adjust this behaviour, override `BaseTranslator.translate_related_object`. + +See the [Customization](customization.md) document for more information. From 12a55360318511541c026362ff7cfe840abbd776 Mon Sep 17 00:00:00 2001 From: Coen van der Kamp Date: Wed, 21 Aug 2024 16:24:15 +0200 Subject: [PATCH 09/10] Add simple blog_post_page template --- tests/testapp/templates/testapp/blog_post_page.html | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 tests/testapp/templates/testapp/blog_post_page.html diff --git a/tests/testapp/templates/testapp/blog_post_page.html b/tests/testapp/templates/testapp/blog_post_page.html new file mode 100644 index 0000000..d33dc31 --- /dev/null +++ b/tests/testapp/templates/testapp/blog_post_page.html @@ -0,0 +1,7 @@ +{% load wagtailcore_tags wagtailimages_tags %} + +

{{page.title}}

+

{{ page.publication_date }} - {{ page.category }}

+{% image page.image fill-320x240 %} +

{{ page.intro|richtext }}

+{% include_block page.body %} From c99d6da18de1be4a9f26f7fee646dcdace5cbca6 Mon Sep 17 00:00:00 2001 From: Coen van der Kamp Date: Thu, 22 Aug 2024 09:06:27 +0200 Subject: [PATCH 10/10] Add link from readme to explanation --- README.md | 6 ++++-- docs/explanation.md | 8 +------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f8d6528..cb9c45b 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,11 @@ Install and configure it as follows: - Add `WAGTAIL_TRANSLATE_DEEPL_KEY = "..."` to your settings. - Set `WAGTAIL_TRANSLATE_TRANSLATOR = "wagtail_translate.translators.deepl.DeepLTranslator"` -## Customise Wagtail Translate +## Documentation -See: [docs/customise_wagtail_translate.md](https://github.com/allcaps/wagtail-translate/tree/main/docs/customise_wagtail_translate.md). +- This readme for installation and basic usage. +- [How to customise Wagtail Translate](https://github.com/allcaps/wagtail-translate/tree/main/docs/customise_wagtail_translate.md) +- [Explanation](https://github.com/allcaps/wagtail-translate/tree/main/docs/explanation.md) ## Contributing diff --git a/docs/explanation.md b/docs/explanation.md index 1f8e7b8..272e657 100644 --- a/docs/explanation.md +++ b/docs/explanation.md @@ -1,6 +1,6 @@ # Explanation -This document outlines the concepts behind Wagtail Translate and explains the motivations for its design choices. +This document details the concepts underlying Wagtail Translate and explains the reasoning behind its design choices. It aims to enhance and expand your understanding of Wagtail Translation. ## Idea and positioning @@ -32,8 +32,6 @@ Wagtail Translate is designed to be flexible, allowing you to tailor its behavio - Define your own signal handler - Customize the translation process -See the [Customization](customization.md) document for more information. - ## Background workers Wagtail Translate uses external services like DeepL to provide translations. These services can take time, fail to respond, or be offline. @@ -42,8 +40,6 @@ By default, Wagtail Translate handles translations synchronously, meaning they b Django's [background workers](https://www.djangoproject.com/weblog/2024/may/29/django-enhancement-proposal-14-background-workers/) are an accepted proposal and are in development. Once available, they will offer a unified way to offload tasks. Wagtail Translate is expected to support them. For now, if you need translations offloaded to a background task, you can customize the default behaviors and implement this yourself. -See the [Customization](customization.md) document for more information. - ## Fields to translate Wagtail Translate introspects the model definition to identify the fields that need translation. You can control which fields are translated by implementing a `get_translatable_fields` method on your model. @@ -83,5 +79,3 @@ This approach prevents deep traversal as related objects can have related object Since Wagtail Translate can't forsee the intended behaviour, it uses the simplest approach: use if the translation exists, otherwise use the original object. This means that sometimes the content editor needs to step in and translate the related object, and select that related object. To adjust this behaviour, override `BaseTranslator.translate_related_object`. - -See the [Customization](customization.md) document for more information.