Skip to content

Commit 3c9f04b

Browse files
authored
v1.15.0
2 parents aaae31a + 34eec39 commit 3c9f04b

31 files changed

+1145
-18
lines changed

docs/sources/configure/integrations/outgoing-webhooks/index.md

+13
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ refs:
2020
destination: /docs/oncall/<ONCALL_VERSION>/configure/integrations/labels/#alert-group-labels
2121
- pattern: /docs/grafana-cloud/
2222
destination: /docs/grafana-cloud/alerting-and-irm/oncall/configure/integrations/labels/#alert-group-labels
23+
personal-webhook:
24+
- pattern: /docs/oncall/
25+
destination: /docs/oncall/<ONCALL_VERSION>/manage/notify/webhook
26+
- pattern: /docs/grafana-cloud/
27+
destination: /docs/grafana-cloud/alerting-and-irm/oncall/manage/notify/webhook
2328
integration-labels:
2429
- pattern: /docs/oncall/
2530
destination: /docs/oncall/<ONCALL_VERSION>/configure/integrations/labels/
@@ -109,6 +114,7 @@ This setting does not restrict outgoing webhook execution to events from the sel
109114
The type of event that will cause this outgoing webhook to execute. The types of triggers are:
110115

111116
- [Manual or Escalation Step](#escalation-step)
117+
- [Personal Notification](#personal-notification)
112118
- [Alert Group Created](#alert-group-created)
113119
- [Acknowledged](#acknowledged)
114120
- [Resolved](#resolved)
@@ -310,6 +316,7 @@ Context information about the event that triggered the outgoing webhook.
310316

311317
- `{{ event.type }}` - Lower case string matching [type of event](#event-types)
312318
- `{{ event.time }}` - Time event was triggered
319+
- `{{ event.user.* }}` - Context data as provided by the user for [Personal Notification](ref:personal-webhook) webhooks
313320

314321
#### `user`
315322

@@ -482,6 +489,12 @@ Now the result is correct:
482489
This event will trigger when the outgoing webhook is included as a step in an escalation chain.
483490
Webhooks with this trigger type can also be manually triggered in the context of an alert group in the web UI.
484491

492+
### Personal Notification
493+
494+
`event.type` `personal notification`
495+
496+
This event will trigger when the outgoing webhook is included as a step in a user's personal notification rules.
497+
485498
### Alert Group Created
486499

487500
`event.type` `alert group created`
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
title: Webhook as personal notification channel
3+
menuTitle: Webhook
4+
description: Learn more about using webhooks as a personal notification channel in Grafana OnCall.
5+
weight: 700
6+
keywords:
7+
- OnCall
8+
- Notifications
9+
- ChatOps
10+
- Webhook
11+
- Channels
12+
canonical: https://grafana.com/docs/oncall/latest/manage/notify/webhook/
13+
aliases:
14+
- /docs/grafana-cloud/alerting-and-irm/oncall/manage/notify/webhook/
15+
- /docs/grafana-cloud/alerting-and-irm/oncall/notify/webhook/
16+
refs:
17+
outgoing-webhooks:
18+
- pattern: /docs/oncall/
19+
destination: /docs/oncall/<ONCALL_VERSION>/configure/integrations/outgoing-webhooks/
20+
- pattern: /docs/grafana-cloud/
21+
destination: /docs/grafana-cloud/alerting-and-irm/oncall/configure/integrations/outgoing-webhooks/
22+
---
23+
24+
# Webhook as a personal notification channel
25+
26+
It is possible to setup a webhook as a personal notification channel in your user profile.
27+
The webhook will be triggered as a personal notification rule according to your notification policy configuration.
28+
29+
## Configure a webhook to be used as personal notification
30+
31+
In the webhooks page, you (or a user with the right permissions) need to define a [webhook](ref:outgoing-webhooks) as usual,
32+
but with the `Personal Notification` trigger type.
33+
34+
Each user will then be able to choose a webhook (between those with the above trigger type) as a notification channel in
35+
their profile. Optionally, they can also provide additional context data (as a JSON dict, e.g. `{"user_ID": "some-id"}`)
36+
which will be available when evaluating the webhook templates. This data can be referenced via `{{ event.user.<key> }}`
37+
(e.g. `{{ event.user.user_ID }}`).

docs/sources/oncall-api-reference/outgoing_webhooks.md

+1
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ curl "{{API_URL}}/api/v1/webhooks/" \
132132
For more detail, refer to [Event types](ref:event-types).
133133

134134
- `escalation`
135+
- `personal notification`
135136
- `alert group created`
136137
- `acknowledge`
137138
- `resolve`

docs/sources/oncall-api-reference/personal_notification_rules.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ The above command returns JSON structured in the following way:
4343
| ----------- | :------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
4444
| `user_id` | Yes | User ID |
4545
| `position` | Optional | Personal notification rules execute one after another starting from `position=0`. `Position=-1` will put the escalation policy to the end of the list. A new escalation policy created with a position of an existing escalation policy will move the old one (and all following) down on the list. |
46-
| `type` | Yes | One of: `wait`, `notify_by_slack`, `notify_by_sms`, `notify_by_phone_call`, `notify_by_telegram`, `notify_by_email`, `notify_by_mobile_app`, `notify_by_mobile_app_critical`, or `notify_by_msteams` (**NOTE** `notify_by_msteams` is only available on Grafana Cloud). |
46+
| `type` | Yes | One of: `wait`, `notify_by_slack`, `notify_by_sms`, `notify_by_phone_call`, `notify_by_telegram`, `notify_by_email`, `notify_by_mobile_app`, `notify_by_mobile_app_critical`, `notify_by_webhook` or `notify_by_msteams` (**NOTE** `notify_by_msteams` is only available on Grafana Cloud). |
4747
| `duration` | Optional | A time in seconds to wait (when `type=wait`). Can be one of 60, 300, 900, 1800, or 3600. |
4848
| `important` | Optional | Boolean value indicates if a rule is "important". Default is `false`. |
4949

engine/apps/api/tests/test_webhooks.py

+97
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from unittest.mock import patch
33

44
import pytest
5+
from django.core.exceptions import ObjectDoesNotExist
56
from django.urls import reverse
67
from rest_framework import status
78
from rest_framework.response import Response
@@ -1253,3 +1254,99 @@ def test_webhook_trigger_manual(
12531254
)
12541255
assert response.status_code == status.HTTP_404_NOT_FOUND
12551256
assert mock_execute.apply_async.call_count == 0
1257+
1258+
1259+
@pytest.mark.django_db
1260+
def test_current_personal_notification(
1261+
make_organization_and_user_with_plugin_token,
1262+
make_custom_webhook,
1263+
make_user_auth_headers,
1264+
make_personal_notification_webhook,
1265+
):
1266+
organization, user, token = make_organization_and_user_with_plugin_token()
1267+
with pytest.raises(ObjectDoesNotExist):
1268+
user.personal_webhook
1269+
1270+
webhook = make_custom_webhook(organization, trigger_type=Webhook.TRIGGER_PERSONAL_NOTIFICATION)
1271+
1272+
client = APIClient()
1273+
url = reverse("api-internal:webhooks-current-personal-notification")
1274+
1275+
# no webhook setup
1276+
response = client.get(url, **make_user_auth_headers(user, token))
1277+
assert response.status_code == status.HTTP_200_OK
1278+
assert response.json() == {"webhook": None, "context": None}
1279+
1280+
# setup personal webhook
1281+
personal_webhook = make_personal_notification_webhook(user, webhook)
1282+
response = client.get(url, **make_user_auth_headers(user, token))
1283+
assert response.status_code == status.HTTP_200_OK
1284+
assert response.json() == {"webhook": webhook.public_primary_key, "context": {}}
1285+
1286+
# update context data
1287+
personal_webhook.context_data = {"test": "test"}
1288+
response = client.get(url, **make_user_auth_headers(user, token))
1289+
assert response.status_code == status.HTTP_200_OK
1290+
assert response.json() == {"webhook": webhook.public_primary_key, "context": {"test": "test"}}
1291+
1292+
1293+
@pytest.mark.django_db
1294+
def test_set_personal_notification(
1295+
make_organization_and_user_with_plugin_token,
1296+
make_custom_webhook,
1297+
make_user_auth_headers,
1298+
):
1299+
organization, user, token = make_organization_and_user_with_plugin_token()
1300+
with pytest.raises(ObjectDoesNotExist):
1301+
user.personal_webhook
1302+
1303+
webhook = make_custom_webhook(organization, trigger_type=Webhook.TRIGGER_PERSONAL_NOTIFICATION)
1304+
other_webhook = make_custom_webhook(organization, trigger_type=Webhook.TRIGGER_MANUAL)
1305+
1306+
client = APIClient()
1307+
url = reverse("api-internal:webhooks-set-personal-notification")
1308+
1309+
# webhook id is required
1310+
data = {}
1311+
response = client.post(
1312+
url, data=json.dumps(data), content_type="application/json", **make_user_auth_headers(user, token)
1313+
)
1314+
assert response.status_code == status.HTTP_400_BAD_REQUEST
1315+
assert response.json()["webhook"] == "This field is required."
1316+
1317+
# invalid webhook type
1318+
data = {"webhook": other_webhook.public_primary_key}
1319+
response = client.post(
1320+
url, data=json.dumps(data), content_type="application/json", **make_user_auth_headers(user, token)
1321+
)
1322+
assert response.status_code == status.HTTP_400_BAD_REQUEST
1323+
assert response.json()["webhook"] == "Webhook not found."
1324+
1325+
# check backend info
1326+
data = {"webhook": webhook.public_primary_key}
1327+
response = client.post(
1328+
url, data=json.dumps(data), content_type="application/json", **make_user_auth_headers(user, token)
1329+
)
1330+
assert response.status_code == status.HTTP_200_OK
1331+
user.refresh_from_db()
1332+
assert user.personal_webhook.webhook == webhook
1333+
assert user.personal_webhook.context_data == {}
1334+
1335+
# update context data
1336+
data = {"webhook": webhook.public_primary_key, "context": {"test": "test"}}
1337+
response = client.post(
1338+
url, data=json.dumps(data), content_type="application/json", **make_user_auth_headers(user, token)
1339+
)
1340+
assert response.status_code == status.HTTP_200_OK
1341+
user.refresh_from_db()
1342+
assert user.personal_webhook.context_data == {"test": "test"}
1343+
1344+
# invalid context
1345+
data = {"webhook": webhook.public_primary_key, "context": "not-json"}
1346+
response = client.post(
1347+
url, data=json.dumps(data), content_type="application/json", **make_user_auth_headers(user, token)
1348+
)
1349+
assert response.status_code == status.HTTP_400_BAD_REQUEST
1350+
assert response.json()["context"] == "Invalid context."
1351+
user.refresh_from_db()
1352+
assert user.personal_webhook.context_data == {"test": "test"}

engine/apps/api/views/features.py

+4
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class Feature(enum.StrEnum):
2727
LABELS = "labels"
2828
GOOGLE_OAUTH2 = "google_oauth2"
2929
SERVICE_DEPENDENCIES = "service_dependencies"
30+
PERSONAL_WEBHOOK = "personal_webhook"
3031

3132

3233
class FeaturesAPIView(APIView):
@@ -76,4 +77,7 @@ def _get_enabled_features(self, request):
7677
if settings.FEATURE_SERVICE_DEPENDENCIES_ENABLED:
7778
enabled_features.append(Feature.SERVICE_DEPENDENCIES)
7879

80+
if settings.FEATURE_PERSONAL_WEBHOOK_ENABLED:
81+
enabled_features.append(Feature.PERSONAL_WEBHOOK)
82+
7983
return enabled_features

engine/apps/api/views/webhooks.py

+80-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from apps.api.views.labels import schedule_update_label_cache
2020
from apps.auth_token.auth import PluginAuthentication
2121
from apps.labels.utils import is_labels_feature_enabled
22-
from apps.webhooks.models import Webhook, WebhookResponse
22+
from apps.webhooks.models import PersonalNotificationWebhook, Webhook, WebhookResponse
2323
from apps.webhooks.presets.preset_options import WebhookPresetOptions
2424
from apps.webhooks.tasks import execute_webhook
2525
from apps.webhooks.utils import apply_jinja_template_for_json
@@ -89,6 +89,8 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin[Webhook], ModelView
8989
"preview_template": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE],
9090
"preset_options": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
9191
"trigger_manual": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
92+
"current_personal_notification": [RBACPermission.Permissions.USER_SETTINGS_READ],
93+
"set_personal_notification": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
9294
}
9395

9496
model = Webhook
@@ -336,3 +338,80 @@ def trigger_manual(self, request, pk):
336338
(webhook.pk, alert_group.pk, user.pk, None), kwargs={"trigger_type": Webhook.TRIGGER_MANUAL}
337339
)
338340
return Response(status=status.HTTP_200_OK)
341+
342+
@extend_schema(
343+
responses={
344+
status.HTTP_200_OK: inline_serializer(
345+
name="PersonalNotificationWebhook",
346+
fields={
347+
"webhook": serializers.CharField(),
348+
"context": serializers.DictField(required=False, allow_null=True),
349+
},
350+
)
351+
},
352+
)
353+
@action(methods=["get"], detail=False)
354+
def current_personal_notification(self, request):
355+
user = self.request.user
356+
notification_channel = {
357+
"webhook": None,
358+
"context": None,
359+
}
360+
try:
361+
personal_webhook = PersonalNotificationWebhook.objects.get(user=user)
362+
except PersonalNotificationWebhook.DoesNotExist:
363+
personal_webhook = None
364+
365+
if personal_webhook is not None:
366+
notification_channel["webhook"] = personal_webhook.webhook.public_primary_key
367+
notification_channel["context"] = personal_webhook.context_data
368+
369+
return Response(notification_channel)
370+
371+
@extend_schema(
372+
request=inline_serializer(
373+
name="PersonalNotificationWebhookRequest",
374+
fields={
375+
"webhook": serializers.CharField(),
376+
"context": serializers.DictField(required=False, allow_null=True),
377+
},
378+
),
379+
responses={status.HTTP_200_OK: None},
380+
)
381+
@action(methods=["post"], detail=False)
382+
def set_personal_notification(self, request):
383+
"""Set up a webhook as personal notification channel for the user."""
384+
user = self.request.user
385+
386+
webhook_id = request.data.get("webhook")
387+
if not webhook_id:
388+
raise BadRequest(detail={"webhook": "This field is required."})
389+
390+
try:
391+
webhook = Webhook.objects.get(
392+
organization=user.organization,
393+
public_primary_key=webhook_id,
394+
trigger_type=Webhook.TRIGGER_PERSONAL_NOTIFICATION,
395+
)
396+
except Webhook.DoesNotExist:
397+
raise BadRequest(detail={"webhook": "Webhook not found."})
398+
399+
context = request.data.get("context", None)
400+
if context is not None:
401+
if not isinstance(context, dict):
402+
raise BadRequest(detail={"context": "Invalid context."})
403+
404+
try:
405+
context = json.dumps(context)
406+
except TypeError:
407+
raise BadRequest(detail={"context": "Invalid context."})
408+
409+
# set or update personal webhook for user
410+
PersonalNotificationWebhook.objects.update_or_create(
411+
user=user,
412+
defaults={
413+
"webhook": webhook,
414+
"additional_context_data": context,
415+
},
416+
)
417+
return Response(status=status.HTTP_200_OK)

engine/apps/webhooks/backend.py

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import typing
2+
3+
from django.core.exceptions import ObjectDoesNotExist
4+
5+
from apps.base.messaging import BaseMessagingBackend
6+
7+
if typing.TYPE_CHECKING:
8+
from apps.alerts.models import AlertGroup
9+
from apps.base.models import UserNotificationPolicy
10+
from apps.user_management.models import User
11+
12+
13+
class PersonalWebhookBackend(BaseMessagingBackend):
14+
backend_id = "WEBHOOK"
15+
label = "Webhook"
16+
short_label = "Webhook"
17+
available_for_use = True
18+
19+
def serialize_user(self, user: "User"):
20+
try:
21+
personal_webhook = user.personal_webhook
22+
except ObjectDoesNotExist:
23+
return None
24+
return {"id": personal_webhook.webhook.public_primary_key, "name": personal_webhook.webhook.name}
25+
26+
def unlink_user(self, user):
27+
try:
28+
user.personal_webhook.delete()
29+
except ObjectDoesNotExist:
30+
pass
31+
32+
def notify_user(
33+
self, user: "User", alert_group: "AlertGroup", notification_policy: typing.Optional["UserNotificationPolicy"]
34+
):
35+
from apps.webhooks.tasks import notify_user_async
36+
37+
notify_user_async.delay(
38+
user_pk=user.pk,
39+
alert_group_pk=alert_group.pk,
40+
notification_policy_pk=notification_policy.pk if notification_policy else None,
41+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Generated by Django 4.2.16 on 2025-01-27 18:46
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
import mirage.fields
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('user_management', '0029_remove_organization_general_log_channel_id_db'),
12+
('webhooks', '0017_alter_webhook_trigger_type_and_more'),
13+
]
14+
15+
operations = [
16+
migrations.AlterField(
17+
model_name='webhook',
18+
name='trigger_type',
19+
field=models.IntegerField(choices=[(0, 'Manual or escalation step'), (1, 'Alert Group Created'), (2, 'Acknowledged'), (3, 'Resolved'), (4, 'Silenced'), (5, 'Unsilenced'), (6, 'Unresolved'), (7, 'Unacknowledged'), (8, 'Status change'), (9, 'Personal notification')], default=0, null=True),
20+
),
21+
migrations.AlterField(
22+
model_name='webhookresponse',
23+
name='trigger_type',
24+
field=models.IntegerField(choices=[(0, 'Manual or escalation step'), (1, 'Alert Group Created'), (2, 'Acknowledged'), (3, 'Resolved'), (4, 'Silenced'), (5, 'Unsilenced'), (6, 'Unresolved'), (7, 'Unacknowledged'), (8, 'Status change'), (9, 'Personal notification')]),
25+
),
26+
migrations.CreateModel(
27+
name='PersonalNotificationWebhook',
28+
fields=[
29+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
30+
('additional_context_data', mirage.fields.EncryptedTextField(null=True)),
31+
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='personal_webhook', to='user_management.user')),
32+
('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='personal_channels', to='webhooks.webhook')),
33+
],
34+
),
35+
]
+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
from .webhook import Webhook, WebhookResponse # noqa: F401
1+
from .webhook import PersonalNotificationWebhook, Webhook, WebhookResponse # noqa: F401

0 commit comments

Comments
 (0)