django-cfg 1.4.4__py3-none-any.whl → 1.4.6__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_cfg/CHANGELOG.md +57 -0
- django_cfg/CONTRIBUTING.md +145 -0
- django_cfg/LICENSE +21 -0
- django_cfg/apps/api/endpoints/__init__.py +5 -0
- django_cfg/apps/api/endpoints/checker.py +603 -0
- django_cfg/apps/api/endpoints/drf_views.py +55 -0
- django_cfg/apps/api/endpoints/serializers.py +123 -0
- django_cfg/apps/api/endpoints/tests.py +281 -0
- django_cfg/apps/api/endpoints/urls.py +14 -0
- django_cfg/apps/api/endpoints/views.py +41 -0
- django_cfg/apps/payments/admin_interface/views/api/webhook_admin.py +2 -2
- django_cfg/apps/payments/middleware/rate_limiting.py +9 -3
- django_cfg/apps/urls.py +2 -0
- django_cfg/management/commands/check_endpoints.py +169 -0
- django_cfg/management/commands/rundramatiq.py +3 -3
- django_cfg/modules/django_ipc_client/client.py +1 -0
- django_cfg/modules/django_unfold/dashboard.py +1 -0
- django_cfg/pyproject.toml +148 -0
- django_cfg/routing/routers.py +15 -2
- {django_cfg-1.4.4.dist-info → django_cfg-1.4.6.dist-info}/METADATA +1 -1
- {django_cfg-1.4.4.dist-info → django_cfg-1.4.6.dist-info}/RECORD +24 -12
- {django_cfg-1.4.4.dist-info → django_cfg-1.4.6.dist-info}/WHEEL +0 -0
- {django_cfg-1.4.4.dist-info → django_cfg-1.4.6.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.4.4.dist-info → django_cfg-1.4.6.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,603 @@
|
|
1
|
+
"""
|
2
|
+
Endpoints Status Checker
|
3
|
+
|
4
|
+
Utility for checking all registered Django URL endpoints.
|
5
|
+
"""
|
6
|
+
|
7
|
+
import time
|
8
|
+
import re
|
9
|
+
import uuid
|
10
|
+
from typing import List, Dict, Any, Optional, Tuple
|
11
|
+
from django.urls import get_resolver, URLPattern, URLResolver
|
12
|
+
from django.test import Client
|
13
|
+
from django.utils import timezone
|
14
|
+
from django.contrib.auth import get_user_model
|
15
|
+
|
16
|
+
|
17
|
+
def get_url_group(url_pattern: str, depth: int = 3) -> str:
|
18
|
+
"""
|
19
|
+
Extract group from URL pattern up to specified depth.
|
20
|
+
|
21
|
+
Examples:
|
22
|
+
/api/accounts/profile/ → api/accounts
|
23
|
+
/api/payments/webhook/status/ → api/payments/webhook
|
24
|
+
/cfg/health/drf/ → cfg/health/drf
|
25
|
+
/admin/auth/user/ → admin/auth/user
|
26
|
+
|
27
|
+
Args:
|
28
|
+
url_pattern: URL pattern string
|
29
|
+
depth: Maximum depth for grouping (default: 3)
|
30
|
+
|
31
|
+
Returns:
|
32
|
+
Group name as string
|
33
|
+
"""
|
34
|
+
# Remove leading/trailing slashes and split
|
35
|
+
parts = [p for p in url_pattern.strip('/').split('/') if p and '<' not in p]
|
36
|
+
|
37
|
+
# Take up to depth parts
|
38
|
+
group_parts = parts[:depth]
|
39
|
+
|
40
|
+
return '/'.join(group_parts) if group_parts else 'root'
|
41
|
+
|
42
|
+
|
43
|
+
def should_check_endpoint(url_pattern: str, url_name: Optional[str] = None) -> bool:
|
44
|
+
"""
|
45
|
+
Determine if endpoint should be checked.
|
46
|
+
|
47
|
+
Excludes:
|
48
|
+
- Health check endpoints (to avoid recursion)
|
49
|
+
- Admin endpoints
|
50
|
+
- Static/media files
|
51
|
+
- Django internal endpoints
|
52
|
+
- Schema/Swagger/Redoc documentation endpoints
|
53
|
+
- DRF format suffix patterns (causes kwarg errors)
|
54
|
+
|
55
|
+
Args:
|
56
|
+
url_pattern: URL pattern string
|
57
|
+
url_name: Optional URL name
|
58
|
+
|
59
|
+
Returns:
|
60
|
+
True if endpoint should be checked
|
61
|
+
"""
|
62
|
+
# Exclude patterns
|
63
|
+
exclude_patterns = [
|
64
|
+
r'^/?static/',
|
65
|
+
r'^/?media/',
|
66
|
+
r'^/?admin/',
|
67
|
+
r'^/?cfg/health/', # Exclude health endpoints (recursion prevention)
|
68
|
+
r'^/?cfg/api/endpoints/', # Exclude ourselves
|
69
|
+
r'^/__debug__/',
|
70
|
+
r'^/__reload__/',
|
71
|
+
r'^/?schema/', # Exclude schema/swagger/redoc documentation endpoints
|
72
|
+
]
|
73
|
+
|
74
|
+
for pattern in exclude_patterns:
|
75
|
+
if re.match(pattern, url_pattern):
|
76
|
+
return False
|
77
|
+
|
78
|
+
# Exclude DRF format suffix patterns (e.g., \.(?P<format>[a-z0-9]+))
|
79
|
+
# These cause "got an unexpected keyword argument 'format'" errors in action methods
|
80
|
+
if r'\.(?P<format>' in url_pattern or '<drf_format_suffix:' in url_pattern:
|
81
|
+
return False
|
82
|
+
|
83
|
+
# Exclude URL names
|
84
|
+
exclude_names = [
|
85
|
+
'django_cfg_health',
|
86
|
+
'django_cfg_quick_health',
|
87
|
+
'django_cfg_drf_health',
|
88
|
+
'django_cfg_drf_quick_health',
|
89
|
+
'endpoints_status',
|
90
|
+
'endpoints_status_drf',
|
91
|
+
]
|
92
|
+
|
93
|
+
if url_name in exclude_names:
|
94
|
+
return False
|
95
|
+
|
96
|
+
return True
|
97
|
+
|
98
|
+
|
99
|
+
def get_test_value_for_parameter(param_name: str, param_pattern: str) -> str:
|
100
|
+
"""
|
101
|
+
Generate appropriate test value for URL parameter based on name and pattern.
|
102
|
+
|
103
|
+
Args:
|
104
|
+
param_name: Parameter name (e.g., 'slug', 'pk', 'id', 'uuid')
|
105
|
+
param_pattern: Regex pattern for the parameter
|
106
|
+
|
107
|
+
Returns:
|
108
|
+
Test value string
|
109
|
+
|
110
|
+
Examples:
|
111
|
+
slug -> 'test-slug'
|
112
|
+
pk -> '1'
|
113
|
+
id -> '1'
|
114
|
+
uuid -> generated UUID
|
115
|
+
format -> 'json'
|
116
|
+
"""
|
117
|
+
param_lower = param_name.lower()
|
118
|
+
|
119
|
+
# UUID parameters
|
120
|
+
if 'uuid' in param_lower:
|
121
|
+
return str(uuid.uuid4())
|
122
|
+
|
123
|
+
# Primary key / ID parameters
|
124
|
+
if param_lower in ['pk', 'id']:
|
125
|
+
return '1'
|
126
|
+
|
127
|
+
# Slug parameters
|
128
|
+
if 'slug' in param_lower:
|
129
|
+
return 'test-slug'
|
130
|
+
|
131
|
+
# Format parameters (for DRF format suffixes)
|
132
|
+
if 'format' in param_lower:
|
133
|
+
return 'json'
|
134
|
+
|
135
|
+
# Username parameters
|
136
|
+
if 'username' in param_lower:
|
137
|
+
return 'testuser'
|
138
|
+
|
139
|
+
# Year/Month/Day parameters
|
140
|
+
if param_lower == 'year':
|
141
|
+
return '2024'
|
142
|
+
if param_lower == 'month':
|
143
|
+
return '01'
|
144
|
+
if param_lower == 'day':
|
145
|
+
return '01'
|
146
|
+
|
147
|
+
# Generic string parameter - check pattern
|
148
|
+
if '[a-z0-9]' in param_pattern or '[\\w]' in param_pattern:
|
149
|
+
return 'test'
|
150
|
+
|
151
|
+
# Numeric parameter
|
152
|
+
if '[0-9]' in param_pattern or '\\d' in param_pattern:
|
153
|
+
return '1'
|
154
|
+
|
155
|
+
# Default
|
156
|
+
return 'test'
|
157
|
+
|
158
|
+
|
159
|
+
def resolve_parametrized_url(url_pattern: str) -> Optional[str]:
|
160
|
+
"""
|
161
|
+
Resolve URL pattern with parameters to concrete URL with test values.
|
162
|
+
|
163
|
+
Supports both regex patterns and Django typed path converters.
|
164
|
+
|
165
|
+
Args:
|
166
|
+
url_pattern: URL pattern with Django parameters
|
167
|
+
|
168
|
+
Returns:
|
169
|
+
Resolved URL with test values, or None if cannot resolve
|
170
|
+
|
171
|
+
Examples:
|
172
|
+
Regex patterns:
|
173
|
+
'/api/products/(?P<slug>[^/]+)/' -> '/api/products/test-slug/'
|
174
|
+
'/api/users/(?P<pk>[0-9]+)/' -> '/api/users/1/'
|
175
|
+
|
176
|
+
Typed converters:
|
177
|
+
'/api/products/<int:pk>/' -> '/api/products/1/'
|
178
|
+
'/api/posts/<slug:slug>/' -> '/api/posts/test-slug/'
|
179
|
+
'/api/items/<uuid:item_id>/' -> '/api/items/<uuid>/'
|
180
|
+
"""
|
181
|
+
resolved_url = url_pattern
|
182
|
+
|
183
|
+
# First, handle Django typed path converters: <converter:name>
|
184
|
+
# Pattern: <type:name>
|
185
|
+
typed_converter_regex = r'<([^:>]+):([^>]+)>'
|
186
|
+
|
187
|
+
typed_matches = list(re.finditer(typed_converter_regex, url_pattern))
|
188
|
+
|
189
|
+
for match in typed_matches:
|
190
|
+
converter_type = match.group(1)
|
191
|
+
param_name = match.group(2)
|
192
|
+
full_match = match.group(0)
|
193
|
+
|
194
|
+
# Get test value based on converter type
|
195
|
+
if converter_type == 'int':
|
196
|
+
test_value = '1'
|
197
|
+
elif converter_type == 'slug':
|
198
|
+
test_value = 'test-slug'
|
199
|
+
elif converter_type == 'uuid':
|
200
|
+
test_value = str(uuid.uuid4())
|
201
|
+
elif converter_type == 'str':
|
202
|
+
test_value = get_test_value_for_parameter(param_name, '')
|
203
|
+
elif converter_type == 'path':
|
204
|
+
test_value = 'test/path'
|
205
|
+
elif converter_type == 'drf_format_suffix':
|
206
|
+
test_value = 'json'
|
207
|
+
else:
|
208
|
+
# Unknown converter - use parameter name to guess
|
209
|
+
test_value = get_test_value_for_parameter(param_name, '')
|
210
|
+
|
211
|
+
# Replace typed converter with test value
|
212
|
+
resolved_url = resolved_url.replace(full_match, test_value, 1)
|
213
|
+
|
214
|
+
# Then handle regex patterns: (?P<name>pattern)
|
215
|
+
param_regex = r'\(\?P<([^>]+)>([^)]+)\)'
|
216
|
+
|
217
|
+
regex_matches = re.finditer(param_regex, resolved_url)
|
218
|
+
|
219
|
+
for match in regex_matches:
|
220
|
+
param_name = match.group(1)
|
221
|
+
param_pattern = match.group(2)
|
222
|
+
full_match = match.group(0)
|
223
|
+
|
224
|
+
# Get test value for this parameter
|
225
|
+
test_value = get_test_value_for_parameter(param_name, param_pattern)
|
226
|
+
|
227
|
+
# Replace parameter with test value
|
228
|
+
resolved_url = resolved_url.replace(full_match, test_value, 1)
|
229
|
+
|
230
|
+
# Clean up any remaining regex syntax
|
231
|
+
resolved_url = re.sub(r'[\^$\\]', '', resolved_url)
|
232
|
+
|
233
|
+
# Check if resolution was successful (no parameter patterns left)
|
234
|
+
if '(?P<' in resolved_url or '<' in resolved_url:
|
235
|
+
return None
|
236
|
+
|
237
|
+
return resolved_url
|
238
|
+
|
239
|
+
|
240
|
+
def collect_endpoints(
|
241
|
+
urlpatterns=None,
|
242
|
+
prefix: str = '',
|
243
|
+
namespace: str = '',
|
244
|
+
include_unnamed: bool = True
|
245
|
+
) -> List[Dict[str, Any]]:
|
246
|
+
"""
|
247
|
+
Recursively collect all URL endpoints.
|
248
|
+
|
249
|
+
Args:
|
250
|
+
urlpatterns: URL patterns to process (default: root resolver)
|
251
|
+
prefix: Current URL prefix
|
252
|
+
namespace: Current URL namespace
|
253
|
+
include_unnamed: Include endpoints without names
|
254
|
+
|
255
|
+
Returns:
|
256
|
+
List of endpoint dictionaries
|
257
|
+
"""
|
258
|
+
if urlpatterns is None:
|
259
|
+
resolver = get_resolver()
|
260
|
+
urlpatterns = resolver.url_patterns
|
261
|
+
|
262
|
+
endpoints = []
|
263
|
+
|
264
|
+
for pattern in urlpatterns:
|
265
|
+
if isinstance(pattern, URLResolver):
|
266
|
+
# This is an include() - recurse
|
267
|
+
new_prefix = prefix + str(pattern.pattern)
|
268
|
+
new_namespace = namespace
|
269
|
+
|
270
|
+
if hasattr(pattern, 'namespace') and pattern.namespace:
|
271
|
+
new_namespace = (
|
272
|
+
f"{namespace}:{pattern.namespace}"
|
273
|
+
if namespace
|
274
|
+
else pattern.namespace
|
275
|
+
)
|
276
|
+
|
277
|
+
# Recursively collect nested patterns
|
278
|
+
endpoints.extend(
|
279
|
+
collect_endpoints(
|
280
|
+
pattern.url_patterns,
|
281
|
+
new_prefix,
|
282
|
+
new_namespace,
|
283
|
+
include_unnamed
|
284
|
+
)
|
285
|
+
)
|
286
|
+
|
287
|
+
elif isinstance(pattern, URLPattern):
|
288
|
+
# Regular URL pattern
|
289
|
+
full_pattern = prefix + str(pattern.pattern)
|
290
|
+
|
291
|
+
# Clean up the pattern
|
292
|
+
clean_pattern = re.sub(r'\^|\$', '', full_pattern)
|
293
|
+
clean_pattern = re.sub(r'\\/', '/', clean_pattern)
|
294
|
+
|
295
|
+
# Ensure leading slash
|
296
|
+
if not clean_pattern.startswith('/'):
|
297
|
+
clean_pattern = '/' + clean_pattern
|
298
|
+
|
299
|
+
url_name = getattr(pattern, 'name', None)
|
300
|
+
|
301
|
+
# Skip unnamed if requested
|
302
|
+
if not include_unnamed and not url_name:
|
303
|
+
continue
|
304
|
+
|
305
|
+
# Check if should include this endpoint
|
306
|
+
if not should_check_endpoint(clean_pattern, url_name):
|
307
|
+
continue
|
308
|
+
|
309
|
+
# Get view info
|
310
|
+
view_name = 'unknown'
|
311
|
+
if hasattr(pattern, 'callback'):
|
312
|
+
callback = pattern.callback
|
313
|
+
if hasattr(callback, 'view_class'):
|
314
|
+
view_name = callback.view_class.__name__
|
315
|
+
elif hasattr(callback, '__name__'):
|
316
|
+
view_name = callback.__name__
|
317
|
+
|
318
|
+
# Handle patterns with parameters
|
319
|
+
if '<' in clean_pattern or '(?P<' in clean_pattern:
|
320
|
+
# Try to resolve with test values
|
321
|
+
resolved_url = resolve_parametrized_url(clean_pattern)
|
322
|
+
|
323
|
+
if resolved_url:
|
324
|
+
# Successfully resolved - can test it
|
325
|
+
endpoints.append({
|
326
|
+
'url': resolved_url,
|
327
|
+
'url_pattern': clean_pattern, # Keep original pattern for reference
|
328
|
+
'url_name': url_name,
|
329
|
+
'namespace': namespace,
|
330
|
+
'group': get_url_group(clean_pattern),
|
331
|
+
'view': view_name,
|
332
|
+
'status': 'pending',
|
333
|
+
'has_parameters': True,
|
334
|
+
})
|
335
|
+
else:
|
336
|
+
# Cannot resolve - skip
|
337
|
+
endpoints.append({
|
338
|
+
'url': clean_pattern,
|
339
|
+
'url_name': url_name,
|
340
|
+
'namespace': namespace,
|
341
|
+
'group': get_url_group(clean_pattern),
|
342
|
+
'view': view_name,
|
343
|
+
'status': 'skipped',
|
344
|
+
'reason': 'cannot_resolve_parameters',
|
345
|
+
})
|
346
|
+
else:
|
347
|
+
# No parameters - can test directly
|
348
|
+
endpoints.append({
|
349
|
+
'url': clean_pattern,
|
350
|
+
'url_name': url_name,
|
351
|
+
'namespace': namespace,
|
352
|
+
'group': get_url_group(clean_pattern),
|
353
|
+
'view': view_name,
|
354
|
+
'status': 'pending',
|
355
|
+
})
|
356
|
+
|
357
|
+
return endpoints
|
358
|
+
|
359
|
+
|
360
|
+
def create_test_user_and_get_token() -> Optional[str]:
|
361
|
+
"""
|
362
|
+
Create test user and generate JWT token.
|
363
|
+
|
364
|
+
Returns:
|
365
|
+
JWT access token or None if JWT not available
|
366
|
+
"""
|
367
|
+
try:
|
368
|
+
from rest_framework_simplejwt.tokens import RefreshToken
|
369
|
+
|
370
|
+
User = get_user_model()
|
371
|
+
|
372
|
+
# Create or get test user
|
373
|
+
username = 'endpoint_test_user'
|
374
|
+
email = 'endpoint_test@test.com'
|
375
|
+
|
376
|
+
user, created = User.objects.get_or_create(
|
377
|
+
username=username,
|
378
|
+
defaults={'email': email, 'is_active': True}
|
379
|
+
)
|
380
|
+
|
381
|
+
if created:
|
382
|
+
user.set_password('testpass123')
|
383
|
+
user.save()
|
384
|
+
|
385
|
+
# Generate JWT token
|
386
|
+
refresh = RefreshToken.for_user(user)
|
387
|
+
access_token = str(refresh.access_token)
|
388
|
+
|
389
|
+
return access_token
|
390
|
+
|
391
|
+
except ImportError:
|
392
|
+
# JWT not installed
|
393
|
+
return None
|
394
|
+
except Exception:
|
395
|
+
# Any other error
|
396
|
+
return None
|
397
|
+
|
398
|
+
|
399
|
+
def check_endpoint(
|
400
|
+
endpoint: Dict[str, Any],
|
401
|
+
client: Optional[Client] = None,
|
402
|
+
timeout: int = 5,
|
403
|
+
auth_token: Optional[str] = None,
|
404
|
+
auto_auth: bool = True
|
405
|
+
) -> tuple[Dict[str, Any], Optional[str]]:
|
406
|
+
"""
|
407
|
+
Check a single endpoint health.
|
408
|
+
|
409
|
+
Automatically creates test user and retries with JWT if endpoint returns 401/403.
|
410
|
+
|
411
|
+
Args:
|
412
|
+
endpoint: Endpoint dictionary from collect_endpoints()
|
413
|
+
client: Django test client (creates new if None)
|
414
|
+
timeout: Request timeout in seconds
|
415
|
+
auth_token: JWT token (created automatically on first 401/403)
|
416
|
+
auto_auth: Auto-retry with JWT on 401/403 (default: True)
|
417
|
+
|
418
|
+
Returns:
|
419
|
+
Tuple of (updated endpoint dictionary, auth_token if created)
|
420
|
+
"""
|
421
|
+
if client is None:
|
422
|
+
client = Client()
|
423
|
+
|
424
|
+
# Skip if already marked as skipped
|
425
|
+
if endpoint.get('status') == 'skipped':
|
426
|
+
return endpoint, auth_token
|
427
|
+
|
428
|
+
url = endpoint['url']
|
429
|
+
token_created = False
|
430
|
+
|
431
|
+
try:
|
432
|
+
start_time = time.time()
|
433
|
+
|
434
|
+
# First attempt - without auth
|
435
|
+
# Add special header to bypass rate limiting for internal checks
|
436
|
+
extra_headers = {
|
437
|
+
'SERVER_NAME': 'localhost',
|
438
|
+
'HTTP_X_DJANGO_CFG_INTERNAL_CHECK': 'true'
|
439
|
+
}
|
440
|
+
response = client.get(url, timeout=timeout, **extra_headers)
|
441
|
+
response_time = (time.time() - start_time) * 1000 # Convert to ms
|
442
|
+
status_code = response.status_code
|
443
|
+
|
444
|
+
# If unauthorized and auto_auth enabled, retry with token
|
445
|
+
requires_auth = False
|
446
|
+
if status_code in [401, 403] and auto_auth:
|
447
|
+
requires_auth = True
|
448
|
+
|
449
|
+
# Create token if not provided (only once!)
|
450
|
+
if auth_token is None:
|
451
|
+
auth_token = create_test_user_and_get_token()
|
452
|
+
token_created = True
|
453
|
+
|
454
|
+
if auth_token:
|
455
|
+
start_time = time.time()
|
456
|
+
extra_headers['HTTP_AUTHORIZATION'] = f'Bearer {auth_token}'
|
457
|
+
response = client.get(url, timeout=timeout, **extra_headers)
|
458
|
+
response_time = (time.time() - start_time) * 1000 # Convert to ms
|
459
|
+
status_code = response.status_code
|
460
|
+
|
461
|
+
# Determine if healthy
|
462
|
+
# 200-299: Success
|
463
|
+
# 300-399: Redirects (OK)
|
464
|
+
# 401, 403: Auth required (expected, still healthy)
|
465
|
+
# 404: Not found (might be OK if endpoint exists but has no data)
|
466
|
+
# 405: Method not allowed (endpoint exists, just wrong method)
|
467
|
+
# 429: Rate limited (expected for rate-limited APIs, still healthy)
|
468
|
+
# 500+: Server errors (unhealthy)
|
469
|
+
|
470
|
+
is_healthy = status_code in [
|
471
|
+
200, 201, 204, # Success
|
472
|
+
301, 302, 303, 307, 308, # Redirects
|
473
|
+
401, 403, # Auth required (expected)
|
474
|
+
405, # Method not allowed (endpoint exists)
|
475
|
+
429, # Rate limited (expected for rate-limited APIs)
|
476
|
+
]
|
477
|
+
|
478
|
+
# Special handling for 404
|
479
|
+
reason = None
|
480
|
+
if status_code == 404:
|
481
|
+
# 404 might be OK for some endpoints (e.g., detail views with no data)
|
482
|
+
# Mark as warning rather than unhealthy
|
483
|
+
is_healthy = None # Will be marked as 'warning'
|
484
|
+
reason = 'Not Found - endpoint works but no data exists (empty list or test object not found)'
|
485
|
+
|
486
|
+
endpoint.update({
|
487
|
+
'status_code': status_code,
|
488
|
+
'response_time_ms': round(response_time, 2),
|
489
|
+
'is_healthy': is_healthy,
|
490
|
+
'status': 'healthy' if is_healthy else ('warning' if is_healthy is None else 'unhealthy'),
|
491
|
+
'last_checked': timezone.now().isoformat(),
|
492
|
+
})
|
493
|
+
|
494
|
+
if reason:
|
495
|
+
endpoint['reason'] = reason
|
496
|
+
|
497
|
+
if requires_auth:
|
498
|
+
endpoint['required_auth'] = True
|
499
|
+
|
500
|
+
if status_code == 429:
|
501
|
+
endpoint['rate_limited'] = True
|
502
|
+
|
503
|
+
except Exception as e:
|
504
|
+
from django.db import DatabaseError, OperationalError
|
505
|
+
|
506
|
+
# Multi-database compatibility: treat DB errors as warnings, not errors
|
507
|
+
# Common in multi-database setups with db_constraint=False ForeignKeys
|
508
|
+
is_db_error = isinstance(e, (DatabaseError, OperationalError))
|
509
|
+
error_message = str(e)[:200]
|
510
|
+
|
511
|
+
# Check for cross-database JOIN errors (common with SQLite multi-db)
|
512
|
+
is_cross_db_error = any(keyword in str(e).lower() for keyword in [
|
513
|
+
'no such table',
|
514
|
+
'no such column',
|
515
|
+
'cannot join',
|
516
|
+
'cross-database',
|
517
|
+
'multi-database'
|
518
|
+
])
|
519
|
+
|
520
|
+
endpoint.update({
|
521
|
+
'status_code': None,
|
522
|
+
'response_time_ms': None,
|
523
|
+
'is_healthy': False if not (is_db_error or is_cross_db_error) else None,
|
524
|
+
'status': 'warning' if (is_db_error or is_cross_db_error) else 'error',
|
525
|
+
'error': error_message,
|
526
|
+
'error_type': 'database' if (is_db_error or is_cross_db_error) else 'general',
|
527
|
+
'last_checked': timezone.now().isoformat(),
|
528
|
+
})
|
529
|
+
|
530
|
+
# Return endpoint and token (if it was created)
|
531
|
+
return endpoint, (auth_token if token_created else None)
|
532
|
+
|
533
|
+
|
534
|
+
def check_all_endpoints(
|
535
|
+
include_unnamed: bool = False,
|
536
|
+
timeout: int = 5,
|
537
|
+
auto_auth: bool = True
|
538
|
+
) -> Dict[str, Any]:
|
539
|
+
"""
|
540
|
+
Check all registered endpoints.
|
541
|
+
|
542
|
+
Args:
|
543
|
+
include_unnamed: Include endpoints without names
|
544
|
+
timeout: Request timeout in seconds
|
545
|
+
auto_auth: Automatically retry with JWT auth on 401/403 (default: True)
|
546
|
+
|
547
|
+
Returns:
|
548
|
+
Dictionary with overall status and all endpoints
|
549
|
+
"""
|
550
|
+
# Collect endpoints
|
551
|
+
endpoints = collect_endpoints(include_unnamed=include_unnamed)
|
552
|
+
|
553
|
+
# Create client once
|
554
|
+
client = Client()
|
555
|
+
|
556
|
+
# Token will be created lazily on first 401/403
|
557
|
+
auth_token = None
|
558
|
+
|
559
|
+
# Check each endpoint
|
560
|
+
checked_endpoints = []
|
561
|
+
for endpoint in endpoints:
|
562
|
+
# Check endpoint (will auto-retry with JWT if needed)
|
563
|
+
checked, new_token = check_endpoint(
|
564
|
+
endpoint,
|
565
|
+
client=client,
|
566
|
+
timeout=timeout,
|
567
|
+
auth_token=auth_token,
|
568
|
+
auto_auth=auto_auth
|
569
|
+
)
|
570
|
+
|
571
|
+
# If token was created on first 401/403, save it for ALL subsequent endpoints
|
572
|
+
if new_token and auth_token is None:
|
573
|
+
auth_token = new_token
|
574
|
+
|
575
|
+
checked_endpoints.append(checked)
|
576
|
+
|
577
|
+
# Calculate statistics
|
578
|
+
total = len(checked_endpoints)
|
579
|
+
healthy = sum(1 for e in checked_endpoints if e.get('status') == 'healthy')
|
580
|
+
unhealthy = sum(1 for e in checked_endpoints if e.get('status') == 'unhealthy')
|
581
|
+
warnings = sum(1 for e in checked_endpoints if e.get('status') == 'warning')
|
582
|
+
errors = sum(1 for e in checked_endpoints if e.get('status') == 'error')
|
583
|
+
skipped = sum(1 for e in checked_endpoints if e.get('status') == 'skipped')
|
584
|
+
|
585
|
+
# Determine overall status
|
586
|
+
if errors > 0 or unhealthy > 0:
|
587
|
+
overall_status = 'unhealthy'
|
588
|
+
elif warnings > 0:
|
589
|
+
overall_status = 'degraded'
|
590
|
+
else:
|
591
|
+
overall_status = 'healthy'
|
592
|
+
|
593
|
+
return {
|
594
|
+
'status': overall_status,
|
595
|
+
'timestamp': timezone.now().isoformat(),
|
596
|
+
'total_endpoints': total,
|
597
|
+
'healthy': healthy,
|
598
|
+
'unhealthy': unhealthy,
|
599
|
+
'warnings': warnings,
|
600
|
+
'errors': errors,
|
601
|
+
'skipped': skipped,
|
602
|
+
'endpoints': checked_endpoints,
|
603
|
+
}
|
@@ -0,0 +1,55 @@
|
|
1
|
+
"""
|
2
|
+
Django CFG Endpoints Status DRF Views
|
3
|
+
|
4
|
+
DRF browsable API views with Tailwind theme support.
|
5
|
+
"""
|
6
|
+
|
7
|
+
from rest_framework.views import APIView
|
8
|
+
from rest_framework.response import Response
|
9
|
+
from rest_framework import status
|
10
|
+
from rest_framework.permissions import AllowAny
|
11
|
+
|
12
|
+
from .checker import check_all_endpoints
|
13
|
+
from .serializers import EndpointsStatusSerializer
|
14
|
+
|
15
|
+
|
16
|
+
class DRFEndpointsStatusView(APIView):
|
17
|
+
"""
|
18
|
+
Django CFG endpoints status check with DRF Browsable API.
|
19
|
+
|
20
|
+
Checks all registered URL endpoints and returns their health status.
|
21
|
+
Excludes health check endpoints and admin to avoid recursion.
|
22
|
+
|
23
|
+
Query Parameters:
|
24
|
+
- include_unnamed: Include endpoints without names (default: false)
|
25
|
+
- timeout: Request timeout in seconds (default: 5)
|
26
|
+
- auto_auth: Auto-retry with JWT on 401/403 (default: true)
|
27
|
+
|
28
|
+
This endpoint uses DRF Browsable API with Tailwind CSS theme! 🎨
|
29
|
+
"""
|
30
|
+
|
31
|
+
permission_classes = [AllowAny] # Public endpoint
|
32
|
+
serializer_class = EndpointsStatusSerializer # For schema generation
|
33
|
+
|
34
|
+
def get(self, request):
|
35
|
+
"""Return endpoints status data."""
|
36
|
+
# Get query parameters
|
37
|
+
include_unnamed = request.query_params.get('include_unnamed', 'false').lower() == 'true'
|
38
|
+
timeout = int(request.query_params.get('timeout', 5))
|
39
|
+
auto_auth = request.query_params.get('auto_auth', 'true').lower() == 'true'
|
40
|
+
|
41
|
+
# Check all endpoints
|
42
|
+
status_data = check_all_endpoints(
|
43
|
+
include_unnamed=include_unnamed,
|
44
|
+
timeout=timeout,
|
45
|
+
auto_auth=auto_auth
|
46
|
+
)
|
47
|
+
|
48
|
+
# Return appropriate HTTP status
|
49
|
+
http_status = status.HTTP_200_OK
|
50
|
+
if status_data["status"] == "unhealthy":
|
51
|
+
http_status = status.HTTP_503_SERVICE_UNAVAILABLE
|
52
|
+
elif status_data["status"] == "degraded":
|
53
|
+
http_status = status.HTTP_200_OK # Still operational
|
54
|
+
|
55
|
+
return Response(status_data, status=http_status)
|