From 5f87f83b2b612d68ecd96109bc5e050040321b45 Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 29 Jun 2022 14:05:02 +0300 Subject: [PATCH 01/15] Update public endpoint for routes - Add field for telegram - Add support for messaging backends --- engine/apps/api/serializers/channel_filter.py | 3 +- engine/apps/base/messaging.py | 2 +- engine/apps/public_api/serializers/routes.py | 208 +++++++++++------- 3 files changed, 133 insertions(+), 80 deletions(-) diff --git a/engine/apps/api/serializers/channel_filter.py b/engine/apps/api/serializers/channel_filter.py index 89a84a4a0c..c8b58ef2c4 100644 --- a/engine/apps/api/serializers/channel_filter.py +++ b/engine/apps/api/serializers/channel_filter.py @@ -93,6 +93,7 @@ def validate_filtering_term(self, filtering_term): def validate_notification_backends(self, notification_backends): # NOTE: updates the whole field, handling dict updates per backend if notification_backends is not None: + organization = self.context["request"].auth.organization if not isinstance(notification_backends, dict): raise serializers.ValidationError(["Invalid messaging backend data"]) current = self.instance.notification_backends or {} @@ -101,7 +102,7 @@ def validate_notification_backends(self, notification_backends): if backend is None: raise serializers.ValidationError(["Invalid messaging backend"]) updated_data = backend.validate_channel_filter_data( - self.instance, + organization, notification_backends[backend_id], ) # update existing backend data diff --git a/engine/apps/base/messaging.py b/engine/apps/base/messaging.py index 3b2885403f..2e9e1e880d 100644 --- a/engine/apps/base/messaging.py +++ b/engine/apps/base/messaging.py @@ -13,7 +13,7 @@ def get_templater_class(self): if self.templater: return import_string(self.templater) - def validate_channel_filter_data(self, channel_filter, data): + def validate_channel_filter_data(self, organization, data): """Validate JSON channel data for a channel filter update. Ensure the required/expected data is provided as needed by the backend. diff --git a/engine/apps/public_api/serializers/routes.py b/engine/apps/public_api/serializers/routes.py index 6bd0e4c987..0b2613faca 100644 --- a/engine/apps/public_api/serializers/routes.py +++ b/engine/apps/public_api/serializers/routes.py @@ -2,14 +2,125 @@ from rest_framework import serializers from apps.alerts.models import AlertReceiveChannel, ChannelFilter, EscalationChain +from apps.base.messaging import get_messaging_backend_from_id, get_messaging_backends from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField from common.api_helpers.exceptions import BadRequest from common.api_helpers.mixins import OrderedModelSerializerMixin -class ChannelFilterSerializer(OrderedModelSerializerMixin, serializers.ModelSerializer): +class BaseChannelFilterSerializer(OrderedModelSerializerMixin, serializers.ModelSerializer): + """Base Channel Filter serializer with validation methods""" + + def __init__(self, *args, **kwargs): + """Update existing fields of the serializer with messaging backends fields""" + + super().__init__(*args, **kwargs) + for backend_id, _ in get_messaging_backends(): + backend = get_messaging_backend_from_id(backend_id) + if backend is None: + continue + field = backend_id.lower() + self._declared_fields[field] = serializers.DictField(required=False) + self.Meta.fields.append(field) + + def to_representation(self, instance): + result = super().to_representation(instance) + result["slack"] = {"channel_id": instance.slack_channel_id} + result["telegram"] = {"id": instance.telegram_channel.public_primary_key if instance.telegram_channel else None} + # add representation for other messaging backends + for backend_id, _ in get_messaging_backends(): + backend = get_messaging_backend_from_id(backend_id) + if backend is None: + continue + field = backend_id.lower() + channel_id = None + if instance.notification_backends and instance.notification_backends.get(backend_id, {}).get("channel"): + channel_id = instance.notification_backends[backend_id]["channel"] + result[field] = {"id": channel_id} + return result + + def _correct_validated_data(self, validated_data): + organization = self.context["request"].auth.organization + + slack_field = validated_data.pop("slack", {}) + if "channel_id" in slack_field: + validated_data["slack_channel_id"] = self._validate_slack_channel_id(slack_field.get("channel_id")) + + telegram_field = validated_data.pop("telegram", {}) + if "id" in telegram_field: + validated_data["telegram_channel"] = self._validate_telegram_channel(telegram_field.get("id")) + + notification_backends = {} + for backend_id, _ in get_messaging_backends(): + field = backend_id.lower() + backend_field = validated_data.pop(field, {}) + if backend_field: + backend = get_messaging_backend_from_id(backend_id) + if backend is None: + continue + channel_id = backend_field.get("id") + notification_backend = {"channel": channel_id, "enabled": True} + backend.validate_channel_filter_data(organization, notification_backend) + notification_backends[backend_id] = notification_backend + if notification_backends: + validated_data["notification_backends"] = notification_backends + return validated_data + + def _validate_slack_channel_id(self, slack_channel_id): + SlackChannel = apps.get_model("slack", "SlackChannel") + + if slack_channel_id is not None: + slack_channel_id = slack_channel_id.upper() + organization = self.context["request"].auth.organization + slack_team_identity = organization.slack_team_identity + try: + slack_team_identity.get_cached_channels().get(slack_id=slack_channel_id) + except SlackChannel.DoesNotExist: + raise BadRequest(detail="Slack channel does not exist") + return slack_channel_id + + def _validate_telegram_channel(self, telegram_channel_id): + TelegramToOrganizationConnector = apps.get_model("telegram", "TelegramToOrganizationConnector") + if telegram_channel_id is not None: + organization = self.context["request"].auth.organization + try: + telegram_channel = organization.telegram_channel.get(public_primary_key=telegram_channel_id) + except TelegramToOrganizationConnector.DoesNotExist: + raise BadRequest(detail="Telegram channel does not exist") + return telegram_channel + return + + def _update_notification_backends(self, notification_backends): + if notification_backends is not None: + current = self.instance.notification_backends or {} + for backend_id in notification_backends: + backend = get_messaging_backend_from_id(backend_id) + if backend is None: + continue + # update existing backend data + notification_backends[backend_id] = current.get(backend_id, {}) | notification_backends[backend_id] + return notification_backends + + def validate_escalation_chain_id(self, escalation_chain): + if escalation_chain is None: + return escalation_chain + if self.instance is not None: + alert_receive_channel = self.instance.alert_receive_channel + else: + alert_receive_channel = AlertReceiveChannel.objects.get( + public_primary_key=self.initial_data["integration_id"] + ) + + if escalation_chain.team != alert_receive_channel.team: + raise BadRequest(detail="Escalation chain must be assigned to the same team as the integration") + + return escalation_chain + + +class ChannelFilterSerializer(BaseChannelFilterSerializer): id = serializers.CharField(read_only=True, source="public_primary_key") slack = serializers.DictField(required=False) + telegram = serializers.DictField(required=False) routing_regex = serializers.CharField(allow_null=False, required=True, source="filtering_term") position = serializers.IntegerField(required=False, source="order") integration_id = OrganizationFilteredPrimaryKeyRelatedField( @@ -33,15 +144,11 @@ class Meta: "position", "is_the_last_route", "slack", + "telegram", "manual_order", ] read_only_fields = ("is_the_last_route",) - def to_representation(self, instance): - result = super().to_representation(instance) - result["slack"] = {"channel_id": instance.slack_channel_id} - return result - def create(self, validated_data): validated_data = self._correct_validated_data(validated_data) manual_order = validated_data.pop("manual_order") @@ -71,42 +178,15 @@ def validate(self, attrs): else: raise BadRequest(detail="Route with this regex already exists") - def validate_escalation_chain_id(self, escalation_chain): - if self.instance is not None: - alert_receive_channel = self.instance.alert_receive_channel - else: - alert_receive_channel = AlertReceiveChannel.objects.get( - public_primary_key=self.initial_data["integration_id"] - ) - - if escalation_chain.team != alert_receive_channel.team: - raise BadRequest(detail="Escalation chain must be assigned to the same team as the integration") - - return escalation_chain - - def _correct_validated_data(self, validated_data): - slack_field = validated_data.pop("slack", {}) - if "channel_id" in slack_field: - validated_data["slack_channel_id"] = self._validate_slack_channel_id(slack_field.get("channel_id")) - return validated_data - - def _validate_slack_channel_id(self, slack_channel_id): - SlackChannel = apps.get_model("slack", "SlackChannel") - - if slack_channel_id is not None: - slack_channel_id = slack_channel_id.upper() - organization = self.context["request"].auth.organization - slack_team_identity = organization.slack_team_identity - try: - slack_team_identity.get_cached_channels().get(slack_id=slack_channel_id) - except SlackChannel.DoesNotExist: - raise BadRequest(detail="Slack channel does not exist") - return slack_channel_id - class ChannelFilterUpdateSerializer(ChannelFilterSerializer): integration_id = OrganizationFilteredPrimaryKeyRelatedField(source="alert_receive_channel", read_only=True) routing_regex = serializers.CharField(allow_null=False, required=False, source="filtering_term") + escalation_chain_id = OrganizationFilteredPrimaryKeyRelatedField( + queryset=EscalationChain.objects, + source="escalation_chain", + required=False, + ) class Meta(ChannelFilterSerializer.Meta): read_only_fields = [*ChannelFilterSerializer.Meta.read_only_fields, "integration_id"] @@ -122,12 +202,18 @@ def update(self, instance, validated_data): ) self._change_position(order, instance) + if validated_data.get("notification_backends"): + validated_data["notification_backends"] = self._update_notification_backends( + validated_data["notification_backends"] + ) + return super().update(instance, validated_data) -class DefaultChannelFilterSerializer(OrderedModelSerializerMixin, serializers.ModelSerializer): +class DefaultChannelFilterSerializer(BaseChannelFilterSerializer): id = serializers.CharField(read_only=True, source="public_primary_key") slack = serializers.DictField(required=False) + telegram = serializers.DictField(required=False) escalation_chain_id = OrganizationFilteredPrimaryKeyRelatedField( queryset=EscalationChain.objects, source="escalation_chain", @@ -140,48 +226,14 @@ class Meta: fields = [ "id", "slack", + "telegram", "escalation_chain_id", ] - def _validate_slack_channel_id(self, slack_channel_id): - SlackChannel = apps.get_model("slack", "SlackChannel") - - if slack_channel_id is not None: - slack_channel_id = slack_channel_id.upper() - organization = self.context["request"].auth.organization - slack_team_identity = organization.slack_team_identity - try: - slack_team_identity.get_cached_channels().get(slack_id=slack_channel_id) - except SlackChannel.DoesNotExist: - raise BadRequest(detail="Slack channel does not exist") - return slack_channel_id - - def _correct_validated_data(self, validated_data): - slack_field = validated_data.pop("slack", {}) - if "channel_id" in slack_field: - validated_data["slack_channel_id"] = self._validate_slack_channel_id(slack_field.get("channel_id")) - return validated_data - - def to_representation(self, instance): - result = super().to_representation(instance) - result["slack"] = {"channel_id": instance.slack_channel_id} - return result - def update(self, instance, validated_data): validated_data = self._correct_validated_data(validated_data) - return super().update(instance, validated_data) - - def validate_escalation_chain_id(self, escalation_chain): - if escalation_chain is None: - return escalation_chain - if self.instance is not None: - alert_receive_channel = self.instance.alert_receive_channel - else: - alert_receive_channel = AlertReceiveChannel.objects.get( - public_primary_key=self.initial_data["integration_id"] + if validated_data.get("notification_backends"): + validated_data["notification_backends"] = self._update_notification_backends( + validated_data["notification_backends"] ) - - if escalation_chain.team != alert_receive_channel.team: - raise BadRequest(detail="Escalation chain must be assigned to the same team as the integration") - - return escalation_chain + return super().update(instance, validated_data) From f95edaed193ca216f125bb249297e8dac4dc09ea Mon Sep 17 00:00:00 2001 From: Julia Date: Wed, 29 Jun 2022 14:11:40 +0300 Subject: [PATCH 02/15] Update tests for routes --- engine/apps/public_api/tests/test_routes.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/engine/apps/public_api/tests/test_routes.py b/engine/apps/public_api/tests/test_routes.py index b5502c8c78..9acee6ebea 100644 --- a/engine/apps/public_api/tests/test_routes.py +++ b/engine/apps/public_api/tests/test_routes.py @@ -4,6 +4,9 @@ from rest_framework.test import APIClient from apps.alerts.models import ChannelFilter +from apps.base.tests.messaging_backend import TestOnlyBackend + +TEST_MESSAGING_BACKEND_FIELD = TestOnlyBackend.backend_id.lower() @pytest.fixture() @@ -44,6 +47,8 @@ def test_get_route( "position": channel_filter.order, "is_the_last_route": channel_filter.is_default, "slack": {"channel_id": channel_filter.slack_channel_id}, + "telegram": {"id": None}, + TEST_MESSAGING_BACKEND_FIELD: {"id": None}, } assert response.status_code == status.HTTP_200_OK @@ -74,6 +79,8 @@ def test_get_routes_list( "position": channel_filter.order, "is_the_last_route": channel_filter.is_default, "slack": {"channel_id": channel_filter.slack_channel_id}, + "telegram": {"id": None}, + TEST_MESSAGING_BACKEND_FIELD: {"id": None}, } ], } @@ -108,6 +115,8 @@ def test_get_routes_filter_by_integration_id( "position": channel_filter.order, "is_the_last_route": channel_filter.is_default, "slack": {"channel_id": channel_filter.slack_channel_id}, + "telegram": {"id": None}, + TEST_MESSAGING_BACKEND_FIELD: {"id": None}, } ], } @@ -140,6 +149,8 @@ def test_create_route( "position": 0, "is_the_last_route": False, "slack": {"channel_id": None}, + "telegram": {"id": None}, + TEST_MESSAGING_BACKEND_FIELD: {"id": None}, } assert response.status_code == status.HTTP_201_CREATED @@ -198,6 +209,8 @@ def test_update_route( "position": new_channel_filter.order, "is_the_last_route": new_channel_filter.is_default, "slack": {"channel_id": new_channel_filter.slack_channel_id}, + "telegram": {"id": None}, + TEST_MESSAGING_BACKEND_FIELD: {"id": None}, } assert response.status_code == status.HTTP_200_OK From a153d283e44c07e80479eb663d2968c2c9b99c95 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Mon, 4 Jul 2022 17:51:56 -0300 Subject: [PATCH 03/15] Initial web schedule model and serializers. Add override shift type. --- .../api/serializers/schedule_polymorphic.py | 8 +- engine/apps/api/serializers/schedule_web.py | 46 ++++++ engine/apps/api/tests/test_schedules.py | 125 +++++++++++++++-- .../public_api/serializers/on_call_shifts.py | 31 +++- .../serializers/schedules_polymorphic.py | 7 +- .../public_api/serializers/schedules_web.py | 96 +++++++++++++ .../public_api/tests/test_on_call_shifts.py | 71 +++++++++- .../apps/public_api/tests/test_schedules.py | 132 +++++++++++++++++- .../migrations/0005_auto_20220704_1947.py | 36 +++++ engine/apps/schedules/models/__init__.py | 7 +- .../schedules/models/custom_on_call_shift.py | 16 ++- .../apps/schedules/models/on_call_schedule.py | 66 +++++++++ engine/apps/schedules/tests/factories.py | 7 +- .../tests/test_custom_on_call_shift.py | 66 ++++++++- .../src/models/schedule/schedule.types.ts | 1 + .../src/pages/schedules/Schedules.tsx | 2 +- 16 files changed, 691 insertions(+), 26 deletions(-) create mode 100644 engine/apps/api/serializers/schedule_web.py create mode 100644 engine/apps/public_api/serializers/schedules_web.py create mode 100644 engine/apps/schedules/migrations/0005_auto_20220704_1947.py diff --git a/engine/apps/api/serializers/schedule_polymorphic.py b/engine/apps/api/serializers/schedule_polymorphic.py index b9d404ed77..9da806115d 100644 --- a/engine/apps/api/serializers/schedule_polymorphic.py +++ b/engine/apps/api/serializers/schedule_polymorphic.py @@ -6,7 +6,8 @@ ScheduleICalSerializer, ScheduleICalUpdateSerializer, ) -from apps.schedules.models import OnCallScheduleCalendar, OnCallScheduleICal +from apps.api.serializers.schedule_web import ScheduleWebCreateSerializer, ScheduleWebSerializer +from apps.schedules.models import OnCallScheduleCalendar, OnCallScheduleICal, OnCallScheduleWeb from common.api_helpers.mixins import EagerLoadingMixin @@ -18,9 +19,10 @@ class PolymorphicScheduleSerializer(EagerLoadingMixin, PolymorphicSerializer): model_serializer_mapping = { OnCallScheduleICal: ScheduleICalSerializer, OnCallScheduleCalendar: ScheduleCalendarSerializer, + OnCallScheduleWeb: ScheduleWebSerializer, } - SCHEDULE_CLASS_TO_TYPE = {OnCallScheduleCalendar: 0, OnCallScheduleICal: 1} + SCHEDULE_CLASS_TO_TYPE = {OnCallScheduleCalendar: 0, OnCallScheduleICal: 1, OnCallScheduleWeb: 2} def to_resource_type(self, model_or_instance): return self.SCHEDULE_CLASS_TO_TYPE.get(model_or_instance._meta.model) @@ -31,6 +33,7 @@ class PolymorphicScheduleCreateSerializer(PolymorphicScheduleSerializer): model_serializer_mapping = { OnCallScheduleICal: ScheduleICalCreateSerializer, OnCallScheduleCalendar: ScheduleCalendarCreateSerializer, + OnCallScheduleWeb: ScheduleWebCreateSerializer, } @@ -39,4 +42,5 @@ class PolymorphicScheduleUpdateSerializer(PolymorphicScheduleSerializer): OnCallScheduleICal: ScheduleICalUpdateSerializer, # There is no difference between create and Update serializers for ScheduleCalendar OnCallScheduleCalendar: ScheduleCalendarCreateSerializer, + OnCallScheduleWeb: ScheduleWebCreateSerializer, } diff --git a/engine/apps/api/serializers/schedule_web.py b/engine/apps/api/serializers/schedule_web.py new file mode 100644 index 0000000000..fadc8b4bd7 --- /dev/null +++ b/engine/apps/api/serializers/schedule_web.py @@ -0,0 +1,46 @@ +from rest_framework import serializers + +from apps.api.serializers.schedule_base import ScheduleBaseSerializer +from apps.schedules.models import OnCallScheduleWeb +from apps.schedules.tasks import schedule_notify_about_empty_shifts_in_schedule, schedule_notify_about_gaps_in_schedule +from apps.slack.models import SlackChannel, SlackUserGroup +from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField + + +class ScheduleWebSerializer(ScheduleBaseSerializer): + time_zone = serializers.CharField(required=False) + + class Meta: + model = OnCallScheduleWeb + fields = [*ScheduleBaseSerializer.Meta.fields, "slack_channel", "time_zone"] + + +class ScheduleWebCreateSerializer(ScheduleWebSerializer): + slack_channel_id = OrganizationFilteredPrimaryKeyRelatedField( + filter_field="slack_team_identity__organizations", + queryset=SlackChannel.objects, + required=False, + allow_null=True, + ) + user_group = OrganizationFilteredPrimaryKeyRelatedField( + filter_field="slack_team_identity__organizations", + queryset=SlackUserGroup.objects, + required=False, + allow_null=True, + ) + + class Meta(ScheduleWebSerializer.Meta): + fields = [*ScheduleBaseSerializer.Meta.fields, "slack_channel_id", "time_zone"] + + def update(self, instance, validated_data): + updated_schedule = super().update(instance, validated_data) + + old_time_zone = instance.time_zone + updated_time_zone = updated_schedule.time_zone + if old_time_zone != updated_time_zone: + updated_schedule.drop_cached_ical() + updated_schedule.check_empty_shifts_for_next_week() + updated_schedule.check_gaps_for_next_week() + schedule_notify_about_empty_shifts_in_schedule.apply_async((instance.pk,)) + schedule_notify_about_gaps_in_schedule.apply_async((instance.pk,)) + return updated_schedule diff --git a/engine/apps/api/tests/test_schedules.py b/engine/apps/api/tests/test_schedules.py index 29a8c530a5..71ad67ae97 100644 --- a/engine/apps/api/tests/test_schedules.py +++ b/engine/apps/api/tests/test_schedules.py @@ -9,7 +9,13 @@ from rest_framework.serializers import ValidationError from rest_framework.test import APIClient -from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleCalendar, OnCallScheduleICal +from apps.schedules.models import ( + CustomOnCallShift, + OnCallSchedule, + OnCallScheduleCalendar, + OnCallScheduleICal, + OnCallScheduleWeb, +) from common.constants.role import Role ICAL_URL = "https://calendar.google.com/calendar/ical/amixr.io_37gttuakhrtr75ano72p69rt78%40group.calendar.google.com/private-1d00a680ba5be7426c3eb3ef1616e26d/basic.ics" @@ -41,12 +47,18 @@ def schedule_internal_api_setup( ical_url_primary=ICAL_URL, ) - return user, token, calendar_schedule, ical_schedule, slack_channel + web_schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + + return user, token, calendar_schedule, ical_schedule, web_schedule, slack_channel @pytest.mark.django_db def test_get_list_schedules(schedule_internal_api_setup, make_user_auth_headers): - user, token, calendar_schedule, ical_schedule, slack_channel = schedule_internal_api_setup + user, token, calendar_schedule, ical_schedule, web_schedule, slack_channel = schedule_internal_api_setup client = APIClient() url = reverse("api-internal:schedule-list") @@ -85,6 +97,22 @@ def test_get_list_schedules(schedule_internal_api_setup, make_user_auth_headers) "notify_empty_oncall": 0, "notify_oncall_shift_freq": 1, }, + { + "id": web_schedule.public_primary_key, + "type": 2, + "time_zone": "UTC", + "team": None, + "name": "test_web_schedule", + "slack_channel": None, + "user_group": None, + "warnings": [], + "on_call_now": [], + "has_gaps": False, + "mention_oncall_next": False, + "mention_oncall_start": True, + "notify_empty_oncall": 0, + "notify_oncall_shift_freq": 1, + }, ] response = client.get(url, format="json", **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK @@ -93,7 +121,7 @@ def test_get_list_schedules(schedule_internal_api_setup, make_user_auth_headers) @pytest.mark.django_db def test_get_detail_calendar_schedule(schedule_internal_api_setup, make_user_auth_headers): - user, token, calendar_schedule, _, _ = schedule_internal_api_setup + user, token, calendar_schedule, _, _, _ = schedule_internal_api_setup client = APIClient() url = reverse("api-internal:schedule-detail", kwargs={"pk": calendar_schedule.public_primary_key}) @@ -122,7 +150,7 @@ def test_get_detail_calendar_schedule(schedule_internal_api_setup, make_user_aut @pytest.mark.django_db def test_get_detail_ical_schedule(schedule_internal_api_setup, make_user_auth_headers): - user, token, _, ical_schedule, _ = schedule_internal_api_setup + user, token, _, ical_schedule, _, _ = schedule_internal_api_setup client = APIClient() url = reverse("api-internal:schedule-detail", kwargs={"pk": ical_schedule.public_primary_key}) @@ -149,9 +177,37 @@ def test_get_detail_ical_schedule(schedule_internal_api_setup, make_user_auth_he assert response.data == expected_payload +@pytest.mark.django_db +def test_get_detail_web_schedule(schedule_internal_api_setup, make_user_auth_headers): + user, token, _, _, web_schedule, _ = schedule_internal_api_setup + client = APIClient() + url = reverse("api-internal:schedule-detail", kwargs={"pk": web_schedule.public_primary_key}) + + expected_payload = { + "id": web_schedule.public_primary_key, + "team": None, + "name": "test_web_schedule", + "type": 2, + "time_zone": "UTC", + "slack_channel": None, + "user_group": None, + "warnings": [], + "on_call_now": [], + "has_gaps": False, + "mention_oncall_next": False, + "mention_oncall_start": True, + "notify_empty_oncall": 0, + "notify_oncall_shift_freq": 1, + } + + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + assert response.data == expected_payload + + @pytest.mark.django_db def test_create_calendar_schedule(schedule_internal_api_setup, make_user_auth_headers): - user, token, _, _, _ = schedule_internal_api_setup + user, token, _, _, _, _ = schedule_internal_api_setup client = APIClient() url = reverse("api-internal:schedule-list") data = { @@ -180,7 +236,7 @@ def test_create_calendar_schedule(schedule_internal_api_setup, make_user_auth_he @pytest.mark.django_db def test_create_ical_schedule(schedule_internal_api_setup, make_user_auth_headers): - user, token, _, _, _ = schedule_internal_api_setup + user, token, _, _, _, _ = schedule_internal_api_setup client = APIClient() url = reverse("api-internal:schedule-list") with patch( @@ -210,9 +266,37 @@ def test_create_ical_schedule(schedule_internal_api_setup, make_user_auth_header assert response.data == data +@pytest.mark.django_db +def test_create_web_schedule(schedule_internal_api_setup, make_user_auth_headers): + user, token, _, _, _, _ = schedule_internal_api_setup + client = APIClient() + url = reverse("api-internal:schedule-list") + data = { + "name": "created_web_schedule", + "type": 2, + "time_zone": "UTC", + "slack_channel_id": None, + "user_group": None, + "team": None, + "warnings": [], + "on_call_now": [], + "has_gaps": False, + "mention_oncall_next": False, + "mention_oncall_start": True, + "notify_empty_oncall": 0, + "notify_oncall_shift_freq": 1, + } + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + # modify initial data by adding id and None for optional fields + schedule = OnCallSchedule.objects.get(public_primary_key=response.data["id"]) + data["id"] = schedule.public_primary_key + assert response.status_code == status.HTTP_201_CREATED + assert response.data == data + + @pytest.mark.django_db def test_create_invalid_ical_schedule(schedule_internal_api_setup, make_user_auth_headers): - user, token, _, ical_schedule, _ = schedule_internal_api_setup + user, token, _, ical_schedule, _, _ = schedule_internal_api_setup client = APIClient() url = reverse("api-internal:custom_button-list") with patch( @@ -231,7 +315,7 @@ def test_create_invalid_ical_schedule(schedule_internal_api_setup, make_user_aut @pytest.mark.django_db def test_update_calendar_schedule(schedule_internal_api_setup, make_user_auth_headers): - user, token, calendar_schedule, _, _ = schedule_internal_api_setup + user, token, calendar_schedule, _, _, _ = schedule_internal_api_setup client = APIClient() url = reverse("api-internal:schedule-detail", kwargs={"pk": calendar_schedule.public_primary_key}) @@ -250,7 +334,7 @@ def test_update_calendar_schedule(schedule_internal_api_setup, make_user_auth_he @pytest.mark.django_db def test_update_ical_schedule(schedule_internal_api_setup, make_user_auth_headers): - user, token, _, ical_schedule, _ = schedule_internal_api_setup + user, token, _, ical_schedule, _, _ = schedule_internal_api_setup client = APIClient() url = reverse("api-internal:schedule-detail", kwargs={"pk": ical_schedule.public_primary_key}) @@ -267,9 +351,28 @@ def test_update_ical_schedule(schedule_internal_api_setup, make_user_auth_header assert updated_instance.name == "updated_ical_schedule" +@pytest.mark.django_db +def test_update_web_schedule(schedule_internal_api_setup, make_user_auth_headers): + user, token, _, _, web_schedule, _ = schedule_internal_api_setup + client = APIClient() + + url = reverse("api-internal:schedule-detail", kwargs={"pk": web_schedule.public_primary_key}) + data = { + "name": "updated_web_schedule", + "type": 2, + "team": None, + } + response = client.put( + url, data=json.dumps(data), content_type="application/json", **make_user_auth_headers(user, token) + ) + updated_instance = OnCallSchedule.objects.get(public_primary_key=web_schedule.public_primary_key) + assert response.status_code == status.HTTP_200_OK + assert updated_instance.name == "updated_web_schedule" + + @pytest.mark.django_db def test_delete_schedule(schedule_internal_api_setup, make_user_auth_headers): - user, token, calendar_schedule, ical_schedule, _ = schedule_internal_api_setup + user, token, calendar_schedule, ical_schedule, _, _ = schedule_internal_api_setup client = APIClient() for calendar in (calendar_schedule, ical_schedule): diff --git a/engine/apps/public_api/serializers/on_call_shifts.py b/engine/apps/public_api/serializers/on_call_shifts.py index 97f4f34e29..1fd85aa656 100644 --- a/engine/apps/public_api/serializers/on_call_shifts.py +++ b/engine/apps/public_api/serializers/on_call_shifts.py @@ -196,7 +196,7 @@ def validate_rolling_users(self, rolling_users): return result def _validate_frequency_and_week_start(self, event_type, frequency, week_start): - if event_type != CustomOnCallShift.TYPE_SINGLE_EVENT: + if event_type not in (CustomOnCallShift.TYPE_SINGLE_EVENT, CustomOnCallShift.TYPE_OVERRIDE): if frequency is None: raise BadRequest(detail="Field 'frequency' is required for this on-call shift type") elif frequency == CustomOnCallShift.FREQUENCY_WEEKLY and week_start is None: @@ -266,6 +266,18 @@ def _get_fields_to_represent(self, instance, result): ], CustomOnCallShift.TYPE_RECURRENT_EVENT: ["rolling_users", "start_rotation_from_user_index"], CustomOnCallShift.TYPE_ROLLING_USERS_EVENT: ["users"], + CustomOnCallShift.TYPE_OVERRIDE: [ + "level", + "frequency", + "interval", + "until", + "by_day", + "by_month", + "by_monthday", + "week_start", + "rolling_users", + "start_rotation_from_user_index", + ], } for field in fields_to_remove_map[event_type]: result.pop(field, None) @@ -289,9 +301,24 @@ def _correct_validated_data(self, event_type, validated_data): ], CustomOnCallShift.TYPE_RECURRENT_EVENT: ["rolling_users", "start_rotation_from_user_index"], CustomOnCallShift.TYPE_ROLLING_USERS_EVENT: ["users"], + CustomOnCallShift.TYPE_OVERRIDE: [ + "priority_level", + "frequency", + "interval", + "by_day", + "by_month", + "by_monthday", + "rolling_users", + "start_rotation_from_user_index", + ], } for field in fields_to_update_map[event_type]: - validated_data[field] = None if field != "users" else [] + value = None + if field == "users": + value = [] + elif field == "priority_level": + value = 0 + validated_data[field] = value validated_data_list_fields = ["by_day", "by_month", "by_monthday", "rolling_users"] diff --git a/engine/apps/public_api/serializers/schedules_polymorphic.py b/engine/apps/public_api/serializers/schedules_polymorphic.py index 54ed910498..4445fdc35f 100644 --- a/engine/apps/public_api/serializers/schedules_polymorphic.py +++ b/engine/apps/public_api/serializers/schedules_polymorphic.py @@ -3,7 +3,8 @@ from apps.public_api.serializers.schedules_calendar import ScheduleCalendarSerializer, ScheduleCalendarUpdateSerializer from apps.public_api.serializers.schedules_ical import ScheduleICalSerializer, ScheduleICalUpdateSerializer -from apps.schedules.models import OnCallScheduleCalendar, OnCallScheduleICal +from apps.public_api.serializers.schedules_web import ScheduleWebSerializer, ScheduleWebUpdateSerializer +from apps.schedules.models import OnCallScheduleCalendar, OnCallScheduleICal, OnCallScheduleWeb from common.api_helpers.mixins import EagerLoadingMixin @@ -15,9 +16,10 @@ class PolymorphicScheduleSerializer(EagerLoadingMixin, PolymorphicSerializer): model_serializer_mapping = { OnCallScheduleICal: ScheduleICalSerializer, OnCallScheduleCalendar: ScheduleCalendarSerializer, + OnCallScheduleWeb: ScheduleWebSerializer, } - SCHEDULE_CLASS_TO_TYPE = {OnCallScheduleCalendar: "calendar", OnCallScheduleICal: "ical"} + SCHEDULE_CLASS_TO_TYPE = {OnCallScheduleCalendar: "calendar", OnCallScheduleICal: "ical", OnCallScheduleWeb: "web"} def to_resource_type(self, model_or_instance): return self.SCHEDULE_CLASS_TO_TYPE.get(model_or_instance._meta.model) @@ -27,6 +29,7 @@ class PolymorphicScheduleUpdateSerializer(PolymorphicScheduleSerializer): model_serializer_mapping = { OnCallScheduleICal: ScheduleICalUpdateSerializer, OnCallScheduleCalendar: ScheduleCalendarUpdateSerializer, + OnCallScheduleWeb: ScheduleWebUpdateSerializer, } def update(self, instance, validated_data): diff --git a/engine/apps/public_api/serializers/schedules_web.py b/engine/apps/public_api/serializers/schedules_web.py new file mode 100644 index 0000000000..2ed1eed8fe --- /dev/null +++ b/engine/apps/public_api/serializers/schedules_web.py @@ -0,0 +1,96 @@ +import pytz +from django.utils import timezone +from rest_framework import serializers + +from apps.public_api.serializers.schedules_base import ScheduleBaseSerializer +from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb +from apps.schedules.tasks import ( + drop_cached_ical_task, + schedule_notify_about_empty_shifts_in_schedule, + schedule_notify_about_gaps_in_schedule, +) +from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField, UsersFilteredByOrganizationField +from common.api_helpers.exceptions import BadRequest + + +class ScheduleWebSerializer(ScheduleBaseSerializer): + time_zone = serializers.CharField(required=True) + shifts = UsersFilteredByOrganizationField( + queryset=CustomOnCallShift.objects, + required=False, + source="custom_shifts", + ) + + class Meta: + model = OnCallScheduleWeb + fields = [ + "id", + "team_id", + "name", + "time_zone", + "slack", + "on_call_now", + "shifts", + ] + + def validate_time_zone(self, tz): + try: + timezone.now().astimezone(pytz.timezone(tz)) + except pytz.exceptions.UnknownTimeZoneError: + raise BadRequest(detail="Invalid time zone") + return tz + + def validate_shifts(self, shifts): + # Get team_id from instance, if it exists, otherwise get it from initial data. + # Handle empty string instead of None. In this case change team_id value to None. + team_id = self.instance.team_id if self.instance else (self.initial_data.get("team_id") or None) + for shift in shifts: + if shift.team_id != team_id: + raise BadRequest(detail="Shifts must be assigned to the same team as the schedule") + + return shifts + + def to_internal_value(self, data): + if data.get("shifts", []) is None: # handle a None value + data["shifts"] = [] + result = super().to_internal_value(data) + return result + + +class ScheduleWebUpdateSerializer(ScheduleWebSerializer): + time_zone = serializers.CharField(required=False) + team_id = TeamPrimaryKeyRelatedField(read_only=True, source="team") + + class Meta: + model = OnCallScheduleWeb + fields = [ + "id", + "team_id", + "name", + "time_zone", + "slack", + "on_call_now", + "shifts", + ] + extra_kwargs = { + "name": {"required": False}, + } + + def update(self, instance, validated_data): + validated_data = self._correct_validated_data(validated_data) + new_time_zone = validated_data.get("time_zone", instance.time_zone) + new_shifts = validated_data.get("shifts", []) + existing_shifts = instance.custom_shifts.all() + + ical_changed = False + + if new_time_zone != instance.time_zone or set(existing_shifts) != set(new_shifts): + ical_changed = True + + if ical_changed: + drop_cached_ical_task.apply_async( + (instance.pk,), + ) + schedule_notify_about_empty_shifts_in_schedule.apply_async((instance.pk,)) + schedule_notify_about_gaps_in_schedule.apply_async((instance.pk,)) + return super().update(instance, validated_data) diff --git a/engine/apps/public_api/tests/test_on_call_shifts.py b/engine/apps/public_api/tests/test_on_call_shifts.py index 3910cb9434..8311c00e2e 100644 --- a/engine/apps/public_api/tests/test_on_call_shifts.py +++ b/engine/apps/public_api/tests/test_on_call_shifts.py @@ -5,7 +5,7 @@ from rest_framework import status from rest_framework.test import APIClient -from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar +from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar, OnCallScheduleWeb invalid_field_data_1 = { "frequency": None, @@ -76,6 +76,39 @@ def test_get_on_call_shift(make_organization_and_user_with_token, make_on_call_s assert response.data == result +@pytest.mark.django_db +def test_get_override_on_call_shift(make_organization_and_user_with_token, make_on_call_shift, make_schedule): + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + data = { + "start": datetime.datetime.now().replace(microsecond=0), + "duration": datetime.timedelta(seconds=7200), + "schedule": schedule, + } + on_call_shift = make_on_call_shift(organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **data) + on_call_shift.users.add(user) + + url = reverse("api-public:on_call_shifts-detail", kwargs={"pk": on_call_shift.public_primary_key}) + + response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") + + result = { + "id": on_call_shift.public_primary_key, + "team_id": None, + "name": on_call_shift.name, + "type": "override", + "time_zone": None, + "start": on_call_shift.start.strftime("%Y-%m-%dT%H:%M:%S"), + "duration": int(on_call_shift.duration.total_seconds()), + "users": [user.public_primary_key], + } + + assert response.status_code == status.HTTP_200_OK + assert response.data == result + + @pytest.mark.django_db def test_create_on_call_shift(make_organization_and_user_with_token): @@ -127,6 +160,42 @@ def test_create_on_call_shift(make_organization_and_user_with_token): assert response.data == result +@pytest.mark.django_db +def test_create_override_on_call_shift(make_organization_and_user_with_token): + + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + url = reverse("api-public:on_call_shifts-list") + + start = datetime.datetime.now() + data = { + "team_id": None, + "name": "test name", + "type": "override", + "start": start.strftime("%Y-%m-%dT%H:%M:%S"), + "duration": 10800, + "users": [user.public_primary_key], + } + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + on_call_shift = CustomOnCallShift.objects.get(public_primary_key=response.data["id"]) + + result = { + "id": on_call_shift.public_primary_key, + "team_id": None, + "name": data["name"], + "type": "override", + "time_zone": None, + "start": data["start"], + "duration": data["duration"], + "users": [user.public_primary_key], + } + + assert response.status_code == status.HTTP_201_CREATED + assert response.data == result + + @pytest.mark.django_db def test_update_on_call_shift(make_organization_and_user_with_token, make_on_call_shift, make_schedule): organization, user, token = make_organization_and_user_with_token() diff --git a/engine/apps/public_api/tests/test_schedules.py b/engine/apps/public_api/tests/test_schedules.py index 13777a1091..99411b9f53 100644 --- a/engine/apps/public_api/tests/test_schedules.py +++ b/engine/apps/public_api/tests/test_schedules.py @@ -6,7 +6,13 @@ from rest_framework import status from rest_framework.test import APIClient -from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleCalendar, OnCallScheduleICal +from apps.schedules.models import ( + CustomOnCallShift, + OnCallSchedule, + OnCallScheduleCalendar, + OnCallScheduleICal, + OnCallScheduleWeb, +) ICAL_URL = "https://calendar.google.com/calendar/ical/amixr.io_37gttuakhrtr75ano72p69rt78%40group.calendar.google.com/private-1d00a680ba5be7426c3eb3ef1616e26d/basic.ics" @@ -138,6 +144,130 @@ def test_update_calendar_schedule( assert response.json() == result +@pytest.mark.django_db +def test_get_web_schedule( + make_organization_and_user_with_token, + make_schedule, +): + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + slack_channel_id = "SLACKCHANNELID" + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + channel=slack_channel_id, + ) + + url = reverse("api-public:schedules-detail", kwargs={"pk": schedule.public_primary_key}) + + response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") + + result = { + "id": schedule.public_primary_key, + "team_id": None, + "name": schedule.name, + "type": "web", + "time_zone": "UTC", + "on_call_now": [], + "shifts": [], + "slack": { + "channel_id": "SLACKCHANNELID", + "user_group_id": None, + }, + } + + assert response.status_code == status.HTTP_200_OK + assert response.json() == result + + +@pytest.mark.django_db +def test_create_web_schedule(make_organization_and_user_with_token): + + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + url = reverse("api-public:schedules-list") + + data = { + "team_id": None, + "name": "schedule test name", + "time_zone": "Europe/Moscow", + "type": "web", + } + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + schedule = OnCallSchedule.objects.get(public_primary_key=response.data["id"]) + + result = { + "id": schedule.public_primary_key, + "team_id": None, + "name": schedule.name, + "type": "web", + "time_zone": "Europe/Moscow", + "on_call_now": [], + "shifts": [], + "slack": { + "channel_id": None, + "user_group_id": None, + }, + } + + assert response.status_code == status.HTTP_201_CREATED + assert response.json() == result + + +@pytest.mark.django_db +def test_update_web_schedule( + make_organization_and_user_with_token, + make_schedule, +): + + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + slack_channel_id = "SLACKCHANNELID" + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + channel=slack_channel_id, + ) + + url = reverse("api-public:schedules-detail", kwargs={"pk": schedule.public_primary_key}) + + data = { + "name": "RENAMED", + "time_zone": "Europe/Moscow", + } + + assert schedule.name != data["name"] + assert schedule.time_zone != data["time_zone"] + + response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + + result = { + "id": schedule.public_primary_key, + "team_id": None, + "name": data["name"], + "type": "web", + "time_zone": data["time_zone"], + "on_call_now": [], + "shifts": [], + "slack": { + "channel_id": "SLACKCHANNELID", + "user_group_id": None, + }, + } + + assert response.status_code == status.HTTP_200_OK + schedule.refresh_from_db() + assert schedule.name == data["name"] + assert schedule.time_zone == data["time_zone"] + assert response.json() == result + + @pytest.mark.django_db def test_update_ical_url_overrides_calendar_schedule( make_organization_and_user_with_token, diff --git a/engine/apps/schedules/migrations/0005_auto_20220704_1947.py b/engine/apps/schedules/migrations/0005_auto_20220704_1947.py new file mode 100644 index 0000000000..03b77e602c --- /dev/null +++ b/engine/apps/schedules/migrations/0005_auto_20220704_1947.py @@ -0,0 +1,36 @@ +# Generated by Django 3.2.5 on 2022-07-04 19:47 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('schedules', '0004_customoncallshift_until'), + ] + + operations = [ + migrations.CreateModel( + name='OnCallScheduleWeb', + fields=[ + ('oncallschedule_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='schedules.oncallschedule')), + ('time_zone', models.CharField(default='UTC', max_length=100)), + ], + options={ + 'abstract': False, + 'base_manager_name': 'objects', + }, + bases=('schedules.oncallschedule',), + ), + migrations.AddField( + model_name='customoncallshift', + name='schedule', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='custom_shifts', to='schedules.oncallschedule'), + ), + migrations.AlterField( + model_name='customoncallshift', + name='type', + field=models.IntegerField(choices=[(0, 'Single event'), (1, 'Recurrent event'), (2, 'Rolling users'), (3, 'Override')]), + ), + ] diff --git a/engine/apps/schedules/models/__init__.py b/engine/apps/schedules/models/__init__.py index c5a6279099..9300830a53 100644 --- a/engine/apps/schedules/models/__init__.py +++ b/engine/apps/schedules/models/__init__.py @@ -1,2 +1,7 @@ from .custom_on_call_shift import CustomOnCallShift # noqa: F401 -from .on_call_schedule import OnCallSchedule, OnCallScheduleCalendar, OnCallScheduleICal # noqa: F401 +from .on_call_schedule import ( # noqa: F401 + OnCallSchedule, + OnCallScheduleCalendar, + OnCallScheduleICal, + OnCallScheduleWeb, +) diff --git a/engine/apps/schedules/models/custom_on_call_shift.py b/engine/apps/schedules/models/custom_on_call_shift.py index 7cb72ef595..aa721d2868 100644 --- a/engine/apps/schedules/models/custom_on_call_shift.py +++ b/engine/apps/schedules/models/custom_on_call_shift.py @@ -61,18 +61,21 @@ class CustomOnCallShift(models.Model): TYPE_SINGLE_EVENT, TYPE_RECURRENT_EVENT, TYPE_ROLLING_USERS_EVENT, - ) = range(3) + TYPE_OVERRIDE, + ) = range(4) TYPE_CHOICES = ( (TYPE_SINGLE_EVENT, "Single event"), (TYPE_RECURRENT_EVENT, "Recurrent event"), (TYPE_ROLLING_USERS_EVENT, "Rolling users"), + (TYPE_OVERRIDE, "Override"), ) PUBLIC_TYPE_CHOICES_MAP = { TYPE_SINGLE_EVENT: "single_event", TYPE_RECURRENT_EVENT: "recurrent_event", TYPE_ROLLING_USERS_EVENT: "rolling_users", + TYPE_OVERRIDE: "override", } (MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY) = range(7) @@ -128,6 +131,13 @@ class CustomOnCallShift(models.Model): null=True, default=None, ) + schedule = models.ForeignKey( + "schedules.OnCallSchedule", + on_delete=models.CASCADE, + related_name="custom_shifts", + null=True, + default=None, + ) name = models.CharField(max_length=200) time_zone = models.CharField(max_length=100, null=True, default=None) source = models.IntegerField(choices=SOURCE_CHOICES, default=SOURCE_API) @@ -136,7 +146,7 @@ class CustomOnCallShift(models.Model): start_rotation_from_user_index = models.PositiveIntegerField(null=True, default=None) uuid = models.UUIDField(default=uuid4) # event uuid - type = models.IntegerField(choices=TYPE_CHOICES) # "rolling_users", "recurrent_event", "single_event" + type = models.IntegerField(choices=TYPE_CHOICES) # "rolling_users", "recurrent_event", "single_event", "override" start = models.DateTimeField() # event start datetime duration = models.DurationField() # duration in seconds @@ -191,7 +201,7 @@ def repr_settings_for_client_side_logging(self) -> str: f"source: {self.get_source_display()}, type: {self.get_type_display()}, users: {users_verbal}, " f"start: {self.start.isoformat()}, duration: {self.duration}, priority level: {self.priority_level}" ) - if self.type != CustomOnCallShift.TYPE_SINGLE_EVENT: + if self.type not in (CustomOnCallShift.TYPE_SINGLE_EVENT, CustomOnCallShift.TYPE_OVERRIDE): result += ( f", frequency: {self.get_frequency_display()}, interval: {self.interval}, " f"week start: {self.week_start}, by day: {self.by_day}, by month: {self.by_month}, " diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 51e7b8b888..9e84d8d3bb 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -16,6 +16,7 @@ list_of_gaps_in_schedule, list_users_to_notify_from_ical, ) +from apps.schedules.models import CustomOnCallShift from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length @@ -394,3 +395,68 @@ def repr_settings_for_client_side_logging(self): result = super().repr_settings_for_client_side_logging result += f", overrides calendar url: {self.ical_url_overrides}" return result + + +class OnCallScheduleWeb(OnCallSchedule): + time_zone = models.CharField(max_length=100, default="UTC") + + def _generate_ical_file_from_shifts(self, qs): + """Generate iCal events file from custom on-call shifts.""" + ical = None + if qs.exists(): + end_line = "END:VCALENDAR" + calendar = Calendar() + calendar.add("prodid", "-//web schedule//oncall//") + calendar.add("version", "2.0") + calendar.add("method", "PUBLISH") + ical_file = calendar.to_ical().decode() + ical = ical_file.replace(end_line, "").strip() + ical = f"{ical}\r\n" + for event in qs.all(): + ical += event.convert_to_ical(self.time_zone) + ical += f"{end_line}\r\n" + return ical + + def _generate_ical_file_primary(self): + qs = self.custom_shifts.exclude(type=CustomOnCallShift.TYPE_OVERRIDE) + return self._generate_ical_file_from_shifts(qs) + + def _generate_ical_file_overrides(self): + qs = self.custom_shifts.filter(type=CustomOnCallShift.TYPE_OVERRIDE) + return self._generate_ical_file_from_shifts(qs) + + @cached_property + def _ical_file_primary(self): + """Return cached ical file with iCal events from custom on-call shifts.""" + if self.cached_ical_file_primary is None: + self.cached_ical_file_primary = self._generate_ical_file_primary() + self.save(update_fields=["cached_ical_file_primary"]) + return self.cached_ical_file_primary + + def _refresh_primary_ical_file(self): + self.prev_ical_file_primary = self.cached_ical_file_primary + self.cached_ical_file_primary = self._generate_ical_file_primary() + self.save(update_fields=["cached_ical_file_primary", "prev_ical_file_primary"]) + + def _drop_primary_ical_file(self): + self.prev_ical_file_primary = self.cached_ical_file_primary + self.cached_ical_file_primary = None + self.save(update_fields=["cached_ical_file_primary", "prev_ical_file_primary"]) + + @cached_property + def _ical_file_overrides(self): + """Return cached ical file with iCal events from custom on-call overrides shifts.""" + if self.cached_ical_file_overrides is None: + self.cached_ical_file_overrides = self._generate_ical_file_overrides() + self.save(update_fields=["cached_ical_file_overrides"]) + return self.cached_ical_file_overrides + + def _refresh_overrides_ical_file(self): + self.prev_ical_file_overrides = self.cached_ical_file_overrides + self.cached_ical_file_overrides = self._generate_ical_file_overrides() + self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"]) + + def _drop_overrides_ical_file(self): + self.prev_ical_file_overrides = self.cached_ical_file_overrides + self.cached_ical_file_overrides = None + self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"]) diff --git a/engine/apps/schedules/tests/factories.py b/engine/apps/schedules/tests/factories.py index 50a7e393f1..63c793e7cf 100644 --- a/engine/apps/schedules/tests/factories.py +++ b/engine/apps/schedules/tests/factories.py @@ -1,6 +1,6 @@ import factory -from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar, OnCallScheduleICal +from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar, OnCallScheduleICal, OnCallScheduleWeb from common.utils import UniqueFaker @@ -25,6 +25,11 @@ class Meta: model = OnCallScheduleCalendar +class OnCallScheduleWebFactory(OnCallScheduleFactory): + class Meta: + model = OnCallScheduleWeb + + class CustomOnCallShiftFactory(factory.DjangoModelFactory): name = UniqueFaker("sentence", nb_words=2) diff --git a/engine/apps/schedules/tests/test_custom_on_call_shift.py b/engine/apps/schedules/tests/test_custom_on_call_shift.py index c2fb8a2602..8e507c89a9 100644 --- a/engine/apps/schedules/tests/test_custom_on_call_shift.py +++ b/engine/apps/schedules/tests/test_custom_on_call_shift.py @@ -2,7 +2,7 @@ from django.utils import timezone from apps.schedules.ical_utils import list_users_to_notify_from_ical -from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleCalendar +from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleCalendar, OnCallScheduleWeb @pytest.mark.django_db @@ -32,6 +32,29 @@ def test_get_on_call_users_from_single_event(make_organization_and_user, make_on assert user in users_on_call +@pytest.mark.django_db +def test_get_on_call_users_from_web_schedule_override(make_organization_and_user, make_on_call_shift, make_schedule): + organization, user = make_organization_and_user() + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + date = timezone.now().replace(tzinfo=None, microsecond=0) + + data = { + "start": date, + "duration": timezone.timedelta(seconds=10800), + "schedule": schedule, + } + + on_call_shift = make_on_call_shift(organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **data) + on_call_shift.users.add(user) + + # user is on-call + date = date + timezone.timedelta(minutes=5) + users_on_call = list_users_to_notify_from_ical(schedule, date) + assert len(users_on_call) == 1 + assert user in users_on_call + + @pytest.mark.django_db def test_get_on_call_users_from_recurrent_event(make_organization_and_user, make_on_call_shift, make_schedule): organization, user = make_organization_and_user() @@ -72,6 +95,47 @@ def test_get_on_call_users_from_recurrent_event(make_organization_and_user, make assert user in users_on_call +@pytest.mark.django_db +def test_get_on_call_users_from_web_schedule_recurrent_event( + make_organization_and_user, make_on_call_shift, make_schedule +): + organization, user = make_organization_and_user() + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + date = timezone.now().replace(tzinfo=None, microsecond=0) + + data = { + "priority_level": 1, + "start": date, + "duration": timezone.timedelta(seconds=10800), + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "interval": 2, + "schedule": schedule, + } + + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, **data + ) + on_call_shift.users.add(user) + + # user is on-call + date = date + timezone.timedelta(minutes=5) + users_on_call = list_users_to_notify_from_ical(schedule, date) + assert len(users_on_call) == 1 + assert user in users_on_call + + # user is not on-call according to event recurrence rules (interval = 2) + date = date + timezone.timedelta(days=1) + users_on_call = list_users_to_notify_from_ical(schedule, date) + assert len(users_on_call) == 0 + + # user is on-call again + date = date + timezone.timedelta(days=1) + users_on_call = list_users_to_notify_from_ical(schedule, date) + assert len(users_on_call) == 1 + assert user in users_on_call + + @pytest.mark.django_db def test_get_on_call_users_from_rolling_users_event( make_organization_and_user, make_user_for_organization, make_on_call_shift, make_schedule diff --git a/grafana-plugin/src/models/schedule/schedule.types.ts b/grafana-plugin/src/models/schedule/schedule.types.ts index d22c4bd8a6..567be12ba3 100644 --- a/grafana-plugin/src/models/schedule/schedule.types.ts +++ b/grafana-plugin/src/models/schedule/schedule.types.ts @@ -6,6 +6,7 @@ import { UserGroup } from 'models/user_group/user_group.types'; export enum ScheduleType { 'Calendar', 'Ical', + 'Web', } export interface Schedule { diff --git a/grafana-plugin/src/pages/schedules/Schedules.tsx b/grafana-plugin/src/pages/schedules/Schedules.tsx index d07b39ea43..52501020ce 100644 --- a/grafana-plugin/src/pages/schedules/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules/Schedules.tsx @@ -350,7 +350,7 @@ class SchedulesPage extends React.Component Date: Tue, 5 Jul 2022 15:50:38 -0300 Subject: [PATCH 04/15] Add schedule filter_events plugin API --- engine/apps/api/tests/test_schedules.py | 138 ++++++++++++++++++++++++ engine/apps/api/views/schedule.py | 59 ++++++++-- engine/apps/schedules/ical_utils.py | 36 ++++--- 3 files changed, 209 insertions(+), 24 deletions(-) diff --git a/engine/apps/api/tests/test_schedules.py b/engine/apps/api/tests/test_schedules.py index 71ad67ae97..b0e61d5487 100644 --- a/engine/apps/api/tests/test_schedules.py +++ b/engine/apps/api/tests/test_schedules.py @@ -429,6 +429,144 @@ def test_events_calendar( "calendar_type": OnCallSchedule.PRIMARY, "is_empty": False, "is_gap": False, + "shift_uuid": str(on_call_shift.uuid), + } + ], + } + assert response.status_code == status.HTTP_200_OK + assert response.data == expected_result + + +@pytest.mark.django_db +def test_filter_events_calendar( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_schedule, + make_on_call_shift, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + + now = timezone.now().replace(microsecond=0) + start_date = now - timezone.timedelta(days=7) + data = { + "start": start_date, + "duration": timezone.timedelta(seconds=7200), + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_WEEKLY, + "by_day": ["MO", "FR"], + "schedule": schedule, + } + + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, **data + ) + on_call_shift.users.add(user) + + url = reverse("api-internal:schedule-filter-events", kwargs={"pk": schedule.public_primary_key}) + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + + # current week events are expected + mon_start = now - timezone.timedelta(days=start_date.weekday()) + fri_start = mon_start + timezone.timedelta(days=4) + expected_result = { + "id": schedule.public_primary_key, + "name": "test_web_schedule", + "type": 2, + "events": [ + { + "all_day": False, + "start": mon_start, + "end": mon_start + on_call_shift.duration, + "users": [{"display_name": user.username, "pk": user.public_primary_key}], + "priority_level": on_call_shift.priority_level, + "source": "api", + "calendar_type": OnCallSchedule.PRIMARY, + "is_empty": False, + "is_gap": False, + "shift_uuid": str(on_call_shift.uuid), + }, + { + "all_day": False, + "start": fri_start, + "end": fri_start + on_call_shift.duration, + "users": [{"display_name": user.username, "pk": user.public_primary_key}], + "priority_level": on_call_shift.priority_level, + "source": "api", + "calendar_type": OnCallSchedule.PRIMARY, + "is_empty": False, + "is_gap": False, + "shift_uuid": str(on_call_shift.uuid), + }, + ], + } + assert response.status_code == status.HTTP_200_OK + assert response.data == expected_result + + +@pytest.mark.django_db +def test_filter_events_range_calendar( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_schedule, + make_on_call_shift, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + + now = timezone.now().replace(microsecond=0) + start_date = now - timezone.timedelta(days=7) + data = { + "start": start_date, + "duration": timezone.timedelta(seconds=7200), + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_WEEKLY, + "by_day": ["MO", "FR"], + "schedule": schedule, + } + + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, **data + ) + on_call_shift.users.add(user) + + mon_start = now - timezone.timedelta(days=start_date.weekday()) + request_date = mon_start + timezone.timedelta(days=2) + + url = reverse("api-internal:schedule-filter-events", kwargs={"pk": schedule.public_primary_key}) + url += "?date={}&days=3".format(request_date.strftime("%Y-%m-%d")) + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + + # only friday occurrence is expected + fri_start = mon_start + timezone.timedelta(days=4) + expected_result = { + "id": schedule.public_primary_key, + "name": "test_web_schedule", + "type": 2, + "events": [ + { + "all_day": False, + "start": fri_start, + "end": fri_start + on_call_shift.duration, + "users": [{"display_name": user.username, "pk": user.public_primary_key}], + "priority_level": on_call_shift.priority_level, + "source": "api", + "calendar_type": OnCallSchedule.PRIMARY, + "is_empty": False, + "is_gap": False, + "shift_uuid": str(on_call_shift.uuid), } ], } diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index d27676d4d9..819950eb5a 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -54,6 +54,7 @@ class ScheduleView( AnyRole: ( *READ_ACTIONS, "events", + "filter_events", "notify_empty_oncall_options", "notify_oncall_shift_freq_options", "mention_options", @@ -190,14 +191,11 @@ def get_request_timezone(self): return user_tz, date - @action(detail=True, methods=["get"]) - def events(self, request, pk): - user_tz, date = self.get_request_timezone() - with_empty = self.request.query_params.get("with_empty", False) == "true" - with_gap = self.request.query_params.get("with_gap", False) == "true" - schedule = self.original_get_object() - shifts = list_of_oncall_shifts_from_ical(schedule, date, user_tz, with_empty, with_gap) or [] - events_result = [] + def _filter_events(self, schedule, timezone, starting_date, days, with_empty, with_gap): + shifts = ( + list_of_oncall_shifts_from_ical(schedule, starting_date, timezone, with_empty, with_gap, days=days) or [] + ) + events = [] # for start, end, users, priority_level, source in shifts: for shift in shifts: all_day = type(shift["start"]) == datetime.date @@ -219,8 +217,20 @@ def events(self, request, pk): "calendar_type": shift["calendar_type"], "is_empty": len(shift["users"]) == 0 and not is_gap, "is_gap": is_gap, + "shift_uuid": shift["shift_uuid"], } - events_result.append(shift_json) + events.append(shift_json) + + return events + + @action(detail=True, methods=["get"]) + def events(self, request, pk): + user_tz, date = self.get_request_timezone() + with_empty = self.request.query_params.get("with_empty", False) == "true" + with_gap = self.request.query_params.get("with_gap", False) == "true" + + schedule = self.original_get_object() + events = self._filter_events(schedule, user_tz, date, days=1, with_empty=with_empty, with_gap=with_gap) slack_channel = ( { @@ -237,7 +247,36 @@ def events(self, request, pk): "name": schedule.name, "type": PolymorphicScheduleSerializer().to_resource_type(schedule), "slack_channel": slack_channel, - "events": events_result, + "events": events, + } + return Response(result, status=status.HTTP_200_OK) + + @action(detail=True, methods=["get"]) + def filter_events(self, request, pk): + user_tz, date = self.get_request_timezone() + with_empty = self.request.query_params.get("with_empty", False) == "true" + with_gap = self.request.query_params.get("with_gap", False) == "true" + + starting_date = date if self.request.query_params.get("date") else None + if starting_date is None: + # default to current week start + starting_date = date - datetime.timedelta(days=date.weekday()) + + try: + days = int(self.request.query_params.get("days", 7)) # fallback to a week + except ValueError: + raise BadRequest(detail="Invalid days format") + + schedule = self.original_get_object() + events = self._filter_events( + schedule, user_tz, starting_date, days=days, with_empty=with_empty, with_gap=with_gap + ) + + result = { + "id": schedule.public_primary_key, + "name": schedule.name, + "type": PolymorphicScheduleSerializer().to_resource_type(schedule), + "events": events, } return Response(result, status=status.HTTP_200_OK) diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index cb0bc342ec..8bd1c967f1 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -75,12 +75,15 @@ def memoized_users_in_ical(usernames_from_ical, organization): ICAL_ATTENDEE = "ATTENDEE" ICAL_UID = "UID" RE_PRIORITY = re.compile(r"^\[L(\d)\]") +RE_EVENT_UID = re.compile(r"amixr-([\w\d-]+)-U(\d+)-E(\d+)-S(\d+)") logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) # used for display schedule events on web -def list_of_oncall_shifts_from_ical(schedule, date, user_timezone="UTC", with_empty_shifts=False, with_gaps=False): +def list_of_oncall_shifts_from_ical( + schedule, date, user_timezone="UTC", with_empty_shifts=False, with_gaps=False, days=1 +): """ Parse the ical file and return list of events with users This function is used in serializer for api schedules/events/ endpoint @@ -106,7 +109,7 @@ def list_of_oncall_shifts_from_ical(schedule, date, user_timezone="UTC", with_em user_timezone_offset = timezone.datetime.now().astimezone(pytz.timezone(user_timezone)).utcoffset() datetime_min = timezone.datetime.combine(date, datetime.time.min) + timezone.timedelta(milliseconds=1) datetime_start = (datetime_min - user_timezone_offset).astimezone(pytz.UTC) - datetime_end = datetime_start + timezone.timedelta(hours=23, minutes=59, seconds=59) + datetime_end = datetime_start + timezone.timedelta(days=days - 1, hours=23, minutes=59, seconds=59) result_datetime = [] result_date = [] @@ -137,6 +140,7 @@ def list_of_oncall_shifts_from_ical(schedule, date, user_timezone="UTC", with_em "source": None, "calendar_type": None, "is_gap": True, + "shift_uuid": None, } ) result = sorted(result_datetime, key=lambda dt: dt["start"]) + result_date @@ -150,7 +154,7 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ result_date = [] for event in events: priority = parse_priority_from_string(event.get(ICAL_SUMMARY, "[L0]")) - source = parse_source_from_string(event.get(ICAL_UID)) + uuid, source = parse_event_uid(event.get(ICAL_UID)) users = get_users_from_ical_event(event, schedule.organization) # Define on-call shift out of ical event that has the actual user if len(users) > 0 or with_empty_shifts: @@ -166,6 +170,7 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ "priority": priority, "source": source, "calendar_type": calendar_type, + "shift_uuid": uuid, } ) else: @@ -180,13 +185,15 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ "priority": priority, "source": source, "calendar_type": calendar_type, + "shift_uuid": uuid, } ) return result_datetime, result_date EmptyShift = namedtuple( - "EmptyShift", ["start", "end", "summary", "description", "attendee", "all_day", "calendar_type", "calendar_tz"] + "EmptyShift", + ["start", "end", "summary", "description", "attendee", "all_day", "calendar_type", "calendar_tz", "shift_uuid"], ) @@ -230,6 +237,7 @@ def list_of_empty_shifts_in_schedule(schedule, start_date, end_date): summary = event.get(ICAL_SUMMARY, "") description = event.get(ICAL_DESCRIPTION, "") attendee = event.get(ICAL_ATTENDEE, "") + uuid, _ = parse_event_uid(event.get(ICAL_UID)) event_hash = hash(f"{event[ICAL_UID]}{summary}{description}{attendee}") if event_hash in checked_events: @@ -257,6 +265,7 @@ def list_of_empty_shifts_in_schedule(schedule, start_date, end_date): all_day=all_day, calendar_type=calendar_type, calendar_tz=calendar_tz, + shift_uuid=uuid, ) ) empty_shifts.extend(empty_shifts_per_calendar) @@ -330,17 +339,16 @@ def parse_priority_from_string(string): return priority -def parse_source_from_string(string): - CustomOnCallShift = apps.get_model("schedules", "CustomOnCallShift") - split_string = string.split("-") +def parse_event_uid(string): + uuid = None source_verbal = None - if len(split_string) >= 2 and split_string[0] == "amixr": - regex = re.compile(r"^S(\d)$") - source = re.findall(regex, split_string[-1]) - if len(source) > 0: - source = int(source[0]) - source_verbal = CustomOnCallShift.SOURCE_CHOICES[source][1] - return source_verbal + match = RE_EVENT_UID.match(string) + if match: + uuid, _, _, source = match.groups() + source = int(source) + CustomOnCallShift = apps.get_model("schedules", "CustomOnCallShift") + source_verbal = CustomOnCallShift.SOURCE_CHOICES[source][1] + return uuid, source_verbal def get_usernames_from_ical_event(event): From 87fda3caf67b85b0ca5ffa5da5ee86216cd7ee8f Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Wed, 6 Jul 2022 15:47:21 -0300 Subject: [PATCH 05/15] Use shift public key in events --- engine/apps/api/tests/test_schedules.py | 16 +++-- engine/apps/api/views/schedule.py | 4 +- .../public_api/serializers/schedules_web.py | 3 +- engine/apps/schedules/ical_utils.py | 36 ++++++++---- .../schedules/models/custom_on_call_shift.py | 2 +- .../apps/schedules/models/on_call_schedule.py | 58 ++----------------- .../apps/schedules/tests/test_ical_utils.py | 21 ++++++- 7 files changed, 67 insertions(+), 73 deletions(-) diff --git a/engine/apps/api/tests/test_schedules.py b/engine/apps/api/tests/test_schedules.py index b0e61d5487..c788d521c5 100644 --- a/engine/apps/api/tests/test_schedules.py +++ b/engine/apps/api/tests/test_schedules.py @@ -429,7 +429,9 @@ def test_events_calendar( "calendar_type": OnCallSchedule.PRIMARY, "is_empty": False, "is_gap": False, - "shift_uuid": str(on_call_shift.uuid), + "shift": { + "pk": on_call_shift.public_primary_key, + }, } ], } @@ -490,7 +492,9 @@ def test_filter_events_calendar( "calendar_type": OnCallSchedule.PRIMARY, "is_empty": False, "is_gap": False, - "shift_uuid": str(on_call_shift.uuid), + "shift": { + "pk": on_call_shift.public_primary_key, + }, }, { "all_day": False, @@ -502,7 +506,9 @@ def test_filter_events_calendar( "calendar_type": OnCallSchedule.PRIMARY, "is_empty": False, "is_gap": False, - "shift_uuid": str(on_call_shift.uuid), + "shift": { + "pk": on_call_shift.public_primary_key, + }, }, ], } @@ -566,7 +572,9 @@ def test_filter_events_range_calendar( "calendar_type": OnCallSchedule.PRIMARY, "is_empty": False, "is_gap": False, - "shift_uuid": str(on_call_shift.uuid), + "shift": { + "pk": on_call_shift.public_primary_key, + }, } ], } diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index 819950eb5a..4839ecd836 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -217,7 +217,9 @@ def _filter_events(self, schedule, timezone, starting_date, days, with_empty, wi "calendar_type": shift["calendar_type"], "is_empty": len(shift["users"]) == 0 and not is_gap, "is_gap": is_gap, - "shift_uuid": shift["shift_uuid"], + "shift": { + "pk": shift["shift_pk"], + }, } events.append(shift_json) diff --git a/engine/apps/public_api/serializers/schedules_web.py b/engine/apps/public_api/serializers/schedules_web.py index 2ed1eed8fe..4d9c6b841b 100644 --- a/engine/apps/public_api/serializers/schedules_web.py +++ b/engine/apps/public_api/serializers/schedules_web.py @@ -1,5 +1,4 @@ import pytz -from django.utils import timezone from rest_framework import serializers from apps.public_api.serializers.schedules_base import ScheduleBaseSerializer @@ -35,7 +34,7 @@ class Meta: def validate_time_zone(self, tz): try: - timezone.now().astimezone(pytz.timezone(tz)) + pytz.timezone(tz) except pytz.exceptions.UnknownTimeZoneError: raise BadRequest(detail="Invalid time zone") return tz diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index 8bd1c967f1..65b6d574c7 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -75,7 +75,8 @@ def memoized_users_in_ical(usernames_from_ical, organization): ICAL_ATTENDEE = "ATTENDEE" ICAL_UID = "UID" RE_PRIORITY = re.compile(r"^\[L(\d)\]") -RE_EVENT_UID = re.compile(r"amixr-([\w\d-]+)-U(\d+)-E(\d+)-S(\d+)") +RE_EVENT_UID_V1 = re.compile(r"amixr-([\w\d-]+)-U(\d+)-E(\d+)-S(\d+)") +RE_EVENT_UID_V2 = re.compile(r"oncall-([\w\d-]+)-PK([\w\d]+)-U(\d+)-E(\d+)-S(\d+)") logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @@ -140,7 +141,7 @@ def list_of_oncall_shifts_from_ical( "source": None, "calendar_type": None, "is_gap": True, - "shift_uuid": None, + "shift_pk": None, } ) result = sorted(result_datetime, key=lambda dt: dt["start"]) + result_date @@ -154,7 +155,7 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ result_date = [] for event in events: priority = parse_priority_from_string(event.get(ICAL_SUMMARY, "[L0]")) - uuid, source = parse_event_uid(event.get(ICAL_UID)) + pk, source = parse_event_uid(event.get(ICAL_UID)) users = get_users_from_ical_event(event, schedule.organization) # Define on-call shift out of ical event that has the actual user if len(users) > 0 or with_empty_shifts: @@ -170,7 +171,7 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ "priority": priority, "source": source, "calendar_type": calendar_type, - "shift_uuid": uuid, + "shift_pk": pk, } ) else: @@ -185,7 +186,7 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ "priority": priority, "source": source, "calendar_type": calendar_type, - "shift_uuid": uuid, + "shift_pk": pk, } ) return result_datetime, result_date @@ -193,7 +194,7 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_ EmptyShift = namedtuple( "EmptyShift", - ["start", "end", "summary", "description", "attendee", "all_day", "calendar_type", "calendar_tz", "shift_uuid"], + ["start", "end", "summary", "description", "attendee", "all_day", "calendar_type", "calendar_tz", "shift_pk"], ) @@ -237,7 +238,7 @@ def list_of_empty_shifts_in_schedule(schedule, start_date, end_date): summary = event.get(ICAL_SUMMARY, "") description = event.get(ICAL_DESCRIPTION, "") attendee = event.get(ICAL_ATTENDEE, "") - uuid, _ = parse_event_uid(event.get(ICAL_UID)) + pk, _ = parse_event_uid(event.get(ICAL_UID)) event_hash = hash(f"{event[ICAL_UID]}{summary}{description}{attendee}") if event_hash in checked_events: @@ -265,7 +266,7 @@ def list_of_empty_shifts_in_schedule(schedule, start_date, end_date): all_day=all_day, calendar_type=calendar_type, calendar_tz=calendar_tz, - shift_uuid=uuid, + shift_pk=pk, ) ) empty_shifts.extend(empty_shifts_per_calendar) @@ -340,15 +341,26 @@ def parse_priority_from_string(string): def parse_event_uid(string): - uuid = None + pk = None + source = None source_verbal = None - match = RE_EVENT_UID.match(string) + + match = RE_EVENT_UID_V2.match(string) if match: - uuid, _, _, source = match.groups() + _, pk, _, _, source = match.groups() + else: + # eventually this path would be automatically deprecated + # once all ical representations are refreshed + match = RE_EVENT_UID_V1.match(string) + if match: + _, _, _, source = match.groups() + + if source is not None: source = int(source) CustomOnCallShift = apps.get_model("schedules", "CustomOnCallShift") source_verbal = CustomOnCallShift.SOURCE_CHOICES[source][1] - return uuid, source_verbal + + return pk, source_verbal def get_usernames_from_ical_event(event): diff --git a/engine/apps/schedules/models/custom_on_call_shift.py b/engine/apps/schedules/models/custom_on_call_shift.py index aa721d2868..0f2753da32 100644 --- a/engine/apps/schedules/models/custom_on_call_shift.py +++ b/engine/apps/schedules/models/custom_on_call_shift.py @@ -230,7 +230,7 @@ def convert_to_ical(self, time_zone="UTC"): def generate_ical(self, user, start, user_counter, counter=1, time_zone="UTC"): # create event for each user in a list because we can't parse multiple users from ical summary event = Event() - event["uid"] = f"amixr-{self.uuid}-U{user_counter}-E{counter}-S{self.source}" + event["uid"] = f"oncall-{self.uuid}-PK{self.public_primary_key}-U{user_counter}-E{counter}-S{self.source}" event.add("summary", self.get_summary_with_user_for_ical(user)) event.add("dtstart", self.convert_dt_to_schedule_timezone(start, time_zone)) event.add("dtend", self.convert_dt_to_schedule_timezone(start + self.duration, time_zone)) diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 9e84d8d3bb..05493789ec 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -213,10 +213,14 @@ def _refresh_overrides_ical_file(self): raise NotImplementedError def _drop_primary_ical_file(self): - raise NotImplementedError + self.prev_ical_file_primary = self.cached_ical_file_primary + self.cached_ical_file_primary = None + self.save(update_fields=["cached_ical_file_primary", "prev_ical_file_primary"]) def _drop_overrides_ical_file(self): - raise NotImplementedError + self.prev_ical_file_overrides = self.cached_ical_file_overrides + self.cached_ical_file_overrides = None + self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"]) class OnCallScheduleICal(OnCallSchedule): @@ -255,26 +259,6 @@ def _ical_file_overrides(self): cached_ical_file = self.cached_ical_file_overrides return cached_ical_file - def _drop_primary_ical_file(self): - self.prev_ical_file_primary = self.cached_ical_file_primary - self.cached_ical_file_primary = None - self.save( - update_fields=[ - "cached_ical_file_primary", - "prev_ical_file_primary", - ] - ) - - def _drop_overrides_ical_file(self): - self.prev_ical_file_overrides = self.cached_ical_file_overrides - self.cached_ical_file_overrides = None - self.save( - update_fields=[ - "cached_ical_file_overrides", - "prev_ical_file_overrides", - ] - ) - def _refresh_primary_ical_file(self): self.prev_ical_file_primary = self.cached_ical_file_primary if self.ical_url_primary is not None: @@ -351,26 +335,6 @@ def _refresh_overrides_ical_file(self): ) self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides", "ical_file_error_overrides"]) - def _drop_primary_ical_file(self): - self.prev_ical_file_primary = self.cached_ical_file_primary - self.cached_ical_file_primary = None - self.save( - update_fields=[ - "cached_ical_file_primary", - "prev_ical_file_primary", - ] - ) - - def _drop_overrides_ical_file(self): - self.prev_ical_file_overrides = self.cached_ical_file_overrides - self.cached_ical_file_overrides = None - self.save( - update_fields=[ - "cached_ical_file_overrides", - "prev_ical_file_overrides", - ] - ) - def _generate_ical_file_primary(self): """ Generate iCal events file from custom on-call shifts (created via API) @@ -438,11 +402,6 @@ def _refresh_primary_ical_file(self): self.cached_ical_file_primary = self._generate_ical_file_primary() self.save(update_fields=["cached_ical_file_primary", "prev_ical_file_primary"]) - def _drop_primary_ical_file(self): - self.prev_ical_file_primary = self.cached_ical_file_primary - self.cached_ical_file_primary = None - self.save(update_fields=["cached_ical_file_primary", "prev_ical_file_primary"]) - @cached_property def _ical_file_overrides(self): """Return cached ical file with iCal events from custom on-call overrides shifts.""" @@ -455,8 +414,3 @@ def _refresh_overrides_ical_file(self): self.prev_ical_file_overrides = self.cached_ical_file_overrides self.cached_ical_file_overrides = self._generate_ical_file_overrides() self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"]) - - def _drop_overrides_ical_file(self): - self.prev_ical_file_overrides = self.cached_ical_file_overrides - self.cached_ical_file_overrides = None - self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"]) diff --git a/engine/apps/schedules/tests/test_ical_utils.py b/engine/apps/schedules/tests/test_ical_utils.py index 8032334d80..2a737df2d4 100644 --- a/engine/apps/schedules/tests/test_ical_utils.py +++ b/engine/apps/schedules/tests/test_ical_utils.py @@ -1,7 +1,9 @@ +from uuid import uuid4 + import pytest from django.utils import timezone -from apps.schedules.ical_utils import list_users_to_notify_from_ical, users_in_ical +from apps.schedules.ical_utils import list_users_to_notify_from_ical, parse_event_uid, users_in_ical from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar from common.constants.role import Role @@ -58,3 +60,20 @@ def test_list_users_to_notify_from_ical_viewers_inclusion( else: assert len(users_on_call) == 1 assert set(users_on_call) == {user} + + +def test_parse_event_uid_v1(): + uuid = uuid4() + event_uid = f"amixr-{uuid}-U1-E2-S1" + pk, source = parse_event_uid(event_uid) + assert pk is None + assert source == "api" + + +def test_parse_event_uid_v2(): + uuid = uuid4() + pk_value = "OABCDEF12345" + event_uid = f"oncall-{uuid}-PK{pk_value}-U3-E1-S2" + pk, source = parse_event_uid(event_uid) + assert pk == pk_value + assert source == "slack" From 9ba3f5a9bbc592c6056d8b7f6dd1f0d824e1e102 Mon Sep 17 00:00:00 2001 From: Julia Date: Thu, 7 Jul 2022 16:31:25 +0300 Subject: [PATCH 06/15] Update public endpoint for routes - Add abilities to enable/disable notification in different messangers --- engine/apps/public_api/serializers/routes.py | 49 ++++++++++++-------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/engine/apps/public_api/serializers/routes.py b/engine/apps/public_api/serializers/routes.py index 0b2613faca..b25463313b 100644 --- a/engine/apps/public_api/serializers/routes.py +++ b/engine/apps/public_api/serializers/routes.py @@ -15,8 +15,7 @@ def __init__(self, *args, **kwargs): """Update existing fields of the serializer with messaging backends fields""" super().__init__(*args, **kwargs) - for backend_id, _ in get_messaging_backends(): - backend = get_messaging_backend_from_id(backend_id) + for backend_id, backend in get_messaging_backends(): if backend is None: continue field = backend_id.lower() @@ -25,41 +24,53 @@ def __init__(self, *args, **kwargs): def to_representation(self, instance): result = super().to_representation(instance) - result["slack"] = {"channel_id": instance.slack_channel_id} - result["telegram"] = {"id": instance.telegram_channel.public_primary_key if instance.telegram_channel else None} + result["slack"] = {"channel_id": instance.slack_channel_id, "enabled": instance.notify_in_slack is True} + result["telegram"] = { + "id": instance.telegram_channel.public_primary_key if instance.telegram_channel else None, + "enabled": instance.notify_in_telegram is True, + } # add representation for other messaging backends - for backend_id, _ in get_messaging_backends(): - backend = get_messaging_backend_from_id(backend_id) + for backend_id, backend in get_messaging_backends(): if backend is None: continue field = backend_id.lower() channel_id = None - if instance.notification_backends and instance.notification_backends.get(backend_id, {}).get("channel"): - channel_id = instance.notification_backends[backend_id]["channel"] - result[field] = {"id": channel_id} + notification_enabled = False + if instance.notification_backends and instance.notification_backends.get(backend_id): + channel_id = instance.notification_backends[backend_id].get("channel") + notification_enabled = instance.notification_backends[backend_id].get("enabled") is True + result[field] = {"id": channel_id, "enabled": notification_enabled} return result def _correct_validated_data(self, validated_data): organization = self.context["request"].auth.organization slack_field = validated_data.pop("slack", {}) - if "channel_id" in slack_field: - validated_data["slack_channel_id"] = self._validate_slack_channel_id(slack_field.get("channel_id")) + if slack_field: + if "channel_id" in slack_field: + validated_data["slack_channel_id"] = self._validate_slack_channel_id(slack_field.get("channel_id")) + if "enabled" in slack_field: + validated_data["notify_in_slack"] = slack_field.get("enabled") is True telegram_field = validated_data.pop("telegram", {}) - if "id" in telegram_field: - validated_data["telegram_channel"] = self._validate_telegram_channel(telegram_field.get("id")) + if telegram_field: + if "id" in telegram_field: + validated_data["telegram_channel"] = self._validate_telegram_channel(telegram_field.get("id")) + if "enabled" in telegram_field: + validated_data["notify_in_telegram"] = telegram_field.get("enabled") is True notification_backends = {} - for backend_id, _ in get_messaging_backends(): + for backend_id, backend in get_messaging_backends(): + if backend is None: + continue field = backend_id.lower() backend_field = validated_data.pop(field, {}) if backend_field: - backend = get_messaging_backend_from_id(backend_id) - if backend is None: - continue - channel_id = backend_field.get("id") - notification_backend = {"channel": channel_id, "enabled": True} + notification_backend = {} + if "id" in backend_field: + notification_backend["channel"] = backend_field["id"] + if "enabled" in backend_field: + notification_backend["enabled"] = backend_field["enabled"] backend.validate_channel_filter_data(organization, notification_backend) notification_backends[backend_id] = notification_backend if notification_backends: From 3b53543e735c2ac09f782fb72d06a9ed41b76bac Mon Sep 17 00:00:00 2001 From: Julia Date: Thu, 7 Jul 2022 16:34:01 +0300 Subject: [PATCH 07/15] Update tests for routes in public api --- .../public_api/tests/test_integrations.py | 28 ++- engine/apps/public_api/tests/test_routes.py | 174 ++++++++++++++++-- 2 files changed, 181 insertions(+), 21 deletions(-) diff --git a/engine/apps/public_api/tests/test_integrations.py b/engine/apps/public_api/tests/test_integrations.py index a035241d95..de992a16e5 100644 --- a/engine/apps/public_api/tests/test_integrations.py +++ b/engine/apps/public_api/tests/test_integrations.py @@ -3,6 +3,10 @@ from rest_framework import status from rest_framework.test import APIClient +from apps.base.tests.messaging_backend import TestOnlyBackend + +TEST_MESSAGING_BACKEND_FIELD = TestOnlyBackend.backend_id.lower() + @pytest.mark.django_db def test_get_list_integrations( @@ -31,7 +35,9 @@ def test_get_list_integrations( "default_route": { "escalation_chain_id": None, "id": default_channel_filter.public_primary_key, - "slack": {"channel_id": None}, + "slack": {"channel_id": None, "enabled": True}, + "telegram": {"id": None, "enabled": False}, + TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False}, }, "heartbeat": { "link": f"{integration.integration_url}heartbeat/", @@ -159,7 +165,9 @@ def test_update_integration_template( "default_route": { "escalation_chain_id": None, "id": default_channel_filter.public_primary_key, - "slack": {"channel_id": None}, + "slack": {"channel_id": None, "enabled": True}, + "telegram": {"id": None, "enabled": False}, + TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False}, }, "heartbeat": { "link": f"{integration.integration_url}heartbeat/", @@ -232,7 +240,9 @@ def test_update_resolve_signal_template( "default_route": { "escalation_chain_id": None, "id": default_channel_filter.public_primary_key, - "slack": {"channel_id": None}, + "slack": {"channel_id": None, "enabled": True}, + "telegram": {"id": None, "enabled": False}, + TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False}, }, "heartbeat": { "link": f"{integration.integration_url}heartbeat/", @@ -337,7 +347,9 @@ def test_update_sms_template_with_empty_dict( "default_route": { "escalation_chain_id": None, "id": default_channel_filter.public_primary_key, - "slack": {"channel_id": None}, + "slack": {"channel_id": None, "enabled": True}, + "telegram": {"id": None, "enabled": False}, + TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False}, }, "heartbeat": { "link": f"{integration.integration_url}heartbeat/", @@ -394,7 +406,9 @@ def test_update_integration_name( "default_route": { "escalation_chain_id": None, "id": default_channel_filter.public_primary_key, - "slack": {"channel_id": None}, + "slack": {"channel_id": None, "enabled": True}, + "telegram": {"id": None, "enabled": False}, + TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False}, }, "heartbeat": { "link": f"{integration.integration_url}heartbeat/", @@ -454,7 +468,9 @@ def test_set_default_template( "default_route": { "escalation_chain_id": None, "id": default_channel_filter.public_primary_key, - "slack": {"channel_id": None}, + "slack": {"channel_id": None, "enabled": True}, + "telegram": {"id": None, "enabled": False}, + TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False}, }, "heartbeat": { "link": f"{integration.integration_url}heartbeat/", diff --git a/engine/apps/public_api/tests/test_routes.py b/engine/apps/public_api/tests/test_routes.py index 9acee6ebea..184286c536 100644 --- a/engine/apps/public_api/tests/test_routes.py +++ b/engine/apps/public_api/tests/test_routes.py @@ -7,6 +7,7 @@ from apps.base.tests.messaging_backend import TestOnlyBackend TEST_MESSAGING_BACKEND_FIELD = TestOnlyBackend.backend_id.lower() +TEST_MESSAGING_BACKEND_ID = "TESTBACKENDID" @pytest.fixture() @@ -46,9 +47,9 @@ def test_get_route( "routing_regex": channel_filter.filtering_term, "position": channel_filter.order, "is_the_last_route": channel_filter.is_default, - "slack": {"channel_id": channel_filter.slack_channel_id}, - "telegram": {"id": None}, - TEST_MESSAGING_BACKEND_FIELD: {"id": None}, + "slack": {"channel_id": channel_filter.slack_channel_id, "enabled": True}, + "telegram": {"id": None, "enabled": False}, + TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False}, } assert response.status_code == status.HTTP_200_OK @@ -78,9 +79,9 @@ def test_get_routes_list( "routing_regex": channel_filter.filtering_term, "position": channel_filter.order, "is_the_last_route": channel_filter.is_default, - "slack": {"channel_id": channel_filter.slack_channel_id}, - "telegram": {"id": None}, - TEST_MESSAGING_BACKEND_FIELD: {"id": None}, + "slack": {"channel_id": channel_filter.slack_channel_id, "enabled": True}, + "telegram": {"id": None, "enabled": False}, + TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False}, } ], } @@ -114,9 +115,9 @@ def test_get_routes_filter_by_integration_id( "routing_regex": channel_filter.filtering_term, "position": channel_filter.order, "is_the_last_route": channel_filter.is_default, - "slack": {"channel_id": channel_filter.slack_channel_id}, - "telegram": {"id": None}, - TEST_MESSAGING_BACKEND_FIELD: {"id": None}, + "slack": {"channel_id": channel_filter.slack_channel_id, "enabled": True}, + "telegram": {"id": None, "enabled": False}, + TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False}, } ], } @@ -148,9 +149,9 @@ def test_create_route( "routing_regex": data_for_create["routing_regex"], "position": 0, "is_the_last_route": False, - "slack": {"channel_id": None}, - "telegram": {"id": None}, - TEST_MESSAGING_BACKEND_FIELD: {"id": None}, + "slack": {"channel_id": None, "enabled": True}, + "telegram": {"id": None, "enabled": False}, + TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False}, } assert response.status_code == status.HTTP_201_CREATED @@ -208,9 +209,9 @@ def test_update_route( "routing_regex": data_to_update["routing_regex"], "position": new_channel_filter.order, "is_the_last_route": new_channel_filter.is_default, - "slack": {"channel_id": new_channel_filter.slack_channel_id}, - "telegram": {"id": None}, - TEST_MESSAGING_BACKEND_FIELD: {"id": None}, + "slack": {"channel_id": new_channel_filter.slack_channel_id, "enabled": True}, + "telegram": {"id": None, "enabled": False}, + TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False}, } assert response.status_code == status.HTTP_200_OK @@ -237,3 +238,146 @@ def test_delete_route( assert response.status_code == status.HTTP_204_NO_CONTENT with pytest.raises(ChannelFilter.DoesNotExist): new_channel_filter.refresh_from_db() + + +@pytest.mark.django_db +def test_create_route_with_messaging_backend( + route_public_api_setup, + make_slack_team_identity, + make_slack_channel, +): + organization, _, token, alert_receive_channel, escalation_chain, _ = route_public_api_setup + slack_team_identity = make_slack_team_identity() + organization.slack_team_identity = slack_team_identity + organization.save(update_fields=["slack_team_identity"]) + + slack_id = "TEST_SLACK_ID" + + slack_channel = make_slack_channel(slack_team_identity, slack_id=slack_id) + + client = APIClient() + + url = reverse("api-public:routes-list") + + data_for_create = { + "integration_id": alert_receive_channel.public_primary_key, + "routing_regex": "testreg", + "escalation_chain_id": escalation_chain.public_primary_key, + "slack": {"channel_id": slack_channel.slack_id, "enabled": True}, + "telegram": {"id": None, "enabled": True}, + TEST_MESSAGING_BACKEND_FIELD: {"id": TEST_MESSAGING_BACKEND_ID, "enabled": True}, + } + response = client.post(url, format="json", HTTP_AUTHORIZATION=token, data=data_for_create) + + expected_response = { + "id": response.data["id"], + "integration_id": alert_receive_channel.public_primary_key, + "escalation_chain_id": escalation_chain.public_primary_key, + "routing_regex": data_for_create["routing_regex"], + "position": 0, + "is_the_last_route": False, + "slack": {"channel_id": slack_channel.slack_id, "enabled": True}, + "telegram": {"id": None, "enabled": True}, + TEST_MESSAGING_BACKEND_FIELD: {"id": TEST_MESSAGING_BACKEND_ID, "enabled": True}, + } + + assert response.status_code == status.HTTP_201_CREATED + assert response.json() == expected_response + + +@pytest.mark.django_db +def test_update_route_with_messaging_backend( + route_public_api_setup, + make_channel_filter, + make_slack_team_identity, + make_slack_channel, +): + + organization, _, token, alert_receive_channel, escalation_chain, _ = route_public_api_setup + slack_team_identity = make_slack_team_identity() + organization.slack_team_identity = slack_team_identity + organization.save(update_fields=["slack_team_identity"]) + + slack_id = "TEST_SLACK_ID" + + slack_channel = make_slack_channel(slack_team_identity, slack_id=slack_id) + + new_channel_filter = make_channel_filter( + alert_receive_channel, + is_default=False, + filtering_term="testreg", + escalation_chain=escalation_chain, + ) + + client = APIClient() + + url = reverse("api-public:routes-detail", kwargs={"pk": new_channel_filter.public_primary_key}) + data_to_update = { + "slack": {"channel_id": slack_channel.slack_id, "enabled": False}, + "telegram": {"id": None, "enabled": True}, + TEST_MESSAGING_BACKEND_FIELD: {"id": TEST_MESSAGING_BACKEND_ID}, + } + + # check if route data is different + assert new_channel_filter.slack_channel_id != slack_channel.slack_id + assert new_channel_filter.notify_in_slack != data_to_update["slack"]["enabled"] + assert new_channel_filter.notify_in_telegram != data_to_update["telegram"]["enabled"] + assert new_channel_filter.notification_backends is None + + response = client.put(url, format="json", HTTP_AUTHORIZATION=token, data=data_to_update) + + expected_response = { + "id": response.data["id"], + "integration_id": alert_receive_channel.public_primary_key, + "escalation_chain_id": escalation_chain.public_primary_key, + "routing_regex": new_channel_filter.filtering_term, + "position": 0, + "is_the_last_route": False, + "slack": {"channel_id": slack_channel.slack_id, "enabled": False}, + "telegram": {"id": None, "enabled": True}, + TEST_MESSAGING_BACKEND_FIELD: {"id": TEST_MESSAGING_BACKEND_ID, "enabled": False}, + } + + assert response.status_code == status.HTTP_200_OK + assert response.json() == expected_response + + new_channel_filter.refresh_from_db() + + # check if route data is different was changed correctly + assert new_channel_filter.slack_channel_id == slack_channel.slack_id + assert new_channel_filter.notify_in_slack == data_to_update["slack"]["enabled"] + assert new_channel_filter.notify_in_telegram == data_to_update["telegram"]["enabled"] + assert new_channel_filter.notification_backends == { + TestOnlyBackend.backend_id: {"channel": TEST_MESSAGING_BACKEND_ID} + } + + data_to_update = { + "slack": {"channel_id": None, "enabled": False}, + "telegram": {"id": None, "enabled": False}, + TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": True}, + } + + response = client.put(url, format="json", HTTP_AUTHORIZATION=token, data=data_to_update) + + expected_response = { + "id": response.data["id"], + "integration_id": alert_receive_channel.public_primary_key, + "escalation_chain_id": escalation_chain.public_primary_key, + "routing_regex": new_channel_filter.filtering_term, + "position": 0, + "is_the_last_route": False, + "slack": {"channel_id": None, "enabled": False}, + "telegram": {"id": None, "enabled": False}, + TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": True}, + } + + assert response.status_code == status.HTTP_200_OK + assert response.json() == expected_response + + new_channel_filter.refresh_from_db() + + # check if route data is different was changed correctly + assert new_channel_filter.slack_channel_id == data_to_update["slack"]["channel_id"] + assert new_channel_filter.notify_in_slack == data_to_update["slack"]["enabled"] + assert new_channel_filter.notify_in_telegram == data_to_update["telegram"]["enabled"] + assert new_channel_filter.notification_backends == {TestOnlyBackend.backend_id: {"channel": None, "enabled": True}} From 835fdaa3b7b29c46f82b1d870bcedf2e333d5dca Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Thu, 7 Jul 2022 14:38:22 -0300 Subject: [PATCH 08/15] Do not allow override shifts in calendar schedules --- .../serializers/schedules_calendar.py | 2 + .../apps/public_api/tests/test_schedules.py | 75 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/engine/apps/public_api/serializers/schedules_calendar.py b/engine/apps/public_api/serializers/schedules_calendar.py index 55c2d2a229..23f7f67425 100644 --- a/engine/apps/public_api/serializers/schedules_calendar.py +++ b/engine/apps/public_api/serializers/schedules_calendar.py @@ -51,6 +51,8 @@ def validate_shifts(self, shifts): for shift in shifts: if shift.team_id != team_id: raise BadRequest(detail="Shifts must be assigned to the same team as the schedule") + if shift.type == CustomOnCallShift.TYPE_OVERRIDE: + raise BadRequest(detail="Shifts of type override are not supported in this schedule") return shifts diff --git a/engine/apps/public_api/tests/test_schedules.py b/engine/apps/public_api/tests/test_schedules.py index 99411b9f53..d1a34e0426 100644 --- a/engine/apps/public_api/tests/test_schedules.py +++ b/engine/apps/public_api/tests/test_schedules.py @@ -367,6 +367,81 @@ def test_update_calendar_schedule_with_custom_event( assert response.json() == result +@pytest.mark.django_db +def test_update_calendar_schedule_invalid_override( + make_organization_and_user_with_token, + make_schedule, + make_on_call_shift, +): + + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleCalendar, + ) + data = { + "start": timezone.now().replace(tzinfo=None, microsecond=0), + "duration": timezone.timedelta(seconds=10800), + } + on_call_shift = make_on_call_shift(organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **data) + + url = reverse("api-public:schedules-detail", kwargs={"pk": schedule.public_primary_key}) + + data = { + "shifts": [on_call_shift.public_primary_key], + } + + response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Shifts of type override are not supported in this schedule"} + + +@pytest.mark.django_db +def test_update_web_schedule_with_override( + make_organization_and_user_with_token, + make_schedule, + make_on_call_shift, +): + + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + ) + data = { + "start": timezone.now().replace(tzinfo=None, microsecond=0), + "duration": timezone.timedelta(seconds=10800), + } + on_call_shift = make_on_call_shift(organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **data) + + url = reverse("api-public:schedules-detail", kwargs={"pk": schedule.public_primary_key}) + + data = { + "shifts": [on_call_shift.public_primary_key], + } + + response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + expected = { + "id": schedule.public_primary_key, + "team_id": None, + "name": schedule.name, + "type": "web", + "time_zone": schedule.time_zone, + "on_call_now": [], + "shifts": data["shifts"], + "slack": { + "channel_id": None, + "user_group_id": None, + }, + } + assert response.status_code == status.HTTP_200_OK + assert response.json() == expected + + @pytest.mark.django_db def test_delete_calendar_schedule( make_organization_and_user_with_token, From f38e79f41f7ec98c8a966a7125aef08717cd1c4d Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Thu, 7 Jul 2022 16:36:04 -0300 Subject: [PATCH 09/15] Limit number of slack messages in resolution notes popup --- .../apps/slack/scenarios/resolution_note.py | 25 +++++- .../test_resolution_note.py | 77 +++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/engine/apps/slack/scenarios/resolution_note.py b/engine/apps/slack/scenarios/resolution_note.py index a82ccb69a1..f8bea2c38e 100644 --- a/engine/apps/slack/scenarios/resolution_note.py +++ b/engine/apps/slack/scenarios/resolution_note.py @@ -237,6 +237,7 @@ class ResolutionNoteModalStep(CheckAlertIsUnarchivedMixin, scenario_step.Scenari ] RESOLUTION_NOTE_TEXT_BLOCK_ID = "resolution_note_text" + RESOLUTION_NOTE_MESSAGES_MAX_COUNT = 25 def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None, data=None): AlertGroup = apps.get_model("alerts", "AlertGroup") @@ -299,7 +300,27 @@ def get_resolution_notes_blocks(self, alert_group, resolution_note_window_action blocks = [] other_resolution_notes = alert_group.resolution_notes.filter(~Q(source=ResolutionNote.Source.SLACK)) - resolution_note_slack_messages = alert_group.resolution_note_slack_messages.filter(posted_by_bot=False) + resolution_note_slack_messages = alert_group.resolution_note_slack_messages.filter( + posted_by_bot=False + ).order_by("-pk") + if resolution_note_slack_messages.count() > self.RESOLUTION_NOTE_MESSAGES_MAX_COUNT: + blocks.extend( + [ + { + "type": "divider", + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + ":warning: Listing up to last {} thread messages, " + "you can still add any other message using contextual menu actions." + ).format(self.RESOLUTION_NOTE_MESSAGES_MAX_COUNT), + }, + }, + ] + ) if action_resolve: blocks.extend( [ @@ -333,7 +354,7 @@ def get_resolution_notes_blocks(self, alert_group, resolution_note_window_action ] ) - for message in resolution_note_slack_messages: + for message in resolution_note_slack_messages[: self.RESOLUTION_NOTE_MESSAGES_MAX_COUNT]: user_verbal = message.user.get_user_verbal_for_team_for_slack(mention=True) blocks.append( { diff --git a/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py b/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py index 080f5d40e5..edea824e6b 100644 --- a/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py +++ b/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py @@ -101,3 +101,80 @@ def test_get_resolution_notes_blocks_non_empty( ] assert blocks == expected_blocks + + +@pytest.mark.django_db +def test_get_resolution_notes_blocks_latest_limit( + make_organization_and_user_with_slack_identities, + make_alert_receive_channel, + make_alert_group, + make_resolution_note_slack_message, +): + SlackResolutionNoteModalStep = ScenarioStep.get_step("resolution_note", "ResolutionNoteModalStep") + organization, user, slack_team_identity, _ = make_organization_and_user_with_slack_identities() + step = SlackResolutionNoteModalStep(slack_team_identity) + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + + max_count = SlackResolutionNoteModalStep.RESOLUTION_NOTE_MESSAGES_MAX_COUNT + messages = [ + make_resolution_note_slack_message(alert_group=alert_group, user=user, added_by_user=user, ts=i, text=i) + for i in range(max_count * 2) + ] + + blocks = step.get_resolution_notes_blocks(alert_group, "", False) + + expected_blocks = [ + { + "type": "divider", + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + ":warning: Listing up to last {} thread messages, " + "you can still add any other message using contextual menu actions." + ).format(max_count), + }, + }, + ] + for m in list(reversed(messages))[:max_count]: + expected_blocks += [ + { + "type": "divider", + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{} \n{}".format( + m.user.get_user_verbal_for_team_for_slack(mention=True), + float(m.ts), + m.text, + ), + }, + "accessory": { + "type": "button", + "style": "primary", + "text": { + "type": "plain_text", + "text": "Add", + "emoji": True, + }, + "action_id": "AddRemoveThreadMessageStep", + "value": json.dumps( + { + "resolution_note_window_action": "edit", + "msg_value": "add", + "message_pk": m.pk, + "resolution_note_pk": None, + "alert_group_pk": alert_group.pk, + } + ), + }, + }, + ] + + assert blocks == expected_blocks From a0f3c22e88f3d2f8a60d447ff16f004a633ae4c9 Mon Sep 17 00:00:00 2001 From: Julia Date: Fri, 8 Jul 2022 12:05:59 +0300 Subject: [PATCH 10/15] Small correction for boolean checks --- engine/apps/public_api/serializers/routes.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/engine/apps/public_api/serializers/routes.py b/engine/apps/public_api/serializers/routes.py index b25463313b..5779b98ea3 100644 --- a/engine/apps/public_api/serializers/routes.py +++ b/engine/apps/public_api/serializers/routes.py @@ -24,10 +24,10 @@ def __init__(self, *args, **kwargs): def to_representation(self, instance): result = super().to_representation(instance) - result["slack"] = {"channel_id": instance.slack_channel_id, "enabled": instance.notify_in_slack is True} + result["slack"] = {"channel_id": instance.slack_channel_id, "enabled": bool(instance.notify_in_slack)} result["telegram"] = { "id": instance.telegram_channel.public_primary_key if instance.telegram_channel else None, - "enabled": instance.notify_in_telegram is True, + "enabled": bool(instance.notify_in_telegram), } # add representation for other messaging backends for backend_id, backend in get_messaging_backends(): @@ -38,7 +38,7 @@ def to_representation(self, instance): notification_enabled = False if instance.notification_backends and instance.notification_backends.get(backend_id): channel_id = instance.notification_backends[backend_id].get("channel") - notification_enabled = instance.notification_backends[backend_id].get("enabled") is True + notification_enabled = bool(instance.notification_backends[backend_id].get("enabled")) result[field] = {"id": channel_id, "enabled": notification_enabled} return result @@ -50,14 +50,14 @@ def _correct_validated_data(self, validated_data): if "channel_id" in slack_field: validated_data["slack_channel_id"] = self._validate_slack_channel_id(slack_field.get("channel_id")) if "enabled" in slack_field: - validated_data["notify_in_slack"] = slack_field.get("enabled") is True + validated_data["notify_in_slack"] = bool(slack_field.get("enabled")) telegram_field = validated_data.pop("telegram", {}) if telegram_field: if "id" in telegram_field: validated_data["telegram_channel"] = self._validate_telegram_channel(telegram_field.get("id")) if "enabled" in telegram_field: - validated_data["notify_in_telegram"] = telegram_field.get("enabled") is True + validated_data["notify_in_telegram"] = bool(telegram_field.get("enabled")) notification_backends = {} for backend_id, backend in get_messaging_backends(): From d18d78a48f224c53827b55772edf308f077ee17e Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Fri, 8 Jul 2022 12:03:18 -0300 Subject: [PATCH 11/15] Disable web schedule create/update from public API --- .../apps/public_api/tests/test_schedules.py | 59 ++----------------- engine/apps/public_api/views/schedules.py | 9 ++- 2 files changed, 14 insertions(+), 54 deletions(-) diff --git a/engine/apps/public_api/tests/test_schedules.py b/engine/apps/public_api/tests/test_schedules.py index d1a34e0426..52acd29f0e 100644 --- a/engine/apps/public_api/tests/test_schedules.py +++ b/engine/apps/public_api/tests/test_schedules.py @@ -198,24 +198,8 @@ def test_create_web_schedule(make_organization_and_user_with_token): } response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") - schedule = OnCallSchedule.objects.get(public_primary_key=response.data["id"]) - - result = { - "id": schedule.public_primary_key, - "team_id": None, - "name": schedule.name, - "type": "web", - "time_zone": "Europe/Moscow", - "on_call_now": [], - "shifts": [], - "slack": { - "channel_id": None, - "user_group_id": None, - }, - } - - assert response.status_code == status.HTTP_201_CREATED - assert response.json() == result + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Web schedule creation is not enabled through API"} @pytest.mark.django_db @@ -246,26 +230,8 @@ def test_update_web_schedule( assert schedule.time_zone != data["time_zone"] response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") - - result = { - "id": schedule.public_primary_key, - "team_id": None, - "name": data["name"], - "type": "web", - "time_zone": data["time_zone"], - "on_call_now": [], - "shifts": [], - "slack": { - "channel_id": "SLACKCHANNELID", - "user_group_id": None, - }, - } - - assert response.status_code == status.HTTP_200_OK - schedule.refresh_from_db() - assert schedule.name == data["name"] - assert schedule.time_zone == data["time_zone"] - assert response.json() == result + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Web schedule update is not enabled through API"} @pytest.mark.django_db @@ -425,21 +391,8 @@ def test_update_web_schedule_with_override( } response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") - expected = { - "id": schedule.public_primary_key, - "team_id": None, - "name": schedule.name, - "type": "web", - "time_zone": schedule.time_zone, - "on_call_now": [], - "shifts": data["shifts"], - "slack": { - "channel_id": None, - "user_group_id": None, - }, - } - assert response.status_code == status.HTTP_200_OK - assert response.json() == expected + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Web schedule update is not enabled through API"} @pytest.mark.django_db diff --git a/engine/apps/public_api/views/schedules.py b/engine/apps/public_api/views/schedules.py index 946463cbef..e52d72fc9a 100644 --- a/engine/apps/public_api/views/schedules.py +++ b/engine/apps/public_api/views/schedules.py @@ -11,9 +11,10 @@ from apps.public_api.serializers import PolymorphicScheduleSerializer, PolymorphicScheduleUpdateSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from apps.schedules.ical_utils import ical_export_from_schedule -from apps.schedules.models import OnCallSchedule +from apps.schedules.models import OnCallSchedule, OnCallScheduleWeb from apps.slack.tasks import update_slack_user_group_for_schedules from apps.user_management.organization_log_creator import OrganizationLogType, create_organization_log +from common.api_helpers.exceptions import BadRequest from common.api_helpers.filters import ByTeamFilter from common.api_helpers.mixins import RateLimitHeadersMixin, UpdateSerializerMixin from common.api_helpers.paginators import FiftyPageSizePaginator @@ -55,6 +56,9 @@ def get_object(self): raise NotFound def perform_create(self, serializer): + if serializer.validated_data["type"] == "web": + raise BadRequest(detail="Web schedule creation is not enabled through API") + serializer.save() instance = serializer.instance @@ -67,6 +71,9 @@ def perform_create(self, serializer): create_organization_log(organization, user, OrganizationLogType.TYPE_SCHEDULE_CREATED, description) def perform_update(self, serializer): + if isinstance(serializer.instance, OnCallScheduleWeb): + raise BadRequest(detail="Web schedule update is not enabled through API") + organization = self.request.auth.organization user = self.request.user old_state = serializer.instance.repr_settings_for_client_side_logging From df32f9099f5643833ac74a51749d04114f7eb418 Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Mon, 11 Jul 2022 13:16:56 +0100 Subject: [PATCH 12/15] Add API support for user timezone and working hours (#201) * add API support for user timezone and working hours * add tests --- engine/apps/api/serializers/user.py | 55 +++++++++ engine/apps/api/tests/test_user.py | 107 ++++++++++++++++++ engine/apps/api/views/user.py | 7 +- .../migrations/0002_auto_20220705_1214.py | 24 ++++ engine/apps/user_management/models/user.py | 30 ++++- 5 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 engine/apps/user_management/migrations/0002_auto_20220705_1214.py diff --git a/engine/apps/api/serializers/user.py b/engine/apps/api/serializers/user.py index 51418885b3..7346651598 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -1,3 +1,6 @@ +import time + +import pytz from django.conf import settings from rest_framework import serializers @@ -9,6 +12,7 @@ from apps.oss_installation.utils import cloud_user_identity_status from apps.twilioapp.utils import check_phone_number_is_valid from apps.user_management.models import User +from apps.user_management.models.user import default_working_hours from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField from common.api_helpers.mixins import EagerLoadingMixin from common.constants.role import Role @@ -29,6 +33,7 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): organization = FastOrganizationSerializer(read_only=True) current_team = TeamPrimaryKeyRelatedField(allow_null=True, required=False) + timezone = serializers.CharField(allow_null=True, required=False) avatar = serializers.URLField(source="avatar_url", read_only=True) permissions = serializers.SerializerMethodField() @@ -47,6 +52,8 @@ class Meta: "username", "role", "avatar", + "timezone", + "working_hours", "unverified_phone_number", "verified_phone_number", "slack_user_identity", @@ -63,6 +70,52 @@ class Meta: "verified_phone_number", ] + def validate_timezone(self, tz): + if tz is None: + return tz + + try: + pytz.timezone(tz) + except pytz.UnknownTimeZoneError: + raise serializers.ValidationError("not a valid timezone") + + return tz + + def validate_working_hours(self, working_hours): + if not isinstance(working_hours, dict): + raise serializers.ValidationError("must be dict") + + # check that all days are present + if sorted(working_hours.keys()) != sorted(default_working_hours().keys()): + raise serializers.ValidationError("missing some days") + + for day in working_hours: + periods = working_hours[day] + + if not isinstance(periods, list): + raise serializers.ValidationError("periods must be list") + + for period in periods: + if not isinstance(period, dict): + raise serializers.ValidationError("period must be dict") + + if sorted(period.keys()) != sorted(["start", "end"]): + raise serializers.ValidationError("'start' and 'end' fields must be present") + + if not isinstance(period["start"], str) or not isinstance(period["end"], str): + raise serializers.ValidationError("'start' and 'end' fields must be str") + + try: + start = time.strptime(period["start"], "%H:%M:%S") + end = time.strptime(period["end"], "%H:%M:%S") + except ValueError: + raise serializers.ValidationError("'start' and 'end' fields must be in '%H:%M:%S' format") + + if start >= end: + raise serializers.ValidationError("'start' must be less than 'end'") + + return working_hours + def validate_unverified_phone_number(self, value): if value: if check_phone_number_is_valid(value): @@ -110,6 +163,8 @@ class UserHiddenFieldsSerializer(UserSerializer): "current_team", "username", "avatar", + "timezone", + "working_hours", "notification_chain_verbal", "permissions", ] diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index dd23feb576..7c0646163d 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -10,6 +10,7 @@ from apps.base.constants import ADMIN_PERMISSIONS, EDITOR_PERMISSIONS from apps.base.models import UserNotificationPolicy +from apps.user_management.models.user import default_working_hours from common.constants.role import Role @@ -67,6 +68,8 @@ def test_update_user_cant_change_email_and_username( "email": admin.email, "username": admin.username, "role": admin.role, + "timezone": None, + "working_hours": default_working_hours(), "unverified_phone_number": phone_number, "verified_phone_number": None, "telegram_configuration": None, @@ -113,6 +116,8 @@ def test_list_users( "email": admin.email, "username": admin.username, "role": admin.role, + "timezone": None, + "working_hours": default_working_hours(), "unverified_phone_number": None, "verified_phone_number": None, "telegram_configuration": None, @@ -134,6 +139,8 @@ def test_list_users( "email": editor.email, "username": editor.username, "role": editor.role, + "timezone": None, + "working_hours": default_working_hours(), "unverified_phone_number": None, "verified_phone_number": None, "telegram_configuration": None, @@ -1485,3 +1492,103 @@ def test_viewer_cant_unlink_backend_another_user( response = client.post(url, format="json", **make_user_auth_headers(second_user, token)) assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_change_timezone( + make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers +): + organization = make_organization() + user = make_user_for_organization(organization, role=Role.EDITOR) + _, token = make_token_for_organization(organization) + + client = APIClient() + url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key}) + + data = {"timezone": "Europe/London"} + + response = client.put(f"{url}", data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + assert "timezone" in response.json() + assert response.json()["timezone"] == "Europe/London" + + +@pytest.mark.django_db +@pytest.mark.parametrize("timezone", ["", 1, "NotATimezone"]) +def test_invalid_timezone( + make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers, timezone +): + organization = make_organization() + user = make_user_for_organization(organization, role=Role.EDITOR) + _, token = make_token_for_organization(organization) + + client = APIClient() + url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key}) + + data = {"timezone": timezone} + + response = client.put(f"{url}", data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_change_working_hours( + make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers +): + organization = make_organization() + user = make_user_for_organization(organization, role=Role.EDITOR) + _, token = make_token_for_organization(organization) + + client = APIClient() + url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key}) + + periods = [{"start": "05:00:00", "end": "23:00:00"}] + working_hours = { + day: periods for day in ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] + } + + data = {"working_hours": working_hours} + + response = client.put(f"{url}", data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + assert "working_hours" in response.json() + assert response.json()["working_hours"] == working_hours + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "working_hours_extra", + [ + {}, + {"sunday": 1}, + {"sunday": ""}, + {"sunday": {"start": "18:00:00"}}, + {"sunday": {"start": "", "end": ""}}, + {"sunday": {"start": "18:00:00", "end": None}}, + {"sunday": {"start": "18:00:00", "end": "18:00:00"}}, + {"sunday": {"start": "18:00:00", "end": "9:00:00"}}, + {"sunday": {"start": "18:00:00", "end": "9:00:00", "extra": 1}}, + ], +) +def test_invalid_working_hours( + make_organization, + make_user_for_organization, + make_token_for_organization, + make_user_auth_headers, + working_hours_extra, +): + organization = make_organization() + user = make_user_for_organization(organization, role=Role.EDITOR) + _, token = make_token_for_organization(organization) + + client = APIClient() + url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key}) + + periods = [{"start": "05:00:00", "end": "23:00:00"}] + working_hours = {day: periods for day in ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]} + working_hours.update(working_hours_extra) + + data = {"working_hours": working_hours} + response = client.put(f"{url}", data, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index e7d20a3248..b79be50f7d 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -1,6 +1,7 @@ import logging from urllib.parse import urljoin +import pytz from django.apps import apps from django.conf import settings from django.core.exceptions import ObjectDoesNotExist @@ -123,7 +124,7 @@ class UserView( "mobile_app_verification_token", "mobile_app_auth_token", ), - AnyRole: ("retrieve",), + AnyRole: ("retrieve", "timezone_options"), } action_object_permissions = { @@ -236,6 +237,10 @@ def current(self, request): serializer = UserSerializer(self.get_queryset().get(pk=self.request.user.pk)) return Response(serializer.data) + @action(detail=False, methods=["get"]) + def timezone_options(self, request): + return Response(pytz.common_timezones) + @action(detail=True, methods=["get"]) def get_verification_code(self, request, pk): user = self.get_object() diff --git a/engine/apps/user_management/migrations/0002_auto_20220705_1214.py b/engine/apps/user_management/migrations/0002_auto_20220705_1214.py new file mode 100644 index 0000000000..976bfe7a5e --- /dev/null +++ b/engine/apps/user_management/migrations/0002_auto_20220705_1214.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.13 on 2022-07-05 12:14 + +import apps.user_management.models.user +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0001_squashed_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='_timezone', + field=models.CharField(default=None, max_length=50, null=True), + ), + migrations.AddField( + model_name='user', + name='working_hours', + field=models.JSONField(default=apps.user_management.models.user.default_working_hours, null=True), + ), + ] diff --git a/engine/apps/user_management/models/user.py b/engine/apps/user_management/models/user.py index ecdc46e0e6..ca769243ac 100644 --- a/engine/apps/user_management/models/user.py +++ b/engine/apps/user_management/models/user.py @@ -30,6 +30,16 @@ def generate_public_primary_key_for_user(): return new_public_primary_key +def default_working_hours(): + weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday"] + weekends = ["saturday", "sunday"] + + working_hours = {day: [{"start": "09:00:00", "end": "17:00:00"}] for day in weekdays} + working_hours |= {day: [] for day in weekends} + + return working_hours + + class UserManager(models.Manager): @staticmethod def sync_for_team(team, api_members: list[dict]): @@ -128,6 +138,10 @@ class Meta: role = models.PositiveSmallIntegerField(choices=Role.choices()) avatar_url = models.URLField() + # don't use "_timezone" directly, use the "timezone" property since it can be populated via slack user identity + _timezone = models.CharField(max_length=50, null=True, default=None) + working_hours = models.JSONField(null=True, default=default_working_hours) + notification = models.ManyToManyField("alerts.AlertGroup", through="alerts.UserHasNotification") unverified_phone_number = models.CharField(max_length=20, null=True, default=None) @@ -222,11 +236,17 @@ def repr_settings_for_client_side_logging(self): @property def timezone(self): - slack_user_identity = self.slack_user_identity - if slack_user_identity: - return slack_user_identity.timezone - else: - return None + if self._timezone: + return self._timezone + + if self.slack_user_identity: + return self.slack_user_identity.timezone + + return None + + @timezone.setter + def timezone(self, value): + self._timezone = value def short(self): return {"username": self.username, "pk": self.public_primary_key, "avatar": self.avatar_url} From f8f86db7b000bd41285de3e2c8d19a9e32da1806 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Tue, 12 Jul 2022 12:56:01 +0400 Subject: [PATCH 13/15] Fix phone notifications for oss (#171) * Fix phone notifications for oss * Don't pass status_callback_method while processing 21609 error from twilio --- engine/apps/twilioapp/twilio_client.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/engine/apps/twilioapp/twilio_client.py b/engine/apps/twilioapp/twilio_client.py index 9c2d6cc3b5..3048a90fe5 100644 --- a/engine/apps/twilioapp/twilio_client.py +++ b/engine/apps/twilioapp/twilio_client.py @@ -25,9 +25,17 @@ def twilio_number(self): def send_message(self, body, to): status_callback = settings.BASE_URL + reverse("twilioapp:sms_status_events") - return self.twilio_api_client.messages.create( - body=body, to=to, from_=self.twilio_number, status_callback=status_callback - ) + try: + return self.twilio_api_client.messages.create( + body=body, to=to, from_=self.twilio_number, status_callback=status_callback + ) + except TwilioRestException as e: + # If status callback is not valid and not accessible from public url then trying to send message without it + # https://www.twilio.com/docs/api/errors/21609 + if e.code == 21609: + logger.warning("twilio_client.send_message: Twilio error 21609. Status Callback is not public url") + return self.twilio_api_client.messages.create(body=body, to=to, from_=self.twilio_number) + raise e # Use responsibly def parse_number(self, number): @@ -149,6 +157,17 @@ def make_call(self, message, to): status_callback_method="POST", ) except TwilioRestException as e: + # If status callback is not valid and not accessible from public url then trying to make call without it + # https://www.twilio.com/docs/api/errors/21609 + if e.code == 21609: + logger.warning("twilio_client.make_call: Twilio error 21609. Status Callback is not public url") + return self.twilio_api_client.calls.create( + url=url, + to=to, + from_=self.twilio_number, + method="GET", + ) + raise e def create_log_record(self, **kwargs): From 37187ef18a8ee618569c1db6d5a76005602c8f1f Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Tue, 12 Jul 2022 12:56:27 +0400 Subject: [PATCH 14/15] Manual incidents for all teams (#194) * Fix creation of manual incident via submenu * Remove legacy finish_configuration_attachments * Add manual incidents for all teams * Fix manual incident creation from slash command * Fix slack title template * Get rid of migration --- .../renderers/web_renderer.py | 2 +- .../templaters/slack_templater.py | 54 ++ .../templaters/web_templater.py | 2 +- .../alerts/models/alert_receive_channel.py | 4 +- .../slack/scenarios/invited_to_channel.py | 42 ++ .../apps/slack/scenarios/manual_incident.py | 687 ++++++++++++++++++ engine/apps/slack/scenarios/public_menu.py | 536 -------------- .../apps/slack/scenarios/resolution_note.py | 143 ++++ engine/apps/slack/scenarios/scenario_step.py | 74 -- engine/apps/slack/views.py | 6 +- engine/config_integrations/manual.py | 43 +- 11 files changed, 949 insertions(+), 644 deletions(-) create mode 100644 engine/apps/slack/scenarios/invited_to_channel.py create mode 100644 engine/apps/slack/scenarios/manual_incident.py delete mode 100644 engine/apps/slack/scenarios/public_menu.py diff --git a/engine/apps/alerts/incident_appearance/renderers/web_renderer.py b/engine/apps/alerts/incident_appearance/renderers/web_renderer.py index f7eecbab49..e68d453cd0 100644 --- a/engine/apps/alerts/incident_appearance/renderers/web_renderer.py +++ b/engine/apps/alerts/incident_appearance/renderers/web_renderer.py @@ -14,7 +14,7 @@ def render(self): "title": str_or_backup(templated_alert.title, "Alert"), "message": str_or_backup(templated_alert.message, ""), "image_url": str_or_backup(templated_alert.image_url, None), - "source_link": str_or_backup(templated_alert.image_url, None), + "source_link": str_or_backup(templated_alert.source_link, None), } return rendered_alert diff --git a/engine/apps/alerts/incident_appearance/templaters/slack_templater.py b/engine/apps/alerts/incident_appearance/templaters/slack_templater.py index 6c6e3efedf..48246cd2d1 100644 --- a/engine/apps/alerts/incident_appearance/templaters/slack_templater.py +++ b/engine/apps/alerts/incident_appearance/templaters/slack_templater.py @@ -1,3 +1,5 @@ +from django.apps import apps + from apps.alerts.incident_appearance.templaters.alert_templater import AlertTemplater @@ -12,3 +14,55 @@ def _postformat(self, templated_alert): if templated_alert.title: templated_alert.title = templated_alert.title.replace("\n", "").replace("\r", "") return templated_alert + + def render(self): + """ + Overriden render method to modify payload of manual integration alerts + """ + self._modify_payload_for_manual_integration_if_needed() + return super().render() + + def _modify_payload_for_manual_integration_if_needed(self): + """ + Modifies payload of alerts made from manual incident integration. + It is needed to simplify templates. + """ + payload = self.alert.raw_request_data + # First check if payload look like payload from manual incident integration and was not modified before. + if "view" in payload and "private_metadata" in payload.get("view", {}) and "oncall" not in payload: + AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel") + # If so - check it with db query. + if self.alert.group.channel.integration == AlertReceiveChannel.INTEGRATION_MANUAL: + metadata = payload.get("view", {}).get("private_metadata", {}) + payload["oncall"] = {} + if "message" in metadata: + # If alert was made from message + domain = payload.get("team", {}).get("domain", "unknown") + channel_id = metadata.get("channel_id", "unknown") + message = metadata.get("message", {}) + message_ts = message.get("ts", "unknown") + message_text = message.get("text", "unknown") + payload["oncall"]["permalink"] = f"https://{domain}.slack.com/archives/{channel_id}/p{message_ts}" + payload["oncall"]["author_username"] = metadata.get("author_username", "Unknown") + payload["oncall"]["title"] = "Message from @" + payload["oncall"]["author_username"] + payload["oncall"]["message"] = message_text + else: + # If alert was made via slash command + message_text = ( + payload.get("view", {}) + .get("state", {}) + .get("values", {}) + .get("MESSAGE_INPUT", {}) + .get("FinishCreateIncidentViewStep", {}) + .get("value", "unknown") + ) + payload["oncall"]["permalink"] = None + payload["oncall"]["title"] = self.alert.title + payload["oncall"]["message"] = message_text + created_by = self.alert.integration_unique_data.get("created_by", None) + username = payload.get("user", {}).get("name", None) + author_username = created_by or username or "unknown" + payload["oncall"]["author_username"] = author_username + + self.alert.raw_request_data = payload + self.alert.save(update_fields=["raw_request_data"]) diff --git a/engine/apps/alerts/incident_appearance/templaters/web_templater.py b/engine/apps/alerts/incident_appearance/templaters/web_templater.py index 913e285783..fa7c29aae4 100644 --- a/engine/apps/alerts/incident_appearance/templaters/web_templater.py +++ b/engine/apps/alerts/incident_appearance/templaters/web_templater.py @@ -32,5 +32,5 @@ def _postformat(self, templated_alert): def _slack_format_for_web(self, data): sf = self.slack_formatter - sf.hyperlink_mention_format = "[title](url)" + sf.hyperlink_mention_format = "[{title}]({url})" return sf.format(data) diff --git a/engine/apps/alerts/models/alert_receive_channel.py b/engine/apps/alerts/models/alert_receive_channel.py index a2a9dad7d7..f408efcedb 100644 --- a/engine/apps/alerts/models/alert_receive_channel.py +++ b/engine/apps/alerts/models/alert_receive_channel.py @@ -448,7 +448,9 @@ def description(self): def get_or_create_manual_integration(cls, defaults, **kwargs): try: alert_receive_channel = cls.objects.get( - organization=kwargs["organization"], integration=kwargs["integration"] + organization=kwargs["organization"], + integration=kwargs["integration"], + team=kwargs["team"], ) except cls.DoesNotExist: kwargs.update(defaults) diff --git a/engine/apps/slack/scenarios/invited_to_channel.py b/engine/apps/slack/scenarios/invited_to_channel.py new file mode 100644 index 0000000000..976884c16f --- /dev/null +++ b/engine/apps/slack/scenarios/invited_to_channel.py @@ -0,0 +1,42 @@ +import logging + +from django.utils import timezone + +from apps.slack.scenarios import scenario_step +from apps.slack.slack_client import SlackClientWithErrorHandling + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + + +class InvitedToChannelStep(scenario_step.ScenarioStep): + tags = [ + scenario_step.ScenarioStep.TAG_TRIGGERED_BY_SYSTEM, + ] + + def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): + if payload["event"]["user"] == slack_team_identity.bot_user_id: + channel_id = payload["event"]["channel"] + slack_client = SlackClientWithErrorHandling(slack_team_identity.bot_access_token) + channel = slack_client.api_call("conversations.info", channel=channel_id)["channel"] + + slack_team_identity.cached_channels.update_or_create( + slack_id=channel["id"], + defaults={ + "name": channel["name"], + "is_archived": channel["is_archived"], + "is_shared": channel["is_shared"], + "last_populated": timezone.now().date(), + }, + ) + else: + logger.info("Other user was invited to a channel with a bot.") + + +STEPS_ROUTING = [ + { + "payload_type": scenario_step.PAYLOAD_TYPE_EVENT_CALLBACK, + "event_type": scenario_step.EVENT_TYPE_MEMBER_JOINED_CHANNEL, + "step": InvitedToChannelStep, + }, +] diff --git a/engine/apps/slack/scenarios/manual_incident.py b/engine/apps/slack/scenarios/manual_incident.py new file mode 100644 index 0000000000..0833cfa554 --- /dev/null +++ b/engine/apps/slack/scenarios/manual_incident.py @@ -0,0 +1,687 @@ +import json +from uuid import uuid4 + +from django.apps import apps +from django.conf import settings + +from apps.alerts.models import AlertReceiveChannel +from apps.slack.scenarios import scenario_step +from apps.slack.slack_client.exceptions import SlackAPIException + +MANUAL_INCIDENT_TEAM_SELECT_ID = "manual_incident_team_select" +MANUAL_INCIDENT_ORG_SELECT_ID = "manual_incident_org_select" +MANUAL_INCIDENT_ROUTE_SELECT_ID = "manual_incident_route_select" +MANUAL_INCIDENT_TITLE_INPUT_ID = "manual_incident_title_input" +MANUAL_INCIDENT_MESSAGE_INPUT_ID = "manual_incident_message_input" + +DEFAULT_TEAM_VALUE = "default_team" + + +class StartCreateIncidentFromMessage(scenario_step.ScenarioStep): + """ + StartCreateIncidentFromMessage triggers creation of a manual incident from the slack message via submenu + """ + + callback_id = [ + "incident_create", + "incident_create_staging", + "incident_create_develop", + ] + + def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): + input_id_prefix = _generate_input_id_prefix() + + channel_id = payload["channel"]["id"] + try: + image_url = payload["message"]["files"][0]["permalink"] + except KeyError: + image_url = None + private_metadata = { + "channel_id": channel_id, + "image_url": image_url, + "message": { + "user": payload["message"].get("user"), + "text": payload["message"].get("text"), + "ts": payload["message"].get("ts"), + }, + "input_id_prefix": input_id_prefix, + "with_title_and_message_inputs": False, + "submit_routing_uid": FinishCreateIncidentFromMessage.routing_uid(), + } + + blocks = _get_manual_incident_initial_form_fields( + slack_team_identity, slack_user_identity, input_id_prefix, payload + ) + view = _get_manual_incident_form_view( + FinishCreateIncidentFromMessage.routing_uid(), blocks, json.dumps(private_metadata) + ) + self._slack_client.api_call( + "views.open", + trigger_id=payload["trigger_id"], + view=view, + ) + + +class FinishCreateIncidentFromMessage(scenario_step.ScenarioStep): + """ + FinishCreateIncidentFromMessage creates a manual incident from the slack message via submenu + """ + + def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): + Alert = apps.get_model("alerts", "Alert") + + private_metadata = json.loads(payload["view"]["private_metadata"]) + + channel_id = private_metadata["channel_id"] + + input_id_prefix = private_metadata["input_id_prefix"] + selected_organization = _get_selected_org_from_payload(payload, input_id_prefix) + selected_team = _get_selected_team_from_payload(payload, input_id_prefix) + selected_route = _get_selected_route_from_payload(payload, input_id_prefix) + + user = slack_user_identity.get_user(selected_organization) + alert_receive_channel = AlertReceiveChannel.get_or_create_manual_integration( + organization=selected_organization, + team=selected_team, + integration=AlertReceiveChannel.INTEGRATION_MANUAL, + deleted_at=None, + defaults={ + "author": user, + "verbal_name": f"Manual incidents ({selected_team.name if selected_team else 'General'} team)", + }, + ) + + author_username = slack_user_identity.slack_verbal + try: + permalink = self._slack_client.api_call( + "chat.getPermalink", + channel=private_metadata["channel_id"], + message_ts=private_metadata["message"]["ts"], + ) + permalink = permalink.get("permalink", None) + except SlackAPIException: + permalink = None + title = "Message from {}".format(author_username) + message = private_metadata["message"]["text"] + + # Deprecated, use custom oncall property instead. + # update private metadata in payload to use it in alert rendering + payload["view"]["private_metadata"] = private_metadata + payload["view"]["private_metadata"]["author_username"] = author_username + # Custom oncall property in payload to simplify rendering + payload["oncall"] = {} + payload["oncall"]["title"] = title + payload["oncall"]["message"] = message + payload["oncall"]["author_username"] = author_username + payload["oncall"]["permalink"] = permalink + Alert.create( + title=title, + message=message, + image_url=private_metadata["image_url"], + # Link to the slack message is not here bc it redirects to browser + link_to_upstream_details=None, + alert_receive_channel=alert_receive_channel, + raw_request_data=payload, + integration_unique_data={"created_by": user.get_user_verbal_for_team_for_slack()}, + force_route_id=selected_route.pk, + ) + + try: + self._slack_client.api_call( + "chat.postEphemeral", + channel=channel_id, + user=slack_user_identity.slack_id, + text=":white_check_mark: Alert successfully submitted", + ) + except SlackAPIException as e: + if e.response["error"] == "channel_not_found" or e.response["error"] == "user_not_in_channel": + self._slack_client.api_call( + "chat.postEphemeral", + channel=slack_user_identity.im_channel_id, + user=slack_user_identity.slack_id, + text=":white_check_mark: Alert successfully submitted", + ) + else: + raise e + + +class StartCreateIncidentFromSlashCommand(scenario_step.ScenarioStep): + """ + StartCreateIncidentFromSlashCommand triggers creation of a manual incident from the slack message via slash command + """ + + command_name = [settings.SLACK_SLASH_COMMAND_NAME] + TITLE_INPUT_BLOCK_ID = "TITLE_INPUT" + MESSAGE_INPUT_BLOCK_ID = "MESSAGE_INPUT" + + def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): + input_id_prefix = _generate_input_id_prefix() + + try: + channel_id = payload["event"]["channel"] + except KeyError: + channel_id = payload["channel_id"] + + private_metadata = { + "channel_id": channel_id, + "input_id_prefix": input_id_prefix, + "with_title_and_message_inputs": True, + "submit_routing_uid": FinishCreateIncidentFromSlashCommand.routing_uid(), + } + + blocks = _get_manual_incident_initial_form_fields( + slack_team_identity, slack_user_identity, input_id_prefix, payload, with_title_and_message_inputs=True + ) + view = _get_manual_incident_form_view( + FinishCreateIncidentFromSlashCommand.routing_uid(), blocks, json.dumps(private_metadata) + ) + + self._slack_client.api_call( + "views.open", + trigger_id=payload["trigger_id"], + view=view, + ) + + +class FinishCreateIncidentFromSlashCommand(scenario_step.ScenarioStep): + """ + FinishCreateIncidentFromSlashCommand creates a manual incident from the slack message via slash message + """ + + def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): + Alert = apps.get_model("alerts", "Alert") + + title = _get_title_from_payload(payload) + message = _get_message_from_payload(payload) + + private_metadata = json.loads(payload["view"]["private_metadata"]) + + channel_id = private_metadata["channel_id"] + + input_id_prefix = private_metadata["input_id_prefix"] + selected_organization = _get_selected_org_from_payload(payload, input_id_prefix) + selected_team = _get_selected_team_from_payload(payload, input_id_prefix) + selected_route = _get_selected_route_from_payload(payload, input_id_prefix) + + user = slack_user_identity.get_user(selected_organization) + alert_receive_channel = AlertReceiveChannel.get_or_create_manual_integration( + organization=selected_organization, + team=selected_team, + integration=AlertReceiveChannel.INTEGRATION_MANUAL, + deleted_at=None, + defaults={ + "author": user, + "verbal_name": f"Manual incidents ({selected_team.name if selected_team else 'General'} team)", + }, + ) + + author_username = slack_user_identity.slack_verbal + + try: + self._slack_client.api_call( + "chat.postEphemeral", + channel=channel_id, + user=slack_user_identity.slack_id, + text=":white_check_mark: Alert *{}* successfully submitted".format(title), + ) + except SlackAPIException as e: + if e.response["error"] == "channel_not_found": + self._slack_client.api_call( + "chat.postEphemeral", + channel=slack_user_identity.im_channel_id, + user=slack_user_identity.slack_id, + text=":white_check_mark: Alert *{}* successfully submitted".format(title), + ) + else: + raise e + + # Deprecated, use custom oncall property instead. + # Update private metadata to use it in rendering: + payload["view"]["private_metadata"] = private_metadata + # Custom oncall property to simplify rendering + payload["oncall"] = {} + payload["oncall"]["title"] = title + payload["oncall"]["message"] = message + payload["oncall"]["author_username"] = author_username + payload["oncall"]["permalink"] = None + + Alert.create( + title=title, + message=message, + image_url=None, + link_to_upstream_details=None, + alert_receive_channel=alert_receive_channel, + raw_request_data=payload, + integration_unique_data={ + "created_by": author_username, + }, + force_route_id=selected_route.pk, + ) + + +# OnChange steps responsible for rerendering manual incident creation form on change values in selects. +# They are works both with incident creation from submenu and slack command. + + +class OnOrgChange(scenario_step.ScenarioStep): + def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): + private_metadata = json.loads(payload["view"]["private_metadata"]) + with_title_and_message_inputs = private_metadata.get("with_title_and_message_inputs", False) + submit_routing_uid = private_metadata.get("submit_routing_uid") + old_input_id_prefix, new_input_id_prefix, new_private_metadata = _get_and_change_input_id_prefix_from_metadata( + private_metadata + ) + + selected_organization = _get_selected_org_from_payload(payload, old_input_id_prefix) + # Set selected team to default because org is changed. + selected_team = None + + user = slack_user_identity.get_user(selected_organization) + manual_integration = AlertReceiveChannel.get_or_create_manual_integration( + organization=selected_organization, + team=selected_team, + integration=AlertReceiveChannel.INTEGRATION_MANUAL, + deleted_at=None, + defaults={ + "author": user, + "verbal_name": f"Manual incidents ({selected_team.name if selected_team else 'General'} team)", + }, + ) + selected_route = manual_integration.default_channel_filter + + organization_select = _get_organization_select( + slack_team_identity, slack_user_identity, selected_organization, new_input_id_prefix + ) + team_select = _get_team_select(slack_user_identity, selected_organization, selected_team, new_input_id_prefix) + route_select = _get_route_select(manual_integration, selected_route, new_input_id_prefix) + + blocks = [organization_select, team_select, route_select] + if with_title_and_message_inputs: + blocks.extend([_get_title_input(payload), _get_message_input(payload)]) + view = _get_manual_incident_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata)) + self._slack_client.api_call( + "views.update", + trigger_id=payload["trigger_id"], + view=view, + view_id=payload["view"]["id"], + ) + + +class OnTeamChange(scenario_step.ScenarioStep): + def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): + private_metadata = json.loads(payload["view"]["private_metadata"]) + with_title_and_message_inputs = private_metadata.get("with_title_and_message_inputs", False) + submit_routing_uid = private_metadata.get("submit_routing_uid") + old_input_id_prefix, new_input_id_prefix, new_private_metadata = _get_and_change_input_id_prefix_from_metadata( + private_metadata + ) + + selected_organization = _get_selected_org_from_payload(payload, old_input_id_prefix) + selected_team = _get_selected_team_from_payload(payload, old_input_id_prefix) + + user = slack_user_identity.get_user(selected_organization) + manual_integration = AlertReceiveChannel.get_or_create_manual_integration( + organization=selected_organization, + team=selected_team, + integration=AlertReceiveChannel.INTEGRATION_MANUAL, + deleted_at=None, + defaults={ + "author": user, + "verbal_name": f"Manual incidents ({selected_team.name if selected_team else 'General'} team)", + }, + ) + initial_route = manual_integration.default_channel_filter + + organization_select = _get_organization_select( + slack_team_identity, slack_user_identity, selected_organization, new_input_id_prefix + ) + team_select = _get_team_select(slack_user_identity, selected_organization, selected_team, new_input_id_prefix) + route_select = _get_route_select(manual_integration, initial_route, new_input_id_prefix) + + blocks = [organization_select, team_select, route_select] + if with_title_and_message_inputs: + blocks.extend([_get_title_input(payload), _get_message_input(payload)]) + view = _get_manual_incident_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata)) + self._slack_client.api_call( + "views.update", + trigger_id=payload["trigger_id"], + view=view, + view_id=payload["view"]["id"], + ) + + +class OnRouteChange(scenario_step.ScenarioStep): + """ + OnRouteChange is just a plug to handle change of value on route select + """ + + def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): + pass + + +def _get_manual_incident_form_view(routing_uid, blocks, private_metatada): + view = { + "type": "modal", + "callback_id": routing_uid, + "title": { + "type": "plain_text", + "text": "Create an Incident", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + "emoji": True, + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "blocks": blocks, + "private_metadata": private_metatada, + } + + return view + + +def _get_manual_incident_initial_form_fields( + slack_team_identity, slack_user_identity, input_id_prefix, payload, with_title_and_message_inputs=False +): + initial_organization = ( + slack_team_identity.organizations.filter(users__slack_user_identity=slack_user_identity) + .order_by("pk") + .distinct() + .first() + ) + + organization_select = _get_organization_select( + slack_team_identity, slack_user_identity, initial_organization, input_id_prefix + ) + + initial_team = None # means default team + team_select = _get_team_select(slack_user_identity, initial_organization, initial_team, input_id_prefix) + + user = slack_user_identity.get_user(initial_organization) + manual_integration = AlertReceiveChannel.get_or_create_manual_integration( + organization=initial_organization, + team=initial_team, + integration=AlertReceiveChannel.INTEGRATION_MANUAL, + deleted_at=None, + defaults={ + "author": user, + "verbal_name": f"Manual incidents ({initial_team.name if initial_team else 'General'} team)", + }, + ) + + initial_route = manual_integration.default_channel_filter + route_select = _get_route_select(manual_integration, initial_route, input_id_prefix) + blocks = [organization_select, team_select, route_select] + if with_title_and_message_inputs: + title_input = _get_title_input(payload) + message_input = _get_message_input(payload) + blocks.append(title_input) + blocks.append(message_input) + return blocks + + +def _get_organization_select(slack_team_identity, slack_user_identity, value, input_id_prefix): + organizations = slack_team_identity.organizations.filter( + users__slack_user_identity=slack_user_identity, + ).distinct() + organizations_options = [] + initial_option_idx = 0 + for idx, org in enumerate(organizations): + if org == value: + initial_option_idx = idx + organizations_options.append( + { + "text": { + "type": "plain_text", + "text": f"{org.org_title}", + "emoji": True, + }, + "value": f"{org.pk}", + } + ) + + organization_select = { + "type": "section", + "text": {"type": "mrkdwn", "text": "Select an organization"}, + "block_id": input_id_prefix + MANUAL_INCIDENT_ORG_SELECT_ID, + "accessory": { + "type": "static_select", + "placeholder": {"type": "plain_text", "text": "Select an organization", "emoji": True}, + "options": organizations_options, + "action_id": OnOrgChange.routing_uid(), + "initial_option": organizations_options[initial_option_idx], + }, + } + + return organization_select + + +def _get_selected_org_from_payload(payload, input_id_prefix): + Organization = apps.get_model("user_management", "Organization") + selected_org_id = payload["view"]["state"]["values"][input_id_prefix + MANUAL_INCIDENT_ORG_SELECT_ID][ + OnOrgChange.routing_uid() + ]["selected_option"]["value"] + org = Organization.objects.filter(pk=selected_org_id).first() + return org + + +def _get_team_select(slack_user_identity, organization, value, input_id_prefix): + teams = organization.teams.filter( + users__slack_user_identity=slack_user_identity, + ).distinct() + team_options = [] + # Adding pseudo option for default team + initial_option_idx = 0 + team_options.append( + { + "text": { + "type": "plain_text", + "text": f"General", + "emoji": True, + }, + "value": DEFAULT_TEAM_VALUE, + } + ) + for idx, team in enumerate(teams): + if team == value: + # Add 1 because default team option was added before cycle, so option indicies are shifted + initial_option_idx = idx + 1 + team_options.append( + { + "text": { + "type": "plain_text", + "text": f"{team.name}", + "emoji": True, + }, + "value": f"{team.pk}", + } + ) + + team_select = { + "type": "section", + "text": {"type": "mrkdwn", "text": "Select a team"}, + "block_id": input_id_prefix + MANUAL_INCIDENT_TEAM_SELECT_ID, + "accessory": { + "type": "static_select", + "placeholder": {"type": "plain_text", "text": "Select a team", "emoji": True}, + "options": team_options, + "action_id": OnTeamChange.routing_uid(), + "initial_option": team_options[initial_option_idx], + }, + } + return team_select + + +def _get_selected_team_from_payload(payload, input_id_prefix): + Team = apps.get_model("user_management", "Team") + selected_team_id = payload["view"]["state"]["values"][input_id_prefix + MANUAL_INCIDENT_TEAM_SELECT_ID][ + OnTeamChange.routing_uid() + ]["selected_option"]["value"] + if selected_team_id == DEFAULT_TEAM_VALUE: + return None + team = Team.objects.filter(pk=selected_team_id).first() + return team + + +def _get_route_select(integration, value, input_id_prefix): + route_options = [] + initial_option_idx = 0 + for idx, route in enumerate(integration.channel_filters.all()): + filtering_term = f'"{route.filtering_term}"' + if route.is_default: + filtering_term = "default" + if value == route: + initial_option_idx = idx + route_options.append( + { + "text": { + "type": "plain_text", + "text": f"{filtering_term}", + "emoji": True, + }, + "value": f"{route.pk}", + } + ) + route_select = { + "type": "section", + "text": {"type": "mrkdwn", "text": "Select a route"}, + "block_id": input_id_prefix + MANUAL_INCIDENT_ROUTE_SELECT_ID, + "accessory": { + "type": "static_select", + "placeholder": {"type": "plain_text", "text": "Select a route", "emoji": True}, + "options": route_options, + "initial_option": route_options[initial_option_idx], + "action_id": OnRouteChange.routing_uid(), + }, + } + return route_select + + +def _get_selected_route_from_payload(payload, input_id_prefix): + ChannelFilter = apps.get_model("alerts", "ChannelFilter") + selected_org_id = payload["view"]["state"]["values"][input_id_prefix + MANUAL_INCIDENT_ROUTE_SELECT_ID][ + OnRouteChange.routing_uid() + ]["selected_option"]["value"] + channel_filter = ChannelFilter.objects.filter(pk=selected_org_id).first() + return channel_filter + + +def _get_and_change_input_id_prefix_from_metadata(metadata): + old_input_id_prefix = metadata["input_id_prefix"] + new_input_id_prefix = _generate_input_id_prefix() + metadata["input_id_prefix"] = new_input_id_prefix + return old_input_id_prefix, new_input_id_prefix, metadata + + +def _get_title_input(payload): + title_input_block = { + "type": "input", + "block_id": MANUAL_INCIDENT_TITLE_INPUT_ID, + "label": { + "type": "plain_text", + "text": "Title:", + }, + "element": { + "type": "plain_text_input", + "action_id": FinishCreateIncidentFromSlashCommand.routing_uid(), + "placeholder": { + "type": "plain_text", + "text": " ", + }, + }, + } + if payload.get("text", None) is not None: + title_input_block["element"]["initial_value"] = payload["text"] + return title_input_block + + +def _get_title_from_payload(payload): + title = payload["view"]["state"]["values"][MANUAL_INCIDENT_TITLE_INPUT_ID][ + FinishCreateIncidentFromSlashCommand.routing_uid() + ]["value"] + return title + + +def _get_message_input(payload): + message_input_block = { + "type": "input", + "block_id": MANUAL_INCIDENT_MESSAGE_INPUT_ID, + "label": { + "type": "plain_text", + "text": "Message:", + }, + "element": { + "type": "plain_text_input", + "action_id": FinishCreateIncidentFromSlashCommand.routing_uid(), + "multiline": True, + "placeholder": { + "type": "plain_text", + "text": " ", + }, + }, + "optional": True, + } + if payload.get("message", {}).get("text") is not None: + message_input_block["element"]["initial_value"] = payload["message"]["text"] + return message_input_block + + +def _get_message_from_payload(payload): + message = ( + payload["view"]["state"]["values"][MANUAL_INCIDENT_MESSAGE_INPUT_ID][ + FinishCreateIncidentFromSlashCommand.routing_uid() + ]["value"] + or "" + ) + return message + + +# _generate_input_id_prefix returns uniq str to not to preserve input's values between view update +# https://api.slack.com/methods/views.update#markdown +def _generate_input_id_prefix(): + return str(uuid4()) + + +STEPS_ROUTING = [ + { + "payload_type": scenario_step.PAYLOAD_TYPE_MESSAGE_ACTION, + "message_action_callback_id": StartCreateIncidentFromMessage.callback_id, + "step": StartCreateIncidentFromMessage, + }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, + "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "block_action_id": OnOrgChange.routing_uid(), + "step": OnOrgChange, + }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, + "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "block_action_id": OnTeamChange.routing_uid(), + "step": OnTeamChange, + }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, + "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "block_action_id": OnRouteChange.routing_uid(), + "step": OnRouteChange, + }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION, + "view_callback_id": FinishCreateIncidentFromMessage.routing_uid(), + "step": FinishCreateIncidentFromMessage, + }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_SLASH_COMMAND, + "command_name": StartCreateIncidentFromSlashCommand.command_name, + "step": StartCreateIncidentFromSlashCommand, + }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION, + "view_callback_id": FinishCreateIncidentFromSlashCommand.routing_uid(), + "step": FinishCreateIncidentFromSlashCommand, + }, +] diff --git a/engine/apps/slack/scenarios/public_menu.py b/engine/apps/slack/scenarios/public_menu.py deleted file mode 100644 index c11a7f7509..0000000000 --- a/engine/apps/slack/scenarios/public_menu.py +++ /dev/null @@ -1,536 +0,0 @@ -import json -import logging - -from django.apps import apps -from django.conf import settings -from django.http import JsonResponse -from django.utils import timezone - -from apps.slack.scenarios import scenario_step -from apps.slack.slack_client import SlackClientWithErrorHandling -from apps.slack.slack_client.exceptions import SlackAPIException - -from .step_mixins import CheckAlertIsUnarchivedMixin - -logger = logging.getLogger(__name__) -logger.setLevel(logging.DEBUG) - - -class InvitedToChannelStep(scenario_step.ScenarioStep): - - tags = [ - scenario_step.ScenarioStep.TAG_TRIGGERED_BY_SYSTEM, - ] - - def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): - if payload["event"]["user"] == slack_team_identity.bot_user_id: - channel_id = payload["event"]["channel"] - slack_client = SlackClientWithErrorHandling(slack_team_identity.bot_access_token) - channel = slack_client.api_call("conversations.info", channel=channel_id)["channel"] - - slack_team_identity.cached_channels.update_or_create( - slack_id=channel["id"], - defaults={ - "name": channel["name"], - "is_archived": channel["is_archived"], - "is_shared": channel["is_shared"], - "last_populated": timezone.now().date(), - }, - ) - else: - logger.info("Other user was invited to a channel with a bot.") - - -class CloseEphemeralButtonStep(scenario_step.ScenarioStep): - - random_prefix_for_routing = "qwe2id" - - def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): - return JsonResponse({"response_type": "ephemeral", "delete_original": True}) - - -class CreateIncidentManuallyStep(scenario_step.ScenarioStep): - command_name = [settings.SLACK_SLASH_COMMAND_NAME] - tags = [ - scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, - ] - - TITLE_INPUT_BLOCK_ID = "TITLE_INPUT" - MESSAGE_INPUT_BLOCK_ID = "MESSAGE_INPUT" - - def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): - try: - channel_id = payload["event"]["channel"] - except KeyError: - channel_id = payload["channel_id"] - - blocks = self.get_create_incident_blocks(payload, slack_team_identity, slack_user_identity) - - view = { - "type": "modal", - "callback_id": FinishCreateIncidentViewStep.routing_uid(), - "title": { - "type": "plain_text", - "text": "Create an Incident", - }, - "close": { - "type": "plain_text", - "text": "Cancel", - "emoji": True, - }, - "submit": { - "type": "plain_text", - "text": "Submit", - }, - "blocks": blocks, - "private_metadata": json.dumps({"channel_id": channel_id}), - } - self._slack_client.api_call( - "views.open", - trigger_id=payload["trigger_id"], - view=view, - ) - - def get_create_incident_blocks(self, payload, slack_team_identity, slack_user_identity): - blocks = [] - organization_selection_block = self.get_select_organization_route_element( - slack_team_identity, slack_user_identity - ) - title_incident_block = { - "type": "input", - "block_id": self.TITLE_INPUT_BLOCK_ID, - "label": { - "type": "plain_text", - "text": "Title:", - }, - "element": { - "type": "plain_text_input", - "action_id": FinishCreateIncidentViewStep.routing_uid(), - "placeholder": { - "type": "plain_text", - "text": " ", - }, - }, - } - if payload.get("text", None) is not None: - title_incident_block["element"]["initial_value"] = payload["text"] - message_incident_block = { - "type": "input", - "block_id": self.MESSAGE_INPUT_BLOCK_ID, - "label": { - "type": "plain_text", - "text": "Message:", - }, - "element": { - "type": "plain_text_input", - "action_id": FinishCreateIncidentViewStep.routing_uid(), - "multiline": True, - "placeholder": { - "type": "plain_text", - "text": " ", - }, - }, - "optional": True, - } - if payload.get("message", {}).get("text") is not None: - message_incident_block["element"]["initial_value"] = payload["message"]["text"] - - blocks.append(organization_selection_block) - blocks.append(title_incident_block) - blocks.append(message_incident_block) - return blocks - - -class FinishCreateIncidentViewStep(scenario_step.ScenarioStep): - - tags = [ - scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, - ] - - def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): - AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel") - ChannelFilter = apps.get_model("alerts", "ChannelFilter") - - Alert = apps.get_model("alerts", "Alert") - payload_values = payload["view"]["state"]["values"] - title = payload_values[CreateIncidentManuallyStep.TITLE_INPUT_BLOCK_ID][self.routing_uid()]["value"] - text = payload_values[CreateIncidentManuallyStep.MESSAGE_INPUT_BLOCK_ID][self.routing_uid()]["value"] or "" - - private_metadata = json.loads(payload["view"]["private_metadata"]) - # update private metadata in payload to use it in alert rendering - payload["view"]["private_metadata"] = private_metadata - - channel_id = private_metadata["channel_id"] - - alert_receive_channel = AlertReceiveChannel.get_or_create_manual_integration( - organization=self.organization, - integration=AlertReceiveChannel.INTEGRATION_MANUAL, - deleted_at=None, - defaults={"author": self.user}, - ) - try: - self._slack_client.api_call( - "chat.postEphemeral", - channel=channel_id, - user=slack_user_identity.slack_id, - text=":white_check_mark: Alert *{}* successfully submitted".format(title), - ) - except SlackAPIException as e: - if e.response["error"] == "channel_not_found": - self._slack_client.api_call( - "chat.postEphemeral", - channel=slack_user_identity.im_channel_id, - user=slack_user_identity.slack_id, - text=":white_check_mark: Alert *{}* successfully submitted".format(title), - ) - else: - raise e - user_verbal = self.user.get_user_verbal_for_team_for_slack() - channel_filter_pk = payload["view"]["state"]["values"][ - scenario_step.ScenarioStep.SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID - ][scenario_step.ScenarioStep.SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID]["selected_option"]["value"].split("-")[1] - channel_filter = ChannelFilter.objects.get(pk=channel_filter_pk) - Alert.create( - title=title, - message="{} created by {}".format( - text, - user_verbal, - ), - image_url=None, - link_to_upstream_details=None, - alert_receive_channel=alert_receive_channel, - raw_request_data=payload, - integration_unique_data={ - "created_by": user_verbal, - }, - force_route_id=channel_filter.pk, - ) - - -class CreateIncidentSubmenuStep(scenario_step.ScenarioStep): - callback_id = [ - "incident_create", - "incident_create_staging", - "incident_create_develop", - ] - tags = [ - scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, - ] - - def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): - try: - image_url = payload["message"]["files"][0]["permalink"] - except KeyError: - image_url = None - channel_id = payload["channel"]["id"] - - private_metadata = { - "channel_id": channel_id, - "image_url": image_url, - "message": { - "user": payload["message"].get("user"), - "text": payload["message"].get("text"), - "ts": payload["message"].get("ts"), - }, - } - - organization_selection_block = self.get_select_organization_route_element( - slack_team_identity, slack_user_identity - ) - view = { - "type": "modal", - "callback_id": FinishCreateIncidentSubmenuStep.routing_uid(), - "title": { - "type": "plain_text", - "text": "Create an Incident", - }, - "close": { - "type": "plain_text", - "text": "Cancel", - "emoji": True, - }, - "submit": { - "type": "plain_text", - "text": "Submit", - }, - "blocks": [organization_selection_block], - "private_metadata": json.dumps(private_metadata), - } - self._slack_client.api_call( - "views.open", - trigger_id=payload["trigger_id"], - view=view, - ) - - -class FinishCreateIncidentSubmenuStep(scenario_step.ScenarioStep): - - tags = [ - scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, - ] - - def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): - AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel") - Alert = apps.get_model("alerts", "Alert") - - private_metadata = json.loads(payload["view"]["private_metadata"]) - # update private metadata in payload to use it in alert rendering - payload["view"]["private_metadata"] = private_metadata - - channel_id = private_metadata["channel_id"] - author = private_metadata["message"]["user"] - - alert_receive_channel = AlertReceiveChannel.get_or_create_manual_integration( - organization=self.organization, - integration=AlertReceiveChannel.INTEGRATION_MANUAL, - deleted_at=None, - defaults={"author": self.user}, - ) - - author_username = "Unknown" - if author: - try: - author_username = self._slack_client.api_call( - "users.info", - user=author, - ) - author_username = author_username.get("user", {}).get("real_name", None) - except SlackAPIException: - pass - payload["view"]["private_metadata"]["author_username"] = author_username - - try: - permalink = self._slack_client.api_call( - "chat.getPermalink", - channel=private_metadata["channel_id"], - message_ts=private_metadata["message"]["ts"], - ) - permalink = permalink.get("permalink", None) - except SlackAPIException: - permalink = None - - permalink = "<{}|Original message...>".format(permalink) if permalink is not None else "" - Alert.create( - title="Message from {}".format(author_username), - message="{}\n{}".format(private_metadata["message"]["text"], permalink), - image_url=private_metadata["image_url"], - # Link to the slack message is not here bc it redirects to browser - link_to_upstream_details=None, - alert_receive_channel=alert_receive_channel, - raw_request_data=payload, - integration_unique_data={"created_by": self.user.get_user_verbal_for_team_for_slack()}, - ) - try: - self._slack_client.api_call( - "chat.postEphemeral", - channel=channel_id, - user=slack_user_identity.slack_id, - text=":white_check_mark: Alert successfully submitted", - ) - except SlackAPIException as e: - if e.response["error"] == "channel_not_found" or e.response["error"] == "user_not_in_channel": - self._slack_client.api_call( - "chat.postEphemeral", - channel=slack_user_identity.im_channel_id, - user=slack_user_identity.slack_id, - text=":white_check_mark: Alert successfully submitted", - ) - else: - raise e - - -class AddToResolutionoteStep(CheckAlertIsUnarchivedMixin, scenario_step.ScenarioStep): - callback_id = [ - "add_resolution_note", - "add_resolution_note_staging", - "add_resolution_note_develop", - ] - tags = [ - scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, - ] - - def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): - SlackMessage = apps.get_model("slack", "SlackMessage") - ResolutionNoteSlackMessage = apps.get_model("alerts", "ResolutionNoteSlackMessage") - ResolutionNote = apps.get_model("alerts", "ResolutionNote") - SlackUserIdentity = apps.get_model("slack", "SlackUserIdentity") - - try: - channel_id = payload["channel"]["id"] - except KeyError: - raise Exception("Channel was not found") - - if self.organization and self.organization.general_log_channel_id is None: - try: - return self._slack_client.api_call( - "chat.postEphemeral", - channel=channel_id, - user=slack_user_identity.slack_id, - attachments=CreateIncidentSubmenuStep.finish_configuration_attachments(self.organization), - ) - except SlackAPIException as e: - if e.response["error"] == "channel_not_found" or e.response["error"] == "user_not_in_channel": - return self._slack_client.api_call( - "chat.postEphemeral", - channel=slack_user_identity.im_channel_id, - user=slack_user_identity.slack_id, - attachments=CreateIncidentSubmenuStep.finish_configuration_attachments(self.organization), - ) - else: - raise e - - warning_text = "Unable to add this message to resolution note, this command works only in incident threads." - - try: - slack_message = SlackMessage.objects.get( - slack_id=payload["message"]["thread_ts"], - _slack_team_identity=slack_team_identity, - channel_id=channel_id, - ) - except KeyError: - self.open_warning_window(payload, warning_text) - return - except SlackMessage.DoesNotExist: - self.open_warning_window(payload, warning_text) - return - - try: - alert_group = slack_message.get_alert_group() - except SlackMessage.alert.RelatedObjectDoesNotExist as e: - self.open_warning_window(payload, warning_text) - print( - f"Exception: tried to add message from thread to Resolution Note: " - f"Slack Team Identity pk: {self.slack_team_identity.pk}, " - f"Slack Message id: {slack_message.slack_id}" - ) - raise e - - if not self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): - return - - if payload["message"]["type"] == "message" and "user" in payload["message"]: - message_ts = payload["message_ts"] - thread_ts = payload["message"]["thread_ts"] - - result = self._slack_client.api_call( - "chat.getPermalink", - channel=channel_id, - message_ts=message_ts, - ) - permalink = None - if result["permalink"] is not None: - permalink = result["permalink"] - - if payload["message"]["ts"] in [ - message.ts - for message in alert_group.resolution_note_slack_messages.filter(added_to_resolution_note=True) - ]: - warning_text = "Unable to add the same message again." - self.open_warning_window(payload, warning_text) - return - - elif len(payload["message"]["text"]) > 2900: - warning_text = ( - "Unable to add the message to Resolution note: the message is too long ({}). " - "Max length - 2900 symbols.".format(len(payload["message"]["text"])) - ) - self.open_warning_window(payload, warning_text) - return - - else: - try: - resolution_note_slack_message = ResolutionNoteSlackMessage.objects.get( - ts=message_ts, thread_ts=thread_ts - ) - except ResolutionNoteSlackMessage.DoesNotExist: - text = payload["message"]["text"] - text = text.replace("```", "") - slack_message = SlackMessage.objects.get( - slack_id=thread_ts, - _slack_team_identity=slack_team_identity, - channel_id=channel_id, - ) - alert_group = slack_message.get_alert_group() - author_slack_user_identity = SlackUserIdentity.objects.get( - slack_id=payload["message"]["user"], slack_team_identity=slack_team_identity - ) - author_user = self.organization.users.get(slack_user_identity=author_slack_user_identity) - resolution_note_slack_message = ResolutionNoteSlackMessage( - alert_group=alert_group, - user=author_user, - added_by_user=self.user, - text=text, - slack_channel_id=channel_id, - thread_ts=thread_ts, - ts=message_ts, - permalink=permalink, - ) - resolution_note_slack_message.added_to_resolution_note = True - resolution_note_slack_message.save() - resolution_note = resolution_note_slack_message.get_resolution_note() - if resolution_note is None: - ResolutionNote( - alert_group=alert_group, - author=resolution_note_slack_message.user, - source=ResolutionNote.Source.SLACK, - resolution_note_slack_message=resolution_note_slack_message, - ).save() - else: - resolution_note.recreate() - alert_group.drop_cached_after_resolve_report_json() - alert_group.schedule_cache_for_web() - try: - self._slack_client.api_call( - "reactions.add", - channel=channel_id, - name="memo", - timestamp=resolution_note_slack_message.ts, - ) - except SlackAPIException: - pass - - self._update_slack_message(alert_group) - else: - warning_text = "Unable to add this message to resolution note." - self.open_warning_window(payload, warning_text) - return - - -STEPS_ROUTING = [ - { - "payload_type": scenario_step.PAYLOAD_TYPE_SLASH_COMMAND, - "command_name": CreateIncidentManuallyStep.command_name, - "step": CreateIncidentManuallyStep, - }, - { - "payload_type": scenario_step.PAYLOAD_TYPE_EVENT_CALLBACK, - "event_type": scenario_step.EVENT_TYPE_MEMBER_JOINED_CHANNEL, - "step": InvitedToChannelStep, - }, - { - "payload_type": scenario_step.PAYLOAD_TYPE_INTERACTIVE_MESSAGE, - "action_type": scenario_step.ACTION_TYPE_BUTTON, - "action_name": CloseEphemeralButtonStep.routing_uid(), - "step": CloseEphemeralButtonStep, - }, - { - "payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION, - "view_callback_id": FinishCreateIncidentViewStep.routing_uid(), - "step": FinishCreateIncidentViewStep, - }, - { - "payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION, - "view_callback_id": FinishCreateIncidentSubmenuStep.routing_uid(), - "step": FinishCreateIncidentSubmenuStep, - }, - { - "payload_type": scenario_step.PAYLOAD_TYPE_MESSAGE_ACTION, - "message_action_callback_id": CreateIncidentSubmenuStep.callback_id, - "step": CreateIncidentSubmenuStep, - }, - { - "payload_type": scenario_step.PAYLOAD_TYPE_MESSAGE_ACTION, - "message_action_callback_id": AddToResolutionoteStep.callback_id, - "step": AddToResolutionoteStep, - }, -] diff --git a/engine/apps/slack/scenarios/resolution_note.py b/engine/apps/slack/scenarios/resolution_note.py index f8bea2c38e..e979cb965c 100644 --- a/engine/apps/slack/scenarios/resolution_note.py +++ b/engine/apps/slack/scenarios/resolution_note.py @@ -15,6 +15,144 @@ logger = logging.getLogger(__name__) +class AddToResolutionNoteStep(CheckAlertIsUnarchivedMixin, scenario_step.ScenarioStep): + callback_id = [ + "add_resolution_note", + "add_resolution_note_staging", + "add_resolution_note_develop", + ] + tags = [ + scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE, + ] + + def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): + SlackMessage = apps.get_model("slack", "SlackMessage") + ResolutionNoteSlackMessage = apps.get_model("alerts", "ResolutionNoteSlackMessage") + ResolutionNote = apps.get_model("alerts", "ResolutionNote") + SlackUserIdentity = apps.get_model("slack", "SlackUserIdentity") + + try: + channel_id = payload["channel"]["id"] + except KeyError: + raise Exception("Channel was not found") + + warning_text = "Unable to add this message to resolution note, this command works only in incident threads." + + try: + slack_message = SlackMessage.objects.get( + slack_id=payload["message"]["thread_ts"], + _slack_team_identity=slack_team_identity, + channel_id=channel_id, + ) + except KeyError: + self.open_warning_window(payload, warning_text) + return + except SlackMessage.DoesNotExist: + self.open_warning_window(payload, warning_text) + return + + try: + alert_group = slack_message.get_alert_group() + except SlackMessage.alert.RelatedObjectDoesNotExist as e: + self.open_warning_window(payload, warning_text) + print( + f"Exception: tried to add message from thread to Resolution Note: " + f"Slack Team Identity pk: {self.slack_team_identity.pk}, " + f"Slack Message id: {slack_message.slack_id}" + ) + raise e + + if not self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): + return + + if payload["message"]["type"] == "message" and "user" in payload["message"]: + message_ts = payload["message_ts"] + thread_ts = payload["message"]["thread_ts"] + + result = self._slack_client.api_call( + "chat.getPermalink", + channel=channel_id, + message_ts=message_ts, + ) + permalink = None + if result["permalink"] is not None: + permalink = result["permalink"] + + if payload["message"]["ts"] in [ + message.ts + for message in alert_group.resolution_note_slack_messages.filter(added_to_resolution_note=True) + ]: + warning_text = "Unable to add the same message again." + self.open_warning_window(payload, warning_text) + return + + elif len(payload["message"]["text"]) > 2900: + warning_text = ( + "Unable to add the message to Resolution note: the message is too long ({}). " + "Max length - 2900 symbols.".format(len(payload["message"]["text"])) + ) + self.open_warning_window(payload, warning_text) + return + + else: + try: + resolution_note_slack_message = ResolutionNoteSlackMessage.objects.get( + ts=message_ts, thread_ts=thread_ts + ) + except ResolutionNoteSlackMessage.DoesNotExist: + text = payload["message"]["text"] + text = text.replace("```", "") + slack_message = SlackMessage.objects.get( + slack_id=thread_ts, + _slack_team_identity=slack_team_identity, + channel_id=channel_id, + ) + alert_group = slack_message.get_alert_group() + author_slack_user_identity = SlackUserIdentity.objects.get( + slack_id=payload["message"]["user"], slack_team_identity=slack_team_identity + ) + author_user = self.organization.users.get(slack_user_identity=author_slack_user_identity) + resolution_note_slack_message = ResolutionNoteSlackMessage( + alert_group=alert_group, + user=author_user, + added_by_user=self.user, + text=text, + slack_channel_id=channel_id, + thread_ts=thread_ts, + ts=message_ts, + permalink=permalink, + ) + resolution_note_slack_message.added_to_resolution_note = True + resolution_note_slack_message.save() + resolution_note = resolution_note_slack_message.get_resolution_note() + if resolution_note is None: + ResolutionNote( + alert_group=alert_group, + author=resolution_note_slack_message.user, + source=ResolutionNote.Source.SLACK, + resolution_note_slack_message=resolution_note_slack_message, + ).save() + else: + resolution_note.recreate() + alert_group.drop_cached_after_resolve_report_json() + alert_group.schedule_cache_for_web() + try: + self._slack_client.api_call( + "reactions.add", + channel=channel_id, + name="memo", + timestamp=resolution_note_slack_message.ts, + ) + except SlackAPIException: + pass + + self._update_slack_message(alert_group) + else: + warning_text = "Unable to add this message to resolution note." + self.open_warning_window(payload, warning_text) + return + + class UpdateResolutionNoteStep(scenario_step.ScenarioStep): def process_signal(self, alert_group, resolution_note): if resolution_note.deleted_at: @@ -625,4 +763,9 @@ def process_scenario(self, slack_user_identity, slack_team_identity, payload, ac "block_action_id": AddRemoveThreadMessageStep.routing_uid(), "step": AddRemoveThreadMessageStep, }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_MESSAGE_ACTION, + "message_action_callback_id": AddToResolutionNoteStep.callback_id, + "step": AddToResolutionNoteStep, + }, ] diff --git a/engine/apps/slack/scenarios/scenario_step.py b/engine/apps/slack/scenarios/scenario_step.py index f2b51173bb..b49fc4c5df 100644 --- a/engine/apps/slack/scenarios/scenario_step.py +++ b/engine/apps/slack/scenarios/scenario_step.py @@ -135,28 +135,6 @@ def channel(self, user, payload): channel = user.im_channel_id return channel - @staticmethod - def finish_configuration_attachments(organization): - text = ( - f"A few steps left to finish configuration!\n" - f"Go to your <{organization.web_link}?page=slack|OnCall workspace> and select default channel " - f"for your incidents!" - ) - return [ - { - "color": "#008000", - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": text, - }, - } - ], - } - ] - @classmethod def routing_uid(cls): return cls.random_prefix_for_routing + cls.__name__ @@ -431,55 +409,3 @@ def get_select_user_element( element["initial_option"] = initial_option return element - - def get_select_organization_route_element(self, slack_team_identity, slack_user_identity): - AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel") - - organizations = slack_team_identity.organizations.filter( - users__slack_user_identity=slack_user_identity - ).distinct() - organizations_options = [] - - for organization in organizations: - manual_integration = AlertReceiveChannel.get_or_create_manual_integration( - organization=organization, - integration=AlertReceiveChannel.INTEGRATION_MANUAL, - deleted_at=None, - defaults={"author": self.user}, - ) - - for route in manual_integration.channel_filters.all(): - filtering_term = f'"{route.filtering_term}"' - if route.is_default: - filtering_term = "default" - organizations_options.append( - { - "text": { - "type": "plain_text", - "text": f"{organization.org_title}: {filtering_term}", - "emoji": True, - }, - "value": f"{organization.pk}-{route.pk}", - } - ) - - organization_selection_block = { - "type": "input", - "block_id": ScenarioStep.SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID, - "element": { - "type": "static_select", - "placeholder": { - "type": "plain_text", - "text": "Select organization", - }, - "action_id": ScenarioStep.SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID, - "options": organizations_options, - "initial_option": organizations_options[0], - }, - "label": { - "type": "plain_text", - "text": "Select organization and route:", - "emoji": True, - }, - } - return organization_selection_block diff --git a/engine/apps/slack/views.py b/engine/apps/slack/views.py index 5990b0b6ff..a9818c0c41 100644 --- a/engine/apps/slack/views.py +++ b/engine/apps/slack/views.py @@ -16,11 +16,12 @@ from apps.base.utils import live_settings from apps.slack.scenarios.alertgroup_appearance import STEPS_ROUTING as ALERTGROUP_APPEARANCE_ROUTING from apps.slack.scenarios.distribute_alerts import STEPS_ROUTING as DISTRIBUTION_STEPS_ROUTING +from apps.slack.scenarios.invited_to_channel import STEPS_ROUTING as INVITED_TO_CHANNEL_ROUTING +from apps.slack.scenarios.manual_incident import STEPS_ROUTING as MANUAL_INCIDENT_ROUTING # Importing routes from scenarios from apps.slack.scenarios.onboarding import STEPS_ROUTING as ONBOARDING_STEPS_ROUTING from apps.slack.scenarios.profile_update import STEPS_ROUTING as PROFILE_UPDATE_ROUTING -from apps.slack.scenarios.public_menu import STEPS_ROUTING as PUBLIC_MENU_ROUTING from apps.slack.scenarios.resolution_note import STEPS_ROUTING as RESOLUTION_NOTE_ROUTING from apps.slack.scenarios.scenario_step import ( EVENT_SUBTYPE_BOT_MESSAGE, @@ -57,7 +58,7 @@ SCENARIOS_ROUTES = [] # Add all other routes here SCENARIOS_ROUTES.extend(ONBOARDING_STEPS_ROUTING) SCENARIOS_ROUTES.extend(DISTRIBUTION_STEPS_ROUTING) -SCENARIOS_ROUTES.extend(PUBLIC_MENU_ROUTING) +SCENARIOS_ROUTES.extend(INVITED_TO_CHANNEL_ROUTING) SCENARIOS_ROUTES.extend(SCHEDULES_ROUTING) SCENARIOS_ROUTES.extend(SLACK_CHANNEL_INTEGRATION_ROUTING) SCENARIOS_ROUTES.extend(ALERTGROUP_APPEARANCE_ROUTING) @@ -65,6 +66,7 @@ SCENARIOS_ROUTES.extend(SLACK_USERGROUP_UPDATE_ROUTING) SCENARIOS_ROUTES.extend(CHANNEL_ROUTING) SCENARIOS_ROUTES.extend(PROFILE_UPDATE_ROUTING) +SCENARIOS_ROUTES.extend(MANUAL_INCIDENT_ROUTING) logger = logging.getLogger(__name__) diff --git a/engine/config_integrations/manual.py b/engine/config_integrations/manual.py index bf1825deff..43f4852b5a 100644 --- a/engine/config_integrations/manual.py +++ b/engine/config_integrations/manual.py @@ -12,39 +12,28 @@ description = None # Default templates -slack_title = """{% set metadata = payload.view.private_metadata %} -{%-if "message" in metadata -%} -{% set title = "Message from @" + metadata.author_username %} -{%- else -%} -{% set title = payload.view.state["values"].TITLE_INPUT.FinishCreateIncidentViewStep.value %} -{%- endif -%} -*<{{ grafana_oncall_link }}|#{{ grafana_oncall_incident_id }} {{ title }}>* via {{ integration_name }} +slack_title = """\ +*<{{ grafana_oncall_link }}|#{{ grafana_oncall_incident_id }} {{ payload.oncall.title }}>* via {{ integration_name }} {% if source_link %} (*<{{ source_link }}|source>*) -{%- endif %} +{% endif %} """ -slack_message = """{% set metadata = payload.view.private_metadata %} -{% if "message" in metadata -%} -{{ metadata.message.text }} +slack_message = """{{ payload.oncall.message }} - -{%- else -%} -{{ payload.view.state["values"].MESSAGE_INPUT.FinishCreateIncidentViewStep.value }} - -created by {{ payload.user.name }} -{%- endif -%}""" +created by {{ payload.oncall.author_username }} +""" slack_image_url = None -web_title = """{% set metadata = payload.view.private_metadata %} -{%-if "message" in metadata -%} -{{ "Message from @" + metadata.author_username }} -{%- else -%} -{{ payload.view.state["values"].TITLE_INPUT.FinishCreateIncidentViewStep.value }} -{%- endif -%}""" +web_title = "{{ payload.oncall.title }}" -web_message = slack_message +web_message = """{{ payload.oncall.message }} +{% if source_link %} +<{{ source_link }} | Link to the original message > +{% endif %} +created by {{ payload.oncall.author_username }} +""" web_image_url = slack_image_url @@ -62,11 +51,7 @@ telegram_image_url = slack_image_url -source_link = """\ -{% set metadata = payload.view.private_metadata %} -{%- if "message" in metadata %} -https://{{ payload.team.domain }}.slack.com/archives/{{ payload.channel.id }}/{{ payload.message.ts }} -{% endif -%}""" +source_link = "{{ payload.oncall.permalink }}" grouping_id = """{{ payload }}""" From 35a31701f60e19734ccce08b2502a17acd31aae0 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Tue, 12 Jul 2022 14:15:46 +0400 Subject: [PATCH 15/15] Update CHANGELOG --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e55bb4d175..9602ff0689 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## v1.0.5 (2022-07-12) + +- Manual Incidents enabled for teams +- Fix phone notifications for OSS +- Public API improvements + ## 1.0.4 (2022-06-28) - Allow Telegram DMs without channel connection.