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.
- 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-1.0.0.dist-info/METADATA +0 -448
- django_webhook_subscriber-1.0.0.dist-info/RECORD +0 -33
- {django_webhook_subscriber-1.0.0.dist-info → django_webhook_subscriber-2.0.0.dist-info}/WHEEL +0 -0
- {django_webhook_subscriber-1.0.0.dist-info → django_webhook_subscriber-2.0.0.dist-info}/licenses/LICENSE +0 -0
- {django_webhook_subscriber-1.0.0.dist-info → django_webhook_subscriber-2.0.0.dist-info}/top_level.txt +0 -0
|
@@ -1,208 +1,463 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Deliveries for Django Webhook Subscriber."""
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import logging
|
|
4
|
+
from collections import defaultdict
|
|
4
5
|
|
|
6
|
+
from django.core.cache import cache
|
|
5
7
|
from django.utils import timezone
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
from
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
from django.utils.module_loading import import_string
|
|
9
|
+
|
|
10
|
+
from .conf import rest_webhook_settings as settings
|
|
11
|
+
from .serializers import serialize_webhook_instance
|
|
12
|
+
from .tasks import process_webhook_delivery_batch
|
|
13
|
+
from .utils import (
|
|
14
|
+
clear_content_type_cache,
|
|
15
|
+
get_content_type_id,
|
|
16
|
+
webhooks_disabled,
|
|
11
17
|
)
|
|
12
18
|
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
13
20
|
|
|
14
|
-
def prepare_headers(webhook):
|
|
15
|
-
"""Prepare headers for the webhook request."""
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
class WebhookDeliveryProcessor:
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self.cache_ttl = getattr(settings, "WEBHOOK_CACHE_TTL", 300)
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
headers['Content-Type'] = 'application/json'
|
|
26
|
+
def send_webhook(self, instance, event_name, context=None, **kwargs):
|
|
27
|
+
"""Send webhooks for a given model instance and event."""
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
try:
|
|
30
|
+
# Check if webhooks are globally disabled
|
|
31
|
+
if webhooks_disabled():
|
|
32
|
+
logger.info(
|
|
33
|
+
"Webhooks are disabled, skipping delivery.",
|
|
34
|
+
extra={"instance": instance, "event_name": event_name},
|
|
35
|
+
)
|
|
36
|
+
return {"skipped": "Webhooks disabled"}
|
|
25
37
|
|
|
26
|
-
|
|
38
|
+
# Get active subscriptions for this model and event
|
|
39
|
+
subscriptions = self._get_subscriptions_cached(
|
|
40
|
+
instance, event_name
|
|
41
|
+
)
|
|
27
42
|
|
|
43
|
+
if not subscriptions:
|
|
44
|
+
logger.info(
|
|
45
|
+
"No subscriptions found, skipping delivery.",
|
|
46
|
+
extra={"instance": instance, "event_name": event_name},
|
|
47
|
+
)
|
|
48
|
+
return {"skipped": "No subscriptions"}
|
|
28
49
|
|
|
29
|
-
|
|
30
|
-
|
|
50
|
+
# Optimize payload generation by grouping serializer
|
|
51
|
+
subscription_groups = self._group_subscriptions_by_serializer(
|
|
52
|
+
subscriptions
|
|
53
|
+
)
|
|
31
54
|
|
|
32
|
-
|
|
55
|
+
# Generate payloads for each serializer group
|
|
56
|
+
for serializer_class, subs in subscription_groups.items():
|
|
57
|
+
payload = self._generate_payload(
|
|
58
|
+
instance, event_name, serializer_class
|
|
59
|
+
)
|
|
33
60
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
61
|
+
# Assign payload to all subscriptions in this group
|
|
62
|
+
for sub in subs:
|
|
63
|
+
sub["payload"] = payload
|
|
64
|
+
|
|
65
|
+
# Deliver the webhooks
|
|
66
|
+
result = self._deliver_webhooks(subscriptions)
|
|
67
|
+
|
|
68
|
+
logger.info(
|
|
69
|
+
f"Queued {len(subscriptions)} webhooks for {event_name}",
|
|
70
|
+
extra={"instance": instance, "event_name": event_name},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return result
|
|
40
74
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.error(
|
|
77
|
+
f"Error sending webhook: {e=}",
|
|
78
|
+
extra={"instance": instance, "event_name": event_name},
|
|
79
|
+
exc_info=True,
|
|
80
|
+
)
|
|
81
|
+
return {"error": f"Error sending webhook: {e=}"}
|
|
82
|
+
|
|
83
|
+
def _group_subscriptions_by_serializer(self, subscriptions):
|
|
84
|
+
"""Group subscriptions by their serializer class."""
|
|
85
|
+
|
|
86
|
+
# Using defaultdict to avoid key errors
|
|
87
|
+
groups = defaultdict(list)
|
|
88
|
+
for sub in subscriptions:
|
|
89
|
+
serializer_key = sub.get("serializer_class") or None
|
|
90
|
+
groups[serializer_key].append(sub)
|
|
91
|
+
|
|
92
|
+
return groups
|
|
93
|
+
|
|
94
|
+
def _get_subscriptions_cached(self, instance, event_name):
|
|
95
|
+
"""Retrieve active subscriptions with optimized caching."""
|
|
96
|
+
|
|
97
|
+
content_type_id = get_content_type_id(
|
|
98
|
+
instance._meta.app_label,
|
|
99
|
+
instance._meta.model_name,
|
|
52
100
|
)
|
|
53
101
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
102
|
+
if content_type_id is None:
|
|
103
|
+
logger.error(
|
|
104
|
+
f"Content type not found for {instance._meta.label}",
|
|
105
|
+
extra={"instance": instance},
|
|
106
|
+
)
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
cache_key = f"webhook_subscriptions:{content_type_id}:{event_name}"
|
|
110
|
+
|
|
111
|
+
# Try to get from cache
|
|
112
|
+
subscriptions = cache.get(cache_key)
|
|
113
|
+
|
|
114
|
+
if subscriptions is None:
|
|
115
|
+
# Cache miss - fetch from database
|
|
116
|
+
subscriptions = self._fetch_subscriptions_from_db(
|
|
117
|
+
content_type_id, event_name
|
|
118
|
+
)
|
|
58
119
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
webhook.last_response = response.content
|
|
120
|
+
# Cache the result
|
|
121
|
+
cache.set(cache_key, subscriptions, self.cache_ttl)
|
|
62
122
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
webhook.last_success = now
|
|
123
|
+
logger.debug(
|
|
124
|
+
f"Cached {len(subscriptions)} subscriptions for {cache_key} "
|
|
125
|
+
"key"
|
|
126
|
+
)
|
|
68
127
|
else:
|
|
69
|
-
|
|
128
|
+
logger.debug(
|
|
129
|
+
f"Cache hit for {cache_key}: {len(subscriptions)} "
|
|
130
|
+
"subscriptions"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return subscriptions
|
|
134
|
+
|
|
135
|
+
def _fetch_subscriptions_from_db(self, content_type_id, event_name):
|
|
136
|
+
"""Fetch subscriptions from database with optimizations."""
|
|
137
|
+
|
|
138
|
+
from .models import WebhookSubscription
|
|
139
|
+
|
|
140
|
+
qs = (
|
|
141
|
+
WebhookSubscription.objects.select_related("subscriber")
|
|
142
|
+
.filter(
|
|
143
|
+
is_active=True,
|
|
144
|
+
event_name=event_name,
|
|
145
|
+
subscriber__is_active=True,
|
|
146
|
+
subscriber__content_type_id=content_type_id,
|
|
147
|
+
)
|
|
148
|
+
.only(
|
|
149
|
+
# Only load fields we need
|
|
150
|
+
"id",
|
|
151
|
+
"custom_endpoint",
|
|
152
|
+
"subscriber_id",
|
|
153
|
+
"subscriber__target_url",
|
|
154
|
+
"subscriber__serializer_class",
|
|
155
|
+
)
|
|
156
|
+
)
|
|
70
157
|
|
|
71
|
-
|
|
158
|
+
# Converting to lightweight dict format for caching
|
|
159
|
+
subscriptions = []
|
|
160
|
+
for sub in qs:
|
|
161
|
+
subscription_data = {
|
|
162
|
+
"id": sub.id,
|
|
163
|
+
"subscriber_id": sub.subscriber_id,
|
|
164
|
+
"url": sub.endpoint, # This property handles URL logic
|
|
165
|
+
"serializer_class": sub.subscriber.serializer_class,
|
|
166
|
+
}
|
|
167
|
+
subscriptions.append(subscription_data)
|
|
72
168
|
|
|
73
|
-
|
|
74
|
-
# Handle errors
|
|
75
|
-
delivery_log.error_message = str(e)
|
|
76
|
-
delivery_log.save()
|
|
169
|
+
return subscriptions
|
|
77
170
|
|
|
78
|
-
|
|
79
|
-
webhook
|
|
80
|
-
webhook.save()
|
|
171
|
+
def _generate_payload(self, instance, event_name, serializer_class_path):
|
|
172
|
+
"""Generate webhook payload for a specific serializer."""
|
|
81
173
|
|
|
82
|
-
|
|
174
|
+
try:
|
|
175
|
+
serializer_class = None
|
|
176
|
+
|
|
177
|
+
# Import serializer class if specified
|
|
178
|
+
if serializer_class_path:
|
|
179
|
+
serializer_class = import_string(serializer_class_path)
|
|
180
|
+
|
|
181
|
+
# Serialize the instance
|
|
182
|
+
fields_data = serialize_webhook_instance(
|
|
183
|
+
instance, serializer_class
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Build standard payload structure
|
|
187
|
+
payload = {
|
|
188
|
+
"pk": instance.pk,
|
|
189
|
+
"event_signal": event_name,
|
|
190
|
+
"source": f"{instance._meta.app_label}."
|
|
191
|
+
f"{instance._meta.model_name}",
|
|
192
|
+
"timestamp": timezone.now().isoformat(),
|
|
193
|
+
"fields": fields_data,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return payload
|
|
197
|
+
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.error(
|
|
200
|
+
f"Error generating payload: {e=}",
|
|
201
|
+
extra={"instance": instance, "event_name": event_name},
|
|
202
|
+
exc_info=True,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
"pk": getattr(instance, "pk", None),
|
|
207
|
+
"event_signal": event_name,
|
|
208
|
+
"source": f"{instance._meta.app_label}."
|
|
209
|
+
f"{instance._meta.model_name}",
|
|
210
|
+
"timestamp": timezone.now().isoformat(),
|
|
211
|
+
"error": f"Serialization failed: {e=}",
|
|
212
|
+
"fields": {},
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
def _deliver_webhooks(self, subscriptions):
|
|
216
|
+
"""Deliver webhooks with batch processing and size limits."""
|
|
217
|
+
|
|
218
|
+
if not subscriptions:
|
|
219
|
+
return {"processed": 0}
|
|
220
|
+
|
|
221
|
+
# Splitting large batches to avoid overwhelming Celery
|
|
222
|
+
max_batch_size = getattr(settings, "MAX_BATCH_SIZE")
|
|
223
|
+
|
|
224
|
+
if len(subscriptions) <= max_batch_size:
|
|
225
|
+
# Single batch
|
|
226
|
+
return self._process_single_batch(subscriptions)
|
|
83
227
|
|
|
228
|
+
else:
|
|
229
|
+
# Multiple batches
|
|
230
|
+
return self._process_multiple_batches(
|
|
231
|
+
subscriptions, max_batch_size
|
|
232
|
+
)
|
|
84
233
|
|
|
85
|
-
def
|
|
86
|
-
|
|
234
|
+
def _process_single_batch(self, subscriptions):
|
|
235
|
+
"""Process a single batch of webhook deliveries."""
|
|
87
236
|
|
|
88
|
-
|
|
237
|
+
try:
|
|
238
|
+
result = process_webhook_delivery_batch.delay(subscriptions)
|
|
239
|
+
return {
|
|
240
|
+
"processed": len(subscriptions),
|
|
241
|
+
"batches": 1,
|
|
242
|
+
"task_id": result.id,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
except Exception as e:
|
|
246
|
+
logger.error(f"Failed to queue webhook batch: {e=}", exc_info=True)
|
|
247
|
+
return {"error": f"{e=}", "processed": 0}
|
|
248
|
+
|
|
249
|
+
def _process_multiple_batches(self, subscriptions, batch_size):
|
|
250
|
+
"""Process multiple batches of webhook deliveries."""
|
|
251
|
+
|
|
252
|
+
batches = []
|
|
253
|
+
task_ids = []
|
|
254
|
+
total_processed = 0
|
|
255
|
+
|
|
256
|
+
# Splitting into chunks
|
|
257
|
+
for i in range(0, len(subscriptions), batch_size):
|
|
258
|
+
batch = subscriptions[i:i + batch_size] # fmt: skip
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
result = process_webhook_delivery_batch.delay(batch)
|
|
262
|
+
batches.append({"size": len(batch), "task_id": result.id})
|
|
263
|
+
task_ids.append(result.id)
|
|
264
|
+
total_processed += len(batch)
|
|
265
|
+
|
|
266
|
+
except Exception as e:
|
|
267
|
+
logger.error(
|
|
268
|
+
f"Failed to queue webhook batch {i//batch_size + 1}: {e=}"
|
|
269
|
+
)
|
|
270
|
+
batches.append({"size": len(batch), "error": f"{e=}"})
|
|
271
|
+
logger.info(
|
|
272
|
+
f"Queued {len(batches)} batches for {total_processed} webhook "
|
|
273
|
+
"deliveries"
|
|
274
|
+
)
|
|
89
275
|
|
|
90
|
-
|
|
91
|
-
|
|
276
|
+
return {
|
|
277
|
+
"processed": total_processed,
|
|
278
|
+
"batches": len(batches),
|
|
279
|
+
"batch_details": batches,
|
|
280
|
+
"task_ids": task_ids,
|
|
281
|
+
}
|
|
92
282
|
|
|
93
|
-
|
|
283
|
+
def clear_webhook_cache(self, content_type=None, event_name=None):
|
|
284
|
+
"""Clear cached webhook subscription data."""
|
|
94
285
|
|
|
95
|
-
|
|
96
|
-
return WebhookRegistry.objects.filter(
|
|
97
|
-
content_type=content_type,
|
|
98
|
-
is_active=True,
|
|
99
|
-
)
|
|
286
|
+
from .models import WebhookSubscription
|
|
100
287
|
|
|
288
|
+
if content_type and event_name:
|
|
289
|
+
# Clear specific cache key
|
|
290
|
+
cache_key = f"webhook_subscriptions:{content_type.id}:{event_name}"
|
|
291
|
+
cache.delete(cache_key)
|
|
292
|
+
logger.debug(f"Cleared cache for {cache_key}")
|
|
101
293
|
|
|
102
|
-
|
|
103
|
-
|
|
294
|
+
elif content_type:
|
|
295
|
+
# Clear all cache keys for this content type
|
|
296
|
+
event_names = (
|
|
297
|
+
WebhookSubscription.objects.filter(
|
|
298
|
+
subscriber__content_type=content_type,
|
|
299
|
+
)
|
|
300
|
+
.values_list("event_name", flat=True)
|
|
301
|
+
.distinct()
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
cleared_count = 0
|
|
305
|
+
for event_name in event_names:
|
|
306
|
+
cache_key = (
|
|
307
|
+
f"webhook_subscriptions:{content_type.id}:{event_name}"
|
|
308
|
+
)
|
|
309
|
+
cache.delete(cache_key)
|
|
310
|
+
cleared_count += 1
|
|
311
|
+
logger.debug(
|
|
312
|
+
f"Cleared {cleared_count} cache keys for content type "
|
|
313
|
+
f"{content_type}"
|
|
314
|
+
)
|
|
104
315
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
316
|
+
else:
|
|
317
|
+
# Clear all webhook caches (use pattern-based deletion if
|
|
318
|
+
# available)
|
|
319
|
+
if hasattr(cache, "delete_pattern"):
|
|
320
|
+
# Redis backend supports pattern deletion
|
|
321
|
+
deleted = cache.delete_pattern("webhook_subscriptions:*")
|
|
322
|
+
logger.debug(
|
|
323
|
+
f"Cleared {deleted} webhook cache keys using pattern"
|
|
324
|
+
)
|
|
325
|
+
else:
|
|
326
|
+
# Fallback: clear entire cache
|
|
327
|
+
cache.clear()
|
|
328
|
+
logger.warning(
|
|
329
|
+
"Cleared entire cache (pattern deletion not supported)"
|
|
330
|
+
)
|
|
108
331
|
|
|
109
|
-
|
|
110
|
-
|
|
332
|
+
def get_cache_stats(self):
|
|
333
|
+
"""Get webhook cache statistics."""
|
|
111
334
|
|
|
335
|
+
# Get all possible cache keys
|
|
336
|
+
cache_keys = self._get_all_cache_keys()
|
|
112
337
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
"""Process and deliver the webhook for a given instance and event type.
|
|
338
|
+
stats = {
|
|
339
|
+
"total_possible_keys": len(cache_keys),
|
|
340
|
+
"cached_keys": 0,
|
|
341
|
+
"total_cached_subscriptions": 0,
|
|
342
|
+
"cache_hit_ratio": 0.0,
|
|
343
|
+
"key_details": [],
|
|
344
|
+
}
|
|
121
345
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
using the specified serializer (if any) and calls the delivery function to
|
|
125
|
-
send the webhook.
|
|
126
|
-
If the webhook is configured for async delivery, it will be processed
|
|
127
|
-
asynchronously using Celery tasks. If the webhook is configured for
|
|
128
|
-
synchronous delivery, it will be processed immediately.
|
|
129
|
-
"""
|
|
346
|
+
for key in cache_keys:
|
|
347
|
+
cached_data = cache.get(key)
|
|
130
348
|
|
|
131
|
-
|
|
132
|
-
|
|
349
|
+
key_info = {
|
|
350
|
+
"key": key,
|
|
351
|
+
"is_cached": cached_data is not None,
|
|
352
|
+
"subscription_count": len(cached_data) if cached_data else 0,
|
|
353
|
+
}
|
|
133
354
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
355
|
+
if cached_data is not None:
|
|
356
|
+
stats["cached_keys"] += 1
|
|
357
|
+
stats["total_cached_subscriptions"] += len(cached_data)
|
|
137
358
|
|
|
138
|
-
|
|
139
|
-
if serialized_payload is None:
|
|
140
|
-
from django_webhook_subscriber.serializers import serialize_instance
|
|
359
|
+
stats["key_details"].append(key_info)
|
|
141
360
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
361
|
+
# Calculate hit ratio
|
|
362
|
+
if stats["total_possible_keys"] > 0:
|
|
363
|
+
stats["cache_hit_ratio"] = (
|
|
364
|
+
stats["cached_keys"] / stats["total_possible_keys"]
|
|
365
|
+
) * 100
|
|
147
366
|
|
|
148
|
-
|
|
149
|
-
webhooks = get_webhook_for_model(instance)
|
|
150
|
-
delivery_logs = []
|
|
367
|
+
return stats
|
|
151
368
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
async_webhooks = []
|
|
369
|
+
def _get_all_cache_keys(self):
|
|
370
|
+
"""Get all possible webhook cache keys."""
|
|
155
371
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
event_mapping = {
|
|
159
|
-
'created': 'CREATE',
|
|
160
|
-
'updated': 'UPDATE',
|
|
161
|
-
'deleted': 'DELETE',
|
|
162
|
-
}
|
|
163
|
-
registry_event = event_mapping.get(event_signal, event_signal)
|
|
372
|
+
# Query all unique content_type + event_name combinations
|
|
373
|
+
from .models import WebhookSubscription
|
|
164
374
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
375
|
+
combinations = WebhookSubscription.objects.values_list(
|
|
376
|
+
"subscriber__content_type_id", "event_name"
|
|
377
|
+
).distinct()
|
|
168
378
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
sync_webhooks.append(webhook)
|
|
379
|
+
cache_keys = []
|
|
380
|
+
for content_type_id, event_name in combinations:
|
|
381
|
+
cache_key = f"webhook_subscriptions:{content_type_id}:{event_name}"
|
|
382
|
+
cache_keys.append(cache_key)
|
|
174
383
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
384
|
+
return cache_keys
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# Global instance of the delivery processor
|
|
388
|
+
webhook_delivery_processor = WebhookDeliveryProcessor()
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def send_webhooks(instance, event_name, context=None, **kwargs):
|
|
392
|
+
"""Send webhooks for a given model instance and event."""
|
|
393
|
+
|
|
394
|
+
return webhook_delivery_processor.send_webhook(
|
|
395
|
+
instance,
|
|
396
|
+
event_name,
|
|
397
|
+
context=context,
|
|
398
|
+
**kwargs,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def clear_webhook_cache(content_type=None, event_name=None):
|
|
403
|
+
"""Clear all cached data from the delivery process."""
|
|
404
|
+
|
|
405
|
+
webhook_delivery_processor.clear_webhook_cache(
|
|
406
|
+
content_type=content_type, event_name=event_name
|
|
407
|
+
)
|
|
408
|
+
# Clear cached data from get_content_type_id function
|
|
409
|
+
clear_content_type_cache()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def get_webhook_cache_stats():
|
|
413
|
+
"""Get statistics about the webhook subscription cache."""
|
|
414
|
+
|
|
415
|
+
return webhook_delivery_processor.get_cache_stats()
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def warm_webhook_cache():
|
|
419
|
+
"""Pre-fill the webhook cache by loading all active subscription
|
|
420
|
+
combinations."""
|
|
421
|
+
|
|
422
|
+
processor = webhook_delivery_processor
|
|
423
|
+
warmed_count = 0
|
|
424
|
+
|
|
425
|
+
from django.contrib.contenttypes.models import ContentType
|
|
426
|
+
|
|
427
|
+
from .models import WebhookSubscription
|
|
428
|
+
|
|
429
|
+
# Get all unique combinations of active subscriptions
|
|
430
|
+
combinations = (
|
|
431
|
+
WebhookSubscription.objects.filter(
|
|
432
|
+
is_active=True,
|
|
433
|
+
subscriber__is_active=True,
|
|
205
434
|
)
|
|
206
|
-
|
|
435
|
+
.values_list("subscriber__content_type_id", "event_name")
|
|
436
|
+
.distinct()
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
for content_type_id, event_name in combinations:
|
|
440
|
+
try:
|
|
441
|
+
|
|
442
|
+
content_type = ContentType.objects.get(id=content_type_id)
|
|
443
|
+
model_class = content_type.model_class()
|
|
207
444
|
|
|
208
|
-
|
|
445
|
+
if model_class:
|
|
446
|
+
# Create a minimal dummy instance for cache warming
|
|
447
|
+
dummy_instance = model_class()
|
|
448
|
+
dummy_instance._meta = model_class._meta
|
|
449
|
+
|
|
450
|
+
# This will populate the cache
|
|
451
|
+
processor._get_subscriptions_cached(dummy_instance, event_name)
|
|
452
|
+
warmed_count += 1
|
|
453
|
+
|
|
454
|
+
except Exception as e:
|
|
455
|
+
logger.warning(
|
|
456
|
+
f"Could not warm cache for {content_type_id}:{event_name}: "
|
|
457
|
+
f"{e=}"
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
logger.info(
|
|
461
|
+
f"Warmed webhook cache for {warmed_count} subscription combinations"
|
|
462
|
+
)
|
|
463
|
+
return {"warmed": warmed_count}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""HTTP session management for webhook delivery."""
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from requests.adapters import HTTPAdapter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_webhook_session():
|
|
10
|
+
"""Create an optimized session for webhook delivery."""
|
|
11
|
+
|
|
12
|
+
session = requests.Session()
|
|
13
|
+
|
|
14
|
+
# Configure adapter for connection pooling
|
|
15
|
+
adapter = HTTPAdapter(
|
|
16
|
+
pool_connections=2,
|
|
17
|
+
pool_maxsize=5, # Max connections per host
|
|
18
|
+
max_retries=0, # We handle the retries
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Mount adapter for both HTTP and HTTPS
|
|
22
|
+
session.mount("http://", adapter)
|
|
23
|
+
session.mount("https://", adapter)
|
|
24
|
+
|
|
25
|
+
# Set default handlers
|
|
26
|
+
session.headers.update(
|
|
27
|
+
{
|
|
28
|
+
"User-Agent": "Django-Webhook-Subscriber/2.0",
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
return session
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@contextmanager
|
|
37
|
+
def webhook_session():
|
|
38
|
+
"""Context manager for webhook session.
|
|
39
|
+
|
|
40
|
+
Usage:
|
|
41
|
+
with webhook_session() as session:
|
|
42
|
+
# Use session to send requests like
|
|
43
|
+
response = session.post(url, json=payload)
|
|
44
|
+
...
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
session = create_webhook_session()
|
|
48
|
+
try:
|
|
49
|
+
yield session
|
|
50
|
+
finally:
|
|
51
|
+
session.close()
|