Improve OpenAPI schema coverage (#3629)

# What this PR does

Improves OpenAPI schema coverage for internal API:

- Fixes/Improves `alert group` and `feature` endpoints
- Adds `integration` and `user` endpoints

## Which issue(s) this PR fixes

https://github.com/grafana/oncall/issues/3444

## 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)
This commit is contained in:
Vadim Stepanov 2024-01-12 15:11:22 +00:00 committed by GitHub
parent 8656404598
commit d0904ca405
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 640 additions and 287 deletions

View file

@ -348,7 +348,7 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
return self.silenced and self.silenced_until is not None
@property
def status(self):
def status(self) -> int:
if self.resolved:
return AlertGroup.RESOLVED
elif self.acknowledged:

View file

@ -412,7 +412,7 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
return Alert.objects.filter(group__channel=self).count()
@property
def is_able_to_autoresolve(self):
def is_able_to_autoresolve(self) -> bool:
return self.config.is_able_to_autoresolve
@property
@ -420,7 +420,7 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
return self.config.is_demo_alert_enabled
@property
def description(self):
def description(self) -> str | None:
# TODO: AMV2: Remove this check after legacy integrations are migrated.
if self.integration == AlertReceiveChannel.INTEGRATION_LEGACY_GRAFANA_ALERTING:
contact_points = self.contact_points.all()
@ -496,7 +496,7 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
return urljoin(self.organization.web_link, f"integrations/{self.public_primary_key}")
@property
def integration_url(self):
def integration_url(self) -> str | None:
if self.integration in [
AlertReceiveChannel.INTEGRATION_MANUAL,
AlertReceiveChannel.INTEGRATION_SLACK_CHANNEL,
@ -595,7 +595,7 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
# Heartbeat
@property
def is_available_for_integration_heartbeat(self):
def is_available_for_integration_heartbeat(self) -> bool:
return self.heartbeat_module is not None
@property

View file

@ -151,7 +151,7 @@ class MaintainableObject(models.Model):
)
@property
def till_maintenance_timestamp(self):
def till_maintenance_timestamp(self) -> int | None:
if self.maintenance_started_at is not None and self.maintenance_duration is not None:
return int((self.maintenance_started_at + self.maintenance_duration).astimezone(pytz.UTC).timestamp())
return None

View file

@ -1,3 +1,5 @@
import typing
from django.core.cache import cache
from django.utils import timezone
from rest_framework import serializers
@ -8,6 +10,13 @@ from apps.alerts.models import Alert
from .alerts_field_cache_buster_mixin import AlertsFieldCacheBusterMixin
class RenderForWeb(typing.TypedDict):
title: str
message: str
image_url: str | None
source_link: str | None
class AlertFieldsCacheSerializerMixin(AlertsFieldCacheBusterMixin):
CACHE_KEY_FORMAT_TEMPLATE = "{field_name}_alert_{object_id}"
@ -51,7 +60,7 @@ class AlertSerializer(AlertFieldsCacheSerializerMixin, serializers.ModelSerializ
"created_at",
]
def get_render_for_web(self, obj):
def get_render_for_web(self, obj) -> RenderForWeb:
return AlertFieldsCacheSerializerMixin.get_or_set_web_template_field(
obj,
AlertFieldsCacheSerializerMixin.RENDER_FOR_WEB_FIELD_NAME,

View file

@ -4,7 +4,7 @@ import typing
from django.core.cache import cache
from django.utils import timezone
from drf_spectacular.utils import extend_schema_field, inline_serializer
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from apps.alerts.incident_appearance.renderers.web_renderer import AlertGroupWebRenderer
@ -22,6 +22,17 @@ logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
class RenderForWeb(typing.TypedDict):
title: str
message: str
image_url: str | None
source_link: str | None
class EmptyRenderForWeb(typing.TypedDict):
pass
class AlertGroupFieldsCacheSerializerMixin(AlertsFieldCacheBusterMixin):
CACHE_KEY_FORMAT_TEMPLATE = "{field_name}_alert_group_{object_id}"
@ -80,18 +91,7 @@ class ShortAlertGroupSerializer(AlertGroupFieldsCacheSerializerMixin, serializer
fields = ["pk", "render_for_web", "alert_receive_channel", "inside_organization_number"]
read_only_fields = ["pk", "render_for_web", "alert_receive_channel", "inside_organization_number"]
@extend_schema_field(
inline_serializer(
name="render_for_web",
fields={
"title": serializers.CharField(),
"message": serializers.CharField(),
"image_url": serializers.CharField(),
"source_link": serializers.CharField(),
},
)
)
def get_render_for_web(self, obj: "AlertGroup"):
def get_render_for_web(self, obj: "AlertGroup") -> RenderForWeb | EmptyRenderForWeb:
last_alert = obj.alerts.last()
if last_alert is None:
return {}
@ -170,18 +170,7 @@ class AlertGroupListSerializer(
"labels",
]
@extend_schema_field(
inline_serializer(
name="render_for_web",
fields={
"title": serializers.CharField(),
"message": serializers.CharField(),
"image_url": serializers.CharField(),
"source_link": serializers.CharField(),
},
)
)
def get_render_for_web(self, obj: "AlertGroup"):
def get_render_for_web(self, obj: "AlertGroup") -> RenderForWeb | EmptyRenderForWeb:
if not obj.last_alert:
return {}
return AlertGroupFieldsCacheSerializerMixin.get_or_set_web_template_field(

View file

@ -2,7 +2,6 @@ import typing
from collections import OrderedDict
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db.models import Q
from jinja2 import TemplateSyntaxError
@ -53,17 +52,17 @@ class IntegrationAlertGroupLabels(typing.TypedDict):
class CustomLabelSerializer(serializers.Serializer):
"""This serializer is consistent with apps.api.serializers.labels.LabelSerializer, but allows null for value ID."""
class KeySerializer(serializers.Serializer):
class CustomLabelKeySerializer(serializers.Serializer):
id = serializers.CharField()
name = serializers.CharField()
class ValueSerializer(serializers.Serializer):
class CustomLabelValueSerializer(serializers.Serializer):
# ID is null for templated labels. For such labels, the "name" value is a Jinja2 template.
id = serializers.CharField(allow_null=True)
name = serializers.CharField()
key = KeySerializer()
value = ValueSerializer()
key = CustomLabelKeySerializer()
value = CustomLabelValueSerializer()
class IntegrationAlertGroupLabelsSerializer(serializers.Serializer):
@ -215,9 +214,9 @@ class AlertReceiveChannelSerializer(
default_channel_filter = serializers.SerializerMethodField()
instructions = serializers.SerializerMethodField()
demo_alert_enabled = serializers.BooleanField(source="is_demo_alert_enabled", read_only=True)
is_based_on_alertmanager = serializers.BooleanField(source="has_alertmanager_payload_structure", read_only=True)
is_based_on_alertmanager = serializers.BooleanField(source="based_on_alertmanager", read_only=True)
maintenance_till = serializers.ReadOnlyField(source="till_maintenance_timestamp")
heartbeat = serializers.SerializerMethodField()
heartbeat = IntegrationHeartBeatSerializer(read_only=True, allow_null=True, source="integration_heartbeat")
allow_delete = serializers.SerializerMethodField()
description_short = serializers.CharField(max_length=250, required=False, allow_null=True)
demo_alert_payload = serializers.JSONField(source="config.example_payload", read_only=True)
@ -334,15 +333,16 @@ class AlertReceiveChannelSerializer(
except AlertReceiveChannel.DuplicateDirectPagingError:
raise BadRequest(detail=AlertReceiveChannel.DuplicateDirectPagingError.DETAIL)
def get_instructions(self, obj: "AlertReceiveChannel"):
def get_instructions(self, obj: "AlertReceiveChannel") -> str:
# Deprecated, kept for api-backward compatibility
return ""
# MethodFields are used instead of relevant properties because of properties hit db on each instance in queryset
def get_default_channel_filter(self, obj: "AlertReceiveChannel"):
def get_default_channel_filter(self, obj: "AlertReceiveChannel") -> str | None:
for filter in obj.channel_filters.all():
if filter.is_default:
return filter.public_primary_key
return None
@staticmethod
def validate_integration(integration):
@ -367,21 +367,14 @@ class AlertReceiveChannelSerializer(
else:
raise serializers.ValidationError(detail="Integration with this name already exists")
def get_heartbeat(self, obj: "AlertReceiveChannel"):
try:
heartbeat = obj.integration_heartbeat
except ObjectDoesNotExist:
return None
return IntegrationHeartBeatSerializer(heartbeat).data
def get_allow_delete(self, obj: "AlertReceiveChannel"):
def get_allow_delete(self, obj: "AlertReceiveChannel") -> bool:
# don't allow deleting direct paging integrations
return obj.integration != AlertReceiveChannel.INTEGRATION_DIRECT_PAGING
def get_alert_count(self, obj: "AlertReceiveChannel"):
def get_alert_count(self, obj: "AlertReceiveChannel") -> int:
return 0
def get_alert_groups_count(self, obj: "AlertReceiveChannel"):
def get_alert_groups_count(self, obj: "AlertReceiveChannel") -> int:
return 0
def get_routes_count(self, obj: "AlertReceiveChannel") -> int:
@ -428,10 +421,10 @@ class FilterAlertReceiveChannelSerializer(serializers.ModelSerializer[AlertRecei
model = AlertReceiveChannel
fields = ["value", "display_name", "integration_url"]
def _get_value(self, obj: "AlertReceiveChannel"):
def _get_value(self, obj: "AlertReceiveChannel") -> str:
return obj.public_primary_key
def get_display_name(self, obj: "AlertReceiveChannel"):
def get_display_name(self, obj: "AlertReceiveChannel") -> str:
display_name = obj.verbal_name or AlertReceiveChannel.INTEGRATION_CHOICES[obj.integration][1]
return display_name

View file

@ -41,10 +41,10 @@ class IntegrationHeartBeatSerializer(EagerLoadingMixin, serializers.ModelSeriali
{"alert_receive_channel": "Heartbeat is not available for this integration"}
)
def get_last_heartbeat_time_verbal(self, obj):
def get_last_heartbeat_time_verbal(self, obj) -> str | None:
return self._last_heartbeat_time_verbal(obj) if obj.last_heartbeat_time else None
def get_instruction(self, obj):
def get_instruction(self, obj) -> str:
# Deprecated. Kept for API backward compatibility.
return ""

View file

@ -14,5 +14,5 @@ class SlackUserIdentitySerializer(serializers.ModelSerializer):
fields = ["slack_login", "slack_id", "avatar", "name", "display_name"]
read_only_fields = ["slack_login", "slack_id", "avatar", "name", "display_name"]
def get_display_name(self, obj):
def get_display_name(self, obj) -> str | None:
return obj.profile_display_name or obj.slack_verbal

View file

@ -9,10 +9,10 @@ from apps.api.serializers.telegram import TelegramToUserConnectorSerializer
from apps.base.messaging import get_messaging_backends
from apps.base.models import UserNotificationPolicy
from apps.base.utils import live_settings
from apps.oss_installation.constants import CloudSyncStatus
from apps.oss_installation.utils import cloud_user_identity_status
from apps.schedules.ical_utils import SchedulesOnCallUsers
from apps.user_management.models import User
from apps.user_management.models.user import default_working_hours
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField, TimeZoneField
from common.api_helpers.mixins import EagerLoadingMixin
from common.api_helpers.utils import check_phone_number_is_valid
@ -31,6 +31,26 @@ class UserPermissionSerializer(serializers.Serializer):
action = serializers.CharField(read_only=True)
class NotificationChainVerbal(typing.TypedDict):
default: str
important: str
class WorkingHoursPeriodSerializer(serializers.Serializer):
start = serializers.CharField()
end = serializers.CharField()
class WorkingHoursSerializer(serializers.Serializer):
monday = serializers.ListField(child=WorkingHoursPeriodSerializer())
tuesday = serializers.ListField(child=WorkingHoursPeriodSerializer())
wednesday = serializers.ListField(child=WorkingHoursPeriodSerializer())
thursday = serializers.ListField(child=WorkingHoursPeriodSerializer())
friday = serializers.ListField(child=WorkingHoursPeriodSerializer())
saturday = serializers.ListField(child=WorkingHoursPeriodSerializer())
sunday = serializers.ListField(child=WorkingHoursPeriodSerializer())
class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
pk = serializers.CharField(read_only=True, source="public_primary_key")
slack_user_identity = SlackUserIdentitySerializer(read_only=True)
@ -47,6 +67,7 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
avatar_full = serializers.URLField(source="avatar_full_url", read_only=True)
notification_chain_verbal = serializers.SerializerMethodField()
cloud_connection_status = serializers.SerializerMethodField()
working_hours = WorkingHoursSerializer(required=False)
SELECT_RELATED = ["telegram_verification_code", "telegram_connection", "organization", "slack_user_identity"]
@ -82,29 +103,8 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
]
def validate_working_hours(self, working_hours):
if not isinstance(working_hours, dict):
raise serializers.ValidationError("must be dict")
# check that all days are present
if sorted(working_hours.keys()) != sorted(default_working_hours().keys()):
raise serializers.ValidationError("missing some days")
for day in working_hours:
periods = working_hours[day]
if not isinstance(periods, list):
raise serializers.ValidationError("periods must be list")
for period in periods:
if not isinstance(period, dict):
raise serializers.ValidationError("period must be dict")
if sorted(period.keys()) != sorted(["start", "end"]):
raise serializers.ValidationError("'start' and 'end' fields must be present")
if not isinstance(period["start"], str) or not isinstance(period["end"], str):
raise serializers.ValidationError("'start' and 'end' fields must be str")
for period in working_hours[day]:
try:
start = time.strptime(period["start"], "%H:%M:%S")
end = time.strptime(period["end"], "%H:%M:%S")
@ -113,7 +113,6 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
if start >= end:
raise serializers.ValidationError("'start' must be less than 'end'")
return working_hours
def validate_unverified_phone_number(self, value):
@ -127,18 +126,18 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
else:
return None
def get_messaging_backends(self, obj: User):
def get_messaging_backends(self, obj: User) -> dict[str, dict]:
serialized_data = {}
supported_backends = get_messaging_backends()
for backend_id, backend in supported_backends:
serialized_data[backend_id] = backend.serialize_user(obj)
return serialized_data
def get_notification_chain_verbal(self, obj: User):
def get_notification_chain_verbal(self, obj: User) -> NotificationChainVerbal:
default, important = UserNotificationPolicy.get_short_verbals_for_user(user=obj)
return {"default": " - ".join(default), "important": " - ".join(important)}
def get_cloud_connection_status(self, obj: User):
def get_cloud_connection_status(self, obj: User) -> CloudSyncStatus | None:
if settings.IS_OPEN_SOURCE and live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED:
connector = self.context.get("connector", None)
identities = self.context.get("cloud_identities", {})

View file

@ -4,14 +4,7 @@ from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from apps.api.views.features import (
FEATURE_GRAFANA_CLOUD_CONNECTION,
FEATURE_GRAFANA_CLOUD_NOTIFICATIONS,
FEATURE_LIVE_SETTINGS,
FEATURE_MSTEAMS,
FEATURE_SLACK,
FEATURE_TELEGRAM,
)
from apps.api.views.features import Feature
@pytest.mark.django_db
@ -35,9 +28,9 @@ def test_features_view(
@pytest.mark.parametrize(
"feature_attr,expected_feature",
[
("FEATURE_SLACK_INTEGRATION_ENABLED", FEATURE_SLACK),
("FEATURE_TELEGRAM_INTEGRATION_ENABLED", FEATURE_TELEGRAM),
("FEATURE_LIVE_SETTINGS_ENABLED", FEATURE_LIVE_SETTINGS),
("FEATURE_SLACK_INTEGRATION_ENABLED", Feature.SLACK),
("FEATURE_TELEGRAM_INTEGRATION_ENABLED", Feature.TELEGRAM),
("FEATURE_LIVE_SETTINGS_ENABLED", Feature.LIVE_SETTINGS),
],
)
def test_core_features_switch(
@ -76,9 +69,9 @@ def test_oss_features_enabled_in_oss_installation_by_default(
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert FEATURE_GRAFANA_CLOUD_CONNECTION in response.json()
assert FEATURE_GRAFANA_CLOUD_NOTIFICATIONS in response.json()
assert FEATURE_MSTEAMS not in response.json()
assert Feature.GRAFANA_CLOUD_CONNECTION in response.json()
assert Feature.GRAFANA_CLOUD_NOTIFICATIONS in response.json()
assert Feature.MSTEAMS not in response.json()
@pytest.mark.django_db
@ -93,14 +86,14 @@ def test_non_oss_features_enabled(
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert FEATURE_MSTEAMS in response.json()
assert Feature.MSTEAMS in response.json()
@pytest.mark.django_db
@pytest.mark.parametrize(
"feature_attr,expected_feature",
[
("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", FEATURE_GRAFANA_CLOUD_NOTIFICATIONS),
("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", Feature.GRAFANA_CLOUD_NOTIFICATIONS),
],
)
def test_oss_features_switch(

View file

@ -4,8 +4,7 @@ from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Count, Max, Q
from django.utils import timezone
from django_filters import rest_framework as filters
from django_filters.widgets import RangeWidget
from drf_spectacular.utils import extend_schema, extend_schema_view, inline_serializer
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
@ -29,7 +28,12 @@ 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
from common.api_helpers.filters import (
NO_TEAM_VALUE,
DateRangeFilterMixin,
ModelFieldFilterMixin,
MultipleChoiceCharFilter,
)
from common.api_helpers.mixins import PreviewTemplateMixin, PublicPrimaryKeyMixin, TeamFilteringMixin
from common.api_helpers.paginators import AlertGroupCursorPaginator
@ -55,31 +59,6 @@ def get_user_queryset(request):
return User.objects.filter(organization=request.user.organization).distinct()
class AlertGroupFilterBackend(filters.DjangoFilterBackend):
"""
See here for more context on how this works
https://github.com/carltongibson/django-filter/discussions/1572
https://youtu.be/e52S1SjuUeM?t=841
"""
def get_filterset(self, request, queryset, view):
filterset = super().get_filterset(request, queryset, view)
filterset.form.fields["integration"].queryset = get_integration_queryset(request)
filterset.form.fields["escalation_chain"].queryset = get_escalation_chain_queryset(request)
user_queryset = get_user_queryset(request)
filterset.form.fields["silenced_by"].queryset = user_queryset
filterset.form.fields["acknowledged_by"].queryset = user_queryset
filterset.form.fields["resolved_by"].queryset = user_queryset
filterset.form.fields["invitees_are"].queryset = user_queryset
filterset.form.fields["involved_users_are"].queryset = user_queryset
return filterset
class AlertGroupFilter(DateRangeFilterMixin, ModelFieldFilterMixin, filters.FilterSet):
"""
Examples of possible date formats here https://docs.djangoproject.com/en/1.9/ref/settings/#datetime-input-formats
@ -87,69 +66,55 @@ class AlertGroupFilter(DateRangeFilterMixin, ModelFieldFilterMixin, filters.Filt
FILTER_BY_INVOLVED_USERS_ALERT_GROUPS_CUTOFF = 1000
started_at_gte = filters.DateTimeFilter(field_name="started_at", lookup_expr="gte")
started_at_lte = filters.DateTimeFilter(field_name="started_at", lookup_expr="lte")
resolved_at_lte = filters.DateTimeFilter(field_name="resolved_at", lookup_expr="lte")
is_root = filters.BooleanFilter(field_name="root_alert_group", lookup_expr="isnull")
id__in = filters.BaseInFilter(field_name="public_primary_key", lookup_expr="in")
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__)
silenced_at = filters.CharFilter(field_name="silenced_at", method=DateRangeFilterMixin.filter_date_range.__name__)
silenced_by = filters.ModelMultipleChoiceFilter(
field_name="silenced_by_user",
queryset=None,
to_field_name="public_primary_key",
method=ModelFieldFilterMixin.filter_model_field.__name__,
started_at = filters.CharFilter(
field_name="started_at",
method=DateRangeFilterMixin.filter_date_range.__name__,
)
integration = filters.ModelMultipleChoiceFilter(
resolved_at = filters.CharFilter(
field_name="resolved_at",
method=DateRangeFilterMixin.filter_date_range.__name__,
)
integration = MultipleChoiceCharFilter(
field_name="channel",
queryset=None,
queryset=get_integration_queryset,
to_field_name="public_primary_key",
method=ModelFieldFilterMixin.filter_model_field.__name__,
)
escalation_chain = filters.ModelMultipleChoiceFilter(
escalation_chain = MultipleChoiceCharFilter(
field_name="channel_filter__escalation_chain",
queryset=None,
queryset=get_escalation_chain_queryset,
to_field_name="public_primary_key",
method=ModelFieldFilterMixin.filter_model_field.__name__,
)
started_at_range = filters.DateFromToRangeFilter(
field_name="started_at", widget=RangeWidget(attrs={"type": "date"})
)
resolved_by = filters.ModelMultipleChoiceFilter(
resolved_by = MultipleChoiceCharFilter(
field_name="resolved_by_user",
queryset=None,
queryset=get_user_queryset,
to_field_name="public_primary_key",
method=ModelFieldFilterMixin.filter_model_field.__name__,
)
acknowledged_by = filters.ModelMultipleChoiceFilter(
acknowledged_by = MultipleChoiceCharFilter(
field_name="acknowledged_by_user",
queryset=None,
queryset=get_user_queryset,
to_field_name="public_primary_key",
method=ModelFieldFilterMixin.filter_model_field.__name__,
)
invitees_are = filters.ModelMultipleChoiceFilter(
queryset=None, to_field_name="public_primary_key", method="filter_invitees_are"
silenced_by = MultipleChoiceCharFilter(
field_name="silenced_by_user",
queryset=get_user_queryset,
to_field_name="public_primary_key",
method=ModelFieldFilterMixin.filter_model_field.__name__,
)
involved_users_are = filters.ModelMultipleChoiceFilter(
queryset=None, to_field_name="public_primary_key", method="filter_by_involved_users"
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")
class Meta:
model = AlertGroup
fields = [
"id__in",
"started_at_gte",
"started_at_lte",
"resolved_at_lte",
"is_root",
"resolved_by",
"acknowledged_by",
]
def filter_status(self, queryset, name, value):
if not value:
return queryset
@ -263,12 +228,6 @@ class AlertGroupTeamFilteringMixin(TeamFilteringMixin):
return Response(data={"error_code": "wrong_team"}, status=status.HTTP_403_FORBIDDEN)
@extend_schema_view(
list=extend_schema(description="Fetch a list of alert groups"),
retrieve=extend_schema(description="Fetch a single alert group"),
destroy=extend_schema(description="Delete an alert group"),
preview_template=extend_schema(description="Preview a template for an alert group"),
)
class AlertGroupView(
PreviewTemplateMixin,
AlertGroupTeamFilteringMixin,
@ -278,6 +237,10 @@ class AlertGroupView(
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""
Internal API endpoints for alert groups.
"""
authentication_classes = (
MobileAppAuthTokenAuthentication,
PluginAuthentication,
@ -307,15 +270,12 @@ class AlertGroupView(
"escalation_snapshot": [RBACPermission.Permissions.ALERT_GROUPS_READ],
}
http_method_names = ["get", "post", "delete"]
queryset = AlertGroup.objects.none() # needed for drf-spectacular introspection
serializer_class = AlertGroupSerializer
pagination_class = AlertGroupCursorPaginator
filter_backends = [SearchFilter, AlertGroupFilterBackend]
# search_fields = ["=public_primary_key", "=inside_organization_number", "web_title_cache"]
filter_backends = [SearchFilter, filters.DjangoFilterBackend]
filterset_class = AlertGroupFilter
def get_serializer_class(self):
@ -375,7 +335,7 @@ class AlertGroupView(
obj = self.enrich([obj])[0]
return obj
def retrieve(self, request, pk, *args, **kwargs):
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
@ -433,7 +393,7 @@ class AlertGroupView(
- 1: web
"""
return super().retrieve(request, pk, *args, **kwargs)
return super().retrieve(request, *args, **kwargs)
def enrich(self, alert_groups):
"""
@ -482,9 +442,12 @@ class AlertGroupView(
delete_alert_group.apply_async((instance.pk, request.user.pk))
return Response(status=status.HTTP_204_NO_CONTENT)
@extend_schema(responses=inline_serializer(name="AlertGroupStats", fields={"count": serializers.IntegerField()}))
@action(detail=False)
def stats(self, *args, **kwargs):
@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
"""
@ -492,12 +455,9 @@ class AlertGroupView(
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,
}
)
return Response({"count": count})
@extend_schema(responses=AlertGroupSerializer)
@action(methods=["post"], detail=True)
def acknowledge(self, request, pk):
"""
@ -512,6 +472,7 @@ class AlertGroupView(
return Response(AlertGroupSerializer(alert_group, context={"request": self.request}).data)
@extend_schema(responses=AlertGroupSerializer)
@action(methods=["post"], detail=True)
def unacknowledge(self, request, pk):
"""
@ -534,6 +495,12 @@ class AlertGroupView(
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):
"""
@ -579,6 +546,7 @@ class AlertGroupView(
alert_group.resolve_by_user(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):
"""
@ -597,6 +565,10 @@ class AlertGroupView(
alert_group.un_resolve_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="AlertGroupAttach", fields={"root_alert_group_pk": serializers.CharField()}),
responses=AlertGroupSerializer,
)
@action(methods=["post"], detail=True)
def attach(self, request, pk=None):
"""
@ -622,6 +594,7 @@ class AlertGroupView(
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):
"""
@ -636,6 +609,10 @@ class AlertGroupView(
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):
"""
@ -655,9 +632,13 @@ class AlertGroupView(
@extend_schema(
responses=inline_serializer(
name="silence_options",
fields={"value": serializers.CharField(), "display_name": serializers.CharField()},
many=True,
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]
),
},
)
)
@action(methods=["get"], detail=False)
@ -670,6 +651,7 @@ class AlertGroupView(
]
return Response(data)
@extend_schema(responses=AlertGroupSerializer)
@action(methods=["post"], detail=True)
def unsilence(self, request, pk=None):
"""
@ -693,6 +675,10 @@ class AlertGroupView(
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):
"""
@ -715,12 +701,32 @@ class AlertGroupView(
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
"""
filter_name = request.query_params.get("search", None)
api_root = "/api/internal/v1/"
now = timezone.now()
@ -779,7 +785,6 @@ class AlertGroupView(
{"display_name": "silenced", "value": AlertGroup.SILENCED},
],
},
# {'name': 'is_root', 'type': 'boolean', 'default': True},
{
"name": "started_at",
"type": "daterange",
@ -812,41 +817,60 @@ class AlertGroupView(
}
)
if filter_name is not None:
filter_options = list(filter(lambda f: filter_name in f["name"], filter_options))
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_public_pks = self.request.data.get("alert_group_pks", [])
action_with_incidents = self.request.data.get("action", None)
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_with_incidents not in AlertGroup.BULK_ACTIONS:
if action_name not in AlertGroup.BULK_ACTIONS:
return Response("Unknown action", status=status.HTTP_400_BAD_REQUEST)
if action_with_incidents == AlertGroup.SILENCE:
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_public_pks
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_with_incidents}")
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):
"""

View file

@ -1,7 +1,11 @@
import typing
from django.db.models import Q
from django_filters import rest_framework as filters
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import status
from drf_spectacular.plumbing import resolve_type_hint
from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema, extend_schema_view, inline_serializer
from rest_framework import serializers, status
from rest_framework.decorators import action
from rest_framework.filters import SearchFilter
from rest_framework.permissions import IsAuthenticated
@ -39,6 +43,14 @@ from common.exceptions import MaintenanceCouldNotBeStartedError, TeamCanNotBeCha
from common.insight_log import EntityEvent, write_resource_insight_log
class AlertReceiveChannelCounter(typing.TypedDict):
alerts_count: int
alert_groups_count: int
AlertReceiveChannelCounters = dict[str, AlertReceiveChannelCounter]
class AlertReceiveChannelFilter(ByTeamModelFieldFilterMixin, filters.FilterSet):
maintenance_mode = filters.MultipleChoiceFilter(
choices=AlertReceiveChannel.MAINTENANCE_MODE_CHOICES, method="filter_maintenance_mode"
@ -71,6 +83,17 @@ class AlertReceiveChannelFilter(ByTeamModelFieldFilterMixin, filters.FilterSet):
return queryset
@extend_schema_view(
list=extend_schema(
responses=PolymorphicProxySerializer(
component_name="AlertReceiveChannelPolymorphic",
serializers=[AlertReceiveChannelSerializer, FilterAlertReceiveChannelSerializer],
resource_type_field_name=None,
)
),
update=extend_schema(responses=AlertReceiveChannelUpdateSerializer),
partial_update=extend_schema(responses=AlertReceiveChannelUpdateSerializer),
)
class AlertReceiveChannelView(
PreviewTemplateMixin,
TeamFilteringMixin,
@ -79,6 +102,10 @@ class AlertReceiveChannelView(
UpdateSerializerMixin,
ModelViewSet,
):
"""
Internal API endpoints for alert receive channels (integrations).
"""
authentication_classes = (
MobileAppAuthTokenAuthentication,
PluginAuthentication,
@ -86,6 +113,7 @@ class AlertReceiveChannelView(
permission_classes = (IsAuthenticated, RBACPermission)
model = AlertReceiveChannel
queryset = AlertReceiveChannel.objects.none() # needed for drf-spectacular introspection
serializer_class = AlertReceiveChannelSerializer
filter_serializer_class = FilterAlertReceiveChannelSerializer
update_serializer_class = AlertReceiveChannelUpdateSerializer
@ -194,6 +222,14 @@ class AlertReceiveChannelView(
schedule_update_label_cache(self.model.__name__, self.request.auth.organization, ids)
return page
@extend_schema(
request=inline_serializer(
name="AlertReceiveChannelSendDemoAlert",
fields={
"demo_alert_payload": serializers.DictField(required=False, allow_null=True),
},
),
)
@action(detail=True, methods=["post"], throttle_classes=[DemoAlertThrottler])
def send_demo_alert(self, request, pk):
instance = self.get_object()
@ -209,6 +245,19 @@ class AlertReceiveChannelView(
return Response(status=status.HTTP_200_OK)
@extend_schema(
responses=inline_serializer(
name="AlertReceiveChannelIntegrationOptions",
fields={
"value": serializers.CharField(),
"display_name": serializers.CharField(),
"short_description": serializers.CharField(),
"featured": serializers.BooleanField(),
"featured_tag_name": serializers.CharField(allow_null=True),
},
many=True,
)
)
@action(detail=False, methods=["get"])
def integration_options(self, request):
choices = []
@ -231,6 +280,11 @@ class AlertReceiveChannelView(
choices.append(choice)
return Response(featured_choices + choices)
@extend_schema(
parameters=[
inline_serializer(name="AlertReceiveChannelChangeTeam", fields={"team_id": serializers.CharField()})
]
)
@action(detail=True, methods=["put"])
def change_team(self, request, pk):
instance = self.get_object()
@ -249,6 +303,7 @@ class AlertReceiveChannelView(
return Response()
@extend_schema(responses={status.HTTP_200_OK: resolve_type_hint(AlertReceiveChannelCounters)})
@action(methods=["get"], detail=False)
def counters(self, request):
queryset = self.filter_queryset(self.get_queryset(eager=False))
@ -260,6 +315,11 @@ class AlertReceiveChannelView(
}
return Response(response)
@extend_schema(
# make operation_id unique, otherwise drf-spectacular will issue a warning
operation_id="alert_receive_channels_counters_per_integration_retrieve",
responses={status.HTTP_200_OK: resolve_type_hint(AlertReceiveChannelCounters)},
)
@action(methods=["get"], detail=True, url_path="counters")
def counters_per_integration(self, request, pk):
alert_receive_channel = self.get_object()
@ -287,10 +347,22 @@ class AlertReceiveChannelView(
except AttributeError:
return None
@extend_schema(
responses=inline_serializer(
name="AlertReceiveChannelFilters",
fields={
"name": serializers.CharField(),
"display_name": serializers.CharField(required=False),
"type": serializers.CharField(),
"href": serializers.CharField(),
"global": serializers.BooleanField(required=False),
},
many=True,
)
)
@action(methods=["get"], detail=False)
def filters(self, request):
organization = self.request.auth.organization
filter_name = request.query_params.get("search", None)
api_root = "/api/internal/v1/"
filter_options = [
@ -317,11 +389,19 @@ class AlertReceiveChannelView(
}
)
if filter_name is not None:
filter_options = list(filter(lambda f: filter_name in f["name"], filter_options))
return Response(filter_options)
@extend_schema(
request=inline_serializer(
name="AlertReceiveChannelStartMaintenance",
fields={
"mode": serializers.ChoiceField(choices=MaintainableObject.MAINTENANCE_MODE_CHOICES),
"duration": serializers.ChoiceField(
choices=MaintainableObject.maintenance_duration_options_in_seconds()
),
},
),
)
@action(detail=True, methods=["post"])
def start_maintenance(self, request, pk):
instance = self.get_object()
@ -355,8 +435,7 @@ class AlertReceiveChannelView(
@action(detail=True, methods=["post"])
def stop_maintenance(self, request, pk):
instance = self.get_object()
user = request.user
instance.force_disable_maintenance(user)
instance.force_disable_maintenance(request.user)
return Response(status=status.HTTP_200_OK)
@action(detail=True, methods=["post"])
@ -394,6 +473,20 @@ class AlertReceiveChannelView(
instance.save()
return Response(status=status.HTTP_200_OK)
@extend_schema(
parameters=[
inline_serializer(
name="AlertReceiveChannelValidateName",
fields={
"verbal_name": serializers.CharField(),
},
)
],
responses={
status.HTTP_200_OK: None,
status.HTTP_409_CONFLICT: None,
},
)
@action(detail=False, methods=["get"])
def validate_name(self, request):
"""
@ -412,6 +505,17 @@ class AlertReceiveChannelView(
return r
@extend_schema(
responses=inline_serializer(
name="AlertReceiveChannelConnectedContactPoints",
fields={
"uid": serializers.CharField(),
"name": serializers.CharField(),
"contact_points": serializers.ListField(child=serializers.CharField()),
},
many=True,
)
)
@action(detail=True, methods=["get"])
def connected_contact_points(self, request, pk):
instance = self.get_object()
@ -420,12 +524,32 @@ class AlertReceiveChannelView(
contact_points = instance.grafana_alerting_sync_manager.get_connected_contact_points()
return Response(contact_points)
@extend_schema(
responses=inline_serializer(
name="AlertReceiveChannelContactPoints",
fields={
"uid": serializers.CharField(),
"name": serializers.CharField(),
"contact_points": serializers.ListField(child=serializers.CharField()),
},
many=True,
)
)
@action(detail=False, methods=["get"])
def contact_points(self, request):
organization = request.auth.organization
contact_points = GrafanaAlertingSyncManager.get_contact_points(organization)
return Response(contact_points)
@extend_schema(
request=inline_serializer(
name="AlertReceiveChannelConnectContactPoint",
fields={
"datasource_uid": serializers.CharField(),
"contact_point_name": serializers.CharField(),
},
),
)
@action(detail=True, methods=["post"])
def connect_contact_point(self, request, pk):
instance = self.get_object()
@ -443,6 +567,15 @@ class AlertReceiveChannelView(
raise BadRequest(detail=error)
return Response(status=status.HTTP_200_OK)
@extend_schema(
request=inline_serializer(
name="AlertReceiveChannelCreateContactPoint",
fields={
"datasource_uid": serializers.CharField(),
"contact_point_name": serializers.CharField(),
},
),
)
@action(detail=True, methods=["post"])
def create_contact_point(self, request, pk):
instance = self.get_object()
@ -460,6 +593,15 @@ class AlertReceiveChannelView(
raise BadRequest(detail=error)
return Response(status=status.HTTP_201_CREATED)
@extend_schema(
request=inline_serializer(
name="AlertReceiveChannelDisconnectContactPoint",
fields={
"datasource_uid": serializers.CharField(),
"contact_point_name": serializers.CharField(),
},
),
)
@action(detail=True, methods=["post"])
def disconnect_contact_point(self, request, pk):
instance = self.get_object()

View file

@ -1,6 +1,9 @@
import enum
from django.conf import settings
from drf_spectacular.utils import OpenApiExample, extend_schema
from rest_framework import serializers
from drf_spectacular.plumbing import resolve_type_hint
from drf_spectacular.utils import extend_schema
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
@ -8,14 +11,16 @@ from apps.auth_token.auth import PluginAuthentication
from apps.base.utils import live_settings
from apps.labels.utils import is_labels_feature_enabled
FEATURE_MSTEAMS = "msteams"
FEATURE_SLACK = "slack"
FEATURE_TELEGRAM = "telegram"
FEATURE_LIVE_SETTINGS = "live_settings"
FEATURE_GRAFANA_CLOUD_NOTIFICATIONS = "grafana_cloud_notifications"
FEATURE_GRAFANA_CLOUD_CONNECTION = "grafana_cloud_connection"
FEATURE_GRAFANA_ALERTING_V2 = "grafana_alerting_v2"
FEATURE_LABELS = "labels"
class Feature(enum.StrEnum):
MSTEAMS = "msteams"
SLACK = "slack"
TELEGRAM = "telegram"
LIVE_SETTINGS = "live_settings"
GRAFANA_CLOUD_NOTIFICATIONS = "grafana_cloud_notifications"
GRAFANA_CLOUD_CONNECTION = "grafana_cloud_connection"
GRAFANA_ALERTING_V2 = "grafana_alerting_v2"
LABELS = "labels"
class FeaturesAPIView(APIView):
@ -26,16 +31,7 @@ class FeaturesAPIView(APIView):
authentication_classes = (PluginAuthentication,)
@extend_schema(
request=None,
responses=serializers.ListField(child=serializers.CharField()),
examples=[
OpenApiExample(
name="Example response",
value=["slack", "telegram", "grafana_cloud_connection", "live_settings", "grafana_cloud_notifications"],
)
],
)
@extend_schema(responses={status.HTTP_200_OK: resolve_type_hint(list[Feature])})
def get(self, request):
data = self._get_enabled_features(request)
return Response(data)
@ -44,25 +40,25 @@ class FeaturesAPIView(APIView):
enabled_features = []
if settings.FEATURE_SLACK_INTEGRATION_ENABLED:
enabled_features.append(FEATURE_SLACK)
enabled_features.append(Feature.SLACK)
if settings.FEATURE_TELEGRAM_INTEGRATION_ENABLED:
enabled_features.append(FEATURE_TELEGRAM)
enabled_features.append(Feature.TELEGRAM)
if settings.IS_OPEN_SOURCE:
# Features below should be enabled only in OSS
enabled_features.append(FEATURE_GRAFANA_CLOUD_CONNECTION)
enabled_features.append(Feature.GRAFANA_CLOUD_CONNECTION)
if settings.FEATURE_LIVE_SETTINGS_ENABLED:
enabled_features.append(FEATURE_LIVE_SETTINGS)
enabled_features.append(Feature.LIVE_SETTINGS)
if live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED:
enabled_features.append(FEATURE_GRAFANA_CLOUD_NOTIFICATIONS)
enabled_features.append(Feature.GRAFANA_CLOUD_NOTIFICATIONS)
else:
enabled_features.append(FEATURE_MSTEAMS)
enabled_features.append(Feature.MSTEAMS)
if settings.FEATURE_GRAFANA_ALERTING_V2_ENABLED:
enabled_features.append(FEATURE_GRAFANA_ALERTING_V2)
enabled_features.append(Feature.GRAFANA_ALERTING_V2)
if is_labels_feature_enabled(self.request.auth.organization):
enabled_features.append(FEATURE_LABELS)
enabled_features.append(Feature.LABELS)
return enabled_features

View file

@ -145,6 +145,8 @@ class LabelsViewSet(LabelsFeatureFlagViewSet):
return super().handle_exception(exc)
# specifying a tag explicitly to avoid these endpoints being grouped with alert group endpoints
@extend_schema(tags=["alert group labels"])
class AlertGroupLabelsViewSet(LabelsFeatureFlagViewSet):
"""
This viewset is similar to LabelsViewSet, but it works with alert group labels.

View file

@ -1,4 +1,5 @@
import logging
import typing
import pytz
from django.conf import settings
@ -8,7 +9,9 @@ from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django_filters import rest_framework as filters
from rest_framework import mixins, status, viewsets
from drf_spectacular.plumbing import resolve_type_hint
from drf_spectacular.utils import PolymorphicProxySerializer, 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
@ -60,12 +63,13 @@ from apps.phone_notifications.exceptions import (
from apps.phone_notifications.phone_backend import PhoneBackend
from apps.schedules.ical_utils import get_cached_oncall_users_for_multiple_schedules
from apps.schedules.models import OnCallSchedule
from apps.schedules.models.on_call_schedule import ScheduleEvent
from apps.telegram.client import TelegramClient
from apps.telegram.models import TelegramVerificationCode
from apps.user_management.models import Team, User
from common.api_helpers.exceptions import Conflict
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, TeamModelMultipleChoiceFilter
from common.api_helpers.mixins import FilterSerializerMixin, PublicPrimaryKeyMixin
from common.api_helpers.mixins import PublicPrimaryKeyMixin
from common.api_helpers.paginators import HundredPageSizePaginator
from common.api_helpers.utils import create_engine_url
from common.insight_log import (
@ -86,6 +90,17 @@ UPCOMING_SHIFTS_DEFAULT_DAYS = 7
UPCOMING_SHIFTS_MAX_DAYS = 65
class UpcomingShift(typing.TypedDict):
schedule_id: str
schedule_name: str
is_oncall: bool
current_shift: ScheduleEvent | None
next_shift: ScheduleEvent | None
UpcomingShifts = list[UpcomingShift]
class CurrentUserView(APIView):
authentication_classes = (MobileAppAuthTokenAuthentication, PluginAuthentication)
permission_classes = (IsAuthenticated,)
@ -143,12 +158,15 @@ class UserFilter(ByTeamModelFieldFilterMixin, filters.FilterSet):
class UserView(
PublicPrimaryKeyMixin,
FilterSerializerMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""
Internal API endpoints for users.
"""
authentication_classes = (
MobileAppAuthTokenAuthentication,
PluginAuthentication,
@ -208,7 +226,7 @@ class UserView(
],
}
filter_serializer_class = FilterUserSerializer
queryset = User.objects.none() # needed for drf-spectacular introspection
pagination_class = HundredPageSizePaginator
@ -264,7 +282,7 @@ class UserView(
is_filters_request = query_params.get("filters", "false") == "true"
if is_list_request and is_filters_request:
return self.get_filter_serializer_class()
return FilterUserSerializer
elif is_list_request and self._is_currently_oncall_request():
return UserIsCurrentlyOnCallSerializer
@ -287,6 +305,13 @@ class UserView(
return queryset.order_by("id")
@extend_schema(
responses=PolymorphicProxySerializer(
component_name="UserPolymorphic",
serializers=[FilterUserSerializer, UserIsCurrentlyOnCallSerializer, UserSerializer],
resource_type_field_name=None,
)
)
def list(self, request, *args, **kwargs) -> Response:
queryset = self.filter_queryset(self.get_queryset())
@ -324,6 +349,7 @@ class UserView(
serializer = self.get_serializer(queryset, many=True, context=context)
return Response(serializer.data)
@extend_schema(responses=UserSerializer)
def retrieve(self, request, *args, **kwargs) -> Response:
context = self.get_serializer_context()
@ -345,6 +371,14 @@ class UserView(
serializer = self.get_serializer(instance, context=context)
return Response(serializer.data)
@extend_schema(request=UserSerializer, responses=UserSerializer)
def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)
@extend_schema(request=UserSerializer, responses=UserSerializer)
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
def wrong_team_response(self) -> Response:
"""
This method returns 403 and {"error_code": "wrong_team", "owner_team": {"name", "id", "email", "avatar_url"}}.
@ -371,6 +405,7 @@ class UserView(
serializer = UserSerializer(self.get_queryset().get(pk=self.request.user.pk))
return Response(serializer.data)
@extend_schema(responses={status.HTTP_200_OK: resolve_type_hint(typing.List[str])})
@action(detail=False, methods=["get"])
def timezone_options(self, request) -> Response:
return Response(pytz.common_timezones)
@ -429,6 +464,7 @@ class UserView(
)
return Response(status=status.HTTP_200_OK)
@extend_schema(parameters=[inline_serializer(name="UserVerifyNumber", fields={"token": serializers.CharField()})])
@action(
detail=True,
methods=["put"],
@ -508,6 +544,13 @@ class UserView(
return Response(status=status.HTTP_200_OK)
@extend_schema(
parameters=[
inline_serializer(
name="UserSendTestPush", fields={"critical": serializers.BooleanField(required=False, default=False)}
)
]
)
@action(detail=True, methods=["post"], throttle_classes=[TestPushThrottler])
def send_test_push(self, request, pk) -> Response:
user = self.get_object()
@ -527,6 +570,11 @@ class UserView(
)
return Response(status=status.HTTP_200_OK)
@extend_schema(
parameters=[
inline_serializer(name="UserGetBackendVerificationCode", fields={"backend": serializers.CharField()})
]
)
@action(detail=True, methods=["get"])
def get_backend_verification_code(self, request, pk) -> Response:
user = self.get_object()
@ -539,6 +587,15 @@ class UserView(
code = backend.generate_user_verification_code(user)
return Response(code)
@extend_schema(
responses=inline_serializer(
name="UserGetTelegramVerificationCode",
fields={
"telegram_code": serializers.CharField(),
"bot_link": serializers.CharField(),
},
)
)
@action(detail=True, methods=["get"])
def get_telegram_verification_code(self, request, pk) -> Response:
user = self.get_object()
@ -596,6 +653,9 @@ class UserView(
return Response(status=status.HTTP_400_BAD_REQUEST)
return Response(status=status.HTTP_200_OK)
@extend_schema(
parameters=[inline_serializer(name="UserUnlinkBackend", fields={"backend": serializers.CharField()})]
)
@action(detail=True, methods=["post"])
def unlink_backend(self, request, pk) -> Response:
# TODO: insight logs support
@ -619,6 +679,15 @@ class UserView(
return Response(status=status.HTTP_400_BAD_REQUEST)
return Response(status=status.HTTP_200_OK)
@extend_schema(
parameters=[
inline_serializer(
name="UserUpcomingShiftsParams",
fields={"days": serializers.IntegerField(required=False, default=UPCOMING_SHIFTS_DEFAULT_DAYS)},
)
],
responses={status.HTTP_200_OK: resolve_type_hint(UpcomingShifts)},
)
@action(detail=True, methods=["get"])
def upcoming_shifts(self, request, pk) -> Response:
user = self.get_object()
@ -658,6 +727,28 @@ class UserView(
return Response(upcoming, status=status.HTTP_200_OK)
@extend_schema(
methods=["get"],
responses=inline_serializer(
name="UserExportTokenGetResponse",
fields={
"created_at": serializers.DateTimeField(),
"revoked_at": serializers.DateTimeField(allow_null=True),
"active": serializers.BooleanField(),
},
),
)
@extend_schema(
methods=["post"],
responses=inline_serializer(
name="UserExportTokenPostResponse",
fields={
"token": serializers.CharField(),
"created_at": serializers.DateTimeField(),
"export_url": serializers.CharField(),
},
),
)
@action(detail=True, methods=["get", "post", "delete"])
def export_token(self, request, pk) -> Response:
user = self.get_object()

View file

@ -4,6 +4,7 @@ from typing import Tuple
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from drf_spectacular.extensions import OpenApiAuthenticationExtension
from rest_framework import exceptions
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.request import Request
@ -142,6 +143,22 @@ class PluginAuthentication(BasePluginAuthentication):
raise exceptions.AuthenticationFailed("Non-existent or anonymous user.")
class PluginAuthenticationSchema(OpenApiAuthenticationExtension):
target_class = PluginAuthentication
name = "PluginAuthentication"
def get_security_definition(self, auto_schema):
return {
"type": "apiKey",
"in": "header",
"name": "Authorization",
"description": (
"Additional X-Instance-Context and X-Grafana-Context headers must be set. "
"THIS WILL NOT WORK IN SWAGGER UI."
),
}
class GrafanaIncidentUser(AnonymousUser):
@property
def is_authenticated(self):

View file

@ -95,7 +95,10 @@ class IntegrationHeartBeat(models.Model):
return not self.is_expired
@property
def link(self) -> str:
def link(self) -> str | None:
if not self.alert_receive_channel.integration_url:
return None
return urljoin(self.alert_receive_channel.integration_url, "heartbeat/")
# Insight logs

View file

@ -1,5 +1,6 @@
from typing import Optional, Tuple
from drf_spectacular.extensions import OpenApiAuthenticationExtension
from rest_framework import exceptions
from rest_framework.authentication import BaseAuthentication, get_authorization_header
@ -43,3 +44,15 @@ class MobileAppAuthTokenAuthentication(BaseAuthentication):
return None, None
return auth_token.user, auth_token
class MobileAppAuthTokenAuthenticationSchema(OpenApiAuthenticationExtension):
target_class = MobileAppAuthTokenAuthentication
name = "MobileAppAuthTokenAuthentication"
def get_security_definition(self, auto_schema):
return {
"type": "apiKey",
"in": "header",
"name": "Authorization",
}

View file

@ -1,4 +1,8 @@
CLOUD_NOT_SYNCED = 0
CLOUD_SYNCED_USER_NOT_FOUND = 1
CLOUD_SYNCED_PHONE_NOT_VERIFIED = 2
CLOUD_SYNCED_PHONE_VERIFIED = 3
from enum import IntEnum
class CloudSyncStatus(IntEnum):
NOT_SYNCED = 0
SYNCED_USER_NOT_FOUND = 1
SYNCED_PHONE_NOT_VERIFIED = 2
SYNCED_PHONE_VERIFIED = 3

View file

@ -3,7 +3,7 @@ from urllib.parse import urljoin
from django.utils import timezone
from apps.oss_installation import constants as oss_constants
from apps.oss_installation.constants import CloudSyncStatus
from apps.schedules.ical_utils import list_users_to_notify_from_ical_for_period
logger = logging.getLogger(__name__)
@ -70,15 +70,15 @@ def active_oss_users_count():
def cloud_user_identity_status(connector, identity):
link = None
if connector is None:
status = oss_constants.CLOUD_NOT_SYNCED
status = CloudSyncStatus.NOT_SYNCED
elif identity is None:
status = oss_constants.CLOUD_SYNCED_USER_NOT_FOUND
status = CloudSyncStatus.SYNCED_USER_NOT_FOUND
link = connector.cloud_url
else:
if identity.phone_number_verified:
status = oss_constants.CLOUD_SYNCED_PHONE_VERIFIED
status = CloudSyncStatus.SYNCED_PHONE_VERIFIED
else:
status = oss_constants.CLOUD_SYNCED_PHONE_NOT_VERIFIED
status = CloudSyncStatus.SYNCED_PHONE_NOT_VERIFIED
link = urljoin(connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={identity.cloud_id}")
return status, link

View file

@ -20,12 +20,9 @@ class CloudUsersPagination(HundredPageSizePaginator):
# the override ignore here is expected. The parent classes' get_paginated_response method does not
# take a matched_users_count argument. This is fine in this case
def get_paginated_response(self, data: PaginatedData, matched_users_count: int) -> Response: # type: ignore[override]
return Response(
{
**self._get_paginated_response_data(data),
"matched_users_count": matched_users_count,
}
)
response = super().get_paginated_response(data)
response.data["matched_users_count"] = matched_users_count
return response
class CloudUsersView(CloudUsersPagination, APIView):

View file

@ -147,7 +147,7 @@ class SlackUserIdentity(models.Model):
)
@property
def slack_verbal(self):
def slack_verbal(self) -> str | None:
return (
self.profile_real_name_normalized
or self.profile_real_name

View file

@ -257,7 +257,7 @@ class User(models.Model):
return urljoin(self.organization.grafana_url, self.avatar_url)
@property
def verified_phone_number(self):
def verified_phone_number(self) -> str | None:
"""
Use property to highlight that _verified_phone_number should not be modified directly
"""

View file

@ -1,4 +1,5 @@
from django.core.exceptions import ObjectDoesNotExist
from drf_spectacular.utils import extend_schema_field
from rest_framework import fields, serializers
from rest_framework.exceptions import ValidationError
from rest_framework.relations import RelatedField
@ -9,6 +10,7 @@ from common.api_helpers.exceptions import BadRequest
from common.timezones import raise_exception_if_not_valid_timezone
@extend_schema_field(serializers.CharField)
class OrganizationFilteredPrimaryKeyRelatedField(RelatedField):
"""
This field is used to filter entities by organization
@ -42,6 +44,7 @@ class OrganizationFilteredPrimaryKeyRelatedField(RelatedField):
return self.display_func(instance)
@extend_schema_field(serializers.CharField)
class TeamPrimaryKeyRelatedField(RelatedField):
"""
This field is used to get user teams

View file

@ -3,6 +3,8 @@ from datetime import datetime
from django.db.models import Q
from django_filters import rest_framework as filters
from django_filters.utils import handle_timezone
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from apps.user_management.models import Team
from common.api_helpers.exceptions import BadRequest
@ -48,6 +50,13 @@ class DateRangeFilterMixin:
return start_date, end_date
@extend_schema_field(serializers.CharField)
class MultipleChoiceCharFilter(filters.ModelMultipleChoiceFilter):
"""MultipleChoiceCharFilter with an explicit schema. Otherwise, drf-specacular may generate a wrong schema."""
pass
class ModelFieldFilterMixin:
def filter_model_field(self, queryset, name, value):
if not value:
@ -106,6 +115,7 @@ class ByTeamFilter(ByTeamModelFieldFilterMixin, filters.FilterSet):
)
@extend_schema_field(serializers.CharField)
class TeamModelMultipleChoiceFilter(filters.ModelMultipleChoiceFilter):
def __init__(
self,

View file

@ -6,7 +6,8 @@ from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.db.models import Q
from django.utils.functional import cached_property
from rest_framework import status
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
@ -281,6 +282,21 @@ class PreviewTemplateException(Exception):
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)},
),
)
@action(methods=["post"], detail=True)
def preview_template(self, request, pk):
template_body = request.data.get("template_body", None)

View file

@ -28,40 +28,43 @@ class BasePathPrefixedPagination(BasePagination):
def paginate_queryset(self, queryset, request, view=None):
request.build_absolute_uri = lambda: create_engine_url(request.get_full_path())
# we're setting the request object explicitly here because the way the paginate_quersey works
# between PageNumberPagination and CursorPagination is slightly different. In the latter class,
# it does not set self.request in the paginate_queryset method, whereas in the former it does.
# this leads to an issue in _get_base_paginated_response_data where the self.request would not be set
self.request = request
return super().paginate_queryset(queryset, request, view)
def _get_base_paginated_response_data(self, data: PaginatedData) -> BasePaginatedResponseData:
return {
"next": self.get_next_link(),
"previous": self.get_previous_link(),
"results": data,
"page_size": self.get_page_size(self.request),
}
class PathPrefixedPagePagination(BasePathPrefixedPagination, PageNumberPagination):
def _get_paginated_response_data(self, data: PaginatedData) -> PageBasedPaginationResponseData:
return {
**self._get_base_paginated_response_data(data),
"count": self.page.paginator.count,
"current_page_number": self.page.number,
"total_pages": self.page.paginator.num_pages,
}
def get_paginated_response(self, data: PaginatedData) -> Response:
return Response(self._get_paginated_response_data(data))
response = super().get_paginated_response(data)
response.data.update(
{
"page_size": self.get_page_size(self.request),
"current_page_number": self.page.number,
"total_pages": self.page.paginator.num_pages,
}
)
return response
def get_paginated_response_schema(self, schema):
paginated_schema = super().get_paginated_response_schema(schema)
paginated_schema["properties"].update(
{
"page_size": {"type": "integer"},
"current_page_number": {"type": "integer"},
"total_pages": {"type": "integer"},
}
)
return paginated_schema
class PathPrefixedCursorPagination(BasePathPrefixedPagination, CursorPagination):
def get_paginated_response(self, data: PaginatedData) -> Response:
return Response(self._get_base_paginated_response_data(data))
response = super().get_paginated_response(data)
response.data.update({"page_size": self.page_size})
return response
def get_paginated_response_schema(self, schema):
paginated_schema = super().get_paginated_response_schema(schema)
paginated_schema["properties"].update({"page_size": {"type": "integer"}})
return paginated_schema
class HundredPageSizePaginator(PathPrefixedPagePagination):

47
engine/engine/schema.py Normal file
View file

@ -0,0 +1,47 @@
from drf_spectacular.openapi import AutoSchema
from drf_spectacular.plumbing import get_view_model
from common.api_helpers.mixins import PublicPrimaryKeyMixin
class CustomAutoSchema(AutoSchema):
def _get_serializer(self):
"""Makes so that extra actions (@action on viewset) don't inherit the serializer from the viewset."""
if self._is_extra_action:
return None
return super()._get_serializer()
def _get_paginator(self):
"""Makes so that extra actions (@action on viewset) don't inherit the paginator from the viewset."""
if self._is_extra_action:
return None
return super()._get_paginator()
def get_filter_backends(self):
"""Makes so that extra actions (@action on viewset) don't inherit the filter backends from the viewset."""
if self._is_extra_action:
return []
return super().get_filter_backends()
def _resolve_path_parameters(self, variables):
"""A workaround to make public primary keys appear as strings in the OpenAPI schema."""
parameters = super()._resolve_path_parameters(variables)
if not isinstance(self.view, PublicPrimaryKeyMixin):
return parameters
for parameter in parameters:
if parameter["name"] == "id" and parameter["in"] == "path":
parameter["schema"]["type"] = "string"
model = get_view_model(self.view, emit_warnings=False)
model_name = model._meta.verbose_name if model else "resource"
parameter["description"] = f"A string identifying this {model_name}."
return parameters
@property
def _is_extra_action(self) -> bool:
try:
return self.view.action in [action.__name__ for action in self.view.get_extra_actions()]
except AttributeError:
return False

View file

@ -287,7 +287,7 @@ REST_FRAMEWORK = {
"rest_framework.parsers.MultiPartParser",
),
"DEFAULT_AUTHENTICATION_CLASSES": [],
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_SCHEMA_CLASS": "engine.schema.CustomAutoSchema",
}
@ -315,6 +315,8 @@ if SWAGGER_UI_SETTINGS_URL:
SPECTACULAR_INCLUDED_PATHS = [
"/features",
"/alertgroups",
"/alert_receive_channels",
"/users",
"/labels",
]

View file

@ -55,5 +55,5 @@ REST_FRAMEWORK = {
),
"DEFAULT_AUTHENTICATION_CLASSES": [],
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_SCHEMA_CLASS": "engine.schema.CustomAutoSchema",
}