Skip to content

Commit 497a19f

Browse files
committed
fix(pr): PR comments + tests
1 parent e14cda1 commit 497a19f

File tree

5 files changed

+138
-132
lines changed

5 files changed

+138
-132
lines changed

src/sentry/workflow_engine/endpoints/organization_detector_index.py

Lines changed: 26 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections.abc import Iterable, Sequence
12
from functools import partial
23

34
from django.db.models import Count, Q
@@ -16,6 +17,7 @@
1617
from sentry.api.event_search import SearchConfig, SearchFilter, SearchKey, default_config
1718
from sentry.api.event_search import parse_search_query as base_parse_search_query
1819
from sentry.api.exceptions import ResourceDoesNotExist
20+
from sentry.api.issue_search import convert_actor_or_none_value
1921
from sentry.api.paginator import OffsetPaginator
2022
from sentry.api.serializers import serialize
2123
from sentry.apidocs.constants import (
@@ -29,7 +31,9 @@
2931
from sentry.issues import grouptype
3032
from sentry.models.organization import Organization
3133
from sentry.models.project import Project
32-
from sentry.search.utils import parse_actor_or_none_value
34+
from sentry.models.team import Team
35+
from sentry.users.models.user import User
36+
from sentry.users.services.user import RpcUser
3337
from sentry.workflow_engine.endpoints.serializers import DetectorSerializer
3438
from sentry.workflow_engine.endpoints.utils.filters import apply_filter
3539
from sentry.workflow_engine.endpoints.utils.sortby import SortByParam
@@ -47,31 +51,25 @@
4751
parse_detector_query = partial(base_parse_search_query, config=detector_search_config)
4852

4953

50-
def convert_assignee_value(value: str, projects: list[Project], user) -> Q:
54+
def convert_assignee_values(value: Iterable[str], projects: Sequence[Project], user: User) -> Q:
5155
"""
5256
Convert an assignee search value to a Django Q object for filtering detectors.
5357
"""
54-
if value == "me":
55-
# Handle "me" keyword to search for current user
56-
return Q(owner_user_id=user.id)
57-
elif value == "unassigned":
58-
# Handle "unassigned" keyword
59-
return Q(owner_user_id__isnull=True, owner_team__isnull=True)
60-
else:
61-
# Parse actor (user or team) and create appropriate Q object
62-
actor = parse_actor_or_none_value(projects, value, user)
63-
if actor is None:
64-
# If we can't parse the actor, return a query that matches nothing
65-
return Q(pk__isnull=True)
66-
elif hasattr(actor, "id") and hasattr(actor, "_meta") and actor._meta.model_name == "user":
67-
# It's a user
68-
return Q(owner_user_id=actor.id)
69-
elif hasattr(actor, "id") and hasattr(actor, "_meta") and actor._meta.model_name == "team":
70-
# It's a team
71-
return Q(owner_team_id=actor.id)
58+
actors_or_none: list[RpcUser | Team | None] = convert_actor_or_none_value(
59+
value, projects, user, None
60+
)
61+
assignee_query = Q()
62+
for actor in actors_or_none:
63+
if isinstance(actor, (User, RpcUser)):
64+
assignee_query |= Q(owner_user_id=actor.id)
65+
elif isinstance(actor, Team):
66+
assignee_query |= Q(owner_team_id=actor.id)
67+
elif actor is None:
68+
assignee_query |= Q(owner_team_id__isnull=True, owner_user_id__isnull=True)
7269
else:
7370
# Unknown actor type, return a query that matches nothing
74-
return Q(pk__isnull=True)
71+
assignee_query |= Q(pk__isnull=True)
72+
return assignee_query
7573

7674

7775
# Maps API field name to database field name, with synthetic aggregate fields keeping
@@ -164,17 +162,13 @@ def get(self, request: Request, organization: Organization) -> Response:
164162
case SearchFilter(key=SearchKey("type"), operator=("=" | "IN" | "!=")):
165163
queryset = apply_filter(queryset, filter, "type")
166164
case SearchFilter(key=SearchKey("assignee"), operator=("=" | "IN" | "!=")):
167-
# Handle assignee filtering with support for users, teams, "me", and "unassigned"
168-
if isinstance(filter.value.value, list):
169-
# Handle multiple values (IN operator)
170-
assignee_q = Q()
171-
for value in filter.value.value:
172-
assignee_q |= convert_assignee_value(value, projects, request.user)
173-
else:
174-
# Handle single value
175-
assignee_q = convert_assignee_value(
176-
filter.value.value, projects, request.user
177-
)
165+
# Filter values can be emails, team slugs, "me", "my_teams", "none"
166+
values = (
167+
filter.value.value
168+
if isinstance(filter.value.value, list)
169+
else [filter.value.value]
170+
)
171+
assignee_q = convert_assignee_values(values, projects, request.user)
178172

179173
if filter.operator == "!=":
180174
queryset = queryset.exclude(assignee_q)

static/app/views/detectors/components/detectorSearch.tsx

Lines changed: 5 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,16 @@
11
import {SearchQueryBuilder} from 'sentry/components/searchQueryBuilder';
22
import {t} from 'sentry/locale';
3-
import type {Tag, TagCollection} from 'sentry/types/group';
4-
import type {FieldDefinition} from 'sentry/utils/fields';
5-
import {FieldKey, FieldKind} from 'sentry/utils/fields';
6-
import useAssignedSearchValues from 'sentry/utils/membersAndTeams/useAssignedSearchValues';
3+
import {getFieldDefinition} from 'sentry/utils/fields';
74
import {useLocation} from 'sentry/utils/useLocation';
85
import {useNavigate} from 'sentry/utils/useNavigate';
9-
import {DETECTOR_FILTER_KEYS} from 'sentry/views/detectors/constants';
10-
11-
function getDetectorFilterKeyDefinition(filterKey: string): FieldDefinition | null {
12-
if (DETECTOR_FILTER_KEYS.hasOwnProperty(filterKey) && DETECTOR_FILTER_KEYS[filterKey]) {
13-
const {description, valueType, keywords, values} = DETECTOR_FILTER_KEYS[filterKey];
14-
15-
return {
16-
kind: FieldKind.FIELD,
17-
desc: description,
18-
valueType,
19-
keywords,
20-
values,
21-
};
22-
}
23-
24-
return null;
25-
}
6+
import {useDetectorFilterKeys} from 'sentry/views/detectors/utils/useDetectorFilterKeys';
267

278
export function DetectorSearch() {
289
const location = useLocation();
2910
const navigate = useNavigate();
30-
const assignedValues = useAssignedSearchValues();
11+
const filterKeys = useDetectorFilterKeys();
3112
const query = typeof location.query.query === 'string' ? location.query.query : '';
3213

33-
const filterKeys: TagCollection = Object.fromEntries(
34-
Object.keys(DETECTOR_FILTER_KEYS).map(key => {
35-
const {values} = DETECTOR_FILTER_KEYS[key] ?? {};
36-
37-
// Special handling for assignee field to provide user/team values
38-
// See getFeedbackFilterKeys in static/app/components/feedback/feedbackSearch.tsx
39-
if (key === FieldKey.ASSIGNED) {
40-
return [
41-
key,
42-
{
43-
key,
44-
name: key,
45-
predefined: true,
46-
values: assignedValues,
47-
},
48-
];
49-
}
50-
51-
return [
52-
key,
53-
{
54-
key,
55-
name: key,
56-
predefined: values !== undefined,
57-
values,
58-
},
59-
];
60-
})
61-
);
62-
63-
const getTagValues = (tag: Tag, _query: string): Promise<string[]> => {
64-
// For assignee field, return the assigned values filtered by query
65-
if (tag.key === 'assignee') {
66-
const allAssigneeValues: string[] = [];
67-
assignedValues.forEach(group => {
68-
if (Array.isArray(group.children)) {
69-
group.children.forEach(child => {
70-
if (typeof child.value === 'string') {
71-
allAssigneeValues.push(child.value);
72-
}
73-
});
74-
}
75-
});
76-
77-
// Filter by query if provided
78-
if (_query) {
79-
return Promise.resolve(
80-
allAssigneeValues.filter(value =>
81-
value.toLowerCase().includes(_query.toLowerCase())
82-
)
83-
);
84-
}
85-
return Promise.resolve(allAssigneeValues);
86-
}
87-
88-
// For other fields, return empty array (no dynamic values)
89-
return Promise.resolve([]);
90-
};
91-
9214
return (
9315
<SearchQueryBuilder
9416
initialQuery={query}
@@ -103,9 +25,9 @@ export function DetectorSearch() {
10325
});
10426
}}
10527
filterKeys={filterKeys}
106-
getTagValues={getTagValues}
28+
getTagValues={() => Promise.resolve([])}
10729
searchSource="detectors-list"
108-
fieldDefinitionGetter={getDetectorFilterKeyDefinition}
30+
fieldDefinitionGetter={getFieldDefinition}
10931
disallowUnsupportedFilters
11032
disallowWildcard
11133
disallowLogicalOperators

