Mobile app messaging backend (#874)

* move mobile notifications to a separate backend, remove critical notification

* remove outdated mobile app code

* MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED -> FEATURE_MOBILE_APP_INTEGRATION_ENABLED

* create error log if no devices are set up

* move mobile auth related code to the mobile_app Django app

* move mobile auth related code to the mobile_app Django app

* move mobile auth related code to the mobile_app Django app

* fix typing

* add GCMDevice todos

* add user connection capabilities

* add user connect/disconnect to the messaging backend

* move APNS endpoint to mobile_app Django app

* restore critical notifications

* support hackathon app

* tweak migrations so mobile app auth tokens are preserved

* reuse notify_by IDs

* use mobile app template to render push notification

* add GCM/FCM (Android) support

* fix unlink user

* logger.error -> logger.info
This commit is contained in:
Vadim Stepanov 2022-11-23 15:56:43 +00:00 committed by GitHub
parent d0dbc4207a
commit 255964ceaf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 386 additions and 263 deletions

View file

@ -5,10 +5,8 @@ from django.conf import settings
from django.db import transaction
from django.utils import timezone
from kombu import uuid as celery_uuid
from push_notifications.models import APNSDevice
from apps.alerts.constants import NEXT_ESCALATION_DELAY
from apps.alerts.incident_appearance.renderers.web_renderer import AlertGroupWebRenderer
from apps.alerts.signals import user_notification_action_triggered_signal
from apps.base.messaging import get_messaging_backend_from_id
from apps.base.utils import live_settings
@ -348,47 +346,6 @@ def perform_notification(log_record_pk):
notification_channel=notification_channel,
notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_SLACK,
).save()
elif notification_channel == UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_GENERAL:
message = f"{AlertGroupWebRenderer(alert_group).render().get('title', 'Incident')}"
thread_id = f"{alert_group.channel.organization.public_primary_key}:{alert_group.public_primary_key}"
devices_to_notify = APNSDevice.objects.filter(user_id=user.pk)
devices_to_notify.send_message(
message,
thread_id=thread_id,
category="USER_NEW_INCIDENT",
extra={
"orgId": f"{alert_group.channel.organization.public_primary_key}",
"orgName": f"{alert_group.channel.organization.stack_slug}",
"incidentId": f"{alert_group.public_primary_key}",
"status": f"{alert_group.status}",
"aps": {
"alert": f"{message}",
"sound": "bingbong.aiff",
},
},
)
elif notification_channel == UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_CRITICAL:
message = f"{AlertGroupWebRenderer(alert_group).render().get('title', 'Incident')}"
thread_id = f"{alert_group.channel.organization.public_primary_key}:{alert_group.public_primary_key}"
devices_to_notify = APNSDevice.objects.filter(user_id=user.pk)
devices_to_notify.send_message(
message,
thread_id=thread_id,
category="USER_NEW_INCIDENT",
extra={
"orgId": f"{alert_group.channel.organization.public_primary_key}",
"orgName": f"{alert_group.channel.organization.stack_slug}",
"incidentId": f"{alert_group.public_primary_key}",
"status": f"{alert_group.status}",
"aps": {
"alert": f"Critical page: {message}",
"interruption-level": "critical",
"sound": "ambulance.aiff",
},
},
)
else:
try:
backend_id = UserNotificationPolicy.NotificationChannel(notification_policy.notify_by).name

View file

