oncall-engine/engine/apps/api/views/alert_group.py

888 lines
36 KiB
Python

from datetime import timedelta
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Q
from django.utils import timezone
from django_filters import rest_framework as filters
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework import mixins, serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound
from rest_framework.filters import SearchFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.alerts.constants import ActionSource
from apps.alerts.models import AlertGroup, AlertReceiveChannel, EscalationChain, ResolutionNote
from apps.alerts.paging import unpage_user
from apps.alerts.tasks import delete_alert_group, send_update_resolution_note_signal
from apps.api.errors import AlertGroupAPIError
from apps.api.label_filtering import parse_label_query
from apps.api.permissions import RBACPermission
from apps.api.serializers.alert_group import AlertGroupListSerializer, AlertGroupSerializer
from apps.api.serializers.alert_group_escalation_snapshot import AlertGroupEscalationSnapshotAPISerializer
from apps.api.serializers.team import TeamSerializer
from apps.auth_token.auth import PluginAuthentication
from apps.base.models.user_notification_policy_log_record import UserNotificationPolicyLogRecord
from apps.labels.utils import is_labels_feature_enabled
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
from apps.user_management.models import Team, User
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import (
NO_TEAM_VALUE,
DateRangeFilterMixin,
ModelFieldFilterMixin,
MultipleChoiceCharFilter,
)
from common.api_helpers.mixins import (
AlertGroupEnrichingMixin,
PreviewTemplateMixin,
PublicPrimaryKeyMixin,
TeamFilteringMixin,
)
from common.api_helpers.paginators import AlertGroupCursorPaginator
def get_integration_queryset(request):
if request is None:
return AlertReceiveChannel.objects.none()
return AlertReceiveChannel.objects_with_maintenance.filter(organization=request.user.organization)
def get_escalation_chain_queryset(request):
if request is None:
return EscalationChain.objects.none()
return EscalationChain.objects.filter(organization=request.user.organization)
def get_user_queryset(request):
if request is None:
return User.objects.none()
return User.objects.filter(organization=request.user.organization).distinct()
class AlertGroupFilter(DateRangeFilterMixin, ModelFieldFilterMixin, filters.FilterSet):
"""
Examples of possible date formats here https://docs.djangoproject.com/en/1.9/ref/settings/#datetime-input-formats
"""
FILTER_BY_INVOLVED_USERS_ALERT_GROUPS_CUTOFF = 1000
is_root = filters.BooleanFilter(field_name="root_alert_group", lookup_expr="isnull")
status = filters.MultipleChoiceFilter(choices=AlertGroup.STATUS_CHOICES, method="filter_status")
started_at = filters.CharFilter(
field_name="started_at",
method=DateRangeFilterMixin.filter_date_range.__name__,
)
resolved_at = filters.CharFilter(
field_name="resolved_at",
method=DateRangeFilterMixin.filter_date_range.__name__,
)
integration = MultipleChoiceCharFilter(
field_name="channel",
queryset=get_integration_queryset,
to_field_name="public_primary_key",
method=ModelFieldFilterMixin.filter_model_field.__name__,
)
escalation_chain = MultipleChoiceCharFilter(
field_name="channel_filter__escalation_chain",
queryset=get_escalation_chain_queryset,
to_field_name="public_primary_key",
method=ModelFieldFilterMixin.filter_model_field.__name__,
)
resolved_by = MultipleChoiceCharFilter(
field_name="resolved_by_user",
queryset=get_user_queryset,
to_field_name="public_primary_key",
method=ModelFieldFilterMixin.filter_model_field.__name__,
)
acknowledged_by = MultipleChoiceCharFilter(
field_name="acknowledged_by_user",
queryset=get_user_queryset,
to_field_name="public_primary_key",
method=ModelFieldFilterMixin.filter_model_field.__name__,
)
silenced_by = MultipleChoiceCharFilter(
field_name="silenced_by_user",
queryset=get_user_queryset,
to_field_name="public_primary_key",
method=ModelFieldFilterMixin.filter_model_field.__name__,
)
invitees_are = MultipleChoiceCharFilter(
queryset=get_user_queryset, to_field_name="public_primary_key", method="filter_invitees_are"
)
involved_users_are = MultipleChoiceCharFilter(
queryset=get_user_queryset, to_field_name="public_primary_key", method="filter_by_involved_users"
)
with_resolution_note = filters.BooleanFilter(method="filter_with_resolution_note")
mine = filters.BooleanFilter(method="filter_mine")
def filter_status(self, queryset, name, value):
if not value:
return queryset
try:
statuses = list(map(int, value))
except ValueError:
raise BadRequest(detail="Invalid status value")
filters = {}
q_objects = Q()
if AlertGroup.NEW in statuses:
filters["new"] = AlertGroup.get_new_state_filter()
if AlertGroup.SILENCED in statuses:
filters["silenced"] = AlertGroup.get_silenced_state_filter()
if AlertGroup.ACKNOWLEDGED in statuses:
filters["acknowledged"] = AlertGroup.get_acknowledged_state_filter()
if AlertGroup.RESOLVED in statuses:
filters["resolved"] = AlertGroup.get_resolved_state_filter()
for item in filters:
q_objects |= filters[item]
queryset = queryset.filter(q_objects)
return queryset
def filter_invitees_are(self, queryset, name, value):
users = value
if not users:
return queryset
queryset = queryset.filter(log_records__author__in=users).distinct()
return queryset
def filter_by_involved_users(self, queryset, name, value):
users = value
if not users:
return queryset
# This is expensive to filter all alert groups with involved users,
# so we limit the number of alert groups to filter by the last 1000 for the given user(s)
alert_group_notified_users_ids = list(
UserNotificationPolicyLogRecord.objects.filter(author__in=users)
.order_by("-alert_group_id")
.values_list("alert_group_id", flat=True)
.distinct()[: self.FILTER_BY_INVOLVED_USERS_ALERT_GROUPS_CUTOFF]
)
queryset = queryset.filter(
# user was notified
Q(id__in=alert_group_notified_users_ids)
|
# or interacted with the alert group
Q(acknowledged_by_user__in=users)
| Q(resolved_by_user__in=users)
| Q(silenced_by_user__in=users)
).distinct()
return queryset
def filter_mine(self, queryset, name, value):
if value:
return self.filter_by_involved_users(queryset, "users", [self.request.user])
return queryset
def filter_with_resolution_note(self, queryset, name, value):
if value is True:
queryset = queryset.filter(Q(resolution_notes__isnull=False, resolution_notes__deleted_at=None)).distinct()
elif value is False:
queryset = queryset.filter(
Q(resolution_notes__isnull=True) | ~Q(resolution_notes__deleted_at=None)
).distinct()
return queryset
class AlertGroupTeamFilteringMixin(TeamFilteringMixin):
TEAM_LOOKUP = "team"
def retrieve(self, request, *args, **kwargs):
try:
return super().retrieve(request, *args, **kwargs)
except NotFound:
alert_receive_channels_ids = list(
AlertReceiveChannel.objects.filter(
organization_id=self.request.auth.organization.id,
).values_list("id", flat=True)
)
queryset = AlertGroup.objects.filter(
channel__in=alert_receive_channels_ids,
).only("public_primary_key")
try:
obj = queryset.get(public_primary_key=self.kwargs["pk"])
except ObjectDoesNotExist:
raise NotFound
obj_team = self._getattr_with_related(obj, self.TEAM_LOOKUP)
if obj_team is None or obj_team in self.request.user.teams.all():
if obj_team is None:
obj_team = Team(public_primary_key=None, name="General", email=None, avatar_url=None)
return Response(
data={"error_code": "wrong_team", "owner_team": TeamSerializer(obj_team).data},
status=status.HTTP_403_FORBIDDEN,
)
return Response(data={"error_code": "wrong_team"}, status=status.HTTP_403_FORBIDDEN)
class AlertGroupSearchFilter(SearchFilter):
def filter_queryset(self, request, queryset, view):
search_fields = self.get_search_fields(view, request)
search_terms = self.get_search_terms(request)
if not search_fields or not search_terms:
return queryset
if settings.FEATURE_ALERT_GROUP_SEARCH_CUTOFF_DAYS:
started_at = request.query_params.get("started_at")
end = DateRangeFilterMixin.parse_custom_datetime_range(started_at)[1] if started_at else timezone.now()
queryset = queryset.filter(
started_at__gte=end - timedelta(days=settings.FEATURE_ALERT_GROUP_SEARCH_CUTOFF_DAYS)
)
return super().filter_queryset(request, queryset, view)
def get_search_fields(self, view, request):
return (
["=public_primary_key", "=inside_organization_number", "web_title_cache"]
if settings.FEATURE_ALERT_GROUP_SEARCH_ENABLED
else []
)
class AlertGroupView(
AlertGroupEnrichingMixin,
PreviewTemplateMixin,
AlertGroupTeamFilteringMixin,
PublicPrimaryKeyMixin[AlertGroup],
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""
Internal API endpoints for alert groups.
"""
authentication_classes = (
MobileAppAuthTokenAuthentication,
PluginAuthentication,
)
permission_classes = (IsAuthenticated, RBACPermission)
rbac_permissions = {
"metadata": [RBACPermission.Permissions.ALERT_GROUPS_READ],
"list": [RBACPermission.Permissions.ALERT_GROUPS_READ],
"retrieve": [RBACPermission.Permissions.ALERT_GROUPS_READ],
"stats": [RBACPermission.Permissions.ALERT_GROUPS_READ],
"filters": [RBACPermission.Permissions.ALERT_GROUPS_READ],
"silence_options": [RBACPermission.Permissions.ALERT_GROUPS_READ],
"bulk_action_options": [RBACPermission.Permissions.ALERT_GROUPS_READ],
"destroy": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
"acknowledge": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
"unacknowledge": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
"resolve": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
"unresolve": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
"attach": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
"unattach": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
"silence": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
"unsilence": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
"unpage_user": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
"bulk_action": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
"preview_template": [RBACPermission.Permissions.INTEGRATIONS_TEST],
"escalation_snapshot": [RBACPermission.Permissions.ALERT_GROUPS_READ],
}
queryset = AlertGroup.objects.none() # needed for drf-spectacular introspection
serializer_class = AlertGroupSerializer
pagination_class = AlertGroupCursorPaginator
filter_backends = [AlertGroupSearchFilter, filters.DjangoFilterBackend]
filterset_class = AlertGroupFilter
def get_serializer_class(self):
if self.action == "list":
return AlertGroupListSerializer
return super().get_serializer_class()
def get_queryset(self, ignore_filtering_by_available_teams=False):
# no select_related or prefetch_related is used at this point, it will be done on paginate_queryset.
alert_receive_channels_qs = AlertReceiveChannel.objects_with_deleted.filter(
organization_id=self.request.auth.organization.id
)
if not ignore_filtering_by_available_teams:
alert_receive_channels_qs = alert_receive_channels_qs.filter(*self.available_teams_lookup_args)
# Filter by team(s). Since we really filter teams from integrations, this is not an AlertGroup model filter.
# This is based on the common.api_helpers.ByTeamModelFieldFilterMixin implementation
team_values = self.request.query_params.getlist("team", [])
if team_values:
null_team_lookup = Q(team__isnull=True) if NO_TEAM_VALUE in team_values else None
teams_lookup = Q(team__public_primary_key__in=[ppk for ppk in team_values if ppk != NO_TEAM_VALUE])
if null_team_lookup:
teams_lookup = teams_lookup | null_team_lookup
alert_receive_channels_qs = alert_receive_channels_qs.filter(teams_lookup)
alert_receive_channels_ids = list(alert_receive_channels_qs.values_list("id", flat=True))
queryset = AlertGroup.objects.filter(channel__in=alert_receive_channels_ids)
if self.action in ("list", "stats") and not self.request.query_params.get("started_at"):
queryset = queryset.filter(started_at__gte=timezone.now() - timezone.timedelta(days=30))
if self.action in ("list", "stats") and settings.ALERT_GROUPS_DISABLE_PREFER_ORDERING_INDEX:
# workaround related to MySQL "ORDER BY LIMIT Query Optimizer Bug"
# read more: https://hackmysql.com/infamous-order-by-limit-query-optimizer-bug/
from django_mysql.models import add_QuerySetMixin
queryset = add_QuerySetMixin(queryset)
queryset = queryset.force_index("alert_group_list_index")
# Filter by labels. Since alert group labels are "static" filter by names, not IDs.
label_query = self.request.query_params.getlist("label", [])
kv_pairs = parse_label_query(label_query)
for key, value in kv_pairs:
# Utilize (organization, key_name, value_name, alert_group) index on AlertGroupAssociatedLabel
queryset = queryset.filter(
labels__organization=self.request.auth.organization,
labels__key_name=key,
labels__value_name=value,
)
return queryset
def get_object(self):
obj = super().get_object()
obj = self.enrich([obj])[0]
return obj
def retrieve(self, request, *args, **kwargs):
"""Return alert group details.
It is worth mentioning that `render_after_resolve_report_json` property will return a list
of log entries including actions involving the alert group, notifications triggered for a user
and resolution notes updates.
A few additional notes about the possible values for each key in the logs:
- `time`: humanized time delta respect to now when the action took place
- `action`: human-readable description of the action
- `realm`: resource involved in the action; one of three possible values:
`alert_group`, `user_notification`, `resolution_note`
- `type`: integer value indicating the type of action (see below)
- `created_at`: timestamp corresponding to when the action happened
- `author`: details about the user performing the action
Possible `type` values depending on the realm value:
For `alert_group`:
- 0: Acknowledged
- 1: Unacknowledged
- 2: Invite
- 3: Stop invitation
- 4: Re-invite
- 5: Escalation triggered
- 6: Invitation triggered
- 7: Silenced
- 8: Attached
- 9: Unattached
- 10: Custom button triggered
- 11: Unacknowledged by timeout
- 12: Failed attachment
- 13: Incident resolved
- 14: Incident unresolved
- 15: Unsilenced
- 16: Escalation finished
- 17: Escalation failed
- 18: Acknowledge reminder triggered
- 19: Wiped
- 20: Deleted
- 21: Incident registered
- 22: A route is assigned to the incident
- 23: Trigger direct paging escalation
- 24: Unpage a user
- 25: Restricted
For `user_notification`:
- 0: Personal notification triggered
- 1: Personal notification finished
- 2: Personal notification success,
- 3: Personal notification failed
For `resolution_note`:
- 0: slack
- 1: web
"""
return super().retrieve(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
delete_alert_group.apply_async((instance.pk, request.user.pk))
return Response(status=status.HTTP_204_NO_CONTENT)
@extend_schema(
filters=True, # filter alert groups before counting them
responses=inline_serializer(name="AlertGroupStats", fields={"count": serializers.IntegerField()}),
)
@action(methods=["get"], detail=False)
def stats(self, request):
"""
Return number of alert groups capped at 100001
"""
MAX_COUNT = 100001
alert_groups = self.filter_queryset(self.get_queryset())[:MAX_COUNT]
count = alert_groups.count()
count = f"{MAX_COUNT-1}+" if count == MAX_COUNT else str(count)
return Response({"count": count})
@extend_schema(responses=AlertGroupSerializer)
@action(methods=["post"], detail=True)
def acknowledge(self, request, pk):
"""
Acknowledge an alert group
"""
alert_group = self.get_object()
if alert_group.is_maintenance_incident:
raise BadRequest(detail="Can't acknowledge maintenance alert group")
if alert_group.root_alert_group is not None:
raise BadRequest(detail="Can't acknowledge an attached alert group")
alert_group.acknowledge_by_user_or_backsync(self.request.user, action_source=ActionSource.WEB)
return Response(AlertGroupSerializer(alert_group, context={"request": self.request}).data)
@extend_schema(responses=AlertGroupSerializer)
@action(methods=["post"], detail=True)
def unacknowledge(self, request, pk):
"""
Unacknowledge an alert group
"""
alert_group = self.get_object()
if alert_group.is_maintenance_incident:
raise BadRequest(detail="Can't unacknowledge maintenance alert group")
if alert_group.root_alert_group is not None:
raise BadRequest(detail="Can't unacknowledge an attached alert group")
if not alert_group.acknowledged:
raise BadRequest(detail="The alert group is not acknowledged")
if alert_group.resolved:
raise BadRequest(detail="Can't unacknowledge a resolved alert group")
alert_group.un_acknowledge_by_user_or_backsync(self.request.user, action_source=ActionSource.WEB)
return Response(AlertGroupSerializer(alert_group, context={"request": self.request}).data)
@extend_schema(
request=inline_serializer(
name="AlertGroupResolve", fields={"resolution_note": serializers.CharField(required=False, allow_null=True)}
),
responses=AlertGroupSerializer,
)
@action(methods=["post"], detail=True)
def resolve(self, request, pk):
"""
Resolve an alert group
"""
alert_group = self.get_object()
organization = self.request.user.organization
if alert_group.root_alert_group is not None:
raise BadRequest(detail="Can't resolve an attached alert group")
if alert_group.is_maintenance_incident:
alert_group.stop_maintenance(self.request.user)
else:
resolution_note_text = request.data.get("resolution_note")
if resolution_note_text:
rn = ResolutionNote.objects.create(
alert_group=alert_group,
author=self.request.user,
source=(
ResolutionNote.Source.MOBILE_APP
if isinstance(self.request.successful_authenticator, MobileAppAuthTokenAuthentication)
else ResolutionNote.Source.WEB
),
message_text=resolution_note_text[:3000], # trim text to fit in the db field
)
send_update_resolution_note_signal.apply_async(
kwargs={
"alert_group_pk": alert_group.pk,
"resolution_note_pk": rn.pk,
}
)
else:
# Check resolution note required setting only if resolution_note_text was not provided.
if organization.is_resolution_note_required and not alert_group.has_resolution_notes:
return Response(
data={
"code": AlertGroupAPIError.RESOLUTION_NOTE_REQUIRED.value,
"detail": "Alert group without resolution note cannot be resolved due to organization settings",
},
status=status.HTTP_400_BAD_REQUEST,
)
alert_group.resolve_by_user_or_backsync(self.request.user, action_source=ActionSource.WEB)
return Response(AlertGroupSerializer(alert_group, context={"request": self.request}).data)
@extend_schema(responses=AlertGroupSerializer)
@action(methods=["post"], detail=True)
def unresolve(self, request, pk):
"""
Unresolve an alert group
"""
alert_group = self.get_object()
if alert_group.is_maintenance_incident:
raise BadRequest(detail="Can't unresolve maintenance alert group")
if alert_group.root_alert_group is not None:
raise BadRequest(detail="Can't unresolve an attached alert group")
if not alert_group.resolved:
raise BadRequest(detail="The alert group is not resolved")
alert_group.un_resolve_by_user_or_backsync(self.request.user, action_source=ActionSource.WEB)
return Response(AlertGroupSerializer(alert_group, context={"request": self.request}).data)
@extend_schema(
request=inline_serializer(name="AlertGroupAttach", fields={"root_alert_group_pk": serializers.CharField()}),
responses=AlertGroupSerializer,
)
@action(methods=["post"], detail=True)
def attach(self, request, pk=None):
"""
Attach alert group to another alert group
"""
alert_group = self.get_object()
if alert_group.is_maintenance_incident:
raise BadRequest(detail="Can't attach maintenance alert group")
if alert_group.dependent_alert_groups.count() > 0:
raise BadRequest(detail="Can't attach an alert group because it has another alert groups attached to it")
if not alert_group.is_root_alert_group:
raise BadRequest(detail="Can't attach an alert group because it has already been attached")
try:
root_alert_group = self.get_queryset().get(public_primary_key=request.data["root_alert_group_pk"])
except AlertGroup.DoesNotExist:
return Response(status=status.HTTP_400_BAD_REQUEST)
if root_alert_group.resolved or root_alert_group.root_alert_group is not None:
return Response(status=status.HTTP_400_BAD_REQUEST)
if root_alert_group == alert_group:
return Response(status=status.HTTP_400_BAD_REQUEST)
alert_group.attach_by_user(self.request.user, root_alert_group, action_source=ActionSource.WEB)
return Response(AlertGroupSerializer(alert_group, context={"request": self.request}).data)
@extend_schema(responses=AlertGroupSerializer)
@action(methods=["post"], detail=True)
def unattach(self, request, pk=None):
"""
Unattach an alert group that is already attached to another alert group
"""
alert_group = self.get_object()
if alert_group.is_maintenance_incident:
raise BadRequest(detail="Can't unattach maintenance alert group")
if alert_group.is_root_alert_group:
raise BadRequest(detail="Can't unattach an alert group because it is not attached")
alert_group.un_attach_by_user(self.request.user, action_source=ActionSource.WEB)
return Response(AlertGroupSerializer(alert_group, context={"request": self.request}).data)
@extend_schema(
request=inline_serializer(name="AlertGroupSilence", fields={"delay": serializers.IntegerField()}),
responses=AlertGroupSerializer,
)
@action(methods=["post"], detail=True)
def silence(self, request, pk=None):
"""
Silence an alert group for a specified delay
"""
alert_group = self.get_object()
delay = request.data.get("delay")
if delay is None:
raise BadRequest(detail="Please specify a delay for silence")
if alert_group.root_alert_group is not None:
raise BadRequest(detail="Can't silence an attached alert group")
alert_group.silence_by_user_or_backsync(request.user, silence_delay=delay, action_source=ActionSource.WEB)
return Response(AlertGroupSerializer(alert_group, context={"request": request}).data)
@extend_schema(
responses=inline_serializer(
name="AlertGroupSilenceOptions",
fields={
"value": serializers.ChoiceField(choices=[value for value, _ in AlertGroup.SILENCE_DELAY_OPTIONS]),
"display_name": serializers.ChoiceField(
choices=[display_name for _, display_name in AlertGroup.SILENCE_DELAY_OPTIONS]
),
},
many=True,
)
)
@action(methods=["get"], detail=False)
def silence_options(self, request):
# TODO: DEPRECATED, REMOVE IN A FUTURE RELEASE
"""
Retrieve a list of valid silence options
"""
data = [
{"value": value, "display_name": display_name} for value, display_name in AlertGroup.SILENCE_DELAY_OPTIONS
]
return Response(data)
@extend_schema(responses=AlertGroupSerializer)
@action(methods=["post"], detail=True)
def unsilence(self, request, pk=None):
"""
Unsilence a silenced alert group
"""
alert_group = self.get_object()
if not alert_group.silenced:
raise BadRequest(detail="The alert group is not silenced")
if alert_group.resolved:
raise BadRequest(detail="Can't unsilence a resolved alert group")
if alert_group.acknowledged:
raise BadRequest(detail="Can't unsilence an acknowledged alert group")
if alert_group.root_alert_group is not None:
raise BadRequest(detail="Can't unsilence an attached alert group")
alert_group.un_silence_by_user_or_backsync(request.user, action_source=ActionSource.WEB)
return Response(AlertGroupSerializer(alert_group, context={"request": request}).data)
@extend_schema(
request=inline_serializer(name="AlertGroupUnpageUser", fields={"user_id": serializers.CharField()}),
responses=AlertGroupSerializer,
)
@action(methods=["post"], detail=True)
def unpage_user(self, request, pk=None):
"""
Remove a user that was directly paged for the alert group
"""
organization = request.auth.organization
from_user = request.user
alert_group = self.get_object()
try:
user_id = request.data["user_id"]
except KeyError:
raise BadRequest(detail="Please specify user_id")
try:
user = organization.users.get(public_primary_key=user_id)
except User.DoesNotExist:
raise BadRequest(detail="User not found")
unpage_user(alert_group=alert_group, user=user, from_user=from_user)
return Response(status=status.HTTP_200_OK)
@extend_schema(
responses=inline_serializer(
name="AlertGroupFilters",
fields={
"name": serializers.CharField(),
"type": serializers.CharField(),
"href": serializers.CharField(required=False),
"global": serializers.BooleanField(required=False),
"default": serializers.JSONField(required=False),
"description": serializers.CharField(required=False),
"options": inline_serializer(
name="AlertGroupFiltersOptions",
fields={
"value": serializers.CharField(),
"display_name": serializers.IntegerField(),
},
),
},
many=True,
)
)
@action(methods=["get"], detail=False)
def filters(self, request):
"""
Retrieve a list of valid filter options that can be used to filter alert groups
"""
api_root = "/api/internal/v1/"
default_day_range = 30
default_datetime_range = f"now-{default_day_range}d_now"
filter_options = [
{
"name": "team",
"type": "team_select",
"href": api_root + "teams/",
"global": True,
},
{"name": "integration", "type": "options", "href": api_root + "alert_receive_channels/?filters=true"},
{"name": "escalation_chain", "type": "options", "href": api_root + "escalation_chains/?filters=true"},
{
"name": "acknowledged_by",
"type": "options",
"href": api_root + "users/?filters=true&roles=0&roles=1&roles=2",
"default": {"display_name": self.request.user.username, "value": self.request.user.public_primary_key},
},
{
"name": "resolved_by",
"type": "options",
"href": api_root + "users/?filters=true&roles=0&roles=1&roles=2",
},
{
"name": "silenced_by",
"type": "options",
"href": api_root + "users/?filters=true&roles=0&roles=1&roles=2",
},
{
"name": "invitees_are",
"type": "options",
"href": api_root + "users/?filters=true&roles=0&roles=1&roles=2",
},
{
"name": "involved_users_are",
"type": "options",
"href": api_root + "users/?filters=true&roles=0&roles=1&roles=2",
"default": {"display_name": self.request.user.username, "value": self.request.user.public_primary_key},
"description": f"This filter works only for last {AlertGroupFilter.FILTER_BY_INVOLVED_USERS_ALERT_GROUPS_CUTOFF} alert groups these users involved in.",
},
{
"name": "status",
"type": "options",
"options": [
{"display_name": "firing", "value": AlertGroup.NEW},
{"display_name": "acknowledged", "value": AlertGroup.ACKNOWLEDGED},
{"display_name": "resolved", "value": AlertGroup.RESOLVED},
{"display_name": "silenced", "value": AlertGroup.SILENCED},
],
},
{
"name": "started_at",
"type": "daterange",
"default": default_datetime_range,
},
{
"name": "resolved_at",
"type": "daterange",
"default": default_datetime_range,
},
{
"name": "with_resolution_note",
"type": "boolean",
"default": "true",
},
{
"name": "mine",
"type": "boolean",
"default": "true",
"description": f"This filter works only for last {AlertGroupFilter.FILTER_BY_INVOLVED_USERS_ALERT_GROUPS_CUTOFF} alert groups you're involved in.",
},
]
if settings.FEATURE_ALERT_GROUP_SEARCH_ENABLED:
description = "Search by alert group ID, number or title."
if settings.FEATURE_ALERT_GROUP_SEARCH_CUTOFF_DAYS:
description += (
f" The search is limited to alert groups started in the last "
f"{settings.FEATURE_ALERT_GROUP_SEARCH_CUTOFF_DAYS} days of the specified date range."
)
filter_options = [{"name": "search", "type": "search", "description": description}] + filter_options
if is_labels_feature_enabled(self.request.auth.organization):
filter_options.append(
{
"name": "label",
"display_name": "Label",
"type": "alert_group_labels",
}
)
return Response(filter_options)
@extend_schema(
request=inline_serializer(
name="AlertGroupBulkActionRequest",
fields={
"alert_group_pks": serializers.ListField(child=serializers.CharField()),
"action": serializers.ChoiceField(choices=AlertGroup.BULK_ACTIONS),
"delay": serializers.IntegerField(
required=False, allow_null=True, help_text="only applicable for silence"
),
},
)
)
@action(methods=["post"], detail=False)
def bulk_action(self, request):
"""
Perform a bulk action on a list of alert groups
"""
alert_group_pks = self.request.data.get("alert_group_pks", [])
action_name = self.request.data.get("action", None)
delay = self.request.data.get("delay")
kwargs = {}
if action_name not in AlertGroup.BULK_ACTIONS:
return Response("Unknown action", status=status.HTTP_400_BAD_REQUEST)
if action_name == AlertGroup.SILENCE:
if delay is None:
raise BadRequest(detail="Please specify a delay for silence")
kwargs["silence_delay"] = delay
alert_groups = AlertGroup.objects.filter(
channel__organization=self.request.auth.organization, public_primary_key__in=alert_group_pks
)
kwargs["user"] = self.request.user
kwargs["alert_groups"] = alert_groups
method = getattr(AlertGroup, f"bulk_{action_name}")
method(**kwargs)
return Response(status=status.HTTP_200_OK)
@extend_schema(
responses=inline_serializer(
name="AlertGroupBulkActionOptions",
fields={
"value": serializers.ChoiceField(choices=AlertGroup.BULK_ACTIONS),
"display_name": serializers.ChoiceField(choices=AlertGroup.BULK_ACTIONS),
},
many=True,
)
)
@action(methods=["get"], detail=False)
def bulk_action_options(self, request):
"""
Retrieve a list of valid bulk action options
"""
return Response(
[{"value": action_name, "display_name": action_name} for action_name in AlertGroup.BULK_ACTIONS]
)
# This method is required for PreviewTemplateMixin
def get_alert_to_template(self, payload=None):
return self.get_object().alerts.first()
@action(methods=["get"], detail=True)
def escalation_snapshot(self, request, pk=None):
alert_group = self.get_object()
escalation_snapshot = alert_group.escalation_snapshot
result = AlertGroupEscalationSnapshotAPISerializer(escalation_snapshot).data if escalation_snapshot else {}
return Response(result)