Skip to content

Commit 3e3349e

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 3e3349e

9 files changed

Lines changed: 494 additions & 6 deletions

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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@ 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, needs editing, and unfinished
175+
strings.
176+
173177
You can toggle notifications for watched projects and administered projects and it
174178
can be further tweaked (or muted) per project and component. Visit the component
175179
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,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Copyright © Michal Čihař <michal@weblate.org>
2+
#
3+
# SPDX-License-Identifier: GPL-3.0-or-later
4+
5+
# Generated by Django 6.0.5 on 2026-05-12 13:58
6+
7+
from django.db import migrations, models
8+
9+
10+
class Migration(migrations.Migration):
11+
dependencies = [
12+
("accounts", "0028_alter_profile_codesite_alter_profile_fediverse_and_more"),
13+
]
14+
15+
operations = [
16+
migrations.AlterField(
17+
model_name="subscription",
18+
name="notification",
19+
field=models.CharField(
20+
choices=[
21+
(
22+
"RepositoryNotification",
23+
"Operation was performed in the repository",
24+
),
25+
("LockNotification", "Component was locked or unlocked"),
26+
("LicenseNotification", "License was changed"),
27+
("ParseErrorNotification", "Parse error occurred"),
28+
("NewStringNotificaton", "String is available for translation"),
29+
(
30+
"TranslationActivitySummaryNotification",
31+
"Translation activity summary",
32+
),
33+
(
34+
"NewContributorNotificaton",
35+
"Contributor made their first translation",
36+
),
37+
("NewSuggestionNotificaton", "Suggestion was added"),
38+
("LanguageTranslatedNotificaton", "Language was translated"),
39+
("ComponentTranslatedNotificaton", "Component was translated"),
40+
("NewCommentNotificaton", "Comment was added"),
41+
("MentionCommentNotificaton", "You were mentioned in a comment"),
42+
(
43+
"LastAuthorCommentNotificaton",
44+
"Your translation received a comment",
45+
),
46+
("TranslatedStringNotificaton", "String was edited by user"),
47+
("ApprovedStringNotificaton", "String was approved"),
48+
("ChangedStringNotificaton", "String was changed"),
49+
(
50+
"NewTranslationNotificaton",
51+
"New language was added or requested",
52+
),
53+
(
54+
"NewComponentNotificaton",
55+
"New translation component was created",
56+
),
57+
("NewAnnouncementNotificaton", "Announcement was published"),
58+
("NewAlertNotificaton", "New alert emerged in a component"),
59+
("MergeFailureNotification", "Repository operation failed"),
60+
("PendingSuggestionsNotification", "Pending suggestions exist"),
61+
("ToDoStringsNotification", "Unfinished strings exist"),
62+
],
63+
max_length=100,
64+
),
65+
),
66+
]

weblate/accounts/notifications.py

Lines changed: 269 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
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
1415
from django.conf import settings
1516
from django.core.exceptions import ObjectDoesNotExist
1617
from django.core.signing import TimestampSigner
17-
from django.db.models import IntegerChoices, Q
18+
from django.db.models import Count, IntegerChoices, Q
19+
from django.db.models.functions import Coalesce
1820
from django.template.loader import render_to_string
1921
from django.urls import reverse
2022
from django.utils import timezone
@@ -46,6 +48,7 @@
4648

4749
if TYPE_CHECKING:
4850
from collections.abc import Iterable
51+
from datetime import datetime
4952

5053
from django.db.models import QuerySet
5154
from django_stubs_ext import StrOrPromise
@@ -637,6 +640,271 @@ class NewStringNotificaton(Notification):
637640
required_attr = "unit"
638641

639642

