make mobile app notification title and subtitle templatable (#3845)

# What this PR does

Closes https://github.com/grafana/oncall/issues/2050

https://www.loom.com/share/cca9af04f905456087f25e9cbf1845ab

## 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] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
This commit is contained in:
Joey Orlando 2024-02-08 17:23:15 -05:00 committed by GitHub
parent 7cd814507e
commit dd73e589ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 266 additions and 59 deletions

View file

@ -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)

View file

@ -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

View file

@ -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.
<img src="/static/img/oncall/mobile-app-v1-android-notification-policies.png" width="300px">
@ -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].
<img src="/static/img/oncall/mobile-app-alert-notification-custom-template.png" width="400px">
{{% docs/reference %}}
[shift-swaps]: "/docs/oncall/ -> /docs/oncall/<ONCALL VERSION>/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/<ONCALL VERSION>/jinja2-templating#appearance-templates"
[templating]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/oncall/jinja2-templating#appearance-templates"
{{% /docs/reference %}}

View file

@ -117,10 +117,10 @@ class AlertTemplater(ABC):
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):

View file

@ -563,7 +563,7 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode
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"

View file

@ -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")

View file

@ -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:

View file

@ -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 @@ class MobileAppBackend(BaseMessagingBackend):
critical=critical,
)
@property
def customizable_templates(self):
"""
Disable customization if templates for mobile app
"""
return False
class MobileAppCriticalBackend(MobileAppBackend):
"""

View file

@ -6,7 +6,7 @@ from celery.utils.log import get_task_logger
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)

View file

@ -2,9 +2,7 @@ from unittest.mock import patch
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

View file

@ -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"
)

View file

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

View file

@ -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 },
],
},
];

View file

@ -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;

View file

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