Skip to content

Commit 0662646

Browse files
feat(savedsearch): Use visibility in OrganizationSearchesEndpoint (getsentry#40942)
1 parent d6c3d00 commit 0662646

File tree

3 files changed

+158
-95
lines changed

3 files changed

+158
-95
lines changed

src/sentry/api/endpoints/organization_searches.py

Lines changed: 53 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ class OrganizationSearchSerializer(serializers.Serializer):
1818
sort = serializers.ChoiceField(
1919
choices=SortOptions.as_choices(), default=SortOptions.DATE, required=False
2020
)
21+
# TODO(epurkhiser): Once the frontend is deployed we should change this to
22+
# default to OWNER since that is a more sane default than organization
23+
# visibile.
24+
visibility = serializers.ChoiceField(
25+
choices=Visibility.as_choices(include_pinned=False),
26+
default=Visibility.ORGANIZATION,
27+
required=False,
28+
)
2129

2230

2331
@region_silo_endpoint
@@ -39,49 +47,58 @@ def get(self, request: Request, organization) -> Response:
3947
search_type = SearchType(int(request.GET.get("type", 0)))
4048
except ValueError as e:
4149
return Response({"detail": "Invalid input for `type`. Error: %s" % str(e)}, status=400)
42-
org_searches_q = Q(Q(owner=request.user) | Q(owner__isnull=True), organization=organization)
43-
global_searches_q = Q(is_global=True)
44-
saved_searches = list(
45-
SavedSearch.objects.filter(org_searches_q | global_searches_q, type=search_type).extra(
46-
select={"has_owner": "owner_id is not null", "name__upper": "UPPER(name)"},
47-
order_by=["-has_owner", "name__upper"],
50+
51+
query = (
52+
SavedSearch.objects
53+
# Do not include pinned or personal searches from other users in
54+
# the same organization. DOES include the requesting users pinned
55+
# search
56+
.exclude(
57+
~Q(owner=request.user),
58+
visibility__in=(Visibility.OWNER, Visibility.OWNER_PINNED),
4859
)
60+
.filter(
61+
Q(organization=organization) | Q(is_global=True),
62+
type=search_type,
63+
)
64+
.extra(order_by=["name"])
4965
)
5066

51-
return Response(serialize(saved_searches, request.user))
67+
return Response(serialize(list(query), request.user))
5268

5369
def post(self, request: Request, organization) -> Response:
5470
serializer = OrganizationSearchSerializer(data=request.data)
5571

56-
if serializer.is_valid():
57-
result = serializer.validated_data
58-
# Prevent from creating duplicate queries
59-
if SavedSearch.objects.filter(
60-
Q(is_global=True) | Q(organization=organization, owner__isnull=True),
61-
query=result["query"],
62-
).exists():
63-
return Response(
64-
{"detail": "Query {} already exists".format(result["query"])}, status=400
65-
)
72+
if not serializer.is_valid():
73+
return Response(serializer.errors, status=400)
6674

67-
saved_search = SavedSearch.objects.create(
68-
organization=organization,
69-
type=result["type"],
70-
name=result["name"],
71-
query=result["query"],
72-
sort=result["sort"],
73-
# NOTE: We have not yet exposed the API for setting the
74-
# visibility of a saved search, but we don't want to use
75-
# the model default of 'owner'. Existing is to be visible
76-
# to the organization.
77-
visibility=Visibility.ORGANIZATION,
78-
)
79-
analytics.record(
80-
"organization_saved_search.created",
81-
search_type=SearchType(saved_search.type).name,
82-
org_id=organization.id,
83-
query=saved_search.query,
75+
result = serializer.validated_data
76+
77+
# Prevent from creating duplicate queries
78+
if (
79+
SavedSearch.objects
80+
# Query duplication for pinned searches is fine, exlcuded these
81+
.exclude(visibility=Visibility.OWNER_PINNED)
82+
.filter(Q(is_global=True) | Q(organization=organization), query=result["query"])
83+
.exists()
84+
):
85+
return Response(
86+
{"detail": "Query {} already exists".format(result["query"])}, status=400
8487
)
85-
return Response(serialize(saved_search, request.user))
8688

87-
return Response(serializer.errors, status=400)
89+
saved_search = SavedSearch.objects.create(
90+
organization=organization,
91+
owner=request.user,
92+
type=result["type"],
93+
name=result["name"],
94+
query=result["query"],
95+
sort=result["sort"],
96+
visibility=result["visibility"],
97+
)
98+
analytics.record(
99+
"organization_saved_search.created",
100+
search_type=SearchType(saved_search.type).name,
101+
org_id=organization.id,
102+
query=saved_search.query,
103+
)
104+
return Response(serialize(saved_search, request.user))

src/sentry/models/savedsearch.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,18 +70,12 @@ class SavedSearch(Model):
7070
# is_global does NOT have an associated organization_id
7171
is_global = models.NullBooleanField(null=True, default=False, db_index=True)
7272

73-
# XXX(epurkhiser): This is different from "creator". Owner is a misnomer
74-
# for this column, as this actually indicates that the search is "pinned"
75-
# by the user. A user may only have one pinned search epr (org, type)
76-
#
77-
# XXX(epurkhiser): Once the visibility column is correctly in use this
78-
# column will be used essentially as "created_by"
73+
# Creator of the saved search. When visibility is
74+
# Visibility.{OWNER,OWNER_PINNED} this field is used to constrain who the
75+
# search is visibile to.
7976
owner = FlexibleForeignKey("sentry.User", null=True)
8077

8178
# Defines who can see the saved search
82-
#
83-
# NOTE: `owner_pinned` has special behavior in that the saved search will
84-
# not appear in the user saved search list
8579
visibility = models.CharField(
8680
max_length=16, default=Visibility.OWNER, choices=Visibility.as_choices(include_pinned=True)
8781
)

tests/sentry/api/endpoints/test_organization_searches.py

Lines changed: 102 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -20,72 +20,106 @@ def get_response(self, *args, **params):
2020
return super().get_response(*args, **params)
2121

2222
def create_base_data(self):
23+
user_1 = self.user
24+
user_2 = self.create_user()
25+
26+
self.create_member(organization=self.organization, user=user_2)
27+
2328
# Depending on test we run migrations in Django 1.8. This causes
2429
# extra rows to be created, so remove them to keep this test working
2530
SavedSearch.objects.filter(is_global=True).delete()
2631

27-
SavedSearch.objects.create(
28-
name="foo",
32+
# Note names are prefixed with A-Z to make it easy to understand the sorting
33+
34+
savedsearch_global = SavedSearch.objects.create(
35+
name="A Global Query",
36+
query="is:unresolved",
37+
sort=SortOptions.DATE,
38+
is_global=True,
39+
visibility=Visibility.ORGANIZATION,
40+
date_added=timezone.now(),
41+
)
42+
savedsearch_org = SavedSearch.objects.create(
43+
organization=self.organization,
44+
owner=user_1,
45+
name="B Simple SavedSearch 1",
2946
query="some test",
47+
sort=SortOptions.NEW,
48+
visibility=Visibility.ORGANIZATION,
49+
date_added=timezone.now(),
50+
)
51+
savedsearch_org_diff_owner = SavedSearch.objects.create(
52+
organization=self.organization,
53+
owner=user_2,
54+
name="C Simple SavedSearch for same org diff owner",
55+
query="some other test",
56+
sort=SortOptions.DATE,
57+
visibility=Visibility.ORGANIZATION,
58+
date_added=timezone.now(),
59+
)
60+
savedsearch_owner_me = SavedSearch.objects.create(
61+
organization=self.organization,
62+
owner=user_1,
63+
name="D My personal search",
64+
query="some other test",
3065
sort=SortOptions.DATE,
66+
visibility=Visibility.OWNER,
3167
date_added=timezone.now(),
3268
)
33-
SavedSearch.objects.create(
69+
savedsearch_other_owner = SavedSearch.objects.create(
3470
organization=self.organization,
35-
owner=self.create_user(),
36-
name="foo",
37-
query="some other user's query",
71+
owner=user_2,
72+
name="E Other user personal search",
73+
query="whatever",
3874
sort=SortOptions.DATE,
75+
visibility=Visibility.OWNER,
3976
date_added=timezone.now(),
4077
)
41-
included = [
42-
SavedSearch.objects.create(
43-
name="Global Query",
44-
query="is:unresolved",
45-
sort=SortOptions.DATE,
46-
is_global=True,
47-
date_added=timezone.now(),
48-
),
49-
SavedSearch.objects.create(
50-
organization=self.organization,
51-
name="foo",
52-
query="some test",
53-
sort=SortOptions.DATE,
54-
date_added=timezone.now(),
55-
),
56-
SavedSearch.objects.create(
57-
organization=self.organization,
58-
name="wat",
59-
query="is:unassigned is:unresolved",
60-
sort=SortOptions.NEW,
61-
date_added=timezone.now(),
62-
),
63-
]
64-
return included
78+
savedsearch_my_pinned = SavedSearch.objects.create(
79+
organization=self.organization,
80+
owner=user_1,
81+
name="F My pinned search",
82+
query="whatever",
83+
sort=SortOptions.DATE,
84+
visibility=Visibility.OWNER_PINNED,
85+
date_added=timezone.now(),
86+
)
87+
savedsearch_other_pinned = SavedSearch.objects.create(
88+
organization=self.organization,
89+
owner=user_2,
90+
name="G Other user pinned search",
91+
query="whatever",
92+
sort=SortOptions.DATE,
93+
visibility=Visibility.OWNER_PINNED,
94+
date_added=timezone.now(),
95+
)
96+
97+
return {
98+
"savedsearch_global": savedsearch_global,
99+
"savedsearch_org": savedsearch_org,
100+
"savedsearch_org_diff_owner": savedsearch_org_diff_owner,
101+
"savedsearch_owner_me": savedsearch_owner_me,
102+
"savedsearch_other_owner": savedsearch_other_owner,
103+
"savedsearch_my_pinned": savedsearch_my_pinned,
104+
"savedsearch_other_pinned": savedsearch_other_pinned,
105+
}
65106

66107
def check_results(self, expected):
67108
self.login_as(user=self.user)
68-
expected.sort(key=lambda search: (not search.is_pinned, search.name.lower()))
69109
response = self.get_success_response(self.organization.slug)
70110
assert response.data == serialize(expected)
71111

72112
def test_simple(self):
73-
included = self.create_base_data()
74-
self.check_results(included)
75-
76-
def test_pinned(self):
77-
included = self.create_base_data()
78-
pinned_query = SavedSearch.objects.create(
79-
organization=self.organization,
80-
owner=self.user,
81-
name="My Pinned Query",
82-
query="pinned junk",
83-
sort=SortOptions.NEW,
84-
date_added=timezone.now(),
85-
visibility=Visibility.OWNER_PINNED,
113+
objs = self.create_base_data()
114+
self.check_results(
115+
[
116+
objs["savedsearch_global"],
117+
objs["savedsearch_org"],
118+
objs["savedsearch_org_diff_owner"],
119+
objs["savedsearch_owner_me"],
120+
objs["savedsearch_my_pinned"],
121+
]
86122
)
87-
included.append(pinned_query)
88-
self.check_results(included)
89123

90124

91125
@region_silo_test
@@ -109,19 +143,30 @@ def test_simple(self):
109143
search_type = SearchType.ISSUE.value
110144
name = "test"
111145
query = "hello"
146+
visibility = Visibility.ORGANIZATION
147+
112148
self.login_as(user=self.manager)
113149
resp = self.get_success_response(
114-
self.organization.slug, type=search_type, name=name, query=query
150+
self.organization.slug,
151+
type=search_type,
152+
name=name,
153+
query=query,
154+
visibility=visibility,
115155
)
116156
assert resp.data["name"] == name
117157
assert resp.data["query"] == query
118158
assert resp.data["type"] == search_type
159+
assert resp.data["visibility"] == visibility
119160
assert SavedSearch.objects.filter(id=resp.data["id"]).exists()
120161

121-
def test_perms(self):
162+
def test_member_cannot_create_org_search(self):
122163
self.login_as(user=self.member)
123164
resp = self.get_response(
124-
self.organization.slug, type=SearchType.ISSUE.value, name="hello", query="test"
165+
self.organization.slug,
166+
type=SearchType.ISSUE.value,
167+
name="hello",
168+
query="test",
169+
Visibility=Visibility.ORGANIZATION,
125170
)
126171
assert resp.status_code == 403
127172

@@ -131,6 +176,7 @@ def test_exists(self):
131176
name="Some global search",
132177
query="is:unresolved",
133178
is_global=True,
179+
visibility=Visibility.ORGANIZATION,
134180
)
135181
self.login_as(user=self.manager)
136182
resp = self.get_response(
@@ -147,20 +193,26 @@ def test_exists(self):
147193
type=SearchType.ISSUE.value,
148194
name="Some org search",
149195
query="org search",
196+
visibility=Visibility.ORGANIZATION,
150197
)
151198
resp = self.get_response(
152199
self.organization.slug,
153200
type=SearchType.ISSUE.value,
154201
name="hello",
155202
query=org_search.query,
203+
visibility=Visibility.ORGANIZATION,
156204
)
157205
assert resp.status_code == 400
158206
assert "already exists" in resp.data["detail"]
159207

160208
def test_empty(self):
161209
self.login_as(user=self.manager)
162210
resp = self.get_response(
163-
self.organization.slug, type=SearchType.ISSUE.value, name="hello", query=""
211+
self.organization.slug,
212+
type=SearchType.ISSUE.value,
213+
name="hello",
214+
query="",
215+
visibility=Visibility.ORGANIZATION,
164216
)
165217
assert resp.status_code == 400
166218
assert "This field may not be blank." == resp.data["query"][0]

0 commit comments

Comments
 (0)