|
8 | 8 | from copy import copy |
9 | 9 | from email.utils import formataddr |
10 | 10 | from typing import TYPE_CHECKING, Any, ClassVar, cast |
| 11 | +from urllib.parse import urlencode |
11 | 12 | from uuid import uuid4 |
12 | 13 |
|
13 | 14 | from dateutil.relativedelta import relativedelta |
14 | 15 | from django.conf import settings |
15 | 16 | from django.core.exceptions import ObjectDoesNotExist |
16 | 17 | 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 |
18 | 20 | from django.template.loader import render_to_string |
19 | 21 | from django.urls import reverse |
20 | 22 | from django.utils import timezone |
|
46 | 48 |
|
47 | 49 | if TYPE_CHECKING: |
48 | 50 | from collections.abc import Iterable |
| 51 | + from datetime import datetime |
49 | 52 |
|
50 | 53 | from django.db.models import QuerySet |
51 | 54 | from django_stubs_ext import StrOrPromise |
@@ -637,6 +640,271 @@ class NewStringNotificaton(Notification): |
637 | 640 | required_attr = "unit" |
638 | 641 |
|
639 | 642 |
|
| 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 | + |
640 | 908 | @register_notification |
641 | 909 | class NewContributorNotificaton(Notification): |
642 | 910 | actions = (ActionEvents.NEW_CONTRIBUTOR,) |
|
0 commit comments