Skip to content

Commit f928557

Browse files
authored
feat(issue-views): Update GET endpoint to validate view's projects (#84338)
This PR updates the GET `organization/.../group-search-views` endpoint to validate a views projecst before being sent. The logic is: If your org doesn't have the multi project stream feature (`organizations:global-views`) any views that have an invalid projects parameter ("All Projects", "My Projects", or len(projects) > 1) will be set to the first project you have, alphabetically. No validation is necessary for organizations that do have the multi project stream feature.
1 parent 8ead265 commit f928557

File tree

3 files changed

+268
-15
lines changed

3 files changed

+268
-15
lines changed

src/sentry/api/serializers/models/groupsearchview.py

+19-2
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,32 @@ class GroupSearchViewSerializerResponse(TypedDict):
2121

2222
@register(GroupSearchView)
2323
class GroupSearchViewSerializer(Serializer):
24+
def __init__(self, *args, **kwargs):
25+
self.has_global_views = kwargs.pop("has_global_views", None)
26+
self.default_project = kwargs.pop("default_project", None)
27+
super().__init__(*args, **kwargs)
28+
2429
def serialize(self, obj, attrs, user, **kwargs) -> GroupSearchViewSerializerResponse:
30+
if self.has_global_views is False:
31+
is_all_projects = False
32+
33+
projects = list(obj.projects.values_list("id", flat=True))
34+
num_projects = len(projects)
35+
if num_projects != 1:
36+
projects = [projects[0] if num_projects > 1 else self.default_project]
37+
38+
else:
39+
is_all_projects = obj.is_all_projects
40+
projects = list(obj.projects.values_list("id", flat=True))
41+
2542
return {
2643
"id": str(obj.id),
2744
"name": obj.name,
2845
"query": obj.query,
2946
"querySort": obj.query_sort,
3047
"position": obj.position,
31-
"projects": list(obj.projects.values_list("id", flat=True)),
32-
"isAllProjects": obj.is_all_projects,
48+
"projects": projects,
49+
"isAllProjects": is_all_projects,
3350
"environments": obj.environments,
3451
"timeFilters": obj.time_filters,
3552
"dateCreated": obj.date_added,

src/sentry/issues/endpoints/organization_group_search_views.py

+41-9
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
GroupSearchViewValidator,
1919
GroupSearchViewValidatorResponse,
2020
)
21-
from sentry.models.groupsearchview import GroupSearchView
21+
from sentry.models.groupsearchview import DEFAULT_TIME_FILTER, GroupSearchView
2222
from sentry.models.organization import Organization
2323
from sentry.models.project import Project
2424
from sentry.models.savedsearch import SortOptions
@@ -31,6 +31,9 @@
3131
"query": "is:unresolved issue.priority:[high, medium]",
3232
"querySort": SortOptions.DATE.value,
3333
"position": 0,
34+
"isAllProjects": False,
35+
"environments": [],
36+
"timeFilters": DEFAULT_TIME_FILTER,
3437
"dateCreated": None,
3538
"dateUpdated": None,
3639
}
@@ -65,23 +68,55 @@ def get(self, request: Request, organization: Organization) -> Response:
6568
):
6669
return Response(status=status.HTTP_404_NOT_FOUND)
6770

68-
query = GroupSearchView.objects.filter(organization=organization, user_id=request.user.id)
71+
has_global_views = features.has("organizations:global-views", organization)
72+
73+
query = GroupSearchView.objects.filter(
74+
organization=organization, user_id=request.user.id
75+
).prefetch_related("projects")
6976

70-
# Return only the prioritized view if user has no custom views yet
77+
# Return only the default view(s) if user has no custom views yet
7178
if not query.exists():
7279
return self.paginate(
7380
request=request,
7481
paginator=SequencePaginator(
75-
[(idx, view) for idx, view in enumerate(DEFAULT_VIEWS)]
82+
[
83+
(
84+
idx,
85+
{
86+
**view,
87+
"projects": (
88+
[]
89+
if has_global_views
90+
else [pick_default_project(organization, request.user)]
91+
),
92+
},
93+
)
94+
for idx, view in enumerate(DEFAULT_VIEWS)
95+
]
7696
),
7797
on_results=lambda results: serialize(results, request.user),
7898
)
7999

