Skip to content

Commit 84b4d0c

Browse files
committed
fix(translate): corrected string ordering
The project and category language views defaulted to wrong ordering and didn't show a component switch reliably. Fixes #19613
1 parent 2c21610 commit 84b4d0c

6 files changed

Lines changed: 219 additions & 34 deletions

File tree

docs/changes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Weblate 2026.6
1414

1515
* Hardened :http:post:`/api/screenshots/` access checks against private project enumeration.
1616
* Searching for strings with content changes without a recorded author now supports ``changed_by:""``.
17+
* Project and category language translation sessions now keep strings grouped by component priority and show component switch warnings reliably.
1718

1819
.. rubric:: Compatibility
1920

weblate/trans/models/unit.py

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,32 @@
7676

7777

7878
NEWLINES = re.compile(r"\r\n|\r|\n")
79+
LOCKED_COMPONENT_ORDER_FIELD = "translation__component__locked"
80+
COMPONENT_ORDER_FIELDS = [
81+
# Order by priority for custom ordering
82+
"translation__component__priority",
83+
# Show glossaries at the end
84+
"translation__component__is_glossary",
85+
"translation__component__name",
86+
]
87+
88+
89+
def orders_units_by_component(obj: object) -> bool:
90+
"""Return whether the object scope spans component-ordered units."""
91+
if isinstance(obj, (Project, Category)):
92+
return True
93+
94+
from weblate.utils.stats import CategoryLanguage, ProjectLanguage # noqa: PLC0415
95+
96+
return isinstance(obj, (ProjectLanguage, CategoryLanguage))
97+
98+
99+
def get_component_order_fields(sign: str = "") -> list[str]:
100+
return [
101+
# Show locked components at the end, regardless of sort direction
102+
LOCKED_COMPONENT_ORDER_FIELD,
103+
*(f"{sign}{field}" for field in COMPONENT_ORDER_FIELDS),
104+
]
79105

80106

81107
def fill_in_source_translation(units: Iterable[Unit]) -> None:
@@ -254,6 +280,7 @@ def order_by_request(self, form_data, obj) -> UnitQuerySet:
254280
},
255281
}
256282
sort_list = []
283+
component_sort = False
257284
for choice in sort_list_request:
258285
unsigned_choice = choice.replace("-", "")
259286
if unsigned_choice in countable_sort_choices:
@@ -266,33 +293,25 @@ def order_by_request(self, form_data, obj) -> UnitQuerySet:
266293
)
267294
if unsigned_choice in available_sort_choices:
268295
if unsigned_choice == "component":
296+
component_sort = True
269297
sign = "-" if choice[0] == "-" else ""
270-
sort_list.extend(
271-
[
272-
f"{sign}translation__component__priority",
273-
f"{sign}translation__component__is_glossary",
274-
f"{sign}translation__component__name",
275-
]
276-
)
298+
sort_list.extend(get_component_order_fields(sign))
277299
continue
278300

279301
if unsigned_choice == "labels":
280302
choice = choice.replace("labels", "max_labels_name")
281303
sort_list.append(choice)
304+
if (
305+
component_sort
306+
and orders_units_by_component(obj)
307+
and "position" not in sort_list
308+
):
309+
sort_list.append("position")
282310
if not sort_list:
283311
if hasattr(obj, "component") and obj.component.is_glossary:
284312
sort_list = ["source"]
285-
elif isinstance(obj, (Project, Category)):
286-
sort_list = [
287-
# Show locked components at the end
288-
"translation__component__locked",
289-
# Order by priority for custom ordering
290-
"translation__component__priority",
291-
# Show glossaries at the end
292-
"translation__component__is_glossary",
293-
"translation__component__name",
294-
"-priority",
295-
]
313+
elif orders_units_by_component(obj):
314+
sort_list = [*get_component_order_fields(), "-priority", "position"]
296315
else:
297316
sort_list = ["-priority", "position"]
298317
if "max_labels_name" in sort_list or "-max_labels_name" in sort_list:

weblate/trans/tests/test_edit.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@
3333
STATE_NEEDS_REWRITING,
3434
STATE_TRANSLATED,
3535
)
36-
from weblate.utils.stats import ProjectLanguage
36+
from weblate.utils.stats import CategoryLanguage, ProjectLanguage
37+
from weblate.utils.views import get_sort_name
3738

3839
if TYPE_CHECKING:
3940
from weblate.checks.base import BaseCheck
@@ -1354,6 +1355,99 @@ def test_revert_history_after_component_move_uses_current_translate_url(
13541355
f'href="{translate_url}?checksum={unit.checksum}&revert={change.id}"',
13551356
)
13561357

