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'),