|
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 |
|
46 | 47 |
|
47 | 48 | if TYPE_CHECKING: |
48 | 49 | from collections.abc import Iterable |
| 50 | + from datetime import datetime |
49 | 51 |
|
50 | 52 | from django.db.models import QuerySet |
51 | 53 | from django_stubs_ext import StrOrPromise |
@@ -637,6 +639,202 @@ class NewStringNotificaton(Notification): |
637 | 639 | required_attr = "unit" |
638 | 640 |
|
639 | 641 |
|
| 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 | + |
640 | 838 | @register_notification |
641 | 839 | class NewContributorNotificaton(Notification): |
642 | 840 | actions = (ActionEvents.NEW_CONTRIBUTOR,) |
|
0 commit comments