1358+
def test_project_language_warns_when_switching_component(self) -> None:
1359+
Component.objects.filter(pk=self.component.pk).update(priority=120)
1360+
high_component = self.create_po(name="High", priority=80, project=self.project)
1361+
high_translation = high_component.translation_set.get(
1362+
language=self.translation.language
1363+
)
1364+
translate_url = ProjectLanguage(
1365+
self.project, self.translation.language
1366+
).get_translate_url()
1367+
high_component_offset = high_translation.unit_set.count()
1368+
1369+
response = self.client.get(translate_url, {"offset": high_component_offset})
1370+
self.assertNotContains(response, "You have shifted from")
1371+
self.assertContains(response, 'value="component,-priority"')
1372+
1373+
response = self.client.get(translate_url, {"offset": high_component_offset + 1})
1374+
self.assertContains(response, "You have shifted from")
1375+
1376+
def test_language_scope_sort_defaults_to_component_priority(self) -> None:
1377+
request = SimpleNamespace(GET={})
1378+
category = self.create_category(self.project)
1379+
1380+
self.assertEqual(
1381+
get_sort_name(
1382+
request, ProjectLanguage(self.project, self.translation.language)
1383+
)["query"],
1384+
"component,-priority",
1385+
)
1386+
self.assertEqual(
1387+
get_sort_name(
1388+
request, CategoryLanguage(category, self.translation.language)
1389+
)["query"],
1390+
"component,-priority",
1391+
)
1392+
1393+
def test_project_language_submitted_search_keeps_component_order(self) -> None:
1394+
Component.objects.filter(pk=self.component.pk).update(priority=120)
1395+
high_component = self.create_po(
1396+
name="High", locked=True, priority=80, project=self.project
1397+
)
1398+
high_translation = high_component.translation_set.get(
1399+
language=self.translation.language
1400+
)
1401+
translate_url = ProjectLanguage(
1402+
self.project, self.translation.language
1403+
).get_translate_url()
1404+
1405+
self.client.get(translate_url, {"sort_by": "component,-priority", "offset": 1})
1406+
1407+
session = self.client.session
1408+
session_keys = session.keys()
1409+
search_key = next(key for key in session_keys if key.startswith("search_"))
1410+
expected_ids = [
1411+
*self.translation.unit_set.order_by("position").values_list(
1412+
"pk", flat=True
1413+
),
1414+
*high_translation.unit_set.order_by("position").values_list(
1415+
"pk", flat=True
1416+
),
1417+
]
1418+
self.assertEqual(session[search_key]["ids"], expected_ids)
1419+
1420+
def test_project_language_ignores_stale_component_shift_unit(self) -> None:
1421+
Component.objects.filter(pk=self.component.pk).update(priority=120)
1422+
high_component = self.create_po(name="High", priority=80, project=self.project)
1423+
high_translation = high_component.translation_set.get(
1424+
language=self.translation.language
1425+
)
1426+
translate_url = ProjectLanguage(
1427+
self.project, self.translation.language
1428+
).get_translate_url()
1429+
high_component_offset = high_translation.unit_set.count()
1430+
1431+
response = self.client.get(translate_url, {"offset": high_component_offset})
1432+
self.assertEqual(response.status_code, 200)
1433+
1434+
last_unit_id = Unit.objects.order_by("-pk").values_list("pk", flat=True)[0]
1435+
stale_unit_id = last_unit_id + 1
1436+
session = self.client.session
1437+
session_keys = session.keys()
1438+
search_key = next(key for key in session_keys if key.startswith("search_"))
1439+
session_result = session[search_key]
1440+
session_result["last_viewed_unit_id"] = stale_unit_id
1441+
session[search_key] = session_result
1442+
session.save()
1443+
1444+
response = self.client.get(translate_url, {"offset": high_component_offset + 1})
1445+
self.assertEqual(response.status_code, 200)
1446+
self.assertNotContains(response, "You have shifted from")
1447+
self.assertNotEqual(
1448+
self.client.session[search_key]["last_viewed_unit_id"], stale_unit_id
1449+
)
1450+
13571451
def test_revert_plural(self) -> None:
13581452
source = "Orangutan has %d banana.\n"
13591453
target = [

weblate/trans/tests/test_models.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from weblate.trans.models import (
2828
Announcement,
2929
AutoComponentList,
30+
Category,
3031
Change,
3132
Comment,
3233
Component,
@@ -57,7 +58,7 @@
5758
STATE_READONLY,
5859
STATE_TRANSLATED,
5960
)
60-
from weblate.utils.stats import GlobalStats
61+
from weblate.utils.stats import CategoryLanguage, GlobalStats, ProjectLanguage
6162
from weblate.utils.version import GIT_VERSION
6263

6364

@@ -1065,6 +1066,57 @@ def test_order_by_request(self) -> None:
10651066
).order_by_request({"sort_by": "position,timestamp"}, None)
10661067
self.assertEqual(multiple_ordered_unit.count(), 4)
10671068