100+
default_project = None
101+
if not has_global_views:
102+
default_project = pick_default_project(organization, request.user)
103+
if default_project is None:
104+
return Response(
105+
status=status.HTTP_400_BAD_REQUEST,
106+
data={"detail": "You do not have access to any projects."},
107+
)
108+
80109
return self.paginate(
81110
request=request,
82111
queryset=query,
83112
order_by="position",
84-
on_results=lambda x: serialize(x, request.user, serializer=GroupSearchViewSerializer()),
113+
on_results=lambda x: serialize(
114+
x,
115+
request.user,
116+
serializer=GroupSearchViewSerializer(
117+
has_global_views=has_global_views, default_project=default_project
118+
),
119+
),
85120
)
86121

87122
def put(self, request: Request, organization: Organization) -> Response:
@@ -90,7 +125,6 @@ def put(self, request: Request, organization: Organization) -> Response:
90125
will delete any views that are not included in the request, add views if
91126
they are new, and update existing views if they are included in the request.
92127
This endpoint is explcititly designed to be used by our frontend.
93-
94128
"""
95129
if not features.has(
96130
"organizations:issue-stream-custom-views", organization, actor=request.user
@@ -166,7 +200,7 @@ def bulk_update_views(
166200
_update_existing_view(org, user_id, view, position=idx)
167201

168202

169-
def pick_default_project(org: Organization, user: User | AnonymousUser) -> int:
203+
def pick_default_project(org: Organization, user: User | AnonymousUser) -> int | None:
170204
user_teams = Team.objects.get_for_user(organization=org, user=user)
171205
user_team_ids = [team.id for team in user_teams]
172206
default_user_project = (
@@ -175,8 +209,6 @@ def pick_default_project(org: Organization, user: User | AnonymousUser) -> int:
175209
.values_list("id", flat=True)
176210
.first()
177211
)
178-
if default_user_project is None:
179-
raise ValidationError("You do not have access to any projects")
180212
return default_user_project
181213

182214

tests/sentry/issues/endpoints/test_organization_group_search_views.py

+208-4
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from sentry.api.serializers.base import serialize
55
from sentry.api.serializers.rest_framework.groupsearchview import GroupSearchViewValidatorResponse
6+
from sentry.issues.endpoints.organization_group_search_views import DEFAULT_VIEWS
67
from sentry.models.groupsearchview import GroupSearchView
78
from sentry.testutils.cases import APITestCase, TransactionTestCase
89
from sentry.testutils.helpers.features import with_feature
@@ -366,7 +367,7 @@ def create_base_data_with_page_filters(self) -> list[GroupSearchView]:
366367
query_sort="date",
367368
position=0,
368369
time_filters={"period": "14d"},
369-
environments=["production"],
370+
environments=[],
370371
)
371372
first_custom_view_user_one.projects.set([self.project1])
372373

@@ -378,7 +379,7 @@ def create_base_data_with_page_filters(self) -> list[GroupSearchView]:
378379
query_sort="new",
379380
position=1,
380381
time_filters={"period": "7d"},
381-
environments=["staging"],
382+
environments=["staging", "production"],
382383
)
383384
second_custom_view_user_one.projects.set([self.project1, self.project2, self.project3])
384385

@@ -413,7 +414,7 @@ def test_not_including_page_filters_does_not_reset_them_for_existing_views(self)
413414
# Original Page filters
414415
assert views[0]["timeFilters"] == {"period": "14d"}
415416
assert views[0]["projects"] == [self.project1.id]
416-
assert views[0]["environments"] == ["production"]
417+
assert views[0]["environments"] == []
417418

418419
view = views[0]
419420
# Change nothing but the name
@@ -428,7 +429,7 @@ def test_not_including_page_filters_does_not_reset_them_for_existing_views(self)
428429
# Ensure these have not been changed
429430
assert views[0]["timeFilters"] == {"period": "14d"}
430431
assert views[0]["projects"] == [self.project1.id]
431-
assert views[0]["environments"] == ["production"]
432+
assert views[0]["environments"] == []
432433

433434
@with_feature({"organizations:issue-stream-custom-views": True})
434435
@with_feature({"organizations:global-views": True})
@@ -584,6 +585,209 @@ def test_invalid_project_ids(self) -> None:
584585
assert response.content == b'{"detail":"One or more projects do not exist"}'
585586

586587

588+
class OrganizationGroupSearchViewsGetPageFiltersTest(APITestCase):
589+
def create_base_data_with_page_filters(self) -> None:
590+
self.team_1 = self.create_team(organization=self.organization, slug="team-1")
591+
self.team_2 = self.create_team(organization=self.organization, slug="team-2")
592+
593+
# User 1 is on team 1 only
594+
user_1 = self.user
595+
self.create_team_membership(user=user_1, team=self.team_1)
596+
# User 2 is on team 1 and team 2
597+
self.user_2 = self.create_user()
598+
self.create_member(
599+
organization=self.organization, user=self.user_2, teams=[self.team_1, self.team_2]
600+
)
601+
# User 3 has no views and should get the default views
602+
self.user_3 = self.create_user()
603+
self.create_member(organization=self.organization, user=self.user_3, teams=[self.team_1])
604+
# User 4 is part of no teams, should error out
605+
self.user_4 = self.create_user()
606+
self.create_member(organization=self.organization, user=self.user_4)
607+
608+
# This project should NEVER get chosen as a default since it does not belong to any teams
609+
self.project1 = self.create_project(
610+
organization=self.organization, slug="project-a", teams=[]
611+
)
612+
# This project should be User 2's default project since it's the alphabetically the first one
613+
self.project2 = self.create_project(
614+
organization=self.organization, slug="project-b", teams=[self.team_2]
615+
)
616+
# This should be User 1's default project since it's the only one that the user has access to
617+
self.project3 = self.create_project(
618+
organization=self.organization, slug="project-c", teams=[self.team_1, self.team_2]
619+
)
620+
621+
first_issue_view_user_one = GroupSearchView.objects.create(
622+
name="Issue View One",
623+
organization=self.organization,
624+
user_id=user_1.id,
625+
query="is:unresolved",
626+
query_sort="date",
627+
position=0,
628+
is_all_projects=False,
629+
time_filters={"period": "14d"},
630+
environments=[],
631+
)
632+
first_issue_view_user_one.projects.set([self.project3])
633+
634+
second_issue_view_user_one = GroupSearchView.objects.create(
635+
name="Issue View Two",
636+
organization=self.organization,
637+
user_id=user_1.id,
638+
query="is:resolved",
639+
query_sort="new",
640+
position=1,
641+
is_all_projects=False,
642+
time_filters={"period": "7d"},
643+
environments=["staging", "production"],
644+
)
645+
second_issue_view_user_one.projects.set([])
646+
647+
third_issue_view_user_one = GroupSearchView.objects.create(
648+
name="Issue View Three",
649+
organization=self.organization,
650+
user_id=user_1.id,
651+
query="is:ignored",
652+
query_sort="freq",
653+
position=2,
654+
is_all_projects=True,
655+
time_filters={"period": "30d"},
656+
environments=["development"],
657+
)
658+
third_issue_view_user_one.projects.set([])
659+
660+
first_issue_view_user_two = GroupSearchView.objects.create(
661+
name="Issue View One",
662+
organization=self.organization,
663+
user_id=self.user_2.id,
664+
query="is:unresolved",
665+
query_sort="date",
666+
position=0,
667+
is_all_projects=False,
668+
time_filters={"period": "14d"},
669+
environments=[],
670+
)
671+
first_issue_view_user_two.projects.set([])
672+
673+
first_issue_view_user_four = GroupSearchView.objects.create(
674+
name="Issue View One",
675+
organization=self.organization,
676+
user_id=self.user_4.id,
677+
query="is:unresolved",
678+
query_sort="date",
679+
position=0,
680+
is_all_projects=False,
681+
time_filters={"period": "14d"},
682+
environments=[],
683+
)
684+
first_issue_view_user_four.projects.set([])
685+
686+
def setUp(self) -> None:
687+
self.create_base_data_with_page_filters()
688+
self.url = reverse(
689+
"sentry-api-0-organization-group-search-views",
690+
kwargs={"organization_id_or_slug": self.organization.slug},
691+
)
692+
693+
@with_feature({"organizations:issue-stream-custom-views": True})
694+
@with_feature({"organizations:global-views": True})
695+
def test_basic_get_page_filters_with_global_filters(self) -> None:
696+
self.login_as(user=self.user)
697+
response = self.client.get(self.url)
698+
699+
assert response.data[0]["timeFilters"] == {"period": "14d"}
700+
assert response.data[0]["projects"] == [self.project3.id]
701+
assert response.data[0]["environments"] == []
702+
assert response.data[0]["isAllProjects"] is False
703+
704+
assert response.data[1]["timeFilters"] == {"period": "7d"}
705+
assert response.data[1]["projects"] == []
706+
assert response.data[1]["environments"] == ["staging", "production"]
707+
assert response.data[1]["isAllProjects"] is False
708+
709+
assert response.data[2]["timeFilters"] == {"period": "30d"}
710+
assert response.data[2]["projects"] == []
711+
assert response.data[2]["environments"] == ["development"]
712+
assert response.data[2]["isAllProjects"] is True
713+
714+
@with_feature({"organizations:issue-stream-custom-views": True})
715+
@with_feature({"organizations:global-views": False})
716+
def test_get_page_filters_without_global_filters(self) -> None:
717+
self.login_as(user=self.user)
718+
response = self.client.get(self.url)
719+
720+
assert response.data[0]["timeFilters"] == {"period": "14d"}
721+
assert response.data[0]["projects"] == [self.project3.id]
722+
assert response.data[0]["environments"] == []
723+
assert response.data[0]["isAllProjects"] is False
724+
725+
assert response.data[1]["timeFilters"] == {"period": "7d"}
726+
assert response.data[1]["projects"] == [self.project3.id]
727+
assert response.data[1]["environments"] == ["staging", "production"]
728+
assert response.data[1]["isAllProjects"] is False
729+
730+
assert response.data[2]["timeFilters"] == {"period": "30d"}
731+
assert response.data[2]["projects"] == [self.project3.id]
732+
assert response.data[2]["environments"] == ["development"]
733+
assert response.data[2]["isAllProjects"] is False
734+
735+
@with_feature({"organizations:issue-stream-custom-views": True})
736+
@with_feature({"organizations:global-views": False})
737+
def test_get_page_filters_without_global_filters_user_2(self) -> None:
738+
self.login_as(user=self.user_2)
739+
response = self.client.get(self.url)
740+
741+
assert response.data[0]["timeFilters"] == {"period": "14d"}
742+
assert response.data[0]["projects"] == [self.project2.id]
743+
assert response.data[0]["environments"] == []
744+
assert response.data[0]["isAllProjects"] is False
745+
746+
@with_feature({"organizations:issue-stream-custom-views": True})
747+
@with_feature({"organizations:global-views": True})
748+
def test_default_page_filters_with_global_views(self) -> None:
749+
self.login_as(user=self.user_3)
750+
response = self.client.get(self.url)
751+
752+
default_view_queries = {view["query"] for view in DEFAULT_VIEWS}
753+
received_queries = {view["query"] for view in response.data}
754+
755+
assert default_view_queries == received_queries
756+
757+
for view in response.data:
758+
assert view["timeFilters"] == {"period": "14d"}
759+
# Global views means default project should be "My Projects"
760+
assert view["projects"] == []
761+
assert view["environments"] == []
762+
assert view["isAllProjects"] is False
763+
764+
@with_feature({"organizations:issue-stream-custom-views": True})
765+
@with_feature({"organizations:global-views": False})
766+
def test_default_page_filters_without_global_views(self) -> None:
767+
self.login_as(user=self.user_3)
768+
response = self.client.get(self.url)
769+
770+
default_view_queries = {view["query"] for view in DEFAULT_VIEWS}
771+
received_queries = {view["query"] for view in response.data}
772+
773+
assert default_view_queries == received_queries
774+
775+
for view in response.data:
776+
assert view["timeFilters"] == {"period": "14d"}
777+
# No global views means default project should be a single project
778+
assert view["projects"] == [self.project3.id]
779+
assert view["environments"] == []
780+
assert view["isAllProjects"] is False
781+
782+
@with_feature({"organizations:issue-stream-custom-views": True})
783+
@with_feature({"organizations:global-views": False})
784+
def test_error_when_no_projects_found(self) -> None:
785+
self.login_as(user=self.user_4)
786+
response = self.client.get(self.url)
787+
assert response.status_code == 400
788+
assert response.data == {"detail": "You do not have access to any projects."}
789+
790+
587791
class OrganizationGroupSearchViewsPutRegressionTest(APITestCase):
588792
endpoint = "sentry-api-0-organization-group-search-views"
589793
method = "put"

0 commit comments

Comments
 (0)