@ -1,6 +1,7 @@
from django.conf import settings
from django.urls import include, path, re_path
from apps.mobile_app.views import APNSDeviceAuthorizedViewSet
from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path
from .views import UserNotificationPolicyView, auth
@ -8,7 +9,6 @@ from .views.alert_group import AlertGroupView
from .views.alert_receive_channel import AlertReceiveChannelView
from .views.alert_receive_channel_template import AlertReceiveChannelTemplateView
from .views.alerts import AlertDetailView
from .views.apns_device import APNSDeviceAuthorizedViewSet
from .views.channel_filter import ChannelFilterView
from .views.custom_button import CustomButtonView
from .views.escalation_chain import EscalationChainViewSet
@ -68,7 +68,8 @@ router.register(r"tokens", PublicApiTokenView, basename="api_token")
router.register(r"live_settings", LiveSettingViewSet, basename="live_settings")
router.register(r"oncall_shifts", OnCallShiftView, basename="oncall_shifts")
if settings.MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED:
# TODO: remove this when the hackathon app is deprecated (APNSDeviceAuthorizedViewSet is registered in mobile_app)
if settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED:
router.register(r"device/apns", APNSDeviceAuthorizedViewSet)
urlpatterns = [

View file

@ -14,7 +14,8 @@ from apps.alerts.constants import ActionSource
from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel
from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdminOrEditor
from apps.api.serializers.alert_group import AlertGroupListSerializer, AlertGroupSerializer
from apps.auth_token.auth import MobileAppAuthTokenAuthentication, PluginAuthentication
from apps.auth_token.auth import PluginAuthentication
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
from apps.user_management.models import User
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import DateRangeFilterMixin, ModelFieldFilterMixin

View file

@ -1,7 +0,0 @@
from push_notifications.api.rest_framework import APNSDeviceAuthorizedViewSet
from apps.auth_token.auth import MobileAppAuthTokenAuthentication, PluginAuthentication
class APNSDeviceAuthorizedViewSet(APNSDeviceAuthorizedViewSet):
authentication_classes = (MobileAppAuthTokenAuthentication, PluginAuthentication)

View file

@ -36,7 +36,7 @@ class FeaturesAPIView(APIView):
if settings.FEATURE_TELEGRAM_INTEGRATION_ENABLED:
enabled_features.append(FEATURE_TELEGRAM)
if settings.MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED:
if settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED:
mobile_app_settings = DynamicSetting.objects.get_or_create(
name="mobile_app_settings",
defaults={

View file

@ -2,7 +2,8 @@ from rest_framework import mixins, viewsets
from rest_framework.permissions import IsAuthenticated
from apps.api.serializers.team import TeamSerializer
from apps.auth_token.auth import MobileAppAuthTokenAuthentication, PluginAuthentication
from apps.auth_token.auth import PluginAuthentication
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
from apps.user_management.models import Team

View file

@ -25,17 +25,13 @@ from apps.api.permissions import (
)
from apps.api.serializers.team import TeamSerializer
from apps.api.serializers.user import FilterUserSerializer, UserHiddenFieldsSerializer, UserSerializer
from apps.auth_token.auth import (
MobileAppAuthTokenAuthentication,
MobileAppVerificationTokenAuthentication,
PluginAuthentication,
)
from apps.auth_token.auth import PluginAuthentication
from apps.auth_token.constants import SCHEDULE_EXPORT_TOKEN_NAME
from apps.auth_token.models import UserScheduleExportAuthToken
from apps.auth_token.models.mobile_app_auth_token import MobileAppAuthToken
from apps.auth_token.models.mobile_app_verification_token import MobileAppVerificationToken
from apps.base.messaging import get_messaging_backend_from_id
from apps.base.utils import live_settings
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication, MobileAppVerificationTokenAuthentication
from apps.mobile_app.models import MobileAppAuthToken
from apps.telegram.client import TelegramClient
from apps.telegram.models import TelegramVerificationCode
from apps.twilioapp.phone_manager import PhoneManager
@ -473,62 +469,6 @@ class UserView(
raise NotFound
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["get", "post", "delete"])
def mobile_app_verification_token(self, request, pk):
DynamicSetting = apps.get_model("base", "DynamicSetting")
if not settings.MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED:
return Response(status=status.HTTP_404_NOT_FOUND)
mobile_app_settings = DynamicSetting.objects.get_or_create(
name="mobile_app_settings",
defaults={
"json_value": {
"org_ids": [],
}
},
)[0]
if self.request.auth.organization.pk not in mobile_app_settings.json_value["org_ids"]:
return Response(status=status.HTTP_404_NOT_FOUND)
user = self.get_object()
if self.request.method == "GET":
try:
token = MobileAppVerificationToken.objects.get(user=user)
except MobileAppVerificationToken.DoesNotExist:
raise NotFound
response = {
"token_id": token.id,
"user_id": token.user_id,
"organization_id": token.organization_id,
"created_at": token.created_at,
"revoked_at": token.revoked_at,
}
return Response(response, status=status.HTTP_200_OK)
if self.request.method == "POST":
# If token already exists revoke it
try:
token = MobileAppVerificationToken.objects.get(user=user)
token.delete()
except MobileAppVerificationToken.DoesNotExist:
pass
instance, token = MobileAppVerificationToken.create_auth_token(user, user.organization)
data = {"id": instance.pk, "token": token, "created_at": instance.created_at}
return Response(data, status=status.HTTP_201_CREATED)
if self.request.method == "DELETE":
try:
token = MobileAppVerificationToken.objects.get(user=user)
token.delete()
except MobileAppVerificationToken.DoesNotExist:
raise NotFound
return Response(status=status.HTTP_204_NO_CONTENT)
@action(
methods=["get", "post", "delete"],
detail=False,
@ -537,7 +477,7 @@ class UserView(
def mobile_app_auth_token(self, request):
DynamicSetting = apps.get_model("base", "DynamicSetting")
if not settings.MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED:
if not settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED:
return Response(status=status.HTTP_404_NOT_FOUND)
mobile_app_settings = DynamicSetting.objects.get_or_create(
@ -582,7 +522,7 @@ class UserView(
try:
token = MobileAppAuthToken.objects.get(user=self.request.user)
token.delete()
except MobileAppVerificationToken.DoesNotExist:
except MobileAppAuthToken.DoesNotExist:
raise NotFound
return Response(status=status.HTTP_204_NO_CONTENT)

View file

@ -1,4 +1,3 @@
from django.apps import apps
from django.conf import settings
from django.http import Http404
from rest_framework import status
@ -150,7 +149,6 @@ class UserNotificationPolicyView(UpdateSerializerMixin, ModelViewSet):
"""
Returns list of options for user notification policies dropping options that requires disabled features.
"""
DynamicSetting = apps.get_model("base", "DynamicSetting")
choices = []
for notification_channel in NotificationChannelAPIOptions.AVAILABLE_FOR_USE:
slack_integration_required = (
@ -160,16 +158,10 @@ class UserNotificationPolicyView(UpdateSerializerMixin, ModelViewSet):
notification_channel
in NotificationChannelAPIOptions.TELEGRAM_INTEGRATION_REQUIRED_NOTIFICATION_CHANNELS
)
mobile_app_integration_required = (
notification_channel
in NotificationChannelAPIOptions.MOBILE_APP_INTEGRATION_REQUIRED_NOTIFICATION_CHANNELS
)
if slack_integration_required and not settings.FEATURE_SLACK_INTEGRATION_ENABLED:
continue
if telegram_integration_required and not settings.FEATURE_TELEGRAM_INTEGRATION_ENABLED:
continue
if mobile_app_integration_required and not settings.MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED:
continue
# extra backends may be enabled per organization
built_in_backend_names = {b[0] for b in BUILT_IN_BACKENDS}
@ -178,20 +170,6 @@ class UserNotificationPolicyView(UpdateSerializerMixin, ModelViewSet):
if extra_messaging_backend is None:
continue
mobile_app_settings = DynamicSetting.objects.get_or_create(
name="mobile_app_settings",
defaults={
"json_value": {
"org_ids": [],
}
},
)[0]
if (
mobile_app_integration_required
and settings.MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED
and self.request.auth.organization.pk not in mobile_app_settings.json_value["org_ids"]
):
continue
choices.append(
{
"value": notification_channel,

View file

@ -17,8 +17,6 @@ from common.constants.role import Role
from .constants import SCHEDULE_EXPORT_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME
from .exceptions import InvalidToken
from .models import ApiAuthToken, PluginAuthToken, ScheduleExportAuthToken, SlackAuthToken, UserScheduleExportAuthToken
from .models.mobile_app_auth_token import MobileAppAuthToken
from .models.mobile_app_verification_token import MobileAppVerificationToken
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
@ -215,39 +213,3 @@ class UserScheduleExportAuthentication(BaseAuthentication):
raise exceptions.AuthenticationFailed("Export token is deactivated")
return auth_token.user, auth_token
class MobileAppVerificationTokenAuthentication(BaseAuthentication):
model = MobileAppVerificationToken
def authenticate(self, request) -> Tuple[User, MobileAppVerificationToken]:
auth = get_authorization_header(request).decode("utf-8")
user, auth_token = self.authenticate_credentials(auth)
return user, auth_token
def authenticate_credentials(self, token_string: str) -> Tuple[User, MobileAppVerificationToken]:
try:
auth_token = self.model.validate_token_string(token_string)
except InvalidToken:
raise exceptions.AuthenticationFailed("Invalid token")
return auth_token.user, auth_token
class MobileAppAuthTokenAuthentication(BaseAuthentication):
model = MobileAppAuthToken
def authenticate(self, request) -> Tuple[User, MobileAppAuthToken]:
auth = get_authorization_header(request).decode("utf-8")
user, auth_token = self.authenticate_credentials(auth)
if user is None:
return None
return user, auth_token
def authenticate_credentials(self, token_string: str) -> Tuple[User, MobileAppAuthToken]:
try:
auth_token = self.model.validate_token_string(token_string)
except InvalidToken:
return None, None
return auth_token.user, auth_token

View file

@ -8,5 +8,3 @@ SLACK_AUTH_TOKEN_NAME = "slack_login_token"
SCHEDULE_EXPORT_TOKEN_NAME = "token"
SCHEDULE_EXPORT_TOKEN_CHARACTER_LENGTH = 32
MOBILE_APP_AUTH_VERIFICATION_TOKEN_TIMEOUT_SECONDS = 60

View file

@ -1,6 +1,6 @@
# Generated by Django 3.2.5 on 2022-05-31 14:46
from django.utils import timezone
import apps.auth_token.models.mobile_app_verification_token
import apps.auth_token.models.slack_auth_token
from django.db import migrations, models
@ -48,7 +48,7 @@ class Migration(migrations.Migration):
('digest', models.CharField(max_length=128)),
('created_at', models.DateTimeField(auto_now_add=True)),
('revoked_at', models.DateTimeField(null=True)),
('expire_date', models.DateTimeField(default=apps.auth_token.models.mobile_app_verification_token.get_expire_date)),
('expire_date', models.DateTimeField(default=timezone.now)),
],
options={
'abstract': False,

View file

@ -0,0 +1,37 @@
# Generated by Django 3.2.16 on 2022-11-21 16:10
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('auth_token', '0002_squashed_initial'),
]
operations = [
migrations.RemoveField(
model_name='mobileappverificationtoken',
name='organization',
),
migrations.RemoveField(
model_name='mobileappverificationtoken',
name='user',
),
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.DeleteModel(
name='MobileAppAuthToken',
),
],
database_operations=[
migrations.AlterModelTable(
name='MobileAppAuthToken',
table='mobile_app_mobileappauthtoken',
),
],
),
migrations.DeleteModel(
name='MobileAppVerificationToken',
),
]

View file

@ -1,29 +0,0 @@
from typing import Tuple
from django.db import models
from apps.auth_token import constants, crypto
from apps.auth_token.models.base_auth_token import BaseAuthToken
from apps.user_management.models import Organization, User
class MobileAppAuthToken(BaseAuthToken):
user = models.ForeignKey(
to=User, null=False, blank=False, related_name="mobile_app_auth_tokens", on_delete=models.CASCADE
)
organization = models.ForeignKey(
to=Organization, null=False, blank=False, related_name="mobile_app_auth_tokens", on_delete=models.CASCADE
)
@classmethod
def create_auth_token(cls, user: User, organization: Organization) -> Tuple["MobileAppAuthToken", str]:
token_string = crypto.generate_token_string()
digest = crypto.hash_token_string(token_string)
instance = cls.objects.create(
token_key=token_string[: constants.TOKEN_KEY_LENGTH],
digest=digest,
user=user,
organization=organization,
)
return instance, token_string

View file

@ -35,8 +35,6 @@ BUILT_IN_BACKENDS = (
("SMS", 1),
("PHONE_CALL", 2),
("TELEGRAM", 3),
("MOBILE_PUSH_GENERAL", 5),
("MOBILE_PUSH_CRITICAL", 6),
)
@ -201,8 +199,6 @@ class NotificationChannelOptions:
UserNotificationPolicy.NotificationChannel.SMS,
UserNotificationPolicy.NotificationChannel.PHONE_CALL,
UserNotificationPolicy.NotificationChannel.TELEGRAM,
UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_GENERAL,
UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_CRITICAL,
] + [
getattr(UserNotificationPolicy.NotificationChannel, backend_id)
for backend_id, b in get_messaging_backends()
@ -213,10 +209,6 @@ class NotificationChannelOptions:
SLACK_INTEGRATION_REQUIRED_NOTIFICATION_CHANNELS = [UserNotificationPolicy.NotificationChannel.SLACK]
TELEGRAM_INTEGRATION_REQUIRED_NOTIFICATION_CHANNELS = [UserNotificationPolicy.NotificationChannel.TELEGRAM]
MOBILE_APP_INTEGRATION_REQUIRED_NOTIFICATION_CHANNELS = [
UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_GENERAL,
UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_CRITICAL,
]
class NotificationChannelAPIOptions(NotificationChannelOptions):
@ -225,8 +217,6 @@ class NotificationChannelAPIOptions(NotificationChannelOptions):
UserNotificationPolicy.NotificationChannel.SMS: "SMS \U00002709\U0001F4F2",
UserNotificationPolicy.NotificationChannel.PHONE_CALL: "Phone call \U0000260E",
UserNotificationPolicy.NotificationChannel.TELEGRAM: "Telegram \U0001F916",
UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_GENERAL: "Mobile App",
UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_CRITICAL: "Mobile App Critical",
}
LABELS.update(
{
@ -240,8 +230,6 @@ class NotificationChannelAPIOptions(NotificationChannelOptions):
UserNotificationPolicy.NotificationChannel.SMS: "SMS",
UserNotificationPolicy.NotificationChannel.PHONE_CALL: "\U0000260E",
UserNotificationPolicy.NotificationChannel.TELEGRAM: "Telegram",
UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_GENERAL: "Mobile App",
UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_CRITICAL: "Mobile App Critical",
}
SHORT_LABELS.update(
{
@ -257,8 +245,6 @@ class NotificationChannelPublicAPIOptions(NotificationChannelAPIOptions):
UserNotificationPolicy.NotificationChannel.SMS: "notify_by_sms",
UserNotificationPolicy.NotificationChannel.PHONE_CALL: "notify_by_phone_call",
UserNotificationPolicy.NotificationChannel.TELEGRAM: "notify_by_telegram",
UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_GENERAL: "notify_by_mobile_app",
UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_CRITICAL: "notify_by_mobile_app_critical",
}
LABELS.update(
{

View file

@ -287,10 +287,6 @@ class UserNotificationPolicyLogRecord(models.Model):
result += f"called {user_verbal} by phone"
elif notification_channel == UserNotificationPolicy.NotificationChannel.TELEGRAM:
result += f"sent telegram message to {user_verbal}"
elif notification_channel == UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_GENERAL:
result += f"sent push notifications to {user_verbal}"
elif notification_channel == UserNotificationPolicy.NotificationChannel.MOBILE_PUSH_CRITICAL:
result += f"sent push critical notifications to {user_verbal}"
elif notification_channel is None:
result += f"invited {user_verbal} but notification channel is unspecified"
else:

View file

View file

@ -0,0 +1,13 @@
from apps.alerts.incident_appearance.templaters.alert_templater import AlertTemplater
from common.utils import str_or_backup
class AlertMobileAppTemplater(AlertTemplater):
def _render_for(self):
return "MOBILE_APP"
def get_push_notification_message(alert_group):
alert = alert_group.alerts.first()
templated_alert = AlertMobileAppTemplater(alert).render()
return str_or_backup(templated_alert.title, "Alert Group")

View file

@ -0,0 +1,45 @@
from typing import Optional, Tuple
from rest_framework import exceptions
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from apps.auth_token.exceptions import InvalidToken
from apps.user_management.models import User
from .models import MobileAppAuthToken, MobileAppVerificationToken
class MobileAppVerificationTokenAuthentication(BaseAuthentication):
model = MobileAppVerificationToken
def authenticate(self, request) -> Tuple[User, MobileAppVerificationToken]:
auth = get_authorization_header(request).decode("utf-8")
user, auth_token = self.authenticate_credentials(auth)
return user, auth_token
def authenticate_credentials(self, token_string: str) -> Tuple[User, MobileAppVerificationToken]:
try:
auth_token = self.model.validate_token_string(token_string)
except InvalidToken:
raise exceptions.AuthenticationFailed("Invalid token")
return auth_token.user, auth_token
class MobileAppAuthTokenAuthentication(BaseAuthentication):
model = MobileAppAuthToken
def authenticate(self, request) -> Optional[Tuple[User, MobileAppAuthToken]]:
auth = get_authorization_header(request).decode("utf-8")
user, auth_token = self.authenticate_credentials(auth)
if user is None:
return None
return user, auth_token
def authenticate_credentials(self, token_string: str) -> Tuple[Optional[User], Optional[MobileAppAuthToken]]:
try:
auth_token = self.model.validate_token_string(token_string)
except InvalidToken:
return None, None
return auth_token.user, auth_token

View file

@ -0,0 +1,56 @@
from push_notifications.models import APNSDevice
from apps.base.messaging import BaseMessagingBackend
from apps.mobile_app.tasks import notify_user_async
class MobileAppBackend(BaseMessagingBackend):
backend_id = "MOBILE_APP"
label = "Mobile app"
short_label = "Mobile app"
available_for_use = True
template_fields = ["title"]
# TODO: add QR code generation (base64 encode?)
def generate_user_verification_code(self, user):
from apps.mobile_app.models import MobileAppVerificationToken
# remove existing token before creating a new one
MobileAppVerificationToken.objects.filter(user=user).delete()
_, token = MobileAppVerificationToken.create_auth_token(user, user.organization)
return token
def unlink_user(self, user):
from apps.mobile_app.models import MobileAppAuthToken
token = MobileAppAuthToken.objects.get(user=user)
token.delete()
def serialize_user(self, user):
# TODO: add Android support using GCMDevice
return {"connected": APNSDevice.objects.filter(user_id=user.pk).exists()}
def notify_user(self, user, alert_group, notification_policy, critical=False):
notify_user_async.delay(
user_pk=user.pk,
alert_group_pk=alert_group.pk,
notification_policy_pk=notification_policy.pk,
critical=critical,
)
class MobileAppCriticalBackend(MobileAppBackend):
"""
This notification backend should not exist, criticality of the push notification should be an option passed to the
MobileAppBackend messaging backend.
TODO: add ability to pass options to messaging backends both on backend and frontend, delete this backend after that
"""
backend_id = "MOBILE_APP_CRITICAL"
label = "Mobile app critical"
short_label = "Mobile app critical"
template_fields = []
def notify_user(self, user, alert_group, notification_policy, critical=True):
super().notify_user(user, alert_group, notification_policy, critical)

View file

@ -0,0 +1,54 @@
# Generated by Django 3.2.16 on 2022-11-21 16:10
import apps.mobile_app.models
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('user_management', '0004_auto_20221025_0316'),
('auth_token', '0003_auto_20221121_1610'),
]
operations = [
migrations.CreateModel(
name='MobileAppVerificationToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token_key', models.CharField(db_index=True, max_length=8)),
('digest', models.CharField(max_length=128)),
('created_at', models.DateTimeField(auto_now_add=True)),
('revoked_at', models.DateTimeField(null=True)),
('expire_date', models.DateTimeField(default=apps.mobile_app.models.get_expire_date)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mobile_app_verification_token_set', to='user_management.organization')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mobile_app_verification_token_set', to='user_management.user')),
],
options={
'abstract': False,
},
),
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.CreateModel(
name='MobileAppAuthToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token_key', models.CharField(db_index=True, max_length=8)),
('digest', models.CharField(max_length=128)),
('created_at', models.DateTimeField(auto_now_add=True)),
('revoked_at', models.DateTimeField(null=True)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mobile_app_auth_tokens', to='user_management.organization')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mobile_app_auth_tokens', to='user_management.user')),
],
options={
'abstract': False,
},
),
],
database_operations=[],
)
]

