django-webhook-subscriber 1.0.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-1.0.0.dist-info/METADATA +0 -448
  38. django_webhook_subscriber-1.0.0.dist-info/RECORD +0 -33
  39. {django_webhook_subscriber-1.0.0.dist-info → django_webhook_subscriber-2.0.0.dist-info}/WHEEL +0 -0
  40. {django_webhook_subscriber-1.0.0.dist-info → django_webhook_subscriber-2.0.0.dist-info}/licenses/LICENSE +0 -0
  41. {django_webhook_subscriber-1.0.0.dist-info → django_webhook_subscriber-2.0.0.dist-info}/top_level.txt +0 -0
@@ -1,181 +1,358 @@
1
1
  """Models for Django Webhook Subscriber."""
2
2
 
3
- import uuid
4
-
3
+ from django.core.exceptions import ValidationError
5
4
  from django.db import models
5
+ from django.utils import timezone
6
6
  from django.utils.translation import gettext_lazy as _
7
- from django.contrib.contenttypes.models import ContentType
8
-
9
- from django_webhook_subscriber import managers
10
7
 
8
+ from .managers import WebhookDeliveryLogManager
9
+ from .utils import generate_secret
10
+ from .validators import validate_class_path, validate_headers
11
11
 
12
- class WebhookRegistry(models.Model):
13
- """Webhook model to store webhook configurations.
14
12
 
15
- This represents a webhook registration in the system, including the
16
- associated model, event types, endpoint URL, authentication details,
17
- and response handling settings. The model uses Django's content types
18
- framework to allow association with any model in the system.
13
+ class WebhookSubscriber(models.Model):
14
+ """
15
+ Represents an external service subscribing to receive webhooks from a
16
+ specific model.
19
17
  """
20
18
 
21
19
  id = models.AutoField(primary_key=True)
22
20
  name = models.CharField(
23
- max_length=255,
24
- help_text=_('Name of this webhook'),
21
+ max_length=255, help_text=_("Name of this subscriber")
25
22
  )
23
+ description = models.TextField(blank=True)
26
24
 
27
- # Model reference
25
+ # What model they're subscribing to
28
26
  content_type = models.ForeignKey(
29
- ContentType,
27
+ "contenttypes.ContentType",
30
28
  on_delete=models.CASCADE,
31
- help_text=_('The model this webhook is associated with'),
32
- )
33
- event_signals = models.JSONField(
34
- default=list,
35
- help_text=_(
36
- 'Event types that trigger this webhook (e.g., CREATE, UPDATE,'
37
- ' DELETE)'
38
- ),
29
+ help_text=_("The model this subscriber watches"),
39
30
  )
40
31
 
41
- # Webhook delivery settings
42
- endpoint = models.CharField(
43
- max_length=255,
44
- help_text=_('URL to send the webhook to'),
32
+ # Endpoint configuration
33
+ target_url = models.CharField(
34
+ max_length=500, # Increased length for long URLs
35
+ help_text=_("The base URL for webhook delivery"),
45
36
  )
46
37
  secret = models.CharField(
47
38
  max_length=64,
48
- default=uuid.uuid4,
39
+ default=generate_secret,
49
40
  help_text=_(
50
- 'Secret key for webhook authentication via X-Secret header'
41
+ "Secret key for webhook authentication via X-Secret header"
51
42
  ),
52
43
  )
53
- # Later on this would be a multiselect field
54
- headers = models.JSONField(
55
- default=dict,
44
+
45
+ serializer_class = models.CharField(
46
+ max_length=512,
56
47
  blank=True,
48
+ validators=[validate_class_path],
57
49
  help_text=_(
58
- 'Additional headers to send with the webhook (JSON format)'
50
+ "Dot path to DRF serializer class "
51
+ "(e.g., 'myapp.serializers.MySerializer')"
59
52
  ),
60
53
  )
61
54
 
62
- # Response handling
63
- keep_last_response = models.BooleanField(
64
- default=True,
65
- help_text=_('Whether to store the last response received'),
66
- )
67
- last_response = models.TextField(
68
- blank=True,
69
- null=True,
70
- help_text=_('Last response received from the webhook endpoint'),
71
- )
72
- last_success = models.DateTimeField(
73
- blank=True,
74
- null=True,
75
- help_text=_('Timestamp of last successful delivery'),
76
- )
77
- last_failure = models.DateTimeField(
55
+ # Headers and configuration
56
+ headers = models.JSONField(
57
+ default=dict,
78
58
  blank=True,
79
- null=True,
80
- help_text=_('Timestamp of last failed delivery'),
59
+ validators=[validate_headers],
60
+ help_text=_("Additional headers to send (JSON format)"),
81
61
  )
82
62
 
83
- # Async delivery settings
63
+ # Delivery settings
84
64
  max_retries = models.PositiveIntegerField(
85
- default=3,
86
- null=True,
87
- blank=True,
88
- help_text=_('Maximum number of delivery attempts'),
65
+ default=3, help_text=_("Max delivery attempts")
89
66
  )
90
67
  retry_delay = models.PositiveIntegerField(
91
- default=60,
92
- null=True,
93
- blank=True,
94
- help_text=_('Seconds to wait between retry attempts'),
68
+ default=60, help_text=_("Seconds between retries")
95
69
  )
96
- use_async = models.BooleanField(
97
- default=None,
98
- null=True,
99
- blank=True,
100
- help_text=_(
101
- 'Whether to use async delivery (None = use system default)'
102
- ),
70
+ timeout = models.PositiveIntegerField(
71
+ default=30, help_text=_("Request timeout in seconds")
103
72
  )
104
73
 
105
- # Metadata
106
- is_active = models.BooleanField(
107
- default=True,
108
- help_text=_('Whether this webhook is active'),
74
+ # Auto-disable settings
75
+ auto_disable_after_failures = models.PositiveIntegerField(
76
+ default=10,
77
+ help_text=_("Auto-disable after N consecutive failures (0 = never)"),
109
78
  )
79
+
80
+ # Status tracking
81
+ is_active = models.BooleanField(default=True)
82
+ consecutive_failures = models.PositiveIntegerField(default=0)
83
+ last_success = models.DateTimeField(null=True, blank=True)
84
+ last_failure = models.DateTimeField(null=True, blank=True)
85
+
86
+ # Metadata
110
87
  created_at = models.DateTimeField(auto_now_add=True)
111
88
  updated_at = models.DateTimeField(auto_now=True)
112
89
 
113
90
  class Meta:
114
- ordering = ['-created_at']
115
- db_table = 'django_webhook_subscriber_webhook_registry'
116
- verbose_name = _('Webhook')
117
- verbose_name_plural = _('Webhooks')
91
+ ordering = ["-created_at"]
92
+ db_table = "django_webhook_subscriber_webhook_subscriber"
93
+ verbose_name = _("Webhook Subscriber")
94
+ verbose_name_plural = _("Webhook Subscribers")
95
+ unique_together = (("target_url", "content_type"),)
96
+ indexes = [
97
+ models.Index(fields=["content_type", "is_active"]),
98
+ models.Index(fields=["is_active"]),
99
+ models.Index(fields=["content_type"]),
100
+ ]
118
101
 
119
102
  def __str__(self):
120
- return f'{self.name} - {self.endpoint}'
103
+ return f"{self.name} ({self.target_url})"
121
104
 
105
+ def clean(self):
106
+ """Additional validation."""
107
+ super().clean()
122
108
 
123
- class WebhookDeliveryLog(models.Model):
124
- """Webhook delivery log model to store delivery attempts and responses.
109
+ # Validate timeout is reasonable
110
+ if self.timeout > 300: # 5 minutes max
111
+ raise ValidationError("timeout cannot exceed 300 seconds")
125
112
 
