diff --git a/engine/apps/alerts/tasks/notify_user.py b/engine/apps/alerts/tasks/notify_user.py index 7bd2f03d..b841aa3f 100644 --- a/engine/apps/alerts/tasks/notify_user.py +++ b/engine/apps/alerts/tasks/notify_user.py @@ -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 diff --git a/engine/apps/api/urls.py b/engine/apps/api/urls.py index 3b7f46bf..05d87b75 100644 --- a/engine/apps/api/urls.py +++ b/engine/apps/api/urls.py @@ -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 = [ diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index 02d92e26..0af8fb9b 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -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 diff --git a/engine/apps/api/views/apns_device.py b/engine/apps/api/views/apns_device.py deleted file mode 100644 index ad3b817e..00000000 --- a/engine/apps/api/views/apns_device.py +++ /dev/null @@ -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) diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py index cc69514f..3a0ebfd5 100644 --- a/engine/apps/api/views/features.py +++ b/engine/apps/api/views/features.py @@ -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={ diff --git a/engine/apps/api/views/team.py b/engine/apps/api/views/team.py index 0a33e71f..58bf17b3 100644 --- a/engine/apps/api/views/team.py +++ b/engine/apps/api/views/team.py @@ -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 diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index c27a713d..de0a8590 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -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) diff --git a/engine/apps/api/views/user_notification_policy.py b/engine/apps/api/views/user_notification_policy.py index 5cc6399e..c1d7e553 100644 --- a/engine/apps/api/views/user_notification_policy.py +++ b/engine/apps/api/views/user_notification_policy.py @@ -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, diff --git a/engine/apps/auth_token/auth.py b/engine/apps/auth_token/auth.py index 68022469..47d8ece9 100644 --- a/engine/apps/auth_token/auth.py +++ b/engine/apps/auth_token/auth.py @@ -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 diff --git a/engine/apps/auth_token/constants.py b/engine/apps/auth_token/constants.py index 676b6c88..6ea64f67 100644 --- a/engine/apps/auth_token/constants.py +++ b/engine/apps/auth_token/constants.py @@ -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 diff --git a/engine/apps/auth_token/migrations/0001_squashed_initial.py b/engine/apps/auth_token/migrations/0001_squashed_initial.py index c8cb6854..7c7fe23a 100644 --- a/engine/apps/auth_token/migrations/0001_squashed_initial.py +++ b/engine/apps/auth_token/migrations/0001_squashed_initial.py @@ -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, diff --git a/engine/apps/auth_token/migrations/0003_auto_20221121_1610.py b/engine/apps/auth_token/migrations/0003_auto_20221121_1610.py new file mode 100644 index 00000000..08406946 --- /dev/null +++ b/engine/apps/auth_token/migrations/0003_auto_20221121_1610.py @@ -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', + ), + ] diff --git a/engine/apps/auth_token/models/mobile_app_auth_token.py b/engine/apps/auth_token/models/mobile_app_auth_token.py deleted file mode 100644 index 333ed788..00000000 --- a/engine/apps/auth_token/models/mobile_app_auth_token.py +++ /dev/null @@ -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 diff --git a/engine/apps/base/models/user_notification_policy.py b/engine/apps/base/models/user_notification_policy.py index a4c4b876..1a4a3526 100644 --- a/engine/apps/base/models/user_notification_policy.py +++ b/engine/apps/base/models/user_notification_policy.py @@ -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( { diff --git a/engine/apps/base/models/user_notification_policy_log_record.py b/engine/apps/base/models/user_notification_policy_log_record.py index 4256faef..3d9e366b 100644 --- a/engine/apps/base/models/user_notification_policy_log_record.py +++ b/engine/apps/base/models/user_notification_policy_log_record.py @@ -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: diff --git a/engine/apps/mobile_app/__init__.py b/engine/apps/mobile_app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/mobile_app/alert_rendering.py b/engine/apps/mobile_app/alert_rendering.py new file mode 100644 index 00000000..b33e1159 --- /dev/null +++ b/engine/apps/mobile_app/alert_rendering.py @@ -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") diff --git a/engine/apps/mobile_app/auth.py b/engine/apps/mobile_app/auth.py new file mode 100644 index 00000000..72d0646a --- /dev/null +++ b/engine/apps/mobile_app/auth.py @@ -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 diff --git a/engine/apps/mobile_app/backend.py b/engine/apps/mobile_app/backend.py new file mode 100644 index 00000000..82187250 --- /dev/null +++ b/engine/apps/mobile_app/backend.py @@ -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) diff --git a/engine/apps/mobile_app/migrations/0001_initial.py b/engine/apps/mobile_app/migrations/0001_initial.py new file mode 100644 index 00000000..de3faf32 --- /dev/null +++ b/engine/apps/mobile_app/migrations/0001_initial.py @@ -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=[], + ) + ] diff --git a/engine/apps/mobile_app/migrations/__init__.py b/engine/apps/mobile_app/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/auth_token/models/mobile_app_verification_token.py b/engine/apps/mobile_app/models.py similarity index 65% rename from engine/apps/auth_token/models/mobile_app_verification_token.py rename to engine/apps/mobile_app/models.py index f67f8f3f..e42ade25 100644 --- a/engine/apps/auth_token/models/mobile_app_verification_token.py +++ b/engine/apps/mobile_app/models.py @@ -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 diff --git a/engine/apps/mobile_app/tasks.py b/engine/apps/mobile_app/tasks.py new file mode 100644 index 00000000..e6a6a4bf --- /dev/null +++ b/engine/apps/mobile_app/tasks.py @@ -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, + }, + ) diff --git a/engine/apps/mobile_app/urls.py b/engine/apps/mobile_app/urls.py new file mode 100644 index 00000000..059503fb --- /dev/null +++ b/engine/apps/mobile_app/urls.py @@ -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 diff --git a/engine/apps/mobile_app/views.py b/engine/apps/mobile_app/views.py new file mode 100644 index 00000000..9755b74d --- /dev/null +++ b/engine/apps/mobile_app/views.py @@ -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,) diff --git a/engine/engine/urls.py b/engine/engine/urls.py index 7a86b57f..ab6934d8 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -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")), diff --git a/engine/settings/base.py b/engine/settings/base.py index b50b19b6..49036059 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -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", diff --git a/engine/settings/prod_without_db.py b/engine/settings/prod_without_db.py index 879bf3e9..ba5e5a33 100644 --- a/engine/settings/prod_without_db.py +++ b/engine/settings/prod_without_db.py @@ -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 diff --git a/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx b/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx index d629b90f..5413c270 100644 --- a/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx +++ b/grafana-plugin/src/containers/MobileAppVerification/MobileAppVerification.tsx @@ -34,26 +34,19 @@ const MobileAppVerification = observer((props: MobileAppVerificationProps) => { const [isMobileAppVerificationTokenExisting, setIsMobileAppVerificationTokenExisting] = useState(false); const [MobileAppVerificationTokenLoading, setMobileAppVerificationTokenLoading] = useState(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 (
{MobileAppVerificationTokenLoading ? ( diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index 3db546fc..8d7ea002 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -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', - }); - } }