django-webhook-subscriber 1.0.0__py3-none-any.whl → 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. django_webhook_subscriber/__init__.py +7 -1
  2. django_webhook_subscriber/admin.py +831 -182
  3. django_webhook_subscriber/apps.py +3 -20
  4. django_webhook_subscriber/conf.py +11 -24
  5. django_webhook_subscriber/delivery.py +414 -159
  6. django_webhook_subscriber/http.py +51 -0
  7. django_webhook_subscriber/management/commands/webhook.py +169 -0
  8. django_webhook_subscriber/management/commands/webhook_cache.py +173 -0
  9. django_webhook_subscriber/management/commands/webhook_logs.py +226 -0
  10. django_webhook_subscriber/management/commands/webhook_performance_test.py +469 -0
  11. django_webhook_subscriber/management/commands/webhook_send.py +96 -0
  12. django_webhook_subscriber/management/commands/webhook_status.py +139 -0
  13. django_webhook_subscriber/managers.py +36 -14
  14. django_webhook_subscriber/migrations/0002_remove_webhookregistry_content_type_and_more.py +192 -0
  15. django_webhook_subscriber/models.py +291 -114
  16. django_webhook_subscriber/serializers.py +16 -50
  17. django_webhook_subscriber/tasks.py +434 -56
  18. django_webhook_subscriber/tests/factories.py +40 -0
  19. django_webhook_subscriber/tests/settings.py +27 -8
  20. django_webhook_subscriber/tests/test_delivery.py +453 -190
  21. django_webhook_subscriber/tests/test_http.py +32 -0
  22. django_webhook_subscriber/tests/test_managers.py +26 -37
  23. django_webhook_subscriber/tests/test_models.py +341 -251
  24. django_webhook_subscriber/tests/test_serializers.py +22 -56
  25. django_webhook_subscriber/tests/test_tasks.py +477 -189
  26. django_webhook_subscriber/tests/test_utils.py +98 -94
  27. django_webhook_subscriber/utils.py +87 -69
  28. django_webhook_subscriber/validators.py +53 -0
  29. django_webhook_subscriber-2.0.0.dist-info/METADATA +774 -0
  30. django_webhook_subscriber-2.0.0.dist-info/RECORD +38 -0
  31. django_webhook_subscriber/management/commands/check_webhook_tasks.py +0 -113
  32. django_webhook_subscriber/management/commands/clean_webhook_logs.py +0 -65
  33. django_webhook_subscriber/management/commands/test_webhook.py +0 -96
  34. django_webhook_subscriber/signals.py +0 -152
  35. django_webhook_subscriber/testing.py +0 -14
  36. django_webhook_subscriber/tests/test_signals.py +0 -268
  37. django_webhook_subscriber-1.0.0.dist-info/METADATA +0 -448
  38. django_webhook_subscriber-1.0.0.dist-info/RECORD +0 -33
  39. {django_webhook_subscriber-1.0.0.dist-info → django_webhook_subscriber-2.0.0.dist-info}/WHEEL +0 -0
  40. {django_webhook_subscriber-1.0.0.dist-info → django_webhook_subscriber-2.0.0.dist-info}/licenses/LICENSE +0 -0
  41. {django_webhook_subscriber-1.0.0.dist-info → django_webhook_subscriber-2.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,169 @@
1
+ """webhook command for Django Webhook Subscriber."""
2
+
3
+ import json
4
+ import time
5
+
6
+ from django.core.management.base import BaseCommand, CommandError
7
+ from django_webhook_subscriber.models import WebhookSubscriber
8
+ from django_webhook_subscriber.sessions import create_webhook_session
9
+ from django_webhook_subscriber.utils import generate_headers
10
+
11
+
12
+ class Command(BaseCommand):
13
+ help = "Test webhook endpoints"
14
+
15
+ def add_arguments(self, parser):
16
+
17
+ parser.add_argument(
18
+ "--subscriber-id",
19
+ "-s",
20
+ type=int,
21
+ help="Test specific subscriber by ID",
22
+ )
23
+ parser.add_argument(
24
+ "--all",
25
+ "-a",
26
+ action="store_true",
27
+ help="Test all active subscribers",
28
+ )
29
+ parser.add_argument(
30
+ "--timeout",
31
+ "-t",
32
+ type=int,
33
+ default=10,
34
+ help="Test timeout in seconds (default: 10)",
35
+ )
36
+ parser.add_argument(
37
+ "--method",
38
+ "-m",
39
+ choices=["HEAD", "GET", "POST"],
40
+ default="HEAD",
41
+ help="HTTP method to use for testing (default: HEAD)",
42
+ )
43
+ parser.add_argument(
44
+ "--payload",
45
+ "-p",
46
+ type=str,
47
+ help="JSON payload for POST requests",
48
+ )
49
+
50
+ def handle(self, *args, **options):
51
+ subscribers = self.get_subscribers(options)
52
+
53
+ if not subscribers:
54
+ raise CommandError("No subscribers found to test.")
55
+
56
+ self.stderr.write(f"Testing {subscribers.count()} subscriber(s)...")
57
+
58
+ results = []
59
+ with create_webhook_session() as session:
60
+ for subscriber in subscribers:
61
+ result = self.test_subscriber(session, subscriber, options)
62
+ results.append(result)
63
+
64
+ self.print_results(results)
65
+
66
+ def get_subscribers(self, options):
67
+ qs = WebhookSubscriber.objects.filter(is_active=True)
68
+ if options.get("subscriber_id"):
69
+ qs = qs.filter(id=options["subscriber_id"])
70
+ elif options.get("all"):
71
+ return qs
72
+ else:
73
+ raise CommandError("Please specify --subscriber-id or --all.")
74
+
75
+ def test_subscriber(self, session, subscriber, options):
76
+ start_time = time.time()
77
+ method = options["method"]
78
+ timeout = min(options["timeout"], subscriber.timeout)
79
+
80
+ self.stdout.write(
81
+ f"Testing {subscriber.name} ({subscriber.target_url})..."
82
+ )
83
+
84
+ try:
85
+ headers = generate_headers(subscriber)
86
+
87
+ if method == "POST" and options.get("payload"):
88
+ try:
89
+ payload = json.loads(options["payload"])
90
+ except json.JSONDecodeError as e:
91
+ return {
92
+ "subscriber": subscriber,
93
+ "success": False,
94
+ "error": f"Invalid JSON payload: {e=}",
95
+ "duration_ms": 0,
96
+ }
97
+ response = session.post(
98
+ subscriber.target_url,
99
+ json=payload,
100
+ headers=headers,
101
+ timeout=timeout,
102
+ )
103
+ elif method == "GET":
104
+ response = session.get(
105
+ subscriber.target_url,
106
+ headers=headers,
107
+ timeout=timeout,
108
+ )
109
+ else: # HEAD or default
110
+ response = session.head(
111
+ subscriber.target_url,
112
+ headers=headers,
113
+ timeout=timeout,
114
+ )
115
+
116
+ duration_ms = int((time.time() - start_time) * 1000)
117
+
118
+ return {
119
+ "subscriber": subscriber,
120
+ "success": True,
121
+ "status_code": response.status_code,
122
+ "duration_ms": duration_ms,
123
+ "headers": dict(response.headers),
124
+ "error": None,
125
+ }
126
+
127
+ except Exception as e:
128
+ duration_ms = int((time.time() - start_time) * 1000)
129
+ return {
130
+ "subscriber": subscriber,
131
+ "success": False,
132
+ "status_code": None,
133
+ "duration_ms": duration_ms,
134
+ "error": f"{e=}",
135
+ }
136
+
137
+ def print_results(self, results):
138
+ self.stdout.write("\n" + "=" * 60)
139
+ self.stdout.write("TEST RESULTS")
140
+ self.stdout.write("=" * 60)
141
+
142
+ successful = 0
143
+ failed = 0
144
+
145
+ for result in results:
146
+ subscriber = result["subscriber"]
147
+
148
+ if result["success"]:
149
+ successful += 1
150
+ style = self.style.SUCCESS
151
+ status = (
152
+ f"✓ {result['status_code']} ({result['duration_ms']}ms)"
153
+ )
154
+ else:
155
+ failed += 1
156
+ style = self.style.ERROR
157
+ status = f"✗ {result['error']} ({result['duration_ms']}ms)"
158
+
159
+ self.stdout.write(style(f"{subscriber.name}: {status}"))
160
+
161
+ self.stdout.write("\n" + "-" * 60)
162
+ self.stdout.write(
163
+ f"Total: {len(results)} | Success: {successful} | Failed: {failed}"
164
+ )
165
+
166
+ if failed > 0:
167
+ self.stdout.write(
168
+ self.style.WARNING(f"⚠ {failed} endpoint(s) failed testing")
169
+ )
@@ -0,0 +1,173 @@
1
+ """webhook_cache command for Django Webhook Subscriber."""
2
+
3
+ from django.contrib.contenttypes.models import ContentType
4
+ from django.core.management.base import BaseCommand, CommandError
5
+ from django_webhook_subscriber.delivery import (
6
+ clear_webhook_cache,
7
+ get_webhook_cache_stats,
8
+ warm_webhook_cache,
9
+ )
10
+
11
+
12
+ class Command(BaseCommand):
13
+ help = "Manage webhook cache operations"
14
+
15
+ def add_arguments(self, parser):
16
+ subparsers = parser.add_subparsers(dest="action", help="Cache actions")
17
+
18
+ # Clear command
19
+ clear_parser = subparsers.add_parser(
20
+ "clear", help="Clear webhook cache"
21
+ )
22
+ clear_parser.add_argument(
23
+ "--content-type",
24
+ "-c",
25
+ type=str,
26
+ help="Clear cache for specific content type "
27
+ "(app_label.model_name)",
28
+ )
29
+ clear_parser.add_argument(
30
+ "--event",
31
+ "-e",
32
+ type=str,
33
+ help="Clear cache for specific event name",
34
+ )
35
+
36
+ # Stats command
37
+ subparsers.add_parser("stats", help="Show cache statistics")
38
+
39
+ # List command
40
+ list_parser = subparsers.add_parser("list", help="List cached keys")
41
+ list_parser.add_argument(
42
+ "--show-empty",
43
+ action="store_true",
44
+ help="Show keys that are not cached",
45
+ )
46
+
47
+ # Warm command
48
+ subparsers.add_parser("warm", help="Pre-warm the cache")
49
+
50
+ def handle(self, *args, **options):
51
+ """Handle the command based on the provided action."""
52
+
53
+ action = options.get("action")
54
+
55
+ if not action:
56
+ self.print_help("manage.py", "webhook_cache")
57
+ return
58
+
59
+ if action == "clear":
60
+ self.handle_clear(options)
61
+ elif action == "stats":
62
+ self.handle_stats()
63
+ elif action == "list":
64
+ self.handle_list(options)
65
+ elif action == "warm":
66
+ self.handle_warm()
67
+
68
+ def handle_clear(self, options):
69
+ """Clear webhook cache with optional filtering."""
70
+
71
+ content_type = None
72
+ if options["content_type"]:
73
+ try:
74
+ app_label, model_name = options["content_type"].split(".")
75
+ content_type = ContentType.objects.get(
76
+ app_label=app_label, model=model_name
77
+ )
78
+
79
+ except (ValueError, ContentType.DoesNotExist):
80
+ raise CommandError(
81
+ f'Invalid content type: {options["content_type"]}'
82
+ )
83
+
84
+ clear_webhook_cache(
85
+ content_type=content_type,
86
+ event_name=options.get("event"),
87
+ )
88
+
89
+ if content_type and options.get("event"):
90
+ self.stdout.write(
91
+ self.style.SUCCESS(
92
+ f"Cleared cache for {content_type} - {options['event']}"
93
+ )
94
+ )
95
+ elif content_type:
96
+ self.stdout.write(
97
+ self.style.SUCCESS(f"Cleared cache for {content_type}")
98
+ )
99
+ elif options.get("event"):
100
+ self.stdout.write(
101
+ self.style.SUCCESS(
102
+ f"Cleared cache for event '{options['event']}'"
103
+ )
104
+ )
105
+ else:
106
+ self.stdout.write(self.style.SUCCESS("Cleared all webhook cache"))
107
+
108
+ def handle_stats(self):
109
+ """Show detailed cache statistics."""
110
+ try:
111
+ stats = get_webhook_cache_stats()
112
+
113
+ self.stdout.write(self.style.SUCCESS("Webhook Cache Statistics"))
114
+
115
+ self.stdout.write("-" * 40)
116
+ self.stdout.write(
117
+ f"Total possible keys: {stats['total_possible_keys']}"
118
+ )
119
+ self.stdout.write(f"Actually cached keys: {stats['cached_keys']}")
120
+ self.stdout.write(
121
+ f"Cache hit ratio: {stats['cache_hit_ratio']:.1f}"
122
+ )
123
+ self.stdout.write(
124
+ "Total cached subscriptions: "
125
+ f"{stats['total_cached_subscriptions']}"
126
+ )
127
+
128
+ if stats["key_details"]:
129
+ self.stdout.write("\nDetailed breakdown:")
130
+ for detail in stats["key_details"]:
131
+ status = "CACHED" if detail["is_cached"] else "NOT CACHED"
132
+ self.stdout.write(
133
+ f" {detail['key']}: {status} "
134
+ f"({detail['subscription_count']} subscriptions)"
135
+ )
136
+
137
+ except Exception as e:
138
+ raise CommandError(f"Error getting cache stats: {e=}")
139
+
140
+ def handle_list(self, options):
141
+ """List all webhook cache keys."""
142
+
143
+ try:
144
+ stats = get_webhook_cache_stats()
145
+ show_empty = options.get("show_empty", False)
146
+
147
+ for detail in stats["key_details"]:
148
+ if not show_empty and not detail["is_cached"]:
149
+ continue
150
+
151
+ status = "CACHED" if detail["is_cached"] else "NOT CACHED"
152
+ self.stdout.write(
153
+ f"{detail['key']}: {status} "
154
+ f"({detail['subscription_count']} subscriptions)"
155
+ )
156
+
157
+ except Exception as e:
158
+ raise CommandError(f"Error listing cache keys: {e=}")
159
+
160
+ def handle_warm(self):
161
+ """Pre-warm the webhook cache."""
162
+
163
+ try:
164
+ result = warm_webhook_cache()
165
+ self.stdout.write(
166
+ self.style.SUCCESS(
167
+ f"Cache warmed for {result['warmed']} subscription "
168
+ "combinations."
169
+ )
170
+ )
171
+
172
+ except Exception as e:
173
+ raise CommandError(f"Error warming cache: {e=}")
@@ -0,0 +1,226 @@
1
+ """webhook_logs command for Django Webhook Subscriber."""
2
+
3
+ from datetime import timedelta
4
+
5
+ from django.core.management.base import BaseCommand
6
+ from django.db import models, transaction
7
+ from django.utils import timezone
8
+ from django_webhook_subscriber.conf import rest_webhook_settings
9
+ from django_webhook_subscriber.models import (
10
+ WebhookDeliveryLog,
11
+ )
12
+
13
+
14
+ class Command(BaseCommand):
15
+ help = "Manage webhook delivery logs"
16
+
17
+ def add_arguments(self, parser):
18
+
19
+ subparsers = parser.add_subparsers(dest="action", help="Log actions")
20
+
21
+ # Cleanup command
22
+ cleanup_parser = subparsers.add_parser(
23
+ "cleanup", help="Clean up old logs"
24
+ )
25
+ cleanup_parser.add_argument(
26
+ "--days",
27
+ type=int,
28
+ help="Keep logs newer than N days (overrides settings)",
29
+ )
30
+ cleanup_parser.add_argument(
31
+ "--subscription-id",
32
+ "-s",
33
+ type=int,
34
+ help="Clean logs for specific subscription by ID",
35
+ )
36
+ cleanup_parser.add_argument(
37
+ "--dry-run",
38
+ "-d",
39
+ action="store_true",
40
+ help="Show what would be deleted without actually deleting",
41
+ )
42
+
43
+ # Stats command
44
+ stats_parser = subparsers.add_parser(
45
+ "stats", help="Show log statistics"
46
+ )
47
+ stats_parser.add_argument(
48
+ "--days",
49
+ type=int,
50
+ default=30,
51
+ help="Show stats for last N days (default: 30)",
52
+ )
53
+
54
+ # Error command
55
+ errors_parser = subparsers.add_parser(
56
+ "errors", help="Show recent error logs"
57
+ )
58
+ errors_parser.add_argument(
59
+ "--limit",
60
+ type=int,
61
+ default=10,
62
+ help="Number of recent errors to show (default: 10)",
63
+ )
64
+
65
+ def handle(self, *args, **options):
66
+ action = options.get("action")
67
+
68
+ if not action:
69
+ self.print_help("manage.py", "webhook_logs")
70
+ return
71
+
72
+ if action == "cleanup":
73
+ self.handle_cleanup(options)
74
+ elif action == "stats":
75
+ self.handle_stats(options)
76
+ elif action == "errors":
77
+ self.handle_errors(options)
78
+
79
+ def handle_cleanup(self, options):
80
+ """Clean up old webhook logs."""
81
+ retention_days = (
82
+ options.get("days") or rest_webhook_settings.LOG_RETENTION_DAYS
83
+ )
84
+ cutoff_date = timezone.now() - timedelta(days=retention_days)
85
+
86
+ query = WebhookDeliveryLog.objects.filter(created_at__lte=cutoff_date)
87
+
88
+ if options.get("subscription_id"):
89
+ query = query.filter(subscription_id=options["subscription_id"])
90
+
91
+ total_count = query.count()
92
+ if total_count == 0:
93
+ self.stdout.write(self.style.SUCCESS("No old logs to clean up"))
94
+ return
95
+
96
+ if options.get("dry_run"):
97
+ self.stdout.write(
98
+ self.style.WARNING(
99
+ f"DRY RUN: Would delete {total_count} logs older than "
100
+ f"{cutoff_date}"
101
+ )
102
+ )
103
+ return
104
+
105
+ # Delete in batches to avoid memory issues
106
+ batch_size = 1000
107
+ total_deleted = 0
108
+ with transaction.atomic():
109
+ while True:
110
+ batch_ids = list(
111
+ query.values_list("id", flat=True)[:batch_size]
112
+ )
113
+ if not batch_ids:
114
+ break
115
+
116
+ deleted_count = WebhookDeliveryLog.objects.filter(
117
+ id__in=batch_ids
118
+ ).delete()[0]
119
+ total_deleted += deleted_count
120
+
121
+ self.stdout.write(
122
+ f"Deleted {deleted_count} logs (total: {total_deleted})"
123
+ )
124
+
125
+ self.stdout.write(
126
+ self.style.SUCCESS(
127
+ f"Cleanup completed. Deleted {total_deleted} logs older than "
128
+ f"{cutoff_date}"
129
+ )
130
+ )
131
+
132
+ def handle_stats(self, options):
133
+ """Show webhook log statistics."""
134
+
135
+ days = options["days"]
136
+ since_date = timezone.now() - timedelta(days=days)
137
+
138
+ logs = WebhookDeliveryLog.objects.filter(created_at__gte=since_date)
139
+
140
+ total_logs = logs.count()
141
+ successful_logs = logs.filter(
142
+ response_status__gte=200, response_status__lt=300
143
+ ).count()
144
+ error_logs = logs.exclude(
145
+ response_status__gte=200, response_status__lt=300
146
+ ).count()
147
+
148
+ # Success rate
149
+ success_rate = (
150
+ (successful_logs / total_logs * 100) if total_logs > 0 else 0
151
+ )
152
+
153
+ # Top failing subscriptions
154
+ failing_subs = (
155
+ logs.exclude(response_status__gte=200, response_status__lt=300)
156
+ .values(
157
+ "subscription__subscriber__name", "subscription__event_name"
158
+ )
159
+ .annotate(error_count=models.Count("id"))
160
+ .order_by("-error_count")[:5]
161
+ )
162
+
163
+ # Average response time
164
+ avg_duration = (
165
+ logs.exclude(delivery_duration_ms__isnull=True).aggregate(
166
+ avg_duration=models.Avg("delivery_duration_ms")
167
+ )["avg_duration"]
168
+ or 0
169
+ )
170
+
171
+ self.stdout.write(
172
+ self.style.SUCCESS(f"Webhook Statistics (Last {days} days)")
173
+ )
174
+ self.stdout.write("-" * 50)
175
+ self.stdout.write(f"Total deliveries: {total_logs}")
176
+ self.stdout.write(f"Successful: {successful_logs}")
177
+ self.stdout.write(f"Failed: {error_logs}")
178
+ self.stdout.write(f"Success rate: {success_rate:.1f}%")
179
+
180
+ if avg_duration:
181
+ self.stdout.write(f"Average response time: {avg_duration:.0f}ms")
182
+
183
+ if failing_subs:
184
+ self.stdout.write("\nTop failing subscriptions:")
185
+ for sub in failing_subs:
186
+ self.stdout.write(
187
+ f" {sub['subscription__subscriber__name']} - "
188
+ f"{sub['subscription__event_name']}: {sub['error_count']} "
189
+ "errors"
190
+ )
191
+
192
+ def handle_errors(self, options):
193
+ """Show recent webhook errors."""
194
+
195
+ limit = options["limit"]
196
+
197
+ error_logs = (
198
+ WebhookDeliveryLog.objects.exclude(
199
+ response_status__gte=200, response_status__lt=300
200
+ )
201
+ .select_related("subscription", "subscription__subscriber")
202
+ .order_by("-created_at")[:limit]
203
+ )
204
+
205
+ if not error_logs:
206
+ self.stdout.write(self.style.SUCCESS("No recent errors found"))
207
+ return
208
+
209
+ self.stdout.write(
210
+ self.style.ERROR(f"Recent Webhook Errors (Last {limit})")
211
+ )
212
+ self.stdout.write("-" * 60)
213
+
214
+ for log in error_logs:
215
+ subscriber_name = log.subscription.subscriber.name
216
+ event_name = log.subscription.event_name
217
+
218
+ if log.error_message:
219
+ error_info = f"Exception: {log.error_message[:100]}"
220
+ else:
221
+ error_info = f"HTTP {log.response_status}"
222
+
223
+ self.stdout.write(
224
+ f"{log.created_at.strftime('%Y-%m-%d %H:%M')} | "
225
+ f"{subscriber_name} - {event_name} | {error_info}"
226
+ )