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,73 +1,451 @@
1
1
  """Tasks for Django Webhook Subscriber."""
2
2
 
3
- from celery import shared_task
4
-
5
-
6
- @shared_task(
7
- bind=True,
8
- max_retries=3, # Default to 3 retries
9
- default_retry_delay=60, # Wait 60 seconds between retries
10
- autoretry_for=(Exception,), # Retry for any exception
11
- retry_backoff=True, # Use exponential backoff
12
- )
13
- def async_deliver_webhook(self, webhook_id, payload, event_signal):
14
- """Asynchronously deliver a webhook.
15
-
16
- This task is designed to be called when a webhook delivery fails.
17
- It will retry the delivery based on the webhook's settings.
18
- The task will also respect the retry settings defined in the
19
- WebhookRegistry model.
20
- If the webhook has been deactivated, the task will not attempt
21
- to deliver the webhook again.
22
- """
23
-
24
- from django_webhook_subscriber.models import WebhookRegistry
25
- from django_webhook_subscriber.delivery import deliver_webhook
3
+ import logging
4
+ import time
5
+ from datetime import timedelta
6
+
7
+ import requests
8
+ from celery import group, shared_task
9
+ from django.utils import timezone
10
+
11
+ from .conf import rest_webhook_settings
12
+ from .http import webhook_session
13
+ from .utils import generate_headers
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ # =============================================================================
19
+ # Webhook delivery task with retry logic
20
+ # =============================================================================
21
+
22
+
23
+ @shared_task(bind=True)
24
+ def deliver_webhook(self, url, payload, subscription_id, attempt=1, **kwargs):
25
+ """Deliver a webhook with proper retry logic and error handling."""
26
+
27
+ from .models import WebhookDeliveryLog, WebhookSubscription
28
+
29
+ # Load subscription with error handling
30
+ try:
31
+ subscription = WebhookSubscription.objects.select_related(
32
+ "subscriber"
33
+ ).get(id=subscription_id)
34
+
35
+ except WebhookSubscription.DoesNotExist:
36
+ logger.error(f"Subscription {subscription_id} not found")
37
+ return {"error": "Subscription not found"}
38
+
39
+ # Check if subscription is still active
40
+ if not subscription.is_active or not subscription.subscriber.is_active:
41
+ logger.info(
42
+ f"Skipping delivery for inactive subscription {subscription_id}"
43
+ )
44
+ return {"skipped": "Subscription inactive"}
45
+
46
+ start_time = time.time()
47
+
48
+ # Create delivery log entry
49
+ log = WebhookDeliveryLog.objects.create(
50
+ subscription=subscription,
51
+ payload=payload,
52
+ delivery_url=url,
53
+ attempt_number=attempt,
54
+ is_retry=bool(attempt > 1),
55
+ )
56
+
57
+ try:
58
+ with webhook_session() as session:
59
+ # Generate headers for this specific request
60
+ headers = generate_headers(subscription.subscriber)
61
+
62
+ # Make the HTTP request
63
+ response = session.post(
64
+ url,
65
+ json=payload,
66
+ headers=headers,
67
+ timeout=subscription.subscriber.timeout,
68
+ )
69
+
70
+ # Calculate delivery duration
71
+ delivery_duration = int((time.time() - start_time) * 1000)
72
+
73
+ # Update log with response data
74
+ log.response_status = response.status_code
75
+ log.response_body = response.text[:10240] # Limit size
76
+ log.response_headers = dict(response.headers)
77
+ log.delivery_duration_ms = delivery_duration
78
+ log.save()
79
+
80
+ # Determine if delivery was successful
81
+ is_success = 200 <= response.status_code < 300
82
+
83
+ subscription.record_delivery_attempt(
84
+ success=is_success,
85
+ response_text=response.content,
86
+ )
87
+
88
+ # Update subscriber stats
89
+ if is_success:
90
+ subscription.subscriber.record_success()
91
+ logger.info(
92
+ f"Webhook delivered successfully to {url} "
93
+ f"(attempt {attempt}, {delivery_duration}ms)"
94
+ )
95
+ return {
96
+ "success": True,
97
+ "status_code": response.status_code,
98
+ "duration_ms": delivery_duration,
99
+ "attempt": attempt,
100
+ }
101
+
102
+ else:
103
+ # Handle HTTP error responses
104
+ subscription.subscriber.record_failure()
105
+ logger.warning(
106
+ f"Webhook delivery failed: {response.status_code} "
107
+ f"for {url} (attempt {attempt})"
108
+ )
109
+
110
+ should_retry = _should_retry_delivery(
111
+ subscription, attempt, response.status_code
112
+ )
113
+
114
+ if should_retry:
115
+ _schedule_retry(url, payload, subscription_id, attempt)
116
+
117
+ return {
118
+ "success": False,
119
+ "status_code": response.status_code,
120
+ "duration_ms": delivery_duration,
121
+ "attempt": attempt,
122
+ "will_retry": should_retry,
123
+ }
124
+ except requests.exceptions.Timeout as e:
125
+ error_msg = f"Timeout after {subscription.subscriber.timeout}s"
126
+ _handle_delivery_exception(
127
+ log, subscription, error_msg, url, payload, attempt
128
+ )
129
+ return {"error": f"Timeout error: {e=}", "attempt": attempt}
130
+
131
+ except requests.exceptions.ConnectionError as e:
132
+ error_msg = f"Connection error: {e=}"
133
+ _handle_delivery_exception(
134
+ log, subscription, error_msg, url, payload, attempt
135
+ )
136
+ return {"error": f"Connection error: {e=}", "attempt": attempt}
137
+
138
+ except Exception as e:
139
+ error_msg = f"Unexpected error: {e=}"
140
+ _handle_delivery_exception(
141
+ log, subscription, error_msg, url, payload, attempt
142
+ )
143
+
144
+ logger.error(
145
+ f"Unexpected error delivering webhook: {e=}",
146
+ exc_info=True,
147
+ )
148
+ return {"error": f"Unexpected error: {e=}", "attempt": attempt}
149
+
150
+
151
+ def _should_retry_delivery(subscription, attempt, status_code=None):
152
+ """Determine if a delivery should be retried."""
153
+
154
+ # Don't retry if max attempts reached
155
+ if attempt >= subscription.subscriber.max_retries + 1:
156
+ return False
157
+
158
+ # Don't retry client errors (4xx) - these won't succeed on retry
159
+ if status_code and 400 <= status_code < 500:
160
+ logger.info(f"Not retrying client error {status_code}")
161
+ return False
162
+
163
+ # Retry server errors (5xx) and network issues
164
+ return True
165
+
166
+
167
+ def _schedule_retry(url, payload, subscription_id, current_attempt):
168
+ """Schedule a retry for a failed delivery."""
169
+
170
+ from .models import WebhookSubscription
171
+
172
+ try:
173
+ subscription = WebhookSubscription.objects.get(id=subscription_id)
174
+ retry_delay = subscription.subscriber.retry_delay
175
+ next_attempt = current_attempt + 1
176
+
177
+ logger.info(
178
+ f"Scheduling retry {next_attempt} for subscription "
179
+ f"{subscription_id} in {retry_delay} seconds"
180
+ )
181
+
182
+ deliver_webhook.apply_async(
183
+ args=(url, payload, subscription_id),
184
+ kwargs={"attempt": next_attempt},
185
+ countdown=retry_delay,
186
+ )
187
+
188
+ except Exception as e:
189
+ logger.error(f"Failed to schedule retry: {e=}")
190
+
191
+
192
+ def _handle_delivery_exception(
193
+ log, subscription, error_msg, url, payload, attempt
194
+ ):
195
+ """Handle exceptions during delivery."""
196
+
197
+ # Update log with error details
198
+ log.error_message = error_msg
199
+ log.save()
200
+
201
+ # Update subscription and subscriber
202
+ subscription.record_delivery_attempt(success=False)
203
+ subscription.subscriber.record_failure()
204
+
205
+ logger.error(f"Webhook delivery error: {error_msg}")
206
+
207
+ # schedule retry if appropriate
208
+ should_retry = _should_retry_delivery(subscription, attempt)
209
+ if should_retry:
210
+ _schedule_retry(url, payload, subscription.id, attempt)
211
+
212
+
213
+ # =============================================================================
214
+ # Batch processing task
215
+ # =============================================================================
216
+
217
+
218
+ @shared_task
219
+ def process_webhook_delivery_batch(subscriptions):
220
+ if not subscriptions:
221
+ logger.warning("Empty subscription batch received")
222
+ return {"processed": 0, "error": "Empty batch"}
26
223
 
