Skip to content

Commit a979b24

Browse files
mifu67cathtengmarkstory
authored
feat(member merge): merge account endpoint GET (#91100)
GET request for the user merge account endpoint. Fetches a list of user accounts with the same primary email as the authenticated requesting user + fetches their organization information. --------- Co-authored-by: Cathy Teng <70817427+cathteng@users.noreply.github.com> Co-authored-by: Mark Story <mark@mark-story.com>
1 parent 76f649b commit a979b24

File tree

5 files changed

+144
-0
lines changed

5 files changed

+144
-0
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from django.contrib.auth.models import AnonymousUser
2+
from rest_framework.request import Request
3+
from rest_framework.response import Response
4+
5+
from sentry.api.api_owners import ApiOwner
6+
from sentry.api.api_publish_status import ApiPublishStatus
7+
from sentry.api.base import Endpoint, control_silo_endpoint
8+
from sentry.api.paginator import OffsetPaginator
9+
from sentry.api.permissions import SentryIsAuthenticated
10+
from sentry.api.serializers import serialize
11+
from sentry.users.api.serializers.user import UserSerializerWithOrgMemberships
12+
from sentry.users.models.user import User
13+
14+
15+
@control_silo_endpoint
16+
class AuthMergeUserAccountsEndpoint(Endpoint):
17+
publish_status = {
18+
"GET": ApiPublishStatus.PRIVATE,
19+
"POST": ApiPublishStatus.PRIVATE,
20+
}
21+
owner = ApiOwner.ENTERPRISE
22+
permission_classes = (SentryIsAuthenticated,)
23+
"""
24+
List and merge user accounts with the same primary email address.
25+
"""
26+
27+
def get(self, request: Request) -> Response:
28+
user = request.user
29+
if isinstance(user, AnonymousUser):
30+
return Response(
31+
status=401,
32+
data={"error": "You must be authenticated to use this endpoint"},
33+
)
34+
35+
shared_email = user.email
36+
if not shared_email:
37+
return Response(
38+
status=400,
39+
data={"error": "Shared email is empty or null"},
40+
)
41+
queryset = User.objects.filter(email=shared_email).order_by("last_active")
42+
return self.paginate(
43+
request=request,
44+
queryset=queryset,
45+
on_results=lambda x: serialize(x, user, UserSerializerWithOrgMemberships()),
46+
paginator_cls=OffsetPaginator,
47+
)

src/sentry/api/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.conf.urls import include
44
from django.urls import URLPattern, URLResolver, re_path
55

6+
from sentry.api.endpoints.auth_merge_user_accounts import AuthMergeUserAccountsEndpoint
67
from sentry.api.endpoints.group_ai_summary import GroupAiSummaryEndpoint
78
from sentry.api.endpoints.group_autofix_setup_check import GroupAutofixSetupCheck
89
from sentry.api.endpoints.group_integration_details import GroupIntegrationDetailsEndpoint
@@ -929,6 +930,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
929930
AuthValidateEndpoint.as_view(),
930931
name="sentry-api-0-auth-test",
931932
),
933+
re_path(
934+
r"^merge-accounts/$",
935+
AuthMergeUserAccountsEndpoint.as_view(),
936+
name="sentry-api-0-auth-merge-accounts",
937+
),
932938
]
933939

