Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/sentry/analytics/events/inapp_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,9 @@ class InviteRequestSentEvent(InAppRequestSentEvent):
type = "invite_request.sent"


class JoinRequestSentEvent(InAppRequestSentEvent):
type = "join_request.sent"


analytics.register(InviteRequestSentEvent)
analytics.register(JoinRequestSentEvent)
3 changes: 3 additions & 0 deletions src/sentry/api/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,9 @@ def add_cursor_headers(self, request, response, cursor_result):
def respond(self, context=None, **kwargs):
return Response(context, **kwargs)

def respond_with_text(self, text):
return self.respond({"text": text})

def get_per_page(self, request, default_per_page=100, max_per_page=100):
try:
per_page = int(request.GET.get("per_page", default_per_page))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from sentry.api.serializers.models.organization_member import OrganizationMemberWithTeamsSerializer
from sentry.app import locks
from sentry.models import AuditLogEntryEvent, InviteStatus, OrganizationMember
from sentry.tasks.members import send_invite_request_notification_email
from sentry.notifications.notifications.invitations.invite_request import InviteRequestNotification
from sentry.utils.retries import TimedRetryPolicy

from .organization_member_index import OrganizationMemberSerializer, save_team_assignments
Expand Down Expand Up @@ -91,6 +91,6 @@ def post(self, request, organization):
event=AuditLogEntryEvent.INVITE_REQUEST_ADD,
)

send_invite_request_notification_email.delay(om.id)
InviteRequestNotification(om, request.user).send()

return Response(serialize(om), status=201)
5 changes: 3 additions & 2 deletions src/sentry/api/endpoints/organization_join_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
from sentry.api.validators import AllowedEmailField
from sentry.app import ratelimiter
from sentry.models import AuthProvider, InviteStatus, OrganizationMember
from sentry.notifications.notifications.invitations.join_request import JoinRequestNotification
from sentry.signals import join_request_created
from sentry.tasks.members import send_invite_request_notification_email

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -71,7 +71,8 @@ def post(self, request, organization):
member = create_organization_join_request(organization, email, ip_address)

if member:
send_invite_request_notification_email.delay(member.id)
JoinRequestNotification(member, request.user).send()
# legacy analytics
join_request_created.send_robust(sender=self, member=member)

return Response(status=204)
4 changes: 1 addition & 3 deletions src/sentry/api/endpoints/organization_member_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,7 @@ def get_allowed_roles(request, organization, member=None):
if member and roles.get(acting_member.role).priority < roles.get(member.role).priority:
can_admin = False
else:
allowed_roles = [
r for r in roles.get_all() if r.priority <= roles.get(acting_member.role).priority
]
allowed_roles = acting_member.get_allowed_roles_to_invite()
can_admin = bool(allowed_roles)
elif is_active_superuser(request):
allowed_roles = roles.get_all()
Expand Down
1 change: 0 additions & 1 deletion src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,6 @@ def SOCIAL_AUTH_DEFAULT_USERNAME():
"sentry.tasks.groupowner",
"sentry.tasks.integrations",
"sentry.tasks.low_priority_symbolication",
"sentry.tasks.members",
"sentry.tasks.merge",
"sentry.tasks.releasemonitor",
"sentry.tasks.options",
Expand Down
16 changes: 16 additions & 0 deletions src/sentry/integrations/slack/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,24 @@ class IntegrationSlackLinkIdentity(analytics.Event): # type: ignore
)


class IntegrationSlackApproveMemberInvitation(analytics.Event): # type: ignore
type = "integrations.slack.approve_member_invitation"

attributes = (
analytics.Attribute("organization_id"),
analytics.Attribute("actor_id"),
analytics.Attribute("invitation_type"),
)


class IntegrationSlackRejectMemberInvitation(IntegrationSlackApproveMemberInvitation):
type = "integrations.slack.reject_member_invitation"


analytics.register(SlackIntegrationAssign)
analytics.register(SlackIntegrationNotificationSent)
analytics.register(SlackIntegrationStatus)
analytics.register(IntegrationSlackChartUnfurl)
analytics.register(IntegrationSlackLinkIdentity)
analytics.register(IntegrationSlackApproveMemberInvitation)
analytics.register(IntegrationSlackRejectMemberInvitation)
115 changes: 109 additions & 6 deletions src/sentry/integrations/slack/endpoints/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from typing import Any, Mapping, MutableMapping

from django.urls import reverse
from requests import post
from rest_framework.request import Request
from rest_framework.response import Response
Expand All @@ -10,15 +11,26 @@
from sentry.api import ApiClient, client
from sentry.api.base import Endpoint
from sentry.api.helpers.group_index import update_groups
from sentry.auth.access import from_member
from sentry.exceptions import UnableToAcceptMemberInvitationException
from sentry.integrations.slack.client import SlackClient
from sentry.integrations.slack.requests.action import SlackActionRequest
from sentry.integrations.slack.requests.base import SlackRequestError
from sentry.integrations.slack.views.link_identity import build_linking_url
from sentry.integrations.slack.views.unlink_identity import build_unlinking_url
from sentry.models import Group, Identity, IdentityProvider, Integration, Project
from sentry.models import (
Group,
Identity,
IdentityProvider,
Integration,
InviteStatus,
OrganizationMember,
Project,
)
from sentry.notifications.utils.actions import MessageAction
from sentry.shared_integrations.exceptions import ApiError
from sentry.utils import json
from sentry.utils.http import absolute_uri
from sentry.web.decorators import transaction_start

from ..message_builder import SlackBody
Expand Down Expand Up @@ -216,17 +228,20 @@ def post(self, request: Request) -> Response:
except SlackRequestError as e:
return self.respond(status=e.status)

data = slack_request.data
action_option = slack_request.action_option

