Merge pull request #3675 from grafana/dev

v1.3.86
This commit is contained in:
Matias Bordese 2024-01-12 14:24:36 -03:00 committed by GitHub
commit 128240000d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
76 changed files with 1031 additions and 585 deletions

View file

@ -5,7 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
## v1.3.86 (2024-01-12)
### Fixed
- Fix unicode characters not rendering correctly in webhooks @mderynck ([#3670](https://github.com/grafana/oncall/pull/3670))
- UI bug related to time inputs for "current UTC time is in" range escalation policy step ([#3585](https://github.com/grafana/oncall/issues/3585)]
- MS Teams Connection user profile tab - shouldn't reshow connection steps if already connected ([#2427](https://github.com/grafana/oncall-private/issues/2427))
- Fix internal schedule detail API to set oncall_now for a schedule in orgs with multiple entries ([#3671](https://github.com/grafana/oncall/pull/3671))
## v1.3.85 (2024-01-12)
Maintenance release
## v1.3.84 (2024-01-10)

View file

@ -11,7 +11,7 @@ if not running_under_parent_tiltfile:
# Load the custom Grafana extensions
v1alpha1.extension_repo(
name="grafana-tilt-extensions",
ref="main",
ref="v1.2.0",
url="https://github.com/grafana/tilt-extensions",
)
v1alpha1.extension(

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

@ -565,6 +565,69 @@ def test_get_detail_web_schedule(
assert response.data == expected_payload
@pytest.mark.django_db
def test_get_detail_schedule_oncall_now_multipage_objects(
make_organization_and_user_with_plugin_token, make_schedule, make_on_call_shift, make_user_auth_headers
):
organization, user, token = make_organization_and_user_with_plugin_token()
# make sure our schedule would be in the second page of the listing page
for i in range(16):
make_schedule(organization, schedule_class=OnCallScheduleWeb, name=f"schedule {i}")
schedule = make_schedule(
organization,
schedule_class=OnCallScheduleWeb,
name="test_web_schedule",
)
now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
start_date = now - timezone.timedelta(days=7)
data = {
"start": start_date,
"rotation_start": start_date,
"duration": timezone.timedelta(seconds=86400),
"priority_level": 1,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
on_call_shift.add_rolling_users([[user]])
client = APIClient()
url = reverse("api-internal:schedule-detail", kwargs={"pk": schedule.public_primary_key})
expected_payload = {
"id": schedule.public_primary_key,
"team": None,
"name": "test_web_schedule",
"type": 2,
"time_zone": "UTC",
"slack_channel": None,
"user_group": None,
"warnings": [],
"on_call_now": [
{
"pk": user.public_primary_key,
"username": user.username,
"avatar": user.avatar_url,
"avatar_full": user.avatar_full_url,
}
],
"has_gaps": False,
"mention_oncall_next": False,
"mention_oncall_start": True,
"notify_empty_oncall": 0,
"notify_oncall_shift_freq": 1,
"number_of_escalation_chains": 0,
"enable_web_overrides": True,
}
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.data == expected_payload
@pytest.mark.django_db
def test_create_calendar_schedule(schedule_internal_api_setup, make_user_auth_headers):
user, token, _, _, _, _ = schedule_internal_api_setup

View file

@ -287,6 +287,48 @@ def test_list_users_filtered_by_public_primary_key(
assert returned_user_pks == [user1.public_primary_key]
@pytest.mark.django_db
def test_list_users_filtered_by_team(
make_organization,
make_team,
make_user_for_organization,
make_token_for_organization,
make_user_auth_headers,
):
organization = make_organization()
user1 = make_user_for_organization(organization)
user2 = make_user_for_organization(organization)
team1 = make_team(organization)
team2 = make_team(organization)
team3 = make_team(organization)
user1.teams.add(team1)
user1.teams.add(team2)
user2.teams.add(team2)
_, token = make_token_for_organization(organization)
client = APIClient()
url = reverse("api-internal:user-list")
def _get_user_pks(teams):
response = client.get(
url,
data={"team": [team.public_primary_key for team in teams]}, # these are query params
**make_user_auth_headers(user1, token),
)
assert response.status_code == status.HTTP_200_OK
return [u["pk"] for u in response.json()["results"]]
assert _get_user_pks([team1]) == [user1.public_primary_key]
assert _get_user_pks([team1, team2]) == [user1.public_primary_key, user2.public_primary_key]
assert _get_user_pks([team3]) == []
# check non-existent team returns bad request
response = client.get(f"{url}?team=null", **make_user_auth_headers(user1, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_notification_chain_verbal(
make_organization,

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

@ -137,8 +137,14 @@ class ScheduleView(
The result of this method is cached and is reused for the whole lifetime of a request,
since self.get_serializer_context() is called multiple times for every instance in the queryset.
"""
current_page_schedules = self.paginate_queryset(self.filter_queryset(self.get_queryset(annotate=False)))
return get_oncall_users_for_multiple_schedules(current_page_schedules)
current_schedules = self.get_queryset(annotate=False).none()
if self.action == "list":
# listing page, only get oncall users for current page schedules
current_schedules = self.paginate_queryset(self.filter_queryset(self.get_queryset(annotate=False)))
elif self.kwargs.get("pk"):
# if this is a particular schedule detail, only consider it as current
current_schedules = [self.get_object(annotate=False)]
return get_oncall_users_for_multiple_schedules(current_schedules)
def get_serializer_context(self):
context = super().get_serializer_context()

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,11 +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.mixins import FilterSerializerMixin, PublicPrimaryKeyMixin
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, TeamModelMultipleChoiceFilter
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 (
@ -85,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,)
@ -113,7 +129,7 @@ class CurrentUserView(APIView):
return Response(serializer.data)
class UserFilter(filters.FilterSet):
class UserFilter(ByTeamModelFieldFilterMixin, filters.FilterSet):
"""
https://django-filter.readthedocs.io/en/master/guide/rest_framework.html
"""
@ -122,6 +138,7 @@ class UserFilter(filters.FilterSet):
# TODO: remove "roles" in next version
roles = filters.MultipleChoiceFilter(field_name="role", choices=LegacyAccessControlRole.choices())
permission = filters.ChoiceFilter(method="filter_by_permission", choices=ALL_PERMISSION_CHOICES)
team = TeamModelMultipleChoiceFilter(field_name="teams", null_label=None, null_value=None)
class Meta:
model = User
@ -141,12 +158,15 @@ class UserFilter(filters.FilterSet):
class UserView(
PublicPrimaryKeyMixin,
FilterSerializerMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""
Internal API endpoints for users.
"""
authentication_classes = (
MobileAppAuthTokenAuthentication,
PluginAuthentication,
@ -206,7 +226,7 @@ class UserView(
],
}
filter_serializer_class = FilterUserSerializer
queryset = User.objects.none() # needed for drf-spectacular introspection
pagination_class = HundredPageSizePaginator
@ -262,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
@ -285,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())
@ -322,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()
@ -343,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"}}.
@ -369,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)
@ -427,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"],
@ -506,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()
@ -525,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()
@ -537,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()
@ -594,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
@ -617,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()
@ -656,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

@ -92,6 +92,46 @@ def test_notify_user_about_new_alert_group_no_device_connected(
)
@patch("apps.mobile_app.utils.send_message_to_fcm_device", return_value=False)
@pytest.mark.django_db
def test_notify_user_about_new_alert_group_error_log_if_not_succeeded(
mock_send_message_to_fcm_device,
settings,
make_organization_and_user,
make_user_notification_policy,
make_alert_receive_channel,
make_channel_filter,
make_alert_group,
make_alert,
):
# create a user and connect a mobile device
organization, user = make_organization_and_user()
FCMDevice.objects.create(user=user, registration_id="test_device_id")
# set up notification policy and alert group
notification_policy = make_user_notification_policy(
user,
UserNotificationPolicy.Step.NOTIFY,
notify_by=MOBILE_APP_BACKEND_ID,
)
alert_receive_channel = make_alert_receive_channel(organization=organization)
channel_filter = make_channel_filter(alert_receive_channel)
alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter)
make_alert(alert_group=alert_group, raw_request_data={})
# check FCM is contacted directly when using the cloud license
settings.LICENSE = CLOUD_LICENSE_NAME
settings.IS_OPEN_SOURCE = False
notify_user_about_new_alert_group(
user_pk=user.pk,
alert_group_pk=alert_group.pk,
notification_policy_pk=notification_policy.pk,
critical=False,
)
mock_send_message_to_fcm_device.assert_called_once()
log_record = alert_group.personal_log_records.last()
assert log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED
@pytest.mark.django_db
def test_fcm_message_user_settings(
make_organization_and_user, make_alert_receive_channel, make_alert_group, make_alert

View file

@ -44,7 +44,7 @@ def _send_push_notification_to_fcm_relay(message: Message) -> requests.Response:
return response
def send_message_to_fcm_device(device: "FCMDevice", message: Message) -> None:
def send_message_to_fcm_device(device: "FCMDevice", message: Message) -> bool:
"""
https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream
"""
@ -60,9 +60,10 @@ def send_message_to_fcm_device(device: "FCMDevice", message: Message) -> None:
if isinstance(response, FIREBASE_ERRORS_TO_NOT_RETRY):
logger.warning(f"FCM error {response} is not being retried as we explicitly do not want to retry it")
return
return False
raise response
return True
def send_push_notification(
@ -97,7 +98,10 @@ def send_push_notification(
else:
raise
else:
send_message_to_fcm_device(device_to_notify, message)
succeeded = send_message_to_fcm_device(device_to_notify, message)
if not succeeded:
_error_cb()
return False
# notification succeeded (otherwise raised exception before)
return True

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

@ -158,6 +158,7 @@ class TelegramToUserConnector(models.Model):
)
else:
self._nudge_about_alert_group_message(telegram_client, old_alert_group_message)
TelegramToUserConnector.create_telegram_notification_success(alert_group, self.user, notification_policy)
# send DM message with the link to the alert group post in channel
def send_link_to_channel_message(self, alert_group: AlertGroup, notification_policy: UserNotificationPolicy):

View file

@ -5,7 +5,7 @@ from telegram import error
from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord
from apps.telegram.client import TelegramClient
from apps.telegram.models import TelegramMessage
from apps.telegram.models import TelegramMessage, TelegramToUserConnector
@patch.object(TelegramClient, "send_raw_message", side_effect=error.BadRequest("Replied message not found"))
@ -188,6 +188,43 @@ def test_personal_connector_send_full_alert_group(
assert log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS
@patch.object(TelegramToUserConnector, "_nudge_about_alert_group_message")
@pytest.mark.django_db
def test_personal_connector_send_full_alert_group_second_time(
mock_nudge_about_alert_group_message,
make_organization_and_user,
make_telegram_user_connector,
make_user_notification_policy,
make_alert_receive_channel,
make_alert_group,
make_alert,
make_telegram_message,
):
# set up a user with Telegram account connected
organization, user = make_organization_and_user()
connector = make_telegram_user_connector(user)
notification_policy = make_user_notification_policy(
user,
UserNotificationPolicy.Step.NOTIFY,
notify_by=UserNotificationPolicy.NotificationChannel.TELEGRAM,
important=False,
)
# create an alert group with an existing Telegram message in channel
alert_receive_channel = make_alert_receive_channel(organization)
alert_group = make_alert_group(alert_receive_channel)
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
make_telegram_message(
alert_group=alert_group,
message_type=TelegramMessage.PERSONAL_MESSAGE,
chat_id=connector.telegram_chat_id,
)
user.telegram_connection.send_full_alert_group(alert_group, notification_policy)
mock_nudge_about_alert_group_message.assert_called_once()
log_record = notification_policy.personal_log_records.last()
assert log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS
@patch.object(TelegramClient, "send_message", side_effect=[error.BadRequest("error"), None])
@pytest.mark.django_db
def test_personal_connector_send_full_alert_group_formatting_error(

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

@ -278,3 +278,21 @@ def test_escaping_payload_with_single_quote_in_string(make_organization, make_cu
}
request_kwargs = webhook.build_request_kwargs(payload)
assert request_kwargs == {"headers": {}, "json": {"data": "{'text': \"Hi, it's alert\"}"}}
@pytest.mark.django_db
def test_escaping_unicode_in_string(make_organization, make_custom_webhook):
organization = make_organization()
webhook = make_custom_webhook(
organization=organization,
data='{"data" : "{{ alert_payload.text }}"}',
forward_all=False,
)
payload = {
"alert_payload": {
"text": "東京",
}
}
request_kwargs = webhook.build_request_kwargs(payload)
assert request_kwargs == {"headers": {}, "json": {"data": "東京"}}

View file

@ -82,7 +82,7 @@ def escape_string(string: str):
json.dumps is the simples way to escape all special characters in string.
First and last chars are quotes from json.dumps(), we don't need them, only escaping.
"""
return json.dumps(string)[1:-1]
return json.dumps(string, ensure_ascii=False)[1:-1]
class EscapeDoubleQuotesDict(dict):

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:
@ -85,8 +94,7 @@ class ByTeamModelFieldFilterMixin:
if null_team_lookup is not None:
teams_lookup = teams_lookup | null_team_lookup
queryset = queryset.filter(teams_lookup)
return queryset
return queryset.filter(teams_lookup).distinct()
def get_team_queryset(request):
@ -107,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",
}

View file

@ -190,7 +190,6 @@ class GForm extends React.Component<GFormProps, {}> {
{({ register, errors, control, getValues, setValue }) => {
const renderField = (formItem: FormItem, formIndex: number) => {
if (formItem.isVisible && !formItem.isVisible(getValues())) {
setValue(formItem.name, undefined); // clear input value on hide
return null;
}

View file

@ -59,6 +59,9 @@ const TimeRange = (props: TimeRangeProps) => {
const handleChangeFrom = useCallback(
(value: moment.Moment) => {
if (!value.isValid()) {
return;
}
setFrom(value);
if (value.isSame(to, 'minute')) {
@ -74,6 +77,9 @@ const TimeRange = (props: TimeRangeProps) => {
const handleChangeTo = useCallback(
(value: moment.Moment) => {
if (!value.isValid()) {
return;
}
setTo(value);
if (value.isSame(from, 'minute')) {

View file

@ -60,7 +60,7 @@ const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserInfo }: U
isCurrent && organizationStore.currentOrganization?.slack_team_identity && !storeUser.slack_user_identity,
isCurrent && store.hasFeature(AppFeature.Telegram) && !storeUser.telegram_configuration,
isCurrent,
store.hasFeature(AppFeature.MsTeams),
store.hasFeature(AppFeature.MsTeams) && !storeUser.messaging_backends.MSTEAMS,
];
const title = (

View file

@ -9,6 +9,7 @@ import { useStore } from 'state/useStore';
import ICalConnector from './ICalConnector';
// import MobileAppConnector from './MobileAppConnector';
import MSTeamsConnector from './MSTeamsConnector';
import MobileAppConnector from './MobileAppConnector';
import PhoneConnector from './PhoneConnector';
import SlackConnector from './SlackConnector';
@ -27,6 +28,7 @@ export const Connectors: FC<ConnectorsProps> = (props) => {
<MobileAppConnector {...props} />
<SlackConnector {...props} />
{store.hasFeature(AppFeature.Telegram) && <TelegramConnector {...props} />}
{store.hasFeature(AppFeature.MsTeams) && <MSTeamsConnector {...props} />}
<Legend>Calendar export</Legend>
<ICalConnector {...props} />
</>

View file

@ -9,7 +9,7 @@ import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
import { User } from 'models/user/user.types';
import { useStore } from 'state/useStore';
import styles from 'containers/UserSettings/parts/connectors/index.module.css';
import styles from 'containers/UserSettings/parts/connectors/Connectors.module.css';
const cx = cn.bind(styles);

View file

@ -1,35 +0,0 @@
import React, { FC } from 'react';
import { Legend } from '@grafana/ui';
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
import { User } from 'models/user/user.types';
import { AppFeature } from 'state/features';
import { useStore } from 'state/useStore';
import ICalConnector from './ICalConnector';
import MSTeamsConnector from './MSTeamsConnector';
import MobileAppConnector from './MobileAppConnector';
import PhoneConnector from './PhoneConnector';
import SlackConnector from './SlackConnector';
import TelegramConnector from './TelegramConnector';
interface ConnectorsProps {
id: User['pk'];
onTabChange: (tab: UserSettingsTab) => void;
}
export const Connectors: FC<ConnectorsProps> = (props) => {
const store = useStore();
return (
<>
<PhoneConnector {...props} />
<MobileAppConnector {...props} />
<SlackConnector {...props} />
{store.hasFeature(AppFeature.Telegram) && <TelegramConnector {...props} />}
{store.hasFeature(AppFeature.MsTeams) && <MSTeamsConnector {...props} />}
<Legend>Calendar export</Legend>
<ICalConnector {...props} />
</>
);
};

View file

@ -68,7 +68,6 @@ export class AlertReceiveChannelStore extends BaseStore {
this.path = '/alert_receive_channels/';
}
@action.bound
getSearchResult(_query = '') {
if (!this.searchResult) {
return undefined;
@ -79,7 +78,6 @@ export class AlertReceiveChannelStore extends BaseStore {
);
}
@action.bound
getPaginatedSearchResult(_query = '') {
if (!this.paginatedSearchResult) {
return undefined;
@ -94,7 +92,7 @@ export class AlertReceiveChannelStore extends BaseStore {
};
}
@action.bound
@action
async loadItem(id: AlertReceiveChannel['id'], skipErrorHandling = false): Promise<AlertReceiveChannel> {
const alertReceiveChannel = await this.getById(id, skipErrorHandling);
@ -111,7 +109,7 @@ export class AlertReceiveChannelStore extends BaseStore {
return alertReceiveChannel;
}
@action.bound
@action
async updateItems(query: any = '') {
const params = typeof query === 'string' ? { search: query } : query;
@ -141,7 +139,6 @@ export class AlertReceiveChannelStore extends BaseStore {
return results;
}
@action.bound
async updatePaginatedItems({
filters,
page = 1,
@ -189,7 +186,6 @@ export class AlertReceiveChannelStore extends BaseStore {
return results;
}
@action.bound
populateHearbeats(alertReceiveChannels: AlertReceiveChannel[]) {
const heartbeats = alertReceiveChannels.reduce((acc: any, alertReceiveChannel: AlertReceiveChannel) => {
if (alertReceiveChannel.heartbeat) {
@ -225,7 +221,7 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
@action.bound
@action
async updateChannelFilters(alertReceiveChannelId: AlertReceiveChannel['id'], isOverwrite = false) {
const response = await makeRequest(`/channel_filters/`, {
params: { alert_receive_channel: alertReceiveChannelId },
@ -263,7 +259,7 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
@action.bound
@action
async updateChannelFilter(channelFilterId: ChannelFilter['id']) {
const response = await makeRequest(`/channel_filters/${channelFilterId}/`, {});
@ -274,14 +270,13 @@ export class AlertReceiveChannelStore extends BaseStore {
};
});
}
@action.bound
async migrateChannel(id: AlertReceiveChannel['id']) {
return await makeRequest(`/alert_receive_channels/${id}/migrate`, {
method: 'POST',
});
}
@action.bound
async createChannelFilter(data: Partial<ChannelFilter>) {
return await makeRequest('/channel_filters/', {
method: 'POST',
@ -289,7 +284,7 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
@action.bound
@action
async saveChannelFilter(channelFilterId: ChannelFilter['id'], data: Partial<ChannelFilter>) {
const response = await makeRequest(`/channel_filters/${channelFilterId}/`, {
method: 'PUT',
@ -306,7 +301,7 @@ export class AlertReceiveChannelStore extends BaseStore {
return response;
}
@action.bound
@action
async moveChannelFilterToPosition(
alertReceiveChannelId: AlertReceiveChannel['id'],
oldIndex: number,
@ -325,7 +320,7 @@ export class AlertReceiveChannelStore extends BaseStore {
this.updateChannelFilters(alertReceiveChannelId, true);
}
@action.bound
@action
async deleteChannelFilter(channelFilterId: ChannelFilter['id']) {
const channelFilter = this.channelFilters[channelFilterId];
@ -350,7 +345,6 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
@action.bound
getIntegration(alertReceiveChannel: Partial<AlertReceiveChannel>): SelectOption {
return (
this.alertReceiveChannelOptions &&
@ -374,12 +368,11 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
@action.bound
async deleteAlertReceiveChannel(id: AlertReceiveChannel['id']) {
return await this.delete(id);
}
@action.bound
@action
async updateTemplates(alertReceiveChannelId: AlertReceiveChannel['id'], alertGroupId?: Alert['pk']) {
const response = await makeRequest(`/alert_receive_channel_templates/${alertReceiveChannelId}/`, {
params: { alert_group_id: alertGroupId },
@ -394,7 +387,7 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
@action.bound
@action
async updateItem(id: AlertReceiveChannel['id']) {
const item = await this.getById(id);
@ -406,7 +399,7 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
@action.bound
@action
async saveTemplates(alertReceiveChannelId: AlertReceiveChannel['id'], data: Partial<AlertTemplatesDTO>) {
const response = await makeRequest(`/alert_receive_channel_templates/${alertReceiveChannelId}/`, {
method: 'PUT',
@ -422,12 +415,11 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
@action.bound
async getGrafanaAlertingContactPoints() {
return await makeRequest(`${this.path}contact_points/`, {}).catch(showApiError);
}
@action.bound
@action
async updateConnectedContactPoints(alertReceiveChannelId: AlertReceiveChannel['id']) {
const response = await makeRequest(`${this.path}${alertReceiveChannelId}/connected_contact_points `, {});
@ -451,7 +443,6 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
@action.bound
async connectContactPoint(
alertReceiveChannelId: AlertReceiveChannel['id'],
datasource_uid: string,
@ -466,7 +457,6 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
@action.bound
async disconnectContactPoint(
alertReceiveChannelId: AlertReceiveChannel['id'],
datasource_uid: string,
@ -481,7 +471,6 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
@action.bound
async createContactPoint(
alertReceiveChannelId: AlertReceiveChannel['id'],
datasource_uid: string,
@ -496,14 +485,12 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
@action.bound
async getAccessLogs(alertReceiveChannelId: AlertReceiveChannel['id']) {
const { integration_log } = await makeRequest(`/alert_receive_channel_access_log/${alertReceiveChannelId}/`, {});
return integration_log;
}
@action.bound
async sendDemoAlert(id: AlertReceiveChannel['id'], payload: string = undefined) {
const requestConfig: any = {
method: 'POST',
@ -518,12 +505,10 @@ export class AlertReceiveChannelStore extends BaseStore {
await makeRequest(`${this.path}${id}/send_demo_alert/`, requestConfig).catch(showApiError);
}
@action.bound
async sendDemoAlertToParticularRoute(id: ChannelFilter['id']) {
await makeRequest(`/channel_filters/${id}/send_demo_alert/`, { method: 'POST' }).catch(showApiError);
}
@action.bound
async convertRegexpTemplateToJinja2Template(id: ChannelFilter['id']) {
const result = await makeRequest(`/channel_filters/${id}/convert_from_regex_to_jinja2/`, { method: 'POST' }).catch(
showApiError
@ -531,7 +516,6 @@ export class AlertReceiveChannelStore extends BaseStore {
return result;
}
@action.bound
async renderPreview(id: AlertReceiveChannel['id'], template_name: string, template_body: string, payload: JSON) {
return await makeRequest(`${this.path}${id}/preview_template/`, {
method: 'POST',
@ -539,7 +523,6 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
@action.bound
async changeTeam(id: AlertReceiveChannel['id'], teamId: GrafanaTeam['id']) {
return await makeRequest(`${this.path}${id}/change_team`, {
params: { team_id: String(teamId) },
@ -547,7 +530,7 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
@action.bound
@action
async updateCounters() {
const counters = await makeRequest(`${this.path}counters`, {
method: 'GET',
@ -558,7 +541,7 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
@action.bound
@action
async updateCountersForIntegration(id: AlertReceiveChannel['id']): Promise<any> {
const counters = await makeRequest(`${this.path}${id}/counters`, {
method: 'GET',
@ -576,7 +559,6 @@ export class AlertReceiveChannelStore extends BaseStore {
return counters;
}
@action.bound
startMaintenanceMode = (id: AlertReceiveChannel['id'], mode: MaintenanceMode, duration: number): Promise<void> =>
makeRequest<null>(`${this.path}${id}/start_maintenance/`, {
method: 'POST',
@ -586,13 +568,11 @@ export class AlertReceiveChannelStore extends BaseStore {
},
});
@action.bound
stopMaintenanceMode = (id: AlertReceiveChannel['id']) =>
makeRequest<null>(`${this.path}${id}/stop_maintenance/`, {
method: 'POST',
});
@action.bound
addLabel = (id: AlertReceiveChannel['id'], data) => {
makeRequest(`${this.path}${id}/associate_label`, {
method: 'POST',

View file

@ -20,7 +20,6 @@ export class AlertReceiveChannelFiltersStore extends BaseStore {
this.path = '/alert_receive_channels/';
}
@action.bound
getSearchResult() {
if (!this.searchResult) {
return undefined;
@ -29,7 +28,7 @@ export class AlertReceiveChannelFiltersStore extends BaseStore {
return this.searchResult.map((value: SelectOption['value']) => this.items?.[value]);
}
@action.bound
@action
async updateItems(query = '') {
const results = await makeRequest(`${this.path}`, {
params: { search: query, filters: true },

View file

@ -84,7 +84,6 @@ export class AlertGroupStore extends BaseStore {
this.path = '/alertgroups/';
}
@action.bound
async attachAlert(pk: Alert['pk'], rootPk: Alert['pk']) {
return await makeRequest(`${this.path}${pk}/attach/`, {
method: 'POST',
@ -92,14 +91,13 @@ export class AlertGroupStore extends BaseStore {
}).catch(showApiError);
}
@action.bound
async unattachAlert(pk: Alert['pk']) {
return await makeRequest(`${this.path}${pk}/unattach/`, {
method: 'POST',
}).catch(showApiError);
}
@action.bound
@action
async updateItem(id: Alert['pk']) {
const item = await this.getById(id);
@ -111,7 +109,6 @@ export class AlertGroupStore extends BaseStore {
});
}
@action.bound
getSearchResult(query = '') {
if (!this.searchResult[query]) {
return undefined;
@ -120,7 +117,6 @@ export class AlertGroupStore extends BaseStore {
return this.searchResult[query].map((id: Alert['pk']) => this.items[id]);
}
@action.bound
async getAlertGroupsForIntegration(integrationId: AlertReceiveChannel['id']) {
const { results } = await makeRequest(`${this.path}`, {
params: { integration: integrationId },
@ -128,12 +124,10 @@ export class AlertGroupStore extends BaseStore {
return results;
}
@action.bound
async getAlertsFromGroup(pk: Alert['pk']) {
return await makeRequest(`${this.path}${pk}`, {});
}
@action.bound
async updateSilenceOptions() {
const result = await makeRequest(`${this.path}silence_options/`, {});
@ -142,7 +136,6 @@ export class AlertGroupStore extends BaseStore {
});
}
@action.bound
async resolve(id: Alert['pk'], delay: number) {
await makeRequest(`${this.path}${id}/silence/`, {
method: 'POST',
@ -150,28 +143,24 @@ export class AlertGroupStore extends BaseStore {
});
}
@action.bound
async unresolve(id: Alert['pk']) {
await makeRequest(`${this.path}${id}/unresolve/`, {
method: 'POST',
});
}
@action.bound
async acknowledge(id: Alert['pk']) {
await makeRequest(`${this.path}${id}/acknowledge/`, {
method: 'POST',
});
}
@action.bound
async unacknowledge(id: Alert['pk']) {
await makeRequest(`${this.path}${id}/unacknowledge/`, {
method: 'POST',
});
}
@action.bound
async silence(id: Alert['pk'], delay: number) {
await makeRequest(`${this.path}${id}/silence/`, {
method: 'POST',
@ -179,14 +168,13 @@ export class AlertGroupStore extends BaseStore {
});
}
@action.bound
async unsilence(id: Alert['pk']) {
await makeRequest(`${this.path}${id}/unsilence/`, {
method: 'POST',
});
}
@action.bound
@action
async updateBulkActions() {
const response = await makeRequest(`${this.path}bulk_action_options/`, {});
@ -201,7 +189,6 @@ export class AlertGroupStore extends BaseStore {
});
}
@action.bound
async bulkAction(data: any) {
return await makeRequest(`${this.path}bulk_action/`, {
method: 'POST',
@ -209,7 +196,6 @@ export class AlertGroupStore extends BaseStore {
});
}
@action.bound
async renderPreview(id: Alert['pk'], template_name: string, template_body: string) {
return await makeRequest(`${this.path}${id}/preview_template/`, {
method: 'POST',
@ -219,7 +205,6 @@ export class AlertGroupStore extends BaseStore {
// methods were moved from rootBaseStore.
// TODO check if methods are dublicating existing ones
@action.bound
async updateIncidents() {
await Promise.all([
this.getNewIncidentsStats(),
@ -232,12 +217,12 @@ export class AlertGroupStore extends BaseStore {
this.setLiveUpdatesPaused(false);
}
@action.bound
@action
setLiveUpdatesPaused(value: boolean) {
this.liveUpdatesPaused = value;
}
@action.bound
@action
async updateIncidentFilters(params: any, keepCursor = false) {
if (!keepCursor) {
this.setIncidentsCursor(undefined);
@ -248,28 +233,28 @@ export class AlertGroupStore extends BaseStore {
await this.updateIncidents();
}
@action.bound
@action
async updateIncidentsCursor(cursor: string) {
this.setIncidentsCursor(cursor);
this.updateAlertGroups();
}
@action.bound
@action
async setIncidentsCursor(cursor: string) {
this.incidentsCursor = cursor;
LocationHelper.update({ cursor }, 'partial');
}
@action.bound
@action
async setIncidentsItemsPerPage() {
this.setIncidentsCursor(undefined);
this.updateAlertGroups();
}
@action.bound
@action
async updateAlertGroups() {
this.alertGroupsLoading = true;
@ -313,7 +298,6 @@ export class AlertGroupStore extends BaseStore {
});
}
@action.bound
getAlertSearchResult(query: string) {
const result = this.alertsSearchResult[query];
if (!result) {
@ -328,7 +312,6 @@ export class AlertGroupStore extends BaseStore {
};
}
@action.bound
async getAlert(pk: Alert['pk']) {
return await makeRequest(`${this.path}${pk}`, {}).then((alert: Alert) => {
runInAction(() => {
@ -343,7 +326,7 @@ export class AlertGroupStore extends BaseStore {
return await makeRequest(`/alerts/${pk}`, {});
}
@action.bound
@action
async getNewIncidentsStats() {
const result = await makeRequest(`${this.path}stats/`, {
params: {
@ -357,7 +340,7 @@ export class AlertGroupStore extends BaseStore {
});
}
@action.bound
@action
async getAcknowledgedIncidentsStats() {
const result = await makeRequest(`${this.path}stats/`, {
params: {
@ -371,7 +354,7 @@ export class AlertGroupStore extends BaseStore {
});
}
@action.bound
@action
async getResolvedIncidentsStats() {
const result = await makeRequest(`${this.path}stats/`, {
params: {
@ -385,7 +368,7 @@ export class AlertGroupStore extends BaseStore {
});
}
@action.bound
@action
async getSilencedIncidentsStats() {
const result = await makeRequest(`${this.path}stats/`, {
params: {
@ -399,7 +382,7 @@ export class AlertGroupStore extends BaseStore {
});
}
@action.bound
@action
async doIncidentAction(alertId: Alert['pk'], action: AlertAction, isUndo = false, data?: any) {
this.updateAlert(alertId, { loading: true });
@ -446,7 +429,7 @@ export class AlertGroupStore extends BaseStore {
}
}
@action.bound
@action
async updateAlert(pk: Alert['pk'], value: Partial<Alert>) {
this.alerts.set(pk, {
...(this.alerts.get(pk) as Alert),
@ -454,7 +437,6 @@ export class AlertGroupStore extends BaseStore {
});
}
@action.bound
async unpageUser(alertId: Alert['pk'], userId: User['pk']) {
return await makeRequest(`${this.path}${alertId}/unpage_user`, {
method: 'POST',
@ -462,7 +444,7 @@ export class AlertGroupStore extends BaseStore {
}).catch(this.onApiError);
}
@action.bound
@action
async fetchTableSettings(): Promise<void> {
const tableSettings = await makeRequest('/alertgroup_table_settings', {});
@ -477,7 +459,7 @@ export class AlertGroupStore extends BaseStore {
});
}
@action.bound
@action
@AutoLoadingState(ActionKey.ADD_NEW_COLUMN_TO_ALERT_GROUP)
async updateTableSettings(
columns: { visible: AlertGroupColumn[]; hidden: AlertGroupColumn[] },
@ -495,21 +477,18 @@ export class AlertGroupStore extends BaseStore {
});
}
@action.bound
async resetTableSettings(): Promise<void> {
return await makeRequest('/alertgroup_table_settings/reset', { method: 'POST' }).catch(() =>
openErrorNotification('There was an error resetting the table settings')
);
}
@action.bound
async loadLabelsKeys(): Promise<Array<ApiSchemas['LabelKey']>> {
return await makeRequest(`/alertgroups/labels/keys/`, {}).catch(() =>
openErrorNotification('There was an error processing your request')
);
}
@action.bound
async loadValuesForLabelKey(
key: ApiSchemas['LabelKey']['id'],
search = ''

View file

@ -21,7 +21,7 @@ export class ApiTokenStore extends BaseStore {
this.path = '/tokens/';
}
@action.bound
@action
async updateItems(query = '') {
const results = await makeRequest(`${this.path}`, {
params: { search: query },
@ -46,7 +46,6 @@ export class ApiTokenStore extends BaseStore {
});
}
@action.bound
getSearchResult(query = '') {
if (!this.searchResult[query]) {
return undefined;
@ -55,7 +54,6 @@ export class ApiTokenStore extends BaseStore {
return this.searchResult[query].map((apiTokenId: ApiToken['id']) => this.items[apiTokenId]);
}
@action.bound
async revokeApiToken(id: ApiToken['id']) {
return await makeRequest(`${this.path}${id}/`, {
method: 'DELETE',

View file

@ -42,7 +42,7 @@ export default class BaseStore {
throw error;
}
@action.bound
@action
async getAll(query = '') {
return await makeRequest(`${this.path}`, {
params: { search: query },
@ -50,7 +50,7 @@ export default class BaseStore {
}).catch(this.onApiError);
}
@action.bound
@action
async getById(id: string, skipErrorHandling = false, fromOrganization = false) {
return await makeRequest(`${this.path}${id}`, {
method: 'GET',
@ -58,7 +58,7 @@ export default class BaseStore {
}).catch((error) => this.onApiError(error, skipErrorHandling));
}
@action.bound
@action
async create<RT = any>(data: any, skipErrorHandling = false): Promise<RT | void> {
return await makeRequest<RT>(this.path, {
method: 'POST',
@ -68,7 +68,7 @@ export default class BaseStore {
});
}
@action.bound
@action
async update<RT = any>(id: any, data: any, params: any = null, skipErrorHandling = false): Promise<RT | void> {
const result = await makeRequest<RT>(`${this.path}${id}/`, {
method: 'PUT',
@ -83,7 +83,7 @@ export default class BaseStore {
return result;
}
@action.bound
@action
async delete(id: any) {
const result = await makeRequest(`${this.path}${id}/`, {
method: 'DELETE',

View file

@ -24,7 +24,7 @@ export class CloudStore extends BaseStore {
this.path = '/cloud_users/';
}
@action.bound
@action
async updateItems(page = 1) {
const { matched_users_count, results } = await makeRequest(this.path, {
params: { page },
@ -49,7 +49,6 @@ export class CloudStore extends BaseStore {
});
}
@action.bound
getSearchResult() {
return {
matched_users_count: this.searchResult.matched_users_count,
@ -57,22 +56,18 @@ export class CloudStore extends BaseStore {
};
}
@action.bound
async syncCloudUsers() {
return await makeRequest(`${this.path}`, { method: 'POST' });
}
@action.bound
async syncCloudUser(id: string) {
return await makeRequest(`${this.path}${id}/sync/`, { method: 'POST' });
}
@action.bound
async getCloudHeartbeat() {
return await makeRequest(`/cloud_heartbeat/`, { method: 'POST' });
}
@action.bound
async getCloudUser(id: string) {
return await makeRequest(`${this.path}${id}`, { method: 'GET' });
}
@ -86,12 +81,10 @@ export class CloudStore extends BaseStore {
});
}
@action.bound
async getCloudConnectionStatus() {
return await makeRequest(`/cloud_connection/`, { method: 'GET' });
}
@action.bound
async disconnectToCloud() {
return await makeRequest(`/cloud_connection/`, { method: 'DELETE' });
}

View file

@ -29,7 +29,7 @@ export class DirectPagingStore extends BaseStore {
this.path = '/direct_paging/';
}
@action.bound
@action
addUserToSelectedUsers = (user: UserCurrentlyOnCall) => {
this.selectedUserResponders = [
...this.selectedUserResponders,
@ -40,22 +40,22 @@ export class DirectPagingStore extends BaseStore {
];
};
@action.bound
@action
resetSelectedUsers = () => {
this.selectedUserResponders = [];
};
@action.bound
@action
updateSelectedTeam = (team: GrafanaTeam) => {
this.selectedTeamResponder = team;
};
@action.bound
@action
resetSelectedTeam = () => {
this.selectedTeamResponder = null;
};
@action.bound
@action
removeSelectedUser(index: number) {
this.selectedUserResponders = [
...this.selectedUserResponders.slice(0, index),
@ -63,7 +63,7 @@ export class DirectPagingStore extends BaseStore {
];
}
@action.bound
@action
updateSelectedUserImportantStatus(index: number, important: boolean) {
this.selectedUserResponders = [
...this.selectedUserResponders.slice(0, index),
@ -75,7 +75,6 @@ export class DirectPagingStore extends BaseStore {
];
}
@action.bound
async createManualAlertRule(data: ManualAlertGroupPayload): Promise<DirectPagingResponse | void> {
return await makeRequest<DirectPagingResponse>(this.path, {
method: 'POST',
@ -83,7 +82,6 @@ export class DirectPagingStore extends BaseStore {
}).catch(this.onApiError);
}
@action.bound
async updateAlertGroup(alertId: Alert['pk'], data: ManualAlertGroupPayload): Promise<DirectPagingResponse | void> {
return await makeRequest<DirectPagingResponse>(this.path, {
method: 'POST',

View file

@ -30,7 +30,7 @@ export class EscalationChainStore extends BaseStore {
this.path = '/escalation_chains/';
}
@action.bound
@action
async loadItem(id: EscalationChain['id'], skipErrorHandling = false): Promise<EscalationChain> {
const escalationChain = await this.getById(id, skipErrorHandling);
@ -44,7 +44,7 @@ export class EscalationChainStore extends BaseStore {
return escalationChain;
}
@action.bound
@action
async updateById(id: EscalationChain['id']) {
const response = await this.getById(id);
@ -56,7 +56,7 @@ export class EscalationChainStore extends BaseStore {
});
}
@action.bound
@action
async save(id: EscalationChain['id'], data: Partial<EscalationChain>) {
const response = await super.update(id, data);
@ -68,7 +68,7 @@ export class EscalationChainStore extends BaseStore {
});
}
@action.bound
@action
async updateEscalationChainDetails(id: EscalationChain['id']) {
const response = await makeRequest(`${this.path}${id}/details/`, {});
@ -80,7 +80,7 @@ export class EscalationChainStore extends BaseStore {
});
}
@action.bound
@action
async updateItem(id: EscalationChain['id'], skipErrorHandling = false): Promise<EscalationChain> {
let escalationChain;
try {
@ -107,7 +107,7 @@ export class EscalationChainStore extends BaseStore {
return escalationChain;
}
@action.bound
@action
async updateItems(query: any = '') {
const params = typeof query === 'string' ? { search: query } : query;
@ -140,7 +140,6 @@ export class EscalationChainStore extends BaseStore {
this.loading = false;
}
@action.bound
getSearchResult(query = '') {
if (!this.searchResult[query]) {
return undefined;
@ -149,7 +148,6 @@ export class EscalationChainStore extends BaseStore {
return this.searchResult[query].map((escalationChainId: EscalationChain['id']) => this.items[escalationChainId]);
}
@action.bound
clone = (escalationChainId: EscalationChain['id'], data: Partial<EscalationChain>): Promise<EscalationChain> =>
makeRequest<EscalationChain>(`${this.path}${escalationChainId}/copy/`, {
method: 'POST',

View file

@ -64,7 +64,7 @@ export class EscalationPolicyStore extends BaseStore {
});
}
@action.bound
@action
async updateEscalationPolicies(escalationChainId: EscalationChain['id']) {
const response = await makeRequest(this.path, {
params: { escalation_chain: escalationChainId },
@ -91,7 +91,7 @@ export class EscalationPolicyStore extends BaseStore {
});
}
@action.bound
@action
createEscalationPolicy(escalationChainId: EscalationChain['id'], data: Partial<EscalationPolicy>) {
return super.create({
...data,
@ -99,7 +99,7 @@ export class EscalationPolicyStore extends BaseStore {
});
}
@action.bound
@action
async saveEscalationPolicy(id: EscalationPolicy['id'], data: Partial<EscalationPolicy>) {
this.items[id] = {
...this.items[id],
@ -113,7 +113,7 @@ export class EscalationPolicyStore extends BaseStore {
}
}
@action.bound
@action
async moveEscalationPolicyToPosition(oldIndex: any, newIndex: any, escalationChainId: EscalationChain['id']) {
const escalationPolicyId = this.escalationChainToEscalationPolicy[escalationChainId][oldIndex];
@ -130,7 +130,7 @@ export class EscalationPolicyStore extends BaseStore {
this.updateEscalationPolicies(escalationChainId);
}
@action.bound
@action
async deleteEscalationPolicy(data: Partial<EscalationPolicy>) {
const index = this.escalationChainToEscalationPolicy[data.escalation_chain].findIndex(
(escalationPolicyId: EscalationPolicy['id']) => escalationPolicyId === data.id

View file

@ -39,7 +39,7 @@ export class FiltersStore extends BaseStore {
}
}
@action.bound
@action
setNeedToParseFilters(value: boolean) {
this.needToParseFilters = value;
}
@ -54,7 +54,7 @@ export class FiltersStore extends BaseStore {
return this._globalValues;
}
@action.bound
@action
public async updateOptionsForPage(page: string) {
const result = await makeRequest(`/${getApiPathByPage(page)}/filters/`, {});
@ -73,7 +73,7 @@ export class FiltersStore extends BaseStore {
return result;
}
@action.bound
@action
updateValuesForPage(page: string, value: FiltersValues) {
this.values = {
...this.values,
@ -81,12 +81,12 @@ export class FiltersStore extends BaseStore {
};
}
@action.bound
@action
setCurrentTablePageNum(page: PAGE, currentTablePageNum: number) {
this.currentTablePageNum[page] = currentTablePageNum;
}
@action.bound
@action
applyLabelFilter = (label: LabelKeyValue, page: PAGE) => {
const currentLabelFilterValues = this.values[page]?.label || [];
const labelToAddString = `${label.key.id}:${label.value.id}`;

View file

@ -20,7 +20,7 @@ export class GlobalSettingStore extends BaseStore {
this.path = '/live_settings/';
}
@action.bound
@action
async updateById(id: GlobalSetting['id']) {
const response = await this.getById(id);
@ -32,7 +32,7 @@ export class GlobalSettingStore extends BaseStore {
});
}
@action.bound
@action
async updateItems(query = '') {
const results = await this.getAll();
@ -55,7 +55,6 @@ export class GlobalSettingStore extends BaseStore {
});
}
@action.bound
getSearchResult(query = '') {
if (!this.searchResult[query]) {
return undefined;
@ -64,7 +63,6 @@ export class GlobalSettingStore extends BaseStore {
return this.searchResult[query].map((globalSettingId: GlobalSetting['id']) => this.items[globalSettingId]);
}
@action.bound
async getGlobalSettingItemByName(name: string) {
const results = await this.getAll();
return results.find((element: { name: string }) => element.name === name);

View file

@ -22,7 +22,7 @@ export class GrafanaTeamStore extends BaseStore {
this.path = '/teams/';
}
@action.bound
@action
async updateTeam(id: GrafanaTeam['id'], data: Partial<GrafanaTeam>) {
const result = await this.update(id, data);
@ -61,7 +61,6 @@ export class GrafanaTeamStore extends BaseStore {
});
}
@action.bound
getSearchResult() {
return this.searchResult.map((teamId: GrafanaTeam['id']) => this.items[teamId]);
}

View file

@ -22,7 +22,7 @@ export class HeartbeatStore extends BaseStore {
this.path = '/heartbeats/';
}
@action.bound
@action
async updateTimeoutOptions() {
const result = await makeRequest(`${this.path}timeout_options/`, {});
@ -31,7 +31,7 @@ export class HeartbeatStore extends BaseStore {
});
}
@action.bound
@action
async saveHeartbeat(id: Heartbeat['id'], data: Partial<Heartbeat>) {
const response = await super.update<Heartbeat>(id, data);
@ -47,7 +47,7 @@ export class HeartbeatStore extends BaseStore {
});
}
@action.bound
@action
async createHeartbeat(alertReceiveChannelId: AlertReceiveChannel['id'], data: Partial<Heartbeat>) {
const response = await super.create<Heartbeat>({
alert_receive_channel: alertReceiveChannelId,

View file

@ -12,7 +12,7 @@ class LoaderStoreClass {
makeObservable(this);
}
@action.bound
@action
setLoadingAction(actionKey: string, isLoading: boolean) {
this.items[actionKey] = isLoading;
}

View file

@ -26,7 +26,7 @@ export class MSTeamsChannelStore extends BaseStore {
this.path = '/msteams/channels/';
}
@action.bound
@action
async updateMSTeamsChannels() {
const response = await makeRequest(this.path, {});
@ -48,7 +48,7 @@ export class MSTeamsChannelStore extends BaseStore {
});
}
@action.bound
@action
async updateById(id: MSTeamsChannel['id']) {
const response = await this.getById(id);
@ -60,7 +60,7 @@ export class MSTeamsChannelStore extends BaseStore {
});
}
@action.bound
@action
async updateItems(query = '') {
const result = await this.getAll();
@ -83,7 +83,6 @@ export class MSTeamsChannelStore extends BaseStore {
});
}
@action.bound
getSearchResult(query = '') {
if (!this.searchResult[query]) {
return undefined;
@ -96,38 +95,31 @@ export class MSTeamsChannelStore extends BaseStore {
return Boolean(this.getSearchResult('')?.length);
}
@action.bound
async startAutoUpdate() {
this.autoUpdateTimer = setInterval(this.updateMSTeamsChannels.bind(this), 3000);
}
@action.bound
async stopAutoUpdate() {
if (this.autoUpdateTimer) {
clearInterval(this.autoUpdateTimer);
}
}
@action.bound
async getMSTeamsChannelVerificationCode() {
return await makeRequest(`/current_team/get_channel_verification_code/?backend=MSTEAMS`, {
withCredentials: true,
});
}
@action.bound
async makeMSTeamsChannelDefault(id: MSTeamsChannel['id']) {
return makeRequest(`/msteams/channels/${id}/set_default/`, {
method: 'POST',
});
}
@action.bound
async deleteMSTeamsChannel(id: MSTeamsChannel['id']) {
return super.delete(id);
}
@action.bound
async getMSTeamsChannels() {
return super.getAll();
}

View file

@ -25,7 +25,6 @@ export class OrganizationStore extends BaseStore {
});
}
@action.bound
async saveCurrentOrganization(data: Partial<Organization>) {
this.currentOrganization = await makeRequest(this.path, {
method: 'PUT',

View file

@ -13,6 +13,7 @@ export interface Organization {
discussion_group_name: string;
};
name: string;
stack_slug: string;
slack_team_identity: {
general_log_channel_id: string;
general_log_channel_pk: string;

View file

@ -28,7 +28,7 @@ export class OutgoingWebhookStore extends BaseStore {
this.path = '/webhooks/';
}
@action.bound
@action
async loadItem(id: OutgoingWebhook['id'], skipErrorHandling = false): Promise<OutgoingWebhook> {
const outgoingWebhook = await this.getById(id, skipErrorHandling);
@ -42,7 +42,7 @@ export class OutgoingWebhookStore extends BaseStore {
return outgoingWebhook;
}
@action.bound
@action
async updateById(id: OutgoingWebhook['id']) {
const response = await this.getById(id);
@ -54,7 +54,7 @@ export class OutgoingWebhookStore extends BaseStore {
});
}
@action.bound
@action
async updateItem(id: OutgoingWebhook['id'], fromOrganization = false) {
const response = await this.getById(id, false, fromOrganization);
@ -66,7 +66,7 @@ export class OutgoingWebhookStore extends BaseStore {
});
}
@action.bound
@action
async updateItems(query: any = '') {
const params = typeof query === 'string' ? { search: query } : query;
@ -95,7 +95,6 @@ export class OutgoingWebhookStore extends BaseStore {
});
}
@action.bound
getSearchResult(query = '') {
if (!this.searchResult[query]) {
return undefined;
@ -104,14 +103,12 @@ export class OutgoingWebhookStore extends BaseStore {
return this.searchResult[query].map((outgoingWebhookId: OutgoingWebhook['id']) => this.items[outgoingWebhookId]);
}
@action.bound
async getLastResponses(id: OutgoingWebhook['id']) {
const result = await makeRequest(`${this.path}${id}/responses`, {});
return result;
}
@action.bound
async renderPreview(id: OutgoingWebhook['id'], template_name: string, template_body: string, payload) {
return await makeRequest(`${this.path}${id}/preview_template/`, {
method: 'POST',

View file

@ -1,5 +1,3 @@
import { action, makeObservable } from 'mobx';
import { Alert } from 'models/alertgroup/alertgroup.types';
import BaseStore from 'models/base_store';
import { makeRequest } from 'network';
@ -8,12 +6,10 @@ import { RootStore } from 'state';
export class ResolutionNotesStore extends BaseStore {
constructor(rootStore: RootStore) {
super(rootStore);
makeObservable(this);
this.path = '/resolution_notes/';
}
@action.bound
async createResolutionNote(alertGroupId: Alert['pk'], text: string) {
return await makeRequest(`${this.path}`, {
method: 'POST',

View file

@ -133,7 +133,7 @@ export class ScheduleStore extends BaseStore {
this.path = '/schedules/';
}
@action.bound
@action
async loadItem(id: Schedule['id'], skipErrorHandling = false): Promise<Schedule> {
const schedule = await this.getById(id, skipErrorHandling);
@ -147,7 +147,7 @@ export class ScheduleStore extends BaseStore {
return schedule;
}
@action.bound
@action
async updateItems(
f: RemoteFiltersType | string = { searchTerm: '', type: undefined, used: undefined },
page = 1,
@ -182,7 +182,7 @@ export class ScheduleStore extends BaseStore {
});
}
@action.bound
@action
async updateItem(id: Schedule['id'], fromOrganization = false) {
if (id) {
let schedule;
@ -211,7 +211,6 @@ export class ScheduleStore extends BaseStore {
}
}
@action.bound
getSearchResult() {
return {
page_size: this.searchResult.page_size,
@ -231,28 +230,24 @@ export class ScheduleStore extends BaseStore {
});
}
@action.bound
async reloadIcal(scheduleId: Schedule['id']) {
await makeRequest(`/schedules/${scheduleId}/reload_ical/`, {
method: 'POST',
});
}
@action.bound
async getICalLink(scheduleId: Schedule['id']) {
return await makeRequest(`/schedules/${scheduleId}/export_token/`, {
method: 'GET',
});
}
@action.bound
async createICalLink(scheduleId: Schedule['id']) {
return await makeRequest(`/schedules/${scheduleId}/export_token/`, {
method: 'POST',
});
}
@action.bound
async deleteICalLink(scheduleId: Schedule['id']) {
await makeRequest(`/schedules/${scheduleId}/export_token/`, {
method: 'DELETE',
@ -261,7 +256,7 @@ export class ScheduleStore extends BaseStore {
// ------- NEW SCHEDULES API ENDPOINTS ---------
@action.bound
@action
async createRotation(scheduleId: Schedule['id'], isOverride: boolean, params: Partial<Shift>) {
const type = isOverride ? 3 : 2;
@ -282,12 +277,10 @@ export class ScheduleStore extends BaseStore {
return response;
}
@action.bound
setRotationFormLiveParams(params: RotationFormLiveParams) {
this.rotationFormLiveParams = params;
}
@action.bound
async updateRotationPreview(
scheduleId: Schedule['id'],
shiftId: Shift['id'] | 'new',
@ -331,7 +324,7 @@ export class ScheduleStore extends BaseStore {
});
}
@action.bound
@action
async updateShiftsSwapPreview(scheduleId: Schedule['id'], startMoment: dayjs.Dayjs, params: Partial<ShiftSwap>) {
const fromString = getFromString(startMoment);
@ -357,7 +350,7 @@ export class ScheduleStore extends BaseStore {
});
}
@action.bound
@action
clearPreview() {
this.finalPreview = undefined;
this.rotationPreview = undefined;
@ -366,7 +359,7 @@ export class ScheduleStore extends BaseStore {
this.rotationFormLiveParams = undefined;
}
@action.bound
@action
async updateRotation(shiftId: Shift['id'], params: Partial<Shift>) {
const response = await makeRequest(`/oncall_shifts/${shiftId}`, {
params: { force: true },
@ -384,7 +377,7 @@ export class ScheduleStore extends BaseStore {
return response;
}
@action.bound
@action
async updateRotationAsNew(shiftId: Shift['id'], params: Partial<Shift>) {
const response = await makeRequest(`/oncall_shifts/${shiftId}`, {
data: { ...params },
@ -401,7 +394,7 @@ export class ScheduleStore extends BaseStore {
return response;
}
@action.bound
@action
updateRelatedEscalationChains = async (id: Schedule['id']) => {
const response = await makeRequest(`/schedules/${id}/related_escalation_chains`, {
method: 'GET',
@ -417,7 +410,7 @@ export class ScheduleStore extends BaseStore {
return response;
};
@action.bound
@action
updateRelatedUsers = async (id: Schedule['id']) => {
const { users } = await makeRequest(`/schedules/${id}/next_shifts_per_user`, {
method: 'GET',
@ -433,7 +426,7 @@ export class ScheduleStore extends BaseStore {
return users;
};
@action.bound
@action
async updateOncallShifts(scheduleId: Schedule['id']) {
const { results } = await makeRequest(`/oncall_shifts/`, {
params: {
@ -456,7 +449,7 @@ export class ScheduleStore extends BaseStore {
});
}
@action.bound
@action
async updateOncallShift(shiftId: Shift['id']) {
if (this.shiftsCurrentlyUpdating[shiftId]) {
return;
@ -478,7 +471,7 @@ export class ScheduleStore extends BaseStore {
return response;
}
@action.bound
@action
async saveOncallShift(shiftId: Shift['id'], data: Partial<Shift>) {
const response = await makeRequest(`/oncall_shifts/${shiftId}`, { method: 'PUT', data });
@ -492,7 +485,6 @@ export class ScheduleStore extends BaseStore {
return response;
}
@action.bound
async deleteOncallShift(shiftId: Shift['id'], force?: boolean) {
return await makeRequest(`/oncall_shifts/${shiftId}`, {
method: 'DELETE',
@ -500,7 +492,7 @@ export class ScheduleStore extends BaseStore {
}).catch(this.onApiError);
}
@action.bound
@action
async updateEvents(scheduleId: Schedule['id'], startMoment: dayjs.Dayjs, type: RotationType = 'rotation', days = 9) {
const dayBefore = startMoment.subtract(1, 'day');
@ -556,14 +548,13 @@ export class ScheduleStore extends BaseStore {
]);
}
@action.bound
async updateFrequencyOptions() {
return await makeRequest(`/oncall_shifts/frequency_options/`, {
method: 'GET',
});
}
@action.bound
@action
async updateDaysOptions() {
const result = await makeRequest(`/oncall_shifts/days_options/`, {
method: 'GET',
@ -574,22 +565,19 @@ export class ScheduleStore extends BaseStore {
});
}
@action.bound
async createShiftSwap(params: Partial<ShiftSwap>) {
return await makeRequest(`/shift_swaps/`, { method: 'POST', data: params }).catch(this.onApiError);
}
@action.bound
async deleteShiftSwap(shiftSwapId: ShiftSwap['id']) {
return await makeRequest(`/shift_swaps/${shiftSwapId}`, { method: 'DELETE' }).catch(this.onApiError);
}
@action.bound
async takeShiftSwap(shiftSwapId: ShiftSwap['id']) {
return await makeRequest(`/shift_swaps/${shiftSwapId}/take`, { method: 'POST' }).catch(this.onApiError);
}
@action.bound
@action
async loadShiftSwap(id: ShiftSwap['id']) {
const result = await makeRequest(`/shift_swaps/${id}`, { params: { expand_users: true } });
@ -600,7 +588,7 @@ export class ScheduleStore extends BaseStore {
return result;
}
@action.bound
@action
async updateShiftSwaps(scheduleId: Schedule['id'], startMoment: dayjs.Dayjs, days = 9) {
const fromString = getFromString(startMoment);
@ -642,7 +630,7 @@ export class ScheduleStore extends BaseStore {
}
@AutoLoadingState(ActionKey.UPDATE_PERSONAL_EVENTS)
@action.bound
@action
async updatePersonalEvents(userPk: User['pk'], startMoment: dayjs.Dayjs, days = 9, isUpdateOnCallNow = false) {
const fromString = getFromString(startMoment);

View file

@ -19,7 +19,7 @@ export class SlackStore extends BaseStore {
makeObservable(this);
}
@action.bound
@action
async updateSlackSettings() {
const result = await makeRequest('/slack_settings/', {});
@ -28,7 +28,7 @@ export class SlackStore extends BaseStore {
});
}
@action.bound
@action
async saveSlackSettings(data: Partial<SlackSettings>) {
const result = await makeRequest('/slack_settings/', {
data,
@ -40,7 +40,7 @@ export class SlackStore extends BaseStore {
});
}
@action.bound
@action
async setGeneralLogChannelId(id: SlackChannel['id']) {
return await makeRequest('/set_general_channel/', {
method: 'POST',
@ -48,7 +48,7 @@ export class SlackStore extends BaseStore {
});
}
@action.bound
@action
async updateSlackIntegrationData(slack_id: string) {
const result = await makeRequest('/slack_integration/', {
params: { slack_id },
@ -61,7 +61,6 @@ export class SlackStore extends BaseStore {
return result;
}
@action.bound
async reinstallSlackIntegration(slack_id: string) {
return await makeRequest('/slack_integration/', {
validateStatus: function (status) {
@ -72,19 +71,16 @@ export class SlackStore extends BaseStore {
}).catch(this.onApiError);
}
@action.bound
async slackLogin() {
const url_for_redirect = await makeRequest('/login/slack-login/', {});
window.location = url_for_redirect;
}
@action.bound
async installSlackIntegration() {
const url_for_redirect = await makeRequest('/login/slack-install-free/', {});
window.location = url_for_redirect;
}
@action.bound
async removeSlackIntegration() {
return await makeRequest('/slack/reset_slack/', { method: 'POST' });
}

View file

@ -21,7 +21,7 @@ export class SlackChannelStore extends BaseStore {
this.path = '/slack_channels/';
}
@action.bound // deprecated, use updateItem instead
@action // deprecated, use updateItem instead
async updateById(id: SlackChannel['id']) {
const response = await this.getById(id);
@ -33,7 +33,7 @@ export class SlackChannelStore extends BaseStore {
});
}
@action.bound
@action
async updateItem(id: SlackChannel['id']) {
const response = await this.getById(id);
@ -45,7 +45,7 @@ export class SlackChannelStore extends BaseStore {
});
}
@action.bound
@action
async updateItems(query = '') {
const { results } = await makeRequest(`${this.path}`, {
params: { search: query },

View file

@ -26,7 +26,7 @@ export class TelegramChannelStore extends BaseStore {
this.path = '/telegram_channels/';
}
@action.bound
@action
async updateTelegramChannels() {
const response = await makeRequest(this.path, {});
@ -48,7 +48,7 @@ export class TelegramChannelStore extends BaseStore {
});
}
@action.bound
@action
async updateById(id: TelegramChannel['id']) {
const response = await this.getById(id);
@ -60,7 +60,7 @@ export class TelegramChannelStore extends BaseStore {
});
}
@action.bound
@action
async updateItems(query = '') {
const result = await this.getAll();
@ -83,7 +83,6 @@ export class TelegramChannelStore extends BaseStore {
});
}
@action.bound
getSearchResult(query = '') {
if (!this.searchResult[query]) {
return undefined;
@ -96,38 +95,34 @@ export class TelegramChannelStore extends BaseStore {
return Boolean(this.getSearchResult('')?.length);
}
@action.bound
async startAutoUpdate() {
this.autoUpdateTimer = setInterval(this.updateTelegramChannels.bind(this), 3000);
}
@action.bound
async stopAutoUpdate() {
if (this.autoUpdateTimer) {
clearInterval(this.autoUpdateTimer);
}
}
@action.bound
async getTelegramVerificationCode() {
return await makeRequest(`/current_team/get_telegram_verification_code/`, {
withCredentials: true,
});
}
@action.bound
@action
async makeTelegramChannelDefault(id: TelegramChannel['id']) {
return makeRequest(`/telegram_channels/${id}/set_default/`, {
method: 'POST',
});
}
@action.bound
@action
async deleteTelegramChannel(id: TelegramChannel['id']) {
return super.delete(id);
}
@action.bound
async getTelegramChannels() {
return super.getAll();
}

View file

@ -41,7 +41,7 @@ describe('UserStore.unlinkBackend', () => {
test('it makes the proper API call and returns the response', async () => {
makeRequest.mockResolvedValueOnce('hello');
Object.defineProperty(userStore, 'loadCurrentUser', { value: jest.fn() });
userStore.loadCurrentUser = jest.fn();
await userStore.unlinkBackend(userPk, backend);

View file

@ -60,7 +60,7 @@ export class UserStore extends BaseStore {
return this.items[this.currentUserPk as User['pk']];
}
@action.bound
@action
async loadCurrentUser() {
const response = await makeRequest('/user/', {});
const timezone = await this.refreshTimezone(response.pk);
@ -74,7 +74,7 @@ export class UserStore extends BaseStore {
});
}
@action.bound
@action
async refreshTimezone(id: User['pk']) {
const { timezone: grafanaPreferencesTimezone } = config.bootData.user;
const timezone = grafanaPreferencesTimezone === 'browser' ? dayjs.tz.guess() : grafanaPreferencesTimezone;
@ -87,7 +87,7 @@ export class UserStore extends BaseStore {
return timezone;
}
@action.bound
@action
async loadUser(userPk: User['pk'], skipErrorHandling = false): Promise<User> {
const user = await this.getById(userPk, skipErrorHandling);
@ -101,7 +101,7 @@ export class UserStore extends BaseStore {
return user;
}
@action.bound
@action
async updateItem(userPk: User['pk']) {
if (this.itemsCurrentlyUpdating[userPk]) {
return;
@ -124,7 +124,6 @@ export class UserStore extends BaseStore {
/**
* NOTE: if is_currently_oncall=all the backend will not paginate the results, it will send back an array of ALL users
*/
@action.bound
async search<RT = PaginatedUsersResponse<User>>(f: any = { searchTerm: '' }, page = 1): Promise<RT> {
const filters = typeof f === 'string' ? { searchTerm: f } : f; // for GSelect compatibility
const { searchTerm: search, ...restFilters } = filters;
@ -133,7 +132,7 @@ export class UserStore extends BaseStore {
});
}
@action.bound
@action
async updateItems(f: any = { searchTerm: '' }, page = 1, invalidateFn?: () => boolean): Promise<any> {
const response = await this.search(f, page);
@ -168,7 +167,6 @@ export class UserStore extends BaseStore {
return response;
}
@action.bound
getSearchResult() {
return {
page_size: this.searchResult.page_size,
@ -177,12 +175,11 @@ export class UserStore extends BaseStore {
};
}
@action.bound
sendTelegramConfirmationCode = async (userPk: User['pk']) => {
return await makeRequest(`/users/${userPk}/get_telegram_verification_code/`, {});
};
@action.bound
@action
unlinkSlack = async (userPk: User['pk']) => {
await makeRequest(`/users/${userPk}/unlink_slack/`, {
method: 'POST',
@ -198,7 +195,7 @@ export class UserStore extends BaseStore {
});
};
@action.bound
@action
unlinkTelegram = async (userPk: User['pk']) => {
await makeRequest(`/users/${userPk}/unlink_telegram/`, {
method: 'POST',
@ -214,13 +211,12 @@ export class UserStore extends BaseStore {
});
};
@action.bound
sendBackendConfirmationCode = (userPk: User['pk'], backend: string) =>
makeRequest<string>(`/users/${userPk}/get_backend_verification_code?backend=${backend}`, {
method: 'GET',
});
@action.bound
@action
unlinkBackend = async (userPk: User['pk'], backend: string) => {
await makeRequest(`/users/${userPk}/unlink_backend/?backend=${backend}`, {
method: 'POST',
@ -229,7 +225,7 @@ export class UserStore extends BaseStore {
this.loadCurrentUser();
};
@action.bound
@action
async createUser(data: any) {
const user = await this.create(data);
@ -243,7 +239,7 @@ export class UserStore extends BaseStore {
return user;
}
@action.bound
@action
async updateUser(data: Partial<User>) {
const user = await makeRequest(`/users/${data.pk}/`, {
method: 'PUT',
@ -265,7 +261,7 @@ export class UserStore extends BaseStore {
});
}
@action.bound
@action
async updateCurrentUser(data: Partial<User>) {
const user = await makeRequest(`/user/`, {
method: 'PUT',
@ -283,7 +279,7 @@ export class UserStore extends BaseStore {
});
}
@action.bound
@action
async fetchVerificationCode(userPk: User['pk'], recaptchaToken: string) {
await makeRequest(`/users/${userPk}/get_verification_code/`, {
method: 'GET',
@ -291,7 +287,7 @@ export class UserStore extends BaseStore {
}).catch(throttlingError);
}
@action.bound
@action
async fetchVerificationCall(userPk: User['pk'], recaptchaToken: string) {
await makeRequest(`/users/${userPk}/get_verification_call/`, {
method: 'GET',
@ -299,21 +295,21 @@ export class UserStore extends BaseStore {
}).catch(throttlingError);
}
@action.bound
@action
async verifyPhone(userPk: User['pk'], token: string) {
return await makeRequest(`/users/${userPk}/verify_number/?token=${token}`, {
method: 'PUT',
}).catch(throttlingError);
}
@action.bound
@action
async forgetPhone(userPk: User['pk']) {
return await makeRequest(`/users/${userPk}/forget_number/`, {
method: 'PUT',
});
}
@action.bound
@action
async updateNotificationPolicies(id: User['pk']) {
const importantEPs = await makeRequest('/notification_policies/', {
params: { user: id, important: true },
@ -331,7 +327,7 @@ export class UserStore extends BaseStore {
});
}
@action.bound
@action
async moveNotificationPolicyToPosition(userPk: User['pk'], oldIndex: number, newIndex: number, offset: number) {
const notificationPolicy = this.notificationPolicies[userPk][oldIndex + offset];
@ -346,7 +342,7 @@ export class UserStore extends BaseStore {
this.updateItem(userPk); // to update notification_chain_verbal
}
@action.bound
@action
async addNotificationPolicy(userPk: User['pk'], important: NotificationPolicyType['important']) {
await makeRequest(`/notification_policies/`, {
method: 'POST',
@ -358,7 +354,7 @@ export class UserStore extends BaseStore {
this.updateItem(userPk); // to update notification_chain_verbal
}
@action.bound
@action
async updateNotificationPolicy(userPk: User['pk'], id: NotificationPolicyType['id'], value: NotificationPolicyType) {
this.notificationPolicies = {
...this.notificationPolicies,
@ -384,7 +380,7 @@ export class UserStore extends BaseStore {
this.updateItem(userPk); // to update notification_chain_verbal
}
@action.bound
@action
async deleteNotificationPolicy(userPk: User['pk'], id: NotificationPolicyType['id']) {
await makeRequest(`/notification_policies/${id}`, { method: 'DELETE' }).catch(this.onApiError);
@ -404,7 +400,7 @@ export class UserStore extends BaseStore {
});
}
@action.bound
@action
async sendTestPushNotification(userId: User['pk'], isCritical: boolean) {
return await makeRequest(`/users/${userId}/send_test_push`, {
method: 'POST',
@ -423,7 +419,7 @@ export class UserStore extends BaseStore {
});
}
@action.bound
@action
async makeTestCall(userPk: User['pk']) {
this.isTestCallInProgress = true;
@ -438,7 +434,6 @@ export class UserStore extends BaseStore {
});
}
@action.bound
async sendTestSms(userPk: User['pk']) {
this.isTestCallInProgress = true;
@ -451,21 +446,18 @@ export class UserStore extends BaseStore {
});
}
@action.bound
async getiCalLink(userPk: User['pk']) {
return await makeRequest(`/users/${userPk}/export_token/`, {
method: 'GET',
});
}
@action.bound
async createiCalLink(userPk: User['pk']) {
return await makeRequest(`/users/${userPk}/export_token/`, {
method: 'POST',
});
}
@action.bound
async deleteiCalLink(userPk: User['pk']) {
await makeRequest(`/users/${userPk}/export_token/`, {
method: 'DELETE',

View file

@ -21,7 +21,7 @@ export class UserGroupStore extends BaseStore {
this.path = '/user_groups/';
}
@action.bound
@action
async updateItems(query = '') {
const result = await makeRequest(`${this.path}`, {
params: { search: query },
@ -46,7 +46,6 @@ export class UserGroupStore extends BaseStore {
});
}
@action.bound
getSearchResult(query = '') {
if (!this.searchResult[query]) {
return undefined;

View file

@ -39,11 +39,12 @@ import getTotalAlertGroupsScene from './scenes/TotalAlertGroups';
import getTotalAlertGroupsByStateScene from './scenes/TotalAlertGroupsByState';
import getVariables from './variables';
const getDefaultStackValue = (isOpenSource: boolean) =>
isOpenSource ? 'self_hosted_stack' : location.host.split('.')[0];
const Insights = observer(() => {
const { isOpenSource, insightsDatasource } = useStore();
const {
isOpenSource,
insightsDatasource,
organizationStore: { currentOrganization },
} = useStore();
const [showAllStackInfo, setShowAllStackInfo] = useState(false);
const [datasource, setDatasource] = useState<string>();
@ -51,7 +52,7 @@ const Insights = observer(() => {
() => ({
isOpenSource,
datasource: { uid: isOpenSource ? '$datasource' : insightsDatasource },
stack: getDefaultStackValue(isOpenSource),
stack: currentOrganization?.stack_slug,
}),
[]
);
@ -72,8 +73,8 @@ const Insights = observer(() => {
setDatasource(`${text}`);
});
return () => {
stackListener?.unsubscribe?.();
dataSourceListener?.unsubscribe?.();
stackListener?.unsubscribe();
dataSourceListener?.unsubscribe();
};
}, []);

View file

@ -142,7 +142,7 @@ export class RootBaseStore {
this.setIsBasicDataLoaded(true);
};
@action.bound
@action
loadMasterData = async () => {
Promise.all([
this.userStore.updateNotificationPolicyOptions(),
@ -151,12 +151,12 @@ export class RootBaseStore {
]);
};
@action.bound
@action
setIsBasicDataLoaded(value: boolean) {
this.isBasicDataLoaded = value;
}
@action.bound
@action
setupPluginError(errorMsg: string) {
this.initializationError = errorMsg;
}
@ -327,12 +327,12 @@ export class RootBaseStore {
this.pageTitle = title;
}
@action.bound
@action
async removeSlackIntegration() {
await this.slackStore.removeSlackIntegration();
}
@action.bound
@action
async installSlackIntegration() {
await this.slackStore.installSlackIntegration();
}

View file

@ -181,7 +181,7 @@ describe('rootBaseStore', () => {
});
isUserActionAllowed.mockReturnValueOnce(true);
PluginState.installPlugin = jest.fn().mockResolvedValueOnce(null);
Object.defineProperty(rootBaseStore.userStore, 'loadCurrentUser', { value: mockedLoadCurrentUser });
rootBaseStore.userStore.loadCurrentUser = mockedLoadCurrentUser;
// test
await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl));
@ -224,7 +224,7 @@ describe('rootBaseStore', () => {
});
isUserActionAllowed.mockReturnValueOnce(true);
PluginState.installPlugin = jest.fn().mockResolvedValueOnce(null);
Object.defineProperty(rootBaseStore.userStore, 'loadCurrentUser', { value: mockedLoadCurrentUser });
rootBaseStore.userStore.loadCurrentUser = mockedLoadCurrentUser;
// test
await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl));
@ -300,7 +300,7 @@ describe('rootBaseStore', () => {
version: 'asdfasdf',
license: 'asdfasdf',
});
Object.defineProperty(rootBaseStore.userStore, 'loadCurrentUser', { value: mockedLoadCurrentUser });
rootBaseStore.userStore.loadCurrentUser = mockedLoadCurrentUser;
// test
await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl));
@ -329,7 +329,7 @@ describe('rootBaseStore', () => {
license: 'asdfasdf',
});
PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce(updatePluginStatusError);
Object.defineProperty(rootBaseStore.userStore, 'loadCurrentUser', { value: mockedLoadCurrentUser });
rootBaseStore.userStore.loadCurrentUser = mockedLoadCurrentUser;
// test
await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl));