Skip to content
Open
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
5 changes: 5 additions & 0 deletions pontoon/base/user_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,8 @@ def can_translate(user: User, project: "Project", locale: "Locale") -> bool:
return user.has_perm("base.can_translate_project_locale", project_locale)

return user.has_perm("base.can_translate_locale", locale)


def can_pretranslate(user: User) -> bool:
"""Check if user has suitable permissions to manually trigger batch pretranslation."""
return user.is_superuser or user.groups.filter(name="pretranslators").exists()
118 changes: 118 additions & 0 deletions pontoon/batch/actions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import logging

from django.db.models import QuerySet
from django.utils import timezone

Expand All @@ -12,9 +14,13 @@
from pontoon.base.models.translation import TranslationQuerySet
from pontoon.batch import utils
from pontoon.messaging.notifications import send_badge_notification
from pontoon.pretranslation.pretranslate import get_pretranslation
from pontoon.translations.utils import parse_source_string_to_json


logger = logging.getLogger(__name__)


def batch_action_template(form, user, translations, locale):
"""Empty batch action, does nothing, only used for documentation.

Expand Down Expand Up @@ -403,6 +409,117 @@ def copy_translation_from_locale(
}


def pretranslate_translations(user, locale: Locale, entities: QuerySet[Entity]):
"""
Enable teams to pretranslate an entire project or a subset of strings on projects
for which pretranslation is not enabled by the admins.
"""

entity_pks_with_translation = set(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is excluding approved translations, but it's going to pretranslate strings that already have a pretranslation. We should probably exclude those by filtering the authors (pt_authors = get_pretranslation_authors()).

That means also renaming this variable, e.g. excluded_entity_pks.

Again, flagging @mathjazz for an opinion. Since we want to change the behavior for the task, we might want to pretranslate strings with pending suggestions .filter(Q(approved=True) | Q(user__in=pt_authors.values())).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agreed - let's follow what the API does, so:

  • Skip strings with pretranslations
  • Pretranslate strings with unreviewed suggestions
  • Pretranslate strings with fuzzy translations

Translation.objects.filter(
locale=locale,
entity__in=entities,
approved=True,
).values_list("entity__pk", flat=True)
)

already_active_entity_pks = set(
Translation.objects.filter(
locale=locale,
entity__in=entities,
active=True,
).values_list("entity__pk", flat=True)
)

eligible_entities = entities.exclude(pk__in=entity_pks_with_translation)

before_level = badges_translation_level(user)

translations_to_create = []

invalid_entity_pks = []

for entity in eligible_entities:
try:
pretranslation = get_pretranslation(entity, locale)
except ValueError as e:
logger.info(f"Pretranslation error for entity {entity.pk}: {e!r}")
invalid_entity_pks.append(entity.pk)
continue

string, engine = pretranslation

try:
_, value, properties = parse_source_string_to_json(
entity.resource.format, string, locale.plural_catchall
)
except ValueError as e:
logger.error(
f"Unparsable pretranslation for entity {entity.pk}: {e!r}: {string!r}"
)
invalid_entity_pks.append(entity.pk)
continue

translations_to_create.append(
Translation(
locale=locale,
entity=entity,
string=string,
approved=False,
rejected=False,
fuzzy=False,
pretranslated=True,
active=entity.pk not in already_active_entity_pks,
user=user,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Waiting for @mathjazz opinion, but I think we shouldn't attribute this to the user running pretranslation, but to tm or gt, like we do when running pretranslation as a task. At that point, updating badge levels is unnecessary.

In case, the same applies later for the actionlog.

@MundiaNderi MundiaNderi Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Removed the badge updates. Happy to revisit attributing pretranslation to tm/gt next week in a follow-up if @mathjazz thinks it's worth it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agreed with flod. Pretranslations should be attributed to system users used by the pretranslation service.

value=value,
properties=properties,
)
)

changed_translations = Translation.objects.bulk_create(translations_to_create)
changed_translations_qs = Translation.objects.filter(
pk__in=[t.pk for t in changed_translations]
)

count, translated_resources, changed_entities = utils.get_translations_info(
changed_translations_qs, locale
)

actions_to_log = [
ActionLog(
action_type=ActionLog.ActionType.TRANSLATION_CREATED,
performed_by=user,
translation=t,
)
for t in changed_translations
]

ActionLog.objects.bulk_create(actions_to_log)

after_level = badges_translation_level(user)

badge_update = {}

if after_level > before_level:
badge_update["level"] = after_level
badge_update["name"] = "Translation Champion"
send_badge_notification(user, badge_update["name"], badge_update["level"])

changed_translation_pks = [t.pk for t in changed_translations]

return {
"count": count,
"translated_resources": translated_resources,
"changed_entities": changed_entities,
"latest_translation_pk": max(changed_translation_pks)
if changed_translation_pks
else None,
"changed_translation_pks": changed_translation_pks,
"invalid_translation_pks": invalid_entity_pks,
"badge_update": badge_update,
}


"""A map of action names to functions.