27
224
  try:
28
- webhook = WebhookRegistry.objects.get(pk=webhook_id)
225
+ batch_size = len(subscriptions)
226
+ batch_id = f"batch_{timezone.now().strftime('%Y%m%d_%H%M%S')}"
227
+
228
+ logger.info(
229
+ f"Processing webhook batch {batch_id} with {batch_size} deliveries"
230
+ )
231
+
232
+ # Validate subscriptions before processing
233
+ valid_subscriptions = []
234
+ for sub in subscriptions:
235
+ if _validate_subscription_data(sub):
236
+ valid_subscriptions.append(sub)
237
+
238
+ else:
239
+ logger.warning(f"Invalid subscription data: {sub}")
240
+
241
+ if not valid_subscriptions:
242
+ logger.error("No valid subscriptions in batch")
243
+ return {"processed": 0, "error": "No valid subscriptions"}
244
+
245
+ # Create delivery tasks
246
+ delivery_tasks = []
247
+ for sub in valid_subscriptions:
248
+ delivery_tasks.append(
249
+ deliver_webhook.s(
250
+ url=sub["url"],
251
+ payload=sub["payload"],
252
+ subscription_id=sub["id"],
253
+ )
254
+ )
255
+
256
+ # Execute tasks in parallel with error handling
257
+ job = group(delivery_tasks)
258
+ result = job.apply_async()
29
259
 
