From 3297d2d73ebe8f0a679a210be262e4eb8fd2f572 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 30 Jun 2026 18:20:59 +0400 Subject: [PATCH 01/14] add aboutcode app for notifications Signed-off-by: tdruez --- aboutcode/notification/__init__.py | 14 +++ aboutcode/notification/models.py | 158 +++++++++++++++++++++++++++++ notification/models.py | 80 +++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 aboutcode/notification/__init__.py create mode 100644 aboutcode/notification/models.py diff --git a/aboutcode/notification/__init__.py b/aboutcode/notification/__init__.py new file mode 100644 index 00000000..c574209b --- /dev/null +++ b/aboutcode/notification/__init__.py @@ -0,0 +1,14 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +from aboutcode.notification.models import AbstractWebhookSubscription +from aboutcode.notification.models import AbstractWebhookDelivery + +__version__ = "0.1.0" + +__all__ = ["AbstractWebhookSubscription", "AbstractWebhookDelivery"] diff --git a/aboutcode/notification/models.py b/aboutcode/notification/models.py new file mode 100644 index 00000000..ab3046fe --- /dev/null +++ b/aboutcode/notification/models.py @@ -0,0 +1,158 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +import json +import logging +from urllib.parse import urlparse + +from django.core.serializers.json import DjangoJSONEncoder +from django.db import models +from django.utils.translation import gettext_lazy as _ + +import requests + +logger = logging.getLogger(__name__) + + +class AbstractWebhookSubscription(models.Model): + """ + Abstract base for Webhook subscription models. + + Subclasses must implement get_payload(context) and create_delivery(payload, context). + Override get_slack_payload(context) to support Slack webhook URLs. + """ + + target_url = models.URLField( + _("Target URL"), + max_length=1024, + blank=False, + help_text=_( + "The URL to which the POST request will be sent when the Webhook is triggered." + ), + ) + is_active = models.BooleanField( + default=True, + help_text=_( + "Indicates whether the Webhook is currently active and should be triggered." + ), + ) + created_date = models.DateTimeField( + auto_now_add=True, + editable=False, + help_text=_("The date and time when the Webhook subscription was created."), + ) + + class Meta: + abstract = True + ordering = ["-created_date"] + + def get_payload(self, context): + raise NotImplementedError + + def get_slack_payload(self, context): + """Return a Slack-specific payload, or None to fall back to get_payload.""" + return None + + def create_delivery(self, payload, context): + raise NotImplementedError + + def deliver(self, context, timeout=10): + """Deliver this Webhook by sending a POST request to the target_url.""" + logger.info(f"Delivering Webhook {self.uuid}") + + if not self.is_active: + logger.info(f"Webhook {self.uuid} is not active.") + return False + + parsed = urlparse(self.target_url) + if parsed.hostname == "hooks.slack.com" and ( + slack_payload := self.get_slack_payload(context) + ): + payload = slack_payload + else: + payload = self.get_payload(context) + + delivery = self.create_delivery(payload, context) + + try: + response = requests.post( + url=self.target_url, + data=json.dumps(payload, cls=DjangoJSONEncoder), + headers={"Content-Type": "application/json"}, + timeout=timeout, + ) + except requests.exceptions.RequestException as exception: + logger.error(exception) + delivery.delivery_error = str(exception) + delivery.save() + return delivery + + delivery.response_status_code = response.status_code + delivery.response_text = response.text + delivery.save() + + if delivery.success: + logger.info(f"Webhook {self.uuid} delivered successfully.") + else: + logger.info(f"Webhook {self.uuid} returned a {response.status_code}.") + + return delivery + + +class AbstractWebhookDelivery(models.Model): + """Abstract base for Webhook delivery history models.""" + + target_url = models.URLField( + _("Target URL"), + max_length=1024, + blank=False, + help_text=_( + "Stores a copy of the Webhook target URL in case the subscription object " + "is deleted." + ), + ) + sent_date = models.DateTimeField( + auto_now_add=True, + editable=False, + help_text=_("The date and time when the Webhook was sent."), + ) + payload = models.JSONField( + blank=True, + default=dict, + help_text=_("The JSON payload that was sent to the target URL."), + ) + response_status_code = models.PositiveIntegerField( + null=True, + blank=True, + help_text=_("The HTTP status code received in response to the Webhook request."), + ) + response_text = models.TextField( + blank=True, + help_text=_("The text response received from the target URL."), + ) + delivery_error = models.TextField( + blank=True, + help_text=_("Any error messages encountered during the Webhook delivery."), + ) + + class Meta: + abstract = True + verbose_name = _("webhook delivery") + verbose_name_plural = _("webhook deliveries") + ordering = ["-sent_date"] + + def __str__(self): + return f"Webhook uuid={self.uuid} posted at {self.sent_date}" + + @property + def delivered(self): + return bool(self.response_status_code) + + @property + def success(self): + return self.response_status_code in (200, 201, 202) diff --git a/notification/models.py b/notification/models.py index c0f37be8..e57c2e39 100644 --- a/notification/models.py +++ b/notification/models.py @@ -6,6 +6,8 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # +import logging + from django import template from django.conf import settings from django.db import models @@ -14,6 +16,84 @@ from rest_hooks.models import AbstractHook from dje.models import DataspacedModel +from dje.models import DataspacedQuerySet +from dje.models import HistoryFieldsMixin + +from aboutcode.notification import AbstractWebhookSubscription +from aboutcode.notification import AbstractWebhookDelivery + + +logger = logging.getLogger("dje") + + +class WebhookSubscriptionQuerySet(DataspacedQuerySet): + def active(self): + return self.filter(is_active=True) + + +class WebhookSubscription(DataspacedModel, AbstractWebhookSubscription): + """ + A model to define Webhook subscriptions. + + This model captures the necessary details to configure a Webhook, including the + target URL and the specific events that trigger the Webhook. + """ + + event = models.CharField( + max_length=64, + ) + extra_payload = models.JSONField( + blank=True, + default=dict, + help_text=_("Extra data as JSON to be included in the payload"), + ) + extra_headers = models.JSONField( + blank=True, + default=dict, + help_text=_("Extra headers as JSON to be included in the request"), + ) + + objects = WebhookSubscriptionQuerySet.as_manager() + + class Meta(AbstractWebhookSubscription.Meta): + unique_together = ("dataspace", "uuid") + + def __str__(self): + return f"{self.event} => {self.target_url}" + + def get_payload(self, instance): + return instance.serialize_hook(hook=self) + + def create_delivery(self, payload, instance): + return WebhookDelivery( + dataspace=self.dataspace, + webhook_subscription=self, + target_url=self.target_url, + payload=payload, + ) + + +class WebhookDelivery(DataspacedModel, AbstractWebhookDelivery): + """ + Stores historical data for Webhook deliveries. + + This model keeps track of each delivery attempt made by a Webhook subscription, + including the payload sent, the response received, and any errors that occurred + during the delivery process. + """ + + webhook_subscription = models.ForeignKey( + WebhookSubscription, + related_name="deliveries", + editable=False, + blank=True, + null=True, + on_delete=models.SET_NULL, + help_text=_("The Webhook subscription associated with this delivery."), + ) + + class Meta(AbstractWebhookDelivery.Meta): + unique_together = [("dataspace", "uuid")] # DataspacedModel is first as we want to apply it last for proper overrides From 12231d5f7d8f68c81b17ec7e7f8f721d931a4ffa Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 3 Jul 2026 17:42:11 +0400 Subject: [PATCH 02/14] refine model and add migrations files Signed-off-by: tdruez --- Makefile | 14 +++-- .../__init__.py | 4 +- .../{notification => notifications}/models.py | 30 ++++++---- compose.dev.yml | 1 + ...002_webhooksubscription_webhookdelivery.py | 60 +++++++++++++++++++ ..._migrate_webhook_to_webhooksubscription.py | 35 +++++++++++ notification/models.py | 43 ++++++++++--- workflow/models.py | 4 +- 8 files changed, 160 insertions(+), 31 deletions(-) rename aboutcode/{notification => notifications}/__init__.py (73%) rename aboutcode/{notification => notifications}/models.py (85%) create mode 100644 notification/migrations/0002_webhooksubscription_webhookdelivery.py create mode 100644 notification/migrations/0003_migrate_webhook_to_webhooksubscription.py diff --git a/Makefile b/Makefile index d082b8e5..91c60390 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,14 @@ run: @echo "-> Run the Docker compose services in dev mode (hot reload on code changes)" ${COMPOSE} up +start: + @echo "-> Start the Docker compose services in background" + ${COMPOSE} up -d + +# make logs TAIL=100 SERVICE=db +logs: + ${COMPOSE} logs -f --tail=${TAIL:-50} ${SERVICE} + bash: # Open a bash session in the running web container ${COMPOSE} exec web bash @@ -181,8 +189,4 @@ initdb: psql: ${DOCKER_EXEC} ${DB_CONTAINER_NAME} psql --username=${DB_USERNAME} postgres -# $ make log SERVICE=db -log: - ${DOCKER_COMPOSE} logs --tail="100" ${SERVICE} - -.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 +.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 diff --git a/aboutcode/notification/__init__.py b/aboutcode/notifications/__init__.py similarity index 73% rename from aboutcode/notification/__init__.py rename to aboutcode/notifications/__init__.py index c574209b..a932eae0 100644 --- a/aboutcode/notification/__init__.py +++ b/aboutcode/notifications/__init__.py @@ -6,8 +6,8 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # -from aboutcode.notification.models import AbstractWebhookSubscription -from aboutcode.notification.models import AbstractWebhookDelivery +from aboutcode.notifications.models import AbstractWebhookDelivery +from aboutcode.notifications.models import AbstractWebhookSubscription __version__ = "0.1.0" diff --git a/aboutcode/notification/models.py b/aboutcode/notifications/models.py similarity index 85% rename from aboutcode/notification/models.py rename to aboutcode/notifications/models.py index ab3046fe..f7c5a0b6 100644 --- a/aboutcode/notification/models.py +++ b/aboutcode/notifications/models.py @@ -37,9 +37,7 @@ class AbstractWebhookSubscription(models.Model): ) is_active = models.BooleanField( default=True, - help_text=_( - "Indicates whether the Webhook is currently active and should be triggered." - ), + help_text=_("Indicates whether the Webhook is currently active and should be triggered."), ) created_date = models.DateTimeField( auto_now_add=True, @@ -61,7 +59,11 @@ def get_slack_payload(self, context): def create_delivery(self, payload, context): raise NotImplementedError - def deliver(self, context, timeout=10): + def get_headers(self): + """Return the HTTP headers to include in the Webhook request.""" + return {"Content-Type": "application/json"} + + def deliver(self, context, timeout=10, payload_override=None): """Deliver this Webhook by sending a POST request to the target_url.""" logger.info(f"Delivering Webhook {self.uuid}") @@ -69,13 +71,16 @@ def deliver(self, context, timeout=10): logger.info(f"Webhook {self.uuid} is not active.") return False - parsed = urlparse(self.target_url) - if parsed.hostname == "hooks.slack.com" and ( - slack_payload := self.get_slack_payload(context) - ): - payload = slack_payload + if payload_override: + payload = payload_override else: - payload = self.get_payload(context) + parsed = urlparse(self.target_url) + if parsed.hostname == "hooks.slack.com" and ( + slack_payload := self.get_slack_payload(context) + ): + payload = slack_payload + else: + payload = self.get_payload(context) delivery = self.create_delivery(payload, context) @@ -83,7 +88,7 @@ def deliver(self, context, timeout=10): response = requests.post( url=self.target_url, data=json.dumps(payload, cls=DjangoJSONEncoder), - headers={"Content-Type": "application/json"}, + headers=self.get_headers(), timeout=timeout, ) except requests.exceptions.RequestException as exception: @@ -112,8 +117,7 @@ class AbstractWebhookDelivery(models.Model): max_length=1024, blank=False, help_text=_( - "Stores a copy of the Webhook target URL in case the subscription object " - "is deleted." + "Stores a copy of the Webhook target URL in case the subscription object is deleted." ), ) sent_date = models.DateTimeField( diff --git a/compose.dev.yml b/compose.dev.yml index 564c0db4..d2d7d440 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -36,6 +36,7 @@ services: - "8000:8000" volumes: - ./.env:/opt/dejacode/.env + - ./aboutcode:/opt/dejacode/aboutcode - ./component_catalog:/opt/dejacode/component_catalog - ./dejacode:/opt/dejacode/dejacode - ./dejacode_toolkit:/opt/dejacode/dejacode_toolkit diff --git a/notification/migrations/0002_webhooksubscription_webhookdelivery.py b/notification/migrations/0002_webhooksubscription_webhookdelivery.py new file mode 100644 index 00000000..bf85eccb --- /dev/null +++ b/notification/migrations/0002_webhooksubscription_webhookdelivery.py @@ -0,0 +1,60 @@ +# Generated by Django 6.0.6 on 2026-07-03 13:38 + +import django.db.models.deletion +import dje.models +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dje', '0015_alter_dataspaceconfiguration_purldb_api_key_and_more'), + ('notification', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='WebhookSubscription', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')), + ('target_url', models.URLField(help_text='The URL to which the POST request will be sent when the Webhook is triggered.', max_length=1024, verbose_name='Target URL')), + ('is_active', models.BooleanField(default=True, help_text='Indicates whether the Webhook is currently active and should be triggered.')), + ('created_date', models.DateTimeField(auto_now_add=True, help_text='The date and time when the Webhook subscription was created.')), + ('event', models.CharField(max_length=64)), + ('extra_payload', models.JSONField(blank=True, default=dict, help_text='Extra data as JSON to be included in the payload')), + ('extra_headers', models.JSONField(blank=True, default=dict, help_text='Extra headers as JSON to be included in the request')), + ('dataspace', models.ForeignKey(editable=False, help_text='A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.', on_delete=django.db.models.deletion.PROTECT, to='dje.dataspace')), + ], + options={ + 'ordering': ['-created_date'], + 'abstract': False, + 'unique_together': {('dataspace', 'uuid')}, + }, + bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model), + ), + migrations.CreateModel( + name='WebhookDelivery', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')), + ('target_url', models.URLField(help_text='Stores a copy of the Webhook target URL in case the subscription object is deleted.', max_length=1024, verbose_name='Target URL')), + ('sent_date', models.DateTimeField(auto_now_add=True, help_text='The date and time when the Webhook was sent.')), + ('payload', models.JSONField(blank=True, default=dict, help_text='The JSON payload that was sent to the target URL.')), + ('response_status_code', models.PositiveIntegerField(blank=True, help_text='The HTTP status code received in response to the Webhook request.', null=True)), + ('response_text', models.TextField(blank=True, help_text='The text response received from the target URL.')), + ('delivery_error', models.TextField(blank=True, help_text='Any error messages encountered during the Webhook delivery.')), + ('dataspace', models.ForeignKey(editable=False, help_text='A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.', on_delete=django.db.models.deletion.PROTECT, to='dje.dataspace')), + ('webhook_subscription', models.ForeignKey(blank=True, editable=False, help_text='The Webhook subscription associated with this delivery.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deliveries', to='notification.webhooksubscription')), + ], + options={ + 'verbose_name': 'webhook delivery', + 'verbose_name_plural': 'webhook deliveries', + 'ordering': ['-sent_date'], + 'abstract': False, + 'unique_together': {('dataspace', 'uuid')}, + }, + bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model), + ), + ] diff --git a/notification/migrations/0003_migrate_webhook_to_webhooksubscription.py b/notification/migrations/0003_migrate_webhook_to_webhooksubscription.py new file mode 100644 index 00000000..fcbcb51a --- /dev/null +++ b/notification/migrations/0003_migrate_webhook_to_webhooksubscription.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0.6 on 2026-07-03 13:38 + +from django.db import migrations + + +def migrate_webhooks_to_subscriptions(apps, schema_editor): + Webhook = apps.get_model("notification", "Webhook") + WebhookSubscription = apps.get_model("notification", "WebhookSubscription") + + subscriptions = [ + WebhookSubscription( + dataspace=webhook.dataspace, + target_url=webhook.target, + is_active=webhook.is_active, + event=webhook.event, + extra_payload=webhook.extra_payload, + extra_headers=webhook.extra_headers, + ) + for webhook in Webhook.objects.select_related("dataspace").iterator() + ] + + WebhookSubscription.objects.bulk_create(subscriptions) + + +class Migration(migrations.Migration): + dependencies = [ + ("notification", "0002_webhooksubscription_webhookdelivery"), + ] + + operations = [ + migrations.RunPython( + migrate_webhooks_to_subscriptions, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/notification/models.py b/notification/models.py index e57c2e39..2958a400 100644 --- a/notification/models.py +++ b/notification/models.py @@ -15,13 +15,10 @@ from rest_hooks.models import AbstractHook +from aboutcode.notifications import AbstractWebhookDelivery +from aboutcode.notifications import AbstractWebhookSubscription from dje.models import DataspacedModel from dje.models import DataspacedQuerySet -from dje.models import HistoryFieldsMixin - -from aboutcode.notification import AbstractWebhookSubscription -from aboutcode.notification import AbstractWebhookDelivery - logger = logging.getLogger("dje") @@ -61,8 +58,36 @@ class Meta(AbstractWebhookSubscription.Meta): def __str__(self): return f"{self.event} => {self.target_url}" + def dict(self): + return {"uuid": str(self.uuid), "event": self.event, "target": self.target_url} + + def get_extra_headers(self): + """Inject `hook_env` context in headers template values.""" + if hook_env := settings.HOOK_ENV: + hook_env_context = template.Context(hook_env) + return { + key: self.render_template(value, hook_env_context) + for key, value in self.extra_headers.items() + } + return self.extra_headers + + @staticmethod + def render_template(value, hook_env_context): + if "{{" in value and "}}" in value: + return template.Template(value).render(hook_env_context) + return value + + def get_headers(self): + headers = {"Content-Type": "application/json"} + if self.extra_headers: + headers.update(self.get_extra_headers()) + return headers + def get_payload(self, instance): - return instance.serialize_hook(hook=self) + payload = instance.serialize_hook(hook=self) + if self.extra_payload: + payload.update(self.extra_payload) + return payload def create_delivery(self, payload, instance): return WebhookDelivery( @@ -158,7 +183,7 @@ def find_and_fire_hook( "is_active": True, } - hooks = Webhook.objects.scope(dataspace).filter(**filters) + webhooks = WebhookSubscription.objects.scope(dataspace).filter(**filters) - for hook in hooks: - hook.deliver_hook(instance, payload_override) + for webhook in webhooks: + webhook.deliver(instance, payload_override=payload_override) diff --git a/workflow/models.py b/workflow/models.py index b861cba0..cf626d43 100644 --- a/workflow/models.py +++ b/workflow/models.py @@ -589,7 +589,7 @@ def get_involved_users(self, exclude=None): return users def serialize_hook(self, hook): - if "hooks.slack.com" in hook.target: + if "hooks.slack.com" in hook.target_url: return request_slack_payload(self, created="added" in hook.event) from workflow.api import RequestSerializer @@ -789,7 +789,7 @@ def as_html(self): return mark_safe(html) def serialize_hook(self, hook): - if "hooks.slack.com" in hook.target: + if "hooks.slack.com" in hook.target_url: return request_comment_slack_payload(self) from workflow.api import RequestCommentSerializer From cdf17a4d87389fc5ff1ceb0488373f566dcff2d8 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 3 Jul 2026 17:51:24 +0400 Subject: [PATCH 03/14] remove the legacy Webhook model Signed-off-by: tdruez --- dejacode/settings.py | 3 +- dje/management/commands/flushdataset.py | 6 +- dje/tests/test_access.py | 9 +- etc/scripts/build_deb_docker.py | 3 +- etc/scripts/build_nix_docker.py | 18 +-- etc/scripts/build_rpm_docker.py | 4 +- notification/admin.py | 20 ++-- .../migrations/0004_delete_webhook.py | 16 +++ notification/models.py | 48 +------- notification/tests/test_models.py | 33 +++--- notification/tests/test_tasks.py | 108 ++++++++---------- 11 files changed, 109 insertions(+), 159 deletions(-) create mode 100644 notification/migrations/0004_delete_webhook.py diff --git a/dejacode/settings.py b/dejacode/settings.py index 2bc493bf..c255c023 100644 --- a/dejacode/settings.py +++ b/dejacode/settings.py @@ -665,7 +665,6 @@ def get_fake_redis_connection(config, use_strict_redis): # https://github.com/zapier/django-rest-hooks HOOK_FINDER = "notification.models.find_and_fire_hook" -HOOK_DELIVERER = "notification.tasks.deliver_hook_wrapper" HOOK_EVENTS = { # 'any.event.name': 'App.Model.Action' (created/updated/deleted) # If you want a Hook to be triggered for all users, add '+' to built-in Hooks. @@ -676,7 +675,7 @@ def get_fake_redis_connection(config, use_strict_redis): "user.locked_out": None, "vulnerability.data_update": None, } -# Provide context variables to the `Webhook` values such as `extra_headers`. +# Provide context variables to WebhookSubscription extra_headers template values. HOOK_ENV = env.dict("HOOK_ENV", default={}) # Django-axes diff --git a/dje/management/commands/flushdataset.py b/dje/management/commands/flushdataset.py index c20d3345..a517313f 100644 --- a/dje/management/commands/flushdataset.py +++ b/dje/management/commands/flushdataset.py @@ -17,7 +17,8 @@ from dje.models import ExternalReference from dje.models import ExternalSource from dje.models import get_unsecured_manager -from notification.models import Webhook +from notification.models import WebhookDelivery +from notification.models import WebhookSubscription from vulnerabilities.models import Vulnerability @@ -55,7 +56,8 @@ def handle(self, *args, **options): UsagePolicy, ExternalReference, ExternalSource, - Webhook, + WebhookDelivery, + WebhookSubscription, Vulnerability, ] ) diff --git a/dje/tests/test_access.py b/dje/tests/test_access.py index c1138880..835fef14 100644 --- a/dje/tests/test_access.py +++ b/dje/tests/test_access.py @@ -41,7 +41,7 @@ from dje.tests import create_user from dje.tests import refresh_url_cache from license_library.models import License -from notification.models import Webhook +from notification.models import WebhookSubscription from product_portfolio.models import Product @@ -439,14 +439,13 @@ def test_user_locked_out_on_unsuccessful_login_attempts(self): attempt = AccessAttempt.objects.get(username=credentials["username"]) self.assertEqual(2, attempt.failures_since_start) - @mock.patch("requests.Session.post", autospec=True) + @mock.patch("requests.post") def test_notification_on_unsuccessful_login_attempts(self, method_mock): user = create_user(username="real_user", dataspace=self.dataspace) extra_payload = {"username": "DejaCode Webhook"} - Webhook.objects.create( + WebhookSubscription.objects.create( dataspace=self.dataspace, - target="http://127.0.0.1:8000/", - user=user, + target_url="http://127.0.0.1:8000/", event="user.locked_out", extra_payload=extra_payload, ) diff --git a/etc/scripts/build_deb_docker.py b/etc/scripts/build_deb_docker.py index 004969d0..9b1dde7d 100644 --- a/etc/scripts/build_deb_docker.py +++ b/etc/scripts/build_deb_docker.py @@ -61,7 +61,7 @@ def build_deb_with_docker(): filtered_dependencies = [ dep for dep in dependencies - if "django-rest-hooks" not in dep and "django_notifications_patched" not in dep + if "django_notifications_patched" not in dep ] docker_cmd = [ @@ -98,7 +98,6 @@ def build_deb_with_docker(): rm -rf build/ # Install non-PyPI dependencies - 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 pip install https://github.com/dejacode/django-notifications-patched/archive/refs/tags/2.0.0.tar.gz # Install dependencies directly diff --git a/etc/scripts/build_nix_docker.py b/etc/scripts/build_nix_docker.py index 1d842de8..76cab467 100644 --- a/etc/scripts/build_nix_docker.py +++ b/etc/scripts/build_nix_docker.py @@ -242,21 +242,9 @@ def create_defualt_nix(dependencies_list, meta_dict): print("Processing {}/{}: {}".format(idx + 1, deps_size, dep["name"])) name = dep["name"] version = dep["version"] - # Handle 'django_notifications_patched' and 'django-rest-hooks' seperately - if name == "django-rest-hooks" or name == "django_notifications_patched": - if name == "django-rest-hooks" and version == "1.6.1": - nix_content += " " + name + " = python.pkgs.buildPythonPackage {\n" - nix_content += ' pname = "django-rest-hooks";\n' - nix_content += ' version = "1.6.1";\n' - nix_content += ' format = "wheel";\n' - nix_content += " src = pkgs.fetchurl {\n" - 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' - nix_content += ( - ' sha256 = "1byakq3ghpqhm0mjjkh8v5y6g3wlnri2vvfifyi9ky36l12vqx74";\n' - ) - nix_content += " };\n" - nix_content += " };\n" - elif name == "django_notifications_patched" and version == "2.0.0": + # Handle 'django_notifications_patched' seperately + if name == "django_notifications_patched": + if name == "django_notifications_patched" and version == "2.0.0": nix_content += " " + name + " = self.buildPythonPackage rec {\n" nix_content += ' pname = "django_notifications_patched";\n' nix_content += ' version = "2.0.0";\n' diff --git a/etc/scripts/build_rpm_docker.py b/etc/scripts/build_rpm_docker.py index 235e8309..cc9c019e 100644 --- a/etc/scripts/build_rpm_docker.py +++ b/etc/scripts/build_rpm_docker.py @@ -61,7 +61,7 @@ def build_rpm_with_docker(): filtered_dependencies = [ dep for dep in dependencies - if "django-rest-hooks" not in dep and "django-notifications-patched" not in dep + if "django-notifications-patched" not in dep ] # Create a requirements.txt content for installation @@ -189,8 +189,6 @@ def build_rpm_with_docker(): dnf install -y postgresql-devel # Install non-PyPI dependencies -/tmp/venv_build/bin/python -m 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 /tmp/venv_build/bin/python -m pip install \\ https://github.com/dejacode/django-notifications-patched/archive/refs/tags/2.0.0.tar.gz diff --git a/notification/admin.py b/notification/admin.py index c3111c1d..e4217798 100644 --- a/notification/admin.py +++ b/notification/admin.py @@ -15,20 +15,20 @@ from dje.admin import ProhibitDataspaceLookupMixin from dje.admin import dejacode_site from dje.forms import DataspacedAdminForm -from notification.models import Webhook +from notification.models import WebhookSubscription HOOK_EVENTS = settings.HOOK_EVENTS if HOOK_EVENTS is None: raise ImproperlyConfigured("settings.HOOK_EVENTS is not defined") -class WebookForm(DataspacedAdminForm): +class WebhookSubscriptionForm(DataspacedAdminForm): EVENTS = [(event, event) for event in HOOK_EVENTS.keys()] class Meta: - model = Webhook + model = WebhookSubscription fields = [ - "target", + "target_url", "event", "is_active", "extra_payload", @@ -39,15 +39,11 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["event"] = forms.ChoiceField(choices=self.EVENTS) - add = not kwargs.get("instance") - if add: - self.instance.user = self.request.user - -@admin.register(Webhook, site=dejacode_site) -class WebookAdmin(ProhibitDataspaceLookupMixin, DataspacedAdmin): - list_display = ("__str__", "event", "target", "is_active", "dataspace") - form = WebookForm +@admin.register(WebhookSubscription, site=dejacode_site) +class WebhookSubscriptionAdmin(ProhibitDataspaceLookupMixin, DataspacedAdmin): + list_display = ("__str__", "event", "target_url", "is_active", "dataspace") + form = WebhookSubscriptionForm list_filter = ("is_active", "event") activity_log = False actions = [] diff --git a/notification/migrations/0004_delete_webhook.py b/notification/migrations/0004_delete_webhook.py new file mode 100644 index 00000000..c1d61d05 --- /dev/null +++ b/notification/migrations/0004_delete_webhook.py @@ -0,0 +1,16 @@ +# Generated by Django 6.0.6 on 2026-07-03 13:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('notification', '0003_migrate_webhook_to_webhooksubscription'), + ] + + operations = [ + migrations.DeleteModel( + name='Webhook', + ), + ] diff --git a/notification/models.py b/notification/models.py index 2958a400..e6c0cfc4 100644 --- a/notification/models.py +++ b/notification/models.py @@ -13,8 +13,6 @@ from django.db import models from django.utils.translation import gettext_lazy as _ -from rest_hooks.models import AbstractHook - from aboutcode.notifications import AbstractWebhookDelivery from aboutcode.notifications import AbstractWebhookSubscription from dje.models import DataspacedModel @@ -83,6 +81,12 @@ def get_headers(self): headers.update(self.get_extra_headers()) return headers + def deliver(self, context, timeout=10, payload_override=None): + if payload_override and self.extra_payload: + payload_override = {**payload_override} + payload_override.update(self.extra_payload) + return super().deliver(context, timeout=timeout, payload_override=payload_override) + def get_payload(self, instance): payload = instance.serialize_hook(hook=self) if self.extra_payload: @@ -121,46 +125,6 @@ class Meta(AbstractWebhookDelivery.Meta): unique_together = [("dataspace", "uuid")] -# DataspacedModel is first as we want to apply it last for proper overrides -class Webhook(DataspacedModel, AbstractHook): - is_active = models.BooleanField(default=True) - extra_payload = models.JSONField( - blank=True, - default=dict, - help_text=_("Extra data as JSON to be included in the payload"), - ) - extra_headers = models.JSONField( - blank=True, - default=dict, - help_text=_("Extra headers as JSON to be included in the request"), - ) - - class Meta: - unique_together = ("dataspace", "uuid") - - def __str__(self): - return f"{self.event} => {self.target}" - - def dict(self): - return {"uuid": str(self.uuid), "event": self.event, "target": self.target} - - def get_extra_headers(self): - """Inject `hook_env` context in headers template values.""" - if hook_env := settings.HOOK_ENV: - hook_env_context = template.Context(hook_env) - return { - key: self.render_template(value, hook_env_context) - for key, value in self.extra_headers.items() - } - - return self.extra_headers - - @staticmethod - def render_template(value, hook_env_context): - if "{{" in value and "}}" in value: - return template.Template(value).render(hook_env_context) - return value - def find_and_fire_hook( event_name, diff --git a/notification/tests/test_models.py b/notification/tests/test_models.py index 5c63a4b8..fcdf73d0 100644 --- a/notification/tests/test_models.py +++ b/notification/tests/test_models.py @@ -11,39 +11,38 @@ from dje.models import Dataspace from dje.tests import create_superuser -from notification.models import Webhook +from notification.models import WebhookSubscription -class NotificationModelsTestCase(TestCase): +class WebhookSubscriptionModelTestCase(TestCase): def setUp(self): self.nexb_dataspace = Dataspace.objects.create(name="nexB") self.nexb_user = create_superuser("nexb_user", self.nexb_dataspace) - self.webhook1 = Webhook.objects.create( + self.webhook = WebhookSubscription.objects.create( dataspace=self.nexb_dataspace, - target="http://1.2.3.4/", - user=self.nexb_user, + target_url="http://1.2.3.4/", event="request.added", ) - def test_notification_webhook_model_str(self): - self.assertEqual("request.added => http://1.2.3.4/", str(self.webhook1)) + def test_webhook_subscription_str(self): + self.assertEqual("request.added => http://1.2.3.4/", str(self.webhook)) - def test_notification_webhook_model_dict(self): + def test_webhook_subscription_dict(self): expected = { - "uuid": str(self.webhook1.uuid), - "event": self.webhook1.event, - "target": self.webhook1.target, + "uuid": str(self.webhook.uuid), + "event": self.webhook.event, + "target": self.webhook.target_url, } - self.assertEqual(expected, self.webhook1.dict()) + self.assertEqual(expected, self.webhook.dict()) - def test_notification_webhook_model_get_extra_headers(self): - self.webhook1.extra_headers = {"Header": "{{ENV_VALUE}}"} - self.webhook1.save() + def test_webhook_subscription_get_extra_headers(self): + self.webhook.extra_headers = {"Header": "{{ENV_VALUE}}"} + self.webhook.save() expected = {"Header": "{{ENV_VALUE}}"} - self.assertEqual(expected, self.webhook1.get_extra_headers()) + self.assertEqual(expected, self.webhook.get_extra_headers()) expected = {"Header": "some_value"} with override_settings(HOOK_ENV={"ENV_VALUE": "some_value"}): - self.assertEqual(expected, self.webhook1.get_extra_headers()) + self.assertEqual(expected, self.webhook.get_extra_headers()) diff --git a/notification/tests/test_tasks.py b/notification/tests/test_tasks.py index e46c1581..6ee03ad3 100644 --- a/notification/tests/test_tasks.py +++ b/notification/tests/test_tasks.py @@ -15,8 +15,7 @@ from dje.models import Dataspace from dje.tests import create_superuser -from notification.models import Webhook -from notification.tasks import deliver_hook_wrapper +from notification.models import WebhookSubscription from workflow.models import Priority from workflow.models import Question from workflow.models import Request @@ -52,16 +51,18 @@ def setUp(self): self.priority1 = Priority.objects.create(label="Urgent", dataspace=self.nexb_dataspace) - @patch("requests.Session.post", autospec=True) + @patch("requests.post") def test_notification_task_on_workflow_request_creation_generic_url(self, method_mock): method_mock.return_value = None self.client.login(username="nexb_user", password="secret") url = self.request_template1.get_absolute_url() - target = "http://127.0.0.1:8000/" - webhook = Webhook.objects.create( - dataspace=self.nexb_dataspace, target=target, user=self.nexb_user, event="request.added" + target_url = "http://127.0.0.1:8000/" + webhook = WebhookSubscription.objects.create( + dataspace=self.nexb_dataspace, + target_url=target_url, + event="request.added", ) data = { @@ -78,7 +79,7 @@ def test_notification_task_on_workflow_request_creation_generic_url(self, method expected = { "uuid": str(webhook.uuid), "event": "request.added", - "target": target, + "target": target_url, } self.assertEqual(expected, results["hook"]) data = results["data"] @@ -88,7 +89,7 @@ def test_notification_task_on_workflow_request_creation_generic_url(self, method self.assertEqual("open", data["status"]) self.assertEqual(self.nexb_user.username, data["assignee"]) - @patch("requests.Session.post", autospec=True) + @patch("requests.post") def test_notification_task_on_workflow_request_creation_slack_url(self, method_mock): method_mock.return_value = None @@ -96,9 +97,11 @@ def test_notification_task_on_workflow_request_creation_slack_url(self, method_m url = self.request_template1.get_absolute_url() site_url = settings.SITE_URL.rstrip("/") - target = "https://hooks.slack.com" - Webhook.objects.create( - dataspace=self.nexb_dataspace, target=target, user=self.nexb_user, event="request.added" + target_url = "https://hooks.slack.com" + WebhookSubscription.objects.create( + dataspace=self.nexb_dataspace, + target_url=target_url, + event="request.added", ) data = { @@ -130,17 +133,16 @@ def test_notification_task_on_workflow_request_creation_slack_url(self, method_m self.assertEqual(expected, results["attachments"]) - @patch("requests.Session.post", autospec=True) + @patch("requests.post") def test_notification_task_on_workflow_request_edition_generic_url(self, method_mock): method_mock.return_value = None self.client.login(username="nexb_user", password="secret") - target = "http://127.0.0.1:8000/" - webhook = Webhook.objects.create( + target_url = "http://127.0.0.1:8000/" + webhook = WebhookSubscription.objects.create( dataspace=self.nexb_dataspace, - target=target, - user=self.nexb_user, + target_url=target_url, event="request.updated", ) @@ -161,7 +163,7 @@ def test_notification_task_on_workflow_request_edition_generic_url(self, method_ expected = { "uuid": str(webhook.uuid), "event": "request.updated", - "target": target, + "target": target_url, } self.assertEqual(expected, results["hook"]) data = results["data"] @@ -171,18 +173,17 @@ def test_notification_task_on_workflow_request_edition_generic_url(self, method_ self.assertEqual("open", data["status"]) self.assertEqual(self.nexb_user.username, data["assignee"]) - @patch("requests.Session.post", autospec=True) + @patch("requests.post") def test_notification_task_on_workflow_request_edition_slack_url(self, method_mock): method_mock.return_value = None self.client.login(username="nexb_user", password="secret") site_url = settings.SITE_URL.rstrip("/") - target = "https://hooks.slack.com" - Webhook.objects.create( + target_url = "https://hooks.slack.com" + WebhookSubscription.objects.create( dataspace=self.nexb_dataspace, - target=target, - user=self.nexb_user, + target_url=target_url, event="request.updated", ) @@ -219,7 +220,7 @@ def test_notification_task_on_workflow_request_edition_slack_url(self, method_mo self.assertEqual(expected, results["attachments"]) - @patch("requests.Session.post", autospec=True) + @patch("requests.post") def test_notification_task_on_workflow_request_add_comment_generic_url(self, method_mock): method_mock.return_value = None @@ -233,11 +234,10 @@ def test_notification_task_on_workflow_request_add_comment_generic_url(self, met ) url = request1.get_absolute_url() - target = "http://127.0.0.1:8000/" - webhook = Webhook.objects.create( + target_url = "http://127.0.0.1:8000/" + webhook = WebhookSubscription.objects.create( dataspace=self.nexb_dataspace, - target=target, - user=self.nexb_user, + target_url=target_url, event="request_comment.added", ) @@ -250,7 +250,7 @@ def test_notification_task_on_workflow_request_add_comment_generic_url(self, met expected = { "uuid": str(webhook.uuid), "event": "request_comment.added", - "target": target, + "target": target_url, } self.assertEqual(expected, results["hook"]) data = results["data"] @@ -258,7 +258,7 @@ def test_notification_task_on_workflow_request_add_comment_generic_url(self, met self.assertEqual("nexb_user", data["user"]) self.assertEqual("A comment content", data["text"]) - @patch("requests.Session.post", autospec=True) + @patch("requests.post") def test_notification_task_on_workflow_request_add_comment_slack_url(self, method_mock): method_mock.return_value = None @@ -273,11 +273,10 @@ def test_notification_task_on_workflow_request_add_comment_slack_url(self, metho url = request1.get_absolute_url() site_url = settings.SITE_URL.rstrip("/") - target = "https://hooks.slack.com" - Webhook.objects.create( + target_url = "https://hooks.slack.com" + WebhookSubscription.objects.create( dataspace=self.nexb_dataspace, - target=target, - user=self.nexb_user, + target_url=target_url, event="request_comment.added", ) @@ -305,38 +304,29 @@ def test_notification_task_on_workflow_request_add_comment_slack_url(self, metho self.assertEqual(expected, results["attachments"]) - @patch("requests.Session.post", autospec=True) - def test_notification_task_deliver_hook_task_extra_payload(self, method_mock): + @patch("requests.post") + def test_notification_webhook_extra_payload_merged_into_payload(self, method_mock): method_mock.return_value = None - target = "https://localhost" - base_payload = { - "key1": "base1", - "key2": "base2", - } - extra_payload = { - "key2": "extra2", - "key3": "extra3", - } - webhook = Webhook.objects.create( + self.client.login(username="nexb_user", password="secret") + url = self.request_template1.get_absolute_url() + + extra_payload = {"custom_key": "custom_value", "title": "overridden_title"} + WebhookSubscription.objects.create( dataspace=self.nexb_dataspace, - target=target, - user=self.nexb_user, + target_url="https://localhost", event="request.added", extra_payload=extra_payload, ) - deliver_hook_wrapper( - target=webhook.target, - payload=base_payload, - instance=None, - hook=webhook, - ) + data = { + "title": "Title", + "field_0": "Value for field_0", + "content_type": self.component_ct.id, + "assignee": self.nexb_user.id, + } + self.client.post(url, data) results = json.loads(method_mock.call_args_list[0][1]["data"]) - expected = { - "key1": "base1", - "key2": "extra2", # extra_payload always overrides the base_payload - "key3": "extra3", - } - self.assertEqual(expected, results) + self.assertEqual("custom_value", results["data"]["custom_key"]) + self.assertEqual("overridden_title", results["data"]["title"]) From 4cceb9bbc91b3cba73187091fa55d4e113967ebb Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 3 Jul 2026 18:23:20 +0400 Subject: [PATCH 04/14] remove rest hook Signed-off-by: tdruez --- dejacode/settings.py | 21 ++---- dje/notification.py | 6 +- dje/tests/test_access.py | 1 + etc/scripts/build_deb_docker.py | 4 +- etc/scripts/build_rpm_docker.py | 4 +- notification/admin.py | 7 +- notification/apps.py | 18 +++++ notification/models.py | 19 +++-- notification/tasks.py | 70 +++++++----------- pyproject.toml | 2 - ...ango_rest_hooks-1.6.1-py2.py3-none-any.whl | Bin 14171 -> 0 bytes ...est_hooks-1.6.1-py2.py3-none-any.whl.ABOUT | 16 ---- uv.lock | 18 +---- vulnerabilities/fetch.py | 4 +- vulnerabilities/tests/test_fetch.py | 2 +- 15 files changed, 79 insertions(+), 113 deletions(-) delete mode 100644 thirdparty/dist/django_rest_hooks-1.6.1-py2.py3-none-any.whl delete mode 100644 thirdparty/dist/django_rest_hooks-1.6.1-py2.py3-none-any.whl.ABOUT diff --git a/dejacode/settings.py b/dejacode/settings.py index c255c023..4759c91f 100644 --- a/dejacode/settings.py +++ b/dejacode/settings.py @@ -327,7 +327,6 @@ def gettext_noop(s): "crispy_bootstrap5", "guardian", "django_filters", - "rest_hooks", "notifications", "axes", "django_otp", @@ -663,18 +662,14 @@ def get_fake_redis_connection(config, use_strict_redis): # django-altcha ALTCHA_HMAC_KEY = env.str("DEJACODE_ALTCHA_HMAC_KEY", default="") -# https://github.com/zapier/django-rest-hooks -HOOK_FINDER = "notification.models.find_and_fire_hook" -HOOK_EVENTS = { - # 'any.event.name': 'App.Model.Action' (created/updated/deleted) - # If you want a Hook to be triggered for all users, add '+' to built-in Hooks. - "request.added": "workflow.Request.created+", - "request.updated": "workflow.Request.updated+", - "request_comment.added": "workflow.RequestComment.created+", - "user.added_or_updated": None, - "user.locked_out": None, - "vulnerability.data_update": None, -} +WEBHOOK_EVENTS = [ + "request.added", + "request.updated", + "request_comment.added", + "user.added_or_updated", + "user.locked_out", + "vulnerability.data_update", +] # Provide context variables to WebhookSubscription extra_headers template values. HOOK_ENV = env.dict("HOOK_ENV", default={}) diff --git a/dje/notification.py b/dje/notification.py index 4371ca4a..c0458f62 100644 --- a/dje/notification.py +++ b/dje/notification.py @@ -21,7 +21,7 @@ from dje.models import History from dje.tasks import send_mail_task from dje.tasks import send_mail_to_admins_task -from notification.models import find_and_fire_hook +from notification.models import fire_webhooks ADDITION = History.ADDITION CHANGE = History.CHANGE @@ -228,7 +228,7 @@ def notify_on_user_locked_out(request, username, **kwargs): if not reference_dataspace: return - find_and_fire_hook( + fire_webhooks( "user.locked_out", instance=None, dataspace=reference_dataspace, @@ -248,7 +248,7 @@ def notify_on_user_added_or_updated(instance, **kwargs): if not reference_dataspace: return - find_and_fire_hook( + fire_webhooks( "user.added_or_updated", instance=instance, dataspace=reference_dataspace, diff --git a/dje/tests/test_access.py b/dje/tests/test_access.py index 835fef14..b1f7a4db 100644 --- a/dje/tests/test_access.py +++ b/dje/tests/test_access.py @@ -441,6 +441,7 @@ def test_user_locked_out_on_unsuccessful_login_attempts(self): @mock.patch("requests.post") def test_notification_on_unsuccessful_login_attempts(self, method_mock): + method_mock.return_value = None user = create_user(username="real_user", dataspace=self.dataspace) extra_payload = {"username": "DejaCode Webhook"} WebhookSubscription.objects.create( diff --git a/etc/scripts/build_deb_docker.py b/etc/scripts/build_deb_docker.py index 9b1dde7d..1ca54f6d 100644 --- a/etc/scripts/build_deb_docker.py +++ b/etc/scripts/build_deb_docker.py @@ -59,9 +59,7 @@ def build_deb_with_docker(): dependencies = project.get("dependencies", []) filtered_dependencies = [ - dep - for dep in dependencies - if "django_notifications_patched" not in dep + dep for dep in dependencies if "django_notifications_patched" not in dep ] docker_cmd = [ diff --git a/etc/scripts/build_rpm_docker.py b/etc/scripts/build_rpm_docker.py index cc9c019e..b264dc78 100644 --- a/etc/scripts/build_rpm_docker.py +++ b/etc/scripts/build_rpm_docker.py @@ -59,9 +59,7 @@ def build_rpm_with_docker(): dependencies = project["dependencies"] filtered_dependencies = [ - dep - for dep in dependencies - if "django-notifications-patched" not in dep + dep for dep in dependencies if "django-notifications-patched" not in dep ] # Create a requirements.txt content for installation diff --git a/notification/admin.py b/notification/admin.py index e4217798..44925e2b 100644 --- a/notification/admin.py +++ b/notification/admin.py @@ -9,7 +9,6 @@ from django import forms from django.conf import settings from django.contrib import admin -from django.core.exceptions import ImproperlyConfigured from dje.admin import DataspacedAdmin from dje.admin import ProhibitDataspaceLookupMixin @@ -17,13 +16,9 @@ from dje.forms import DataspacedAdminForm from notification.models import WebhookSubscription -HOOK_EVENTS = settings.HOOK_EVENTS -if HOOK_EVENTS is None: - raise ImproperlyConfigured("settings.HOOK_EVENTS is not defined") - class WebhookSubscriptionForm(DataspacedAdminForm): - EVENTS = [(event, event) for event in HOOK_EVENTS.keys()] + EVENTS = [(event, event) for event in settings.WEBHOOK_EVENTS] class Meta: model = WebhookSubscription diff --git a/notification/apps.py b/notification/apps.py index 2b8b7e94..b8bd90ea 100644 --- a/notification/apps.py +++ b/notification/apps.py @@ -13,3 +13,21 @@ class NotificationConfig(AppConfig): name = "notification" verbose_name = _("Notification") + + def ready(self): + from django.db.models.signals import post_save + + from notification.models import fire_webhooks + from workflow.models import Request + from workflow.models import RequestComment + + def fire_request_webhook(sender, instance, created, **kwargs): + event = "request.added" if created else "request.updated" + fire_webhooks(event, instance) + + def fire_request_comment_webhook(sender, instance, created, **kwargs): + if created: + fire_webhooks("request_comment.added", instance) + + post_save.connect(fire_request_webhook, sender=Request, weak=False) + post_save.connect(fire_request_comment_webhook, sender=RequestComment, weak=False) diff --git a/notification/models.py b/notification/models.py index e6c0cfc4..00b0223b 100644 --- a/notification/models.py +++ b/notification/models.py @@ -90,7 +90,7 @@ def deliver(self, context, timeout=10, payload_override=None): def get_payload(self, instance): payload = instance.serialize_hook(hook=self) if self.extra_payload: - payload.update(self.extra_payload) + payload["data"].update(self.extra_payload) return payload def create_delivery(self, payload, instance): @@ -125,18 +125,18 @@ class Meta(AbstractWebhookDelivery.Meta): unique_together = [("dataspace", "uuid")] - -def find_and_fire_hook( +def fire_webhooks( event_name, instance, - user_override=None, dataspace=None, payload_override=None, ): """ - Fire active Webhook instances found in the `dataspace` for the `event_name`. + Enqueue async delivery for each active WebhookSubscription in `dataspace` matching `event_name`. If `dataspace` is not provided, uses the Dataspace of the `instance`. """ + from notification.tasks import deliver_webhook_task + if not dataspace and instance: dataspace = instance.dataspace if not dataspace: @@ -150,4 +150,11 @@ def find_and_fire_hook( webhooks = WebhookSubscription.objects.scope(dataspace).filter(**filters) for webhook in webhooks: - webhook.deliver(instance, payload_override=payload_override) + task_kwargs = {"webhook_subscription_pk": webhook.pk} + if payload_override is not None: + task_kwargs["payload_override"] = payload_override + if instance is not None: + task_kwargs["instance_app_label"] = instance._meta.app_label + task_kwargs["instance_model_name"] = instance._meta.model_name + task_kwargs["instance_pk"] = instance.pk + deliver_webhook_task.delay(**task_kwargs) diff --git a/notification/tasks.py b/notification/tasks.py index 36802e9f..e351bfa3 100644 --- a/notification/tasks.py +++ b/notification/tasks.py @@ -6,55 +6,41 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # -import json +import logging -from django.template.defaultfilters import truncatechars - -import requests from django_rq import job -from rest_framework.utils import encoders -from dje.tasks import logger +logger = logging.getLogger("dje") @job -def deliver_hook_task( - target, payload, instance_id=None, hook_id=None, extra_headers=None, **kwargs +def deliver_webhook_task( + webhook_subscription_pk, + payload_override=None, + instance_app_label=None, + instance_model_name=None, + instance_pk=None, ): - """ - target: the url to receive the payload. - payload: a python primitive data structure - instance_id: a possibly None "trigger" instance ID - hook_id: the ID of defining Hook object - extra_headers: Additional headers such as Authentication ones - """ - session = requests.Session() - - session.headers.update({"Content-Type": "application/json"}) - if extra_headers: - session.headers.update(extra_headers) - - logger.info(f"Delivering Webhook hook_id={hook_id} to target={truncatechars(target, 25)}") - try: - session.post(url=target, data=payload) - except requests.ConnectionError: - return - + """Deliver a webhook payload to the target URL of the given WebhookSubscription.""" + from django.apps import apps -def deliver_hook_wrapper(target, payload, instance, hook): - if hook.extra_payload: - payload.update(hook.extra_payload) + from notification.models import WebhookSubscription - # Using ID's instead of objects for proper serialization - kwargs = { - "target": target, - "payload": json.dumps(payload, cls=encoders.JSONEncoder), - "hook_id": hook.id, - } - - if instance: - kwargs["instance_id"] = instance.id - if hook.extra_headers: - kwargs["extra_headers"] = hook.get_extra_headers() + try: + webhook_subscription = WebhookSubscription.objects.get(pk=webhook_subscription_pk) + except WebhookSubscription.DoesNotExist: + logger.error(f"WebhookSubscription pk={webhook_subscription_pk} not found.") + return - deliver_hook_task.delay(**kwargs) + instance = None + if instance_app_label and instance_model_name and instance_pk: + try: + model_class = apps.get_model(instance_app_label, instance_model_name) + instance = model_class.objects.get(pk=instance_pk) + except Exception: + logger.error( + f"Instance {instance_app_label}.{instance_model_name} pk={instance_pk} not found." + ) + return + + webhook_subscription.deliver(instance, payload_override=payload_override) diff --git a/pyproject.toml b/pyproject.toml index fdb86d88..4250adc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,8 +115,6 @@ dependencies = [ # license expressions "boolean.py==5.0", "license-expression==30.4.4", - # Webhooks - "django-rest-hooks==1.6.1", # django-notifications "django_notifications_patched==2.0.0", "jsonfield==3.2.0", diff --git a/thirdparty/dist/django_rest_hooks-1.6.1-py2.py3-none-any.whl b/thirdparty/dist/django_rest_hooks-1.6.1-py2.py3-none-any.whl deleted file mode 100644 index 3fb10f020c3e856b74be659010f05ffe2eb128f0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14171 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 S007d*#|IPupqK8iU;hV{5hLgT diff --git a/thirdparty/dist/django_rest_hooks-1.6.1-py2.py3-none-any.whl.ABOUT b/thirdparty/dist/django_rest_hooks-1.6.1-py2.py3-none-any.whl.ABOUT deleted file mode 100644 index 56bebfe5..00000000 --- a/thirdparty/dist/django_rest_hooks-1.6.1-py2.py3-none-any.whl.ABOUT +++ /dev/null @@ -1,16 +0,0 @@ -about_resource: django_rest_hooks-1.6.1-py2.py3-none-any.whl -name: django-rest-hooks -version: 1.6.1 -download_url: https://github.com/nexB/django-rest-hooks/releases/download/1.6.1/django_rest_hooks-1.6.1-py2.py3-none-any.whl -homepage_url: https://github.com/nexb/django-rest-hooks/tree/1.6.1 -package_url: pkg:github/nexb/django-rest-hooks@1.6.1?download_url=https://github.com/nexB/django-rest-hooks/releases/download/1.6.1/django_rest_hooks-1.6.1-py2.py3-none-any.whl -license_expression: bsd-new -copyright: Copyright django-rest-hooks project contributors -attribute: yes -owner: Zapier -checksum_md5: ab6041ca965e9b7c92388d01f7a2fbb4 -checksum_sha1: 9e62b86c7dcaa742b1a92140ffc8d4448327f6dd -licenses: - - key: bsd-new - name: BSD-3-Clause - file: bsd-new.LICENSE diff --git a/uv.lock b/uv.lock index e43038b4..78f5b77a 100644 --- a/uv.lock +++ b/uv.lock @@ -11,7 +11,7 @@ supported-markers = [ ] [options] -exclude-newer = "2026-06-11T13:07:42.745317Z" +exclude-newer = "2026-06-26T14:02:46.162492Z" exclude-newer-span = "P7D" [[package]] @@ -191,7 +191,7 @@ wheels = [ [[package]] name = "dejacode" -version = "5.8.0" +version = "5.8.1" source = { editable = "." } dependencies = [ { name = "aboutcode-toolkit", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -225,7 +225,6 @@ dependencies = [ { name = "django-notifications-patched", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "django-otp", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "django-registration", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "django-rest-hooks", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "django-rq", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "djangorestframework", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "drf-yasg", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -331,7 +330,6 @@ requires-dist = [ { name = "django-notifications-patched", specifier = "==2.0.0" }, { name = "django-otp", specifier = "==1.7.0" }, { name = "django-registration", specifier = "==5.2.1" }, - { name = "django-rest-hooks", specifier = "==1.6.1" }, { name = "django-rq", specifier = "==3.2.2" }, { name = "djangorestframework", specifier = "==3.16.1" }, { name = "drf-yasg", specifier = "==1.21.15" }, @@ -544,18 +542,6 @@ wheels = [ { path = "django_registration-5.2.1-py3-none-any.whl" }, ] -[[package]] -name = "django-rest-hooks" -version = "1.6.1" -source = { registry = "thirdparty/dist" } -dependencies = [ - { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, -] -wheels = [ - { path = "django_rest_hooks-1.6.1-py2.py3-none-any.whl" }, -] - [[package]] name = "django-rq" version = "3.2.2" diff --git a/vulnerabilities/fetch.py b/vulnerabilities/fetch.py index 0ccd095a..7bb55cbb 100644 --- a/vulnerabilities/fetch.py +++ b/vulnerabilities/fetch.py @@ -25,7 +25,7 @@ from dje.models import DejacodeUser from dje.utils import chunked_queryset from dje.utils import humanize_time -from notification.models import find_and_fire_hook +from notification.models import fire_webhooks from vulnerabilities.models import Vulnerability logger = logging.getLogger("dje") @@ -319,7 +319,7 @@ def notify_vulnerability_data_update(dataspace): # 1. Webhooks (simple message) message = f"{vulnerability_count} vulnerabilities affecting {package_count} packages" - find_and_fire_hook( + fire_webhooks( "vulnerability.data_update", instance=None, dataspace=dataspace, diff --git a/vulnerabilities/tests/test_fetch.py b/vulnerabilities/tests/test_fetch.py index ccdbc9c9..636e9b4e 100644 --- a/vulnerabilities/tests/test_fetch.py +++ b/vulnerabilities/tests/test_fetch.py @@ -155,7 +155,7 @@ def test_vulnerabilities_fetch_for_packages_cross_batch_no_spurious_update( # when encountered in response_37's batch, because created_advisory_uids guards it. self.assertEqual(results, {"created": 2, "updated": 0}) - @mock.patch("vulnerabilities.fetch.find_and_fire_hook") + @mock.patch("vulnerabilities.fetch.fire_webhooks") def test_vulnerabilities_fetch_notify_vulnerability_data_update(self, mock_fire_hook): notify_vulnerability_data_update(self.dataspace) mock_fire_hook.assert_not_called() From 2b32c293ea89c70fc5d4772669ed846a710f8ce2 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 3 Jul 2026 18:26:34 +0400 Subject: [PATCH 05/14] fix arbitrary position in the sanitized URL Signed-off-by: tdruez --- workflow/models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/workflow/models.py b/workflow/models.py index cf626d43..5c7ba93c 100644 --- a/workflow/models.py +++ b/workflow/models.py @@ -9,6 +9,7 @@ import json import logging import os +from urllib.parse import urlparse from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey @@ -589,7 +590,7 @@ def get_involved_users(self, exclude=None): return users def serialize_hook(self, hook): - if "hooks.slack.com" in hook.target_url: + if urlparse(hook.target_url).hostname == "hooks.slack.com": return request_slack_payload(self, created="added" in hook.event) from workflow.api import RequestSerializer @@ -789,7 +790,7 @@ def as_html(self): return mark_safe(html) def serialize_hook(self, hook): - if "hooks.slack.com" in hook.target_url: + if urlparse(hook.target_url).hostname == "hooks.slack.com": return request_comment_slack_payload(self) from workflow.api import RequestCommentSerializer From 732a30d7065332d1a8dcffb32a64d707d5d2faee Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 6 Jul 2026 09:49:52 +0400 Subject: [PATCH 06/14] add help text Signed-off-by: tdruez --- ...002_webhooksubscription_webhookdelivery.py | 177 ++++++++++++++---- notification/models.py | 2 + 2 files changed, 147 insertions(+), 32 deletions(-) diff --git a/notification/migrations/0002_webhooksubscription_webhookdelivery.py b/notification/migrations/0002_webhooksubscription_webhookdelivery.py index bf85eccb..46c67896 100644 --- a/notification/migrations/0002_webhooksubscription_webhookdelivery.py +++ b/notification/migrations/0002_webhooksubscription_webhookdelivery.py @@ -7,53 +7,166 @@ class Migration(migrations.Migration): - dependencies = [ - ('dje', '0015_alter_dataspaceconfiguration_purldb_api_key_and_more'), - ('notification', '0001_initial'), + ("dje", "0015_alter_dataspaceconfiguration_purldb_api_key_and_more"), + ("notification", "0001_initial"), ] operations = [ migrations.CreateModel( - name='WebhookSubscription', + name="WebhookSubscription", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')), - ('target_url', models.URLField(help_text='The URL to which the POST request will be sent when the Webhook is triggered.', max_length=1024, verbose_name='Target URL')), - ('is_active', models.BooleanField(default=True, help_text='Indicates whether the Webhook is currently active and should be triggered.')), - ('created_date', models.DateTimeField(auto_now_add=True, help_text='The date and time when the Webhook subscription was created.')), - ('event', models.CharField(max_length=64)), - ('extra_payload', models.JSONField(blank=True, default=dict, help_text='Extra data as JSON to be included in the payload')), - ('extra_headers', models.JSONField(blank=True, default=dict, help_text='Extra headers as JSON to be included in the request')), - ('dataspace', models.ForeignKey(editable=False, help_text='A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.', on_delete=django.db.models.deletion.PROTECT, to='dje.dataspace')), + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("uuid", models.UUIDField(default=uuid.uuid4, editable=False, verbose_name="UUID")), + ( + "target_url", + models.URLField( + help_text="The URL to which the POST request will be sent when the Webhook is triggered.", + max_length=1024, + verbose_name="Target URL", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Indicates whether the Webhook is currently active and should be triggered.", + ), + ), + ( + "created_date", + models.DateTimeField( + auto_now_add=True, + help_text="The date and time when the Webhook subscription was created.", + ), + ), + ( + "event", + models.CharField( + help_text="The event type that triggers this Webhook subscription.", + max_length=64, + verbose_name="Event", + ), + ), + ( + "extra_payload", + models.JSONField( + blank=True, + default=dict, + help_text="Extra data as JSON to be included in the payload", + ), + ), + ( + "extra_headers", + models.JSONField( + blank=True, + default=dict, + help_text="Extra headers as JSON to be included in the request", + ), + ), + ( + "dataspace", + models.ForeignKey( + editable=False, + help_text="A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.", + on_delete=django.db.models.deletion.PROTECT, + to="dje.dataspace", + ), + ), ], options={ - 'ordering': ['-created_date'], - 'abstract': False, - 'unique_together': {('dataspace', 'uuid')}, + "ordering": ["-created_date"], + "abstract": False, + "unique_together": {("dataspace", "uuid")}, }, bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model), ), migrations.CreateModel( - name='WebhookDelivery', + name="WebhookDelivery", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')), - ('target_url', models.URLField(help_text='Stores a copy of the Webhook target URL in case the subscription object is deleted.', max_length=1024, verbose_name='Target URL')), - ('sent_date', models.DateTimeField(auto_now_add=True, help_text='The date and time when the Webhook was sent.')), - ('payload', models.JSONField(blank=True, default=dict, help_text='The JSON payload that was sent to the target URL.')), - ('response_status_code', models.PositiveIntegerField(blank=True, help_text='The HTTP status code received in response to the Webhook request.', null=True)), - ('response_text', models.TextField(blank=True, help_text='The text response received from the target URL.')), - ('delivery_error', models.TextField(blank=True, help_text='Any error messages encountered during the Webhook delivery.')), - ('dataspace', models.ForeignKey(editable=False, help_text='A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.', on_delete=django.db.models.deletion.PROTECT, to='dje.dataspace')), - ('webhook_subscription', models.ForeignKey(blank=True, editable=False, help_text='The Webhook subscription associated with this delivery.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deliveries', to='notification.webhooksubscription')), + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("uuid", models.UUIDField(default=uuid.uuid4, editable=False, verbose_name="UUID")), + ( + "target_url", + models.URLField( + help_text="Stores a copy of the Webhook target URL in case the subscription object is deleted.", + max_length=1024, + verbose_name="Target URL", + ), + ), + ( + "sent_date", + models.DateTimeField( + auto_now_add=True, help_text="The date and time when the Webhook was sent." + ), + ), + ( + "payload", + models.JSONField( + blank=True, + default=dict, + help_text="The JSON payload that was sent to the target URL.", + ), + ), + ( + "response_status_code", + models.PositiveIntegerField( + blank=True, + help_text="The HTTP status code received in response to the Webhook request.", + null=True, + ), + ), + ( + "response_text", + models.TextField( + blank=True, help_text="The text response received from the target URL." + ), + ), + ( + "delivery_error", + models.TextField( + blank=True, + help_text="Any error messages encountered during the Webhook delivery.", + ), + ), + ( + "dataspace", + models.ForeignKey( + editable=False, + help_text="A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.", + on_delete=django.db.models.deletion.PROTECT, + to="dje.dataspace", + ), + ), + ( + "webhook_subscription", + models.ForeignKey( + blank=True, + editable=False, + help_text="The Webhook subscription associated with this delivery.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="deliveries", + to="notification.webhooksubscription", + ), + ), ], options={ - 'verbose_name': 'webhook delivery', - 'verbose_name_plural': 'webhook deliveries', - 'ordering': ['-sent_date'], - 'abstract': False, - 'unique_together': {('dataspace', 'uuid')}, + "verbose_name": "webhook delivery", + "verbose_name_plural": "webhook deliveries", + "ordering": ["-sent_date"], + "abstract": False, + "unique_together": {("dataspace", "uuid")}, }, bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model), ), diff --git a/notification/models.py b/notification/models.py index 00b0223b..03a60ab6 100644 --- a/notification/models.py +++ b/notification/models.py @@ -35,7 +35,9 @@ class WebhookSubscription(DataspacedModel, AbstractWebhookSubscription): """ event = models.CharField( + _("Event"), max_length=64, + help_text=_("The event type that triggers this Webhook subscription."), ) extra_payload = models.JSONField( blank=True, From 914d3e3ad09b0214cc35186e6969e0207e8a40e8 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 6 Jul 2026 10:08:46 +0400 Subject: [PATCH 07/14] refin the models Signed-off-by: tdruez --- aboutcode/notifications/models.py | 2 -- notification/models.py | 4 +++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/aboutcode/notifications/models.py b/aboutcode/notifications/models.py index f7c5a0b6..191cf137 100644 --- a/aboutcode/notifications/models.py +++ b/aboutcode/notifications/models.py @@ -47,7 +47,6 @@ class AbstractWebhookSubscription(models.Model): class Meta: abstract = True - ordering = ["-created_date"] def get_payload(self, context): raise NotImplementedError @@ -148,7 +147,6 @@ class Meta: abstract = True verbose_name = _("webhook delivery") verbose_name_plural = _("webhook deliveries") - ordering = ["-sent_date"] def __str__(self): return f"Webhook uuid={self.uuid} posted at {self.sent_date}" diff --git a/notification/models.py b/notification/models.py index 03a60ab6..9f67a64f 100644 --- a/notification/models.py +++ b/notification/models.py @@ -52,8 +52,9 @@ class WebhookSubscription(DataspacedModel, AbstractWebhookSubscription): objects = WebhookSubscriptionQuerySet.as_manager() - class Meta(AbstractWebhookSubscription.Meta): + class Meta: unique_together = ("dataspace", "uuid") + ordering = ["-created_date"] def __str__(self): return f"{self.event} => {self.target_url}" @@ -125,6 +126,7 @@ class WebhookDelivery(DataspacedModel, AbstractWebhookDelivery): class Meta(AbstractWebhookDelivery.Meta): unique_together = [("dataspace", "uuid")] + ordering = ["-sent_date"] def fire_webhooks( From d13a6a9542f505288ddda5741a410b66490b399c Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 6 Jul 2026 10:11:46 +0400 Subject: [PATCH 08/14] refactor queryset Signed-off-by: tdruez --- aboutcode/notifications/__init__.py | 7 ++++++- aboutcode/notifications/models.py | 7 +++++++ notification/models.py | 6 +++--- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/aboutcode/notifications/__init__.py b/aboutcode/notifications/__init__.py index a932eae0..c96e2748 100644 --- a/aboutcode/notifications/__init__.py +++ b/aboutcode/notifications/__init__.py @@ -8,7 +8,12 @@ from aboutcode.notifications.models import AbstractWebhookDelivery from aboutcode.notifications.models import AbstractWebhookSubscription +from aboutcode.notifications.models import WebhookSubscriptionQuerySetMixin __version__ = "0.1.0" -__all__ = ["AbstractWebhookSubscription", "AbstractWebhookDelivery"] +__all__ = [ + "AbstractWebhookSubscription", + "AbstractWebhookDelivery", + "WebhookSubscriptionQuerySetMixin", +] diff --git a/aboutcode/notifications/models.py b/aboutcode/notifications/models.py index 191cf137..a227ec5e 100644 --- a/aboutcode/notifications/models.py +++ b/aboutcode/notifications/models.py @@ -19,6 +19,13 @@ logger = logging.getLogger(__name__) +class WebhookSubscriptionQuerySetMixin: + """Mixin for WebhookSubscription querysets. Combine with the project's base QuerySet.""" + + def active(self): + return self.filter(is_active=True) + + class AbstractWebhookSubscription(models.Model): """ Abstract base for Webhook subscription models. diff --git a/notification/models.py b/notification/models.py index 9f67a64f..118d7fa8 100644 --- a/notification/models.py +++ b/notification/models.py @@ -15,15 +15,15 @@ from aboutcode.notifications import AbstractWebhookDelivery from aboutcode.notifications import AbstractWebhookSubscription +from aboutcode.notifications import WebhookSubscriptionQuerySetMixin from dje.models import DataspacedModel from dje.models import DataspacedQuerySet logger = logging.getLogger("dje") -class WebhookSubscriptionQuerySet(DataspacedQuerySet): - def active(self): - return self.filter(is_active=True) +class WebhookSubscriptionQuerySet(WebhookSubscriptionQuerySetMixin, DataspacedQuerySet): + pass class WebhookSubscription(DataspacedModel, AbstractWebhookSubscription): From 5da71bb96177d77300312745ebb1baf9f69de4cc Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 6 Jul 2026 11:51:31 +0400 Subject: [PATCH 09/14] move event field to the abstract models Signed-off-by: tdruez --- aboutcode/notifications/models.py | 7 +++++++ .../migrations/0002_webhooksubscription_webhookdelivery.py | 2 ++ notification/models.py | 5 ----- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/aboutcode/notifications/models.py b/aboutcode/notifications/models.py index a227ec5e..4b2abe85 100644 --- a/aboutcode/notifications/models.py +++ b/aboutcode/notifications/models.py @@ -46,6 +46,13 @@ class AbstractWebhookSubscription(models.Model): default=True, help_text=_("Indicates whether the Webhook is currently active and should be triggered."), ) + event = models.CharField( + _("Event"), + max_length=64, + null=True, + blank=True, + help_text=_("The event type that triggers this Webhook subscription."), + ) created_date = models.DateTimeField( auto_now_add=True, editable=False, diff --git a/notification/migrations/0002_webhooksubscription_webhookdelivery.py b/notification/migrations/0002_webhooksubscription_webhookdelivery.py index 46c67896..c134a6ac 100644 --- a/notification/migrations/0002_webhooksubscription_webhookdelivery.py +++ b/notification/migrations/0002_webhooksubscription_webhookdelivery.py @@ -48,8 +48,10 @@ class Migration(migrations.Migration): ( "event", models.CharField( + blank=True, help_text="The event type that triggers this Webhook subscription.", max_length=64, + null=True, verbose_name="Event", ), ), diff --git a/notification/models.py b/notification/models.py index 118d7fa8..9d48f7c8 100644 --- a/notification/models.py +++ b/notification/models.py @@ -34,11 +34,6 @@ class WebhookSubscription(DataspacedModel, AbstractWebhookSubscription): target URL and the specific events that trigger the Webhook. """ - event = models.CharField( - _("Event"), - max_length=64, - help_text=_("The event type that triggers this Webhook subscription."), - ) extra_payload = models.JSONField( blank=True, default=dict, From 2cf7fdcd06c7390357de984d2ffd70b428354f49 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 6 Jul 2026 12:04:56 +0400 Subject: [PATCH 10/14] refactor the settings Signed-off-by: tdruez --- dejacode/settings.py | 12 +++--------- notification/admin.py | 4 ++-- notification/models.py | 11 ++++++++++- notification/tests/test_models.py | 2 +- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/dejacode/settings.py b/dejacode/settings.py index 4759c91f..3ec518cd 100644 --- a/dejacode/settings.py +++ b/dejacode/settings.py @@ -662,16 +662,10 @@ def get_fake_redis_connection(config, use_strict_redis): # django-altcha ALTCHA_HMAC_KEY = env.str("DEJACODE_ALTCHA_HMAC_KEY", default="") -WEBHOOK_EVENTS = [ - "request.added", - "request.updated", - "request_comment.added", - "user.added_or_updated", - "user.locked_out", - "vulnerability.data_update", -] # Provide context variables to WebhookSubscription extra_headers template values. -HOOK_ENV = env.dict("HOOK_ENV", default={}) +# HOOK_ENV is the legacy name, kept for backward compatibility. +_legacy_hook_env = env.dict("HOOK_ENV", default={}) +DEJACODE_WEBHOOK_ENV = env.dict("DEJACODE_WEBHOOK_ENV", default=_legacy_hook_env) # Django-axes # Enable or disable Axes plugin functionality diff --git a/notification/admin.py b/notification/admin.py index 44925e2b..33367b15 100644 --- a/notification/admin.py +++ b/notification/admin.py @@ -7,18 +7,18 @@ # from django import forms -from django.conf import settings from django.contrib import admin from dje.admin import DataspacedAdmin from dje.admin import ProhibitDataspaceLookupMixin from dje.admin import dejacode_site from dje.forms import DataspacedAdminForm +from notification.models import WEBHOOK_EVENTS from notification.models import WebhookSubscription class WebhookSubscriptionForm(DataspacedAdminForm): - EVENTS = [(event, event) for event in settings.WEBHOOK_EVENTS] + EVENTS = [(event, event) for event in WEBHOOK_EVENTS] class Meta: model = WebhookSubscription diff --git a/notification/models.py b/notification/models.py index 9d48f7c8..2574bc75 100644 --- a/notification/models.py +++ b/notification/models.py @@ -21,6 +21,15 @@ logger = logging.getLogger("dje") +WEBHOOK_EVENTS = [ + "request.added", + "request.updated", + "request_comment.added", + "user.added_or_updated", + "user.locked_out", + "vulnerability.data_update", +] + class WebhookSubscriptionQuerySet(WebhookSubscriptionQuerySetMixin, DataspacedQuerySet): pass @@ -59,7 +68,7 @@ def dict(self): def get_extra_headers(self): """Inject `hook_env` context in headers template values.""" - if hook_env := settings.HOOK_ENV: + if hook_env := settings.DEJACODE_WEBHOOK_ENV: hook_env_context = template.Context(hook_env) return { key: self.render_template(value, hook_env_context) diff --git a/notification/tests/test_models.py b/notification/tests/test_models.py index fcdf73d0..1fba03ef 100644 --- a/notification/tests/test_models.py +++ b/notification/tests/test_models.py @@ -44,5 +44,5 @@ def test_webhook_subscription_get_extra_headers(self): self.assertEqual(expected, self.webhook.get_extra_headers()) expected = {"Header": "some_value"} - with override_settings(HOOK_ENV={"ENV_VALUE": "some_value"}): + with override_settings(DEJACODE_WEBHOOK_ENV={"ENV_VALUE": "some_value"}): self.assertEqual(expected, self.webhook.get_extra_headers()) From f83e09b7cfcb6f182168fdbdc82dff7f51b34316 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 6 Jul 2026 15:00:22 +0400 Subject: [PATCH 11/14] consolidate migration Signed-off-by: tdruez --- ...002_webhooksubscription_webhookdelivery.py | 26 ++++++++++++++ ..._migrate_webhook_to_webhooksubscription.py | 35 ------------------- .../migrations/0004_delete_webhook.py | 16 --------- 3 files changed, 26 insertions(+), 51 deletions(-) delete mode 100644 notification/migrations/0003_migrate_webhook_to_webhooksubscription.py delete mode 100644 notification/migrations/0004_delete_webhook.py diff --git a/notification/migrations/0002_webhooksubscription_webhookdelivery.py b/notification/migrations/0002_webhooksubscription_webhookdelivery.py index c134a6ac..1851d4cf 100644 --- a/notification/migrations/0002_webhooksubscription_webhookdelivery.py +++ b/notification/migrations/0002_webhooksubscription_webhookdelivery.py @@ -6,6 +6,25 @@ from django.db import migrations, models +def migrate_webhooks_to_subscriptions(apps, schema_editor): + Webhook = apps.get_model("notification", "Webhook") + WebhookSubscription = apps.get_model("notification", "WebhookSubscription") + + subscriptions = [ + WebhookSubscription( + dataspace=webhook.dataspace, + target_url=webhook.target, + is_active=webhook.is_active, + event=webhook.event, + extra_payload=webhook.extra_payload, + extra_headers=webhook.extra_headers, + ) + for webhook in Webhook.objects.select_related("dataspace").iterator() + ] + + WebhookSubscription.objects.bulk_create(subscriptions) + + class Migration(migrations.Migration): dependencies = [ ("dje", "0015_alter_dataspaceconfiguration_purldb_api_key_and_more"), @@ -172,4 +191,11 @@ class Migration(migrations.Migration): }, bases=(dje.models.DataspaceForeignKeyValidationMixin, models.Model), ), + migrations.RunPython( + migrate_webhooks_to_subscriptions, + reverse_code=migrations.RunPython.noop, + ), + migrations.DeleteModel( + name="Webhook", + ), ] diff --git a/notification/migrations/0003_migrate_webhook_to_webhooksubscription.py b/notification/migrations/0003_migrate_webhook_to_webhooksubscription.py deleted file mode 100644 index fcbcb51a..00000000 --- a/notification/migrations/0003_migrate_webhook_to_webhooksubscription.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 6.0.6 on 2026-07-03 13:38 - -from django.db import migrations - - -def migrate_webhooks_to_subscriptions(apps, schema_editor): - Webhook = apps.get_model("notification", "Webhook") - WebhookSubscription = apps.get_model("notification", "WebhookSubscription") - - subscriptions = [ - WebhookSubscription( - dataspace=webhook.dataspace, - target_url=webhook.target, - is_active=webhook.is_active, - event=webhook.event, - extra_payload=webhook.extra_payload, - extra_headers=webhook.extra_headers, - ) - for webhook in Webhook.objects.select_related("dataspace").iterator() - ] - - WebhookSubscription.objects.bulk_create(subscriptions) - - -class Migration(migrations.Migration): - dependencies = [ - ("notification", "0002_webhooksubscription_webhookdelivery"), - ] - - operations = [ - migrations.RunPython( - migrate_webhooks_to_subscriptions, - reverse_code=migrations.RunPython.noop, - ), - ] diff --git a/notification/migrations/0004_delete_webhook.py b/notification/migrations/0004_delete_webhook.py deleted file mode 100644 index c1d61d05..00000000 --- a/notification/migrations/0004_delete_webhook.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 6.0.6 on 2026-07-03 13:50 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('notification', '0003_migrate_webhook_to_webhooksubscription'), - ] - - operations = [ - migrations.DeleteModel( - name='Webhook', - ), - ] From f0b08a1f0b307dcb1c8fd3bf32e4844441aa8c9e Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 6 Jul 2026 15:14:50 +0400 Subject: [PATCH 12/14] add unit test Signed-off-by: tdruez --- notification/tasks.py | 8 +- notification/tests/test_models.py | 139 ++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 4 deletions(-) diff --git a/notification/tasks.py b/notification/tasks.py index e351bfa3..0c30826a 100644 --- a/notification/tasks.py +++ b/notification/tasks.py @@ -8,8 +8,12 @@ import logging +from django.apps import apps + from django_rq import job +from notification.models import WebhookSubscription + logger = logging.getLogger("dje") @@ -22,10 +26,6 @@ def deliver_webhook_task( instance_pk=None, ): """Deliver a webhook payload to the target URL of the given WebhookSubscription.""" - from django.apps import apps - - from notification.models import WebhookSubscription - try: webhook_subscription = WebhookSubscription.objects.get(pk=webhook_subscription_pk) except WebhookSubscription.DoesNotExist: diff --git a/notification/tests/test_models.py b/notification/tests/test_models.py index 1fba03ef..3c9c39c7 100644 --- a/notification/tests/test_models.py +++ b/notification/tests/test_models.py @@ -6,12 +6,42 @@ # See https://aboutcode.org for more information about AboutCode FOSS projects. # +import json +from unittest.mock import MagicMock +from unittest.mock import patch + from django.test import TestCase from django.test.utils import override_settings +import requests as req + from dje.models import Dataspace from dje.tests import create_superuser +from notification.models import WebhookDelivery from notification.models import WebhookSubscription +from notification.models import fire_webhooks + + +class WebhookSubscriptionQuerySetTestCase(TestCase): + def setUp(self): + self.nexb_dataspace = Dataspace.objects.create(name="nexB") + self.active_webhook = WebhookSubscription.objects.create( + dataspace=self.nexb_dataspace, + target_url="http://1.2.3.4/", + event="request.added", + is_active=True, + ) + self.inactive_webhook = WebhookSubscription.objects.create( + dataspace=self.nexb_dataspace, + target_url="http://1.2.3.5/", + event="request.added", + is_active=False, + ) + + def test_active_returns_only_active_subscriptions(self): + active = list(WebhookSubscription.objects.active()) + self.assertIn(self.active_webhook, active) + self.assertNotIn(self.inactive_webhook, active) class WebhookSubscriptionModelTestCase(TestCase): @@ -36,6 +66,15 @@ def test_webhook_subscription_dict(self): } self.assertEqual(expected, self.webhook.dict()) + def test_webhook_subscription_get_headers_default(self): + self.assertEqual({"Content-Type": "application/json"}, self.webhook.get_headers()) + + def test_webhook_subscription_get_headers_with_extra(self): + self.webhook.extra_headers = {"X-Token": "abc"} + self.webhook.save() + expected = {"Content-Type": "application/json", "X-Token": "abc"} + self.assertEqual(expected, self.webhook.get_headers()) + def test_webhook_subscription_get_extra_headers(self): self.webhook.extra_headers = {"Header": "{{ENV_VALUE}}"} self.webhook.save() @@ -46,3 +85,103 @@ def test_webhook_subscription_get_extra_headers(self): expected = {"Header": "some_value"} with override_settings(DEJACODE_WEBHOOK_ENV={"ENV_VALUE": "some_value"}): self.assertEqual(expected, self.webhook.get_extra_headers()) + + @patch("requests.post") + def test_webhook_subscription_deliver_inactive_returns_false(self, mock_post): + self.webhook.is_active = False + self.webhook.save() + result = self.webhook.deliver(None, payload_override={"key": "val"}) + self.assertFalse(result) + mock_post.assert_not_called() + + @patch("requests.post") + def test_webhook_subscription_deliver_request_exception_saves_error(self, mock_post): + mock_post.side_effect = req.exceptions.ConnectionError("Connection refused") + delivery = self.webhook.deliver(None, payload_override={"key": "val"}) + self.assertIsNotNone(delivery) + self.assertFalse(delivery.delivered) + self.assertIn("Connection refused", delivery.delivery_error) + + @patch("requests.post") + def test_webhook_subscription_deliver_payload_override_merges_extra_payload(self, mock_post): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "" + mock_post.return_value = mock_response + self.webhook.extra_payload = {"env": "prod"} + self.webhook.save() + self.webhook.deliver(None, payload_override={"key": "val"}) + call_data = json.loads(mock_post.call_args[1]["data"]) + self.assertEqual("prod", call_data["env"]) + self.assertEqual("val", call_data["key"]) + + @patch("requests.post") + def test_webhook_subscription_deliver_payload_override_without_extra_payload(self, mock_post): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "" + mock_post.return_value = mock_response + self.webhook.deliver(None, payload_override={"key": "val"}) + call_data = json.loads(mock_post.call_args[1]["data"]) + self.assertEqual({"key": "val"}, call_data) + + +class WebhookDeliveryModelTestCase(TestCase): + def setUp(self): + self.nexb_dataspace = Dataspace.objects.create(name="nexB") + self.webhook = WebhookSubscription.objects.create( + dataspace=self.nexb_dataspace, + target_url="http://1.2.3.4/", + event="request.added", + ) + self.delivery = WebhookDelivery.objects.create( + dataspace=self.nexb_dataspace, + webhook_subscription=self.webhook, + target_url=self.webhook.target_url, + payload={"key": "value"}, + ) + + def test_webhook_delivery_str(self): + self.assertIn(f"uuid={self.delivery.uuid}", str(self.delivery)) + + def test_webhook_delivery_delivered_false_when_no_status_code(self): + self.assertFalse(self.delivery.delivered) + + def test_webhook_delivery_delivered_true_when_status_code_set(self): + self.delivery.response_status_code = 200 + self.assertTrue(self.delivery.delivered) + + def test_webhook_delivery_success_on_2xx(self): + for status_code in (200, 201, 202): + self.delivery.response_status_code = status_code + self.assertTrue(self.delivery.success, f"Expected success for {status_code}") + + def test_webhook_delivery_success_false_on_non_2xx(self): + for status_code in (400, 404, 500): + self.delivery.response_status_code = status_code + self.assertFalse(self.delivery.success, f"Expected failure for {status_code}") + + +class FireWebhooksTestCase(TestCase): + def setUp(self): + self.nexb_dataspace = Dataspace.objects.create(name="nexB") + + def test_fire_webhooks_requires_dataspace_or_instance(self): + with self.assertRaises(AttributeError): + fire_webhooks("request.added", None) + + @patch("requests.post") + def test_fire_webhooks_no_matching_event_makes_no_request(self, mock_post): + fire_webhooks("no.such.event", None, dataspace=self.nexb_dataspace) + mock_post.assert_not_called() + + @patch("requests.post") + def test_fire_webhooks_skips_inactive_subscriptions(self, mock_post): + WebhookSubscription.objects.create( + dataspace=self.nexb_dataspace, + target_url="http://1.2.3.4/", + event="request.added", + is_active=False, + ) + fire_webhooks("request.added", None, dataspace=self.nexb_dataspace) + mock_post.assert_not_called() From 3acdf10d4a1f246efb0c4f4fecdb2eff84e1fa54 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 6 Jul 2026 15:20:14 +0400 Subject: [PATCH 13/14] display webhook delivery in admin Signed-off-by: tdruez --- notification/admin.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/notification/admin.py b/notification/admin.py index 33367b15..d683bec2 100644 --- a/notification/admin.py +++ b/notification/admin.py @@ -14,6 +14,7 @@ from dje.admin import dejacode_site from dje.forms import DataspacedAdminForm from notification.models import WEBHOOK_EVENTS +from notification.models import WebhookDelivery from notification.models import WebhookSubscription @@ -35,6 +36,24 @@ def __init__(self, *args, **kwargs): self.fields["event"] = forms.ChoiceField(choices=self.EVENTS) +class WebhookDeliveryInline(admin.TabularInline): + model = WebhookDelivery + fields = ("sent_date", "response_status_code", "delivery_error") + readonly_fields = ("sent_date", "response_status_code", "delivery_error") + extra = 0 + can_delete = False + show_change_link = False + verbose_name_plural = "Last 10 deliveries" + + def has_add_permission(self, request, obj=None): + return False + + def get_queryset(self, request): + qs = super().get_queryset(request) + latest_pks = qs.order_by("-sent_date").values_list("pk", flat=True)[:10] + return qs.filter(pk__in=latest_pks).order_by("-sent_date") + + @admin.register(WebhookSubscription, site=dejacode_site) class WebhookSubscriptionAdmin(ProhibitDataspaceLookupMixin, DataspacedAdmin): list_display = ("__str__", "event", "target_url", "is_active", "dataspace") @@ -44,3 +63,4 @@ class WebhookSubscriptionAdmin(ProhibitDataspaceLookupMixin, DataspacedAdmin): actions = [] actions_to_remove = ["copy_to", "compare_with"] email_notification_on = () + inlines = [WebhookDeliveryInline] From 5acd936ed3f89a16bea3db760b5ca2f8912ed19f Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 6 Jul 2026 15:32:06 +0400 Subject: [PATCH 14/14] use uuid Signed-off-by: tdruez --- notification/models.py | 2 +- notification/tasks.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/notification/models.py b/notification/models.py index 2574bc75..661ef5a4 100644 --- a/notification/models.py +++ b/notification/models.py @@ -158,7 +158,7 @@ def fire_webhooks( webhooks = WebhookSubscription.objects.scope(dataspace).filter(**filters) for webhook in webhooks: - task_kwargs = {"webhook_subscription_pk": webhook.pk} + task_kwargs = {"webhook_subscription_uuid": webhook.uuid} if payload_override is not None: task_kwargs["payload_override"] = payload_override if instance is not None: diff --git a/notification/tasks.py b/notification/tasks.py index 0c30826a..7c42c97e 100644 --- a/notification/tasks.py +++ b/notification/tasks.py @@ -19,7 +19,7 @@ @job def deliver_webhook_task( - webhook_subscription_pk, + webhook_subscription_uuid, payload_override=None, instance_app_label=None, instance_model_name=None, @@ -27,9 +27,9 @@ def deliver_webhook_task( ): """Deliver a webhook payload to the target URL of the given WebhookSubscription.""" try: - webhook_subscription = WebhookSubscription.objects.get(pk=webhook_subscription_pk) + webhook_subscription = WebhookSubscription.objects.get(uuid=webhook_subscription_uuid) except WebhookSubscription.DoesNotExist: - logger.error(f"WebhookSubscription pk={webhook_subscription_pk} not found.") + logger.error(f"WebhookSubscription uuid={webhook_subscription_uuid} not found.") return instance = None