View file

@ -4,10 +4,11 @@ from django.db import models
from django.utils import timezone
from apps.auth_token import constants, crypto
from apps.auth_token.constants import MOBILE_APP_AUTH_VERIFICATION_TOKEN_TIMEOUT_SECONDS
from apps.auth_token.models import BaseAuthToken
from apps.user_management.models import Organization, User
MOBILE_APP_AUTH_VERIFICATION_TOKEN_TIMEOUT_SECONDS = 60
def get_expire_date():
return timezone.now() + timezone.timedelta(seconds=MOBILE_APP_AUTH_VERIFICATION_TOKEN_TIMEOUT_SECONDS)
@ -46,3 +47,25 @@ class MobileAppVerificationToken(BaseAuthToken):
organization=organization,
)
return instance, token_string
class MobileAppAuthToken(BaseAuthToken):
user = models.ForeignKey(
to=User, null=False, blank=False, related_name="mobile_app_auth_tokens", on_delete=models.CASCADE
)
organization = models.ForeignKey(
to=Organization, null=False, blank=False, related_name="mobile_app_auth_tokens", on_delete=models.CASCADE
)
@classmethod
def create_auth_token(cls, user: User, organization: Organization) -> Tuple["MobileAppAuthToken", str]:
token_string = crypto.generate_token_string()
digest = crypto.hash_token_string(token_string)
instance = cls.objects.create(
token_key=token_string[: constants.TOKEN_KEY_LENGTH],
digest=digest,
user=user,
organization=organization,
)
return instance, token_string

