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.
Files changed (41) hide show
  1. django_webhook_subscriber/__init__.py +7 -1
  2. django_webhook_subscriber/admin.py +831 -182
  3. django_webhook_subscriber/apps.py +3 -20
  4. django_webhook_subscriber/conf.py +11 -24
  5. django_webhook_subscriber/delivery.py +414 -159
  6. django_webhook_subscriber/http.py +51 -0
  7. django_webhook_subscriber/management/commands/webhook.py +169 -0
  8. django_webhook_subscriber/management/commands/webhook_cache.py +173 -0
  9. django_webhook_subscriber/management/commands/webhook_logs.py +226 -0
  10. django_webhook_subscriber/management/commands/webhook_performance_test.py +469 -0
  11. django_webhook_subscriber/management/commands/webhook_send.py +96 -0
  12. django_webhook_subscriber/management/commands/webhook_status.py +139 -0
  13. django_webhook_subscriber/managers.py +36 -14
  14. django_webhook_subscriber/migrations/0002_remove_webhookregistry_content_type_and_more.py +192 -0
  15. django_webhook_subscriber/models.py +291 -114
  16. django_webhook_subscriber/serializers.py +16 -50
  17. django_webhook_subscriber/tasks.py +434 -56
  18. django_webhook_subscriber/tests/factories.py +40 -0
  19. django_webhook_subscriber/tests/settings.py +27 -8
  20. django_webhook_subscriber/tests/test_delivery.py +453 -190
  21. django_webhook_subscriber/tests/test_http.py +32 -0
  22. django_webhook_subscriber/tests/test_managers.py +26 -37
  23. django_webhook_subscriber/tests/test_models.py +341 -251
  24. django_webhook_subscriber/tests/test_serializers.py +22 -56
  25. django_webhook_subscriber/tests/test_tasks.py +477 -189
  26. django_webhook_subscriber/tests/test_utils.py +98 -94
  27. django_webhook_subscriber/utils.py +87 -69
  28. django_webhook_subscriber/validators.py +53 -0
  29. django_webhook_subscriber-2.0.0.dist-info/METADATA +774 -0
  30. django_webhook_subscriber-2.0.0.dist-info/RECORD +38 -0
  31. django_webhook_subscriber/management/commands/check_webhook_tasks.py +0 -113
  32. django_webhook_subscriber/management/commands/clean_webhook_logs.py +0 -65
  33. django_webhook_subscriber/management/commands/test_webhook.py +0 -96
  34. django_webhook_subscriber/signals.py +0 -152
  35. django_webhook_subscriber/testing.py +0 -14
  36. django_webhook_subscriber/tests/test_signals.py +0 -268
  37. django_webhook_subscriber-0.4.0.dist-info/METADATA +0 -448
  38. django_webhook_subscriber-0.4.0.dist-info/RECORD +0 -33
  39. {django_webhook_subscriber-0.4.0.dist-info → django_webhook_subscriber-2.0.0.dist-info}/WHEEL +0 -0
  40. {django_webhook_subscriber-0.4.0.dist-info → django_webhook_subscriber-2.0.0.dist-info}/licenses/LICENSE +0 -0
  41. {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
- # Check if the configuration is stored correctly
27
- self.assertEqual(config['serializer'], 'UserSerializer')
28
- self.assertEqual(config['events'], ['CREATE', 'UPDATE'])
3
+ from django_webhook_subscriber import utils
29
4
 
30
- def test_register_model_config_default(self):
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
- # Check if the configuration is stored correctly
36
- self.assertIsNone(config['serializer'])
37
- self.assertEqual(config['events'], ['CREATE', 'UPDATE', 'DELETE'])
8
+ class GenerateHeadersFunctionTests(TestCase):
9
+ def setUp(self):
10
+ self.subscriber = WebhookSubscriberFactory()
38
11
 
39
- def test_get_webhook_config(self):
40
- # Register the User model with a serializer and events
41
- register_model_config(User, events=['CREATE', 'UPDATE'])
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
- # Retrieve the configuration for the User model
44
- config = get_webhook_config(User)
45
- self.assertIsNotNone(config)
46
- config = get_webhook_config('other_model')
47
- self.assertIsNone(config)
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
- # Disable all webhooks
56
- unregister_webhook_signals()
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
- # updating the user
63
- user.save()
64
- mock_delivery.assert_not_called()
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
- # deleting the user
67
- user.delete()
68
- mock_delivery.assert_not_called()
47
+ # Context manager should disable webhooks
48
+ with utils.disable_webhooks():
49
+ self.assertTrue(utils.webhooks_disabled())
69
50
 
70
- # creating a webhook delivery log
71
- WebhookRegistry.objects.create(
72
- name='Test Webhook',
73
- content_type=ContentType.objects.get_for_model(User),
74
- event_signals=['CREATE', 'UPDATE'],
75
- endpoint='http://example.com/webhook/',
76
- )
77
- mock_delivery.assert_not_called()
78
-
79
- @override_settings(WEBHOOK_SUBSCRIBER_MODELS={'auth.User': {}})
80
- @patch('django_webhook_subscriber.signals.process_webhook_event')
81
- def test_unregister_webhook_signals_specific_model(self, mock_delivery):
82
- register_webhook_signals(User)
83
- register_webhook_signals(WebhookRegistry)
84
-
85
- # Disable all webhooks
86
- unregister_webhook_signals(model_class=User)
87
-
88
- # creating a user
89
- user = User.objects.create(username='testuser')
90
- mock_delivery.assert_not_called()
91
-
92
- # updating the user
93
- user.save()
94
- mock_delivery.assert_not_called()
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
- # Verify that the webhook delivery log signal is still connected
108
- mock_delivery.assert_called()
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
- # Dictionary to store model -> configuration mappings
4
- _webhook_registry = {}
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
- def get_webhook_config(model_class):
8
- """Retrieve the webhook configuration for a model class.
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
- def register_model_config(model_class, serializer=None, events=None):
16
- """Register a model class with its configuration.
18
+ headers = subscriber.headers.copy() if subscriber.headers else {}
17
19
 
18
- Stores the model class and its configuration in the global
19
- registry - _webhook_registry.
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
- # Store the configuration in the registry
30
- _webhook_registry[model_class] = {
31
- 'serializer': serializer,
32
- 'events': events,
33
- }
49
+ def webhooks_disabled():
50
+ """Check if webhooks are currently disabled in this context."""
34
51
 
35
- return _webhook_registry[model_class]
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
- def unregister_webhook_signals(model_class=None):
39
- """Disable webhooks by disconnecting all signals.
58
+ # Webhooks are disabled if either context or settings says so
59
+ return check_disabled or settings_disabled
40
60
 
41
- If a specific model class is provided as an argument, only that model's
42
- signals will be disconnected. If no model class is provided, all signals
43
- will be disconnected.
44
- This function is useful for disabling webhooks in tests or when
45
- reconfiguring the webhook system.
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
- from django_webhook_subscriber.signals import (
49
- webhook_post_save,
50
- webhook_post_delete,
51
- )
52
- from django.db.models.signals import post_save, post_delete
53
-
54
- if model_class:
55
- # If a specific model class is provided, remove its entry from the
56
- # registry
57
- if model_class in _webhook_registry:
58
- post_save.disconnect(
59
- webhook_post_save,
60
- sender=model_class,
61
- dispatch_uid=f'webhook_post_save_{model_class.__name__}',
62
- )
63
- post_delete.disconnect(
64
- webhook_post_delete,
65
- sender=model_class,
66
- dispatch_uid=f'webhook_post_delete_{model_class.__name__}',
67
- )
68
- return _webhook_registry
69
-
70
- # Disconnect all signals
71
- for model_class in _webhook_registry.keys():
72
- # Disconnect signals for the model class
73
- post_save.disconnect(
74
- webhook_post_save,
75
- sender=model_class,
76
- dispatch_uid=f'webhook_post_save_{model_class.__name__}',
77
- )
78
- post_delete.disconnect(
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}'")