diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fe15105..e03de072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## v1.3.59 (2023-11-16) + +### Added + +- Populate `users` field of the public Shift GET API with `rolling_users` from the type override created from web UI([#3303](https://github.com/grafana/oncall/pull/3303)) +- Do not retry to update slack user group on every API error ([#3363](https://github.com/grafana/oncall/pull/3363)) +- Allow specifying a comma-separated list of redis-servers to the `REDIS_URI` engine environment variable by @joeyorlando + ([#3368](https://github.com/grafana/oncall/pull/3368)) + +### Fixed + +- Fixed recurrency limit issue in the Rotation Modal ([#3358](https://github.com/grafana/oncall/pull/3358)) +- Added dragging boundary constraints for Rotation Modal and show scroll for the users list ([#3365](https://github.com/grafana/oncall/pull/3365)) +- Delete direct paging integration on team delete by @vadimkerr ([#3367](https://github.com/grafana/oncall/pull/3367)) + ## v1.3.58 (2023-11-14) ### Added diff --git a/dev/scripts/generate-fake-data/requirements.txt b/dev/scripts/generate-fake-data/requirements.txt index 976c00d2..2b67a670 100644 --- a/dev/scripts/generate-fake-data/requirements.txt +++ b/dev/scripts/generate-fake-data/requirements.txt @@ -1,3 +1,3 @@ -aiohttp==3.8.5 +aiohttp==3.8.6 Faker==16.4.0 tqdm==4.64.1 diff --git a/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py b/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py index 2cb7ced3..f4f36b61 100644 --- a/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py +++ b/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py @@ -5,6 +5,7 @@ from django.utils.text import Truncator from apps.alerts.incident_appearance.renderers.base_renderer import AlertBaseRenderer, AlertGroupBaseRenderer from apps.alerts.incident_appearance.templaters import AlertSlackTemplater +from apps.slack.constants import BLOCK_SECTION_TEXT_MAX_SIZE from apps.slack.scenarios.scenario_step import ScenarioStep from apps.slack.types import Block from common.utils import is_string_with_visible_characters, str_or_backup @@ -23,7 +24,6 @@ class AlertSlackRenderer(AlertBaseRenderer): return AlertSlackTemplater def render_alert_blocks(self) -> Block.AnyBlocks: - BLOCK_SECTION_TEXT_MAX_SIZE = 2800 blocks: Block.AnyBlocks = [] title = Truncator(str_or_backup(self.templated_alert.title, "Alert")) diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index a7be71da..6f6aec12 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -179,6 +179,7 @@ class AlertGroupSlackRenderingMixin: class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.Model): + acknowledged_by_user: typing.Optional["User"] alerts: "RelatedManager['Alert']" dependent_alert_groups: "RelatedManager['AlertGroup']" channel: "AlertReceiveChannel" @@ -187,7 +188,9 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. resolution_notes: "RelatedManager['ResolutionNote']" resolution_note_slack_messages: "RelatedManager['ResolutionNoteSlackMessage']" resolved_by_alert: typing.Optional["Alert"] + resolved_by_user: typing.Optional["User"] root_alert_group: typing.Optional["AlertGroup"] + silenced_by_user: typing.Optional["User"] slack_log_message: typing.Optional["SlackMessage"] slack_messages: "RelatedManager['SlackMessage']" users: "RelatedManager['User']" diff --git a/engine/apps/alerts/models/alert_receive_channel.py b/engine/apps/alerts/models/alert_receive_channel.py index 826b1b96..03c433ca 100644 --- a/engine/apps/alerts/models/alert_receive_channel.py +++ b/engine/apps/alerts/models/alert_receive_channel.py @@ -87,6 +87,10 @@ def number_to_smiles_translator(number): return "".join(reversed(smileset)) +class IntegrationAlertGroupLabels(typing.TypedDict): + inheritable: typing.Dict[str, bool] + + class AlertReceiveChannelQueryset(models.QuerySet): def delete(self): self.update(deleted_at=timezone.now()) @@ -261,6 +265,7 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject): # Don't allow multiple Direct Paging integrations per team if ( self.integration == AlertReceiveChannel.INTEGRATION_DIRECT_PAGING + and not self.deleted_at and AlertReceiveChannel.objects.filter( organization=self.organization, team=self.team, integration=self.integration ) @@ -638,6 +643,21 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject): result["team"] = "General" return result + @property + def alert_group_labels(self) -> IntegrationAlertGroupLabels: + """ + Alert group labels configuration for the integration used by AlertReceiveChannelSerializer. + See AlertReceiveChannelAssociatedLabel.inheritable for more details. + """ + return {"inheritable": {label.key_id: label.inheritable for label in self.labels.all()}} + + @alert_group_labels.setter + def alert_group_labels(self, value: IntegrationAlertGroupLabels) -> None: + """Setter for alert_group_labels used by AlertReceiveChannelSerializer""" + inheritable_key_ids = [key_id for key_id, inheritable in value["inheritable"].items() if inheritable] + self.labels.filter(key_id__in=inheritable_key_ids).update(inheritable=True) + self.labels.filter(~Q(key_id__in=inheritable_key_ids)).update(inheritable=False) + @receiver(post_save, sender=AlertReceiveChannel) def listen_for_alertreceivechannel_model_save( diff --git a/engine/apps/api/serializers/alert_group.py b/engine/apps/api/serializers/alert_group.py index 3aac2cda..1205429e 100644 --- a/engine/apps/api/serializers/alert_group.py +++ b/engine/apps/api/serializers/alert_group.py @@ -1,21 +1,22 @@ import datetime import logging +import typing from django.core.cache import cache from django.utils import timezone from drf_spectacular.utils import extend_schema_field, inline_serializer from rest_framework import serializers -from apps.alerts.incident_appearance.renderers.classic_markdown_renderer import AlertGroupClassicMarkdownRenderer from apps.alerts.incident_appearance.renderers.web_renderer import AlertGroupWebRenderer from apps.alerts.models import AlertGroup +from apps.alerts.models.alert_group import PagedUser from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField from common.api_helpers.mixins import EagerLoadingMixin from .alert import AlertSerializer from .alert_receive_channel import FastAlertReceiveChannelSerializer from .alerts_field_cache_buster_mixin import AlertsFieldCacheBusterMixin -from .user import FastUserSerializer, PagedUserSerializer, UserShortSerializer +from .user import FastUserSerializer, UserShortSerializer logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @@ -90,7 +91,7 @@ class ShortAlertGroupSerializer(AlertGroupFieldsCacheSerializerMixin, serializer }, ) ) - def get_render_for_web(self, obj): + def get_render_for_web(self, obj: "AlertGroup"): last_alert = obj.alerts.last() if last_alert is None: return {} @@ -102,7 +103,9 @@ class ShortAlertGroupSerializer(AlertGroupFieldsCacheSerializerMixin, serializer ) -class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerializerMixin, serializers.ModelSerializer): +class AlertGroupListSerializer( + EagerLoadingMixin, AlertGroupFieldsCacheSerializerMixin, serializers.ModelSerializer[AlertGroup] +): pk = serializers.CharField(read_only=True, source="public_primary_key") alert_receive_channel = FastAlertReceiveChannelSerializer(source="channel") status = serializers.ReadOnlyField() @@ -116,7 +119,6 @@ class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerialize alerts_count = serializers.IntegerField(read_only=True) render_for_web = serializers.SerializerMethodField() - render_for_classic_markdown = serializers.SerializerMethodField() labels = AlertGroupLabelSerializer(many=True, read_only=True) @@ -158,7 +160,6 @@ class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerialize "silenced_until", "related_users", "render_for_web", - "render_for_classic_markdown", "dependent_alert_groups", "root_alert_group", "status", @@ -179,7 +180,7 @@ class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerialize }, ) ) - def get_render_for_web(self, obj): + def get_render_for_web(self, obj: "AlertGroup"): if not obj.last_alert: return {} return AlertGroupFieldsCacheSerializerMixin.get_or_set_web_template_field( @@ -189,21 +190,12 @@ class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerialize AlertGroupWebRenderer, ) - def get_render_for_classic_markdown(self, obj): - """Deprecated. TODO: remove""" - if not obj.last_alert: - return {} - return AlertGroupFieldsCacheSerializerMixin.get_or_set_web_template_field( - obj, - obj.last_alert, - AlertGroupFieldsCacheSerializerMixin.RENDER_FOR_CLASSIC_MARKDOWN_FIELD_NAME, - AlertGroupClassicMarkdownRenderer, - ) - @extend_schema_field(UserShortSerializer(many=True)) - def get_related_users(self, obj): - users_ids = set() - users = [] + def get_related_users(self, obj: "AlertGroup"): + from apps.user_management.models import User + + users_ids: typing.Set[str] = set() + users: typing.List[User] = [] # add resolved and acknowledged by_user explicitly because logs are already prefetched # when def acknowledge/resolve are called in view. @@ -241,7 +233,7 @@ class AlertGroupSerializer(AlertGroupListSerializer): "paged_users", ] - def get_last_alert_at(self, obj) -> datetime.datetime: + def get_last_alert_at(self, obj: "AlertGroup") -> datetime.datetime: last_alert = obj.alerts.last() if not last_alert: @@ -250,7 +242,7 @@ class AlertGroupSerializer(AlertGroupListSerializer): return last_alert.created_at @extend_schema_field(AlertSerializer(many=True)) - def get_limited_alerts(self, obj): + def get_limited_alerts(self, obj: "AlertGroup"): """ Overriding default alerts because there are alert_groups with thousands of them. It's just too slow, we need to cut here. @@ -258,6 +250,5 @@ class AlertGroupSerializer(AlertGroupListSerializer): alerts = obj.alerts.order_by("-pk")[:100] return AlertSerializer(alerts, many=True).data - @extend_schema_field(PagedUserSerializer(many=True)) - def get_paged_users(self, obj): + def get_paged_users(self, obj: "AlertGroup") -> typing.List[PagedUser]: return obj.get_paged_users() diff --git a/engine/apps/api/serializers/alert_receive_channel.py b/engine/apps/api/serializers/alert_receive_channel.py index 07d4be8c..ad9ddaf5 100644 --- a/engine/apps/api/serializers/alert_receive_channel.py +++ b/engine/apps/api/serializers/alert_receive_channel.py @@ -1,3 +1,4 @@ +import typing from collections import OrderedDict from django.conf import settings @@ -33,7 +34,15 @@ def valid_jinja_template_for_serializer_method_field(template): pass -class AlertReceiveChannelSerializer(EagerLoadingMixin, LabelsSerializerMixin, serializers.ModelSerializer): +class IntegrationAlertGroupLabelsSerializer(serializers.Serializer): + """Alert group labels configuration for the integration. See AlertReceiveChannel.alert_group_labels for details.""" + + inheritable = serializers.DictField(child=serializers.BooleanField()) + + +class AlertReceiveChannelSerializer( + EagerLoadingMixin, LabelsSerializerMixin, serializers.ModelSerializer[AlertReceiveChannel] +): id = serializers.CharField(read_only=True, source="public_primary_key") integration_url = serializers.ReadOnlyField() alert_count = serializers.SerializerMethodField() @@ -55,6 +64,7 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, LabelsSerializerMixin, se connected_escalations_chains_count = serializers.SerializerMethodField() inbound_email = serializers.CharField(required=False) is_legacy = serializers.SerializerMethodField() + alert_group_labels = IntegrationAlertGroupLabelsSerializer(required=False) # integration heartbeat is in PREFETCH_RELATED not by mistake. # With using of select_related ORM builds strange join @@ -95,6 +105,7 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, LabelsSerializerMixin, se "inbound_email", "is_legacy", "labels", + "alert_group_labels", ] read_only_fields = [ "created_at", @@ -128,6 +139,7 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, LabelsSerializerMixin, se is_able_to_autoresolve = _integration.is_able_to_autoresolve labels = validated_data.pop("labels", None) + alert_group_labels = validated_data.pop("alert_group_labels", None) try: instance = AlertReceiveChannel.create( **validated_data, @@ -137,7 +149,12 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, LabelsSerializerMixin, se ) except AlertReceiveChannel.DuplicateDirectPagingError: raise BadRequest(detail=AlertReceiveChannel.DuplicateDirectPagingError.DETAIL) + + # Create label associations first, then update inheritable labels self.update_labels_association_if_needed(labels, instance, organization) + if alert_group_labels: + instance.alert_group_labels = alert_group_labels + return instance def update(self, instance, validated_data): @@ -149,12 +166,12 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, LabelsSerializerMixin, se except AlertReceiveChannel.DuplicateDirectPagingError: raise BadRequest(detail=AlertReceiveChannel.DuplicateDirectPagingError.DETAIL) - def get_instructions(self, obj): + def get_instructions(self, obj: "AlertReceiveChannel"): # 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): + def get_default_channel_filter(self, obj: "AlertReceiveChannel"): for filter in obj.channel_filters.all(): if filter.is_default: return filter.public_primary_key @@ -178,29 +195,29 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, LabelsSerializerMixin, se else: raise serializers.ValidationError(detail="Integration with this name already exists") - def get_heartbeat(self, obj): + 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): + def get_allow_delete(self, obj: "AlertReceiveChannel"): return True - def get_alert_count(self, obj): + def get_alert_count(self, obj: "AlertReceiveChannel"): return 0 - def get_alert_groups_count(self, obj): + def get_alert_groups_count(self, obj: "AlertReceiveChannel"): return 0 - def get_routes_count(self, obj) -> int: + def get_routes_count(self, obj: "AlertReceiveChannel") -> int: return obj.channel_filters.count() - def get_is_legacy(self, obj) -> bool: + def get_is_legacy(self, obj: "AlertReceiveChannel") -> bool: return has_legacy_prefix(obj.integration) - def get_connected_escalations_chains_count(self, obj) -> int: + def get_connected_escalations_chains_count(self, obj: "AlertReceiveChannel") -> int: return ( ChannelFilter.objects.filter(alert_receive_channel=obj, escalation_chain__isnull=False) .values("escalation_chain") @@ -214,7 +231,7 @@ class AlertReceiveChannelUpdateSerializer(AlertReceiveChannelSerializer): read_only_fields = [*AlertReceiveChannelSerializer.Meta.read_only_fields, "integration"] -class FastAlertReceiveChannelSerializer(serializers.ModelSerializer): +class FastAlertReceiveChannelSerializer(serializers.ModelSerializer[AlertReceiveChannel]): id = serializers.CharField(read_only=True, source="public_primary_key") integration = serializers.CharField(read_only=True) deleted = serializers.SerializerMethodField() @@ -223,27 +240,30 @@ class FastAlertReceiveChannelSerializer(serializers.ModelSerializer): model = AlertReceiveChannel fields = ["id", "integration", "verbal_name", "deleted"] - def get_deleted(self, obj): + def get_deleted(self, obj: "AlertReceiveChannel") -> bool: return obj.deleted_at is not None -class FilterAlertReceiveChannelSerializer(serializers.ModelSerializer): - value = serializers.SerializerMethodField() +class FilterAlertReceiveChannelSerializer(serializers.ModelSerializer[AlertReceiveChannel]): + # don't use get_value as the method name, otherwise this will override the get_value method on + # serializers.ModelSerializer, which may cause unexpected behavior (+ this violates the "Lisov substition + # principle" which mypy complains about) + value = serializers.SerializerMethodField(method_name="_get_value") display_name = serializers.SerializerMethodField() class Meta: model = AlertReceiveChannel fields = ["value", "display_name", "integration_url"] - def get_value(self, obj): + def _get_value(self, obj: "AlertReceiveChannel"): return obj.public_primary_key - def get_display_name(self, obj): + def get_display_name(self, obj: "AlertReceiveChannel"): display_name = obj.verbal_name or AlertReceiveChannel.INTEGRATION_CHOICES[obj.integration][1] return display_name -class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.ModelSerializer): +class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.ModelSerializer[AlertReceiveChannel]): id = serializers.CharField(read_only=True, source="public_primary_key") payload_example = SerializerMethodField() @@ -259,7 +279,7 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode ] extra_kwargs = {"integration": {"required": True}} - def get_payload_example(self, obj): + def get_payload_example(self, obj: "AlertReceiveChannel"): from apps.alerts.models import AlertGroup if "alert_group_id" in self.context["request"].query_params: @@ -276,7 +296,7 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode except AttributeError: return None - def get_is_based_on_alertmanager(self, obj): + def get_is_based_on_alertmanager(self, obj: "AlertReceiveChannel"): return obj.based_on_alertmanager # Override method to pass field_name directly in set_value to handle None values for WritableSerializerField @@ -352,7 +372,7 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode set_value(ret, [field_name], value) return errors - def to_representation(self, obj): + def to_representation(self, obj: "AlertReceiveChannel"): ret = super().to_representation(obj) core_templates = self._get_core_templates(obj) @@ -364,7 +384,7 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode return ret - def _get_messaging_backend_templates(self, obj): + def _get_messaging_backend_templates(self, obj: "AlertReceiveChannel"): """Return additional messaging backend templates if any.""" templates = {} for backend_id, backend in get_messaging_backends(): @@ -383,7 +403,7 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode templates[f"{field_name}_is_default"] = is_default return templates - def _get_core_templates(self, obj): + def _get_core_templates(self, obj: "AlertReceiveChannel"): core_templates = {} for template_name in self.core_templates_names: @@ -396,10 +416,9 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode return core_templates @property - def core_templates_names(self): + def core_templates_names(self) -> typing.List[str]: """ - core_templates_names returns names of templates introduced before messaging backends system with respect to - enabled integrations. + returns names of templates introduced before messaging backends system with respect to enabled integrations. """ core_templates = [ "web_title_template", @@ -413,21 +432,16 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode "acknowledge_condition_template", ] - slack_integration_required_templates = [ - "slack_title_template", - "slack_message_template", - "slack_image_url_template", - ] - telegram_integration_required_templates = [ - "telegram_title_template", - "telegram_message_template", - "telegram_image_url_template", - ] - - apppend = [] - if settings.FEATURE_SLACK_INTEGRATION_ENABLED: - core_templates += slack_integration_required_templates + core_templates += [ + "slack_title_template", + "slack_message_template", + "slack_image_url_template", + ] if settings.FEATURE_TELEGRAM_INTEGRATION_ENABLED: - core_templates += telegram_integration_required_templates - return apppend + core_templates + core_templates += [ + "telegram_title_template", + "telegram_message_template", + "telegram_image_url_template", + ] + return core_templates diff --git a/engine/apps/api/serializers/alerts_field_cache_buster_mixin.py b/engine/apps/api/serializers/alerts_field_cache_buster_mixin.py index f9d3b7f4..1b8618da 100644 --- a/engine/apps/api/serializers/alerts_field_cache_buster_mixin.py +++ b/engine/apps/api/serializers/alerts_field_cache_buster_mixin.py @@ -5,8 +5,7 @@ from django.core.cache import cache class AlertsFieldCacheBusterMixin: RENDER_FOR_WEB_FIELD_NAME = "render_for_web" - RENDER_FOR_CLASSIC_MARKDOWN_FIELD_NAME = "render_for_classic_markdown" - ALL_FIELD_NAMES = [RENDER_FOR_WEB_FIELD_NAME, RENDER_FOR_CLASSIC_MARKDOWN_FIELD_NAME] + ALL_FIELD_NAMES = [RENDER_FOR_WEB_FIELD_NAME] @classmethod def calculate_cache_key(cls, field_name: str, obj: typing.Any) -> str: diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py index 194a5645..e5c1ece0 100644 --- a/engine/apps/api/tests/test_alert_receive_channel.py +++ b/engine/apps/api/tests/test_alert_receive_channel.py @@ -851,6 +851,35 @@ def test_update_alert_receive_channels_direct_paging( assert response.json()["detail"] == AlertReceiveChannel.DuplicateDirectPagingError.DETAIL +@pytest.mark.django_db +def test_delete_alert_receive_channel_direct_paging_duplicate( + make_organization_and_user_with_plugin_token, make_team, make_alert_receive_channel, make_user_auth_headers +): + """Check that it's possible to delete direct paging integration even if there is a duplicate for the team.""" + organization, user, token = make_organization_and_user_with_plugin_token() + integration = make_alert_receive_channel( + organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, team=None + ) + + # Create a team, add direct paging integration to it, then delete the team. + # There will be 2 direct paging integrations for the team "No team" as a result. + team = make_team(organization) + make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, team=team) + team.delete() + assert ( + organization.alert_receive_channels.filter( + integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, team=None + ).count() + == 2 + ) + + client = APIClient() + url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": integration.public_primary_key}) + response = client.delete(url, **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_204_NO_CONTENT + + @pytest.mark.django_db def test_start_maintenance_integration( make_user_auth_headers, @@ -1350,3 +1379,69 @@ def test_update_alert_receive_channel_labels_duplicate_key( assert response.status_code == status.HTTP_400_BAD_REQUEST assert alert_receive_channel.labels.count() == 0 + + +@pytest.mark.django_db +def test_alert_group_labels_get( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_integration_label_association, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + alert_receive_channel = make_alert_receive_channel(organization) + + client = APIClient() + url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key}) + + response = client.get(url, **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + assert response.json()["alert_group_labels"] == {"inheritable": {}} + + label = make_integration_label_association(organization, alert_receive_channel) + response = client.get(url, **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + assert response.json()["alert_group_labels"] == {"inheritable": {label.key_id: True}} + + +@pytest.mark.django_db +def test_alert_group_labels_put( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_integration_label_association, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + alert_receive_channel = make_alert_receive_channel(organization) + label_1 = make_integration_label_association(organization, alert_receive_channel) + label_2 = make_integration_label_association(organization, alert_receive_channel, inheritable=False) + + client = APIClient() + url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key}) + data = {"alert_group_labels": {"inheritable": {label_1.key_id: False, label_2.key_id: True}}} + response = client.put(url, data, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["alert_group_labels"] == {"inheritable": {label_1.key_id: False, label_2.key_id: True}} + + +@pytest.mark.django_db +def test_alert_group_labels_post(alert_receive_channel_internal_api_setup, make_user_auth_headers): + user, token, _ = alert_receive_channel_internal_api_setup + + labels = [{"key": {"id": "test", "name": "test"}, "value": {"id": "123", "name": "123"}}] + alert_group_labels = {"inheritable": {"test": False}} + data = { + "integration": AlertReceiveChannel.INTEGRATION_GRAFANA, + "team": None, + "labels": labels, + "alert_group_labels": alert_group_labels, + } + + client = APIClient() + url = reverse("api-internal:alert_receive_channel-list") + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["labels"] == labels + assert response.json()["alert_group_labels"] == alert_group_labels diff --git a/engine/apps/api/tests/test_openapi_schema.py b/engine/apps/api/tests/test_openapi_schema.py new file mode 100644 index 00000000..2068ba61 --- /dev/null +++ b/engine/apps/api/tests/test_openapi_schema.py @@ -0,0 +1,18 @@ +import pytest +import yaml +from django.test import override_settings +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + + +@pytest.mark.django_db +@override_settings(DRF_SPECTACULAR_ENABLED=True) +def test_fetching_the_openapi_schema_works(settings, reload_urls): + reload_urls() + + client = APIClient() + response = client.get(reverse("schema")) + + assert response.status_code == status.HTTP_200_OK + assert yaml.safe_load(response.content)["info"]["title"] == settings.SPECTACULAR_SETTINGS["TITLE"] diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index bfafb85e..81a26704 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -5,7 +5,7 @@ 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, inline_serializer +from drf_spectacular.utils import extend_schema, extend_schema_view, inline_serializer from rest_framework import mixins, serializers, status, viewsets from rest_framework.decorators import action from rest_framework.exceptions import NotFound @@ -267,6 +267,12 @@ 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, @@ -290,8 +296,6 @@ class AlertGroupView( "filters": [RBACPermission.Permissions.ALERT_GROUPS_READ], "silence_options": [RBACPermission.Permissions.ALERT_GROUPS_READ], "bulk_action_options": [RBACPermission.Permissions.ALERT_GROUPS_READ], - "create": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], - "update": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], "destroy": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], "acknowledge": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], "unacknowledge": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], @@ -478,7 +482,9 @@ class AlertGroupView( @extend_schema(responses=inline_serializer(name="AlertGroupStats", fields={"count": serializers.IntegerField()})) @action(detail=False) def stats(self, *args, **kwargs): - """Return number of alert groups capped at 100001""" + """ + Return number of alert groups capped at 100001 + """ MAX_COUNT = 100001 alert_groups = self.filter_queryset(self.get_queryset())[:MAX_COUNT] count = alert_groups.count() @@ -491,6 +497,9 @@ class AlertGroupView( @action(methods=["post"], detail=True) def acknowledge(self, request, pk): + """ + Acknowledge an alert group + """ alert_group = self.get_object() if alert_group.is_maintenance_incident: raise BadRequest(detail="Can't acknowledge maintenance alert group") @@ -502,6 +511,9 @@ class AlertGroupView( @action(methods=["post"], detail=True) def unacknowledge(self, request, pk): + """ + Unacknowledge an alert group + """ alert_group = self.get_object() if alert_group.is_maintenance_incident: raise BadRequest(detail="Can't unacknowledge maintenance alert group") @@ -521,6 +533,9 @@ class AlertGroupView( @action(methods=["post"], detail=True) def resolve(self, request, pk): + """ + Resolve an alert group + """ alert_group = self.get_object() organization = self.request.user.organization @@ -563,6 +578,9 @@ class AlertGroupView( @action(methods=["post"], detail=True) def unresolve(self, request, pk): + """ + Unresolve an alert group + """ alert_group = self.get_object() if alert_group.is_maintenance_incident: raise BadRequest(detail="Can't unresolve maintenance alert group") @@ -603,6 +621,9 @@ class AlertGroupView( @action(methods=["post"], detail=True) def unattach(self, request, pk=None): + """ + Unattach an alert group that is already attached to another alert group + """ alert_group = self.get_object() if alert_group.is_maintenance_incident: raise BadRequest(detail="Can't unattach maintenance alert group") @@ -614,6 +635,9 @@ class AlertGroupView( @action(methods=["post"], detail=True) def silence(self, request, pk=None): + """ + Silence an alert group for a specified delay + """ alert_group = self.get_object() delay = request.data.get("delay") @@ -635,6 +659,9 @@ class AlertGroupView( ) @action(methods=["get"], detail=False) def silence_options(self, request): + """ + Retrieve a list of valid silence options + """ data = [ {"value": value, "display_name": display_name} for value, display_name in AlertGroup.SILENCE_DELAY_OPTIONS ] @@ -642,6 +669,9 @@ class AlertGroupView( @action(methods=["post"], detail=True) def unsilence(self, request, pk=None): + """ + Unsilence a silenced alert group + """ alert_group = self.get_object() if not alert_group.silenced: @@ -662,6 +692,9 @@ class AlertGroupView( @action(methods=["post"], detail=True) def unpage_user(self, request, pk=None): + """ + Remove a user that was directly paged for the alert group + """ organization = request.auth.organization from_user = request.user alert_group = self.get_object() @@ -681,6 +714,9 @@ class AlertGroupView( @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/" @@ -780,6 +816,9 @@ class AlertGroupView( @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) delay = self.request.data.get("delay") @@ -807,6 +846,9 @@ class AlertGroupView( @action(methods=["get"], detail=False) def bulk_action_options(self, request): + """ + Retrieve a list of valid bulk action options + """ return Response( [{"value": action_name, "display_name": action_name} for action_name in AlertGroup.BULK_ACTIONS] ) diff --git a/engine/apps/labels/migrations/0003_alertreceivechannelassociatedlabel_inherit.py b/engine/apps/labels/migrations/0003_alertreceivechannelassociatedlabel_inherit.py new file mode 100644 index 00000000..fcba6e8a --- /dev/null +++ b/engine/apps/labels/migrations/0003_alertreceivechannelassociatedlabel_inherit.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.6 on 2023-11-09 10:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('labels', '0002_alertgroupassociatedlabel_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='alertreceivechannelassociatedlabel', + name='inheritable', + field=models.BooleanField(default=True, null=True), + ), + ] diff --git a/engine/apps/labels/models.py b/engine/apps/labels/models.py index 233d47b4..f9861a72 100644 --- a/engine/apps/labels/models.py +++ b/engine/apps/labels/models.py @@ -105,6 +105,9 @@ class AlertReceiveChannelAssociatedLabel(AssociatedLabel): "alerts.AlertReceiveChannel", on_delete=models.CASCADE, related_name="labels" ) + # If inheritable is True, then the label will be passed down to alert groups + inheritable = models.BooleanField(default=True, null=True) + class Meta: unique_together = ["key_id", "value_id", "alert_receive_channel_id"] diff --git a/engine/apps/labels/tests/test_alert_group.py b/engine/apps/labels/tests/test_alert_group.py index d022926c..a5ac35ca 100644 --- a/engine/apps/labels/tests/test_alert_group.py +++ b/engine/apps/labels/tests/test_alert_group.py @@ -32,6 +32,7 @@ def test_assign_labels(make_organization, make_alert_receive_channel, make_integ organization = make_organization() alert_receive_channel = make_alert_receive_channel(organization) label = make_integration_label_association(organization, alert_receive_channel) + make_integration_label_association(organization, alert_receive_channel, inheritable=False) alert = Alert.create( title="the title", diff --git a/engine/apps/labels/utils.py b/engine/apps/labels/utils.py index b26e6c63..98d1bf95 100644 --- a/engine/apps/labels/utils.py +++ b/engine/apps/labels/utils.py @@ -55,8 +55,7 @@ def assign_labels(alert_group: "AlertGroup", alert_receive_channel: "AlertReceiv if not is_labels_feature_enabled(alert_receive_channel.organization): return - # inherit all labels from the integration - # FIXME: this is a temporary solution before we have a UI for configuring inherited labels + # inherit labels from the integration alert_group_labels = [ AlertGroupAssociatedLabel( alert_group=alert_group, @@ -64,6 +63,6 @@ def assign_labels(alert_group: "AlertGroup", alert_receive_channel: "AlertReceiv key_name=label.key.name, value_name=label.value.name, ) - for label in alert_receive_channel.labels.all().select_related("key", "value") + for label in alert_receive_channel.labels.filter(inheritable=True).select_related("key", "value") ] AlertGroupAssociatedLabel.objects.bulk_create(alert_group_labels) diff --git a/engine/apps/public_api/serializers/on_call_shifts.py b/engine/apps/public_api/serializers/on_call_shifts.py index 7b9dfb83..796755b1 100644 --- a/engine/apps/public_api/serializers/on_call_shifts.py +++ b/engine/apps/public_api/serializers/on_call_shifts.py @@ -256,6 +256,15 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer result["rotation_start"] = instance.rotation_start.strftime("%Y-%m-%dT%H:%M:%S") if instance.until is not None: result["until"] = instance.until.strftime("%Y-%m-%dT%H:%M:%S") + + # Populate "users" field using "rolling_users" field for web overrides + # To support the behavior of the web UI, which creates overrides populating the rolling_users field + if ( + result["type"] == CustomOnCallShift.PUBLIC_TYPE_CHOICES_MAP[CustomOnCallShift.TYPE_OVERRIDE] + and instance.source == CustomOnCallShift.SOURCE_WEB + and result["rolling_users"] is not None + ): + result["users"] = list({u for r in result["rolling_users"] for u in r}) result = self._get_fields_to_represent(instance, result) return result diff --git a/engine/apps/public_api/tests/test_on_call_shifts.py b/engine/apps/public_api/tests/test_on_call_shifts.py index ce531e06..c3312493 100644 --- a/engine/apps/public_api/tests/test_on_call_shifts.py +++ b/engine/apps/public_api/tests/test_on_call_shifts.py @@ -165,6 +165,51 @@ def test_get_override_on_call_shift(make_organization_and_user_with_token, make_ assert response.data == result +@pytest.mark.django_db +def test_get_web_override_shift( + make_organization_and_user_with_token, make_user_for_organization, make_on_call_shift, make_schedule +): + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + start_data = timezone.now().replace(microsecond=0) + schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar) + data = { + "name": "override shift", + "start": start_data, + "rotation_start": start_data, + "duration": timezone.timedelta(hours=9), + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, + source=CustomOnCallShift.SOURCE_WEB, + shift_type=CustomOnCallShift.TYPE_OVERRIDE, + **data, + ) + on_call_shift.add_rolling_users([[user]]) + + url = reverse("api-public:on_call_shifts-detail", kwargs={"pk": on_call_shift.public_primary_key}) + + response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") + + result = { + "id": on_call_shift.public_primary_key, + "team_id": None, + "schedule": schedule.public_primary_key, + "name": on_call_shift.name, + "type": "override", + "time_zone": None, + "start": on_call_shift.start.strftime("%Y-%m-%dT%H:%M:%S"), + "rotation_start": on_call_shift.start.strftime("%Y-%m-%dT%H:%M:%S"), + "duration": int(on_call_shift.duration.total_seconds()), + "users": list({user.public_primary_key}), + } + + assert response.status_code == status.HTTP_200_OK + assert response.data == result + + @pytest.mark.django_db def test_create_on_call_shift(make_organization_and_user_with_token): _, user, token = make_organization_and_user_with_token() diff --git a/engine/apps/slack/constants.py b/engine/apps/slack/constants.py index 4dd73bb1..f72529fd 100644 --- a/engine/apps/slack/constants.py +++ b/engine/apps/slack/constants.py @@ -12,6 +12,7 @@ SLACK_RATE_LIMIT_TIMEOUT = datetime.timedelta(minutes=5) SLACK_RATE_LIMIT_DELAY = 10 CACHE_UPDATE_INCIDENT_SLACK_MESSAGE_LIFETIME = 60 * 10 +BLOCK_SECTION_TEXT_MAX_SIZE = 2800 PRIVATE_METADATA_MAX_LENGTH = 3000 DIVIDER: Block.Divider = {"type": "divider"} diff --git a/engine/apps/slack/errors.py b/engine/apps/slack/errors.py index 844c8b7f..ac750ac6 100644 --- a/engine/apps/slack/errors.py +++ b/engine/apps/slack/errors.py @@ -53,7 +53,11 @@ class SlackAPIInvalidAuthError(SlackAPIError): class SlackAPIUsergroupNotFoundError(SlackAPIError): - errors = ("no_such_subteam",) + errors = ("no_such_subteam", "subteam_not_found") + + +class SlackAPIInvalidUsersError(SlackAPIError): + errors = ("invalid_users",) class SlackAPIChannelNotFoundError(SlackAPIError): diff --git a/engine/apps/slack/models/slack_usergroup.py b/engine/apps/slack/models/slack_usergroup.py index 0bd45f27..a99c9ee8 100644 --- a/engine/apps/slack/models/slack_usergroup.py +++ b/engine/apps/slack/models/slack_usergroup.py @@ -10,7 +10,13 @@ from django.utils import timezone from apps.api.permissions import RBACPermission from apps.slack.client import SlackClient -from apps.slack.errors import SlackAPIError, SlackAPIPermissionDeniedError +from apps.slack.errors import ( + SlackAPIError, + SlackAPIInvalidUsersError, + SlackAPIPermissionDeniedError, + SlackAPITokenError, + SlackAPIUsergroupNotFoundError, +) from apps.slack.models import SlackTeamIdentity from apps.user_management.models.user import User from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length @@ -112,10 +118,15 @@ class SlackUserGroup(models.Model): def update_members(self, slack_ids): sc = SlackClient(self.slack_team_identity) - sc.usergroups_users_update(usergroup=self.slack_id, users=slack_ids) - - self.members = slack_ids - self.save(update_fields=("members",)) + try: + sc.usergroups_users_update(usergroup=self.slack_id, users=slack_ids) + except (SlackAPITokenError, SlackAPIUsergroupNotFoundError, SlackAPIInvalidUsersError) as err: + logger.warning(f"Slack usergroup update failed: {err}") + except SlackAPIError: + raise + else: + self.members = slack_ids + self.save(update_fields=("members",)) def get_users_from_members_for_organization(self, organization): return organization.users.filter( diff --git a/engine/apps/slack/scenarios/resolution_note.py b/engine/apps/slack/scenarios/resolution_note.py index 09eea274..8a0f9c5a 100644 --- a/engine/apps/slack/scenarios/resolution_note.py +++ b/engine/apps/slack/scenarios/resolution_note.py @@ -4,9 +4,10 @@ import logging import typing from django.db.models import Q +from django.utils.text import Truncator from apps.api.permissions import RBACPermission -from apps.slack.constants import DIVIDER +from apps.slack.constants import BLOCK_SECTION_TEXT_MAX_SIZE, DIVIDER from apps.slack.errors import ( SlackAPIChannelArchivedError, SlackAPIChannelInactiveError, @@ -295,9 +296,10 @@ class UpdateResolutionNoteStep(scenario_step.ScenarioStep): def get_resolution_note_blocks(self, resolution_note: "ResolutionNote") -> Block.AnyBlocks: blocks: Block.AnyBlocks = [] author_verbal = resolution_note.author_verbal(mention=False) + resolution_note_text = Truncator(resolution_note.text) resolution_note_text_block = { "type": "section", - "text": {"type": "mrkdwn", "text": resolution_note.text}, + "text": {"type": "mrkdwn", "text": resolution_note_text.chars(BLOCK_SECTION_TEXT_MAX_SIZE)}, } blocks.append(resolution_note_text_block) context_block = { diff --git a/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py b/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py index 7e2b500c..a64d0208 100644 --- a/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py +++ b/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest from apps.slack.client import SlackClient +from apps.slack.constants import BLOCK_SECTION_TEXT_MAX_SIZE from apps.slack.errors import SlackAPIViewNotFoundError from apps.slack.scenarios.scenario_step import ScenarioStep from apps.slack.tests.conftest import build_slack_response @@ -106,6 +107,47 @@ def test_get_resolution_notes_blocks_non_empty( assert blocks == expected_blocks +@pytest.mark.django_db +def test_get_resolution_note_blocks_truncate_text( + make_organization_and_user_with_slack_identities, + make_alert_receive_channel, + make_alert_group, + make_resolution_note, +): + UpdateResolutionNoteStep = ScenarioStep.get_step("resolution_note", "UpdateResolutionNoteStep") + organization, user, slack_team_identity, _ = make_organization_and_user_with_slack_identities() + step = UpdateResolutionNoteStep(slack_team_identity) + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + resolution_note = make_resolution_note(alert_group=alert_group, author=user, message_text="a" * 3000) + author_verbal = resolution_note.author_verbal(mention=False) + + blocks = step.get_resolution_note_blocks(resolution_note) + + expected_blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + # text is truncated, ellipsis added + "text": resolution_note.text[: BLOCK_SECTION_TEXT_MAX_SIZE - 1] + "…", + }, + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"{author_verbal} resolution note from {resolution_note.get_source_display()}.", + } + ], + }, + ] + + assert blocks == expected_blocks + + @pytest.mark.django_db def test_get_resolution_notes_blocks_latest_limit( make_organization_and_user_with_slack_identities, diff --git a/engine/apps/slack/tests/test_user_group.py b/engine/apps/slack/tests/test_user_group.py index 0af070f2..c1f9c09c 100644 --- a/engine/apps/slack/tests/test_user_group.py +++ b/engine/apps/slack/tests/test_user_group.py @@ -4,6 +4,12 @@ import pytest from apps.schedules.models.on_call_schedule import OnCallScheduleQuerySet, OnCallScheduleWeb from apps.slack.client import SlackClient +from apps.slack.errors import ( + SlackAPIError, + SlackAPIInvalidUsersError, + SlackAPITokenError, + SlackAPIUsergroupNotFoundError, +) from apps.slack.models import SlackUserGroup from apps.slack.tasks import ( populate_slack_usergroups_for_team, @@ -20,14 +26,49 @@ def test_update_members(make_organization_with_slack_team_identity, make_slack_u user_group = make_slack_user_group(slack_team_identity) slack_ids = ["slack_id_1", "slack_id_2"] - - with patch.object(SlackClient, "api_call") as mock: + with patch("apps.slack.client.SlackClient.usergroups_users_update") as mock_update: user_group.update_members(slack_ids) - mock.assert_called() + mock_update.assert_called_once() assert user_group.members == slack_ids +@pytest.mark.django_db +@pytest.mark.parametrize("exception", [SlackAPITokenError, SlackAPIUsergroupNotFoundError, SlackAPIInvalidUsersError]) +def test_slack_user_group_update_errors( + make_organization_with_slack_team_identity, + make_slack_user_group, + exception, +): + organization, slack_team_identity = make_organization_with_slack_team_identity() + user_group = make_slack_user_group(slack_team_identity=slack_team_identity) + + slack_ids = ["slack_id_1", "slack_id_2"] + with patch("apps.slack.client.SlackClient.usergroups_users_update", side_effect=exception("Error")) as mock_update: + user_group.update_members(slack_ids) + + mock_update.assert_called_once() + assert user_group.members is None + + +@pytest.mark.django_db +def test_slack_user_group_update_errors_raise( + make_organization_with_slack_team_identity, + make_slack_user_group, +): + organization, slack_team_identity = make_organization_with_slack_team_identity() + user_group = make_slack_user_group(slack_team_identity=slack_team_identity) + + slack_ids = ["slack_id_1", "slack_id_2"] + with patch( + "apps.slack.client.SlackClient.usergroups_users_update", side_effect=SlackAPIError("Error") + ) as mock_update: + with pytest.raises(SlackAPIError): + user_group.update_members(slack_ids) + + mock_update.assert_called_once() + + @pytest.mark.django_db def test_oncall_slack_user_identities( make_organization_with_slack_team_identity, diff --git a/engine/apps/user_management/models/team.py b/engine/apps/user_management/models/team.py index bbf329d8..628e35d5 100644 --- a/engine/apps/user_management/models/team.py +++ b/engine/apps/user_management/models/team.py @@ -95,8 +95,11 @@ class TeamManager(models.Manager["Team"]): for integration in direct_paging_integrations_to_create: metrics_add_integration_to_cache(integration) - # delete excess teams + # delete excess teams and their direct paging integrations team_ids_to_delete = existing_team_ids - grafana_teams.keys() + organization.alert_receive_channels.filter( + team__team_id__in=team_ids_to_delete, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING + ).delete() organization.teams.filter(team_id__in=team_ids_to_delete).delete() # collect teams diffs to update metrics cache diff --git a/engine/apps/user_management/tests/test_sync.py b/engine/apps/user_management/tests/test_sync.py index ac7eb99d..7153aa5c 100644 --- a/engine/apps/user_management/tests/test_sync.py +++ b/engine/apps/user_management/tests/test_sync.py @@ -101,9 +101,13 @@ def test_sync_users_for_organization_role_none(make_organization, make_user_for_ @pytest.mark.django_db -def test_sync_teams_for_organization(make_organization, make_team): +def test_sync_teams_for_organization(make_organization, make_team, make_alert_receive_channel): organization = make_organization() teams = tuple(make_team(organization, team_id=team_id) for team_id in (1, 2)) + direct_paging_integrations = tuple( + make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, team=team) + for team in teams + ) api_teams = tuple( {"id": team_id, "name": "Test", "email": "test@test.test", "avatarUrl": "test.test/test"} for team_id in (2, 3) @@ -113,14 +117,16 @@ def test_sync_teams_for_organization(make_organization, make_team): assert organization.teams.count() == 2 - # check that excess teams are deleted + # check that excess teams and direct paging integrations are deleted assert not organization.teams.filter(pk=teams[0].pk).exists() + assert not organization.alert_receive_channels.filter(pk=direct_paging_integrations[0].pk).exists() # check that existing teams are updated updated_team = organization.teams.filter(pk=teams[1].pk).first() assert updated_team is not None assert updated_team.name == api_teams[0]["name"] assert updated_team.email == api_teams[0]["email"] + assert organization.alert_receive_channels.filter(pk=direct_paging_integrations[1].pk).exists() # check that missing teams are created created_team = organization.teams.filter(team_id=api_teams[1]["id"]).first() diff --git a/engine/requirements.txt b/engine/requirements.txt index 87aabd88..f29283d2 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -57,5 +57,5 @@ urllib3==1.26.18 prometheus_client==0.16.0 lxml==4.9.2 babel==2.12.1 -drf-spectacular==0.26.2 +drf-spectacular==0.26.5 grpcio==1.57.0 diff --git a/engine/settings/base.py b/engine/settings/base.py index bbc14bb6..dd749033 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -220,9 +220,7 @@ if REDIS_USE_SSL: CACHES = { "default": { "BACKEND": "redis_cache.RedisCache", - "LOCATION": [ - REDIS_URI, - ], + "LOCATION": REDIS_URI, "OPTIONS": { "DB": REDIS_DATABASE, "PARSER_CLASS": "redis.connection.HiredisParser", diff --git a/grafana-plugin/src/components/TooltipBadge/TooltipBadge.tsx b/grafana-plugin/src/components/TooltipBadge/TooltipBadge.tsx index 2d36667b..99ede4a1 100644 --- a/grafana-plugin/src/components/TooltipBadge/TooltipBadge.tsx +++ b/grafana-plugin/src/components/TooltipBadge/TooltipBadge.tsx @@ -11,9 +11,9 @@ interface TooltipBadgeProps { className?: string; borderType: Partial; text?: number | string; - tooltipTitle: string; tooltipContent: React.ReactNode; + tooltipTitle?: string; icon?: IconName; customIcon?: React.ReactNode; addPadding?: boolean; diff --git a/grafana-plugin/src/components/UserGroups/UserGroups.module.css b/grafana-plugin/src/components/UserGroups/UserGroups.module.css index 04440d08..5111f18e 100644 --- a/grafana-plugin/src/components/UserGroups/UserGroups.module.css +++ b/grafana-plugin/src/components/UserGroups/UserGroups.module.css @@ -24,25 +24,6 @@ margin-top: 12px; } -/* -.separator::before { - display: block; - content: ''; - flex-grow: 1; - border-bottom: var(--border-medium); - height: 0; - margin-right: 5px; -} - -.separator::after { - display: block; - content: ''; - flex-grow: 1; - border-bottom: var(--border-medium); - height: 0; - margin-left: 5px; -} */ - .groups { width: 100%; padding: 0; @@ -51,8 +32,8 @@ display: flex; flex-direction: column; gap: 1px; - max-height: calc(100vh - 600px); - overflow: scroll; + max-height: 300px; + overflow: auto; } .user { diff --git a/grafana-plugin/src/containers/IntegrationLabelsForm/IntegrationLabelsForm.module.css b/grafana-plugin/src/containers/IntegrationLabelsForm/IntegrationLabelsForm.module.css new file mode 100644 index 00000000..89d574a5 --- /dev/null +++ b/grafana-plugin/src/containers/IntegrationLabelsForm/IntegrationLabelsForm.module.css @@ -0,0 +1,13 @@ +.labels-list { + margin: 0; + list-style-type: none; + + > li { + margin: 10px 0; + } +} + +.buttons { + width: 100%; + margin-top: 30px; +} diff --git a/grafana-plugin/src/containers/IntegrationLabelsForm/IntegrationLabelsForm.tsx b/grafana-plugin/src/containers/IntegrationLabelsForm/IntegrationLabelsForm.tsx new file mode 100644 index 00000000..4577a293 --- /dev/null +++ b/grafana-plugin/src/containers/IntegrationLabelsForm/IntegrationLabelsForm.tsx @@ -0,0 +1,105 @@ +import React, { useState } from 'react'; + +import { Button, Drawer, HorizontalGroup, Icon, InlineSwitch, Input, Label, Tooltip, VerticalGroup } from '@grafana/ui'; +import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; + +import Text from 'components/Text/Text'; +import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types'; +import { LabelKey } from 'models/label/label.types'; +import { useStore } from 'state/useStore'; + +import styles from './IntegrationLabelsForm.module.css'; + +const cx = cn.bind(styles); + +interface IntegrationLabelsFormProps { + id: AlertReceiveChannel['id']; + onSubmit: () => void; + onHide: () => void; + onOpenIntegraionSettings: (id: AlertReceiveChannel['id']) => void; +} + +const IntegrationLabelsForm = observer((props: IntegrationLabelsFormProps) => { + const { id, onHide, onSubmit, onOpenIntegraionSettings } = props; + + const store = useStore(); + + const { alertReceiveChannelStore } = store; + + const alertReceiveChannel = alertReceiveChannelStore.items[id]; + + const [alertGroupLabels, setAlertGroupLabels] = useState(alertReceiveChannel.alert_group_labels); + + const handleSave = () => { + alertReceiveChannelStore.saveAlertReceiveChannel(id, { alert_group_labels: alertGroupLabels }); + + onSubmit(); + + onHide(); + }; + + const handleOpenIntegrationSettings = () => { + onHide(); + + onOpenIntegraionSettings(id); + }; + + const getInheritanceChangeHandler = (keyId: LabelKey['id']) => { + return (event: React.ChangeEvent) => { + setAlertGroupLabels((alertGroupLabels) => ({ + ...alertGroupLabels, + inheritable: { ...alertGroupLabels.inheritable, [keyId]: event.target.checked }, + })); + }; + }; + + return ( + + + + + + + + +
    + {alertReceiveChannel.labels.length ? ( + alertReceiveChannel.labels.map((label) => ( +
  • + + + + + +
  • + )) + ) : ( + + There are no labels to inherit yet + + Add labels to the integration + + + )} +
