2022-06-03 08:09:47 -06:00
|
|
|
from typing import Tuple
|
|
|
|
|
|
2022-12-20 12:41:34 +01:00
|
|
|
from django.conf import settings
|
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
|
2022-06-03 08:09:47 -06:00
|
|
|
from django.db import models
|
|
|
|
|
from django.utils import timezone
|
|
|
|
|
|
|
|
|
|
from apps.auth_token import constants, crypto
|
|
|
|
|
from apps.auth_token.models import BaseAuthToken
|
|
|
|
|
from apps.user_management.models import Organization, User
|
|
|
|
|
|
2022-12-20 12:41:34 +01:00
|
|
|
MOBILE_APP_AUTH_VERIFICATION_TOKEN_TIMEOUT_SECONDS = 60 * (5 if settings.DEBUG else 1)
|
2022-11-23 15:56:43 +00:00
|
|
|
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
def get_expire_date():
|
|
|
|
|
return timezone.now() + timezone.timedelta(seconds=MOBILE_APP_AUTH_VERIFICATION_TOKEN_TIMEOUT_SECONDS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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) -> 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
|
2022-11-23 15:56:43 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class MobileAppAuthToken(BaseAuthToken):
|
add unique idx on user column in mobileapp authtoken table (#1482)
# Which issue(s) this PR fixes
Solves the (rare) issue where a user could potentially have > 1
mobileapp auth token, leading to 500 errors when trying to interact w/
the authtoken (ex. disconnect a mobile app from a user's profile):
```shell
2023-03-07 10:12:13 source=engine:app google_trace_id=e14bf933d634068a48caf093ce43c7f5/5550677047491218352 logger=django.request Internal Server Error: /api/internal/v1/users/U6WJ3BRLM1TR7/unlink_backend
Traceback (most recent call last):
File "/usr/local/lib/python3.9/site-packages/django/core/handlers/exception.py", line 47, in inner
response = get_response(request)
File "/usr/local/lib/python3.9/site-packages/django/core/handlers/base.py", line 181, in _get_response
response = wrapped_callback(request, *callback_args, **callback_kwargs)
File "/usr/local/lib/python3.9/site-packages/django/views/decorators/csrf.py", line 54, in wrapped_view
return view_func(*args, **kwargs)
File "/usr/local/lib/python3.9/site-packages/rest_framework/viewsets.py", line 125, in view
return self.dispatch(request, *args, **kwargs)
File "/usr/local/lib/python3.9/site-packages/rest_framework/views.py", line 509, in dispatch
response = self.handle_exception(exc)
File "/usr/local/lib/python3.9/site-packages/rest_framework/views.py", line 469, in handle_exception
self.raise_uncaught_exception(exc)
File "/usr/local/lib/python3.9/site-packages/rest_framework/views.py", line 480, in raise_uncaught_exception
raise exc
File "/usr/local/lib/python3.9/site-packages/rest_framework/views.py", line 506, in dispatch
response = handler(request, *args, **kwargs)
File "/etc/app/apps/api/views/user.py", line 453, in unlink_backend
backend.unlink_user(user)
File "/etc/app/apps/mobile_app/backend.py", line 34, in unlink_user
token = MobileAppAuthToken.objects.get(user=user)
File "/usr/local/lib/python3.9/site-packages/django/db/models/manager.py", line 85, in manager_method
return getattr(self.get_queryset(), name)(*args, **kwargs)
File "/usr/local/lib/python3.9/site-packages/django/db/models/query.py", line 439, in get
raise self.model.MultipleObjectsReturned(
apps.mobile_app.models.MobileAppAuthToken.MultipleObjectsReturned: get() returned more than one MobileAppAuthToken -- it returned 2!
```
## Checklist
- [x] Tests updated
- [ ] Documentation added (N/A)
- [x] `CHANGELOG.md` updated
2023-03-08 13:50:57 +01:00
|
|
|
user = models.OneToOneField(to=User, null=False, blank=False, on_delete=models.CASCADE)
|
2022-11-23 15:56:43 +00:00
|
|
|
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
|
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, null=False, on_delete=models.CASCADE)
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
)
|
2023-05-09 10:28:47 -04:00
|
|
|
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)
|
2023-05-04 12:59:57 -04:00
|
|
|
|
2023-05-12 12:23:42 +02:00
|
|
|
# Push notification settings for info notifications
|
2023-05-04 12:59:57 -04:00
|
|
|
# this is used for non escalation related push notifications such as the
|
|
|
|
|
# "You're going OnCall soon" push notification
|
2023-05-12 13:56:12 +02:00
|
|
|
info_notifications_enabled = models.BooleanField(default=False)
|
2023-05-04 12:59:57 -04:00
|
|
|
|
2023-05-12 12:23:42 +02:00
|
|
|
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)
|
|
|
|
|
|
2023-05-04 12:59:57 -04:00
|
|
|
# these choices + the below column are used to calculate when to send the "You're Going OnCall soon"
|
|
|
|
|
# push notification
|
|
|
|
|
# ONE_HOUR, TWELVE_HOURS, ONE_DAY, ONE_WEEK = range(4)
|
|
|
|
|
TWELVE_HOURS_IN_SECONDS = 12 * 60 * 60
|
|
|
|
|
ONE_DAY_IN_SECONDS = TWELVE_HOURS_IN_SECONDS * 2
|
|
|
|
|
ONE_WEEK_IN_SECONDS = ONE_DAY_IN_SECONDS * 7
|
|
|
|
|
|
|
|
|
|
NOTIFICATION_TIMING_CHOICES = (
|
|
|
|
|
(TWELVE_HOURS_IN_SECONDS, "twelve hours before"),
|
|
|
|
|
(ONE_DAY_IN_SECONDS, "one day before"),
|
|
|
|
|
(ONE_WEEK_IN_SECONDS, "one week before"),
|
|
|
|
|
)
|
|
|
|
|
going_oncall_notification_timing = models.IntegerField(
|
|
|
|
|
choices=NOTIFICATION_TIMING_CHOICES, default=TWELVE_HOURS_IN_SECONDS
|
|
|
|
|
)
|