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/notifications/__init__.py b/aboutcode/notifications/__init__.py new file mode 100644 index 00000000..c96e2748 --- /dev/null +++ b/aboutcode/notifications/__init__.py @@ -0,0 +1,19 @@ +# +# 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.notifications.models import AbstractWebhookDelivery +from aboutcode.notifications.models import AbstractWebhookSubscription +from aboutcode.notifications.models import WebhookSubscriptionQuerySetMixin + +__version__ = "0.1.0" + +__all__ = [ + "AbstractWebhookSubscription", + "AbstractWebhookDelivery", + "WebhookSubscriptionQuerySetMixin", +] diff --git a/aboutcode/notifications/models.py b/aboutcode/notifications/models.py new file mode 100644 index 00000000..4b2abe85 --- /dev/null +++ b/aboutcode/notifications/models.py @@ -0,0 +1,174 @@ +# +# 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 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. + + 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."), + ) + 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, + help_text=_("The date and time when the Webhook subscription was created."), + ) + + class Meta: + abstract = True + + 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 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}") + + if not self.is_active: + logger.info(f"Webhook {self.uuid} is not active.") + return False + + if payload_override: + payload = payload_override + else: + 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=self.get_headers(), + 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") + + 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/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/dejacode/settings.py b/dejacode/settings.py index 2bc493bf..3ec518cd 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,21 +662,10 @@ 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_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. - "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, -} -# Provide context variables to the `Webhook` values such as `extra_headers`. -HOOK_ENV = env.dict("HOOK_ENV", default={}) +# Provide context variables to WebhookSubscription extra_headers template values. +# 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/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/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 c1138880..b1f7a4db 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,14 @@ 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): + method_mock.return_value = None 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..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-rest-hooks" not in dep and "django_notifications_patched" not in dep + dep for dep in dependencies if "django_notifications_patched" not in dep ] docker_cmd = [ @@ -98,7 +96,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..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-rest-hooks" not in dep and "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 @@ -189,8 +187,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..d683bec2 100644 --- a/notification/admin.py +++ b/notification/admin.py @@ -7,28 +7,24 @@ # 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 from dje.admin import dejacode_site from dje.forms import DataspacedAdminForm -from notification.models import Webhook +from notification.models import WEBHOOK_EVENTS +from notification.models import WebhookDelivery +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): - EVENTS = [(event, event) for event in HOOK_EVENTS.keys()] +class WebhookSubscriptionForm(DataspacedAdminForm): + EVENTS = [(event, event) for event in WEBHOOK_EVENTS] class Meta: - model = Webhook + model = WebhookSubscription fields = [ - "target", + "target_url", "event", "is_active", "extra_payload", @@ -39,17 +35,32 @@ 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 + +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(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 = [] actions_to_remove = ["copy_to", "compare_with"] email_notification_on = () + inlines = [WebhookDeliveryInline] 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/migrations/0002_webhooksubscription_webhookdelivery.py b/notification/migrations/0002_webhooksubscription_webhookdelivery.py new file mode 100644 index 00000000..1851d4cf --- /dev/null +++ b/notification/migrations/0002_webhooksubscription_webhookdelivery.py @@ -0,0 +1,201 @@ +# 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 + + +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"), + ("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( + blank=True, + help_text="The event type that triggers this Webhook subscription.", + max_length=64, + null=True, + 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")}, + }, + 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), + ), + migrations.RunPython( + migrate_webhooks_to_subscriptions, + reverse_code=migrations.RunPython.noop, + ), + migrations.DeleteModel( + name="Webhook", + ), + ] diff --git a/notification/models.py b/notification/models.py index c0f37be8..661ef5a4 100644 --- a/notification/models.py +++ b/notification/models.py @@ -6,19 +6,43 @@ # 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 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 aboutcode.notifications import WebhookSubscriptionQuerySetMixin from dje.models import DataspacedModel +from dje.models import DataspacedQuerySet + +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 -# DataspacedModel is first as we want to apply it last for proper overrides -class Webhook(DataspacedModel, AbstractHook): - is_active = models.BooleanField(default=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. + """ + extra_payload = models.JSONField( blank=True, default=dict, @@ -30,24 +54,26 @@ class Webhook(DataspacedModel, AbstractHook): help_text=_("Extra headers as JSON to be included in the request"), ) + objects = WebhookSubscriptionQuerySet.as_manager() + class Meta: unique_together = ("dataspace", "uuid") + ordering = ["-created_date"] def __str__(self): - return f"{self.event} => {self.target}" + return f"{self.event} => {self.target_url}" def dict(self): - return {"uuid": str(self.uuid), "event": self.event, "target": self.target} + 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: + if hook_env := settings.DEJACODE_WEBHOOK_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 @@ -56,18 +82,69 @@ def render_template(value, hook_env_context): 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 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: + payload["data"].update(self.extra_payload) + return payload + + 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. + """ -def find_and_fire_hook( + 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")] + ordering = ["-sent_date"] + + +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: @@ -78,7 +155,14 @@ def find_and_fire_hook( "is_active": True, } - hooks = Webhook.objects.scope(dataspace).filter(**filters) - - for hook in hooks: - hook.deliver_hook(instance, payload_override) + webhooks = WebhookSubscription.objects.scope(dataspace).filter(**filters) + + for webhook in webhooks: + task_kwargs = {"webhook_subscription_uuid": webhook.uuid} + 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..7c42c97e 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 +from django.apps import apps -import requests from django_rq import job -from rest_framework.utils import encoders -from dje.tasks import logger +from notification.models import WebhookSubscription + +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_uuid, + 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)}") + """Deliver a webhook payload to the target URL of the given WebhookSubscription.""" try: - session.post(url=target, data=payload) - except requests.ConnectionError: + webhook_subscription = WebhookSubscription.objects.get(uuid=webhook_subscription_uuid) + except WebhookSubscription.DoesNotExist: + logger.error(f"WebhookSubscription uuid={webhook_subscription_uuid} not found.") return - -def deliver_hook_wrapper(target, payload, instance, hook): - if hook.extra_payload: - payload.update(hook.extra_payload) - - # 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() - - 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/notification/tests/test_models.py b/notification/tests/test_models.py index 5c63a4b8..3c9c39c7 100644 --- a/notification/tests/test_models.py +++ b/notification/tests/test_models.py @@ -6,44 +6,182 @@ # 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 Webhook +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 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_webhook_subscription_get_headers_default(self): + self.assertEqual({"Content-Type": "application/json"}, self.webhook.get_headers()) - def test_notification_webhook_model_get_extra_headers(self): - self.webhook1.extra_headers = {"Header": "{{ENV_VALUE}}"} - self.webhook1.save() + 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() 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()) + 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() 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"]) 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 3fb10f02..00000000 Binary files a/thirdparty/dist/django_rest_hooks-1.6.1-py2.py3-none-any.whl and /dev/null differ 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() diff --git a/workflow/models.py b/workflow/models.py index b861cba0..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: + 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: + if urlparse(hook.target_url).hostname == "hooks.slack.com": return request_comment_slack_payload(self) from workflow.api import RequestCommentSerializer