Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion src/wagtail_localize_smartling/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optimisation nit, nonblocking: could this be done more efficiently with a set.difference, I'm wondering

Comment on lines +373 to +380
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This filtering/logging uses t.target_locale.language_code, which will trigger an extra DB query per Translation if translations is a queryset without select_related('target_locale') (e.g. job.translations.all() in the resubmit view). Consider ensuring locales are fetched in bulk (e.g. select_related when possible) or restructuring to use target_locale_id plus a single Locale lookup, to avoid N+1 queries when excluding locales.

Suggested change
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]
# Bulk-load locales to avoid N+1 queries on t.target_locale
target_locale_ids = {
t.target_locale_id for t in translations_list if getattr(t, "target_locale_id", None)
}
locales_by_id = Locale.objects.in_bulk(target_locale_ids) if target_locale_ids else {}
def _is_excluded(translation: Translation) -> bool:
locale = locales_by_id.get(getattr(translation, "target_locale_id", None))
return bool(locale and locale.language_code in excluded)
excluded_translations = [t for t in translations_list if _is_excluded(t)]
if excluded_translations:
excluded_language_codes = sorted(
{
locales_by_id[t.target_locale_id].language_code
for t in excluded_translations
if getattr(t, "target_locale_id", None) in locales_by_id
}
)
logger.info(
"Excluding %d translation(s) for locales: %s",
len(excluded_translations),
", ".join(excluded_language_codes),
)
translations_list = [t for t in translations_list if not _is_excluded(t)]

Copilot uses AI. Check for mistakes.
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
Expand Down
22 changes: 22 additions & 0 deletions src/wagtail_localize_smartling/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Comment on lines +199 to +200
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message here says EXCLUDE_LOCALES must be a "list, tuple, or set", but the code also accepts a frozenset. Update the message to match the accepted types (and consider saying it must be an iterable of locale code strings).

Suggested change
f"{setting_name}['EXCLUDE_LOCALES'] must be a list, tuple, or set "
f"of locale code strings"
f"{setting_name}['EXCLUDE_LOCALES'] must be an iterable "
f"(list, tuple, set, or frozenset) of locale code strings"

Copilot uses AI. Check for mistakes.
)
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)
Comment on lines +202 to +214
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

invalid_codes = set(exclude) - valid_locale_codes will raise a TypeError if EXCLUDE_LOCALES contains any unhashable items (e.g. a nested list) and currently doesn't validate that each entry is a string. Consider explicitly validating each element is a str (and rejecting non-hashable values) so misconfiguration consistently raises ImproperlyConfigured with a clear message instead of crashing during settings init.

Suggested change
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)
# Validate that each entry is a string so we can safely use it in a set
exclude_codes: list[str] = []
for value in exclude:
if not isinstance(value, str):
raise ImproperlyConfigured(
f"{setting_name}['EXCLUDE_LOCALES'] must contain only strings; "
f"got {value!r} (type {type(value).__name__})"
)
exclude_codes.append(value)
valid_locale_codes = {
code
for code, _ in getattr(
django_settings, "WAGTAIL_CONTENT_LANGUAGES", []
)
}
invalid_codes = set(exclude_codes) - 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_codes)

Copilot uses AI. Check for mistakes.

return SmartlingSettings(**settings_kwargs)


Expand Down
113 changes: 113 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
65 changes: 65 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading