From 75b397ed68ebe705a906b47c3985100d6c6828ab Mon Sep 17 00:00:00 2001 From: Etchegoyen Matthieu Date: Tue, 21 May 2024 11:26:13 +0200 Subject: [PATCH 1/8] WIP --- django_webhook/signals.py | 39 ++++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/django_webhook/signals.py b/django_webhook/signals.py index f8b282f..65c32fe 100644 --- a/django_webhook/signals.py +++ b/django_webhook/signals.py @@ -6,6 +6,7 @@ from django.db import models from django.db.models.signals import ModelSignal, post_delete, post_save from django.forms import model_to_dict +from django.utils.module_loading import import_string from django_webhook.models import Webhook @@ -26,7 +27,7 @@ def __init__(self, signal: ModelSignal, signal_name: str, model_cls: models.Mode self.signal = signal self.signal_name = signal_name self.model_cls = model_cls - + # pylint: disable=unused-argument def run(self, sender, created=False, instance=None, **kwargs): action_type = None @@ -44,7 +45,7 @@ def run(self, sender, created=False, instance=None, **kwargs): for id, uuid in webhook_ids: payload = dict( topic=topic, - object=model_dict(instance), + object=self.model_dict(instance), object_type=self.model_label, webhook_uuid=str(uuid), ) @@ -63,29 +64,37 @@ def uid(self): def model_label(self): return self.model_cls._meta.label + def model_dict(self, model): + """ + Returns the model instance as a dict, nested values for related models. + """ + fields = { + field.name: field.value_from_object(model) for field in model._meta.fields + } + return model_to_dict(model, fields=fields) # type: ignore + + +def connect_signals(): + SignalListenerClass = import_string( + dotted_path=settings.DJANGO_WEBHOOK.get( + "SIGNAL_LISTENER", "django_webhook.signals.SignalListener" + ) + ) + + print(">" * 100) + print(SignalListenerClass) -def connect_signals(): for cls in _active_models(): - post_save_listener = SignalListener( + post_save_listener = SignalListenerClass( signal=post_save, signal_name="post_save", model_cls=cls ) - post_delete_listener = SignalListener( + post_delete_listener = SignalListenerClass( signal=post_delete, signal_name="post_delete", model_cls=cls ) post_save_listener.connect() post_delete_listener.connect() -def model_dict(model): - """ - Returns the model instance as a dict, nested values for related models. - """ - fields = { - field.name: field.value_from_object(model) for field in model._meta.fields - } - return model_to_dict(model, fields=fields) # type: ignore - - def _active_models(): model_names = settings.DJANGO_WEBHOOK.get("MODELS", []) model_classes = [] From 8537f8163b55fe2bd3613e827e7318a809d36828 Mon Sep 17 00:00:00 2001 From: Etchegoyen Matthieu Date: Tue, 21 May 2024 11:53:54 +0200 Subject: [PATCH 2/8] WIP --- django_webhook/signals.py | 46 ++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/django_webhook/signals.py b/django_webhook/signals.py index 65c32fe..e4771f4 100644 --- a/django_webhook/signals.py +++ b/django_webhook/signals.py @@ -9,6 +9,7 @@ from django.utils.module_loading import import_string from django_webhook.models import Webhook +from typing import assert_never, Protocol from .tasks import fire_webhook from .util import cache @@ -18,7 +19,12 @@ DELETE = "delete" -class SignalListener: +class SignalListenerBase(Protocol): + def run(self, sender, created=False, instance=None, **kwargs): ... + def connect(self): ... + + +class SignalListener(SignalListenerBase): def __init__(self, signal: ModelSignal, signal_name: str, model_cls: models.Model): valid_signals = ["post_save", "post_delete"] if signal_name not in valid_signals: @@ -27,7 +33,7 @@ def __init__(self, signal: ModelSignal, signal_name: str, model_cls: models.Mode self.signal = signal self.signal_name = signal_name self.model_cls = model_cls - + # pylint: disable=unused-argument def run(self, sender, created=False, instance=None, **kwargs): action_type = None @@ -38,19 +44,27 @@ def run(self, sender, created=False, instance=None, **kwargs): action_type = UPDATE case "post_delete": action_type = DELETE + case _: + assert_never(self.signal_name) topic = f"{self.model_label}/{action_type}" - webhook_ids = _find_webhooks(topic) + webhook_ids = self.find_webhooks(topic=topic, instance=instance) for id, uuid in webhook_ids: payload = dict( topic=topic, - object=self.model_dict(instance), + object=self.serialize(instance), object_type=self.model_label, webhook_uuid=str(uuid), ) fire_webhook.delay(id, payload) + def find_webhooks(self, topic: str, instance=None): + return _find_webhooks(topic) + + def serialize(self, instance): + return model_dict(instance) + def connect(self): self.signal.connect( self.run, sender=self.model_cls, weak=False, dispatch_uid=self.uid # type: ignore @@ -64,26 +78,14 @@ def uid(self): def model_label(self): return self.model_cls._meta.label - def model_dict(self, model): - """ - Returns the model instance as a dict, nested values for related models. - """ - fields = { - field.name: field.value_from_object(model) for field in model._meta.fields - } - return model_to_dict(model, fields=fields) # type: ignore - -def connect_signals(): +def connect_signals(): SignalListenerClass = import_string( dotted_path=settings.DJANGO_WEBHOOK.get( "SIGNAL_LISTENER", "django_webhook.signals.SignalListener" ) ) - print(">" * 100) - print(SignalListenerClass) - for cls in _active_models(): post_save_listener = SignalListenerClass( signal=post_save, signal_name="post_save", model_cls=cls @@ -95,6 +97,16 @@ def connect_signals(): post_delete_listener.connect() +def model_dict(model): + """ + Returns the model instance as a dict, nested values for related models. + """ + fields = { + field.name: field.value_from_object(model) for field in model._meta.fields + } + return model_to_dict(model, fields=fields) # type: ignore + + def _active_models(): model_names = settings.DJANGO_WEBHOOK.get("MODELS", []) model_classes = [] From 92d23db5f0f595f2a324745b4f01a2e34358d6ff Mon Sep 17 00:00:00 2001 From: Etchegoyen Matthieu Date: Wed, 22 May 2024 15:17:07 +0200 Subject: [PATCH 3/8] WIP --- django_webhook/signals.py | 39 +++++++++++++++++---------------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/django_webhook/signals.py b/django_webhook/signals.py index e4771f4..63576b9 100644 --- a/django_webhook/signals.py +++ b/django_webhook/signals.py @@ -9,7 +9,7 @@ from django.utils.module_loading import import_string from django_webhook.models import Webhook -from typing import assert_never, Protocol +from typing import assert_never from .tasks import fire_webhook from .util import cache @@ -19,12 +19,7 @@ DELETE = "delete" -class SignalListenerBase(Protocol): - def run(self, sender, created=False, instance=None, **kwargs): ... - def connect(self): ... - - -class SignalListener(SignalListenerBase): +class SignalListener: def __init__(self, signal: ModelSignal, signal_name: str, model_cls: models.Model): valid_signals = ["post_save", "post_delete"] if signal_name not in valid_signals: @@ -53,7 +48,7 @@ def run(self, sender, created=False, instance=None, **kwargs): for id, uuid in webhook_ids: payload = dict( topic=topic, - object=self.serialize(instance), + object=self.model_dict(instance), object_type=self.model_label, webhook_uuid=str(uuid), ) @@ -62,9 +57,6 @@ def run(self, sender, created=False, instance=None, **kwargs): def find_webhooks(self, topic: str, instance=None): return _find_webhooks(topic) - def serialize(self, instance): - return model_dict(instance) - def connect(self): self.signal.connect( self.run, sender=self.model_cls, weak=False, dispatch_uid=self.uid # type: ignore @@ -78,6 +70,15 @@ def uid(self): def model_label(self): return self.model_cls._meta.label + def model_dict(self, model): + """ + Returns the model instance as a dict, nested values for related models. + """ + fields = { + field.name: field.value_from_object(model) for field in model._meta.fields + } + return model_to_dict(model, fields=fields) # type: ignore + def connect_signals(): SignalListenerClass = import_string( @@ -85,7 +86,11 @@ def connect_signals(): "SIGNAL_LISTENER", "django_webhook.signals.SignalListener" ) ) - + if not issubclass(SignalListenerClass, SignalListener): + raise ValueError( + f"{SignalListenerClass} must be a subclass of {SignalListener}" + ) + for cls in _active_models(): post_save_listener = SignalListenerClass( signal=post_save, signal_name="post_save", model_cls=cls @@ -97,16 +102,6 @@ def connect_signals(): post_delete_listener.connect() -def model_dict(model): - """ - Returns the model instance as a dict, nested values for related models. - """ - fields = { - field.name: field.value_from_object(model) for field in model._meta.fields - } - return model_to_dict(model, fields=fields) # type: ignore - - def _active_models(): model_names = settings.DJANGO_WEBHOOK.get("MODELS", []) model_classes = [] From 6d75b6e5270b8d9b40fbbe748aa77165d28d36c5 Mon Sep 17 00:00:00 2001 From: Etchegoyen Matthieu Date: Tue, 28 May 2024 11:54:23 +0200 Subject: [PATCH 4/8] WIP --- django_webhook/apps.py | 2 +- django_webhook/signals.py | 4 +++ tests/test_override.py | 75 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 tests/test_override.py diff --git a/django_webhook/apps.py b/django_webhook/apps.py index ab8ea66..ffa569b 100644 --- a/django_webhook/apps.py +++ b/django_webhook/apps.py @@ -22,5 +22,5 @@ def ready(self): from django_webhook.models import populate_topics_from_settings from django_webhook.signals import connect_signals - connect_signals() + # connect_signals() populate_topics_from_settings() diff --git a/django_webhook/signals.py b/django_webhook/signals.py index 63576b9..7690fdf 100644 --- a/django_webhook/signals.py +++ b/django_webhook/signals.py @@ -31,6 +31,7 @@ def __init__(self, signal: ModelSignal, signal_name: str, model_cls: models.Mode # pylint: disable=unused-argument def run(self, sender, created=False, instance=None, **kwargs): + print("!" * 100) action_type = None match self.signal_name: case "post_save" if created: @@ -91,6 +92,9 @@ def connect_signals(): f"{SignalListenerClass} must be a subclass of {SignalListener}" ) + print("C" * 100) + print(SignalListenerClass) + for cls in _active_models(): post_save_listener = SignalListenerClass( signal=post_save, signal_name="post_save", model_cls=cls diff --git a/tests/test_override.py b/tests/test_override.py new file mode 100644 index 0000000..f24a71e --- /dev/null +++ b/tests/test_override.py @@ -0,0 +1,75 @@ +from django.db.models.base import Model as Model +from django.db.models.signals import ModelSignal +from django_webhook.signals import SignalListener, connect_signals +from django_webhook.settings import defaults +from tests.models import Country +from django.test import override_settings +import pytest + +from django_webhook.test_factories import WebhookFactory, WebhookTopicFactory +import json + + +class DummySignalListener: + pass + + +def test_invalid_signal_listener(): + with override_settings( + DJANGO_WEBHOOK=defaults + | {"SIGNAL_LISTENER": "tests.test_override.UnknownSignalListener"} + ): + with pytest.raises(ImportError): + connect_signals() + + with override_settings( + DJANGO_WEBHOOK=defaults + | {"SIGNAL_LISTENER": "tests.test_override.DummySignalListener"} + ): + with pytest.raises(ValueError): + connect_signals() + + +class CustomSignalListener(SignalListener): + def run(self, sender, created=False, instance=None, **kwargs): + print("R" * 100) + if isinstance(instance, Country) and instance.name in [ + "France", + "Spain", + "Italy", + "Germany", + ]: + return super().run(sender, created, instance, **kwargs) + + def model_dict(self, model): + print("M" * 100) + return {"id": model.id, "country_name": model.name} + + +@override_settings( + DJANGO_WEBHOOK={ + "SIGNAL_LISTENER": "tests.test_override.CustomSignalListener", + "MODELS": ["tests.Country"], + } + | defaults +) +@pytest.mark.django_db +def test_override_signal_listener(responses): + country = Country.objects.create(name="France") + webhook = WebhookFactory( + topics=[ + WebhookTopicFactory(name="tests.Country/update"), + ], + ) + responses.post(webhook.url) + + connect_signals() + country.save() + + assert len(responses.calls) == 1 + assert json.loads(responses.calls[0].request.body) == { + "topic": "tests.Country/update", + "object": {"id": country.id, "country_name": "France"}, + "object_type": "tests.Country", + "webhook_uuid": str(webhook.uuid), + } From 6a36e197886c7058b5a5174bf60a24bf1b673025 Mon Sep 17 00:00:00 2001 From: Etchegoyen Matthieu Date: Tue, 28 May 2024 11:56:49 +0200 Subject: [PATCH 5/8] WIP --- django_webhook/apps.py | 2 +- django_webhook/signals.py | 5 +---- tests/test_override.py | 2 -- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/django_webhook/apps.py b/django_webhook/apps.py index ffa569b..ab8ea66 100644 --- a/django_webhook/apps.py +++ b/django_webhook/apps.py @@ -22,5 +22,5 @@ def ready(self): from django_webhook.models import populate_topics_from_settings from django_webhook.signals import connect_signals - # connect_signals() + connect_signals() populate_topics_from_settings() diff --git a/django_webhook/signals.py b/django_webhook/signals.py index 7690fdf..7472e80 100644 --- a/django_webhook/signals.py +++ b/django_webhook/signals.py @@ -91,10 +91,7 @@ def connect_signals(): raise ValueError( f"{SignalListenerClass} must be a subclass of {SignalListener}" ) - - print("C" * 100) - print(SignalListenerClass) - + for cls in _active_models(): post_save_listener = SignalListenerClass( signal=post_save, signal_name="post_save", model_cls=cls diff --git a/tests/test_override.py b/tests/test_override.py index f24a71e..be80215 100644 --- a/tests/test_override.py +++ b/tests/test_override.py @@ -32,7 +32,6 @@ def test_invalid_signal_listener(): class CustomSignalListener(SignalListener): def run(self, sender, created=False, instance=None, **kwargs): - print("R" * 100) if isinstance(instance, Country) and instance.name in [ "France", "Spain", @@ -42,7 +41,6 @@ def run(self, sender, created=False, instance=None, **kwargs): return super().run(sender, created, instance, **kwargs) def model_dict(self, model): - print("M" * 100) return {"id": model.id, "country_name": model.name} From c9f59b8f9d2f11dfbdde616c4a57b0d3fed21daf Mon Sep 17 00:00:00 2001 From: Etchegoyen Matthieu Date: Wed, 29 May 2024 20:08:28 +0200 Subject: [PATCH 6/8] WIP --- django_webhook/signals.py | 2 +- tests/test_override.py | 37 +++++++++++++++++++++++++++---------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/django_webhook/signals.py b/django_webhook/signals.py index 7472e80..1b6b984 100644 --- a/django_webhook/signals.py +++ b/django_webhook/signals.py @@ -65,7 +65,7 @@ def connect(self): @property def uid(self): - return f"django_webhook_{self.model_label}_{self.signal}" + return f"django_webhook_{self.model_label}_{self.signal_name}" @property def model_label(self): diff --git a/tests/test_override.py b/tests/test_override.py index be80215..91edcbc 100644 --- a/tests/test_override.py +++ b/tests/test_override.py @@ -2,6 +2,7 @@ from django.db.models.signals import ModelSignal from django_webhook.signals import SignalListener, connect_signals from django_webhook.settings import defaults +from django.db.models.signals import post_save, post_delete from tests.models import Country from django.test import override_settings import pytest @@ -9,6 +10,8 @@ from django_webhook.test_factories import WebhookFactory, WebhookTopicFactory import json +from unittest.mock import patch + class DummySignalListener: pass @@ -44,24 +47,38 @@ def model_dict(self, model): return {"id": model.id, "country_name": model.name} -@override_settings( - DJANGO_WEBHOOK={ - "SIGNAL_LISTENER": "tests.test_override.CustomSignalListener", - "MODELS": ["tests.Country"], - } - | defaults -) +def _disconnect_signals(): + assert post_save.disconnect( + sender=Country, + dispatch_uid="django_webhook_tests.Country_post_save", + ) + assert post_delete.disconnect( + sender=Country, + dispatch_uid="django_webhook_tests.Country_post_delete", + ) + + @pytest.mark.django_db def test_override_signal_listener(responses): country = Country.objects.create(name="France") webhook = WebhookFactory( topics=[ - WebhookTopicFactory(name="tests.Country/update"), - ], + WebhookTopicFactory(name="tests.Country/update") + ] ) responses.post(webhook.url) - connect_signals() + _disconnect_signals() + + with override_settings( + DJANGO_WEBHOOK={ + "SIGNAL_LISTENER": "tests.test_override.CustomSignalListener", + "MODELS": ["tests.Country"], + } + | defaults + ): + connect_signals() + country.save() assert len(responses.calls) == 1 From 72f092b6129df76534f58e63e8688d34a2a18d18 Mon Sep 17 00:00:00 2001 From: Etchegoyen Matthieu Date: Fri, 5 Jul 2024 19:45:15 +0200 Subject: [PATCH 7/8] cleanup --- django_webhook/signals.py | 1 - 1 file changed, 1 deletion(-) diff --git a/django_webhook/signals.py b/django_webhook/signals.py index 1b6b984..d50e668 100644 --- a/django_webhook/signals.py +++ b/django_webhook/signals.py @@ -31,7 +31,6 @@ def __init__(self, signal: ModelSignal, signal_name: str, model_cls: models.Mode # pylint: disable=unused-argument def run(self, sender, created=False, instance=None, **kwargs): - print("!" * 100) action_type = None match self.signal_name: case "post_save" if created: From 5429e2fcf3a69a92e8429704642ba3e8d4ab6d46 Mon Sep 17 00:00:00 2001 From: Etchegoyen Matthieu Date: Thu, 11 Jul 2024 13:38:43 +0200 Subject: [PATCH 8/8] fix migration issue --- django_webhook/apps.py | 1 + 1 file changed, 1 insertion(+) diff --git a/django_webhook/apps.py b/django_webhook/apps.py index ab8ea66..65c8185 100644 --- a/django_webhook/apps.py +++ b/django_webhook/apps.py @@ -4,6 +4,7 @@ class WebhooksConfig(AppConfig): name = "django_webhook" + default_auto_field = "django.db.models.AutoField" def ready(self): from django.conf import settings