643+
@register_notification
644+
class TranslationActivitySummaryNotification(Notification):
645+
verbose_plural = verbose = pgettext_lazy(
646+
"Notification name", "Translation activity summary"
647+
)
648+
filter_languages = True
649+
650+
activity_fields: ClassVar[tuple[str, ...]] = (
651+
"added",
652+
"updated",
653+
"translated",
654+
"approved",
655+
"needs_editing",
656+
)
657+
activity_actions: ClassVar[dict[str, tuple[ActionEvents, ...]]] = {
658+
"added": (
659+
ActionEvents.NEW_UNIT,
660+
ActionEvents.NEW_UNIT_REPO,
661+
ActionEvents.NEW_UNIT_UPLOAD,
662+
),
663+
"updated": (
664+
ActionEvents.SOURCE_CHANGE,
665+
ActionEvents.STRING_REPO_UPDATE,
666+
ActionEvents.STRING_UPLOAD_UPDATE,
667+
),
668+
"translated": (
669+
ActionEvents.CHANGE,
670+
ActionEvents.NEW,
671+
ActionEvents.ACCEPT,
672+
),
673+
"approved": (ActionEvents.APPROVE,),
674+
"needs_editing": (ActionEvents.MARKED_EDIT,),
675+
}
676+
digest_template = "translation_activity_summary"
677+
since: datetime | None = None
678+
679+
@classmethod
680+
def get_freq_choices(cls) -> list[tuple[int, StrOrPromise]]:
681+
return [
682+
x
683+
for x in super().get_freq_choices()
684+
if x[0] != NotificationFrequency.FREQ_INSTANT
685+
]
686+
687+
@classmethod
688+
def get_activity_actions(cls) -> tuple[ActionEvents, ...]:
689+
return tuple(
690+
action for actions in cls.activity_actions.values() for action in actions
691+
)
692+
693+
@classmethod
694+
def get_activity_field(cls, action: int) -> str | None:
695+
for field, actions in cls.activity_actions.items():
696+
if action in actions:
697+
return field
698+
return None
699+
700+
@staticmethod
701+
def get_action_query(action: ActionEvents) -> str:
702+
with override("en"):
703+
action_name = str(action.label).lower().replace(" ", "-")
704+
return f"change_action:{action_name}"
705+
706+
def get_activity_query(self, actions: tuple[ActionEvents, ...]) -> str:
707+
if self.since is None:
708+
msg = "Activity summary period is not set"
709+
raise ValueError(msg)
710+
action_query = " OR ".join(self.get_action_query(action) for action in actions)
711+
if len(actions) > 1:
712+
action_query = f"({action_query})"
713+
return f"change_time:>={self.since.isoformat()} AND {action_query}"
714+
715+
@staticmethod
716+
def get_search_url(translation: Translation, query: str) -> str:
717+
return f"{translation.get_translate_url()}?{urlencode({'q': query})}"
718+
719+
def notify_daily(self) -> None:
720+
self.notify_activity_summary(NotificationFrequency.FREQ_DAILY, days=1)
721+
722+
def notify_weekly(self) -> None:
723+
self.notify_activity_summary(NotificationFrequency.FREQ_WEEKLY, weeks=1)
724+
725+
def notify_monthly(self) -> None:
726+
self.notify_activity_summary(NotificationFrequency.FREQ_MONTHLY, months=1)
727+
728+
def get_activity_change_filter(self, frequency: NotificationFrequency) -> Q:
729+
from weblate.accounts.models import Subscription # noqa: PLC0415
730+
731+
subscriptions = Subscription.objects.filter(
732+
notification=self.get_name(),
733+
frequency=frequency,
734+
user__is_active=True,
735+
user__is_bot=False,
736+
)
737+
if not subscriptions.exists():
738+
return Q(pk__in=())
739+
740+
if subscriptions.filter(
741+
scope__in=(NotificationScope.SCOPE_ALL, NotificationScope.SCOPE_ADMIN)
742+
).exists():
743+
return Q()
744+
745+
project_ids = set(
746+
subscriptions.filter(
747+
scope=NotificationScope.SCOPE_PROJECT, project__isnull=False
748+
).values_list("project_id", flat=True)
749+
)
750+
project_ids.update(
751+
subscriptions.filter(
752+
scope=NotificationScope.SCOPE_WATCHED,
753+
user__profile__watched__isnull=False,
754+
).values_list("user__profile__watched", flat=True)
755+
)
756+
component_ids = set(
757+
subscriptions.filter(
758+
scope=NotificationScope.SCOPE_COMPONENT, component__isnull=False
759+
).values_list("component_id", flat=True)
760+
)
761+
762+
query = Q()
763+
if project_ids:
764+
query |= Q(project_id__in=project_ids)
765+
if component_ids:
766+
query |= Q(component_id__in=component_ids)
767+
return query or Q(pk__in=())
768+
769+
def get_activity_change_rows(self, frequency: NotificationFrequency):
770+
return (
771+
Change.objects.filter(
772+
self.get_activity_change_filter(frequency),
773+
action__in=self.get_activity_actions(),
774+
timestamp__gte=self.since,
775+
translation__isnull=False,
776+
)
777+
.annotate(summary_unit_id=Coalesce("unit_id", "id"))
778+
.values("translation_id", "action", "user_id")
779+
.annotate(count=Count("summary_unit_id", distinct=True))
780+
)
781+
782+
def get_activity_summary_users(
783+
self,
784+
frequency: NotificationFrequency,
785+
translation: Translation,
786+
actor_user_id: int | None,
787+
) -> Iterable[User]:
788+
component = translation.component
789+
project = component.project
790+
last_user = None
791+
for subscription in self.get_subscriptions(
792+
None, project, component, translation, None
793+
):
794+
user = subscription.user
795+
if user == last_user or (
796+
actor_user_id is not None and user.pk == actor_user_id
797+
):
798+
continue
799+
800+
last_user = user
801+
if subscription.frequency != frequency:
802+
continue
803+
if not user.can_access_project(project):
804+
continue
805+
806+
user.current_subscription = subscription
807+
yield user
808+
809+
def notify_activity_summary(
810+
self,
811+
frequency: NotificationFrequency,
812+
*,
813+
days: int = 0,
814+
weeks: int = 0,
815+
months: int = 0,
816+
) -> None:
817+
self.since = timezone.now() - relativedelta(
818+
days=days, weeks=weeks, months=months
819+
)
820+
activity_rows = list(self.get_activity_change_rows(frequency))
821+
translation_ids = {row["translation_id"] for row in activity_rows}
822+
translations = {
823+
translation.pk: translation
824+
for translation in prefetch_stats(
825+
Translation.objects.filter(pk__in=translation_ids).prefetch()
826+
)
827+
}
828+
829+
users = {}
830+
notifications: dict[int, dict[int, dict[str, Any]]] = defaultdict(dict)
831+
for row in activity_rows:
832+
field = self.get_activity_field(row["action"])
833+
translation = translations.get(row["translation_id"])
834+
if field is None or translation is None:
835+
continue
836+
837+
for user in self.get_activity_summary_users(
838+
frequency, translation, row["user_id"]
839+
):
840+
users[user.pk] = user
841+
user_notifications = notifications[user.pk]
842+
summary = user_notifications.setdefault(
843+
translation.pk,
844+
{
845+
"translation": translation,
846+
**dict.fromkeys(self.activity_fields, 0),
847+
},
848+
)
849+
summary[field] += row["count"]
850+
851+
for userid, user_notifications in notifications.items():
852+
summaries = self.get_summary_rows(user_notifications, translations)
853+
if not summaries:
854+
continue
855+
user = users[userid]
856+
self.send_digest(
857+
user.profile.language,
858+
user.email,
859+
summaries=summaries,
860+
subscription=user.current_subscription,
861+
)
862+
863+
def get_summary_rows(
864+
self,
865+
summaries: dict[int, dict[str, Any]],
866+
translations: dict[int, Translation],
867+
) -> list[dict[str, Any]]:
868+
result = []
869+
for translation_id, summary in summaries.items():
870+
translation = translations.get(translation_id, summary["translation"])
871+
total = 0
872+
row = {"translation": translation}
873+
for field in self.activity_fields:
874+
count = summary[field]
875+
total += count
876+
row[field] = {
877+
"count": count,
878+
"url": self.get_search_url(
879+
translation,
880+
self.get_activity_query(self.activity_actions[field]),
881+
),
882+
}
883+
row["unfinished"] = {
884+
"count": translation.stats.todo,
885+
"url": self.get_search_url(translation, "state:<translated"),
886+
}
887+
row["total"] = total
888+
result.append(row)
889+
return sorted(result, key=lambda item: str(item["translation"]))
890+
891+
def get_context(
892+
self,
893+
change: Change | None = None,
894+
subscription: Subscription | None = None,
895+
extracontext: dict | None = None,
896+
*,
897+
changes: QuerySet[Change] | list[Change] | list[dict[str, Any]] | None = None,
898+
summaries: list[dict[str, Any]] | None = None,
899+
) -> dict[str, Any]:
900+
context = super().get_context(
901+
change, subscription, extracontext, changes=changes, summaries=summaries
902+
)
903+
if summaries:
904+
context["total_count"] = sum(item["total"] for item in summaries)
905+
return context
906+
907+
640908
@register_notification
641909
class NewContributorNotificaton(Notification):
642910
actions = (ActionEvents.NEW_CONTRIBUTOR,)

0 commit comments

Comments
 (0)