oncall-engine/engine/apps/mobile_app/views.py

273 lines
11 KiB
Python
Raw Permalink Normal View History

import enum
import logging
import time
import typing
import requests
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
modify push notification settings + use fcm-django library (#998) - swaps out `django-push-notifications` for [`fcm-django`](https://github.com/grafana/fcm-django). Again.. this is a fork of the parent repo for exactly the same reason.. the migrations point to `auth_user` without letting us use our own user model, this has been patched in the `grafana` fork. The reason why we are using `fcm-django` vs `django-push-notifications` is that the latter does not support the new FCM API, only the "legacy" API. The legacy FCM API does not support certain push notification settings that we would like to use. - modifies the iOS/Android specific push notification settings - adds a `flower` pod in the `docker-compose-developer.yml`, useful for debugging tasks locally - sets the mobile app verification token TTL to 5 minutes when developing locally. The default of 1 minute makes working with device emulators really tricky.. This PR also swaps out the base image in `engine/Dockerfile` from `python:3.9-alpine3.16` to `python:3.9-slim-buster`. As to why.. in short, with the introduction of the `fcm-django` library there is now a peer-dependency on [`grpcio`](https://github.com/grpc/grpc) (which is used by `firebase_admin`.. which I am using in this PR to interact directly with Firebase Cloud Messaging (FCM)). `grpcio` does not publish wheels (read: compiled binaries) for the Alpine distro. It does publish wheels for Debian and hence `pip install -r requirements.txt` does not need to build this library from the source distribution. This is a [known "issue"](https://github.com/grpc/grpc/issues/22815#issuecomment-1107874367) and the recommended solution in the community is to.. not use alpine. These were the numbers, when building the image locally, in terms of image size and build time: | | Local image size (uncompressed | Build time (may differ based on your network speed) | | ------------------------- | -------------------------------------- | ---------- | | `python:3.9-alpine3.16` | 785MB | 320s | | `python:3.9-slim-buster` | 1.05GB | 90s | Co-authored-by: Salvatore Giordano <salvatoregiordanoo@gmail.com>
2022-12-20 12:41:34 +01:00
from fcm_django.api.rest_framework import FCMDeviceAuthorizedViewSet as BaseFCMDeviceAuthorizedViewSet
from rest_framework import mixins, status, viewsets
from rest_framework.exceptions import NotFound, ParseError
Mobile app settings backend (#1571) # What this PR does Adds mobile app settings support to OnCall backend. - Adds a new Django model `MobileAppUserSettings` to store push notification settings - Adds a new endpoint `/mobile_app/v1/user_settings` to fetch/update settings from the mobile app Some additional info on implementation: at first I wanted to extend the messaging backend system to allow storing / retrieving per-user data and implement mobile app settings based on those changes. After some thought I decided not to extend the messaging backend system and have this as functionality specific to the `mobile_app` Django app. Currently the messaging backend system is used by the backend and plugin UI, but mobile app settings are specific only to the mobile app and not configurable in the plugin UI. **tldr: wanted to extend messaging backend system, but decided not to do that** # Usage ## Get settings via API `GET /mobile_app/v1/user_settings` Example response: ```json { "default_notification_sound_name": "default_sound", # sound name without file extension "default_notification_volume_type": "constant", "default_notification_volume": 0.8, "default_notification_volume_override": false, "important_notification_sound_name": "default_sound_important", # sound name without file extension "important_notification_volume_type": "constant", "important_notification_volume": 0.8, "important_notification_override_dnd": true } ``` ## Update settings via API `PUT /mobile_app/v1/user_settings` - see example response above for payload shape. Note that sound names must be passed without file extension. When sending push notifications, the backend will add `.mp3` to sound names and pass it to push notification data for Android. For iOS, sound names will be suffixed with `.aiff` to be used by APNS. ## Get settings from notification data for Android All the settings from example response will be available in push notification data (along with `orgId`, `alertGroupId`, `title`, etc.). Fields `default_notification_volume`, `default_notification_volume_override` and `important_notification_volume` , `important_notification_override_dnd` will be converted to strings due to FCM limitations. Fields `default_notification_sound_name` and `important_notification_sound_name` will be suffixed with `.mp3` in push notification data. ## iOS limitations While Android push notifications are handled purely on the mobile app side, iOS notifications are sent via APNS which imposes some limitations. - Notification volume cannot be overridden for non-critical notifications (so fields `default_notification_volume_override` and `default_notification_volume` will be disregarded for iOS notifications) - It's not possible to control volume type (i.e. "constant" vs "intensifying") via APNS. A possible workaround is to have different sound files for "constant" and "intensifying" and pass that as `default_notification_sound_name` / `important_notification_sound_name`. # Which issue(s) this PR fixes Related to https://github.com/grafana/oncall-private/issues/1602 # Checklist - [x] Tests updated - [x] No changelog updates since the changes are not user-facing
2023-03-22 14:47:18 +00:00
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication, MobileAppVerificationTokenAuthentication
from apps.mobile_app.models import FCMDevice, MobileAppAuthToken, MobileAppUserSettings
from apps.mobile_app.serializers import FCMDeviceSerializer, MobileAppUserSettingsSerializer
from common.cloud_auth_api.client import CloudAuthApiClient, CloudAuthApiException
if typing.TYPE_CHECKING:
from apps.user_management.models import Organization, User
PROXY_REQUESTS_TIMEOUT = 5
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
modify push notification settings + use fcm-django library (#998) - swaps out `django-push-notifications` for [`fcm-django`](https://github.com/grafana/fcm-django). Again.. this is a fork of the parent repo for exactly the same reason.. the migrations point to `auth_user` without letting us use our own user model, this has been patched in the `grafana` fork. The reason why we are using `fcm-django` vs `django-push-notifications` is that the latter does not support the new FCM API, only the "legacy" API. The legacy FCM API does not support certain push notification settings that we would like to use. - modifies the iOS/Android specific push notification settings - adds a `flower` pod in the `docker-compose-developer.yml`, useful for debugging tasks locally - sets the mobile app verification token TTL to 5 minutes when developing locally. The default of 1 minute makes working with device emulators really tricky.. This PR also swaps out the base image in `engine/Dockerfile` from `python:3.9-alpine3.16` to `python:3.9-slim-buster`. As to why.. in short, with the introduction of the `fcm-django` library there is now a peer-dependency on [`grpcio`](https://github.com/grpc/grpc) (which is used by `firebase_admin`.. which I am using in this PR to interact directly with Firebase Cloud Messaging (FCM)). `grpcio` does not publish wheels (read: compiled binaries) for the Alpine distro. It does publish wheels for Debian and hence `pip install -r requirements.txt` does not need to build this library from the source distribution. This is a [known "issue"](https://github.com/grpc/grpc/issues/22815#issuecomment-1107874367) and the recommended solution in the community is to.. not use alpine. These were the numbers, when building the image locally, in terms of image size and build time: | | Local image size (uncompressed | Build time (may differ based on your network speed) | | ------------------------- | -------------------------------------- | ---------- | | `python:3.9-alpine3.16` | 785MB | 320s | | `python:3.9-slim-buster` | 1.05GB | 90s | Co-authored-by: Salvatore Giordano <salvatoregiordanoo@gmail.com>
2022-12-20 12:41:34 +01:00
class FCMDeviceAuthorizedViewSet(BaseFCMDeviceAuthorizedViewSet):
authentication_classes = (MobileAppAuthTokenAuthentication,)
serializer_class = FCMDeviceSerializer
model = FCMDevice
def create(self, request, *args, **kwargs):
"""Overrides `create` from BaseFCMDeviceAuthorizedViewSet to add filtering by user on getting instance"""
serializer = None
is_update = False
if settings.FCM_DJANGO_SETTINGS["UPDATE_ON_DUPLICATE_REG_ID"] and "registration_id" in request.data:
instance = self.model.objects.filter(
registration_id=request.data["registration_id"], user=self.request.user
).first()
if instance:
serializer = self.get_serializer(instance, data=request.data)
is_update = True
if not serializer:
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
if is_update:
self.perform_update(serializer)
return Response(serializer.data)
else:
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def get_object(self):
"""Overrides original method to add filtering by user"""
try:
obj = self.model.objects.get(registration_id=self.kwargs["registration_id"], user=self.request.user)
except ObjectDoesNotExist:
raise NotFound
# May raise a permission denied
self.check_object_permissions(self.request, obj)
return obj
class MobileAppAuthTokenAPIView(APIView):
authentication_classes = (MobileAppVerificationTokenAuthentication,)
def get(self, request):
try:
token = MobileAppAuthToken.objects.get(user=self.request.user)
except MobileAppAuthToken.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,
"stack_slug": self.request.auth.organization.stack_slug,
}
return Response(response, status=status.HTTP_200_OK)
def post(self, request):
# If token already exists revoke it
try:
token = MobileAppAuthToken.objects.get(user=self.request.user)
token.delete()
except MobileAppAuthToken.DoesNotExist:
pass
instance, token = MobileAppAuthToken.create_auth_token(self.request.user, self.request.user.organization)
data = {
"id": instance.pk,
"token": token,
"created_at": instance.created_at,
"stack_slug": self.request.auth.organization.stack_slug,
}
return Response(data, status=status.HTTP_201_CREATED)
def delete(self, request):
try:
token = MobileAppAuthToken.objects.get(user=self.request.user)
token.delete()
except MobileAppAuthToken.DoesNotExist:
raise NotFound
return Response(status=status.HTTP_204_NO_CONTENT)
Mobile app settings backend (#1571) # What this PR does Adds mobile app settings support to OnCall backend. - Adds a new Django model `MobileAppUserSettings` to store push notification settings - Adds a new endpoint `/mobile_app/v1/user_settings` to fetch/update settings from the mobile app Some additional info on implementation: at first I wanted to extend the messaging backend system to allow storing / retrieving per-user data and implement mobile app settings based on those changes. After some thought I decided not to extend the messaging backend system and have this as functionality specific to the `mobile_app` Django app. Currently the messaging backend system is used by the backend and plugin UI, but mobile app settings are specific only to the mobile app and not configurable in the plugin UI. **tldr: wanted to extend messaging backend system, but decided not to do that** # Usage ## Get settings via API `GET /mobile_app/v1/user_settings` Example response: ```json { "default_notification_sound_name": "default_sound", # sound name without file extension "default_notification_volume_type": "constant", "default_notification_volume": 0.8, "default_notification_volume_override": false, "important_notification_sound_name": "default_sound_important", # sound name without file extension "important_notification_volume_type": "constant", "important_notification_volume": 0.8, "important_notification_override_dnd": true } ``` ## Update settings via API `PUT /mobile_app/v1/user_settings` - see example response above for payload shape. Note that sound names must be passed without file extension. When sending push notifications, the backend will add `.mp3` to sound names and pass it to push notification data for Android. For iOS, sound names will be suffixed with `.aiff` to be used by APNS. ## Get settings from notification data for Android All the settings from example response will be available in push notification data (along with `orgId`, `alertGroupId`, `title`, etc.). Fields `default_notification_volume`, `default_notification_volume_override` and `important_notification_volume` , `important_notification_override_dnd` will be converted to strings due to FCM limitations. Fields `default_notification_sound_name` and `important_notification_sound_name` will be suffixed with `.mp3` in push notification data. ## iOS limitations While Android push notifications are handled purely on the mobile app side, iOS notifications are sent via APNS which imposes some limitations. - Notification volume cannot be overridden for non-critical notifications (so fields `default_notification_volume_override` and `default_notification_volume` will be disregarded for iOS notifications) - It's not possible to control volume type (i.e. "constant" vs "intensifying") via APNS. A possible workaround is to have different sound files for "constant" and "intensifying" and pass that as `default_notification_sound_name` / `important_notification_sound_name`. # Which issue(s) this PR fixes Related to https://github.com/grafana/oncall-private/issues/1602 # Checklist - [x] Tests updated - [x] No changelog updates since the changes are not user-facing
2023-03-22 14:47:18 +00:00
class MobileAppUserSettingsViewSet(
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
Mobile app settings backend (#1571) # What this PR does Adds mobile app settings support to OnCall backend. - Adds a new Django model `MobileAppUserSettings` to store push notification settings - Adds a new endpoint `/mobile_app/v1/user_settings` to fetch/update settings from the mobile app Some additional info on implementation: at first I wanted to extend the messaging backend system to allow storing / retrieving per-user data and implement mobile app settings based on those changes. After some thought I decided not to extend the messaging backend system and have this as functionality specific to the `mobile_app` Django app. Currently the messaging backend system is used by the backend and plugin UI, but mobile app settings are specific only to the mobile app and not configurable in the plugin UI. **tldr: wanted to extend messaging backend system, but decided not to do that** # Usage ## Get settings via API `GET /mobile_app/v1/user_settings` Example response: ```json { "default_notification_sound_name": "default_sound", # sound name without file extension "default_notification_volume_type": "constant", "default_notification_volume": 0.8, "default_notification_volume_override": false, "important_notification_sound_name": "default_sound_important", # sound name without file extension "important_notification_volume_type": "constant", "important_notification_volume": 0.8, "important_notification_override_dnd": true } ``` ## Update settings via API `PUT /mobile_app/v1/user_settings` - see example response above for payload shape. Note that sound names must be passed without file extension. When sending push notifications, the backend will add `.mp3` to sound names and pass it to push notification data for Android. For iOS, sound names will be suffixed with `.aiff` to be used by APNS. ## Get settings from notification data for Android All the settings from example response will be available in push notification data (along with `orgId`, `alertGroupId`, `title`, etc.). Fields `default_notification_volume`, `default_notification_volume_override` and `important_notification_volume` , `important_notification_override_dnd` will be converted to strings due to FCM limitations. Fields `default_notification_sound_name` and `important_notification_sound_name` will be suffixed with `.mp3` in push notification data. ## iOS limitations While Android push notifications are handled purely on the mobile app side, iOS notifications are sent via APNS which imposes some limitations. - Notification volume cannot be overridden for non-critical notifications (so fields `default_notification_volume_override` and `default_notification_volume` will be disregarded for iOS notifications) - It's not possible to control volume type (i.e. "constant" vs "intensifying") via APNS. A possible workaround is to have different sound files for "constant" and "intensifying" and pass that as `default_notification_sound_name` / `important_notification_sound_name`. # Which issue(s) this PR fixes Related to https://github.com/grafana/oncall-private/issues/1602 # Checklist - [x] Tests updated - [x] No changelog updates since the changes are not user-facing
2023-03-22 14:47:18 +00:00
authentication_classes = (MobileAppAuthTokenAuthentication,)
permission_classes = (IsAuthenticated,)
serializer_class = MobileAppUserSettingsSerializer
def get_object(self):
mobile_app_settings, _ = MobileAppUserSettings.objects.get_or_create(user=self.request.user)
return mobile_app_settings
def notification_timing_options(self, request):
choices = [
{"value": item[0], "display_name": item[1]} for item in MobileAppUserSettings.NOTIFICATION_TIMING_CHOICES
]
return Response(choices)
class MobileAppGatewayView(APIView):
authentication_classes = (MobileAppAuthTokenAuthentication,)
permission_classes = (IsAuthenticated,)
class SupportedDownstreamBackends(enum.StrEnum):
INCIDENT = "incident"
ALL_SUPPORTED_DOWNSTREAM_BACKENDS = list(SupportedDownstreamBackends)
def initial(self, request: Request, *args, **kwargs):
# If the mobile app gateway is not enabled, return a 404
if not settings.MOBILE_APP_GATEWAY_ENABLED:
raise NotFound
super().initial(request, *args, **kwargs)
@classmethod
def _get_auth_token(cls, downstream_backend: SupportedDownstreamBackends, user: "User") -> str:
"""
RS256 = asymmetric = public/private key pair
HS256 = symmetric = shared secret (don't use this)
"""
org = user.organization
token_scopes = {
cls.SupportedDownstreamBackends.INCIDENT: [CloudAuthApiClient.Scopes.INCIDENT_WRITE],
}[downstream_backend]
return f"{org.stack_id}:{CloudAuthApiClient().request_signed_token(user, token_scopes)}"
@classmethod
def _get_downstream_headers(
cls, request: Request, downstream_backend: SupportedDownstreamBackends, user: "User"
) -> typing.Dict[str, str]:
headers = {
"Authorization": f"Bearer {cls._get_auth_token(downstream_backend, user)}",
}
if (v := request.META.get("CONTENT_TYPE", None)) is not None:
headers["Content-Type"] = v
return headers
@classmethod
def _get_downstream_url(
cls, organization: "Organization", downstream_backend: SupportedDownstreamBackends, downstream_path: str
) -> str:
downstream_url = {
cls.SupportedDownstreamBackends.INCIDENT: organization.grafana_incident_backend_url,
}[downstream_backend]
if downstream_url is None:
raise ParseError(
f"Downstream URL not found for backend {downstream_backend} for organization {organization.pk}"
)
return f"{downstream_url}/{downstream_path}"
def _proxy_request(self, request: Request, *args, **kwargs) -> Response:
request_start = time.perf_counter()
downstream_backend = kwargs["downstream_backend"]
downstream_path = kwargs["downstream_path"]
method = request.method
user = request.user
org = user.organization
if downstream_backend not in self.ALL_SUPPORTED_DOWNSTREAM_BACKENDS:
raise NotFound(f"Downstream backend {downstream_backend} not supported")
if downstream_backend == self.SupportedDownstreamBackends.INCIDENT and not org.is_grafana_incident_enabled:
raise NotFound(f"Incident is not enabled for organization {org.pk}")
downstream_url = self._get_downstream_url(user.organization, downstream_backend, downstream_path)
log_msg_common = f"{downstream_backend} request to {method} {downstream_url}"
logger.info(f"Proxying {log_msg_common}")
downstream_request_handler = getattr(requests, method.lower())
final_status = None
response_data = None
try:
downstream_response = downstream_request_handler(
downstream_url,
data=request.body,
params=request.query_params.dict(),
headers=self._get_downstream_headers(request, downstream_backend, user),
timeout=PROXY_REQUESTS_TIMEOUT, # set a timeout to prevent hanging
)
final_status = downstream_response.status_code
response_data = downstream_response.json()
logger.info(f"Successfully proxied {log_msg_common}")
# raise an exception if the response status code is not 2xx
# (exception handler will log and still return the response)
downstream_response.raise_for_status()
return Response(status=downstream_response.status_code, data=response_data)
except (
requests.exceptions.RequestException,
requests.exceptions.JSONDecodeError,
requests.exceptions.Timeout,
CloudAuthApiException,
) as e:
if isinstance(e, requests.exceptions.JSONDecodeError):
final_status = status.HTTP_400_BAD_REQUEST
elif isinstance(e, requests.exceptions.Timeout):
final_status = status.HTTP_504_GATEWAY_TIMEOUT
elif final_status is None:
final_status = status.HTTP_502_BAD_GATEWAY
logger.error(
(
f"MobileAppGatewayView: error while proxying request\n"
f"method={method}\n"
f"downstream_backend={downstream_backend}\n"
f"downstream_path={downstream_path}\n"
f"downstream_url={downstream_url}\n"
f"final_status={final_status}"
),
exc_info=True,
)
return Response(status=final_status, data=response_data)
finally:
request_end = time.perf_counter()
seconds = request_end - request_start
logging.info(
f"outbound latency={str(seconds)} status={final_status} "
f"method={method.upper()} url={downstream_url} "
f"slow={int(seconds > settings.SLOW_THRESHOLD_SECONDS)} "
)
"""
See the default `APIView.dispatch` for more info. Basically this just routes all requests for
ALL HTTP verbs to the `MobileAppGatewayView._proxy_request` method.
"""
for method in APIView.http_method_names:
setattr(MobileAppGatewayView, method.lower(), MobileAppGatewayView._proxy_request)