static/app/views/detectors/constants.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export const DETECTOR_FILTER_KEYS: Record<
3737
keywords: ['type'],
3838
},
3939
assignee: {
40-
description: 'User or team assigned to the detector',
40+
description: 'User or team assigned to the monitor',
4141
valueType: FieldValueType.STRING,
4242
keywords: ['assigned', 'owner'],
4343
},
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {useMemo} from 'react';
2+
3+
import type {TagCollection} from 'sentry/types/group';
4+
import useAssignedSearchValues from 'sentry/utils/membersAndTeams/useAssignedSearchValues';
5+
import {DETECTOR_FILTER_KEYS} from 'sentry/views/detectors/constants';
6+
7+
export function useDetectorFilterKeys(): TagCollection {
8+
const assignedValues = useAssignedSearchValues();
9+
10+
return useMemo(() => {
11+
return Object.fromEntries(
12+
Object.keys(DETECTOR_FILTER_KEYS).map(key => {
13+
const {values} = DETECTOR_FILTER_KEYS[key] ?? {};
14+
15+
// Special handling for assignee field to provide user/team values
16+
if (key === 'assignee') {
17+
return [
18+
key,
19+
{
20+
key,
21+
name: key,
22+
predefined: true,
23+
values: assignedValues,
24+
},
25+
];
26+
}
27+
28+
return [
29+
key,
30+
{
31+
key,
32+
name: key,
33+
predefined: values !== undefined,
34+
values,
35+
},
36+
];
37+
})
38+
);
39+
}, [assignedValues]);
40+
}

