Skip to content

Commit b97a263

Browse files
flodolomathjazz
andauthored
Don't reject strings with partial files in Upload translations (#4491)
Co-authored-by: Matjaž Horvat <matjaz.horvat@gmail.com>
1 parent e852341 commit b97a263

9 files changed

Lines changed: 336 additions & 94 deletions

File tree

pontoon/base/tests/views/test_upload.py

Lines changed: 199 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import pytest
44

5-
from pontoon.base.models import Translation
5+
from django.contrib.messages import get_messages
6+
7+
from pontoon.base.models import ChangedEntityLocale, Translation
8+
from pontoon.test.factories import EntityFactory, TranslationFactory
69

710

811
@pytest.fixture
@@ -49,6 +52,39 @@ def upload(client, **args):
4952
return response
5053

5154

55+
@pytest.fixture
56+
def approved_po_translation(po_translation):
57+
po_translation.approved = True
58+
po_translation.active = True
59+
po_translation.save()
60+
61+
yield po_translation
62+
63+
64+
@pytest.fixture
65+
def upload_po(translator_a, project_locale_a, po_translation):
66+
"""
67+
Upload the given contents as a .po file for the `po_translation` resource,
68+
returning the list of (tags, message) pairs.
69+
"""
70+
71+
def _upload_po(po_contents):
72+
with NamedTemporaryFile("w+", suffix=".po") as fp:
73+
fp.write(po_contents)
74+
fp.flush()
75+
response = upload(
76+
translator_a.client,
77+
slug=project_locale_a.project.slug,
78+
code=project_locale_a.locale.code,
79+
part=po_translation.entity.resource.path,
80+
uploadfile=open(fp.name),
81+
)
82+
assert response.status_code == 303
83+
return [(m.tags, m.message) for m in get_messages(response.wsgi_request)]
84+
85+
return _upload_po
86+
87+
5288
@pytest.mark.django_db
5389
def test_upload_login_required(
5490
client,
@@ -144,33 +180,174 @@ def test_upload_project_locale_is_readonly(
144180

145181

146182
@pytest.mark.django_db
147-
def test_upload_file(
148-
translator_a,
149-
project_locale_a,
150-
po_translation,
151-
):
183+
def test_upload_file(upload_po, po_translation):
152184
"""
153185
Test a positive upload which changes the translation.
154186
"""
155-
po_contents = 'msgid "test_key"\nmsgstr "new translation"'
156-
with NamedTemporaryFile("w+", suffix=".po") as fp:
157-
fp.write(po_contents)
158-
fp.flush()
159-
response = upload(
160-
translator_a.client,
161-
slug=project_locale_a.project.slug,
162-
code=project_locale_a.locale.code,
163-
part=po_translation.entity.resource.path,
164-
uploadfile=open(fp.name),
165-
)
166-
167-
assert response.status_code == 303
187+
messages = upload_po('msgid "test_key"\nmsgstr "new translation"')
188+
assert messages == [
189+
("upload success", "Translations uploaded: 1 updated, 0 unchanged.")
190+
]
168191

169192
translation = Translation.objects.get(string="new translation")
170-
171-
assert translation.entity.key == ["test_key"]
172-
assert translation.entity.resource.path == "resource_a.po"
193+
assert translation.entity == po_translation.entity
173194
assert translation.approved
174195
assert translation.user
175196
assert not translation.warnings.exists()
176197
assert not translation.errors.exists()
198+
199+
200+
@pytest.mark.django_db
201+
def test_upload_is_additive(upload_po, approved_po_translation, locale_a):
202+
"""
203+
Approved translations missing from the uploaded file are left untouched.
204+
"""
205+
resource = approved_po_translation.entity.resource
206+
other_translation = TranslationFactory(
207+
entity=EntityFactory(resource=resource, string="other", key=["other_key"]),
208+
locale=locale_a,
209+
string="other translation",
210+
value=["other translation"],
211+
approved=True,
212+
active=True,
213+
)
214+
215+
messages = upload_po('msgid "test_key"\nmsgstr "new translation"')
216+
assert messages == [
217+
("upload success", "Translations uploaded: 1 updated, 0 unchanged.")
218+
]
219+
220+
approved_po_translation.refresh_from_db()
221+
assert not approved_po_translation.approved
222+
assert approved_po_translation.rejected
223+
assert (
224+
Translation.objects.get(
225+
entity=approved_po_translation.entity, approved=True
226+
).string
227+
== "new translation"
228+
)
229+
230+
other_translation.refresh_from_db()
231+
assert other_translation.approved
232+
assert other_translation.active
233+
assert not other_translation.rejected
234+
235+
236+
@pytest.mark.django_db
237+
def test_upload_approves_matching_suggestion(upload_po, po_translation, locale_a):
238+
"""
239+
An uploaded translation matching an existing suggestion approves it,
240+
and the entity is still marked as changed for the next sync.
241+
"""
242+
assert not po_translation.approved
243+
244+
messages = upload_po(f'msgid "test_key"\nmsgstr "{po_translation.string}"')
245+
assert messages == [
246+
("upload success", "Translations uploaded: 1 updated, 0 unchanged.")
247+
]
248+
249+
assert Translation.objects.filter(entity=po_translation.entity).count() == 1
250+
po_translation.refresh_from_db()
251+
assert po_translation.approved
252+
assert ChangedEntityLocale.objects.filter(
253+
entity=po_translation.entity, locale=locale_a
254+
).exists()
255+
256+
257+
@pytest.mark.django_db
258+
def test_upload_approves_matching_fuzzy_translation(upload_po, po_translation):
259+
"""
260+
An uploaded non-fuzzy translation matching an existing fuzzy one approves it.
261+
"""
262+
po_translation.fuzzy = True
263+
po_translation.active = True
264+
po_translation.save()
265+
266+
messages = upload_po(f'msgid "test_key"\nmsgstr "{po_translation.string}"')
267+
assert messages == [
268+
("upload success", "Translations uploaded: 1 updated, 0 unchanged.")
269+
]
270+
271+
assert Translation.objects.filter(entity=po_translation.entity).count() == 1
272+
po_translation.refresh_from_db()
273+
assert po_translation.approved
274+
assert not po_translation.fuzzy
275+
276+
277+
@pytest.mark.django_db
278+
def test_upload_identical_translation_is_ignored(upload_po, approved_po_translation):
279+
messages = upload_po(f'msgid "test_key"\nmsgstr "{approved_po_translation.string}"')
280+
assert messages == [
281+
("upload info", "Translations uploaded: 0 updated, 1 unchanged.")
282+
]
283+
284+
assert (
285+
Translation.objects.filter(entity=approved_po_translation.entity).count() == 1
286+
)
287+
approved_po_translation.refresh_from_db()
288+
assert approved_po_translation.approved
289+
290+
291+
@pytest.mark.django_db
292+
def test_upload_undefined_keys_are_reported(upload_po, po_translation):
293+
"""
294+
Translations for keys that are missing or obsolete in Pontoon are ignored.
295+
"""
296+
resource = po_translation.entity.resource
297+
EntityFactory(resource=resource, string="old", key=["obsolete_key"], obsolete=True)
298+
299+
messages = upload_po(
300+
'msgid "test_key"\nmsgstr "new translation"\n\n'
301+
'msgid "obsolete_key"\nmsgstr "obsolete translation"\n\n'
302+
'msgid "missing_key"\nmsgstr "missing translation"\n'
303+
)
304+
assert messages == [
305+
(
306+
"upload success",
307+
"Translations uploaded: 1 updated, 0 unchanged, 2 not found in Pontoon.",
308+
)
309+
]
310+
assert set(
311+
Translation.objects.filter(entity__resource=resource).values_list(
312+
"string", flat=True
313+
)
314+
) == {po_translation.string, "new translation"}
315+
316+
317+
@pytest.mark.django_db
318+
def test_upload_ignores_translations_of_obsolete_entities(
319+
upload_po, po_translation, locale_a
320+
):
321+
"""
322+
A key removed and later re-added leaves an obsolete entity with the same key.
323+
If the new key is untranslated, the upload should fill it and be counted as
324+
translated, even if that matches the original translation in the obsolete key.
325+
"""
326+
TranslationFactory(
327+
entity=EntityFactory(
328+
resource=po_translation.entity.resource,
329+
string="entity a",
330+
key=["test_key"],
331+
obsolete=True,
332+
),
333+
locale=locale_a,
334+
string="new translation",
335+
value=["new translation"],
336+
approved=True,
337+
active=True,
338+
)
339+
340+
messages = upload_po('msgid "test_key"\nmsgstr "new translation"')
341+
assert messages == [
342+
("upload success", "Translations uploaded: 1 updated, 0 unchanged.")
343+
]
344+
assert (
345+
Translation.objects.get(entity=po_translation.entity, approved=True).string
346+
== "new translation"
347+
)
348+
349+
350+
@pytest.mark.django_db
351+
def test_upload_file_without_translations(upload_po):
352+
messages = upload_po("# Just a comment\n")
353+
assert messages == [("error", "No translations found in uploaded file.")]

pontoon/base/views.py

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from pontoon.actionlog.models import ActionLog
3535
from pontoon.actionlog.utils import log_action
3636
from pontoon.base import forms, utils
37+
from pontoon.base.badge_utils import badges_review_level, badges_translation_level
3738
from pontoon.base.get_entities import (
3839
get_entities_for_project_locale,
3940
get_mismatched_filters,
@@ -71,7 +72,7 @@
7172
from pontoon.checks.libraries import run_checks
7273
from pontoon.checks.utils import are_blocking_checks
7374
from pontoon.contributors.utils import users_with_translations_counts
74-
from pontoon.messaging.notifications import send_notification
75+
from pontoon.messaging.notifications import send_badge_notification, send_notification
7576

7677

7778
log = logging.getLogger(__name__)
@@ -1058,26 +1059,42 @@ def upload(request):
10581059
project, locale
10591060
):
10601061
return HttpResponseForbidden("You don't have permission to upload files.")
1061-
get_object_or_404(Resource, project=project, path=res_path)
1062+
resource = get_object_or_404(Resource, project=project, path=res_path)
10621063

10631064
form = forms.UploadFileForm(request.POST, request.FILES)
10641065
if form.is_valid():
10651066
from pontoon.sync.utils import import_uploaded_file
10661067

10671068
upload = request.FILES["uploadfile"]
10681069
try:
1069-
badge_name, badge_level = import_uploaded_file(
1070-
project, locale, res_path, upload, request.user
1070+
translation_before_level = badges_translation_level(request.user)
1071+
review_before_level = badges_review_level(request.user)
1072+
result = import_uploaded_file(
1073+
project, locale, resource, upload, request.user
10711074
)
1072-
messages.success(request, "Translations updated from uploaded file.")
1073-
if badge_name:
1074-
message = json.dumps(
1075-
{
1076-
"name": badge_name,
1077-
"level": badge_level,
1078-
}
1079-
)
1080-
messages.info(request, message, extra_tags="badge")
1075+
summary = [f"{result.updated} updated", f"{result.unchanged} unchanged"]
1076+
if result.undefined:
1077+
summary.append(f"{result.undefined} not found in Pontoon")
1078+
message = f"Translations uploaded: {', '.join(summary)}."
1079+
if result.updated:
1080+
messages.success(request, message, extra_tags="upload")
1081+
else:
1082+
messages.info(request, message, extra_tags="upload")
1083+
1084+
badge_levels = (
1085+
(
1086+
"Translation Champion",
1087+
translation_before_level,
1088+
badges_translation_level,
1089+
),
1090+
("Review Master", review_before_level, badges_review_level),
1091+
)
1092+
for badge_name, before_level, get_level in badge_levels:
1093+
after_level = get_level(request.user)
1094+
if after_level > before_level:
1095+
send_badge_notification(request.user, badge_name, after_level)
1096+
message = json.dumps({"name": badge_name, "level": after_level})
1097+
messages.info(request, message, extra_tags="badge")
10811098
except Exception as error:
10821099
messages.error(request, str(error))
10831100
else:

pontoon/sync/core/paths.py

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -48,24 +48,3 @@ def find_paths(
4848
log.debug(f"[{project.slug}] Paths({name}): ref_root={rel_root} base={rel_base}")
4949

5050
return paths
51-
52-
53-
class UploadPaths:
54-
"""
55-
moz.l10n.paths -like interface for sync'ing content from a single file.
56-
Implements minimal functionality required by `find_db_updates()`.
57-
"""
58-
59-
ref_root = ""
60-
61-
def __init__(self, ref_path: str, locale_code: str, file_path: str):
62-
self._ref_path = ref_path
63-
self._locale_code = locale_code
64-
self._file_path = file_path
65-
66-
def find_reference(self, target_path: str):
67-
return (
68-
(self._ref_path, {"locale": self._locale_code})
69-
if target_path == self._file_path
70-
else None
71-
)

pontoon/sync/core/translations_from_repo.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
from pontoon.checks import DB_FORMATS
3535
from pontoon.checks.utils import bulk_run_checks
3636
from pontoon.sync.core.checkout import Checkout, Checkouts
37-
from pontoon.sync.core.paths import UploadPaths
3837
from pontoon.sync.formats import RepoTranslation, as_repo_translations
3938

4039

@@ -136,7 +135,7 @@ def find_db_updates(
136135
project: Project,
137136
locale_map: dict[str, Locale],
138137
changed_target_paths: Iterable[str],
139-
paths: L10nConfigPaths | L10nDiscoverPaths | UploadPaths,
138+
paths: L10nConfigPaths | L10nDiscoverPaths,
140139
db_changes: Iterable[ChangedEntityLocale],
141140
) -> Updates | None:
142141
"""
@@ -178,9 +177,7 @@ def find_db_updates(
178177
except Exception as error:
179178
scope = f"[{project.slug}:{db_path}, {locale.code}]"
180179
log.warning(f"{scope} Skipping resource with parse error: {error}")
181-
elif splitext(target_path)[1] in l10n_extensions and not isinstance(
182-
paths, UploadPaths
183-
):
180+
elif splitext(target_path)[1] in l10n_extensions:
184181
log.debug(
185182
f"[{project.slug}:{relpath(target_path, paths.base)}] Not an L10n target path"
186183
)

0 commit comments

Comments
 (0)