# Actions list may be empty when receiving a dialog response
action_list_raw = data.get("actions", [])
action_list = [MessageAction(**action_data) for action_data in action_list_raw]
action_option = (action_list[0].value if len(action_list) else None) or ""
if action_option in ["approve_member", "reject_member"]:
return self.handle_member_approval(slack_request)

# if a user is just clicking our auto response in the messages tab we just return a 200
if action_option == "sentry_docs_link_clicked":
return self.respond()

# Actions list may be empty when receiving a dialog response
data = slack_request.data
action_list_raw = data.get("actions", [])
action_list = [MessageAction(**action_data) for action_data in action_list_raw]

channel_id = slack_request.channel_id
user_id = slack_request.user_id
integration = slack_request.integration
Expand Down Expand Up @@ -346,3 +361,91 @@ def post(self, request: Request) -> Response:
body = self.construct_reply(attachment, is_message=self.is_message(data))

return self.respond(body)

def handle_member_approval(self, slack_request: SlackActionRequest):
try:
# get_identity can return Noone
identity = slack_request.get_identity()
except IdentityProvider.DoesNotExist:
identity = None

if not identity:
return self.respond_with_text("Identity not linked for user.")

member_id = slack_request.callback_data["member_id"]

try:
member = OrganizationMember.objects.get_member_invite_query(member_id).get()
except OrganizationMember.DoesNotExist:
# member request is gone, likely someone else rejected it
member_email = slack_request.callback_data["member_email"]
return self.respond_with_text(f"Member invitation for {member_email} no longer exists.")

organization = member.organization

if not organization.has_access(identity.user):
return self.respond_with_text(
"You don't have access to the organization for the invitation."
)

# row should exist because we have access
member_of_approver = OrganizationMember.objects.get(
user=identity.user, organization=organization
)
access = from_member(member_of_approver)
if not access.has_scope("member:admin"):
return self.respond_with_text(
"You don't have permission to approve member invitations."
)

# validate the org options and check against allowed_roles
allowed_roles = member_of_approver.get_allowed_roles_to_invite()
try:
member.validate_invitation(identity.user, allowed_roles)
except UnableToAcceptMemberInvitationException as err:
return self.respond_with_text(str(err))

original_status = member.invite_status
member_email = member.email
try:
if slack_request.action_option == "approve_member":
member.approve_member_invitation(identity.user, referrer="slack")
else:
member.reject_member_invitation(identity.user)
except Exception as err:
# shouldn't error but if it does, respond to the user
logger.error(
err,
extra={
"organization_id": organization.id,
"member_id": member.id,
},
)
return self.respond_ephemeral(DEFAULT_ERROR_MESSAGE)

# record analytics and respond with success
approve_member = slack_request.action_option == "approve_member"
event_name = (
"integrations.slack.approve_member_invitation"
if approve_member
else "integrations.slack.reject_member_invitation"
)
invite_type = (
"Invite" if original_status == InviteStatus.REQUESTED_TO_BE_INVITED.value else "Join"
)
analytics.record(
event_name,
actor_id=identity.user_id,
organization_id=member.organization_id,
invitation_type=invite_type.lower(),
)

verb = "approved" if approve_member else "rejected"

manage_url = absolute_uri(
reverse("sentry-organization-members", args=[member.organization.slug])
)
body = {
"text": f"{invite_type} request for {member_email} has been {verb}. <{manage_url}|See Members and Requests>.",
}
return self.respond(body)
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def get_slack_button(action: MessageAction) -> Mapping[str, Any]:
"name": action.name,
"type": action.type,
}
for field in ("style", "url", "value"):
for field in ("style", "url", "value", "action_id"):
value = getattr(action, field, None)
if value:
kwargs[field] = value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from sentry.integrations.slack.message_builder.issues import SlackIssuesMessageBuilder
from sentry.models import Team, User
from sentry.notifications.notifications.base import BaseNotification, ProjectNotification
from sentry.utils import json


def get_message_builder(klass: str) -> type[SlackNotificationsMessageBuilder]:
Expand All @@ -30,12 +31,14 @@ def __init__(
self.recipient = recipient

def build(self) -> SlackBody:
callback_id_raw = self.notification.get_callback_data()
return self._build(
title=self.notification.build_attachment_title(),
title_link=self.notification.get_title_link(),
text=self.notification.get_message_description(),
footer=self.notification.build_notification_footer(self.recipient),
actions=self.notification.get_message_actions(),
callback_id=json.dumps(callback_id_raw) if callback_id_raw else None,
)


Expand Down
7 changes: 7 additions & 0 deletions src/sentry/integrations/slack/requests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ def data(self) -> Mapping[str, Any]:
self._validate_data()
return self._data

@property
def action_option(self) -> Optional[str]:
# Actions list may be empty when receiving a dialog response
action_list = self.data.get("actions", [])
action_option: Optional[str] = action_list and action_list[0].get("value", "")
return action_option

@property
def logging_data(self) -> Mapping[str, str]:
data = {
Expand Down
11 changes: 10 additions & 1 deletion src/sentry/notifications/notifications/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ def __init__(self, organization: Organization):

@property
def org_slug(self) -> str:
return str(self.organization.slug)
slug: str = self.organization.slug
return slug

@property
def org_name(self) -> str:
name: str = self.organization.name
return name

def get_filename(self) -> str:
raise NotImplementedError
Expand Down Expand Up @@ -92,6 +98,9 @@ def get_log_params(self, recipient: Team | User) -> Mapping[str, Any]:
def get_message_actions(self) -> Sequence[MessageAction]:
return []

def get_callback_data(self) -> Mapping[str, Any] | None:
return None


class ProjectNotification(BaseNotification, abc.ABC):
def __init__(self, project: Project) -> None:
Expand Down
Empty file.
Loading