Skip to content

Commit 1a6d778

Browse files
authored
Fix notification plan builder (#4726)
# What this PR does Fixes building notification plan if one or more notifications were bundled ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes.
1 parent 1622d94 commit 1a6d778

File tree

7 files changed

+161
-14
lines changed

7 files changed

+161
-14
lines changed

engine/apps/alerts/incident_log_builder/incident_log_builder.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.db.models import Q
44
from django.utils import timezone
55

6+
from apps.alerts.constants import BUNDLED_NOTIFICATION_DELAY_SECONDS
67
from apps.base.messaging import get_messaging_backend_from_id
78
from apps.schedules.ical_utils import list_users_to_notify_from_ical
89

@@ -640,6 +641,24 @@ def _get_notification_plan_for_user(
640641

641642
last_user_log = None
642643

644+
# get ids of notification policies with bundled notification
645+
notification_policies_in_bundle = (
646+
self.alert_group.bundled_notifications.all()
647+
.values(
648+
"notification_policy",
649+
"bundle_uuid",
650+
)
651+
.distinct()
652+
)
653+
# get lists of notification policies with scheduled but not triggered bundled notifications
654+
# and of all notification policies with bundled notifications
655+
notification_policy_ids_in_scheduled_bundle: typing.Set[int] = set()
656+
notification_policy_ids_in_bundle: typing.Set[int] = set()
657+
for notification_policy_in_bundle in notification_policies_in_bundle:
658+
if notification_policy_in_bundle["bundle_uuid"] is None:
659+
notification_policy_ids_in_scheduled_bundle.add(notification_policy_in_bundle["notification_policy"])
660+
notification_policy_ids_in_bundle.add(notification_policy_in_bundle["notification_policy"])
661+
643662
notification_policy_order = 0
644663
if not future_step: # escalation step has been passed, so escalation for user has been already triggered.
645664
last_user_log = (
@@ -651,6 +670,8 @@ def _get_notification_plan_for_user(
651670
UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FINISHED,
652671
],
653672
)
673+
# exclude logs with bundled notification
674+
.exclude(notification_policy_id__in=notification_policy_ids_in_bundle)
654675
.order_by("created_at")
655676
.last()
656677
)
@@ -673,19 +694,30 @@ def _get_notification_plan_for_user(
673694
_, notification_policies = user_to_notify.get_notification_policies_or_use_default_fallback(important=important)
674695

675696
for notification_policy in notification_policies:
676-
future_notification = notification_policy.order >= notification_policy_order
697+
# notification step has been passed but was bundled and delayed - show this step in notification plan
698+
is_scheduled_bundled_notification = notification_policy.id in notification_policy_ids_in_scheduled_bundle
699+
# notification step has not been passed - show this step in notification plan as well
700+
future_notification = (
701+
notification_policy.order >= notification_policy_order
702+
and notification_policy.id not in notification_policy_ids_in_bundle
703+
)
677704
if notification_policy.step == UserNotificationPolicy.Step.WAIT:
678705
wait_delay = notification_policy.wait_delay
679706
if wait_delay is not None:
680707
timedelta += wait_delay # increase timedelta for next steps
681-
elif future_notification:
708+
elif future_notification or is_scheduled_bundled_notification:
709+
notification_timedelta = (
710+
timedelta + timezone.timedelta(seconds=BUNDLED_NOTIFICATION_DELAY_SECONDS)
711+
if is_scheduled_bundled_notification
712+
else timedelta
713+
)
682714
plan_line = self._render_user_notification_line(
683715
user_to_notify, notification_policy, for_slack=for_slack
684716
)
685717
# add plan_line to user plan_lines list
686-
if not notification_plan_dict.get(timedelta):
718+
if not notification_plan_dict.get(notification_timedelta):
687719
plan = {"user_id": user_to_notify.pk, "plan_lines": [plan_line]}
688-
notification_plan_dict.setdefault(timedelta, []).append(plan)
720+
notification_plan_dict.setdefault(notification_timedelta, []).append(plan)
689721
else:
690-
notification_plan_dict[timedelta][0]["plan_lines"].append(plan_line)
722+
notification_plan_dict[notification_timedelta][0]["plan_lines"].append(plan_line)
691723
return notification_plan_dict
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Generated by Django 4.2.10 on 2024-07-24 14:24
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('alerts', '0054_usernotificationbundle_bundlednotification_and_more'),
11+
]
12+
13+
operations = [
14+
migrations.AlterField(
15+
model_name='bundlednotification',
16+
name='alert_group',
17+
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bundled_notifications', to='alerts.alertgroup'),
18+
),
19+
]

engine/apps/alerts/models/alert_group.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
Alert,
4343
AlertGroupLogRecord,
4444
AlertReceiveChannel,
45+
BundledNotification,
4546
ResolutionNote,
4647
ResolutionNoteSlackMessage,
4748
)
@@ -189,6 +190,7 @@ def slack_templated_first_alert(self):
189190
class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.Model):
190191
acknowledged_by_user: typing.Optional["User"]
191192
alerts: "RelatedManager['Alert']"
193+
bundled_notifications: "RelatedManager['BundledNotification']"
192194
dependent_alert_groups: "RelatedManager['AlertGroup']"
193195
channel: "AlertReceiveChannel"
194196
log_records: "RelatedManager['AlertGroupLogRecord']"

