Skip to content

Commit

Permalink
Add ability to cancel webhooks
Browse files Browse the repository at this point in the history
  • Loading branch information
MrThearMan committed Feb 7, 2023
1 parent 997f4a6 commit 9cf7e6a
Show file tree
Hide file tree
Showing 5 changed files with 204 additions and 92 deletions.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "django-signal-webhooks"
version = "0.2.2"
version = "0.2.3"
description = "Add webhooks to django using signals."
authors = [
"Matti Lamppu <lamppu.matti.akseli@gmail.com>",
Expand Down
7 changes: 7 additions & 0 deletions signal_webhooks/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
__all__ = [
"WebhookCancelled",
]


class WebhookCancelled(Exception):
"""Webhook was cancelled before it was sent."""
76 changes: 32 additions & 44 deletions signal_webhooks/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@
import logging
from datetime import datetime, timezone
from threading import Thread
from typing import Any

import httpx
from asgiref.sync import sync_to_async
from django.db.models import QuerySet, signals
from django.db.models.base import ModelBase
from django.dispatch import receiver

from .exceptions import WebhookCancelled
from .settings import webhook_settings
from .typing import (
TYPE_CHECKING,
Any,
Callable,
ClientMethodKwargs,
Dict,
Expand Down Expand Up @@ -49,72 +50,59 @@ def webhook_update_create_handler(sender: ModelBase, **kwargs) -> None:
kwargs: PostSaveData
ref = reference_for_model(type(kwargs["instance"]))

hook: Optional[Callable] = ...
hooks: Optional[HooksData] = webhook_settings.HOOKS.get(ref)

if hooks is None:
return
if hooks is ...:
webhook_settings.TASK_HANDLER(
default_post_save_handler,
ref=ref,
data=webhook_settings.SERIALIZER(kwargs["instance"]),
created=kwargs["created"],
)
return

hook = hooks.get("CREATE") if kwargs["created"] else hooks.get("UPDATE")

if hooks is not ...:
hook = hooks.get("CREATE") if kwargs["created"] else hooks.get("UPDATE")
if hook is None:
return
if hook is ...:
webhook_settings.TASK_HANDLER(
default_post_save_handler,
ref=ref,
data=webhook_settings.SERIALIZER(kwargs["instance"]),
created=kwargs["created"],
)
hook = default_post_save_handler

try:
data = webhook_settings.SERIALIZER(kwargs["instance"])
except WebhookCancelled as error:
method = "Create" if kwargs["created"] else "Update"
logger.info(f"{method} webhook for {ref!r} cancelled before it was sent. Reason given: {error}")
return
except Exception as error:
method = "Create" if kwargs["created"] else "Update"
logger.exception(f"{method} webhook data for {ref!r} could not be created.", exc_info=error)
return

webhook_settings.TASK_HANDLER(
hook,
ref=ref,
data=webhook_settings.SERIALIZER(kwargs["instance"]),
created=kwargs["created"],
)
webhook_settings.TASK_HANDLER(hook, ref=ref, data=data, created=kwargs["created"])


@receiver(signals.post_delete, dispatch_uid=webhook_settings.DISPATCH_UID_POST_DELETE)
def webhook_delete_handler(sender: ModelBase, **kwargs) -> None:
kwargs: PostDeleteData
ref = reference_for_model(type(kwargs["instance"]))

hook: Optional[Callable] = ...
hooks: Optional[HooksData] = webhook_settings.HOOKS.get(ref)

if hooks is None:
return
if hooks is ...:
webhook_settings.TASK_HANDLER(
default_post_delete_handler,
ref=ref,
data=webhook_settings.SERIALIZER(kwargs["instance"]),
)
return

hook = hooks.get("DELETE")

if hooks is not ...:
hook = hooks.get("DELETE")
if hook is None:
return
if hook is ...:
webhook_settings.TASK_HANDLER(
default_post_delete_handler,
ref=ref,
data=webhook_settings.SERIALIZER(kwargs["instance"]),
)
hook = default_post_delete_handler

try:
data = webhook_settings.SERIALIZER(kwargs["instance"])
except WebhookCancelled as error:
logger.info(f"Delete webhook for {ref!r} cancelled before it was sent. Reason given: {error}")
return
except Exception as error:
logger.exception(f"Delete webhook data for {ref!r} could not be created.", exc_info=error)
return

webhook_settings.TASK_HANDLER(
hook,
ref=ref,
data=webhook_settings.SERIALIZER(kwargs["instance"]),
)
webhook_settings.TASK_HANDLER(hook, ref=ref, data=data)


def default_error_handler(hook: "Webhook", error: Optional[Exception]) -> None:
Expand Down
6 changes: 5 additions & 1 deletion tests/my_app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@
]


def webhook_function():
return {"fizz": "buzz"}


class MyModel(models.Model):
"""Testing model."""

name = models.CharField(max_length=256)

def webhook_data(self):
return {"fizz": "buzz"}
return webhook_function()


class TestModel(models.Model):
Expand Down
205 changes: 159 additions & 46 deletions tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.core.exceptions import ImproperlyConfigured, ValidationError
from httpx import Response

from signal_webhooks.exceptions import WebhookCancelled
from signal_webhooks.models import Webhook
from signal_webhooks.typing import SignalChoices
from signal_webhooks.utils import get_webhookhook_model
Expand Down Expand Up @@ -518,6 +519,164 @@ def test_webhook__single_webhook__webhook_data(settings):
assert hook.last_failure is None


@pytest.mark.django_db(transaction=True)
def test_webhook__single_webhook__webhook_data__cancel_webhook(settings, caplog):
settings.SIGNAL_WEBHOOKS = {
"TASK_HANDLER": "signal_webhooks.handlers.sync_task_handler",
"HOOKS": {
"tests.my_app.models.MyModel": ...,
},
}

response = Response(204)

Webhook.objects.create(
name="foo",
signal=SignalChoices.ALL,
ref="tests.my_app.models.MyModel",
endpoint="http://www.example.com/",
)

item = MyModel(name="x")

def func():
raise WebhookCancelled("Just because.")

method_1 = "signal_webhooks.handlers.httpx.AsyncClient.post"
method_2 = "tests.my_app.models.webhook_function"
# Sqlite cannot handle updating the Webhook after model delete
method_3 = "signal_webhooks.models.Webhook.objects.bulk_update"

with patch(method_1, return_value=response) as m1, patch(method_2, side_effect=func) as m2:
item.save()

m1.assert_not_called()
m2.assert_called_once()

assert len(caplog.messages) == 1
assert caplog.messages[0] == (
"Create webhook for 'tests.my_app.models.MyModel' cancelled before it was sent. Reason given: Just because."
)
caplog.clear()

hook = Webhook.objects.get(name="foo")

assert hook.last_success is None
assert hook.last_failure is None

item.name = "xx"
with patch(method_1, return_value=response) as m3, patch(method_2, side_effect=func) as m4:
item.save(update_fields=["name"])

assert len(caplog.messages) == 1
assert caplog.messages[0] == (
"Update webhook for 'tests.my_app.models.MyModel' cancelled before it was sent. Reason given: Just because."
)
caplog.clear()

m3.assert_not_called()
m4.assert_called_once()

hook = Webhook.objects.get(name="foo")

assert hook.last_success is None
assert hook.last_failure is None

with patch(method_1, return_value=response) as m5, patch(method_3) as m6, patch(method_2, side_effect=func) as m7:
item.delete()

assert len(caplog.messages) == 1
assert caplog.messages[0] == (
"Delete webhook for 'tests.my_app.models.MyModel' cancelled before it was sent. Reason given: Just because."
)
caplog.clear()

m5.assert_not_called()
m6.assert_not_called()
m7.assert_called_once()

hook = Webhook.objects.get(name="foo")

assert hook.last_success is None
assert hook.last_failure is None


@pytest.mark.django_db(transaction=True)
def test_webhook__single_webhook__webhook_data__data_fetching_failed(settings, caplog):
settings.SIGNAL_WEBHOOKS = {
"TASK_HANDLER": "signal_webhooks.handlers.sync_task_handler",
"HOOKS": {
"tests.my_app.models.MyModel": ...,
},
}

response = Response(204)

Webhook.objects.create(
name="foo",
signal=SignalChoices.ALL,
ref="tests.my_app.models.MyModel",
endpoint="http://www.example.com/",
)

item = MyModel(name="x")

def func():
raise Exception("foo")

method_1 = "signal_webhooks.handlers.httpx.AsyncClient.post"
method_2 = "tests.my_app.models.webhook_function"
# Sqlite cannot handle updating the Webhook after model delete
method_3 = "signal_webhooks.models.Webhook.objects.bulk_update"

with patch(method_1, return_value=response) as m1, patch(method_2, side_effect=func) as m2:
item.save()

m1.assert_not_called()
m2.assert_called_once()

assert len(caplog.messages) == 1
assert caplog.messages[0] == "Create webhook data for 'tests.my_app.models.MyModel' could not be created."
caplog.clear()

hook = Webhook.objects.get(name="foo")

assert hook.last_success is None
assert hook.last_failure is None

item.name = "xx"
with patch(method_1, return_value=response) as m3, patch(method_2, side_effect=func) as m4:
item.save(update_fields=["name"])

assert len(caplog.messages) == 1
assert caplog.messages[0] == "Update webhook data for 'tests.my_app.models.MyModel' could not be created."
caplog.clear()

m3.assert_not_called()
m4.assert_called_once()

hook = Webhook.objects.get(name="foo")

assert hook.last_success is None
assert hook.last_failure is None

with patch(method_1, return_value=response) as m5, patch(method_3) as m6, patch(method_2, side_effect=func) as m7:
item.delete()

assert len(caplog.messages) == 1
assert caplog.messages[0] == "Delete webhook data for 'tests.my_app.models.MyModel' could not be created."
caplog.clear()

m5.assert_not_called()
m6.assert_not_called()
m7.assert_called_once()

hook = Webhook.objects.get(name="foo")

assert hook.last_success is None
assert hook.last_failure is None


@pytest.mark.django_db(transaction=True)
def test_webhook__single_webhook__correct_signal__create_only(settings):
settings.SIGNAL_WEBHOOKS = {
Expand Down Expand Up @@ -1177,52 +1336,6 @@ def test_webhook__multiple_webhooks__failure(settings):
assert hook_2.last_failure is not None


@pytest.mark.django_db(transaction=True)
def test_webhook__multiple_webhooks__failure(settings):
settings.SIGNAL_WEBHOOKS = {
"TASK_HANDLER": "signal_webhooks.handlers.sync_task_handler",
"HOOKS": {
"django.contrib.auth.models.User": ...,
},
}

Webhook.objects.create(
name="foo",
signal=SignalChoices.ALL,
ref="django.contrib.auth.models.User",
endpoint="http://www.example.com/",
)

Webhook.objects.create(
name="bar",
signal=SignalChoices.ALL,
ref="django.contrib.auth.models.User",
endpoint="http://www.example1.com/",
)

user = User(
username="x",
email="user@user.com",
is_staff=True,
is_superuser=True,
)

with patch("signal_webhooks.handlers.httpx.AsyncClient.post", return_value=Response(400)) as mock:
user.save()

mock.assert_called()
assert mock.call_count == 2

hook_1 = Webhook.objects.get(name="foo")
hook_2 = Webhook.objects.get(name="bar")

assert hook_1.last_success is None
assert hook_1.last_failure is not None

assert hook_2.last_success is None
assert hook_2.last_failure is not None


@pytest.mark.django_db(transaction=True)
def test_webhook__multiple_webhooks__sending_timeout(settings):
settings.SIGNAL_WEBHOOKS = {
Expand Down

0 comments on commit 9cf7e6a

Please sign in to comment.