from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from jinja2 import TemplateSyntaxError from rest_framework import fields, serializers 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 apps.integrations.legacy_prefix import has_legacy_prefix, remove_legacy_prefix from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField from common.api_helpers.exceptions import BadRequest from common.api_helpers.mixins import PHONE_CALL, SLACK, SMS, TELEGRAM, WEB, EagerLoadingMixin from common.jinja_templater import jinja_template_env from common.utils import timed_lru_cache from .integtration_heartbeat import IntegrationHeartBeatSerializer from .maintenance import MaintainableObjectSerializerMixin from .routes import DefaultChannelFilterSerializer # Behaviour templates are named differently in public api PUBLIC_BEHAVIOUR_TEMPLATES_FIELDS = ["resolve_signal", "grouping_key", "acknowledge_signal", "source_link"] # TEMPLATE_PUBLIC_API_NAME_TO_DB_FIELD is map from template name in public api to its db field. # It's applied only for legacy messengers, which are not using messaging backend system TEMPLATE_PUBLIC_API_NAME_TO_DB_FIELD = { "grouping_key": "grouping_id_template", "resolve_signal": "resolve_condition_template", "acknowledge_signal": "acknowledge_condition_template", "source_link": "source_link_template", "slack": { "title": "slack_title_template", "message": "slack_message_template", "image_url": "slack_image_url_template", }, "web": { "title": "web_title_template", "message": "web_message_template", "image_url": "web_image_url_template", }, "sms": { "title": "sms_title_template", }, "phone_call": { "title": "phone_call_title_template", }, "telegram": { "title": "telegram_title_template", "message": "telegram_message_template", "image_url": "telegram_image_url_template", }, } TEMPLATES_WITH_SEPARATE_DB_FIELD = [SLACK, WEB, PHONE_CALL, SMS, TELEGRAM] + PUBLIC_BEHAVIOUR_TEMPLATES_FIELDS PUBLIC_API_CUSTOMIZABLE_NOTIFICATION_CHANNEL_TEMPLATES = [SLACK, WEB, PHONE_CALL, SMS, TELEGRAM] for _, backend in get_messaging_backends(): if backend.customizable_templates: PUBLIC_API_CUSTOMIZABLE_NOTIFICATION_CHANNEL_TEMPLATES.append(backend.slug) class IntegrationTypeField(fields.CharField): def to_representation(self, value): return remove_legacy_prefix(value) def to_internal_value(self, data): if data not in AlertReceiveChannel.INTEGRATION_TYPES: raise BadRequest(detail="Invalid integration type") if has_legacy_prefix(data): raise BadRequest("This integration type is deprecated") if data == AlertReceiveChannel.INTEGRATION_DIRECT_PAGING: raise BadRequest(detail="Direct paging integrations can't be created") return data class IntegrationSerializer(EagerLoadingMixin, serializers.ModelSerializer, MaintainableObjectSerializerMixin): id = serializers.CharField(read_only=True, source="public_primary_key") name = serializers.CharField(required=False, source="verbal_name") team_id = TeamPrimaryKeyRelatedField(required=False, allow_null=True, source="team") link = serializers.ReadOnlyField(source="integration_url") inbound_email = serializers.ReadOnlyField() type = IntegrationTypeField(source="integration") templates = serializers.DictField(required=False) default_route = serializers.DictField(required=False) heartbeat = serializers.SerializerMethodField() description_short = serializers.CharField(max_length=250, required=False, allow_null=True) PREFETCH_RELATED = ["channel_filters"] SELECT_RELATED = ["organization", "integration_heartbeat", "team"] class Meta: model = AlertReceiveChannel fields = MaintainableObjectSerializerMixin.Meta.fields + [ "id", "name", "description_short", "team_id", "link", "inbound_email", "type", "default_route", "templates", "heartbeat", ] def to_representation(self, instance): result = super().to_representation(instance) default_route = self._get_default_route_iterative(instance) serializer = DefaultChannelFilterSerializer(default_route, context=self.context) result["default_route"] = serializer.data # add additional templates for messaging backends result["templates"].update(self._get_messaging_backend_templates(instance)) return result def create(self, validated_data): validated_data = self._correct_validated_data(validated_data) default_route_data = validated_data.pop("default_route", None) organization = self.context["request"].auth.organization integration = validated_data.get("integration") if integration == AlertReceiveChannel.INTEGRATION_GRAFANA_ALERTING: # TODO: probably only needs to check if unified alerting is on connection_error = GrafanaAlertingSyncManager.check_for_connection_errors(organization) if connection_error: raise serializers.ValidationError(connection_error) validated_data = self._add_service_label_if_needed(organization, validated_data) user = self.context["request"].user with transaction.atomic(): try: instance = AlertReceiveChannel.create( **validated_data, author=user if not user.is_service_account else None, service_account=user.service_account if user.is_service_account else None, organization=organization, ) except AlertReceiveChannel.DuplicateDirectPagingError: raise BadRequest(detail=AlertReceiveChannel.DuplicateDirectPagingError.DETAIL) if default_route_data: serializer = DefaultChannelFilterSerializer( instance.default_channel_filter, default_route_data, context=self.context ) serializer.is_valid(raise_exception=True) serializer.save() # Create default service_name label instance.create_service_name_dynamic_label() return instance def update(self, *args, **kwargs): try: return super().update(*args, **kwargs) except AlertReceiveChannel.DuplicateDirectPagingError: raise BadRequest(detail=AlertReceiveChannel.DuplicateDirectPagingError.DETAIL) def validate(self, attrs): self.validate_name_uniqueness(attrs) return attrs def validate_name_uniqueness(self, attrs): organization = self.context["request"].auth.organization verbal_name = attrs.get("verbal_name", None) if verbal_name is None: return team = attrs.get("team", self.instance.team) if self.instance else attrs.get("team") try: obj = AlertReceiveChannel.objects.get( organization=organization, team=team, verbal_name=verbal_name, ) except AlertReceiveChannel.DoesNotExist: return except AlertReceiveChannel.MultipleObjectsReturned: raise BadRequest(detail="An integration with this name already exists for this team") if self.instance and obj.id == self.instance.id: return else: raise BadRequest(detail="An integration with this name already exists for this team") def validate_templates(self, templates): if not isinstance(templates, dict): raise BadRequest(detail="Invalid template data") for notification_channel in PUBLIC_API_CUSTOMIZABLE_NOTIFICATION_CHANNEL_TEMPLATES: template_data = templates.get(notification_channel, {}) if template_data is None: continue if not isinstance(template_data, dict): raise BadRequest(detail=f"Invalid {notification_channel} template data") for attr, attr_template in template_data.items(): if attr_template is None: continue try: jinja_template_env.from_string(attr_template) except TemplateSyntaxError: raise BadRequest(detail=f"invalid {notification_channel} {attr} template") for template_name in PUBLIC_BEHAVIOUR_TEMPLATES_FIELDS: template_data = templates.get(template_name, "") if template_data is None: continue if not isinstance(template_data, str): raise BadRequest(detail=f"Invalid {template_name} template data") try: jinja_template_env.from_string(template_data) except TemplateSyntaxError: raise BadRequest(detail=f"Invalid {template_name} template data") return templates def _correct_validated_data(self, validated_data): """ Process input templates data. 1. Reshapes it. 1.1 We are receiving templates in dict format { resolve_signal: "resolve me!" slack: { title: "title", message: "message", image_url: "image_url", }, ... } but store them in separate fields: slack_title_template, slack_message_template. See _correct_validated_data_for_legacy_template method 1.2 We are storing templates from messaging backends in separate messaging_backends_templates field. So, we need to shape input data related to messaging_backends_templates also. 2. Handle None templates. Public API set template to default value in two cases: (This behaviour is required by terraform plugin). 2.1 None for the whole template: { slack: None, ... } In that case all slack templates will be set to default. 2.2 One particular field is None: { slack: { title: "My custom title: message: None, }, ... } In that case slack message template will be set to default. TODO: System described above is too complicated, should be simplified. It can be simplified via unification all chatops integrations and messaging_backends and/or by introducing unified templates """ validated_data = self._correct_validated_data_for_messaging_backends_templates(validated_data) validated_data = self._correct_validated_data_for_legacy_templates(validated_data) validated_data.pop("templates", {}) return validated_data def _correct_validated_data_for_legacy_templates(self, validated_data): """ _correct_validated_data_for_legacy_template reshapes validated data to store them. It converts data from "templates" dict to db fields, which were used before messaging backends. Example: { "slack": { "title": Hello } } Will be converted to slack_title_template=Hello """ templates_data_from_request = validated_data.get("templates", {}) for template_backend_name, template_from_request in templates_data_from_request.items(): # correct_validated_data for templates with its own db fields. if template_backend_name in TEMPLATES_WITH_SEPARATE_DB_FIELD: if type(template_from_request) is str: # if it's plain template: {"resolve_signal": "resolve me"} try: validated_data[ TEMPLATE_PUBLIC_API_NAME_TO_DB_FIELD[template_backend_name] ] = template_from_request except KeyError: raise BadRequest(detail="Invalid template data") elif type(template_from_request) is dict: # if it's nested template: {slack: {"title": "some title"}} for attr, template in template_from_request.items(): try: validated_data[TEMPLATE_PUBLIC_API_NAME_TO_DB_FIELD[template_backend_name][attr]] = template except KeyError: raise BadRequest(detail="Invalid template data") elif template_from_request is None: # if it's we receive None, it's needed to set template to default value try: template_to_set_to_default = TEMPLATE_PUBLIC_API_NAME_TO_DB_FIELD[template_backend_name] if type(template_to_set_to_default) is str: # if we receive None for plain template just set it to None validated_data[TEMPLATE_PUBLIC_API_NAME_TO_DB_FIELD[template_backend_name]] = None elif type(template_to_set_to_default) is dict: # if we receive None for nested template set all it's fields to None for key in template_to_set_to_default.keys(): validated_data[TEMPLATE_PUBLIC_API_NAME_TO_DB_FIELD[template_backend_name][key]] = None except KeyError: raise BadRequest(detail="Invalid template data") return validated_data def _correct_validated_data_for_messaging_backends_templates(self, validated_data): """ _correct_validated_data_for_messaging_backends_templates reshapes validated data to store them. It converts data from "templates" dict to messaging_backends_templates field format. Example: { "msteams": { "title": Hello } } Will be converted to messaging_backends={"MSTEAMS": {"title": "Hello"}, """ templates_data_from_request = validated_data.get("templates", {}) messaging_backends_templates = self.instance.messaging_backends_templates if self.instance else {} if messaging_backends_templates is None: messaging_backends_templates = {} for backend_id, backend in get_messaging_backends(): if not backend.customizable_templates: continue backend_template = {} if backend.slug in templates_data_from_request: # check to modify only templates from request data template_from_request = templates_data_from_request[backend.slug] else: continue if template_from_request is None: # If we receive None backend template, like {"msteams": None }, set all template fields to none. for field in backend.template_fields: backend_template[field] = None elif type(template_from_request) is dict: # go through existing backend_template and update with values from request backend_template = messaging_backends_templates.get(backend_id, {}) for field in backend.template_fields: try: updated_field_template = template_from_request[field] except KeyError: continue backend_template[field] = updated_field_template # remove backend-specific template from payload templates_data_from_request.pop(backend.slug, None) if backend_template: messaging_backends_templates[backend_id] = backend_template validated_data["messaging_backends_templates"] = messaging_backends_templates return validated_data @staticmethod def _get_messaging_backend_templates(instance): result = {} messaging_backends_templates = instance.messaging_backends_templates or {} for backend_id, backend in get_messaging_backends(): if not backend.customizable_templates: continue if not backend.template_fields: continue result[backend.slug] = { field: messaging_backends_templates.get(backend_id, {}).get(field) for field in backend.template_fields } return result def get_heartbeat(self, obj): try: heartbeat = obj.integration_heartbeat except ObjectDoesNotExist: return None return IntegrationHeartBeatSerializer(heartbeat).data @timed_lru_cache(timeout=5) def _get_default_route_iterative(self, obj): """ Gets default route iterative to not hit db on each integration instance. """ for filter in obj.channel_filters.all(): if filter.is_default: return filter class IntegrationUpdateSerializer(IntegrationSerializer): type = IntegrationTypeField(source="integration", read_only=True) def update(self, instance, validated_data): validated_data = self._correct_validated_data(validated_data) default_route_data = validated_data.pop("default_route", None) default_route = instance.default_channel_filter if default_route_data: serializer = DefaultChannelFilterSerializer(default_route, default_route_data, context=self.context) serializer.is_valid(raise_exception=True) serializer.save() return super().update(instance, validated_data)