Skip to content

Commit c946d7b

Browse files
ref(rules): Fix typing for rule_snooze & rule (#80306)
1 parent 3820064 commit c946d7b

File tree

3 files changed

+118
-77
lines changed

3 files changed

+118
-77
lines changed

pyproject.toml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,6 @@ module = [
157157
"sentry.api.endpoints.project_rules_configuration",
158158
"sentry.api.endpoints.project_servicehook_stats",
159159
"sentry.api.endpoints.project_transaction_names",
160-
"sentry.api.endpoints.rule_snooze",
161160
"sentry.api.endpoints.team_details",
162161
"sentry.api.endpoints.team_release_count",
163162
"sentry.api.endpoints.user_subscriptions",
@@ -182,7 +181,6 @@ module = [
182181
"sentry.api.serializers.models.team",
183182
"sentry.api.serializers.rest_framework.mentions",
184183
"sentry.api.serializers.rest_framework.notification_action",
185-
"sentry.api.serializers.rest_framework.rule",
186184
"sentry.auth.helper",
187185
"sentry.auth.provider",
188186
"sentry.auth.system",

src/sentry/api/endpoints/rule_snooze.py

Lines changed: 104 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import datetime
2-
from typing import Literal
2+
from typing import Any, Generic, TypeVar
33

44
from django.contrib.auth.models import AnonymousUser
5+
from django.core.exceptions import ObjectDoesNotExist
56
from rest_framework import serializers, status
67
from rest_framework.exceptions import NotFound, PermissionDenied
78
from rest_framework.request import Request
@@ -15,6 +16,8 @@
1516
from sentry.api.exceptions import BadRequest
1617
from sentry.api.serializers import Serializer, register, serialize
1718
from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer
19+
from sentry.db.models.base import Model
20+
from sentry.db.models.manager.base_query_set import BaseQuerySet
1821
from sentry.incidents.models.alert_rule import AlertRule
1922
from sentry.models.organization import Organization
2023
from sentry.models.organizationmember import OrganizationMember
@@ -73,69 +76,61 @@ def serialize(self, obj, attrs, user, **kwargs):
7376
return result
7477

7578

76-
@region_silo_endpoint
77-
class BaseRuleSnoozeEndpoint(ProjectEndpoint):
79+
T = TypeVar("T", bound=Model)
80+
81+
82+
class BaseRuleSnoozeEndpoint(ProjectEndpoint, Generic[T]):
7883
permission_classes = (ProjectAlertRulePermission,)
79-
rule_model = type[Rule] | type[AlertRule]
80-
rule_field = Literal["rule", "alert_rule"]
84+
rule_field: str # abstract, value comes from child class
8185

8286
def convert_args(self, request: Request, rule_id: int, *args, **kwargs):
8387
(args, kwargs) = super().convert_args(request, *args, **kwargs)
8488
project = kwargs["project"]
8589
try:
86-
if self.rule_model is AlertRule:
87-
queryset = self.rule_model.objects.fetch_for_project(project)
88-
else:
89-
queryset = self.rule_model.objects.filter(project=project)
90+
queryset = self.fetch_rule_list(project=project)
9091
rule = queryset.get(id=rule_id)
91-
except self.rule_model.DoesNotExist:
92+
except ObjectDoesNotExist:
9293
raise NotFound(detail="Rule does not exist")
9394

9495
kwargs["rule"] = rule
9596

9697
return (args, kwargs)
9798

98-
def post(self, request: Request, project: Project, rule: Rule | AlertRule) -> Response:
99+
def post(self, request: Request, project: Project, rule: T) -> Response:
99100
serializer = RuleSnoozeValidator(data=request.data)
100101
if not serializer.is_valid():
101102
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
102103

103104
data = serializer.validated_data
104105

105106
if not can_edit_alert_rule(project.organization, request):
106-
raise PermissionDenied(
107-
detail="Requesting user cannot mute this rule.", code=status.HTTP_403_FORBIDDEN
108-
)
109-
110-
kwargs = {self.rule_field: rule}
107+
raise PermissionDenied(detail="Requesting user cannot mute this rule.")
111108

112109
user_id = request.user.id if data.get("target") == "me" else None
113-
rule_snooze, created = RuleSnooze.objects.get_or_create(
114-
user_id=user_id,
115-
defaults={
116-
"owner_id": request.user.id,
117-
"until": data.get("until"),
118-
"date_added": datetime.datetime.now(datetime.UTC),
119-
},
120-
**kwargs,
121-
)
122-
# don't allow editing of a rulesnooze object for a given rule and user (or no user)
123-
if not created:
110+
111+
try:
112+
rule_snooze = self.fetch_instance(
113+
user_id=user_id,
114+
rule=rule,
115+
)
116+
# don't allow editing of a rulesnooze object for a given rule and user (or no user)
124117
return Response(
125118
{"detail": "RuleSnooze already exists for this rule and scope."},
126119
status=status.HTTP_410_GONE,
127120
)
121+
except RuleSnooze.DoesNotExist:
122+
rule_snooze = self.create_instance(
123+
rule=rule,
124+
user_id=user_id,
125+
owner_id=request.user.id,
126+
until=data.get("until"),
127+
date_added=datetime.datetime.now(datetime.UTC),
128+
)
128129

129130
if not user_id:
130131
# create an audit log entry if the rule is snoozed for everyone
131-
audit_log_event = "RULE_SNOOZE" if self.rule_model == Rule else "ALERT_RULE_SNOOZE"
132-
133-
self.create_audit_entry(
134-
request=request,
135-
organization=project.organization,
136-
target_object=rule.id,
137-
event=audit_log.get_event_id(audit_log_event),
138-
data=rule.get_audit_log_data(),
132+
self.record_audit_log_entry(
133+
request=request, organization=project.organization, rule=rule
139134
)
140135

141136
analytics.record(
@@ -154,13 +149,12 @@ def post(self, request: Request, project: Project, rule: Rule | AlertRule) -> Re
154149
status=status.HTTP_201_CREATED,
155150
)
156151

157-
def delete(self, request: Request, project: Project, rule: Rule | AlertRule) -> Response:
152+
def delete(self, request: Request, project: Project, rule: T) -> Response:
158153
# find if there is a mute for all that I can remove
159154
shared_snooze = None
160155
deletion_type = None
161-
kwargs = {self.rule_field: rule, "user_id": None}
162156
try:
163-
shared_snooze = RuleSnooze.objects.get(**kwargs)
157+
shared_snooze = self.fetch_instance(user_id=None, rule=rule)
164158
except RuleSnooze.DoesNotExist:
165159
pass
166160

@@ -170,10 +164,9 @@ def delete(self, request: Request, project: Project, rule: Rule | AlertRule) ->
170164
deletion_type = "everyone"
171165

172166
# next check if there is a mute for me that I can remove
173-
kwargs = {self.rule_field: rule, "user_id": request.user.id}
174167
my_snooze = None
175168
try:
176-
my_snooze = RuleSnooze.objects.get(**kwargs)
169+
my_snooze = self.fetch_instance(user_id=request.user.id, rule=rule)
177170
except RuleSnooze.DoesNotExist:
178171
pass
179172
else:
@@ -197,32 +190,98 @@ def delete(self, request: Request, project: Project, rule: Rule | AlertRule) ->
197190
# didn't find a match but there is a shared snooze
198191
if shared_snooze:
199192
raise PermissionDenied(
200-
detail="Requesting user cannot unmute this rule.", code=status.HTTP_403_FORBIDDEN
193+
detail="Requesting user cannot unmute this rule.",
201194
)
202195
# no snooze at all found
203196
return Response(
204197
{"detail": "This rulesnooze object doesn't exist."},
205198
status=status.HTTP_404_NOT_FOUND,
206199
)
207200

201+
def record_audit_log_entry(
202+
self, request: Request, organization: Organization, rule: T, **kwargs: Any
203+
) -> None:
204+
raise NotImplementedError()
205+
206+
def fetch_instance(self, rule: T, user_id: int | None, **kwargs: Any) -> RuleSnooze:
207+
raise NotImplementedError()
208+
209+
def create_instance(self, rule: T, user_id: int | None, **kwargs: Any) -> RuleSnooze:
210+
raise NotImplementedError()
211+
212+
def fetch_rule_list(self, project: Project) -> BaseQuerySet[T]:
213+
raise NotImplementedError()
214+
208215

209216
@region_silo_endpoint
210-
class RuleSnoozeEndpoint(BaseRuleSnoozeEndpoint):
217+
class RuleSnoozeEndpoint(BaseRuleSnoozeEndpoint[Rule]):
211218
owner = ApiOwner.ISSUES
212219
publish_status = {
213220
"DELETE": ApiPublishStatus.UNKNOWN,
214221
"POST": ApiPublishStatus.UNKNOWN,
215222
}
216-
rule_model = Rule
217223
rule_field = "rule"
218224

225+
def fetch_rule_list(self, project: Project) -> BaseQuerySet[Rule]:
226+
queryset = Rule.objects.filter(project=project)
227+
228+
return queryset
229+
230+
def fetch_instance(self, rule: Rule, user_id: int | None, **kwargs: Any) -> RuleSnooze:
231+
rule_snooze = RuleSnooze.objects.get(user_id=user_id, rule=rule, **kwargs)
232+
233+
return rule_snooze
234+
235+
def create_instance(self, rule: Rule, user_id: int | None, **kwargs: Any) -> RuleSnooze:
236+
rule_snooze = RuleSnooze.objects.create(user_id=user_id, rule=rule, **kwargs)
237+
238+
return rule_snooze
239+
240+
def record_audit_log_entry(
241+
self, request: Request, organization: Organization, rule: Rule, **kwargs: Any
242+
) -> None:
243+
self.create_audit_entry(
244+
request=request,
245+
organization=organization,
246+
target_object=rule.id,
247+
event=audit_log.get_event_id("RULE_SNOOZE"),
248+
data=rule.get_audit_log_data(),
249+
**kwargs,
250+
)
251+
219252

220253
@region_silo_endpoint
221-
class MetricRuleSnoozeEndpoint(BaseRuleSnoozeEndpoint):
254+
class MetricRuleSnoozeEndpoint(BaseRuleSnoozeEndpoint[AlertRule]):
222255
owner = ApiOwner.ISSUES
223256
publish_status = {
224257
"DELETE": ApiPublishStatus.UNKNOWN,
225258
"POST": ApiPublishStatus.UNKNOWN,
226259
}
227-
rule_model = AlertRule
228260
rule_field = "alert_rule"
261+
262+
def fetch_rule_list(self, project: Project) -> BaseQuerySet[AlertRule]:
263+
queryset = AlertRule.objects.fetch_for_project(project=project)
264+
265+
return queryset
266+
267+
def fetch_instance(self, rule: AlertRule, user_id: int | None, **kwargs: Any) -> RuleSnooze:
268+
rule_snooze = RuleSnooze.objects.get(user_id=user_id, alert_rule=rule, **kwargs)
269+
270+
return rule_snooze
271+
272+
def create_instance(self, rule: AlertRule, user_id: int | None, **kwargs: Any) -> RuleSnooze:
273+
rule_snooze = RuleSnooze.objects.create(user_id=user_id, alert_rule=rule, **kwargs)
274+
275+
return rule_snooze
276+
277+
def record_audit_log_entry(
278+
self, request: Request, organization: Organization, rule: AlertRule, **kwargs: Any
279+
) -> None:
280+
self.create_audit_entry(
281+
request=request,
282+
organization=organization,
283+
target_object=rule.id,
284+
event=audit_log.get_event_id("ALERT_RULE_SNOOZE"),
285+
data=rule.get_audit_log_data(),
286+
**kwargs,
287+
)

src/sentry/api/serializers/rest_framework/rule.py

Lines changed: 14 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
from drf_spectacular.types import OpenApiTypes
66
from drf_spectacular.utils import extend_schema_field
77
from rest_framework import serializers
8+
from rest_framework.exceptions import ValidationError
89

910
from sentry import features
1011
from sentry.api.fields.actor import ActorField
1112
from sentry.constants import MIGRATED_CONDITIONS, SENTRY_APP_ACTIONS, TICKET_ACTIONS
1213
from sentry.models.environment import Environment
1314
from sentry.rules import rules
14-
15-
ValidationError = serializers.ValidationError
15+
from sentry.rules.actions.sentry_apps.notify_event import NotifyEventSentryAppAction
1616

1717

1818
@extend_schema_field(field=OpenApiTypes.OBJECT)
@@ -46,12 +46,16 @@ def to_internal_value(self, data):
4646
msg = "Invalid node. Could not find '%s'"
4747
raise ValidationError(msg % data["id"])
4848

49+
# instance of a RuleBase class
4950
node = cls(project=self.context["project"], data=data)
5051

5152
# Nodes with user-declared fields will manage their own validation
5253
if node.id in SENTRY_APP_ACTIONS:
5354
if not data.get("hasSchemaFormConfig"):
5455
raise ValidationError("Please configure your integration settings.")
56+
assert isinstance(
57+
node, NotifyEventSentryAppAction
58+
), "node must be an instance of NotifyEventSentryAppAction to use self_validate"
5559
node.self_validate()
5660
return data
5761

@@ -64,10 +68,15 @@ def to_internal_value(self, data):
6468
# XXX(epurkhiser): Very hacky, but we really just want validation
6569
# errors that are more specific, not just 'this wasn't filled out',
6670
# give a more generic error for those.
67-
first_error = next(iter(form.errors.values()))[0]
71+
# Still hacky, but a bit clearer, for each field there can be a list of errors so
72+
# we get the first error's message for each field and then get the first error message from that list
73+
first_error_message = [
74+
error_list[0]["message"]
75+
for field, error_list in form.errors.get_json_data().items()
76+
][0]
6877

69-
if first_error != "This field is required.":
70-
raise ValidationError(first_error)
78+
if first_error_message != "This field is required.":
79+
raise ValidationError(first_error_message)
7180

7281
raise ValidationError("Ensure all required fields are filled in.")
7382

@@ -168,31 +177,6 @@ def validate_conditions(self, conditions):
168177
def validate(self, attrs):
169178
return super().validate(validate_actions(attrs))
170179

171-
def save(self, rule):
172-
rule.project = self.context["project"]
173-
if "environment" in self.validated_data:
174-
environment = self.validated_data["environment"]
175-
rule.environment_id = int(environment) if environment else environment
176-
if self.validated_data.get("name"):
177-
rule.label = self.validated_data["name"]
178-
if self.validated_data.get("actionMatch"):
179-
rule.data["action_match"] = self.validated_data["actionMatch"]
180-
if self.validated_data.get("filterMatch"):
181-
rule.data["filter_match"] = self.validated_data["filterMatch"]
182-
if self.validated_data.get("actions") is not None:
183-
rule.data["actions"] = self.validated_data["actions"]
184-
if self.validated_data.get("conditions") is not None:
185-
rule.data["conditions"] = self.validated_data["conditions"]
186-
if self.validated_data.get("frequency"):
187-
rule.data["frequency"] = self.validated_data["frequency"]
188-
if self.validated_data.get("owner"):
189-
actor = self.validated_data["owner"].resolve_to_actor()
190-
rule.owner_id = actor.id
191-
rule.owner_user_id = actor.user_id
192-
rule.owner_team_id = actor.team_id
193-
rule.save()
194-
return rule
195-
196180

197181
ACTION_UUID_KEY = "uuid"
198182

0 commit comments

Comments
 (0)