Webhooks Public API (#2790)

# What this PR does
- Add public API for Webhooks CRUD, and GET webhook responses
- Add insight resource logs for internal and public webhook API calls
- Change public actions API to wrap Webhooks to maintain compatibility
with existing callers
 

## Which issue(s) this PR fixes

#2792 
#2793 

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
This commit is contained in:
Michael Derynck 2023-08-22 14:05:52 -06:00 committed by GitHub
parent 0dfa882a06
commit 7440a836da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 986 additions and 198 deletions

View file

@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Public API for webhooks @mderynck ([#2790](https://github.com/grafana/oncall/pull/2790))
### Changed
- Public API for actions now wraps webhooks @mderynck ([#2790](https://github.com/grafana/oncall/pull/2790))
- Allow mobile app to access status endpoint @mderynck ([#2791](https://github.com/grafana/oncall/pull/2791))
## v1.3.26 (2023-08-22)

View file

@ -1,17 +1,21 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/outgoing_webhooks/
title: Outgoing webhooks HTTP API
title: Outgoing Webhooks HTTP API
weight: 700
---
# Outgoing webhooks (actions)
# Outgoing Webhooks
Used in escalation policies with type `trigger_action`.
> ⚠️ A note about actions: Before version **v1.3.11** webhooks existed as actions within the API, the /actions
> endpoint remains available and is compatible with previous callers but under the hood it will interact with the
> new webhooks objects. It is recommended to use the /webhooks endpoint going forward which has more features.
## List actions
For more details about specific fields of a webhook see [outgoing webhooks][outgoing-webhooks] documentation.
## List webhooks
```shell
curl "{{API_URL}}/api/v1/actions/" \
curl "{{API_URL}}/api/v1/webhooks/" \
--request GET \
--header "Authorization: meowmeowmeow" \
--header "Content-Type: application/json"
@ -21,21 +25,210 @@ The above command returns JSON structured in the following way:
```json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": "KGEFG74LU1D8L",
"name": "Publish alert group notification to JIRA"
"id": "{{WEBHOOK_UID}}",
"name": "Demo Webhook",
"is_webhook_enabled": true,
"team": null,
"data": "{\"labels\" : {{ alert_payload.commonLabels | tojson()}}}",
"username": null,
"password": null,
"authorization_header": "****************",
"trigger_template": null,
"headers": null,
"url": "https://example.com",
"forward_all": false,
"http_method": "POST",
"trigger_type": "acknowledge",
"integration_filter": [
"CRV8A5MXC751A"
]
}
],
"current_page_number": 1,
"page_size": 50,
"count": 1,
"current_page_number": 1,
"total_pages": 1
}
```
**HTTP request**
## Get webhook
`GET {{API_URL}}/api/v1/actions/`
```shell
curl "{{API_URL}}/api/v1/webhooks/{{WEBHOOK_UID}}/" \
--request GET \
--header "Authorization: meowmeowmeow" \
--header "Content-Type: application/json"
```
The above command returns JSON structured in the following way:
```json
{
"id": "{{WEBHOOK_UID}}",
"name": "Demo Webhook",
"is_webhook_enabled": true,
"team": null,
"data": "{\"labels\" : {{ alert_payload.commonLabels | tojson()}}}",
"username": null,
"password": null,
"authorization_header": "****************",
"trigger_template": null,
"headers": null,
"url": "https://example.com",
"forward_all": false,
"http_method": "POST",
"trigger_type": "acknowledge",
"integration_filter": [
"CRV8A5MXC751A"
]
}
```
## Create webhook
```shell
curl "{{API_URL}}/api/v1/webhooks/" \
--request POST \
--header "Authorization: meowmeowmeow" \
--header "Content-Type: application/json" \
--data '{
"name": "New Webhook",
"url": "https://example.com",
"http_method": "POST",
"trigger_type" : "resolve"
}'
```
### Trigger Types
See [here](outgoing-webhooks#event-types) for details
- `escalation`
- `alert group created`
- `acknowledge`
- `resolve`
- `silence`
- `unsilence`
- `unresolve`
- `unacknowledge`
### HTTP Methods
- `POST`
- `GET`
- `PUT`
- `DELETE`
- `OPTIONS`
The above command returns JSON structured in the following way:
```json
{
"id": "{{WEBHOOK_UID}}",
"name": "New Webhook",
"is_webhook_enabled": true,
"team": null,
"data": null,
"username": null,
"password": null,
"authorization_header": null,
"trigger_template": null,
"headers": null,
"url": "https://example.com",
"forward_all": true,
"http_method": "POST",
"trigger_type": "resolve",
"integration_filter": null
}
```
## Update webhook
```shell
curl "{{API_URL}}/api/v1/webhooks/{{WEBHOOK_UID}}/" \
--request PUT \
--header "Authorization: meowmeowmeow" \
--header "Content-Type: application/json" \
--data '{
"is_webhook_enabled": false
}'
```
The above command returns JSON structured in the following way:
```json
{
"id": "{{WEBHOOK_UID}}",
"name": "New Webhook",
"is_webhook_enabled": false,
"team": null,
"data": null,
"username": null,
"password": null,
"authorization_header": null,
"trigger_template": null,
"headers": null,
"url": "https://example.com",
"forward_all": true,
"http_method": "POST",
"trigger_type": "resolve",
"integration_filter": null
}
```
## Delete webhook
```shell
curl "{{API_URL}}/api/v1/webhooks/{{WEBHOOK_UID}}/" \
--request DELETE \
--header "Authorization: meowmeowmeow" \
--header "Content-Type: application/json"
```
## Get webhook responses
```shell
curl "{{API_URL}}/api/v1/webhooks/{{WEBHOOK_UID}}/responses" \
--request GET \
--header "Authorization: meowmeowmeow" \
--header "Content-Type: application/json"
```
The above command returns JSON structured in the following way:
```json
{
"next": null,
"previous": null,
"results": [
{
"timestamp": "2023-08-18T16:38:23.106015Z",
"url": "https://example.com",
"request_trigger": "",
"request_headers": "{\"Authorization\": \"****************\"}",
"request_data": "{\"labels\": {\"alertname\": \"InstanceDown\", \"job\": \"node\", \"severity\": \"critical\"}}",
"status_code": 200,
"content": "",
"event_data": "{\"event\": {\"type\": \"acknowledge\", \"time\": \"2023-08-18T16:38:21.442981+00:00\"}, \"user\": {\"id\": \"UK49JJNPZMFLJ\", \"username\": \"oncall\", \"email\": \"admin@localhost\"}, \"alert_group\": {\"id\": \"IZQERPWKWCGH1\", \"integration_id\": \"CRV8A5MXC751A\", \"route_id\": \"RWNCT6C77M3WM\", \"alerts_count\": 1, \"state\": \"acknowledged\", \"created_at\": \"2023-08-18T16:34:27.678406Z\", \"resolved_at\": null, \"acknowledged_at\": \"2023-08-18T16:38:21.442981Z\", \"title\": \"[firing:2] InstanceDown \", \"permalinks\": {\"slack\": null, \"telegram\": null, \"web\": \"http://localhost:3000/a/grafana-oncall-app/alert-groups/IZQERPWKWCGH1\"}}, \"alert_group_id\": \"IZQERPWKWCGH1\", \"alert_payload\": {\"alerts\": [{\"endsAt\": \"0001-01-01T00:00:00Z\", \"labels\": {\"job\": \"node\", \"group\": \"production\", \"instance\": \"localhost:8081\", \"severity\": \"critical\", \"alertname\": \"InstanceDown\"}, \"status\": \"firing\", \"startsAt\": \"2023-06-12T08:24:38.326Z\", \"annotations\": {\"title\": \"Instance localhost:8081 down\", \"description\": \"localhost:8081 of job node has been down for more than 1 minute.\"}, \"fingerprint\": \"f404ecabc8dd5cd7\", \"generatorURL\": \"\"}, {\"endsAt\": \"0001-01-01T00:00:00Z\", \"labels\": {\"job\": \"node\", \"group\": \"canary\", \"instance\": \"localhost:8082\", \"severity\": \"critical\", \"alertname\": \"InstanceDown\"}, \"status\": \"firing\", \"startsAt\": \"2023-06-12T08:24:38.326Z\", \"annotations\": {\"title\": \"Instance localhost:8082 down\", \"description\": \"localhost:8082 of job node has been down for more than 1 minute.\"}, \"fingerprint\": \"f8f08d4e32c61a9d\", \"generatorURL\": \"\"}], \"status\": \"firing\", \"version\": \"4\", \"groupKey\": \"{}:{alertname=\\\"InstanceDown\\\"}\", \"receiver\": \"combo\", \"numFiring\": 2, \"externalURL\": \"\", \"groupLabels\": {\"alertname\": \"InstanceDown\"}, \"numResolved\": 0, \"commonLabels\": {\"job\": \"node\", \"severity\": \"critical\", \"alertname\": \"InstanceDown\"}, \"truncatedAlerts\": 0, \"commonAnnotations\": {}}, \"integration\": {\"id\": \"CRV8A5MXC751A\", \"type\": \"alertmanager\", \"name\": \"One - Alertmanager\", \"team\": null}, \"notified_users\": [], \"users_to_be_notified\": []}"
},
{
"timestamp": "2023-08-18T16:34:38.580574Z",
"url": "https://example.com",
"request_trigger": "",
"request_headers": null,
"request_data": "Data - Template Warning: Object of type Undefined is not JSON serializable",
"status_code": null,
"content": null,
"event_data": "{\"event\": {\"type\": \"acknowledge\", \"time\": \"2023-08-18T16:34:37.940655+00:00\"}, \"user\": {\"id\": \"UK49JJNPZMFLJ\", \"username\": \"oncall\", \"email\": \"admin@localhost\"}, \"alert_group\": {\"id\": \"IZQERPWKWCGH1\", \"integration_id\": \"CRV8A5MXC751A\", \"route_id\": \"RWNCT6C77M3WM\", \"alerts_count\": 1, \"state\": \"acknowledged\", \"created_at\": \"2023-08-18T16:34:27.678406Z\", \"resolved_at\": null, \"acknowledged_at\": \"2023-08-18T16:34:37.940655Z\", \"title\": \"[firing:2] InstanceDown \", \"permalinks\": {\"slack\": null, \"telegram\": null, \"web\": \"http://localhost:3000/a/grafana-oncall-app/alert-groups/IZQERPWKWCGH1\"}}, \"alert_group_id\": \"IZQERPWKWCGH1\", \"alert_payload\": {\"alerts\": [{\"endsAt\": \"0001-01-01T00:00:00Z\", \"labels\": {\"job\": \"node\", \"group\": \"production\", \"instance\": \"localhost:8081\", \"severity\": \"critical\", \"alertname\": \"InstanceDown\"}, \"status\": \"firing\", \"startsAt\": \"2023-06-12T08:24:38.326Z\", \"annotations\": {\"title\": \"Instance localhost:8081 down\", \"description\": \"localhost:8081 of job node has been down for more than 1 minute.\"}, \"fingerprint\": \"f404ecabc8dd5cd7\", \"generatorURL\": \"\"}, {\"endsAt\": \"0001-01-01T00:00:00Z\", \"labels\": {\"job\": \"node\", \"group\": \"canary\", \"instance\": \"localhost:8082\", \"severity\": \"critical\", \"alertname\": \"InstanceDown\"}, \"status\": \"firing\", \"startsAt\": \"2023-06-12T08:24:38.326Z\", \"annotations\": {\"title\": \"Instance localhost:8082 down\", \"description\": \"localhost:8082 of job node has been down for more than 1 minute.\"}, \"fingerprint\": \"f8f08d4e32c61a9d\", \"generatorURL\": \"\"}], \"status\": \"firing\", \"version\": \"4\", \"groupKey\": \"{}:{alertname=\\\"InstanceDown\\\"}\", \"receiver\": \"combo\", \"numFiring\": 2, \"externalURL\": \"\", \"groupLabels\": {\"alertname\": \"InstanceDown\"}, \"numResolved\": 0, \"commonLabels\": {\"job\": \"node\", \"severity\": \"critical\", \"alertname\": \"InstanceDown\"}, \"truncatedAlerts\": 0, \"commonAnnotations\": {}}, \"integration\": {\"id\": \"CRV8A5MXC751A\", \"type\": \"alertmanager\", \"name\": \"One - Alertmanager\", \"team\": null}, \"notified_users\": [], \"users_to_be_notified\": []}"
}
],
"page_size": 50,
"count": 2,
"current_page_number": 1,
"total_pages": 1
}
```

View file

@ -371,7 +371,7 @@ To fix change the template to:
```json
{
"labels": "{{ alert_payload.labels | tojson()}}"
"labels": {{ alert_payload.labels | tojson()}}
}
```

View file

@ -44,7 +44,6 @@ class WebhookSerializer(serializers.ModelSerializer):
"is_webhook_enabled",
"is_legacy",
"team",
"data",
"user",
"username",
"password",

View file

@ -64,8 +64,8 @@ def test_get_list_webhooks(webhook_internal_api_setup, make_user_auth_headers):
"event_data": "",
},
"trigger_template": None,
"trigger_type": None,
"trigger_type_name": "",
"trigger_type": "0",
"trigger_type_name": "Escalation step",
}
]
@ -106,8 +106,8 @@ def test_get_detail_webhook(webhook_internal_api_setup, make_user_auth_headers):
"event_data": "",
},
"trigger_template": None,
"trigger_type": None,
"trigger_type_name": "",
"trigger_type": "0",
"trigger_type_name": "Escalation step",
}
response = client.get(url, format="json", **make_user_auth_headers(user, token))

View file

@ -18,6 +18,7 @@ from apps.webhooks.utils import apply_jinja_template_for_json
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, TeamModelMultipleChoiceFilter
from common.api_helpers.mixins import PublicPrimaryKeyMixin, TeamFilteringMixin
from common.insight_log import EntityEvent, write_resource_insight_log
from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning
NEW_WEBHOOK_PK = "new"
@ -60,6 +61,30 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
search_fields = ["public_primary_key", "name"]
filterset_class = WebhooksFilter
def perform_create(self, serializer):
serializer.save()
write_resource_insight_log(instance=serializer.instance, author=self.request.user, event=EntityEvent.CREATED)
def perform_update(self, serializer):
prev_state = serializer.instance.insight_logs_serialized
serializer.save()
new_state = serializer.instance.insight_logs_serialized
write_resource_insight_log(
instance=serializer.instance,
author=self.request.user,
event=EntityEvent.UPDATED,
prev_state=prev_state,
new_state=new_state,
)
def perform_destroy(self, instance):
write_resource_insight_log(
instance=instance,
author=self.request.user,
event=EntityEvent.DELETED,
)
instance.delete()
def get_queryset(self, ignore_filtering_by_available_teams=False):
queryset = Webhook.objects.filter(
organization=self.request.auth.organization,

View file

@ -1,106 +1,64 @@
import json
from collections import defaultdict
from django.core.validators import URLValidator, ValidationError
from jinja2 import TemplateError
from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from apps.alerts.models import CustomButton
from apps.public_api.serializers.webhooks import WebhookCreateSerializer, WebhookTriggerTypeField
from apps.webhooks.models import Webhook
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
from common.api_helpers.utils import CurrentOrganizationDefault
from common.jinja_templater import jinja_template_env
from common.api_helpers.utils import CurrentTeamDefault
class ActionCreateSerializer(serializers.ModelSerializer):
id = serializers.CharField(read_only=True, source="public_primary_key")
organization = serializers.HiddenField(default=CurrentOrganizationDefault())
team_id = TeamPrimaryKeyRelatedField(required=False, allow_null=True, source="team")
url = serializers.CharField(required=True, allow_null=False, allow_blank=False, source="webhook")
class ActionCreateSerializer(WebhookCreateSerializer):
team_id = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault(), source="team")
user = serializers.CharField(required=False, source="username")
trigger_type = WebhookTriggerTypeField(required=False)
forward_whole_payload = serializers.BooleanField(required=False, source="forward_all")
class Meta:
model = CustomButton
model = Webhook
fields = [
"id",
"name",
"is_webhook_enabled",
"organization",
"team_id",
"url",
"user",
"data",
"user",
"password",
"authorization_header",
"trigger_template",
"headers",
"url",
"forward_whole_payload",
"http_method",
"trigger_type",
"integration_filter",
]
extra_kwargs = {
"name": {"required": True, "allow_null": False, "allow_blank": False},
"data": {"required": False, "allow_null": True, "allow_blank": False},
"user": {"required": False, "allow_null": True, "allow_blank": False},
"password": {"required": False, "allow_null": True, "allow_blank": False},
"authorization_header": {"required": False, "allow_null": True, "allow_blank": False},
"forward_whole_payload": {"required": False, "allow_null": True},
"url": {"required": True, "allow_null": False, "allow_blank": False},
}
validators = [UniqueTogetherValidator(queryset=CustomButton.objects.all(), fields=["name", "organization"])]
def validate_url(self, url):
if url:
try:
URLValidator()(url)
except ValidationError:
raise serializers.ValidationError("URL is incorrect")
return url
return None
def validate_data(self, data):
if not data:
return None
try:
template = jinja_template_env.from_string(data)
except TemplateError:
raise serializers.ValidationError("Data has incorrect template")
try:
rendered = template.render(
{
# Validate that the template can be rendered with a JSON-ish alert payload.
# We don't know what the actual payload will be, so we use a defaultdict
# so that attribute access within a template will never fail
# (provided it's only one level deep - we won't accept templates that attempt
# to do nested attribute access).
# Every attribute access should return a string to ensure that users are
# correctly using `tojson` or wrapping fields in strings.
# If we instead used a `defaultdict(dict)` or `defaultdict(lambda: 1)` we
# would accidentally accept templates such as `{"name": {{ alert_payload.name }}}`
# which would then fail at the true render time due to the
# lack of explicit quotes around the template variable; this would render
# as `{"name": some_alert_name}` which is not valid JSON.
"alert_payload": defaultdict(str),
"alert_group_id": "abcd",
}
)
json.loads(rendered)
except ValueError:
raise serializers.ValidationError("Data has incorrect format")
return data
def validate_forward_whole_payload(self, data):
if data is None:
return False
return data
validators = [UniqueTogetherValidator(queryset=Webhook.objects.all(), fields=["name", "organization"])]
class ActionUpdateSerializer(ActionCreateSerializer):
url = serializers.CharField(required=False, allow_null=False, allow_blank=False, source="webhook")
user = serializers.CharField(required=False, source="username")
trigger_type = WebhookTriggerTypeField(required=False)
forward_whole_payload = serializers.BooleanField(required=False, source="forward_all")
class Meta(ActionCreateSerializer.Meta):
extra_kwargs = {
"name": {"required": False, "allow_null": False, "allow_blank": False},
"data": {"required": False, "allow_null": True, "allow_blank": False},
"is_webhook_enabled": {"required": False, "allow_null": False},
"user": {"required": False, "allow_null": True, "allow_blank": False},
"password": {"required": False, "allow_null": True, "allow_blank": False},
"authorization_header": {"required": False, "allow_null": True, "allow_blank": False},
"forward_whole_payload": {"required": False, "allow_null": True},
"trigger_template": {"required": False, "allow_null": True, "allow_blank": False},
"headers": {"required": False, "allow_null": True, "allow_blank": False},
"url": {"required": False, "allow_null": False, "allow_blank": False},
"data": {"required": False, "allow_null": True, "allow_blank": False},
"forward_whole_payload": {"required": False, "allow_null": False},
"http_method": {"required": False, "allow_null": False, "allow_blank": False},
"integration_filter": {"required": False, "allow_null": True},
}

View file

@ -0,0 +1,170 @@
from collections import defaultdict
from rest_framework import fields, serializers
from rest_framework.validators import UniqueTogetherValidator
from apps.alerts.models import AlertReceiveChannel
from apps.webhooks.models import Webhook, WebhookResponse
from apps.webhooks.models.webhook import PUBLIC_WEBHOOK_HTTP_METHODS, WEBHOOK_FIELD_PLACEHOLDER
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.utils import CurrentOrganizationDefault, CurrentTeamDefault, CurrentUserDefault
from common.jinja_templater import apply_jinja_template
from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning
INTEGRATION_FILTER_MESSAGE = "integration_filter must be a list of valid integration ids"
class WebhookTriggerTypeField(fields.CharField):
def to_representation(self, value):
return Webhook.PUBLIC_TRIGGER_TYPES_MAP[value]
def to_internal_value(self, data):
try:
trigger_type = [
key
for key, value in Webhook.PUBLIC_TRIGGER_TYPES_MAP.items()
if value == data and key in Webhook.PUBLIC_TRIGGER_TYPES_MAP
][0]
except IndexError:
raise BadRequest(detail=f"trigger_type must one of {Webhook.PUBLIC_ALL_TRIGGER_TYPES}")
return trigger_type
class WebhookResponseSerializer(serializers.ModelSerializer):
class Meta:
model = WebhookResponse
fields = [
"timestamp",
"url",
"request_trigger",
"request_headers",
"request_data",
"status_code",
"content",
"event_data",
]
class WebhookCreateSerializer(serializers.ModelSerializer):
id = serializers.CharField(read_only=True, source="public_primary_key")
organization = serializers.HiddenField(default=CurrentOrganizationDefault())
team = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault())
user = serializers.HiddenField(default=CurrentUserDefault())
trigger_type = WebhookTriggerTypeField()
class Meta:
model = Webhook
fields = [
"id",
"name",
"is_webhook_enabled",
"organization",
"team",
"user",
"data",
"username",
"password",
"authorization_header",
"trigger_template",
"headers",
"url",
"forward_all",
"http_method",
"trigger_type",
"integration_filter",
]
extra_kwargs = {
"name": {"required": True, "allow_null": False, "allow_blank": False},
"url": {"required": True, "allow_null": False, "allow_blank": False},
"http_method": {"required": True, "allow_null": False, "allow_blank": False},
}
validators = [UniqueTogetherValidator(queryset=Webhook.objects.all(), fields=["name", "organization"])]
def to_representation(self, instance):
result = super().to_representation(instance)
if instance.password:
result["password"] = WEBHOOK_FIELD_PLACEHOLDER
if instance.authorization_header:
result["authorization_header"] = WEBHOOK_FIELD_PLACEHOLDER
return result
def to_internal_value(self, data):
webhook = self.instance
if data.get("password") == WEBHOOK_FIELD_PLACEHOLDER:
data["password"] = webhook.password
if data.get("authorization_header") == WEBHOOK_FIELD_PLACEHOLDER:
data["authorization_header"] = webhook.authorization_header
return super().to_internal_value(data)
def _validate_template_field(self, template):
try:
apply_jinja_template(template, alert_payload=defaultdict(str), alert_group_id="alert_group_1")
except JinjaTemplateError as e:
raise serializers.ValidationError(e.fallback_message)
except JinjaTemplateWarning:
# Suppress render exceptions since we do not have a representative payload to test with
pass
return template
def validate_trigger_template(self, trigger_template):
if not trigger_template:
return None
return self._validate_template_field(trigger_template)
def validate_headers(self, headers):
if not headers:
return None
return self._validate_template_field(headers)
def validate_url(self, url):
if not url:
return None
return self._validate_template_field(url)
def validate_data(self, data):
if not data:
return None
return self._validate_template_field(data)
def validate_forward_all(self, data):
if data is None:
return False
return data
def validate_http_method(self, http_method):
if http_method not in PUBLIC_WEBHOOK_HTTP_METHODS:
raise serializers.ValidationError(f"Must be one of {PUBLIC_WEBHOOK_HTTP_METHODS}")
return http_method
def validate_integration_filter(self, integration_filter):
if integration_filter:
if type(integration_filter) is not list:
raise serializers.ValidationError(INTEGRATION_FILTER_MESSAGE)
integrations = AlertReceiveChannel.objects.filter(
organization=self.context["request"].auth.organization, public_primary_key__in=integration_filter
)
if len(integrations) != len(integration_filter):
raise serializers.ValidationError(INTEGRATION_FILTER_MESSAGE)
return integration_filter
class WebhookUpdateSerializer(WebhookCreateSerializer):
trigger_type = WebhookTriggerTypeField(required=False)
class Meta(WebhookCreateSerializer.Meta):
extra_kwargs = {
"name": {"required": False, "allow_null": False, "allow_blank": False},
"is_webhook_enabled": {"required": False, "allow_null": False},
"username": {"required": False, "allow_null": True, "allow_blank": False},
"password": {"required": False, "allow_null": True, "allow_blank": False},
"authorization_header": {"required": False, "allow_null": True, "allow_blank": False},
"trigger_template": {"required": False, "allow_null": True, "allow_blank": False},
"headers": {"required": False, "allow_null": True, "allow_blank": False},
"url": {"required": False, "allow_null": False, "allow_blank": False},
"data": {"required": False, "allow_null": True, "allow_blank": False},
"forward_all": {"required": False, "allow_null": False},
"http_method": {"required": False, "allow_null": False, "allow_blank": False},
"integration_filter": {"required": False, "allow_null": True},
}

View file

@ -3,18 +3,15 @@ from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from apps.alerts.models import CustomButton
from apps.webhooks.models import Webhook
@pytest.mark.django_db
def test_get_custom_actions(
make_organization_and_user_with_token,
make_custom_action,
):
def test_get_custom_actions(make_organization_and_user_with_token, make_custom_webhook):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
custom_action = make_custom_action(organization=organization)
custom_action = make_custom_webhook(organization=organization)
url = reverse("api-public:actions-list")
@ -29,12 +26,18 @@ def test_get_custom_actions(
"id": custom_action.public_primary_key,
"name": custom_action.name,
"team_id": None,
"url": custom_action.webhook,
"url": custom_action.url,
"data": custom_action.data,
"user": custom_action.user,
"password": custom_action.password,
"authorization_header": custom_action.authorization_header,
"forward_whole_payload": custom_action.forward_whole_payload,
"forward_whole_payload": custom_action.forward_all,
"is_webhook_enabled": custom_action.is_webhook_enabled,
"trigger_template": custom_action.trigger_template,
"headers": custom_action.headers,
"http_method": custom_action.http_method,
"trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type],
"integration_filter": custom_action.integration_filter,
}
],
"current_page_number": 1,
@ -49,13 +52,13 @@ def test_get_custom_actions(
@pytest.mark.django_db
def test_get_custom_actions_filter_by_name(
make_organization_and_user_with_token,
make_custom_action,
make_custom_webhook,
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
custom_action = make_custom_action(organization=organization)
make_custom_action(organization=organization)
custom_action = make_custom_webhook(organization=organization)
make_custom_webhook(organization=organization)
url = reverse("api-public:actions-list")
response = client.get(f"{url}?name={custom_action.name}", format="json", HTTP_AUTHORIZATION=f"{token}")
@ -69,12 +72,18 @@ def test_get_custom_actions_filter_by_name(
"id": custom_action.public_primary_key,
"name": custom_action.name,
"team_id": None,
"url": custom_action.webhook,
"url": custom_action.url,
"data": custom_action.data,
"user": custom_action.user,
"user": custom_action.username,
"password": custom_action.password,
"authorization_header": custom_action.authorization_header,
"forward_whole_payload": custom_action.forward_whole_payload,
"forward_whole_payload": custom_action.forward_all,
"is_webhook_enabled": custom_action.is_webhook_enabled,
"trigger_template": custom_action.trigger_template,
"headers": custom_action.headers,
"http_method": custom_action.http_method,
"trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type],
"integration_filter": custom_action.integration_filter,
}
],
"current_page_number": 1,
@ -89,12 +98,12 @@ def test_get_custom_actions_filter_by_name(
@pytest.mark.django_db
def test_get_custom_actions_filter_by_name_empty_result(
make_organization_and_user_with_token,
make_custom_action,
make_custom_webhook,
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
make_custom_action(organization=organization)
make_custom_webhook(organization=organization)
url = reverse("api-public:actions-list")
@ -117,12 +126,12 @@ def test_get_custom_actions_filter_by_name_empty_result(
@pytest.mark.django_db
def test_get_custom_action(
make_organization_and_user_with_token,
make_custom_action,
make_custom_webhook,
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
custom_action = make_custom_action(organization=organization)
custom_action = make_custom_webhook(organization=organization)
url = reverse("api-public:actions-detail", kwargs={"pk": custom_action.public_primary_key})
@ -132,12 +141,18 @@ def test_get_custom_action(
"id": custom_action.public_primary_key,
"name": custom_action.name,
"team_id": None,
"url": custom_action.webhook,
"url": custom_action.url,
"data": custom_action.data,
"user": custom_action.user,
"user": custom_action.username,
"password": custom_action.password,
"authorization_header": custom_action.authorization_header,
"forward_whole_payload": custom_action.forward_whole_payload,
"forward_whole_payload": custom_action.forward_all,
"is_webhook_enabled": custom_action.is_webhook_enabled,
"trigger_template": custom_action.trigger_template,
"headers": custom_action.headers,
"http_method": custom_action.http_method,
"trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type],
"integration_filter": custom_action.integration_filter,
}
assert response.status_code == status.HTTP_200_OK
@ -158,18 +173,24 @@ def test_create_custom_action(make_organization_and_user_with_token):
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
custom_action = CustomButton.objects.get(public_primary_key=response.data["id"])
custom_action = Webhook.objects.get(public_primary_key=response.data["id"])
expected_result = {
"id": custom_action.public_primary_key,
"name": custom_action.name,
"team_id": None,
"url": custom_action.webhook,
"url": custom_action.url,
"data": custom_action.data,
"user": custom_action.user,
"user": custom_action.username,
"password": custom_action.password,
"authorization_header": custom_action.authorization_header,
"forward_whole_payload": custom_action.forward_whole_payload,
"forward_whole_payload": custom_action.forward_all,
"is_webhook_enabled": custom_action.is_webhook_enabled,
"trigger_template": custom_action.trigger_template,
"headers": custom_action.headers,
"http_method": custom_action.http_method,
"trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type],
"integration_filter": custom_action.integration_filter,
}
assert response.status_code == status.HTTP_201_CREATED
@ -195,18 +216,24 @@ def test_create_custom_action_nested_data(make_organization_and_user_with_token)
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
custom_action = CustomButton.objects.get(public_primary_key=response.data["id"])
custom_action = Webhook.objects.get(public_primary_key=response.data["id"])
expected_result = {
"id": custom_action.public_primary_key,
"name": custom_action.name,
"team_id": None,
"url": custom_action.webhook,
"url": custom_action.url,
"data": custom_action.data,
"user": custom_action.user,
"user": custom_action.username,
"password": custom_action.password,
"authorization_header": custom_action.authorization_header,
"forward_whole_payload": custom_action.forward_whole_payload,
"forward_whole_payload": custom_action.forward_all,
"is_webhook_enabled": custom_action.is_webhook_enabled,
"trigger_template": custom_action.trigger_template,
"headers": custom_action.headers,
"http_method": custom_action.http_method,
"trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type],
"integration_filter": custom_action.integration_filter,
}
assert response.status_code == status.HTTP_201_CREATED
@ -232,18 +259,24 @@ def test_create_custom_action_valid_after_render(make_organization_and_user_with
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
custom_action = CustomButton.objects.get(public_primary_key=response.data["id"])
custom_action = Webhook.objects.get(public_primary_key=response.data["id"])
expected_result = {
"id": custom_action.public_primary_key,
"name": custom_action.name,
"team_id": None,
"url": custom_action.webhook,
"url": custom_action.url,
"data": custom_action.data,
"user": custom_action.user,
"user": custom_action.username,
"password": custom_action.password,
"authorization_header": custom_action.authorization_header,
"forward_whole_payload": custom_action.forward_whole_payload,
"forward_whole_payload": custom_action.forward_all,
"is_webhook_enabled": custom_action.is_webhook_enabled,
"trigger_template": custom_action.trigger_template,
"headers": custom_action.headers,
"http_method": custom_action.http_method,
"trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type],
"integration_filter": custom_action.integration_filter,
}
assert response.status_code == status.HTTP_201_CREATED
@ -269,94 +302,39 @@ def test_create_custom_action_valid_after_render_use_all_data(make_organization_
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
custom_action = CustomButton.objects.get(public_primary_key=response.data["id"])
custom_action = Webhook.objects.get(public_primary_key=response.data["id"])
expected_result = {
"id": custom_action.public_primary_key,
"name": custom_action.name,
"team_id": None,
"url": custom_action.webhook,
"url": custom_action.url,
"data": custom_action.data,
"user": custom_action.user,
"user": custom_action.username,
"password": custom_action.password,
"authorization_header": custom_action.authorization_header,
"forward_whole_payload": custom_action.forward_whole_payload,
"forward_whole_payload": custom_action.forward_all,
"is_webhook_enabled": custom_action.is_webhook_enabled,
"trigger_template": custom_action.trigger_template,
"headers": custom_action.headers,
"http_method": custom_action.http_method,
"trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type],
"integration_filter": custom_action.integration_filter,
}
assert response.status_code == status.HTTP_201_CREATED
assert response.json() == expected_result
@pytest.mark.django_db
def test_create_custom_action_invalid_data(
make_organization_and_user_with_token,
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
url = reverse("api-public:actions-list")
data = {
"name": "Test outgoing webhook",
"url": "invalid_url",
}
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data["url"][0] == "URL is incorrect"
data = {
"name": "Test outgoing webhook",
}
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data["url"][0] == "This field is required."
data = {
"url": "https://example.com",
}
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data["name"][0] == "This field is required."
data = {
"name": "Test outgoing webhook",
"url": "https://example.com",
"data": "invalid_json",
}
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data["data"][0] == "Data has incorrect format"
data = {
"name": "Test outgoing webhook",
"url": "https://example.com",
# This would need a `| tojson` or some double quotes around it to pass validation.
"data": "{{ alert_payload.name }}",
}
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data["data"][0] == "Data has incorrect format"
@pytest.mark.django_db
def test_update_custom_action(
make_organization_and_user_with_token,
make_custom_action,
make_custom_webhook,
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
custom_action = make_custom_action(organization=organization)
custom_action = make_custom_webhook(organization=organization)
url = reverse("api-public:actions-detail", kwargs={"pk": custom_action.public_primary_key})
@ -372,12 +350,18 @@ def test_update_custom_action(
"id": custom_action.public_primary_key,
"name": data["name"],
"team_id": None,
"url": custom_action.webhook,
"url": custom_action.url,
"data": custom_action.data,
"user": custom_action.user,
"user": custom_action.username,
"password": custom_action.password,
"authorization_header": custom_action.authorization_header,
"forward_whole_payload": custom_action.forward_whole_payload,
"forward_whole_payload": custom_action.forward_all,
"is_webhook_enabled": custom_action.is_webhook_enabled,
"trigger_template": custom_action.trigger_template,
"headers": custom_action.headers,
"http_method": custom_action.http_method,
"trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type],
"integration_filter": custom_action.integration_filter,
}
assert response.status_code == status.HTTP_200_OK
@ -389,12 +373,12 @@ def test_update_custom_action(
@pytest.mark.django_db
def test_delete_custom_action(
make_organization_and_user_with_token,
make_custom_action,
make_custom_webhook,
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
custom_action = make_custom_action(organization=organization)
custom_action = make_custom_webhook(organization=organization)
url = reverse("api-public:actions-detail", kwargs={"pk": custom_action.public_primary_key})
assert custom_action.deleted_at is None

View file

@ -0,0 +1,320 @@
import json
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from apps.webhooks.models import Webhook
def _get_expected_result(webhook):
return {
"id": webhook.public_primary_key,
"name": webhook.name,
"team": webhook.team,
"url": webhook.url,
"data": webhook.data,
"username": webhook.username,
"password": webhook.password,
"authorization_header": webhook.authorization_header,
"forward_all": webhook.forward_all,
"is_webhook_enabled": webhook.is_webhook_enabled,
"trigger_template": webhook.trigger_template,
"headers": webhook.headers,
"http_method": webhook.http_method,
"trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[webhook.trigger_type],
"integration_filter": webhook.integration_filter,
}
@pytest.mark.django_db
def test_get_webhooks(make_organization_and_user_with_token, make_custom_webhook):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
webhook = make_custom_webhook(organization=organization)
url = reverse("api-public:webhooks-list")
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
expected_payload = {
"count": 1,
"next": None,
"previous": None,
"results": [_get_expected_result(webhook)],
"current_page_number": 1,
"page_size": 50,
"total_pages": 1,
}
assert response.status_code == status.HTTP_200_OK
assert response.data == expected_payload
@pytest.mark.django_db
def test_get_webhooks_filter_by_name(
make_organization_and_user_with_token,
make_custom_webhook,
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
webhook = make_custom_webhook(organization=organization)
make_custom_webhook(organization=organization)
url = reverse("api-public:webhooks-list")
response = client.get(f"{url}?name={webhook.name}", format="json", HTTP_AUTHORIZATION=f"{token}")
expected_payload = {
"count": 1,
"next": None,
"previous": None,
"results": [_get_expected_result(webhook)],
"current_page_number": 1,
"page_size": 50,
"total_pages": 1,
}
assert response.status_code == status.HTTP_200_OK
assert response.data == expected_payload
@pytest.mark.django_db
def test_get_webhooks_filter_by_name_empty_result(
make_organization_and_user_with_token,
make_custom_webhook,
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
make_custom_webhook(organization=organization)
url = reverse("api-public:webhooks-list")
response = client.get(f"{url}?name=NonExistentName", format="json", HTTP_AUTHORIZATION=f"{token}")
expected_payload = {
"count": 0,
"next": None,
"previous": None,
"results": [],
"current_page_number": 1,
"page_size": 50,
"total_pages": 1,
}
assert response.status_code == status.HTTP_200_OK
assert response.data == expected_payload
@pytest.mark.django_db
def test_get_webhook(
make_organization_and_user_with_token,
make_custom_webhook,
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
webhook = make_custom_webhook(organization=organization)
url = reverse("api-public:webhooks-detail", kwargs={"pk": webhook.public_primary_key})
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
expected_payload = _get_expected_result(webhook)
assert response.status_code == status.HTTP_200_OK
assert response.data == expected_payload
@pytest.mark.django_db
def test_create_webhook(make_organization_and_user_with_token):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
url = reverse("api-public:webhooks-list")
data = {}
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_400_BAD_REQUEST
data["name"] = "Test outgoing webhook"
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_400_BAD_REQUEST
data["url"] = "https://example.com"
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_400_BAD_REQUEST
data["trigger_type"] = "escalation"
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_400_BAD_REQUEST
data["http_method"] = "POST"
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_201_CREATED
webhook = Webhook.objects.get(public_primary_key=response.data["id"])
expected_result = _get_expected_result(webhook)
assert response.data == expected_result
@pytest.mark.django_db
def test_create_webhook_nested_data(make_organization_and_user_with_token):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
url = reverse("api-public:webhooks-list")
data = {
"name": "Test outgoing webhook with nested data",
"url": "https://example.com",
"data": '{"nested_item": "{{ alert_payload.foo.bar | to_json }}"}',
"http_method": "POST",
"trigger_type": "acknowledge",
}
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_400_BAD_REQUEST
data["data"] = '{"nested_item": "{{ alert_payload.foo.bar | tojson() }}"}'
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
webhook = Webhook.objects.get(public_primary_key=response.data["id"])
expected_result = _get_expected_result(webhook)
assert response.status_code == status.HTTP_201_CREATED
assert response.json() == expected_result
@pytest.mark.django_db
def test_update_webhook(
make_organization_and_user_with_token,
make_custom_webhook,
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
webhook = make_custom_webhook(organization=organization)
url = reverse("api-public:webhooks-detail", kwargs={"pk": webhook.public_primary_key})
data = {
"name": "RENAMED",
}
assert webhook.name != data["name"]
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
expected_result = _get_expected_result(webhook)
expected_result["name"] = data["name"]
assert response.status_code == status.HTTP_200_OK
webhook.refresh_from_db()
assert webhook.name == expected_result["name"]
assert response.data == expected_result
@pytest.mark.django_db
def test_delete_webhook(
make_organization_and_user_with_token,
make_custom_webhook,
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
webhook = make_custom_webhook(organization=organization)
url = reverse("api-public:webhooks-detail", kwargs={"pk": webhook.public_primary_key})
assert webhook.deleted_at is None
response = client.delete(url, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_204_NO_CONTENT
webhook.refresh_from_db()
assert webhook.deleted_at is not None
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.data["detail"] == "Not found."
@pytest.mark.django_db
def test_get_webhook_responses(
make_organization_and_user_with_token,
make_custom_webhook,
make_webhook_response,
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
webhook = make_custom_webhook(organization=organization)
webhook.refresh_from_db()
response_count = 20
for i in range(0, response_count):
make_webhook_response(
webhook=webhook,
trigger_type=webhook.trigger_type,
status_code=200,
content=json.dumps({"id": "third-party-id"}),
event_data=json.dumps({"test": "abc"}),
)
url = reverse("api-public:webhooks-responses", kwargs={"pk": webhook.public_primary_key})
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
webhook_response = response.data["results"][0]
assert webhook_response["status_code"] == 200
assert webhook_response["content"] == '{"id": "third-party-id"}'
assert webhook_response["event_data"] == '{"test": "abc"}'
assert response.data["count"] == 20
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
def test_webhook_validate_integration_filters(
make_organization_and_user_with_token,
make_custom_webhook,
make_alert_receive_channel,
):
organization, user, token = make_organization_and_user_with_token()
alert_receive_channel = make_alert_receive_channel(organization)
webhook = make_custom_webhook(organization=organization)
url = reverse("api-public:webhooks-detail", kwargs={"pk": webhook.public_primary_key})
data = {"integration_filter": alert_receive_channel.public_primary_key}
client = APIClient()
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == 400
data["integration_filter"] = ["abc"]
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == 400
data["integration_filter"] = [alert_receive_channel.public_primary_key, alert_receive_channel.public_primary_key]
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == 400
data["integration_filter"] = [alert_receive_channel.public_primary_key]
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
webhook.refresh_from_db()
assert response.status_code == 200
assert response.data["integration_filter"] == data["integration_filter"]
assert webhook.integration_filter == data["integration_filter"]
data["integration_filter"] = []
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
webhook.refresh_from_db()
assert response.status_code == 200
assert response.data["integration_filter"] == data["integration_filter"]
assert webhook.integration_filter == data["integration_filter"]
data["integration_filter"] = None
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
webhook.refresh_from_db()
assert response.status_code == 200
assert response.data["integration_filter"] == data["integration_filter"]
assert webhook.integration_filter == data["integration_filter"]

View file

@ -26,6 +26,7 @@ router.register(r"user_groups", views.UserGroupView, basename="user_groups")
router.register(r"on_call_shifts", views.CustomOnCallShiftView, basename="on_call_shifts")
router.register(r"teams", views.TeamView, basename="teams")
router.register(r"shift_swaps", views.ShiftSwapViewSet, basename="shift_swap")
router.register(r"webhooks", views.WebhooksView, basename="webhooks")
urlpatterns = [

View file

@ -17,3 +17,4 @@ from .slack_channels import SlackChannelView # noqa: F401
from .teams import TeamView # noqa: F401
from .user_groups import UserGroupView # noqa: F401
from .users import UserView # noqa: F401
from .webhooks import WebhooksView # noqa: F401

View file

@ -2,10 +2,11 @@ from django_filters import rest_framework as filters
from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import ModelViewSet
from apps.alerts.models import CustomButton
from apps.api.serializers.webhook import WebhookSerializer
from apps.auth_token.auth import ApiTokenAuthentication
from apps.public_api.serializers.action import ActionCreateSerializer, ActionUpdateSerializer
from apps.public_api.throttlers.user_throttle import UserThrottle
from apps.webhooks.models import Webhook
from common.api_helpers.filters import ByTeamFilter
from common.api_helpers.mixins import PublicPrimaryKeyMixin, RateLimitHeadersMixin, UpdateSerializerMixin
from common.api_helpers.paginators import FiftyPageSizePaginator
@ -18,7 +19,7 @@ class ActionView(RateLimitHeadersMixin, PublicPrimaryKeyMixin, UpdateSerializerM
pagination_class = FiftyPageSizePaginator
throttle_classes = [UserThrottle]
model = CustomButton
model = WebhookSerializer
serializer_class = ActionCreateSerializer
update_serializer_class = ActionUpdateSerializer
@ -27,7 +28,7 @@ class ActionView(RateLimitHeadersMixin, PublicPrimaryKeyMixin, UpdateSerializerM
def get_queryset(self):
action_name = self.request.query_params.get("name", None)
queryset = CustomButton.objects.filter(organization=self.request.auth.organization)
queryset = Webhook.objects.filter(organization=self.request.auth.organization)
if action_name:
queryset = queryset.filter(name=action_name)

View file

@ -0,0 +1,94 @@
from django_filters import rest_framework as filters
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from apps.auth_token.auth import ApiTokenAuthentication
from apps.public_api.serializers.webhooks import (
WebhookCreateSerializer,
WebhookResponseSerializer,
WebhookUpdateSerializer,
)
from apps.public_api.throttlers import UserThrottle
from apps.webhooks.models import Webhook, WebhookResponse
from common.api_helpers.filters import ByTeamFilter
from common.api_helpers.mixins import RateLimitHeadersMixin, UpdateSerializerMixin
from common.api_helpers.paginators import FiftyPageSizePaginator
from common.insight_log import EntityEvent, write_resource_insight_log
class WebhooksView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet):
authentication_classes = (ApiTokenAuthentication,)
permission_classes = (IsAuthenticated,)
pagination_class = FiftyPageSizePaginator
throttle_classes = [UserThrottle]
model = Webhook
serializer_class = WebhookCreateSerializer
update_serializer_class = WebhookUpdateSerializer
filter_backends = (filters.DjangoFilterBackend,)
filterset_class = ByTeamFilter
def get_queryset(self):
webhook_name = self.request.query_params.get("name", None)
queryset = Webhook.objects.filter(organization=self.request.auth.organization)
if webhook_name:
queryset = queryset.filter(name=webhook_name)
return queryset.order_by("id")
def get_object(self):
public_primary_key = self.kwargs["pk"]
try:
return Webhook.objects.filter(organization=self.request.auth.organization).get(
public_primary_key=public_primary_key
)
except Webhook.DoesNotExist:
raise NotFound
def perform_create(self, serializer):
serializer.save()
write_resource_insight_log(
instance=serializer.instance,
author=self.request.user,
event=EntityEvent.CREATED,
)
def perform_update(self, serializer):
prev_state = serializer.instance.insight_logs_serialized
serializer.save()
new_state = serializer.instance.insight_logs_serialized
write_resource_insight_log(
instance=serializer.instance,
author=self.request.user,
event=EntityEvent.UPDATED,
prev_state=prev_state,
new_state=new_state,
)
def perform_destroy(self, instance):
write_resource_insight_log(
instance=instance,
author=self.request.user,
event=EntityEvent.DELETED,
)
instance.delete()
@action(methods=["get"], detail=True)
def responses(self, request, pk):
webhook = self.get_object()
queryset = WebhookResponse.objects.filter(webhook_id=webhook.id, trigger_type=webhook.trigger_type).order_by(
"-timestamp"
)
page = self.paginate_queryset(queryset)
if page is not None:
response_serializer = WebhookResponseSerializer(page, many=True)
return self.get_paginated_response(response_serializer.data)
response_serializer = WebhookResponseSerializer(queryset, many=True)
return Response(response_serializer.data)

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.20 on 2023-08-14 22:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('webhooks', '0009_alter_webhook_authorization_header'),
]
operations = [
migrations.AlterField(
model_name='webhook',
name='trigger_type',
field=models.IntegerField(choices=[(0, 'Escalation step'), (1, 'Alert Group Created'), (2, 'Acknowledged'), (3, 'Resolved'), (4, 'Silenced'), (5, 'Unsilenced'), (6, 'Unresolved'), (7, 'Unacknowledged')], default=0, null=True),
),
]

View file

@ -1,8 +1,10 @@
import json
import logging
import typing
from json import JSONDecodeError
import requests
from celery.utils.log import get_task_logger
from django.conf import settings
from django.core.validators import MinLengthValidator
from django.db import models
@ -30,6 +32,10 @@ if typing.TYPE_CHECKING:
from apps.alerts.models import EscalationPolicy
WEBHOOK_FIELD_PLACEHOLDER = "****************"
PUBLIC_WEBHOOK_HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
logger = get_task_logger(__name__)
logger.setLevel(logging.DEBUG)
def generate_public_primary_key_for_webhook():
@ -88,6 +94,19 @@ class Webhook(models.Model):
(TRIGGER_UNACKNOWLEDGE, "Unacknowledged"),
)
PUBLIC_TRIGGER_TYPES_MAP = {
TRIGGER_ESCALATION_STEP: "escalation",
TRIGGER_ALERT_GROUP_CREATED: "alert group created",
TRIGGER_ACKNOWLEDGE: "acknowledge",
TRIGGER_RESOLVE: "resolve",
TRIGGER_SILENCE: "silence",
TRIGGER_UNSILENCE: "unsilence",
TRIGGER_UNRESOLVE: "unresolve",
TRIGGER_UNACKNOWLEDGE: "unacknowledge",
}
PUBLIC_ALL_TRIGGER_TYPES = [i for i in PUBLIC_TRIGGER_TYPES_MAP.values()]
public_primary_key = models.CharField(
max_length=20,
validators=[MinLengthValidator(settings.PUBLIC_PRIMARY_KEY_MIN_LENGTH + 1)],
@ -119,7 +138,7 @@ class Webhook(models.Model):
data = models.TextField(null=True, default=None)
forward_all = models.BooleanField(default=True)
http_method = models.CharField(max_length=32, default="POST")
trigger_type = models.IntegerField(choices=TRIGGER_TYPES, default=None, null=True)
trigger_type = models.IntegerField(choices=TRIGGER_TYPES, default=TRIGGER_ESCALATION_STEP, null=True)
is_webhook_enabled = models.BooleanField(null=True, default=True)
integration_filter = models.JSONField(default=None, null=True, blank=True)
is_legacy = models.BooleanField(null=True, default=False)