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

205 lines
8.1 KiB
Python
Raw Permalink Normal View History

from __future__ import annotations # https://stackoverflow.com/a/33533514
import typing
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 django.core import validators
from django.db import models
from django.db.models import JSONField
from django.utils import timezone
from fcm_django.models import FCMDevice as BaseFCMDevice
from apps.auth_token import constants, crypto
from apps.auth_token.models import BaseAuthToken
from apps.mobile_app.types import MessageType, Platform
if typing.TYPE_CHECKING:
from apps.user_management.models import Organization, User
MOBILE_APP_AUTH_VERIFICATION_TOKEN_TIMEOUT_SECONDS = 60 * 5 # 5 minutes
def get_expire_date():
return timezone.now() + timezone.timedelta(seconds=MOBILE_APP_AUTH_VERIFICATION_TOKEN_TIMEOUT_SECONDS)
def default_notification_timing_options():
return [MobileAppUserSettings.FIFTEEN_MINUTES_IN_SECONDS]
class ActiveFCMDeviceQuerySet(models.QuerySet):
def filter(self, *args, **kwargs):
return super().filter(*args, **kwargs, active=True)
class FCMDevice(BaseFCMDevice):
active_objects = ActiveFCMDeviceQuerySet.as_manager()
class Meta:
# don't create a table for this model..
# https://docs.djangoproject.com/en/dev/topics/db/models/#differences-between-proxy-inheritance-and-unmanaged-models
proxy = True
@classmethod
def get_active_device_for_user(cls, user: "User") -> FCMDevice | None:
return cls.active_objects.filter(user=user).first()
class MobileAppVerificationTokenQueryset(models.QuerySet):
def filter(self, *args, **kwargs):
now = timezone.now()
return super().filter(*args, **kwargs, revoked_at=None, expire_date__gte=now)
def delete(self):
self.update(revoked_at=timezone.now())
class MobileAppVerificationToken(BaseAuthToken):
objects = MobileAppVerificationTokenQueryset.as_manager()
user = models.ForeignKey(
"user_management.User",
related_name="mobile_app_verification_token_set",
on_delete=models.CASCADE,
)
organization = models.ForeignKey(
"user_management.Organization", related_name="mobile_app_verification_token_set", on_delete=models.CASCADE
)
expire_date = models.DateTimeField(default=get_expire_date)
@classmethod
def create_auth_token(
cls, user: "User", organization: "Organization"
) -> typing.Tuple["MobileAppVerificationToken", str]:
token_string = crypto.generate_short_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
class MobileAppAuthToken(BaseAuthToken):
objects: models.Manager["MobileAppAuthToken"]
user = models.OneToOneField(to="user_management.User", null=False, blank=False, on_delete=models.CASCADE)
organization = models.ForeignKey(
to="user_management.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") -> typing.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
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 MobileAppUserSettings(models.Model):
# Sound names are stored without extension, extension is added when sending push notifications
IOS_SOUND_NAME_EXTENSION = ".aiff"
ANDROID_SOUND_NAME_EXTENSION = ".mp3"
class VolumeType(models.TextChoices):
CONSTANT = "constant"
INTENSIFYING = "intensifying"
user = models.OneToOneField(to="user_management.User", null=False, on_delete=models.CASCADE)
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
# Push notification settings for default notifications
default_notification_sound_name = models.CharField(max_length=100, default="default_sound")
default_notification_volume_type = models.CharField(
max_length=50, choices=VolumeType.choices, default=VolumeType.CONSTANT
)
# APNS only allows to specify volume for critical notifications,
# so "default_notification_volume" and "default_notification_volume_override" are only used on Android
default_notification_volume = models.FloatField(
validators=[validators.MinValueValidator(0.0), validators.MaxValueValidator(1.0)], default=0.8
)
default_notification_volume_override = models.BooleanField(default=False)
# Push notification settings for important notifications
important_notification_sound_name = models.CharField(max_length=100, default="default_sound_important")
important_notification_volume_type = models.CharField(
max_length=50, choices=VolumeType.choices, default=VolumeType.CONSTANT
)
important_notification_volume = models.FloatField(
validators=[validators.MinValueValidator(0.0), validators.MaxValueValidator(1.0)], default=0.8
)
important_notification_volume_override = models.BooleanField(default=True, null=True)
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
# For the "Mobile push important" step it's possible to make notifications non-critical
# if "override DND" setting is disabled in the app
important_notification_override_dnd = models.BooleanField(default=True)
# Push notification settings for info notifications
# this is used for non escalation related push notifications such as the
# "You're going OnCall soon" and "You have a new shift swap request" push notifications
info_notifications_enabled = models.BooleanField(default=False)
info_notification_sound_name = models.CharField(max_length=100, default="default_sound", null=True)
info_notification_volume_type = models.CharField(
max_length=50, choices=VolumeType.choices, default=VolumeType.CONSTANT, null=True
)
# APNS only allows to specify volume for critical notifications,
# so "info_notification_volume" and "info_notification_volume_override" are only used on Android
info_notification_volume = models.FloatField(
validators=[validators.MinValueValidator(0.0), validators.MaxValueValidator(1.0)], default=0.8, null=True
)
info_notification_volume_override = models.BooleanField(default=False, null=True)
# these choices + the below column are used to calculate when to send the "You're Going OnCall soon"
# push notification
FIFTEEN_MINUTES_IN_SECONDS = 15 * 60
ONE_HOUR_IN_SECONDS = 60 * 60
SIX_HOURS_IN_SECONDS = 6 * 60 * 60
TWELVE_HOURS_IN_SECONDS = 12 * 60 * 60
ONE_DAY_IN_SECONDS = TWELVE_HOURS_IN_SECONDS * 2
NOTIFICATION_TIMING_CHOICES = (
(FIFTEEN_MINUTES_IN_SECONDS, "15 minutes before"),
(ONE_HOUR_IN_SECONDS, "1 hour before"),
(SIX_HOURS_IN_SECONDS, "6 hours before"),
(TWELVE_HOURS_IN_SECONDS, "12 hours before"),
(ONE_DAY_IN_SECONDS, "1 day before"),
)
ALL_NOTIFICATION_TIMING_CHOICES_SECONDS = [choice[0] for choice in NOTIFICATION_TIMING_CHOICES]
going_oncall_notification_timing = JSONField(default=default_notification_timing_options)
locale = models.CharField(max_length=50, null=True)
time_zone = models.CharField(max_length=100, default="UTC")
def get_notification_sound_name(self, message_type: MessageType, platform: Platform) -> str:
sound_name = {
MessageType.DEFAULT: self.default_notification_sound_name,
MessageType.IMPORTANT: self.important_notification_sound_name,
MessageType.INFO: self.info_notification_sound_name,
}[message_type]
# If sound name already contains an extension, return it as is
if "." in sound_name:
return sound_name
# Add appropriate extension based on platform, for cases when no extension is specified in the sound name
extension = {
Platform.IOS: self.IOS_SOUND_NAME_EXTENSION,
Platform.ANDROID: self.ANDROID_SOUND_NAME_EXTENSION,
}[platform]
return f"{sound_name}{extension}"