126
- This model records each delivery attempt made to a webhook endpoint,
127
- including the payload sent, the response received, and any errors
128
- encountered. It is useful for debugging and monitoring webhook
129
- performance.
130
- """
113
+ # Validate retry settings
114
+ if self.retry_delay > 3600: # 1 hour max
115
+ raise ValidationError("retry_delay cannot exceed 3600 seconds")
116
+
117
+ @property
118
+ def model_class(self):
119
+ return self.content_type.model_class()
120
+
121
+ @property
122
+ def model_name(self):
123
+ return f"{self.content_type.app_label}.{self.content_type.model}"
124
+
125
+ def record_success(self):
126
+ """Record a successful delivery."""
127
+ from django.utils import timezone
128
+
129
+ self.consecutive_failures = 0
130
+ self.last_success = timezone.now()
131
+ self.save(update_fields=["consecutive_failures", "last_success"])
132
+
133
+ def record_failure(self):
134
+ """Record a failed delivery and handle auto-disable."""
135
+
136
+ self.consecutive_failures += 1
137
+ self.last_failure = timezone.now()
138
+
139
+ # Auto-disable if threshold reached
140
+ if (
141
+ self.auto_disable_after_failures > 0
142
+ and self.consecutive_failures >= self.auto_disable_after_failures
143
+ ):
144
+ self.is_active = False
145
+
146
+ self.save(
147
+ update_fields=["consecutive_failures", "last_failure", "is_active"]
148
+ )
149
+
150
+ def save(self, *args, **kwargs):
151
+ self.full_clean() # Always validate
152
+ super().save(*args, **kwargs)
153
+
154
+ # Clear cache on save
155
+ from .delivery import clear_webhook_cache
156
+
157
+ clear_webhook_cache(content_type=self.content_type)
158
+
159
+
160
+ class WebhookSubscription(models.Model):
161
+ """Individual event subscription for a subscriber."""
131
162
 
132
163
  id = models.AutoField(primary_key=True)
133
- webhook = models.ForeignKey(
134
- WebhookRegistry,
164
+ subscriber = models.ForeignKey(
165
+ WebhookSubscriber,
135
166
  on_delete=models.CASCADE,
136
- related_name='delivery_logs',
137
- help_text=_('The webhook that was delivered'),
138
- )
139
- # Later on this could be a choice field
140
- event_signal = models.CharField(
141
- max_length=255,
142
- help_text=_('The event type that triggered this delivery'),
167
+ related_name="subscriptions",
168
+ limit_choices_to=models.Q(is_active=True),
143
169
  )
144
170
 
145
- # Delivery details
146
- payload = models.JSONField(help_text=_('The payload that was sent'))
171
+ event_name = models.CharField(
172
+ max_length=100,
173
+ help_text=_("Event name (e.g., 'created', 'published', 'archived')"),
174
+ )
147
175
 
148
- # Delivery status
149
- response_status = models.IntegerField(
150
- null=True,
176
+ custom_endpoint = models.CharField(
177
+ max_length=512,
151
178
  blank=True,
152
- help_text=_('HTTP status code of the response'),
179
+ help_text=_("Optional custom endpoint path or full URL"),
153
180
  )
154
- response_body = models.TextField(
155
- null=True,
156
- blank=True,
157
- help_text=_('The body of the response received'),
181
+
182
+ # Status
183
+ is_active = models.BooleanField(default=True)
184
+
185
+ # Response handling
186
+ keep_last_response = models.BooleanField(
187
+ default=True,
188
+ help_text=_("Whether to store the last response received"),
158
189
  )
159
- error_message = models.TextField(
160
- null=True,
190
+ last_response = models.TextField(
161
191
  blank=True,
162
- help_text=_('Error message if the delivery failed'),
192
+ help_text=_("Last response received (truncated if too long)"),
163
193
  )
194
+ last_success = models.DateTimeField(blank=True, null=True)
195
+ last_failure = models.DateTimeField(blank=True, null=True)
196
+
197
+ # Added tracking fields
198
+ consecutive_failures = models.PositiveIntegerField(default=0)
199
+ total_deliveries = models.PositiveIntegerField(default=0)
200
+ successful_deliveries = models.PositiveIntegerField(default=0)
201
+
202
+ # Metadata
203
+ created_at = models.DateTimeField(auto_now_add=True)
204
+ updated_at = models.DateTimeField(auto_now=True)
205
+
206
+ class Meta:
207
+ ordering = ("subscriber__name", "event_name")
208
+ db_table = "django_webhook_subscriber_subscription"
209
+ verbose_name = _("Webhook Subscription")
210
+ verbose_name_plural = _("Webhook Subscriptions")
211
+ unique_together = (("subscriber", "event_name"),)
212
+ indexes = [
213
+ models.Index(fields=["subscriber", "event_name"]),
214
+ models.Index(fields=["is_active"]),
215
+ models.Index(fields=["subscriber", "is_active"]),
216
+ models.Index(fields=["event_name", "is_active"]),
217
+ ]
218
+
219
+ def __str__(self):
220
+ return f"{self.subscriber.name} - {self.event_name}"
221
+
222
+ @property
223
+ def endpoint(self):
224
+ """Full URL endpoint for this subscription."""
225
+ if not self.custom_endpoint:
226
+ return self.subscriber.target_url
227
+
228
+ # Check if custom_endpoint is a full URL
229
+ if self.custom_endpoint.startswith(("http://", "https://")):
230
+ return self.custom_endpoint
231
+
232
+ # Join base URL with endpoint path
233
+ base_url = self.subscriber.target_url.rstrip("/")
234
+ endpoint = self.custom_endpoint.lstrip("/")
235
+ return f"{base_url}/{endpoint}" if endpoint else base_url
236
+
237
+ @property
238
+ def success_rate(self):
239
+ """Calculate success rate percentage."""
240
+ if self.total_deliveries == 0:
241
+ return None
242
+ return (self.successful_deliveries / self.total_deliveries) * 100
243
+
244
+ def record_delivery_attempt(self, success=False, response_text=None):
245
+ """Record a delivery attempt."""
246
+
247
+ self.total_deliveries += 1
248
+
249
+ if success:
250
+ self.successful_deliveries += 1
251
+ self.consecutive_failures = 0
252
+ self.last_success = timezone.now()
253
+ else:
254
+ self.consecutive_failures += 1
255
+ self.last_failure = timezone.now()
256
+
257
+ # Store response if requested and not too large
258
+ if self.keep_last_response and response_text:
259
+ self.last_response = response_text[:1024] # Limit size
260
+
261
+ self.save(
262
+ update_fields=[
263
+ "total_deliveries",
264
+ "successful_deliveries",
265
+ "consecutive_failures",
266
+ "last_success",
267
+ "last_failure",
268
+ "last_response",
269
+ ]
270
+ )
271
+
272
+ # Proxy properties for backward compatibility
273
+ @property
274
+ def model_name(self):
275
+ return self.subscriber.model_name
276
+
277
+ @property
278
+ def content_type(self):
279
+ return self.subscriber.content_type
280
+
281
+ @property
282
+ def serializer_class(self):
283
+ return self.subscriber.serializer_class
284
+
285
+
286
+ class WebhookDeliveryLog(models.Model):
287
+ """Log of webhook delivery attempts."""
288
+
289
+ id = models.AutoField(primary_key=True)
290
+ subscription = models.ForeignKey(
291
+ WebhookSubscription,
292
+ on_delete=models.CASCADE,
293
+ related_name="delivery_logs", # Better name
294
+ )
295
+
296
+ # Retry tracking
297
+ attempt_number = models.PositiveSmallIntegerField(default=1)
298
+ is_retry = models.BooleanField(default=False)
299
+
300
+ # Payload and response data
301
+ payload = models.JSONField(default=dict)
302
+ response_status = models.PositiveIntegerField(null=True, blank=True)
303
+ response_body = models.TextField(blank=True)
304
+ response_headers = models.JSONField(default=dict, blank=True)
305
+
306
+ # Error tracking
307
+ error_message = models.TextField(blank=True)
308
+
309
+ # Delivery metadata
310
+ delivery_url = models.CharField(max_length=500)
311
+ delivery_duration_ms = models.PositiveIntegerField(null=True, blank=True)
164
312
 
165
313
  # Metadata
166
314
  created_at = models.DateTimeField(auto_now_add=True)
167
315
 
168
316
  # Manager
169
- objects = managers.WebhookDeliveryLogManager()
317
+ objects = WebhookDeliveryLogManager()
170
318
 
171
319
  class Meta:
172
- ordering = ['-created_at']
173
- db_table = 'django_webhook_subscriber_webhook_delivery_log'
174
- verbose_name = _('Webhook Delivery Log')
175
- verbose_name_plural = _('Webhook Delivery Logs')
320
+ ordering = ["-created_at"]
321
+ db_table = "django_webhook_subscriber_webhook_delivery_log"
322
+ verbose_name = _("Webhook Delivery Log")
323
+ verbose_name_plural = _("Webhook Delivery Logs")
324
+ indexes = [
325
+ models.Index(fields=["subscription", "-created_at"]),
326
+ models.Index(fields=["response_status"]),
327
+ models.Index(fields=["created_at"]),
328
+ models.Index(fields=["attempt_number"]),
329
+ ]
176
330
 
177
331
  def __str__(self):
178
- return (
179
- f'{self.webhook} - {self.event_signal} - '
180
- f'{self.response_status or "Failed"}'
181
- )
332
+ status = f" ({self.response_status})" if self.response_status else ""
333
+ retry_info = f" (retry {self.attempt_number})" if self.is_retry else ""
334
+ return f"{self.subscription}{status}{retry_info}"
335
+
336
+ @property
337
+ def event_name(self):
338
+ return self.subscription.event_name
339
+
340
+ @property
341
+ def is_success(self):
342
+ """Check if delivery was successful."""
343
+
344
+ # If there's an error message, it means an exception occurred
345
+ if self.error_message:
346
+ return False
347
+
348
+ return self.response_status and 200 <= self.response_status < 300
349
+
350
+ @property
351
+ def is_client_error(self):
352
+ """Check if error was client-side (4xx)."""
353
+ return self.response_status and 400 <= self.response_status < 500
354
+
355
+ @property
356
+ def is_server_error(self):
357
+ """Check if error was server-side (5xx)."""
358
+ return self.response_status and 500 <= self.response_status < 600
@@ -1,14 +1,9 @@
1
- """Serializers for Django Webhook Subscriber
1
+ """Serializers for Django Webhook Subscriber"""
2
2
 
3
- This module contains serializers for handling webhook events in Django REST
4
- Framework. It provides functionality to serialize model instances into a
5
- format suitable for webhook payloads.
6
- """
3
+ from rest_framework.serializers import Serializer, ModelSerializer
7
4
 
8
- from rest_framework import serializers
9
5
 
10
-
11
- def serialize_webhook_instance(instance, field_serializer):
6
+ def serialize_webhook_instance(instance, field_serializer=None):
12
7
  """Default serializer for webhook events.
13
8
 
14
9
  This function receives an instance, and serializes all its fields into a
@@ -17,30 +12,7 @@ def serialize_webhook_instance(instance, field_serializer):
17
12
  ValueError.
18
13
  """
19
14
 
20
- # Check that the field_serializer is a rest_framework serializer
21
- if not issubclass(field_serializer, serializers.Serializer):
22
- raise ValueError(
23
- 'field_serializer must be a subclass of rest_framework.Serializer'
24
- )
25
-
26
- # Create an instance of the serializer
27
- serializer = field_serializer(instance=instance)
28
- # Serialize the instance
29
- serialized_data = serializer.data
30
- # Return the serialized data
31
- return serialized_data
32
-
33
-
34
- def serialize_instance(instance, event_signal, field_serializer=None):
35
- """Serialize a model instance for a webhook event.
36
-
37
- This function takes a model instance, an event type, and an optional
38
- serializer class. It serializes the instance using the specified
39
- serializer class. If no serializer class is provided, it falls back to
40
- a default serializer that serializes all fields of the model instance.
41
- """
42
-
43
- class DefaultWebhookSerializer(serializers.ModelSerializer):
15
+ class DefaultWebhookSerializer(ModelSerializer):
44
16
  """Default serializer class for webhook events.
