Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ref(notifications): org request notification #29362

Merged
merged 16 commits into from
Oct 22, 2021
2 changes: 2 additions & 0 deletions src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,8 @@ def create_partitioned_queues(name):
"organizations:release-comparison-performance": False,
# Enable percent displays in issue stream
"organizations:issue-percent-display": False,
# send organization request notifications through Slack
"organizations:slack-requests": False,
# Enable team insights page
"organizations:team-insights": False,
# Adds additional filters and a new section to issue alert rules.
Expand Down
1 change: 1 addition & 0 deletions src/sentry/features/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@
default_manager.add("organizations:sso-rippling", OrganizationFeature)
default_manager.add("organizations:sso-saml2", OrganizationFeature)
default_manager.add("organizations:sso-scim", OrganizationFeature, True)
default_manager.add("organizations:slack-requests", OrganizationFeature, True)
default_manager.add("organizations:team-insights", OrganizationFeature, True)
default_manager.add("organizations:symbol-sources", OrganizationFeature)
default_manager.add("organizations:transaction-comparison", OrganizationFeature, True)
Expand Down
4 changes: 2 additions & 2 deletions src/sentry/integrations/slack/message_builder/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
Team,
User,
)
from sentry.notifications.notifications.base import BaseNotification
from sentry.notifications.notifications.base import BaseNotification, ProjectNotification
from sentry.notifications.notifications.rules import AlertRuleNotification
from sentry.utils import json
from sentry.utils.dates import to_timestamp
Expand Down Expand Up @@ -312,7 +312,7 @@ def __init__(
rules: Optional[List[Rule]] = None,
link_to_event: bool = False,
issue_details: bool = False,
notification: Optional[BaseNotification] = None,
notification: Optional[ProjectNotification] = None,
recipient: Optional[Union["Team", "User"]] = None,
) -> None:
super().__init__()
Expand Down
17 changes: 15 additions & 2 deletions src/sentry/integrations/slack/message_builder/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
NewProcessingIssuesActivityNotification,
)
from sentry.notifications.notifications.activity.release import ReleaseActivityNotification
from sentry.notifications.notifications.base import BaseNotification
from sentry.notifications.notifications.base import BaseNotification, ProjectNotification
from sentry.notifications.utils import get_release
from sentry.utils.http import absolute_uri