tests/sentry/workflow_engine/endpoints/test_organization_detector_index.py

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from unittest import mock
22

3+
from django.db.models import Q
34
from rest_framework.exceptions import ErrorDetail
45

56
from sentry.api.serializers import serialize
67
from sentry.grouping.grouptype import ErrorGroupType
78
from sentry.incidents.grouptype import MetricIssue
89
from sentry.incidents.models.alert_rule import AlertRuleDetectionType
910
from sentry.models.environment import Environment
11+
from sentry.search.utils import _HACKY_INVALID_USER
1012
from sentry.snuba.dataset import Dataset
1113
from sentry.snuba.models import (
1214
QuerySubscription,
@@ -19,6 +21,7 @@
1921
from sentry.testutils.silo import region_silo_test
2022
from sentry.uptime.grouptype import UptimeDomainCheckFailure
2123
from sentry.uptime.types import DATA_SOURCE_UPTIME_SUBSCRIPTION
24+
from sentry.workflow_engine.endpoints.organization_detector_index import convert_assignee_values
2225
from sentry.workflow_engine.models import DataCondition, DataConditionGroup, DataSource, Detector
2326
from sentry.workflow_engine.models.data_condition import Condition
2427
from sentry.workflow_engine.registry import data_source_type_registry
@@ -543,21 +546,68 @@ def test_invalid_owner(self):
543546
)
544547
assert "owner" in response.data
545548

