Browse Source

ref(notifications): Clean ActivityNotifications (#25554)

Marcos Gaeta 3 years ago
parent
commit
04a1b7cc7c

+ 10 - 45
src/sentry/notifications/activity/base.py

@@ -1,14 +1,15 @@
 import re
 import re
-from typing import Any, Mapping, MutableMapping, Optional, Set, Tuple
+from typing import Any, Mapping, MutableMapping, Optional, Tuple
 from urllib.parse import urlparse, urlunparse
 from urllib.parse import urlparse, urlunparse
 
 
 from django.utils.html import escape
 from django.utils.html import escape
 from django.utils.safestring import SafeString, mark_safe
 from django.utils.safestring import SafeString, mark_safe
 
 
-from sentry.models import Activity, GroupSubscription, User, UserOption
+from sentry.models import Activity, User
 from sentry.notifications.notify import notify
 from sentry.notifications.notify import notify
 from sentry.notifications.types import GroupSubscriptionReason
 from sentry.notifications.types import GroupSubscriptionReason
 from sentry.notifications.utils.avatar import avatar_as_html
 from sentry.notifications.utils.avatar import avatar_as_html
+from sentry.notifications.utils.participants import get_participants_for_group
 from sentry.types.integrations import ExternalProviders
 from sentry.types.integrations import ExternalProviders
 from sentry.utils.http import absolute_uri
 from sentry.utils.http import absolute_uri
 
 
@@ -23,48 +24,6 @@ class ActivityNotification:
     def should_email(self) -> bool:
     def should_email(self) -> bool:
         return True
         return True
 
 
-    def get_providers_from_which_to_remove_user(
-        self,
-        user: User,
-        participants_by_provider: Mapping[ExternalProviders, Mapping[User, int]],
-    ) -> Set[ExternalProviders]:
-        """
-        Given a mapping of provider to mappings of users to why they should receive
-        notifications for an activity, return the set of providers where the user
-        has opted out of receiving notifications.
-        """
-
-        providers = {
-            provider
-            for provider, participants in participants_by_provider.items()
-            if user in participants
-        }
-        if (
-            providers
-            and UserOption.objects.get_value(user, key="self_notifications", default="0") == "0"
-        ):
-            return providers
-        return set()
-
-    def get_participants(self) -> Mapping[ExternalProviders, Mapping[User, int]]:
-        # TODO(dcramer): not used yet today except by Release's
-        if not self.group:
-            return {}
-
-        participants_by_provider: MutableMapping[
-            ExternalProviders, MutableMapping[User, int]
-        ] = GroupSubscription.objects.get_participants(self.group)
-        user_option = self.activity.user
-        if user_option:
-            # Optionally remove the actor that created the activity from the recipients list.
-            providers = self.get_providers_from_which_to_remove_user(
-                user_option, participants_by_provider
-            )
-            for provider in providers:
-                del participants_by_provider[provider][user_option]
-
-        return participants_by_provider
-
     def get_template(self) -> str:
     def get_template(self) -> str:
         return "sentry/emails/activity/generic.txt"
         return "sentry/emails/activity/generic.txt"
 
 
@@ -78,6 +37,12 @@ class ActivityNotification:
         referrer = re.sub("Notification$", "Email", self.__class__.__name__)
         referrer = re.sub("Notification$", "Email", self.__class__.__name__)
         return str(self.group.get_absolute_url(params={"referrer": referrer}))
         return str(self.group.get_absolute_url(params={"referrer": referrer}))
 
 
+    def get_participants_with_group_subscription_reason(
+        self,
+    ) -> Mapping[ExternalProviders, Mapping[User, int]]:
+        """ This is overridden by the activity subclasses. """
+        return get_participants_for_group(self.group, self.activity.user)
+
     def get_base_context(self) -> MutableMapping[str, Any]:
     def get_base_context(self) -> MutableMapping[str, Any]:
         """ The most basic context shared by every notification type. """
         """ The most basic context shared by every notification type. """
         activity = self.activity
         activity = self.activity
@@ -184,7 +149,7 @@ class ActivityNotification:
         if not self.should_email():
         if not self.should_email():
             return
             return
 
 
-        participants_by_provider = self.get_participants()
+        participants_by_provider = self.get_participants_with_group_subscription_reason()
         if not participants_by_provider:
         if not participants_by_provider:
             return
             return
 
 

+ 7 - 20
src/sentry/notifications/activity/new_processing_issues.py

@@ -1,35 +1,22 @@
-from typing import Any, Iterable, MutableMapping
+from typing import Any, MutableMapping
 
 
-from sentry.models import Activity, EventError, Mapping, NotificationSetting, User
+from sentry.models import Activity, Mapping, NotificationSetting, User
 from sentry.notifications.types import GroupSubscriptionReason
 from sentry.notifications.types import GroupSubscriptionReason
+from sentry.notifications.utils import summarize_issues
 from sentry.types.integrations import ExternalProviders
 from sentry.types.integrations import ExternalProviders
 from sentry.utils.http import absolute_uri
 from sentry.utils.http import absolute_uri
 
 
 from .base import ActivityNotification
 from .base import ActivityNotification
 
 
 
 
-def summarize_issues(issues: Iterable[Any]) -> Iterable[Mapping[str, str]]:
-    rv = []
-    for issue in issues:
-        extra_info = None
-        msg_d = dict(issue["data"])
-        msg_d["type"] = issue["type"]
-
-        if "image_path" in issue["data"]:
-            extra_info = issue["data"]["image_path"].rsplit("/", 1)[-1]
-            if "image_arch" in issue["data"]:
-                extra_info = "{} ({})".format(extra_info, issue["data"]["image_arch"])
-
-        rv.append({"message": EventError(msg_d).message, "extra_info": extra_info})
-    return rv
-
-
 class NewProcessingIssuesActivityNotification(ActivityNotification):
 class NewProcessingIssuesActivityNotification(ActivityNotification):
     def __init__(self, activity: Activity) -> None:
     def __init__(self, activity: Activity) -> None:
-        ActivityNotification.__init__(self, activity)
+        super().__init__(activity)
         self.issues = summarize_issues(self.activity.data["issues"])
         self.issues = summarize_issues(self.activity.data["issues"])
 
 
-    def get_participants(self) -> Mapping[ExternalProviders, Mapping[User, int]]:
+    def get_participants_with_group_subscription_reason(
+        self,
+    ) -> Mapping[ExternalProviders, Mapping[User, int]]:
         users_by_provider = NotificationSetting.objects.get_notification_recipients(self.project)
         users_by_provider = NotificationSetting.objects.get_notification_recipients(self.project)
         return {
         return {
             provider: {user: GroupSubscriptionReason.processing_issue for user in users}
             provider: {user: GroupSubscriptionReason.processing_issue for user in users}

+ 50 - 171
src/sentry/notifications/activity/release.py

@@ -1,34 +1,19 @@
-from collections import defaultdict
-from typing import Any, List, Mapping, MutableMapping, Optional, Set
-
-from django.db.models import Count
-
-from sentry.db.models.query import in_iexact
-from sentry.models import (
-    Activity,
-    CommitFileChange,
-    Deploy,
-    Environment,
-    Group,
-    GroupLink,
-    NotificationSetting,
-    ProjectTeam,
-    Release,
-    ReleaseCommit,
-    Repository,
-    User,
-    UserEmail,
-)
-from sentry.notifications.helpers import (
-    get_deploy_values_by_provider,
-    transform_to_notification_settings_by_user,
-)
-from sentry.notifications.notify import notification_providers
-from sentry.notifications.types import (
-    GroupSubscriptionReason,
-    NotificationSettingOptionValues,
-    NotificationSettingTypes,
+from typing import Any, Iterable, List, Mapping, MutableMapping, Optional, Set
+
+from sentry.models import Activity, Project, User
+from sentry.notifications.utils import (
+    get_commits_for_release,
+    get_deploy,
+    get_environment_for_deploy,
+    get_file_count,
+    get_group_counts_by_project,
+    get_projects,
+    get_release,
+    get_repos,
+    get_users_by_emails,
+    get_users_by_teams,
 )
 )
+from sentry.notifications.utils.participants import get_participants_for_release
 from sentry.types.integrations import ExternalProviders
 from sentry.types.integrations import ExternalProviders
 from sentry.utils.compat import zip
 from sentry.utils.compat import zip
 from sentry.utils.http import absolute_uri
 from sentry.utils.http import absolute_uri
@@ -40,174 +25,68 @@ class ReleaseActivityNotification(ActivityNotification):
     def __init__(self, activity: Activity) -> None:
     def __init__(self, activity: Activity) -> None:
         super().__init__(activity)
         super().__init__(activity)
         self.organization = self.project.organization
         self.organization = self.project.organization
-        self.user_id_team_lookup: Optional[MutableMapping[int, List[int]]] = None
+        self.user_id_team_lookup: Optional[Mapping[int, List[int]]] = None
         self.email_list: Set[str] = set()
         self.email_list: Set[str] = set()
         self.user_ids: Set[int] = set()
         self.user_ids: Set[int] = set()
-
-        try:
-            self.deploy = Deploy.objects.get(id=activity.data["deploy_id"])
-        except Deploy.DoesNotExist:
-            self.deploy = None
-
-        try:
-            self.release = Release.objects.get(
-                organization_id=self.project.organization_id, version=activity.data["version"]
-            )
-        except Release.DoesNotExist:
-            self.release = None
-            self.repos = []
-            self.projects = []
-        else:
-            self.projects = list(self.release.projects.all())
-            self.commit_list = [
-                rc.commit
-                for rc in ReleaseCommit.objects.filter(release=self.release).select_related(
-                    "commit", "commit__author"
-                )
-            ]
-            repos = {
-                r_id: {"name": r_name, "commits": []}
-                for r_id, r_name in Repository.objects.filter(
-                    organization_id=self.project.organization_id,
-                    id__in={c.repository_id for c in self.commit_list},
-                ).values_list("id", "name")
-            }
-
-            self.email_list = {c.author.email for c in self.commit_list if c.author}
-            if self.email_list:
-                users = {
-                    ue.email: ue.user
-                    for ue in UserEmail.objects.filter(
-                        in_iexact("email", self.email_list),
-                        is_verified=True,
-                        user__sentry_orgmember_set__organization=self.organization,
-                    ).select_related("user")
-                }
-                self.user_ids = {u.id for u in users.values()}
-
-            else:
-                users = {}
-
-            for commit in self.commit_list:
-                repos[commit.repository_id]["commits"].append(
-                    (commit, users.get(commit.author.email) if commit.author_id else None)
-                )
-
-            self.repos = list(repos.values())
-
-            self.environment = (
-                Environment.objects.get(id=self.deploy.environment_id).name or "Default Environment"
-            )
-
-            self.group_counts_by_project = dict(
-                Group.objects.filter(
-                    project__in=self.projects,
-                    id__in=GroupLink.objects.filter(
-                        linked_type=GroupLink.LinkedType.commit,
-                        linked_id__in=ReleaseCommit.objects.filter(
-                            release=self.release
-                        ).values_list("commit_id", flat=True),
-                    ).values_list("group_id", flat=True),
-                )
-                .values_list("project")
-                .annotate(num_groups=Count("id"))
-            )
+        self.deploy = get_deploy(activity)
+
+        self.release = get_release(activity, self.organization)
+        if not self.release:
+            self.repos: Iterable[Mapping[str, Any]] = set()
+            self.projects: Set[Project] = set()
+            self.version = "unknown"
+            return
+
+        self.projects = set(self.release.projects.all())
+        self.commit_list = get_commits_for_release(self.release)
+        self.email_list = {c.author.email for c in self.commit_list if c.author}
+        users = get_users_by_emails(self.email_list, self.organization)
+        self.user_ids = {u.id for u in users.values()}
+        self.repos = get_repos(self.commit_list, users, self.organization)
+        self.environment = get_environment_for_deploy(self.deploy)
+        self.group_counts_by_project = get_group_counts_by_project(self.release, self.projects)
+        self.version = self.release.version
 
 
     def should_email(self) -> bool:
     def should_email(self) -> bool:
         return bool(self.release and self.deploy)
         return bool(self.release and self.deploy)
 
 
-    def get_reason(self, user: User, value: NotificationSettingOptionValues) -> Optional[int]:
-        # Members who opt into all deploy emails.
-        if value == NotificationSettingOptionValues.ALWAYS:
-            return GroupSubscriptionReason.deploy_setting
-
-        # Members which have been seen in the commit log.
-        elif value == NotificationSettingOptionValues.COMMITTED_ONLY and user.id in self.user_ids:
-            return GroupSubscriptionReason.committed
-        return None
-
-    def get_participants(self) -> Mapping[ExternalProviders, Mapping[User, int]]:
-        # Collect all users with verified emails on a team in the related projects.
-        users = list(User.objects.get_team_members_with_verified_email_for_projects(self.projects))
-
-        # Get all the involved users' settings for deploy-emails (including
-        # users' organization-independent settings.)
-        notification_settings = NotificationSetting.objects.get_for_users_by_parent(
-            NotificationSettingTypes.DEPLOY,
-            users=users,
-            parent=self.organization,
-        )
-        notification_settings_by_user = transform_to_notification_settings_by_user(
-            notification_settings, users
-        )
-
-        # Map users to their setting value. Prioritize user/org specific, then
-        # user default, then product default.
-        users_to_reasons_by_provider: MutableMapping[
-            ExternalProviders, MutableMapping[User, int]
-        ] = defaultdict(dict)
-        for user in users:
-            notification_settings_by_scope = notification_settings_by_user.get(user, {})
-            values_by_provider = get_deploy_values_by_provider(
-                notification_settings_by_scope, notification_providers()
-            )
-            for provider, value in values_by_provider.items():
-                reason_option = self.get_reason(user, value)
-                if reason_option:
-                    users_to_reasons_by_provider[provider][user] = reason_option
-        return users_to_reasons_by_provider
+    def get_participants_with_group_subscription_reason(
+        self,
+    ) -> Mapping[ExternalProviders, Mapping[User, int]]:
+        return get_participants_for_release(self.projects, self.organization, self.user_ids)
 
 
     def get_users_by_teams(self) -> Mapping[int, List[int]]:
     def get_users_by_teams(self) -> Mapping[int, List[int]]:
         if not self.user_id_team_lookup:
         if not self.user_id_team_lookup:
-            user_teams: MutableMapping[int, List[int]] = defaultdict(list)
-            queryset = User.objects.filter(
-                sentry_orgmember_set__organization_id=self.organization.id
-            ).values_list("id", "sentry_orgmember_set__teams")
-            for user_id, team_id in queryset:
-                user_teams[user_id].append(team_id)
-            self.user_id_team_lookup = user_teams
+            self.user_id_team_lookup = get_users_by_teams(self.organization)
         return self.user_id_team_lookup
         return self.user_id_team_lookup
 
 
     def get_context(self) -> MutableMapping[str, Any]:
     def get_context(self) -> MutableMapping[str, Any]:
-        file_count = (
-            CommitFileChange.objects.filter(
-                commit__in=self.commit_list, organization_id=self.organization.id
-            )
-            .values("filename")
-            .distinct()
-            .count()
-        )
-
         return {
         return {
             **self.get_base_context(),
             **self.get_base_context(),
             "commit_count": len(self.commit_list),
             "commit_count": len(self.commit_list),
             "author_count": len(self.email_list),
             "author_count": len(self.email_list),
-            "file_count": file_count,
+            "file_count": get_file_count(self.commit_list, self.organization),
             "repos": self.repos,
             "repos": self.repos,
             "release": self.release,
             "release": self.release,
             "deploy": self.deploy,
             "deploy": self.deploy,
             "environment": self.environment,
             "environment": self.environment,
             "setup_repo_link": absolute_uri(f"/organizations/{self.organization.slug}/repos/"),
             "setup_repo_link": absolute_uri(f"/organizations/{self.organization.slug}/repos/"),
-            "text_description": f"Version {self.release.version} was deployed to {self.environment}",
+            "text_description": f"Version {self.version} was deployed to {self.environment}",
         }
         }
 
 
+    def get_projects(self, user: User) -> Set[Project]:
+        if user.is_superuser or self.organization.flags.allow_joinleave:
+            return self.projects
+        team_ids = self.get_users_by_teams()[user.id]
+        return get_projects(self.projects, team_ids)
+
     def get_user_context(
     def get_user_context(
         self, user: User, reason: Optional[int] = None
         self, user: User, reason: Optional[int] = None
     ) -> MutableMapping[str, Any]:
     ) -> MutableMapping[str, Any]:
-        if user.is_superuser or self.organization.flags.allow_joinleave:
-            projects = self.projects
-        else:
-            teams = self.get_users_by_teams()[user.id]
-            team_projects = set(
-                ProjectTeam.objects.filter(team_id__in=teams)
-                .values_list("project_id", flat=True)
-                .distinct()
-            )
-            projects = [p for p in self.projects if p.id in team_projects]
-
+        projects = self.get_projects(user)
         release_links = [
         release_links = [
             absolute_uri(
             absolute_uri(
-                f"/organizations/{self.organization.slug}/releases/{self.release.version}/?project={p.id}"
+                f"/organizations/{self.organization.slug}/releases/{self.version}/?project={p.id}"
             )
             )
             for p in projects
             for p in projects
         ]
         ]
@@ -220,7 +99,7 @@ class ReleaseActivityNotification(ActivityNotification):
         }
         }
 
 
     def get_subject(self) -> str:
     def get_subject(self) -> str:
-        return f"Deployed version {self.release.version} to {self.environment}"
+        return f"Deployed version {self.version} to {self.environment}"
 
 
     def get_title(self) -> str:
     def get_title(self) -> str:
         return self.get_subject()
         return self.get_subject()

+ 150 - 0
src/sentry/notifications/utils/__init__.py

@@ -0,0 +1,150 @@
+from collections import defaultdict
+from typing import Any, Iterable, List, Mapping, MutableMapping, Optional, Set
+
+from django.db.models import Count
+
+from sentry.db.models.query import in_iexact
+from sentry.models import (
+    Activity,
+    Commit,
+    CommitFileChange,
+    Deploy,
+    Environment,
+    EventError,
+    Group,
+    GroupLink,
+    Organization,
+    Project,
+    ProjectTeam,
+    Release,
+    ReleaseCommit,
+    Repository,
+    User,
+    UserEmail,
+)
+
+
+def get_projects(projects: Iterable[Project], team_ids: Iterable[int]) -> Set[Project]:
+    team_projects = set(
+        ProjectTeam.objects.filter(team_id__in=team_ids)
+        .values_list("project_id", flat=True)
+        .distinct()
+    )
+    return {p for p in projects if p.id in team_projects}
+
+
+def get_users_by_teams(organization: Organization) -> Mapping[int, List[int]]:
+    user_teams: MutableMapping[int, List[int]] = defaultdict(list)
+    queryset = User.objects.filter(
+        sentry_orgmember_set__organization_id=organization.id
+    ).values_list("id", "sentry_orgmember_set__teams")
+    for user_id, team_id in queryset:
+        user_teams[user_id].append(team_id)
+    return user_teams
+
+
+def get_deploy(activity: Activity) -> Optional[Deploy]:
+    try:
+        return Deploy.objects.get(id=activity.data["deploy_id"])
+    except Deploy.DoesNotExist:
+        return None
+
+
+def get_release(activity: Activity, organization: Organization) -> Optional[Release]:
+    try:
+        return Release.objects.get(
+            organization_id=organization.id, version=activity.data["version"]
+        )
+    except Release.DoesNotExist:
+        return None
+
+
+def get_group_counts_by_project(
+    release: Release, projects: Iterable[Project]
+) -> Mapping[Project, int]:
+    return dict(
+        Group.objects.filter(
+            project__in=projects,
+            id__in=GroupLink.objects.filter(
+                linked_type=GroupLink.LinkedType.commit,
+                linked_id__in=ReleaseCommit.objects.filter(release=release).values_list(
+                    "commit_id", flat=True
+                ),
+            ).values_list("group_id", flat=True),
+        )
+        .values_list("project")
+        .annotate(num_groups=Count("id"))
+    )
+
+
+def get_users_by_emails(emails: Iterable[str], organization: Organization) -> Mapping[str, User]:
+    if not emails:
+        return {}
+
+    return {
+        ue.email: ue.user
+        for ue in UserEmail.objects.filter(
+            in_iexact("email", emails),
+            is_verified=True,
+            user__sentry_orgmember_set__organization=organization,
+        ).select_related("user")
+    }
+
+
+def get_repos(
+    commits: Iterable[Commit], users_by_email: Mapping[str, User], organization: Organization
+) -> Iterable[Mapping[str, Any]]:
+    repos = {
+        r_id: {"name": r_name, "commits": []}
+        for r_id, r_name in Repository.objects.filter(
+            organization_id=organization.id,
+            id__in={c.repository_id for c in commits},
+        ).values_list("id", "name")
+    }
+    for commit in commits:
+        user_option = users_by_email.get(commit.author.email) if commit.author_id else None
+        repos[commit.repository_id]["commits"].append((commit, user_option))
+
+    return list(repos.values())
+
+
+def get_commits_for_release(release: Release) -> Set[Commit]:
+    return {
+        rc.commit
+        for rc in ReleaseCommit.objects.filter(release=release).select_related(
+            "commit", "commit__author"
+        )
+    }
+
+
+def get_environment_for_deploy(deploy: Optional[Deploy]) -> str:
+    if deploy:
+        environment = Environment.objects.get(id=deploy.environment_id)
+        if environment and environment.name:
+            return str(environment.name)
+    return "Default Environment"
+
+
+def get_file_count(commits: Iterable[Commit], organization: Organization) -> int:
+    return int(
+        CommitFileChange.objects.filter(commit__in=commits, organization_id=organization.id)
+        .values("filename")
+        .distinct()
+        .count()
+    )
+
+
+def summarize_issues(issues: Iterable[Any]) -> Iterable[Mapping[str, str]]:
+    rv = []
+    for issue in issues:
+        extra_info = None
+        msg_d = dict(issue["data"])
+        msg_d["type"] = issue["type"]
+
+        if "image_path" in issue["data"]:
+            extra_info = issue["data"]["image_path"].rsplit("/", 1)[-1]
+            if "image_arch" in issue["data"]:
+                extra_info = "{} ({})".format(extra_info, issue["data"]["image_arch"])
+
+        rv.append({"message": EventError(msg_d).message, "extra_info": extra_info})
+    return rv

+ 112 - 0
src/sentry/notifications/utils/participants.py

@@ -0,0 +1,112 @@
+from collections import defaultdict
+from typing import Iterable, Mapping, MutableMapping, Optional, Set
+
+from sentry.models import (
+    Group,
+    GroupSubscription,
+    NotificationSetting,
+    Organization,
+    Project,
+    User,
+    UserOption,
+)
+from sentry.notifications.helpers import (
+    get_deploy_values_by_provider,
+    transform_to_notification_settings_by_user,
+)
+from sentry.notifications.notify import notification_providers
+from sentry.notifications.types import (
+    GroupSubscriptionReason,
+    NotificationSettingOptionValues,
+    NotificationSettingTypes,
+)
+from sentry.types.integrations import ExternalProviders
+
+
+def get_providers_from_which_to_remove_user(
+    user: User,
+    participants_by_provider: Mapping[ExternalProviders, Mapping[User, int]],
+) -> Set[ExternalProviders]:
+    """
+    Given a mapping of provider to mappings of users to why they should receive
+    notifications for an activity, return the set of providers where the user
+    has opted out of receiving notifications.
+    """
+
+    providers = {
+        provider
+        for provider, participants in participants_by_provider.items()
+        if user in participants
+    }
+    if (
+        providers
+        and UserOption.objects.get_value(user, key="self_notifications", default="0") == "0"
+    ):
+        return providers
+    return set()
+
+
+def get_participants_for_group(
+    group: Group, user: Optional[User] = None
+) -> Mapping[ExternalProviders, Mapping[User, int]]:
+    # TODO(dcramer): not used yet today except by Release's
+    if not group:
+        return {}
+
+    participants_by_provider: MutableMapping[
+        ExternalProviders, MutableMapping[User, int]
+    ] = GroupSubscription.objects.get_participants(group)
+    if user:
+        # Optionally remove the actor that created the activity from the recipients list.
+        providers = get_providers_from_which_to_remove_user(user, participants_by_provider)
+        for provider in providers:
+            del participants_by_provider[provider][user]
+
+    return participants_by_provider
+
+
+def get_reason(
+    user: User, value: NotificationSettingOptionValues, user_ids: Set[int]
+) -> Optional[int]:
+    # Members who opt into all deploy emails.
+    if value == NotificationSettingOptionValues.ALWAYS:
+        return GroupSubscriptionReason.deploy_setting
+
+    # Members which have been seen in the commit log.
+    elif value == NotificationSettingOptionValues.COMMITTED_ONLY and user.id in user_ids:
+        return GroupSubscriptionReason.committed
+    return None
+
+
+def get_participants_for_release(
+    projects: Iterable[Project], organization: Organization, user_ids: Set[int]
+) -> Mapping[ExternalProviders, Mapping[User, int]]:
+    # Collect all users with verified emails on a team in the related projects.
+    users = list(User.objects.get_team_members_with_verified_email_for_projects(projects))
+
+    # Get all the involved users' settings for deploy-emails (including
+    # users' organization-independent settings.)
+    notification_settings = NotificationSetting.objects.get_for_users_by_parent(
+        NotificationSettingTypes.DEPLOY,
+        users=users,
+        parent=organization,
+    )
+    notification_settings_by_user = transform_to_notification_settings_by_user(
+        notification_settings, users
+    )
+
+    # Map users to their setting value. Prioritize user/org specific, then
+    # user default, then product default.
+    users_to_reasons_by_provider: MutableMapping[
+        ExternalProviders, MutableMapping[User, int]
+    ] = defaultdict(dict)
+    for user in users:
+        notification_settings_by_scope = notification_settings_by_user.get(user, {})
+        values_by_provider = get_deploy_values_by_provider(
+            notification_settings_by_scope, notification_providers()
+        )
+        for provider, value in values_by_provider.items():
+            reason_option = get_reason(user, value, user_ids)
+            if reason_option:
+                users_to_reasons_by_provider[provider][user] = reason_option
+    return users_to_reasons_by_provider

+ 7 - 3
tests/sentry/mail/activity/test_note.py

@@ -25,7 +25,7 @@ class NoteTestCase(ActivityTestCase):
 
 
     def test_simple(self):
     def test_simple(self):
         # Defaults: SUBSCRIBE_ONLY and self_notifications:0
         # Defaults: SUBSCRIBE_ONLY and self_notifications:0
-        assert not self.email.get_participants()
+        assert not self.email.get_participants_with_group_subscription_reason()
 
 
     def test_allow_self_notifications(self):
     def test_allow_self_notifications(self):
         NotificationSetting.objects.update_settings(
         NotificationSetting.objects.update_settings(
@@ -36,7 +36,9 @@ class NoteTestCase(ActivityTestCase):
         )
         )
         UserOption.objects.create(user=self.user, key="self_notifications", value="1")
         UserOption.objects.create(user=self.user, key="self_notifications", value="1")
 
 
-        participants = self.email.get_participants()[ExternalProviders.EMAIL]
+        participants = self.email.get_participants_with_group_subscription_reason()[
+            ExternalProviders.EMAIL
+        ]
         assert len(participants) == 1
         assert len(participants) == 1
         assert participants == {
         assert participants == {
             self.user: GroupSubscriptionReason.implicit,
             self.user: GroupSubscriptionReason.implicit,
@@ -51,5 +53,7 @@ class NoteTestCase(ActivityTestCase):
         )
         )
         UserOption.objects.create(user=self.user, key="self_notifications", value="0")
         UserOption.objects.create(user=self.user, key="self_notifications", value="0")
 
 
-        participants = self.email.get_participants()[ExternalProviders.EMAIL]
+        participants = self.email.get_participants_with_group_subscription_reason()[
+            ExternalProviders.EMAIL
+        ]
         assert len(participants) == 0
         assert len(participants) == 0

+ 9 - 3
tests/sentry/mail/activity/test_release.py

@@ -86,7 +86,9 @@ class ReleaseTestCase(ActivityTestCase):
         # for that org -- also tests to make sure org overrides default preference
         # for that org -- also tests to make sure org overrides default preference
         # user5 committed with another email address and is still included.
         # user5 committed with another email address and is still included.
 
 
-        participants = email.get_participants()[ExternalProviders.EMAIL]
+        participants = email.get_participants_with_group_subscription_reason()[
+            ExternalProviders.EMAIL
+        ]
         assert len(participants) == 3
         assert len(participants) == 3
         assert participants == {
         assert participants == {
             self.user1: GroupSubscriptionReason.committed,
             self.user1: GroupSubscriptionReason.committed,
@@ -143,7 +145,9 @@ class ReleaseTestCase(ActivityTestCase):
         )
         )
 
 
         # only user3 is included because they opted into all deploy emails
         # only user3 is included because they opted into all deploy emails
-        participants = email.get_participants()[ExternalProviders.EMAIL]
+        participants = email.get_participants_with_group_subscription_reason()[
+            ExternalProviders.EMAIL
+        ]
         assert len(participants) == 1
         assert len(participants) == 1
         assert participants == {self.user3: GroupSubscriptionReason.deploy_setting}
         assert participants == {self.user3: GroupSubscriptionReason.deploy_setting}
 
 
@@ -188,7 +192,9 @@ class ReleaseTestCase(ActivityTestCase):
 
 
         # user3 and user 6 are included because they oped into all deploy emails
         # user3 and user 6 are included because they oped into all deploy emails
         # (one on an org level, one as their default)
         # (one on an org level, one as their default)
-        participants = email.get_participants()[ExternalProviders.EMAIL]
+        participants = email.get_participants_with_group_subscription_reason()[
+            ExternalProviders.EMAIL
+        ]
         assert len(participants) == 2
         assert len(participants) == 2
         assert participants == {
         assert participants == {
             user6: GroupSubscriptionReason.deploy_setting,
             user6: GroupSubscriptionReason.deploy_setting,