diff --git a/CHANGELOG.md b/CHANGELOG.md index df0ca37b41..ee22627fd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Allow mobile app to access escalation options endpoints @imtoori ([#3847](https://github.com/grafana/oncall/pull/3847)) +- Enable templating for alert escalation mobile app push notifications by @joeyorlando ([#3845](https://github.com/grafana/oncall/pull/3845)) ## v1.3.102 (2024-02-06) diff --git a/docs/sources/jinja2-templating/_index.md b/docs/sources/jinja2-templating/_index.md index 236108049a..b62571d7e3 100644 --- a/docs/sources/jinja2-templating/_index.md +++ b/docs/sources/jinja2-templating/_index.md @@ -112,6 +112,7 @@ How alerts are displayed in the UI, messengers, and notifications - `Title` for SMS - `Title` for Phone Call - `Title`, `Message` for Email +- `Title`, `Message` for Mobile app push notifications #### Behavioral templates diff --git a/docs/sources/mobile-app/push-notifications/index.md b/docs/sources/mobile-app/push-notifications/index.md index ae87381777..32e25c48e6 100644 --- a/docs/sources/mobile-app/push-notifications/index.md +++ b/docs/sources/mobile-app/push-notifications/index.md @@ -23,7 +23,7 @@ There are four types of push notifications for the mobile app: To receive push notifications from the Grafana OnCall mobile app, you must add them to your notification policy steps. **Important notifications** should include **Mobile push important** and **Default notifications** should include **Mobile push**. -In the **Settings** tab of the mobile app, tap on **Notification policies** to review, reorder, remove, add or change steps. +In the **Settings** tab of the mobile app, tap on **Notification policies** to review, reorder, remove, add or change steps. Alternatively, you can do the same on desktop. From Grafana OnCall, navigate to the **Users** page, click **View my profile** and navigate to the **User Info** tab. @@ -87,13 +87,23 @@ To enable or disable on-call shift notifications, use the **info notifications** ### Shift swap notifications -Shift swap notifications are generated when a [shift swap ][shift-swaps] is requested, +Shift swap notifications are generated when a [shift swap][shift-swaps] is requested, informing all users in the on-call schedule (except the initiator) about it. To enable or disable shift swap notifications and their follow-ups, use the **info notifications** section in the **Push notifications** settings. +## Templating of alert notifications + +It is possible to modify the title and body (or subtitle), for push notifications related to alert escalations. For +more information on how to do this see the [docs on Appearance templates][templating]. + + + {{% docs/reference %}} [shift-swaps]: "/docs/oncall/ -> /docs/oncall//on-call-schedules/shift-swaps" [shift-swaps]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/oncall/on-call-schedules/shift-swaps" + +[templating]: "/docs/oncall/ -> /docs/oncall//jinja2-templating#appearance-templates" +[templating]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/oncall/jinja2-templating#appearance-templates" {{% /docs/reference %}} diff --git a/engine/apps/alerts/incident_appearance/templaters/alert_templater.py b/engine/apps/alerts/incident_appearance/templaters/alert_templater.py index 3e326cf265..9fa554e242 100644 --- a/engine/apps/alerts/incident_appearance/templaters/alert_templater.py +++ b/engine/apps/alerts/incident_appearance/templaters/alert_templater.py @@ -117,10 +117,10 @@ def _preformat_request_data(self, request_data): preformatted_data = request_data return preformatted_data - def _preformat(self, data): + def _preformat(self, data: str) -> str: return data - def _postformat(self, templated_alert): + def _postformat(self, templated_alert: TemplatedAlert) -> TemplatedAlert: return templated_alert def _apply_templates(self, data): diff --git a/engine/apps/api/serializers/alert_receive_channel.py b/engine/apps/api/serializers/alert_receive_channel.py index a08baf2dd0..0c1e4f87a0 100644 --- a/engine/apps/api/serializers/alert_receive_channel.py +++ b/engine/apps/api/serializers/alert_receive_channel.py @@ -563,7 +563,7 @@ def _get_messaging_backend_templates(self, obj: "AlertReceiveChannel"): is_default = False if obj.messaging_backends_templates: value = obj.messaging_backends_templates.get(backend_id, {}).get(field) - if not value: + if not value and not backend.skip_default_template_fields: value = obj.get_default_template_attribute(backend_id, field) is_default = True field_name = f"{backend.slug}_{field}_template" diff --git a/engine/apps/base/messaging.py b/engine/apps/base/messaging.py index d03f6c18e7..9b2e6b7548 100644 --- a/engine/apps/base/messaging.py +++ b/engine/apps/base/messaging.py @@ -10,6 +10,7 @@ class BaseMessagingBackend: templater = None template_fields = ("title", "message", "image_url") + skip_default_template_fields = False def __init__(self, *args, **kwargs): self.notification_channel_id = kwargs.get("notification_channel_id") diff --git a/engine/apps/mobile_app/alert_rendering.py b/engine/apps/mobile_app/alert_rendering.py index 37453c15c0..3c67d8d342 100644 --- a/engine/apps/mobile_app/alert_rendering.py +++ b/engine/apps/mobile_app/alert_rendering.py @@ -1,23 +1,57 @@ +import typing + from emoji import emojize -from apps.alerts.incident_appearance.templaters.alert_templater import AlertTemplater +from apps.alerts.incident_appearance.templaters.alert_templater import AlertTemplater, TemplatedAlert +from apps.alerts.models import AlertGroup from common.utils import str_or_backup +def _validate_fcm_length_limit(value: typing.Optional[str]) -> str: + """ + NOTE: technically FCM limits the data we send based on total # of bytes, not characters for title/subtitle. For now + lets simply limit the title and subtitle to 200 characters and see how that goes with avoiding the `message is too big` + FCM exception + + https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode + """ + MAX_ALERT_TITLE_LENGTH = 200 + + if value is None: + return "" + return f"{value[:MAX_ALERT_TITLE_LENGTH]}..." if len(value) > MAX_ALERT_TITLE_LENGTH else value + + class AlertMobileAppTemplater(AlertTemplater): def _render_for(self): return "MOBILE_APP" + def _postformat(self, templated_alert: TemplatedAlert) -> TemplatedAlert: + templated_alert.title = _validate_fcm_length_limit(templated_alert.title) + templated_alert.message = _validate_fcm_length_limit(templated_alert.message) + return templated_alert + + +def _templatize_alert(alert_group: AlertGroup) -> TemplatedAlert: + alert = alert_group.alerts.first() + return AlertMobileAppTemplater(alert).render() + + +def get_push_notification_title(alert_group: AlertGroup, critical: bool) -> str: + return _templatize_alert(alert_group).title or ("New Important Alert" if critical else "New Alert") + + +def get_push_notification_subtitle(alert_group: AlertGroup) -> str: + templatized_subtitle = _templatize_alert(alert_group).message + if templatized_subtitle: + # only return the templatized subtitle if it resolves to something that is not None + # otherwise fallback to the default + return templatized_subtitle -def get_push_notification_subtitle(alert_group): - MAX_ALERT_TITLE_LENGTH = 200 alert = alert_group.alerts.first() templated_alert = AlertMobileAppTemplater(alert).render() - alert_title = str_or_backup(templated_alert.title, "Alert Group") - # limit alert title length to prevent FCM `message is too big` exception - # https://firebase.google.com/docs/cloud-messaging/concept-options#notifications_and_data_messages - if len(alert_title) > MAX_ALERT_TITLE_LENGTH: - alert_title = f"{alert_title[:MAX_ALERT_TITLE_LENGTH]}..." + + alert_title = _validate_fcm_length_limit(str_or_backup(templated_alert.title, "Alert Group")) status_verbose = "Firing" # TODO: we should probably de-duplicate this text if alert_group.resolved: diff --git a/engine/apps/mobile_app/backend.py b/engine/apps/mobile_app/backend.py index 463af8b4e2..3082c34037 100644 --- a/engine/apps/mobile_app/backend.py +++ b/engine/apps/mobile_app/backend.py @@ -11,7 +11,10 @@ class MobileAppBackend(BaseMessagingBackend): label = "Mobile push" short_label = "Mobile push" available_for_use = True - template_fields = ["title"] + + templater = "apps.mobile_app.alert_rendering.AlertMobileAppTemplater" + template_fields = ("title", "message") + skip_default_template_fields = True def generate_user_verification_code(self, user): from apps.mobile_app.models import MobileAppVerificationToken @@ -51,13 +54,6 @@ def notify_user(self, user, alert_group, notification_policy, critical=False): critical=critical, ) - @property - def customizable_templates(self): - """ - Disable customization if templates for mobile app - """ - return False - class MobileAppCriticalBackend(MobileAppBackend): """ diff --git a/engine/apps/mobile_app/tasks/new_alert_group.py b/engine/apps/mobile_app/tasks/new_alert_group.py index 27c9754435..980b3c8d69 100644 --- a/engine/apps/mobile_app/tasks/new_alert_group.py +++ b/engine/apps/mobile_app/tasks/new_alert_group.py @@ -6,7 +6,7 @@ from firebase_admin.messaging import APNSPayload, Aps, ApsAlert, CriticalSound, Message from apps.alerts.models import AlertGroup -from apps.mobile_app.alert_rendering import get_push_notification_subtitle +from apps.mobile_app.alert_rendering import get_push_notification_subtitle, get_push_notification_title from apps.mobile_app.types import FCMMessageData, MessageType, Platform from apps.mobile_app.utils import ( MAX_RETRIES, @@ -31,7 +31,7 @@ def _get_fcm_message(alert_group: AlertGroup, user: User, device_to_notify: "FCM thread_id = f"{alert_group.channel.organization.public_primary_key}:{alert_group.public_primary_key}" - alert_title = "New Important Alert" if critical else "New Alert" + alert_title = get_push_notification_title(alert_group, critical) alert_subtitle = get_push_notification_subtitle(alert_group) mobile_app_user_settings, _ = MobileAppUserSettings.objects.get_or_create(user=user) diff --git a/engine/apps/mobile_app/tests/tasks/test_new_alert_group.py b/engine/apps/mobile_app/tests/tasks/test_new_alert_group.py index 362ab7dd08..c2d4c67280 100644 --- a/engine/apps/mobile_app/tests/tasks/test_new_alert_group.py +++ b/engine/apps/mobile_app/tests/tasks/test_new_alert_group.py @@ -2,9 +2,7 @@ import pytest -from apps.alerts.incident_appearance.templaters.alert_templater import TemplatedAlert from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord -from apps.mobile_app.alert_rendering import AlertMobileAppTemplater, get_push_notification_subtitle from apps.mobile_app.models import FCMDevice, MobileAppUserSettings from apps.mobile_app.tasks.new_alert_group import _get_fcm_message, notify_user_about_new_alert_group @@ -219,37 +217,3 @@ def test_fcm_message_user_settings_critical_override_dnd_disabled( apns_sound = message.apns.payload.aps.sound assert apns_sound.critical is False assert message.apns.payload.aps.custom_data["interruption-level"] == "time-sensitive" - - -@pytest.mark.django_db -@pytest.mark.parametrize( - "alert_title", - [ - "Some short title", - "Some long title" * 100, - ], -) -def test_get_push_notification_subtitle( - alert_title, - make_organization_and_user, - make_alert_receive_channel, - make_alert_group, - make_alert, -): - MAX_ALERT_TITLE_LENGTH = 200 - organization, user = make_organization_and_user() - alert_receive_channel = make_alert_receive_channel(organization=organization) - alert_group = make_alert_group(alert_receive_channel) - make_alert(alert_group=alert_group, title=alert_title, raw_request_data={"title": alert_title}) - expected_alert_title = ( - f"{alert_title[:MAX_ALERT_TITLE_LENGTH]}..." if len(alert_title) > MAX_ALERT_TITLE_LENGTH else alert_title - ) - expected_result = ( - f"#1 {expected_alert_title}\n" + f"via {alert_group.channel.short_name}" + "\nStatus: Firing, alerts: 1" - ) - templated_alert = TemplatedAlert() - templated_alert.title = alert_title - with patch.object(AlertMobileAppTemplater, "render", return_value=templated_alert): - result = get_push_notification_subtitle(alert_group) - assert len(expected_alert_title) <= MAX_ALERT_TITLE_LENGTH + 3 - assert result == expected_result diff --git a/engine/apps/mobile_app/tests/test_alert_rendering.py b/engine/apps/mobile_app/tests/test_alert_rendering.py new file mode 100644 index 0000000000..6b8c39d99f --- /dev/null +++ b/engine/apps/mobile_app/tests/test_alert_rendering.py @@ -0,0 +1,173 @@ +from unittest.mock import patch + +import pytest + +from apps.alerts.incident_appearance.templaters.alert_templater import TemplatedAlert +from apps.mobile_app.alert_rendering import get_push_notification_subtitle, get_push_notification_title +from apps.mobile_app.backend import MobileAppBackend + +MAX_ALERT_TITLE_LENGTH = 200 + +# this is a dirty hack to get around EXTRA_MESSAGING_BACKENDS being set in settings/ci-test.py +# we can't simply change the value because 100s of tests fail as they rely on the value being set to a specific value 🫠 +# see where this value is used in the unitest.mock.patch calls down below for more context +backend = MobileAppBackend(notification_channel_id=5) + + +def _make_messaging_backend_template(title_template=None, message_template=None) -> str: + return {"MOBILE_APP": {"title": title_template, "message": message_template}} + + +@pytest.mark.parametrize( + "critical,expected_title", + [ + (True, "New Important Alert"), + (False, "New Alert"), + ], +) +@pytest.mark.django_db +def test_get_push_notification_title_no_template_set( + make_organization, + make_alert_receive_channel, + make_alert_group, + make_alert, + critical, + expected_title, +): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization=organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group, raw_request_data={}) + + assert get_push_notification_title(alert_group, critical) == expected_title + + +@pytest.mark.parametrize( + "template,payload,expected_title", + [ + ("{{ payload.foo }}", {"foo": "bar"}, "bar"), + # template resolves to falsy value, make sure we don't show an empty notification title + ("{{ payload.foo }}", {}, "New Alert"), + ("oh nooo", {}, "oh nooo"), + ], +) +@patch("apps.base.messaging._messaging_backends", return_value={"MOBILE_APP": backend}) +@pytest.mark.django_db +def test_get_push_notification_title_template_set( + _mock_messaging_backends, + make_organization, + make_alert_receive_channel, + make_alert_group, + make_alert, + template, + payload, + expected_title, +): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel( + organization=organization, + messaging_backends_templates=_make_messaging_backend_template(title_template=template), + ) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data=payload) + + assert get_push_notification_title(alert_group, False) == expected_title + + +@pytest.mark.parametrize( + "alert_title", + [ + "Some short title", + "Some long title" * 100, + ], +) +@patch("apps.mobile_app.alert_rendering.AlertMobileAppTemplater.render") +@pytest.mark.django_db +def test_get_push_notification_subtitle_no_template_set( + mock_alert_templater_render, + alert_title, + make_organization, + make_alert_receive_channel, + make_alert_group, + make_alert, +): + templated_alert = TemplatedAlert() + templated_alert.title = alert_title + mock_alert_templater_render.return_value = templated_alert + + organization = make_organization() + alert_receive_channel = make_alert_receive_channel( + organization=organization, messaging_backends_templates=_make_messaging_backend_template() + ) + + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, title=alert_title, raw_request_data={"title": alert_title}) + + result = get_push_notification_subtitle(alert_group) + + expected_alert_title = ( + f"{alert_title[:MAX_ALERT_TITLE_LENGTH]}..." if len(alert_title) > MAX_ALERT_TITLE_LENGTH else alert_title + ) + assert len(expected_alert_title) <= MAX_ALERT_TITLE_LENGTH + 3 + assert result == ( + f"#1 {expected_alert_title}\n" + f"via {alert_group.channel.short_name}" + "\nStatus: Firing, alerts: 1" + ) + + +@pytest.mark.parametrize( + "template,payload,expected_subtitle", + [ + ("{{ payload.foo }}", {"foo": "bar"}, "bar"), + ("oh nooo", {}, "oh nooo"), + ], +) +@patch("apps.base.messaging._messaging_backends", return_value={"MOBILE_APP": backend}) +@pytest.mark.django_db +def test_get_push_notification_subtitle_template_set( + _mock_messaging_backends, + make_organization, + make_alert_receive_channel, + make_alert_group, + make_alert, + template, + payload, + expected_subtitle, +): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel( + organization=organization, + messaging_backends_templates=_make_messaging_backend_template(message_template=template), + ) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data=payload) + + assert get_push_notification_subtitle(alert_group) == expected_subtitle + + +@patch("apps.mobile_app.alert_rendering.AlertMobileAppTemplater.render") +@pytest.mark.django_db +def test_get_push_notification_subtitle_template_set_resolves_to_blank_value_doesnt_show_blank_subtitle( + mock_alert_templater_render, + make_organization, + make_alert_receive_channel, + make_alert_group, + make_alert, +): + alert_title = "Some short title" + template = "{{ payload.foo }}" + templated_alert = TemplatedAlert() + templated_alert.title = alert_title + + mock_alert_templater_render.return_value = templated_alert + + organization = make_organization() + alert_receive_channel = make_alert_receive_channel( + organization=organization, + messaging_backends_templates=_make_messaging_backend_template(message_template=template), + ) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data={"bar": "hello"}) + + assert get_push_notification_subtitle(alert_group) == ( + f"#1 {alert_title}\n" + f"via {alert_group.channel.short_name}" + "\nStatus: Firing, alerts: 1" + ) diff --git a/grafana-plugin/src/components/AlertTemplates/CommonAlertTemplatesForm.config.ts b/grafana-plugin/src/components/AlertTemplates/CommonAlertTemplatesForm.config.ts index 41999de794..266dcfcbf1 100644 --- a/grafana-plugin/src/components/AlertTemplates/CommonAlertTemplatesForm.config.ts +++ b/grafana-plugin/src/components/AlertTemplates/CommonAlertTemplatesForm.config.ts @@ -72,6 +72,12 @@ export const commonTemplateForEdit: { [id: string]: TemplateForEdit } = { }, type: 'plain', }, + mobile_app_title_template: { + name: IntegrationTemplateOptions.MobileAppTitle.key, + displayName: 'Mobile app title', + description: '', + type: 'plain', + }, slack_message_template: { name: IntegrationTemplateOptions.SlackMessage.key, displayName: 'Slack message', @@ -99,6 +105,12 @@ export const commonTemplateForEdit: { [id: string]: TemplateForEdit } = { }, type: 'plain', }, + mobile_app_message_template: { + name: IntegrationTemplateOptions.MobileAppMessage.key, + displayName: 'Mobile app message', + description: '', + type: 'plain', + }, slack_image_url_template: { name: IntegrationTemplateOptions.SlackImage.key, displayName: 'Slack image url', diff --git a/grafana-plugin/src/containers/IntegrationContainers/IntegrationCommonTemplatesList.config.ts b/grafana-plugin/src/containers/IntegrationContainers/IntegrationCommonTemplatesList.config.ts index d93f015c47..877aa03674 100644 --- a/grafana-plugin/src/containers/IntegrationContainers/IntegrationCommonTemplatesList.config.ts +++ b/grafana-plugin/src/containers/IntegrationContainers/IntegrationCommonTemplatesList.config.ts @@ -132,4 +132,15 @@ export const commonTemplatesToRender: TemplateBlock[] = [ { name: 'email_message_template', label: 'Message', height: MONACO_INPUT_HEIGHT_TALL }, ], }, + { + name: 'Mobile push notifications', + contents: [ + { + name: 'mobile_app_title_template', + label: 'Title', + height: MONACO_INPUT_HEIGHT_SMALL, + }, + { name: 'mobile_app_message_template', label: 'Message', height: MONACO_INPUT_HEIGHT_TALL }, + ], + }, ]; diff --git a/grafana-plugin/src/containers/IntegrationTemplate/IntegrationTemplate.tsx b/grafana-plugin/src/containers/IntegrationTemplate/IntegrationTemplate.tsx index ac31117cac..e399091ec3 100644 --- a/grafana-plugin/src/containers/IntegrationTemplate/IntegrationTemplate.tsx +++ b/grafana-plugin/src/containers/IntegrationTemplate/IntegrationTemplate.tsx @@ -150,6 +150,8 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => { case IntegrationTemplateOptions.TelegramImage.key: case IntegrationTemplateOptions.EmailTitle.key: case IntegrationTemplateOptions.EmailMessage.key: + case IntegrationTemplateOptions.MobileAppTitle.key: + case IntegrationTemplateOptions.MobileAppMessage.key: return slackMessageTemplateCheatSheet; case LabelTemplateOptions.AlertGroupDynamicLabel.key: return alertGroupDynamicLabelCheatSheet; diff --git a/grafana-plugin/src/pages/integration/IntegrationCommon.config.ts b/grafana-plugin/src/pages/integration/IntegrationCommon.config.ts index cf7b15541f..23cb0441b2 100644 --- a/grafana-plugin/src/pages/integration/IntegrationCommon.config.ts +++ b/grafana-plugin/src/pages/integration/IntegrationCommon.config.ts @@ -26,6 +26,8 @@ export const IntegrationTemplateOptions = { TelegramTitle: new KeyValuePair('telegram_title_template', 'Title'), TelegramMessage: new KeyValuePair('telegram_message_template', 'Message'), TelegramImage: new KeyValuePair('telegram_image_url_template', 'Image'), + MobileAppTitle: new KeyValuePair('mobile_app_title_template', 'Title'), + MobileAppMessage: new KeyValuePair('mobile_app_message_template', 'Message'), Email: new KeyValuePair('Email', 'Email'), Slack: new KeyValuePair('Slack', 'Slack'),