Skip to content

Commit 25b9e5d

Browse files
committed
feat(notifications): add translation activity summary notification
This allows users to follow activity without being overwhelmed with all the details. Fixes #13071
1 parent 1fd6931 commit 25b9e5d

7 files changed

Lines changed: 337 additions & 1 deletion

File tree

docs/changes.rst

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

88
* Added :ref:`file_format_params` for :ref:`markdown`, including ``line_max_length``, ``md_extract_code_blocks``, ``md_extract_frontmatter``, and ``md_no_placeholders``.
99
* Added :ref:`mdx` support for translating Markdown text while preserving JSX syntax.
10+
* Added a digest-only translation activity summary notification, see :ref:`notifications`.
1011
* :ref:`CSV <csv>` and :ref:`XLSX <xlsx>` downloads in :ref:`download` now export plural strings as separate plural-form rows that can be imported back.
1112
* :ref:`file_format_params` now include ``po_set_language_team``, ``po_set_last_translator``, ``po_set_x_generator``, and ``po_report_msgid_bugs_to`` to control whether Weblate updates the ``Language-Team``, ``Last-Translator``, ``X-Generator``, and ``Report-Msgid-Bugs-To`` headers in Gettext PO and POT files.
1213
* Added a :ref:`backup-management-command` to run configured backup services synchronously.

docs/user/profile.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,9 @@ example about new strings to translate), while some trigger at component level
170170
(for example merge errors). These two groups of notifications are visually
171171
separated in the settings.
172172

173+
The :guilabel:`Translation activity summary` notification is digest-only and
174+
summarizes added, updated, translated, approved, and unfinished strings.
175+
173176
You can toggle notifications for watched projects and administered projects and it
174177
can be further tweaked (or muted) per project and component. Visit the component
175178
overview page and select appropriate choice from the :guilabel:`Watching` menu.