+
+ + + + +
+
+
+ ); +}); + +export default IntegrationLabelsForm; diff --git a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx index 13be43ab..f816c77a 100644 --- a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx +++ b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx @@ -38,7 +38,7 @@ import styles from './RemoteFilters.module.css'; const cx = cn.bind(styles); interface RemoteFiltersProps extends WithStoreProps { - onChange: (filters: { [key: string]: any }, isOnMount: boolean, invalidateFn: () => boolean) => void; + onChange: (filters: Record, isOnMount: boolean, invalidateFn: () => boolean) => void; query: KeyValue; page: PAGE; defaultFilters?: FiltersValues; @@ -49,7 +49,7 @@ interface RemoteFiltersProps extends WithStoreProps { interface RemoteFiltersState { filterOptions?: FilterOption[]; filters: FilterOption[]; - values: { [key: string]: any }; + values: Record; hadInteraction: boolean; lastRequestId: string; } diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.module.css b/grafana-plugin/src/containers/RotationForm/RotationForm.module.css index 1bc22f4f..d97b751f 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.module.css +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.module.css @@ -1,4 +1,4 @@ -.body { +.container { margin: 15px -15px; padding: 15px 0; border-top: var(--border-medium); diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index ed02dd3b..5cf41457 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -15,7 +15,7 @@ import { import cn from 'classnames/bind'; import dayjs from 'dayjs'; import { observer } from 'mobx-react'; -import Draggable from 'react-draggable'; +import Draggable, { DraggableData, DraggableEvent } from 'react-draggable'; import Block from 'components/GBlock/Block'; import Modal from 'components/Modal/Modal'; @@ -106,6 +106,10 @@ const RotationForm = observer((props: RotationFormProps) => { const [errors, setErrors] = useState<{ [key: string]: string[] }>({}); + const [bounds, setDraggableBounds] = useState<{ left: number; right: number; top: number; bottom: number }>( + undefined + ); + const [rotationName, setRotationName] = useState(`[L${layerPriority}] Rotation`); const [isOpen, setIsOpen] = useState(false); const [offsetTop, setOffsetTop] = useState(0); @@ -283,22 +287,19 @@ const RotationForm = observer((props: RotationFormProps) => { [showActiveOnSelectedPartOfDay, showActiveOnSelectedDays, repeatEveryValue] ); - const handleRepeatEveryValueChange = useCallback( - (option) => { - const value = Math.floor(Number(option.value)); - if (isNaN(value) || value < 1) { - return; - } + const handleRepeatEveryValueChange = (option) => { + const value = Math.floor(Number(option.value)); + if (isNaN(value) || value < 1) { + return; + } - setShiftPeriodDefaultValue(undefined); - setRepeatEveryValue(value); + setShiftPeriodDefaultValue(undefined); + setRepeatEveryValue(value); - if (!showActiveOnSelectedPartOfDay) { - setShiftEnd(shiftStart.add(value, repeatEveryPeriodToUnitName[repeatEveryPeriod])); - } - }, - [showActiveOnSelectedPartOfDay, repeatEveryPeriod] - ); + if (!showActiveOnSelectedPartOfDay) { + setShiftEnd(rotationStart.add(value, repeatEveryPeriodToUnitName[repeatEveryPeriod])); + } + }; const handleRotationStartChange = useCallback( (value) => { @@ -426,7 +427,8 @@ const RotationForm = observer((props: RotationFormProps) => { handle=".drag-handler" defaultClassName={cx('draggable')} positionOffset={{ x: 0, y: offsetTop }} - bounds={{ top: 0 }} + bounds={bounds || 'body'} + onStart={onDraggableInit} >
{children}
@@ -460,7 +462,7 @@ const RotationForm = observer((props: RotationFormProps) => { -
+
{hasUpdatedShift && ( @@ -687,6 +689,20 @@ const RotationForm = observer((props: RotationFormProps) => { )} ); + + function onDraggableInit(_e: DraggableEvent, data: DraggableData) { + if (!data) { + return; + } + + const scrollbarView = document.querySelector('.scrollbar-view')?.getBoundingClientRect(); + + const x = data.node.offsetLeft; + const top = -data.node.offsetTop + (scrollbarView?.top || 100); + const bottom = window.innerHeight - (data.node.offsetTop + data.node.offsetHeight); + + setDraggableBounds({ left: -x, right: x, top: top - offsetTop, bottom: bottom - offsetTop }); + } }); interface ShiftPeriodProps { diff --git a/grafana-plugin/src/containers/TemplatesAlertGroupsList/TemplatesAlertGroupsList.tsx b/grafana-plugin/src/containers/TemplatesAlertGroupsList/TemplatesAlertGroupsList.tsx index 8c49e1dd..1b2127d7 100644 --- a/grafana-plugin/src/containers/TemplatesAlertGroupsList/TemplatesAlertGroupsList.tsx +++ b/grafana-plugin/src/containers/TemplatesAlertGroupsList/TemplatesAlertGroupsList.tsx @@ -301,7 +301,6 @@ const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => { diff --git a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts index ebb2fbba..ec366bf2 100644 --- a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts +++ b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts @@ -49,6 +49,7 @@ export interface AlertReceiveChannel { allow_delete: boolean; deleted?: boolean; labels: LabelKeyValue[]; + alert_group_labels: { inheritable: Record }; } export interface AlertReceiveChannelChoice { diff --git a/grafana-plugin/src/models/alertgroup/alertgroup.ts b/grafana-plugin/src/models/alertgroup/alertgroup.ts index 80a8cb29..e2fecd3c 100644 --- a/grafana-plugin/src/models/alertgroup/alertgroup.ts +++ b/grafana-plugin/src/models/alertgroup/alertgroup.ts @@ -30,9 +30,6 @@ export class AlertGroupStore extends BaseStore { @observable alertGroupsLoading = false; - @observable - needToParseFilters = false; - @observable incidentFilters: any; diff --git a/grafana-plugin/src/models/alertgroup/alertgroup.types.ts b/grafana-plugin/src/models/alertgroup/alertgroup.types.ts index 2a707d73..082c6186 100644 --- a/grafana-plugin/src/models/alertgroup/alertgroup.types.ts +++ b/grafana-plugin/src/models/alertgroup/alertgroup.types.ts @@ -1,6 +1,7 @@ import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types'; import { Channel } from 'models/channel'; import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; +import { LabelKeyValue } from 'models/label/label.types'; import { PagedUser, User } from 'models/user/user.types'; export enum IncidentStatus { @@ -82,6 +83,7 @@ export interface Alert { paged_users: PagedUser[]; team: GrafanaTeam['id']; grafana_incident_id: string | null; + labels: LabelKeyValue[]; // set by client loading?: boolean; diff --git a/grafana-plugin/src/models/filters/filters.ts b/grafana-plugin/src/models/filters/filters.ts index 472dd727..ac4a63f3 100644 --- a/grafana-plugin/src/models/filters/filters.ts +++ b/grafana-plugin/src/models/filters/filters.ts @@ -24,7 +24,7 @@ export class FiltersStore extends BaseStore { private _globalValues: FiltersValues = {}; @observable - public needToParseFilters = false; + needToParseFilters = false; constructor(rootStore: RootStore) { super(rootStore); @@ -35,6 +35,11 @@ export class FiltersStore extends BaseStore { } } + @action + setNeedToParseFilters(value: boolean) { + this.needToParseFilters = value; + } + set globalValues(value: any) { this._globalValues = value; diff --git a/grafana-plugin/src/pages/incident/Incident.tsx b/grafana-plugin/src/pages/incident/Incident.tsx index 64ac84ed..5a913c77 100644 --- a/grafana-plugin/src/pages/incident/Incident.tsx +++ b/grafana-plugin/src/pages/incident/Incident.tsx @@ -1,5 +1,6 @@ import React, { useState, SyntheticEvent } from 'react'; +import { LabelTag } from '@grafana/labels'; import { Button, HorizontalGroup, @@ -34,6 +35,7 @@ import { PluginBridge, SupportedPlugin } from 'components/PluginBridge/PluginBri import PluginLink from 'components/PluginLink/PluginLink'; import SourceCode from 'components/SourceCode/SourceCode'; import Text from 'components/Text/Text'; +import TooltipBadge from 'components/TooltipBadge/TooltipBadge'; import AddResponders from 'containers/AddResponders/AddResponders'; import { prepareForUpdate } from 'containers/AddResponders/AddResponders.helpers'; import { UserResponder } from 'containers/AddResponders/AddResponders.types'; @@ -50,6 +52,7 @@ import { import { ResolutionNoteSourceTypesToDisplayName } from 'models/resolution_note/resolution_note.types'; import { User } from 'models/user/user.types'; import { IncidentDropdown } from 'pages/incidents/parts/IncidentDropdown'; +import { AppFeature } from 'state/features'; import { PageProps, WithStoreProps } from 'state/types'; import { useStore } from 'state/useStore'; import { withMobXProviderContext } from 'state/withStore'; @@ -339,6 +342,22 @@ class IncidentPage extends React.Component />
+ {Boolean(store.hasFeature(AppFeature.Labels) && incident.labels.length) && ( + + {incident.labels.map((label) => ( + + ))} + + } + /> + )} + {integration && ( ; affectedRows: { [key: string]: boolean }; - filters?: IncidentsFiltersType; + filters?: Record; pagination: Pagination; showAddAlertGroupForm: boolean; } @@ -583,6 +587,37 @@ class Incidents extends React.Component ); } + renderLabels(item: AlertType) { + if (!item.labels.length) { + return null; + } + + return ( + + {item.labels.map((label) => ( + + +
+ {store.hasFeature(AppFeature.Labels) && ( + +
openLabelsForm()}> + Alert group labels +
+
+ )} + {showHeartbeatSettings() && (
= ({ setIsIntegrationSettingsOpen(true); } + function openLabelsForm() { + setLabelsFormOpen(true); + } + function openStartMaintenance() { setMaintenanceData({ disabled: true, alert_receive_channel_id: alertReceiveChannel.id }); } @@ -1061,20 +1089,17 @@ const IntegrationHeader: React.FC = ({ )} - {renderLabels && ( + {Boolean(renderLabels && alertReceiveChannel.labels.length) && ( - {alertReceiveChannel.labels.length - ? alertReceiveChannel.labels.map((label) => ( - - )) - : 'No labels attached'} + {alertReceiveChannel.labels.map((label) => ( + + ))} } /> diff --git a/grafana-plugin/src/pages/integrations/Integrations.tsx b/grafana-plugin/src/pages/integrations/Integrations.tsx index 205d4eeb..b3acc3b2 100644 --- a/grafana-plugin/src/pages/integrations/Integrations.tsx +++ b/grafana-plugin/src/pages/integrations/Integrations.tsx @@ -34,6 +34,7 @@ import TextEllipsisTooltip from 'components/TextEllipsisTooltip/TextEllipsisTool import TooltipBadge from 'components/TooltipBadge/TooltipBadge'; import { WithContextMenu } from 'components/WithContextMenu/WithContextMenu'; import IntegrationForm from 'containers/IntegrationForm/IntegrationForm'; +import IntegrationLabelsForm from 'containers/IntegrationLabelsForm/IntegrationLabelsForm'; import RemoteFilters from 'containers/RemoteFilters/RemoteFilters'; import TeamName from 'containers/TeamName/TeamName'; import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; @@ -80,6 +81,7 @@ const FILTERS_DEBOUNCE_MS = 500; interface IntegrationsState extends PageBaseState { integrationsFilters: SupportedIntegrationFilters; alertReceiveChannelId?: AlertReceiveChannel['id'] | 'new'; + alertReceiveChannelIdToShowLabels?: AlertReceiveChannel['id']; confirmationModal: { isOpen: boolean; title: any; @@ -192,7 +194,13 @@ class Integrations extends React.Component render() { const { store, query } = this.props; - const { alertReceiveChannelId, confirmationModal, activeTab, integrationsFilters } = this.state; + const { + alertReceiveChannelId, + alertReceiveChannelIdToShowLabels, + confirmationModal, + activeTab, + integrationsFilters, + } = this.state; const { alertReceiveChannelStore } = store; const { count, results, page_size } = alertReceiveChannelStore.getPaginatedSearchResult(); @@ -289,6 +297,19 @@ class Integrations extends React.Component /> )} + {alertReceiveChannelIdToShowLabels && ( + { + this.setState({ alertReceiveChannelIdToShowLabels: undefined }); + }} + onSubmit={this.update} + id={alertReceiveChannelIdToShowLabels} + onOpenIntegraionSettings={(id: AlertReceiveChannel['id']) => { + this.setState({ alertReceiveChannelId: id }); + }} + /> + )} + {confirmationModal && ( borderType="primary" placement="top" text={alertReceiveChannelCounter?.alerts_count + '/' + alertReceiveChannelCounter?.alert_groups_count} - tooltipTitle="" tooltipContent={ alertReceiveChannelCounter?.alerts_count + ' alert' + @@ -452,29 +472,30 @@ class Integrations extends React.Component } renderLabels(item: AlertReceiveChannel) { + if (!item.labels.length) { + return null; + } + return ( - {item.labels?.length - ? item.labels.map((label) => ( - - -
+ {store.hasFeature(AppFeature.Labels) && ( + +
this.onLabelsEditClick(item.id)}> + Alert group labels +
+
+ )} + openNotification('Integration ID has been copied')}>
@@ -631,6 +662,10 @@ class Integrations extends React.Component this.setState({ alertReceiveChannelId: id }); }; + onLabelsEditClick = (id: AlertReceiveChannel['id']) => { + this.setState({ alertReceiveChannelIdToShowLabels: id }); + }; + handleDeleteAlertReceiveChannel = (alertReceiveChannelId: AlertReceiveChannel['id']) => { const { store } = this.props; @@ -666,7 +701,7 @@ class Integrations extends React.Component LocationHelper.update({ label: newLabelFilter }, 'partial'); - filtersStore.needToParseFilters = true; + filtersStore.setNeedToParseFilters(true); }; }; diff --git a/helm/oncall/README.md b/helm/oncall/README.md index a4eaa782..993f0649 100644 --- a/helm/oncall/README.md +++ b/helm/oncall/README.md @@ -192,6 +192,19 @@ externalRedis: passwordKey: "" ``` +### Running split ingestion and API services + +You can run a detached service for handling integrations by setting up the following variables: + +```yaml +detached_integrations: + enabled: true +detached_integrations_service: + enabled: true +``` + +This will run an integrations-only service listening by default in port 30003. + ### Set up Slack and Telegram You can set up Slack connection via following variables: