From 9cf7e6a01b5727b5f9a708523aef672c797faa40 Mon Sep 17 00:00:00 2001 From: Matti Lamppu Date: Tue, 7 Feb 2023 21:38:46 +0200 Subject: [PATCH] Add ability to cancel webhooks --- pyproject.toml | 2 +- signal_webhooks/exceptions.py | 7 ++ signal_webhooks/handlers.py | 76 ++++++------- tests/my_app/models.py | 6 +- tests/test_hooks.py | 205 ++++++++++++++++++++++++++-------- 5 files changed, 204 insertions(+), 92 deletions(-) create mode 100644 signal_webhooks/exceptions.py diff --git a/pyproject.toml b/pyproject.toml index 1d11f34..6f791ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 ", diff --git a/signal_webhooks/exceptions.py b/signal_webhooks/exceptions.py new file mode 100644 index 0000000..84b0d5b --- /dev/null +++ b/signal_webhooks/exceptions.py @@ -0,0 +1,7 @@ +__all__ = [ + "WebhookCancelled", +] + + +class WebhookCancelled(Exception): + """Webhook was cancelled before it was sent.""" diff --git a/signal_webhooks/handlers.py b/signal_webhooks/handlers.py index 1601dcb..3b74b0b 100644 --- a/signal_webhooks/handlers.py +++ b/signal_webhooks/handlers.py @@ -2,7 +2,6 @@ import logging from datetime import datetime, timezone from threading import Thread -from typing import Any import httpx from asgiref.sync import sync_to_async @@ -10,9 +9,11 @@ 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, @@ -49,37 +50,30 @@ 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) @@ -87,34 +81,28 @@ 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: diff --git a/tests/my_app/models.py b/tests/my_app/models.py index 2c63e60..3deda86 100644 --- a/tests/my_app/models.py +++ b/tests/my_app/models.py @@ -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): diff --git a/tests/test_hooks.py b/tests/test_hooks.py index c890d13..2850b33 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -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 @@ -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 = { @@ -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 = {