# What this PR does Added last alert information and optimized the API call so it makes 10x less queries by: * prefetching chatops messages (based on @vadimkerr 's https://github.com/grafana/oncall/pull/4738) * using `enrich` from private api Previously: <img width="1102" alt="Screenshot 2024-09-24 at 4 47 00 PM" src="https://github.com/user-attachments/assets/84edb78e-257a-49cd-bc94-083dd8d043d7"> Now: <img width="1066" alt="Screenshot 2024-09-24 at 4 44 56 PM" src="https://github.com/user-attachments/assets/e7dfcc40-dce6-4a0d-9677-910aab2b4f17"> ## Which issue(s) this PR closes Related to [issue link here] <!-- *Note*: If you want the issue to be auto-closed once the PR is merged, change "Related to" to "Closes" in the line above. If you have more than one GitHub issue that this PR closes, be sure to preface each issue link with a [closing keyword](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/using-keywords-in-issues-and-pull-requests#linking-a-pull-request-to-an-issue). This ensures that the issue(s) are auto-closed once the PR has been merged. --> ## Checklist - [ ] Unit, integration, and e2e (if applicable) tests updated - [ ] Documentation added (or `pr:no public docs` PR label added if not required) - [ ] 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. --------- Co-authored-by: Vadim Stepanov <vadimkerr@gmail.com>
467 lines
18 KiB
Python
467 lines
18 KiB
Python
import json
|
|
import math
|
|
import typing
|
|
|
|
from django.core.exceptions import ObjectDoesNotExist
|
|
from django.db import models
|
|
from django.db.models import Count, Max, Q
|
|
from django.utils.functional import cached_property
|
|
from drf_spectacular.utils import extend_schema, inline_serializer
|
|
from rest_framework import serializers, status
|
|
from rest_framework.decorators import action
|
|
from rest_framework.exceptions import NotFound, Throttled
|
|
from rest_framework.request import Request
|
|
from rest_framework.response import Response
|
|
|
|
from apps.alerts.incident_appearance.templaters import (
|
|
AlertPhoneCallTemplater,
|
|
AlertSlackTemplater,
|
|
AlertSmsTemplater,
|
|
AlertTelegramTemplater,
|
|
AlertWebTemplater,
|
|
TemplateLoader,
|
|
)
|
|
from apps.alerts.models import Alert, AlertGroup
|
|
from apps.base.messaging import get_messaging_backends
|
|
from common.api_helpers.exceptions import BadRequest
|
|
from common.jinja_templater import apply_jinja_template
|
|
from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning
|
|
|
|
X_INSTANCE_CONTEXT = "X-Instance-Context"
|
|
|
|
X_GRAFANA_CONTEXT = "X-Grafana-Context"
|
|
|
|
|
|
class UpdateSerializerMixin:
|
|
serializer_class = None
|
|
update_serializer_class = None
|
|
|
|
def get_serializer_class(self):
|
|
if self.action in ["update", "partial_update"]:
|
|
return self.get_update_serializer_class()
|
|
return super().get_serializer_class()
|
|
|
|
def get_update_serializer_class(self):
|
|
assert self.update_serializer_class is not None, (
|
|
"'%s' should either include a `update_serializer_class` attribute,"
|
|
"or override the `get_update_serializer_class()` method." % self.__class__.__name__
|
|
)
|
|
return self.update_serializer_class
|
|
|
|
|
|
# Use this mixin at the very left of list of inherited SerializersMixins
|
|
class FilterSerializerMixin:
|
|
serializer_class = None
|
|
filter_serializer_class = None
|
|
|
|
def get_serializer_class(self):
|
|
is_filters_request = self.request.query_params.get("filters", "false") == "true"
|
|
if self.action in ["list"] and is_filters_request:
|
|
return self.get_filter_serializer_class()
|
|
else:
|
|
return super().get_serializer_class()
|
|
|
|
def get_filter_serializer_class(self):
|
|
assert self.filter_serializer_class is not None, (
|
|
"'%s' should either include a `filter_serializer_class` attribute,"
|
|
"or override the `get_update_serializer_class()` method." % self.__class__.__name__
|
|
)
|
|
return self.filter_serializer_class
|
|
|
|
|
|
# Use this mixin at the very left of list of inherited SerializersMixins
|
|
class ShortSerializerMixin:
|
|
serializer_class = None
|
|
short_serializer_class = None
|
|
|
|
def get_serializer_class(self):
|
|
is_short_request = self.request.query_params.get("short", "false") == "true"
|
|
if self.action in ["list"] and is_short_request:
|
|
return self.get_short_serializer_class()
|
|
else:
|
|
return super().get_serializer_class()
|
|
|
|
def get_short_serializer_class(self):
|
|
assert self.short_serializer_class is not None, (
|
|
"'%s' should either include a `short_serializer_class` attribute,"
|
|
"or override the `get_list_serializer_class()` method." % self.__class__.__name__
|
|
)
|
|
return self.short_serializer_class
|
|
|
|
|
|
class CreateSerializerMixin:
|
|
serializer_class = None
|
|
create_serializer_class = None
|
|
|
|
def get_serializer_class(self):
|
|
if self.action in ["create", "destroy"]:
|
|
return self.get_create_serializer_class()
|
|
return super().get_serializer_class()
|
|
|
|
def get_create_serializer_class(self):
|
|
assert self.create_serializer_class is not None, (
|
|
"'%s' should either include a `create_serializer_class` attribute,"
|
|
"or override the `get_update_serializer_class()` method." % self.__class__.__name__
|
|
)
|
|
return self.create_serializer_class
|
|
|
|
|
|
class ListSerializerMixin:
|
|
serializer_class = None
|
|
list_serializer_class = None
|
|
|
|
def get_serializer_class(self):
|
|
if self.action in ["retrieve", "list"]:
|
|
return self.get_list_serializer_class()
|
|
return super().get_serializer_class()
|
|
|
|
def get_list_serializer_class(self):
|
|
assert self.list_serializer_class is not None, (
|
|
"'%s' should either include a `list_serializer_class` attribute,"
|
|
"or override the `get_list_serializer_class()` method." % self.__class__.__name__
|
|
)
|
|
return self.list_serializer_class
|
|
|
|
|
|
class EagerLoadingMixin:
|
|
@classmethod
|
|
def setup_eager_loading(cls, queryset):
|
|
if hasattr(cls, "SELECT_RELATED"):
|
|
queryset = queryset.select_related(*cls.SELECT_RELATED)
|
|
if hasattr(cls, "PREFETCH_RELATED"):
|
|
queryset = queryset.prefetch_related(*cls.PREFETCH_RELATED)
|
|
return queryset
|
|
|
|
|
|
class RateLimitHeadersMixin:
|
|
# This mixin add RateLimit-Reset header to RateLimited response
|
|
def handle_exception(self, exc):
|
|
if isinstance(exc, Throttled):
|
|
if exc.wait is not None:
|
|
wait = f"{math.ceil(exc.wait)}"
|
|
else:
|
|
# if wait is none use maximum wait delay.
|
|
# This case can be reproduced if decrease ratelimit when self.history is not empty
|
|
wait = f"{350}"
|
|
self.headers["RateLimit-Reset"] = wait
|
|
return super().handle_exception(exc)
|
|
|
|
|
|
_MT = typing.TypeVar("_MT", bound=models.Model)
|
|
|
|
|
|
class PublicPrimaryKeyMixin(typing.Generic[_MT]):
|
|
def get_object(self, queryset_kwargs=None) -> _MT:
|
|
pk = self.kwargs["pk"]
|
|
if queryset_kwargs is None:
|
|
queryset_kwargs = {}
|
|
queryset = self.filter_queryset(self.get_queryset(**queryset_kwargs))
|
|
|
|
try:
|
|
obj = queryset.get(public_primary_key=pk)
|
|
except ObjectDoesNotExist:
|
|
raise NotFound
|
|
|
|
# May raise a permission denied
|
|
self.check_object_permissions(self.request, obj)
|
|
|
|
return obj
|
|
|
|
|
|
class TeamFilteringMixin:
|
|
"""
|
|
This mixin returns 403 and {"error_code": "wrong_team", "owner_team": {"name", "id", "email", "avatar_url"}}
|
|
in case a requested instance doesn't belong to user's current_team.
|
|
"""
|
|
|
|
TEAM_LOOKUP = "team"
|
|
|
|
@property
|
|
def available_teams_lookup_args(self):
|
|
"""
|
|
This property returns a list of Q objects that are used to filter instances by teams available to the user.
|
|
NOTE: use .distinct() after filtering by available teams as it may return duplicate instances.
|
|
"""
|
|
available_teams_lookup_args = []
|
|
if not self.request.user.is_admin:
|
|
available_teams_lookup_args = [
|
|
Q(**{f"{self.TEAM_LOOKUP}__users": self.request.user})
|
|
| Q(**{f"{self.TEAM_LOOKUP}__is_sharing_resources_to_all": True})
|
|
| Q(**{f"{self.TEAM_LOOKUP}__isnull": True})
|
|
]
|
|
return available_teams_lookup_args
|
|
|
|
def retrieve(self, request, *args, **kwargs):
|
|
try:
|
|
return super().retrieve(request, *args, **kwargs)
|
|
except NotFound:
|
|
queryset = self.filter_queryset(self.get_queryset(ignore_filtering_by_available_teams=True))
|
|
try:
|
|
queryset.get(public_primary_key=self.kwargs["pk"])
|
|
except ObjectDoesNotExist:
|
|
raise NotFound
|
|
return Response(data={"error_code": "wrong_team"}, status=status.HTTP_403_FORBIDDEN)
|
|
|
|
@staticmethod
|
|
def _getattr_with_related(obj, lookup):
|
|
entries = lookup.split("__")
|
|
|
|
result = getattr(obj, entries[0])
|
|
for entry in entries[1:]:
|
|
result = getattr(result, entry)
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def _remove_filter(lookup, queryset):
|
|
"""
|
|
This method removes a lookup from queryset.
|
|
E.g. for queryset = Instance.objects.filter(a=5, team=None), _remove_filter("team", queryset) will modify the
|
|
queryset to Instance.objects.filter(a=5).
|
|
"""
|
|
query = queryset.query
|
|
q = Q(**{lookup: None})
|
|
clause, _ = query._add_q(q, query.used_aliases)
|
|
|
|
def filter_lookups(child):
|
|
try:
|
|
return child.lhs.target != clause.children[0].lhs.target
|
|
except AttributeError:
|
|
return child.children[0].lhs.target != clause.children[0].lhs.target
|
|
|
|
query.where.children = list(filter(filter_lookups, query.where.children))
|
|
|
|
|
|
# TODO: move to separate file
|
|
SLACK = "slack"
|
|
WEB = "web"
|
|
PHONE_CALL = "phone_call"
|
|
SMS = "sms"
|
|
TELEGRAM = "telegram"
|
|
# templates with its own field in db, this concept replaced by messaging_backend_templates field
|
|
NOTIFICATION_CHANNEL_OPTIONS = [SLACK, WEB, PHONE_CALL, SMS, TELEGRAM]
|
|
|
|
TITLE = "title"
|
|
MESSAGE = "message"
|
|
IMAGE_URL = "image_url"
|
|
RESOLVE_CONDITION = "resolve_condition"
|
|
ACKNOWLEDGE_CONDITION = "acknowledge_condition"
|
|
GROUPING_ID = "grouping_id"
|
|
SOURCE_LINK = "source_link"
|
|
ROUTE = "route"
|
|
ALERT_GROUP_MULTI_LABEL = "alert_group_multi_label"
|
|
ALERT_GROUP_DYNAMIC_LABEL = "alert_group_dynamic_label"
|
|
|
|
NOTIFICATION_CHANNEL_TO_TEMPLATER_MAP = {
|
|
SLACK: AlertSlackTemplater,
|
|
WEB: AlertWebTemplater,
|
|
PHONE_CALL: AlertPhoneCallTemplater,
|
|
SMS: AlertSmsTemplater,
|
|
TELEGRAM: AlertTelegramTemplater,
|
|
}
|
|
|
|
# add additionally supported messaging backends
|
|
for _, backend in get_messaging_backends():
|
|
if backend.templater is not None:
|
|
NOTIFICATION_CHANNEL_OPTIONS.append(backend.slug)
|
|
NOTIFICATION_CHANNEL_TO_TEMPLATER_MAP[backend.slug] = backend.get_templater_class()
|
|
|
|
APPEARANCE_TEMPLATE_NAMES = [TITLE, MESSAGE, IMAGE_URL]
|
|
BEHAVIOUR_TEMPLATE_NAMES = [
|
|
RESOLVE_CONDITION,
|
|
ACKNOWLEDGE_CONDITION,
|
|
GROUPING_ID,
|
|
SOURCE_LINK,
|
|
ROUTE,
|
|
ALERT_GROUP_MULTI_LABEL,
|
|
ALERT_GROUP_DYNAMIC_LABEL,
|
|
]
|
|
ALL_TEMPLATE_NAMES = APPEARANCE_TEMPLATE_NAMES + BEHAVIOUR_TEMPLATE_NAMES
|
|
|
|
|
|
class PreviewTemplateException(Exception):
|
|
pass
|
|
|
|
|
|
class PreviewTemplateMixin:
|
|
@extend_schema(
|
|
description="Preview template",
|
|
request=inline_serializer(
|
|
name="PreviewTemplateRequest",
|
|
fields={
|
|
"template_body": serializers.CharField(required=False, allow_null=True),
|
|
"template_name": serializers.CharField(required=False, allow_null=True),
|
|
"payload": serializers.DictField(required=False, allow_null=True),
|
|
},
|
|
),
|
|
responses=inline_serializer(
|
|
name="PreviewTemplateResponse",
|
|
fields={
|
|
"preview": serializers.CharField(allow_null=True),
|
|
"is_valid_json_object": serializers.BooleanField(),
|
|
},
|
|
),
|
|
)
|
|
@action(methods=["post"], detail=True)
|
|
def preview_template(self, request, pk):
|
|
template_body = request.data.get("template_body", None)
|
|
template_name = request.data.get("template_name", None)
|
|
payload = request.data.get("payload", None)
|
|
|
|
try:
|
|
alert_to_template = self.get_alert_to_template(payload=payload)
|
|
if alert_to_template is None:
|
|
raise BadRequest(detail="Alert to preview does not exist")
|
|
except PreviewTemplateException as e:
|
|
raise BadRequest(detail=str(e))
|
|
|
|
if template_body is None or template_name is None:
|
|
response = {"preview": None}
|
|
return Response(response, status=status.HTTP_200_OK)
|
|
|
|
notification_channel, attr_name = self.parse_name_and_notification_channel(template_name)
|
|
if attr_name is None:
|
|
raise BadRequest(detail={"template_name": "Template name is missing"})
|
|
if attr_name not in ALL_TEMPLATE_NAMES:
|
|
raise BadRequest(detail={"template_name": "Unknown template name"})
|
|
if attr_name in APPEARANCE_TEMPLATE_NAMES:
|
|
if notification_channel is None:
|
|
raise BadRequest(detail={"notification_channel": "notification_channel is required"})
|
|
if notification_channel not in NOTIFICATION_CHANNEL_OPTIONS:
|
|
raise BadRequest(detail={"notification_channel": "Unknown notification_channel"})
|
|
|
|
if attr_name in APPEARANCE_TEMPLATE_NAMES:
|
|
|
|
class PreviewTemplateLoader(TemplateLoader):
|
|
def get_attr_template(self, attr, alert_receive_channel, render_for=None):
|
|
if attr == attr_name and render_for == notification_channel:
|
|
return template_body
|
|
else:
|
|
return super().get_attr_template(attr, alert_receive_channel, render_for)
|
|
|
|
templater_cls = NOTIFICATION_CHANNEL_TO_TEMPLATER_MAP[notification_channel]
|
|
templater = templater_cls(alert_to_template)
|
|
templater.template_manager = PreviewTemplateLoader()
|
|
try:
|
|
templated_alert = templater.render()
|
|
except (JinjaTemplateError, JinjaTemplateWarning) as e:
|
|
return Response({"preview": e.fallback_message}, status.HTTP_200_OK)
|
|
|
|
templated_attr = getattr(templated_alert, attr_name)
|
|
|
|
elif attr_name in BEHAVIOUR_TEMPLATE_NAMES:
|
|
try:
|
|
templated_attr = apply_jinja_template(template_body, payload=alert_to_template.raw_request_data)
|
|
except (JinjaTemplateError, JinjaTemplateWarning) as e:
|
|
return Response({"preview": e.fallback_message}, status.HTTP_200_OK)
|
|
else:
|
|
templated_attr = None
|
|
response = {"preview": templated_attr, "is_valid_json_object": self.is_valid_json_object(templated_attr)}
|
|
return Response(response, status=status.HTTP_200_OK)
|
|
|
|
def is_valid_json_object(self, json_str):
|
|
try:
|
|
return isinstance(json.loads(json_str), dict)
|
|
except ValueError:
|
|
return False
|
|
|
|
def get_alert_to_template(self, payload=None):
|
|
raise NotImplementedError
|
|
|
|
@staticmethod
|
|
def parse_name_and_notification_channel(template_param):
|
|
template_param = template_param.replace("_template", "")
|
|
attr_name = None
|
|
destination = None
|
|
if template_param.startswith(tuple(BEHAVIOUR_TEMPLATE_NAMES)):
|
|
attr_name = template_param
|
|
elif template_param.startswith(tuple(NOTIFICATION_CHANNEL_OPTIONS)):
|
|
for notification_channel in NOTIFICATION_CHANNEL_OPTIONS:
|
|
if template_param.startswith(notification_channel):
|
|
destination = notification_channel
|
|
attr_name = template_param[len(destination) + 1 :]
|
|
break
|
|
return destination, attr_name
|
|
|
|
|
|
class GrafanaContext(typing.TypedDict):
|
|
IsAnonymous: bool
|
|
|
|
|
|
class InstanceContext(typing.TypedDict):
|
|
stack_id: int
|
|
org_id: int
|
|
grafana_token: str
|
|
|
|
|
|
class GrafanaHeadersMixin:
|
|
request: Request
|
|
|
|
@cached_property
|
|
def grafana_context(self) -> GrafanaContext:
|
|
if X_GRAFANA_CONTEXT in self.request.headers:
|
|
grafana_context: GrafanaContext = json.loads(self.request.headers[X_GRAFANA_CONTEXT])
|
|
else:
|
|
grafana_context = None
|
|
return grafana_context
|
|
|
|
@cached_property
|
|
def instance_context(self) -> InstanceContext:
|
|
if X_INSTANCE_CONTEXT in self.request.headers:
|
|
instance_context: InstanceContext = json.loads(self.request.headers[X_INSTANCE_CONTEXT])
|
|
else:
|
|
instance_context = None
|
|
return instance_context
|
|
|
|
|
|
class AlertGroupEnrichingMixin:
|
|
def paginate_queryset(self, queryset):
|
|
"""
|
|
All SQL joins (select_related and prefetch_related) will be performed AFTER pagination, so it only joins tables
|
|
for one page of alert groups, not the whole table.
|
|
"""
|
|
alert_groups = super().paginate_queryset(queryset.only("id"))
|
|
alert_groups = self.enrich(alert_groups)
|
|
return alert_groups
|
|
|
|
def enrich(self, alert_groups: typing.List[AlertGroup]) -> typing.List[AlertGroup]:
|
|
"""
|
|
This method performs select_related and prefetch_related (using setup_eager_loading) as well as in-memory joins
|
|
to add additional info like alert_count and last_alert for every alert group efficiently.
|
|
We need the last_alert because it's used by AlertGroupWebRenderer.
|
|
"""
|
|
|
|
# enrich alert groups with select_related and prefetch_related
|
|
alert_group_pks = [alert_group.pk for alert_group in alert_groups]
|
|
queryset = AlertGroup.objects.filter(pk__in=alert_group_pks).order_by("-started_at")
|
|
|
|
queryset = self.get_serializer_class().setup_eager_loading(queryset)
|
|
alert_groups = list(queryset)
|
|
|
|
# get info on alerts count and last alert ID for every alert group
|
|
alerts_info = (
|
|
Alert.objects.values("group_id")
|
|
.filter(group_id__in=alert_group_pks)
|
|
.annotate(alerts_count=Count("group_id"), last_alert_id=Max("id"))
|
|
)
|
|
alerts_info_map = {info["group_id"]: info for info in alerts_info}
|
|
|
|
# fetch last alerts for every alert group
|
|
last_alert_ids = [info["last_alert_id"] for info in alerts_info_map.values()]
|
|
last_alerts = Alert.objects.filter(pk__in=last_alert_ids)
|
|
for alert in last_alerts:
|
|
# link group back to alert
|
|
alert.group = [alert_group for alert_group in alert_groups if alert_group.pk == alert.group_id][0]
|
|
alerts_info_map[alert.group_id].update({"last_alert": alert})
|
|
|
|
# add additional "alerts_count" and "last_alert" fields to every alert group
|
|
for alert_group in alert_groups:
|
|
try:
|
|
alert_group.last_alert = alerts_info_map[alert_group.pk]["last_alert"]
|
|
alert_group.alerts_count = alerts_info_map[alert_group.pk]["alerts_count"]
|
|
except KeyError:
|
|
# alert group has no alerts
|
|
alert_group.last_alert = None
|
|
alert_group.alerts_count = 0
|
|
|
|
return alert_groups
|