View file

@ -0,0 +1,95 @@
from celery.utils.log import get_task_logger
from django.conf import settings
from push_notifications.models import APNSDevice, GCMDevice
from apps.alerts.models import AlertGroup
from apps.mobile_app.alert_rendering import get_push_notification_message
from apps.user_management.models import User
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
MAX_RETRIES = 1 if settings.DEBUG else 10
logger = get_task_logger(__name__)
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=MAX_RETRIES)
def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical):
# avoid circular import
from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord
try:
user = User.objects.get(pk=user_pk)
except User.DoesNotExist:
logger.warning(f"User {user_pk} does not exist")
return
try:
alert_group = AlertGroup.all_objects.get(pk=alert_group_pk)
except AlertGroup.DoesNotExist:
logger.warning(f"Alert group {alert_group_pk} does not exist")
return
try:
notification_policy = UserNotificationPolicy.objects.get(pk=notification_policy_pk)
except UserNotificationPolicy.DoesNotExist:
logger.warning(f"User notification policy {notification_policy_pk} does not exist")
return
# APNS is for notifying iOS devices, GCM for Android
apns_devices_to_notify = APNSDevice.objects.filter(user=user)
gcm_devices_to_notify = GCMDevice.objects.filter(user=user)
# create an error log in case user has no devices set up
if not apns_devices_to_notify.exists() and not gcm_devices_to_notify.exists():
UserNotificationPolicyLogRecord.objects.create(
author=user,
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED,
notification_policy=notification_policy,
alert_group=alert_group,
reason="Mobile push notification error",
notification_step=notification_policy.step,
notification_channel=notification_policy.notify_by,
)
logger.info(f"Error while sending a mobile push notification: user {user_pk} has no devices set up")
return
message = get_push_notification_message(alert_group)
thread_id = f"{alert_group.channel.organization.public_primary_key}:{alert_group.public_primary_key}"
if critical:
aps = {
"alert": f"Critical page: {message}",
"interruption-level": "critical",
"sound": "ambulance.aiff",
}
else:
aps = {
"alert": message,
"sound": "bingbong.aiff",
}
apns_devices_to_notify.send_message(
message,
thread_id=thread_id,
category="USER_NEW_INCIDENT", # TODO: rename to USER_NEW_ALERT_GROUP
extra={
"orgId": alert_group.channel.organization.public_primary_key,
"orgName": alert_group.channel.organization.stack_slug,
"alertGroupId": alert_group.public_primary_key,
"incidentId": alert_group.public_primary_key, # TODO: remove after hackathon app is deprecated
"status": alert_group.status,
"aps": aps,
},
)
gcm_devices_to_notify.send_message(
message,
thread_id=thread_id,
category="USER_NEW_INCIDENT", # TODO: rename to USER_NEW_ALERT_GROUP
extra={
"orgId": alert_group.channel.organization.public_primary_key,
"orgName": alert_group.channel.organization.stack_slug,
"alertGroupId": alert_group.public_primary_key,
"status": alert_group.status,
"aps": aps,
},
)

