From c50c1f9c274ebfb6506c1ec325d1c6c6bb2a46cd Mon Sep 17 00:00:00 2001 From: Mike Ihbe Date: Wed, 14 Aug 2024 15:00:45 -0700 Subject: [PATCH 01/38] CRUDL APIs for escalation policies and rotation schedules --- migrations_lockfile.txt | 2 +- src/sentry/api/urls.py | 34 +++ .../examples/escalation_policy_examples.py | 103 ++++++++ .../examples/rotation_schedule_examples.py | 84 +++++++ src/sentry/apidocs/parameters.py | 20 ++ .../escalation_policies/endpoints/__init__.py | 0 .../endpoints/escalation_policy_details.py | 86 +++++++ .../endpoints/escalation_policy_index.py | 105 ++++++++ .../endpoints/rotation_schedule_details.py | 87 +++++++ .../endpoints/rotation_schedule_index.py | 104 ++++++++ .../serializers/escalation_policy.py | 182 ++++++++++++++ .../serializers/rotation_schedule.py | 167 +++++++++++++ .../escalation_policies/models/__init__.py | 0 .../models/escalation_policy.py | 72 ++++++ .../models/escalation_policy_state.py | 31 +++ .../models/rotation_schedule.py | 111 +++++++++ .../migrations/0748_escalation_policies.py | 227 ++++++++++++++++++ src/sentry/testutils/factories.py | 93 +++++++ src/sentry/testutils/fixtures.py | 41 ++++ .../test_escalation_policy_details.py | 53 ++++ .../endpoints/test_escalation_policy_index.py | 121 ++++++++++ .../test_rotation_schedule_details.py | 53 ++++ .../endpoints/test_rotation_schedule_index.py | 126 ++++++++++ .../serializers/test_escalation_policy.py | 26 ++ .../serializers/test_rotation_schedule.py | 63 +++++ 25 files changed, 1990 insertions(+), 1 deletion(-) create mode 100644 src/sentry/apidocs/examples/escalation_policy_examples.py create mode 100644 src/sentry/apidocs/examples/rotation_schedule_examples.py create mode 100644 src/sentry/escalation_policies/endpoints/__init__.py create mode 100644 src/sentry/escalation_policies/endpoints/escalation_policy_details.py create mode 100644 src/sentry/escalation_policies/endpoints/escalation_policy_index.py create mode 100644 src/sentry/escalation_policies/endpoints/rotation_schedule_details.py create mode 100644 src/sentry/escalation_policies/endpoints/rotation_schedule_index.py create mode 100644 src/sentry/escalation_policies/endpoints/serializers/escalation_policy.py create mode 100644 src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py create mode 100644 src/sentry/escalation_policies/models/__init__.py create mode 100644 src/sentry/escalation_policies/models/escalation_policy.py create mode 100644 src/sentry/escalation_policies/models/escalation_policy_state.py create mode 100644 src/sentry/escalation_policies/models/rotation_schedule.py create mode 100644 src/sentry/migrations/0748_escalation_policies.py create mode 100644 tests/sentry/escalation_policies/endpoints/test_escalation_policy_details.py create mode 100644 tests/sentry/escalation_policies/endpoints/test_escalation_policy_index.py create mode 100644 tests/sentry/escalation_policies/endpoints/test_rotation_schedule_details.py create mode 100644 tests/sentry/escalation_policies/endpoints/test_rotation_schedule_index.py create mode 100644 tests/sentry/escalation_policies/serializers/test_escalation_policy.py create mode 100644 tests/sentry/escalation_policies/serializers/test_rotation_schedule.py diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 7bfad071c772e2..dc2f09460e4bab 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -10,6 +10,6 @@ hybridcloud: 0016_add_control_cacheversion nodestore: 0002_nodestore_no_dictfield remote_subscriptions: 0003_drop_remote_subscription replays: 0004_index_together -sentry: 0747_create_datasecrecywaiver_table +sentry: 0748_escalation_policies social_auth: 0002_default_auto_field uptime: 0006_projectuptimesubscription_name_owner diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 646a450f7be8d9..4d77ec62377225 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -77,6 +77,18 @@ DiscoverSavedQueryDetailEndpoint, DiscoverSavedQueryVisitEndpoint, ) +from sentry.escalation_policies.endpoints.escalation_policy_details import ( + OrganizationEscalationPolicyDetailsEndpoint, +) +from sentry.escalation_policies.endpoints.escalation_policy_index import ( + OrganizationEscalationPolicyIndexEndpoint, +) +from sentry.escalation_policies.endpoints.rotation_schedule_details import ( + OrganizationRotationScheduleDetailsEndpoint, +) +from sentry.escalation_policies.endpoints.rotation_schedule_index import ( + OrganizationRotationScheduleIndexEndpoint, +) from sentry.incidents.endpoints.organization_alert_rule_activations import ( OrganizationAlertRuleActivationsEndpoint, ) @@ -1175,6 +1187,28 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: OrganizationCombinedRuleIndexEndpoint.as_view(), name="sentry-api-0-organization-combined-rules", ), + # Escalation Policies + re_path( + r"^(?P[^\/]+)/escalation-policies/$", + OrganizationEscalationPolicyIndexEndpoint.as_view(), + name="sentry-api-0-organization-escalation-policies", + ), + re_path( + r"^(?P[^\/]+)/escalation-policies/(?P[^\/]+)/$", + OrganizationEscalationPolicyDetailsEndpoint.as_view(), + name="sentry-api-0-organization-escalation-policy-details", + ), + # Rotation Schedules + re_path( + r"^(?P[^\/]+)/rotation-schedules/$", + OrganizationRotationScheduleIndexEndpoint.as_view(), + name="sentry-api-0-organization-rotation-schedules", + ), + re_path( + r"^(?P[^\/]+)/rotation-schedules/(?P[^\/]+)/$", + OrganizationRotationScheduleDetailsEndpoint.as_view(), + name="sentry-api-0-organization-rotation-schedule-details", + ), # Data Export re_path( r"^(?P[^\/]+)/data-export/$", diff --git a/src/sentry/apidocs/examples/escalation_policy_examples.py b/src/sentry/apidocs/examples/escalation_policy_examples.py new file mode 100644 index 00000000000000..58cf6e5e662750 --- /dev/null +++ b/src/sentry/apidocs/examples/escalation_policy_examples.py @@ -0,0 +1,103 @@ +from drf_spectacular.utils import OpenApiExample + + +class EscalationPolicyExamples: + GET_ESCALATION_POLICY = [ + OpenApiExample( + "Get detailed view of an escalation policy", + value={ + "id": "1", + "name": "Escalation 1", + "description": "i am a happy escalation path", + "repeat_n_times": 2, + "steps": [ + { + "step_number": 1, + "escalate_after_sec": 30, + "recipients": [ + { + "type": "team", + "data": { + "id": "4554497953890304", + "slug": "foo", + "name": "foo", + "isMember": False, + "teamRole": None, + "flags": {"idp:provisioned": False}, + "access": frozenset( + { + "alerts:read", + "member:read", + "event:read", + "org:read", + "event:write", + "project:releases", + "team:read", + "project:read", + } + ), + "hasAccess": True, + "isPending": False, + "memberCount": 0, + "avatar": {"avatarType": "letter_avatar", "avatarUuid": None}, + }, + }, + { + "type": "user", + "data": { + "id": 1, + "name": "", + "email": "admin@localhost", + "display_name": "admin@localhost", + "avatar": {"avatarType": "letter_avatar", "avatarUuid": None}, + }, + }, + ], + } + ], + }, + status_codes=["200"], + response_only=True, + ) + ] + + LIST_ESCALATION_POLICIES = [ + OpenApiExample( + "List escalation policies for an organization", + value=[GET_ESCALATION_POLICY[0].value], + status_codes=["200"], + response_only=True, + ) + ] + + CREATE_OR_UPDATE_ESCALATION_POLICY = [ + OpenApiExample( + "Create an escalation policy for an organization", + value={ + "id": "177104", + "name": "Apdex % Check", + "description": "i am a happy escalation path", + "repeat_n_times": 2, + "team_id": "38432982", + "user_id": None, + "steps": [ + { + "escalate_after_sec": 60, + "recipients": [ + { + "schedule_id": "38432982", + }, + { + "team_id": "38432982", + }, + { + "user_id": "38432982", + }, + ], + } + ], + }, + status_codes=["200", "201"], + response_only=True, + ) + ] diff --git a/src/sentry/apidocs/examples/rotation_schedule_examples.py b/src/sentry/apidocs/examples/rotation_schedule_examples.py new file mode 100644 index 00000000000000..4a97357b3a6c2d --- /dev/null +++ b/src/sentry/apidocs/examples/rotation_schedule_examples.py @@ -0,0 +1,84 @@ +from drf_spectacular.utils import OpenApiExample + + +class RotationScheduleExamples: + GET_ROTATION_SCHEDULE = [ + OpenApiExample( + "Get detailed view of a rotation schedule", + value={ + "id": "1", + "name": "Escalation 1", + "organization_id": "123", + "team_id": "48237492", + "user_id": None, + "schedule_layers": [ + { + "rotation_type": "weekly", + "handoff_time": "0 4 * * 1", + "start_time": "2024-01-01T00:00:00+00:00", + "schedule_layer_restrictions": { + "Sun": [], + "Mon": [["08:00", "17:00"]], + "Tue": [["08:00", "17:00"]], + "Wed": [["08:00", "17:00"]], + "Thu": [["08:00", "17:00"]], + "Fri": [["08:00", "17:00"]], + "Sat": [], + }, + "users": [ + { + "id": 1, + "name": "", + "email": "admin@localhost", + "display_name": "admin@localhost", + "avatar": {"avatarType": "letter_avatar", "avatarUuid": None}, + } + ], + } + ], + }, + status_codes=["200"], + response_only=True, + ) + ] + + LIST_ROTATION_SCHEDULES = [ + OpenApiExample( + "List rotation schedules for an organization", + value=[GET_ROTATION_SCHEDULE[0].value], + status_codes=["200"], + response_only=True, + ) + ] + + CREATE_OR_UPDATE_ROTATION_SCHEDULE = [ + OpenApiExample( + "Create or update a rotation schedule", + value={ + "id": "1", + "name": "Escalation 1", + "organization_id": "123", + "team_id": "48237492", + "user_id": None, + "schedule_layers": [ + { + "rotation_type": "weekly", + "handoff_time": "0 4 * * 1", + "start_time": "2024-01-01T00:00:00+00:00", + "schedule_layer_restrictions": { + "Sun": [], + "Mon": [["08:00", "17:00"]], + "Tue": [["08:00", "17:00"]], + "Wed": [["08:00", "17:00"]], + "Thu": [["08:00", "17:00"]], + "Fri": [["08:00", "17:00"]], + "Sat": [], + }, + "user_ids": ["123", "456"], + } + ], + }, + status_codes=["200", "201"], + response_only=True, + ) + ] diff --git a/src/sentry/apidocs/parameters.py b/src/sentry/apidocs/parameters.py index 53ca248c2b8c20..5982d02bdf4a42 100644 --- a/src/sentry/apidocs/parameters.py +++ b/src/sentry/apidocs/parameters.py @@ -273,6 +273,26 @@ class MetricAlertParams: ) +class EscalationPolicyParams: + ESCALATION_POLICY_ID = OpenApiParameter( + name="escalation_policy_id", + location="path", + required=True, + type=int, + description="The ID of the escalation policy you'd like to query.", + ) + + +class RotationScheduleParams: + ROTATION_SCHEDULE_ID = OpenApiParameter( + name="rotation_schedule_id", + location="path", + required=True, + type=int, + description="The ID of the rotation schedule you'd like to query.", + ) + + class VisibilityParams: QUERY = OpenApiParameter( name="query", diff --git a/src/sentry/escalation_policies/endpoints/__init__.py b/src/sentry/escalation_policies/endpoints/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/src/sentry/escalation_policies/endpoints/escalation_policy_details.py b/src/sentry/escalation_policies/endpoints/escalation_policy_details.py new file mode 100644 index 00000000000000..940ccb0dd93ef5 --- /dev/null +++ b/src/sentry/escalation_policies/endpoints/escalation_policy_details.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import region_silo_endpoint +from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.exceptions import ResourceDoesNotExist +from sentry.api.serializers.base import serialize +from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED +from sentry.apidocs.parameters import EscalationPolicyParams, GlobalParams +from sentry.escalation_policies.endpoints.serializers.escalation_policy import ( + EscalationPolicySerializer, + EscalationPolicySerializerResponse, +) +from sentry.escalation_policies.models.escalation_policy import EscalationPolicy + + +@extend_schema(tags=["Escalation Policies"]) +@region_silo_endpoint +class OrganizationEscalationPolicyDetailsEndpoint(OrganizationEndpoint): + owner = ApiOwner.ENTERPRISE + publish_status = { + "DELETE": ApiPublishStatus.PUBLIC, + "GET": ApiPublishStatus.PUBLIC, + } + permission_classes = (OrganizationPermission,) + + def convert_args(self, request: Request, escalation_policy_id, *args, **kwargs): + args, kwargs = super().convert_args(request, *args, **kwargs) + organization = kwargs["organization"] + + try: + kwargs["escalation_policy"] = EscalationPolicy.objects.get( + organization=organization, id=escalation_policy_id + ) + except EscalationPolicy.DoesNotExist: + raise ResourceDoesNotExist + + return args, kwargs + + @extend_schema( + operation_id="Get an escalation policy", + parameters=[GlobalParams.ORG_ID_OR_SLUG, EscalationPolicyParams.ESCALATION_POLICY_ID], + request=None, + responses={ + 200: EscalationPolicySerializerResponse, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=None, # TODO: + ) + def get(self, request: Request, organization, escalation_policy) -> Response: + """ + Return a single escalation policy + """ + escalation_policy = EscalationPolicy.objects.get( + organization_id=organization.id, + ) + serializer = EscalationPolicySerializer() + + return Response(serialize(escalation_policy, serializer)) + + @extend_schema( + operation_id="Delete an Escalation Policy for an Organization", + parameters=[GlobalParams.ORG_ID_OR_SLUG, EscalationPolicyParams.ESCALATION_POLICY_ID], + request=None, + responses={ + 204: None, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=None, # TODO: + ) + def delete(self, request: Request, organization, escalation_policy) -> Response: + """ + Create or update an escalation policy for the given organization. + """ + escalation_policy.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/sentry/escalation_policies/endpoints/escalation_policy_index.py b/src/sentry/escalation_policies/endpoints/escalation_policy_index.py new file mode 100644 index 00000000000000..3d8d891e4e14a6 --- /dev/null +++ b/src/sentry/escalation_policies/endpoints/escalation_policy_index.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.exceptions import ValidationError +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import region_silo_endpoint +from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.paginator import OffsetPaginator +from sentry.api.serializers.base import serialize +from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED +from sentry.apidocs.examples.escalation_policy_examples import EscalationPolicyExamples +from sentry.apidocs.parameters import GlobalParams +from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.escalation_policies.endpoints.serializers.escalation_policy import ( + EscalationPolicyPutSerializer, + EscalationPolicySerializer, + EscalationPolicySerializerResponse, +) +from sentry.escalation_policies.models.escalation_policy import EscalationPolicy + + +@extend_schema(tags=["Escalation Policies"]) +@region_silo_endpoint +class OrganizationEscalationPolicyIndexEndpoint(OrganizationEndpoint): + owner = ApiOwner.ENTERPRISE + publish_status = { + "GET": ApiPublishStatus.PUBLIC, + "PUT": ApiPublishStatus.PUBLIC, + } + permission_classes = (OrganizationPermission,) + + @extend_schema( + operation_id="List an Organization's Escalation Policies", + parameters=[GlobalParams.ORG_ID_OR_SLUG], + request=None, + responses={ + 200: inline_sentry_response_serializer( + "ListEscalationPolicies", list[EscalationPolicySerializerResponse] + ), + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=EscalationPolicyExamples.LIST_ESCALATION_POLICIES, + ) + def get(self, request: Request, organization) -> Response: + """ + Return a list of escalation policies bound to an organization. + """ + queryset = EscalationPolicy.objects.filter( + organization_id=organization.id, + ) + serializer = EscalationPolicySerializer() + + return self.paginate( + request=request, + queryset=queryset, + order_by=("id",), + paginator_cls=OffsetPaginator, + on_results=lambda x: serialize(x, request.user, serializer=serializer), + ) + + @extend_schema( + operation_id="Create or update an Escalation Policy for an Organization", + parameters=[GlobalParams.ORG_ID_OR_SLUG], + request=EscalationPolicyPutSerializer, + responses={ + 200: EscalationPolicySerializerResponse, + 201: EscalationPolicySerializerResponse, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=EscalationPolicyExamples.CREATE_OR_UPDATE_ESCALATION_POLICY, + ) + def put(self, request: Request, organization) -> Response: + """ + Create or update an escalation policy for the given organization. + """ + serializer = EscalationPolicyPutSerializer( + context={ + "organization": organization, + "access": request.access, + "user": request.user, + "ip_address": request.META.get("REMOTE_ADDR"), + }, + data=request.data, + ) + + if not serializer.is_valid(): + raise ValidationError(serializer.errors) + + # TODO: Check permissions -- if a policy with passed in ID is found, policy must be part of this org + # TODO: assert organization_id is added properly + + policy = serializer.save() + if "id" in request.data: + return Response(serialize(policy, request.user), status=status.HTTP_200_OK) + else: + return Response(serialize(policy, request.user), status=status.HTTP_201_CREATED) diff --git a/src/sentry/escalation_policies/endpoints/rotation_schedule_details.py b/src/sentry/escalation_policies/endpoints/rotation_schedule_details.py new file mode 100644 index 00000000000000..f902efdb301853 --- /dev/null +++ b/src/sentry/escalation_policies/endpoints/rotation_schedule_details.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import region_silo_endpoint +from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.exceptions import ResourceDoesNotExist +from sentry.api.serializers import serialize +from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED +from sentry.apidocs.examples.rotation_schedule_examples import RotationScheduleExamples +from sentry.apidocs.parameters import GlobalParams, RotationScheduleParams +from sentry.escalation_policies.endpoints.serializers.rotation_schedule import ( + RotationScheduleSerializer, + RotationScheduleSerializerResponse, +) +from sentry.escalation_policies.models.rotation_schedule import RotationSchedule + + +@extend_schema(tags=["Rotation Schedules"]) +@region_silo_endpoint +class OrganizationRotationScheduleDetailsEndpoint(OrganizationEndpoint): + owner = ApiOwner.ENTERPRISE + publish_status = { + "DELETE": ApiPublishStatus.PUBLIC, + "GET": ApiPublishStatus.PUBLIC, + } + permission_classes = (OrganizationPermission,) + + def convert_args(self, request: Request, rotation_schedule_id, *args, **kwargs): + args, kwargs = super().convert_args(request, *args, **kwargs) + organization = kwargs["organization"] + + try: + kwargs["rotation_schedule"] = RotationSchedule.objects.get( + organization=organization, id=rotation_schedule_id + ) + except RotationSchedule.DoesNotExist: + raise ResourceDoesNotExist + + return args, kwargs + + @extend_schema( + operation_id="Get an Rotation Schedule", + parameters=[GlobalParams.ORG_ID_OR_SLUG, RotationScheduleParams.ROTATION_SCHEDULE_ID], + request=None, + responses={ + 200: RotationScheduleSerializerResponse, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=RotationScheduleExamples.GET_ROTATION_SCHEDULE, + ) + def get(self, request: Request, organization, rotation_schedule) -> Response: + """ + Return a single Rotation Schedule + """ + rotation_schedule = RotationSchedule.objects.get( + organization_id=organization.id, + ) + serializer = RotationScheduleSerializer() + + return Response(serialize(rotation_schedule, serializer)) + + @extend_schema( + operation_id="Delete an Rotation Schedule for an Organization", + parameters=[GlobalParams.ORG_ID_OR_SLUG, RotationScheduleParams.ROTATION_SCHEDULE_ID], + request=None, + responses={ + 204: None, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=None, + ) + def delete(self, request: Request, organization, rotation_schedule) -> Response: + """ + Create or update an Rotation Schedule for the given organization. + """ + rotation_schedule.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/sentry/escalation_policies/endpoints/rotation_schedule_index.py b/src/sentry/escalation_policies/endpoints/rotation_schedule_index.py new file mode 100644 index 00000000000000..deebb358109554 --- /dev/null +++ b/src/sentry/escalation_policies/endpoints/rotation_schedule_index.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.exceptions import ValidationError +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import region_silo_endpoint +from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.paginator import OffsetPaginator +from sentry.api.serializers.base import serialize +from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED +from sentry.apidocs.examples.rotation_schedule_examples import RotationScheduleExamples +from sentry.apidocs.parameters import GlobalParams +from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.escalation_policies.endpoints.serializers.rotation_schedule import ( + RotationSchedulePutSerializer, + RotationScheduleSerializer, + RotationScheduleSerializerResponse, +) +from sentry.escalation_policies.models.rotation_schedule import RotationSchedule + + +@extend_schema(tags=["Rotation Schedules"]) +@region_silo_endpoint +class OrganizationRotationScheduleIndexEndpoint(OrganizationEndpoint): + owner = ApiOwner.ENTERPRISE + publish_status = { + "GET": ApiPublishStatus.PUBLIC, + "PUT": ApiPublishStatus.PUBLIC, + } + permission_classes = (OrganizationPermission,) + + @extend_schema( + operation_id="List an Organization's Rotation Schedules", + parameters=[GlobalParams.ORG_ID_OR_SLUG], + request=None, + responses={ + 200: inline_sentry_response_serializer( + "ListRotationSchedules", list[RotationScheduleSerializerResponse] + ), + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=RotationScheduleExamples.LIST_ROTATION_SCHEDULES, + ) + def get(self, request: Request, organization) -> Response: + """ + Return a list of Rotation Schedules bound to an organization. + """ + queryset = RotationSchedule.objects.filter( + organization_id=organization.id, + ) + serializer = RotationScheduleSerializer() + + return self.paginate( + request=request, + queryset=queryset, + order_by=("id",), + paginator_cls=OffsetPaginator, + on_results=lambda x: serialize(x, request.user, serializer=serializer), + ) + + @extend_schema( + operation_id="Create or update an RotationSchedule for an Organization", + parameters=[GlobalParams.ORG_ID_OR_SLUG], + request=RotationSchedulePutSerializer, + responses={ + 200: RotationScheduleSerializerResponse, + 201: RotationScheduleSerializerResponse, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=RotationScheduleExamples.CREATE_OR_UPDATE_ROTATION_SCHEDULE, + ) + def put(self, request: Request, organization) -> Response: + """ + Create or update a rotation schedule for the given organization. + """ + serializer = RotationSchedulePutSerializer( + context={ + "organization": organization, + "access": request.access, + "user": request.user, + "ip_address": request.META.get("REMOTE_ADDR"), + }, + data=request.data, + ) + + if not serializer.is_valid(): + raise ValidationError(serializer.errors) + + # TODO: Check permissions -- if a rotation with passed in ID is found, rotation must be part of this org + + schedule = serializer.save() + if "id" in request.data: + return Response(serialize(schedule, request.user), status=status.HTTP_200_OK) + else: + return Response(serialize(schedule, request.user), status=status.HTTP_201_CREATED) diff --git a/src/sentry/escalation_policies/endpoints/serializers/escalation_policy.py b/src/sentry/escalation_policies/endpoints/serializers/escalation_policy.py new file mode 100644 index 00000000000000..a0ed0e1019fa6b --- /dev/null +++ b/src/sentry/escalation_policies/endpoints/serializers/escalation_policy.py @@ -0,0 +1,182 @@ +from typing import TypedDict + +from django.db import router, transaction +from rest_framework import serializers + +from sentry.api.serializers import Serializer, register, serialize +from sentry.api.serializers.models.team import BaseTeamSerializerResponse +from sentry.escalation_policies.endpoints.serializers.rotation_schedule import ( + RotationScheduleSerializerResponse, +) +from sentry.escalation_policies.models.escalation_policy import ( + EscalationPolicy, + EscalationPolicyStep, + EscalationPolicyStepRecipient, +) +from sentry.escalation_policies.models.rotation_schedule import RotationSchedule +from sentry.models.team import Team +from sentry.users.services.user.model import RpcUser +from sentry.users.services.user.service import user_service + + +class EscalationPolicyPutStepRecipientSerializer(serializers.Serializer): + schedule_id = serializers.IntegerField(required=False) + team_id = serializers.IntegerField(required=False) + user_id = serializers.IntegerField(required=False) + + def validate(self, data): + """ + Validate that at least one of schedule_id, team_id, or user_id is present + """ + error_message = "One of schedule_id, team_id, or user_id must be included in recipients" + if not any(data.values()): + raise serializers.ValidationError(error_message) + return data + + +class EscalationPolicyPutStepSerializer(serializers.Serializer): + escalate_after_sec = serializers.IntegerField(min_value=0) + recipients = serializers.ListField(child=EscalationPolicyPutStepRecipientSerializer()) + + +class EscalationPolicyPutSerializer(serializers.Serializer): + id = serializers.IntegerField(required=False) + name = serializers.CharField(max_length=256, required=True) + description = serializers.CharField(max_length=256, required=False) + repeat_n_times = serializers.IntegerField(min_value=1, required=True) + steps = serializers.ListField(child=EscalationPolicyPutStepSerializer()) + # Owner + team_id = serializers.IntegerField(required=False) + user_id = serializers.IntegerField(required=False) + + def create(self, validated_data): + """ + Create or replace an EscalationPolicy instance from the validated data. + """ + validated_data["organization_id"] = self.context["organization"].id + with transaction.atomic(router.db_for_write(EscalationPolicy)): + if "id" in validated_data: + EscalationPolicy.objects.filter(id=validated_data["id"]).delete() + steps = validated_data.pop("steps") + escalation_policy = EscalationPolicy.objects.create(**validated_data) + i = 0 + for step in steps: + i += 1 + orm_step = escalation_policy.steps.create( + escalate_after_sec=step["escalate_after_sec"], + step_number=i, + ) + for recipient in step["recipients"]: + orm_step.recipients.create( + schedule_id=recipient.get("schedule_id"), + team_id=recipient.get("team_id"), + user_id=recipient.get("user_id"), + ) + return escalation_policy + + +class EscalationPolicyStepRecipientResponse(TypedDict, total=False): + type: str + data: BaseTeamSerializerResponse | RpcUser | RotationScheduleSerializerResponse + + +class EscalationPolicyStepSerializerResponse(TypedDict, total=False): + escalate_after_sec: int + recipients: list[EscalationPolicyStepRecipientResponse] + + +class EscalationPolicySerializerResponse(TypedDict, total=False): + id: int + name: str + description: str | None + repeat_n_times: int + steps: list[EscalationPolicyStepSerializerResponse] + team: BaseTeamSerializerResponse + user: RpcUser + + +@register(EscalationPolicy) +class EscalationPolicySerializer(Serializer): + def __init__(self, expand=None): + self.expand = expand or [] + + def get_attrs(self, item_list, user, **kwargs): + results = super().get_attrs(item_list, user) + + steps = list( + EscalationPolicyStep.objects.filter(policy__in=item_list).order_by("step_number") + ) + recipients = EscalationPolicyStepRecipient.objects.filter( + escalation_policy_step__in=steps + ).all() + owning_user_ids = [i.user_id for i in item_list if i.user_id] + owning_team_ids = [i.team_id for i in item_list if i.team_id] + + teams = { + team.id: team + for team in Team.objects.filter( + id__in=[r.team_id for r in recipients if r.team_id] + owning_team_ids + ).all() + } + users = { + user.id: user + for user in user_service.get_many_by_id( + ids=[r.user_id for r in recipients if r.user_id] + owning_user_ids + ) + } + schedules = { + schedule.id: schedule + for schedule in RotationSchedule.objects.filter( + id__in=[r.schedule_id for r in recipients if r.schedule_id] + ).all() + } + + for policy in item_list: + steps = [ + EscalationPolicyStepSerializerResponse( + step_number=step.step_number, + escalate_after_sec=step.escalate_after_sec, + # Team recipients + User recipients + Schedule recipients + recipients=[ + EscalationPolicyStepRecipientResponse( + type="team", data=serialize(teams[r.team_id]) + ) + for r in recipients + if r.escalation_policy_step_id == step.id and r.team_id + ] + + [ + EscalationPolicyStepRecipientResponse( + type="user", data=serialize(users[r.user_id]) + ) + for r in recipients + if r.escalation_policy_step_id == step.id and r.user_id + ] + + [ + EscalationPolicyStepRecipientResponse( + type="schedule", data=serialize(schedules[r.schedule_id]) + ) + for r in recipients + if r.escalation_policy_step_id == step.id and r.schedule_id + ], + ) + for step in steps + if step.policy_id == policy.id + ] + steps.sort(key=lambda x: x["step_number"]) + results[policy] = { + "team": teams.get(policy.team_id), + "user": users.get(policy.user_id), + "steps": steps, + } + return results + + def serialize(self, obj, attrs, user, **kwargs): + return EscalationPolicySerializerResponse( + id=str(obj.id), + name=obj.name, + description=obj.description, + repeat_n_times=obj.repeat_n_times, + steps=attrs["steps"], + team=attrs["team"], + user=attrs["user"], + ) diff --git a/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py b/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py new file mode 100644 index 00000000000000..56e833ad4e9771 --- /dev/null +++ b/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py @@ -0,0 +1,167 @@ +from datetime import datetime, timezone +from typing import TypedDict + +from django.db import router, transaction +from rest_framework import serializers + +from sentry.api.serializers.base import Serializer, register +from sentry.escalation_policies.models.rotation_schedule import ( + RotationSchedule, + RotationScheduleLayer, + RotationScheduleOverride, + RotationScheduleUserOrder, +) +from sentry.models.team import Team +from sentry.users.services.user.model import RpcUser +from sentry.users.services.user.service import user_service + + +class RotationScheduleLayerPutSerializer(serializers.Serializer): + rotation_type = serializers.CharField(max_length=256, required=True) + handoff_time = serializers.CharField(max_length=32, required=True) + schedule_layer_restrictions = serializers.JSONField() + start_time = serializers.DateTimeField(required=True) + user_ids = serializers.ListField(child=serializers.IntegerField(), required=False) + + +class RotationSchedulePutSerializer(serializers.Serializer): + id = serializers.IntegerField(required=False) + name = serializers.CharField(max_length=256, required=True) + + schedule_layers = serializers.ListField( + child=RotationScheduleLayerPutSerializer(), required=True + ) + # Owner + team_id = serializers.IntegerField(required=False) + user_id = serializers.IntegerField(required=False) + + def create(self, validated_data): + """ + Create or replace an RotationSchedule instance from the validated data. + """ + validated_data["organization_id"] = self.context["organization"].id + with transaction.atomic(router.db_for_write(RotationSchedule)): + overrides = [] + if "id" in validated_data: + # We're updating, so we need to maintain overrides + overrides = RotationScheduleOverride.objects.filter( + rotation_schedule_id=validated_data["id"] + ) + RotationSchedule.objects.filter(id=validated_data["id"]).delete() + + layers = validated_data.pop("schedule_layers") + + schedule = RotationSchedule.objects.create(**validated_data) + + RotationScheduleOverride.objects.bulk_create( + [ + RotationScheduleOverride( + rotation_schedule_id=schedule.id, + user_id=override.user_id, + start_time=override.start_time, + end_time=override.end_time, + ) + for override in overrides + ] + ) + + i = 0 + for layer in layers: + i += 1 + orm_layer = schedule.layers.create( + precedence=i, + rotation_type=layer["rotation_type"], + handoff_time=layer["handoff_time"], + schedule_layer_restrictions=layer["schedule_layer_restrictions"], + start_time=layer["start_time"], + ) + for j, user_id in enumerate(layer["user_ids"]): + orm_layer.user_orders.create(user_id=user_id, order=j) + return schedule + + +class RotationScheduleLayerSerializerResponse(TypedDict, total=False): + rotation_type: str + handoff_time: str + schedule_layer_restrictions: dict + start_time: datetime + users: RpcUser + + +class RotationScheduleSerializerResponse(TypedDict, total=False): + id: int + name: str + organization_id: int + schedule_layers: list[RotationScheduleLayerSerializerResponse] + # Owner + team_id: int | None + user_id: int | None + + +@register(RotationSchedule) +class RotationScheduleSerializer(Serializer): + def __init__(self, expand=None): + self.expand = expand or [] + + def get_attrs(self, item_list, user, **kwargs): + results = super().get_attrs(item_list, user) + + layers = list( + RotationScheduleLayer.objects.filter(schedule__in=item_list).order_by("precedence") + ) + user_orders = RotationScheduleUserOrder.objects.filter( + schedule_layer__in=layers, + ).all() + overrides = RotationScheduleOverride.objects.filter( + rotation_schedule__in=item_list, + end_time__gte=datetime.now(tz=timezone.utc), + ).all() + owning_user_ids = [i.user_id for i in item_list if i.user_id] + owning_team_ids = [i.team_id for i in item_list if i.team_id] + override_users = [o.user_id for o in overrides] + + teams = {team.id: team for team in Team.objects.filter(id__in=owning_team_ids).all()} + users = { + user.id: user + for user in user_service.get_many_by_id( + ids=[uo.user_id for uo in user_orders] + owning_user_ids + override_users + ) + } + + for schedule in item_list: + schedule_layers = [layer for layer in layers if layer.schedule_id == schedule.id] + + layers_attr = [] + for layer in schedule_layers: + ordered_users = [ + (uo.user_id, uo.order) for uo in user_orders if uo.schedule_layer_id == layer.id + ] + ordered_users.sort(key=lambda tuple: tuple[1]) + ordered_users = [users[user_id] for user_id, _ in ordered_users] + + layers_attr.append( + RotationScheduleLayerSerializerResponse( + rotation_type=layer.rotation_type, + handoff_time=layer.handoff_time, + schedule_layer_restrictions=layer.schedule_layer_restrictions, + start_time=layer.start_time, + users=ordered_users, + ) + ) + + results[schedule] = { + "team": teams.get(schedule.team_id), + "user": users.get(schedule.user_id), + "layers": layers_attr, + } + return results + + def serialize(self, obj, attrs, user, **kwargs): + return RotationScheduleSerializerResponse( + id=str(obj.id), + name=obj.name, + organization_id=obj.organization.id, + layers=attrs["layers"], + team=attrs["team"], + user=attrs["user"], + ) diff --git a/src/sentry/escalation_policies/models/__init__.py b/src/sentry/escalation_policies/models/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/src/sentry/escalation_policies/models/escalation_policy.py b/src/sentry/escalation_policies/models/escalation_policy.py new file mode 100644 index 00000000000000..245197f55bbb7b --- /dev/null +++ b/src/sentry/escalation_policies/models/escalation_policy.py @@ -0,0 +1,72 @@ +from django.conf import settings +from django.db import models + +from sentry.backup.scopes import RelocationScope +from sentry.db.models import Model +from sentry.db.models.base import region_silo_model +from sentry.db.models.fields.foreignkey import FlexibleForeignKey +from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey +from sentry.escalation_policies.models.rotation_schedule import RotationSchedule +from sentry.models.team import Team + + +@region_silo_model +class EscalationPolicy(Model): + """ + Escalation policies are a scheduled ordering of steps that occur until an incident is acknowledged. + Each step can fire notifications to any combination of schedules, users and/or teams. + Policies can be repeated N times. + """ + + __relocation_scope__ = RelocationScope.Organization + + organization = FlexibleForeignKey("sentry.Organization", on_delete=models.CASCADE) + name = models.CharField(max_length=200) + description = models.TextField(null=True, blank=True) + # Owner + team = models.ForeignKey("sentry.Team", null=True, on_delete=models.SET_NULL) + user_id = HybridCloudForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete="SET_NULL") + repeat_n_times = models.PositiveIntegerField(default=1) + + class Meta: + app_label = "sentry" + db_table = "sentry_escalation_policy" + + +@region_silo_model +class EscalationPolicyStep(Model): + """ + A step in an escalation policy. + """ + + __relocation_scope__ = RelocationScope.Organization + + policy = models.ForeignKey(EscalationPolicy, on_delete=models.CASCADE, related_name="steps") + step_number = models.PositiveIntegerField() + escalate_after_sec = models.PositiveIntegerField() + + class Meta: + app_label = "sentry" + db_table = "sentry_escalation_policy_step" + unique_together = (("policy", "step_number"),) + ordering = ["step_number"] + + +@region_silo_model +class EscalationPolicyStepRecipient(Model): + """ + A recipient of an escalation policy step. + """ + + __relocation_scope__ = RelocationScope.Organization + + escalation_policy_step = models.ForeignKey( + EscalationPolicyStep, on_delete=models.CASCADE, related_name="recipients" + ) + schedule = FlexibleForeignKey(RotationSchedule, null=True, on_delete=models.CASCADE) + team = FlexibleForeignKey(Team, null=True, on_delete=models.CASCADE) + user_id = HybridCloudForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete="CASCADE") + + class Meta: + app_label = "sentry" + db_table = "sentry_escalation_policy_step_recipient" diff --git a/src/sentry/escalation_policies/models/escalation_policy_state.py b/src/sentry/escalation_policies/models/escalation_policy_state.py new file mode 100644 index 00000000000000..30c26ee5834cb3 --- /dev/null +++ b/src/sentry/escalation_policies/models/escalation_policy_state.py @@ -0,0 +1,31 @@ +from django.db import models +from django.utils.translation import gettext_lazy + +from sentry.escalation_policies.models.escalation_policy import EscalationPolicy +from sentry.models.group import Group + + +class EscalationPolicyStateType(models.TextChoices): + UNACKNOWLEDGED = "unacknowledged", gettext_lazy("Unacknowledged") + ACKNOWLEDGED = "acknowledged", gettext_lazy("Acknowledged") + RESOLVED = "resolved", gettext_lazy("Resolved") + + +class EscalationPolicyState(models.Model): + """ + An instance of EscalationPolicyState will be created whenever a new escalation policy is triggered. + + A materialized “current state” of a triggered escalation policy is useful in that it allows us to + query state for active incidents, see what is unacknowledged at a glance, and quickly determine + the next steps that will be taken. + """ + + group = models.ForeignKey(Group, on_delete=models.CASCADE) + escalation_policy = models.ForeignKey(EscalationPolicy, on_delete=models.CASCADE) + run_step_n = models.PositiveIntegerField(null=True) + run_step_at = models.DateTimeField(null=True) + state = models.CharField(max_length=32, choices=EscalationPolicyStateType.choices) + + class Meta: + app_label = "sentry" + db_table = "sentry_escalation_policy_state" diff --git a/src/sentry/escalation_policies/models/rotation_schedule.py b/src/sentry/escalation_policies/models/rotation_schedule.py new file mode 100644 index 00000000000000..ab1738df5824e1 --- /dev/null +++ b/src/sentry/escalation_policies/models/rotation_schedule.py @@ -0,0 +1,111 @@ +from datetime import datetime +from datetime import timezone as tz + +from django.conf import settings +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy + +from sentry.backup.scopes import RelocationScope +from sentry.db.models import FlexibleForeignKey, region_silo_model +from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey +from sentry.models.organization import Organization + + +@region_silo_model +class RotationSchedule(models.Model): + """Schedules consist of layers. Each layer has an ordered user rotation and has corresponding + rotation times and restrictions. The layers are then superimposed with higher layers + having higher precedence resulting in a single materialized schedule. + + For example, This structure allows you to schedule a 24 hour coverage rotation with 3 eight hour shifts from + different places. + """ + + __relocation_scope__ = RelocationScope.Organization + + organization = models.ForeignKey(Organization, on_delete=models.CASCADE) + name = models.CharField(max_length=256, unique=True) + # Owner is team or user + team = FlexibleForeignKey( + "sentry.Team", null=True, on_delete=models.SET_NULL, related_name="schedules" + ) + user_id = HybridCloudForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete="SET_NULL") + + class Meta: + app_label = "sentry" + db_table = "sentry_rotation_schedule" + + +class RotationScheduleLayerRotationType(models.TextChoices): + DAILY = "daily", gettext_lazy("Daily") + WEEKLY = "weekly", gettext_lazy("Weekly") + FORTNIGHTLY = "fortnightly", gettext_lazy("Fortnightly") + MONTHLY = "monthly", gettext_lazy("Monthly") + + +@region_silo_model +class RotationScheduleLayer(models.Model): + __relocation_scope__ = RelocationScope.Organization + + # each layer that gets created has a higher precedence overriding lower layers + schedule = models.ForeignKey(RotationSchedule, on_delete=models.CASCADE, related_name="layers") + precedence = models.PositiveIntegerField() + rotation_type = models.CharField( + max_length=20, choices=RotationScheduleLayerRotationType.choices + ) + # %% Validate that for: + # %% Daily: cron is just a time + # %% Weekly: cron is a weekday and time + # %% Fortnightly: cron is a weekday and time + # %% Monthly: cron is a day of month and a time + handoff_time = models.CharField(max_length=20) + """ + { + "Sun": [["08:00", "10:00"]], + "Mon": [["08:00", "17:00"]], + "Tues": [["08:00", "17:00"]], + ... + } + """ + schedule_layer_restrictions = models.JSONField() + start_time = models.DateTimeField(default=timezone.now) + + class Meta: + app_label = "sentry" + db_table = "sentry_rotation_schedule_layer" + unique_together = ("schedule_id", "precedence") + ordering = ("precedence",) + + +@region_silo_model +class RotationScheduleUserOrder(models.Model): + schedule_layer = models.ForeignKey( + RotationScheduleLayer, on_delete=models.CASCADE, related_name="user_orders" + ) + user_id = HybridCloudForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete="CASCADE") + order = models.PositiveIntegerField() + + class Meta: + app_label = "sentry" + db_table = "sentry_rotation_schedule_layer_user_order" + unique_together = ("schedule_layer", "user_id") + + +@region_silo_model +class RotationScheduleOverride(models.Model): + __relocation_scope__ = RelocationScope.Organization + + rotation_schedule = models.ForeignKey( + RotationSchedule, on_delete=models.CASCADE, related_name="overrides" + ) + start_time = models.DateTimeField() + end_time = models.DateTimeField() + user_id = HybridCloudForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete="CASCADE") + + class Meta: + app_label = "sentry" + db_table = "sentry_rotation_schedule_override" + + +DEFAULT_ROTATION_START_TIME = datetime(2020, 1, 1).replace(tzinfo=tz.utc) diff --git a/src/sentry/migrations/0748_escalation_policies.py b/src/sentry/migrations/0748_escalation_policies.py new file mode 100644 index 00000000000000..32e21f0838b09c --- /dev/null +++ b/src/sentry/migrations/0748_escalation_policies.py @@ -0,0 +1,227 @@ +# Generated by Django 5.0.7 on 2024-08-09 21:40 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + +import sentry.db.models.fields.bounded +import sentry.db.models.fields.foreignkey +import sentry.db.models.fields.hybrid_cloud_foreign_key +from sentry.new_migrations.migrations import CheckedMigration + + +class Migration(CheckedMigration): + # This flag is used to mark that a migration shouldn't be automatically run in production. + # This should only be used for operations where it's safe to run the migration after your + # code has deployed. So this should not be used for most operations that alter the schema + # of a table. + # Here are some things that make sense to mark as post deployment: + # - Large data migrations. Typically we want these to be run manually so that they can be + # monitored and not block the deploy for a long period of time while they run. + # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to + # run this outside deployments so that we don't block them. Note that while adding an index + # is a schema change, it's completely safe to run the operation after the code has deployed. + # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment + + is_post_deployment = False + + dependencies = [ + ("sentry", "0747_create_datasecrecywaiver_table"), + ] + + operations = [ + migrations.CreateModel( + name="EscalationPolicy", + fields=[ + ( + "id", + sentry.db.models.fields.bounded.BoundedBigAutoField( + primary_key=True, serialize=False + ), + ), + ("name", models.CharField(max_length=200)), + ("description", models.TextField(blank=True, null=True)), + ( + "user_id", + sentry.db.models.fields.hybrid_cloud_foreign_key.HybridCloudForeignKey( + "sentry.User", db_index=True, null=True, on_delete="SET_NULL" + ), + ), + ("repeat_n_times", models.PositiveIntegerField(default=1)), + ( + "team", + models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, to="sentry.team" + ), + ), + ], + options={ + "db_table": "sentry_escalation_policy", + }, + ), + migrations.CreateModel( + name="EscalationPolicyStep", + fields=[ + ( + "id", + sentry.db.models.fields.bounded.BoundedBigAutoField( + primary_key=True, serialize=False + ), + ), + ("step_number", models.PositiveIntegerField()), + ("escalate_after_sec", models.PositiveIntegerField()), + ( + "policy", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="sentry.escalationpolicy" + ), + ), + ], + options={ + "db_table": "sentry_escalation_policy_step", + "ordering": ["step_number"], + "unique_together": {("policy", "step_number")}, + }, + ), + migrations.CreateModel( + name="RotationSchedule", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ("name", models.CharField(max_length=256, unique=True)), + ( + "user_id", + sentry.db.models.fields.hybrid_cloud_foreign_key.HybridCloudForeignKey( + "sentry.User", db_index=True, null=True, on_delete="SET_NULL" + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="sentry.organization" + ), + ), + ( + "team", + sentry.db.models.fields.foreignkey.FlexibleForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="schedules", + to="sentry.team", + ), + ), + ], + options={ + "db_table": "sentry_rotation_schedule", + }, + ), + migrations.CreateModel( + name="EscalationPolicyStepRecipient", + fields=[ + ( + "id", + sentry.db.models.fields.bounded.BoundedBigAutoField( + primary_key=True, serialize=False + ), + ), + ( + "user_id", + sentry.db.models.fields.hybrid_cloud_foreign_key.HybridCloudForeignKey( + "sentry.User", db_index=True, null=True, on_delete="CASCADE" + ), + ), + ( + "escalation_policy_step", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="sentry.escalationpolicystep", + ), + ), + ( + "team", + sentry.db.models.fields.foreignkey.FlexibleForeignKey( + null=True, on_delete=django.db.models.deletion.CASCADE, to="sentry.team" + ), + ), + ( + "schedule", + sentry.db.models.fields.foreignkey.FlexibleForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="sentry.rotationschedule", + ), + ), + ], + options={ + "db_table": "sentry_escalation_policy_step_recipient", + }, + ), + migrations.CreateModel( + name="RotationScheduleLayer", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ("precedence", models.PositiveIntegerField()), + ("rotation_type", models.CharField(max_length=20)), + ("handoff_time", models.CharField(max_length=20)), + ("schedule_layer_restrictions", models.JSONField()), + ("start_time", models.DateTimeField(default=django.utils.timezone.now)), + ( + "schedule", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="sentry.rotationschedule" + ), + ), + ], + options={ + "db_table": "sentry_rotation_schedule_layer", + "ordering": ("precedence",), + "unique_together": {("schedule_id", "precedence")}, + }, + ), + migrations.CreateModel( + name="RotationScheduleOverride", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ("start_time", models.DateTimeField()), + ("end_time", models.DateTimeField()), + ( + "user_id", + sentry.db.models.fields.hybrid_cloud_foreign_key.HybridCloudForeignKey( + "sentry.User", db_index=True, null=True, on_delete="CASCADE" + ), + ), + ( + "rotation_schedule", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="sentry.rotationschedule" + ), + ), + ], + options={ + "db_table": "sentry_rotation_schedule_override", + }, + ), + migrations.CreateModel( + name="RotationScheduleUserOrder", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ( + "user_id", + sentry.db.models.fields.hybrid_cloud_foreign_key.HybridCloudForeignKey( + "sentry.User", db_index=True, null=True, on_delete="CASCADE" + ), + ), + ("order", models.PositiveIntegerField()), + ( + "schedule_layer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="sentry.rotationschedulelayer", + ), + ), + ], + options={ + "db_table": "sentry_rotation_schedule_layer_user_order", + "unique_together": {("schedule_layer", "user_id")}, + }, + ), + ] diff --git a/src/sentry/testutils/factories.py b/src/sentry/testutils/factories.py index 51d55f62e3f76c..9da5fe7fddfff4 100644 --- a/src/sentry/testutils/factories.py +++ b/src/sentry/testutils/factories.py @@ -31,6 +31,17 @@ from sentry.auth.access import RpcBackedAccess from sentry.auth.services.auth.model import RpcAuthState, RpcMemberSsoState from sentry.constants import SentryAppInstallationStatus, SentryAppStatus +from sentry.escalation_policies.models.escalation_policy import ( + EscalationPolicy, + EscalationPolicyStep, + EscalationPolicyStepRecipient, +) +from sentry.escalation_policies.models.rotation_schedule import ( + DEFAULT_ROTATION_START_TIME, + RotationScheduleLayer, + RotationScheduleLayerRotationType, + RotationScheduleUserOrder, +) from sentry.event_manager import EventManager from sentry.eventstore.models import Event from sentry.hybridcloud.models.webhookpayload import WebhookPayload @@ -1601,6 +1612,88 @@ def create_alert_rule( return alert_rule + @staticmethod + @assume_test_silo_mode(SiloMode.REGION) + def create_escalation_policy( + organization, + schedules=None, + teams=None, + users=None, + name=None, + user_id=None, + team_id=None, + description=None, + repeat_n_times=1, + ): + if not name: + name = petname.generate(2, " ", letters=10).title() + if not description: + description = petname.generate(2, " ", letters=10).title() + + policy = EscalationPolicy.objects.create( + organization=organization, + name=name, + description=description, + repeat_n_times=repeat_n_times, + user_id=user_id, + team_id=team_id, + ) + + step1 = EscalationPolicyStep.objects.create( + policy=policy, + step_number=1, + escalate_after_sec=30, + ) + step2 = EscalationPolicyStep.objects.create( + policy=policy, + step_number=2, + escalate_after_sec=30, + ) + for step in [step1, step2]: + for schedule in schedules or []: + EscalationPolicyStepRecipient.objects.create( + escalation_policy_step=step, + schedule=schedule, + ) + for team in teams or []: + EscalationPolicyStepRecipient.objects.create( + escalation_policy_step=step, + team=team, + ) + for user in users or []: + EscalationPolicyStepRecipient.objects.create( + escalation_policy_step=step, + user_id=user.id, + ) + + return policy + + @staticmethod + @assume_test_silo_mode(SiloMode.REGION) + def create_rotation_schedule_layer( + schedule, + user_ids, + precedence, + rotation_type=RotationScheduleLayerRotationType.WEEKLY, + handoff_time="0 16 * * 1", + restrictions=None, + start_time=DEFAULT_ROTATION_START_TIME, + ): + layer = RotationScheduleLayer.objects.create( + schedule=schedule, + precedence=precedence, + rotation_type=rotation_type, + handoff_time=handoff_time, + schedule_layer_restrictions=restrictions, + start_time=start_time, + ) + for i, id in enumerate(user_ids): + RotationScheduleUserOrder.objects.create( + user_id=id, + schedule_layer=layer, + order=i + 1, + ) + @staticmethod @assume_test_silo_mode(SiloMode.REGION) def create_alert_rule_activation( diff --git a/src/sentry/testutils/fixtures.py b/src/sentry/testutils/fixtures.py index fdbde0b210a185..cb3435cb341e57 100644 --- a/src/sentry/testutils/fixtures.py +++ b/src/sentry/testutils/fixtures.py @@ -8,6 +8,10 @@ from django.utils import timezone from django.utils.functional import cached_property +from sentry.escalation_policies.models.rotation_schedule import ( + DEFAULT_ROTATION_START_TIME, + RotationScheduleLayerRotationType, +) from sentry.eventstore.models import Event from sentry.incidents.models.alert_rule import AlertRuleMonitorTypeInt from sentry.incidents.models.incident import IncidentActivityType @@ -413,6 +417,43 @@ def create_alert_rule(self, organization=None, projects=None, *args, **kwargs): projects = [self.project] return Factories.create_alert_rule(organization, projects, *args, **kwargs) + def create_escalation_policy( + self, organization=None, schedules=None, teams=None, users=None, *args, **kwargs + ): + if not organization: + organization = self.organization + if teams is None: + teams = [self.team] + if users is None: + users = [self.user] + return Factories.create_escalation_policy( + organization, schedules, teams, users, *args, **kwargs + ) + + def create_rotation_schedule_layer( + self, + schedule, + user_ids, + precedence=None, + rotation_type=RotationScheduleLayerRotationType.WEEKLY, + handoff_time="0 16 * * 1", + restrictions=None, + start_time=DEFAULT_ROTATION_START_TIME, + *args, + **kwargs, + ): + return Factories.create_rotation_schedule_layer( + schedule, + user_ids, + precedence, + rotation_type, + handoff_time, + restrictions, + start_time, + *args, + **kwargs, + ) + def create_alert_rule_activation( self, alert_rule=None, diff --git a/tests/sentry/escalation_policies/endpoints/test_escalation_policy_details.py b/tests/sentry/escalation_policies/endpoints/test_escalation_policy_details.py new file mode 100644 index 00000000000000..cda996aad31263 --- /dev/null +++ b/tests/sentry/escalation_policies/endpoints/test_escalation_policy_details.py @@ -0,0 +1,53 @@ +from django.urls import reverse + +from sentry.escalation_policies.models.escalation_policy import EscalationPolicy +from sentry.testutils.cases import APITestCase + + +class EscalationPolicyCreateTest(APITestCase): + def test_get(self): + self.login_as(user=self.user) + + project = self.create_project(name="foo") + policy = self.create_escalation_policy( + organization=project.organization, + name="Escalation 1", + description="i am a happy escalation path", + repeat_n_times=2, + user_id=self.user.id, + ) + + url = reverse( + "sentry-api-0-organization-escalation-policy-details", + kwargs={ + "organization_id_or_slug": project.organization.slug, + "escalation_policy_id": policy.id, + }, + ) + response = self.client.get(url) + assert response.status_code == 200, response.content + assert response.data["id"] == str(policy.id) + + def test_delete(self): + self.login_as(user=self.user) + + project = self.create_project(name="foo") + policy = self.create_escalation_policy( + organization=project.organization, + name="Escalation 1", + description="i am a happy escalation path", + repeat_n_times=2, + user_id=self.user.id, + ) + + url = reverse( + "sentry-api-0-organization-escalation-policy-details", + kwargs={ + "organization_id_or_slug": project.organization.slug, + "escalation_policy_id": policy.id, + }, + ) + response = self.client.delete(url) + assert response.status_code == 204, response.content + + assert not EscalationPolicy.objects.filter(id=policy.id).exists() diff --git a/tests/sentry/escalation_policies/endpoints/test_escalation_policy_index.py b/tests/sentry/escalation_policies/endpoints/test_escalation_policy_index.py new file mode 100644 index 00000000000000..ce0c32d2323591 --- /dev/null +++ b/tests/sentry/escalation_policies/endpoints/test_escalation_policy_index.py @@ -0,0 +1,121 @@ +from django.urls import reverse + +from sentry.escalation_policies.models.escalation_policy import EscalationPolicy +from sentry.testutils.cases import APITestCase + + +class EscalationPolicyCreateTest(APITestCase): + def test_get(self): + self.login_as(user=self.user) + + project = self.create_project(name="foo") + policy = self.create_escalation_policy( + organization=project.organization, + name="Escalation 1", + description="i am a happy escalation path", + repeat_n_times=2, + user_id=self.user.id, + ) + + url = reverse( + "sentry-api-0-organization-escalation-policies", + kwargs={ + "organization_id_or_slug": project.organization.slug, + }, + ) + response = self.client.get(url) + assert response.status_code == 200, response.content + assert len(response.data) == 1 + assert response.data[0]["id"] == str(policy.id) + + def test_new(self): + self.login_as(user=self.user) + + project = self.create_project(name="foo") + + url = reverse( + "sentry-api-0-organization-escalation-policies", + kwargs={ + "organization_id_or_slug": project.organization.slug, + }, + ) + response = self.client.put( + url, + data={ + "name": "Escalation 1", + "description": "i am a happy escalation path", + "repeat_n_times": 2, + "user_id": self.user.id, + "steps": [ + { + "escalate_after_sec": 60, + "recipients": [ + { + "user_id": self.user.id, + }, + ], + } + ], + }, + format="json", + ) + + assert response.status_code == 201, response.content + + policy = EscalationPolicy.objects.get( + organization_id=project.organization.id, + id=response.data["id"], + ) + assert policy.organization == project.organization + + def test_update(self): + self.login_as(user=self.user) + + project = self.create_project(name="foo") + + policy = self.create_escalation_policy( + organization=project.organization, + name="Escalation 1", + description="i am a happy escalation path", + repeat_n_times=2, + user_id=self.user.id, + ) + + url = reverse( + "sentry-api-0-organization-escalation-policies", + kwargs={ + "organization_id_or_slug": project.organization.slug, + }, + ) + response = self.client.put( + url, + data={ + "id": policy.id, + "name": "Escalation 2", + "description": "i am an updated escalation path", + "repeat_n_times": 1, + "user_id": self.user.id, + "steps": [ + { + "escalate_after_sec": 60, + "recipients": [ + { + "user_id": self.user.id, + }, + ], + } + ], + }, + format="json", + ) + + assert response.status_code == 200, response.content + + policy = EscalationPolicy.objects.get( + id=policy.id, + ) + assert len(policy.steps.all()) == 1 + assert policy.name == "Escalation 2" + assert policy.description == "i am an updated escalation path" + assert policy.repeat_n_times == 1 + assert policy.steps.first().recipients.first().user_id == self.user.id diff --git a/tests/sentry/escalation_policies/endpoints/test_rotation_schedule_details.py b/tests/sentry/escalation_policies/endpoints/test_rotation_schedule_details.py new file mode 100644 index 00000000000000..cda996aad31263 --- /dev/null +++ b/tests/sentry/escalation_policies/endpoints/test_rotation_schedule_details.py @@ -0,0 +1,53 @@ +from django.urls import reverse + +from sentry.escalation_policies.models.escalation_policy import EscalationPolicy +from sentry.testutils.cases import APITestCase + + +class EscalationPolicyCreateTest(APITestCase): + def test_get(self): + self.login_as(user=self.user) + + project = self.create_project(name="foo") + policy = self.create_escalation_policy( + organization=project.organization, + name="Escalation 1", + description="i am a happy escalation path", + repeat_n_times=2, + user_id=self.user.id, + ) + + url = reverse( + "sentry-api-0-organization-escalation-policy-details", + kwargs={ + "organization_id_or_slug": project.organization.slug, + "escalation_policy_id": policy.id, + }, + ) + response = self.client.get(url) + assert response.status_code == 200, response.content + assert response.data["id"] == str(policy.id) + + def test_delete(self): + self.login_as(user=self.user) + + project = self.create_project(name="foo") + policy = self.create_escalation_policy( + organization=project.organization, + name="Escalation 1", + description="i am a happy escalation path", + repeat_n_times=2, + user_id=self.user.id, + ) + + url = reverse( + "sentry-api-0-organization-escalation-policy-details", + kwargs={ + "organization_id_or_slug": project.organization.slug, + "escalation_policy_id": policy.id, + }, + ) + response = self.client.delete(url) + assert response.status_code == 204, response.content + + assert not EscalationPolicy.objects.filter(id=policy.id).exists() diff --git a/tests/sentry/escalation_policies/endpoints/test_rotation_schedule_index.py b/tests/sentry/escalation_policies/endpoints/test_rotation_schedule_index.py new file mode 100644 index 00000000000000..2bea843a34aecb --- /dev/null +++ b/tests/sentry/escalation_policies/endpoints/test_rotation_schedule_index.py @@ -0,0 +1,126 @@ +from django.urls import reverse + +from sentry.escalation_policies.models.rotation_schedule import RotationSchedule +from sentry.testutils.cases import APITestCase + + +class RotationScheduleCreateTest(APITestCase): + def test_get(self): + self.login_as(user=self.user) + + project = self.create_project(name="foo") + schedule = RotationSchedule.objects.create( + name="schedule A", + organization=project.organization, + ) + + url = reverse( + "sentry-api-0-organization-rotation-schedules", + kwargs={ + "organization_id_or_slug": project.organization.slug, + }, + ) + response = self.client.get(url) + assert response.status_code == 200, response.content + assert len(response.data) == 1 + assert response.data[0]["id"] == str(schedule.id) + + def test_new(self): + self.login_as(user=self.user) + + project = self.create_project(name="foo") + userA = self.create_user() + userB = self.create_user() + + url = reverse( + "sentry-api-0-organization-rotation-schedules", + kwargs={ + "organization_id_or_slug": project.organization.slug, + }, + ) + response = self.client.put( + url, + data={ + "name": "Schedule A", + "user_id": self.user.id, + "schedule_layers": [ + { + "rotation_type": "weekly", + "handoff_time": "0 4 * * 1", + "start_time": "2024-01-01T00:00:00+00:00", + "schedule_layer_restrictions": { + "Sun": [], + "Mon": [["08:00", "17:00"]], + "Tue": [["08:00", "17:00"]], + "Wed": [["08:00", "17:00"]], + "Thu": [["08:00", "17:00"]], + "Fri": [["08:00", "17:00"]], + "Sat": [], + }, + "user_ids": [userA.id, userB.id], + } + ], + }, + format="json", + ) + + assert response.status_code == 201, response.content + + policy = RotationSchedule.objects.get( + organization_id=project.organization.id, + id=response.data["id"], + ) + assert policy.organization == project.organization + + def test_update(self): + self.login_as(user=self.user) + + project = self.create_project(name="foo") + userA = self.create_user() + userB = self.create_user() + + schedule = RotationSchedule.objects.create( + name="schedule A", + organization=project.organization, + ) + + url = reverse( + "sentry-api-0-organization-rotation-schedules", + kwargs={ + "organization_id_or_slug": project.organization.slug, + }, + ) + response = self.client.put( + url, + data={ + "id": schedule.id, + "name": "Schedule B", + "user_id": self.user.id, + "schedule_layers": [ + { + "rotation_type": "weekly", + "handoff_time": "0 4 * * 1", + "start_time": "2024-01-01T00:00:00+00:00", + "schedule_layer_restrictions": { + "Sun": [], + "Mon": [["08:00", "17:00"]], + "Tue": [["08:00", "17:00"]], + "Wed": [["08:00", "17:00"]], + "Thu": [["08:00", "17:00"]], + "Fri": [["08:00", "17:00"]], + "Sat": [], + }, + "user_ids": [userA.id, userB.id], + } + ], + }, + format="json", + ) + + assert response.status_code == 200, response.content + + schedule = RotationSchedule.objects.get( + id=schedule.id, + ) + assert len(schedule.layers.all()) == 1 + assert schedule.name == "Schedule B" diff --git a/tests/sentry/escalation_policies/serializers/test_escalation_policy.py b/tests/sentry/escalation_policies/serializers/test_escalation_policy.py new file mode 100644 index 00000000000000..e964ec450c210c --- /dev/null +++ b/tests/sentry/escalation_policies/serializers/test_escalation_policy.py @@ -0,0 +1,26 @@ +from sentry.api.serializers import serialize +from sentry.testutils.cases import TestCase + + +class BaseEscalationPolicySerializerTest: + def assert_escalation_policy_serialized(self, policy, result): + assert result["id"] == str(policy.id) + assert result["name"] == str(policy.name) + assert result["description"] == policy.description + assert len(result["steps"]) == 2 + assert result["team"] is None + assert result["user"] is None + + assert result["steps"][0]["escalate_after_sec"] == 30 + assert result["steps"][0]["recipients"][0]["type"] == "team" + assert result["steps"][0]["recipients"][1]["type"] == "user" + assert result["steps"][1]["escalate_after_sec"] == 30 + assert result["steps"][1]["recipients"][0]["type"] == "team" + assert result["steps"][1]["recipients"][1]["type"] == "user" + + +class EscalationPolicySerializerTest(BaseEscalationPolicySerializerTest, TestCase): + def test_simple(self): + policy = self.create_escalation_policy() + result = serialize(policy) + self.assert_escalation_policy_serialized(policy, result) diff --git a/tests/sentry/escalation_policies/serializers/test_rotation_schedule.py b/tests/sentry/escalation_policies/serializers/test_rotation_schedule.py new file mode 100644 index 00000000000000..e923d6aa71c495 --- /dev/null +++ b/tests/sentry/escalation_policies/serializers/test_rotation_schedule.py @@ -0,0 +1,63 @@ +from datetime import datetime +from datetime import timezone as tz + +from sentry.api.serializers import serialize +from sentry.escalation_policies.models.rotation_schedule import RotationSchedule +from sentry.testutils.cases import TestCase + + +class EscalationPolicySerializerTest(TestCase): + def test_simple(self): + schedule = RotationSchedule.objects.create( + name="schedule A", + organization=self.organization, + ) + userA = self.create_user() + userB = self.create_user() + userC = self.create_user() + userD = self.create_user() + self.create_rotation_schedule_layer( + schedule, + [userA.id, userB.id], + 1, + restrictions={ + "Mon": [["00:00", "12:00"]], + "Tue": [["00:00", "12:00"]], + "Wed": [["00:00", "12:00"]], + "Thu": [["00:00", "12:00"]], + "Fri": [["00:00", "12:00"]], + }, + ) + self.create_rotation_schedule_layer( + schedule, + [userC.id, userD.id], + 2, + restrictions={ + "Mon": [["12:00", "24:00"]], + "Tue": [["12:00", "24:00"]], + "Wed": [["12:00", "24:00"]], + "Thu": [["12:00", "24:00"]], + "Fri": [["12:00", "24:00"]], + }, + ) + + result = serialize(schedule) + + assert result["id"] == str(schedule.id) + assert result["name"] == str(schedule.name) + assert len(result["layers"]) == 2 + assert result["team"] is None + assert result["user"] is None + + assert result["layers"][0]["rotation_type"] == "weekly" + assert result["layers"][0]["handoff_time"] == "0 16 * * 1" + assert result["layers"][0]["start_time"] == datetime(2020, 1, 1).replace(tzinfo=tz.utc) + assert result["layers"][0]["schedule_layer_restrictions"]["Mon"] == [["00:00", "12:00"]] + assert result["layers"][0]["users"][0].id == userA.id + assert result["layers"][0]["users"][1].id == userB.id + assert result["layers"][1]["rotation_type"] == "weekly" + assert result["layers"][1]["handoff_time"] == "0 16 * * 1" + assert result["layers"][1]["start_time"] == datetime(2020, 1, 1).replace(tzinfo=tz.utc) + assert result["layers"][1]["schedule_layer_restrictions"]["Mon"] == [["12:00", "24:00"]] + assert result["layers"][1]["users"][0].id == userC.id + assert result["layers"][1]["users"][1].id == userD.id From 3aed1600190d1211db7d246d2a84103c7a929390 Mon Sep 17 00:00:00 2001 From: Mike Ihbe Date: Wed, 14 Aug 2024 15:09:49 -0700 Subject: [PATCH 02/38] CRUDL for rotation schedules and escalation policies --- .../endpoints/serializers/escalation_policy.py | 2 +- .../endpoints/serializers/rotation_schedule.py | 5 +++-- .../escalation_policies/models/escalation_policy.py | 3 +-- .../escalation_policies/models/rotation_schedule.py | 9 +++++---- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/sentry/escalation_policies/endpoints/serializers/escalation_policy.py b/src/sentry/escalation_policies/endpoints/serializers/escalation_policy.py index a0ed0e1019fa6b..aa156cc373bc9b 100644 --- a/src/sentry/escalation_policies/endpoints/serializers/escalation_policy.py +++ b/src/sentry/escalation_policies/endpoints/serializers/escalation_policy.py @@ -49,7 +49,7 @@ class EscalationPolicyPutSerializer(serializers.Serializer): team_id = serializers.IntegerField(required=False) user_id = serializers.IntegerField(required=False) - def create(self, validated_data): + def create(self, validated_data: "EscalationPolicyPutSerializer"): """ Create or replace an EscalationPolicy instance from the validated data. """ diff --git a/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py b/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py index 56e833ad4e9771..83305a3527b94f 100644 --- a/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py +++ b/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py @@ -2,6 +2,7 @@ from typing import TypedDict from django.db import router, transaction +from django.db.models.query import QuerySet from rest_framework import serializers from sentry.api.serializers.base import Serializer, register @@ -35,13 +36,13 @@ class RotationSchedulePutSerializer(serializers.Serializer): team_id = serializers.IntegerField(required=False) user_id = serializers.IntegerField(required=False) - def create(self, validated_data): + def create(self, validated_data: "RotationScheduleSerializer"): """ Create or replace an RotationSchedule instance from the validated data. """ validated_data["organization_id"] = self.context["organization"].id with transaction.atomic(router.db_for_write(RotationSchedule)): - overrides = [] + overrides: QuerySet[RotationScheduleOverride, RotationScheduleOverride] = [] if "id" in validated_data: # We're updating, so we need to maintain overrides overrides = RotationScheduleOverride.objects.filter( diff --git a/src/sentry/escalation_policies/models/escalation_policy.py b/src/sentry/escalation_policies/models/escalation_policy.py index 245197f55bbb7b..ccee25cfbc5c49 100644 --- a/src/sentry/escalation_policies/models/escalation_policy.py +++ b/src/sentry/escalation_policies/models/escalation_policy.py @@ -2,8 +2,7 @@ from django.db import models from sentry.backup.scopes import RelocationScope -from sentry.db.models import Model -from sentry.db.models.base import region_silo_model +from sentry.db.models.base import Model, region_silo_model from sentry.db.models.fields.foreignkey import FlexibleForeignKey from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey from sentry.escalation_policies.models.rotation_schedule import RotationSchedule diff --git a/src/sentry/escalation_policies/models/rotation_schedule.py b/src/sentry/escalation_policies/models/rotation_schedule.py index ab1738df5824e1..b4adaee216ca8c 100644 --- a/src/sentry/escalation_policies/models/rotation_schedule.py +++ b/src/sentry/escalation_policies/models/rotation_schedule.py @@ -3,6 +3,7 @@ from django.conf import settings from django.db import models +from django.db.models.base import Model from django.utils import timezone from django.utils.translation import gettext_lazy @@ -13,7 +14,7 @@ @region_silo_model -class RotationSchedule(models.Model): +class RotationSchedule(Model): """Schedules consist of layers. Each layer has an ordered user rotation and has corresponding rotation times and restrictions. The layers are then superimposed with higher layers having higher precedence resulting in a single materialized schedule. @@ -45,7 +46,7 @@ class RotationScheduleLayerRotationType(models.TextChoices): @region_silo_model -class RotationScheduleLayer(models.Model): +class RotationScheduleLayer(Model): __relocation_scope__ = RelocationScope.Organization # each layer that gets created has a higher precedence overriding lower layers @@ -79,7 +80,7 @@ class Meta: @region_silo_model -class RotationScheduleUserOrder(models.Model): +class RotationScheduleUserOrder(Model): schedule_layer = models.ForeignKey( RotationScheduleLayer, on_delete=models.CASCADE, related_name="user_orders" ) @@ -93,7 +94,7 @@ class Meta: @region_silo_model -class RotationScheduleOverride(models.Model): +class RotationScheduleOverride(Model): __relocation_scope__ = RelocationScope.Organization rotation_schedule = models.ForeignKey( From b1e24ba68ea1a496c66d3bfbc5b396acdd5969f2 Mon Sep 17 00:00:00 2001 From: Mike Ihbe Date: Sun, 18 Aug 2024 20:49:19 -0700 Subject: [PATCH 03/38] Add schedule layer coalescing logic and tests --- .../serializers/rotation_schedule.py | 6 +- src/sentry/escalation_policies/logic.py | 217 +++++++++++++ .../models/rotation_schedule.py | 42 ++- src/sentry/testutils/factories.py | 4 +- src/sentry/testutils/fixtures.py | 2 +- .../sentry/escalation_policies/test_logic.py | 288 ++++++++++++++++++ 6 files changed, 545 insertions(+), 14 deletions(-) create mode 100644 src/sentry/escalation_policies/logic.py create mode 100644 tests/sentry/escalation_policies/test_logic.py diff --git a/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py b/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py index 83305a3527b94f..b4e5de18c77c51 100644 --- a/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py +++ b/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py @@ -2,7 +2,6 @@ from typing import TypedDict from django.db import router, transaction -from django.db.models.query import QuerySet from rest_framework import serializers from sentry.api.serializers.base import Serializer, register @@ -27,6 +26,7 @@ class RotationScheduleLayerPutSerializer(serializers.Serializer): class RotationSchedulePutSerializer(serializers.Serializer): id = serializers.IntegerField(required=False) + organization_id = serializers.IntegerField(required=False) name = serializers.CharField(max_length=256, required=True) schedule_layers = serializers.ListField( @@ -36,13 +36,13 @@ class RotationSchedulePutSerializer(serializers.Serializer): team_id = serializers.IntegerField(required=False) user_id = serializers.IntegerField(required=False) - def create(self, validated_data: "RotationScheduleSerializer"): + def create(self, validated_data): """ Create or replace an RotationSchedule instance from the validated data. """ validated_data["organization_id"] = self.context["organization"].id with transaction.atomic(router.db_for_write(RotationSchedule)): - overrides: QuerySet[RotationScheduleOverride, RotationScheduleOverride] = [] + overrides = [] if "id" in validated_data: # We're updating, so we need to maintain overrides overrides = RotationScheduleOverride.objects.filter( diff --git a/src/sentry/escalation_policies/logic.py b/src/sentry/escalation_policies/logic.py new file mode 100644 index 00000000000000..c117d9ae4c64a3 --- /dev/null +++ b/src/sentry/escalation_policies/logic.py @@ -0,0 +1,217 @@ +from datetime import date, datetime, timedelta +from time import strptime +from typing import TypedDict + +from sentry.escalation_policies.models.rotation_schedule import ( + RotationScheduleLayer, + ScheduleLayerRestriction, + rotation_schedule_layer_rotation_type_to_days, +) + + +class RotationPeriod(TypedDict): + start_time: datetime + end_time: datetime + user_id: int | None + + +strptime_format = "%H:%M" + + +def clock_time_on_this_day(date: date, clock_time_str: str) -> datetime: + if clock_time_str == "24:00": + clock_time_str = "00:00" + date = date + timedelta(days=1) + time = strptime(clock_time_str, strptime_format) + return datetime.combine(date, datetime(*time[:6]).time()) + + +# Accepts a list like [["08:00", "10:00"]] and returns rotation periods mapping to non-overlapping periods +# e.g. [["00:00", "08:00"], ["10:00", "23:59"] +def invert_daily_layer_restrictions( + date: date, + layer_restrictions: ScheduleLayerRestriction, +) -> list[RotationPeriod]: + day = date.strftime("%a") # assumes locale="en_US" + restrictions = layer_restrictions.get(day, []) + if len(restrictions) == 0: + return [] + + restricted_times = [ + [clock_time_on_this_day(date, restriction[0]), clock_time_on_this_day(date, restriction[1])] + for restriction in restrictions + ] + current_time_on_this_day = clock_time_on_this_day(date, "00:00") + inverted_periods = [] + restricted_times_idx = 0 + while restricted_times_idx < len(restricted_times): + if current_time_on_this_day < restricted_times[restricted_times_idx][0]: + inverted_periods.append( + [current_time_on_this_day, restricted_times[restricted_times_idx][0]] + ) + current_time_on_this_day = restricted_times[restricted_times_idx][1] + else: + current_time_on_this_day = restricted_times[restricted_times_idx][1] + restricted_times_idx += 1 + + next_day = clock_time_on_this_day(date + timedelta(days=1), "00:00") + if current_time_on_this_day < next_day: + inverted_periods.append([current_time_on_this_day, next_day]) + return [ + RotationPeriod( + start_time=period[0], + end_time=period[1], + user_id=None, + ) + for period in inverted_periods + ] + + +def apply_layer_restrictions( + layer_rotation_period: RotationPeriod, layer_restrictions: ScheduleLayerRestriction +) -> list[RotationPeriod]: + date = layer_rotation_period["start_time"].date() + rotation_periods = [layer_rotation_period] + while date <= layer_rotation_period["end_time"].date(): + restricted_periods = invert_daily_layer_restrictions(date, layer_restrictions) + for restricted_period in restricted_periods: + rotation_periods = coalesce_rotation_period(rotation_periods, restricted_period) + date += timedelta(days=1) + return rotation_periods + + +# Accept a list of RotationPeriods and a new rotation period. Returns a list of RotationPeriods +# that has coalesced the new period by overwriting the existing list +# Performance for this could be improved from O(n^2) to O(n) by accepting a second list to coalesce +def coalesce_rotation_period( + periods: list[RotationPeriod], new_period: RotationPeriod +) -> list[RotationPeriod]: + if len(periods) == 0: + return [new_period] + coalesced_periods = [] + period_idx = 0 + + # Prepend case + if new_period["end_time"] < periods[0]["start_time"] and new_period["user_id"] is not None: + return [new_period] + periods + + # Append case + if new_period["start_time"] > periods[-1]["end_time"] and new_period["user_id"] is not None: + return periods + [new_period] + + # Overlap case + + # Add all non-overlapping existing periods + while period_idx < len(periods) and periods[period_idx]["end_time"] <= new_period["start_time"]: + coalesced_periods.append(periods[period_idx]) + period_idx += 1 + + # Do we have an overlap? + if period_idx < len(periods) and new_period["start_time"] < periods[period_idx]["end_time"]: + # Split the current period + current_period = periods[period_idx] + if current_period["start_time"] < new_period["start_time"]: + coalesced_periods.append( + RotationPeriod( + start_time=current_period["start_time"], + end_time=new_period["start_time"], + user_id=current_period["user_id"], + ), + ) + # Don't put in actual periods for unassigned time + if new_period["user_id"] is not None: + coalesced_periods.append( + RotationPeriod( + start_time=new_period["start_time"], + end_time=new_period["end_time"], + user_id=new_period["user_id"], + ) + ) + if new_period["end_time"] < current_period["end_time"]: + coalesced_periods.append( + RotationPeriod( + start_time=new_period["end_time"], + end_time=current_period["end_time"], + user_id=current_period["user_id"], + ) + ) + period_idx += 1 + + while period_idx < len(periods): + coalesced_periods.append(periods[period_idx]) + period_idx += 1 + + return coalesced_periods + + +# Iterator for a schedule layer that spits out rotation periods during the time period +class ScheduleLayerRotationPeriodIterator: + def __init__(self, schedule_layer): + self.schedule_layer = schedule_layer + self.user_ids = ( + schedule_layer.user_orders.order_by("order").values_list("user_id", flat=True).all() + ) + self.current_time = clock_time_on_this_day( + self.schedule_layer.start_date, + self.schedule_layer.handoff_time, + ) + schedule_layer.start_date + self.current_user_index = 0 + self.buffer = [] + + def __iter__(self): + return self + + def __next__(self): + if len(self.buffer) > 0: + return self.buffer.pop(0) + end_time = clock_time_on_this_day( + ( + self.current_time + + timedelta( + days=rotation_schedule_layer_rotation_type_to_days[ + self.schedule_layer.rotation_type + ] + ) + ).date(), + self.schedule_layer.handoff_time, + ) + self.buffer = apply_layer_restrictions( + { + "start_time": self.current_time, + "end_time": end_time, + "user_id": self.user_ids[self.current_user_index % len(self.user_ids)], + }, + self.schedule_layer.schedule_layer_restrictions, + ) + self.current_time = end_time + self.current_user_index += 1 + + return self.buffer.pop(0) + + # skip the iterator to the first period containing the passed time + def fast_forward_to(self, time) -> None: + period = next(self) + if period["end_time"] > time: + self.buffer.append(period) + return + + +def coalesce_schedule_layers( + schedule_layers: list[RotationScheduleLayer], start_date: datetime, end_date: datetime +): + """ + This function takes a valid list of schedule layers and coalesces them into a single schedule layer. + """ + schedule_layers = list(schedule_layers) + schedule_layers.sort(key=lambda layer: layer.precedence) + schedule: list[RotationPeriod] = [] + for layer in schedule_layers: + period_iterator = ScheduleLayerRotationPeriodIterator(layer) + period_iterator.fast_forward_to(start_date) + while True: + period = next(period_iterator) + schedule = coalesce_rotation_period(schedule, period) + if period["start_time"] > end_date: + break + return schedule diff --git a/src/sentry/escalation_policies/models/rotation_schedule.py b/src/sentry/escalation_policies/models/rotation_schedule.py index b4adaee216ca8c..cc67e699c01e8c 100644 --- a/src/sentry/escalation_policies/models/rotation_schedule.py +++ b/src/sentry/escalation_policies/models/rotation_schedule.py @@ -1,5 +1,5 @@ from datetime import datetime -from datetime import timezone as tz +from typing import TypedDict from django.conf import settings from django.db import models @@ -45,6 +45,14 @@ class RotationScheduleLayerRotationType(models.TextChoices): MONTHLY = "monthly", gettext_lazy("Monthly") +rotation_schedule_layer_rotation_type_to_days = { + RotationScheduleLayerRotationType.DAILY: 1, + RotationScheduleLayerRotationType.WEEKLY: 7, + RotationScheduleLayerRotationType.FORTNIGHTLY: 14, + RotationScheduleLayerRotationType.MONTHLY: 30, +} + + @region_silo_model class RotationScheduleLayer(Model): __relocation_scope__ = RelocationScope.Organization @@ -55,11 +63,7 @@ class RotationScheduleLayer(Model): rotation_type = models.CharField( max_length=20, choices=RotationScheduleLayerRotationType.choices ) - # %% Validate that for: - # %% Daily: cron is just a time - # %% Weekly: cron is a weekday and time - # %% Fortnightly: cron is a weekday and time - # %% Monthly: cron is a day of month and a time + # %% Validate that this is just a time HH:MM ("%H:%M") handoff_time = models.CharField(max_length=20) """ { @@ -70,7 +74,9 @@ class RotationScheduleLayer(Model): } """ schedule_layer_restrictions = models.JSONField() - start_time = models.DateTimeField(default=timezone.now) + + # Must be a DATE, time is handoff time + start_date = models.DateField(default=timezone.now) class Meta: app_label = "sentry" @@ -79,6 +85,26 @@ class Meta: ordering = ("precedence",) +""" + { + "Sun": [["08:00", "10:00"]], + "Mon": [["08:00", "17:00"]], + "Tues": [["08:00", "17:00"]], + ... + } +""" + + +class ScheduleLayerRestriction(TypedDict, total=False): + Sun: list[tuple[str, str]] + Mon: list[tuple[str, str]] + Tue: list[tuple[str, str]] + Wed: list[tuple[str, str]] + Thu: list[tuple[str, str]] + Fri: list[tuple[str, str]] + Sat: list[tuple[str, str]] + + @region_silo_model class RotationScheduleUserOrder(Model): schedule_layer = models.ForeignKey( @@ -109,4 +135,4 @@ class Meta: db_table = "sentry_rotation_schedule_override" -DEFAULT_ROTATION_START_TIME = datetime(2020, 1, 1).replace(tzinfo=tz.utc) +DEFAULT_ROTATION_START_TIME = datetime(2024, 1, 1) diff --git a/src/sentry/testutils/factories.py b/src/sentry/testutils/factories.py index 9da5fe7fddfff4..b6e3db13ebbacc 100644 --- a/src/sentry/testutils/factories.py +++ b/src/sentry/testutils/factories.py @@ -1675,7 +1675,7 @@ def create_rotation_schedule_layer( user_ids, precedence, rotation_type=RotationScheduleLayerRotationType.WEEKLY, - handoff_time="0 16 * * 1", + handoff_time="00:00", restrictions=None, start_time=DEFAULT_ROTATION_START_TIME, ): @@ -1685,7 +1685,7 @@ def create_rotation_schedule_layer( rotation_type=rotation_type, handoff_time=handoff_time, schedule_layer_restrictions=restrictions, - start_time=start_time, + start_date=start_time, ) for i, id in enumerate(user_ids): RotationScheduleUserOrder.objects.create( diff --git a/src/sentry/testutils/fixtures.py b/src/sentry/testutils/fixtures.py index cb3435cb341e57..0ccc40dd2a7783 100644 --- a/src/sentry/testutils/fixtures.py +++ b/src/sentry/testutils/fixtures.py @@ -436,7 +436,7 @@ def create_rotation_schedule_layer( user_ids, precedence=None, rotation_type=RotationScheduleLayerRotationType.WEEKLY, - handoff_time="0 16 * * 1", + handoff_time="00:00", restrictions=None, start_time=DEFAULT_ROTATION_START_TIME, *args, diff --git a/tests/sentry/escalation_policies/test_logic.py b/tests/sentry/escalation_policies/test_logic.py new file mode 100644 index 00000000000000..866e56bc09f202 --- /dev/null +++ b/tests/sentry/escalation_policies/test_logic.py @@ -0,0 +1,288 @@ +from datetime import date, datetime, timedelta +from typing import TypedDict + +from sentry.escalation_policies.logic import ( + RotationPeriod, + apply_layer_restrictions, + coalesce_rotation_period, + coalesce_schedule_layers, + invert_daily_layer_restrictions, +) +from sentry.escalation_policies.models.rotation_schedule import ( + DEFAULT_ROTATION_START_TIME, + RotationSchedule, + RotationScheduleLayerRotationType, + ScheduleLayerRestriction, +) +from sentry.testutils.cases import TestCase + +time_format = "%Y-%m-%d %H:%M" + + +class RotationScheduleLogicTest(TestCase): + def test_coalesce_rotation_period(self): + class TestData(TypedDict): + schedule: list[RotationPeriod] + period: RotationPeriod + expected_results: list[RotationPeriod] + + tests: list[TestData] = [ + { + "schedule": [ + { + "start_time": datetime(2024, 1, 1, 0, 0), + "end_time": datetime(2024, 1, 1, 12, 0), + "user_id": 2, + } + ], + "period": { + "start_time": datetime(2024, 1, 2, 0, 0), + "end_time": datetime(2024, 1, 2, 12, 0), + "user_id": 3, + }, + "expected_results": [ + { + "start_time": datetime(2024, 1, 1, 0, 0), + "end_time": datetime(2024, 1, 1, 12, 0), + "user_id": 2, + }, + { + "start_time": datetime(2024, 1, 2, 0, 0), + "end_time": datetime(2024, 1, 2, 12, 0), + "user_id": 3, + }, + ], + }, + ] + for test in tests: + result = coalesce_rotation_period( + test["schedule"], + test["period"], + ) + self.assertEqual(result, test["expected_results"]) + + def test_invert_daily_layer_restrictions(self): + class TestData(TypedDict): + date: date + layer_restriction: ScheduleLayerRestriction + expected_results: list[RotationPeriod] + + tests: list[TestData] = [ + # Start section + { + "date": date(2024, 1, 1), # Mon + "layer_restriction": { + "Mon": [("00:00", "12:00")], + }, + "expected_results": [ + RotationPeriod( + start_time=datetime(2024, 1, 1, 12, 0, 0), + end_time=datetime(2024, 1, 2, 0, 0, 0), + user_id=None, + ), + ], + }, + # Middle section + { + "date": date(2024, 1, 1), # Mon + "layer_restriction": { + "Mon": [("08:00", "17:00")], + }, + "expected_results": [ + RotationPeriod( + start_time=datetime(2024, 1, 1, 0, 0, 0), + end_time=datetime(2024, 1, 1, 8, 0, 0), + user_id=None, + ), + RotationPeriod( + start_time=datetime(2024, 1, 1, 17, 0, 0), + end_time=datetime(2024, 1, 2, 0, 0, 0), + user_id=None, + ), + ], + }, + # End section + { + "date": date(2024, 1, 1), # Mon + "layer_restriction": { + "Mon": [("12:00", "24:00")], + }, + "expected_results": [ + RotationPeriod( + start_time=datetime(2024, 1, 1, 0, 0, 0), + end_time=datetime(2024, 1, 1, 12, 0, 0), + user_id=None, + ), + ], + }, + ] + + for i, test in enumerate(tests): + result = invert_daily_layer_restrictions(test["date"], test["layer_restriction"]) + assert result == test["expected_results"], "Test %d failed" % i + + def test_apply_layer_restrictions(self): + class TestData(TypedDict): + period: RotationPeriod + layer_restrictions: ScheduleLayerRestriction + expected_results: list[RotationPeriod] + + tests: list[TestData] = [ + # Simple + { + "period": { + "start_time": datetime.strptime("2024-01-01 01:00", time_format), # Monday + "end_time": datetime.strptime("2024-01-02 00:00", time_format), + "user_id": 1, + }, + "layer_restrictions": { + "Mon": [("00:00", "12:00")], + }, + "expected_results": [ + RotationPeriod( + start_time=datetime.strptime("2024-01-01 01:00", time_format), + end_time=datetime.strptime("2024-01-01 12:00", time_format), + user_id=1, + ), + ], + }, + # Simple split + { + "period": { + "start_time": datetime.strptime("2024-01-01 00:00", time_format), # Monday + "end_time": datetime.strptime("2024-01-02 00:00", time_format), + "user_id": 1, + }, + "layer_restrictions": { + "Mon": [("08:00", "17:00")], + }, + "expected_results": [ + RotationPeriod( + start_time=datetime.strptime("2024-01-01 08:00", time_format), + end_time=datetime.strptime("2024-01-01 17:00", time_format), + user_id=1, + ), + ], + }, + # week-day business hours + { + "period": { + "start_time": datetime.strptime("2024-01-01 00:00", time_format), # Monday + "end_time": datetime.strptime("2024-01-08 00:00", time_format), + "user_id": 1, + }, + "layer_restrictions": { + "Mon": [("08:00", "17:00")], + "Tue": [("08:00", "17:00")], + "Wed": [("08:00", "17:00")], + "Thu": [("08:00", "17:00")], + "Fri": [("08:00", "17:00")], + }, + "expected_results": [ + RotationPeriod( + start_time=datetime.strptime("2024-01-01 08:00", time_format), + end_time=datetime.strptime("2024-01-01 17:00", time_format), + user_id=1, + ), + RotationPeriod( + start_time=datetime.strptime("2024-01-02 08:00", time_format), + end_time=datetime.strptime("2024-01-02 17:00", time_format), + user_id=1, + ), + RotationPeriod( + start_time=datetime.strptime("2024-01-03 08:00", time_format), + end_time=datetime.strptime("2024-01-03 17:00", time_format), + user_id=1, + ), + RotationPeriod( + start_time=datetime.strptime("2024-01-04 08:00", time_format), + end_time=datetime.strptime("2024-01-04 17:00", time_format), + user_id=1, + ), + RotationPeriod( + start_time=datetime.strptime("2024-01-05 08:00", time_format), + end_time=datetime.strptime("2024-01-05 17:00", time_format), + user_id=1, + ), + ], + }, + ] + + for i, test in enumerate(tests): + result = apply_layer_restrictions( + test["period"], + test["layer_restrictions"], + ) + assert result == test["expected_results"], "Test %d failed" % i + + def test_coalesce_schedule_layers_basic(self): + schedule = RotationSchedule.objects.create( + name="schedule A", + organization=self.organization, + ) + userA = self.create_user() + userB = self.create_user() + userC = self.create_user() + userD = self.create_user() + self.create_rotation_schedule_layer( + schedule, + [userA.id, userB.id], + 1, + rotation_type=RotationScheduleLayerRotationType.DAILY, + restrictions={ + "Mon": [["00:00", "12:00"]], + "Tue": [["00:00", "12:00"]], + "Wed": [["00:00", "12:00"]], + }, + ) + self.create_rotation_schedule_layer( + schedule, + [userC.id, userD.id], + 2, + rotation_type=RotationScheduleLayerRotationType.DAILY, + restrictions={ + "Mon": [["12:00", "24:00"]], + "Tue": [["12:00", "24:00"]], + }, + ) + periods = coalesce_schedule_layers( + schedule.layers.all(), + DEFAULT_ROTATION_START_TIME - timedelta(days=1), + DEFAULT_ROTATION_START_TIME + timedelta(days=3), + ) + assert periods == [ + # Mon + RotationPeriod( + start_time=datetime.strptime("2024-01-01 00:00", time_format), + end_time=datetime.strptime("2024-01-01 12:00", time_format), + user_id=userA.id, + ), + RotationPeriod( + start_time=datetime.strptime("2024-01-01 12:00", time_format), + end_time=datetime.strptime("2024-01-02 00:00", time_format), + user_id=userC.id, + ), + # Tue + RotationPeriod( + start_time=datetime.strptime("2024-01-02 00:00", time_format), + end_time=datetime.strptime("2024-01-02 12:00", time_format), + user_id=userB.id, + ), + RotationPeriod( + start_time=datetime.strptime("2024-01-02 12:00", time_format), + end_time=datetime.strptime("2024-01-03 00:00", time_format), + user_id=userD.id, + ), + # Wed - schedule 2 no restrictions + RotationPeriod( + start_time=datetime.strptime("2024-01-03 00:00", time_format), + end_time=datetime.strptime("2024-01-04 00:00", time_format), + user_id=userC.id, + ), + # Thursday - schedule 2 no restrictions + RotationPeriod( + start_time=datetime.strptime("2024-01-04 00:00", time_format), + end_time=datetime.strptime("2024-01-05 00:00", time_format), + user_id=userD.id, + ), + ] From a4f3c51cae7374fc5a4d492741393f7327906cdc Mon Sep 17 00:00:00 2001 From: Mike Ihbe Date: Sun, 18 Aug 2024 21:26:08 -0700 Subject: [PATCH 04/38] Fix up tests --- src/sentry/escalation_policies/__init__.py | 18 +++++++++++++ .../serializers/rotation_schedule.py | 2 +- src/sentry/escalation_policies/logic.py | 18 +++++++------ .../sentry/escalation_policies/test_logic.py | 27 +++++++++++++++++++ 4 files changed, 56 insertions(+), 9 deletions(-) create mode 100644 src/sentry/escalation_policies/__init__.py diff --git a/src/sentry/escalation_policies/__init__.py b/src/sentry/escalation_policies/__init__.py new file mode 100644 index 00000000000000..35a503b361ef4b --- /dev/null +++ b/src/sentry/escalation_policies/__init__.py @@ -0,0 +1,18 @@ +from datetime import UTC, datetime + +from sentry.escalation_policies.logic import coalesce_schedule_layers +from sentry.escalation_policies.models.rotation_schedule import RotationSchedule + + +# Take a rotation schedule and a time and return the user ID for the oncall user +def determine_schedule_oncall( + schedule: RotationSchedule, time: datetime | None = None +) -> int | None: + if time is None: + time = datetime.now(UTC) + rotation_periods = coalesce_schedule_layers(schedule.layers.all(), time, time) + + if len(rotation_periods) == 0: + return None + + return rotation_periods[0]["user_id"] diff --git a/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py b/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py index b4e5de18c77c51..cab451c7471544 100644 --- a/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py +++ b/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py @@ -159,7 +159,7 @@ def get_attrs(self, item_list, user, **kwargs): def serialize(self, obj, attrs, user, **kwargs): return RotationScheduleSerializerResponse( - id=str(obj.id), + id=obj.id, name=obj.name, organization_id=obj.organization.id, layers=attrs["layers"], diff --git a/src/sentry/escalation_policies/logic.py b/src/sentry/escalation_policies/logic.py index c117d9ae4c64a3..021f8ba673d4ad 100644 --- a/src/sentry/escalation_policies/logic.py +++ b/src/sentry/escalation_policies/logic.py @@ -190,16 +190,18 @@ def __next__(self): return self.buffer.pop(0) # skip the iterator to the first period containing the passed time + # This overrides current iterator state. def fast_forward_to(self, time) -> None: - period = next(self) - if period["end_time"] > time: - self.buffer.append(period) - return + while True: + period = next(self) + if period["end_time"] > time: + self.buffer.append(period) + return def coalesce_schedule_layers( - schedule_layers: list[RotationScheduleLayer], start_date: datetime, end_date: datetime -): + schedule_layers: list[RotationScheduleLayer], start_time: datetime, end_time: datetime +) -> list[RotationPeriod]: """ This function takes a valid list of schedule layers and coalesces them into a single schedule layer. """ @@ -208,10 +210,10 @@ def coalesce_schedule_layers( schedule: list[RotationPeriod] = [] for layer in schedule_layers: period_iterator = ScheduleLayerRotationPeriodIterator(layer) - period_iterator.fast_forward_to(start_date) + period_iterator.fast_forward_to(start_time) while True: period = next(period_iterator) schedule = coalesce_rotation_period(schedule, period) - if period["start_time"] > end_date: + if period["start_time"] > end_time: break return schedule diff --git a/tests/sentry/escalation_policies/test_logic.py b/tests/sentry/escalation_policies/test_logic.py index 866e56bc09f202..6842846280e9be 100644 --- a/tests/sentry/escalation_policies/test_logic.py +++ b/tests/sentry/escalation_policies/test_logic.py @@ -1,6 +1,7 @@ from datetime import date, datetime, timedelta from typing import TypedDict +from sentry.escalation_policies import determine_schedule_oncall from sentry.escalation_policies.logic import ( RotationPeriod, apply_layer_restrictions, @@ -204,6 +205,11 @@ class TestData(TypedDict): end_time=datetime.strptime("2024-01-05 17:00", time_format), user_id=1, ), + RotationPeriod( + start_time=datetime.strptime("2024-01-06 00:00", time_format), + end_time=datetime.strptime("2024-01-08 00:00", time_format), + user_id=1, + ), ], }, ] @@ -286,3 +292,24 @@ def test_coalesce_schedule_layers_basic(self): user_id=userD.id, ), ] + + assert userA.id == determine_schedule_oncall( + schedule, + datetime.strptime("2024-01-01 01:00", time_format), + ) + assert userC.id == determine_schedule_oncall( + schedule, + datetime.strptime("2024-01-01 13:00", time_format), + ) + assert userB.id == determine_schedule_oncall( + schedule, + datetime.strptime("2024-01-02 01:00", time_format), + ) + assert userD.id == determine_schedule_oncall( + schedule, + datetime.strptime("2024-01-02 13:00", time_format), + ) + assert userA.id == determine_schedule_oncall( + schedule, + datetime.strptime("2024-01-09 01:00", time_format), + ) From 569ab55c48de9bc6d2ad07152eae813dc9db613b Mon Sep 17 00:00:00 2001 From: Mike Ihbe Date: Mon, 19 Aug 2024 10:10:57 -0700 Subject: [PATCH 05/38] Fix up tests --- .../endpoints/rotation_schedule_details.py | 4 +++- .../endpoints/rotation_schedule_index.py | 8 +++++-- .../serializers/rotation_schedule.py | 21 ++++++++++++------- .../endpoints/test_rotation_schedule_index.py | 10 ++++----- .../serializers/test_rotation_schedule.py | 11 +++++----- 5 files changed, 33 insertions(+), 21 deletions(-) diff --git a/src/sentry/escalation_policies/endpoints/rotation_schedule_details.py b/src/sentry/escalation_policies/endpoints/rotation_schedule_details.py index f902efdb301853..611974128a394c 100644 --- a/src/sentry/escalation_policies/endpoints/rotation_schedule_details.py +++ b/src/sentry/escalation_policies/endpoints/rotation_schedule_details.py @@ -11,6 +11,7 @@ from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission from sentry.api.exceptions import ResourceDoesNotExist from sentry.api.serializers import serialize +from sentry.api.utils import get_date_range_from_stats_period from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED from sentry.apidocs.examples.rotation_schedule_examples import RotationScheduleExamples from sentry.apidocs.parameters import GlobalParams, RotationScheduleParams @@ -63,7 +64,8 @@ def get(self, request: Request, organization, rotation_schedule) -> Response: rotation_schedule = RotationSchedule.objects.get( organization_id=organization.id, ) - serializer = RotationScheduleSerializer() + start, end = get_date_range_from_stats_period(params=request.GET) + serializer = RotationScheduleSerializer(start_date=start, end_date=end) return Response(serialize(rotation_schedule, serializer)) diff --git a/src/sentry/escalation_policies/endpoints/rotation_schedule_index.py b/src/sentry/escalation_policies/endpoints/rotation_schedule_index.py index deebb358109554..7156ca34cbe04e 100644 --- a/src/sentry/escalation_policies/endpoints/rotation_schedule_index.py +++ b/src/sentry/escalation_policies/endpoints/rotation_schedule_index.py @@ -12,6 +12,7 @@ from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission from sentry.api.paginator import OffsetPaginator from sentry.api.serializers.base import serialize +from sentry.api.utils import get_date_range_from_stats_period from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED from sentry.apidocs.examples.rotation_schedule_examples import RotationScheduleExamples from sentry.apidocs.parameters import GlobalParams @@ -36,7 +37,7 @@ class OrganizationRotationScheduleIndexEndpoint(OrganizationEndpoint): @extend_schema( operation_id="List an Organization's Rotation Schedules", - parameters=[GlobalParams.ORG_ID_OR_SLUG], + parameters=[GlobalParams.ORG_ID_OR_SLUG, GlobalParams.START, GlobalParams.END], request=None, responses={ 200: inline_sentry_response_serializer( @@ -55,7 +56,10 @@ def get(self, request: Request, organization) -> Response: queryset = RotationSchedule.objects.filter( organization_id=organization.id, ) - serializer = RotationScheduleSerializer() + + start, end = get_date_range_from_stats_period(params=request.GET) + + serializer = RotationScheduleSerializer(start_date=start, end_date=end) return self.paginate( request=request, diff --git a/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py b/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py index cab451c7471544..797053dfb110e7 100644 --- a/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py +++ b/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime, timedelta from typing import TypedDict from django.db import router, transaction @@ -20,7 +20,7 @@ class RotationScheduleLayerPutSerializer(serializers.Serializer): rotation_type = serializers.CharField(max_length=256, required=True) handoff_time = serializers.CharField(max_length=32, required=True) schedule_layer_restrictions = serializers.JSONField() - start_time = serializers.DateTimeField(required=True) + start_date = serializers.DateTimeField(required=True) user_ids = serializers.ListField(child=serializers.IntegerField(), required=False) @@ -74,7 +74,7 @@ def create(self, validated_data): rotation_type=layer["rotation_type"], handoff_time=layer["handoff_time"], schedule_layer_restrictions=layer["schedule_layer_restrictions"], - start_time=layer["start_time"], + start_date=layer["start_date"], ) for j, user_id in enumerate(layer["user_ids"]): orm_layer.user_orders.create(user_id=user_id, order=j) @@ -101,8 +101,14 @@ class RotationScheduleSerializerResponse(TypedDict, total=False): @register(RotationSchedule) class RotationScheduleSerializer(Serializer): - def __init__(self, expand=None): - self.expand = expand or [] + def __init__(self, start_date=None, end_date=None): + self.start_date = start_date + self.end_date = end_date + if start_date is None: + self.start_date = datetime.combine( + datetime.now(tz=UTC), datetime.min.time(), tzinfo=UTC + ) + self.end_date = self.start_date + timedelta(days=7) def get_attrs(self, item_list, user, **kwargs): results = super().get_attrs(item_list, user) @@ -115,7 +121,8 @@ def get_attrs(self, item_list, user, **kwargs): ).all() overrides = RotationScheduleOverride.objects.filter( rotation_schedule__in=item_list, - end_time__gte=datetime.now(tz=timezone.utc), + start_time__lte=self.end_date, + end_time__gte=self.start_date, ).all() owning_user_ids = [i.user_id for i in item_list if i.user_id] owning_team_ids = [i.team_id for i in item_list if i.team_id] @@ -145,7 +152,7 @@ def get_attrs(self, item_list, user, **kwargs): rotation_type=layer.rotation_type, handoff_time=layer.handoff_time, schedule_layer_restrictions=layer.schedule_layer_restrictions, - start_time=layer.start_time, + start_time=layer.start_date, users=ordered_users, ) ) diff --git a/tests/sentry/escalation_policies/endpoints/test_rotation_schedule_index.py b/tests/sentry/escalation_policies/endpoints/test_rotation_schedule_index.py index 2bea843a34aecb..29ebbc88849030 100644 --- a/tests/sentry/escalation_policies/endpoints/test_rotation_schedule_index.py +++ b/tests/sentry/escalation_policies/endpoints/test_rotation_schedule_index.py @@ -23,7 +23,7 @@ def test_get(self): response = self.client.get(url) assert response.status_code == 200, response.content assert len(response.data) == 1 - assert response.data[0]["id"] == str(schedule.id) + assert response.data[0]["id"] == schedule.id def test_new(self): self.login_as(user=self.user) @@ -46,8 +46,8 @@ def test_new(self): "schedule_layers": [ { "rotation_type": "weekly", - "handoff_time": "0 4 * * 1", - "start_time": "2024-01-01T00:00:00+00:00", + "handoff_time": "04:00", + "start_date": "2024-01-01T00:00:00+00:00", "schedule_layer_restrictions": { "Sun": [], "Mon": [["08:00", "17:00"]], @@ -99,8 +99,8 @@ def test_update(self): "schedule_layers": [ { "rotation_type": "weekly", - "handoff_time": "0 4 * * 1", - "start_time": "2024-01-01T00:00:00+00:00", + "handoff_time": "00:400", + "start_date": "2024-01-01T00:00:00+00:00", "schedule_layer_restrictions": { "Sun": [], "Mon": [["08:00", "17:00"]], diff --git a/tests/sentry/escalation_policies/serializers/test_rotation_schedule.py b/tests/sentry/escalation_policies/serializers/test_rotation_schedule.py index e923d6aa71c495..785d0ba18348c1 100644 --- a/tests/sentry/escalation_policies/serializers/test_rotation_schedule.py +++ b/tests/sentry/escalation_policies/serializers/test_rotation_schedule.py @@ -1,5 +1,4 @@ from datetime import datetime -from datetime import timezone as tz from sentry.api.serializers import serialize from sentry.escalation_policies.models.rotation_schedule import RotationSchedule @@ -43,21 +42,21 @@ def test_simple(self): result = serialize(schedule) - assert result["id"] == str(schedule.id) + assert result["id"] == schedule.id assert result["name"] == str(schedule.name) assert len(result["layers"]) == 2 assert result["team"] is None assert result["user"] is None assert result["layers"][0]["rotation_type"] == "weekly" - assert result["layers"][0]["handoff_time"] == "0 16 * * 1" - assert result["layers"][0]["start_time"] == datetime(2020, 1, 1).replace(tzinfo=tz.utc) + assert result["layers"][0]["handoff_time"] == "00:00" + assert result["layers"][0]["start_time"] == datetime(2024, 1, 1).date() assert result["layers"][0]["schedule_layer_restrictions"]["Mon"] == [["00:00", "12:00"]] assert result["layers"][0]["users"][0].id == userA.id assert result["layers"][0]["users"][1].id == userB.id assert result["layers"][1]["rotation_type"] == "weekly" - assert result["layers"][1]["handoff_time"] == "0 16 * * 1" - assert result["layers"][1]["start_time"] == datetime(2020, 1, 1).replace(tzinfo=tz.utc) + assert result["layers"][1]["handoff_time"] == "00:00" + assert result["layers"][1]["start_time"] == datetime(2024, 1, 1).date() assert result["layers"][1]["schedule_layer_restrictions"]["Mon"] == [["12:00", "24:00"]] assert result["layers"][1]["users"][0].id == userC.id assert result["layers"][1]["users"][1].id == userD.id From 382d490f5610a5e66ee546c91b8873c4815d6af4 Mon Sep 17 00:00:00 2001 From: Mike Ihbe Date: Mon, 19 Aug 2024 11:50:35 -0700 Subject: [PATCH 06/38] Add coalesced schedules to schedule serializers. use UTC everywhere explicitly --- .../endpoints/rotation_schedule_details.py | 2 +- .../serializers/rotation_schedule.py | 9 +- src/sentry/escalation_policies/logic.py | 6 +- .../models/rotation_schedule.py | 4 +- .../endpoints/test_rotation_schedule_index.py | 2 +- .../serializers/test_rotation_schedule.py | 79 ++++++++++++++- .../sentry/escalation_policies/test_logic.py | 96 +++++++++---------- 7 files changed, 137 insertions(+), 61 deletions(-) diff --git a/src/sentry/escalation_policies/endpoints/rotation_schedule_details.py b/src/sentry/escalation_policies/endpoints/rotation_schedule_details.py index 611974128a394c..6d5f59a7a4fe61 100644 --- a/src/sentry/escalation_policies/endpoints/rotation_schedule_details.py +++ b/src/sentry/escalation_policies/endpoints/rotation_schedule_details.py @@ -67,7 +67,7 @@ def get(self, request: Request, organization, rotation_schedule) -> Response: start, end = get_date_range_from_stats_period(params=request.GET) serializer = RotationScheduleSerializer(start_date=start, end_date=end) - return Response(serialize(rotation_schedule, serializer)) + return Response(serialize(rotation_schedule, serializer=serializer)) @extend_schema( operation_id="Delete an Rotation Schedule for an Organization", diff --git a/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py b/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py index 797053dfb110e7..c47f4c0e7bac20 100644 --- a/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py +++ b/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py @@ -5,6 +5,7 @@ from rest_framework import serializers from sentry.api.serializers.base import Serializer, register +from sentry.escalation_policies.logic import RotationPeriod, coalesce_schedule_layers from sentry.escalation_policies.models.rotation_schedule import ( RotationSchedule, RotationScheduleLayer, @@ -97,11 +98,13 @@ class RotationScheduleSerializerResponse(TypedDict, total=False): # Owner team_id: int | None user_id: int | None + coalesced_rotation_periods: list[RotationPeriod] @register(RotationSchedule) class RotationScheduleSerializer(Serializer): def __init__(self, start_date=None, end_date=None): + super().__init__() self.start_date = start_date self.end_date = end_date if start_date is None: @@ -156,11 +159,14 @@ def get_attrs(self, item_list, user, **kwargs): users=ordered_users, ) ) - + coalesced_rotation_periods = coalesce_schedule_layers( + schedule.layers.all(), self.start_date, self.end_date + ) results[schedule] = { "team": teams.get(schedule.team_id), "user": users.get(schedule.user_id), "layers": layers_attr, + "coalesced_rotation_periods": coalesced_rotation_periods, } return results @@ -172,4 +178,5 @@ def serialize(self, obj, attrs, user, **kwargs): layers=attrs["layers"], team=attrs["team"], user=attrs["user"], + coalesced_rotation_periods=attrs["coalesced_rotation_periods"], ) diff --git a/src/sentry/escalation_policies/logic.py b/src/sentry/escalation_policies/logic.py index 021f8ba673d4ad..2d72a8b8de7748 100644 --- a/src/sentry/escalation_policies/logic.py +++ b/src/sentry/escalation_policies/logic.py @@ -1,4 +1,4 @@ -from datetime import date, datetime, timedelta +from datetime import UTC, date, datetime, timedelta from time import strptime from typing import TypedDict @@ -23,7 +23,7 @@ def clock_time_on_this_day(date: date, clock_time_str: str) -> datetime: clock_time_str = "00:00" date = date + timedelta(days=1) time = strptime(clock_time_str, strptime_format) - return datetime.combine(date, datetime(*time[:6]).time()) + return datetime.combine(date, datetime(*time[:6]).time(), tzinfo=UTC) # Accepts a list like [["08:00", "10:00"]] and returns rotation periods mapping to non-overlapping periods @@ -195,7 +195,7 @@ def fast_forward_to(self, time) -> None: while True: period = next(self) if period["end_time"] > time: - self.buffer.append(period) + self.buffer = [period] + self.buffer return diff --git a/src/sentry/escalation_policies/models/rotation_schedule.py b/src/sentry/escalation_policies/models/rotation_schedule.py index cc67e699c01e8c..d36b8113ac3ad0 100644 --- a/src/sentry/escalation_policies/models/rotation_schedule.py +++ b/src/sentry/escalation_policies/models/rotation_schedule.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import UTC, datetime from typing import TypedDict from django.conf import settings @@ -135,4 +135,4 @@ class Meta: db_table = "sentry_rotation_schedule_override" -DEFAULT_ROTATION_START_TIME = datetime(2024, 1, 1) +DEFAULT_ROTATION_START_TIME = datetime(2024, 1, 1, tzinfo=UTC) diff --git a/tests/sentry/escalation_policies/endpoints/test_rotation_schedule_index.py b/tests/sentry/escalation_policies/endpoints/test_rotation_schedule_index.py index 29ebbc88849030..dc44a199e965d0 100644 --- a/tests/sentry/escalation_policies/endpoints/test_rotation_schedule_index.py +++ b/tests/sentry/escalation_policies/endpoints/test_rotation_schedule_index.py @@ -99,7 +99,7 @@ def test_update(self): "schedule_layers": [ { "rotation_type": "weekly", - "handoff_time": "00:400", + "handoff_time": "04:00", "start_date": "2024-01-01T00:00:00+00:00", "schedule_layer_restrictions": { "Sun": [], diff --git a/tests/sentry/escalation_policies/serializers/test_rotation_schedule.py b/tests/sentry/escalation_policies/serializers/test_rotation_schedule.py index 785d0ba18348c1..a4e4501570e858 100644 --- a/tests/sentry/escalation_policies/serializers/test_rotation_schedule.py +++ b/tests/sentry/escalation_policies/serializers/test_rotation_schedule.py @@ -1,7 +1,13 @@ -from datetime import datetime +from datetime import UTC, datetime from sentry.api.serializers import serialize -from sentry.escalation_policies.models.rotation_schedule import RotationSchedule +from sentry.escalation_policies.endpoints.serializers.rotation_schedule import ( + RotationScheduleSerializer, +) +from sentry.escalation_policies.models.rotation_schedule import ( + RotationSchedule, + RotationScheduleLayerRotationType, +) from sentry.testutils.cases import TestCase @@ -19,6 +25,7 @@ def test_simple(self): schedule, [userA.id, userB.id], 1, + rotation_type=RotationScheduleLayerRotationType.DAILY, restrictions={ "Mon": [["00:00", "12:00"]], "Tue": [["00:00", "12:00"]], @@ -31,6 +38,7 @@ def test_simple(self): schedule, [userC.id, userD.id], 2, + rotation_type=RotationScheduleLayerRotationType.DAILY, restrictions={ "Mon": [["12:00", "24:00"]], "Tue": [["12:00", "24:00"]], @@ -40,7 +48,11 @@ def test_simple(self): }, ) - result = serialize(schedule) + serializer = RotationScheduleSerializer( + start_date=datetime(2024, 1, 1, tzinfo=UTC), + end_date=datetime(2024, 1, 5, tzinfo=UTC), + ) + result = serialize(schedule, serializer=serializer) assert result["id"] == schedule.id assert result["name"] == str(schedule.name) @@ -48,15 +60,72 @@ def test_simple(self): assert result["team"] is None assert result["user"] is None - assert result["layers"][0]["rotation_type"] == "weekly" + assert result["layers"][0]["rotation_type"] == "daily" assert result["layers"][0]["handoff_time"] == "00:00" assert result["layers"][0]["start_time"] == datetime(2024, 1, 1).date() assert result["layers"][0]["schedule_layer_restrictions"]["Mon"] == [["00:00", "12:00"]] assert result["layers"][0]["users"][0].id == userA.id assert result["layers"][0]["users"][1].id == userB.id - assert result["layers"][1]["rotation_type"] == "weekly" + assert result["layers"][1]["rotation_type"] == "daily" assert result["layers"][1]["handoff_time"] == "00:00" assert result["layers"][1]["start_time"] == datetime(2024, 1, 1).date() assert result["layers"][1]["schedule_layer_restrictions"]["Mon"] == [["12:00", "24:00"]] assert result["layers"][1]["users"][0].id == userC.id assert result["layers"][1]["users"][1].id == userD.id + assert result["coalesced_rotation_periods"] == [ + { + "start_time": datetime(2024, 1, 1, 0, 0, tzinfo=UTC), + "end_time": datetime(2024, 1, 1, 12, 0, tzinfo=UTC), + "user_id": userA.id, + }, + { + "end_time": datetime(2024, 1, 2, 0, 0, tzinfo=UTC), + "start_time": datetime(2024, 1, 1, 12, 0, tzinfo=UTC), + "user_id": userC.id, + }, + { + "end_time": datetime(2024, 1, 2, 12, 0, tzinfo=UTC), + "start_time": datetime(2024, 1, 2, 0, 0, tzinfo=UTC), + "user_id": userB.id, + }, + { + "end_time": datetime(2024, 1, 3, 0, 0, tzinfo=UTC), + "start_time": datetime(2024, 1, 2, 12, 0, tzinfo=UTC), + "user_id": userD.id, + }, + { + "end_time": datetime(2024, 1, 3, 12, 0, tzinfo=UTC), + "start_time": datetime(2024, 1, 3, 0, 0, tzinfo=UTC), + "user_id": userA.id, + }, + { + "end_time": datetime(2024, 1, 4, 0, 0, tzinfo=UTC), + "start_time": datetime(2024, 1, 3, 12, 0, tzinfo=UTC), + "user_id": userC.id, + }, + { + "end_time": datetime(2024, 1, 4, 12, 0, tzinfo=UTC), + "start_time": datetime(2024, 1, 4, 0, 0, tzinfo=UTC), + "user_id": userB.id, + }, + { + "end_time": datetime(2024, 1, 5, 0, 0, tzinfo=UTC), + "start_time": datetime(2024, 1, 4, 12, 0, tzinfo=UTC), + "user_id": userD.id, + }, + { + "end_time": datetime(2024, 1, 5, 12, 0, tzinfo=UTC), + "start_time": datetime(2024, 1, 5, 0, 0, tzinfo=UTC), + "user_id": userA.id, + }, + { + "end_time": datetime(2024, 1, 6, 0, 0, tzinfo=UTC), + "start_time": datetime(2024, 1, 5, 12, 0, tzinfo=UTC), + "user_id": userC.id, + }, + { + "end_time": datetime(2024, 1, 7, 0, 0, tzinfo=UTC), + "start_time": datetime(2024, 1, 6, 0, 0, tzinfo=UTC), + "user_id": userB.id, + }, + ] diff --git a/tests/sentry/escalation_policies/test_logic.py b/tests/sentry/escalation_policies/test_logic.py index 6842846280e9be..bcef9b9fc3afe1 100644 --- a/tests/sentry/escalation_policies/test_logic.py +++ b/tests/sentry/escalation_policies/test_logic.py @@ -1,4 +1,4 @@ -from datetime import date, datetime, timedelta +from datetime import UTC, date, datetime, timedelta from typing import TypedDict from sentry.escalation_policies import determine_schedule_oncall @@ -77,8 +77,8 @@ class TestData(TypedDict): }, "expected_results": [ RotationPeriod( - start_time=datetime(2024, 1, 1, 12, 0, 0), - end_time=datetime(2024, 1, 2, 0, 0, 0), + start_time=datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC), + end_time=datetime(2024, 1, 2, 0, 0, 0, tzinfo=UTC), user_id=None, ), ], @@ -91,13 +91,13 @@ class TestData(TypedDict): }, "expected_results": [ RotationPeriod( - start_time=datetime(2024, 1, 1, 0, 0, 0), - end_time=datetime(2024, 1, 1, 8, 0, 0), + start_time=datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC), + end_time=datetime(2024, 1, 1, 8, 0, 0, tzinfo=UTC), user_id=None, ), RotationPeriod( - start_time=datetime(2024, 1, 1, 17, 0, 0), - end_time=datetime(2024, 1, 2, 0, 0, 0), + start_time=datetime(2024, 1, 1, 17, 0, 0, tzinfo=UTC), + end_time=datetime(2024, 1, 2, 0, 0, 0, tzinfo=UTC), user_id=None, ), ], @@ -110,8 +110,8 @@ class TestData(TypedDict): }, "expected_results": [ RotationPeriod( - start_time=datetime(2024, 1, 1, 0, 0, 0), - end_time=datetime(2024, 1, 1, 12, 0, 0), + start_time=datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC), + end_time=datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC), user_id=None, ), ], @@ -132,8 +132,8 @@ class TestData(TypedDict): # Simple { "period": { - "start_time": datetime.strptime("2024-01-01 01:00", time_format), # Monday - "end_time": datetime.strptime("2024-01-02 00:00", time_format), + "start_time": datetime(2024, 1, 1, 1, 0, tzinfo=UTC), # Monday + "end_time": datetime(2024, 1, 2, 0, 0, tzinfo=UTC), "user_id": 1, }, "layer_restrictions": { @@ -141,8 +141,8 @@ class TestData(TypedDict): }, "expected_results": [ RotationPeriod( - start_time=datetime.strptime("2024-01-01 01:00", time_format), - end_time=datetime.strptime("2024-01-01 12:00", time_format), + start_time=datetime(2024, 1, 1, 1, 0, tzinfo=UTC), + end_time=datetime(2024, 1, 1, 12, 0, tzinfo=UTC), user_id=1, ), ], @@ -150,8 +150,8 @@ class TestData(TypedDict): # Simple split { "period": { - "start_time": datetime.strptime("2024-01-01 00:00", time_format), # Monday - "end_time": datetime.strptime("2024-01-02 00:00", time_format), + "start_time": datetime(2024, 1, 1, 1, 0, tzinfo=UTC), # Monday + "end_time": datetime(2024, 1, 2, 0, 0, tzinfo=UTC), "user_id": 1, }, "layer_restrictions": { @@ -159,8 +159,8 @@ class TestData(TypedDict): }, "expected_results": [ RotationPeriod( - start_time=datetime.strptime("2024-01-01 08:00", time_format), - end_time=datetime.strptime("2024-01-01 17:00", time_format), + start_time=datetime(2024, 1, 1, 8, 0, tzinfo=UTC), + end_time=datetime(2024, 1, 1, 17, 0, tzinfo=UTC), user_id=1, ), ], @@ -168,8 +168,8 @@ class TestData(TypedDict): # week-day business hours { "period": { - "start_time": datetime.strptime("2024-01-01 00:00", time_format), # Monday - "end_time": datetime.strptime("2024-01-08 00:00", time_format), + "start_time": datetime(2024, 1, 1, 0, 0, tzinfo=UTC), # Monday + "end_time": datetime(2024, 1, 8, 0, 0, tzinfo=UTC), # Monday "user_id": 1, }, "layer_restrictions": { @@ -181,33 +181,33 @@ class TestData(TypedDict): }, "expected_results": [ RotationPeriod( - start_time=datetime.strptime("2024-01-01 08:00", time_format), - end_time=datetime.strptime("2024-01-01 17:00", time_format), + start_time=datetime(2024, 1, 1, 8, 0, tzinfo=UTC), + end_time=datetime(2024, 1, 1, 17, 0, tzinfo=UTC), user_id=1, ), RotationPeriod( - start_time=datetime.strptime("2024-01-02 08:00", time_format), - end_time=datetime.strptime("2024-01-02 17:00", time_format), + start_time=datetime(2024, 1, 2, 8, 0, tzinfo=UTC), + end_time=datetime(2024, 1, 2, 17, 0, tzinfo=UTC), user_id=1, ), RotationPeriod( - start_time=datetime.strptime("2024-01-03 08:00", time_format), - end_time=datetime.strptime("2024-01-03 17:00", time_format), + start_time=datetime(2024, 1, 3, 8, 0, tzinfo=UTC), + end_time=datetime(2024, 1, 3, 17, 0, tzinfo=UTC), user_id=1, ), RotationPeriod( - start_time=datetime.strptime("2024-01-04 08:00", time_format), - end_time=datetime.strptime("2024-01-04 17:00", time_format), + start_time=datetime(2024, 1, 4, 8, 0, tzinfo=UTC), + end_time=datetime(2024, 1, 4, 17, 0, tzinfo=UTC), user_id=1, ), RotationPeriod( - start_time=datetime.strptime("2024-01-05 08:00", time_format), - end_time=datetime.strptime("2024-01-05 17:00", time_format), + start_time=datetime(2024, 1, 5, 8, 0, tzinfo=UTC), + end_time=datetime(2024, 1, 5, 17, 0, tzinfo=UTC), user_id=1, ), RotationPeriod( - start_time=datetime.strptime("2024-01-06 00:00", time_format), - end_time=datetime.strptime("2024-01-08 00:00", time_format), + start_time=datetime(2024, 1, 6, 0, 0, tzinfo=UTC), + end_time=datetime(2024, 1, 8, 0, 0, tzinfo=UTC), user_id=1, ), ], @@ -259,57 +259,57 @@ def test_coalesce_schedule_layers_basic(self): assert periods == [ # Mon RotationPeriod( - start_time=datetime.strptime("2024-01-01 00:00", time_format), - end_time=datetime.strptime("2024-01-01 12:00", time_format), + start_time=datetime(2024, 1, 1, 0, 0, tzinfo=UTC), + end_time=datetime(2024, 1, 1, 12, 0, tzinfo=UTC), user_id=userA.id, ), RotationPeriod( - start_time=datetime.strptime("2024-01-01 12:00", time_format), - end_time=datetime.strptime("2024-01-02 00:00", time_format), + start_time=datetime(2024, 1, 1, 12, 0, tzinfo=UTC), + end_time=datetime(2024, 1, 2, 0, 0, tzinfo=UTC), user_id=userC.id, ), # Tue RotationPeriod( - start_time=datetime.strptime("2024-01-02 00:00", time_format), - end_time=datetime.strptime("2024-01-02 12:00", time_format), + start_time=datetime(2024, 1, 2, 0, 0, tzinfo=UTC), + end_time=datetime(2024, 1, 2, 12, 0, tzinfo=UTC), user_id=userB.id, ), RotationPeriod( - start_time=datetime.strptime("2024-01-02 12:00", time_format), - end_time=datetime.strptime("2024-01-03 00:00", time_format), + start_time=datetime(2024, 1, 2, 12, 0, tzinfo=UTC), + end_time=datetime(2024, 1, 3, 0, 0, tzinfo=UTC), user_id=userD.id, ), # Wed - schedule 2 no restrictions RotationPeriod( - start_time=datetime.strptime("2024-01-03 00:00", time_format), - end_time=datetime.strptime("2024-01-04 00:00", time_format), + start_time=datetime(2024, 1, 3, 0, 0, tzinfo=UTC), + end_time=datetime(2024, 1, 4, 0, 0, tzinfo=UTC), user_id=userC.id, ), # Thursday - schedule 2 no restrictions RotationPeriod( - start_time=datetime.strptime("2024-01-04 00:00", time_format), - end_time=datetime.strptime("2024-01-05 00:00", time_format), + start_time=datetime(2024, 1, 4, 0, 0, tzinfo=UTC), + end_time=datetime(2024, 1, 5, 0, 0, tzinfo=UTC), user_id=userD.id, ), ] assert userA.id == determine_schedule_oncall( schedule, - datetime.strptime("2024-01-01 01:00", time_format), + datetime(2024, 1, 1, 1, 0, tzinfo=UTC), ) assert userC.id == determine_schedule_oncall( schedule, - datetime.strptime("2024-01-01 13:00", time_format), + datetime(2024, 1, 1, 13, 0, tzinfo=UTC), ) assert userB.id == determine_schedule_oncall( schedule, - datetime.strptime("2024-01-02 01:00", time_format), + datetime(2024, 1, 2, 1, 0, tzinfo=UTC), ) assert userD.id == determine_schedule_oncall( schedule, - datetime.strptime("2024-01-02 13:00", time_format), + datetime(2024, 1, 2, 13, 0, tzinfo=UTC), ) assert userA.id == determine_schedule_oncall( schedule, - datetime.strptime("2024-01-09 01:00", time_format), + datetime(2024, 1, 9, 1, 0, tzinfo=UTC), ) From b6cf20de8ed67837bd6c261f0488916c827ffb05 Mon Sep 17 00:00:00 2001 From: Mike Ihbe Date: Mon, 19 Aug 2024 15:56:47 -0700 Subject: [PATCH 07/38] Add endpoints and tests for querying escalation policy states --- src/sentry/api/urls.py | 16 +++ src/sentry/apidocs/parameters.py | 14 +++ src/sentry/escalation_policies/__init__.py | 25 +++++ .../endpoints/escalation_policy_details.py | 3 - .../escalation_policy_state_details.py | 100 ++++++++++++++++++ .../escalation_policy_state_index.py | 73 +++++++++++++ .../endpoints/rotation_schedule_details.py | 7 +- .../serializers/escalation_policy_state.py | 70 ++++++++++++ .../models/escalation_policy_state.py | 7 ++ .../test_escalation_policy_details.py | 2 +- .../test_escalation_policy_state_details.py | 64 +++++++++++ .../test_escalation_policy_state_index.py | 31 ++++++ .../test_escalation_policy_state.py | 24 +++++ 13 files changed, 431 insertions(+), 5 deletions(-) create mode 100644 src/sentry/escalation_policies/endpoints/escalation_policy_state_details.py create mode 100644 src/sentry/escalation_policies/endpoints/escalation_policy_state_index.py create mode 100644 src/sentry/escalation_policies/endpoints/serializers/escalation_policy_state.py create mode 100644 tests/sentry/escalation_policies/endpoints/test_escalation_policy_state_details.py create mode 100644 tests/sentry/escalation_policies/endpoints/test_escalation_policy_state_index.py create mode 100644 tests/sentry/escalation_policies/serializers/test_escalation_policy_state.py diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 4d77ec62377225..556f71c0631f64 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -83,6 +83,12 @@ from sentry.escalation_policies.endpoints.escalation_policy_index import ( OrganizationEscalationPolicyIndexEndpoint, ) +from sentry.escalation_policies.endpoints.escalation_policy_state_details import ( + OrganizationEscalationPolicyStateDetailsEndpoint, +) +from sentry.escalation_policies.endpoints.escalation_policy_state_index import ( + OrganizationEscalationPolicyStateIndexEndpoint, +) from sentry.escalation_policies.endpoints.rotation_schedule_details import ( OrganizationRotationScheduleDetailsEndpoint, ) @@ -1198,6 +1204,16 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: OrganizationEscalationPolicyDetailsEndpoint.as_view(), name="sentry-api-0-organization-escalation-policy-details", ), + re_path( + r"^(?P[^\/]+)/escalation-policy-states/$", + OrganizationEscalationPolicyStateIndexEndpoint.as_view(), + name="sentry-api-0-organization-escalation-policy-states", + ), + re_path( + r"^(?P[^\/]+)/escalation-policy-states/(?P[^\/]+)/$", + OrganizationEscalationPolicyStateDetailsEndpoint.as_view(), + name="sentry-api-0-organization-escalation-policy-state-details", + ), # Rotation Schedules re_path( r"^(?P[^\/]+)/rotation-schedules/$", diff --git a/src/sentry/apidocs/parameters.py b/src/sentry/apidocs/parameters.py index 5982d02bdf4a42..aa0c59eddea8a5 100644 --- a/src/sentry/apidocs/parameters.py +++ b/src/sentry/apidocs/parameters.py @@ -281,6 +281,20 @@ class EscalationPolicyParams: type=int, description="The ID of the escalation policy you'd like to query.", ) + ESCALATION_POLICY_STATE_ID = OpenApiParameter( + name="escalation_policy_state_id", + location="path", + required=True, + type=int, + description="The ID of the escalation policy stateyou'd like to query.", + ) + ESCALATION_STATE = OpenApiParameter( + name="state", + location="query", + required=False, + type=str, + description="The value of the escalation policy state to filter by. One of:`resolved`, `acknowledged`, `unacknowledged`.", + ) class RotationScheduleParams: diff --git a/src/sentry/escalation_policies/__init__.py b/src/sentry/escalation_policies/__init__.py index 35a503b361ef4b..c035a4bfa0b9d2 100644 --- a/src/sentry/escalation_policies/__init__.py +++ b/src/sentry/escalation_policies/__init__.py @@ -1,7 +1,13 @@ from datetime import UTC, datetime from sentry.escalation_policies.logic import coalesce_schedule_layers +from sentry.escalation_policies.models.escalation_policy import EscalationPolicy +from sentry.escalation_policies.models.escalation_policy_state import ( + EscalationPolicyState, + EscalationPolicyStateType, +) from sentry.escalation_policies.models.rotation_schedule import RotationSchedule +from sentry.models.group import Group # Take a rotation schedule and a time and return the user ID for the oncall user @@ -16,3 +22,22 @@ def determine_schedule_oncall( return None return rotation_periods[0]["user_id"] + + +def trigger_escalation_policy(policy: EscalationPolicy, group: Group) -> EscalationPolicyState: + return EscalationPolicyState.objects.create( + escalation_policy=policy, + state=EscalationPolicyStateType.UNACKNOWLEDGED, + run_step_n=0, + run_step_at=datetime.now(UTC), + group=group, + ) + + +# TODO: cleanup any outstanding scheduled jobs for this policy state +def alter_escalation_policy_state( + policy_state: EscalationPolicyState, new_state: EscalationPolicyStateType +) -> EscalationPolicyState: + policy_state.state = new_state + policy_state.save() + return policy_state diff --git a/src/sentry/escalation_policies/endpoints/escalation_policy_details.py b/src/sentry/escalation_policies/endpoints/escalation_policy_details.py index 940ccb0dd93ef5..8191fd5399a189 100644 --- a/src/sentry/escalation_policies/endpoints/escalation_policy_details.py +++ b/src/sentry/escalation_policies/endpoints/escalation_policy_details.py @@ -59,9 +59,6 @@ def get(self, request: Request, organization, escalation_policy) -> Response: """ Return a single escalation policy """ - escalation_policy = EscalationPolicy.objects.get( - organization_id=organization.id, - ) serializer = EscalationPolicySerializer() return Response(serialize(escalation_policy, serializer)) diff --git a/src/sentry/escalation_policies/endpoints/escalation_policy_state_details.py b/src/sentry/escalation_policies/endpoints/escalation_policy_state_details.py new file mode 100644 index 00000000000000..5fe2876e06a97b --- /dev/null +++ b/src/sentry/escalation_policies/endpoints/escalation_policy_state_details.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from drf_spectacular.utils import extend_schema +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import region_silo_endpoint +from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.exceptions import ResourceDoesNotExist +from sentry.api.serializers.base import serialize +from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED +from sentry.apidocs.parameters import EscalationPolicyParams, GlobalParams +from sentry.escalation_policies import alter_escalation_policy_state +from sentry.escalation_policies.endpoints.serializers.escalation_policy import ( + EscalationPolicySerializerResponse, +) +from sentry.escalation_policies.endpoints.serializers.escalation_policy_state import ( + EscalationPolicyStatePutSerializer, + EscalationPolicyStateSerializer, +) +from sentry.escalation_policies.models.escalation_policy_state import ( + EscalationPolicyState, + EscalationPolicyStateType, +) + + +@extend_schema(tags=["Escalation Policies"]) +@region_silo_endpoint +class OrganizationEscalationPolicyStateDetailsEndpoint(OrganizationEndpoint): + owner = ApiOwner.ENTERPRISE + publish_status = { + "PUT": ApiPublishStatus.PUBLIC, + "GET": ApiPublishStatus.PUBLIC, + } + permission_classes = (OrganizationPermission,) + + def convert_args(self, request: Request, escalation_policy_state_id, *args, **kwargs): + args, kwargs = super().convert_args(request, *args, **kwargs) + organization = kwargs["organization"] + + try: + kwargs["escalation_policy_state"] = EscalationPolicyState.objects.get( + escalation_policy__organization=organization, id=escalation_policy_state_id + ) + except EscalationPolicyState.DoesNotExist: + raise ResourceDoesNotExist + + return args, kwargs + + @extend_schema( + operation_id="Get an escalation policy state", + parameters=[GlobalParams.ORG_ID_OR_SLUG, EscalationPolicyParams.ESCALATION_POLICY_STATE_ID], + request=None, + responses={ + 200: EscalationPolicySerializerResponse, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=None, # TODO: + ) + def get(self, request: Request, organization, escalation_policy_state) -> Response: + """ + Return a single escalation policy state + """ + return Response(serialize(escalation_policy_state, EscalationPolicyStateSerializer())) + + @extend_schema( + operation_id="Update an Escalation Policy State", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + EscalationPolicyParams.ESCALATION_POLICY_ID, + EscalationPolicyParams.ESCALATION_STATE, + ], + request=None, + responses={ + 204: None, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=None, # TODO: + ) + def put(self, request: Request, organization, escalation_policy_state) -> Response: + """ + Update an escalation policy state + """ + serializer = EscalationPolicyStatePutSerializer(data=request.data) + + if not serializer.is_valid(): + return Response(serializer.errors, status=400) + + new_state = request.data.get(EscalationPolicyParams.ESCALATION_STATE.name, None) + if new_state is not None: + escalation_policy_state = alter_escalation_policy_state( + escalation_policy_state, EscalationPolicyStateType(new_state) + ) + return Response(serialize(escalation_policy_state, EscalationPolicyStateSerializer())) diff --git a/src/sentry/escalation_policies/endpoints/escalation_policy_state_index.py b/src/sentry/escalation_policies/endpoints/escalation_policy_state_index.py new file mode 100644 index 00000000000000..aab9d4f98878a5 --- /dev/null +++ b/src/sentry/escalation_policies/endpoints/escalation_policy_state_index.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from drf_spectacular.utils import extend_schema +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import region_silo_endpoint +from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.paginator import OffsetPaginator +from sentry.api.serializers.base import serialize +from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED +from sentry.apidocs.examples.escalation_policy_examples import EscalationPolicyExamples +from sentry.apidocs.parameters import EscalationPolicyParams, GlobalParams, ReleaseParams +from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.escalation_policies.endpoints.serializers.escalation_policy_state import ( + EscalationPolicyStateSerializer, + EscalationPolicyStateSerializerResponse, +) +from sentry.escalation_policies.models.escalation_policy_state import EscalationPolicyState + + +@extend_schema(tags=["Escalation Policies"]) +@region_silo_endpoint +class OrganizationEscalationPolicyStateIndexEndpoint(OrganizationEndpoint): + owner = ApiOwner.ENTERPRISE + publish_status = { + "GET": ApiPublishStatus.PUBLIC, + } + permission_classes = (OrganizationPermission,) + + @extend_schema( + operation_id="List an Organization's Escalation Policy states filtered by the given parameters", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + ReleaseParams.PROJECT_ID, + EscalationPolicyParams.ESCALATION_STATE, + ], + request=None, + responses={ + 200: inline_sentry_response_serializer( + "ListEscalationPolicyStates", list[EscalationPolicyStateSerializerResponse] + ), + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=EscalationPolicyExamples.LIST_ESCALATION_POLICIES, + ) + def get(self, request: Request, organization) -> Response: + """ + Return a list of escalation policy states filtered by the given parameters. + """ + queryset = EscalationPolicyState.objects.filter( + escalation_policy__organization_id=organization.id, + ) + if request.GET.get(EscalationPolicyParams.ESCALATION_STATE.name, None) is not None: + queryset = queryset.filter( + state=request.GET[EscalationPolicyParams.ESCALATION_STATE.name] + ) + if request.GET.get(ReleaseParams.PROJECT_ID.name, None) is not None: + queryset = queryset.filter(group__project_id=request.GET[ReleaseParams.PROJECT_ID.name]) + + serializer = EscalationPolicyStateSerializer() + + return self.paginate( + request=request, + queryset=queryset, + order_by=("id",), + paginator_cls=OffsetPaginator, + on_results=lambda x: serialize(x, request.user, serializer=serializer), + ) diff --git a/src/sentry/escalation_policies/endpoints/rotation_schedule_details.py b/src/sentry/escalation_policies/endpoints/rotation_schedule_details.py index 6d5f59a7a4fe61..935843bd2b47db 100644 --- a/src/sentry/escalation_policies/endpoints/rotation_schedule_details.py +++ b/src/sentry/escalation_policies/endpoints/rotation_schedule_details.py @@ -47,7 +47,12 @@ def convert_args(self, request: Request, rotation_schedule_id, *args, **kwargs): @extend_schema( operation_id="Get an Rotation Schedule", - parameters=[GlobalParams.ORG_ID_OR_SLUG, RotationScheduleParams.ROTATION_SCHEDULE_ID], + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + RotationScheduleParams.ROTATION_SCHEDULE_ID, + GlobalParams.START, + GlobalParams.END, + ], request=None, responses={ 200: RotationScheduleSerializerResponse, diff --git a/src/sentry/escalation_policies/endpoints/serializers/escalation_policy_state.py b/src/sentry/escalation_policies/endpoints/serializers/escalation_policy_state.py new file mode 100644 index 00000000000000..6b3a4a820e1e6b --- /dev/null +++ b/src/sentry/escalation_policies/endpoints/serializers/escalation_policy_state.py @@ -0,0 +1,70 @@ +from datetime import datetime +from typing import TypedDict + +from rest_framework import serializers + +from sentry.api.serializers.base import Serializer, register, serialize +from sentry.api.serializers.models.group import BaseGroupSerializerResponse +from sentry.escalation_policies.endpoints.serializers.escalation_policy import ( + EscalationPolicySerializerResponse, +) +from sentry.escalation_policies.models.escalation_policy import EscalationPolicy +from sentry.escalation_policies.models.escalation_policy_state import ( + EscalationPolicyState, + EscalationPolicyStateType, +) +from sentry.models.group import Group + + +class EscalationPolicyStatePutSerializer(serializers.Serializer): + state = serializers.ChoiceField(choices=EscalationPolicyStateType.get_choices()) + + +class EscalationPolicyStateSerializerResponse(TypedDict, total=False): + id: int + group: BaseGroupSerializerResponse + escalation_policy: EscalationPolicySerializerResponse + run_step_n: int + run_step_at: datetime + state: EscalationPolicyStateType + + +@register(EscalationPolicyState) +class EscalationPolicyStateSerializer(Serializer): + def __init__(self): + super().__init__() + + def get_attrs(self, item_list, user, **kwargs): + results = super().get_attrs(item_list, user) + + groups = { + group.id: group + for group in Group.objects.filter( + id__in=[i.group_id for i in item_list if i.group_id] + ).all() + } + escalation_policies = { + policy.id: policy + for policy in EscalationPolicy.objects.filter( + id__in=[i.escalation_policy_id for i in item_list] + ).all() + } + + for policy in item_list: + results[policy] = { + "group": serialize(groups.get(policy.group_id)), + "escalation_policy": serialize( + escalation_policies.get(policy.escalation_policy_id) + ), + } + return results + + def serialize(self, obj, attrs, user, **kwargs): + return EscalationPolicySerializerResponse( + id=str(obj.id), + run_step_n=obj.run_step_n, + run_step_at=obj.run_step_at, + state=obj.state, + escalation_policy=attrs["escalation_policy"], + group=attrs["group"], + ) diff --git a/src/sentry/escalation_policies/models/escalation_policy_state.py b/src/sentry/escalation_policies/models/escalation_policy_state.py index 30c26ee5834cb3..c37ca0874d31af 100644 --- a/src/sentry/escalation_policies/models/escalation_policy_state.py +++ b/src/sentry/escalation_policies/models/escalation_policy_state.py @@ -1,4 +1,5 @@ from django.db import models +from django.utils import timezone from django.utils.translation import gettext_lazy from sentry.escalation_policies.models.escalation_policy import EscalationPolicy @@ -10,6 +11,10 @@ class EscalationPolicyStateType(models.TextChoices): ACKNOWLEDGED = "acknowledged", gettext_lazy("Acknowledged") RESOLVED = "resolved", gettext_lazy("Resolved") + @classmethod + def get_choices(cls) -> list[tuple[str, str]]: + return [(key.value, key.name) for key in cls] + class EscalationPolicyState(models.Model): """ @@ -25,6 +30,8 @@ class EscalationPolicyState(models.Model): run_step_n = models.PositiveIntegerField(null=True) run_step_at = models.DateTimeField(null=True) state = models.CharField(max_length=32, choices=EscalationPolicyStateType.choices) + date_added = models.DateTimeField(default=timezone.now) + date_updated = models.DateTimeField(default=timezone.now) class Meta: app_label = "sentry" diff --git a/tests/sentry/escalation_policies/endpoints/test_escalation_policy_details.py b/tests/sentry/escalation_policies/endpoints/test_escalation_policy_details.py index cda996aad31263..2a11167b636bd2 100644 --- a/tests/sentry/escalation_policies/endpoints/test_escalation_policy_details.py +++ b/tests/sentry/escalation_policies/endpoints/test_escalation_policy_details.py @@ -4,7 +4,7 @@ from sentry.testutils.cases import APITestCase -class EscalationPolicyCreateTest(APITestCase): +class EscalationPolicyDetailsTest(APITestCase): def test_get(self): self.login_as(user=self.user) diff --git a/tests/sentry/escalation_policies/endpoints/test_escalation_policy_state_details.py b/tests/sentry/escalation_policies/endpoints/test_escalation_policy_state_details.py new file mode 100644 index 00000000000000..83e3456f3cde77 --- /dev/null +++ b/tests/sentry/escalation_policies/endpoints/test_escalation_policy_state_details.py @@ -0,0 +1,64 @@ +from django.urls import reverse + +from sentry.escalation_policies import trigger_escalation_policy +from sentry.escalation_policies.models.escalation_policy_state import ( + EscalationPolicyState, + EscalationPolicyStateType, +) +from sentry.testutils.cases import APITestCase + + +class EscalationPolicyStateDetailsTest(APITestCase): + def test_get(self): + self.login_as(user=self.user) + + project = self.create_project(name="foo") + policy = self.create_escalation_policy( + organization=project.organization, + name="Escalation 1", + description="i am a happy escalation path", + repeat_n_times=2, + user_id=self.user.id, + ) + group = self.create_group(project=project) + + state = trigger_escalation_policy(policy, group) + + url = reverse( + "sentry-api-0-organization-escalation-policy-state-details", + kwargs={ + "organization_id_or_slug": project.organization.slug, + "escalation_policy_state_id": state.id, + }, + ) + response = self.client.get(url) + assert response.status_code == 200, response.content + assert response.data["id"] == str(policy.id) + + def test_put(self): + self.login_as(user=self.user) + + project = self.create_project(name="foo") + policy = self.create_escalation_policy( + organization=project.organization, + name="Escalation 1", + description="i am a happy escalation path", + repeat_n_times=2, + user_id=self.user.id, + ) + group = self.create_group(project=project) + + state = trigger_escalation_policy(policy, group) + + url = reverse( + "sentry-api-0-organization-escalation-policy-state-details", + kwargs={ + "organization_id_or_slug": project.organization.slug, + "escalation_policy_state_id": state.id, + }, + ) + response = self.client.put(url, data={"state": "acknowledged"}) + assert response.status_code == 200, response.content + + state = EscalationPolicyState.objects.filter(id=state.id).get() + assert state.state == EscalationPolicyStateType.ACKNOWLEDGED diff --git a/tests/sentry/escalation_policies/endpoints/test_escalation_policy_state_index.py b/tests/sentry/escalation_policies/endpoints/test_escalation_policy_state_index.py new file mode 100644 index 00000000000000..9759e5dcf125fe --- /dev/null +++ b/tests/sentry/escalation_policies/endpoints/test_escalation_policy_state_index.py @@ -0,0 +1,31 @@ +from django.urls import reverse + +from sentry.escalation_policies import trigger_escalation_policy +from sentry.testutils.cases import APITestCase + + +class EscalationPolicyStateIndexTest(APITestCase): + def test_get(self): + self.login_as(user=self.user) + + project = self.create_project(name="foo") + policy = self.create_escalation_policy( + organization=project.organization, + name="Escalation 1", + description="i am a happy escalation path", + repeat_n_times=2, + user_id=self.user.id, + ) + group = self.create_group(project=project) + trigger_escalation_policy(policy, group) + + url = reverse( + "sentry-api-0-organization-escalation-policy-states", + kwargs={ + "organization_id_or_slug": project.organization.slug, + }, + ) + response = self.client.get(url) + assert response.status_code == 200, response.content + assert len(response.data) == 1 + assert response.data[0]["id"] == str(policy.id) diff --git a/tests/sentry/escalation_policies/serializers/test_escalation_policy_state.py b/tests/sentry/escalation_policies/serializers/test_escalation_policy_state.py new file mode 100644 index 00000000000000..5c79a4b27eed86 --- /dev/null +++ b/tests/sentry/escalation_policies/serializers/test_escalation_policy_state.py @@ -0,0 +1,24 @@ +from sentry.api.serializers import serialize +from sentry.escalation_policies import trigger_escalation_policy +from sentry.escalation_policies.models.escalation_policy_state import EscalationPolicyStateType +from sentry.testutils.cases import TestCase + + +class BaseEscalationPolicySerializerTest: + def assert_escalation_policy_state_serialized(self, state, result): + assert result["id"] == str(state.id) + assert result["run_step_n"] == 0 + assert result["run_step_at"] is not None + assert result["state"] == EscalationPolicyStateType.UNACKNOWLEDGED + assert result["escalation_policy"] is not None + assert result["group"] is not None + + +class EscalationPolicySerializerTest(BaseEscalationPolicySerializerTest, TestCase): + def test_simple(self): + project = self.create_project(name="foo") + group = self.create_group(project=project) + policy = self.create_escalation_policy() + state = trigger_escalation_policy(policy, group) + result = serialize(state) + self.assert_escalation_policy_state_serialized(state, result) From 118458e333810c78f9ba01fa152d22ce7db3c25f Mon Sep 17 00:00:00 2001 From: Mike Ihbe Date: Mon, 19 Aug 2024 16:09:01 -0700 Subject: [PATCH 08/38] update datastore --- migrations_lockfile.txt | 2 +- .../migrations/0749_escalation_policies2.py | 119 ++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 src/sentry/migrations/0749_escalation_policies2.py diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index dc2f09460e4bab..1eb4966c3ea715 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -10,6 +10,6 @@ hybridcloud: 0016_add_control_cacheversion nodestore: 0002_nodestore_no_dictfield remote_subscriptions: 0003_drop_remote_subscription replays: 0004_index_together -sentry: 0748_escalation_policies +sentry: 0749_escalation_policies2 social_auth: 0002_default_auto_field uptime: 0006_projectuptimesubscription_name_owner diff --git a/src/sentry/migrations/0749_escalation_policies2.py b/src/sentry/migrations/0749_escalation_policies2.py new file mode 100644 index 00000000000000..3521cf533c2aa7 --- /dev/null +++ b/src/sentry/migrations/0749_escalation_policies2.py @@ -0,0 +1,119 @@ +# Generated by Django 5.0.7 on 2024-08-19 23:08 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + +import sentry.db.models.fields.foreignkey +from sentry.new_migrations.migrations import CheckedMigration + + +class Migration(CheckedMigration): + # This flag is used to mark that a migration shouldn't be automatically run in production. + # This should only be used for operations where it's safe to run the migration after your + # code has deployed. So this should not be used for most operations that alter the schema + # of a table. + # Here are some things that make sense to mark as post deployment: + # - Large data migrations. Typically we want these to be run manually so that they can be + # monitored and not block the deploy for a long period of time while they run. + # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to + # run this outside deployments so that we don't block them. Note that while adding an index + # is a schema change, it's completely safe to run the operation after the code has deployed. + # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment + + is_post_deployment = False + + dependencies = [ + ("sentry", "0748_escalation_policies"), + ] + + operations = [ + migrations.RemoveField( + model_name="rotationschedulelayer", + name="start_time", + ), + migrations.AddField( + model_name="escalationpolicy", + name="organization", + field=sentry.db.models.fields.foreignkey.FlexibleForeignKey( + default=None, on_delete=django.db.models.deletion.CASCADE, to="sentry.organization" + ), + preserve_default=False, + ), + migrations.AddField( + model_name="rotationschedulelayer", + name="start_date", + field=models.DateField(default=django.utils.timezone.now), + ), + migrations.AlterField( + model_name="escalationpolicystep", + name="policy", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="steps", + to="sentry.escalationpolicy", + ), + ), + migrations.AlterField( + model_name="escalationpolicysteprecipient", + name="escalation_policy_step", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="recipients", + to="sentry.escalationpolicystep", + ), + ), + migrations.AlterField( + model_name="rotationschedulelayer", + name="schedule", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="layers", + to="sentry.rotationschedule", + ), + ), + migrations.AlterField( + model_name="rotationscheduleoverride", + name="rotation_schedule", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="overrides", + to="sentry.rotationschedule", + ), + ), + migrations.AlterField( + model_name="rotationscheduleuserorder", + name="schedule_layer", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="user_orders", + to="sentry.rotationschedulelayer", + ), + ), + migrations.CreateModel( + name="EscalationPolicyState", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ("run_step_n", models.PositiveIntegerField(null=True)), + ("run_step_at", models.DateTimeField(null=True)), + ("state", models.CharField(max_length=32)), + ("date_added", models.DateTimeField(default=django.utils.timezone.now)), + ("date_updated", models.DateTimeField(default=django.utils.timezone.now)), + ( + "escalation_policy", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="sentry.escalationpolicy" + ), + ), + ( + "group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="sentry.group" + ), + ), + ], + options={ + "db_table": "sentry_escalation_policy_state", + }, + ), + ] From a14dd5c38740147a4b26914300f79c39d41845d9 Mon Sep 17 00:00:00 2001 From: Zachary Collins Date: Mon, 19 Aug 2024 21:16:00 -0700 Subject: [PATCH 09/38] Seed WIP --- bin/escalation-policy-seed-data.py | 310 ++++++++++++++++++ .../models/escalation_policy.py | 1 + .../models/rotation_schedule.py | 3 +- 3 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 bin/escalation-policy-seed-data.py diff --git a/bin/escalation-policy-seed-data.py b/bin/escalation-policy-seed-data.py new file mode 100644 index 00000000000000..434e3e7bbc23db --- /dev/null +++ b/bin/escalation-policy-seed-data.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python +import dataclasses +import datetime +import random +from functools import cached_property +from typing import Protocol + +from sentry.runner import configure + +configure() + +import click + + +@click.command() +def seed_policy_data(): + from sentry.escalation_policies.models.escalation_policy import ( + EscalationPolicy, + EscalationPolicyStep, + EscalationPolicyStepRecipient, + ) + from sentry.escalation_policies.models.rotation_schedule import ( + RotationSchedule, + RotationScheduleLayer, + RotationScheduleLayerRotationType, + RotationScheduleOverride, + RotationScheduleUserOrder, + ScheduleLayerRestriction, + ) + from sentry.models.organization import Organization + from sentry.models.team import Team, TeamStatus + from sentry.testutils.factories import Factories # noqa: S007 + + class TeamAndUserId(Protocol): + team: Team + user_id: int + + @dataclasses.dataclass + class OrgContext: + org_id: int = 1 + team_count: int = 5 + user_count: int = 5 + schedule_count: int = 5 + + def ensure_team_and_user_pool(self): + for i in range(self.team_count - len(self.teams)): + self.teams.append( + Factories.create_team( + organization=self.organization, + name=f"Demo Team {i}", + ) + ) + for i in range(self.user_count - len(self.user_ids)): + u = Factories.create_user() + Factories.create_member(teams=self.teams, user=u, organization=self.organization) + self.user_ids.append(u.id) + for i in range(self.schedule_count - len(self.schedules)): + self.schedules.append(RotationScheduleFactory(org_context=self).build()) + + def contextualize_slug_or_user_id( + self, model: TeamAndUserId, slug_or_user_id: str | int | None + ): + if isinstance(slug_or_user_id, str): + model.team = next(t for t in self.teams if t.slug == slug_or_user_id) + elif isinstance(slug_or_user_id, int): + model.user_id = slug_or_user_id + else: + if random.random() < 0.5: + slug_or_user_id = random.choice(self.teams).slug + else: + slug_or_user_id = random.choice(self.user_ids) + self.contextualize_slug_or_user_id(model, slug_or_user_id) + + @cached_property + def organization(self) -> Organization: + return Organization.objects.get(pk=self.org_id) + + @cached_property + def user_ids(self) -> list[int]: + return list(self.organization.member_set.values_list("user_id", flat=True)) + + @cached_property + def schedules(self) -> list[RotationSchedule]: + return list(RotationSchedule.objects.filter(organization=self.organization)) + + @cached_property + def teams(self) -> list[Team]: + return list( + Team.objects.filter( + status=TeamStatus.ACTIVE, + organization=self.organization, + ) + ) + + @dataclasses.dataclass + class RotationScheduleFactory: + org_context: OrgContext + name: str = "" + owner_user_id_or_team_slug: str | int | None = None + + @cached_property + def organization(self) -> Organization: + return self.org_context.organization + + @cached_property + def rotation_schedule(self) -> RotationSchedule: + schedule = RotationSchedule( + organization=self.organization, + name=self.final_name, + ) + + self.org_context.contextualize_slug_or_user_id( + schedule, self.owner_user_id_or_team_slug + ) + + schedule.save() + return schedule + + @cached_property + def final_name(self) -> str: + c = 1 + name = self.name or "Rotation Schedule" + while True: + if not RotationSchedule.objects.filter( + organization=self.organization, name=name + ).exists(): + break + name = name.removesuffix(" " + str(c)) + c += 1 + name += " " + str(c) + return name + + @cached_property + def overrides(self) -> list[RotationScheduleOverride]: + start = datetime.datetime.utcnow() - datetime.timedelta(hours=12) + results = [] + for i in range(random.randint(0, 6)): + end = start + datetime.timedelta(hours=random.randint(3, 9)) + user_id = random.choice(self.org_context.user_ids) + override = RotationScheduleOverride( + rotation_schedule=self.rotation_schedule, + user_id=user_id, + start_time=start, + end_time=end, + ) + override.save() + start = end + datetime.timedelta(hours=random.randint(3, 9)) + results.append(override) + return results + + def make_restrictions(self) -> list[tuple[str, str]]: + start = 0 + result = [] + for i in range(random.randint(0, 4)): + start += random.randint(1, 60 * 60 * 5) + end = start + random.randint(1, 60 * 60 * 5) + if end >= 60 * 60 * 24: + break + result.append( + (f"{int(start/60):02d}:{start%60:02d}", f"{int(end/60):02d}:{end%60:02d}") + ) + return result + + @cached_property + def schedule_layers(self) -> list[RotationScheduleLayer]: + results = [] + for i in range(random.randint(1, 5)): + layer = RotationScheduleLayer( + schedule=self.rotation_schedule, + precedence=i, + rotation_type=random.choice(list(RotationScheduleLayerRotationType)), + handoff_time=f"{random.randint(0, 23):02d}:{random.randint(0, 59):02d}", + start_date=( + datetime.datetime.utcnow() - datetime.timedelta(days=random.randint(1, 30)) + ).date(), + ) + + restriction: ScheduleLayerRestriction = { + "Sun": self.make_restrictions(), + "Mon": self.make_restrictions(), + "Tue": self.make_restrictions(), + "Wed": self.make_restrictions(), + "Thu": self.make_restrictions(), + "Fri": self.make_restrictions(), + "Sat": self.make_restrictions(), + } + + layer.schedule_layer_restrictions = restriction + layer.save() + results.append(layer) + return results + + @cached_property + def user_orders(self) -> list[RotationScheduleUserOrder]: + result = [] + for layer in self.schedule_layers: + user_ids = random.sample(self.org_context.user_ids, random.randint(1, 5)) + for i, user_id in enumerate(user_ids): + uo = RotationScheduleUserOrder( + schedule_layer=layer, + user_id=user_id, + order=i, + ) + uo.save() + result.append(uo) + return result + + def build(self): + return ( + self.rotation_schedule, + self.schedule_layers, + self.user_orders, + )[0] + + @dataclasses.dataclass + class PolicyFactory: + org_context: OrgContext + owner_user_id_or_team_slug: str | int | None = None + name: str = "" + repeat_n_times: int = dataclasses.field(default_factory=lambda: random.randint(1, 5)) + + @cached_property + def organization(self) -> Organization: + return self.org_context.organization + + @cached_property + def final_name(self) -> str: + c = 1 + name = self.name or "Escalation Policy" + while True: + if not EscalationPolicy.objects.filter( + organization=self.organization, name=name + ).exists(): + break + name = name.removesuffix(" " + str(c)) + c += 1 + name += " " + str(c) + return name + + @cached_property + def policy(self) -> EscalationPolicy: + policy = EscalationPolicy( + organization=self.organization, + name=self.final_name, + description="", + repeat_n_times=self.repeat_n_times, + ) + + self.org_context.contextualize_slug_or_user_id(policy, self.owner_user_id_or_team_slug) + + policy.save() + return policy + + @cached_property + def steps(self): + result = [] + for i in range(5): + step = EscalationPolicyStep( + policy=self.policy, + step_numer=i + 1, + escalate_after_sec=random.randint(1, 5) * 15, + ) + step.save() + result.append(step) + return result + + @cached_property + def recipients(self): + result = [] + for step in self.steps: + result.append( + EscalationPolicyStepRecipient( + escalation_policy_step=step, + schedule=random.choice(self.org_context.schedules), + ) + ) + result.append( + EscalationPolicyStepRecipient( + escalation_policy_step=step, + team=random.choice(self.org_context.teams), + ) + ) + result.append( + EscalationPolicyStepRecipient( + escalation_policy_step=step, + user_id=random.choice(self.org_context.user_ids), + ) + ) + + for recipient in result: + recipient.save() + + return result + + def build(self): + return ( + self.policy, + self.steps, + self.recipients, + )[0] + + context = OrgContext() + context.ensure_team_and_user_pool() + + for i in range(5): + PolicyFactory(org_context=context).build() + + +if __name__ == "__main__": + seed_policy_data() diff --git a/src/sentry/escalation_policies/models/escalation_policy.py b/src/sentry/escalation_policies/models/escalation_policy.py index ccee25cfbc5c49..6c5d6cd128f667 100644 --- a/src/sentry/escalation_policies/models/escalation_policy.py +++ b/src/sentry/escalation_policies/models/escalation_policy.py @@ -30,6 +30,7 @@ class EscalationPolicy(Model): class Meta: app_label = "sentry" db_table = "sentry_escalation_policy" + unique_together = (("organization", "name"),) @region_silo_model diff --git a/src/sentry/escalation_policies/models/rotation_schedule.py b/src/sentry/escalation_policies/models/rotation_schedule.py index d36b8113ac3ad0..6026617242e387 100644 --- a/src/sentry/escalation_policies/models/rotation_schedule.py +++ b/src/sentry/escalation_policies/models/rotation_schedule.py @@ -26,7 +26,7 @@ class RotationSchedule(Model): __relocation_scope__ = RelocationScope.Organization organization = models.ForeignKey(Organization, on_delete=models.CASCADE) - name = models.CharField(max_length=256, unique=True) + name = models.CharField(max_length=256) # Owner is team or user team = FlexibleForeignKey( "sentry.Team", null=True, on_delete=models.SET_NULL, related_name="schedules" @@ -36,6 +36,7 @@ class RotationSchedule(Model): class Meta: app_label = "sentry" db_table = "sentry_rotation_schedule" + unique_together = (("organization", "name"),) class RotationScheduleLayerRotationType(models.TextChoices): From dae20f63b85a04b6718e98d3c7ae2dffa79a63bf Mon Sep 17 00:00:00 2001 From: Zachary Collins Date: Mon, 19 Aug 2024 21:31:23 -0700 Subject: [PATCH 10/38] Let migration be unsafe for now --- migrations_lockfile.txt | 2 +- .../migrations/0749_escalation_policies2.py | 1 + .../0750_add_schedule_name_constraints.py | 41 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 src/sentry/migrations/0750_add_schedule_name_constraints.py diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 1eb4966c3ea715..cccc313535cbab 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -10,6 +10,6 @@ hybridcloud: 0016_add_control_cacheversion nodestore: 0002_nodestore_no_dictfield remote_subscriptions: 0003_drop_remote_subscription replays: 0004_index_together -sentry: 0749_escalation_policies2 +sentry: 0750_add_schedule_name_constraints social_auth: 0002_default_auto_field uptime: 0006_projectuptimesubscription_name_owner diff --git a/src/sentry/migrations/0749_escalation_policies2.py b/src/sentry/migrations/0749_escalation_policies2.py index 3521cf533c2aa7..c23c0bc1fb221c 100644 --- a/src/sentry/migrations/0749_escalation_policies2.py +++ b/src/sentry/migrations/0749_escalation_policies2.py @@ -22,6 +22,7 @@ class Migration(CheckedMigration): # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment is_post_deployment = False + checked = False dependencies = [ ("sentry", "0748_escalation_policies"), diff --git a/src/sentry/migrations/0750_add_schedule_name_constraints.py b/src/sentry/migrations/0750_add_schedule_name_constraints.py new file mode 100644 index 00000000000000..b7b43b7154a4ed --- /dev/null +++ b/src/sentry/migrations/0750_add_schedule_name_constraints.py @@ -0,0 +1,41 @@ +# Generated by Django 5.0.7 on 2024-08-20 04:17 + +from django.db import migrations, models + +from sentry.new_migrations.migrations import CheckedMigration + + +class Migration(CheckedMigration): + # This flag is used to mark that a migration shouldn't be automatically run in production. + # This should only be used for operations where it's safe to run the migration after your + # code has deployed. So this should not be used for most operations that alter the schema + # of a table. + # Here are some things that make sense to mark as post deployment: + # - Large data migrations. Typically we want these to be run manually so that they can be + # monitored and not block the deploy for a long period of time while they run. + # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to + # run this outside deployments so that we don't block them. Note that while adding an index + # is a schema change, it's completely safe to run the operation after the code has deployed. + # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment + + is_post_deployment = False + + dependencies = [ + ("sentry", "0749_escalation_policies2"), + ] + + operations = [ + migrations.AlterField( + model_name="rotationschedule", + name="name", + field=models.CharField(max_length=256), + ), + migrations.AlterUniqueTogether( + name="escalationpolicy", + unique_together={("organization", "name")}, + ), + migrations.AlterUniqueTogether( + name="rotationschedule", + unique_together={("organization", "name")}, + ), + ] From cac957c32018e3f0acc91c7afa6a02992e735538 Mon Sep 17 00:00:00 2001 From: Zachary Collins Date: Mon, 19 Aug 2024 21:36:02 -0700 Subject: [PATCH 11/38] Typo fix --- bin/escalation-policy-seed-data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/escalation-policy-seed-data.py b/bin/escalation-policy-seed-data.py index 434e3e7bbc23db..bfbce76956b176 100644 --- a/bin/escalation-policy-seed-data.py +++ b/bin/escalation-policy-seed-data.py @@ -257,7 +257,7 @@ def steps(self): for i in range(5): step = EscalationPolicyStep( policy=self.policy, - step_numer=i + 1, + step_number=i + 1, escalate_after_sec=random.randint(1, 5) * 15, ) step.save() From 8d911e0f5d2c0d9b0e83b1c7ddaae06019ba4e5c Mon Sep 17 00:00:00 2001 From: Mike Ihbe Date: Tue, 20 Aug 2024 09:53:41 -0700 Subject: [PATCH 12/38] Add rotation periods to each layer serialization --- .../endpoints/serializers/rotation_schedule.py | 4 ++++ .../escalation_policies/serializers/test_rotation_schedule.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py b/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py index c47f4c0e7bac20..fe5c117076797d 100644 --- a/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py +++ b/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py @@ -88,6 +88,7 @@ class RotationScheduleLayerSerializerResponse(TypedDict, total=False): schedule_layer_restrictions: dict start_time: datetime users: RpcUser + rotation_periods: list[RotationPeriod] class RotationScheduleSerializerResponse(TypedDict, total=False): @@ -157,6 +158,9 @@ def get_attrs(self, item_list, user, **kwargs): schedule_layer_restrictions=layer.schedule_layer_restrictions, start_time=layer.start_date, users=ordered_users, + rotation_periods=coalesce_schedule_layers( + [layer], self.start_date, self.end_date + ), ) ) coalesced_rotation_periods = coalesce_schedule_layers( diff --git a/tests/sentry/escalation_policies/serializers/test_rotation_schedule.py b/tests/sentry/escalation_policies/serializers/test_rotation_schedule.py index a4e4501570e858..15213be0465018 100644 --- a/tests/sentry/escalation_policies/serializers/test_rotation_schedule.py +++ b/tests/sentry/escalation_policies/serializers/test_rotation_schedule.py @@ -66,12 +66,14 @@ def test_simple(self): assert result["layers"][0]["schedule_layer_restrictions"]["Mon"] == [["00:00", "12:00"]] assert result["layers"][0]["users"][0].id == userA.id assert result["layers"][0]["users"][1].id == userB.id + assert len(result["layers"][0]["rotation_periods"]) > 0 assert result["layers"][1]["rotation_type"] == "daily" assert result["layers"][1]["handoff_time"] == "00:00" assert result["layers"][1]["start_time"] == datetime(2024, 1, 1).date() assert result["layers"][1]["schedule_layer_restrictions"]["Mon"] == [["12:00", "24:00"]] assert result["layers"][1]["users"][0].id == userC.id assert result["layers"][1]["users"][1].id == userD.id + assert len(result["layers"][1]["rotation_periods"]) > 0 assert result["coalesced_rotation_periods"] == [ { "start_time": datetime(2024, 1, 1, 0, 0, tzinfo=UTC), From 69ecc4c604e015b80a7c8e14b245db2a817f1dc1 Mon Sep 17 00:00:00 2001 From: Michael Sun <55160142+MichaelSun48@users.noreply.github.com> Date: Tue, 20 Aug 2024 09:56:16 -0700 Subject: [PATCH 13/38] feat(escalation_policies): Add FE hooks for Rotation Schedule and Escalation policy endpoints (#76393) Adds FE hooks for the following endpoints: * GET `organization//escalation-policies/` * PUT `organization//escalation-policies/` * GET `organization//escalation-policies/` * DELETE `organization//escalation-policies/` * GET `organization//rotation-schedules/` * PUT `organization//rotation-schedules/` * GET `organization//rotation-schedules/` * DELETE `organization//rotation-schedules/` --- .../mutations/useDeleteEscalationPolicy.ts | 86 +++++++++++++++++++ .../mutations/useDeleteRotationSchedule.ts | 79 +++++++++++++++++ .../mutations/useUpdateEscalationPolicies.ts | 52 +++++++++++ .../mutations/useUpdateRotationSchedule.ts | 51 +++++++++++ .../queries/useFetchEscalationPolicies.ts | 45 ++++++++++ .../useFetchEscalationPolicyDetails.ts | 38 ++++++++ .../useFetchRotationScheduleDetails.ts | 38 ++++++++ .../queries/useFetchRotationSchedules.ts | 42 +++++++++ 8 files changed, 431 insertions(+) create mode 100644 static/app/views/escalationPolicies/mutations/useDeleteEscalationPolicy.ts create mode 100644 static/app/views/escalationPolicies/mutations/useDeleteRotationSchedule.ts create mode 100644 static/app/views/escalationPolicies/mutations/useUpdateEscalationPolicies.ts create mode 100644 static/app/views/escalationPolicies/mutations/useUpdateRotationSchedule.ts create mode 100644 static/app/views/escalationPolicies/queries/useFetchEscalationPolicies.ts create mode 100644 static/app/views/escalationPolicies/queries/useFetchEscalationPolicyDetails.ts create mode 100644 static/app/views/escalationPolicies/queries/useFetchRotationScheduleDetails.ts create mode 100644 static/app/views/escalationPolicies/queries/useFetchRotationSchedules.ts diff --git a/static/app/views/escalationPolicies/mutations/useDeleteEscalationPolicy.ts b/static/app/views/escalationPolicies/mutations/useDeleteEscalationPolicy.ts new file mode 100644 index 00000000000000..a94fa9c51b8c46 --- /dev/null +++ b/static/app/views/escalationPolicies/mutations/useDeleteEscalationPolicy.ts @@ -0,0 +1,86 @@ +import { + getApiQueryData, + setApiQueryData, + useMutation, + type UseMutationOptions, + useQueryClient, +} from 'sentry/utils/queryClient'; +import type RequestError from 'sentry/utils/requestError/requestError'; +import useApi from 'sentry/utils/useApi'; +import { + type EscalationPolicy, + makeFetchEscalationPoliciesKey, +} from 'sentry/views/escalationPolicies/queries/useFetchEscalationPolicies'; + +type DeleteEscalationPolicyParams = { + escalationPolicyId: string; + orgSlug: string; +}; + +type DeleteEscalationPolicyResponse = unknown; + +type DeleteEscalationPolicyContext = { + previousEscalationPolicies?: EscalationPolicy[]; +}; + +export const useDeleteEscalationPolicy = ( + options: Omit< + UseMutationOptions< + DeleteEscalationPolicyResponse, + RequestError, + DeleteEscalationPolicyParams, + DeleteEscalationPolicyContext + >, + 'mutationFn' + > = {} +) => { + const api = useApi(); + const queryClient = useQueryClient(); + + return useMutation< + DeleteEscalationPolicyResponse, + RequestError, + DeleteEscalationPolicyParams, + DeleteEscalationPolicyContext + >({ + ...options, + mutationFn: ({orgSlug, escalationPolicyId}: DeleteEscalationPolicyParams) => + api.requestPromise( + `/organizations/${orgSlug}/escalation-policies/${escalationPolicyId}/`, + { + method: 'DELETE', + } + ), + onMutate: async variables => { + // Delete escalation policy from FE cache + await queryClient.cancelQueries( + makeFetchEscalationPoliciesKey({orgSlug: variables.orgSlug}) + ); + + const previousEscalationPolicies = getApiQueryData( + queryClient, + makeFetchEscalationPoliciesKey({orgSlug: variables.orgSlug}) + ); + + setApiQueryData( + queryClient, + makeFetchEscalationPoliciesKey({orgSlug: variables.orgSlug}), + oldData => { + if (!Array.isArray(oldData)) { + return oldData; + } + + return oldData.filter( + escalationPolicy => escalationPolicy.id !== variables.escalationPolicyId + ); + } + ); + options.onMutate?.(variables); + + return {previousEscalationPolicies}; + }, + onError: (error, variables, context) => { + options.onError?.(error, variables, context); + }, + }); +}; diff --git a/static/app/views/escalationPolicies/mutations/useDeleteRotationSchedule.ts b/static/app/views/escalationPolicies/mutations/useDeleteRotationSchedule.ts new file mode 100644 index 00000000000000..91216871f8b0d3 --- /dev/null +++ b/static/app/views/escalationPolicies/mutations/useDeleteRotationSchedule.ts @@ -0,0 +1,79 @@ +import { + getApiQueryData, + setApiQueryData, + useMutation, + type UseMutationOptions, + useQueryClient, +} from 'sentry/utils/queryClient'; +import type RequestError from 'sentry/utils/requestError/requestError'; +import useApi from 'sentry/utils/useApi'; +import { + makeFetchRotationSchedulesKey, + type RotationSchedule, +} from 'sentry/views/escalationPolicies/queries/useFetchRotationSchedules'; + +type DeleteRotationScheduleParams = { + orgSlug: string; + rotationScheduleId: string; +}; + +type DeleteRotationScheduleResponse = unknown; + +type DeleteRotationScheduleContext = { + previousRotationSchedules?: RotationSchedule[]; +}; + +export const useDeleteRotationSchedule = ( + options: Omit< + UseMutationOptions< + DeleteRotationScheduleResponse, + RequestError, + DeleteRotationScheduleParams, + DeleteRotationScheduleContext + >, + 'mutationFn' + > = {} +) => { + const api = useApi(); + const queryClient = useQueryClient(); + + return useMutation< + DeleteRotationScheduleResponse, + RequestError, + DeleteRotationScheduleParams, + DeleteRotationScheduleContext + >({ + ...options, + mutationFn: ({orgSlug, rotationScheduleId}: DeleteRotationScheduleParams) => + api.requestPromise( + `/organizations/${orgSlug}/rotation-schedules/${rotationScheduleId}/`, + { + method: 'DELETE', + } + ), + onMutate: async variables => { + // Delete rotation schedule from FE cache + await queryClient.cancelQueries( + makeFetchRotationSchedulesKey({orgSlug: variables.orgSlug}) + ); + + const previousRotationSchedules = getApiQueryData( + queryClient, + makeFetchRotationSchedulesKey({orgSlug: variables.orgSlug}) + ); + + setApiQueryData( + queryClient, + makeFetchRotationSchedulesKey({orgSlug: variables.orgSlug}), + previousRotationSchedules?.filter( + rotationSchedule => rotationSchedule.id !== variables.rotationScheduleId + ) + ); + + return {previousRotationSchedules}; + }, + onError: (error, variables, context) => { + options.onError?.(error, variables, context); + }, + }); +}; diff --git a/static/app/views/escalationPolicies/mutations/useUpdateEscalationPolicies.ts b/static/app/views/escalationPolicies/mutations/useUpdateEscalationPolicies.ts new file mode 100644 index 00000000000000..8331ba7e905aa9 --- /dev/null +++ b/static/app/views/escalationPolicies/mutations/useUpdateEscalationPolicies.ts @@ -0,0 +1,52 @@ +import { + setApiQueryData, + useMutation, + type UseMutationOptions, + useQueryClient, +} from 'sentry/utils/queryClient'; +import type RequestError from 'sentry/utils/requestError/requestError'; +import useApi from 'sentry/utils/useApi'; +import { + type EscalationPolicy, + makeFetchEscalationPoliciesKey, +} from 'sentry/views/escalationPolicies/queries/useFetchEscalationPolicies'; + +interface UpdateEscalationPolicyPayload extends Omit { + // If EscalationPolicy id is not provided, a new EscalationPolicy will be created. + id?: string; +} + +interface UpdateEscalationPolicyParams { + escalationPolicy: UpdateEscalationPolicyPayload; + orgSlug: string; +} + +export const useUpdateEscalationPolicy = ( + options: Omit< + UseMutationOptions, + 'mutationFn' + > = {} +) => { + const api = useApi(); + const queryClient = useQueryClient(); + + return useMutation({ + ...options, + mutationFn: ({orgSlug, escalationPolicy}: UpdateEscalationPolicyParams) => + api.requestPromise(`/organizations/${orgSlug}/escalation-policies/`, { + method: 'PUT', + data: escalationPolicy, + }), + onSuccess: (escalationPolicy, parameters, context) => { + setApiQueryData( + queryClient, + makeFetchEscalationPoliciesKey({orgSlug: parameters.orgSlug}), + escalationPolicy // Update the cache with the new escalationPolicy + ); + options.onSuccess?.(escalationPolicy, parameters, context); + }, + onError: (error, variables, context) => { + options.onError?.(error, variables, context); + }, + }); +}; diff --git a/static/app/views/escalationPolicies/mutations/useUpdateRotationSchedule.ts b/static/app/views/escalationPolicies/mutations/useUpdateRotationSchedule.ts new file mode 100644 index 00000000000000..cb2b273a7000b7 --- /dev/null +++ b/static/app/views/escalationPolicies/mutations/useUpdateRotationSchedule.ts @@ -0,0 +1,51 @@ +import { + setApiQueryData, + useMutation, + type UseMutationOptions, + useQueryClient, +} from 'sentry/utils/queryClient'; +import type RequestError from 'sentry/utils/requestError/requestError'; +import useApi from 'sentry/utils/useApi'; +import { + makeFetchRotationSchedulesKey, + type RotationSchedule, +} from 'sentry/views/escalationPolicies/queries/useFetchRotationSchedules'; + +interface UpdateRotationSchedulePayload extends Omit { + id?: string; +} + +interface UpdateRotationScheduleParams { + orgSlug: string; + rotationSchedule: UpdateRotationSchedulePayload; +} + +export const useUpdateRotationSchedule = ( + options: Omit< + UseMutationOptions, + 'mutationFn' + > = {} +) => { + const api = useApi(); + const queryClient = useQueryClient(); + + return useMutation({ + ...options, + mutationFn: ({orgSlug, rotationSchedule}: UpdateRotationScheduleParams) => + api.requestPromise(`/organizations/${orgSlug}/rotation-schedules/`, { + method: 'PUT', + data: rotationSchedule, + }), + onSuccess: (rotationSchedule, parameters, context) => { + setApiQueryData( + queryClient, + makeFetchRotationSchedulesKey({orgSlug: parameters.orgSlug}), + rotationSchedule // Update the cache with the new rotationSchedule + ); + options.onSuccess?.(rotationSchedule, parameters, context); + }, + onError: (error, variables, context) => { + options.onError?.(error, variables, context); + }, + }); +}; diff --git a/static/app/views/escalationPolicies/queries/useFetchEscalationPolicies.ts b/static/app/views/escalationPolicies/queries/useFetchEscalationPolicies.ts new file mode 100644 index 00000000000000..b925873ab865c5 --- /dev/null +++ b/static/app/views/escalationPolicies/queries/useFetchEscalationPolicies.ts @@ -0,0 +1,45 @@ +import { + type ApiQueryKey, + useApiQuery, + type UseApiQueryOptions, +} from 'sentry/utils/queryClient'; + +export interface EscalationPolicy { + description: string; + id: string; + name: string; + organization: string; + repeatNTimes: number; + userId: string; + team?: string; +} + +interface FetchEscalationPoliciesParams { + orgSlug: string; +} + +interface FetchEscalationPoliciesResponse { + escalationPolicies: EscalationPolicy[]; +} + +export const makeFetchEscalationPoliciesKey = ({ + orgSlug, +}: FetchEscalationPoliciesParams): ApiQueryKey => [ + `/organizations/${orgSlug}/escalation-policies/`, + { + query: {}, + }, +]; + +export const useFetchEscalationPolicies = ( + params: FetchEscalationPoliciesParams, + options: Partial> = {} +) => { + return useApiQuery( + makeFetchEscalationPoliciesKey(params), + { + staleTime: 0, + ...options, + } + ); +}; diff --git a/static/app/views/escalationPolicies/queries/useFetchEscalationPolicyDetails.ts b/static/app/views/escalationPolicies/queries/useFetchEscalationPolicyDetails.ts new file mode 100644 index 00000000000000..e8c74c99b0fe25 --- /dev/null +++ b/static/app/views/escalationPolicies/queries/useFetchEscalationPolicyDetails.ts @@ -0,0 +1,38 @@ +import { + type ApiQueryKey, + useApiQuery, + type UseApiQueryOptions, +} from 'sentry/utils/queryClient'; +import type {EscalationPolicy} from 'sentry/views/escalationPolicies/queries/useFetchEscalationPolicies'; + +interface FetchEscalationPolicyDetailsParams { + escalationPolicyId: string; + orgSlug: string; +} + +interface FetchEscalationPolicyDetailsResponse { + escalationPolicy: EscalationPolicy; +} + +export const makeFetchEscalationPoliciesKey = ({ + orgSlug, + escalationPolicyId, +}: FetchEscalationPolicyDetailsParams): ApiQueryKey => [ + `/organizations/${orgSlug}/escalation-policies/${escalationPolicyId}`, + { + query: {}, + }, +]; + +export const useFetchEscalationPolicies = ( + params: FetchEscalationPolicyDetailsParams, + options: Partial> = {} +) => { + return useApiQuery( + makeFetchEscalationPoliciesKey(params), + { + staleTime: 0, + ...options, + } + ); +}; diff --git a/static/app/views/escalationPolicies/queries/useFetchRotationScheduleDetails.ts b/static/app/views/escalationPolicies/queries/useFetchRotationScheduleDetails.ts new file mode 100644 index 00000000000000..2e7fb7a4fb693d --- /dev/null +++ b/static/app/views/escalationPolicies/queries/useFetchRotationScheduleDetails.ts @@ -0,0 +1,38 @@ +import { + type ApiQueryKey, + useApiQuery, + type UseApiQueryOptions, +} from 'sentry/utils/queryClient'; +import type {RotationSchedule} from 'sentry/views/escalationPolicies/queries/useFetchRotationSchedules'; + +interface FetchRotationScheduleDetailsParams { + orgSlug: string; + rotationScheduleId: string; +} + +interface FetchRotationScheduleDetailsResponse { + rotationSchedule: RotationSchedule; +} + +export const makeFetchRotationScheduleDetailsKey = ({ + orgSlug, + rotationScheduleId, +}: FetchRotationScheduleDetailsParams): ApiQueryKey => [ + `/organizations/${orgSlug}/rotation-schedules/${rotationScheduleId}`, + { + query: {}, + }, +]; + +export const useFetchRotationScheduleDetails = ( + params: FetchRotationScheduleDetailsParams, + options: Partial> = {} +) => { + return useApiQuery( + makeFetchRotationScheduleDetailsKey(params), + { + staleTime: 0, + ...options, + } + ); +}; diff --git a/static/app/views/escalationPolicies/queries/useFetchRotationSchedules.ts b/static/app/views/escalationPolicies/queries/useFetchRotationSchedules.ts new file mode 100644 index 00000000000000..5a91c0439c5b16 --- /dev/null +++ b/static/app/views/escalationPolicies/queries/useFetchRotationSchedules.ts @@ -0,0 +1,42 @@ +import { + type ApiQueryKey, + useApiQuery, + type UseApiQueryOptions, +} from 'sentry/utils/queryClient'; + +export interface RotationSchedule { + id: string; + name: string; + organization: string; + userId: string; + team?: string; +} +interface FetchRotationSchedulesParams { + orgSlug: string; +} + +interface FetchRotationSchedulesResponse { + rotationSchedules: RotationSchedule[]; +} + +export const makeFetchRotationSchedulesKey = ({ + orgSlug, +}: FetchRotationSchedulesParams): ApiQueryKey => [ + `/organizations/${orgSlug}/rotation-schedules/`, + { + query: {}, + }, +]; + +export const useFetchRotationSchedules = ( + params: FetchRotationSchedulesParams, + options: Partial> = {} +) => { + return useApiQuery( + makeFetchRotationSchedulesKey(params), + { + staleTime: 0, + ...options, + } + ); +}; From 54772f258e85ac6d3466d667cf6f0cb37d10f355 Mon Sep 17 00:00:00 2001 From: Michael Sun <55160142+MichaelSun48@users.noreply.github.com> Date: Tue, 20 Aug 2024 09:56:38 -0700 Subject: [PATCH 14/38] Add page routes for schedule and escalation policies (#76408) Adds Routes for the Schedule and Escalation Policies pages within the Alerts product section --- static/app/routes.tsx | 14 +++++++++ .../escalationPolicyList.tsx | 29 +++++++++++++++++ static/app/views/alerts/list/header.tsx | 14 ++++++++- .../triageSchedules/triageSchedules.tsx | 31 +++++++++++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 static/app/views/alerts/escalationPolicies/escalationPolicyList.tsx create mode 100644 static/app/views/alerts/triageSchedules/triageSchedules.tsx diff --git a/static/app/routes.tsx b/static/app/routes.tsx index d0d59eb8b60799..0d9c3c0f3cdd68 100644 --- a/static/app/routes.tsx +++ b/static/app/routes.tsx @@ -1164,6 +1164,20 @@ function buildRoutes() { import('sentry/views/alerts/list/incidents'))} /> + + import('sentry/views/alerts/escalationPolicies/escalationPolicyList') + )} + /> + + + import('sentry/views/alerts/triageSchedules/triageSchedules') + )} + /> + + + + + + + WIP by Michael + + + + ); +} + +export default EscalationPolicyList; diff --git a/static/app/views/alerts/list/header.tsx b/static/app/views/alerts/list/header.tsx index 2a2c8539f45bfc..c17ea86ea652a2 100644 --- a/static/app/views/alerts/list/header.tsx +++ b/static/app/views/alerts/list/header.tsx @@ -15,7 +15,7 @@ import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; type Props = { - activeTab: 'stream' | 'rules'; + activeTab: 'stream' | 'rules' | 'policies' | 'schedules'; router: InjectedRouter; }; @@ -86,6 +86,18 @@ function AlertHeader({router, activeTab}: Props) { {t('History')} +
  • + + {t('Escalation Policies')} + +
  • +
  • + + {t('Schedule')} + +
  • ); diff --git a/static/app/views/alerts/triageSchedules/triageSchedules.tsx b/static/app/views/alerts/triageSchedules/triageSchedules.tsx new file mode 100644 index 00000000000000..5ac8544b9b89d2 --- /dev/null +++ b/static/app/views/alerts/triageSchedules/triageSchedules.tsx @@ -0,0 +1,31 @@ +import {Fragment} from 'react'; + +import * as Layout from 'sentry/components/layouts/thirds'; +import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container'; +import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; +import {t} from 'sentry/locale'; +import useOrganization from 'sentry/utils/useOrganization'; +import useRouter from 'sentry/utils/useRouter'; +import AlertHeader from 'sentry/views/alerts/list/header'; + +function TriageSchedulePage() { + const router = useRouter(); + const organization = useOrganization(); + + return ( + + + + + + + + Add your content to the schedules page here! + + + + + ); +} + +export default TriageSchedulePage; From 8e1b516dcd3cf11694745fb8a21d9fa85163bbeb Mon Sep 17 00:00:00 2001 From: Mike Ihbe Date: Tue, 20 Aug 2024 10:14:06 -0700 Subject: [PATCH 15/38] Add occurrence list page --- static/app/routes.tsx | 7 +++++ static/app/views/alerts/list/header.tsx | 9 +++++- .../alerts/occurrences/occurrenceList.tsx | 31 +++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 static/app/views/alerts/occurrences/occurrenceList.tsx diff --git a/static/app/routes.tsx b/static/app/routes.tsx index 0d9c3c0f3cdd68..638698037152d1 100644 --- a/static/app/routes.tsx +++ b/static/app/routes.tsx @@ -1178,6 +1178,13 @@ function buildRoutes() { )} />
    + + import('sentry/views/alerts/occurrences/occurrenceList') + )} + /> + +
  • + + {t('Occurrences')} + +
  • ); diff --git a/static/app/views/alerts/occurrences/occurrenceList.tsx b/static/app/views/alerts/occurrences/occurrenceList.tsx new file mode 100644 index 00000000000000..8b4cd758391ad9 --- /dev/null +++ b/static/app/views/alerts/occurrences/occurrenceList.tsx @@ -0,0 +1,31 @@ +import {Fragment} from 'react'; + +import * as Layout from 'sentry/components/layouts/thirds'; +import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container'; +import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; +import {t} from 'sentry/locale'; +import useOrganization from 'sentry/utils/useOrganization'; +import useRouter from 'sentry/utils/useRouter'; +import AlertHeader from 'sentry/views/alerts/list/header'; + +function OccurrencesPage() { + const router = useRouter(); + const organization = useOrganization(); + + return ( + + + + + + + + Add your content to the schedules page here! + + + + + ); +} + +export default OccurrencesPage; From f6c41afc8783028f6f9e50ac50fa26e165c22440 Mon Sep 17 00:00:00 2001 From: Mike Ihbe Date: Tue, 20 Aug 2024 10:55:04 -0700 Subject: [PATCH 16/38] Camelcase serialized responses --- .../serializers/escalation_policy.py | 15 +-- .../serializers/escalation_policy_state.py | 16 +-- .../serializers/rotation_schedule.py | 62 +++++++---- .../test_escalation_policy_details.py | 2 +- .../endpoints/test_escalation_policy_index.py | 2 +- .../test_escalation_policy_state_details.py | 2 +- .../test_escalation_policy_state_index.py | 4 +- .../test_rotation_schedule_details.py | 2 +- .../serializers/test_escalation_policy.py | 6 +- .../test_escalation_policy_state.py | 8 +- .../serializers/test_rotation_schedule.py | 102 +++++++++--------- 11 files changed, 123 insertions(+), 98 deletions(-) diff --git a/src/sentry/escalation_policies/endpoints/serializers/escalation_policy.py b/src/sentry/escalation_policies/endpoints/serializers/escalation_policy.py index aa156cc373bc9b..e9354cde33cc6d 100644 --- a/src/sentry/escalation_policies/endpoints/serializers/escalation_policy.py +++ b/src/sentry/escalation_policies/endpoints/serializers/escalation_policy.py @@ -81,15 +81,16 @@ class EscalationPolicyStepRecipientResponse(TypedDict, total=False): class EscalationPolicyStepSerializerResponse(TypedDict, total=False): - escalate_after_sec: int + escalateAfterSec: int recipients: list[EscalationPolicyStepRecipientResponse] + stepNumber: int class EscalationPolicySerializerResponse(TypedDict, total=False): id: int name: str description: str | None - repeat_n_times: int + repeatNTimes: int steps: list[EscalationPolicyStepSerializerResponse] team: BaseTeamSerializerResponse user: RpcUser @@ -134,8 +135,8 @@ def get_attrs(self, item_list, user, **kwargs): for policy in item_list: steps = [ EscalationPolicyStepSerializerResponse( - step_number=step.step_number, - escalate_after_sec=step.escalate_after_sec, + stepNumber=step.step_number, + escalateAfterSec=step.escalate_after_sec, # Team recipients + User recipients + Schedule recipients recipients=[ EscalationPolicyStepRecipientResponse( @@ -162,7 +163,7 @@ def get_attrs(self, item_list, user, **kwargs): for step in steps if step.policy_id == policy.id ] - steps.sort(key=lambda x: x["step_number"]) + steps.sort(key=lambda x: x["stepNumber"]) results[policy] = { "team": teams.get(policy.team_id), "user": users.get(policy.user_id), @@ -172,10 +173,10 @@ def get_attrs(self, item_list, user, **kwargs): def serialize(self, obj, attrs, user, **kwargs): return EscalationPolicySerializerResponse( - id=str(obj.id), + id=obj.id, name=obj.name, description=obj.description, - repeat_n_times=obj.repeat_n_times, + repeatNTimes=obj.repeat_n_times, steps=attrs["steps"], team=attrs["team"], user=attrs["user"], diff --git a/src/sentry/escalation_policies/endpoints/serializers/escalation_policy_state.py b/src/sentry/escalation_policies/endpoints/serializers/escalation_policy_state.py index 6b3a4a820e1e6b..96d2b7cca9e5dc 100644 --- a/src/sentry/escalation_policies/endpoints/serializers/escalation_policy_state.py +++ b/src/sentry/escalation_policies/endpoints/serializers/escalation_policy_state.py @@ -23,9 +23,9 @@ class EscalationPolicyStatePutSerializer(serializers.Serializer): class EscalationPolicyStateSerializerResponse(TypedDict, total=False): id: int group: BaseGroupSerializerResponse - escalation_policy: EscalationPolicySerializerResponse - run_step_n: int - run_step_at: datetime + escalationPolicy: EscalationPolicySerializerResponse + runStepN: int + runStepAt: datetime state: EscalationPolicyStateType @@ -60,11 +60,11 @@ def get_attrs(self, item_list, user, **kwargs): return results def serialize(self, obj, attrs, user, **kwargs): - return EscalationPolicySerializerResponse( - id=str(obj.id), - run_step_n=obj.run_step_n, - run_step_at=obj.run_step_at, + return EscalationPolicyStateSerializerResponse( + id=obj.id, + runStepN=obj.run_step_n, + runStepAt=obj.run_step_at, state=obj.state, - escalation_policy=attrs["escalation_policy"], + escalationPolicy=attrs["escalation_policy"], group=attrs["group"], ) diff --git a/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py b/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py index fe5c117076797d..71407fdd61b505 100644 --- a/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py +++ b/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py @@ -5,6 +5,7 @@ from rest_framework import serializers from sentry.api.serializers.base import Serializer, register +from sentry.api.serializers.models.team import BaseTeamSerializerResponse from sentry.escalation_policies.logic import RotationPeriod, coalesce_schedule_layers from sentry.escalation_policies.models.rotation_schedule import ( RotationSchedule, @@ -82,24 +83,43 @@ def create(self, validated_data): return schedule +class RotationPeriodResponse(TypedDict): + startTime: datetime + endTime: datetime + userId: int + + class RotationScheduleLayerSerializerResponse(TypedDict, total=False): - rotation_type: str - handoff_time: str - schedule_layer_restrictions: dict - start_time: datetime + rotationType: str + handoffTime: str + scheduleLayerRestrictions: dict + startTime: datetime users: RpcUser - rotation_periods: list[RotationPeriod] + rotationPeriods: list[RotationPeriodResponse] class RotationScheduleSerializerResponse(TypedDict, total=False): id: int name: str - organization_id: int - schedule_layers: list[RotationScheduleLayerSerializerResponse] + organizationId: int + scheduleLayers: list[RotationScheduleLayerSerializerResponse] # Owner - team_id: int | None - user_id: int | None - coalesced_rotation_periods: list[RotationPeriod] + team: BaseTeamSerializerResponse + user: RpcUser + coalescedRotationPeriods: list[RotationPeriodResponse] + + +def serialize_rotation_periods( + rotation_periods: list[RotationPeriod], +) -> list[RotationPeriodResponse]: + return [ + { + "startTime": period["start_time"], + "endTime": period["end_time"], + "userId": period["user_id"], + } + for period in rotation_periods + ] @register(RotationSchedule) @@ -153,18 +173,18 @@ def get_attrs(self, item_list, user, **kwargs): layers_attr.append( RotationScheduleLayerSerializerResponse( - rotation_type=layer.rotation_type, - handoff_time=layer.handoff_time, - schedule_layer_restrictions=layer.schedule_layer_restrictions, - start_time=layer.start_date, + rotationType=layer.rotation_type, + handoffTime=layer.handoff_time, + scheduleLayerRestrictions=layer.schedule_layer_restrictions, + startTime=layer.start_date, users=ordered_users, - rotation_periods=coalesce_schedule_layers( - [layer], self.start_date, self.end_date + rotationPeriods=serialize_rotation_periods( + coalesce_schedule_layers([layer], self.start_date, self.end_date) ), ) ) - coalesced_rotation_periods = coalesce_schedule_layers( - schedule.layers.all(), self.start_date, self.end_date + coalesced_rotation_periods = serialize_rotation_periods( + coalesce_schedule_layers(schedule.layers.all(), self.start_date, self.end_date) ) results[schedule] = { "team": teams.get(schedule.team_id), @@ -178,9 +198,9 @@ def serialize(self, obj, attrs, user, **kwargs): return RotationScheduleSerializerResponse( id=obj.id, name=obj.name, - organization_id=obj.organization.id, - layers=attrs["layers"], + organizationId=obj.organization.id, + scheduleLayers=attrs["layers"], team=attrs["team"], user=attrs["user"], - coalesced_rotation_periods=attrs["coalesced_rotation_periods"], + coalescedRotationPeriods=attrs["coalesced_rotation_periods"], ) diff --git a/tests/sentry/escalation_policies/endpoints/test_escalation_policy_details.py b/tests/sentry/escalation_policies/endpoints/test_escalation_policy_details.py index 2a11167b636bd2..248bdb5e96629f 100644 --- a/tests/sentry/escalation_policies/endpoints/test_escalation_policy_details.py +++ b/tests/sentry/escalation_policies/endpoints/test_escalation_policy_details.py @@ -26,7 +26,7 @@ def test_get(self): ) response = self.client.get(url) assert response.status_code == 200, response.content - assert response.data["id"] == str(policy.id) + assert response.data["id"] == policy.id def test_delete(self): self.login_as(user=self.user) diff --git a/tests/sentry/escalation_policies/endpoints/test_escalation_policy_index.py b/tests/sentry/escalation_policies/endpoints/test_escalation_policy_index.py index ce0c32d2323591..8692d682671af8 100644 --- a/tests/sentry/escalation_policies/endpoints/test_escalation_policy_index.py +++ b/tests/sentry/escalation_policies/endpoints/test_escalation_policy_index.py @@ -26,7 +26,7 @@ def test_get(self): response = self.client.get(url) assert response.status_code == 200, response.content assert len(response.data) == 1 - assert response.data[0]["id"] == str(policy.id) + assert response.data[0]["id"] == policy.id def test_new(self): self.login_as(user=self.user) diff --git a/tests/sentry/escalation_policies/endpoints/test_escalation_policy_state_details.py b/tests/sentry/escalation_policies/endpoints/test_escalation_policy_state_details.py index 83e3456f3cde77..cdd2c349c85a1a 100644 --- a/tests/sentry/escalation_policies/endpoints/test_escalation_policy_state_details.py +++ b/tests/sentry/escalation_policies/endpoints/test_escalation_policy_state_details.py @@ -33,7 +33,7 @@ def test_get(self): ) response = self.client.get(url) assert response.status_code == 200, response.content - assert response.data["id"] == str(policy.id) + assert response.data["id"] == state.id def test_put(self): self.login_as(user=self.user) diff --git a/tests/sentry/escalation_policies/endpoints/test_escalation_policy_state_index.py b/tests/sentry/escalation_policies/endpoints/test_escalation_policy_state_index.py index 9759e5dcf125fe..d3fd73f7d0c724 100644 --- a/tests/sentry/escalation_policies/endpoints/test_escalation_policy_state_index.py +++ b/tests/sentry/escalation_policies/endpoints/test_escalation_policy_state_index.py @@ -17,7 +17,7 @@ def test_get(self): user_id=self.user.id, ) group = self.create_group(project=project) - trigger_escalation_policy(policy, group) + state = trigger_escalation_policy(policy, group) url = reverse( "sentry-api-0-organization-escalation-policy-states", @@ -28,4 +28,4 @@ def test_get(self): response = self.client.get(url) assert response.status_code == 200, response.content assert len(response.data) == 1 - assert response.data[0]["id"] == str(policy.id) + assert response.data[0]["id"] == state.id diff --git a/tests/sentry/escalation_policies/endpoints/test_rotation_schedule_details.py b/tests/sentry/escalation_policies/endpoints/test_rotation_schedule_details.py index cda996aad31263..ace38e4872f190 100644 --- a/tests/sentry/escalation_policies/endpoints/test_rotation_schedule_details.py +++ b/tests/sentry/escalation_policies/endpoints/test_rotation_schedule_details.py @@ -26,7 +26,7 @@ def test_get(self): ) response = self.client.get(url) assert response.status_code == 200, response.content - assert response.data["id"] == str(policy.id) + assert response.data["id"] == policy.id def test_delete(self): self.login_as(user=self.user) diff --git a/tests/sentry/escalation_policies/serializers/test_escalation_policy.py b/tests/sentry/escalation_policies/serializers/test_escalation_policy.py index e964ec450c210c..c0fb1eb5f62661 100644 --- a/tests/sentry/escalation_policies/serializers/test_escalation_policy.py +++ b/tests/sentry/escalation_policies/serializers/test_escalation_policy.py @@ -4,17 +4,17 @@ class BaseEscalationPolicySerializerTest: def assert_escalation_policy_serialized(self, policy, result): - assert result["id"] == str(policy.id) + assert result["id"] == policy.id assert result["name"] == str(policy.name) assert result["description"] == policy.description assert len(result["steps"]) == 2 assert result["team"] is None assert result["user"] is None - assert result["steps"][0]["escalate_after_sec"] == 30 + assert result["steps"][0]["escalateAfterSec"] == 30 assert result["steps"][0]["recipients"][0]["type"] == "team" assert result["steps"][0]["recipients"][1]["type"] == "user" - assert result["steps"][1]["escalate_after_sec"] == 30 + assert result["steps"][1]["escalateAfterSec"] == 30 assert result["steps"][1]["recipients"][0]["type"] == "team" assert result["steps"][1]["recipients"][1]["type"] == "user" diff --git a/tests/sentry/escalation_policies/serializers/test_escalation_policy_state.py b/tests/sentry/escalation_policies/serializers/test_escalation_policy_state.py index 5c79a4b27eed86..4f7f4d8ec01dd6 100644 --- a/tests/sentry/escalation_policies/serializers/test_escalation_policy_state.py +++ b/tests/sentry/escalation_policies/serializers/test_escalation_policy_state.py @@ -6,11 +6,11 @@ class BaseEscalationPolicySerializerTest: def assert_escalation_policy_state_serialized(self, state, result): - assert result["id"] == str(state.id) - assert result["run_step_n"] == 0 - assert result["run_step_at"] is not None + assert result["id"] == state.id + assert result["runStepN"] == 0 + assert result["runStepAt"] is not None assert result["state"] == EscalationPolicyStateType.UNACKNOWLEDGED - assert result["escalation_policy"] is not None + assert result["escalationPolicy"] is not None assert result["group"] is not None diff --git a/tests/sentry/escalation_policies/serializers/test_rotation_schedule.py b/tests/sentry/escalation_policies/serializers/test_rotation_schedule.py index 15213be0465018..987dfea8c4a255 100644 --- a/tests/sentry/escalation_policies/serializers/test_rotation_schedule.py +++ b/tests/sentry/escalation_policies/serializers/test_rotation_schedule.py @@ -56,78 +56,82 @@ def test_simple(self): assert result["id"] == schedule.id assert result["name"] == str(schedule.name) - assert len(result["layers"]) == 2 + assert len(result["scheduleLayers"]) == 2 assert result["team"] is None assert result["user"] is None - assert result["layers"][0]["rotation_type"] == "daily" - assert result["layers"][0]["handoff_time"] == "00:00" - assert result["layers"][0]["start_time"] == datetime(2024, 1, 1).date() - assert result["layers"][0]["schedule_layer_restrictions"]["Mon"] == [["00:00", "12:00"]] - assert result["layers"][0]["users"][0].id == userA.id - assert result["layers"][0]["users"][1].id == userB.id - assert len(result["layers"][0]["rotation_periods"]) > 0 - assert result["layers"][1]["rotation_type"] == "daily" - assert result["layers"][1]["handoff_time"] == "00:00" - assert result["layers"][1]["start_time"] == datetime(2024, 1, 1).date() - assert result["layers"][1]["schedule_layer_restrictions"]["Mon"] == [["12:00", "24:00"]] - assert result["layers"][1]["users"][0].id == userC.id - assert result["layers"][1]["users"][1].id == userD.id - assert len(result["layers"][1]["rotation_periods"]) > 0 - assert result["coalesced_rotation_periods"] == [ + assert result["scheduleLayers"][0]["rotationType"] == "daily" + assert result["scheduleLayers"][0]["handoffTime"] == "00:00" + assert result["scheduleLayers"][0]["startTime"] == datetime(2024, 1, 1).date() + assert result["scheduleLayers"][0]["scheduleLayerRestrictions"]["Mon"] == [ + ["00:00", "12:00"] + ] + assert result["scheduleLayers"][0]["users"][0].id == userA.id + assert result["scheduleLayers"][0]["users"][1].id == userB.id + assert len(result["scheduleLayers"][0]["rotationPeriods"]) > 0 + assert result["scheduleLayers"][1]["rotationType"] == "daily" + assert result["scheduleLayers"][1]["handoffTime"] == "00:00" + assert result["scheduleLayers"][1]["startTime"] == datetime(2024, 1, 1).date() + assert result["scheduleLayers"][1]["scheduleLayerRestrictions"]["Mon"] == [ + ["12:00", "24:00"] + ] + assert result["scheduleLayers"][1]["users"][0].id == userC.id + assert result["scheduleLayers"][1]["users"][1].id == userD.id + assert len(result["scheduleLayers"][1]["rotationPeriods"]) > 0 + assert result["coalescedRotationPeriods"] == [ { - "start_time": datetime(2024, 1, 1, 0, 0, tzinfo=UTC), - "end_time": datetime(2024, 1, 1, 12, 0, tzinfo=UTC), - "user_id": userA.id, + "startTime": datetime(2024, 1, 1, 0, 0, tzinfo=UTC), + "endTime": datetime(2024, 1, 1, 12, 0, tzinfo=UTC), + "userId": userA.id, }, { - "end_time": datetime(2024, 1, 2, 0, 0, tzinfo=UTC), - "start_time": datetime(2024, 1, 1, 12, 0, tzinfo=UTC), - "user_id": userC.id, + "endTime": datetime(2024, 1, 2, 0, 0, tzinfo=UTC), + "startTime": datetime(2024, 1, 1, 12, 0, tzinfo=UTC), + "userId": userC.id, }, { - "end_time": datetime(2024, 1, 2, 12, 0, tzinfo=UTC), - "start_time": datetime(2024, 1, 2, 0, 0, tzinfo=UTC), - "user_id": userB.id, + "endTime": datetime(2024, 1, 2, 12, 0, tzinfo=UTC), + "startTime": datetime(2024, 1, 2, 0, 0, tzinfo=UTC), + "userId": userB.id, }, { - "end_time": datetime(2024, 1, 3, 0, 0, tzinfo=UTC), - "start_time": datetime(2024, 1, 2, 12, 0, tzinfo=UTC), - "user_id": userD.id, + "endTime": datetime(2024, 1, 3, 0, 0, tzinfo=UTC), + "startTime": datetime(2024, 1, 2, 12, 0, tzinfo=UTC), + "userId": userD.id, }, { - "end_time": datetime(2024, 1, 3, 12, 0, tzinfo=UTC), - "start_time": datetime(2024, 1, 3, 0, 0, tzinfo=UTC), - "user_id": userA.id, + "endTime": datetime(2024, 1, 3, 12, 0, tzinfo=UTC), + "startTime": datetime(2024, 1, 3, 0, 0, tzinfo=UTC), + "userId": userA.id, }, { - "end_time": datetime(2024, 1, 4, 0, 0, tzinfo=UTC), - "start_time": datetime(2024, 1, 3, 12, 0, tzinfo=UTC), - "user_id": userC.id, + "endTime": datetime(2024, 1, 4, 0, 0, tzinfo=UTC), + "startTime": datetime(2024, 1, 3, 12, 0, tzinfo=UTC), + "userId": userC.id, }, { - "end_time": datetime(2024, 1, 4, 12, 0, tzinfo=UTC), - "start_time": datetime(2024, 1, 4, 0, 0, tzinfo=UTC), - "user_id": userB.id, + "endTime": datetime(2024, 1, 4, 12, 0, tzinfo=UTC), + "startTime": datetime(2024, 1, 4, 0, 0, tzinfo=UTC), + "userId": userB.id, }, { - "end_time": datetime(2024, 1, 5, 0, 0, tzinfo=UTC), - "start_time": datetime(2024, 1, 4, 12, 0, tzinfo=UTC), - "user_id": userD.id, + "endTime": datetime(2024, 1, 5, 0, 0, tzinfo=UTC), + "startTime": datetime(2024, 1, 4, 12, 0, tzinfo=UTC), + "userId": userD.id, }, { - "end_time": datetime(2024, 1, 5, 12, 0, tzinfo=UTC), - "start_time": datetime(2024, 1, 5, 0, 0, tzinfo=UTC), - "user_id": userA.id, + "endTime": datetime(2024, 1, 5, 12, 0, tzinfo=UTC), + "startTime": datetime(2024, 1, 5, 0, 0, tzinfo=UTC), + "userId": userA.id, }, { - "end_time": datetime(2024, 1, 6, 0, 0, tzinfo=UTC), - "start_time": datetime(2024, 1, 5, 12, 0, tzinfo=UTC), - "user_id": userC.id, + "endTime": datetime(2024, 1, 6, 0, 0, tzinfo=UTC), + "startTime": datetime(2024, 1, 5, 12, 0, tzinfo=UTC), + "userId": userC.id, }, { - "end_time": datetime(2024, 1, 7, 0, 0, tzinfo=UTC), - "start_time": datetime(2024, 1, 6, 0, 0, tzinfo=UTC), - "user_id": userB.id, + "endTime": datetime(2024, 1, 7, 0, 0, tzinfo=UTC), + "startTime": datetime(2024, 1, 6, 0, 0, tzinfo=UTC), + "userId": userB.id, }, ] From 7dfc1cc883664027b211aa940252c5108cd91bd9 Mon Sep 17 00:00:00 2001 From: Zachary Collins Date: Tue, 20 Aug 2024 12:13:23 -0700 Subject: [PATCH 17/38] Fix data formatting issue --- bin/escalation-policy-seed-data.py | 70 ++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/bin/escalation-policy-seed-data.py b/bin/escalation-policy-seed-data.py index bfbce76956b176..a43addd116e6b6 100644 --- a/bin/escalation-policy-seed-data.py +++ b/bin/escalation-policy-seed-data.py @@ -31,6 +31,19 @@ def seed_policy_data(): from sentry.models.team import Team, TeamStatus from sentry.testutils.factories import Factories # noqa: S007 + def make_restrictions() -> list[tuple[str, str]]: + start = 0 + result = [] + for i in range(random.randint(0, 4)): + start += random.randint(1, 60 * 5) + end = start + random.randint(1, 60 * 5) + if end >= 60 * 24: + break + result.append( + (f"{int(start / 60):02d}:{start % 60:02d}", f"{int(end / 60):02d}:{end % 60:02d}") + ) + return result + class TeamAndUserId(Protocol): team: Team user_id: int @@ -41,6 +54,7 @@ class OrgContext: team_count: int = 5 user_count: int = 5 schedule_count: int = 5 + policy_count: int = 5 def ensure_team_and_user_pool(self): for i in range(self.team_count - len(self.teams)): @@ -57,6 +71,27 @@ def ensure_team_and_user_pool(self): for i in range(self.schedule_count - len(self.schedules)): self.schedules.append(RotationScheduleFactory(org_context=self).build()) + # Fix restrictions by recomputing + for schedule in self.schedules: + for layer in RotationScheduleLayer.objects.filter(schedule=schedule): + restriction: ScheduleLayerRestriction = { + "Sun": make_restrictions(), + "Mon": make_restrictions(), + "Tue": make_restrictions(), + "Wed": make_restrictions(), + "Thu": make_restrictions(), + "Fri": make_restrictions(), + "Sat": make_restrictions(), + } + + layer.schedule_layer_restrictions = restriction + layer.save() + + def ensure_policies(self): + for i in range(self.policy_count - len(self.policies)): + pf = PolicyFactory(self) + self.policies.append(pf.build()) + def contextualize_slug_or_user_id( self, model: TeamAndUserId, slug_or_user_id: str | int | None ): @@ -75,6 +110,10 @@ def contextualize_slug_or_user_id( def organization(self) -> Organization: return Organization.objects.get(pk=self.org_id) + @cached_property + def policies(self) -> list[EscalationPolicy]: + return list(EscalationPolicy.objects.filter(organization=self.organization)) + @cached_property def user_ids(self) -> list[int]: return list(self.organization.member_set.values_list("user_id", flat=True)) @@ -148,19 +187,6 @@ def overrides(self) -> list[RotationScheduleOverride]: results.append(override) return results - def make_restrictions(self) -> list[tuple[str, str]]: - start = 0 - result = [] - for i in range(random.randint(0, 4)): - start += random.randint(1, 60 * 60 * 5) - end = start + random.randint(1, 60 * 60 * 5) - if end >= 60 * 60 * 24: - break - result.append( - (f"{int(start/60):02d}:{start%60:02d}", f"{int(end/60):02d}:{end%60:02d}") - ) - return result - @cached_property def schedule_layers(self) -> list[RotationScheduleLayer]: results = [] @@ -176,13 +202,13 @@ def schedule_layers(self) -> list[RotationScheduleLayer]: ) restriction: ScheduleLayerRestriction = { - "Sun": self.make_restrictions(), - "Mon": self.make_restrictions(), - "Tue": self.make_restrictions(), - "Wed": self.make_restrictions(), - "Thu": self.make_restrictions(), - "Fri": self.make_restrictions(), - "Sat": self.make_restrictions(), + "Sun": make_restrictions(), + "Mon": make_restrictions(), + "Tue": make_restrictions(), + "Wed": make_restrictions(), + "Thu": make_restrictions(), + "Fri": make_restrictions(), + "Sat": make_restrictions(), } layer.schedule_layer_restrictions = restriction @@ -301,9 +327,7 @@ def build(self): context = OrgContext() context.ensure_team_and_user_pool() - - for i in range(5): - PolicyFactory(org_context=context).build() + context.ensure_policies() if __name__ == "__main__": From d3fa4967e058aaa264b571f21edaabae335e5c57 Mon Sep 17 00:00:00 2001 From: Mike Ihbe Date: Tue, 20 Aug 2024 12:56:03 -0700 Subject: [PATCH 18/38] Fix team serialization in APIs --- .../endpoints/serializers/escalation_policy.py | 8 ++++---- .../endpoints/serializers/rotation_schedule.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/sentry/escalation_policies/endpoints/serializers/escalation_policy.py b/src/sentry/escalation_policies/endpoints/serializers/escalation_policy.py index e9354cde33cc6d..17da64901e47ef 100644 --- a/src/sentry/escalation_policies/endpoints/serializers/escalation_policy.py +++ b/src/sentry/escalation_policies/endpoints/serializers/escalation_policy.py @@ -133,7 +133,7 @@ def get_attrs(self, item_list, user, **kwargs): } for policy in item_list: - steps = [ + policy_steps = [ EscalationPolicyStepSerializerResponse( stepNumber=step.step_number, escalateAfterSec=step.escalate_after_sec, @@ -163,11 +163,11 @@ def get_attrs(self, item_list, user, **kwargs): for step in steps if step.policy_id == policy.id ] - steps.sort(key=lambda x: x["stepNumber"]) + policy_steps.sort(key=lambda x: x["stepNumber"]) results[policy] = { - "team": teams.get(policy.team_id), + "team": serialize(teams.get(policy.team_id)), "user": users.get(policy.user_id), - "steps": steps, + "steps": policy_steps, } return results diff --git a/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py b/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py index 71407fdd61b505..77a4f2c70727aa 100644 --- a/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py +++ b/src/sentry/escalation_policies/endpoints/serializers/rotation_schedule.py @@ -4,7 +4,7 @@ from django.db import router, transaction from rest_framework import serializers -from sentry.api.serializers.base import Serializer, register +from sentry.api.serializers.base import Serializer, register, serialize from sentry.api.serializers.models.team import BaseTeamSerializerResponse from sentry.escalation_policies.logic import RotationPeriod, coalesce_schedule_layers from sentry.escalation_policies.models.rotation_schedule import ( @@ -187,7 +187,7 @@ def get_attrs(self, item_list, user, **kwargs): coalesce_schedule_layers(schedule.layers.all(), self.start_date, self.end_date) ) results[schedule] = { - "team": teams.get(schedule.team_id), + "team": serialize(teams.get(schedule.team_id)), "user": users.get(schedule.user_id), "layers": layers_attr, "coalesced_rotation_periods": coalesced_rotation_periods, From 27e3bce945a25ae329475873df1009fb8f33a529 Mon Sep 17 00:00:00 2001 From: Mike Ihbe Date: Tue, 20 Aug 2024 13:46:20 -0700 Subject: [PATCH 19/38] Add a REALLY FUGLY occurrences list that allows for updating state of a escalation policy --- .../serializers/escalation_policy_state.py | 2 + .../alerts/occurrences/occurrenceList.tsx | 195 +++++++++++++++++- .../alerts/occurrences/occurrenceListRow.tsx | 86 ++++++++ .../useUpdateEscalationPolicyState.ts | 66 ++++++ .../queries/useFetchEscalationPolicyStates.ts | 43 ++++ 5 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 static/app/views/alerts/occurrences/occurrenceListRow.tsx create mode 100644 static/app/views/escalationPolicies/mutations/useUpdateEscalationPolicyState.ts create mode 100644 static/app/views/escalationPolicies/queries/useFetchEscalationPolicyStates.ts diff --git a/src/sentry/escalation_policies/endpoints/serializers/escalation_policy_state.py b/src/sentry/escalation_policies/endpoints/serializers/escalation_policy_state.py index 96d2b7cca9e5dc..bdd6133274cb72 100644 --- a/src/sentry/escalation_policies/endpoints/serializers/escalation_policy_state.py +++ b/src/sentry/escalation_policies/endpoints/serializers/escalation_policy_state.py @@ -27,6 +27,7 @@ class EscalationPolicyStateSerializerResponse(TypedDict, total=False): runStepN: int runStepAt: datetime state: EscalationPolicyStateType + dateAdded: datetime @register(EscalationPolicyState) @@ -67,4 +68,5 @@ def serialize(self, obj, attrs, user, **kwargs): state=obj.state, escalationPolicy=attrs["escalation_policy"], group=attrs["group"], + dateAdded=obj.date_added, ) diff --git a/static/app/views/alerts/occurrences/occurrenceList.tsx b/static/app/views/alerts/occurrences/occurrenceList.tsx index 8b4cd758391ad9..a605bf1649cadc 100644 --- a/static/app/views/alerts/occurrences/occurrenceList.tsx +++ b/static/app/views/alerts/occurrences/occurrenceList.tsx @@ -1,16 +1,125 @@ import {Fragment} from 'react'; +import styled from '@emotion/styled'; import * as Layout from 'sentry/components/layouts/thirds'; +import Link from 'sentry/components/links/link'; +import LoadingError from 'sentry/components/loadingError'; import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container'; +import Pagination from 'sentry/components/pagination'; +import {PanelTable} from 'sentry/components/panels/panelTable'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; +import {IconArrow} from 'sentry/icons/iconArrow'; import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry'; +import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import useRouter from 'sentry/utils/useRouter'; +import FilterBar from 'sentry/views/alerts/filterBar'; import AlertHeader from 'sentry/views/alerts/list/header'; +import {OccurrenceListRow} from 'sentry/views/alerts/occurrences/occurrenceListRow'; +import {useUpdateEscalationPolicyState} from 'sentry/views/escalationPolicies/mutations/useUpdateEscalationPolicyState'; +import { + type EscalationPolicyStateTypes, + useFetchEscationPolicyStates, +} from 'sentry/views/escalationPolicies/queries/useFetchEscalationPolicyStates'; + +/* COPIED FROM sentry/views/alerts/rules/alertRulesList */ +const StyledLoadingError = styled(LoadingError)` + grid-column: 1 / -1; + margin-bottom: ${space(4)}; + border-radius: 0; + border-width: 1px 0; +`; +const StyledPanelTable = styled(PanelTable)` + @media (min-width: ${p => p.theme.breakpoints.small}) { + overflow: initial; + } + + grid-template-columns: minmax(250px, 4fr) auto auto 60px auto; + white-space: nowrap; + font-size: ${p => p.theme.fontSizeMedium}; +`; + +const StyledSortLink = styled(Link)` + color: inherit; + display: flex; + align-items: center; + gap: ${space(0.5)}; + + :hover { + color: inherit; + } +`; + +/* END COPY */ function OccurrencesPage() { const router = useRouter(); const organization = useOrganization(); + const location = useLocation(); + + const { + data: escalationPolicyStates = [], + refetch, + getResponseHeader, + isLoading, + isError, + } = useFetchEscationPolicyStates({orgSlug: organization.slug}, {}); + const escalationPolicyStatesPageLinks = getResponseHeader?.('Link'); + + /* COPIED FROM sentry/views/alerts/rules/alertRulesList */ + type SortField = 'date_added' | 'status'; + const defaultSort: SortField = 'date_added'; + + const handleChangeFilter = (activeFilters: string[]) => { + const {cursor: _cursor, page: _page, ...currentQuery} = location.query; + router.push({ + pathname: location.pathname, + query: { + ...currentQuery, + team: activeFilters.length > 0 ? activeFilters : '', + }, + }); + }; + + const handleChangeSearch = (name: string) => { + const {cursor: _cursor, page: _page, ...currentQuery} = location.query; + router.push({ + pathname: location.pathname, + query: { + ...currentQuery, + name, + }, + }); + }; + + const sort: {asc: boolean; field: SortField} = { + asc: location.query.asc === '1', + field: (location.query.sort as SortField) || defaultSort, + }; + const {cursor: _cursor, page: _page, ...currentQuery} = location.query; + const sortArrow = ( + + ); + + /* END COPY */ + + const {mutateAsync: updateEscalationPolicyState} = useUpdateEscalationPolicyState({ + onSuccess: () => { + refetch(); + }, + }); + + const handleStatusChange = async (id: number, state: EscalationPolicyStateTypes) => { + await updateEscalationPolicyState({ + escalationPolicyStateId: id, + orgSlug: organization.id, + state: state, + }); + + return id + status; + }; return ( @@ -20,7 +129,91 @@ function OccurrencesPage() { - Add your content to the schedules page here! + + + {t('Status')} {sort.field === 'status' ? sortArrow : null} + , + + t('Title'), + + + {t('Created')} {sort.field === 'date_added' ? sortArrow : null} + , + + t('Assigned To'), + t('Actions'), + ]} + > + {isError ? ( + + ) : null} + 0} + > + {escalationPolicyStates.map(policyState => { + return ( + + ); + })} + + + { + router.push({ + pathname: path, + query: {...currentQuery, cursor}, + }); + }} + /> diff --git a/static/app/views/alerts/occurrences/occurrenceListRow.tsx b/static/app/views/alerts/occurrences/occurrenceListRow.tsx new file mode 100644 index 00000000000000..d9d5c6ca831bc8 --- /dev/null +++ b/static/app/views/alerts/occurrences/occurrenceListRow.tsx @@ -0,0 +1,86 @@ +import styled from '@emotion/styled'; + +import Access from 'sentry/components/acl/access'; +import {DropdownMenu, type MenuItemProps} from 'sentry/components/dropdownMenu'; +import ErrorBoundary from 'sentry/components/errorBoundary'; +import TimeSince from 'sentry/components/timeSince'; +import {IconEllipsis} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import type { + EscalationPolicyState, + EscalationPolicyStateTypes, +} from 'sentry/views/escalationPolicies/queries/useFetchEscalationPolicyStates'; + +/* COPIED FROM sentry/views/alerts/list/rules/row.tsx */ + +const ActionsColumn = styled('div')` + display: flex; + align-items: center; + justify-content: center; + padding: ${space(1)}; +`; + +/* END COPY */ + +type Props = { + escalationPolicyState: EscalationPolicyState; + onStatusChange: (id: number, state: EscalationPolicyStateTypes) => void; +}; + +export function OccurrenceListRow({escalationPolicyState, onStatusChange}: Props) { + const actions: MenuItemProps[] = [ + { + key: 'acknowledge', + label: t('Acknowledge'), + hidden: escalationPolicyState.state === 'acknowledged', + onAction: () => { + onStatusChange(escalationPolicyState.id, 'acknowledged'); + }, + }, + { + key: 'unacknowledge', + label: t('Unacknowledge'), + hidden: escalationPolicyState.state === 'unacknowledged', + onAction: () => { + onStatusChange(escalationPolicyState.id, 'unacknowledged'); + }, + }, + { + key: 'resolve', + label: t('Resolve'), + hidden: escalationPolicyState.state === 'resolved', + // priority: 'danger', + onAction: () => { + onStatusChange(escalationPolicyState.id, 'resolved'); + }, + }, + ]; + + return ( + +
    {escalationPolicyState.state}
    +
    {escalationPolicyState.group.title}
    + +
    {escalationPolicyState.escalationPolicy.name}
    + + + + {({hasAccess}) => ( + , + showChevron: false, + }} + disabledKeys={hasAccess ? [] : ['delete']} + /> + )} + + +
    + ); +} diff --git a/static/app/views/escalationPolicies/mutations/useUpdateEscalationPolicyState.ts b/static/app/views/escalationPolicies/mutations/useUpdateEscalationPolicyState.ts new file mode 100644 index 00000000000000..06fafba1d25e1e --- /dev/null +++ b/static/app/views/escalationPolicies/mutations/useUpdateEscalationPolicyState.ts @@ -0,0 +1,66 @@ +import { + setApiQueryData, + useMutation, + type UseMutationOptions, + useQueryClient, +} from 'sentry/utils/queryClient'; +import type RequestError from 'sentry/utils/requestError/requestError'; +import useApi from 'sentry/utils/useApi'; +import { + type EscalationPolicyState, + type EscalationPolicyStateTypes, + makeFetchEscalationPolicyStatesKey, +} from 'sentry/views/escalationPolicies/queries/useFetchEscalationPolicyStates'; + +interface UpdateEscalationStatePolicyParams { + escalationPolicyStateId: number; + orgSlug: string; + state: EscalationPolicyStateTypes; +} + +export const useUpdateEscalationPolicyState = ( + options: Omit< + UseMutationOptions< + EscalationPolicyState, + RequestError, + UpdateEscalationStatePolicyParams + >, + 'mutationFn' + > = {} +) => { + const api = useApi(); + const queryClient = useQueryClient(); + + return useMutation< + EscalationPolicyState, + RequestError, + UpdateEscalationStatePolicyParams + >({ + ...options, + mutationFn: ({ + orgSlug, + escalationPolicyStateId, + state, + }: UpdateEscalationStatePolicyParams) => + api.requestPromise( + `/organizations/${orgSlug}/escalation-policy-states/${escalationPolicyStateId}/`, + { + method: 'PUT', + data: { + state: state, + }, + } + ), + onSuccess: (escalationPolicyState, parameters, context) => { + setApiQueryData( + queryClient, + makeFetchEscalationPolicyStatesKey({orgSlug: parameters.orgSlug}), + escalationPolicyState // Update the cache with the new escalationPolicy + ); + options.onSuccess?.(escalationPolicyState, parameters, context); + }, + onError: (error, variables, context) => { + options.onError?.(error, variables, context); + }, + }); +}; diff --git a/static/app/views/escalationPolicies/queries/useFetchEscalationPolicyStates.ts b/static/app/views/escalationPolicies/queries/useFetchEscalationPolicyStates.ts new file mode 100644 index 00000000000000..573ebdb3d9314b --- /dev/null +++ b/static/app/views/escalationPolicies/queries/useFetchEscalationPolicyStates.ts @@ -0,0 +1,43 @@ +import type {Group} from 'sentry/types/group'; +import { + type ApiQueryKey, + useApiQuery, + type UseApiQueryOptions, +} from 'sentry/utils/queryClient'; +import type {EscalationPolicy} from 'sentry/views/escalationPolicies/queries/useFetchEscalationPolicies'; + +export type EscalationPolicyStateTypes = 'acknowledged' | 'unacknowledged' | 'resolved'; + +export interface EscalationPolicyState { + dateAdded: string; + escalationPolicy: EscalationPolicy; + group: Group; + id: number; + state: EscalationPolicyStateTypes; + team?: string; +} +interface FetchEscalationPolicyStatesParams { + orgSlug: string; +} + +export const makeFetchEscalationPolicyStatesKey = ({ + orgSlug, +}: FetchEscalationPolicyStatesParams): ApiQueryKey => [ + `/organizations/${orgSlug}/escalation-policy-states/`, + { + query: {}, + }, +]; + +export const useFetchEscationPolicyStates = ( + params: FetchEscalationPolicyStatesParams, + options: Partial> = {} +) => { + return useApiQuery( + makeFetchEscalationPolicyStatesKey(params), + { + staleTime: 0, + ...options, + } + ); +}; From f1b06f41a710ccbae62f21b0d70cead557a98db3 Mon Sep 17 00:00:00 2001 From: Michael Sun <55160142+MichaelSun48@users.noreply.github.com> Date: Tue, 20 Aug 2024 14:05:38 -0700 Subject: [PATCH 20/38] feat(escalation_policies): Add preliminary design for escalation policy card (#76418) This PR adds an unfinished component for the escalation policy card, ``. This component is not at all reusable or configurable currently. I will work on making it so in the future when I have time. ![image](https://github.com/user-attachments/assets/7e6c5d34-d01b-41d7-8030-add2153b8e32) --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com> --- .../escalationPolicyList.tsx | 166 +++++++++++++++++- 1 file changed, 165 insertions(+), 1 deletion(-) diff --git a/static/app/views/alerts/escalationPolicies/escalationPolicyList.tsx b/static/app/views/alerts/escalationPolicies/escalationPolicyList.tsx index f019a23ad63765..6326bd26c0449c 100644 --- a/static/app/views/alerts/escalationPolicies/escalationPolicyList.tsx +++ b/static/app/views/alerts/escalationPolicies/escalationPolicyList.tsx @@ -1,13 +1,122 @@ import {Fragment} from 'react'; +import styled from '@emotion/styled'; +import ParticipantList from 'sentry/components/group/streamlinedParticipantList'; import * as Layout from 'sentry/components/layouts/thirds'; import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; +import Timeline from 'sentry/components/timeline'; +import {IconClock, IconExclamation, IconMegaphone, IconRefresh} from 'sentry/icons'; import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import type {User} from 'sentry/types'; import useOrganization from 'sentry/utils/useOrganization'; import useRouter from 'sentry/utils/useRouter'; import AlertHeader from 'sentry/views/alerts/list/header'; +function NotifyItem({users}: {users?: User[]}) { + return ( + } + colorConfig={{ + title: 'purple400', + icon: 'purple400', + iconBorder: 'purple200', + }} + > + {users && } + + ); +} + +function EscalateAfterItem({minutes}: {minutes: number}) { + return ( + + Escalate after{' '} + {minutes} minutes if not + acknowledged +
    + } + icon={} + colorConfig={{ + title: 'blue400', + icon: 'blue400', + iconBorder: 'blue200', + }} + showLastLine + /> + ); +} + +function IncidentCreatedItem() { + return ( + } + colorConfig={{ + title: 'red400', + icon: 'red400', + iconBorder: 'red200', + }} + isActive + /> + ); +} + +function RepeatItem() { + return ( + } + colorConfig={{ + title: 'purple400', + icon: 'purple400', + iconBorder: 'purple200', + }} + /> + ); +} + +function SideBarSection({children, title}: {children: React.ReactNode; title: string}) { + return ( +
    + {title} + {children} +
    + ); +} + +interface EscalationPolicyTimelineProps { + title?: string; +} + +function EscalationPolicyTimeline({title}: EscalationPolicyTimelineProps) { + return ( + +

    {title ?? 'Example Escalation Policy'}

    + + + + + + + + + + + Some content here + Some content here + + when in use by a service + + + +
    + ); +} function EscalationPolicyList() { const router = useRouter(); const organization = useOrganization(); @@ -19,11 +128,66 @@ function EscalationPolicyList() { - WIP by Michael + + + ); } +const IncidentCreatedTimelineItem = styled(Timeline.Item)` + border-bottom: 1px solid transparent; + &:not(:last-child) { + border-image: linear-gradient( + to right, + transparent 20px, + ${p => p.theme.translucentInnerBorder} 20px + ) + 100% 1; + } +`; + +const EscalateAfterTimelineItem = styled(Timeline.Item)` + border-bottom: 1px solid transparent; + &:not(:last-child) { + border-image: linear-gradient( + to right, + transparent 20px, + ${p => p.theme.translucentInnerBorder} 20px + ) + 100% 1; + } +`; + +const EscalationPolicyContainer = styled('div')` + border: 1px solid ${p => p.theme.innerBorder}; + border-radius: 5px; + padding: 15px; + display: flex; + flex-direction: column; +`; + +const EscalationPolicyContent = styled('div')` + display: grid; + grid-template-columns: 2fr 1fr; + gap: 15px; +`; + +const SideBarContainer = styled('div')` + display: flex; + flex-direction: column; + gap: 5px; +`; + +const SideBarTitle = styled('h6')` + color: ${p => p.theme.subText}; + display: flex; + align-items: center; + gap: ${space(0.5)}; + font-size: ${p => p.theme.fontSizeMedium}; + margin: ${space(1)} 0 0; +`; + export default EscalationPolicyList; From 6375c1abf01850dd8927a66873d32aa8c167ad2a Mon Sep 17 00:00:00 2001 From: Mike Ihbe Date: Tue, 20 Aug 2024 15:51:25 -0700 Subject: [PATCH 21/38] Wire up escalation policies --- static/app/components/avatar/avatarList.tsx | 54 ++++++++-- .../app/components/avatar/scheduleAvatar.tsx | 26 +++++ .../group/streamlinedParticipantList.tsx | 31 +++++- .../escalationPolicyList.tsx | 100 ++++++++++++++---- .../alerts/occurrences/occurrenceListRow.tsx | 7 +- .../queries/useFetchEscalationPolicies.ts | 35 +++--- 6 files changed, 205 insertions(+), 48 deletions(-) create mode 100644 static/app/components/avatar/scheduleAvatar.tsx diff --git a/static/app/components/avatar/avatarList.tsx b/static/app/components/avatar/avatarList.tsx index 4228790b016242..037dc5a322d528 100644 --- a/static/app/components/avatar/avatarList.tsx +++ b/static/app/components/avatar/avatarList.tsx @@ -2,12 +2,14 @@ import {forwardRef} from 'react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; +import ScheduleAvatar from 'sentry/components/avatar/scheduleAvatar'; import TeamAvatar from 'sentry/components/avatar/teamAvatar'; import UserAvatar from 'sentry/components/avatar/userAvatar'; import {Tooltip} from 'sentry/components/tooltip'; import {space} from 'sentry/styles/space'; import type {Team} from 'sentry/types/organization'; import type {AvatarUser} from 'sentry/types/user'; +import type {RotationSchedule} from 'sentry/views/escalationPolicies/queries/useFetchRotationSchedules'; import {useHasStreamlinedUI} from 'sentry/views/issueDetails/utils'; type UserAvatarProps = React.ComponentProps; @@ -17,6 +19,7 @@ type Props = { className?: string; maxVisibleAvatars?: number; renderTooltip?: UserAvatarProps['renderTooltip']; + schedules?: RotationSchedule[]; teams?: Team[]; tooltipOptions?: UserAvatarProps['tooltipOptions']; typeAvatars?: string; @@ -51,17 +54,40 @@ function AvatarList({ className, users = [], teams = [], + schedules = [], renderTooltip, }: Props) { - const numTeams = teams.length; - const numVisibleTeams = maxVisibleAvatars - numTeams > 0 ? numTeams : maxVisibleAvatars; - const maxVisibleUsers = - maxVisibleAvatars - numVisibleTeams > 0 ? maxVisibleAvatars - numVisibleTeams : 0; + // Prioritize schedules, then teams, then users + let remaining = maxVisibleAvatars; + const visibleAvatarsByType = { + schedules: schedules, + teams: teams, + users: users, + }; + Object.entries(visibleAvatarsByType).forEach(objectType => { + const [key, avatars] = objectType; + if (remaining - avatars.length > 0) { + visibleAvatarsByType[key] = avatars.length; + remaining -= avatars.length; + } else { + visibleAvatarsByType[key] = remaining; + remaining = 0; + } + }); + // Reverse the order since css flex-reverse is used to display the avatars - const visibleTeamAvatars = teams.slice(0, numVisibleTeams).reverse(); - const visibleUserAvatars = users.slice(0, maxVisibleUsers).reverse(); + const visibleTeamAvatars = teams.slice(0, visibleAvatarsByType.teams.length).reverse(); + const visibleUserAvatars = users.slice(0, visibleAvatarsByType.users.length).reverse(); + const visibleScheduleAvatars = schedules + .slice(0, visibleAvatarsByType.schedules.length) + .reverse(); const numCollapsedAvatars = - users.length + teams.length - (visibleUserAvatars.length + visibleTeamAvatars.length); + schedules.length + + users.length + + teams.length - + (visibleUserAvatars.length + + visibleTeamAvatars.length + + visibleScheduleAvatars.length); if (!tooltipOptions.position) { tooltipOptions.position = 'top'; @@ -96,6 +122,15 @@ function AvatarList({ hasTooltip /> ))} + {visibleScheduleAvatars.map(schedule => ( + + ))} ); } @@ -135,6 +170,11 @@ const StyledTeamAvatar = styled(TeamAvatar)` ${AvatarStyle} `; +const StyledScheduleAvatar = styled(ScheduleAvatar)` + overflow: hidden; + ${AvatarStyle} +`; + const CollapsedAvatarsCicle = styled('div')<{size: number}>` display: flex; align-items: center; diff --git a/static/app/components/avatar/scheduleAvatar.tsx b/static/app/components/avatar/scheduleAvatar.tsx new file mode 100644 index 00000000000000..47f4152f055c77 --- /dev/null +++ b/static/app/components/avatar/scheduleAvatar.tsx @@ -0,0 +1,26 @@ +import {BaseAvatar, type BaseAvatarProps} from 'sentry/components/avatar/baseAvatar'; +import type {RotationSchedule} from 'sentry/views/escalationPolicies/queries/useFetchRotationSchedules'; + +interface Props extends BaseAvatarProps { + schedule: RotationSchedule | null | undefined; +} + +function ScheduleAvatar({schedule, tooltip: tooltipProp, ...props}: Props) { + if (!schedule) { + return null; + } + + const tooltip = tooltipProp ?? `#${schedule.name}`; + + return ( + + ); +} + +export default ScheduleAvatar; diff --git a/static/app/components/group/streamlinedParticipantList.tsx b/static/app/components/group/streamlinedParticipantList.tsx index 1a25d6626d9a54..b2f26b37d00e45 100644 --- a/static/app/components/group/streamlinedParticipantList.tsx +++ b/static/app/components/group/streamlinedParticipantList.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled'; import Avatar from 'sentry/components/avatar'; import AvatarList from 'sentry/components/avatar/avatarList'; +import ScheduleAvatar from 'sentry/components/avatar/scheduleAvatar'; import TeamAvatar from 'sentry/components/avatar/teamAvatar'; import {Button} from 'sentry/components/button'; import {Overlay, PositionWrapper} from 'sentry/components/overlay'; @@ -10,30 +11,45 @@ import {t, tn} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {Team, User} from 'sentry/types'; import useOverlay from 'sentry/utils/useOverlay'; +import type {RotationSchedule} from 'sentry/views/escalationPolicies/queries/useFetchRotationSchedules'; interface DropdownListProps { users: User[]; + maxVisibleAvatars?: number; + schedules?: RotationSchedule[]; teams?: Team[]; } -export default function ParticipantList({users, teams}: DropdownListProps) { +export default function ParticipantList({ + users, + teams, + schedules, + maxVisibleAvatars, +}: DropdownListProps) { const {overlayProps, isOpen, triggerProps} = useOverlay({ position: 'bottom-start', shouldCloseOnBlur: true, isKeyboardDismissDisabled: false, }); + users = users || []; + teams = teams || []; + schedules = schedules || []; const theme = useTheme(); - const showHeaders = users.length > 0 && teams && teams.length > 0; + const showHeaders = + Number(users.length > 0) + Number(teams.length > 0) + Number(schedules.length > 0) > + 1; + maxVisibleAvatars = maxVisibleAvatars || 3; return (
    {isOpen && ( @@ -64,6 +80,15 @@ export default function ParticipantList({users, teams}: DropdownListProps) {
    ))} + {showHeaders && ( + {t('Rotation Schedules (%s)', schedules.length)} + )} + {schedules.map(schedule => ( + + +
    {schedule.name}
    +
    + ))} diff --git a/static/app/views/alerts/escalationPolicies/escalationPolicyList.tsx b/static/app/views/alerts/escalationPolicies/escalationPolicyList.tsx index 6326bd26c0449c..880529141ae270 100644 --- a/static/app/views/alerts/escalationPolicies/escalationPolicyList.tsx +++ b/static/app/views/alerts/escalationPolicies/escalationPolicyList.tsx @@ -4,17 +4,43 @@ import styled from '@emotion/styled'; import ParticipantList from 'sentry/components/group/streamlinedParticipantList'; import * as Layout from 'sentry/components/layouts/thirds'; import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container'; +import Pagination from 'sentry/components/pagination'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; import Timeline from 'sentry/components/timeline'; import {IconClock, IconExclamation, IconMegaphone, IconRefresh} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; -import type {User} from 'sentry/types'; +import type {Team, User} from 'sentry/types'; +import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import useRouter from 'sentry/utils/useRouter'; import AlertHeader from 'sentry/views/alerts/list/header'; - -function NotifyItem({users}: {users?: User[]}) { +import { + type EscalationPolicy, + type EscalationPolicyStepRecipient, + useFetchEscalationPolicies, +} from 'sentry/views/escalationPolicies/queries/useFetchEscalationPolicies'; +import type {RotationSchedule} from 'sentry/views/escalationPolicies/queries/useFetchRotationSchedules'; + +function NotifyItem({recipients}: {recipients: EscalationPolicyStepRecipient[]}) { + const users: User[] = + recipients + .filter(r => { + return r.type === 'user'; + }) + .map(r => r.data as User) || []; + const teams: Team[] = + recipients + .filter(r => { + return r.type === 'team'; + }) + .map(r => r.data as Team) || []; + const schedules: RotationSchedule[] = + recipients + .filter(r => { + return r.type === 'schedule'; + }) + .map(r => r.data as RotationSchedule) || []; return ( - {users && } + ); } @@ -66,10 +97,10 @@ function IncidentCreatedItem() { ); } -function RepeatItem() { +function RepeatItem({n}: {n: number}) { return ( 1 ? 's' : '')} icon={} colorConfig={{ title: 'purple400', @@ -89,29 +120,27 @@ function SideBarSection({children, title}: {children: React.ReactNode; title: st ); } -interface EscalationPolicyTimelineProps { - title?: string; -} - -function EscalationPolicyTimeline({title}: EscalationPolicyTimelineProps) { +function EscalationPolicyTimeline({policy}: {policy: EscalationPolicy}) { return ( -

    {title ?? 'Example Escalation Policy'}

    +

    {policy.name}

    - - - - - + {policy.steps.map(policyStep => { + return ( +
    + + +
    + ); + })} +
    - Some content here - Some content here - - when in use by a service - + Some content here
    @@ -120,6 +149,17 @@ function EscalationPolicyTimeline({title}: EscalationPolicyTimelineProps) { function EscalationPolicyList() { const router = useRouter(); const organization = useOrganization(); + const location = useLocation(); + + const { + data: escalationPolicies = [], + // refetch, + getResponseHeader, + // isLoading, + // isError, + } = useFetchEscalationPolicies({orgSlug: organization.slug}, {}); + const escalationPoliciesPageLinks = getResponseHeader?.('Link'); + const {cursor: _cursor, page: _page, ...currentQuery} = location.query; return ( @@ -129,9 +169,23 @@ function EscalationPolicyList() { - + {escalationPolicies.map((escalationPolicy: EscalationPolicy) => ( + + ))} + { + router.push({ + pathname: path, + query: {...currentQuery, cursor}, + }); + }} + /> ); diff --git a/static/app/views/alerts/occurrences/occurrenceListRow.tsx b/static/app/views/alerts/occurrences/occurrenceListRow.tsx index d9d5c6ca831bc8..07cec7b33a389f 100644 --- a/static/app/views/alerts/occurrences/occurrenceListRow.tsx +++ b/static/app/views/alerts/occurrences/occurrenceListRow.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled'; import Access from 'sentry/components/acl/access'; import {DropdownMenu, type MenuItemProps} from 'sentry/components/dropdownMenu'; import ErrorBoundary from 'sentry/components/errorBoundary'; +import Link from 'sentry/components/links/link'; import TimeSince from 'sentry/components/timeSince'; import {IconEllipsis} from 'sentry/icons'; import {t} from 'sentry/locale'; @@ -60,7 +61,11 @@ export function OccurrenceListRow({escalationPolicyState, onStatusChange}: Props return (
    {escalationPolicyState.state}
    -
    {escalationPolicyState.group.title}
    +
    + + {escalationPolicyState.group.title} + +
    {escalationPolicyState.escalationPolicy.name}
    diff --git a/static/app/views/escalationPolicies/queries/useFetchEscalationPolicies.ts b/static/app/views/escalationPolicies/queries/useFetchEscalationPolicies.ts index b925873ab865c5..022f4e645ec26a 100644 --- a/static/app/views/escalationPolicies/queries/useFetchEscalationPolicies.ts +++ b/static/app/views/escalationPolicies/queries/useFetchEscalationPolicies.ts @@ -1,27 +1,37 @@ +import type {Team} from 'sentry/types/organization'; +import type {User} from 'sentry/types/user'; import { type ApiQueryKey, useApiQuery, type UseApiQueryOptions, } from 'sentry/utils/queryClient'; +import type {RotationSchedule} from 'sentry/views/escalationPolicies/queries/useFetchRotationSchedules'; -export interface EscalationPolicy { +export type EscalationPolicyStepRecipient = { + data: Team | User | RotationSchedule; + type: 'user' | 'team' | 'schedule'; +}; +export type EscalationPolicyStep = { + escalateAfterSec: number; + recipients: EscalationPolicyStepRecipient[]; + stepNumber: number; +}; + +export type EscalationPolicy = { description: string; id: string; name: string; organization: string; repeatNTimes: number; + steps: EscalationPolicyStep[]; userId: string; team?: string; -} +}; interface FetchEscalationPoliciesParams { orgSlug: string; } -interface FetchEscalationPoliciesResponse { - escalationPolicies: EscalationPolicy[]; -} - export const makeFetchEscalationPoliciesKey = ({ orgSlug, }: FetchEscalationPoliciesParams): ApiQueryKey => [ @@ -33,13 +43,10 @@ export const makeFetchEscalationPoliciesKey = ({ export const useFetchEscalationPolicies = ( params: FetchEscalationPoliciesParams, - options: Partial> = {} + options: Partial> = {} ) => { - return useApiQuery( - makeFetchEscalationPoliciesKey(params), - { - staleTime: 0, - ...options, - } - ); + return useApiQuery(makeFetchEscalationPoliciesKey(params), { + staleTime: 0, + ...options, + }); }; From 5b7d7091a1da01823e94c1ef0a08ec09ab1c52d1 Mon Sep 17 00:00:00 2001 From: Michael Sun <55160142+MichaelSun48@users.noreply.github.com> Date: Tue, 20 Aug 2024 16:46:26 -0700 Subject: [PATCH 22/38] feat(escalation_policies): Add basic, unfinished designs for schedule component. (#76443) Adds some basic, unfinished designs for the schedule component. Reused lots of assets from Crons. ![image](https://github.com/user-attachments/assets/ef542dfa-acc3-45f0-81ad-64c901676e8e) --- .../triageSchedules/ScheduleTimelineRow.tsx | 170 ++++++++++++++++++ .../triageSchedules/triageSchedules.tsx | 149 ++++++++++++++- 2 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 static/app/views/alerts/triageSchedules/ScheduleTimelineRow.tsx diff --git a/static/app/views/alerts/triageSchedules/ScheduleTimelineRow.tsx b/static/app/views/alerts/triageSchedules/ScheduleTimelineRow.tsx new file mode 100644 index 00000000000000..6835ff0dbe1ec9 --- /dev/null +++ b/static/app/views/alerts/triageSchedules/ScheduleTimelineRow.tsx @@ -0,0 +1,170 @@ +import styled from '@emotion/styled'; + +import UserAvatar from 'sentry/components/avatar/userAvatar'; +import ParticipantList from 'sentry/components/group/streamlinedParticipantList'; +import {space} from 'sentry/styles/space'; +import type {UserSchedulePeriod} from 'sentry/views/alerts/triageSchedules/triageSchedules'; + +interface ScheduleTimelineRowProps { + name: string; + // Must have at least one entry + schedulePeriods: UserSchedulePeriod[]; + totalWidth: number; +} + +export function ScheduleTimelineRow({ + name, + totalWidth, + schedulePeriods, +}: ScheduleTimelineRowProps) { + return ( + + + + {name} + + + + On Rotation Now: + {schedulePeriods[0].user && } + + + + + + + + ); +} + +// Super jank right now but for sake of demoing, make sure the percentages for all users add up to 100. +// Adding more will overflow +function ScheduleTimeline({ + periods, + width, +}: { + periods: UserSchedulePeriod[]; + width: number; +}) { + let currPosition = 0; + return ( + + {periods.map(({percentage, user, backgroundColor}, index) => { + const periodWidth = (percentage / 100) * width; + currPosition += periodWidth; + return user ? ( + + + {user.name} + + ) : null; + })} + + ); +} + +const TimelineContainer = styled('div')` + position: relative; + height: 100%; +`; + +const SchedulePeriod = styled('div')` + position: absolute; + gap: ${space(1)}; + display: flex; + align-items: center; + padding-left: ${space(1)}; + top: calc(50% + 1px); + width: 4px; + height: 28px; + transform: translateY(-50%); + + fill-opacity: 0.7; + + border-top-left-radius: 2px; + border-bottom-left-radius: 2px; + + border-top-right-radius: 2px; + border-bottom-right-radius: 2px; +`; + +const DetailsArea = styled('div')` + border-right: 1px solid ${p => p.theme.border}; + border-radius: 0; + position: relative; + padding: ${space(3)}; + display: block; +`; + +const DetailsHeadline = styled('div')` + display: grid; + gap: ${space(1)}; + grid-template-columns: 1fr minmax(30px, max-content); +`; + +const Name = styled('h3')` + font-size: ${p => p.theme.fontSizeLarge}; + word-break: break-word; + margin-bottom: ${space(0.5)}; +`; + +const ScheduleTitle = styled('h6')` + color: ${p => p.theme.subText}; + display: flex; + align-items: center; + gap: ${space(0.5)}; + font-size: ${p => p.theme.fontSizeMedium}; + margin: ${space(1)} 0 0; +`; + +const TimelineRow = styled('li')` + grid-column: 1/-1; + + display: grid; + grid-template-columns: subgrid; + + /* Disabled monitors become more opaque */ + --disabled-opacity: unset; + + &:last-child { + border-bottom-left-radius: ${p => p.theme.borderRadius}; + border-bottom-right-radius: ${p => p.theme.borderRadius}; + } +`; + +const ScheduleName = styled('h6')` + font-size: ${p => p.theme.fontSizeMedium}; + color: ${p => p.theme.subText}; + display: flex; + align-items: center; + margin: 0; +`; + +const ScheduleContainer = styled('div')` + display: flex; + flex-direction: column; + justify-content: center; + contain: content; + grid-column: 3/-1; +`; + +const ScheduleOuterContainer = styled('div')` + position: relative; + height: calc(${p => p.theme.fontSizeLarge} * ${p => p.theme.text.lineHeightHeading}); + opacity: var(--disabled-opacity); +`; + +const OnRotationContainer = styled('div')` + display: flex; + padding: ${space(1)} ${space(1)}; + flex-direction: column; + border-right: 1px solid ${p => p.theme.innerBorder}; + text-align: left; +`; diff --git a/static/app/views/alerts/triageSchedules/triageSchedules.tsx b/static/app/views/alerts/triageSchedules/triageSchedules.tsx index 5ac8544b9b89d2..0f1c6d471a51b3 100644 --- a/static/app/views/alerts/triageSchedules/triageSchedules.tsx +++ b/static/app/views/alerts/triageSchedules/triageSchedules.tsx @@ -1,12 +1,98 @@ -import {Fragment} from 'react'; +import {Fragment, useRef} from 'react'; +import {useTheme} from '@emotion/react'; +import styled from '@emotion/styled'; +import {Button} from 'sentry/components/button'; import * as Layout from 'sentry/components/layouts/thirds'; import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container'; +import Panel from 'sentry/components/panels/panel'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; +import {IconChevron} from 'sentry/icons'; import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import type {User} from 'sentry/types'; +import {useDimensions} from 'sentry/utils/useDimensions'; import useOrganization from 'sentry/utils/useOrganization'; import useRouter from 'sentry/utils/useRouter'; +import {useUser} from 'sentry/utils/useUser'; import AlertHeader from 'sentry/views/alerts/list/header'; +import {ScheduleTimelineRow} from 'sentry/views/alerts/triageSchedules/ScheduleTimelineRow'; +import { + GridLineLabels, + GridLineOverlay, +} from 'sentry/views/monitors/components/timeline/gridLines'; +import {useTimeWindowConfig} from 'sentry/views/monitors/components/timeline/hooks/useTimeWindowConfig'; + +export interface UserSchedulePeriod { + backgroundColor: string; + /** + * Number between 1 and 100 representing the percentage of the timeline this user should take up + * Leave user undefined if you want to represent a gap in the schedule + */ + percentage: number; + user?: User; +} + +function ScheduleList() { + const elementRef = useRef(null); + const {width: timelineWidth} = useDimensions({elementRef}); + const timeWindowConfig = useTimeWindowConfig({timelineWidth}); + + const user = useUser(); + const theme = useTheme(); + + const schedulePeriods: UserSchedulePeriod[] = [ + { + backgroundColor: theme.green100, + percentage: 33, + user, + }, + { + backgroundColor: theme.blue100, + percentage: 33, + user: undefined, // Represents a gap in the schedule + }, + { + backgroundColor: theme.yellow100, + percentage: 34, + user, + }, + ]; + + return ( + + + + + {/* These buttons are purely cosmetic for now */} +