The keys define the available batch actions in the `batch_edit_translations`
Expand All @@ -415,4 +532,5 @@ def copy_translation_from_locale(
"reject": reject_translations,
"replace": replace_translations,
"copy_from_locale": copy_translation_from_locale,
"pretranslate": pretranslate_translations,
}
5 changes: 5 additions & 0 deletions pontoon/batch/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ class BatchActionsForm(forms.Form):
("reject", "reject"),
("replace", "replace"),
("copy_from_locale", "copy_from_locale"),
("pretranslate", "pretranslate"),
]
)
entities = forms.CharField(required=False)
find = forms.CharField(required=False)
replace = forms.CharField(required=False)
other_locale = forms.CharField(required=False)
pretranslate = forms.CharField(required=False)

def clean_entities(self):
return utils.split_ints(self.cleaned_data["entities"])
Expand All @@ -42,3 +44,6 @@ def clean_replace(self):

def clean_other_locale(self):
return self.decode_field("other_locale")

def clean_pretranslate(self):
return self.decode_field("pretranslate")
77 changes: 75 additions & 2 deletions pontoon/batch/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from unittest.mock import patch

import pytest

from fluent.syntax import FluentParser, FluentSerializer

from pontoon.base.models import Locale, Translation
from pontoon.batch.actions import copy_translation_from_locale
from pontoon.base.models import Entity, Locale, Translation
from pontoon.batch.actions import (
copy_translation_from_locale,
pretranslate_translations,
)
from pontoon.batch.utils import (
find_and_replace,
ftl_find_and_replace,
Expand Down Expand Up @@ -172,3 +177,71 @@ def test_copy_from_another_locale_copies_all_strings():
).count()
== 1
)


@pytest.mark.django_db
@patch("pontoon.batch.actions.get_pretranslation")
def test_pretranslate_translations_skips_approved_includes_suggestions(
get_pretranslation_mock,
):
"""
Pretranslation is skipped for entities with an approved translation,
but generated for entities with only a pending suggestion or no
translation at all.
"""

get_pretranslation_mock.return_value = ("key = pretranslated value", "gt")

project = ProjectFactory(slug="project4", name="Project4")
resource = ResourceFactory(project=project, path="resource.ftl", format="fluent")
entity_approved = EntityFactory(resource=resource, string="key1 = value1")
entity_suggestion = EntityFactory(resource=resource, string="key2 = value2")
entity_untranslated = EntityFactory(resource=resource, string="key3 = value3")
locale = Locale.objects.get(code="en-ZA")
user = UserFactory()

TranslationFactory(
entity=entity_approved,
locale=locale,
string="already approved",
approved=True,
active=True,
)

TranslationFactory(
entity=entity_suggestion,
locale=locale,
string="pending suggestion",
approved=False,
active=True,
)

entities = Entity.objects.filter(
pk__in=[entity_approved.pk, entity_suggestion.pk, entity_untranslated.pk]
)

result = pretranslate_translations(user, locale, entities)