engine/apps/alerts/models/user_notification_bundle.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ class BundledNotification(models.Model):
7777
notification_policy: typing.Optional["UserNotificationPolicy"]
7878
notification_bundle: "UserNotificationBundle"
7979

80-
alert_group = models.ForeignKey("alerts.AlertGroup", on_delete=models.CASCADE)
80+
alert_group = models.ForeignKey("alerts.AlertGroup", on_delete=models.CASCADE, related_name="bundled_notifications")
8181
alert_receive_channel = models.ForeignKey("alerts.AlertReceiveChannel", on_delete=models.CASCADE)
8282
notification_policy = models.ForeignKey("base.UserNotificationPolicy", on_delete=models.SET_NULL, null=True)
8383
notification_bundle = models.ForeignKey(

engine/apps/alerts/tasks/notify_user.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,7 @@ def send_bundled_notification(user_notification_bundle_id: int):
583583
active_alert_group_ids: typing.Set[int] = set()
584584
log_record_notification_triggered = None
585585
is_notification_allowed = user_notification_bundle.user.is_notification_allowed
586+
bundle_uuid = uuid4()
586587

587588
# create logs
588589
for notification in notifications:
@@ -609,11 +610,19 @@ def send_bundled_notification(user_notification_bundle_id: int):
609610
author=user_notification_bundle.user,
610611
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_TRIGGERED,
611612
alert_group=notification.alert_group,
613+
notification_policy=notification.notification_policy,
612614
notification_step=UserNotificationPolicy.Step.NOTIFY,
613615
notification_channel=user_notification_bundle.notification_channel,
614616
)
615617
log_records_to_create.append(log_record_notification_triggered)
616618

619+
# delete non-active notifications and update bundle_uuid for the rest notifications
620+
if not is_notification_allowed:
621+
notifications.delete()
622+
else:
623+
notifications.filter(id__in=skip_notification_ids).delete()
624+
notifications.update(bundle_uuid=bundle_uuid)
625+
617626
if len(log_records_to_create) == 1 and log_record_notification_triggered:
618627
# perform regular notification
619628
log_record_notification_triggered.save()
@@ -629,7 +638,6 @@ def send_bundled_notification(user_notification_bundle_id: int):
629638
False,
630639
)
631640
)
632-
notifications.delete()
633641
else:
634642
UserNotificationPolicyLogRecord.objects.bulk_create(log_records_to_create, batch_size=5000)
635643

