+
+
+ Locale is available if target directory is present.
+
+
+
+
+ Resource is available if target file is present in locale directory.
+
+
+
{{ admin_team_selector.render(locales_available, locales_selected, locales_readonly) }}
{{ form.locales }}
{{ form.locales.errors }}
diff --git a/pontoon/administration/views.py b/pontoon/administration/views.py
index 62756ef97e..d673bb4904 100644
--- a/pontoon/administration/views.py
+++ b/pontoon/administration/views.py
@@ -1,6 +1,8 @@
import csv
import logging
+from typing import cast
+
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
@@ -33,6 +35,8 @@
from pontoon.pretranslation.tasks import pretranslate_task
from pontoon.sync.tasks import sync_project_task
+from ..base.models.project import ProjectQuerySet
+
log = logging.getLogger(__name__)
@@ -127,6 +131,8 @@ def manage_project(request, slug=None, template="admin_project.html"):
subtitle = "Add project"
pk = None
project = None
+ prev_config_file = None
+ prev_set_locales_from_repo = False
# Save project
if request.method == "POST":
@@ -143,7 +149,11 @@ def manage_project(request, slug=None, template="admin_project.html"):
# Update existing project
try:
pk = request.POST["pk"]
- project = Project.objects.visible_for(request.user).get(pk=pk)
+ project = (
+ cast(ProjectQuerySet, Project.objects)
+ .visible_for(request.user)
+ .get(pk=pk)
+ )
form = ProjectForm(request.POST, instance=project)
# Needed if form invalid
repo_formset = RepositoryInlineFormSet(request.POST, instance=project)
@@ -156,6 +166,8 @@ def manage_project(request, slug=None, template="admin_project.html"):
request.POST, instance=project
)
subtitle = "Edit project"
+ prev_config_file = project.configuration_file
+ prev_set_locales_from_repo = project.set_locales_from_repo
# Add a new project
except MultiValueDictKeyError:
@@ -166,7 +178,7 @@ def manage_project(request, slug=None, template="admin_project.html"):
tag_formset = None
if form.is_valid():
- project = form.save(commit=False)
+ project = cast(Project, form.save(commit=False))
repo_formset = RepositoryInlineFormSet(request.POST, instance=project)
external_resource_formset = ExternalResourceInlineFormSet(
request.POST, instance=project
@@ -180,38 +192,43 @@ def manage_project(request, slug=None, template="admin_project.html"):
)
if formsets_valid:
project.save()
-
- # Manually save ProjectLocales due to intermediary model
- locales_form = form.cleaned_data.get("locales", [])
- locales_readonly_form = form.cleaned_data.get("locales_readonly", [])
- locales = locales_form | locales_readonly_form
-
- (
- ProjectLocale.objects.filter(project=project)
- .exclude(locale__pk__in=[loc.pk for loc in locales])
- .delete()
- )
-
- for locale in locales:
- # The implicit pre_save and post_save signals sent here are required
- # to maintain django-guardian permissions.
- ProjectLocale.objects.get_or_create(project=project, locale=locale)
-
- project_locales = ProjectLocale.objects.filter(project=project)
-
- # Update readonly flags
- locales_readonly_pks = [loc.pk for loc in locales_readonly_form]
- project_locales.filter(readonly=True).exclude(
- locale__pk__in=locales_readonly_pks
- ).update(readonly=False)
- project_locales.filter(
- locale__pk__in=locales_readonly_pks, readonly=False
- ).update(readonly=True)
+ pk = project.pk
+ data = form.cleaned_data
+
+ set_locales_from_repo = data.get("set_locales_from_repo", False)
+
+ if set_locales_from_repo:
+ project_locales = ProjectLocale.objects.filter(project=project)
+ else:
+ # Manually save ProjectLocales due to intermediary model
+ locales_form = data.get("locales", set())
+ locales_readonly_form = data.get("locales_readonly", set())
+ locales = locales_form | locales_readonly_form
+
+ ProjectLocale.objects.filter(project=project).exclude(
+ locale__pk__in=[loc.pk for loc in locales]
+ ).delete()
+
+ for locale in locales:
+ # The implicit pre_save and post_save signals sent here are required
+ # to maintain django-guardian permissions.
+ ProjectLocale.objects.get_or_create(
+ project=project, locale=locale
+ )
+
+ project_locales = ProjectLocale.objects.filter(project=project)
+
+ # Update readonly flags
+ locales_readonly_pks = [loc.pk for loc in locales_readonly_form]
+ project_locales.filter(readonly=True).exclude(
+ locale__pk__in=locales_readonly_pks
+ ).update(readonly=False)
+ project_locales.filter(
+ locale__pk__in=locales_readonly_pks, readonly=False
+ ).update(readonly=True)
# Update pretranslate flags
- locales_pretranslate_form = form.cleaned_data.get(
- "locales_pretranslate", []
- )
+ locales_pretranslate_form = data.get("locales_pretranslate", [])
locales_pretranslate_pks = [loc.pk for loc in locales_pretranslate_form]
project_locales.filter(pretranslation_enabled=True).exclude(
locale__pk__in=locales_pretranslate_pks,
@@ -239,7 +256,14 @@ def manage_project(request, slug=None, template="admin_project.html"):
if project.tags_enabled:
tag_formset = TagInlineFormSet(instance=project)
subtitle += ". Saved."
- pk = project.pk
+
+ if project.data_source == Project.DataSource.REPOSITORY and (
+ set_locales_from_repo
+ and not prev_set_locales_from_repo
+ or data.get("configuration_file") != prev_config_file
+ ):
+ sync_project_task.delay(project.pk)
+ subtitle += " Sync started."
else:
subtitle += ". Error."
else:
diff --git a/pontoon/base/migrations/0119_project_set_locales_from_repo_and_more.py b/pontoon/base/migrations/0119_project_set_locales_from_repo_and_more.py
new file mode 100644
index 0000000000..b25855c61c
--- /dev/null
+++ b/pontoon/base/migrations/0119_project_set_locales_from_repo_and_more.py
@@ -0,0 +1,28 @@
+# Generated by Django 5.2.14 on 2026-06-24 09:50
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("base", "0118_fix_terminology_entity_value"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="project",
+ name="set_locales_from_repo",
+ field=models.BooleanField(
+ default=False,
+ help_text="\n Control target locales by their directories' presence in the target repository,\n or in the root locales config if `configuration_file` is not None.\n ",
+ ),
+ ),
+ migrations.AddField(
+ model_name="project",
+ name="set_translated_resources_from_repo",
+ field=models.BooleanField(
+ default=False,
+ help_text="\n If `set_locales_from_repo` is True and `configuration_file` is None,\n setting `set_translated_resources_from_repo` to True will\n control the availability of each resource for translation in each locale\n according to the presence of a file (even if empty) at its expected target path.\n\n If `set_locales_from_repo` is False or `configuration_file` is not None,\n this control has no effect.\n ",
+ ),
+ ),
+ ]
diff --git a/pontoon/base/models/project.py b/pontoon/base/models/project.py
index 574ce8b77e..62f6af023e 100644
--- a/pontoon/base/models/project.py
+++ b/pontoon/base/models/project.py
@@ -237,6 +237,27 @@ class Visibility(models.TextChoices):
""",
)
+ set_locales_from_repo = models.BooleanField(
+ default=False,
+ help_text="""
+ Control target locales by their directories' presence in the target repository,
+ or in the root locales config if `configuration_file` is not None.
+ """,
+ )
+
+ set_translated_resources_from_repo = models.BooleanField(
+ default=False,
+ help_text="""
+ If `set_locales_from_repo` is True and `configuration_file` is None,
+ setting `set_translated_resources_from_repo` to True will
+ control the availability of each resource for translation in each locale
+ according to the presence of a file (even if empty) at its expected target path.
+
+ If `set_locales_from_repo` is False or `configuration_file` is not None,
+ this control has no effect.
+ """,
+ )
+
objects = ProjectQuerySet.as_manager()
project_locale: "ProjectLocaleQuerySet"
diff --git a/pontoon/sync/core/__init__.py b/pontoon/sync/core/__init__.py
index 84b30101f9..4506d3f49d 100644
--- a/pontoon/sync/core/__init__.py
+++ b/pontoon/sync/core/__init__.py
@@ -11,6 +11,7 @@
Entity,
Locale,
Project,
+ ProjectLocale,
TranslatedResource,
Translation,
User,
@@ -50,10 +51,30 @@ def sync_project(
log.error(f"{log_prefix} {e}")
raise e
- locale_map: dict[str, Locale] = {
- lc.code: lc for lc in project.locales.order_by("code")
- }
+ locale_map: dict[str, Locale]
+ if project.set_locales_from_repo:
+ if not paths.locales:
+ errmsg = "No locales found in repo"
+ log.error(f"{log_prefix} {errmsg}")
+ raise Exception(errmsg)
+ locales = Locale.objects.filter(code__in=paths.locales).order_by("code")
+ locale_map = {lc.code: lc for lc in locales}
+
+ # Update project locales
+ ProjectLocale.objects.filter(project=project).exclude(
+ locale__in=locales
+ ).delete()
+ for locale in locales:
+ # The implicit pre_save and post_save signals sent here are required
+ # to maintain django-guardian permissions.
+ ProjectLocale.objects.get_or_create(project=project, locale=locale)
+ ProjectLocale.objects.filter(project=project, readonly=True).update(
+ readonly=False
+ )
+ else:
+ locale_map = {lc.code: lc for lc in project.locales.order_by("code")}
paths.locales = list(locale_map.keys())
+
added_entities_count, changed_paths, removed_paths = sync_resources_from_repo(
project, locale_map, checkouts.source, paths, now
)
diff --git a/pontoon/sync/core/entities.py b/pontoon/sync/core/entities.py
index f92df4cf09..d2957ba551 100644
--- a/pontoon/sync/core/entities.py
+++ b/pontoon/sync/core/entities.py
@@ -375,7 +375,7 @@ def update_translated_resources(
_, locales = paths.target(resource.path)
for lc in locales:
locale = locale_map.get(lc, None)
- if is_translated_resource(paths, resource, locale):
+ if is_translated_resource(project, paths, resource, locale):
assert locale is not None
key = (resource.pk, locale.pk)
if key in prev_tr_keys:
@@ -403,6 +403,7 @@ def update_translated_resources(
def is_translated_resource(
+ project: Project,
paths: L10nConfigPaths | L10nDiscoverPaths,
resource: Resource,
locale: Locale | None,
@@ -410,9 +411,13 @@ def is_translated_resource(
if locale is None:
return False
- if resource.format == Resource.Format.GETTEXT:
- # For gettext, only create TranslatedResource
- # if the resource exists for the locale.
+ if (
+ isinstance(paths, L10nDiscoverPaths)
+ and project.set_locales_from_repo
+ and project.set_translated_resources_from_repo
+ ):
+ # With `set_translated_resources_from_repo`,
+ # only create TranslatedResource if the resource exists for the locale.
target, _ = paths.target(resource.path)
if target is None:
return False
diff --git a/pontoon/sync/core/translations_from_repo.py b/pontoon/sync/core/translations_from_repo.py
index a785ebb122..760da10413 100644
--- a/pontoon/sync/core/translations_from_repo.py
+++ b/pontoon/sync/core/translations_from_repo.py
@@ -53,7 +53,15 @@ def sync_translations_from_repo(
"""(removed_resource_count, updated_translation_count)"""
co = checkouts.target
source_paths: set[str] = set(paths.ref_paths) if checkouts.source == co else set()
- del_count = delete_removed_gettext_resources(project, co, paths, source_paths)
+ del_count = (
+ delete_removed_resources(project, co, paths, source_paths)
+ if (
+ isinstance(paths, L10nDiscoverPaths)
+ and project.set_locales_from_repo
+ and project.set_translated_resources_from_repo
+ )
+ else 0
+ )
changed_target_paths = [
path
@@ -83,10 +91,10 @@ def write_db_updates(
add_translation_memory_entries(project, new_translations + updated_translations)
-def delete_removed_gettext_resources(
+def delete_removed_resources(
project: Project,
target: Checkout,
- paths: L10nConfigPaths | L10nDiscoverPaths,
+ paths: L10nDiscoverPaths,
source_paths: set[str],
) -> int:
rm_t = Q()
@@ -95,7 +103,7 @@ def delete_removed_gettext_resources(
removed_target_paths = (
path
for path in (join(target.path, co_path) for co_path in target.removed)
- if path not in source_paths and splitext(path)[1] in {".po", ".pot"}
+ if path not in source_paths
)
for target_path in removed_target_paths:
ref = paths.find_reference(target_path)
@@ -104,7 +112,7 @@ def delete_removed_gettext_resources(
locale_code = get_path_locale(path_vars)
if locale_code is not None:
db_path = relpath(ref_path, paths.ref_root)
- if not project.configuration_file and db_path.endswith(".pot"):
+ if db_path.endswith(".pot"):
db_path = db_path[:-1]
rm_t |= Q(entity__resource__path=db_path, locale__code=locale_code)
rm_tr |= Q(resource__path=db_path, locale__code=locale_code)
diff --git a/pontoon/sync/tests/test_e2e.py b/pontoon/sync/tests/test_e2e.py
index 0327a045ef..4d8e4523bf 100644
--- a/pontoon/sync/tests/test_e2e.py
+++ b/pontoon/sync/tests/test_e2e.py
@@ -17,6 +17,7 @@
ChangedEntityLocale,
Entity,
Locale,
+ ProjectLocale,
Repository,
TranslatedResource,
Translation,
@@ -242,7 +243,11 @@ def test_add_resources():
assert {
(tr.resource.path, tr.locale.code)
for tr in TranslatedResource.objects.filter(resource__project=project)
- } == {("file.ftl", "de-Test"), ("file.xliff", "de-Test")}
+ } == {
+ ("file.ftl", "de-Test"),
+ ("file.po", "de-Test"),
+ ("file.xliff", "de-Test"),
+ }
# Add an XLIFF translation
TranslationFactory.create(
@@ -848,3 +853,82 @@ def test_add_project_locale():
tr.locale.code: tr.total_strings
for tr in TranslatedResource.objects.filter(resource__project=project)
} == {"fr-Test": 1, "de-Test": 1}
+
+
+@pytest.mark.django_db
+def test_locales_from_repo():
+ mock_vcs = MockVersionControl(changed=[])
+ with mock_setup(mock_vcs) as (repo, _):
+ LocaleFactory.create(code="fr-Test", name="Test French")
+
+ # Database setup
+ project = ProjectFactory.create(
+ name="test-repo-locales",
+ locales=[],
+ repositories=[repo],
+ set_locales_from_repo=True,
+ system_project=False,
+ )
+
+ # Filesystem setup
+ makedirs(repo.checkout_path)
+ build_file_tree(
+ repo.checkout_path,
+ {
+ "en-US": {"messages.json": '{ "key": { "message": "Entity" } }'},
+ "de-Test": {"messages.json": "{}"},
+ "fr-Test": {"messages.json": "{}"},
+ },
+ )
+
+ sync_project_task(project.pk)
+
+ assert {
+ pl.locale.code for pl in ProjectLocale.objects.filter(project=project)
+ } == {"de-Test", "fr-Test"}
+ assert {
+ tr.locale.code: tr.total_strings
+ for tr in TranslatedResource.objects.filter(resource__project=project)
+ } == {"fr-Test": 1, "de-Test": 1}
+
+
+@pytest.mark.django_db
+def test_locales_from_config():
+ mock_vcs = MockVersionControl(changed=[])
+ with mock_setup(mock_vcs) as (repo, _):
+ LocaleFactory.create(code="fr-Test", name="Test French")
+
+ # Database setup
+ project = ProjectFactory.create(
+ name="test-config-locales",
+ configuration_file="l10n.toml",
+ locales=[],
+ repositories=[repo],
+ set_locales_from_repo=True,
+ system_project=False,
+ )
+
+ # Filesystem setup
+ makedirs(repo.checkout_path)
+ build_file_tree(
+ repo.checkout_path,
+ {
+ "foo": {"en": {"messages.json": '{ "key": { "message": "Entity" } }'}},
+ "l10n.toml": dedent("""\
+ locales = ["de-Test", "fr-Test"]
+ [[paths]]
+ reference = "foo/en/**"
+ l10n = "foo/{locale}/**"
+ """),
+ },
+ )
+
+ sync_project_task(project.pk)
+
+ assert {
+ pl.locale.code for pl in ProjectLocale.objects.filter(project=project)
+ } == {"de-Test", "fr-Test"}
+ assert {
+ tr.locale.code: tr.total_strings
+ for tr in TranslatedResource.objects.filter(resource__project=project)
+ } == {"fr-Test": 1, "de-Test": 1}
diff --git a/pontoon/sync/tests/test_translations_from_repo.py b/pontoon/sync/tests/test_translations_from_repo.py
index bfc7ce6558..aa5bacd91e 100644
--- a/pontoon/sync/tests/test_translations_from_repo.py
+++ b/pontoon/sync/tests/test_translations_from_repo.py
@@ -358,6 +358,8 @@ def test_remove_po_target_resource():
name="test-rm-po",
locales=[locale],
repositories=[repo],
+ set_locales_from_repo=True,
+ set_translated_resources_from_repo=True,
visibility="public",
)
res = {}