Skip to content

Commit b4ddd09

Browse files
committed
Download translated resources directly
1 parent 75de78b commit b4ddd09

3 files changed

Lines changed: 184 additions & 151 deletions

File tree

Lines changed: 126 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,143 @@
1-
from os import makedirs
2-
from tempfile import TemporaryDirectory
3-
from unittest.mock import patch
1+
from io import BytesIO
2+
from textwrap import dedent
3+
from zipfile import ZipFile
44

55
import pytest
66

7-
from django.conf import settings
87
from django.test import RequestFactory
98

10-
from pontoon.base.models import Project, Resource
9+
from pontoon.base.models import Project
1110
from pontoon.base.views import download_translations
12-
from pontoon.sync.tests.test_checkouts import MockVersionControl
13-
from pontoon.sync.tests.utils import build_file_tree
1411
from pontoon.test.factories import (
12+
EntityFactory,
1513
LocaleFactory,
1614
ProjectFactory,
17-
RepositoryFactory,
1815
ResourceFactory,
16+
SectionFactory,
1917
TranslatedResourceFactory,
18+
TranslationFactory,
2019
UserFactory,
2120
)
2221

2322

2423
@pytest.mark.django_db
25-
@pytest.mark.parametrize("two_repos", [True, False])
26-
@pytest.mark.parametrize(
27-
"repo_url,expected_location",
28-
[
29-
(
30-
"https://github.com:gh-org/gh-repo.git",
31-
"https://raw.githubusercontent.com/gh-org/gh-repo/HEAD/de-Test/a.ftl",
32-
),
33-
(
34-
"git@gitlab.com:gl-org/gl-repo.git",
35-
"https://gitlab.com/gl-org/gl-repo/-/raw/HEAD/de-Test/a.ftl?inline=false",
36-
),
37-
("http://example.com/tgt-repo", "https://example.com/tgt-repo"),
38-
],
39-
)
40-
def test_download(two_repos, repo_url, expected_location):
41-
with (
42-
TemporaryDirectory() as root,
43-
patch("pontoon.sync.core.checkout.get_repo", return_value=MockVersionControl()),
44-
):
45-
settings.MEDIA_ROOT = root
46-
locale = LocaleFactory.create(code="de-Test")
47-
if two_repos:
48-
repo_src = RepositoryFactory(
49-
url="http://example.com/src-repo", source_repo=True
50-
)
51-
repo_tgt = RepositoryFactory(url=repo_url)
52-
project = ProjectFactory.create(
53-
name="test-dl",
54-
locales=[locale],
55-
repositories=[repo_src, repo_tgt],
56-
visibility=Project.Visibility.PUBLIC,
57-
)
58-
src_root = repo_src.checkout_path
59-
tgt_root = repo_tgt.checkout_path
60-
makedirs(src_root)
61-
build_file_tree(src_root, {"en-US": {"a.ftl": ""}})
62-
makedirs(tgt_root)
63-
build_file_tree(tgt_root, {"de-Test": {"a.ftl": ""}})
64-
else:
65-
repo = RepositoryFactory(url=repo_url)
66-
project = ProjectFactory.create(
67-
name="test-dl",
68-
locales=[locale],
69-
repositories=[repo],
70-
visibility=Project.Visibility.PUBLIC,
71-
)
72-
repo_root = repo.checkout_path
73-
makedirs(repo_root)
74-
build_file_tree(
75-
repo_root, {"en-US": {"a.ftl": ""}, "de-Test": {"a.ftl": ""}}
76-
)
77-
res = ResourceFactory.create(
78-
project=project, path="a.ftl", format=Resource.Format.FLUENT
79-
)
80-
TranslatedResourceFactory.create(locale=locale, resource=res)
24+
def test_download_fluent():
25+
locale = LocaleFactory.create(code="de-Test")
26+
project = ProjectFactory.create(
27+
name="test-dl",
28+
locales=[locale],
29+
visibility=Project.Visibility.PUBLIC,
30+
)
31+
res = ResourceFactory.create(
32+
project=project, format="fluent", path="path/to/file.ftl"
33+
)
34+
TranslatedResourceFactory.create(locale=locale, resource=res)
35+
section = SectionFactory.create(resource=res, key=[], comment="Group")
36+
e1 = EntityFactory.create(resource=res, section=section, key=["e1"], value=["E1"])
37+
e2 = EntityFactory.create(
38+
resource=res,
39+
section=section,
40+
key=["e2"],
41+
value=[],
42+
properties={"attr": ["E2"]},
43+
)
44+
EntityFactory.create(resource=res, section=section, key=["e3"], value=["E3"])
45+
TranslationFactory.create(
46+
locale=locale, entity=e1, value=["T1"], active=True, approved=True
47+
)
48+
TranslationFactory.create(
49+
locale=locale,
50+
entity=e2,
51+
value=[],
52+
properties={"attr": ["T2"]},
53+
active=True,
54+
approved=True,
55+
)
56+
57+
request = RequestFactory().get(
58+
"/translations/?code=de-Test&slug=test-dl&part=path/to/file.ftl"
59+
)
60+
request.user = UserFactory()
61+
response = download_translations(request)
62+
assert response.status_code == 200
63+
assert response["Content-Type"] == "application/zip"
64+
assert (
65+
response["Content-Disposition"]
66+
== "attachment; filename=de-Test_test-dl_path_to_file.zip"
67+
)
68+
bytes_io = BytesIO(response.content)
69+
with ZipFile(bytes_io, "r") as zipfile:
70+
assert zipfile.namelist() == ["de-Test_test-dl_path_to_file.ftl"]
71+
raw = zipfile.read("de-Test_test-dl_path_to_file.ftl")
72+
assert raw.decode("utf-8") == dedent("""\
73+
## Group
74+
75+
e1 = T1
76+
e2 =
77+
.attr = T2
78+
""")
79+
80+
81+
@pytest.mark.django_db
82+
def test_download_xliff():
83+
locale = LocaleFactory.create(code="de-Test")
84+
project = ProjectFactory.create(
85+
name="test-dlx",
86+
locales=[locale],
87+
visibility=Project.Visibility.PUBLIC,
88+
)
89+
res = ResourceFactory.create(project=project, format="xliff", path="file.xlf")
90+
TranslatedResourceFactory.create(locale=locale, resource=res)
91+
section = SectionFactory.create(resource=res, key=["file.foo"], comment="Group")
92+
e1 = EntityFactory.create(
93+
resource=res, section=section, key=["file.foo", "e1"], value=["E1"]
94+
)
95+
e2 = EntityFactory.create(
96+
resource=res, section=section, key=["file.foo", "e2"], value=["E2"]
97+
)
98+
EntityFactory.create(
99+
resource=res, section=section, key=["file.foo", "e3"], value=["E3"]
100+
)
101+
TranslationFactory.create(
102+
locale=locale, entity=e1, value=["T1"], active=True, approved=True
103+
)
104+
TranslationFactory.create(
105+
locale=locale, entity=e2, value=["T2"], active=True, approved=True
106+
)
81107