30
- # Override the retry settings if specified in the webhook
31
- if webhook.max_retries:
32
- self.max_retries = webhook.max_retries
33
- if webhook.retry_delay:
34
- self.default_retry_delay = webhook.retry_delay
260
+ logger.info(
261
+ f"Batch {batch_id} queued successfully with {len(delivery_tasks)} "
262
+ "tasks"
263
+ )
35
264
 
36
- # Skip delivery if webhook has been deactivated since the task was
37
- # queued
38
- if not webhook.is_active:
39
- return None
265
+ return {
266
+ "batch_id": batch_id,
267
+ "processed": len(delivery_tasks),
268
+ "total_requested": batch_size,
269
+ "task_ids": (
270
+ [task.id for task in delivery_tasks]
271
+ if hasattr(result, "children")
272
+ else []
273
+ ),
274
+ }
40
275
 
41
- delivery_log = deliver_webhook(webhook, payload, event_signal)
276
+ except Exception as e:
277
+ logger.error(
278
+ f"Error processing webhook delivery batch: {e=}", exc_info=True
279
+ )
280
+ return {"processed": 0, "error": f"{e=}"}
42
281
 
43
- return delivery_log.id
44
282
 
45
- except WebhookRegistry.DoesNotExist:
46
- # Webhook was deleted, no need to retry
47
- return None
48
- except Exception as exc:
49
- # For any other exception, rely on Celery's retry mechanism
50
- raise self.retry(exc=exc)
283
+ def _validate_subscription_data(subscription):
284
+ """Validate subscription data before processing."""
285
+
286
+ required_fields = ["id", "url", "payload"]
287
+ for field in required_fields:
288
+ if field not in subscription:
289
+ logger.warning(
290
+ f"Missing required field '{field}' in subscription data"
291
+ )
292
+ return False
293
+
294
+ return True
295
+
296
+
297
+ # =============================================================================
298
+ # Cleanup and maintenance tasks
299
+ # =============================================================================
51
300
 
52
301
 
53
302
  @shared_task
