diff --git a/README.md b/README.md index fb734a9..16380f7 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,25 @@ integrates with the Smartling translation platform. ---- + If your project has locales that should never be sent to Smartling — for + example, locales like `en-CA` or `en-GB` that don't exist in Smartling, and + use content from `en-US` — you can exclude them with the `EXCLUDE_LOCALES` + setting. Translations targeting excluded locales will be filtered out before + they enter the Smartling job pipeline, preventing API errors for locales + that aren't configured as target languages in your Smartling project. + + ```python + WAGTAIL_LOCALIZE_SMARTLING = { + # ... + "EXCLUDE_LOCALES": ["en-CA", "en-GB"], + } + ``` + + The locale codes must match entries in your `WAGTAIL_CONTENT_LANGUAGES` + setting. An error will be raised at startup if any codes are invalid. + + ---- + By default, when translations for completed Jobs are imported into Wagtail, the system will send notification emails to anyone in the `Translation approver` Group, and also add a task list of items to (re)publish. You can disable these via diff --git a/src/wagtail_localize_smartling/models.py b/src/wagtail_localize_smartling/models.py index e5e7391..d37e2b9 100644 --- a/src/wagtail_localize_smartling/models.py +++ b/src/wagtail_localize_smartling/models.py @@ -365,9 +365,24 @@ def get_or_create_from_source_and_translation_data( # TODO only submit locales that match Smartling target locales # TODO make sure the source locale matches the Smartling project's language + translations_list = list(translations) + + # Filter out translations for excluded locales + excluded = smartling_settings.EXCLUDE_LOCALES + if excluded: + excluded_translations = [t for t in translations_list if t.target_locale.language_code in excluded] + if excluded_translations: + logger.info( + "Excluding %d translation(s) for locales: %s", + len(excluded_translations), + ", ".join(t.target_locale.language_code for t in excluded_translations), + ) + translations_list = [t for t in translations_list if t.target_locale.language_code not in excluded] + if not translations_list: + return + project = Project.get_current() content_hash = compute_content_hash(translation_source.export_po()) - translations_list = list(translations) remaining_translations = {t.target_locale.pk: t for t in translations_list} # Find existing pending jobs for the same source content diff --git a/src/wagtail_localize_smartling/settings.py b/src/wagtail_localize_smartling/settings.py index 411d9a5..8183536 100644 --- a/src/wagtail_localize_smartling/settings.py +++ b/src/wagtail_localize_smartling/settings.py @@ -42,6 +42,7 @@ class SmartlingSettings: ADD_APPROVAL_TASK_TO_DASHBOARD: bool = True MAX_APPROVAL_TASKS_ON_DASHBOARD: int = 7 SEND_EMAIL_ON_TRANSLATION_IMPORT: bool = True + EXCLUDE_LOCALES: frozenset[str] = dataclasses.field(default_factory=frozenset) def _init_settings() -> SmartlingSettings: @@ -191,6 +192,27 @@ def _init_settings() -> SmartlingSettings: "SEND_EMAIL_ON_TRANSLATION_IMPORT" ] + if "EXCLUDE_LOCALES" in settings_dict: + exclude = settings_dict["EXCLUDE_LOCALES"] + if not isinstance(exclude, (list, tuple, set, frozenset)): + raise ImproperlyConfigured( + f"{setting_name}['EXCLUDE_LOCALES'] must be a list, tuple, or set " + f"of locale code strings" + ) + valid_locale_codes = { + code + for code, _ in getattr( + django_settings, "WAGTAIL_CONTENT_LANGUAGES", [] + ) + } + invalid_codes = set(exclude) - valid_locale_codes + if invalid_codes: + raise ImproperlyConfigured( + f"{setting_name}['EXCLUDE_LOCALES'] contains locale codes not in " + f"WAGTAIL_CONTENT_LANGUAGES: {sorted(invalid_codes)}" + ) + settings_kwargs["EXCLUDE_LOCALES"] = frozenset(exclude) + return SmartlingSettings(**settings_kwargs) diff --git a/tests/test_models.py b/tests/test_models.py index 679947f..ff4ae53 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -442,3 +442,116 @@ def test_get_or_create_no_action_when_all_locales_covered(smartling_project, roo # Should not have created a new job assert Job.objects.count() == 1 + + +# ============================================================================= +# EXCLUDE_LOCALES filtering tests +# ============================================================================= + + +def test_get_or_create_excludes_locales(smartling_project, smartling_settings, root_page): + """Translations for excluded locales are not added to Smartling jobs.""" + smartling_settings.EXCLUDE_LOCALES = frozenset(["de"]) + + user = UserFactory() + page = InfoPageFactory(parent=root_page, title="Test page") + translation_source, _ = TranslationSource.get_or_create_from_instance(page) + + locale_fr = Locale.objects.get(language_code="fr") + locale_de = Locale.objects.get(language_code="de") + + fr_translation = Translation.objects.create( + source=translation_source, + target_locale=locale_fr, + ) + de_translation = Translation.objects.create( + source=translation_source, + target_locale=locale_de, + ) + + Job.get_or_create_from_source_and_translation_data( + translation_source=translation_source, + translations=[fr_translation, de_translation], + user=user, + due_date=None, + ) + + assert Job.objects.count() == 1 + job = Job.objects.first() + assert set(job.translations.all()) == {fr_translation} + + +def test_get_or_create_all_excluded_no_job(smartling_project, smartling_settings, root_page): + """No job is created when all translations target excluded locales.""" + smartling_settings.EXCLUDE_LOCALES = frozenset(["fr", "de"]) + + user = UserFactory() + page = InfoPageFactory(parent=root_page, title="Test page") + translation_source, _ = TranslationSource.get_or_create_from_instance(page) + + fr_translation = Translation.objects.create( + source=translation_source, + target_locale=Locale.objects.get(language_code="fr"), + ) + de_translation = Translation.objects.create( + source=translation_source, + target_locale=Locale.objects.get(language_code="de"), + ) + + Job.get_or_create_from_source_and_translation_data( + translation_source=translation_source, + translations=[fr_translation, de_translation], + user=user, + due_date=None, + ) + + assert Job.objects.count() == 0 + + +def test_get_or_create_excluded_locale_not_added_to_existing_job(smartling_project, smartling_settings, root_page): + """Excluded locales are filtered out, and already-covered locales don't create a new job.""" + smartling_settings.EXCLUDE_LOCALES = frozenset(["de"]) + + user = UserFactory() + page = InfoPageFactory(parent=root_page, title="Test page") + translation_source, _ = TranslationSource.get_or_create_from_instance(page) + content_hash = compute_content_hash(translation_source.export_po()) + + locale_fr = Locale.objects.get(language_code="fr") + locale_de = Locale.objects.get(language_code="de") + + fr_translation = Translation.objects.create( + source=translation_source, + target_locale=locale_fr, + ) + + # Create an existing UNSYNCED job with fr + job = Job.objects.create( + project=smartling_project, + translation_source=translation_source, + user=user, + name="Test job", + description="Test", + reference_number="test", + content_hash=content_hash, + status=JobStatus.UNSYNCED, + ) + job.translations.set([fr_translation]) + + # Submit both fr (already covered) and de (excluded) + de_translation = Translation.objects.create( + source=translation_source, + target_locale=locale_de, + ) + + Job.get_or_create_from_source_and_translation_data( + translation_source=translation_source, + translations=[fr_translation, de_translation], + user=user, + due_date=None, + ) + + # No new job, existing job unchanged + assert Job.objects.count() == 1 + assert job.translations.count() == 1 + assert set(job.translations.all()) == {fr_translation} diff --git a/tests/test_settings.py b/tests/test_settings.py index ef286c7..b97c0fc 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -246,3 +246,68 @@ def test_max_approval_tasks_on_dashboard(): def test_send_email_on_translation_import(): smartling_settings = _init_settings() assert smartling_settings.SEND_EMAIL_ON_TRANSLATION_IMPORT is False + + +@override_settings( + WAGTAIL_LOCALIZE_SMARTLING={ + **REQUIRED_SETTINGS, + "EXCLUDE_LOCALES": ["fr", "de"], + } +) +def test_exclude_locales(): + smartling_settings = _init_settings() + assert smartling_settings.EXCLUDE_LOCALES == frozenset(["fr", "de"]) + + +@override_settings( + WAGTAIL_LOCALIZE_SMARTLING={ + **REQUIRED_SETTINGS, + "EXCLUDE_LOCALES": ["fr", "de", "fr"], + } +) +def test_exclude_locales_duplicate_valid_locales(): + """Having duplicate valid EXCLUDE_LOCALES entries is technically ok.""" + smartling_settings = _init_settings() + assert smartling_settings.EXCLUDE_LOCALES == frozenset(["fr", "de"]) + + +@override_settings(WAGTAIL_LOCALIZE_SMARTLING={**REQUIRED_SETTINGS}) +def test_exclude_locales_default(): + smartling_settings = _init_settings() + assert smartling_settings.EXCLUDE_LOCALES == frozenset() + + +@override_settings( + WAGTAIL_LOCALIZE_SMARTLING={ + **REQUIRED_SETTINGS, + "EXCLUDE_LOCALES": "not-a-list", + } +) +def test_exclude_locales_invalid_type(): + with pytest.raises(ImproperlyConfigured): + _init_settings() + + +@override_settings( + WAGTAIL_LOCALIZE_SMARTLING={ + **REQUIRED_SETTINGS, + "EXCLUDE_LOCALES": ["fr", "xx-YY"], # xx-YY is not a valid locale + } +) +def test_exclude_locales_invalid_locale_code(): + with pytest.raises(ImproperlyConfigured, match="WAGTAIL_CONTENT_LANGUAGES"): + _init_settings() + + +@override_settings( + WAGTAIL_LOCALIZE_SMARTLING={ + **REQUIRED_SETTINGS, + "EXCLUDE_LOCALES": [ + "fr", # valid + "xx-YY", # invalid + ], + } +) +def test_exclude_locales_valid_and_invalid_in_excluded_locales(): + with pytest.raises(ImproperlyConfigured, match="WAGTAIL_CONTENT_LANGUAGES"): + _init_settings()