82-
request = RequestFactory().get(
83-
"/translations/?code=de-Test&slug=test-dl&part=a.ftl"
84-
)
85-
request.user = UserFactory()
86-
response = download_translations(request)
87-
assert response.status_code == 302
88-
assert response.get("Location") == expected_location
108+
request = RequestFactory().get(
109+
"/translations/?code=de-Test&slug=test-dlx&part=file.xlf"
110+
)
111+
request.user = UserFactory()
112+
response = download_translations(request)
113+
assert response.status_code == 200
114+
assert response["Content-Type"] == "application/zip"
115+
assert (
116+
response["Content-Disposition"]
117+
== "attachment; filename=de-Test_test-dlx_file.zip"
118+
)
119+
bytes_io = BytesIO(response.content)
120+
with ZipFile(bytes_io, "r") as zipfile:
121+
assert zipfile.namelist() == ["de-Test_test-dlx_file.xlf"]
122+
raw = zipfile.read("de-Test_test-dlx_file.xlf")
123+
assert raw.decode("utf-8") == dedent("""\
124+
<?xml version="1.0" encoding="utf-8"?>
125+
<xliff>
126+
<file original="file.foo">
127+
<!-- Group -->
128+
<body>
129+
<trans-unit id="e1">
130+
<source>E1</source>
131+
<target>T1</target>
132+
</trans-unit>
133+
<trans-unit id="e2">
134+
<source>E2</source>
135+
<target>T2</target>
136+
</trans-unit>
137+
<trans-unit id="e3">
138+
<source>E3</source>
139+
</trans-unit>
140+
</body>
141+
</file>
142+
</xliff>
143+
""")

