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,266 +1,915 @@
1
- """Admin configuration for Django Webhook Subscriber."""
1
+ """Updated Admin configuration for Django Webhook Subscriber."""
2
2
 
3
3
  from django.contrib import admin
4
- from django.forms import ModelForm
4
+ from django.db import models
5
+ from django.urls import reverse
5
6
  from django.utils.html import format_html
6
- from django.core.exceptions import ValidationError
7
+ from django.utils.safestring import mark_safe
7
8
  from django.utils.translation import gettext_lazy as _
9
+ from django.contrib import messages
10
+
11
+ from .models import WebhookDeliveryLog, WebhookSubscriber, WebhookSubscription
12
+ from .delivery import clear_webhook_cache, get_webhook_cache_stats
13
+ from .tasks import test_webhook_connectivity, cleanup_webhook_logs
14
+
15
+
16
+ # Inline Admin Classes
17
+ class WebhookSubscriptionInline(admin.TabularInline):
18
+ """Inline for managing subscriptions within subscriber admin."""
19
+
20
+ model = WebhookSubscription
21
+ extra = 1
22
+ fields = [
23
+ "event_name",
24
+ "custom_endpoint",
25
+ "is_active",
26
+ "success_rate_display",
27
+ "total_deliveries",
28
+ "consecutive_failures",
29
+ ]
30
+ readonly_fields = ["success_rate_display"]
31
+
32
+ def success_rate_display(self, obj):
33
+ """Display success rate with color coding."""
34
+ if obj.pk and obj.success_rate is not None:
35
+ rate = obj.success_rate
36
+ if rate >= 90:
37
+ color = "green"
38
+ elif rate >= 70:
39
+ color = "orange"
40
+ else:
41
+ color = "red"
42
+ return format_html(
43
+ '<span style="color: {};">{:.1f}%</span>', color, rate
44
+ )
45
+ return "-"
8
46
 
9
- from django_webhook_subscriber.models import (
10
- WebhookRegistry,
11
- WebhookDeliveryLog,
12
- )
13
- from django_webhook_subscriber.utils import _webhook_registry
47
+ success_rate_display.short_description = _("Success Rate")
14
48
 
15
- # Limit the number of inline records to display
16
- MAX_DISPLAYED_LOGS = 10
49
+ def get_queryset(self, request):
50
+ return super().get_queryset(request).select_related("subscriber")
17
51
 
18
52
 
19
- class WebhookRegistryDeliverLogInline(admin.TabularInline):
20
- """Inline admin for WebhookRegistryDeliveryLog model."""
53
+ class WebhookDeliveryLogInline(admin.StackedInline):
54
+ """Inline for recent delivery logs."""
21
55
 
22
56
  model = WebhookDeliveryLog
23
57
  extra = 0
24
- max_num = 10 # this doesn't limit the number of logs
25
- fields = ['created_at', 'event_signal', 'response_status', 'error_message']
58
+ max_num = 5 # Show last 5 deliveries
59
+ fields = [
60
+ "created_at",
61
+ "response_status_display",
62
+ "attempt_number",
63
+ "delivery_duration_ms",
64
+ "error_message_short",
65
+ ]
26
66
  readonly_fields = [
27
- 'created_at',
28
- 'event_signal',
29
- 'response_status',
30
- 'error_message',
67
+ "created_at",
68
+ "response_status_display",
69
+ "attempt_number",
70
+ "delivery_duration_ms",
71
+ "error_message_short",
31
72
  ]
32
73
  can_delete = False
33
- verbose_name_plural = _('Recent Delivery Logs')
74
+ ordering = ["-created_at"]
34
75
 
35
76
  def has_add_permission(self, request, obj=None):
36
77
  return False
37
78
 
38
- def get_queryset(self, request):
39
- """Override get_queryset to limit the number of logs being
40
- displayed."""
41
-
42
- queryset = super().get_queryset(request)
79
+ def response_status_display(self, obj):
80
+ """Display response status with color coding."""
81
+ if obj.response_status:
82
+ if obj.is_success:
83
+ return format_html(
84
+ '<span style="color: green;">✓ {}</span>',
85
+ obj.response_status,
86
+ )
87
+ elif obj.is_client_error:
88
+ return format_html(
89
+ '<span style="color: orange;">⚠ {}</span>',
90
+ obj.response_status,
91
+ )
92
+ else:
93
+ return format_html(
94
+ '<span style="color: red;">✗ {}</span>',
95
+ obj.response_status,
96
+ )
97
+ elif obj.error_message:
98
+ return format_html('<span style="color: red;">✗ Exception</span>')
99
+ return "?"
100
+
101
+ response_status_display.short_description = _("Status")
102
+
103
+ def error_message_short(self, obj):
104
+ """Truncated error message for inline display."""
105
+ if obj.error_message:
106
+ return (
107
+ obj.error_message[:100] + "..."
108
+ if len(obj.error_message) > 100
109
+ else obj.error_message
110
+ )
111
+ return "-"
43
112
 
