Skip to content

Commit 6f6b37e

Browse files
committed
feat(alerts): add unused glossary alert
Warn users that there is a unused language in the glossary. See #20088
1 parent 9681e40 commit 6f6b37e

6 files changed

Lines changed: 247 additions & 22 deletions

File tree

docs/devel/alerts.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Currently the following is covered:
2525
* Duplicate file mask used for linked components
2626
* Conflicting merge request repository setup
2727
* Component seems unused (configurable by :setting:`UNUSED_ALERT_DAYS`)
28+
* Unused glossary languages
2829

2930
The alerts are updated daily, or on related change (for example when
3031
:ref:`component` is changed or when repository is updated).

weblate/glossary/tasks.py

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from __future__ import annotations
66

7+
from typing import TYPE_CHECKING
8+
79
from django.db import transaction
810

911
from weblate.auth.models import get_anonymous
@@ -13,6 +15,9 @@
1315
from weblate.utils.lock import WeblateLockTimeoutError
1416
from weblate.utils.stats import prefetch_stats
1517

18+
if TYPE_CHECKING:
19+
from weblate.trans.models.translation import TranslationQuerySet
20+
1621

1722
@app.task(
1823
trail=False,
@@ -49,6 +54,36 @@ def sync_glossary_languages(pk: int, component: Component | None = None) -> None
4954
component.create_translations_immediate(force_scan=True)
5055

5156

57+
def get_stale_glossary_translations(
58+
project: Project, component: Component | None = None
59+
) -> TranslationQuerySet:
60+
"""
61+
Return glossary translations for languages unused by regular components.
62+
63+
The source translation is excluded because it is expected to exist even if
64+
there are no matching regular component translations.
65+
"""
66+
languages_in_non_glossary_components: set[int] = set(
67+
Translation.objects.filter(
68+
component__project=project, component__is_glossary=False
69+
).values_list("language_id", flat=True)
70+
)
71+
if not languages_in_non_glossary_components:
72+
return Translation.objects.none()
73+
74+
stale_glossaries = Translation.objects.filter(
75+
component__project=project, component__is_glossary=True
76+
)
77+
if component is not None:
78+
stale_glossaries = stale_glossaries.filter(component=component)
79+
80+
return (
81+
stale_glossaries.prefetch()
82+
.exclude(language__id__in=languages_in_non_glossary_components)
83+
.exclude_source()
84+
)
85+
86+
5287
@app.task(trail=False, autoretry_for=(Project.DoesNotExist, WeblateLockTimeoutError))
5388
def cleanup_stale_glossaries(project: int | Project) -> None:
5489
"""
@@ -66,20 +101,7 @@ def cleanup_stale_glossaries(project: int | Project) -> None:
66101
if isinstance(project, int):
67102
project = Project.objects.get(pk=project)
68103

69-
languages_in_non_glossary_components: set[int] = set(
70-
Translation.objects.filter(
71-
component__project=project, component__is_glossary=False
72-
).values_list("language_id", flat=True)
73-
)
74-
75-
glossary_translations = prefetch_stats(
76-
Translation.objects.filter(
77-
component__project=project, component__is_glossary=True
78-
)
79-
.prefetch()
80-
.exclude(language__id__in=languages_in_non_glossary_components)
81-
.exclude_source()
82-
)
104+
glossary_translations = prefetch_stats(get_stale_glossary_translations(project))
83105

84106
component_to_check = []
85107

weblate/glossary/tests.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@
1919
from weblate.glossary.models import get_glossary_terms, get_glossary_tsv
2020
from weblate.glossary.tasks import (
2121
cleanup_stale_glossaries,
22+
get_stale_glossary_translations,
2223
sync_terminology,
2324
)
2425
from weblate.lang.models import Language
26+
from weblate.trans.alerts.registry import update_alerts
2527
from weblate.trans.models import PendingUnitChange, Unit
2628
from weblate.trans.tests.test_views import ViewTestCase
2729
from weblate.trans.tests.utils import get_test_file
@@ -136,6 +138,35 @@ def add_term(self, source, target, context="") -> None:
136138
)
137139
self.glossary.invalidate_cache()
138140

141+
def make_glossary_language_stale(
142+
self, language_code: str, source: str | None = None
143+
) -> Translation:
144+
language = Language.objects.get(code=language_code)
145+
self.component.translation_set.filter(language=language).delete()
146+
glossary = self.glossary_component.translation_set.get(language=language)
147+
if source is not None:
148+
with self.captureOnCommitCallbacks(execute=True):
149+
glossary.add_unit(None, "", source, source, author=self.user)
150+
return glossary
151+
152+
def assert_unused_glossary_language_alert(self, *language_codes: str) -> None:
153+
if not language_codes:
154+
self.assertFalse(
155+
self.glossary_component.alert_set.filter(
156+
name="UnusedGlossaryLanguage"
157+
).exists()
158+
)
159+
return
160+
161+
alert = self.glossary_component.alert_set.get(name="UnusedGlossaryLanguage")
162+
self.assertEqual(
163+
{
164+
occurrence["language_code"]
165+
for occurrence in alert.details["occurrences"]
166+
},
167+
set(language_codes),
168+
)
169+
139170
def test_import(self) -> None:
140171
"""Test for importing of TBX into glossary."""
141172

@@ -651,6 +682,83 @@ def test_stale_glossaries_cleanup(self) -> None:
651682
self.glossary_component.translation_set.count(), initial_count - 1
652683
)
653684

685+
def test_stale_glossary_translations(self) -> None:
686+
self.assertFalse(get_stale_glossary_translations(self.project).exists())
687+
688+
self.make_glossary_language_stale("de")
689+
690+
self.assertEqual(
691+
list(
692+
get_stale_glossary_translations(self.project).values_list(
693+
"language_code", flat=True
694+
)
695+
),
696+
["de"],
697+
)
698+
699+
self.component.delete()
700+
701+
self.assertFalse(get_stale_glossary_translations(self.project).exists())
702+
703+
def test_unused_glossary_language_alert(self) -> None:
704+
glossary = self.make_glossary_language_stale("de", "unused de")
705+
706+
cleanup_stale_glossaries(self.project.id)
707+
708+
self.assertTrue(
709+
self.glossary_component.translation_set.filter(pk=glossary.pk).exists()
710+
)
711+
self.assert_unused_glossary_language_alert()
712+
713+
update_alerts(self.glossary_component, {"UnusedGlossaryLanguage"})
714+
715+
self.assert_unused_glossary_language_alert("de")
716+
alert = self.glossary_component.alert_set.get(name="UnusedGlossaryLanguage")
717+
removal_url = f"{glossary.get_absolute_url()}#organize"
718+
719+
self.assertFalse(self.user.has_perm("translation.delete", glossary))
720+
self.assertNotIn(removal_url, alert.render(self.user))
721+
722+
self.make_manager()
723+
self.user.clear_cache()
724+
self.assertTrue(self.user.has_perm("translation.delete", glossary))
725+
self.assertIn(removal_url, alert.render(self.user))
726+
727+
def test_unused_glossary_language_alert_ignores_blank_local_glossary(self) -> None:
728+
self.make_glossary_language_stale("de")
729+
730+
update_alerts(self.glossary_component, {"UnusedGlossaryLanguage"})
731+
732+
self.assert_unused_glossary_language_alert()
733+
734+
def test_unused_glossary_language_alert_ignores_matching_language(self) -> None:
735+
update_alerts(self.glossary_component, {"UnusedGlossaryLanguage"})
736+
737+
self.assert_unused_glossary_language_alert()
738+
739+
def test_unused_glossary_language_alert_ignores_glossary_only_project(self) -> None:
740+
self.component.delete()
741+
742+
update_alerts(self.glossary_component, {"UnusedGlossaryLanguage"})
743+
744+
self.assert_unused_glossary_language_alert()
745+
746+
def test_unused_glossary_language_alert_updates_on_removal(self) -> None:
747+
czech_glossary = self.make_glossary_language_stale("cs", "unused cs")
748+
german_glossary = self.make_glossary_language_stale("de", "unused de")
749+
update_alerts(self.glossary_component, {"UnusedGlossaryLanguage"})
750+
self.assert_unused_glossary_language_alert("cs", "de")
751+
752+
with self.captureOnCommitCallbacks(execute=True):
753+
german_glossary.remove(self.user)
754+
755+
self.assert_unused_glossary_language_alert("cs")
756+
757+
with self.captureOnCommitCallbacks(execute=True):
758+
czech_glossary.remove(self.user)
759+
760+
self.assert_unused_glossary_language_alert()
761+
654762
def test_prohibited_initial_character(self) -> None:
655763
"""Test that a prohibited initial character in views."""
656764
self.make_manager()
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{% load i18n permissions %}
2+
3+
<p>{% translate "The glossary contains languages not used by any regular component in this project." %}</p>
4+
5+
<p>{% translate "Remove the glossary language if it is no longer needed, or add matching translations to regular components if the glossary should be used." %}</p>
6+
7+
<p>{% translate "The following occurrences were found:" %}</p>
8+
9+
<table class="table table-sm">
10+
<thead>
11+
<tr>
12+
<th>{% translate "Language" %}</th>
13+
<th>{% translate "Language code" %}</th>
14+
<th></th>
15+
</tr>
16+
</thead>
17+
<tbody>
18+
{% for occurrence in occurrences %}
19+
<tr>
20+
<td>{{ occurrence.language }}</td>
21+
<td>{{ occurrence.language_code }}</td>
22+
<td>
23+
{% perm "translation.delete" occurrence.translation as user_can_delete_translation %}
24+
{% if user_can_delete_translation %}
25+
<a href="{{ occurrence.translation.get_absolute_url }}#organize">{% translate "Remove" %}</a>
26+
{% endif %}
27+
</td>
28+
</tr>
29+
{% endfor %}
30+
</tbody>
31+
</table>
32+
33+
{% include "trans/alert/occurrences-limit.html" %}

weblate/trans/alerts/config.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from weblate_language_data.ambiguous import AMBIGUOUS
1717

1818
from weblate.formats.models import FILE_FORMATS
19-
from weblate.trans.alerts.base import AlertSeverity, BaseAlert
19+
from weblate.trans.alerts.base import AlertSeverity, BaseAlert, MultiAlert
2020
from weblate.trans.alerts.registry import register
2121
from weblate.utils.requests import (
2222
format_validation_error,
@@ -341,3 +341,56 @@ class MonolingualGlossary(BaseAlert):
341341
@staticmethod
342342
def check_component(component: Component) -> bool | dict | None:
343343
return component.is_glossary and bool(component.template)
344+
345+
346+
@register
347+
class UnusedGlossaryLanguage(MultiAlert):
348+
verbose = gettext_lazy("Unused glossary language.")
349+
doc_page = "user/glossary"
350+
351+
def process_occurrences(
352+
self, occurrences: list[dict[str, Any]]
353+
) -> list[dict[str, Any]]:
354+
result = super().process_occurrences(occurrences)
355+
updates: dict[int, list[dict[str, Any]]] = {}
356+
for occurrence in result:
357+
if "translation_pk" not in occurrence:
358+
continue
359+
updates.setdefault(occurrence["translation_pk"], []).append(occurrence)
360+
if not updates:
361+
return result
362+
363+
# ruff: ignore[import-outside-top-level]
364+
from weblate.trans.models import Translation
365+
366+
for translation in Translation.objects.filter(pk__in=updates).select_related(
367+
"component", "language"
368+
):
369+
for occurrence in updates[translation.pk]:
370+
occurrence["translation"] = translation
371+
return result
372+
373+
@staticmethod
374+
def check_component(component: Component) -> bool | dict | None:
375+
if not component.is_glossary:
376+
return False
377+
378+
# ruff: ignore[import-outside-top-level]
379+
from weblate.glossary.tasks import get_stale_glossary_translations
380+
381+
# ruff: ignore[import-outside-top-level]
382+
from weblate.utils.stats import prefetch_stats
383+
384+
occurrences = [
385+
{
386+
"language_code": translation.language_code,
387+
"translation_pk": translation.pk,
388+
}
389+
for translation in prefetch_stats(
390+
get_stale_glossary_translations(component.project, component)
391+
)
392+
if not translation.can_be_deleted()
393+
]
394+
if occurrences:
395+
return {"occurrences": occurrences}
396+
return False

weblate/trans/models/translation.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2066,7 +2066,11 @@ def remove(self, user: User) -> None:
20662066
cleanup_stale_glossaries,
20672067
)
20682068

2069+
# ruff: ignore[import-outside-top-level]
2070+
from weblate.trans.alerts.registry import update_alerts
2071+
20692072
author = user.get_author_name()
2073+
component = self.component
20702074
# Log
20712075
self.log_info("removing %s as %s", self.filenames, author)
20722076

@@ -2079,22 +2083,26 @@ def remove(self, user: User) -> None:
20792083
# to ensure add-ons operate on the updated translation set
20802084
self.delete()
20812085
transaction.on_commit(self.stats.update_parents)
2082-
transaction.on_commit(self.component.schedule_update_checks)
2086+
transaction.on_commit(component.schedule_update_checks)
2087+
if component.is_glossary:
2088+
transaction.on_commit(
2089+
lambda: update_alerts(component, {"UnusedGlossaryLanguage"})
2090+
)
20832091

20842092
# Remove file from VCS
20852093
if any(os.path.exists(name) for name in self.filenames):
2086-
with self.component.track_local_head_change():
2094+
with component.track_local_head_change():
20872095
# Notify add-ons (they may update LINGUAS, configure, etc.)
20882096
translation_post_remove.send(sender=self.__class__, translation=self)
20892097

20902098
# Remove files and commit together with add-on changes
2091-
self.component.repository.remove(
2099+
component.repository.remove(
20922100
self.filenames,
20932101
commit_message,
20942102
author,
20952103
extra_commit_files=self.addon_commit_files or None,
20962104
)
2097-
self.component.push_if_needed()
2105+
component.push_if_needed()
20982106

20992107
# Remove blank directory if still present (appstore)
21002108
filename = Path(self.get_filename())
@@ -2103,14 +2111,14 @@ def remove(self, user: User) -> None:
21032111
filename.rmdir()
21042112

21052113
# Record change
2106-
self.component.change_set.create(
2114+
component.change_set.create(
21072115
action=ActionEvents.REMOVE_TRANSLATION,
21082116
target=self.filename,
21092117
user=user,
21102118
author=user,
21112119
)
2112-
if not self.component.is_glossary:
2113-
cleanup_stale_glossaries.delay_on_commit(self.component.project.id)
2120+
if not component.is_glossary:
2121+
cleanup_stale_glossaries.delay_on_commit(component.project.id)
21142122

21152123
def handle_store_change(
21162124
self,

0 commit comments

Comments
 (0)