54
- def process_webhook_batch(webhook_ids, payload, event_signal):
55
- """Process a batch of webhooks.
303
+ def cleanup_webhook_logs(subscription_id=None, days=None):
304
+ """Cleanup old webhook delivery logs."""
305
+ from .models import WebhookDeliveryLog
306
+
307
+ try:
308
+ # Get retention period
309
+ retention_days = days or rest_webhook_settings.LOG_RETENTION_DAYS
310
+ cutoff_date = timezone.now() - timedelta(days=retention_days)
311
+
312
+ # Build query
313
+ query = WebhookDeliveryLog.objects.filter(created_at__lt=cutoff_date)
314
+
315
+ if subscription_id:
316
+ query = query.filter(subscription_id=subscription_id)
317
+
318
+ # Count before deletion
319
+ count = query.count()
320
+
321
+ if count == 0:
322
+ logger.info("No old webhook logs to clean up")
323
+ return {"deleted": 0, "cutoff_date": cutoff_date.isoformat()}
324
+
325
+ # Delete in batches to avoid memory issues
326
+ batch_size = 1000
327
+ total_deleted = 0
328
+
329
+ while True:
330
+ batch_ids = list(query.values_list("id", flat=True)[:batch_size])
331
+ if not batch_ids:
332
+ break
333
+
334
+ deleted_count = WebhookDeliveryLog.objects.filter(
335
+ id__in=batch_ids
336
+ ).delete()[0]
337
+ total_deleted += deleted_count
338
+
339
+ logger.info(
340
+ f"Deleted {deleted_count} webhook logs (total: "
341
+ f"{total_deleted})"
342
+ )
343
+
344
+ return {
345
+ "deleted": total_deleted,
346
+ "cutoff_date": cutoff_date.isoformat(),
347
+ "retention_days": retention_days,
348
+ }
349
+
350
+ except Exception as e:
351
+ logger.error(f"Error during webhook log cleanup: {e=}", exc_info=True)
352
+ return {"error": f"{e=}"}
353
+
354
+
355
+ @shared_task
356
+ def test_webhook_connectivity(subscriber_ids=None):
357
+ """Test connectivity to webhook endpoints."""
358
+
359
+ from .models import WebhookSubscriber
360
+
361
+ try:
362
+ # Get subscribers to test
363
+ query = WebhookSubscriber.objects.filter(is_active=True)
364
+ if subscriber_ids:
365
+ query = query.filter(id__in=subscriber_ids)
366
+
367
+ subscribers = list(query)
368
+
369
+ if not subscribers:
370
+ return {
371
+ "tested": 0,
372
+ "results": [],
373
+ "error": "No active subscribers found",
374
+ }
375
+
376
+ results = []
377
+
378
+ with webhook_session() as session:
379
+ for subscriber in subscribers:
380
+ result = _test_single_endpoint(session, subscriber)
381
+ results.append(result)
382
+
383
+ success_count = sum(1 for r in results if r["success"])
384
+
385
+ logger.info(
386
+ "Connectivity test completed: "
387
+ f"{success_count}/{len(subscribers)} successful"
388
+ )
389
+
390
+ return {
391
+ "tested": len(results),
392
+ "successful": success_count,
393
+ "successful_responses": sum(
394
+ 1
395
+ for r in results
396
+ if r["success"]
397
+ and r["status_code"]
398
+ and 200 <= r["status_code"] < 300
399
+ ),
400
+ "failed": len(results) - success_count,
401
+ "results": results,
402
+ }
403
+ except Exception as e:
404
+ logger.error(f"Error during connectivity test: {e=}", exc_info=True)
405
+ return {"error": f"{e=}"}
406
+
407
+
408
+ def _test_single_endpoint(session, subscriber):
409
+ """Test connectivity to a single subscriber endpoint."""
410
+
411
+ start_time = time.time()
412
+
413
+ try:
414
+ # Use HEAD request for testing (less intrusive)
415
+ headers = generate_headers(subscriber)
416
+
417
+ response = session.head(
418
+ subscriber.target_url,
419
+ headers=headers,
420
+ timeout=min(subscriber.timeout, 10),
421
+ )
56
422
 
57
- This task is designed to be called when a batch of webhooks needs
58
- to be delivered. It will create a group of tasks for each webhook
59
- and deliver them asynchronously.
60
- """
423
+ duration_ms = int((time.time() - start_time) * 1000)
61
424
 
62
- from celery import group
425
+ return {
426
+ "subscriber_id": subscriber.id,
427
+ "subscriber_name": subscriber.name,
428
+ "url": subscriber.target_url,
429
+ "success": True,
430
+ "status_code": response.status_code,
431
+ "duration_ms": duration_ms,
432
+ "error": None,
433
+ }
63
434
 
