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 2ee420e7..a082270e 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 @@ -266,7 +266,7 @@ class EscalationPolicySnapshot: escalation_policy_step=self.step, ) else: - notify_to_users_list = list_users_to_notify_from_ical(on_call_schedule) + notify_to_users_list = list_users_to_notify_from_ical(on_call_schedule, include_viewers=True) if notify_to_users_list is None: log_record = AlertGroupLogRecord( type=AlertGroupLogRecord.TYPE_ESCALATION_FAILED, diff --git a/engine/apps/alerts/tasks/notify_user.py b/engine/apps/alerts/tasks/notify_user.py index 05a9456f..fcbde7d6 100644 --- a/engine/apps/alerts/tasks/notify_user.py +++ b/engine/apps/alerts/tasks/notify_user.py @@ -56,6 +56,13 @@ def notify_user_task( if not user.is_notification_allowed: task_logger.info(f"notify_user_task: user {user.pk} notification is not allowed for role {user.role}") + UserNotificationPolicyLogRecord( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + reason=f"notification is not allowed for user with role {user.role}", + alert_group=alert_group, + notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ALLOWED_USER_ROLE, + ).save() return user_has_notification, _ = UserHasNotification.objects.get_or_create( @@ -257,6 +264,16 @@ def perform_notification(log_record_pk): ).save() return + if not user.is_notification_allowed: + UserNotificationPolicyLogRecord( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + reason=f"notification is not allowed for user with role {user.role}", + alert_group=alert_group, + notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ALLOWED_USER_ROLE, + ).save() + return + if notification_channel == UserNotificationPolicy.NotificationChannel.SMS: SMSMessage.send_sms(user, alert_group, notification_policy) diff --git a/engine/apps/alerts/tests/test_escalation_policy_snapshot.py b/engine/apps/alerts/tests/test_escalation_policy_snapshot.py index a3d27f45..9a555c35 100644 --- a/engine/apps/alerts/tests/test_escalation_policy_snapshot.py +++ b/engine/apps/alerts/tests/test_escalation_policy_snapshot.py @@ -10,6 +10,7 @@ from apps.alerts.escalation_snapshot.utils import eta_for_escalation_step_notify from apps.alerts.models import AlertGroupLogRecord, EscalationPolicy from apps.schedules.ical_utils import list_users_to_notify_from_ical from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar +from common.constants.role import Role def get_escalation_policy_snapshot_from_model(escalation_policy): @@ -200,6 +201,55 @@ def test_escalation_step_notify_on_call_schedule( 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_notify_on_call_schedule_viewer_user( + mocked_execute_tasks, + escalation_step_test_setup, + make_user_for_organization, + make_escalation_policy, + make_schedule, + make_on_call_shift, +): + organization, user, _, channel_filter, alert_group, reason = escalation_step_test_setup + viewer = make_user_for_organization(organization=organization, role=Role.VIEWER) + + schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar) + # create on_call_shift with user to notify + data = { + "start": timezone.datetime.now().replace(microsecond=0), + "duration": timezone.timedelta(seconds=7200), + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_SINGLE_EVENT, **data + ) + on_call_shift.users.add(viewer) + schedule.custom_on_call_shifts.add(on_call_shift) + + notify_schedule_step = make_escalation_policy( + escalation_chain=channel_filter.escalation_chain, + escalation_policy_step=EscalationPolicy.STEP_NOTIFY_SCHEDULE, + notify_schedule=schedule, + ) + escalation_policy_snapshot = get_escalation_policy_snapshot_from_model(notify_schedule_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 notify_schedule_step.log_records.filter(type=AlertGroupLogRecord.TYPE_ESCALATION_TRIGGERED).exists() + assert list(escalation_policy_snapshot.notify_to_users_queue) == list( + list_users_to_notify_from_ical(schedule, include_viewers=True) + ) + assert list(escalation_policy_snapshot.notify_to_users_queue) == [viewer] + 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_notify_user_group( diff --git a/engine/apps/alerts/tests/test_notify_user.py b/engine/apps/alerts/tests/test_notify_user.py index 06677544..0f43305b 100644 --- a/engine/apps/alerts/tests/test_notify_user.py +++ b/engine/apps/alerts/tests/test_notify_user.py @@ -2,9 +2,10 @@ from unittest.mock import patch import pytest -from apps.alerts.tasks.notify_user import perform_notification +from apps.alerts.tasks.notify_user import notify_user_task, perform_notification from apps.base.models.user_notification_policy import UserNotificationPolicy from apps.base.models.user_notification_policy_log_record import UserNotificationPolicyLogRecord +from common.constants.role import Role @pytest.mark.django_db @@ -118,3 +119,62 @@ def test_notify_user_missing_data_errors( assert error_log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED assert error_log_record.reason == "Expected data is missing" assert error_log_record.notification_error_code is None + + +@pytest.mark.django_db +def test_notify_user_perform_notification_error_if_viewer( + make_organization, + make_user, + make_user_notification_policy, + make_alert_receive_channel, + make_alert_group, + make_user_notification_policy_log_record, +): + organization = make_organization() + user_1 = make_user(organization=organization, role=Role.VIEWER, _verified_phone_number="1234567890") + user_notification_policy = make_user_notification_policy( + user=user_1, + step=UserNotificationPolicy.Step.NOTIFY, + notify_by=UserNotificationPolicy.NotificationChannel.SMS, + ) + alert_receive_channel = make_alert_receive_channel(organization=organization) + alert_group = make_alert_group(alert_receive_channel=alert_receive_channel) + log_record = make_user_notification_policy_log_record( + author=user_1, + alert_group=alert_group, + notification_policy=user_notification_policy, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_TRIGGERED, + ) + + perform_notification(log_record.pk) + + error_log_record = UserNotificationPolicyLogRecord.objects.last() + assert error_log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED + assert error_log_record.reason == f"notification is not allowed for user with role {user_1.role}" + assert ( + error_log_record.notification_error_code + == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ALLOWED_USER_ROLE + ) + + +@pytest.mark.django_db +def test_notify_user_error_if_viewer( + make_organization, + make_user, + make_alert_receive_channel, + make_alert_group, +): + organization = make_organization() + user_1 = make_user(organization=organization, role=Role.VIEWER, _verified_phone_number="1234567890") + alert_receive_channel = make_alert_receive_channel(organization=organization) + alert_group = make_alert_group(alert_receive_channel=alert_receive_channel) + + notify_user_task(user_1.pk, alert_group.pk) + + error_log_record = UserNotificationPolicyLogRecord.objects.last() + assert error_log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED + assert error_log_record.reason == f"notification is not allowed for user with role {user_1.role}" + assert ( + error_log_record.notification_error_code + == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ALLOWED_USER_ROLE + ) diff --git a/engine/apps/base/models/user_notification_policy_log_record.py b/engine/apps/base/models/user_notification_policy_log_record.py index 93fd0820..15f86067 100644 --- a/engine/apps/base/models/user_notification_policy_log_record.py +++ b/engine/apps/base/models/user_notification_policy_log_record.py @@ -68,7 +68,8 @@ class UserNotificationPolicyLogRecord(models.Model): ERROR_NOTIFICATION_IN_SLACK_CHANNEL_IS_ARCHIVED, ERROR_NOTIFICATION_IN_SLACK_RATELIMIT, ERROR_NOTIFICATION_MESSAGING_BACKEND_ERROR, - ) = range(25) + ERROR_NOTIFICATION_NOT_ALLOWED_USER_ROLE, + ) = range(26) # for this errors we want to send message to general log channel ERRORS_TO_SEND_IN_SLACK_CHANNEL = [ @@ -266,6 +267,10 @@ class UserNotificationPolicyLogRecord(models.Model): result += f"failed to notify {user_verbal} in Slack, because channel is archived" elif self.notification_error_code == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_SLACK_RATELIMIT: result += f"failed to notify {user_verbal} in Slack due to Slack rate limit" + elif ( + self.notification_error_code == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ALLOWED_USER_ROLE + ): + result += f"failed to notify {user_verbal}, not allowed role" else: # TODO: handle specific backend errors try: diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index 309729cd..cb0bc342 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -26,14 +26,18 @@ if TYPE_CHECKING: from apps.user_management.models import User -def users_in_ical(usernames_from_ical, organization): +def users_in_ical(usernames_from_ical, organization, include_viewers=False): """ Parse ical file and return list of users found """ # Only grafana username will be used, consider adding grafana email and id - users_found_in_ical = organization.users.filter( - Q(role__in=(Role.ADMIN, Role.EDITOR)) & (Q(username__in=usernames_from_ical) | Q(email__in=usernames_from_ical)) + users_found_in_ical = organization.users + if not include_viewers: + users_found_in_ical = users_found_in_ical.filter(role__in=(Role.ADMIN, Role.EDITOR)) + + users_found_in_ical = users_found_in_ical.filter( + (Q(username__in=usernames_from_ical) | Q(email__in=usernames_from_ical)) ).distinct() # Here is the example how we extracted users previously, using slack fields too @@ -260,15 +264,17 @@ def list_of_empty_shifts_in_schedule(schedule, start_date, end_date): return sorted(empty_shifts, key=lambda dt: dt.start) -def list_users_to_notify_from_ical(schedule, events_datetime=None): +def list_users_to_notify_from_ical(schedule, events_datetime=None, include_viewers=False): """ Retrieve on-call users for the current time """ events_datetime = events_datetime if events_datetime else timezone.datetime.now(timezone.utc) - return list_users_to_notify_from_ical_for_period(schedule, events_datetime, events_datetime) + return list_users_to_notify_from_ical_for_period( + schedule, events_datetime, events_datetime, include_viewers=include_viewers + ) -def list_users_to_notify_from_ical_for_period(schedule, start_datetime, end_datetime): +def list_users_to_notify_from_ical_for_period(schedule, start_datetime, end_datetime, include_viewers=False): # get list of iCalendars from current iCal files. If there is more than one calendar, primary calendar will always # be the first calendars = schedule.get_icalendars() @@ -286,7 +292,7 @@ def list_users_to_notify_from_ical_for_period(schedule, start_datetime, end_date parsed_ical_events.setdefault(current_priority, []).extend(current_usernames) # find users by usernames. if users are not found for shift, get users from lower priority for _, usernames in sorted(parsed_ical_events.items(), reverse=True): - users_found_in_ical = users_in_ical(usernames, schedule.organization) + users_found_in_ical = users_in_ical(usernames, schedule.organization, include_viewers=include_viewers) if users_found_in_ical: break if users_found_in_ical: diff --git a/engine/apps/schedules/tests/test_ical_utils.py b/engine/apps/schedules/tests/test_ical_utils.py new file mode 100644 index 00000000..8032334d --- /dev/null +++ b/engine/apps/schedules/tests/test_ical_utils.py @@ -0,0 +1,60 @@ +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.models import CustomOnCallShift, OnCallScheduleCalendar +from common.constants.role import Role + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "include_viewers", + [True, False], +) +def test_users_in_ical_viewers_inclusion(make_organization_and_user, make_user_for_organization, include_viewers): + organization, user = make_organization_and_user() + viewer = make_user_for_organization(organization, Role.VIEWER) + + usernames = [user.username, viewer.username] + result = users_in_ical(usernames, organization, include_viewers=include_viewers) + if include_viewers: + assert set(result) == {user, viewer} + else: + assert set(result) == {user} + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "include_viewers", + [True, False], +) +def test_list_users_to_notify_from_ical_viewers_inclusion( + make_organization_and_user, make_user_for_organization, make_schedule, make_on_call_shift, include_viewers +): + organization, user = make_organization_and_user() + viewer = make_user_for_organization(organization, Role.VIEWER) + + schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar) + date = timezone.now().replace(tzinfo=None, microsecond=0) + data = { + "priority_level": 1, + "start": date, + "duration": timezone.timedelta(seconds=10800), + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_SINGLE_EVENT, **data + ) + on_call_shift.users.add(user) + on_call_shift.users.add(viewer) + schedule.custom_on_call_shifts.add(on_call_shift) + + # get users on-call + date = date + timezone.timedelta(minutes=5) + users_on_call = list_users_to_notify_from_ical(schedule, date, include_viewers=include_viewers) + + if include_viewers: + assert len(users_on_call) == 2 + assert set(users_on_call) == {user, viewer} + else: + assert len(users_on_call) == 1 + assert set(users_on_call) == {user}