546-
def test_owner_not_in_organization(self):
547-
# Create a user in another organization
548-
other_org = self.create_organization()
549-
other_user = self.create_user()
550-
self.create_member(organization=other_org, user=other_user)
551549

552-
# Test with owner not in current organization
553-
data_with_invalid_owner = {
554-
**self.valid_data,
555-
"owner": other_user.get_actor_identifier(),
556-
}
550+
@region_silo_test
551+
class ConvertAssigneeValuesTest(APITestCase):
552+
"""Test the convert_assignee_values function"""
557553

558-
response = self.get_error_response(
559-
self.organization.slug,
560-
**data_with_invalid_owner,
561-
status_code=400,
562-
)
563-
assert "owner" in response.data
554+
def setUp(self):
555+
super().setUp()
556+
self.user = self.create_user()
557+
self.team = self.create_team(organization=self.organization)
558+
self.other_user = self.create_user()
559+
self.create_member(organization=self.organization, user=self.other_user)
560+
self.projects = [self.project]
561+
562+
def test_convert_assignee_values_user_email(self):
563+
result = convert_assignee_values([self.user.email], self.projects, self.user)
564+
expected = Q(owner_user_id=self.user.id)
565+
self.assertEqual(str(result), str(expected))
566+
567+
def test_convert_assignee_values_user_username(self):
568+
result = convert_assignee_values([self.user.username], self.projects, self.user)
569+
expected = Q(owner_user_id=self.user.id)
570+
self.assertEqual(str(result), str(expected))
571+
572+
def test_convert_assignee_values_team_slug(self):
573+
result = convert_assignee_values([f"#{self.team.slug}"], self.projects, self.user)
574+
expected = Q(owner_team_id=self.team.id)
575+
self.assertEqual(str(result), str(expected))
576+
577+
def test_convert_assignee_values_me(self):
578+
result = convert_assignee_values(["me"], self.projects, self.user)
579+
expected = Q(owner_user_id=self.user.id)
580+
self.assertEqual(str(result), str(expected))
581+
582+
def test_convert_assignee_values_none(self):
583+
result = convert_assignee_values(["none"], self.projects, self.user)
584+
expected = Q(owner_team_id__isnull=True, owner_user_id__isnull=True)
585+
self.assertEqual(str(result), str(expected))
586+
587+
def test_convert_assignee_values_multiple(self):
588+
result = convert_assignee_values(
589+
[str(self.user.email), f"#{self.team.slug}"], self.projects, self.user
590+
)
591+
expected = Q(owner_user_id=self.user.id) | Q(owner_team_id=self.team.id)
592+
self.assertEqual(str(result), str(expected))
593+
594+
def test_convert_assignee_values_mixed(self):
595+
result = convert_assignee_values(
596+
["me", "none", f"#{self.team.slug}"], self.projects, self.user
597+
)
598+
expected = (
599+
Q(owner_user_id=self.user.id)
600+
| Q(owner_team_id__isnull=True, owner_user_id__isnull=True)
601+
| Q(owner_team_id=self.team.id)
602+
)
603+
self.assertEqual(str(result), str(expected))
604+
605+
def test_convert_assignee_values_invalid(self):
606+
result = convert_assignee_values(["999999"], self.projects, self.user)
607+
expected = Q(owner_user_id=_HACKY_INVALID_USER.id)
608+
self.assertEqual(str(result), str(expected))
609+
610+
def test_convert_assignee_values_empty(self):
611+
result = convert_assignee_values([], self.projects, self.user)
612+
expected = Q()
613+
self.assertEqual(str(result), str(expected))

0 commit comments

Comments
 (0)