Expand Down Expand Up @@ -51,8 +51,21 @@ def __init__(
self.context = context
self.recipient = recipient


class SlackProjectNotificationsMessageBuilder(SlackNotificationsMessageBuilder):
def __init__(
self,
notification: ProjectNotification,
context: Mapping[str, Any],
recipient: Union["Team", "User"],
) -> None:
super().__init__(notification, context, recipient)
# TODO: use generics here to do this
self.notification: ProjectNotification = notification
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe there is a better way to do this but I'm not sure


def build(self) -> SlackBody:
group = getattr(self.notification, "group", None)
# TODO: refactor so we don't call SlackIssuesMessageBuilder through SlackProjectNotificationsMessageBuilder
if self.notification.is_message_issue_unfurl:
return SlackIssuesMessageBuilder(
group=group,
Expand Down Expand Up @@ -93,4 +106,4 @@ def build_notification_attachment(
recipient: Union["Team", "User"],
) -> SlackBody:
"""@deprecated"""
return SlackNotificationsMessageBuilder(notification, context, recipient).build()
return notification.build_slack_attachment(context, recipient)
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from typing import TYPE_CHECKING, Any, Mapping, Union

from sentry.integrations.slack.message_builder import SlackBody
from sentry.integrations.slack.message_builder.notifications import SlackNotificationsMessageBuilder
from sentry.notifications.notifications.organization_request import OrganizationRequestNotification

if TYPE_CHECKING:
from sentry.models import Team, User


class SlackOrganizationRequestMessageBuilder(SlackNotificationsMessageBuilder):
def __init__(
self,
notification: OrganizationRequestNotification,
context: Mapping[str, Any],
recipient: Union["Team", "User"],
) -> None:
super().__init__(notification, context, recipient)
# TODO: use generics here to do this
self.notification: OrganizationRequestNotification = notification

def build(self) -> SlackBody:
# may need to pass more args to _build and pass recipient to certain helper functions
return self._build(
title=self.notification.build_attachment_title(),
text=self.notification.get_message_description(),
footer=self.notification.build_notification_footer(self.recipient),
actions=self.notification.get_actions(),
color="info",
)
22 changes: 2 additions & 20 deletions src/sentry/integrations/slack/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,10 @@
from collections import defaultdict
from typing import Any, Iterable, Mapping, MutableMapping, Optional, Union

from sentry import analytics
from sentry.integrations.slack.client import SlackClient # NOQA
from sentry.integrations.slack.message_builder.notifications import build_notification_attachment
from sentry.models import ExternalActor, Identity, Integration, Organization, Team, User
from sentry.notifications.notifications.activity.base import ActivityNotification
from sentry.notifications.notifications.base import BaseNotification
from sentry.notifications.notifications.rules import AlertRuleNotification
from sentry.notifications.notify import register_notification_provider
from sentry.shared_integrations.exceptions import ApiError
from sentry.types.integrations import EXTERNAL_PROVIDERS, ExternalProviders
Expand Down Expand Up @@ -109,15 +106,6 @@ def get_channel_and_token_by_recipient(
return output


def get_key(notification: BaseNotification) -> str:
if isinstance(notification, ActivityNotification):
return "activity"
elif isinstance(notification, AlertRuleNotification):
return "issue_alert"
else:
return ""


@register_notification_provider(ExternalProviders.SLACK)
def send_notification_as_slack(
notification: BaseNotification,
Expand Down Expand Up @@ -167,15 +155,9 @@ def send_notification_as_slack(
"is_multiple": is_multiple,
},
)
analytics.record(
"integrations.slack.notification_sent",
organization_id=notification.organization.id,
project_id=notification.project.id,
category=notification.get_category(),
actor_id=recipient.actor_id,
)
notification.record_notification_sent(recipient, ExternalProviders.SLACK)

key = get_key(notification)
key = notification.metrics_key
metrics.incr(
f"{key}.notifications.sent",
instance=f"slack.{key}.notification",
Expand Down
5 changes: 3 additions & 2 deletions src/sentry/integrations/slack/utils/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from sentry.integrations.slack.message_builder.incidents import SlackIncidentsMessageBuilder
from sentry.models import Environment, Integration, Team, User
from sentry.notifications.notifications.activity.release import ReleaseActivityNotification
from sentry.notifications.notifications.base import BaseNotification
from sentry.notifications.notifications.base import BaseNotification, ProjectNotification
from sentry.shared_integrations.exceptions import ApiError
from sentry.utils import json
from sentry.utils.http import absolute_uri
Expand Down Expand Up @@ -89,6 +89,7 @@ def send_confirmation(


def get_referrer_qstring(notification: BaseNotification, recipient: Union["Team", "User"]) -> str:
# TODO: make a generic version that works for other notification types
return (
"?referrer="
+ re.sub("Notification$", "Slack", notification.__class__.__name__)
Expand All @@ -104,7 +105,7 @@ def get_settings_url(notification: BaseNotification, recipient: Union["Team", "U


def build_notification_footer(
notification: BaseNotification, recipient: Union["Team", "User"]
notification: ProjectNotification, recipient: Union["Team", "User"]
) -> str:
if isinstance(recipient, Team):
team = Team.objects.get(id=recipient.id)
Expand Down
115 changes: 40 additions & 75 deletions src/sentry/mail/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,84 +5,37 @@

from sentry import options
from sentry.models import Project, ProjectOption, Team, User
from sentry.notifications.notifications.activity.base import ActivityNotification
from sentry.notifications.notifications.base import BaseNotification
from sentry.notifications.notifications.rules import AlertRuleNotification
from sentry.notifications.notifications.base import BaseNotification, ProjectNotification
from sentry.notifications.notify import register_notification_provider
from sentry.types.integrations import ExternalProviders
from sentry.utils import json
from sentry.utils.email import MessageBuilder, group_id_to_email
from sentry.utils.email import MessageBuilder
from sentry.utils.linksign import generate_signed_link

logger = logging.getLogger(__name__)


def get_headers(notification: BaseNotification) -> Mapping[str, Any]:
headers = {
"X-Sentry-Project": notification.project.slug,
"X-SMTPAPI": json.dumps({"category": notification.get_category()}),
}

group = getattr(notification, "group", None)
if group:
headers.update(
{
"X-Sentry-Logger": group.logger,
"X-Sentry-Logger-Level": group.get_level_display(),
"X-Sentry-Reply-To": group_id_to_email(group.id),
}
)

return headers


def build_subject_prefix(project: "Project", mail_option_key: Optional[str] = None) -> str:
key = mail_option_key or "mail:subject_prefix"
def build_subject_prefix(project: "Project") -> str:
key = "mail:subject_prefix"
return force_text(
ProjectOption.objects.get_value(project, key) or options.get("mail.subject-prefix")
)


def get_subject_with_prefix(
notification: BaseNotification,
context: Optional[Mapping[str, Any]] = None,
mail_option_key: Optional[str] = None,
) -> bytes:

prefix = build_subject_prefix(notification.project, mail_option_key)
return f"{prefix}{notification.get_subject(context)}".encode()


def get_unsubscribe_link(
user_id: int, resource_id: int, key: str = "issue", referrer: Optional[str] = None
) -> str:
return generate_signed_link(
user_id,
f"sentry-account-email-unsubscribe-{key}",
referrer,
kwargs={f"{key}_id": resource_id},
return str(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This got lost in a previous comment but you should avoid casting to satisfy mypy.

signed_link: str = generate_signed_link(
    user_id,
    f"sentry-account-email-unsubscribe-{key}",
    referrer,
    kwargs={f"{key}_id": resource_id},
)
return signed_link

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mgaeta good catch! Let me add that in

generate_signed_link(
user_id,
f"sentry-account-email-unsubscribe-{key}",
referrer,
kwargs={f"{key}_id": resource_id},
)
)


def log_message(notification: BaseNotification, recipient: Union["Team", "User"]) -> None:
extra = {
"project_id": notification.project.id,
"actor_id": recipient.actor_id,
}
group = getattr(notification, "group", None)
if group:
extra.update({"group": group.id})

if isinstance(notification, AlertRuleNotification):
extra.update(
{
"target_type": notification.target_type,
"target_identifier": notification.target_identifier,
}
)
elif isinstance(notification, ActivityNotification):
extra.update({"activity": notification.activity})

extra = notification.get_log_params(recipient)
logger.info("mail.adapter.notify.mail_user", extra=extra)


Expand All @@ -103,8 +56,9 @@ def get_context(
}
# TODO(mgaeta): The unsubscribe system relies on `user_id` so it doesn't
# work with Teams. We should add the `actor_id` to the signed link.
if isinstance(recipient, User) and notification.get_unsubscribe_key():
key, resource_id, referrer = notification.get_unsubscribe_key()
unsubscribe_key = notification.get_unsubscribe_key()
if isinstance(recipient, User) and unsubscribe_key:
key, resource_id, referrer = unsubscribe_key
context.update(
{"unsubscribe_link": get_unsubscribe_link(recipient.id, resource_id, key, referrer)}
)
Expand All @@ -119,25 +73,36 @@ def send_notification_as_email(
shared_context: Mapping[str, Any],
extra_context_by_user_id: Optional[Mapping[int, Mapping[str, Any]]],
) -> None:
headers = get_headers(notification)

for recipient in recipients:
if isinstance(recipient, Team):
# TODO(mgaeta): MessageBuilder only works with Users so filter out Teams for now.
continue
extra_context = (extra_context_by_user_id or {}).get(recipient.id, {})
log_message(notification, recipient)
context = get_context(notification, recipient, shared_context, extra_context)
subject = get_subject_with_prefix(notification, context=context)
msg = MessageBuilder(
subject=subject,
context=context,
template=notification.get_template(),
html_template=notification.get_html_template(),
headers=headers,
reference=notification.get_reference(),
reply_reference=notification.get_reply_reference(),
type=notification.get_type(),
**get_builder_args(notification, recipient, shared_context, extra_context_by_user_id)
)
msg.add_users([recipient.id], project=notification.project)
# TODO: find better way of handling this
if isinstance(notification, ProjectNotification):
msg.add_users([recipient.id], project=notification.project)
msg.send_async()


def get_builder_args(
notification: BaseNotification,
recipient: "User",
shared_context: Optional[Mapping[str, Any]] = None,
extra_context_by_user_id: Optional[Mapping[int, Mapping[str, Any]]] = None,
) -> Mapping[str, Any]:
# TODO: move context logic to single notification class method
extra_context = (extra_context_by_user_id or {}).get(recipient.id, {})
context = get_context(notification, recipient, shared_context or {}, extra_context)
return {
"subject": notification.get_subject_with_prefix(context=context),
"context": context,
"template": notification.get_template(),
"html_template": notification.get_html_template(),
"headers": notification.get_headers(),
"reference": notification.get_reference(),
"reply_reference": notification.get_reply_reference(),
"type": notification.get_type(),
}
5 changes: 3 additions & 2 deletions src/sentry/notifications/notifications/activity/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from django.utils.safestring import SafeString, mark_safe

from sentry.notifications.helpers import get_reason_context
from sentry.notifications.notifications.base import BaseNotification
from sentry.notifications.notifications.base import ProjectNotification
from sentry.notifications.utils import send_activity_notification
from sentry.notifications.utils.avatar import avatar_as_html
from sentry.notifications.utils.participants import get_participants_for_group
Expand All @@ -17,8 +17,9 @@
from sentry.models import Activity, Team, User


class ActivityNotification(BaseNotification, ABC):
class ActivityNotification(ProjectNotification, ABC):
fine_tuning_key = "workflow"
metrics_key = "activity"

def __init__(self, activity: "Activity") -> None:
super().__init__(activity.project)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ class ReleaseActivityNotification(ActivityNotification):
def __init__(self, activity: Activity) -> None:
super().__init__(activity)
self.group = None
self.organization = self.project.organization
self.user_id_team_lookup: Optional[Mapping[int, List[int]]] = None
self.email_list: Set[str] = set()
self.user_ids: Set[int] = set()
Expand Down
Loading