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.
@@ -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)