Skip to content

Commit 0e6951a

Browse files
authored
feat: [five-c] AboutCode app for alerts and notifications (#551)
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent 1ddc619 commit 0e6951a

25 files changed

Lines changed: 812 additions & 246 deletions

Makefile

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ run:
1919
@echo "-> Run the Docker compose services in dev mode (hot reload on code changes)"
2020
${COMPOSE} up
2121

22+
start:
23+
@echo "-> Start the Docker compose services in background"
24+
${COMPOSE} up -d
25+
26+
# make logs TAIL=100 SERVICE=db
27+
logs:
28+
${COMPOSE} logs -f --tail=${TAIL:-50} ${SERVICE}
29+
2230
bash:
2331
# Open a bash session in the running web container
2432
${COMPOSE} exec web bash
@@ -181,8 +189,4 @@ initdb:
181189
psql:
182190
${DOCKER_EXEC} ${DB_CONTAINER_NAME} psql --username=${DB_USERNAME} postgres
183191

184-
# $ make log SERVICE=db
185-
log:
186-
${DOCKER_COMPOSE} logs --tail="100" ${SERVICE}
187-
188-
.PHONY: virtualenv conf dev lock upgrade envfile envfile_dev check outdated doc8 valid clean initdb postgresdb postgresdb_clean migrate run test docs build psql bash shell log superuser
192+
.PHONY: virtualenv conf dev lock upgrade envfile envfile_dev check outdated doc8 valid clean initdb postgresdb postgresdb_clean migrate run test docs build psql bash shell logs start superuser
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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.notifications.models import AbstractWebhookDelivery
10+
from aboutcode.notifications.models import AbstractWebhookSubscription
11+
from aboutcode.notifications.models import WebhookSubscriptionQuerySetMixin
12+
13+
__version__ = "0.1.0"
14+
15+
__all__ = [
16+
"AbstractWebhookSubscription",
17+
"AbstractWebhookDelivery",
18+
"WebhookSubscriptionQuerySetMixin",
19+
]

aboutcode/notifications/models.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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 WebhookSubscriptionQuerySetMixin:
23+
"""Mixin for WebhookSubscription querysets. Combine with the project's base QuerySet."""
24+
25+
def active(self):
26+
return self.filter(is_active=True)
27+
28+
29+
class AbstractWebhookSubscription(models.Model):
30+
"""
31+
Abstract base for Webhook subscription models.
32+
33+
Subclasses must implement get_payload(context) and create_delivery(payload, context).
34+
Override get_slack_payload(context) to support Slack webhook URLs.
35+
"""
36+
37+
target_url = models.URLField(
38+
_("Target URL"),
39+
max_length=1024,
40+
blank=False,
41+
help_text=_(
42+
"The URL to which the POST request will be sent when the Webhook is triggered."
43+
),
44+
)
45+
is_active = models.BooleanField(
46+
default=True,
47+
help_text=_("Indicates whether the Webhook is currently active and should be triggered."),
48+
)
49+
event = models.CharField(
50+
_("Event"),
51+
max_length=64,
52+
null=True,
53+
blank=True,
54+
help_text=_("The event type that triggers this Webhook subscription."),
55+
)
56+
created_date = models.DateTimeField(
57+
auto_now_add=True,
58+
editable=False,
59+
help_text=_("The date and time when the Webhook subscription was created."),
60+
)
61+
62+
class Meta:
63+
abstract = True
64+
65+
def get_payload(self, context):
66+
raise NotImplementedError
67+
68+
def get_slack_payload(self, context):
69+
"""Return a Slack-specific payload, or None to fall back to get_payload."""
70+
return None
71+
72+
def create_delivery(self, payload, context):
73+
raise NotImplementedError
74+
75+
def get_headers(self):
76+
"""Return the HTTP headers to include in the Webhook request."""
77+
return {"Content-Type": "application/json"}
78+
79+
def deliver(self, context, timeout=10, payload_override=None):
80+
"""Deliver this Webhook by sending a POST request to the target_url."""
81+
logger.info(f"Delivering Webhook {self.uuid}")
82+
83+
if not self.is_active:
84+
logger.info(f"Webhook {self.uuid} is not active.")
85+
return False
86+
87+
if payload_override:
88+
payload = payload_override
89+
else:
90+
parsed = urlparse(self.target_url)
91+
if parsed.hostname == "hooks.slack.com" and (
92+
slack_payload := self.get_slack_payload(context)
93+
):
94+
payload = slack_payload
95+
else:
96+
payload = self.get_payload(context)
97+
98+
delivery = self.create_delivery(payload, context)
99+
100+
try:
101+
response = requests.post(
102+
url=self.target_url,
103+
data=json.dumps(payload, cls=DjangoJSONEncoder),
104+
headers=self.get_headers(),
105+
timeout=timeout,
106+
)
107+
except requests.exceptions.RequestException as exception:
108+
logger.error(exception)
109+
delivery.delivery_error = str(exception)
110+
delivery.save()
111+
return delivery
112+
113+
delivery.response_status_code = response.status_code
114+
delivery.response_text = response.text
115+
delivery.save()
116+
117+
if delivery.success:
118+
logger.info(f"Webhook {self.uuid} delivered successfully.")
119+
else:
120+
logger.info(f"Webhook {self.uuid} returned a {response.status_code}.")
121+
122+
return delivery
123+
124+
125+
class AbstractWebhookDelivery(models.Model):
126+
"""Abstract base for Webhook delivery history models."""
127+
128+
target_url = models.URLField(
129+
_("Target URL"),
130+
max_length=1024,
131+
blank=False,
132+
help_text=_(
133+
"Stores a copy of the Webhook target URL in case the subscription object is deleted."
134+
),
135+
)
136+
sent_date = models.DateTimeField(
137+
auto_now_add=True,
138+
editable=False,
139+
help_text=_("The date and time when the Webhook was sent."),
140+
)
141+
payload = models.JSONField(
142+
blank=True,
143+
default=dict,
144+
help_text=_("The JSON payload that was sent to the target URL."),
145+
)
146+
response_status_code = models.PositiveIntegerField(
147+
null=True,
148+
blank=True,
149+
help_text=_("The HTTP status code received in response to the Webhook request."),
150+
)
151+
response_text = models.TextField(
152+
blank=True,
153+
help_text=_("The text response received from the target URL."),
154+
)
155+
delivery_error = models.TextField(
156+
blank=True,
157+
help_text=_("Any error messages encountered during the Webhook delivery."),
158+
)
159+
160+
class Meta:
161+
abstract = True
162+
verbose_name = _("webhook delivery")
163+
verbose_name_plural = _("webhook deliveries")
164+
165+
def __str__(self):
166+
return f"Webhook uuid={self.uuid} posted at {self.sent_date}"
167+
168+
@property
169+
def delivered(self):
170+
return bool(self.response_status_code)
171+
172+
@property
173+
def success(self):
174+
return self.response_status_code in (200, 201, 202)

compose.dev.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ services:
3636
- "8000:8000"
3737
volumes:
3838
- ./.env:/opt/dejacode/.env
39+
- ./aboutcode:/opt/dejacode/aboutcode
3940
- ./component_catalog:/opt/dejacode/component_catalog
4041
- ./dejacode:/opt/dejacode/dejacode
4142
- ./dejacode_toolkit:/opt/dejacode/dejacode_toolkit

dejacode/settings.py

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,6 @@ def gettext_noop(s):
327327
"crispy_bootstrap5",
328328
"guardian",
329329
"django_filters",
330-
"rest_hooks",
331330
"notifications",
332331
"axes",
333332
"django_otp",
@@ -663,21 +662,10 @@ def get_fake_redis_connection(config, use_strict_redis):
663662
# django-altcha
664663
ALTCHA_HMAC_KEY = env.str("DEJACODE_ALTCHA_HMAC_KEY", default="")
665664

666-
# https://github.com/zapier/django-rest-hooks
667-
HOOK_FINDER = "notification.models.find_and_fire_hook"
668-
HOOK_DELIVERER = "notification.tasks.deliver_hook_wrapper"
669-
HOOK_EVENTS = {
670-
# 'any.event.name': 'App.Model.Action' (created/updated/deleted)
671-
# If you want a Hook to be triggered for all users, add '+' to built-in Hooks.
672-
"request.added": "workflow.Request.created+",
673-
"request.updated": "workflow.Request.updated+",
674-
"request_comment.added": "workflow.RequestComment.created+",
675-
"user.added_or_updated": None,
676-
"user.locked_out": None,
677-
"vulnerability.data_update": None,
678-
}
679-
# Provide context variables to the `Webhook` values such as `extra_headers`.
680-
HOOK_ENV = env.dict("HOOK_ENV", default={})
665+
# Provide context variables to WebhookSubscription extra_headers template values.
666+
# HOOK_ENV is the legacy name, kept for backward compatibility.
667+
_legacy_hook_env = env.dict("HOOK_ENV", default={})
668+
DEJACODE_WEBHOOK_ENV = env.dict("DEJACODE_WEBHOOK_ENV", default=_legacy_hook_env)
681669

682670
# Django-axes
683671
# Enable or disable Axes plugin functionality

dje/management/commands/flushdataset.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
from dje.models import ExternalReference
1818
from dje.models import ExternalSource
1919
from dje.models import get_unsecured_manager
20-
from notification.models import Webhook
20+
from notification.models import WebhookDelivery
21+
from notification.models import WebhookSubscription
2122
from vulnerabilities.models import Vulnerability
2223

2324

@@ -55,7 +56,8 @@ def handle(self, *args, **options):
5556
UsagePolicy,
5657
ExternalReference,
5758
ExternalSource,
58-
Webhook,
59+
WebhookDelivery,
60+
WebhookSubscription,
5961
Vulnerability,
6062
]
6163
)