@@ -638,11 +646,7 @@ def send_bundled_notification(user_notification_bundle_id: int):
638646
f"no alert groups to notify about or notification is not allowed for user "
639647
f"{user_notification_bundle.user_id}"
640648
)
641-
notifications.delete()
642649
else:
643-
notifications.filter(id__in=skip_notification_ids).delete()
644-
bundle_uuid = uuid4()
645-
notifications.update(bundle_uuid=bundle_uuid)
646650
task_logger.info(
647651
f"perform bundled notification for alert groups with ids: {active_alert_group_ids}, "
648652
f"bundle_uuid: {bundle_uuid}"

engine/apps/alerts/tests/test_incident_log_builder.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import pytest
2+
from django.utils import timezone
23

34
from apps.alerts.incident_log_builder import IncidentLogBuilder
45
from apps.alerts.models import EscalationPolicy
5-
from apps.base.models import UserNotificationPolicy
6+
from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord
67

78

89
@pytest.mark.django_db
@@ -39,6 +40,92 @@ def test_escalation_plan_messaging_backends(
3940
assert list(plan.values()) == [["send test only backend message to {}".format(user.username)]]
4041

4142

43+
@pytest.mark.django_db
44+
def test_get_notification_plan_for_user_with_bundled_notification(
45+
make_organization_and_user,
46+
make_user_notification_bundle,
47+
make_user_notification_policy,
48+
make_alert_receive_channel,
49+
make_alert_group,
50+
make_user_notification_policy_log_record,
51+
):
52+
"""
53+
Test building notification plan when one of the notifications was bundled:
54+
- test that scheduled but not triggered bundled notification appears in notification plan
55+
"""
56+
57+
organization, user = make_organization_and_user()
58+
alert_receive_channel = make_alert_receive_channel(organization)
59+
alert_group = make_alert_group(alert_receive_channel)
60+
61+
log_builder = IncidentLogBuilder(alert_group)
62+
63+
notification_bundle = make_user_notification_bundle(user, UserNotificationPolicy.NotificationChannel.SMS)
64+
notification_policy_sms = make_user_notification_policy(
65+
user, UserNotificationPolicy.Step.NOTIFY, notify_by=UserNotificationPolicy.NotificationChannel.SMS
66+
)
67+
notification_policy_slack = make_user_notification_policy(
68+
user, UserNotificationPolicy.Step.NOTIFY, notify_by=UserNotificationPolicy.NotificationChannel.SLACK
69+
)
70+
make_user_notification_policy(user, UserNotificationPolicy.Step.WAIT, wait_delay=timezone.timedelta(minutes=5))
71+
make_user_notification_policy(
72+
user, UserNotificationPolicy.Step.NOTIFY, notify_by=UserNotificationPolicy.NotificationChannel.PHONE_CALL
73+
)
74+
75+
# bundled SMS notification has been scheduled, the second notification step "Notify by Slack" has not been passed
76+
# SMS notification should appear in notification plan with timedelta=2min
77+
bundled_sms_notification = notification_bundle.notifications.create(
78+
alert_group=alert_group,
79+
notification_policy=notification_policy_sms,
80+
alert_receive_channel=alert_receive_channel,
81+
)
82+
notification_plan_dict = log_builder._get_notification_plan_for_user(user)
83+
expected_plan_dict = {
84+
timezone.timedelta(0): [
85+
{
86+
"user_id": user.id,
87+
"plan_lines": [f"invite {user.username} in Slack"],
88+
"is_the_first_notification_step": False,
89+
}
90+
],
91+
timezone.timedelta(seconds=120): [{"user_id": user.id, "plan_lines": [f"send sms to {user.username}"]}],
92+
timezone.timedelta(seconds=300): [{"user_id": user.id, "plan_lines": [f"call {user.username} by phone"]}],
93+
}
94+
assert notification_plan_dict == expected_plan_dict
95+
96+
# the second notification step "Notify by Slack" has been passed
97+
make_user_notification_policy_log_record(
98+
author=user,
99+
alert_group=alert_group,
100+
notification_policy=notification_policy_slack,
101+
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_TRIGGERED,
102+
)
103+
notification_plan_dict = log_builder._get_notification_plan_for_user(user)
104+
expected_plan_dict = {
105+
timezone.timedelta(0): [{"user_id": user.id, "plan_lines": [], "is_the_first_notification_step": False}],
106+
timezone.timedelta(seconds=120): [{"user_id": user.id, "plan_lines": [f"send sms to {user.username}"]}],
107+
timezone.timedelta(seconds=300): [{"user_id": user.id, "plan_lines": [f"call {user.username} by phone"]}],
108+
}
109+
assert notification_plan_dict == expected_plan_dict
110+
111+
# bundled SMS notification has been triggered, it should not appear in notification plan anymore
112+
make_user_notification_policy_log_record(
113+
author=user,
114+
alert_group=alert_group,
115+
notification_policy=notification_policy_sms,
116+
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_TRIGGERED,
117+
)
118+
bundled_sms_notification.bundle_uuid = "test_bundle_uuid"
119+
bundled_sms_notification.save()
120+
121+
notification_plan_dict = log_builder._get_notification_plan_for_user(user)
122+
expected_plan_dict = {
123+
timezone.timedelta(0): [{"user_id": user.id, "plan_lines": [], "is_the_first_notification_step": False}],
124+
timezone.timedelta(seconds=300): [{"user_id": user.id, "plan_lines": [f"call {user.username} by phone"]}],
125+
}
126+
assert notification_plan_dict == expected_plan_dict
127+
128+
42129
@pytest.mark.django_db
43130
def test_escalation_plan_custom_webhooks(
44131
make_organization_and_user,

engine/apps/alerts/tests/test_notify_user.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -500,8 +500,11 @@ def test_send_bundle_notification(
500500
f"there is only one alert group in bundled notification, perform regular notification. "
501501
f"alert_group {alert_group_1.id}"
502502
) in caplog.text
503-
# check all notifications were deleted
504-
assert notification_bundle.notifications.all().count() == 0
503+
# check bundle_uuid was set
504+
assert notification_bundle.notifications.filter(bundle_uuid__isnull=True).count() == 0
505+
assert notification_bundle.notifications.all().count() == 1
506+
# cleanup notifications
507+
notification_bundle.notifications.all().delete()
505508

506509
# send notification for 0 active alert group
507510
notification_bundle.append_notification(alert_group_1, notification_policy)

0 commit comments

Comments
 (0)