weblate/accounts/data.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
(
3535
NotificationScope.SCOPE_WATCHED,
3636
NotificationFrequency.FREQ_WEEKLY,
37-
"NewStringNotificaton",
37+
"TranslationActivitySummaryNotification",
3838
),
3939
(
4040
NotificationScope.SCOPE_ADMIN,

weblate/accounts/notifications.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from copy import copy
99
from email.utils import formataddr
1010
from typing import TYPE_CHECKING, Any, ClassVar, cast
11+
from urllib.parse import urlencode
1112
from uuid import uuid4
1213

1314
from dateutil.relativedelta import relativedelta
@@ -46,6 +47,7 @@
4647

4748
if TYPE_CHECKING:
4849
from collections.abc import Iterable
50+
from datetime import datetime
4951

5052
from django.db.models import QuerySet
5153
from django_stubs_ext import StrOrPromise
@@ -637,6 +639,202 @@ class NewStringNotificaton(Notification):
637639
required_attr = "unit"
638640

639641

642+
@register_notification
643+
class TranslationActivitySummaryNotification(Notification):
644+
verbose_plural = verbose = pgettext_lazy(
645+
"Notification name", "Translation activity summary"
646+
)
647+
filter_languages = True
648+
649+
activity_fields: ClassVar[tuple[str, ...]] = (
650+
"added",
651+
"updated",
652+
"translated",
653+
"approved",
654+
"needs_editing",
655+
)
656+
activity_actions: ClassVar[dict[str, tuple[ActionEvents, ...]]] = {
657+
"added": (
658+
ActionEvents.NEW_UNIT,
659+
ActionEvents.NEW_UNIT_REPO,
660+
ActionEvents.NEW_UNIT_UPLOAD,
661+
),
662+
"updated": (
663+
ActionEvents.SOURCE_CHANGE,
664+
ActionEvents.STRING_REPO_UPDATE,
665+
ActionEvents.STRING_UPLOAD_UPDATE,
666+
),
667+
"translated": (
668+
ActionEvents.CHANGE,
669+
ActionEvents.NEW,
670+
ActionEvents.ACCEPT,
671+
),
672+
"approved": (ActionEvents.APPROVE,),
673+
"needs_editing": (ActionEvents.MARKED_EDIT,),
674+
}
675+
digest_template = "translation_activity_summary"
676+
since: datetime | None = None
677+
678+
@classmethod
679+
def get_freq_choices(cls) -> list[tuple[int, StrOrPromise]]:
680+
return [
681+
x
682+
for x in super().get_freq_choices()
683+
if x[0] != NotificationFrequency.FREQ_INSTANT
684+
]
685+
686+
@classmethod
687+
def get_activity_actions(cls) -> tuple[ActionEvents, ...]:
688+
return tuple(
689+
action for actions in cls.activity_actions.values() for action in actions
690+
)
691+
692+
@classmethod
693+
def get_activity_field(cls, action: int) -> str | None:
694+
for field, actions in cls.activity_actions.items():
695+
if action in actions:
696+
return field
697+
return None
698+
699+
@staticmethod
700+
def get_action_query(action: ActionEvents) -> str:
701+
with override("en"):
702+
action_name = str(action.label).lower().replace(" ", "-")
703+
return f"change_action:{action_name}"
704+
705+
def get_activity_query(self, actions: tuple[ActionEvents, ...]) -> str:
706+
if self.since is None:
707+
msg = "Activity summary period is not set"
708+
raise ValueError(msg)
709+
action_query = " OR ".join(self.get_action_query(action) for action in actions)
710+
if len(actions) > 1:
711+
action_query = f"({action_query})"
712+
return f"change_time:>={self.since.isoformat()} AND {action_query}"
713+
714+
@staticmethod
715+
def get_search_url(translation: Translation, query: str) -> str:
716+
return f"{translation.get_translate_url()}?{urlencode({'q': query})}"
717+
718+
def notify_daily(self) -> None:
719+
self.notify_activity_summary(NotificationFrequency.FREQ_DAILY, days=1)
720+
721+
def notify_weekly(self) -> None:
722+
self.notify_activity_summary(NotificationFrequency.FREQ_WEEKLY, weeks=1)
723+
724+
def notify_monthly(self) -> None:
725+
self.notify_activity_summary(NotificationFrequency.FREQ_MONTHLY, months=1)
726+
727+
def notify_activity_summary(
728+
self,
729+
frequency: NotificationFrequency,
730+
*,
731+
days: int = 0,
732+
weeks: int = 0,
733+
months: int = 0,
734+
) -> None:
735+
self.since = timezone.now() - relativedelta(
736+
days=days, weeks=weeks, months=months
737+
)
738+
changes = Change.objects.filter(
739+
action__in=self.get_activity_actions(),
740+
timestamp__gte=self.since,
741+
).prefetch_for_render()
742+
743+
users = {}
744+
notifications: dict[int, dict[int, dict[str, Any]]] = defaultdict(dict)
745+
for change in changes:
746+
change.fill_in_prefetched()
747+
field = self.get_activity_field(change.action)
748+
if field is None or change.translation is None:
749+
continue
750+
for user in self.get_users(frequency, change):
751+
if change.project is not None and not user.can_access_project(
752+
change.project
753+
):
754+
continue
755+
users[user.pk] = user
756+
user_notifications = notifications[user.pk]
757+
summary = user_notifications.setdefault(
758+
change.translation.pk,
759+
{
760+
"translation": change.translation,
761+
**{
762+
activity_field: set()
763+
for activity_field in self.activity_fields
764+
},
765+
},
766+
)
767+
summary[field].add(change.unit_id or change.pk)
768+
769+
translation_ids = {
770+
translation_id
771+
for user_notifications in notifications.values()
772+
for translation_id in user_notifications
773+
}
774+
translations = {
775+
translation.pk: translation
776+
for translation in prefetch_stats(
777+
Translation.objects.filter(pk__in=translation_ids).prefetch()
778+
)
779+
}
780+
781+
for userid, user_notifications in notifications.items():
782+
summaries = self.get_summary_rows(user_notifications, translations)
783+
if not summaries:
784+
continue
785+
user = users[userid]
786+
self.send_digest(
787+
user.profile.language,
788+
user.email,
789+
summaries=summaries,
790+
subscription=user.current_subscription,
791+
)
792+
793+
def get_summary_rows(
794+
self,
795+
summaries: dict[int, dict[str, Any]],
796+
translations: dict[int, Translation],
797+
) -> list[dict[str, Any]]:
798+
result = []
799+
for translation_id, summary in summaries.items():
800+
translation = translations.get(translation_id, summary["translation"])
801+
total = 0
802+
row = {"translation": translation}
803+
for field in self.activity_fields:
804+
count = len(summary[field])
805+
total += count
806+
row[field] = {
807+
"count": count,
808+
"url": self.get_search_url(
809+
translation,
810+
self.get_activity_query(self.activity_actions[field]),
811+
),
812+
}
813+
row["unfinished"] = {
814+
"count": translation.stats.todo,
815+
"url": self.get_search_url(translation, "state:<translated"),
816+
}
817+
row["total"] = total
818+
result.append(row)
819+
return sorted(result, key=lambda item: str(item["translation"]))
820+
821+
def get_context(
822+
self,
823+
change: Change | None = None,
824+
subscription: Subscription | None = None,
825+
extracontext: dict | None = None,
826+
*,
827+
changes: QuerySet[Change] | list[Change] | list[dict[str, Any]] | None = None,
828+
summaries: list[dict[str, Any]] | None = None,
829+
) -> dict[str, Any]:
830+
context = super().get_context(
831+
change, subscription, extracontext, changes=changes, summaries=summaries
832+
)
833+
if summaries:
834+
context["total_count"] = sum(item["total"] for item in summaries)
835+
return context
836+
837+
640838
@register_notification
641839
class NewContributorNotificaton(Notification):
642840
actions = (ActionEvents.NEW_CONTRIBUTOR,)

weblate/accounts/tests/test_notifications.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@
1414
from django.test import SimpleTestCase
1515
from django.test.utils import override_settings
1616

17+
from weblate.accounts.data import DEFAULT_NOTIFICATIONS
1718
from weblate.accounts.models import AuditLog, Profile, Subscription
1819
from weblate.accounts.notifications import (
1920
RECIPIENT_USERNAME_HEADER,
2021
MergeFailureNotification,
2122
NotificationFrequency,
2223
NotificationScope,
24+
TranslationActivitySummaryNotification,
2325
get_email_headers,
2426
get_notification_emails,
2527
)
@@ -114,6 +116,32 @@ def test_raw_notification_has_no_recipient_username(self) -> None:
114116

115117
self.assertNotIn(RECIPIENT_USERNAME_HEADER, messages[0]["headers"])
116118

119+
def test_activity_summary_digest_only(self) -> None:
120+
choices = {
121+
frequency
122+
for frequency, _label in TranslationActivitySummaryNotification.get_freq_choices()
123+
}
124+
125+
self.assertNotIn(NotificationFrequency.FREQ_INSTANT, choices)
126+
127+
def test_activity_summary_default_notification(self) -> None:
128+
self.assertIn(
129+
(
130+
NotificationScope.SCOPE_WATCHED,
131+
NotificationFrequency.FREQ_WEEKLY,
132+
"TranslationActivitySummaryNotification",
133+
),
134+
DEFAULT_NOTIFICATIONS,
135+
)
136+
self.assertNotIn(
137+
(
138+
NotificationScope.SCOPE_WATCHED,
139+
NotificationFrequency.FREQ_WEEKLY,
140+
"NewStringNotificaton",
141+
),
142+
DEFAULT_NOTIFICATIONS,
143+
)
144+
117145

118146
@override_settings(
119147
TEMPLATES=TEMPLATES_RAISE,
@@ -162,6 +190,9 @@ def setUp(self) -> None:
162190
notification=notification,
163191
frequency=NotificationFrequency.FREQ_INSTANT,
164192
)
193+
Subscription.objects.filter(
194+
user=self.user, notification="TranslationActivitySummaryNotification"
195+
).delete()
165196
self.thirduser = User.objects.create_user(
166197
"thirduser", "noreply+third@example.org", "testpassword"
167198
)
@@ -590,6 +621,36 @@ def test_digest_new_lang(self) -> None:
590621
subj="New language was added or requested",
591622
)
592623

