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,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
|
+
)
|