64
- tasks = [
65
- async_deliver_webhook.s(webhook_id, payload, event_signal)
66
- for webhook_id in webhook_ids
67
- ]
435
+ except Exception as e:
436
+ duration_ms = int((time.time() - start_time) * 1000)
68
437
 
69
- # Execute tasks in parallel
70
- if tasks:
71
- return group(tasks).apply_async()
438
+ logger.error(
439
+ f"Error testing webhook endpoint {subscriber.id}: {e=}",
440
+ exc_info=True,
441
+ )
72
442
 
73
- return None
443
+ return {
444
+ "subscriber_id": subscriber.id,
445
+ "subscriber_name": subscriber.name,
446
+ "url": subscriber.target_url,
447
+ "success": False,
448
+ "status_code": None,
449
+ "duration_ms": duration_ms,
450
+ "error": f"{e=}",
451
+ }
@@ -0,0 +1,40 @@
1
+ import factory
2
+
3
+
4
+ class ContentTypeFactory(factory.django.DjangoModelFactory):
5
+ class Meta:
6
+ model = "contenttypes.ContentType"
7
+ django_get_or_create = ("app_label", "model")
8
+
9
+ app_label = factory.Faker("word")
10
+ model = factory.Faker("word")
11
+
12
+
13
+ class WebhookSubscriberFactory(factory.django.DjangoModelFactory):
14
+ class Meta:
15
+ model = "django_webhook_subscriber.WebhookSubscriber"
16
+ django_get_or_create = ("target_url",)
17
+
18
+ name = factory.Faker("company")
19
+ description = factory.Faker("sentence")
20
+ content_type = factory.SubFactory(ContentTypeFactory)
21
+ target_url = factory.Faker("url")
22
+ secret = factory.Faker("sha256")
23
+
24
+
25
+ class WebhookSubscriptionFactory(factory.django.DjangoModelFactory):
26
+ class Meta:
27
+ model = "django_webhook_subscriber.WebhookSubscription"
28
+ django_get_or_create = ("subscriber", "event_name")
29
+
30
+ subscriber = factory.SubFactory(WebhookSubscriberFactory)
31
+ event_name = factory.Faker("word")
32
+
33
+
34
+ class WebhookDeliveryLogFactory(factory.django.DjangoModelFactory):
35
+ class Meta:
36
+ model = "django_webhook_subscriber.WebhookDeliveryLog"
37
+
38
+ subscription = factory.SubFactory(WebhookSubscriptionFactory)
39
+ payload = factory.Faker("json")
40
+ delivery_url = factory.Faker("url")
@@ -1,15 +1,15 @@
1
- SECRET_KEY = 'test_secret_key'
1
+ SECRET_KEY = "test_secret_key"
2
2
  INSTALLED_APPS = [
3
- 'django.contrib.contenttypes',
4
- 'django.contrib.auth',
5
- 'django.contrib.sessions',
6
- 'django_webhook_subscriber',
3
+ "django.contrib.contenttypes",
4
+ "django.contrib.auth",
5
+ "django.contrib.sessions",
6
+ "django_webhook_subscriber",
7
7
  ]
8
8
 
9
9
  DATABASES = {
10
- 'default': {
11
- 'ENGINE': 'django.db.backends.sqlite3',
12
- 'NAME': ':memory:',
10
+ "default": {
11
+ "ENGINE": "django.db.backends.sqlite3",
12
+ "NAME": ":memory:",
13
13
  }
14
14
  }
15
15
 
@@ -17,3 +17,22 @@ USE_TZ = True
17
17
 
18
18
  CELERY_TASK_ALWAYS_EAGER = True # Celery executes tasks synchronously
19
19
  CELERY_TASK_EAGER_PROPAGATES = True # Propagate exceptions to the caller
20
+
21
+
22
+ # Disabling logging for tests
23
+ LOGGING = {
24
+ "version": 1,
25
+ "disable_existing_loggers": True,
26
+ "handlers": {
27
+ "null": {
28
+ "class": "logging.NullHandler",
29
+ },
30
+ },
31
+ "loggers": {
32
+ "django_webhook_subscriber": {
33
+ "handlers": ["null"],
34
+ "level": "DEBUG",
35
+ "propagate": False,
36
+ },
37
+ },
38
+ }