dje/notification.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from dje.models import History
2222
from dje.tasks import send_mail_task
2323
from dje.tasks import send_mail_to_admins_task
24-
from notification.models import find_and_fire_hook
24+
from notification.models import fire_webhooks
2525

2626
ADDITION = History.ADDITION
2727
CHANGE = History.CHANGE
@@ -228,7 +228,7 @@ def notify_on_user_locked_out(request, username, **kwargs):
228228
if not reference_dataspace:
229229
return
230230

231-
find_and_fire_hook(
231+
fire_webhooks(
232232
"user.locked_out",
233233
instance=None,
234234
dataspace=reference_dataspace,
@@ -248,7 +248,7 @@ def notify_on_user_added_or_updated(instance, **kwargs):
248248
if not reference_dataspace:
249249
return
250250

251-
find_and_fire_hook(
251+
fire_webhooks(
252252
"user.added_or_updated",
253253
instance=instance,
254254
dataspace=reference_dataspace,

dje/tests/test_access.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
from dje.tests import create_user
4242
from dje.tests import refresh_url_cache
4343
from license_library.models import License
44-
from notification.models import Webhook
44+
from notification.models import WebhookSubscription
4545
from product_portfolio.models import Product
4646

4747

@@ -439,14 +439,14 @@ def test_user_locked_out_on_unsuccessful_login_attempts(self):
439439
attempt = AccessAttempt.objects.get(username=credentials["username"])
440440
self.assertEqual(2, attempt.failures_since_start)
441441

442-
@mock.patch("requests.Session.post", autospec=True)
442+
@mock.patch("requests.post")
443443
def test_notification_on_unsuccessful_login_attempts(self, method_mock):
444+
method_mock.return_value = None
444445
user = create_user(username="real_user", dataspace=self.dataspace)
445446
extra_payload = {"username": "DejaCode Webhook"}
446-
Webhook.objects.create(
447+
WebhookSubscription.objects.create(
447448
dataspace=self.dataspace,
448-
target="http://127.0.0.1:8000/",
449-
user=user,
449+
target_url="http://127.0.0.1:8000/",
450450
event="user.locked_out",
451451
extra_payload=extra_payload,
452452
)

etc/scripts/build_deb_docker.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,7 @@ def build_deb_with_docker():
5959
dependencies = project.get("dependencies", [])
6060

6161
filtered_dependencies = [
62-
dep
63-
for dep in dependencies
64-
if "django-rest-hooks" not in dep and "django_notifications_patched" not in dep
62+
dep for dep in dependencies if "django_notifications_patched" not in dep
6563
]
6664

6765
docker_cmd = [
@@ -98,7 +96,6 @@ def build_deb_with_docker():
9896
rm -rf build/
9997
10098
# Install non-PyPI dependencies
101-
pip install https://github.com/aboutcode-org/django-rest-hooks/releases/download/1.6.1/django_rest_hooks-1.6.1-py2.py3-none-any.whl
10299
pip install https://github.com/dejacode/django-notifications-patched/archive/refs/tags/2.0.0.tar.gz
103100
104101
# Install dependencies directly

etc/scripts/build_nix_docker.py

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -242,21 +242,9 @@ def create_defualt_nix(dependencies_list, meta_dict):
242242
print("Processing {}/{}: {}".format(idx + 1, deps_size, dep["name"]))
243243
name = dep["name"]
244244
version = dep["version"]
245-
# Handle 'django_notifications_patched' and 'django-rest-hooks' seperately
246-
if name == "django-rest-hooks" or name == "django_notifications_patched":
247-
if name == "django-rest-hooks" and version == "1.6.1":
248-
nix_content += " " + name + " = python.pkgs.buildPythonPackage {\n"
249-
nix_content += ' pname = "django-rest-hooks";\n'
250-
nix_content += ' version = "1.6.1";\n'
251-
nix_content += ' format = "wheel";\n'
252-
nix_content += " src = pkgs.fetchurl {\n"
253-
nix_content += ' url = "https://github.com/aboutcode-org/django-rest-hooks/releases/download/1.6.1/django_rest_hooks-1.6.1-py2.py3-none-any.whl";\n'
254-
nix_content += (
255-
' sha256 = "1byakq3ghpqhm0mjjkh8v5y6g3wlnri2vvfifyi9ky36l12vqx74";\n'
256-
)
257-
nix_content += " };\n"
258-
nix_content += " };\n"
259-
elif name == "django_notifications_patched" and version == "2.0.0":
245+
# Handle 'django_notifications_patched' seperately
246+
if name == "django_notifications_patched":
247+
if name == "django_notifications_patched" and version == "2.0.0":
260248
nix_content += " " + name + " = self.buildPythonPackage rec {\n"
261249
nix_content += ' pname = "django_notifications_patched";\n'
262250
nix_content += ' version = "2.0.0";\n'

0 commit comments

Comments
 (0)