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:
parent
7cd814507e
commit
dd73e589ac
15 changed files with 266 additions and 59 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
173
engine/apps/mobile_app/tests/test_alert_rendering.py
Normal file
173
engine/apps/mobile_app/tests/test_alert_rendering.py
Normal 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"
|
||||
)
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue