-
-
Notifications
You must be signed in to change notification settings - Fork 4.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
ref(rules): Fix typing for rule_snooze & rule #80306
base: master
Are you sure you want to change the base?
Changes from 4 commits
caf2e01
7ce90d3
8887dea
f83c410
a2f3474
e22d2bb
2507812
fd14271
b684856
7dd9791
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,6 +15,7 @@ | |
from sentry.api.exceptions import BadRequest | ||
from sentry.api.serializers import Serializer, register, serialize | ||
from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer | ||
from sentry.db.models.manager.base_query_set import BaseQuerySet | ||
from sentry.incidents.models.alert_rule import AlertRule | ||
from sentry.models.organization import Organization | ||
from sentry.models.organizationmember import OrganizationMember | ||
|
@@ -76,14 +77,15 @@ def serialize(self, obj, attrs, user, **kwargs): | |
@region_silo_endpoint | ||
class BaseRuleSnoozeEndpoint(ProjectEndpoint): | ||
permission_classes = (ProjectAlertRulePermission,) | ||
rule_model = type[Rule] | type[AlertRule] | ||
rule_model: type[AlertRule | Rule] | ||
rule_field = Literal["rule", "alert_rule"] | ||
|
||
def convert_args(self, request: Request, rule_id: int, *args, **kwargs): | ||
(args, kwargs) = super().convert_args(request, *args, **kwargs) | ||
project = kwargs["project"] | ||
try: | ||
if self.rule_model is AlertRule: | ||
queryset: BaseQuerySet[AlertRule | Rule] | ||
if issubclass(self.rule_model, AlertRule): | ||
queryset = self.rule_model.objects.fetch_for_project(project) | ||
else: | ||
queryset = self.rule_model.objects.filter(project=project) | ||
|
@@ -104,20 +106,25 @@ def post(self, request: Request, project: Project, rule: Rule | AlertRule) -> Re | |
|
||
if not can_edit_alert_rule(project.organization, request): | ||
raise PermissionDenied( | ||
detail="Requesting user cannot mute this rule.", code=status.HTTP_403_FORBIDDEN | ||
detail="Requesting user cannot mute this rule.", code=str(status.HTTP_403_FORBIDDEN) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah, good pt. the 403 comes included in the |
||
) | ||
|
||
kwargs = {self.rule_field: rule} | ||
|
||
user_id = request.user.id if data.get("target") == "me" else None | ||
|
||
# Due to mypy requiring keyword arguments to not be kwargs, we need to check the type of rule and query accordingly | ||
# In RuleSnooze, rule and alert_rule are mutually exclusive (cannot both be not null) | ||
rule_parameter = rule if isinstance(rule, Rule) else None | ||
alert_rule_parameter = None if isinstance(rule, Rule) else rule | ||
|
||
rule_snooze, created = RuleSnooze.objects.get_or_create( | ||
user_id=user_id, | ||
rule=rule_parameter, | ||
alert_rule=alert_rule_parameter, | ||
defaults={ | ||
"owner_id": request.user.id, | ||
"until": data.get("until"), | ||
"date_added": datetime.datetime.now(datetime.UTC), | ||
}, | ||
**kwargs, | ||
) | ||
# don't allow editing of a rulesnooze object for a given rule and user (or no user) | ||
if not created: | ||
|
@@ -158,9 +165,14 @@ def delete(self, request: Request, project: Project, rule: Rule | AlertRule) -> | |
# find if there is a mute for all that I can remove | ||
shared_snooze = None | ||
deletion_type = None | ||
kwargs = {self.rule_field: rule, "user_id": None} | ||
try: | ||
shared_snooze = RuleSnooze.objects.get(**kwargs) | ||
# Due to mypy requiring keyword arguments to not be kwargs, we need to check the type of rule and query accordingly | ||
# In RuleSnooze, rule and alert_rule are mutually exclusive (cannot both be not null) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the better fix is to give There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same above too! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nvm 🥲 it turns out a union of Literals in a variable key for a typed dict isn't supported by mypy currently (python/mypy#7752) class RuleField(TypedDict, total=False):
rule: Rule
alert_rule: AlertRule
kwargs: RuleField = {self.rule_field: rule} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. really starting to think this base class is a maintenance problem and trying to be too cute -- and that these are actually two disparate things trying to be shoved into a BaseRuleSnoozeEndpoint shaped box There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeahhhhh like we could delegate the RuleSnooze call to the child classes, and keep the rest of the logic in a helper in the base case, since the main conflict is that the base class doesn't know if it wants rule or alert_rule in the db call. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah or a few composable functions would probably work too! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wait, can you elaborate more on what composable functions means in this context ? The google tells me composable functions when we chain functions together/when functions take in other functions but I'm not sure how that would help in this case :thonk There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe won't need to compose -- but can probably make two separate functions for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pushing the differences in the subclasses into methods/functions could work. We have similar patterns for the |
||
rule_parameter = rule if isinstance(rule, Rule) else None | ||
alert_rule_parameter = None if isinstance(rule, Rule) else rule | ||
shared_snooze = RuleSnooze.objects.get( | ||
rule=rule_parameter, alert_rule=alert_rule_parameter, user_id=None | ||
) | ||
except RuleSnooze.DoesNotExist: | ||
pass | ||
|
||
|
@@ -169,11 +181,13 @@ def delete(self, request: Request, project: Project, rule: Rule | AlertRule) -> | |
shared_snooze.delete() | ||
deletion_type = "everyone" | ||
|
||
# next check if there is a mute for me that I can remove | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be good to retain this comment. |
||
kwargs = {self.rule_field: rule, "user_id": request.user.id} | ||
my_snooze = None | ||
try: | ||
my_snooze = RuleSnooze.objects.get(**kwargs) | ||
rule_parameter = rule if isinstance(rule, Rule) else None | ||
alert_rule_parameter = None if isinstance(rule, Rule) else rule | ||
my_snooze = RuleSnooze.objects.get( | ||
rule=rule_parameter, alert_rule=alert_rule_parameter, user_id=request.user.id | ||
) | ||
except RuleSnooze.DoesNotExist: | ||
pass | ||
else: | ||
|
@@ -197,7 +211,8 @@ def delete(self, request: Request, project: Project, rule: Rule | AlertRule) -> | |
# didn't find a match but there is a shared snooze | ||
if shared_snooze: | ||
raise PermissionDenied( | ||
detail="Requesting user cannot unmute this rule.", code=status.HTTP_403_FORBIDDEN | ||
detail="Requesting user cannot unmute this rule.", | ||
code=str(status.HTTP_403_FORBIDDEN), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This |
||
) | ||
# no snooze at all found | ||
return Response( | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,14 +5,14 @@ | |
from drf_spectacular.types import OpenApiTypes | ||
from drf_spectacular.utils import extend_schema_field | ||
from rest_framework import serializers | ||
from rest_framework.exceptions import ValidationError | ||
|
||
from sentry import features | ||
from sentry.api.fields.actor import ActorField | ||
from sentry.constants import MIGRATED_CONDITIONS, SENTRY_APP_ACTIONS, TICKET_ACTIONS | ||
from sentry.models.environment import Environment | ||
from sentry.rules import rules | ||
|
||
ValidationError = serializers.ValidationError | ||
from sentry.rules.actions.sentry_apps.notify_event import NotifyEventSentryAppAction | ||
|
||
|
||
@extend_schema_field(field=OpenApiTypes.OBJECT) | ||
|
@@ -46,12 +46,16 @@ def to_internal_value(self, data): | |
msg = "Invalid node. Could not find '%s'" | ||
raise ValidationError(msg % data["id"]) | ||
|
||
# instance of a RuleBase class | ||
node = cls(project=self.context["project"], data=data) | ||
|
||
# Nodes with user-declared fields will manage their own validation | ||
if node.id in SENTRY_APP_ACTIONS: | ||
if not data.get("hasSchemaFormConfig"): | ||
raise ValidationError("Please configure your integration settings.") | ||
assert isinstance( | ||
node, NotifyEventSentryAppAction | ||
), "node must be an instance of NotifyEventSentryAppAction to use self_validate" | ||
node.self_validate() | ||
return data | ||
|
||
|
@@ -64,10 +68,15 @@ def to_internal_value(self, data): | |
# XXX(epurkhiser): Very hacky, but we really just want validation | ||
# errors that are more specific, not just 'this wasn't filled out', | ||
# give a more generic error for those. | ||
first_error = next(iter(form.errors.values()))[0] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interestingly, by using There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. so the
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yesssss, though the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would just remove the annotation personally |
||
# Still hacky, but a bit clearer, for each field there can be a list of errors so | ||
# we get the first error's message for each field and then get the first error message from that list | ||
first_error_message: str = [ | ||
error_list[0]["message"] | ||
for field, error_list in form.errors.get_json_data().items() | ||
][0] | ||
|
||
if first_error != "This field is required.": | ||
raise ValidationError(first_error) | ||
if first_error_message != "This field is required.": | ||
raise ValidationError(first_error_message) | ||
|
||
raise ValidationError("Ensure all required fields are filled in.") | ||
|
||
|
@@ -168,31 +177,6 @@ def validate_conditions(self, conditions): | |
def validate(self, attrs): | ||
return super().validate(validate_actions(attrs)) | ||
|
||
def save(self, rule): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No code ran/referenced this ._. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this gets called from rest_framework maybe? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 'o' is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. DRF won't call save() automatically, it would be a method call we're making. However, I wasn't able to find any callsites for this method either. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am not sure -- I thought it was magically invoked by forms somehow -- could probably trace when this function was introduced and see how it was used in that PR and then from there figure out if that code path is still used There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. :0) good thinking, looks like this (vintage) function used to be used in the rules endpoints, but now we use dataclasses (see: ProjectRuleCreator & ProjectRuleUpdater) that do the same actions instead |
||
rule.project = self.context["project"] | ||
if "environment" in self.validated_data: | ||
environment = self.validated_data["environment"] | ||
rule.environment_id = int(environment) if environment else environment | ||
if self.validated_data.get("name"): | ||
rule.label = self.validated_data["name"] | ||
if self.validated_data.get("actionMatch"): | ||
rule.data["action_match"] = self.validated_data["actionMatch"] | ||
if self.validated_data.get("filterMatch"): | ||
rule.data["filter_match"] = self.validated_data["filterMatch"] | ||
if self.validated_data.get("actions") is not None: | ||
rule.data["actions"] = self.validated_data["actions"] | ||
if self.validated_data.get("conditions") is not None: | ||
rule.data["conditions"] = self.validated_data["conditions"] | ||
if self.validated_data.get("frequency"): | ||
rule.data["frequency"] = self.validated_data["frequency"] | ||
if self.validated_data.get("owner"): | ||
actor = self.validated_data["owner"].resolve_to_actor() | ||
rule.owner_id = actor.id | ||
rule.owner_user_id = actor.user_id | ||
rule.owner_team_id = actor.team_id | ||
rule.save() | ||
return rule | ||
|
||
|
||
ACTION_UUID_KEY = "uuid" | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Moved back from Generic to union typehint, since the
Model
bound was giving causing mypy to freak out over whatT
could be. E.g in lines 87-89/89-91 where we accessBaseManager[AlertRule]
to getfetch_for_project
but mypy saysBaseManager[T]
doesn't have access to that func.....There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
might be useful to have an
# abstract
comment on this just so it's clearbtw the original code was a type alias so I'm surprised this worked at all!