45
17
 
46
18
  This class is used to serialize all fields of a model instance into a
@@ -50,26 +22,20 @@ def serialize_instance(instance, event_signal, field_serializer=None):
50
22
 
51
23
  class Meta:
52
24
  model = instance.__class__
53
- fields = '__all__'
25
+ fields = "__all__"
54
26
 
55
- # TODO: need to have this integrated with rest_framework serializers
56
27
  if field_serializer is None:
57
28
  field_serializer = DefaultWebhookSerializer
58
29
 
59
- # Get model metadata
60
- model_class = instance.__class__
61
- app_label = model_class._meta.app_label
62
- model_name = model_class._meta.model_name
63
-
64
- # Create the payload structure
65
- payload = {
66
- 'pk': instance.pk,
67
- 'source': f'{app_label}.{model_name}',
68
- 'event_signal': event_signal,
69
- 'fields': serialize_webhook_instance(
70
- instance,
71
- field_serializer=field_serializer,
72
- ),
73
- }
30
+ # Check that the field_serializer is a rest_framework serializer
31
+ if not issubclass(field_serializer, Serializer):
32
+ raise ValueError(
33
+ "field_serializer must be a subclass of rest_framework.Serializer"
34
+ )
74
35
 
75
- return payload
36
+ # Create an instance of the serializer
37
+ serializer = field_serializer(instance=instance)
38
+ # Serialize the instance
39
+ serialized_data = serializer.data
40
+ # Return the serialized data
41
+ return serialized_data