Skip to content

Commit 3297d2d

Browse files
committed
add aboutcode app for notifications
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent 1ddc619 commit 3297d2d

3 files changed

Lines changed: 252 additions & 0 deletions

File tree

aboutcode/notification/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# DejaCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: AGPL-3.0-only
5+
# See https://github.com/aboutcode-org/dejacode for support or download.
6+
# See https://aboutcode.org for more information about AboutCode FOSS projects.
7+
#
8+
9+
from aboutcode.notification.models import AbstractWebhookSubscription
10+
from aboutcode.notification.models import AbstractWebhookDelivery
11+
12+
__version__ = "0.1.0"
13+
14+
__all__ = ["AbstractWebhookSubscription", "AbstractWebhookDelivery"]

aboutcode/notification/models.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# DejaCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: AGPL-3.0-only
5+
# See https://github.com/aboutcode-org/dejacode for support or download.
6+
# See https://aboutcode.org for more information about AboutCode FOSS projects.
7+
#
8+
9+
import json
10+
import logging
11+
from urllib.parse import urlparse
12+
13+
from django.core.serializers.json import DjangoJSONEncoder
14+
from django.db import models
15+
from django.utils.translation import gettext_lazy as _
16+
17+
import requests
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
class AbstractWebhookSubscription(models.Model):
23+
"""
24+
Abstract base for Webhook subscription models.
25+
26+
Subclasses must implement get_payload(context) and create_delivery(payload, context).
27+
Override get_slack_payload(context) to support Slack webhook URLs.
28+
"""
29+
30+
target_url = models.URLField(
31+
_("Target URL"),
32+
max_length=1024,
33+
blank=False,
34+
help_text=_(
35+
"The URL to which the POST request will be sent when the Webhook is triggered."
36+
),
37+
)
38+
is_active = models.BooleanField(
39+
default=True,
40+
help_text=_(
41+
"Indicates whether the Webhook is currently active and should be triggered."
42+
),
43+
)
44+
created_date = models.DateTimeField(
45+
auto_now_add=True,
46+
editable=False,
47+
help_text=_("The date and time when the Webhook subscription was created."),
48+
)
49+
50+
class Meta:
51+
abstract = True
52+
ordering = ["-created_date"]
53+
54+
def get_payload(self, context):
55+
raise NotImplementedError
56+
57+
def get_slack_payload(self, context):
58+
"""Return a Slack-specific payload, or None to fall back to get_payload."""
59+
return None
60+
61+
def create_delivery(self, payload, context):
62+
raise NotImplementedError
63+
64+
def deliver(self, context, timeout=10):
65+
"""Deliver this Webhook by sending a POST request to the target_url."""
66+
logger.info(f"Delivering Webhook {self.uuid}")
67+
68+
if not self.is_active:
69+
logger.info(f"Webhook {self.uuid} is not active.")
70+
return False
71+
72+
parsed = urlparse(self.target_url)
73+
if parsed.hostname == "hooks.slack.com" and (
74+
slack_payload := self.get_slack_payload(context)
75+
):
76+
payload = slack_payload
77+
else:
78+
payload = self.get_payload(context)
79+
80+
delivery = self.create_delivery(payload, context)
81+
82+
try:
83+
response = requests.post(
84+
url=self.target_url,
85+
data=json.dumps(payload, cls=DjangoJSONEncoder),
86+
headers={"Content-Type": "application/json"},
87+
timeout=timeout,
88+
)
89+
except requests.exceptions.RequestException as exception:
90+
logger.error(exception)
91+
delivery.delivery_error = str(exception)
92+
delivery.save()
93+
return delivery
94+
95+
delivery.response_status_code = response.status_code
96+
delivery.response_text = response.text
97+
delivery.save()
98+
99+
if delivery.success:
100+
logger.info(f"Webhook {self.uuid} delivered successfully.")
101+
else:
102+
logger.info(f"Webhook {self.uuid} returned a {response.status_code}.")
103+
104+
return delivery
105+
106+
107+
class AbstractWebhookDelivery(models.Model):
108+
"""Abstract base for Webhook delivery history models."""
109+
110+
target_url = models.URLField(
111+
_("Target URL"),
112+
max_length=1024,
113+
blank=False,
114+
help_text=_(
115+
"Stores a copy of the Webhook target URL in case the subscription object "
116+
"is deleted."
117+
),
118+
)
119+
sent_date = models.DateTimeField(
120+
auto_now_add=True,
121+
editable=False,
122+
help_text=_("The date and time when the Webhook was sent."),
123+
)
124+
payload = models.JSONField(
125+
blank=True,
126+
default=dict,
127+
help_text=_("The JSON payload that was sent to the target URL."),
128+
)
129+
response_status_code = models.PositiveIntegerField(
130+
null=True,
131+
blank=True,
132+
help_text=_("The HTTP status code received in response to the Webhook request."),
133+
)
134+
response_text = models.TextField(
135+
blank=True,
136+
help_text=_("The text response received from the target URL."),
137+
)
138+
delivery_error = models.TextField(
139+
blank=True,
140+
help_text=_("Any error messages encountered during the Webhook delivery."),
141+
)
142+
143+
class Meta:
144+
abstract = True
145+
verbose_name = _("webhook delivery")
146+
verbose_name_plural = _("webhook deliveries")
147+
ordering = ["-sent_date"]
148+
149+
def __str__(self):
150+
return f"Webhook uuid={self.uuid} posted at {self.sent_date}"
151+
152+
@property
153+
def delivered(self):
154+
return bool(self.response_status_code)
155+
156+
@property
157+
def success(self):
158+
return self.response_status_code in (200, 201, 202)

