diff --git a/.github/workflows/linting-and-tests.yml b/.github/workflows/linting-and-tests.yml index 351f698b..0cba27ae 100644 --- a/.github/workflows/linting-and-tests.yml +++ b/.github/workflows/linting-and-tests.yml @@ -262,6 +262,8 @@ jobs: pytest -x end-to-end-tests: + # TODO: reenable this job once https://github.com/grafana/oncall/issues/1692 is fixed + if: ${{ false }} runs-on: ubuntu-latest name: "End to end tests - Grafana: ${{ matrix.grafana-image-tag }}" strategy: diff --git a/CHANGELOG.md b/CHANGELOG.md index 278c8470..72dc0e52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## v1.2.8 (2023-04-06) + +### Changed + +- Allow editing assigned team via public api ([1619](https://github.com/grafana/oncall/pull/1619)) +- Disable mentions when resolution note is created by @iskhakov ([1696](https://github.com/grafana/oncall/pull/1696)) +- Display warnings on users page in a clean and consistent way by @iskhakov ([#1681](https://github.com/grafana/oncall/pull/1681)) + ## v1.2.7 (2023-04-03) ### Added diff --git a/docs/sources/configure-user-settings/_index.md b/docs/sources/configure-user-settings/_index.md index 5b2ea1e4..4993db89 100644 --- a/docs/sources/configure-user-settings/_index.md +++ b/docs/sources/configure-user-settings/_index.md @@ -51,7 +51,7 @@ integrations, escalation chains, and schedules. OnCall teams are automatically s [Grafana teams](https://grafana.com/docs/grafana/latest/administration/team-management/) created at the organization level of your Grafana instance. To modify global settings like team name or team members, navigate to **Configuration > Teams**. For OnCall-specific team settings, -go to **Alerts and Incidents > OnCall > Settings > Teams and Access Settings**. +go to **Alerts & IRM > OnCall > Settings > Teams and Access Settings**. This section displays a list of teams, allowing you to configure team visibility and access to team resources for all Grafana users, or only admins and team members. You can also set a default team, which is a user-specific setting; diff --git a/engine/apps/alerts/escalation_snapshot/serializers/escalation_policy_snapshot.py b/engine/apps/alerts/escalation_snapshot/serializers/escalation_policy_snapshot.py index 82908ff4..d097833e 100644 --- a/engine/apps/alerts/escalation_snapshot/serializers/escalation_policy_snapshot.py +++ b/engine/apps/alerts/escalation_snapshot/serializers/escalation_policy_snapshot.py @@ -4,6 +4,7 @@ from apps.alerts.models.custom_button import CustomButton from apps.alerts.models.escalation_policy import EscalationPolicy from apps.schedules.models import OnCallSchedule from apps.user_management.models import User +from apps.webhooks.models import Webhook class PrimaryKeyRelatedFieldWithNoneValue(serializers.PrimaryKeyRelatedField): @@ -58,6 +59,7 @@ class EscalationPolicySnapshotSerializer(serializers.ModelSerializer): escalation_counter = serializers.IntegerField(default=0) passed_last_time = serializers.DateTimeField(allow_null=True, default=None) custom_button_trigger = PrimaryKeyRelatedFieldWithNoneValue(allow_null=True, queryset=CustomButton.objects) + custom_webhook = PrimaryKeyRelatedFieldWithNoneValue(allow_null=True, queryset=Webhook.objects, default=None) notify_schedule = PrimaryKeyRelatedFieldWithNoneValue(allow_null=True, queryset=OnCallSchedule.objects) num_alerts_in_window = serializers.IntegerField(allow_null=True, default=None) num_minutes_in_window = serializers.IntegerField(allow_null=True, default=None) @@ -77,6 +79,7 @@ class EscalationPolicySnapshotSerializer(serializers.ModelSerializer): "num_alerts_in_window", "num_minutes_in_window", "custom_button_trigger", + "custom_webhook", "notify_schedule", "notify_to_group", "escalation_counter", diff --git a/engine/apps/alerts/escalation_snapshot/snapshot_classes/escalation_policy_snapshot.py b/engine/apps/alerts/escalation_snapshot/snapshot_classes/escalation_policy_snapshot.py index a082270e..6846b1d2 100644 --- a/engine/apps/alerts/escalation_snapshot/snapshot_classes/escalation_policy_snapshot.py +++ b/engine/apps/alerts/escalation_snapshot/snapshot_classes/escalation_policy_snapshot.py @@ -10,6 +10,7 @@ from apps.alerts.models.alert_group_log_record import AlertGroupLogRecord from apps.alerts.models.escalation_policy import EscalationPolicy from apps.alerts.tasks import ( custom_button_result, + custom_webhook_result, notify_all_task, notify_group_task, notify_user_task, @@ -32,6 +33,7 @@ class EscalationPolicySnapshot: "num_alerts_in_window", "num_minutes_in_window", "custom_button_trigger", + "custom_webhook", "notify_schedule", "notify_to_group", "escalation_counter", @@ -57,6 +59,7 @@ class EscalationPolicySnapshot: num_alerts_in_window, num_minutes_in_window, custom_button_trigger, + custom_webhook, notify_schedule, notify_to_group, escalation_counter, @@ -74,6 +77,7 @@ class EscalationPolicySnapshot: self.num_alerts_in_window = num_alerts_in_window self.num_minutes_in_window = num_minutes_in_window self.custom_button_trigger = custom_button_trigger + self.custom_webhook = custom_webhook self.notify_schedule = notify_schedule self.notify_to_group = notify_to_group self.escalation_counter = escalation_counter # used for STEP_REPEAT_ESCALATION_N_TIMES @@ -116,6 +120,7 @@ class EscalationPolicySnapshot: EscalationPolicy.STEP_NOTIFY_SCHEDULE: self._escalation_step_notify_on_call_schedule, EscalationPolicy.STEP_NOTIFY_SCHEDULE_IMPORTANT: self._escalation_step_notify_on_call_schedule, EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON: self._escalation_step_trigger_custom_button, + EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK: self._escalation_step_trigger_custom_webhook, EscalationPolicy.STEP_NOTIFY_USERS_QUEUE: self._escalation_step_notify_users_queue, EscalationPolicy.STEP_NOTIFY_IF_TIME: self._escalation_step_notify_if_time, EscalationPolicy.STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW: self._escalation_step_notify_if_num_alerts_in_time_window, @@ -431,6 +436,29 @@ class EscalationPolicySnapshot: log_record.save() self._execute_tasks(tasks) + def _escalation_step_trigger_custom_webhook(self, alert_group, **kwargs) -> None: + tasks = [] + webhook = self.custom_webhook + if webhook is not None: + custom_webhook_task = custom_webhook_result.signature( + (webhook.pk, alert_group.pk), + { + "escalation_policy_pk": self.id, + }, + immutable=True, + ) + tasks.append(custom_webhook_task) + else: + log_record = AlertGroupLogRecord( + type=AlertGroupLogRecord.TYPE_ESCALATION_FAILED, + alert_group=alert_group, + escalation_policy=self.escalation_policy, + escalation_error_code=AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_CUSTOM_BUTTON_STEP_IS_NOT_CONFIGURED, + escalation_policy_step=self.step, + ) + log_record.save() + self._execute_tasks(tasks) + def _escalation_step_repeat_escalation_n_times(self, alert_group, **kwargs) -> StepExecutionResultData: if self.escalation_counter < EscalationPolicy.MAX_TIMES_REPEAT: log_record = AlertGroupLogRecord( diff --git a/engine/apps/alerts/migrations/0011_auto_20230329_1617.py b/engine/apps/alerts/migrations/0011_auto_20230329_1617.py new file mode 100644 index 00000000..8526f275 --- /dev/null +++ b/engine/apps/alerts/migrations/0011_auto_20230329_1617.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.18 on 2023-03-29 16:17 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('webhooks', '0002_auto_20230320_1604'), + ('alerts', '0010_channelfilter_filtering_term_type'), + ] + + operations = [ + migrations.AddField( + model_name='escalationpolicy', + name='custom_webhook', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='escalation_policies', to='webhooks.webhook'), + ), + migrations.AlterField( + model_name='escalationpolicy', + name='step', + field=models.IntegerField(choices=[(0, 'Wait'), (1, 'Notify User'), (2, 'Notify Whole Channel'), (3, 'Repeat Escalation (5 times max)'), (4, 'Resolve'), (5, 'Notify Group'), (6, 'Notify Schedule'), (7, 'Notify User (Important)'), (8, 'Notify Group (Important)'), (9, 'Notify Schedule (Important)'), (10, 'Trigger Outgoing Webhook'), (11, 'Notify User (next each time)'), (12, 'Continue escalation only if time is from'), (13, 'Notify multiple Users'), (14, 'Notify multiple Users (Important)'), (15, 'Continue escalation if >X alerts per Y minutes'), (16, 'Trigger Webhook')], default=None, null=True), + ), + ] diff --git a/engine/apps/alerts/models/alert_group_log_record.py b/engine/apps/alerts/models/alert_group_log_record.py index 86bdde42..bc54d671 100644 --- a/engine/apps/alerts/models/alert_group_log_record.py +++ b/engine/apps/alerts/models/alert_group_log_record.py @@ -135,7 +135,8 @@ class AlertGroupLogRecord(models.Model): ERROR_ESCALATION_TRIGGER_CUSTOM_BUTTON_STEP_IS_NOT_CONFIGURED, ERROR_ESCALATION_NOTIFY_IN_SLACK, ERROR_ESCALATION_NOTIFY_IF_NUM_ALERTS_IN_WINDOW_STEP_IS_NOT_CONFIGURED, - ) = range(17) + ERROR_ESCALATION_TRIGGER_CUSTOM_WEBHOOK_ERROR, + ) = range(18) type = models.IntegerField(choices=TYPE_CHOICES) @@ -436,18 +437,18 @@ class AlertGroupLogRecord(models.Model): f"{f' by {author_name}' if author_name else ''}" ) elif self.type == AlertGroupLogRecord.TYPE_CUSTOM_BUTTON_TRIGGERED: + webhook_name = "" + trigger = None if step_specific_info is not None: - custom_button_name = step_specific_info.get("custom_button_name") - custom_button_name = f"`{custom_button_name}`" or "" + webhook_name = step_specific_info.get("webhook_name") or step_specific_info.get("custom_button_name") + trigger = step_specific_info.get("trigger") elif self.custom_button is not None: - custom_button_name = f"`{self.custom_button.name}`" + webhook_name = f"`{self.custom_button.name}`" + if trigger is None and self.author: + trigger = f"{author_name}" else: - custom_button_name = "" - result += f"outgoing webhook {custom_button_name} triggered by " - if self.author: - result += f"{author_name}" - else: - result += "escalation chain" + trigger = trigger or "escalation chain" + result += f"outgoing webhook `{webhook_name}` triggered by {trigger}" elif self.type == AlertGroupLogRecord.TYPE_FAILED_ATTACHMENT: if self.alert_group.slack_message is not None: result += ( @@ -491,6 +492,14 @@ class AlertGroupLogRecord(models.Model): == AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_CUSTOM_BUTTON_STEP_IS_NOT_CONFIGURED ): result += 'skipped escalation step "Trigger Outgoing Webhook" because it is not configured' + elif self.escalation_error_code == AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_CUSTOM_WEBHOOK_ERROR: + webhook_name = trigger = "" + if step_specific_info is not None: + webhook_name = step_specific_info.get("webhook_name", "") + trigger = step_specific_info.get("trigger", "") + result += f"skipped {trigger} outgoing webhook `{webhook_name}`" + if self.reason: + result += f": {self.reason}" elif self.escalation_error_code == AlertGroupLogRecord.ERROR_ESCALATION_NOTIFY_IF_TIME_IS_NOT_CONFIGURED: result += 'skipped escalation step "Continue escalation if time" because it is not configured' elif ( diff --git a/engine/apps/alerts/models/escalation_policy.py b/engine/apps/alerts/models/escalation_policy.py index cabacb36..5852855a 100644 --- a/engine/apps/alerts/models/escalation_policy.py +++ b/engine/apps/alerts/models/escalation_policy.py @@ -44,7 +44,8 @@ class EscalationPolicy(OrderedModel): STEP_NOTIFY_MULTIPLE_USERS, STEP_NOTIFY_MULTIPLE_USERS_IMPORTANT, STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW, - ) = range(16) + STEP_TRIGGER_CUSTOM_WEBHOOK, + ) = range(17) # Must be the same order as previous STEP_CHOICES = ( @@ -64,6 +65,7 @@ class EscalationPolicy(OrderedModel): (STEP_NOTIFY_MULTIPLE_USERS, "Notify multiple Users"), (STEP_NOTIFY_MULTIPLE_USERS_IMPORTANT, "Notify multiple Users (Important)"), (STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW, "Continue escalation if >X alerts per Y minutes"), + (STEP_TRIGGER_CUSTOM_WEBHOOK, "Trigger Webhook"), ) # Ordered step choices available for internal api. @@ -79,6 +81,7 @@ class EscalationPolicy(OrderedModel): STEP_NOTIFY_GROUP, # Other STEP_TRIGGER_CUSTOM_BUTTON, + STEP_TRIGGER_CUSTOM_WEBHOOK, STEP_NOTIFY_USERS_QUEUE, STEP_NOTIFY_IF_TIME, STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW, @@ -100,6 +103,7 @@ class EscalationPolicy(OrderedModel): STEP_NOTIFY_MULTIPLE_USERS, STEP_NOTIFY_MULTIPLE_USERS_IMPORTANT, STEP_TRIGGER_CUSTOM_BUTTON, + STEP_TRIGGER_CUSTOM_WEBHOOK, STEP_REPEAT_ESCALATION_N_TIMES, ] @@ -122,6 +126,7 @@ class EscalationPolicy(OrderedModel): ), # Other STEP_TRIGGER_CUSTOM_BUTTON: ("Trigger outgoing webhook {{custom_action}}", "Trigger outgoing webhook"), + STEP_TRIGGER_CUSTOM_WEBHOOK: ("Trigger webhook {{custom_webhook}}", "Trigger webhook"), STEP_NOTIFY_USERS_QUEUE: ("Round robin notification for {{users}}", "Notify users one by one (round-robin)"), STEP_NOTIFY_IF_TIME: ( "Continue escalation if current time is in {{timerange}} ", @@ -142,6 +147,7 @@ class EscalationPolicy(OrderedModel): STEP_FINAL_NOTIFYALL, STEP_FINAL_RESOLVE, STEP_TRIGGER_CUSTOM_BUTTON, + STEP_TRIGGER_CUSTOM_WEBHOOK, STEP_NOTIFY_USERS_QUEUE, STEP_NOTIFY_IF_TIME, STEP_REPEAT_ESCALATION_N_TIMES, @@ -186,6 +192,7 @@ class EscalationPolicy(OrderedModel): STEP_FINAL_RESOLVE, STEP_FINAL_NOTIFYALL, STEP_TRIGGER_CUSTOM_BUTTON, + STEP_TRIGGER_CUSTOM_WEBHOOK, STEP_NOTIFY_IF_TIME, STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW, STEP_REPEAT_ESCALATION_N_TIMES, @@ -202,6 +209,7 @@ class EscalationPolicy(OrderedModel): STEP_NOTIFY_SCHEDULE: "notify_on_call_from_schedule", STEP_NOTIFY_SCHEDULE_IMPORTANT: "notify_on_call_from_schedule", STEP_TRIGGER_CUSTOM_BUTTON: "trigger_action", + STEP_TRIGGER_CUSTOM_WEBHOOK: "trigger_webhook", STEP_NOTIFY_USERS_QUEUE: "notify_person_next_each_time", STEP_NOTIFY_MULTIPLE_USERS: "notify_persons", STEP_NOTIFY_MULTIPLE_USERS_IMPORTANT: "notify_persons", @@ -256,6 +264,14 @@ class EscalationPolicy(OrderedModel): null=True, ) + custom_webhook = models.ForeignKey( + "webhooks.Webhook", + on_delete=models.CASCADE, + related_name="escalation_policies", + default=None, + null=True, + ) + ONE_MINUTE = timezone.timedelta(minutes=1) FIVE_MINUTES = timezone.timedelta(minutes=5) FIFTEEN_MINUTES = timezone.timedelta(minutes=15) @@ -350,6 +366,10 @@ class EscalationPolicy(OrderedModel): if self.custom_button_trigger: result["outgoing_webhook"] = self.custom_button_trigger.insight_logs_verbal result["outgoing_webhook_id"] = self.custom_button_trigger.public_primary_key + elif self.step == EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK: + if self.custom_button_trigger: + result["outgoing_webhook"] = self.custom_webhook.insight_logs_verbal + result["outgoing_webhook_id"] = self.custom_webhook.public_primary_key elif self.step in [ EscalationPolicy.STEP_NOTIFY_USERS_QUEUE, EscalationPolicy.STEP_NOTIFY_MULTIPLE_USERS, diff --git a/engine/apps/alerts/tasks/__init__.py b/engine/apps/alerts/tasks/__init__.py index 70146f9c..8df7d0c0 100644 --- a/engine/apps/alerts/tasks/__init__.py +++ b/engine/apps/alerts/tasks/__init__.py @@ -8,6 +8,7 @@ from .check_escalation_finished import check_escalation_finished_task # noqa: F from .create_contact_points_for_datasource import create_contact_points_for_datasource # noqa: F401 from .create_contact_points_for_datasource import schedule_create_contact_points_for_datasource # noqa: F401 from .custom_button_result import custom_button_result # noqa: F401 +from .custom_webhook_result import custom_webhook_result # noqa: F401 from .delete_alert_group import delete_alert_group # noqa: F401 from .distribute_alert import distribute_alert # noqa: F401 from .escalate_alert_group import escalate_alert_group # noqa: F401 diff --git a/engine/apps/alerts/tasks/custom_webhook_result.py b/engine/apps/alerts/tasks/custom_webhook_result.py new file mode 100644 index 00000000..149a5900 --- /dev/null +++ b/engine/apps/alerts/tasks/custom_webhook_result.py @@ -0,0 +1,16 @@ +import logging + +from django.conf import settings + +from common.custom_celery_tasks import shared_dedicated_queue_retry_task + +logger = logging.getLogger(__name__) + + +@shared_dedicated_queue_retry_task( + autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None +) +def custom_webhook_result(webhook_pk, alert_group_pk, escalation_policy_pk=None): + from apps.webhooks.tasks import execute_webhook + + execute_webhook.apply_async((webhook_pk, alert_group_pk, None, escalation_policy_pk)) diff --git a/engine/apps/alerts/tests/test_custom_webhook_result.py b/engine/apps/alerts/tests/test_custom_webhook_result.py new file mode 100644 index 00000000..4a023567 --- /dev/null +++ b/engine/apps/alerts/tests/test_custom_webhook_result.py @@ -0,0 +1,17 @@ +from unittest.mock import call, patch + +import pytest + +from apps.alerts.tasks import custom_webhook_result + + +@pytest.mark.django_db +def test_custom_webhook_result_executes_webhook(): + webhook_id = 42 + alert_group_id = 13 + escalation_policy_id = 11 + + with patch("apps.webhooks.tasks.trigger_webhook.execute_webhook.apply_async") as mock_execute: + custom_webhook_result(webhook_id, alert_group_id, escalation_policy_id) + + assert mock_execute.call_args == call((webhook_id, alert_group_id, None, escalation_policy_id)) diff --git a/engine/apps/alerts/tests/test_escalation_policy_snapshot.py b/engine/apps/alerts/tests/test_escalation_policy_snapshot.py index 038a93f8..01c41401 100644 --- a/engine/apps/alerts/tests/test_escalation_policy_snapshot.py +++ b/engine/apps/alerts/tests/test_escalation_policy_snapshot.py @@ -442,6 +442,37 @@ def test_escalation_step_trigger_custom_button( assert mocked_execute_tasks.called +@patch("apps.alerts.escalation_snapshot.snapshot_classes.EscalationPolicySnapshot._execute_tasks", return_value=None) +@pytest.mark.django_db +def test_escalation_step_trigger_custom_webhook( + mocked_execute_tasks, + escalation_step_test_setup, + make_custom_webhook, + make_escalation_policy, +): + organization, _, _, channel_filter, alert_group, reason = escalation_step_test_setup + + custom_webhook = make_custom_webhook(organization=organization) + + trigger_custom_webhook_step = make_escalation_policy( + escalation_chain=channel_filter.escalation_chain, + escalation_policy_step=EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON, + custom_webhook=custom_webhook, + ) + escalation_policy_snapshot = get_escalation_policy_snapshot_from_model(trigger_custom_webhook_step) + expected_eta = timezone.now() + timezone.timedelta(seconds=NEXT_ESCALATION_DELAY) + result = escalation_policy_snapshot.execute(alert_group, reason) + expected_result = EscalationPolicySnapshot.StepExecutionResultData( + eta=result.eta, + stop_escalation=False, + pause_escalation=False, + start_from_beginning=False, + ) + assert expected_eta + timezone.timedelta(seconds=15) > result.eta > expected_eta - timezone.timedelta(seconds=15) + assert result == expected_result + assert mocked_execute_tasks.called + + @patch("apps.alerts.escalation_snapshot.snapshot_classes.EscalationPolicySnapshot._execute_tasks", return_value=None) @pytest.mark.django_db def test_escalation_step_repeat_escalation_n_times( diff --git a/engine/apps/alerts/tests/test_escalation_snapshot.py b/engine/apps/alerts/tests/test_escalation_snapshot.py index 8ce55b8f..b3ddb828 100644 --- a/engine/apps/alerts/tests/test_escalation_snapshot.py +++ b/engine/apps/alerts/tests/test_escalation_snapshot.py @@ -45,6 +45,7 @@ def test_raw_escalation_snapshot(escalation_snapshot_test_setup): "num_alerts_in_window": None, "num_minutes_in_window": None, "custom_button_trigger": None, + "custom_webhook": None, "escalation_counter": 0, "passed_last_time": None, "pause_escalation": False, @@ -63,6 +64,7 @@ def test_raw_escalation_snapshot(escalation_snapshot_test_setup): "num_alerts_in_window": None, "num_minutes_in_window": None, "custom_button_trigger": None, + "custom_webhook": None, "escalation_counter": 0, "passed_last_time": None, "pause_escalation": False, @@ -81,6 +83,7 @@ def test_raw_escalation_snapshot(escalation_snapshot_test_setup): "num_alerts_in_window": None, "num_minutes_in_window": None, "custom_button_trigger": None, + "custom_webhook": None, "escalation_counter": 0, "passed_last_time": None, "pause_escalation": False, diff --git a/engine/apps/api/serializers/escalation_policy.py b/engine/apps/api/serializers/escalation_policy.py index 5d85e534..ca58217d 100644 --- a/engine/apps/api/serializers/escalation_policy.py +++ b/engine/apps/api/serializers/escalation_policy.py @@ -7,6 +7,7 @@ from apps.alerts.models import CustomButton, EscalationChain, EscalationPolicy from apps.schedules.models import OnCallSchedule from apps.slack.models import SlackUserGroup from apps.user_management.models import User +from apps.webhooks.models import Webhook from common.api_helpers.custom_fields import ( OrganizationFilteredPrimaryKeyRelatedField, UsersFilteredByOrganizationField, @@ -22,6 +23,7 @@ TO_TIME = "to_time" NUM_ALERTS_IN_WINDOW = "num_alerts_in_window" NUM_MINUTES_IN_WINDOW = "num_minutes_in_window" CUSTOM_BUTTON_TRIGGER = "custom_button_trigger" +CUSTOM_WEBHOOK_TRIGGER = "custom_webhook" STEP_TYPE_TO_RELATED_FIELD_MAP = { EscalationPolicy.STEP_WAIT: [WAIT_DELAY], @@ -32,6 +34,7 @@ STEP_TYPE_TO_RELATED_FIELD_MAP = { EscalationPolicy.STEP_NOTIFY_IF_TIME: [FROM_TIME, TO_TIME], EscalationPolicy.STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW: [NUM_ALERTS_IN_WINDOW, NUM_MINUTES_IN_WINDOW], EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON: [CUSTOM_BUTTON_TRIGGER], + EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK: [CUSTOM_WEBHOOK_TRIGGER], } @@ -71,6 +74,12 @@ class EscalationPolicySerializer(EagerLoadingMixin, serializers.ModelSerializer) allow_null=True, filter_field="organization", ) + custom_webhook = OrganizationFilteredPrimaryKeyRelatedField( + queryset=Webhook.objects, + required=False, + allow_null=True, + filter_field="organization", + ) class Meta: model = EscalationPolicy @@ -87,13 +96,20 @@ class EscalationPolicySerializer(EagerLoadingMixin, serializers.ModelSerializer) "num_minutes_in_window", "slack_integration_required", "custom_button_trigger", + "custom_webhook", "notify_schedule", "notify_to_group", "important", ] read_only_fields = ("order",) - SELECT_RELATED = ["escalation_chain", "notify_schedule", "notify_to_group", "custom_button_trigger"] + SELECT_RELATED = [ + "escalation_chain", + "notify_schedule", + "notify_to_group", + "custom_button_trigger", + "custom_webhook", + ] PREFETCH_RELATED = ["notify_to_users_queue"] def validate(self, data): @@ -108,6 +124,7 @@ class EscalationPolicySerializer(EagerLoadingMixin, serializers.ModelSerializer) NUM_ALERTS_IN_WINDOW, NUM_MINUTES_IN_WINDOW, CUSTOM_BUTTON_TRIGGER, + CUSTOM_WEBHOOK_TRIGGER, ] step = data.get("step") @@ -216,6 +233,7 @@ class EscalationPolicyUpdateSerializer(EscalationPolicySerializer): NUM_ALERTS_IN_WINDOW, NUM_MINUTES_IN_WINDOW, CUSTOM_BUTTON_TRIGGER, + CUSTOM_WEBHOOK_TRIGGER, ] for f in STEP_TYPE_TO_RELATED_FIELD_MAP.get(step, []): diff --git a/engine/apps/api/serializers/webhook.py b/engine/apps/api/serializers/webhook.py index f9160789..215f299d 100644 --- a/engine/apps/api/serializers/webhook.py +++ b/engine/apps/api/serializers/webhook.py @@ -59,9 +59,7 @@ class WebhookSerializer(serializers.ModelSerializer): "last_response_log", ] extra_kwargs = { - "authorization_header": {"write_only": True}, "name": {"required": True, "allow_null": False, "allow_blank": False}, - "password": {"write_only": True}, "url": {"required": True, "allow_null": False, "allow_blank": False}, } @@ -113,6 +111,6 @@ class WebhookSerializer(serializers.ModelSerializer): def get_trigger_type_name(self, obj): trigger_type_name = "" - if obj.trigger_type: + if obj.trigger_type is not None: trigger_type_name = Webhook.TRIGGER_TYPES[int(obj.trigger_type)][1] return trigger_type_name diff --git a/engine/apps/api/tests/test_escalation_policy.py b/engine/apps/api/tests/test_escalation_policy.py index 54fc2301..c435dc99 100644 --- a/engine/apps/api/tests/test_escalation_policy.py +++ b/engine/apps/api/tests/test_escalation_policy.py @@ -55,6 +55,33 @@ def test_create_escalation_policy(escalation_policy_internal_api_setup, make_use assert response.data["order"] == max_order + 1 +@pytest.mark.django_db +def test_create_escalation_policy_webhook( + escalation_policy_internal_api_setup, make_custom_webhook, make_user_auth_headers +): + token, escalation_chain, _, user, _ = escalation_policy_internal_api_setup + client = APIClient() + url = reverse("api-internal:escalation_policy-list") + + webhook = make_custom_webhook(organization=user.organization) + data = { + "step": EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK, + "escalation_chain": escalation_chain.public_primary_key, + "custom_webhook": webhook.public_primary_key, + } + + max_order = EscalationPolicy.objects.filter(escalation_chain=escalation_chain).aggregate(maxorder=Max("order"))[ + "maxorder" + ] + + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_201_CREATED + assert response.data["order"] == max_order + 1 + assert response.data["custom_webhook"] == webhook.public_primary_key + escalation_policy = EscalationPolicy.objects.get(public_primary_key=response.data["id"]) + assert escalation_policy.custom_webhook == webhook + + @pytest.mark.django_db def test_update_notify_multiple_users_step(escalation_policy_internal_api_setup, make_user_auth_headers): token, _, escalation_policy, first_user, second_user = escalation_policy_internal_api_setup @@ -624,6 +651,7 @@ def test_escalation_policy_can_not_create_with_non_step_type_related_data( (EscalationPolicy.STEP_NOTIFY_IF_TIME, ["from_time", "to_time"]), (EscalationPolicy.STEP_NOTIFY_MULTIPLE_USERS, ["notify_to_users_queue"]), (EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON, ["custom_button_trigger"]), + (EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK, ["custom_webhook"]), ], ) def test_escalation_policy_update_drop_non_step_type_related_data( @@ -662,6 +690,7 @@ def test_escalation_policy_update_drop_non_step_type_related_data( "from_time", "to_time", "custom_button_trigger", + "custom_webhook", ] for f in related_fields: fields_to_check.remove(f) @@ -713,6 +742,7 @@ def test_escalation_policy_switch_importance( "num_minutes_in_window": None, "slack_integration_required": escalation_policy.slack_integration_required, "custom_button_trigger": None, + "custom_webhook": None, "notify_schedule": None, "notify_to_group": None, "important": True, @@ -770,6 +800,7 @@ def test_escalation_policy_filter_by_user( "num_minutes_in_window": None, "slack_integration_required": False, "custom_button_trigger": None, + "custom_webhook": None, "notify_schedule": None, "notify_to_group": None, "important": False, @@ -787,6 +818,7 @@ def test_escalation_policy_filter_by_user( "num_minutes_in_window": None, "slack_integration_required": False, "custom_button_trigger": None, + "custom_webhook": None, "notify_schedule": None, "notify_to_group": None, "important": False, @@ -849,6 +881,7 @@ def test_escalation_policy_filter_by_slack_channel( "num_minutes_in_window": None, "slack_integration_required": False, "custom_button_trigger": None, + "custom_webhook": None, "notify_schedule": None, "notify_to_group": None, "important": False, @@ -864,3 +897,25 @@ def test_escalation_policy_filter_by_slack_channel( assert response.status_code == status.HTTP_200_OK assert response.json() == expected_payload + + +@pytest.mark.django_db +@pytest.mark.parametrize("enabled", [True, False]) +def test_escalation_policy_escalation_options_webhooks( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + enabled, +): + _, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + + url = reverse("api-internal:escalation_policy-escalation-options") + + with patch("apps.api.views.escalation_policy.is_webhooks_enabled_for_organization", return_value=enabled): + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + + returned_options = [option["value"] for option in response.json()] + if enabled: + assert EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK in returned_options + else: + assert EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK not in returned_options diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index c2c4bc1d..fea94f1a 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -12,6 +12,7 @@ from rest_framework.test import APIClient from apps.api.permissions import DONT_USE_LEGACY_PERMISSION_MAPPING, LegacyAccessControlRole from apps.base.models import UserNotificationPolicy +from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb from apps.user_management.models.user import default_working_hours @@ -1650,3 +1651,187 @@ def test_phone_number_verification_recaptcha( mock_verification_start.assert_called_once_with() else: mock_verification_start.assert_not_called() + + +@pytest.mark.django_db +def test_upcoming_shifts_invalid_days( + make_organization, + make_user_for_organization, + make_token_for_organization, + make_user_auth_headers, +): + organization = make_organization() + admin = make_user_for_organization(organization) + _, token = make_token_for_organization(organization) + + client = APIClient() + url = reverse("api-internal:user-upcoming-shifts", kwargs={"pk": admin.public_primary_key}) + "?days=invalid" + + response = client.get(url, format="json", **make_user_auth_headers(admin, token)) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_upcoming_shifts_oncall( + make_organization, + make_user_for_organization, + make_token_for_organization, + make_user_auth_headers, + make_schedule, + make_on_call_shift, +): + organization = make_organization() + admin = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + _, token = make_token_for_organization(organization) + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + ) + shifts = ( + # user, priority, start time (h), duration (seconds) + (admin, 1, 0, (24 * 60 * 60) - 1), # r1-1: 0-23:59:59 + ) + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + for user, priority, start_h, duration in shifts: + data = { + "start": today + timezone.timedelta(hours=start_h), + "rotation_start": today + timezone.timedelta(hours=start_h), + "duration": timezone.timedelta(seconds=duration), + "priority_level": priority, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + schedule.refresh_ical_file() + + client = APIClient() + + url = reverse("api-internal:user-upcoming-shifts", kwargs={"pk": admin.public_primary_key}) + response = client.get(url, format="json", **make_user_auth_headers(admin, token)) + + assert response.status_code == status.HTTP_200_OK + returned_data = response.data + assert returned_data[schedule.public_primary_key]["schedule"] == schedule.name + assert returned_data[schedule.public_primary_key]["is_oncall"] + assert returned_data[schedule.public_primary_key]["current_shift"]["start"] == on_call_shift.start + next_shift_start = on_call_shift.start + timezone.timedelta(days=1) + assert returned_data[schedule.public_primary_key]["next_shift"]["start"] == next_shift_start + + # empty response for other user + url = reverse("api-internal:user-upcoming-shifts", kwargs={"pk": other_user.public_primary_key}) + response = client.get(url, format="json", **make_user_auth_headers(admin, token)) + + assert response.status_code == status.HTTP_200_OK + assert response.data == {} + + +@pytest.mark.django_db +def test_upcoming_shifts_override( + make_organization, + make_user_for_organization, + make_token_for_organization, + make_user_auth_headers, + make_schedule, + make_on_call_shift, +): + organization = make_organization() + admin = make_user_for_organization(organization) + _, token = make_token_for_organization(organization) + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + ) + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + + override_data = { + "start": today + timezone.timedelta(hours=22), + "rotation_start": today + timezone.timedelta(hours=22), + "duration": timezone.timedelta(hours=1), + "schedule": schedule, + } + override = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **override_data + ) + override.add_rolling_users([[admin]]) + schedule.refresh_ical_file() + + client = APIClient() + url = reverse("api-internal:user-upcoming-shifts", kwargs={"pk": admin.public_primary_key}) + + response = client.get(url, format="json", **make_user_auth_headers(admin, token)) + + assert response.status_code == status.HTTP_200_OK + returned_data = response.data + assert returned_data[schedule.public_primary_key]["schedule"] == schedule.name + assert returned_data[schedule.public_primary_key]["is_oncall"] is False + assert returned_data[schedule.public_primary_key]["current_shift"] is None + assert returned_data[schedule.public_primary_key]["next_shift"]["start"] == override.start + + +@pytest.mark.django_db +def test_upcoming_shifts_multiple_schedules( + make_organization, + make_user_for_organization, + make_token_for_organization, + make_user_auth_headers, + make_schedule, + make_on_call_shift, +): + organization = make_organization() + admin = make_user_for_organization(organization) + _, token = make_token_for_organization(organization) + + schedules = [] + for i in range(3): + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + ) + shifts = ( + # user, priority, start time (h), duration (seconds) + (admin, 1, 0, (24 * 60 * 60) - 1), # r1-1: 0-23:59:59 + ) + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + for user, priority, start_h, duration in shifts: + data = { + "start": today + timezone.timedelta(hours=start_h) + timezone.timedelta(days=i), + "rotation_start": today + timezone.timedelta(hours=start_h) + timezone.timedelta(days=i), + "duration": timezone.timedelta(seconds=duration), + "priority_level": priority, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + schedule.refresh_ical_file() + schedules.append(schedule) + + client = APIClient() + url = reverse("api-internal:user-upcoming-shifts", kwargs={"pk": admin.public_primary_key}) + + response = client.get(url, format="json", **make_user_auth_headers(admin, token)) + + assert response.status_code == status.HTTP_200_OK + returned_data = response.data + for i, schedule in enumerate(schedules): + assert returned_data[schedule.public_primary_key]["schedule"] == schedule.name + expected_start = today + timezone.timedelta(hours=start_h) + timezone.timedelta(days=i) + if i == 0: + assert returned_data[schedule.public_primary_key]["is_oncall"] + assert returned_data[schedule.public_primary_key]["current_shift"]["start"] == expected_start + assert returned_data[schedule.public_primary_key]["next_shift"][ + "start" + ] == expected_start + timezone.timedelta(days=1) + else: + assert returned_data[schedule.public_primary_key]["is_oncall"] is False + assert returned_data[schedule.public_primary_key]["current_shift"] is None + assert returned_data[schedule.public_primary_key]["next_shift"]["start"] == expected_start diff --git a/engine/apps/api/tests/test_webhooks.py b/engine/apps/api/tests/test_webhooks.py index 91fb5ca2..44cc7dc9 100644 --- a/engine/apps/api/tests/test_webhooks.py +++ b/engine/apps/api/tests/test_webhooks.py @@ -44,6 +44,8 @@ def test_get_list_webhooks(webhook_internal_api_setup, make_user_auth_headers): "url": "https://github.com/", "data": '{"name": "{{ alert_payload }}"}', "username": "Chris Vanstras", + "password": "qwerty", + "authorization_header": "auth_token", "forward_all": False, "headers": None, "http_method": "POST", @@ -81,6 +83,8 @@ def test_get_detail_webhook(webhook_internal_api_setup, make_user_auth_headers): "url": "https://github.com/", "data": '{"name": "{{ alert_payload }}"}', "username": "Chris Vanstras", + "password": "qwerty", + "authorization_header": "auth_token", "forward_all": False, "headers": None, "http_method": "POST", @@ -123,6 +127,8 @@ def test_create_webhook(mocked_check_webhooks_2_enabled, webhook_internal_api_se "id": webhook.public_primary_key, "data": None, "username": None, + "password": None, + "authorization_header": None, "forward_all": True, "headers": None, "http_method": "POST", @@ -177,6 +183,8 @@ def test_create_valid_templated_field( expected_response = data | { "id": webhook.public_primary_key, "username": None, + "password": None, + "authorization_header": None, "forward_all": True, "headers": None, "data": None, diff --git a/engine/apps/api/views/escalation_policy.py b/engine/apps/api/views/escalation_policy.py index c61fdaa4..471cbc4f 100644 --- a/engine/apps/api/views/escalation_policy.py +++ b/engine/apps/api/views/escalation_policy.py @@ -14,6 +14,7 @@ from apps.api.serializers.escalation_policy import ( EscalationPolicyUpdateSerializer, ) from apps.auth_token.auth import PluginAuthentication +from apps.webhooks.utils import is_webhooks_enabled_for_organization from common.api_helpers.exceptions import BadRequest from common.api_helpers.mixins import ( CreateSerializerMixin, @@ -140,6 +141,10 @@ class EscalationPolicyView( def escalation_options(self, request): choices = [] for step in EscalationPolicy.INTERNAL_API_STEPS: + if step == EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK and not is_webhooks_enabled_for_organization( + self.request.auth.organization.pk + ): + continue verbal = EscalationPolicy.INTERNAL_API_STEPS_TO_VERBAL_MAP[step] can_change_importance = ( step in EscalationPolicy.IMPORTANT_STEPS_SET or step in EscalationPolicy.DEFAULT_STEPS_SET diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index d1bc4f1d..b5ddcafa 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -37,6 +37,7 @@ from apps.auth_token.models import UserScheduleExportAuthToken from apps.base.messaging import get_messaging_backend_from_id from apps.base.utils import live_settings from apps.mobile_app.auth import MobileAppAuthTokenAuthentication +from apps.schedules.models import OnCallSchedule from apps.telegram.client import TelegramClient from apps.telegram.models import TelegramVerificationCode from apps.twilioapp.phone_manager import PhoneManager @@ -139,6 +140,7 @@ class UserView( "unlink_backend": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "make_test_call": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "export_token": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "upcoming_shifts": [RBACPermission.Permissions.USER_SETTINGS_WRITE], } rbac_object_permissions = { @@ -159,6 +161,7 @@ class UserView( "unlink_backend", "make_test_call", "export_token", + "upcoming_shifts", ], IsOwnerOrHasUserSettingsReadPermission: [ "check_availability", @@ -460,6 +463,31 @@ class UserView( return Response(status=status.HTTP_400_BAD_REQUEST) return Response(status=status.HTTP_200_OK) + @action(detail=True, methods=["get"]) + def upcoming_shifts(self, request, pk): + user = self.get_object() + try: + days = int(request.query_params.get("days", 7)) # fallback to a week + except ValueError: + return Response(status=status.HTTP_400_BAD_REQUEST) + + # filter user-related schedules + schedules = OnCallSchedule.objects.related_to_user(user) + + # check upcoming shifts + upcoming = {} + for schedule in schedules: + current_shift, upcoming_shift = schedule.upcoming_shift_for_user(user, days=days) + if current_shift or upcoming_shift: + upcoming[schedule.public_primary_key] = { + "schedule": schedule.name, + "is_oncall": current_shift is not None, + "current_shift": current_shift, + "next_shift": upcoming_shift, + } + + return Response(upcoming, status=status.HTTP_200_OK) + @action(detail=True, methods=["get", "post", "delete"]) def export_token(self, request, pk): user = self.get_object() diff --git a/engine/apps/api/views/webhooks.py b/engine/apps/api/views/webhooks.py index de492a3d..8eaf3126 100644 --- a/engine/apps/api/views/webhooks.py +++ b/engine/apps/api/views/webhooks.py @@ -1,4 +1,3 @@ -from django.apps import apps from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django_filters import rest_framework as filters from rest_framework.decorators import action @@ -12,6 +11,7 @@ from apps.api.permissions import RBACPermission from apps.api.serializers.webhook import WebhookSerializer from apps.auth_token.auth import PluginAuthentication from apps.webhooks.models import Webhook +from apps.webhooks.utils import is_webhooks_enabled_for_organization from common.api_helpers.filters import ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, TeamModelMultipleChoiceFilter from common.api_helpers.mixins import PublicPrimaryKeyMixin, TeamFilteringMixin @@ -85,16 +85,7 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet): instance.delete() def check_webhooks_2_enabled(self): - DynamicSetting = apps.get_model("base", "DynamicSetting") - enabled_webhooks_2_orgs = DynamicSetting.objects.get_or_create( - name="enabled_webhooks_2_orgs", - defaults={ - "json_value": { - "org_ids": [], - } - }, - )[0] - if self.request.auth.organization.pk not in enabled_webhooks_2_orgs.json_value["org_ids"]: + if not is_webhooks_enabled_for_organization(self.request.auth.organization.pk): raise PermissionDenied("Webhooks 2 not enabled for organization. Permission denied.") @action(methods=["get"], detail=False) diff --git a/engine/apps/public_api/serializers/action.py b/engine/apps/public_api/serializers/action.py index 5fb3f1ee..2f33c0bd 100644 --- a/engine/apps/public_api/serializers/action.py +++ b/engine/apps/public_api/serializers/action.py @@ -93,7 +93,6 @@ class ActionCreateSerializer(serializers.ModelSerializer): class ActionUpdateSerializer(ActionCreateSerializer): - team_id = TeamPrimaryKeyRelatedField(source="team", read_only=True) url = serializers.CharField(required=False, allow_null=False, allow_blank=False, source="webhook") class Meta(ActionCreateSerializer.Meta): diff --git a/engine/apps/public_api/serializers/escalation_chains.py b/engine/apps/public_api/serializers/escalation_chains.py index 13b7fa75..6563036f 100644 --- a/engine/apps/public_api/serializers/escalation_chains.py +++ b/engine/apps/public_api/serializers/escalation_chains.py @@ -18,7 +18,3 @@ class EscalationChainSerializer(serializers.ModelSerializer): "organization", "team_id", ) - - -class EscalationChainUpdateSerializer(EscalationChainSerializer): - team_id = TeamPrimaryKeyRelatedField(source="team", read_only=True) diff --git a/engine/apps/public_api/serializers/integrations.py b/engine/apps/public_api/serializers/integrations.py index fb55dcd5..2c42da5d 100644 --- a/engine/apps/public_api/serializers/integrations.py +++ b/engine/apps/public_api/serializers/integrations.py @@ -359,7 +359,6 @@ class IntegrationSerializer(EagerLoadingMixin, serializers.ModelSerializer, Main class IntegrationUpdateSerializer(IntegrationSerializer): type = IntegrationTypeField(source="integration", read_only=True) - team_id = TeamPrimaryKeyRelatedField(source="team", read_only=True) def update(self, instance, validated_data): validated_data = self._correct_validated_data(validated_data) diff --git a/engine/apps/public_api/serializers/on_call_shifts.py b/engine/apps/public_api/serializers/on_call_shifts.py index 5568b23b..76d922e2 100644 --- a/engine/apps/public_api/serializers/on_call_shifts.py +++ b/engine/apps/public_api/serializers/on_call_shifts.py @@ -359,7 +359,6 @@ class CustomOnCallShiftUpdateSerializer(CustomOnCallShiftSerializer): name = serializers.CharField(required=False) start = serializers.DateTimeField(required=False) rotation_start = serializers.DateTimeField(required=False) - team_id = TeamPrimaryKeyRelatedField(read_only=True, source="team") def update(self, instance, validated_data): event_type = validated_data.get("type", instance.type) diff --git a/engine/apps/public_api/serializers/schedules_calendar.py b/engine/apps/public_api/serializers/schedules_calendar.py index dbbf221c..5cecce89 100644 --- a/engine/apps/public_api/serializers/schedules_calendar.py +++ b/engine/apps/public_api/serializers/schedules_calendar.py @@ -5,7 +5,7 @@ from apps.schedules.tasks import ( 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.custom_fields import UsersFilteredByOrganizationField from common.api_helpers.exceptions import BadRequest from common.timezones import TimeZoneField @@ -60,7 +60,6 @@ class ScheduleCalendarSerializer(ScheduleBaseSerializer): class ScheduleCalendarUpdateSerializer(ScheduleCalendarSerializer): time_zone = TimeZoneField(required=False) - team_id = TeamPrimaryKeyRelatedField(read_only=True, source="team") class Meta: model = OnCallScheduleCalendar diff --git a/engine/apps/public_api/serializers/schedules_ical.py b/engine/apps/public_api/serializers/schedules_ical.py index 2f7b9b4e..458265c4 100644 --- a/engine/apps/public_api/serializers/schedules_ical.py +++ b/engine/apps/public_api/serializers/schedules_ical.py @@ -34,7 +34,7 @@ class ScheduleICalSerializer(ScheduleBaseSerializer): class ScheduleICalUpdateSerializer(ScheduleICalSerializer): - team_id = TeamPrimaryKeyRelatedField(read_only=True, source="team") + team_id = TeamPrimaryKeyRelatedField(required=False, allow_null=True, source="team") class Meta: model = OnCallScheduleICal diff --git a/engine/apps/public_api/serializers/schedules_web.py b/engine/apps/public_api/serializers/schedules_web.py index a4a737c4..d1c89254 100644 --- a/engine/apps/public_api/serializers/schedules_web.py +++ b/engine/apps/public_api/serializers/schedules_web.py @@ -11,6 +11,7 @@ from common.timezones import TimeZoneField class ScheduleWebSerializer(ScheduleBaseSerializer): + team_id = TeamPrimaryKeyRelatedField(required=False, allow_null=True, source="team") time_zone = TimeZoneField(required=True) shifts = UsersFilteredByOrganizationField( queryset=CustomOnCallShift.objects, @@ -49,7 +50,6 @@ class ScheduleWebSerializer(ScheduleBaseSerializer): class ScheduleWebUpdateSerializer(ScheduleWebSerializer): time_zone = TimeZoneField(required=False) - team_id = TeamPrimaryKeyRelatedField(read_only=True, source="team") class Meta: model = OnCallScheduleWeb diff --git a/engine/apps/public_api/views/escalation_chains.py b/engine/apps/public_api/views/escalation_chains.py index e24310cd..017ca470 100644 --- a/engine/apps/public_api/views/escalation_chains.py +++ b/engine/apps/public_api/views/escalation_chains.py @@ -6,15 +6,14 @@ from rest_framework.viewsets import ModelViewSet from apps.alerts.models import EscalationChain from apps.auth_token.auth import ApiTokenAuthentication from apps.public_api.serializers import EscalationChainSerializer -from apps.public_api.serializers.escalation_chains import EscalationChainUpdateSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from common.api_helpers.filters import ByTeamFilter -from common.api_helpers.mixins import RateLimitHeadersMixin, UpdateSerializerMixin +from common.api_helpers.mixins import RateLimitHeadersMixin from common.api_helpers.paginators import FiftyPageSizePaginator from common.insight_log import EntityEvent, write_resource_insight_log -class EscalationChainView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet): +class EscalationChainView(RateLimitHeadersMixin, ModelViewSet): authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) @@ -22,7 +21,6 @@ class EscalationChainView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelVie model = EscalationChain serializer_class = EscalationChainSerializer - update_serializer_class = EscalationChainUpdateSerializer pagination_class = FiftyPageSizePaginator diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 3b0658f0..1fde3070 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -11,6 +11,7 @@ from django.apps import apps from django.conf import settings from django.core.validators import MinLengthValidator from django.db import models +from django.db.models import Q from django.db.utils import DatabaseError from django.utils import timezone from django.utils.functional import cached_property @@ -72,6 +73,15 @@ class OnCallScheduleQuerySet(PolymorphicQuerySet): def get_oncall_users(self, events_datetime=None): return get_oncall_users_for_multiple_schedules(self, events_datetime) + def related_to_user(self, user): + return self.filter( + Q(cached_ical_file_primary__contains=user.username) + | Q(cached_ical_file_primary__contains=user.email) + | Q(cached_ical_file_overrides__contains=user.username) + | Q(cached_ical_file_overrides__contains=user.username), + organization=user.organization, + ) + class OnCallSchedule(PolymorphicModel): objects = PolymorphicManager.from_queryset(OnCallScheduleQuerySet)() @@ -284,6 +294,28 @@ class OnCallSchedule(PolymorphicModel): events = self._resolve_schedule(events) return events + def upcoming_shift_for_user(self, user, days=7): + user_tz = user.timezone or "UTC" + now = timezone.now() + starting_date = now.date() + current_shift = upcoming_shift = None + + events = self.final_events(user_tz, starting_date, days=days) + for e in events: + if e["end"] < now: + # shift is finished, ignore + continue + users = {u["pk"] for u in e["users"]} + if user.public_primary_key in users: + if e["start"] < now and e["end"] > now: + # shift is in progress + current_shift = e + continue + upcoming_shift = e + break + + return current_shift, upcoming_shift + def quality_report(self, date: Optional[timezone.datetime], days: Optional[int]) -> QualityReport: """ Return schedule quality report to be used by the web UI. diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index fd54caf3..83e886a3 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -1153,3 +1153,96 @@ def test_polymorphic_delete_related( # Check that deleting the organization works as expected organization.hard_delete() assert not OnCallSchedule.objects.exists() + + +@pytest.mark.django_db +def test_user_related_schedules( + make_organization, + make_user_for_organization, + make_schedule, + make_on_call_shift, +): + organization = make_organization() + admin = make_user_for_organization(organization) + + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + schedule1 = make_schedule(organization, schedule_class=OnCallScheduleWeb) + shifts = ( + # user, priority, start time (h), duration (seconds) + (admin, 1, 0, (24 * 60 * 60) - 1), # r1-1: 0-23:59:59 + ) + for user, priority, start_h, duration in shifts: + data = { + "start": today + timezone.timedelta(hours=start_h), + "rotation_start": today + timezone.timedelta(hours=start_h), + "duration": timezone.timedelta(seconds=duration), + "priority_level": priority, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule1, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + schedule1.refresh_ical_file() + + schedule2 = make_schedule(organization, schedule_class=OnCallScheduleWeb) + override_data = { + "start": today + timezone.timedelta(hours=22), + "rotation_start": today + timezone.timedelta(hours=22), + "duration": timezone.timedelta(hours=1), + "schedule": schedule2, + } + override = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **override_data + ) + override.add_rolling_users([[admin]]) + schedule2.refresh_ical_file() + + # schedule2 + make_schedule(organization, schedule_class=OnCallScheduleWeb) + + schedules = OnCallSchedule.objects.related_to_user(admin) + assert list(schedules) == [schedule1, schedule2] + + +@pytest.mark.django_db +def test_upcoming_shift_for_user( + make_organization, + make_user_for_organization, + make_schedule, + make_on_call_shift, +): + organization = make_organization() + admin = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + shifts = ( + # user, priority, start time (h), duration (seconds) + (admin, 1, 0, (24 * 60 * 60) - 1), # r1-1: 0-23:59:59 + ) + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + for user, priority, start_h, duration in shifts: + data = { + "start": today + timezone.timedelta(hours=start_h), + "rotation_start": today + timezone.timedelta(hours=start_h), + "duration": timezone.timedelta(seconds=duration), + "priority_level": priority, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + schedule.refresh_ical_file() + + current_shift, upcoming_shift = schedule.upcoming_shift_for_user(admin) + assert current_shift is not None and current_shift["start"] == on_call_shift.start + next_shift_start = on_call_shift.start + timezone.timedelta(days=1) + assert upcoming_shift is not None and upcoming_shift["start"] == next_shift_start + + current_shift, upcoming_shift = schedule.upcoming_shift_for_user(other_user) + assert current_shift is None + assert upcoming_shift is None diff --git a/engine/apps/slack/scenarios/resolution_note.py b/engine/apps/slack/scenarios/resolution_note.py index 94c2a8df..72a014e8 100644 --- a/engine/apps/slack/scenarios/resolution_note.py +++ b/engine/apps/slack/scenarios/resolution_note.py @@ -352,7 +352,7 @@ class UpdateResolutionNoteStep(scenario_step.ScenarioStep): def get_resolution_note_blocks(self, resolution_note): blocks = [] - author_verbal = resolution_note.author_verbal(mention=True) + author_verbal = resolution_note.author_verbal(mention=False) resolution_note_text_block = { "type": "section", "text": {"type": "mrkdwn", "text": resolution_note.text}, diff --git a/engine/apps/webhooks/tasks/trigger_webhook.py b/engine/apps/webhooks/tasks/trigger_webhook.py index dd9a4bf3..e194b8e9 100644 --- a/engine/apps/webhooks/tasks/trigger_webhook.py +++ b/engine/apps/webhooks/tasks/trigger_webhook.py @@ -6,7 +6,7 @@ from celery.utils.log import get_task_logger from django.apps import apps from django.conf import settings -from apps.alerts.models import AlertGroup +from apps.alerts.models import AlertGroup, AlertGroupLogRecord, EscalationPolicy from apps.user_management.models import User from apps.webhooks.models import Webhook, WebhookResponse from apps.webhooks.utils import ( @@ -29,6 +29,7 @@ TRIGGER_TYPE_TO_LABEL = { Webhook.TRIGGER_SILENCE: "silence", Webhook.TRIGGER_UNSILENCE: "unsilence", Webhook.TRIGGER_UNRESOLVE: "unresolve", + Webhook.TRIGGER_ESCALATION_STEP: "escalation", } @@ -40,18 +41,14 @@ def send_webhook_event(trigger_type, alert_group_id, team_id=None, organization_ webhooks_qs = Webhooks.objects.filter(trigger_type=trigger_type, organization_id=organization_id, team_id=team_id) for webhook in webhooks_qs: - execute_webhook.apply_async((webhook.pk, alert_group_id, user_id)) + execute_webhook.apply_async((webhook.pk, alert_group_id, user_id, None)) def _isoformat_date(date_value): return date_value.isoformat() if date_value else None -def _build_payload(trigger_type, alert_group, user_id): - user = None - if user_id is not None: - user = User.objects.filter(pk=user_id).first() - +def _build_payload(trigger_type, alert_group, user): event = { "type": TRIGGER_TYPE_TO_LABEL[trigger_type], } @@ -83,7 +80,7 @@ def _build_payload(trigger_type, alert_group, user_id): @shared_dedicated_queue_retry_task( autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None ) -def execute_webhook(webhook_pk, alert_group_id, user_id): +def execute_webhook(webhook_pk, alert_group_id, user_id, escalation_policy_id): Webhooks = apps.get_model("webhooks", "Webhook") try: webhook = Webhooks.objects.get(pk=webhook_pk) @@ -96,7 +93,11 @@ def execute_webhook(webhook_pk, alert_group_id, user_id): except AlertGroup.DoesNotExist: return - data = _build_payload(webhook.trigger_type, alert_group, user_id) + user = None + if user_id is not None: + user = User.objects.filter(pk=user_id).first() + + data = _build_payload(webhook.trigger_type, alert_group, user) status = { "url": None, "request_trigger": None, @@ -107,7 +108,7 @@ def execute_webhook(webhook_pk, alert_group_id, user_id): "webhook": webhook, } - exception = None + exception = error = None try: triggered, status["request_trigger"] = webhook.check_trigger(data) if triggered: @@ -128,15 +129,15 @@ def execute_webhook(webhook_pk, alert_group_id, user_id): # do not add a log entry if the webhook is not triggered return except InvalidWebhookUrl as e: - status["url"] = e.message + status["url"] = error = e.message except InvalidWebhookTrigger as e: - status["request_trigger"] = e.message + status["request_trigger"] = error = e.message except InvalidWebhookHeaders as e: - status["request_headers"] = e.message + status["request_headers"] = error = e.message except InvalidWebhookData as e: - status["request_data"] = e.message + status["request_data"] = error = e.message except Exception as e: - status["content"] = str(e) + status["content"] = error = str(e) exception = e # create response entry @@ -146,5 +147,35 @@ def execute_webhook(webhook_pk, alert_group_id, user_id): **status, ) + escalation_policy = step = None + if escalation_policy_id: + escalation_policy = EscalationPolicy.objects.filter(pk=escalation_policy_id).first() + step = EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK + + # create log record + error_code = None + # reuse existing webhooks record type (TODO: rename after migration) + log_type = AlertGroupLogRecord.TYPE_CUSTOM_BUTTON_TRIGGERED + reason = str(status["status_code"]) + if error is not None: + log_type = AlertGroupLogRecord.TYPE_ESCALATION_FAILED + error_code = AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_CUSTOM_WEBHOOK_ERROR + reason = error + + AlertGroupLogRecord.objects.create( + type=log_type, + alert_group=alert_group, + author=user, + reason=reason, + step_specific_info={ + "webhook_name": webhook.name, + "webhook_id": webhook.public_primary_key, + "trigger": TRIGGER_TYPE_TO_LABEL[webhook.trigger_type], + }, + escalation_policy=escalation_policy, + escalation_policy_step=step, + escalation_error_code=error_code, + ) + if exception: raise exception diff --git a/engine/apps/webhooks/tests/test_trigger_webhook.py b/engine/apps/webhooks/tests/test_trigger_webhook.py index 4a8408ab..c83145b0 100644 --- a/engine/apps/webhooks/tests/test_trigger_webhook.py +++ b/engine/apps/webhooks/tests/test_trigger_webhook.py @@ -4,6 +4,7 @@ from unittest.mock import call, patch import pytest from django.utils import timezone +from apps.alerts.models import AlertGroupLogRecord, EscalationPolicy from apps.public_api.serializers import IncidentSerializer from apps.webhooks.models import Webhook from apps.webhooks.tasks import execute_webhook, send_webhook_event @@ -42,7 +43,7 @@ def test_send_webhook_event_filters( for trigger_type, _ in Webhook.TRIGGER_TYPES: with patch("apps.webhooks.tasks.trigger_webhook.execute_webhook.apply_async") as mock_execute: send_webhook_event(trigger_type, alert_group.pk, organization_id=organization.pk) - assert mock_execute.call_args == call((webhooks[trigger_type].pk, alert_group.pk, None)) + assert mock_execute.call_args == call((webhooks[trigger_type].pk, alert_group.pk, None, None)) # other team alert_receive_channel = make_alert_receive_channel(organization, team=other_team) @@ -51,14 +52,14 @@ def test_send_webhook_event_filters( send_webhook_event( Webhook.TRIGGER_ACKNOWLEDGE, alert_group.pk, organization_id=organization.pk, team_id=other_team.pk ) - assert mock_execute.call_args == call((other_team_webhook.pk, alert_group.pk, None)) + assert mock_execute.call_args == call((other_team_webhook.pk, alert_group.pk, None, None)) # other org alert_receive_channel = make_alert_receive_channel(other_organization) alert_group = make_alert_group(alert_receive_channel) with patch("apps.webhooks.tasks.trigger_webhook.execute_webhook.apply_async") as mock_execute: send_webhook_event(Webhook.TRIGGER_NEW, alert_group.pk, organization_id=other_organization.pk) - assert mock_execute.call_args == call((other_org_webhook.pk, alert_group.pk, None)) + assert mock_execute.call_args == call((other_org_webhook.pk, alert_group.pk, None, None)) @pytest.mark.django_db @@ -89,7 +90,7 @@ def test_execute_webhook_ok( mock_gethostbyname.return_value = "8.8.8.8" with patch("apps.webhooks.models.webhook.requests") as mock_requests: mock_requests.post.return_value = mock_response - execute_webhook(webhook.pk, alert_group.pk, user.pk) + execute_webhook(webhook.pk, alert_group.pk, user.pk, None) assert mock_requests.post.called expected_call = call( @@ -106,6 +107,75 @@ def test_execute_webhook_ok( assert log.request_data == json.dumps({"value": alert_group.public_primary_key}) assert log.request_headers == json.dumps({"some-header": alert_group.public_primary_key}) assert log.url == "https://something/{}/".format(alert_group.public_primary_key) + # check log record + log_record = alert_group.log_records.last() + assert log_record.type == AlertGroupLogRecord.TYPE_CUSTOM_BUTTON_TRIGGERED + expected_info = { + "trigger": "acknowledge", + "webhook_id": webhook.public_primary_key, + "webhook_name": webhook.name, + } + assert log_record.step_specific_info == expected_info + assert log_record.escalation_policy is None + assert log_record.escalation_policy_step is None + assert log_record.rendered_log_line_action() == f"outgoing webhook `{webhook.name}` triggered by acknowledge" + + +@pytest.mark.django_db +def test_execute_webhook_via_escalation_ok( + make_organization, + make_user_for_organization, + make_alert_receive_channel, + make_alert_group, + make_custom_webhook, + make_escalation_chain, + make_escalation_policy, +): + organization = make_organization() + user = make_user_for_organization(organization) + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group( + alert_receive_channel, acknowledged_at=timezone.now(), acknowledged=True, acknowledged_by=user.pk + ) + webhook = make_custom_webhook( + organization=organization, + url="https://something/{{ alert_group_id }}/", + http_method="POST", + trigger_type=Webhook.TRIGGER_ESCALATION_STEP, + trigger_template="{{{{ alert_group.integration_id == '{}' }}}}".format( + alert_receive_channel.public_primary_key + ), + headers='{"some-header": "{{ alert_group_id }}"}', + data='{"value": "{{ alert_group_id }}"}', + forward_all=False, + ) + escalation_chain = make_escalation_chain(organization) + escalation_policy = make_escalation_policy( + escalation_chain=escalation_chain, + escalation_policy_step=EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK, + custom_webhook=webhook, + ) + + mock_response = MockResponse() + with patch("apps.webhooks.utils.socket.gethostbyname") as mock_gethostbyname: + mock_gethostbyname.return_value = "8.8.8.8" + with patch("apps.webhooks.models.webhook.requests") as mock_requests: + mock_requests.post.return_value = mock_response + execute_webhook(webhook.pk, alert_group.pk, user.pk, escalation_policy.pk) + + assert mock_requests.post.called + # check log record + log_record = alert_group.log_records.last() + assert log_record.type == AlertGroupLogRecord.TYPE_CUSTOM_BUTTON_TRIGGERED + expected_info = { + "trigger": "escalation", + "webhook_id": webhook.public_primary_key, + "webhook_name": webhook.name, + } + assert log_record.step_specific_info == expected_info + assert log_record.escalation_policy == escalation_policy + assert log_record.escalation_policy_step == EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK + assert log_record.rendered_log_line_action() == f"outgoing webhook `{webhook.name}` triggered by escalation" @pytest.mark.django_db @@ -131,7 +201,7 @@ def test_execute_webhook_ok_forward_all( mock_gethostbyname.return_value = "8.8.8.8" with patch("apps.webhooks.models.webhook.requests") as mock_requests: mock_requests.post.return_value = mock_response - execute_webhook(webhook.pk, alert_group.pk, user.pk) + execute_webhook(webhook.pk, alert_group.pk, user.pk, None) assert mock_requests.post.called expected_data = { @@ -197,7 +267,7 @@ def test_execute_webhook_using_responses_data( mock_gethostbyname.return_value = "8.8.8.8" with patch("apps.webhooks.models.webhook.requests") as mock_requests: mock_requests.post.return_value = mock_response - execute_webhook(webhook.pk, alert_group.pk, user.pk) + execute_webhook(webhook.pk, alert_group.pk, user.pk, None) assert mock_requests.post.called expected_data = {"value": "updated"} @@ -232,7 +302,7 @@ def test_execute_webhook_trigger_false( ) with patch("apps.webhooks.models.webhook.requests") as mock_requests: - execute_webhook(webhook.pk, alert_group.pk, None) + execute_webhook(webhook.pk, alert_group.pk, None, None) assert not mock_requests.post.called # check no logs @@ -293,7 +363,7 @@ def test_execute_webhook_errors( # make it a valid URL when resolving name mock_gethostbyname.return_value = "8.8.8.8" with patch("apps.webhooks.models.webhook.requests") as mock_requests: - execute_webhook(webhook.pk, alert_group.pk, None) + execute_webhook(webhook.pk, alert_group.pk, None, None) assert not mock_requests.post.called log = webhook.responses.all()[0] @@ -301,3 +371,16 @@ def test_execute_webhook_errors( assert log.content is None error = getattr(log, log_field_name) assert error == expected_error + # check log record + log_record = alert_group.log_records.last() + assert log_record.type == AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_CUSTOM_WEBHOOK_ERROR + expected_info = { + "trigger": "resolve", + "webhook_id": webhook.public_primary_key, + "webhook_name": webhook.name, + } + assert log_record.step_specific_info == expected_info + assert log_record.reason == expected_error + assert ( + log_record.rendered_log_line_action() == f"skipped resolve outgoing webhook `{webhook.name}`: {expected_error}" + ) diff --git a/engine/apps/webhooks/utils.py b/engine/apps/webhooks/utils.py index 092c169c..813eeb8d 100644 --- a/engine/apps/webhooks/utils.py +++ b/engine/apps/webhooks/utils.py @@ -4,6 +4,7 @@ import re import socket from urllib.parse import urlparse +from django.apps import apps from django.conf import settings from apps.base.utils import live_settings @@ -132,3 +133,16 @@ def serialize_event(event, alert_group, user, responses=None): data["responses"] = responses return data + + +def is_webhooks_enabled_for_organization(organization_id): + DynamicSetting = apps.get_model("base", "DynamicSetting") + enabled_webhooks_orgs = DynamicSetting.objects.get_or_create( + name="enabled_webhooks_2_orgs", + defaults={ + "json_value": { + "org_ids": [], + } + }, + )[0] + return organization_id in enabled_webhooks_orgs.json_value["org_ids"] diff --git a/grafana-plugin/src/components/Policy/EscalationPolicy.tsx b/grafana-plugin/src/components/Policy/EscalationPolicy.tsx index ac0811c3..987d3e7b 100644 --- a/grafana-plugin/src/components/Policy/EscalationPolicy.tsx +++ b/grafana-plugin/src/components/Policy/EscalationPolicy.tsx @@ -22,6 +22,7 @@ import { } from 'models/escalation_policy/escalation_policy.types'; import { GrafanaTeamStore } from 'models/grafana_team/grafana_team'; import { OutgoingWebhookStore } from 'models/outgoing_webhook/outgoing_webhook'; +import { OutgoingWebhook2Store } from 'models/outgoing_webhook_2/outgoing_webhook_2'; import { ScheduleStore } from 'models/schedule/schedule'; import { WaitDelay } from 'models/wait_delay'; import { SelectOption } from 'state/types'; @@ -47,6 +48,7 @@ export interface EscalationPolicyProps { isSlackInstalled: boolean; teamStore: GrafanaTeamStore; outgoingWebhookStore: OutgoingWebhookStore; + outgoingWebhook2Store: OutgoingWebhook2Store; scheduleStore: ScheduleStore; } @@ -99,6 +101,8 @@ export class EscalationPolicy extends React.Component + Manage "Default notifications" +
+ in personal settings + + ), + }, + { + value: 1, + label: 'Important', + // @ts-ignore + description: ( + <> + Manage "Important notifications" +
+ in personal settings + + ), + }, ]} width={'auto'} /> @@ -365,6 +391,40 @@ export class EscalationPolicy extends React.Component + { + const team = teamStore.items[outgoingWebhook2Store.items[item.value].team]; + return ( + <> + {item.label} + + + ); + }} + width={'auto'} + /> + + ); + } + _getOnSelectChangeHandler = (field: string) => { return (option: SelectableValue) => { const { data, onChange = () => {} } = this.props; diff --git a/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx index 59fa3759..5ffb7c3e 100644 --- a/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx +++ b/grafana-plugin/src/components/ScheduleQuality/ScheduleQuality.tsx @@ -4,8 +4,8 @@ import { Tooltip, VerticalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; import PluginLink from 'components/PluginLink/PluginLink'; -import ScheduleCounter from 'components/ScheduleCounter/ScheduleCounter'; import { ScheduleQualityDetails } from 'components/ScheduleQualityDetails/ScheduleQualityDetails'; +import StatusCounterBadgeWithTooltip from 'components/StatusCounterBadgeWithTooltip/StatusCounterBadgeWithTooltip'; import Tag from 'components/Tag/Tag'; import Text from 'components/Text/Text'; import { Schedule, ScheduleScoreQualityResponse, ScheduleScoreQualityResult } from 'models/schedule/schedule.types'; @@ -40,7 +40,7 @@ const ScheduleQuality: FC = ({ schedule, lastUpdated }) => <>
{relatedEscalationChains?.length > 0 && schedule?.number_of_escalation_chains > 0 && ( - = ({ schedule, lastUpdated }) => )} {schedule.warnings?.length > 0 && ( - ; count: number; tooltipTitle: string; @@ -23,7 +23,7 @@ const typeToIcon = { const cx = cn.bind(styles); -const ScheduleCounter: FC = (props) => { +const StatusCounterBadgeWithTooltip: FC = (props) => { const { type, count, tooltipTitle, tooltipContent, onHover, addPadding } = props; return ( @@ -55,4 +55,4 @@ const ScheduleCounter: FC = (props) => { ); }; -export default ScheduleCounter; +export default StatusCounterBadgeWithTooltip; diff --git a/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx b/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx index aa9bd5ab..910416db 100644 --- a/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx +++ b/grafana-plugin/src/containers/EscalationChainSteps/EscalationChainSteps.tsx @@ -90,6 +90,7 @@ const EscalationChainSteps = observer((props: EscalationChainStepsProps) => { teamStore={store.grafanaTeamStore} scheduleStore={store.scheduleStore} outgoingWebhookStore={store.outgoingWebhookStore} + outgoingWebhook2Store={store.outgoingWebhook2Store} /> ); }) diff --git a/grafana-plugin/src/models/escalation_policy/escalation_policy.types.ts b/grafana-plugin/src/models/escalation_policy/escalation_policy.types.ts index d171cee6..d3faa65b 100644 --- a/grafana-plugin/src/models/escalation_policy/escalation_policy.types.ts +++ b/grafana-plugin/src/models/escalation_policy/escalation_policy.types.ts @@ -19,6 +19,7 @@ export interface EscalationPolicy { to_time: string | null; notify_to_channel: Channel['id'] | null; custom_button_trigger: ActionDTO['id'] | null; + custom_webhook: ActionDTO['id'] | null; notify_to_group: UserGroup['id'] | null; notify_schedule: Schedule['id'] | null; important: boolean | null; diff --git a/grafana-plugin/src/models/filters/filters.helpers.ts b/grafana-plugin/src/models/filters/filters.helpers.ts index 7356c1c2..b3ca0149 100644 --- a/grafana-plugin/src/models/filters/filters.helpers.ts +++ b/grafana-plugin/src/models/filters/filters.helpers.ts @@ -1,6 +1,10 @@ export const getApiPathByPage = (page: string) => { return ( - { outgoing_webhooks: 'custom_buttons', incidents: 'alertgroups', integrations: 'alert_receive_channels' }[page] || - page + { + outgoing_webhooks: 'custom_buttons', + outgoing_webhooks_2: 'webhooks', + incidents: 'alertgroups', + integrations: 'alert_receive_channels', + }[page] || page ); }; diff --git a/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.ts b/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.ts index 893f5f84..b23f8a86 100644 --- a/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.ts +++ b/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.ts @@ -1,7 +1,6 @@ import { action, observable } from 'mobx'; import BaseStore from 'models/base_store'; -import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types'; import { makeRequest } from 'network'; import { RootStore } from 'state'; @@ -14,6 +13,9 @@ export class OutgoingWebhook2Store extends BaseStore { @observable.shallow searchResult: { [key: string]: Array } = {}; + @observable + incidentFilters: any; + constructor(rootStore: RootStore) { super(rootStore); @@ -45,7 +47,6 @@ export class OutgoingWebhook2Store extends BaseStore { @action async updateItem(id: OutgoingWebhook2['id'], fromOrganization = false) { const response = await this.getById(id, false, fromOrganization); - this.items = { ...this.items, [id]: response, @@ -75,10 +76,17 @@ export class OutgoingWebhook2Store extends BaseStore { this.searchResult = { ...this.searchResult, - [key]: results.map((item: OutgoingWebhook) => item.id), + [key]: results.map((item: OutgoingWebhook2) => item.id), }; } + @action + async updateOutgoingWebhooks2Filters(params: any) { + this.incidentFilters = params; + + this.updateItems(); + } + getSearchResult(query = '') { if (!this.searchResult[query]) { return undefined; diff --git a/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.types.ts b/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.types.ts index 79a20558..fdad4b74 100644 --- a/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.types.ts +++ b/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.types.ts @@ -1,3 +1,5 @@ +import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; + export interface OutgoingWebhook2 { authorization_header: string; data: string; @@ -7,7 +9,7 @@ export interface OutgoingWebhook2 { last_run: string; name: string; password: string; - team: null; + team: GrafanaTeam['id']; trigger_type: number; trigger_type_name: string; url: string; diff --git a/grafana-plugin/src/pages/schedules/Schedules.tsx b/grafana-plugin/src/pages/schedules/Schedules.tsx index 84f0aea9..42edf46e 100644 --- a/grafana-plugin/src/pages/schedules/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules/Schedules.tsx @@ -11,8 +11,8 @@ import Avatar from 'components/Avatar/Avatar'; import { MatchMediaTooltip } from 'components/MatchMediaTooltip/MatchMediaTooltip'; import NewScheduleSelector from 'components/NewScheduleSelector/NewScheduleSelector'; import PluginLink from 'components/PluginLink/PluginLink'; -import ScheduleCounter from 'components/ScheduleCounter/ScheduleCounter'; import { SchedulesFiltersType } from 'components/SchedulesFilters/SchedulesFilters.types'; +import StatusCounterBadgeWithTooltip from 'components/StatusCounterBadgeWithTooltip/StatusCounterBadgeWithTooltip'; import Table from 'components/Table/Table'; import Text from 'components/Text/Text'; import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; @@ -306,7 +306,7 @@ class SchedulesPage extends React.Component {item.number_of_escalation_chains > 0 && ( - 0 && ( - { } return ( -
- - {texts.join(', ')} -
+ + + {texts.map((warning, index) => ( + + {warning} + + ))} + + } + /> + ); } diff --git a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx index 4412037f..1c430ebf 100644 --- a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx @@ -163,7 +163,7 @@ export const Root = observer((props: AppRootProps) => { - +