Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
19 changes: 19 additions & 0 deletions aboutcode/notifications/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
174 changes: 174 additions & 0 deletions aboutcode/notifications/models.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 4 additions & 16 deletions dejacode/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,6 @@ def gettext_noop(s):
"crispy_bootstrap5",
"guardian",
"django_filters",
"rest_hooks",
"notifications",
"axes",
"django_otp",
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions dje/management/commands/flushdataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -55,7 +56,8 @@ def handle(self, *args, **options):
UsagePolicy,
ExternalReference,
ExternalSource,
Webhook,
WebhookDelivery,
WebhookSubscription,
Vulnerability,
]
)
Expand Down
6 changes: 3 additions & 3 deletions dje/notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
10 changes: 5 additions & 5 deletions dje/tests/test_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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,
)
Expand Down
5 changes: 1 addition & 4 deletions etc/scripts/build_deb_docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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
Expand Down
18 changes: 3 additions & 15 deletions etc/scripts/build_nix_docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading
Loading