pontoon/base/views.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
HttpRequest,
2121
HttpResponse,
2222
HttpResponseForbidden,
23-
HttpResponseRedirect,
2423
JsonResponse,
2524
StreamingHttpResponse,
2625
)
@@ -1008,9 +1007,12 @@ def perform_checks(request):
10081007

10091008
@transaction.atomic
10101009
def download_translations(request):
1011-
"""Download translated resource from its backing repository."""
1010+
"""Download translated resource."""
10121011

1013-
from pontoon.sync.utils import translations_target_url
1012+
from io import BytesIO
1013+
from zipfile import ZIP_DEFLATED, ZipFile
1014+
1015+
from pontoon.sync.utils import serialize_translated_resource
10141016

10151017
try:
10161018
slug = request.GET["slug"]
@@ -1020,16 +1022,21 @@ def download_translations(request):
10201022
raise Http404
10211023

10221024
project = get_object_or_404(Project.objects.visible_for(request.user), slug=slug)
1025+
resource = get_object_or_404(Resource, project=project, path=res_path)
10231026
locale = get_object_or_404(Locale, code=code)
10241027

1025-
# FIXME This is a temporary hack, to be replaced by 04/2025 with proper downloads.
1026-
# Once fixed, we should remove SSH credentials from the web pod
1027-
# https://github.com/mozilla/webservices-infra/pull/9295
1028-
url = translations_target_url(project, locale, res_path)
1029-
if url and url.startswith("https://"):
1030-
return HttpResponseRedirect(url)
1031-
else:
1032-
raise Http404
1028+
filename = re.sub(r"[/\\]", "_", f"{locale.code}_{slug}_{res_path}")
1029+
bytes_io = BytesIO()
1030+
zipfile = ZipFile(bytes_io, "w", compression=ZIP_DEFLATED)
1031+
zipfile.writestr(filename, serialize_translated_resource(resource, locale))
1032+
zipfile.close()
1033+
1034+
response = HttpResponse()
1035+
response.content = bytes_io.getvalue()
1036+
response["Content-Type"] = "application/zip"
1037+
zip_name = re.sub(r"[^.]+$", "zip", filename)
1038+
response["Content-Disposition"] = f"attachment; filename={zip_name}"
1039+
return response
10331040

10341041

10351042
@login_required(redirect_field_name="", login_url="/403")

pontoon/sync/utils.py

Lines changed: 40 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,53 @@
1-
import re
2-
3-
from io import BytesIO
4-
from os.path import basename, exists, join, relpath
1+
from os.path import basename, join
52
from tempfile import TemporaryDirectory
6-
from zipfile import ZipFile
3+
4+
from moz.l10n.resource import serialize_resource
75

86
from django.core.files import File
7+
from django.db.models import Q
98
from django.utils import timezone
109