934940
BROADCAST_URLS = [

src/sentry/users/api/serializers/user.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,10 @@ class DetailedSelfUserSerializerResponse(UserSerializerResponse):
324324
permissions: Sequence[str]
325325

326326

327+
class UserSerializerWithOrgMembershipsResponse(UserSerializerResponse):
328+
organizations: Sequence[str]
329+
330+
327331
class DetailedSelfUserSerializer(UserSerializer):
328332
"""
329333
Return additional information for operating on behalf of a user, like their permissions.
@@ -396,3 +400,49 @@ def serialize(
396400
"Incorrectly calling `DetailedSelfUserSerializer`. See docstring for details."
397401
)
398402
return d
403+
404+
405+
class UserSerializerWithOrgMemberships(UserSerializer):
406+
def get_attrs(
407+
self,
408+
item_list: Sequence[User],
409+
user: User | AnonymousUser | RpcUser,
410+
**kwargs: Any,
411+
) -> MutableMapping[User, Any]:
412+
attrs = super().get_attrs(item_list, user, **kwargs)
413+
414+
memberships = OrganizationMemberMapping.objects.filter(
415+
user_id__in={u.id for u in item_list}
416+
).values_list("user_id", "organization_id", named=True)
417+
active_org_id_to_name = dict(
418+
OrganizationMapping.objects.filter(
419+
organization_id__in={m.organization_id for m in memberships},
420+
status=OrganizationStatus.ACTIVE,
421+
).values_list("organization_id", "name")
422+
)
423+
active_organization_ids = active_org_id_to_name.keys()
424+
425+
user_org_memberships: DefaultDict[int, list[str]] = defaultdict(list)
426+
for membership in memberships:
427+
if membership.organization_id in active_organization_ids:
428+
user_org_memberships[membership.user_id].append(
429+
active_org_id_to_name[membership.organization_id]
430+
)
431+
for item in item_list:
432+
attrs[item]["organizations"] = user_org_memberships[item.id]
433+
434+
return attrs
435+
436+
def serialize(
437+
self,
438+
obj: User,
439+
attrs: Mapping[str, Any],
440+
user: User | AnonymousUser | RpcUser,
441+
**kwargs: Any,
442+
) -> UserSerializerWithOrgMembershipsResponse:
443+
response = cast(
444+
UserSerializerWithOrgMembershipsResponse, super().serialize(obj, attrs, user)
445+
)
446+
447+
response["organizations"] = sorted(attrs["organizations"])
448+
return response

static/app/data/controlsiloUrlPatterns.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ const patterns: RegExp[] = [
116116
new RegExp('^api/0/auth/config/$'),
117117
new RegExp('^api/0/auth/login/$'),
118118
new RegExp('^api/0/auth/validate/$'),
119+
new RegExp('^api/0/auth/merge-accounts/$'),
119120
new RegExp('^api/0/auth-v2/login/$'),
120121
new RegExp('^api/0/broadcasts/$'),
121122
new RegExp('^api/0/broadcasts/[^/]+/$'),
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from sentry.testutils.cases import APITestCase
2+
from sentry.testutils.silo import control_silo_test
3+
4+
5+
@control_silo_test
6+
class ListUserAccountsWithSharedEmailTest(APITestCase):
7+
endpoint = "sentry-api-0-auth-merge-accounts"
8+
method = "get"
9+
10+
def test_simple(self):
11+
user1 = self.create_user(username="mifu1", email="mifu@example.com")
12+
user2 = self.create_user(username="mifu2", email="mifu@example.com")
13+
# unrelated user
14+
self.create_user(username="unrelated-mifu", email="michelle@email.com")
15+
16+
self.login_as(user1)
17+
response = self.get_success_response()
18+
assert len(response.data) == 2
19+
assert response.data[0]["username"] == user1.username
20+
assert response.data[1]["username"] == user2.username
21+
22+
def test_with_orgs(self):
23+
user1 = self.create_user(username="powerful mifu", email="mifu@example.com")
24+
user2 = self.create_user(username="transcendent mifu", email="mifu@example.com")
25+
self.create_user(username="garden variety mifu", email="mifu@example.com")
26+
27+
org1 = self.create_organization(name="hojicha")
28+
org2 = self.create_organization(name="matcha")
29+
org3 = self.create_organization(name="oolong")
30+
31+
self.create_member(user=user1, organization=org1)
32+
self.create_member(user=user1, organization=org2)
33+
self.create_member(user=user2, organization=org3)
34+
35+
self.login_as(user1)
36+
response = self.get_success_response()
37+
38+
assert response.data[0]["organizations"] == [org1.name, org2.name]
39+
assert response.data[1]["organizations"] == [org3.name]
40+
assert response.data[2]["organizations"] == []

0 commit comments

Comments
 (0)