django-webhook-subscriber 0.4.0__py3-none-any.whl → 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_webhook_subscriber/__init__.py +7 -1
- django_webhook_subscriber/admin.py +831 -182
- django_webhook_subscriber/apps.py +3 -20
- django_webhook_subscriber/conf.py +11 -24
- django_webhook_subscriber/delivery.py +414 -159
- django_webhook_subscriber/http.py +51 -0
- django_webhook_subscriber/management/commands/webhook.py +169 -0
- django_webhook_subscriber/management/commands/webhook_cache.py +173 -0
- django_webhook_subscriber/management/commands/webhook_logs.py +226 -0
- django_webhook_subscriber/management/commands/webhook_performance_test.py +469 -0
- django_webhook_subscriber/management/commands/webhook_send.py +96 -0
- django_webhook_subscriber/management/commands/webhook_status.py +139 -0
- django_webhook_subscriber/managers.py +36 -14
- django_webhook_subscriber/migrations/0002_remove_webhookregistry_content_type_and_more.py +192 -0
- django_webhook_subscriber/models.py +291 -114
- django_webhook_subscriber/serializers.py +16 -50
- django_webhook_subscriber/tasks.py +434 -56
- django_webhook_subscriber/tests/factories.py +40 -0
- django_webhook_subscriber/tests/settings.py +27 -8
- django_webhook_subscriber/tests/test_delivery.py +453 -190
- django_webhook_subscriber/tests/test_http.py +32 -0
- django_webhook_subscriber/tests/test_managers.py +26 -37
- django_webhook_subscriber/tests/test_models.py +341 -251
- django_webhook_subscriber/tests/test_serializers.py +22 -56
- django_webhook_subscriber/tests/test_tasks.py +477 -189
- django_webhook_subscriber/tests/test_utils.py +98 -94
- django_webhook_subscriber/utils.py +87 -69
- django_webhook_subscriber/validators.py +53 -0
- django_webhook_subscriber-2.0.0.dist-info/METADATA +774 -0
- django_webhook_subscriber-2.0.0.dist-info/RECORD +38 -0
- django_webhook_subscriber/management/commands/check_webhook_tasks.py +0 -113
- django_webhook_subscriber/management/commands/clean_webhook_logs.py +0 -65
- django_webhook_subscriber/management/commands/test_webhook.py +0 -96
- django_webhook_subscriber/signals.py +0 -152
- django_webhook_subscriber/testing.py +0 -14
- django_webhook_subscriber/tests/test_signals.py +0 -268
- django_webhook_subscriber-0.4.0.dist-info/METADATA +0 -448
- django_webhook_subscriber-0.4.0.dist-info/RECORD +0 -33
- {django_webhook_subscriber-0.4.0.dist-info → django_webhook_subscriber-2.0.0.dist-info}/WHEEL +0 -0
- {django_webhook_subscriber-0.4.0.dist-info → django_webhook_subscriber-2.0.0.dist-info}/licenses/LICENSE +0 -0
- {django_webhook_subscriber-0.4.0.dist-info → django_webhook_subscriber-2.0.0.dist-info}/top_level.txt +0 -0
|
@@ -1,108 +1,112 @@
|
|
|
1
|
-
from unittest.mock import patch
|
|
2
|
-
|
|
3
1
|
from django.test import TestCase, override_settings
|
|
4
|
-
from django.contrib.auth.models import User
|
|
5
|
-
from django.contrib.contenttypes.models import ContentType
|
|
6
|
-
|
|
7
|
-
from django_webhook_subscriber.models import WebhookRegistry
|
|
8
|
-
from django_webhook_subscriber.signals import register_webhook_signals
|
|
9
|
-
from django_webhook_subscriber.utils import (
|
|
10
|
-
register_model_config,
|
|
11
|
-
get_webhook_config,
|
|
12
|
-
unregister_webhook_signals,
|
|
13
|
-
)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class WebhookRegistrationTests(TestCase):
|
|
17
|
-
def test_register_model_config(self):
|
|
18
|
-
|
|
19
|
-
# Register the User model with a serializer and events
|
|
20
|
-
config = register_model_config(
|
|
21
|
-
User,
|
|
22
|
-
serializer='UserSerializer',
|
|
23
|
-
events=['CREATE', 'UPDATE'],
|
|
24
|
-
)
|
|
25
2
|
|
|
26
|
-
|
|
27
|
-
self.assertEqual(config['serializer'], 'UserSerializer')
|
|
28
|
-
self.assertEqual(config['events'], ['CREATE', 'UPDATE'])
|
|
3
|
+
from django_webhook_subscriber import utils
|
|
29
4
|
|
|
30
|
-
|
|
5
|
+
from .factories import ContentTypeFactory, WebhookSubscriberFactory
|
|
31
6
|
|
|
32
|
-
# Register the User model with a serializer and events
|
|
33
|
-
config = register_model_config(User)
|
|
34
7
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
self.
|
|
8
|
+
class GenerateHeadersFunctionTests(TestCase):
|
|
9
|
+
def setUp(self):
|
|
10
|
+
self.subscriber = WebhookSubscriberFactory()
|
|
38
11
|
|
|
39
|
-
def
|
|
40
|
-
|
|
41
|
-
|
|
12
|
+
def test_generate_headers_on_no_default_headers(self):
|
|
13
|
+
headers = utils.generate_headers(self.subscriber)
|
|
14
|
+
# Asserting both X-Secret and Content-Type headers are present
|
|
15
|
+
self.assertIn("X-Secret", headers)
|
|
16
|
+
self.assertIn("Content-Type", headers)
|
|
17
|
+
self.assertEqual(headers["X-Secret"], self.subscriber.secret)
|
|
18
|
+
self.assertEqual(headers["Content-Type"], "application/json")
|
|
42
19
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
20
|
+
def test_generate_headers_on_custom_default_headers(self):
|
|
21
|
+
self.subscriber.headers = {
|
|
22
|
+
"Custom-Header": "Value",
|
|
23
|
+
"Content-Type": "application/xml",
|
|
24
|
+
}
|
|
25
|
+
headers = utils.generate_headers(self.subscriber)
|
|
26
|
+
# Asserting both X-Secret and Content-Type headers are present
|
|
27
|
+
self.assertIn("X-Secret", headers)
|
|
28
|
+
self.assertIn("Content-Type", headers)
|
|
29
|
+
self.assertIn("Custom-Header", headers)
|
|
30
|
+
self.assertEqual(headers["X-Secret"], self.subscriber.secret)
|
|
31
|
+
self.assertEqual(headers["Content-Type"], "application/xml")
|
|
32
|
+
self.assertEqual(headers["Custom-Header"], "Value")
|
|
48
33
|
|
|
49
|
-
@override_settings(WEBHOOK_SUBSCRIBER_MODELS={'auth.User': {}})
|
|
50
|
-
@patch('django_webhook_subscriber.signals.process_webhook_event')
|
|
51
|
-
def test_unregister_webhook_signals(self, mock_delivery):
|
|
52
|
-
register_webhook_signals(WebhookRegistry)
|
|
53
|
-
register_webhook_signals(User)
|
|
54
34
|
|
|
55
|
-
|
|
56
|
-
|
|
35
|
+
class GenerateSecretFunctionTests(TestCase):
|
|
36
|
+
def test_generate_secret_returns_uuid_string(self):
|
|
37
|
+
secret = utils.generate_secret()
|
|
38
|
+
self.assertIsInstance(secret, str)
|
|
39
|
+
self.assertEqual(len(secret), 36) # UUID string length
|
|
57
40
|
|
|
58
|
-
# creating a user
|
|
59
|
-
user = User.objects.create(username='testuser')
|
|
60
|
-
mock_delivery.assert_not_called()
|
|
61
41
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
42
|
+
class WebhookDisableContextTests(TestCase):
|
|
43
|
+
def test_webhook_disable_context_manager(self):
|
|
44
|
+
# By default, webhooks should be enabled
|
|
45
|
+
self.assertFalse(utils.webhooks_disabled())
|
|
65
46
|
|
|
66
|
-
#
|
|
67
|
-
|
|
68
|
-
|
|
47
|
+
# Context manager should disable webhooks
|
|
48
|
+
with utils.disable_webhooks():
|
|
49
|
+
self.assertTrue(utils.webhooks_disabled())
|
|
69
50
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
@override_settings(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
# deleting the user
|
|
97
|
-
user.delete()
|
|
98
|
-
mock_delivery.assert_not_called()
|
|
99
|
-
|
|
100
|
-
# creating a webhook delivery log
|
|
101
|
-
WebhookRegistry.objects.create(
|
|
102
|
-
name='Test Webhook',
|
|
103
|
-
content_type=ContentType.objects.get_for_model(User),
|
|
104
|
-
event_signals=['CREATE', 'UPDATE'],
|
|
105
|
-
endpoint='http://example.com/webhook/',
|
|
51
|
+
# Nested context managers should also work
|
|
52
|
+
with utils.disable_webhooks():
|
|
53
|
+
self.assertTrue(utils.webhooks_disabled())
|
|
54
|
+
|
|
55
|
+
self.assertTrue(utils.webhooks_disabled())
|
|
56
|
+
|
|
57
|
+
# After context manager, webhooks should be enabled again
|
|
58
|
+
self.assertFalse(utils.webhooks_disabled())
|
|
59
|
+
|
|
60
|
+
@override_settings(DISABLE_WEBHOOKS=True)
|
|
61
|
+
def test_webhook_disable_context_manager_when_disabled_in_settings(self):
|
|
62
|
+
# Webhooks should be disabled due to settings
|
|
63
|
+
self.assertTrue(utils.webhooks_disabled())
|
|
64
|
+
|
|
65
|
+
with utils.disable_webhooks():
|
|
66
|
+
# Even inside the context manager, webhooks remain disabled
|
|
67
|
+
self.assertTrue(utils.webhooks_disabled())
|
|
68
|
+
|
|
69
|
+
self.assertTrue(utils.webhooks_disabled())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ContentTypeCacheTests(TestCase):
|
|
73
|
+
def setUp(self):
|
|
74
|
+
self.content_type = ContentTypeFactory(
|
|
75
|
+
app_label="test_app",
|
|
76
|
+
model="testmodel",
|
|
106
77
|
)
|
|
107
|
-
#
|
|
108
|
-
|
|
78
|
+
# Clear cache to ensure fresh state
|
|
79
|
+
utils.get_content_type_id.cache_clear()
|
|
80
|
+
|
|
81
|
+
def test_get_content_type_id_caching(self):
|
|
82
|
+
# First call should hit the database
|
|
83
|
+
with self.assertNumQueries(1):
|
|
84
|
+
ct_id_1 = utils.get_content_type_id("test_app", "testmodel")
|
|
85
|
+
self.assertIsNotNone(ct_id_1)
|
|
86
|
+
|
|
87
|
+
with self.assertNumQueries(0):
|
|
88
|
+
# Second call should use the cache
|
|
89
|
+
ct_id_2 = utils.get_content_type_id("test_app", "testmodel")
|
|
90
|
+
self.assertEqual(ct_id_1, ct_id_2)
|
|
91
|
+
|
|
92
|
+
def test_get_content_type_id_non_existent(self):
|
|
93
|
+
with self.assertNumQueries(1):
|
|
94
|
+
ct_id = utils.get_content_type_id(
|
|
95
|
+
"nonexistent_app",
|
|
96
|
+
"nonexistentmodel",
|
|
97
|
+
)
|
|
98
|
+
self.assertIsNone(ct_id)
|
|
99
|
+
|
|
100
|
+
def test_clear_content_type_cache(self):
|
|
101
|
+
with self.assertNumQueries(1):
|
|
102
|
+
# First call should hit the database
|
|
103
|
+
ct_id_1 = utils.get_content_type_id("test_app", "testmodel")
|
|
104
|
+
self.assertIsNotNone(ct_id_1)
|
|
105
|
+
|
|
106
|
+
# Clear the cache
|
|
107
|
+
utils.clear_content_type_cache()
|
|
108
|
+
|
|
109
|
+
# Next call should hit the database again (not cached)
|
|
110
|
+
with self.assertNumQueries(1):
|
|
111
|
+
ct_id_2 = utils.get_content_type_id("test_app", "testmodel")
|
|
112
|
+
self.assertEqual(ct_id_1, ct_id_2)
|
|
@@ -1,84 +1,102 @@
|
|
|
1
|
-
"""Utility functions for Django Webhook Subscriber
|
|
1
|
+
"""Utility functions for Django Webhook Subscriber"""
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import contextvars
|
|
4
|
+
import uuid
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from functools import lru_cache
|
|
5
7
|
|
|
8
|
+
from django.conf import settings
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
10
|
+
# =============================================================================
|
|
11
|
+
# Header and secret generation
|
|
12
|
+
# =============================================================================
|
|
9
13
|
|
|
10
|
-
If no configuration is found, return None.
|
|
11
|
-
"""
|
|
12
|
-
return _webhook_registry.get(model_class, None)
|
|
13
14
|
|
|
15
|
+
def generate_headers(subscriber):
|
|
16
|
+
"""Generate headers for webhook delivery, including custom headers."""
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
"""Register a model class with its configuration.
|
|
18
|
+
headers = subscriber.headers.copy() if subscriber.headers else {}
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
# Add content type header if not present
|
|
21
|
+
if "Content-Type" not in headers:
|
|
22
|
+
headers["Content-Type"] = "application/json"
|
|
23
|
+
|
|
24
|
+
# Add secret key authentication header
|
|
25
|
+
headers["X-Secret"] = subscriber.secret
|
|
26
|
+
|
|
27
|
+
return headers
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def generate_secret():
|
|
31
|
+
"""Generate a new secret key for webhook authentication."""
|
|
32
|
+
|
|
33
|
+
return str(uuid.uuid4())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# =============================================================================
|
|
37
|
+
# Webhook enabling/disabling context manager
|
|
38
|
+
# =============================================================================
|
|
39
|
+
|
|
40
|
+
# We're using contextvars instead of thread locals because:
|
|
41
|
+
# - contextvars provide proper isolation in async environments (async tasks)
|
|
42
|
+
# - they automatically propagate through async/await boundaries
|
|
43
|
+
# - they work correctly with concurrent execution (asyncio, threading)
|
|
44
|
+
# - they ensure webhook state is isolated per request/task context
|
|
45
|
+
|
|
46
|
+
_webhooks_disabled = contextvars.ContextVar("webhooks_disabled", default=False)
|
|
21
47
|
|
|
22
|
-
# If signal_events is None, set it to all events
|
|
23
|
-
if events is None:
|
|
24
|
-
events = list(['CREATE', 'UPDATE', 'DELETE'])
|
|
25
|
-
else:
|
|
26
|
-
# Normalize signal events to uppercase
|
|
27
|
-
events = [event.upper() for event in events]
|
|
28
48
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
'serializer': serializer,
|
|
32
|
-
'events': events,
|
|
33
|
-
}
|
|
49
|
+
def webhooks_disabled():
|
|
50
|
+
"""Check if webhooks are currently disabled in this context."""
|
|
34
51
|
|
|
35
|
-
|
|
52
|
+
# Check context variable first
|
|
53
|
+
check_disabled = _webhooks_disabled.get()
|
|
36
54
|
|
|
55
|
+
# Check Django settings
|
|
56
|
+
settings_disabled = getattr(settings, "DISABLE_WEBHOOKS", False)
|
|
37
57
|
|
|
38
|
-
|
|
39
|
-
|
|
58
|
+
# Webhooks are disabled if either context or settings says so
|
|
59
|
+
return check_disabled or settings_disabled
|
|
40
60
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
61
|
+
|
|
62
|
+
@contextmanager
|
|
63
|
+
def disable_webhooks():
|
|
64
|
+
"""Context manager to temporarily disable webhooks.
|
|
65
|
+
|
|
66
|
+
Usage:
|
|
67
|
+
with disable_webhooks():
|
|
68
|
+
# Code that should not trigger webhooks
|
|
69
|
+
...
|
|
46
70
|
"""
|
|
47
71
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
webhook_post_delete,
|
|
80
|
-
sender=model_class,
|
|
81
|
-
dispatch_uid=f'webhook_post_delete_{model_class.__name__}',
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
return _webhook_registry
|
|
72
|
+
token = _webhooks_disabled.set(True)
|
|
73
|
+
try:
|
|
74
|
+
yield
|
|
75
|
+
finally:
|
|
76
|
+
_webhooks_disabled.reset(token)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# =============================================================================
|
|
80
|
+
# ContentType caching
|
|
81
|
+
# =============================================================================
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@lru_cache(maxsize=128)
|
|
85
|
+
def get_content_type_id(app_label, model_name) -> int:
|
|
86
|
+
"""Cached lookup for content type ID"""
|
|
87
|
+
|
|
88
|
+
from django.contrib.contenttypes.models import ContentType
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
return ContentType.objects.get(
|
|
92
|
+
app_label=app_label,
|
|
93
|
+
model=model_name,
|
|
94
|
+
).id
|
|
95
|
+
except ContentType.DoesNotExist:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def clear_content_type_cache():
|
|
100
|
+
"""Clear the content type cache"""
|
|
101
|
+
|
|
102
|
+
get_content_type_id.cache_clear()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Validators for Django Webhook Subscriber."""
|
|
2
|
+
|
|
3
|
+
from django.core.exceptions import ValidationError
|
|
4
|
+
from django.utils.module_loading import import_string
|
|
5
|
+
from rest_framework import serializers
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate_class_path(value):
|
|
9
|
+
"""Validator to check if the class path points to a valid class."""
|
|
10
|
+
|
|
11
|
+
# Allow empty values
|
|
12
|
+
if not value:
|
|
13
|
+
return
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
serializer_class = import_string(value)
|
|
17
|
+
# Check that the serializer_class is a rest_framework serializer
|
|
18
|
+
if not issubclass(serializer_class, serializers.Serializer):
|
|
19
|
+
raise ValueError(
|
|
20
|
+
"field_serializer must be a subclass of "
|
|
21
|
+
f"{serializers.Serializer}."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
except ImportError as e:
|
|
25
|
+
raise ValidationError(f"Cannot import class from path: {value}") from e
|
|
26
|
+
except (TypeError, AttributeError) as e:
|
|
27
|
+
# Handles cases where imported object is not a class
|
|
28
|
+
raise ValidationError(
|
|
29
|
+
f"Path '{value}' does not point to a valid class"
|
|
30
|
+
) from e
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def validate_headers(value):
|
|
34
|
+
"""
|
|
35
|
+
Validator to ensure headers is a dict with string keys and string values.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
if not isinstance(value, dict):
|
|
39
|
+
raise ValidationError("Headers must be a dictionary.")
|
|
40
|
+
|
|
41
|
+
for key, val in value.items():
|
|
42
|
+
if not isinstance(key, str):
|
|
43
|
+
raise ValidationError(f"Header key '{key}' must be a string.")
|
|
44
|
+
|
|
45
|
+
# Allow string or None values (some headers might be empty)
|
|
46
|
+
if val is not None and not isinstance(val, str):
|
|
47
|
+
raise ValidationError(
|
|
48
|
+
f"Header value for '{key}' must be a string or None."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Optional: validate header name format (HTTP spec)
|
|
52
|
+
if not key.replace("-", "").replace("_", "").isalnum():
|
|
53
|
+
raise ValidationError(f"Invalid header name format: '{key}'")
|