diff --git a/CHANGELOG.md b/CHANGELOG.md index a97214e5..3dbd9fc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Helm chart: add the option to use a helm hook for the migration job ([1386](https://github.com/grafana/oncall/pull/1386)) - Add endpoints to start and stop maintenance in alert receive channel private api ([1755](https://github.com/grafana/oncall/pull/1755)) - Send demo alert with dynamic payload and get demo payload example on private api ([1700](https://github.com/grafana/oncall/pull/1700)) +- Add is_default fields to templates, remove WritableSerialiserMethodField ([1759](https://github.com/grafana/oncall/pull/1759)) - Allow use of dynamic payloads in alert receive channels preview template in private api ([1756](https://github.com/grafana/oncall/pull/1756)) ## v1.2.11 (2023-04-14) diff --git a/engine/apps/api/serializers/alert_receive_channel.py b/engine/apps/api/serializers/alert_receive_channel.py index 64341317..852c3229 100644 --- a/engine/apps/api/serializers/alert_receive_channel.py +++ b/engine/apps/api/serializers/alert_receive_channel.py @@ -1,22 +1,19 @@ from collections import OrderedDict -from collections.abc import Mapping from django.apps import apps from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError as DjangoValidationError from django.template.loader import render_to_string -from django.utils import timezone from jinja2 import TemplateSyntaxError from rest_framework import serializers from rest_framework.exceptions import ValidationError -from rest_framework.fields import SerializerMethodField, SkipField, get_error_detail, set_value -from rest_framework.settings import api_settings +from rest_framework.fields import SerializerMethodField, set_value from apps.alerts.grafana_alerting_sync_manager.grafana_alerting_sync import GrafanaAlertingSyncManager from apps.alerts.models import AlertReceiveChannel from apps.base.messaging import get_messaging_backends -from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField, WritableSerializerMethodField +from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField from common.api_helpers.exceptions import BadRequest from common.api_helpers.mixins import APPEARANCE_TEMPLATE_NAMES, EagerLoadingMixin from common.api_helpers.utils import CurrentTeamDefault @@ -197,96 +194,23 @@ class FilterAlertReceiveChannelSerializer(serializers.ModelSerializer): class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.ModelSerializer): id = serializers.CharField(read_only=True, source="public_primary_key") - slack_title_template = WritableSerializerMethodField( - allow_null=True, - deserializer_field=serializers.CharField(), - validators=[valid_jinja_template_for_serializer_method_field], - required=False, - ) - slack_message_template = WritableSerializerMethodField( - allow_null=True, - deserializer_field=serializers.CharField(), - validators=[valid_jinja_template_for_serializer_method_field], - required=False, - ) - slack_image_url_template = WritableSerializerMethodField( - allow_null=True, - deserializer_field=serializers.CharField(), - validators=[valid_jinja_template_for_serializer_method_field], - required=False, - ) - web_title_template = WritableSerializerMethodField( - allow_null=True, - deserializer_field=serializers.CharField(), - validators=[valid_jinja_template_for_serializer_method_field], - required=False, - ) - web_message_template = WritableSerializerMethodField( - allow_null=True, - deserializer_field=serializers.CharField(), - validators=[valid_jinja_template_for_serializer_method_field], - required=False, - ) - web_image_url_template = WritableSerializerMethodField( - allow_null=True, - deserializer_field=serializers.CharField(), - validators=[valid_jinja_template_for_serializer_method_field], - required=False, - ) - sms_title_template = WritableSerializerMethodField( - allow_null=True, - deserializer_field=serializers.CharField(), - validators=[valid_jinja_template_for_serializer_method_field], - required=False, - ) - phone_call_title_template = WritableSerializerMethodField( - allow_null=True, - deserializer_field=serializers.CharField(), - validators=[valid_jinja_template_for_serializer_method_field], - required=False, - ) - telegram_title_template = WritableSerializerMethodField( - allow_null=True, - deserializer_field=serializers.CharField(), - validators=[valid_jinja_template_for_serializer_method_field], - required=False, - ) - telegram_message_template = WritableSerializerMethodField( - allow_null=True, - deserializer_field=serializers.CharField(), - validators=[valid_jinja_template_for_serializer_method_field], - required=False, - ) - telegram_image_url_template = WritableSerializerMethodField( - allow_null=True, - deserializer_field=serializers.CharField(), - validators=[valid_jinja_template_for_serializer_method_field], - required=False, - ) - source_link_template = WritableSerializerMethodField( - allow_null=True, - deserializer_field=serializers.CharField(), - validators=[valid_jinja_template_for_serializer_method_field], - required=False, - ) - grouping_id_template = WritableSerializerMethodField( - allow_null=True, - deserializer_field=serializers.CharField(), - validators=[valid_jinja_template_for_serializer_method_field], - required=False, - ) - acknowledge_condition_template = WritableSerializerMethodField( - allow_null=True, - deserializer_field=serializers.CharField(), - validators=[valid_jinja_template_for_serializer_method_field], - required=False, - ) - resolve_condition_template = WritableSerializerMethodField( - allow_null=True, - deserializer_field=serializers.CharField(), - validators=[valid_jinja_template_for_serializer_method_field], - required=False, - ) + CORE_TEMPLATE_NAMES = [ + "slack_title_template", + "slack_message_template", + "slack_image_url_template", + "web_title_template", + "web_message_template", + "web_image_url_template", + "telegram_title_template", + "telegram_message_template", + "telegram_image_url_template", + "sms_title_template", + "phone_call_title_template", + "source_link_template", + "grouping_id_template", + "resolve_condition_template", + "acknowledge_condition_template", + ] payload_example = SerializerMethodField() @@ -295,207 +219,10 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode fields = [ "id", "verbal_name", - "slack_title_template", - "slack_message_template", - "slack_image_url_template", - "sms_title_template", - "phone_call_title_template", - "web_title_template", - "web_message_template", - "web_image_url_template", - "telegram_title_template", - "telegram_message_template", - "telegram_image_url_template", - "source_link_template", - "grouping_id_template", - "resolve_condition_template", "payload_example", - "acknowledge_condition_template", ] extra_kwargs = {"integration": {"required": True}} - # MethodFields are used instead of relevant properties because of properties hit db on each instance in queryset - - def get_slack_title_template(self, obj): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SLACK_TITLE_TEMPLATE[obj.integration] - return obj.slack_title_template or default_template - - def set_slack_title_template(self, value): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SLACK_TITLE_TEMPLATE[self.instance.integration] - if default_template is None or default_template.strip() != value.strip(): - self.instance.slack_title_template = value.strip() - elif default_template is not None and default_template.strip() == value.strip(): - self.instance.slack_title_template = None - - def get_slack_message_template(self, obj): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SLACK_MESSAGE_TEMPLATE[obj.integration] - return obj.slack_message_template or default_template - - def set_slack_message_template(self, value): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SLACK_MESSAGE_TEMPLATE[self.instance.integration] - if default_template is None or default_template.strip() != value.strip(): - self.instance.slack_message_template = value.strip() - elif default_template is not None and default_template.strip() == value.strip(): - self.instance.slack_message_template = None - - def get_slack_image_url_template(self, obj): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SLACK_IMAGE_URL_TEMPLATE[obj.integration] - return obj.slack_image_url_template or default_template - - def set_slack_image_url_template(self, value): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SLACK_IMAGE_URL_TEMPLATE[ - self.instance.integration - ] - if default_template is None or default_template.strip() != value.strip(): - self.instance.slack_image_url_template = value.strip() - elif default_template is not None and default_template.strip() == value.strip(): - self.instance.slack_image_url_template = None - - def get_sms_title_template(self, obj): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SMS_TITLE_TEMPLATE[obj.integration] - return obj.sms_title_template or default_template - - def set_sms_title_template(self, value): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SMS_TITLE_TEMPLATE[self.instance.integration] - if default_template is None or default_template.strip() != value.strip(): - self.instance.sms_title_template = value.strip() - elif default_template is not None and default_template.strip() == value.strip(): - self.instance.sms_title_template = None - - def get_phone_call_title_template(self, obj): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_PHONE_CALL_TITLE_TEMPLATE[obj.integration] - return obj.phone_call_title_template or default_template - - def set_phone_call_title_template(self, value): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_PHONE_CALL_TITLE_TEMPLATE[ - self.instance.integration - ] - if default_template is None or default_template.strip() != value.strip(): - self.instance.phone_call_title_template = value.strip() - elif default_template is not None and default_template.strip() == value.strip(): - self.instance.phone_call_title_template = None - - def get_web_title_template(self, obj): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_WEB_TITLE_TEMPLATE[obj.integration] - return obj.web_title_template or default_template - - def set_web_title_template(self, value): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_WEB_TITLE_TEMPLATE[self.instance.integration] - if default_template is None or default_template.strip() != value.strip(): - self.instance.web_title_template = value.strip() - elif default_template is not None and default_template.strip() == value.strip(): - self.instance.web_title_template = None - self.instance.web_templates_modified_at = timezone.now() - - def get_web_message_template(self, obj): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_WEB_MESSAGE_TEMPLATE[obj.integration] - return obj.web_message_template or default_template - - def set_web_message_template(self, value): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_WEB_MESSAGE_TEMPLATE[self.instance.integration] - if default_template is None or default_template.strip() != value.strip(): - self.instance.web_message_template = value.strip() - elif default_template is not None and default_template.strip() == value.strip(): - self.instance.web_message_template = None - self.instance.web_templates_modified_at = timezone.now() - - def get_web_image_url_template(self, obj): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_WEB_IMAGE_URL_TEMPLATE[obj.integration] - return obj.web_image_url_template or default_template - - def set_web_image_url_template(self, value): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_WEB_IMAGE_URL_TEMPLATE[self.instance.integration] - if default_template is None or default_template.strip() != value.strip(): - self.instance.web_image_url_template = value.strip() - elif default_template is not None and default_template.strip() == value.strip(): - self.instance.web_image_url_template = None - self.instance.web_templates_modified_at = timezone.now() - - def get_telegram_title_template(self, obj): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_TELEGRAM_TITLE_TEMPLATE[obj.integration] - return obj.telegram_title_template or default_template - - def set_telegram_title_template(self, value): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_TELEGRAM_TITLE_TEMPLATE[self.instance.integration] - if default_template is None or default_template.strip() != value.strip(): - self.instance.telegram_title_template = value.strip() - elif default_template is not None and default_template.strip() == value.strip(): - self.instance.telegram_title_template = None - - def get_telegram_message_template(self, obj): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_TELEGRAM_MESSAGE_TEMPLATE[obj.integration] - return obj.telegram_message_template or default_template - - def set_telegram_message_template(self, value): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_TELEGRAM_MESSAGE_TEMPLATE[ - self.instance.integration - ] - if default_template is None or default_template.strip() != value.strip(): - self.instance.telegram_message_template = value.strip() - elif default_template is not None and default_template.strip() == value.strip(): - self.instance.telegram_message_template = None - - def get_telegram_image_url_template(self, obj): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_TELEGRAM_IMAGE_URL_TEMPLATE[obj.integration] - return obj.telegram_image_url_template or default_template - - def set_telegram_image_url_template(self, value): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_TELEGRAM_IMAGE_URL_TEMPLATE[ - self.instance.integration - ] - if default_template is None or default_template.strip() != value.strip(): - self.instance.telegram_image_url_template = value.strip() - elif default_template is not None and default_template.strip() == value.strip(): - self.instance.telegram_image_url_template = None - - def get_source_link_template(self, obj): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SOURCE_LINK_TEMPLATE[obj.integration] - return obj.source_link_template or default_template - - def set_source_link_template(self, value): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_SOURCE_LINK_TEMPLATE[self.instance.integration] - if default_template is None or default_template.strip() != value.strip(): - self.instance.source_link_template = value.strip() - elif default_template is not None and default_template.strip() == value.strip(): - self.instance.source_link_template = None - - def get_grouping_id_template(self, obj): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_GROUPING_ID_TEMPLATE[obj.integration] - return obj.grouping_id_template or default_template - - def set_grouping_id_template(self, value): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_GROUPING_ID_TEMPLATE[self.instance.integration] - if default_template is None or default_template.strip() != value.strip(): - self.instance.grouping_id_template = value.strip() - elif default_template is not None and default_template.strip() == value.strip(): - self.instance.grouping_id_template = None - - def get_acknowledge_condition_template(self, obj): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_ACKNOWLEDGE_CONDITION_TEMPLATE[obj.integration] - return obj.acknowledge_condition_template or default_template - - def set_acknowledge_condition_template(self, value): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_ACKNOWLEDGE_CONDITION_TEMPLATE[ - self.instance.integration - ] - if default_template is None or default_template.strip() != value.strip(): - self.instance.acknowledge_condition_template = value.strip() - elif default_template is not None and default_template.strip() == value.strip(): - self.instance.acknowledge_condition_template = None - - def get_resolve_condition_template(self, obj): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_RESOLVE_CONDITION_TEMPLATE[obj.integration] - return obj.resolve_condition_template or default_template - - def set_resolve_condition_template(self, value): - default_template = AlertReceiveChannel.INTEGRATION_TO_DEFAULT_RESOLVE_CONDITION_TEMPLATE[ - self.instance.integration - ] - if default_template is None or default_template.strip() != value.strip(): - self.instance.resolve_condition_template = value.strip() - elif default_template is not None and default_template.strip() == value.strip(): - self.instance.resolve_condition_template = None - def get_payload_example(self, obj): AlertGroup = apps.get_model("alerts", "AlertGroup") if "alert_group_id" in self.context["request"].query_params: @@ -517,33 +244,15 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode """ Dict of native values <- Dict of primitive datatypes. """ - if not isinstance(data, Mapping): - message = self.error_messages["invalid"].format(datatype=type(data).__name__) - raise ValidationError({api_settings.NON_FIELD_ERRORS_KEY: [message]}, code="invalid") + # First validate and save data from serializer fields + ret = super().to_internal_value(data) - ret = OrderedDict() + # Separately validate and save template fields we generate dynamically errors = OrderedDict() - fields = self._writable_fields - for field in fields: - validate_method = getattr(self, "validate_" + field.field_name, None) - primitive_value = field.get_value(data) - try: - validated_value = field.run_validation(primitive_value) - if validate_method is not None: - validated_value = validate_method(validated_value) - except ValidationError as exc: - errors[field.field_name] = exc.detail - except DjangoValidationError as exc: - errors[field.field_name] = get_error_detail(exc) - except SkipField: - pass - else: - # Line because of which method is overriden - if validated_value is None and isinstance(field, WritableSerializerMethodField): - set_value(ret, [field.field_name], validated_value) - else: - set_value(ret, field.source_attrs, validated_value) + # handle updates for core templates + core_template_errors = self._handle_core_template_updates(data, ret) + errors.update(core_template_errors) # handle updates for messaging backend templates messaging_backend_errors = self._handle_messaging_backend_updates(data, ret) @@ -551,7 +260,6 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode if errors: raise ValidationError(errors) - return ret def _handle_messaging_backend_updates(self, data, ret): @@ -586,10 +294,33 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode return errors + def _handle_core_template_updates(self, data, ret): + """Update core templates if needed.""" + errors = {} + + core_template_names = self.CORE_TEMPLATE_NAMES + + for field_name in core_template_names: + value = data.get(field_name) + validator = jinja_template_env.from_string + if value is not None: + try: + if value: + validator(value) + except TemplateSyntaxError: + errors[field_name] = "invalid template" + except DjangoValidationError: + errors[field_name] = "invalid URL" + set_value(ret, [field_name], value) + return errors + def to_representation(self, obj): ret = super().to_representation(obj) ret = self._get_templates_to_show(ret) + core_templates = self._get_core_templates(obj) + ret.update(core_templates) + # include messaging backend templates additional_templates = self._get_messaging_backend_templates(obj) ret.update(additional_templates) @@ -627,10 +358,26 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode continue for field in backend.template_fields: value = None + is_default = False if obj.messaging_backends_templates: value = obj.messaging_backends_templates.get(backend_id, {}).get(field) if not value: value = obj.get_default_template_attribute(backend_id, field) + is_default = True field_name = f"{backend.slug}_{field}_template" templates[field_name] = value + templates[f"{field_name}_is_default"] = is_default return templates + + def _get_core_templates(self, obj): + core_templates = {} + + core_template_names = self.CORE_TEMPLATE_NAMES + for template_name in core_template_names: + template_value = getattr(obj, template_name) + defaults = getattr(obj, f"INTEGRATION_TO_DEFAULT_{template_name.upper()}", {}) + default_template_value = defaults.get(obj.integration) + core_templates[template_name] = template_value or default_template_value + core_templates[f"{template_name}_is_default"] = not bool(template_value) + + return core_templates diff --git a/engine/apps/api/tests/test_alert_receive_channel_template.py b/engine/apps/api/tests/test_alert_receive_channel_template.py index 1b2a1d6a..e00f75b9 100644 --- a/engine/apps/api/tests/test_alert_receive_channel_template.py +++ b/engine/apps/api/tests/test_alert_receive_channel_template.py @@ -376,4 +376,7 @@ def test_update_alert_receive_channel_templates( # check if updated templates are applied updated_templates_data = response.json() for template_name, prev_template_value in existing_templates_data.items(): - assert updated_templates_data[template_name] == template_update_func(prev_template_value) + if template_name.endswith("_is_default"): + assert updated_templates_data[template_name] is False + else: + assert updated_templates_data[template_name] == template_update_func(prev_template_value) diff --git a/engine/common/api_helpers/custom_fields.py b/engine/common/api_helpers/custom_fields.py index d7114b67..28bc9e30 100644 --- a/engine/common/api_helpers/custom_fields.py +++ b/engine/common/api_helpers/custom_fields.py @@ -101,35 +101,6 @@ class UsersFilteredByOrganizationField(serializers.Field): return queryset.filter(organization=request.user.organization, public_primary_key__in=data).distinct() -class WritableSerializerMethodField(serializers.SerializerMethodField): - """ - Please, NEVER use this field. - It was a mistake to create this one due to necessity to dig deep in drf to fix bugs there. - This field is a workaround to allow to write into SerializerMethodField. - """ - - def __init__(self, method_name=None, **kwargs): - self.method_name = method_name - self.setter_method_name = kwargs.pop("setter_method_name", None) - self.deserializer_field = kwargs.pop("deserializer_field") - - kwargs["source"] = "*" - super(serializers.SerializerMethodField, self).__init__(**kwargs) - - def bind(self, field_name, parent): - retval = super().bind(field_name, parent) - if not self.setter_method_name: - self.setter_method_name = f"set_{field_name}" - - return retval - - def to_internal_value(self, data): - value = self.deserializer_field.to_internal_value(data) - method = getattr(self.parent, self.setter_method_name) - method(value) - return {self.method_name: value} - - class CustomTimeField(fields.TimeField): def to_representation(self, value): result = super().to_representation(value)