1069+
def test_order_by_request_language_scope_orders_by_component(self) -> None:
1070+
category = Category.objects.create(
1071+
name="Docs", slug="docs", project=self.component.project
1072+
)
1073+
Component.objects.filter(pk=self.component.pk).update(
1074+
category=category, priority=120
1075+
)
1076+
high_component = self.create_po(
1077+
category=category,
1078+
name="High",
1079+
locked=True,
1080+
priority=80,
1081+
project=self.component.project,
1082+
)
1083+
language = Language.objects.get(code="cs")
1084+
high_translation = high_component.translation_set.get(language=language)
1085+
high_units = list(high_translation.unit_set.order_by("position"))
1086+
low_units = list(
1087+
self.component.translation_set.get(language=language).unit_set.order_by(
1088+
"position"
1089+
)
1090+
)
1091+
high_priority_unit = high_units[-1]
1092+
Unit.objects.filter(pk=high_priority_unit.pk).update(priority=200)
1093+
1094+
expected_ids = [
1095+
*(unit.pk for unit in low_units),
1096+
high_priority_unit.pk,
1097+
*(unit.pk for unit in high_units if unit.pk != high_priority_unit.pk),
1098+
]
1099+
1100+
scopes = [
1101+
ProjectLanguage(self.component.project, language),
1102+
CategoryLanguage(category, language),
1103+
]
1104+
for scope in scopes:
1105+
for form_data in (
1106+
{},
1107+
{"sort_by": "component,-priority"},
1108+
{"sort_by": "-component,-priority"},
1109+
):
1110+
ordered_ids = list(
1111+
Unit.objects.filter(
1112+
translation__component__in=(self.component, high_component),
1113+
translation__language=language,
1114+
)
1115+
.order_by_request(form_data, scope)
1116+
.values_list("pk", flat=True)
1117+
)
1118+
self.assertEqual(ordered_ids, expected_ids)
1119+
10681120
def test_get_max_length_no_pk(self) -> None:
10691121
unit = Unit.objects.filter(translation__language_code="cs")[0]
10701122
unit.pk = False

weblate/trans/views/edit.py

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,37 @@ def handle_suggestions(
621621
return HttpResponseRedirect(redirect_url)
622622

623623

624+
def handle_component_shift_notice(
625+
request: AuthenticatedHttpRequest, unit_set, search_result, unit
626+
) -> None:
627+
"""Show a warning when sequential navigation moves to another component."""
628+
last_viewed_unit_id = search_result.get("last_viewed_unit_id")
629+
if last_viewed_unit_id:
630+
try:
631+
previous_unit = unit_set.get(pk=last_viewed_unit_id)
632+
except Unit.DoesNotExist:
633+
previous_unit = None
634+
if (
635+
previous_unit is not None
636+
and unit.translation.component != previous_unit.translation.component
637+
):
638+
messages.warning(
639+
request,
640+
gettext("You have shifted from %(previous)s to %(current)s.")
641+
% {
642+
"previous": previous_unit.translation.full_slug,
643+
"current": unit.translation.full_slug,
644+
},
645+
)
646+
647+
search_result["last_viewed_unit_id"] = unit.id
648+
session_key = search_result["key"]
649+
if session_key in request.session:
650+
session_result = request.session[session_key]
651+
session_result["last_viewed_unit_id"] = unit.id
652+
request.session[session_key] = session_result
653+
654+
624655
@transaction.atomic
625656
def translate(request: AuthenticatedHttpRequest, path):
626657
"""Translate, suggest and search view."""
@@ -675,19 +706,7 @@ def translate(request: AuthenticatedHttpRequest, path):
675706
messages.error(request, gettext("Invalid search string!"))
676707
return redirect(obj)
677708

678-
last_viewed_unit_id = search_result.get("last_viewed_unit_id")
679-
if last_viewed_unit_id:
680-
previous_unit = unit_set.get(pk=last_viewed_unit_id)
681-
if unit.translation.component != previous_unit.translation.component:
682-
messages.warning(
683-
request,
684-
gettext("You have shifted from %(previous)s to %(current)s.")
685-
% {
686-
"previous": previous_unit.translation.full_slug,
687-
"current": unit.translation.full_slug,
688-
},
689-
)
690-
search_result["last_viewed_unit_id"] = unit.id
709+
handle_component_shift_notice(request, unit_set, search_result, unit)
691710

692711
# Some URLs we will most likely use
693712
base_unit_url = f"{obj.get_translate_url()}?{search_result['url']}&offset="

weblate/utils/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ def setup(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None: #
239239

240240
def get_sort_name(request: AuthenticatedHttpRequest, obj=None):
241241
"""Get sort name."""
242-
if isinstance(obj, (Project, Category)):
242+
if isinstance(obj, (Project, Category, ProjectLanguage, CategoryLanguage)):
243243
default = "component,-priority"
244244
elif hasattr(obj, "component") and obj.component.is_glossary:
245245
default = "source"

0 commit comments

Comments
 (0)