1110
from pontoon.base.badge_utils import badges_review_level, badges_translation_level
12-
from pontoon.base.models import ChangedEntityLocale, Locale, Project, Repository, User
11+
from pontoon.base.models import (
12+
ChangedEntityLocale,
13+
Locale,
14+
Project,
15+
Resource as DbResource,
16+
Translation,
17+
User,
18+
)
1319
from pontoon.messaging.notifications import send_badge_notification
14-
from pontoon.sync.core.checkout import checkout_repos
15-
from pontoon.sync.core.paths import UploadPaths, find_paths
20+
from pontoon.sync.core.paths import UploadPaths
1621
from pontoon.sync.core.stats import update_stats
1722
from pontoon.sync.core.translations_from_repo import find_db_updates, write_db_updates
18-
from pontoon.sync.core.translations_to_repo import update_changed_resources
19-
20-
21-
# FIXME This is a temporary hack, to be replaced by 04/2025 with proper downloads.
22-
# Once fixed, we should remove SSH credentials from the web pod
23-
# https://github.com/mozilla/webservices-infra/pull/9295
24-
def translations_target_url(
25-
project: Project, locale: Locale, resource_path: str
26-
) -> str | None:
27-
"""The target repository URL for a resource, for direct download."""
28-
29-
if project.repositories.count() > 1:
30-
# HACK: Let's assume that no config is used, and the target repo root is the right base.
31-
target_repo: Repository = project.repositories.get(source_repo=False)
32-
rel_path = f"{locale.code}/{resource_path}"
33-
else:
34-
checkouts = checkout_repos(project, shallow=True)
35-
target_repo = checkouts.target.repo
36-
paths = find_paths(project, checkouts)
37-
target, _ = paths.target(resource_path)
38-
if not target:
39-
return None
40-
abs_path = paths.format_target_path(target, locale.code)
41-
rel_path = relpath(abs_path, checkouts.target.path).replace("\\", "/")
42-
43-
github = re.search(r"\bgithub\.com[:/]([^/]+)/([^/]+)\.git$", target_repo.url)
44-
if github:
45-
org, repo = github.groups()
46-
ref = f"refs/heads/{target_repo.branch}" if target_repo.branch else "HEAD"
47-
return f"https://raw.githubusercontent.com/{org}/{repo}/{ref}/{rel_path}"
48-
49-
gitlab = re.search(r"gitlab\.com[:/]([^/]+)/([^/]+)\.git$", target_repo.url)
50-
if gitlab:
51-
org, repo = gitlab.groups()
52-
ref = target_repo.branch or "HEAD"
53-
return f"https://gitlab.com/{org}/{repo}/-/raw/{ref}/{rel_path}?inline=false"
54-
55-
# Default to bare repo link
56-
return re.sub(r"^.*?(://|@)", "https://", target_repo.url, count=1)
57-
58-
59-
# FIXME Currently not in use, to be refactored for proper download support
60-
def download_translations_zip(
61-
project: Project, locale: Locale
62-
) -> tuple[bytes, str] | tuple[None, None]:
63-
checkouts = checkout_repos(project, shallow=True)
64-
paths = find_paths(project, checkouts)
65-
db_changes = ChangedEntityLocale.objects.filter(
66-
entity__resource__project=project, locale=locale
67-
).select_related("entity__resource", "locale")
68-
update_changed_resources(project, paths, {}, [], db_changes, set(), timezone.now())
69-
70-
bytes_io = BytesIO()
71-
zipfile = ZipFile(bytes_io, "w")
72-
for _, tgt_path in paths.all():
73-
filename = paths.format_target_path(tgt_path, locale.code)
74-
if exists(filename):
75-
arcname = relpath(filename, checkouts.target.path)
76-
zipfile.write(filename, arcname)
77-
zipfile.close()
23+
from pontoon.sync.core.translations_to_repo import (
24+
build_moz_l10n_resource,
25+
build_translated_resource,
26+
)
27+
28+
29+
def serialize_translated_resource(db_res: DbResource, locale: Locale) -> str:
30+
res = build_moz_l10n_resource(db_res)
31+
translations = {
32+
tuple(tx.entity.key): tx
33+
for tx in Translation.objects.filter(
34+
entity__obsolete=False,
35+
entity__resource=db_res,
36+
locale=locale,
37+
active=True,
38+
)
39+
.filter(
40+
Q(approved=True)
41+
| Q(pretranslated=True, warnings__isnull=True)
42+
| Q(fuzzy=True)
43+
)
44+
.select_related("entity")
45+
.iterator()
46+
}
47+
tr_res = build_translated_resource(locale, translations, res)
7848

79-
return bytes_io.getvalue(), f"{project.slug}.zip"
49+
lc_plurals = locale.cldr_plurals_list()
50+
return "".join(serialize_resource(tr_res, gettext_plurals=lc_plurals))
8051

8152

8253
def import_uploaded_file(

0 commit comments

Comments
 (0)