django-cfg 1.2.22__py3-none-any.whl → 1.2.23__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 (67) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/payments/admin/__init__.py +23 -0
  3. django_cfg/apps/payments/admin/api_keys_admin.py +347 -0
  4. django_cfg/apps/payments/admin/balance_admin.py +434 -0
  5. django_cfg/apps/payments/admin/currencies_admin.py +186 -0
  6. django_cfg/apps/payments/admin/filters.py +259 -0
  7. django_cfg/apps/payments/admin/payments_admin.py +142 -0
  8. django_cfg/apps/payments/admin/subscriptions_admin.py +227 -0
  9. django_cfg/apps/payments/admin/tariffs_admin.py +199 -0
  10. django_cfg/apps/payments/config/__init__.py +87 -0
  11. django_cfg/apps/payments/config/module.py +162 -0
  12. django_cfg/apps/payments/config/providers.py +93 -0
  13. django_cfg/apps/payments/config/settings.py +136 -0
  14. django_cfg/apps/payments/config/utils.py +198 -0
  15. django_cfg/apps/payments/decorators.py +291 -0
  16. django_cfg/apps/payments/middleware/api_access.py +261 -0
  17. django_cfg/apps/payments/middleware/rate_limiting.py +216 -0
  18. django_cfg/apps/payments/middleware/usage_tracking.py +296 -0
  19. django_cfg/apps/payments/migrations/0001_initial.py +32 -11
  20. django_cfg/apps/payments/models/__init__.py +18 -0
  21. django_cfg/apps/payments/models/api_keys.py +2 -2
  22. django_cfg/apps/payments/models/balance.py +2 -2
  23. django_cfg/apps/payments/models/base.py +16 -0
  24. django_cfg/apps/payments/models/events.py +2 -2
  25. django_cfg/apps/payments/models/payments.py +2 -2
  26. django_cfg/apps/payments/models/subscriptions.py +2 -2
  27. django_cfg/apps/payments/services/__init__.py +58 -7
  28. django_cfg/apps/payments/services/billing/__init__.py +8 -0
  29. django_cfg/apps/payments/services/cache/__init__.py +15 -0
  30. django_cfg/apps/payments/services/cache/base.py +30 -0
  31. django_cfg/apps/payments/services/cache/simple_cache.py +135 -0
  32. django_cfg/apps/payments/services/core/__init__.py +17 -0
  33. django_cfg/apps/payments/services/core/balance_service.py +449 -0
  34. django_cfg/apps/payments/services/core/payment_service.py +393 -0
  35. django_cfg/apps/payments/services/core/subscription_service.py +616 -0
  36. django_cfg/apps/payments/services/internal_types.py +266 -0
  37. django_cfg/apps/payments/services/middleware/__init__.py +8 -0
  38. django_cfg/apps/payments/services/providers/__init__.py +19 -0
  39. django_cfg/apps/payments/services/providers/base.py +137 -0
  40. django_cfg/apps/payments/services/providers/cryptapi.py +262 -0
  41. django_cfg/apps/payments/services/providers/nowpayments.py +293 -0
  42. django_cfg/apps/payments/services/providers/registry.py +99 -0
  43. django_cfg/apps/payments/services/validators/__init__.py +8 -0
  44. django_cfg/apps/payments/signals/__init__.py +13 -0
  45. django_cfg/apps/payments/signals/api_key_signals.py +150 -0
  46. django_cfg/apps/payments/signals/payment_signals.py +127 -0
  47. django_cfg/apps/payments/signals/subscription_signals.py +196 -0
  48. django_cfg/apps/payments/urls.py +5 -5
  49. django_cfg/apps/payments/utils/__init__.py +42 -0
  50. django_cfg/apps/payments/utils/config_utils.py +243 -0
  51. django_cfg/apps/payments/utils/middleware_utils.py +228 -0
  52. django_cfg/apps/payments/utils/validation_utils.py +94 -0
  53. django_cfg/apps/support/signals.py +16 -4
  54. django_cfg/apps/support/templates/support/chat/ticket_chat.html +1 -1
  55. django_cfg/models/revolution.py +1 -1
  56. django_cfg/modules/base.py +1 -1
  57. django_cfg/modules/django_email.py +42 -4
  58. django_cfg/modules/django_unfold/dashboard.py +20 -0
  59. {django_cfg-1.2.22.dist-info → django_cfg-1.2.23.dist-info}/METADATA +2 -1
  60. {django_cfg-1.2.22.dist-info → django_cfg-1.2.23.dist-info}/RECORD +63 -26
  61. django_cfg/apps/payments/services/base.py +0 -68
  62. django_cfg/apps/payments/services/nowpayments.py +0 -78
  63. django_cfg/apps/payments/services/providers.py +0 -77
  64. django_cfg/apps/payments/services/redis_service.py +0 -215
  65. {django_cfg-1.2.22.dist-info → django_cfg-1.2.23.dist-info}/WHEEL +0 -0
  66. {django_cfg-1.2.22.dist-info → django_cfg-1.2.23.dist-info}/entry_points.txt +0 -0
  67. {django_cfg-1.2.22.dist-info → django_cfg-1.2.23.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,261 @@
1
+ """
2
+ API Access Control Middleware.
3
+ Handles API key authentication and subscription validation.
4
+ """
5
+
6
+ import logging
7
+ from typing import Optional, Tuple
8
+ from django.http import JsonResponse, HttpRequest, HttpResponse
9
+ from django.utils.deprecation import MiddlewareMixin
10
+ from django.conf import settings
11
+ from django.utils import timezone
12
+ from ..models import APIKey, Subscription, EndpointGroup
13
+ from ..services import ApiKeyCache, RateLimitCache
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class APIAccessMiddleware(MiddlewareMixin):
19
+ """
20
+ Middleware for API access control using API keys and subscriptions.
21
+
22
+ Features:
23
+ - API key validation
24
+ - Subscription status checking
25
+ - Endpoint access control
26
+ - Usage tracking
27
+ """
28
+
29
+ def __init__(self, get_response=None):
30
+ super().__init__(get_response)
31
+ self.api_key_cache = ApiKeyCache()
32
+ self.rate_limit_cache = RateLimitCache()
33
+
34
+ # Paths that don't require API key authentication
35
+ self.exempt_paths = getattr(settings, 'PAYMENTS_EXEMPT_PATHS', [
36
+ '/api/v1/api-key/validate/',
37
+ '/api/v1/api-key/create/',
38
+ '/admin/',
39
+ '/cfg/',
40
+ ])
41
+
42
+ # API prefixes that require authentication
43
+ self.api_prefixes = getattr(settings, 'PAYMENTS_API_PREFIXES', [
44
+ '/api/v1/',
45
+ ])
46
+
47
+ def process_request(self, request: HttpRequest) -> Optional[JsonResponse]:
48
+ """Process incoming request for API access control."""
49
+
50
+ # Skip non-API requests
51
+ if not self._is_api_request(request):
52
+ return None
53
+
54
+ # Skip exempt paths
55
+ if self._is_exempt_path(request):
56
+ return None
57
+
58
+ # Extract API key
59
+ api_key = self._extract_api_key(request)
60
+ if not api_key:
61
+ return self._error_response(
62
+ 'API key required',
63
+ status=401,
64
+ error_code='MISSING_API_KEY'
65
+ )
66
+
67
+ # Validate API key
68
+ api_key_obj = self._validate_api_key(api_key)
69
+ if not api_key_obj:
70
+ return self._error_response(
71
+ 'Invalid or expired API key',
72
+ status=401,
73
+ error_code='INVALID_API_KEY'
74
+ )
75
+
76
+ # Check subscription access
77
+ endpoint_group = self._get_endpoint_group(request)
78
+ if endpoint_group:
79
+ subscription = self._check_subscription_access(api_key_obj.user, endpoint_group)
80
+ if not subscription:
81
+ return self._error_response(
82
+ f'No active subscription for {endpoint_group.display_name}',
83
+ status=403,
84
+ error_code='NO_SUBSCRIPTION'
85
+ )
86
+
87
+ # Check usage limits
88
+ if self._is_usage_exceeded(subscription):
89
+ return self._error_response(
90
+ 'Usage limit exceeded for this subscription',
91
+ status=429,
92
+ error_code='USAGE_EXCEEDED'
93
+ )
94
+
95
+ # Store subscription in request for usage tracking
96
+ request.payment_subscription = subscription
97
+
98
+ # Store API key in request
99
+ request.payment_api_key = api_key_obj
100
+ request.payment_user = api_key_obj.user
101
+
102
+ return None
103
+
104
+ def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse:
105
+ """Process response to track API usage."""
106
+
107
+ # Track usage if API key was used
108
+ if hasattr(request, 'payment_api_key') and hasattr(request, 'payment_subscription'):
109
+ self._track_usage(request.payment_api_key, request.payment_subscription, request)
110
+
111
+ return response
112
+
113
+ def _is_api_request(self, request: HttpRequest) -> bool:
114
+ """Check if request is an API request."""
115
+ path = request.path
116
+ return any(path.startswith(prefix) for prefix in self.api_prefixes)
117
+
118
+ def _is_exempt_path(self, request: HttpRequest) -> bool:
119
+ """Check if path is exempt from API key requirement."""
120
+ path = request.path
121
+ return any(path.startswith(exempt) for exempt in self.exempt_paths)
122
+
123
+ def _extract_api_key(self, request: HttpRequest) -> Optional[str]:
124
+ """Extract API key from request headers or query params."""
125
+
126
+ # Try Authorization header first (Bearer token)
127
+ auth_header = request.META.get('HTTP_AUTHORIZATION', '')
128
+ if auth_header.startswith('Bearer '):
129
+ return auth_header[7:] # Remove 'Bearer ' prefix
130
+
131
+ # Try X-API-Key header
132
+ api_key = request.META.get('HTTP_X_API_KEY')
133
+ if api_key:
134
+ return api_key
135
+
136
+ # Try query parameter (less secure, for testing)
137
+ api_key = request.GET.get('api_key')
138
+ if api_key:
139
+ return api_key
140
+
141
+ return None
142
+
143
+ def _validate_api_key(self, api_key: str) -> Optional[APIKey]:
144
+ """Validate API key using Redis cache with DB fallback."""
145
+
146
+ try:
147
+ # Try Redis first
148
+ cached_key = self.redis_service.get_api_key(api_key)
149
+ if cached_key:
150
+ return cached_key
151
+
152
+ # Fallback to database
153
+ api_key_obj = APIKey.objects.select_related('user').filter(
154
+ key_value=api_key,
155
+ is_active=True
156
+ ).first()
157
+
158
+ if api_key_obj and api_key_obj.is_valid():
159
+ # Cache valid key
160
+ self.redis_service.cache_api_key(api_key_obj)
161
+
162
+ # Update last used
163
+ api_key_obj.record_usage()
164
+
165
+ return api_key_obj
166
+
167
+ return None
168
+
169
+ except Exception as e:
170
+ logger.error(f"Error validating API key: {e}")
171
+ return None
172
+
173
+ def _get_endpoint_group(self, request: HttpRequest) -> Optional[EndpointGroup]:
174
+ """Determine endpoint group based on request path."""
175
+
176
+ # This would be customized per project
177
+ # For now, return None (no endpoint group restrictions)
178
+ # In real implementation, this would map URL patterns to endpoint groups
179
+
180
+ path = request.path
181
+
182
+ # Example mapping (would be configurable)
183
+ endpoint_mappings = {
184
+ '/api/v1/payments/': 'payments',
185
+ '/api/v1/subscriptions/': 'billing',
186
+ '/api/v1/users/': 'user_management',
187
+ }
188
+
189
+ for path_prefix, group_name in endpoint_mappings.items():
190
+ if path.startswith(path_prefix):
191
+ try:
192
+ return EndpointGroup.objects.get(name=group_name, is_active=True)
193
+ except EndpointGroup.DoesNotExist:
194
+ continue
195
+
196
+ return None
197
+
198
+ def _check_subscription_access(self, user, endpoint_group: EndpointGroup) -> Optional[Subscription]:
199
+ """Check if user has active subscription for endpoint group."""
200
+
201
+ try:
202
+ # Get active subscription for this endpoint group
203
+ subscription = Subscription.objects.filter(
204
+ user=user,
205
+ endpoint_group=endpoint_group,
206
+ status='active',
207
+ expires_at__gt=timezone.now()
208
+ ).first()
209
+
210
+ return subscription
211
+
212
+ except Exception as e:
213
+ logger.error(f"Error checking subscription access: {e}")
214
+ return None
215
+
216
+ def _is_usage_exceeded(self, subscription: Subscription) -> bool:
217
+ """Check if subscription usage limit is exceeded."""
218
+
219
+ try:
220
+ # Check current usage against limit
221
+ if subscription.usage_limit == 0: # Unlimited
222
+ return False
223
+
224
+ return subscription.usage_current >= subscription.usage_limit
225
+
226
+ except Exception as e:
227
+ logger.error(f"Error checking usage limits: {e}")
228
+ return False
229
+
230
+ def _track_usage(self, api_key: APIKey, subscription: Subscription, request: HttpRequest):
231
+ """Track API usage for billing and analytics."""
232
+
233
+ try:
234
+ # Increment subscription usage
235
+ subscription.usage_current += 1
236
+ subscription.save(update_fields=['usage_current'])
237
+
238
+ # Update Redis cache
239
+ self.redis_service.increment_usage(api_key.key_value, subscription.id)
240
+
241
+ # Log usage for analytics
242
+ logger.info(
243
+ f"API usage tracked - User: {api_key.user.id}, "
244
+ f"Subscription: {subscription.id}, "
245
+ f"Path: {request.path}, "
246
+ f"Usage: {subscription.usage_current}/{subscription.usage_limit}"
247
+ )
248
+
249
+ except Exception as e:
250
+ logger.error(f"Error tracking usage: {e}")
251
+
252
+ def _error_response(self, message: str, status: int = 400, error_code: str = 'ERROR') -> JsonResponse:
253
+ """Return standardized error response."""
254
+
255
+ return JsonResponse({
256
+ 'error': {
257
+ 'code': error_code,
258
+ 'message': message,
259
+ 'timestamp': timezone.now().isoformat(),
260
+ }
261
+ }, status=status)
@@ -0,0 +1,216 @@
1
+ """
2
+ Rate Limiting Middleware.
3
+ Implements sliding window rate limiting using Redis.
4
+ """
5
+
6
+ import logging
7
+ import time
8
+ from typing import Optional
9
+ from django.http import JsonResponse, HttpRequest
10
+ from django.utils.deprecation import MiddlewareMixin
11
+ from django.conf import settings
12
+ from django.utils import timezone
13
+ from ..services import RateLimitCache
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class RateLimitingMiddleware(MiddlewareMixin):
19
+ """
20
+ Rate limiting middleware using sliding window algorithm.
21
+
22
+ Features:
23
+ - Per-API-key rate limiting
24
+ - Per-IP rate limiting (fallback)
25
+ - Sliding window algorithm
26
+ - Redis-based with circuit breaker
27
+ - Configurable limits
28
+ """
29
+
30
+ def __init__(self, get_response=None):
31
+ super().__init__(get_response)
32
+ self.rate_limit_cache = RateLimitCache()
33
+
34
+ # Default rate limits (can be overridden in settings)
35
+ self.default_limits = getattr(settings, 'PAYMENTS_RATE_LIMITS', {
36
+ 'per_minute': 60, # 60 requests per minute
37
+ 'per_hour': 1000, # 1000 requests per hour
38
+ 'per_day': 10000, # 10000 requests per day
39
+ })
40
+
41
+ # Paths exempt from rate limiting
42
+ self.exempt_paths = getattr(settings, 'PAYMENTS_RATE_LIMIT_EXEMPT_PATHS', [
43
+ '/admin/',
44
+ '/cfg/',
45
+ ])
46
+
47
+ # Enable/disable rate limiting
48
+ self.enabled = getattr(settings, 'PAYMENTS_RATE_LIMITING_ENABLED', True)
49
+
50
+ def process_request(self, request: HttpRequest) -> Optional[JsonResponse]:
51
+ """Process request for rate limiting."""
52
+
53
+ if not self.enabled:
54
+ return None
55
+
56
+ # Skip exempt paths
57
+ if self._is_exempt_path(request):
58
+ return None
59
+
60
+ # Get rate limiting key (API key or IP)
61
+ rate_key = self._get_rate_key(request)
62
+ if not rate_key:
63
+ return None
64
+
65
+ # Get rate limits for this key
66
+ limits = self._get_rate_limits(request)
67
+
68
+ # Check each time window
69
+ for window, limit in limits.items():
70
+ if self._is_rate_limited(rate_key, window, limit):
71
+ return self._rate_limit_response(window, limit)
72
+
73
+ # Record this request
74
+ self._record_request(rate_key)
75
+
76
+ return None
77
+
78
+ def _is_exempt_path(self, request: HttpRequest) -> bool:
79
+ """Check if path is exempt from rate limiting."""
80
+ path = request.path
81
+ return any(path.startswith(exempt) for exempt in self.exempt_paths)
82
+
83
+ def _get_rate_key(self, request: HttpRequest) -> Optional[str]:
84
+ """Get rate limiting key (API key preferred, IP as fallback)."""
85
+
86
+ # Use API key if available (from previous middleware)
87
+ if hasattr(request, 'payment_api_key'):
88
+ return f"api_key:{request.payment_api_key.key_value}"
89
+
90
+ # Fallback to IP address
91
+ ip = self._get_client_ip(request)
92
+ if ip:
93
+ return f"ip:{ip}"
94
+
95
+ return None
96
+
97
+ def _get_client_ip(self, request: HttpRequest) -> Optional[str]:
98
+ """Get client IP address."""
99
+
100
+ # Check for forwarded headers first
101
+ forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
102
+ if forwarded_for:
103
+ return forwarded_for.split(',')[0].strip()
104
+
105
+ # Check for real IP header
106
+ real_ip = request.META.get('HTTP_X_REAL_IP')
107
+ if real_ip:
108
+ return real_ip
109
+
110
+ # Fallback to remote address
111
+ return request.META.get('REMOTE_ADDR')
112
+
113
+ def _get_rate_limits(self, request: HttpRequest) -> dict:
114
+ """Get rate limits for this request."""
115
+
116
+ # Check if API key has custom limits
117
+ if hasattr(request, 'payment_api_key'):
118
+ api_key = request.payment_api_key
119
+
120
+ # Check if user has subscription with custom limits
121
+ if hasattr(request, 'payment_subscription'):
122
+ subscription = request.payment_subscription
123
+ # Custom limits based on subscription tier could be implemented here
124
+ # For now, use default limits
125
+ pass
126
+
127
+ return self.default_limits
128
+
129
+ def _is_rate_limited(self, rate_key: str, window: str, limit: int) -> bool:
130
+ """Check if rate limit is exceeded for given window."""
131
+
132
+ try:
133
+ # Get window duration in seconds
134
+ window_seconds = self._get_window_seconds(window)
135
+ if not window_seconds:
136
+ return False
137
+
138
+ # Use Redis sliding window
139
+ current_time = int(time.time())
140
+ window_start = current_time - window_seconds
141
+
142
+ # Get request count in window
143
+ redis_key = f"rate_limit:{rate_key}:{window}"
144
+
145
+ # Use Redis sorted set for sliding window
146
+ # Remove old entries and count current entries
147
+ count = self.redis_service.sliding_window_count(
148
+ redis_key,
149
+ window_start,
150
+ current_time,
151
+ window_seconds
152
+ )
153
+
154
+ return count >= limit
155
+
156
+ except Exception as e:
157
+ logger.error(f"Error checking rate limit: {e}")
158
+ # On error, allow request (fail open)
159
+ return False
160
+
161
+ def _get_window_seconds(self, window: str) -> Optional[int]:
162
+ """Convert window name to seconds."""
163
+
164
+ window_map = {
165
+ 'per_minute': 60,
166
+ 'per_hour': 3600,
167
+ 'per_day': 86400,
168
+ }
169
+
170
+ return window_map.get(window)
171
+
172
+ def _record_request(self, rate_key: str):
173
+ """Record request for rate limiting."""
174
+
175
+ try:
176
+ current_time = int(time.time())
177
+
178
+ # Record for each window
179
+ for window in self.default_limits.keys():
180
+ window_seconds = self._get_window_seconds(window)
181
+ if window_seconds:
182
+ redis_key = f"rate_limit:{rate_key}:{window}"
183
+
184
+ # Add current timestamp to sorted set
185
+ self.redis_service.record_request(
186
+ redis_key,
187
+ current_time,
188
+ window_seconds
189
+ )
190
+
191
+ except Exception as e:
192
+ logger.error(f"Error recording request: {e}")
193
+
194
+ def _rate_limit_response(self, window: str, limit: int) -> JsonResponse:
195
+ """Return rate limit exceeded response."""
196
+
197
+ window_seconds = self._get_window_seconds(window)
198
+ retry_after = window_seconds if window_seconds else 60
199
+
200
+ response = JsonResponse({
201
+ 'error': {
202
+ 'code': 'RATE_LIMIT_EXCEEDED',
203
+ 'message': f'Rate limit exceeded: {limit} requests {window}',
204
+ 'limit': limit,
205
+ 'window': window,
206
+ 'retry_after': retry_after,
207
+ 'timestamp': timezone.now().isoformat(),
208
+ }
209
+ }, status=429)
210
+
211
+ # Add rate limit headers
212
+ response['X-RateLimit-Limit'] = str(limit)
213
+ response['X-RateLimit-Window'] = window
214
+ response['Retry-After'] = str(retry_after)
215
+
216
+ return response