624+
def test_translation_activity_summary(self) -> None:
625+
self.user.subscription_set.all().delete()
626+
self.user.subscription_set.create(
627+
scope=NotificationScope.SCOPE_WATCHED,
628+
notification="TranslationActivitySummaryNotification",
629+
frequency=NotificationFrequency.FREQ_WEEKLY,
630+
)
631+
unit = self.get_unit()
632+
unit.change_set.create(action=ActionEvents.NEW_UNIT)
633+
unit.change_set.create(action=ActionEvents.SOURCE_CHANGE)
634+
unit.change_set.create(user=self.anotheruser, action=ActionEvents.CHANGE)
635+
unit.change_set.create(user=self.anotheruser, action=ActionEvents.APPROVE)
636+
unit.change_set.create(user=self.anotheruser, action=ActionEvents.MARKED_EDIT)
637+
638+
self.assertEqual(len(mail.outbox), 0)
639+
640+
notify_weekly()
641+
642+
self.validate_notifications(1, "[Weblate] Translation activity summary")
643+
content = mail.outbox[0].alternatives[0][0]
644+
self.assertIn("Translation activity in this period", content)
645+
self.assertIn("Test/Test — Czech", content)
646+
self.assertIn("change_action%3Astring-added", content)
647+
self.assertIn("change_action%3Asource-string-changed", content)
648+
self.assertIn("change_action%3Atranslation-changed", content)
649+
self.assertIn("change_action%3Atranslation-approved", content)
650+
self.assertIn("change_action%3Amarked-for-edit", content)
651+
self.assertIn("state%3A%3Ctranslated", content)
652+
self.assertNotIn("Hello, world", content)
653+
593654
def test_reminder(
594655
self,
595656
frequency=NotificationFrequency.FREQ_DAILY,

0 commit comments

Comments
 (0)