# entity_approved: skipped entirely, no new translation created
assert not Translation.objects.filter(
entity=entity_approved, string="key = pretranslated value"
).exists()

# entity_suggestion: pretranslated even though a suggestion already exists;
# existing suggestion is still active, so the new one is not
assert Translation.objects.filter(
entity=entity_suggestion,
string="key = pretranslated value",
pretranslated=True,
active=False,
).exists()

# entity_untranslated: pretranslated and active, since nothing existed before
assert Translation.objects.filter(
entity=entity_untranslated,
string="key = pretranslated value",
pretranslated=True,
active=True,
).exists()

assert result["count"] == 2
76 changes: 76 additions & 0 deletions pontoon/batch/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from unittest.mock import patch

import pytest

from django.contrib.auth.models import Group
from django.urls import reverse

from pontoon.checks.utils import bulk_run_checks
Expand Down Expand Up @@ -234,3 +237,76 @@ def test_batch_find_and_replace_invalid_translations(

assert translation.string == "Test Translation"
assert not translation.approved


@pytest.mark.django_db
def test_batch_pretranslate_forbidden_without_pretranslators_group(
batch_action, member, user_a, locale_a, entity_a, project_locale_a
):
"""
A translator who is not in the pretranslators group cannot pretranslate.
"""
locale_a.translators_group.user_set.add(user_a)

response = batch_action(
action="pretranslate",
locale=locale_a.code,
entities=entity_a.id,
)

assert response.status_code == 403
assert b"pretranslation" in response.content


@pytest.mark.django_db
@patch("pontoon.batch.actions.get_pretranslation")
def test_batch_pretranslate_allowed_for_pretranslators_group(
get_pretranslation_mock,
batch_action,
member,
user_a,
locale_a,
entity_a,
project_locale_a,
):
"""
A translator who is also in the pretranslators group can pretranslate.
"""
get_pretranslation_mock.return_value = ("key = pretranslated value", "gt")

locale_a.translators_group.user_set.add(user_a)
group, _ = Group.objects.get_or_create(name="pretranslators")
user_a.groups.add(group)

response = batch_action(
action="pretranslate",
locale=locale_a.code,
entities=entity_a.id,
)

assert response.status_code != 403


@pytest.mark.django_db
def test_batch_pretranslate_unsupported_locale(
batch_action, member, user_a, locale_a, entity_a, project_locale_a
):
"""
Pretranslation is rejected for a locale with no Google Translate support,
even for a pretranslators-group member.
"""
locale_a.translators_group.user_set.add(user_a)
locale_a.google_translate_code = ""
locale_a.save()

group, _ = Group.objects.get_or_create(name="pretranslators")
user_a.groups.add(group)

response = batch_action(
action="pretranslate",
locale=locale_a.code,
entities=entity_a.id,
)

assert response.status_code == 400
assert b"not supported" in response.content
21 changes: 20 additions & 1 deletion pontoon/batch/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@
)
from pontoon.base.models.translation import Translation, TranslationQuerySet
from pontoon.base.services import readonly_exists
from pontoon.base.user_utils import can_translate
from pontoon.base.user_utils import can_pretranslate, can_translate
from pontoon.base.utils import require_AJAX
from pontoon.batch import forms
from pontoon.batch.actions import (
approve_translations,
copy_translation_from_locale,
pretranslate_translations,
reject_translations,
replace_translations,
)
Expand Down Expand Up @@ -110,6 +111,24 @@ def batch_edit_translations(request):
action_status = copy_translation_from_locale(
user, locale, entities, form.cleaned_data["other_locale"]
)
case "pretranslate":
if not can_pretranslate(user):
return JsonResponse(
{
"status": False,
"message": "Forbidden: You don't have permission for batch pretranslation.",
},
status=403,
)
if not locale.google_translate_code:
return JsonResponse(
{
"status": False,
"message": "Pretranslation is not supported for this locale.",
},
status=400,
)
action_status = pretranslate_translations(user, locale, entities)
case "reject":
action_status = reject_translations(user, locale, entities)
case "replace":
Expand Down
Loading
Loading