oncall-engine/engine/apps/user_management/models/organization.py
Joey Orlando 1bd30b3cf8
chore: remove deprecated AlertGroupPostMortem model + recently refactored/deprecated slack channel related columns (#5240)
# What this PR does

- `AlertGroupPostMortem` has no references in the codebase.. I stumbled
across it while working on https://github.com/grafana/oncall/pull/5224
and decided to just remove it
- Removing old Slack channel related `VARCHAR` columns; these were
refactored to foreign key references to `slack_slackchannel` table in
following PRs:
  - https://github.com/grafana/oncall/pull/5224
  - https://github.com/grafana/oncall/pull/5199
  - https://github.com/grafana/oncall/pull/5191 

## 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] Added the relevant release notes label (see labels prefixed w/
`release:`). These labels dictate how your PR will
    show up in the autogenerated release notes.
2024-11-19 19:23:48 +00:00

400 lines
15 KiB
Python

import logging
import typing
import uuid
from django.conf import settings
from django.core.validators import MinLengthValidator
from django.db import models
from django.db.models import Count, JSONField, Q
from django.utils import timezone
from mirage import fields as mirage_fields
from apps.alerts.models import MaintainableObject
from apps.chatops_proxy.utils import (
register_oncall_tenant_with_async_fallback,
unlink_slack_team,
unregister_oncall_tenant,
)
from apps.grafana_plugin.ui_url_builder import UIURLBuilder
from apps.user_management.subscription_strategy import FreePublicBetaSubscriptionStrategy
from apps.user_management.types import AlertGroupTableColumn
from common.constants.plugin_ids import PluginID
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
if typing.TYPE_CHECKING:
from django.db.models.manager import RelatedManager
from apps.alerts.models import AlertReceiveChannel
from apps.auth_token.models import (
ApiAuthToken,
PluginAuthToken,
ScheduleExportAuthToken,
UserScheduleExportAuthToken,
)
from apps.mobile_app.models import MobileAppAuthToken
from apps.schedules.models import CustomOnCallShift, OnCallSchedule
from apps.slack.models import SlackChannel, SlackTeamIdentity
from apps.telegram.models import TelegramToOrganizationConnector
from apps.user_management.models import Region, Team, User
logger = logging.getLogger(__name__)
def generate_public_primary_key_for_organization():
prefix = "O"
new_public_primary_key = generate_public_primary_key(prefix)
failure_counter = 0
while Organization.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="Organization"
)
failure_counter += 1
return new_public_primary_key
class ProvisionedPlugin(typing.TypedDict):
stackId: int
orgId: int
onCallToken: str
license: str
class OrganizationQuerySet(models.QuerySet):
def create(self, **kwargs):
instance = super().create(**kwargs)
if settings.FEATURE_MULTIREGION_ENABLED:
register_oncall_tenant_with_async_fallback(instance)
return instance
def delete(self):
# Be careful with deleting via queryset - it doesn't delete chatops-proxy connectors.
self.update(deleted_at=timezone.now())
def hard_delete(self):
super().delete()
class OrganizationManager(models.Manager):
def get_queryset(self):
return OrganizationQuerySet(self.model, using=self._db).filter(deleted_at__isnull=True)
# TODO: in a subsequent PR, remove the inheritance from MaintainableObject (plus generate the database migration file)
# this will remove the maintenance related columns that're no longer used on the organization object
# class Organization(models.Model):
class Organization(MaintainableObject):
alert_receive_channels: "RelatedManager['AlertReceiveChannel']"
auth_tokens: "RelatedManager['ApiAuthToken']"
custom_on_call_shifts: "RelatedManager['CustomOnCallShift']"
default_slack_channel: typing.Optional["SlackChannel"]
migration_destination: typing.Optional["Region"]
mobile_app_auth_tokens: "RelatedManager['MobileAppAuthToken']"
oncall_schedules: "RelatedManager['OnCallSchedule']"
plugin_auth_tokens: "RelatedManager['PluginAuthToken']"
schedule_export_token: "RelatedManager['ScheduleExportAuthToken']"
slack_team_identity: typing.Optional["SlackTeamIdentity"]
teams: "RelatedManager['Team']"
telegram_channel: "RelatedManager['TelegramToOrganizationConnector']"
user_schedule_export_token: "RelatedManager['UserScheduleExportAuthToken']"
users: "RelatedManager['User']"
objects: models.Manager["Organization"] = OrganizationManager()
objects_with_deleted = models.Manager()
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_organization,
)
stack_id = models.PositiveIntegerField()
org_id = models.PositiveIntegerField()
stack_slug = models.CharField(max_length=300)
org_slug = models.CharField(max_length=300)
org_title = models.CharField(max_length=300)
region_slug = models.CharField(max_length=300, null=True, default=None)
migration_destination = models.ForeignKey(
to="user_management.Region",
to_field="slug",
db_column="migration_destination_slug",
on_delete=models.SET_NULL,
related_name="regions",
default=None,
null=True,
)
cluster_slug = models.CharField(max_length=300, null=True, default=None)
grafana_url = models.URLField()
api_token = mirage_fields.EncryptedCharField(max_length=300)
(
API_TOKEN_STATUS_PENDING,
API_TOKEN_STATUS_OK,
API_TOKEN_STATUS_FAILED,
) = range(3)
API_TOKEN_STATUS_CHOICES = (
(API_TOKEN_STATUS_PENDING, "API Token Status Pending"),
(API_TOKEN_STATUS_OK, "API Token Status Ok"),
(API_TOKEN_STATUS_FAILED, "API Token Status Failed"),
)
api_token_status = models.IntegerField(
choices=API_TOKEN_STATUS_CHOICES,
default=API_TOKEN_STATUS_PENDING,
)
gcom_token = mirage_fields.EncryptedCharField(max_length=300, null=True, default=None)
gcom_token_org_last_time_synced = models.DateTimeField(null=True, default=None)
gcom_org_contract_type = models.CharField(max_length=300, null=True, default=None)
gcom_org_irm_sku_subscription_start_date = models.DateTimeField(null=True, default=None)
gcom_org_oldest_admin_with_billing_privileges_user_id = models.PositiveIntegerField(null=True)
last_time_synced = models.DateTimeField(null=True, default=None)
is_resolution_note_required = models.BooleanField(default=False)
# TODO: this field is specific to slack and will be moved to a different model
slack_team_identity = models.ForeignKey(
"slack.SlackTeamIdentity", on_delete=models.PROTECT, null=True, default=None, related_name="organizations"
)
default_slack_channel = models.ForeignKey(
"slack.SlackChannel",
null=True,
default=None,
on_delete=models.SET_NULL,
related_name="+",
)
# uuid used to unuqie identify organization in different clusters
uuid = models.UUIDField(default=uuid.uuid4, editable=False)
deleted_at = models.DateTimeField(null=True)
# Organization Settings configured from slack
(
ACKNOWLEDGE_REMIND_NEVER,
ACKNOWLEDGE_REMIND_1H,
ACKNOWLEDGE_REMIND_3H,
ACKNOWLEDGE_REMIND_5H,
ACKNOWLEDGE_REMIND_10H,
) = range(5)
ACKNOWLEDGE_REMIND_CHOICES = (
(ACKNOWLEDGE_REMIND_NEVER, "Never remind"),
(ACKNOWLEDGE_REMIND_1H, "Remind every 1 hour"),
(ACKNOWLEDGE_REMIND_3H, "Remind every 3 hours"),
(ACKNOWLEDGE_REMIND_5H, "Remind every 5 hours"),
(ACKNOWLEDGE_REMIND_10H, "Remind every 10 hours"),
)
ACKNOWLEDGE_REMIND_DELAY = {
ACKNOWLEDGE_REMIND_NEVER: 0,
ACKNOWLEDGE_REMIND_1H: 3600,
ACKNOWLEDGE_REMIND_3H: 10800,
ACKNOWLEDGE_REMIND_5H: 18000,
ACKNOWLEDGE_REMIND_10H: 36000,
}
acknowledge_remind_timeout = models.IntegerField(
choices=ACKNOWLEDGE_REMIND_CHOICES,
default=ACKNOWLEDGE_REMIND_NEVER,
)
(
UNACKNOWLEDGE_TIMEOUT_NEVER,
UNACKNOWLEDGE_TIMEOUT_5MIN,
UNACKNOWLEDGE_TIMEOUT_15MIN,
UNACKNOWLEDGE_TIMEOUT_30MIN,
UNACKNOWLEDGE_TIMEOUT_45MIN,
) = range(5)
UNACKNOWLEDGE_TIMEOUT_CHOICES = (
(UNACKNOWLEDGE_TIMEOUT_NEVER, "and never unack"),
(UNACKNOWLEDGE_TIMEOUT_5MIN, "and unack in 5 min if no response"),
(UNACKNOWLEDGE_TIMEOUT_15MIN, "and unack in 15 min if no response"),
(UNACKNOWLEDGE_TIMEOUT_30MIN, "and unack in 30 min if no response"),
(UNACKNOWLEDGE_TIMEOUT_45MIN, "and unack in 45 min if no response"),
)
UNACKNOWLEDGE_TIMEOUT_DELAY = {
UNACKNOWLEDGE_TIMEOUT_NEVER: 0,
UNACKNOWLEDGE_TIMEOUT_5MIN: 300,
UNACKNOWLEDGE_TIMEOUT_15MIN: 900,
UNACKNOWLEDGE_TIMEOUT_30MIN: 1800,
UNACKNOWLEDGE_TIMEOUT_45MIN: 2700,
}
unacknowledge_timeout = models.IntegerField(
choices=UNACKNOWLEDGE_TIMEOUT_CHOICES,
default=UNACKNOWLEDGE_TIMEOUT_NEVER,
)
# This field is used to calculate public suggestions time
# Not sure if it is needed
datetime = models.DateTimeField(auto_now_add=True)
FREE_PUBLIC_BETA_PRICING = 0
PRICING_CHOICES = ((FREE_PUBLIC_BETA_PRICING, "Free public beta"),)
pricing_version = models.PositiveIntegerField(choices=PRICING_CHOICES, default=FREE_PUBLIC_BETA_PRICING)
is_rbac_permissions_enabled = models.BooleanField(default=False)
is_grafana_incident_enabled = models.BooleanField(default=False)
is_grafana_labels_enabled = models.BooleanField(default=False, null=True)
is_grafana_irm_enabled = models.BooleanField(default=False, null=True)
alert_group_table_columns: list[AlertGroupTableColumn] | None = JSONField(default=None, null=True)
grafana_incident_backend_url = models.CharField(max_length=300, null=True, default=None)
direct_paging_prefer_important_policy = models.BooleanField(default=False, null=True)
class Meta:
unique_together = ("stack_id", "org_id")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.subscription_strategy = self._get_subscription_strategy()
def delete(self):
if settings.FEATURE_MULTIREGION_ENABLED:
unregister_oncall_tenant(str(self.uuid), settings.ONCALL_BACKEND_REGION)
if self.slack_team_identity and not settings.UNIFIED_SLACK_APP_ENABLED:
unlink_slack_team(str(self.uuid), self.slack_team_identity.slack_id)
self.deleted_at = timezone.now()
self.save(update_fields=["deleted_at"])
def hard_delete(self):
super().delete()
def _get_subscription_strategy(self):
if self.pricing_version == self.FREE_PUBLIC_BETA_PRICING:
return FreePublicBetaSubscriptionStrategy(self)
def provision_plugin(self) -> ProvisionedPlugin:
from apps.auth_token.models import PluginAuthToken
_, token = PluginAuthToken.create_auth_token(organization=self)
return {
"stackId": self.stack_id,
"orgId": self.org_id,
"onCallToken": token,
"license": settings.LICENSE,
}
def revoke_plugin(self):
from apps.auth_token.models import PluginAuthToken
PluginAuthToken.objects.filter(organization=self).delete()
"""
Following methods:
phone_calls_left, sms_left, emails_left
serve for calculating notifications' limits and composed from self.subscription_strategy.
"""
def phone_calls_left(self, user):
return self.subscription_strategy.phone_calls_left(user)
def sms_left(self, user):
return self.subscription_strategy.sms_left(user)
# todo: manage backend specific limits in messaging backend
def emails_left(self, user):
return self.subscription_strategy.emails_left(user)
def update_alert_group_table_columns(self, columns: typing.List[AlertGroupTableColumn]) -> None:
if columns != self.alert_group_table_columns:
self.alert_group_table_columns = columns
self.save(update_fields=["alert_group_table_columns"])
def set_default_slack_channel(self, slack_channel: "SlackChannel", user: "User") -> None:
if self.default_slack_channel != slack_channel:
old_default_slack_channel = self.default_slack_channel
old_channel_name = old_default_slack_channel.name if old_default_slack_channel else None
self.default_slack_channel = slack_channel
self.save(update_fields=["default_slack_channel"])
write_chatops_insight_log(
author=user,
event_name=ChatOpsEvent.DEFAULT_CHANNEL_CHANGED,
chatops_type=ChatOpsTypePlug.SLACK.value,
prev_channel=old_channel_name,
new_channel=slack_channel.name,
)
def get_notifiable_direct_paging_integrations(self) -> "RelatedManager['AlertReceiveChannel']":
"""
in layman's terms, this filters down an organization's integrations to ones which meet the following criterias:
- the integration is a direct paging integration
AND at-least one of the following conditions are true for the integration:
- have more than one channel filter associated with it
- OR the organization has either Slack or Telegram configured (as the direct paging integration
would automatically be configured to be notified via these channel(s))
- OR the default channel filter associated with the integration has an escalation chain associated with it
- OR the default channel filter associated with the integration is contactable via a custom
messaging backend
"""
from apps.alerts.models import AlertReceiveChannel
return (
self.alert_receive_channels.annotate(
num_channel_filters=Count("channel_filters"),
# used to determine if the organization has telegram configured
num_org_telegram_channels=Count("organization__telegram_channel"),
)
.filter(
Q(num_channel_filters__gt=1)
| (Q(organization__slack_team_identity__isnull=False) | Q(num_org_telegram_channels__gt=0))
| Q(channel_filters__is_default=True, channel_filters__escalation_chain__isnull=False)
| Q(channel_filters__is_default=True, channel_filters__notification_backends__isnull=False),
integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING,
)
.distinct()
)
@property
def default_slack_channel_slack_id(self) -> typing.Optional[str]:
return self.default_slack_channel.slack_id if self.default_slack_channel else None
@property
def web_link_with_uuid(self):
"""
It's a workaround to pass some unique identifier to the oncall gateway while proxying telegram requests
"""
return UIURLBuilder(self).home(f"?oncall-uuid={self.uuid}")
@property
def active_ui_plugin_id(self) -> str:
"""
If `is_grafana_irm_enabled` is True, this will be IRM, otherwise OnCall
"""
return PluginID.IRM if self.is_grafana_irm_enabled else PluginID.ONCALL
@classmethod
def __str__(self):
return f"{self.pk}: {self.org_title}"
# Insight logs
@property
def insight_logs_type_verbal(self):
return "organization"
@property
def insight_logs_verbal(self):
return self.org_title
@property
def insight_logs_serialized(self):
return {
"name": self.org_title,
"is_resolution_note_required": self.is_resolution_note_required,
}
@property
def insight_logs_metadata(self):
return {}
@property
def is_moved(self):
return self.migration_destination_id is not None