2023-06-12 18:50:33 +02:00
|
|
|
import datetime
|
2024-07-09 11:23:53 -04:00
|
|
|
import typing
|
2022-08-11 17:26:45 -03:00
|
|
|
from enum import unique
|
2022-06-03 08:09:47 -06:00
|
|
|
from typing import Tuple
|
|
|
|
|
|
|
|
|
|
from django.conf import settings
|
|
|
|
|
from django.core.exceptions import ValidationError
|
|
|
|
|
from django.core.validators import MinLengthValidator
|
2024-07-05 15:08:17 -04:00
|
|
|
from django.db import models
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
from apps.base.messaging import get_messaging_backends
|
|
|
|
|
from apps.user_management.models import User
|
2023-07-18 18:17:53 +01:00
|
|
|
from common.ordered_model.ordered_model import OrderedModel
|
2022-06-03 08:09:47 -06:00
|
|
|
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
|
|
|
|
|
|
2024-07-09 11:23:53 -04:00
|
|
|
if typing.TYPE_CHECKING:
|
|
|
|
|
from django.db.models.manager import RelatedManager
|
|
|
|
|
|
|
|
|
|
from apps.base.models import UserNotificationPolicyLogRecord
|
|
|
|
|
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
def generate_public_primary_key_for_notification_policy():
|
|
|
|
|
prefix = "N"
|
|
|
|
|
new_public_primary_key = generate_public_primary_key(prefix)
|
|
|
|
|
|
|
|
|
|
failure_counter = 0
|
|
|
|
|
while UserNotificationPolicy.objects.filter(public_primary_key=new_public_primary_key).exists():
|
|
|
|
|
new_public_primary_key = increase_public_primary_key_length(
|
|
|
|
|
failure_counter=failure_counter, prefix=prefix, model_name="UserNotificationPolicy"
|
|
|
|
|
)
|
|
|
|
|
failure_counter += 1
|
|
|
|
|
|
|
|
|
|
return new_public_primary_key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# base supported notification backends
|
|
|
|
|
BUILT_IN_BACKENDS = (
|
2022-08-11 17:26:45 -03:00
|
|
|
("SLACK", 0),
|
|
|
|
|
("SMS", 1),
|
|
|
|
|
("PHONE_CALL", 2),
|
|
|
|
|
("TELEGRAM", 3),
|
2022-06-03 08:09:47 -06:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _notification_channel_choices():
|
|
|
|
|
"""Return dynamically built choices for available notification channel backends."""
|
|
|
|
|
|
|
|
|
|
# Enum containing notification channel choices on the database level.
|
|
|
|
|
# Also see NotificationChannelOptions class with more logic on notification channels.
|
|
|
|
|
# Do not remove items from this enum if you just want to disable a notification channel temporarily,
|
|
|
|
|
# use NotificationChannelOptions.AVAILABLE_FOR_USE instead.
|
|
|
|
|
supported_backends = list(BUILT_IN_BACKENDS)
|
|
|
|
|
|
2022-08-11 17:26:45 -03:00
|
|
|
for backend_id, backend in get_messaging_backends():
|
|
|
|
|
supported_backends.append((backend_id, backend.notification_channel_id))
|
2022-06-03 08:09:47 -06:00
|
|
|
|
2022-08-11 17:26:45 -03:00
|
|
|
channels_enum = unique(models.IntegerChoices("NotificationChannel", supported_backends))
|
2022-06-03 08:09:47 -06:00
|
|
|
return channels_enum
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_notification_channels = _notification_channel_choices()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_channel_choice(value):
|
|
|
|
|
if value is None:
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
_notification_channels(value)
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise ValidationError("%(value)s is not a valid option", params={"value": value})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UserNotificationPolicy(OrderedModel):
|
2024-07-09 11:23:53 -04:00
|
|
|
personal_log_records: "RelatedManager['UserNotificationPolicyLogRecord']"
|
|
|
|
|
user: typing.Optional[User]
|
|
|
|
|
|
Fix duplicate orders for user notification policies (#2278)
# What this PR does
Fixes an issue when multiple user notification policies have duplicated
order values, leading to the following unexpected behaviours:
1. Not possible to rearrange notification policies that have duplicated
orders.
2. The notification system only executes the first policy from each
order group. For example, if there are policies with orders `[0, 0, 0,
0]`, only the first policy will be executed, and all others will be
skipped. So the user will see four policies in the UI, while only one of
them will be actually executed.
This PR fixes the issue by adding a unique index on `(user_id,
important, order)` for `UserNotificationPolicy` model. However, it's not
possible to add that unique index using the ordering library that we use
due to it's implementation details.
I added a new abstract Django model `OrderedModel` that's able to work
with such unique indices + under concurrent load.
Important info on this new `OrderedModel` abstract model:
- Orders are unique on the DB level
- Orders are allowed to be non-consecutive, for example order sequence
`[100, 150, 400]` is valid
- When deleting an instance, orders of other instances don't change.
This is a notable difference from the library we use. I think it's
better to only delete the instance without changing any other orders,
because it reduces the number of dependencies between instances (e.g.
Terraform drift will be much smaller this way if a policy is deleted via
the web UI).
## Which issue(s) this PR fixes
Related to https://github.com/grafana/oncall-private/issues/1680
## Checklist
- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
2023-06-21 12:13:56 +01:00
|
|
|
order_with_respect_to = ("user_id", "important")
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
public_primary_key = models.CharField(
|
|
|
|
|
max_length=20,
|
|
|
|
|
validators=[MinLengthValidator(settings.PUBLIC_PRIMARY_KEY_MIN_LENGTH + 1)],
|
|
|
|
|
unique=True,
|
|
|
|
|
default=generate_public_primary_key_for_notification_policy,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
user = models.ForeignKey(
|
|
|
|
|
"user_management.User", on_delete=models.CASCADE, related_name="notification_policies", default=None, null=True
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
class Step(models.IntegerChoices):
|
|
|
|
|
WAIT = 0, "Wait"
|
|
|
|
|
NOTIFY = 1, "Notify by"
|
|
|
|
|
|
|
|
|
|
step = models.PositiveSmallIntegerField(choices=Step.choices, default=None, null=True)
|
|
|
|
|
|
|
|
|
|
NotificationChannel = _notification_channels
|
|
|
|
|
notify_by = models.PositiveSmallIntegerField(default=0, validators=[validate_channel_choice])
|
|
|
|
|
|
2023-06-12 18:50:33 +02:00
|
|
|
ONE_MINUTE = datetime.timedelta(minutes=1)
|
|
|
|
|
FIVE_MINUTES = datetime.timedelta(minutes=5)
|
|
|
|
|
FIFTEEN_MINUTES = datetime.timedelta(minutes=15)
|
|
|
|
|
THIRTY_MINUTES = datetime.timedelta(minutes=30)
|
|
|
|
|
HOUR = datetime.timedelta(minutes=60)
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
DURATION_CHOICES = (
|
|
|
|
|
(ONE_MINUTE, "1 min"),
|
|
|
|
|
(FIVE_MINUTES, "5 min"),
|
|
|
|
|
(FIFTEEN_MINUTES, "15 min"),
|
|
|
|
|
(THIRTY_MINUTES, "30 min"),
|
|
|
|
|
(HOUR, "60 min"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
wait_delay = models.DurationField(default=None, null=True, choices=DURATION_CHOICES)
|
|
|
|
|
|
|
|
|
|
important = models.BooleanField(default=False)
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
ordering = ("order",)
|
Fix duplicate orders for user notification policies (#2278)
# What this PR does
Fixes an issue when multiple user notification policies have duplicated
order values, leading to the following unexpected behaviours:
1. Not possible to rearrange notification policies that have duplicated
orders.
2. The notification system only executes the first policy from each
order group. For example, if there are policies with orders `[0, 0, 0,
0]`, only the first policy will be executed, and all others will be
skipped. So the user will see four policies in the UI, while only one of
them will be actually executed.
This PR fixes the issue by adding a unique index on `(user_id,
important, order)` for `UserNotificationPolicy` model. However, it's not
possible to add that unique index using the ordering library that we use
due to it's implementation details.
I added a new abstract Django model `OrderedModel` that's able to work
with such unique indices + under concurrent load.
Important info on this new `OrderedModel` abstract model:
- Orders are unique on the DB level
- Orders are allowed to be non-consecutive, for example order sequence
`[100, 150, 400]` is valid
- When deleting an instance, orders of other instances don't change.
This is a notable difference from the library we use. I think it's
better to only delete the instance without changing any other orders,
because it reduces the number of dependencies between instances (e.g.
Terraform drift will be much smaller this way if a policy is deleted via
the web UI).
## Which issue(s) this PR fixes
Related to https://github.com/grafana/oncall-private/issues/1680
## Checklist
- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
2023-06-21 12:13:56 +01:00
|
|
|
constraints = [
|
|
|
|
|
models.UniqueConstraint(
|
|
|
|
|
fields=["user_id", "important", "order"], name="unique_user_notification_policy_order"
|
|
|
|
|
)
|
|
|
|
|
]
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
|
return f"{self.pk}: {self.short_verbal}"
|
|
|
|
|
|
|
|
|
|
@classmethod
|
2023-06-27 12:23:08 +02:00
|
|
|
def get_short_verbals_for_user(cls, user: User) -> Tuple[Tuple[str, ...], Tuple[str, ...]]:
|
2024-08-15 16:20:55 +02:00
|
|
|
policies = user.notification_policies.all()
|
|
|
|
|
|
|
|
|
|
default = ()
|
|
|
|
|
important = ()
|
|
|
|
|
|
|
|
|
|
for policy in policies:
|
|
|
|
|
if policy.step is None or (policy.step == cls.Step.WAIT and policy.wait_delay is None):
|
|
|
|
|
continue
|
|
|
|
|
if policy.important:
|
|
|
|
|
important += (policy.short_verbal,)
|
|
|
|
|
else:
|
|
|
|
|
default += (policy.short_verbal,)
|
2022-06-03 08:09:47 -06:00
|
|
|
|
|
|
|
|
return default, important
|
|
|
|
|
|
2024-07-09 11:23:53 -04:00
|
|
|
@staticmethod
|
|
|
|
|
def get_default_fallback_policy(user: User) -> "UserNotificationPolicy":
|
|
|
|
|
return UserNotificationPolicy(
|
|
|
|
|
user=user,
|
|
|
|
|
step=UserNotificationPolicy.Step.NOTIFY,
|
|
|
|
|
notify_by=settings.EMAIL_BACKEND_INTERNAL_ID,
|
|
|
|
|
# The important flag doesn't really matter here.. since we're just using this as a transient/fallacbk
|
|
|
|
|
# in-memory object (important is really only used for allowing users to group their
|
|
|
|
|
# notification policy steps)
|
|
|
|
|
important=False,
|
|
|
|
|
order=0,
|
|
|
|
|
)
|
|
|
|
|
|
2022-06-03 08:09:47 -06:00
|
|
|
@property
|
|
|
|
|
def short_verbal(self) -> str:
|
|
|
|
|
if self.step == UserNotificationPolicy.Step.NOTIFY:
|
|
|
|
|
try:
|
|
|
|
|
notification_channel = self.NotificationChannel(self.notify_by)
|
|
|
|
|
except ValueError:
|
|
|
|
|
return "Not set"
|
|
|
|
|
return NotificationChannelAPIOptions.SHORT_LABELS[notification_channel]
|
|
|
|
|
elif self.step == UserNotificationPolicy.Step.WAIT:
|
|
|
|
|
if self.wait_delay is None:
|
|
|
|
|
return "0 min"
|
|
|
|
|
else:
|
|
|
|
|
return self.get_wait_delay_display()
|
|
|
|
|
else:
|
|
|
|
|
return "Not set"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class NotificationChannelOptions:
|
|
|
|
|
"""
|
|
|
|
|
NotificationChannelOptions encapsulates logic of notification channel representation for API and public API,
|
|
|
|
|
integration constraints and contains a list of available notification channels.
|
|
|
|
|
|
|
|
|
|
To prohibit using a notification channel, remove it from AVAILABLE_FOR_USE list.
|
|
|
|
|
Note that removing a notification channel from AVAILABLE_FOR_USE removes it from API and public API,
|
|
|
|
|
but doesn't change anything in the database.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
AVAILABLE_FOR_USE = [
|
|
|
|
|
UserNotificationPolicy.NotificationChannel.SLACK,
|
|
|
|
|
UserNotificationPolicy.NotificationChannel.SMS,
|
|
|
|
|
UserNotificationPolicy.NotificationChannel.PHONE_CALL,
|
|
|
|
|
UserNotificationPolicy.NotificationChannel.TELEGRAM,
|
|
|
|
|
] + [
|
|
|
|
|
getattr(UserNotificationPolicy.NotificationChannel, backend_id)
|
|
|
|
|
for backend_id, b in get_messaging_backends()
|
|
|
|
|
if b.available_for_use
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
DEFAULT_NOTIFICATION_CHANNEL = UserNotificationPolicy.NotificationChannel.SLACK
|
|
|
|
|
|
|
|
|
|
SLACK_INTEGRATION_REQUIRED_NOTIFICATION_CHANNELS = [UserNotificationPolicy.NotificationChannel.SLACK]
|
|
|
|
|
TELEGRAM_INTEGRATION_REQUIRED_NOTIFICATION_CHANNELS = [UserNotificationPolicy.NotificationChannel.TELEGRAM]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class NotificationChannelAPIOptions(NotificationChannelOptions):
|
|
|
|
|
LABELS = {
|
|
|
|
|
UserNotificationPolicy.NotificationChannel.SLACK: "Slack mentions",
|
|
|
|
|
UserNotificationPolicy.NotificationChannel.SMS: "SMS \U00002709\U0001F4F2",
|
|
|
|
|
UserNotificationPolicy.NotificationChannel.PHONE_CALL: "Phone call \U0000260E",
|
|
|
|
|
UserNotificationPolicy.NotificationChannel.TELEGRAM: "Telegram \U0001F916",
|
|
|
|
|
}
|
|
|
|
|
LABELS.update(
|
|
|
|
|
{
|
|
|
|
|
getattr(UserNotificationPolicy.NotificationChannel, backend_id): b.label
|
|
|
|
|
for backend_id, b in get_messaging_backends()
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
SHORT_LABELS = {
|
|
|
|
|
UserNotificationPolicy.NotificationChannel.SLACK: "Slack",
|
|
|
|
|
UserNotificationPolicy.NotificationChannel.SMS: "SMS",
|
|
|
|
|
UserNotificationPolicy.NotificationChannel.PHONE_CALL: "\U0000260E",
|
|
|
|
|
UserNotificationPolicy.NotificationChannel.TELEGRAM: "Telegram",
|
|
|
|
|
}
|
|
|
|
|
SHORT_LABELS.update(
|
|
|
|
|
{
|
|
|
|
|
getattr(UserNotificationPolicy.NotificationChannel, backend_id): b.short_label
|
|
|
|
|
for backend_id, b in get_messaging_backends()
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class NotificationChannelPublicAPIOptions(NotificationChannelAPIOptions):
|
|
|
|
|
LABELS = {
|
|
|
|
|
UserNotificationPolicy.NotificationChannel.SLACK: "notify_by_slack",
|
|
|
|
|
UserNotificationPolicy.NotificationChannel.SMS: "notify_by_sms",
|
|
|
|
|
UserNotificationPolicy.NotificationChannel.PHONE_CALL: "notify_by_phone_call",
|
|
|
|
|
UserNotificationPolicy.NotificationChannel.TELEGRAM: "notify_by_telegram",
|
|
|
|
|
}
|
|
|
|
|
LABELS.update(
|
|
|
|
|
{
|
2023-03-01 16:32:15 +08:00
|
|
|
getattr(UserNotificationPolicy.NotificationChannel, backend_id): "notify_by_{}".format(b.slug)
|
2022-06-03 08:09:47 -06:00
|
|
|
for backend_id, b in get_messaging_backends()
|
|
|
|
|
}
|
|
|
|
|
)
|