View file

@ -0,0 +1,9 @@
from apps.mobile_app.views import APNSDeviceAuthorizedViewSet, GCMDeviceAuthorizedViewSet
from common.api_helpers.optional_slash_router import OptionalSlashRouter
router = OptionalSlashRouter()
router.register("apns", APNSDeviceAuthorizedViewSet)
router.register("gcm", GCMDeviceAuthorizedViewSet)
urlpatterns = router.urls

View file

@ -0,0 +1,12 @@
from push_notifications.api.rest_framework import APNSDeviceAuthorizedViewSet as BaseAPNSDeviceAuthorizedViewSet
from push_notifications.api.rest_framework import GCMDeviceAuthorizedViewSet as BaseGCMDeviceAuthorizedViewSet
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
class APNSDeviceAuthorizedViewSet(BaseAPNSDeviceAuthorizedViewSet):
authentication_classes = (MobileAppAuthTokenAuthentication,)
class GCMDeviceAuthorizedViewSet(BaseGCMDeviceAuthorizedViewSet):
authentication_classes = (MobileAppAuthTokenAuthentication,)

View file

@ -51,6 +51,12 @@ if settings.FEATURE_SLACK_INTEGRATION_ENABLED:
path("slack/", include("apps.slack.urls")),
]
if settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED:
urlpatterns += [
path("mobile_app/", include("apps.mobile_app.urls")),
]
if settings.OSS_INSTALLATION:
urlpatterns += [
path("api/internal/v1/", include("apps.oss_installation.urls")),

View file

@ -52,6 +52,7 @@ FEATURE_LIVE_SETTINGS_ENABLED = getenv_boolean("FEATURE_LIVE_SETTINGS_ENABLED",
FEATURE_TELEGRAM_INTEGRATION_ENABLED = getenv_boolean("FEATURE_TELEGRAM_INTEGRATION_ENABLED", default=True)
FEATURE_EMAIL_INTEGRATION_ENABLED = getenv_boolean("FEATURE_EMAIL_INTEGRATION_ENABLED", default=True)
FEATURE_SLACK_INTEGRATION_ENABLED = getenv_boolean("FEATURE_SLACK_INTEGRATION_ENABLED", default=True)
FEATURE_MOBILE_APP_INTEGRATION_ENABLED = getenv_boolean("FEATURE_MOBILE_APP_INTEGRATION_ENABLED", default=False)
FEATURE_WEB_SCHEDULES_ENABLED = getenv_boolean("FEATURE_WEB_SCHEDULES_ENABLED", default=False)
FEATURE_MULTIREGION_ENABLED = getenv_boolean("FEATURE_MULTIREGION_ENABLED", default=False)
GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True)
@ -202,6 +203,7 @@ INSTALLED_APPS = [
"apps.slack",
"apps.telegram",
"apps.twilioapp",
"apps.mobile_app",
"apps.api",
"apps.api_for_grafana_incident",
"apps.base",
@ -540,9 +542,16 @@ GRAFANA_COM_ADMIN_API_TOKEN = os.environ.get("GRAFANA_COM_ADMIN_API_TOKEN", None
GRAFANA_API_KEY_NAME = "Grafana OnCall"
MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED = getenv_boolean("MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED", default=False)
EXTRA_MESSAGING_BACKENDS = []
if FEATURE_MOBILE_APP_INTEGRATION_ENABLED:
EXTRA_MESSAGING_BACKENDS += [
("apps.mobile_app.backend.MobileAppBackend", 5),
("apps.mobile_app.backend.MobileAppCriticalBackend", 6),
]
PUSH_NOTIFICATIONS_SETTINGS = {
"FCM_API_KEY": os.environ.get("FCM_API_KEY", None),
"GCM_API_KEY": os.environ.get("GCM_API_KEY", None),
"APNS_AUTH_KEY_PATH": os.environ.get("APNS_AUTH_KEY_PATH", None),
"APNS_TOPIC": os.environ.get("APNS_TOPIC", None),
"APNS_AUTH_KEY_ID": os.environ.get("APNS_AUTH_KEY_ID", None),
@ -569,8 +578,6 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = getenv_integer("DATA_UPLOAD_MAX_MEMORY_SIZE", 1_04
# Log inbound/outbound calls as slow=1 if they exceed threshold
SLOW_THRESHOLD_SECONDS = 2.0
EXTRA_MESSAGING_BACKENDS = []
# Email messaging backend
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = os.getenv("EMAIL_HOST")
@ -581,7 +588,7 @@ EMAIL_USE_TLS = getenv_boolean("EMAIL_USE_TLS", True)
EMAIL_FROM_ADDRESS = os.getenv("EMAIL_FROM_ADDRESS")
if FEATURE_EMAIL_INTEGRATION_ENABLED:
EXTRA_MESSAGING_BACKENDS = [("apps.email.backend.EmailBackend", 8)]
EXTRA_MESSAGING_BACKENDS += [("apps.email.backend.EmailBackend", 8)]
INSTALLED_ONCALL_INTEGRATIONS = [
"config_integrations.alertmanager",

View file

@ -105,6 +105,7 @@ CELERY_TASK_ROUTES = {
"apps.integrations.tasks.create_alert": {"queue": "critical"},
"apps.integrations.tasks.create_alertmanager_alerts": {"queue": "critical"},
"apps.integrations.tasks.start_notify_about_integration_ratelimit": {"queue": "critical"},
"apps.mobile_app.tasks.notify_user_async": {"queue": "critical"},
"apps.schedules.tasks.drop_cached_ical.drop_cached_ical_for_custom_events_for_organization": {"queue": "critical"},
"apps.schedules.tasks.drop_cached_ical.drop_cached_ical_task": {"queue": "critical"},
# LONG

View file

@ -34,26 +34,19 @@ const MobileAppVerification = observer((props: MobileAppVerificationProps) => {
const [isMobileAppVerificationTokenExisting, setIsMobileAppVerificationTokenExisting] = useState<boolean>(false);
const [MobileAppVerificationTokenLoading, setMobileAppVerificationTokenLoading] = useState<boolean>(true);
useEffect(() => {
userStore
.getMobileAppVerificationToken(userPk)
.then((_res) => {
setIsMobileAppVerificationTokenExisting(true);
setMobileAppVerificationTokenLoading(false);
})
.catch((_res) => {
setIsMobileAppVerificationTokenExisting(false);
setMobileAppVerificationTokenLoading(false);
});
}, []);
const handleCreateMobileAppVerificationToken = async () => {
setIsMobileAppVerificationTokenExisting(true);
await userStore
.createMobileAppVerificationToken(userPk)
.then((res) => setShowMobileAppVerificationToken(res?.token));
.sendBackendConfirmationCode(userPk, 'MOBILE_APP')
.then((res) => setShowMobileAppVerificationToken(res));
};
useEffect(() => {
handleCreateMobileAppVerificationToken().then(() => {
setMobileAppVerificationTokenLoading(false);
});
}, []);
return (
<div className={cx('mobile-app-settings')}>
{MobileAppVerificationTokenLoading ? (

View file

@ -378,16 +378,4 @@ export class UserStore extends BaseStore {
method: 'DELETE',
});
}
async getMobileAppVerificationToken(userPk: User['pk']) {
return await makeRequest(`/users/${userPk}/mobile_app_verification_token/`, {
method: 'GET',
});
}
async createMobileAppVerificationToken(userPk: User['pk']) {
return await makeRequest(`/users/${userPk}/mobile_app_verification_token/`, {
method: 'POST',
});
}
}