44
- if not queryset.exists() or queryset.count() < MAX_DISPLAYED_LOGS:
45
- return queryset
113
+ error_message_short.short_description = _("Error")
46
114
 
47
- ids = queryset.order_by('-created_at').values('pk')[
48
- :MAX_DISPLAYED_LOGS
49
- ]
50
115
 
51
- qs = WebhookDeliveryLog.objects.filter(pk__in=ids).order_by('-id')
52
- return qs
116
+ # Main Admin Classes
117
+ @admin.register(WebhookSubscriber)
118
+ class WebhookSubscriberAdmin(admin.ModelAdmin):
119
+ """Enhanced admin interface for WebhookSubscriber."""
53
120
 
121
+ list_display = [
122
+ "name",
123
+ "model_display",
124
+ "target_url_display",
125
+ "health_indicator",
126
+ "subscriptions_count",
127
+ "consecutive_failures",
128
+ "last_activity_display",
129
+ "created_at",
130
+ ]
131
+ list_display_links = ["name"]
132
+ list_filter = [
133
+ "is_active",
134
+ "content_type",
135
+ "consecutive_failures",
136
+ "created_at",
137
+ "last_success",
138
+ "last_failure",
139
+ ]
140
+ search_fields = ["name", "description", "target_url"]
141
+ readonly_fields = [
142
+ "created_at",
143
+ "updated_at",
144
+ "last_success",
145
+ "last_failure",
146
+ "consecutive_failures",
147
+ "model_class_display",
148
+ "subscriptions_summary",
149
+ ]
54
150
 
55
- class WebhookRegistryAdminForm(ModelForm):
56
- """Custom form for Webhook admin to validate allowed models for
57
- webhooks."""
151
+ fieldsets = (
152
+ (
153
+ _("Basic Information"),
154
+ {"fields": ("name", "description", "content_type", "is_active")},
155
+ ),
156
+ (
157
+ _("Endpoint Configuration"),
158
+ {
159
+ "fields": (
160
+ "target_url",
161
+ "secret",
162
+ "headers",
163
+ "serializer_class",
164
+ ),
165
+ },
166
+ ),
167
+ (
168
+ _("Delivery Settings"),
169
+ {
170
+ "fields": (
171
+ "max_retries",
172
+ "retry_delay",
173
+ "timeout",
174
+ "auto_disable_after_failures",
175
+ ),
176
+ "classes": ("collapse",),
177
+ },
178
+ ),
179
+ (
180
+ _("Status Information"),
181
+ {
182
+ "fields": (
183
+ "consecutive_failures",
184
+ "last_success",
185
+ "last_failure",
186
+ "model_class_display",
187
+ "subscriptions_summary",
188
+ ),
189
+ "classes": ("collapse",),
190
+ },
191
+ ),
192
+ (
193
+ _("Metadata"),
194
+ {
195
+ "fields": ("created_at", "updated_at"),
196
+ "classes": ("collapse",),
197
+ },
198
+ ),
199
+ )
200
+
201
+ inlines = [WebhookSubscriptionInline]
202
+ actions = [
203
+ "activate_subscribers",
204
+ "deactivate_subscribers",
205
+ "test_connectivity_action",
206
+ "reset_failure_counters",
207
+ "clear_cache_for_subscribers",
208
+ ]
58
209
 
59
- class Meta:
60
- model = WebhookRegistry
61
- fields = '__all__'
210
+ def get_queryset(self, request):
211
+ return (
212
+ super()
213
+ .get_queryset(request)
214
+ .select_related("content_type")
215
+ .prefetch_related("subscriptions")
216
+ .annotate(
217
+ active_subscriptions_count=models.Count(
218
+ "subscriptions",
219
+ filter=models.Q(subscriptions__is_active=True),
220
+ )
221
+ )
222
+ )
62
223
 
63
- def clean_content_type(self):
64
- content_type = self.cleaned_data.get('content_type')
224
+ def model_display(self, obj):
225
+ """Display model information with app label."""
226
+ return f"{obj.content_type.app_label}.{obj.content_type.model}"
227
+
228
+ model_display.short_description = _("Model")
229
+ model_display.admin_order_field = "content_type"
230
+
231
+ def target_url_display(self, obj):
232
+ """Display target URL with link."""
233
+ if obj.target_url:
234
+ return format_html(
235
+ '<a href="{}" target="_blank" title="Open endpoint">{}</a>',
236
+ obj.target_url,
237
+ (
238
+ obj.target_url[:50] + "..."
239
+ if len(obj.target_url) > 50
240
+ else obj.target_url
241
+ ),
242
+ )
243
+ return "-"
65
244
 
66
- # If registry is empty, allow any content type
67
- if not _webhook_registry:
68
- return content_type
245
+ target_url_display.short_description = _("Target URL")
246
+ target_url_display.admin_order_field = "target_url"
69
247
 
