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
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""webhook_send command for Django Webhook Subscriber."""
|
|
2
|
+
|
|
3
|
+
import statistics
|
|
4
|
+
import time
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
7
|
+
|
|
8
|
+
from django.apps import apps
|
|
9
|
+
from django.contrib.contenttypes.models import ContentType
|
|
10
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
11
|
+
from django.utils import timezone
|
|
12
|
+
from django_webhook_subscriber.delivery import send_webhooks
|
|
13
|
+
from django_webhook_subscriber.models import (
|
|
14
|
+
WebhookDeliveryLog,
|
|
15
|
+
WebhookSubscription,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Command(BaseCommand):
|
|
20
|
+
help = "Performance test webhook system with concurrent deliveries"
|
|
21
|
+
|
|
22
|
+
def add_arguments(self, parser):
|
|
23
|
+
# Test configuration
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--model",
|
|
26
|
+
type=str,
|
|
27
|
+
required=True,
|
|
28
|
+
help="Model in format app_label.ModelName",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--event",
|
|
32
|
+
type=str,
|
|
33
|
+
required=True,
|
|
34
|
+
help="Event name to test",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--object-ids",
|
|
38
|
+
type=str,
|
|
39
|
+
help="Comma-separated list of object IDs to test "
|
|
40
|
+
"(e.g., '1,2,3,4,5')",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--object-count",
|
|
44
|
+
type=int,
|
|
45
|
+
help="Number of random objects to test (alternative to "
|
|
46
|
+
"--object-ids)",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Concurrency settings
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--concurrent-webhooks",
|
|
52
|
+
type=int,
|
|
53
|
+
default=10,
|
|
54
|
+
help="Number of concurrent webhook sends (default: 10)",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--batches",
|
|
58
|
+
type=int,
|
|
59
|
+
default=1,
|
|
60
|
+
help="Number of batches to run (default: 1)",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Test options
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--dry-run",
|
|
66
|
+
action="store_true",
|
|
67
|
+
help="Show what would be tested without actually sending webhooks",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--measure-delivery",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help="Wait for and measure actual webhook delivery times.",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--timeout",
|
|
76
|
+
type=int,
|
|
77
|
+
default=60,
|
|
78
|
+
help="Timeout in seconds to wait for delivery when measuring "
|
|
79
|
+
"(default: 60)",
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--warmup",
|
|
83
|
+
action="store_true",
|
|
84
|
+
help="Run a warmup round before the actual test",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def handle(self, *args, **options):
|
|
88
|
+
# Validate and setup
|
|
89
|
+
model_class, test_objects = self.setup_test(options)
|
|
90
|
+
|
|
91
|
+
# Show test plan
|
|
92
|
+
self.show_test_plan(model_class, test_objects, options)
|
|
93
|
+
|
|
94
|
+
# Warmup if requested
|
|
95
|
+
if options["warmup"]:
|
|
96
|
+
self.run_warmup(test_objects, options)
|
|
97
|
+
|
|
98
|
+
# Run the actual performance tests
|
|
99
|
+
results = self.run_performance_test(test_objects, options)
|
|
100
|
+
|
|
101
|
+
# Show results
|
|
102
|
+
self.show_results(results, options)
|
|
103
|
+
|
|
104
|
+
def setup_test(self, options):
|
|
105
|
+
# Parse and validate model
|
|
106
|
+
try:
|
|
107
|
+
app_label, model_name = options["model"].split(".")
|
|
108
|
+
model_class = apps.get_model(app_label, model_name)
|
|
109
|
+
except (ValueError, LookupError) as e:
|
|
110
|
+
raise CommandError(f"Invalid model: {options['model']}: {e}")
|
|
111
|
+
|
|
112
|
+
# Get test objects
|
|
113
|
+
if options.get("object_ids"):
|
|
114
|
+
object_ids = [
|
|
115
|
+
int(x.strip()) for x in options["object_ids"].split(",")
|
|
116
|
+
]
|
|
117
|
+
test_objects = list(model_class.objects.filter(id__in=object_ids))
|
|
118
|
+
|
|
119
|
+
if len(test_objects) != len(object_ids):
|
|
120
|
+
found_ids = [obj.id for obj in test_objects]
|
|
121
|
+
missing_ids = set(object_ids) - set(found_ids)
|
|
122
|
+
raise CommandError(f"Objects not found: {missing_ids}")
|
|
123
|
+
|
|
124
|
+
elif options.get("object_count"):
|
|
125
|
+
count = options["object_count"]
|
|
126
|
+
test_objects = list(model_class.objects.all()[:count])
|
|
127
|
+
|
|
128
|
+
if len(test_objects) < count:
|
|
129
|
+
raise CommandError(
|
|
130
|
+
f"Only {len(test_objects)} objects available, {count} "
|
|
131
|
+
"requested"
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
raise CommandError("Specify either --object-ids or --object-count")
|
|
135
|
+
|
|
136
|
+
# Verify subscriptions exist
|
|
137
|
+
content_type = ContentType.objects.get_for_model(model_class)
|
|
138
|
+
subscription_count = WebhookSubscription.objects.filter(
|
|
139
|
+
subscriber__content_type=content_type,
|
|
140
|
+
event_name=options["event"],
|
|
141
|
+
is_active=True,
|
|
142
|
+
subscriber__is_active=True,
|
|
143
|
+
).count()
|
|
144
|
+
|
|
145
|
+
if subscription_count == 0:
|
|
146
|
+
raise CommandError(
|
|
147
|
+
f"No active subscriptions found for {options['model']} - "
|
|
148
|
+
f"{options['event']}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return model_class, test_objects
|
|
152
|
+
|
|
153
|
+
def show_test_plan(self, model_class, test_objects, options):
|
|
154
|
+
|
|
155
|
+
total_webhooks = len(test_objects) * options["batches"]
|
|
156
|
+
concurrent = options["concurrent_webhooks"]
|
|
157
|
+
|
|
158
|
+
self.stdout.write(self.style.SUCCESS("Performance Test Plan"))
|
|
159
|
+
self.stdout.write("-" * 40)
|
|
160
|
+
self.stdout.write(f"Model: {model_class.__name__}")
|
|
161
|
+
self.stdout.write(f"Event: {options['event']}")
|
|
162
|
+
self.stdout.write(f"Objects to test: {len(test_objects)}")
|
|
163
|
+
self.stdout.write(f"Batches: {options['batches']}")
|
|
164
|
+
self.stdout.write(f"Total webhooks: {total_webhooks}")
|
|
165
|
+
self.stdout.write(f"Concurrent sends: {concurrent}")
|
|
166
|
+
|
|
167
|
+
if options["measure_delivery"]:
|
|
168
|
+
self.stdout.write(f"Delivery timeout: {options['timeout']}s")
|
|
169
|
+
|
|
170
|
+
if options["dry_run"]:
|
|
171
|
+
self.stdout.write(
|
|
172
|
+
self.style.WARNING("DRY RUN - No webhooks will be sent")
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def run_warmup(self, test_objects, options):
|
|
176
|
+
self.stdout.write("\nRunning warmup...")
|
|
177
|
+
|
|
178
|
+
# Use first object for warmup
|
|
179
|
+
warmup_object = test_objects[0]
|
|
180
|
+
|
|
181
|
+
start_time = time.time()
|
|
182
|
+
result = send_webhooks(warmup_object, options["event"])
|
|
183
|
+
self.stdout.write(
|
|
184
|
+
f"Warmup completed in {time.time() - start_time:.2f}s: {result}"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Give systems time to settle
|
|
188
|
+
time.sleep(2)
|
|
189
|
+
|
|
190
|
+
def run_performance_test(self, test_objects, options):
|
|
191
|
+
self.stdout.write("\nStarting performance test...")
|
|
192
|
+
|
|
193
|
+
all_results = []
|
|
194
|
+
|
|
195
|
+
for batch_num in range(options["batches"]):
|
|
196
|
+
if options["batches"] > 1:
|
|
197
|
+
self.stdout.write(
|
|
198
|
+
f"\nBatch {batch_num + 1}/{options['batches']}"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
batch_results = self.run_single_batch(test_objects, options)
|
|
202
|
+
all_results.extend(batch_results)
|
|
203
|
+
|
|
204
|
+
return all_results
|
|
205
|
+
|
|
206
|
+
def run_single_batch(self, test_objects, options):
|
|
207
|
+
concurrent = options["concurrent_webhooks"]
|
|
208
|
+
batch_results = []
|
|
209
|
+
|
|
210
|
+
# Prepare tasks
|
|
211
|
+
tasks = []
|
|
212
|
+
for obj in test_objects:
|
|
213
|
+
tasks.append(
|
|
214
|
+
{
|
|
215
|
+
"object": obj,
|
|
216
|
+
"event": options["event"],
|
|
217
|
+
"start_logs": None, # will be set before sending
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Record initial log state if measuring delivery
|
|
222
|
+
if options["measure_delivery"]:
|
|
223
|
+
for task in tasks:
|
|
224
|
+
task["start_logs"] = WebhookDeliveryLog.objects.filter(
|
|
225
|
+
subscription__subscriber__content_type__model=obj._meta.model_name.lower(), # noqa: E501
|
|
226
|
+
created_at__gte=timezone.now(),
|
|
227
|
+
).count()
|
|
228
|
+
|
|
229
|
+
# Execute concurrent webhook sends
|
|
230
|
+
start_time = time.time()
|
|
231
|
+
|
|
232
|
+
with ThreadPoolExecutor(max_workers=concurrent) as executor:
|
|
233
|
+
# Submit all tasks
|
|
234
|
+
future_to_task = {
|
|
235
|
+
executor.submit(self.send_single_webhook, task): task
|
|
236
|
+
for task in tasks
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
# Collect results
|
|
240
|
+
for future in as_completed(future_to_task):
|
|
241
|
+
task = future_to_task[future]
|
|
242
|
+
try:
|
|
243
|
+
result = future.result()
|
|
244
|
+
task["send_result"] = result
|
|
245
|
+
task["send_duration"] = result.get("duration", 0)
|
|
246
|
+
|
|
247
|
+
except Exception as e:
|
|
248
|
+
task["send_result"] = {"error": f"Exception: {e=}"}
|
|
249
|
+
task["send_duration"] = 0
|
|
250
|
+
|
|
251
|
+
batch_send_duration = time.time() - start_time
|
|
252
|
+
|
|
253
|
+
# Measure delivery times if requested
|
|
254
|
+
if options["measure_delivery"]:
|
|
255
|
+
self.stdout.write("Measuring delivery times...")
|
|
256
|
+
self.measure_delivery_times(tasks, options["timeout"])
|
|
257
|
+
|
|
258
|
+
# Compile batch results
|
|
259
|
+
batch_summary = {
|
|
260
|
+
"send_duration": batch_send_duration,
|
|
261
|
+
"tasks": tasks,
|
|
262
|
+
"concurrent_level": concurrent,
|
|
263
|
+
"timestamp": timezone.now(),
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
batch_results.append(batch_summary)
|
|
267
|
+
|
|
268
|
+
# Show batch summary
|
|
269
|
+
successful_sends = sum(
|
|
270
|
+
1 for task in tasks if not task["send_result"].get("error")
|
|
271
|
+
)
|
|
272
|
+
self.stdout.write(
|
|
273
|
+
f"Batch completed: {successful_sends}/{len(tasks)} successful "
|
|
274
|
+
f"in {batch_send_duration:.2f}s"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return batch_results
|
|
278
|
+
|
|
279
|
+
def send_single_webhook(self, task):
|
|
280
|
+
"""Send a single webhook and measure timing."""
|
|
281
|
+
obj = task["object"]
|
|
282
|
+
event = task["event"]
|
|
283
|
+
|
|
284
|
+
start_time = time.time()
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
result = send_webhooks(obj, event)
|
|
288
|
+
duration = time.time() - start_time
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
"success": True,
|
|
292
|
+
"result": result,
|
|
293
|
+
"duration": duration,
|
|
294
|
+
"object_id": obj.id,
|
|
295
|
+
}
|
|
296
|
+
except Exception as e:
|
|
297
|
+
duration = time.time() - start_time
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
"success": False,
|
|
301
|
+
"error": str(e),
|
|
302
|
+
"duration": duration,
|
|
303
|
+
"object_id": obj.id,
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
def measure_delivery_times(self, tasks, timeout):
|
|
307
|
+
"""Measure actual webhook delivery times."""
|
|
308
|
+
start_time = time.time()
|
|
309
|
+
measured_count = 0
|
|
310
|
+
|
|
311
|
+
while (time.time() - start_time) < timeout:
|
|
312
|
+
for task in tasks:
|
|
313
|
+
if task.get("delivery_measured"):
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
# Check for new delivery logs
|
|
317
|
+
obj = task["object"]
|
|
318
|
+
new_logs = WebhookDeliveryLog.objects.filter(
|
|
319
|
+
subscription__subscriber__content_type__model=obj._meta.model_name.lower(), # noqa: E501
|
|
320
|
+
created_at__gte=task.get(
|
|
321
|
+
"send_start_time", timezone.now()
|
|
322
|
+
),
|
|
323
|
+
payload__pk=obj.id,
|
|
324
|
+
).order_by("-created_at")
|
|
325
|
+
|
|
326
|
+
if new_logs.exists():
|
|
327
|
+
latest_log = new_logs.first()
|
|
328
|
+
task["delivery_duration"] = latest_log.delivery_duration_ms
|
|
329
|
+
task["delivery_status"] = latest_log.response_status
|
|
330
|
+
task["delivery_measured"] = True
|
|
331
|
+
measured_count += 1
|
|
332
|
+
|
|
333
|
+
# Check if all deliveries measured
|
|
334
|
+
if measured_count >= len(tasks):
|
|
335
|
+
break
|
|
336
|
+
|
|
337
|
+
time.sleep(0.5) # Check every 500ms
|
|
338
|
+
|
|
339
|
+
measured_percentage = (measured_count / len(tasks)) * 100
|
|
340
|
+
self.stdout.write(
|
|
341
|
+
f"Measured {measured_count}/{len(tasks)} deliveries "
|
|
342
|
+
f"({measured_percentage:.1f}%)"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def show_results(self, all_results, options):
|
|
346
|
+
"""Display comprehensive test results."""
|
|
347
|
+
if not all_results:
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
self.stdout.write("\n" + "=" * 60)
|
|
351
|
+
self.stdout.write("PERFORMANCE TEST RESULTS")
|
|
352
|
+
self.stdout.write("=" * 60)
|
|
353
|
+
|
|
354
|
+
# Aggregate statistics
|
|
355
|
+
all_tasks = []
|
|
356
|
+
total_send_time = 0
|
|
357
|
+
|
|
358
|
+
for batch in all_results:
|
|
359
|
+
all_tasks.extend(batch["tasks"])
|
|
360
|
+
total_send_time += batch["send_duration"]
|
|
361
|
+
|
|
362
|
+
successful_sends = sum(
|
|
363
|
+
1 for task in all_tasks if not task["send_result"].get("error")
|
|
364
|
+
)
|
|
365
|
+
send_durations = [
|
|
366
|
+
task["send_duration"]
|
|
367
|
+
for task in all_tasks
|
|
368
|
+
if task.get("send_duration")
|
|
369
|
+
]
|
|
370
|
+
|
|
371
|
+
# Send performance metrics
|
|
372
|
+
self.stdout.write("\nSend Performance:")
|
|
373
|
+
self.stdout.write(f" Total webhooks: {len(all_tasks)}")
|
|
374
|
+
self.stdout.write(f" Successful: {successful_sends}")
|
|
375
|
+
self.stdout.write(f" Failed: {len(all_tasks) - successful_sends}")
|
|
376
|
+
self.stdout.write(
|
|
377
|
+
f" Success rate: {(successful_sends/len(all_tasks)*100):.1f}%"
|
|
378
|
+
)
|
|
379
|
+
self.stdout.write(f" Total time: {total_send_time:.2f}s")
|
|
380
|
+
self.stdout.write(
|
|
381
|
+
f" Throughput: {len(all_tasks)/total_send_time:.1f} webhooks/sec"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
if send_durations:
|
|
385
|
+
self.stdout.write(
|
|
386
|
+
f" Avg send time: {statistics.mean(send_durations):.3f}s"
|
|
387
|
+
)
|
|
388
|
+
self.stdout.write(f" Min send time: {min(send_durations):.3f}s")
|
|
389
|
+
self.stdout.write(f" Max send time: {max(send_durations):.3f}s")
|
|
390
|
+
|
|
391
|
+
if len(send_durations) > 1:
|
|
392
|
+
self.stdout.write(
|
|
393
|
+
" Send time std dev: "
|
|
394
|
+
f"{statistics.stdev(send_durations):.3f}s"
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Delivery performance metrics (if measured)
|
|
398
|
+
delivery_tasks = [
|
|
399
|
+
task for task in all_tasks if task.get("delivery_measured")
|
|
400
|
+
]
|
|
401
|
+
|
|
402
|
+
if delivery_tasks:
|
|
403
|
+
self.stdout.write("\nDelivery Performance:")
|
|
404
|
+
delivery_times = [
|
|
405
|
+
task["delivery_duration"]
|
|
406
|
+
for task in delivery_tasks
|
|
407
|
+
if task.get("delivery_duration")
|
|
408
|
+
]
|
|
409
|
+
successful_deliveries = sum(
|
|
410
|
+
1
|
|
411
|
+
for task in delivery_tasks
|
|
412
|
+
if task.get("delivery_status", 0) in range(200, 300)
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
self.stdout.write(f" Measured deliveries: {len(delivery_tasks)}")
|
|
416
|
+
self.stdout.write(
|
|
417
|
+
f" Successful deliveries: {successful_deliveries}"
|
|
418
|
+
)
|
|
419
|
+
self.stdout.write(
|
|
420
|
+
" Delivery success rate: "
|
|
421
|
+
f"{(successful_deliveries/len(delivery_tasks)*100):.1f}%"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
if delivery_times:
|
|
425
|
+
self.stdout.write(
|
|
426
|
+
" Avg delivery time: "
|
|
427
|
+
f"{statistics.mean(delivery_times):.0f}ms"
|
|
428
|
+
)
|
|
429
|
+
self.stdout.write(
|
|
430
|
+
f" Min delivery time: {min(delivery_times)}ms"
|
|
431
|
+
)
|
|
432
|
+
self.stdout.write(
|
|
433
|
+
f" Max delivery time: {max(delivery_times)}ms"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
if len(delivery_times) > 1:
|
|
437
|
+
self.stdout.write(
|
|
438
|
+
" Delivery time std dev: "
|
|
439
|
+
f"{statistics.stdev(delivery_times):.0f}ms"
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# Error analysis
|
|
443
|
+
errors = defaultdict(int)
|
|
444
|
+
for task in all_tasks:
|
|
445
|
+
error = task["send_result"].get("error")
|
|
446
|
+
if error:
|
|
447
|
+
errors[error] += 1
|
|
448
|
+
|
|
449
|
+
if errors:
|
|
450
|
+
self.stdout.write("\nError Analysis:")
|
|
451
|
+
for error, count in sorted(
|
|
452
|
+
errors.items(), key=lambda x: x[1], reverse=True
|
|
453
|
+
):
|
|
454
|
+
self.stdout.write(f" {error}: {count} occurrences")
|
|
455
|
+
|
|
456
|
+
# Concurrency analysis
|
|
457
|
+
if len(all_results) > 1:
|
|
458
|
+
self.stdout.write("\nBatch Analysis:")
|
|
459
|
+
for i, batch in enumerate(all_results):
|
|
460
|
+
batch_successful = sum(
|
|
461
|
+
1
|
|
462
|
+
for task in batch["tasks"]
|
|
463
|
+
if not task["send_result"].get("error")
|
|
464
|
+
)
|
|
465
|
+
self.stdout.write(
|
|
466
|
+
f" Batch {i+1}: {batch_successful}/{len(batch['tasks'])} "
|
|
467
|
+
"successful "
|
|
468
|
+
f"in {batch['send_duration']:.2f}s"
|
|
469
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""webhook_send command for Django Webhook Subscriber."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from django.apps import apps
|
|
6
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
7
|
+
from django_webhook_subscriber.delivery import send_webhooks
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Command(BaseCommand):
|
|
11
|
+
help = "Manually send webhooks for testing"
|
|
12
|
+
|
|
13
|
+
def add_arguments(self, parser):
|
|
14
|
+
parser.add_argument(
|
|
15
|
+
"model",
|
|
16
|
+
type=str,
|
|
17
|
+
help="Model in format app_label.ModelName",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"object_id",
|
|
21
|
+
type=int,
|
|
22
|
+
help="ID of the object to send webhook for",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"event_name",
|
|
26
|
+
type=str,
|
|
27
|
+
help="Event name (e.g., created, updated, deleted)",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--context",
|
|
31
|
+
type=str,
|
|
32
|
+
help="Additional context as JSON string",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--async",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="Send webhooks asynchronously (default behavior)",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def handle(self, *args, **options):
|
|
41
|
+
# Parse model
|
|
42
|
+
try:
|
|
43
|
+
app_label, model_name = options["model"].split(".")
|
|
44
|
+
model_class = apps.get_model(app_label, model_name)
|
|
45
|
+
except (ValueError, LookupError) as e:
|
|
46
|
+
raise CommandError(f"Invalid model: {options['model']}: {e}")
|
|
47
|
+
|
|
48
|
+
# Get object
|
|
49
|
+
try:
|
|
50
|
+
instance = model_class.objects.get(id=options["object_id"])
|
|
51
|
+
except model_class.DoesNotExist:
|
|
52
|
+
raise CommandError(
|
|
53
|
+
f"{model_class.__name__} with ID {options['object_id']} not "
|
|
54
|
+
"found"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Parse context
|
|
58
|
+
context = None
|
|
59
|
+
if options.get("context"):
|
|
60
|
+
try:
|
|
61
|
+
context = json.loads(options["context"])
|
|
62
|
+
except json.JSONDecodeError:
|
|
63
|
+
raise CommandError("Invalid JSON in context")
|
|
64
|
+
|
|
65
|
+
self.stdout.write(
|
|
66
|
+
f"Sending webhook for {model_class.__name__} "
|
|
67
|
+
f"(ID: {instance.id}) - Event: {options['event_name']}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
result = send_webhooks(
|
|
72
|
+
instance=instance,
|
|
73
|
+
event_name=options["event_name"],
|
|
74
|
+
context=context,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if "error" in result:
|
|
78
|
+
self.stdout.write(
|
|
79
|
+
self.style.ERROR(f"Error: {result['error']}")
|
|
80
|
+
)
|
|
81
|
+
elif "skipped" in result:
|
|
82
|
+
self.stdout.write(
|
|
83
|
+
self.style.WARNING(f"Skipped: {result['skipped']}")
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
processed = result.get("processed", 0)
|
|
87
|
+
batches = result.get("batches", 1)
|
|
88
|
+
self.stdout.write(
|
|
89
|
+
self.style.SUCCESS(
|
|
90
|
+
f"Queued {processed} webhook deliveries in {batches} "
|
|
91
|
+
"batch(es)"
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
except Exception as e:
|
|
96
|
+
raise CommandError(f"Error sending webhook: {e}")
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""webhook_status command for Django Webhook Subscriber."""
|
|
2
|
+
|
|
3
|
+
from django.core.management.base import BaseCommand
|
|
4
|
+
from django.db import models
|
|
5
|
+
from django.utils import timezone
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
|
|
8
|
+
from django_webhook_subscriber.models import (
|
|
9
|
+
WebhookSubscriber,
|
|
10
|
+
WebhookSubscription,
|
|
11
|
+
WebhookDeliveryLog,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Command(BaseCommand):
|
|
16
|
+
help = "Show overall webhook system status"
|
|
17
|
+
|
|
18
|
+
def add_arguments(self, parser):
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--detailed",
|
|
21
|
+
action="store_true",
|
|
22
|
+
help="Show detailed status for each subscriber",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def handle(self, *args, **options):
|
|
26
|
+
self.show_overview()
|
|
27
|
+
|
|
28
|
+
if options["detailed"]:
|
|
29
|
+
self.show_detailed_status()
|
|
30
|
+
|
|
31
|
+
def show_overview(self):
|
|
32
|
+
"""Show system overview."""
|
|
33
|
+
# Basic counts
|
|
34
|
+
total_subscribers = WebhookSubscriber.objects.count()
|
|
35
|
+
active_subscribers = WebhookSubscriber.objects.filter(
|
|
36
|
+
is_active=True
|
|
37
|
+
).count()
|
|
38
|
+
total_subscriptions = WebhookSubscription.objects.count()
|
|
39
|
+
active_subscriptions = WebhookSubscription.objects.filter(
|
|
40
|
+
is_active=True
|
|
41
|
+
).count()
|
|
42
|
+
|
|
43
|
+
# Health metrics
|
|
44
|
+
failing_subscribers = WebhookSubscriber.objects.filter(
|
|
45
|
+
consecutive_failures__gt=0
|
|
46
|
+
).count()
|
|
47
|
+
|
|
48
|
+
critical_subscribers = WebhookSubscriber.objects.filter(
|
|
49
|
+
consecutive_failures__gte=5
|
|
50
|
+
).count()
|
|
51
|
+
|
|
52
|
+
# Recent activity (last 24 hours)
|
|
53
|
+
last_24h = timezone.now() - timedelta(hours=24)
|
|
54
|
+
recent_deliveries = WebhookDeliveryLog.objects.filter(
|
|
55
|
+
created_at__gte=last_24h
|
|
56
|
+
).count()
|
|
57
|
+
|
|
58
|
+
recent_successes = WebhookDeliveryLog.objects.filter(
|
|
59
|
+
created_at__gte=last_24h,
|
|
60
|
+
response_status__gte=200,
|
|
61
|
+
response_status__lt=300,
|
|
62
|
+
).count()
|
|
63
|
+
|
|
64
|
+
success_rate_24h = (
|
|
65
|
+
(recent_successes / recent_deliveries * 100)
|
|
66
|
+
if recent_deliveries > 0
|
|
67
|
+
else 0
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
self.stdout.write(self.style.SUCCESS("Webhook System Status"))
|
|
71
|
+
self.stdout.write("=" * 50)
|
|
72
|
+
|
|
73
|
+
self.stdout.write(
|
|
74
|
+
f"Subscribers: {active_subscribers}/{total_subscribers} active"
|
|
75
|
+
)
|
|
76
|
+
self.stdout.write(
|
|
77
|
+
f"Subscriptions: {active_subscriptions}/{total_subscriptions} "
|
|
78
|
+
"active"
|
|
79
|
+
)
|
|
80
|
+
self.stdout.write(
|
|
81
|
+
f"Health: {failing_subscribers} failing, {critical_subscribers} "
|
|
82
|
+
"critical"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
self.stdout.write("\nLast 24 Hours:")
|
|
86
|
+
self.stdout.write(f"Deliveries: {recent_deliveries}")
|
|
87
|
+
self.stdout.write(f"Success rate: {success_rate_24h:.1f}%")
|
|
88
|
+
|
|
89
|
+
def show_detailed_status(self):
|
|
90
|
+
"""Show detailed status for each subscriber."""
|
|
91
|
+
subscribers = WebhookSubscriber.objects.prefetch_related(
|
|
92
|
+
"subscriptions"
|
|
93
|
+
).annotate(
|
|
94
|
+
total_subscriptions=models.Count("subscriptions"),
|
|
95
|
+
active_subscriptions=models.Count(
|
|
96
|
+
"subscriptions", filter=models.Q(subscriptions__is_active=True)
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
self.stdout.write("\n" + "=" * 80)
|
|
101
|
+
self.stdout.write("DETAILED SUBSCRIBER STATUS")
|
|
102
|
+
self.stdout.write("=" * 80)
|
|
103
|
+
|
|
104
|
+
for subscriber in subscribers:
|
|
105
|
+
# Health indicator
|
|
106
|
+
if not subscriber.is_active:
|
|
107
|
+
health = "DISABLED"
|
|
108
|
+
style = self.style.WARNING
|
|
109
|
+
elif subscriber.consecutive_failures == 0:
|
|
110
|
+
health = "HEALTHY"
|
|
111
|
+
style = self.style.SUCCESS
|
|
112
|
+
elif subscriber.consecutive_failures < 5:
|
|
113
|
+
health = (
|
|
114
|
+
f"WARNING ({subscriber.consecutive_failures} failures)"
|
|
115
|
+
)
|
|
116
|
+
style = self.style.WARNING
|
|
117
|
+
else:
|
|
118
|
+
health = (
|
|
119
|
+
f"CRITICAL ({subscriber.consecutive_failures} failures)"
|
|
120
|
+
)
|
|
121
|
+
style = self.style.ERROR
|
|
122
|
+
|
|
123
|
+
self.stdout.write(style(f"\n{subscriber.name} - {health}"))
|
|
124
|
+
self.stdout.write(f" URL: {subscriber.target_url}")
|
|
125
|
+
self.stdout.write(
|
|
126
|
+
f" Subscriptions: {subscriber.active_subscriptions}/"
|
|
127
|
+
f"{subscriber.total_subscriptions} active"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if subscriber.last_success:
|
|
131
|
+
self.stdout.write(
|
|
132
|
+
" Last success: "
|
|
133
|
+
f"{subscriber.last_success.strftime('%Y-%m-%d %H:%M')}"
|
|
134
|
+
)
|
|
135
|
+
if subscriber.last_failure:
|
|
136
|
+
self.stdout.write(
|
|
137
|
+
" Last failure: "
|
|
138
|
+
f"{subscriber.last_failure.strftime('%Y-%m-%d %H:%M')}"
|
|
139
|
+
)
|