notification/models.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
# See https://aboutcode.org for more information about AboutCode FOSS projects.
77
#
88

9+
import logging
10+
911
from django import template
1012
from django.conf import settings
1113
from django.db import models
@@ -14,6 +16,84 @@
1416
from rest_hooks.models import AbstractHook
1517

1618
from dje.models import DataspacedModel
19+
from dje.models import DataspacedQuerySet
20+
from dje.models import HistoryFieldsMixin
21+
22+
from aboutcode.notification import AbstractWebhookSubscription
23+
from aboutcode.notification import AbstractWebhookDelivery
24+
25+
26+
logger = logging.getLogger("dje")
27+
28+
29+
class WebhookSubscriptionQuerySet(DataspacedQuerySet):
30+
def active(self):
31+
return self.filter(is_active=True)
32+
33+
34+
class WebhookSubscription(DataspacedModel, AbstractWebhookSubscription):
35+
"""
36+
A model to define Webhook subscriptions.
37+
38+
This model captures the necessary details to configure a Webhook, including the
39+
target URL and the specific events that trigger the Webhook.
40+
"""
41+
42+
event = models.CharField(
43+
max_length=64,
44+
)
45+
extra_payload = models.JSONField(
46+
blank=True,
47+
default=dict,
48+
help_text=_("Extra data as JSON to be included in the payload"),
49+
)
50+
extra_headers = models.JSONField(
51+
blank=True,
52+
default=dict,
53+
help_text=_("Extra headers as JSON to be included in the request"),
54+
)
55+
56+
objects = WebhookSubscriptionQuerySet.as_manager()
57+
58+
class Meta(AbstractWebhookSubscription.Meta):
59+
unique_together = ("dataspace", "uuid")
60+
61+
def __str__(self):
62+
return f"{self.event} => {self.target_url}"
63+
64+
def get_payload(self, instance):
65+
return instance.serialize_hook(hook=self)
66+
67+
def create_delivery(self, payload, instance):
68+
return WebhookDelivery(
69+
dataspace=self.dataspace,
70+
webhook_subscription=self,
71+
target_url=self.target_url,
72+
payload=payload,
73+
)
74+
75+
76+
class WebhookDelivery(DataspacedModel, AbstractWebhookDelivery):
77+
"""
78+
Stores historical data for Webhook deliveries.
79+
80+
This model keeps track of each delivery attempt made by a Webhook subscription,
81+
including the payload sent, the response received, and any errors that occurred
82+
during the delivery process.
83+
"""
84+
85+
webhook_subscription = models.ForeignKey(
86+
WebhookSubscription,
87+
related_name="deliveries",
88+
editable=False,
89+
blank=True,
90+
null=True,
91+
on_delete=models.SET_NULL,
92+
help_text=_("The Webhook subscription associated with this delivery."),
93+
)
94+
95+
class Meta(AbstractWebhookDelivery.Meta):
96+
unique_together = [("dataspace", "uuid")]
1797

1898

1999
# DataspacedModel is first as we want to apply it last for proper overrides

0 commit comments

Comments
 (0)