70
- # Check if model is registered in the webhook registry
71
- model_class = content_type.model_class()
72
- if model_class in _webhook_registry:
73
- return content_type
248
+ def health_indicator(self, obj):
249
+ """Visual health indicator with status and failure count."""
250
+ if not obj.is_active:
251
+ return format_html('<span style="color: #999;">● Disabled</span>')
74
252
 
75
- # If model not found in registry, raise validation error
76
- raise ValidationError(
77
- _(
78
- 'This model is not allowed for webhooks. Please add it to '
79
- 'WEBHOOK_SUBSCRIBER["WEBHOOK_MODELS"] in settings.'
253
+ # Health based on consecutive failures and recent activity
254
+ if obj.consecutive_failures == 0:
255
+ if obj.last_success:
256
+ return format_html(
257
+ '<span style="color: green;">● Healthy</span>'
258
+ )
259
+ else:
260
+ return format_html(
261
+ '<span style="color: orange;">● Untested</span>'
262
+ )
263
+ elif obj.consecutive_failures < 5:
264
+ return format_html(
265
+ '<span style="color: orange;">● Warning ({})</span>',
266
+ obj.consecutive_failures,
267
+ )
268
+ else:
269
+ return format_html(
270
+ '<span style="color: red;">● Critical ({})</span>',
271
+ obj.consecutive_failures,
80
272
  )
81
- )
82
273
 
83
- def clean_event_signals(self):
84
- content_type = self.cleaned_data.get('content_type')
85
- event_signals = self.cleaned_data.get('event_signals')
274
+ health_indicator.short_description = _("Health")
275
+ health_indicator.admin_order_field = "consecutive_failures"
86
276
 
87
- # If no event types are provided, raise a validation error
88
- if not event_signals:
89
- raise ValidationError(
90
- _('At least one event type must be selected.')
277
+ def subscriptions_count(self, obj):
278
+ """Count of active subscriptions with link."""
279
+ count = getattr(obj, "active_subscriptions_count", 0)
280
+ if count > 0:
281
+ url = reverse(
282
+ "admin:django_webhook_subscriber_webhooksubscription_changelist" # noqa: E501
283
+ )
284
+ return format_html(
285
+ '<a href="{}?subscriber__id__exact={}">{}</a>',
286
+ url,
287
+ obj.pk,
288
+ count,
289
+ )
290
+ return "0"
291
+
292
+ subscriptions_count.short_description = _("Active Subscriptions")
293
+ subscriptions_count.admin_order_field = "active_subscriptions_count"
294
+
295
+ def last_activity_display(self, obj):
296
+ """Display most recent activity (success or failure)."""
297
+ last_activity = None
298
+ activity_type = None
299
+
300
+ if obj.last_success and obj.last_failure:
301
+ if obj.last_success > obj.last_failure:
302
+ last_activity = obj.last_success
303
+ activity_type = "success"
304
+ else:
305
+ last_activity = obj.last_failure
306
+ activity_type = "failure"
307
+ elif obj.last_success:
308
+ last_activity = obj.last_success
309
+ activity_type = "success"
310
+ elif obj.last_failure:
311
+ last_activity = obj.last_failure
312
+ activity_type = "failure"
313
+
314
+ if last_activity:
315
+ color = "green" if activity_type == "success" else "red"
316
+ icon = "✓" if activity_type == "success" else "✗"
317
+ return format_html(
318
+ '<span style="color: {};" title="{}">{} {}</span>',
319
+ color,
320
+ last_activity.strftime("%Y-%m-%d %H:%M:%S"),
321
+ icon,
322
+ last_activity.strftime("%m/%d %H:%M"),
91
323
  )
92
324
 
93
- # Skip validation if content type is not valid
94
- if not content_type:
95
- return event_signals
96
-
97
- # Get model class and check registry
98
- model_class = content_type.model_class()
99
- if model_class not in _webhook_registry:
100
- # Skip validation if model not in registry
101
- return event_signals
102
-
103
- # Get allowed events for this model
104
- allowed_events = _webhook_registry[model_class].get('events', [])
105
-
106
- # Check if all selected event types are valid
107
- invalid_events = [e for e in event_signals if e not in allowed_events]
108
- if invalid_events:
109
- raise ValidationError(
110
- _(
111
- 'The following event types are not allowed for this model:'
112
- ' %(events)s'
113
- )
114
- % {'events': ', '.join(invalid_events)}
325
+ return format_html('<span style="color: #999;">No activity</span>')
326
+
327
+ last_activity_display.short_description = _("Last Activity")
328
+
329
+ def model_class_display(self, obj):
330
+ """Display the actual model class information."""
331
+ model_class = obj.model_class
332
+ if model_class:
333
+ return f"{model_class.__module__}.{model_class.__name__}"
334
+ return "Model not found"
335
+
336
+ model_class_display.short_description = _("Model Class")
337
+
338
+ def subscriptions_summary(self, obj):
339
+ """Summary of all subscriptions with success rates."""
340
+ subscriptions = obj.subscriptions.all()
341
+ if not subscriptions:
342
+ return "No subscriptions"
343
+
344
+ summary_lines = []
345
+ for sub in subscriptions:
346
+ status = "✓" if sub.is_active else "✗"
347
+ rate = (
348
+ f" ({sub.success_rate:.1f}%)"
349
+ if sub.success_rate is not None
350
+ else ""
115
351
  )
352
+ summary_lines.append(f"{status} {sub.event_name}{rate}")
353
+
354
+ return mark_safe("<br>".join(summary_lines))
355
+
356
+ subscriptions_summary.short_description = _("Subscriptions Summary")
357
+
358
+ # Custom Actions
359
+ @admin.action(description=_("Activate selected subscribers"))
360
+ def activate_subscribers(self, request, queryset):
361
+ """Bulk activate subscribers."""
362
+ updated = queryset.update(is_active=True)
363
+ self.message_user(request, f"{updated} subscriber(s) activated.")
364
+
365
+ @admin.action(description=_("Deactivate selected subscribers"))
366
+ def deactivate_subscribers(self, request, queryset):
367
+ """Bulk deactivate subscribers."""
368
+ updated = queryset.update(is_active=False)
369
+ self.message_user(request, f"{updated} subscriber(s) deactivated.")
370
+
371
+ @admin.action(description=_("Test endpoint connectivity"))
372
+ def test_connectivity_action(self, request, queryset):
373
+ """Test connectivity to subscriber endpoints."""
374
+ subscriber_ids = list(queryset.values_list("id", flat=True))
375
+
376
+ # Queue the test task
377
+ result = test_webhook_connectivity.delay(subscriber_ids)
378
+
379
+ self.message_user(
380
+ request,
381
+ f"Connectivity test queued for {len(subscriber_ids)} "
382
+ f"subscriber(s). Task ID: {result.id}",
383
+ messages.INFO,
384
+ )
116
385
 
117
- return event_signals
386
+ @admin.action(description=_("Reset failure counters"))
387
+ def reset_failure_counters(self, request, queryset):
388
+ """Reset consecutive failure counters."""
389
+ updated = queryset.update(consecutive_failures=0)
390
+ self.message_user(
391
+ request, f"Reset failure counters for {updated} subscriber(s)."
392
+ )
118
393
 
394
+ @admin.action(description=_("Clear cache for selected subscribers"))
395
+ def clear_cache_for_subscribers(self, request, queryset):
396
+ """Clear cache for selected subscribers."""
397
+ for subscriber in queryset:
398
+ clear_webhook_cache(content_type=subscriber.content_type)
119
399
 
120
- @admin.register(WebhookRegistry)
121
- class WebhookRegistryAdmin(admin.ModelAdmin):
122
- """Admin configuration for WebhookRegistry model."""
400
+ self.message_user(
401
+ request, f"Cache cleared for {queryset.count()} subscriber(s)."
402
+ )
403
+
404
+
405
+ @admin.register(WebhookSubscription)
406
+ class WebhookSubscriptionAdmin(admin.ModelAdmin):
407
+ """Admin interface for WebhookSubscription."""
123
408
 
124
- form = WebhookRegistryAdminForm
125
409
  list_display = [
126
- 'id',
127
- 'name',
128
- 'content_type',
129
- 'event_signals_display',
130
- 'endpoint',
131
- 'is_active',
132
- 'status_indicator',
133
- 'webhooks_sent',
134
- 'use_async',
410
+ "subscriber_name",
411
+ "event_name",
412
+ "endpoint_display",
413
+ "status_indicator",
414
+ "performance_display",
415
+ "recent_deliveries",
416
+ "created_at",
135
417
  ]
136
- list_display_links = [
137
- 'id',
138
- 'name',
139
- 'content_type',
140
- 'event_signals_display',
418
+ list_display_links = ["subscriber_name", "event_name"]
419
+ list_filter = [
420
+ "is_active",
421
+ "event_name",
422
+ "subscriber__content_type",
423
+ "subscriber__is_active",
424
+ "consecutive_failures",
425
+ "created_at",
426
+ ]
427
+ search_fields = [
428
+ "subscriber__name",
429
+ "event_name",
430
+ "custom_endpoint",
431
+ "subscriber__target_url",
141
432
  ]
142
- list_filter = ['is_active', 'content_type', 'created_at']
143
- search_fields = ['name', 'endpoint']
144
433
  readonly_fields = [
145
- 'created_at',
146
- 'updated_at',
147
- 'last_success',
148
- 'last_failure',
149
- 'last_response',
434
+ "created_at",
435
+ "updated_at",
436
+ "full_endpoint_display",
437
+ "success_rate",
438
+ "total_deliveries",
439
+ "successful_deliveries",
440
+ "consecutive_failures",
150
441
  ]
151
442
 
152
- # Detail Page Configuration
153
- fieldsets = [
443
+ fieldsets = (
154
444
  (
155
- None,
445
+ _("Subscription Details"),
446
+ {"fields": ("subscriber", "event_name", "is_active")},
447
+ ),
448
+ (
449
+ _("Endpoint Configuration"),
450
+ {
451
+ "fields": ("custom_endpoint", "full_endpoint_display"),
452
+ },
453
+ ),
454
+ (
455
+ _("Performance Statistics"),
156
456
  {
157
- 'fields': [
158
- 'name',
159
- 'content_type',
160
- 'event_signals',
161
- 'endpoint',
162
- 'is_active',
163
- ]
457
+ "fields": (
458
+ "success_rate",
459
+ "total_deliveries",
460
+ "successful_deliveries",
461
+ "consecutive_failures",
462
+ ),
463
+ "classes": ("collapse",),
164
464
  },
165
465
  ),
166
- (_('Authentication'), {'fields': ['secret']}),
167
466
  (
168
- _('Advanced'),
467
+ _("Response Handling"),
169
468
  {
170
- 'fields': [
171
- 'headers',
172
- 'keep_last_response',
173
- 'use_async',
174
- 'max_retries',
175
- 'retry_delay',
176
- ],
177
- 'classes': ['collapse'],
469
+ "fields": ("keep_last_response", "last_response"),
470
+ "classes": ("collapse",),
178
471
  },
179
472
  ),
180
473
  (
181
- _('Status'),
182
- {'fields': ['last_success', 'last_failure', 'last_response']},
474
+ _("Metadata"),
475
+ {
476
+ "fields": ("created_at", "updated_at"),
477
+ "classes": ("collapse",),
478
+ },
183
479
  ),
184
- (_('Metadata'), {'fields': ['created_at', 'updated_at']}),
480
+ )
481
+
482
+ inlines = [WebhookDeliveryLogInline]
483
+ actions = [
484
+ "activate_subscriptions",
485
+ "deactivate_subscriptions",
486
+ "reset_subscription_stats",
487
+ "clear_cache_action",
185
488
  ]
186
- inlines = [WebhookRegistryDeliverLogInline]
187
489
 
188
- def event_signals_display(self, obj):
189
- return ', '.join(obj.event_signals) if obj.event_signals else '-'
490
+ def get_queryset(self, request):
491
+ return (
492
+ super()
493
+ .get_queryset(request)
494
+ .select_related("subscriber", "subscriber__content_type")
495
+ .prefetch_related("delivery_logs")
496
+ )
497
+
498
+ def subscriber_name(self, obj):
499
+ """Display subscriber name with link."""
500
+ url = reverse(
501
+ "admin:django_webhook_subscriber_webhooksubscriber_change",
502
+ args=[obj.subscriber.pk],
503
+ )
504
+ return format_html('<a href="{}">{}</a>', url, obj.subscriber.name)
505
+
506
+ subscriber_name.short_description = _("Subscriber")
507
+ subscriber_name.admin_order_field = "subscriber__name"
508
+
509
+ def endpoint_display(self, obj):
510
+ """Display the effective endpoint."""
511
+ endpoint = obj.endpoint
512
+ return format_html(
513
+ '<a href="{}" target="_blank" title="Test endpoint">{}</a>',
514
+ endpoint,
515
+ endpoint[:40] + "..." if len(endpoint) > 40 else endpoint,
516
+ )
190
517
 
191
- event_signals_display.short_description = _('Event Types')
518
+ endpoint_display.short_description = _("Endpoint")
192
519
 
193
520
  def status_indicator(self, obj):
194
- if (
195
- obj.last_response
196
- and obj.last_success
197
- and (not obj.last_failure or obj.last_success > obj.last_failure)
198
- ):
199
- return format_html('<span style="color: green;">●</span> Success')
200
- elif obj.last_failure:
201
- return format_html('<span style="color: red;">●</span> Failed')
521
+ """Visual status indicator."""
522
+ if not obj.is_active:
523
+ return format_html('<span style="color: #999;">● Disabled</span>')
524
+ elif not obj.subscriber.is_active:
525
+ return format_html(
526
+ '<span style="color: orange;">● Subscriber Disabled</span>'
527
+ )
528
+ elif obj.consecutive_failures == 0:
529
+ return format_html('<span style="color: green;">● Active</span>')
530
+ elif obj.consecutive_failures < 3:
531
+ return format_html('<span style="color: orange;">● Warning</span>')
202
532
  else:
203
- return format_html('<span style="color: gray;">●</span> No data')
533
+ return format_html('<span style="color: red;">● Failing</span>')
204
534
 
205
- status_indicator.short_description = _('Status')
535
+ status_indicator.short_description = _("Status")
206
536
 
207
- def webhooks_sent(self, obj):
208
- """Return the number of webhooks sent for this registry."""
209
- return obj.delivery_logs.count()
210
-
211
- webhooks_sent.short_description = _('Log Count')
537
+ def performance_display(self, obj):
538
+ """Display performance metrics."""
539
+ if obj.total_deliveries == 0:
540
+ return format_html(
541
+ '<span style="color: #999;">No deliveries</span>'
542
+ )
212
543
 
213
- actions = ['=activate_webhooks', 'deactivate_webhooks']
544
+ success_rate = obj.success_rate or 0
545
+ if success_rate >= 95:
546
+ color = "green"
547
+ elif success_rate >= 80:
548
+ color = "orange"
549
+ else:
550
+ color = "red"
551
+
552
+ return format_html(
553
+ '<span style="color: {};" title="{} successful / {} total">{}% '
554
+ "({}/{})</span>",
555
+ color,
556
+ obj.successful_deliveries,
557
+ obj.total_deliveries,
558
+ success_rate,
559
+ obj.successful_deliveries,
560
+ obj.total_deliveries,
561
+ )
214
562
 
215
- @admin.action(description=_('Activate selected webhooks'))
216
- def activate_webhooks(self, request, queryset):
217
- """Action to activate selected webhooks."""
218
- count = queryset.update(is_active=True)
219
- self.message_user(request, f'{count} webhook(s) activated.')
563
+ performance_display.short_description = _("Performance")
564
+
565
+ def recent_deliveries(self, obj):
566
+ """Show recent delivery status with timing."""
567
+ recent_logs = obj.delivery_logs.order_by("-created_at")[:5]
568
+ if not recent_logs:
569
+ return format_html('<span style="color: #999;">None</span>')
570
+
571
+ status_icons = []
572
+ for log in recent_logs:
573
+ if log.is_success:
574
+ icon = "✅"
575
+ title = f"Success ({log.response_status})"
576
+ elif log.error_message:
577
+ icon = "❌"
578
+ title = f"Exception: {log.error_message[:50]}"
579
+ elif log.response_status:
580
+ if log.is_client_error:
581
+ icon = "⚠️"
582
+ title = f"Client Error ({log.response_status})"
583
+ else:
584
+ icon = "❌"
585
+ title = f"Server Error ({log.response_status})"
586
+ else:
587
+ icon = "❓"
588
+ title = "Unknown status"
589
+
590
+ # Add timing info if available
591
+ if log.delivery_duration_ms:
592
+ title += f" - {log.delivery_duration_ms}ms"
593
+
594
+ status_icons.append(f'<span title="{title}">{icon}</span>')
595
+
596
+ return format_html(" ".join(status_icons))
597
+
598
+ recent_deliveries.short_description = _("Recent Deliveries")
599
+
600
+ def full_endpoint_display(self, obj):
601
+ """Display the complete endpoint URL."""
602
+ return obj.endpoint
603
+
604
+ full_endpoint_display.short_description = _("Full Endpoint URL")
605
+
606
+ # Custom Actions
607
+ @admin.action(description=_("Activate selected subscriptions"))
608
+ def activate_subscriptions(self, request, queryset):
609
+ """Bulk activate subscriptions."""
610
+ updated = queryset.update(is_active=True)
611
+ self.message_user(request, f"{updated} subscription(s) activated.")
612
+
613
+ @admin.action(description=_("Deactivate selected subscriptions"))
614
+ def deactivate_subscriptions(self, request, queryset):
615
+ """Bulk deactivate subscriptions."""
616
+ updated = queryset.update(is_active=False)
617
+ self.message_user(request, f"{updated} subscription(s) deactivated.")
618
+
619
+ @admin.action(description=_("Reset performance statistics"))
620
+ def reset_subscription_stats(self, request, queryset):
621
+ """Reset performance statistics for subscriptions."""
622
+ updated = queryset.update(
623
+ consecutive_failures=0, total_deliveries=0, successful_deliveries=0
624
+ )
625
+ self.message_user(
626
+ request, f"Reset statistics for {updated} subscription(s)."
627
+ )
220
628
 
221
- @admin.action(description=_('Deactivate selected webhooks'))
222
- def deactivate_webhooks(self, request, queryset):
223
- """Action to deactivate selected webhooks."""
224
- count = queryset.update(is_active=False)
225
- self.message_user(request, f'{count} webhook(s) deactivated.')
629
+ @admin.action(description=_("Clear cache for selected subscriptions"))
630
+ def clear_cache_action(self, request, queryset):
631
+ """Clear cache for selected subscriptions."""
632
+ for sub in queryset:
633
+ clear_webhook_cache(
634
+ content_type=sub.subscriber.content_type,
635
+ event_name=sub.event_name,
636
+ )
637
+ self.message_user(
638
+ request, f"Cache cleared for {queryset.count()} subscription(s)."
639
+ )
226
640
 
227
641
 
228
642
  @admin.register(WebhookDeliveryLog)
229
643
  class WebhookDeliveryLogAdmin(admin.ModelAdmin):
230
- """Admin configuration for WebhookDeliveryLog model."""
644
+ """Comprehensive admin interface for WebhookDeliveryLog."""
231
645
 
232
646
  list_display = [
233
- 'webhook',
234
- 'event_signal',
235
- 'created_at',
236
- 'response_status',
237
- 'has_error',
647
+ "__str__",
648
+ "subscription_display",
649
+ "event_name_display",
650
+ "response_status_display",
651
+ "performance_display",
652
+ "created_at_display",
653
+ ]
654
+ list_display_links = ["__str__", "subscription_display"]
655
+ list_filter = [
656
+ "response_status",
657
+ "subscription__event_name",
658
+ "subscription__subscriber__content_type",
659
+ "is_retry",
660
+ "attempt_number",
661
+ "created_at",
662
+ ("delivery_duration_ms", admin.EmptyFieldListFilter),
238
663
  ]
239
- list_filter = ['webhook', 'event_signal', 'response_status', 'created_at']
240
664
  search_fields = [
241
- 'webhook__name',
242
- 'webhook__endpoint',
243
- 'error_message',
244
- 'payload',
665
+ "subscription__subscriber__name",
666
+ "subscription__event_name",
667
+ "delivery_url",
668
+ "error_message",
245
669
  ]
246
670
  readonly_fields = [
247
- 'webhook',
248
- 'event_signal',
249
- 'payload',
250
- 'response_status',
251
- 'response_body',
252
- 'error_message',
253
- 'created_at',
671
+ "subscription",
672
+ "attempt_number",
673
+ "is_retry",
674
+ "payload_display",
675
+ "response_body_display",
676
+ "response_headers_display",
677
+ "error_message",
678
+ "delivery_url",
679
+ "delivery_duration_ms",
680
+ "created_at",
254
681
  ]
255
682
 
256
- def has_error(self, obj):
257
- return bool(obj.error_message)
683
+ fieldsets = (
684
+ (
685
+ _("Delivery Information"),
686
+ {
687
+ "fields": (
688
+ "subscription",
689
+ "delivery_url",
690
+ "attempt_number",
691
+ "is_retry",
692
+ "delivery_duration_ms",
693
+ "created_at",
694
+ )
695
+ },
696
+ ),
697
+ (
698
+ _("Request Data"),
699
+ {
700
+ "fields": ("payload_display",),
701
+ "classes": ("collapse",),
702
+ },
703
+ ),
704
+ (
705
+ _("Response Data"),
706
+ {
707
+ "fields": (
708
+ "response_status",
709
+ "response_body_display",
710
+ "response_headers_display",
711
+ ),
712
+ "classes": ("collapse",),
713
+ },
714
+ ),
715
+ (
716
+ _("Error Information"),
717
+ {
718
+ "fields": ("error_message",),
719
+ "classes": ("collapse",),
720
+ },
721
+ ),
722
+ )
258
723
 
259
- has_error.boolean = True
260
- has_error.short_description = _('Error')
724
+ actions = ["cleanup_old_logs", "show_cache_stats"]
725
+ date_hierarchy = "created_at"
261
726
 
262
727
  def has_add_permission(self, request):
728
+ """Delivery logs are created automatically."""
263
729
  return False
264
730
 
265
731
  def has_change_permission(self, request, obj=None):
732
+ """Delivery logs are read-only."""
266
733
  return False
734
+
735
+ def subscription_display(self, obj):
736
+ """Display subscription with link."""
737
+ url = reverse(
738
+ "admin:django_webhook_subscriber_webhooksubscription_change",
739
+ args=[obj.subscription.pk],
740
+ )
741
+ return format_html(
742
+ '<a href="{}">{} - {}</a>',
743
+ url,
744
+ obj.subscription.subscriber.name,
745
+ obj.subscription.event_name,
746
+ )
747
+
748
+ subscription_display.short_description = _("Subscription")
749
+ subscription_display.admin_order_field = "subscription__subscriber__name"
750
+
751
+ def event_name_display(self, obj):
752
+ """Display event name."""
753
+ return obj.subscription.event_name
754
+
755
+ event_name_display.short_description = _("Event")
756
+ event_name_display.admin_order_field = "subscription__event_name"
757
+
758
+ def response_status_display(self, obj):
759
+ """Display response status with detailed color coding."""
760
+ if obj.is_success:
761
+ return format_html(
762
+ '<span style="color: green; font-weight: bold;">✅ {} Success'
763
+ "</span>",
764
+ obj.response_status,
765
+ )
766
+ elif obj.error_message:
767
+ return format_html(
768
+ '<span style="color: red; font-weight: bold;">❌ Exception'
769
+ "</span>"
770
+ )
771
+ elif obj.response_status:
772
+ if obj.is_client_error:
773
+ return format_html(
774
+ '<span style="color: orange; font-weight: bold;">⚠️ {} '
775
+ "Client Error</span>",
776
+ obj.response_status,
777
+ )
778
+ elif obj.is_server_error:
779
+ return format_html(
780
+ '<span style="color: red; font-weight: bold;">❌ {} Server '
781
+ "Error</span>",
782
+ obj.response_status,
783
+ )
784
+ else:
785
+ return format_html(
786
+ '<span style="color: purple;">❓ {} Unknown</span>',
787
+ obj.response_status,
788
+ )
789
+ else:
790
+ return format_html(
791
+ '<span style="color: #999;">❓ No Response</span>'
792
+ )
793
+
794
+ response_status_display.short_description = _("Status")
795
+ response_status_display.admin_order_field = "response_status"
796
+
797
+ def performance_display(self, obj):
798
+ """Display performance metrics."""
799
+ parts = []
800
+
801
+ # Retry info
802
+ if obj.is_retry:
803
+ parts.append(f"Retry #{obj.attempt_number}")
804
+ elif obj.attempt_number > 1:
805
+ parts.append(f"Attempt #{obj.attempt_number}")
806
+
807
+ # Duration
808
+ if obj.delivery_duration_ms is not None:
809
+ if obj.delivery_duration_ms < 1000:
810
+ parts.append(f"{obj.delivery_duration_ms}ms")
811
+ else:
812
+ parts.append(f"{obj.delivery_duration_ms/1000:.1f}s")
813
+
814
+ return " | ".join(parts) if parts else "-"
815
+
816
+ performance_display.short_description = _("Performance")
817
+
818
+ def created_at_display(self, obj):
819
+ """Display creation time with relative info."""
820
+ return obj.created_at.strftime("%Y-%m-%d %H:%M:%S")
821
+
822
+ created_at_display.short_description = _("Delivered At")
823
+ created_at_display.admin_order_field = "created_at"
824
+
825
+ def payload_display(self, obj):
826
+ """Display formatted payload."""
827
+ import json
828
+
829
+ try:
830
+ formatted = json.dumps(obj.payload, indent=2, ensure_ascii=False)
831
+ return format_html(
832
+ '<pre style="white-space: pre-wrap; max-height: 300px; '
833
+ 'overflow-y: auto; font-size: 12px;">{}</pre>',
834
+ formatted,
835
+ )
836
+ except Exception:
837
+ return str(obj.payload)
838
+
839
+ payload_display.short_description = _("Payload")
840
+
841
+ def response_body_display(self, obj):
842
+ """Display formatted response body."""
843
+ if obj.response_body:
844
+ # Try to format as JSON if possible
845
+ try:
846
+ import json
847
+
848
+ parsed = json.loads(obj.response_body)
849
+ formatted = json.dumps(parsed, indent=2, ensure_ascii=False)
850
+ content = formatted
851
+ except (json.JSONDecodeError, TypeError):
852
+ content = obj.response_body
853
+
854
+ # Truncate if too long
855
+ if len(content) > 3000:
856
+ content = content[:3000] + "\n... [TRUNCATED]"
857
+
858
+ return format_html(
859
+ '<pre style="white-space: pre-wrap; max-height: 300px; '
860
+ "overflow-y: auto; font-size: 12px; "
861
+ 'padding: 10px; border-radius: 4px;">{}</pre>',
862
+ content,
863
+ )
864
+ return "-"
865
+
866
+ response_body_display.short_description = _("Response Body")
867
+
868
+ def response_headers_display(self, obj):
869
+ """Display formatted response headers."""
870
+ if obj.response_headers:
871
+ import json
872
+
873
+ try:
874
+ formatted = json.dumps(
875
+ obj.response_headers, indent=2, ensure_ascii=False
876
+ )
877
+ return format_html(
878
+ '<pre style="white-space: pre-wrap; font-size: 12px;">{}'
879
+ "</pre>",
880
+ formatted,
881
+ )
882
+ except Exception:
883
+ return str(obj.response_headers)
884
+ return "-"
885
+
886
+ response_headers_display.short_description = _("Response Headers")
887
+
888
+ # Custom Actions
889
+ @admin.action(description=_("Clean up old delivery logs"))
890
+ def cleanup_old_logs(self, request, queryset):
891
+ """Clean up old delivery logs."""
892
+ result = cleanup_webhook_logs.delay()
893
+ self.message_user(
894
+ request,
895
+ f"Log cleanup task queued. Task ID: {result.id}",
896
+ messages.INFO,
897
+ )
898
+
899
+ @admin.action(description=_("Show webhook cache statistics"))
900
+ def show_cache_stats(self, request, queryset):
901
+ """Show cache statistics."""
902
+ try:
903
+ stats = get_webhook_cache_stats()
904
+ message = (
905
+ f"Cache Statistics: "
906
+ f"{stats['cached_keys']}/{stats['total_possible_keys']} keys "
907
+ f"cached ({stats['cache_hit_ratio']:.1f}% hit ratio), "
908
+ f"{stats['total_cached_subscriptions']} total subscriptions "
909
+ "cached"
910
+ )
911
+ self.message_user(request, message, messages.INFO)
912
+ except Exception as e:
913
+ self.message_user(
914
+ request, f"Error getting cache stats: {e}", messages.ERROR
915
+ )