django-cfg 1.2.22__py3-none-any.whl → 1.2.25__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/__init__.py +1 -1
- django_cfg/apps/knowbase/tasks/archive_tasks.py +6 -6
- django_cfg/apps/knowbase/tasks/document_processing.py +3 -3
- django_cfg/apps/knowbase/tasks/external_data_tasks.py +2 -2
- django_cfg/apps/knowbase/tasks/maintenance.py +3 -3
- django_cfg/apps/payments/admin/__init__.py +23 -0
- django_cfg/apps/payments/admin/api_keys_admin.py +347 -0
- django_cfg/apps/payments/admin/balance_admin.py +434 -0
- django_cfg/apps/payments/admin/currencies_admin.py +186 -0
- django_cfg/apps/payments/admin/filters.py +259 -0
- django_cfg/apps/payments/admin/payments_admin.py +142 -0
- django_cfg/apps/payments/admin/subscriptions_admin.py +227 -0
- django_cfg/apps/payments/admin/tariffs_admin.py +199 -0
- django_cfg/apps/payments/config/__init__.py +65 -0
- django_cfg/apps/payments/config/module.py +70 -0
- django_cfg/apps/payments/config/providers.py +115 -0
- django_cfg/apps/payments/config/settings.py +96 -0
- django_cfg/apps/payments/config/utils.py +52 -0
- django_cfg/apps/payments/decorators.py +291 -0
- django_cfg/apps/payments/management/__init__.py +3 -0
- django_cfg/apps/payments/management/commands/README.md +178 -0
- django_cfg/apps/payments/management/commands/__init__.py +3 -0
- django_cfg/apps/payments/management/commands/currency_stats.py +323 -0
- django_cfg/apps/payments/management/commands/populate_currencies.py +246 -0
- django_cfg/apps/payments/management/commands/update_currencies.py +336 -0
- django_cfg/apps/payments/managers/currency_manager.py +65 -14
- django_cfg/apps/payments/middleware/api_access.py +294 -0
- django_cfg/apps/payments/middleware/rate_limiting.py +216 -0
- django_cfg/apps/payments/middleware/usage_tracking.py +296 -0
- django_cfg/apps/payments/migrations/0001_initial.py +125 -11
- django_cfg/apps/payments/models/__init__.py +18 -0
- django_cfg/apps/payments/models/api_keys.py +2 -2
- django_cfg/apps/payments/models/balance.py +2 -2
- django_cfg/apps/payments/models/base.py +16 -0
- django_cfg/apps/payments/models/events.py +2 -2
- django_cfg/apps/payments/models/payments.py +112 -2
- django_cfg/apps/payments/models/subscriptions.py +2 -2
- django_cfg/apps/payments/services/__init__.py +64 -7
- django_cfg/apps/payments/services/billing/__init__.py +8 -0
- django_cfg/apps/payments/services/cache/__init__.py +15 -0
- django_cfg/apps/payments/services/cache/base.py +30 -0
- django_cfg/apps/payments/services/cache/simple_cache.py +135 -0
- django_cfg/apps/payments/services/core/__init__.py +17 -0
- django_cfg/apps/payments/services/core/balance_service.py +447 -0
- django_cfg/apps/payments/services/core/fallback_service.py +432 -0
- django_cfg/apps/payments/services/core/payment_service.py +576 -0
- django_cfg/apps/payments/services/core/subscription_service.py +614 -0
- django_cfg/apps/payments/services/internal_types.py +297 -0
- django_cfg/apps/payments/services/middleware/__init__.py +8 -0
- django_cfg/apps/payments/services/monitoring/__init__.py +22 -0
- django_cfg/apps/payments/services/monitoring/api_schemas.py +222 -0
- django_cfg/apps/payments/services/monitoring/provider_health.py +372 -0
- django_cfg/apps/payments/services/providers/__init__.py +22 -0
- django_cfg/apps/payments/services/providers/base.py +137 -0
- django_cfg/apps/payments/services/providers/cryptapi.py +273 -0
- django_cfg/apps/payments/services/providers/cryptomus.py +310 -0
- django_cfg/apps/payments/services/providers/nowpayments.py +293 -0
- django_cfg/apps/payments/services/providers/registry.py +103 -0
- django_cfg/apps/payments/services/security/__init__.py +34 -0
- django_cfg/apps/payments/services/security/error_handler.py +637 -0
- django_cfg/apps/payments/services/security/payment_notifications.py +342 -0
- django_cfg/apps/payments/services/security/webhook_validator.py +475 -0
- django_cfg/apps/payments/services/validators/__init__.py +8 -0
- django_cfg/apps/payments/signals/__init__.py +13 -0
- django_cfg/apps/payments/signals/api_key_signals.py +160 -0
- django_cfg/apps/payments/signals/payment_signals.py +128 -0
- django_cfg/apps/payments/signals/subscription_signals.py +196 -0
- django_cfg/apps/payments/tasks/__init__.py +12 -0
- django_cfg/apps/payments/tasks/webhook_processing.py +177 -0
- django_cfg/apps/payments/urls.py +5 -5
- django_cfg/apps/payments/utils/__init__.py +45 -0
- django_cfg/apps/payments/utils/billing_utils.py +342 -0
- django_cfg/apps/payments/utils/config_utils.py +245 -0
- django_cfg/apps/payments/utils/middleware_utils.py +228 -0
- django_cfg/apps/payments/utils/validation_utils.py +94 -0
- django_cfg/apps/payments/views/payment_views.py +40 -2
- django_cfg/apps/payments/views/webhook_views.py +266 -0
- django_cfg/apps/payments/viewsets.py +65 -0
- django_cfg/apps/support/signals.py +16 -4
- django_cfg/apps/support/templates/support/chat/ticket_chat.html +1 -1
- django_cfg/cli/README.md +2 -2
- django_cfg/cli/commands/create_project.py +1 -1
- django_cfg/cli/commands/info.py +1 -1
- django_cfg/cli/main.py +1 -1
- django_cfg/cli/utils.py +5 -5
- django_cfg/core/config.py +18 -4
- django_cfg/models/payments.py +546 -0
- django_cfg/models/revolution.py +1 -1
- django_cfg/models/tasks.py +51 -2
- django_cfg/modules/base.py +12 -6
- django_cfg/modules/django_currency/README.md +104 -269
- django_cfg/modules/django_currency/__init__.py +99 -41
- django_cfg/modules/django_currency/clients/__init__.py +11 -0
- django_cfg/modules/django_currency/clients/coingecko_client.py +257 -0
- django_cfg/modules/django_currency/clients/yfinance_client.py +246 -0
- django_cfg/modules/django_currency/core/__init__.py +42 -0
- django_cfg/modules/django_currency/core/converter.py +169 -0
- django_cfg/modules/django_currency/core/exceptions.py +28 -0
- django_cfg/modules/django_currency/core/models.py +54 -0
- django_cfg/modules/django_currency/database/__init__.py +25 -0
- django_cfg/modules/django_currency/database/database_loader.py +507 -0
- django_cfg/modules/django_currency/utils/__init__.py +9 -0
- django_cfg/modules/django_currency/utils/cache.py +92 -0
- django_cfg/modules/django_email.py +42 -4
- django_cfg/modules/django_unfold/dashboard.py +20 -0
- django_cfg/registry/core.py +10 -0
- django_cfg/template_archive/__init__.py +0 -0
- django_cfg/template_archive/django_sample.zip +0 -0
- {django_cfg-1.2.22.dist-info → django_cfg-1.2.25.dist-info}/METADATA +11 -6
- {django_cfg-1.2.22.dist-info → django_cfg-1.2.25.dist-info}/RECORD +113 -50
- django_cfg/apps/agents/examples/__init__.py +0 -3
- django_cfg/apps/agents/examples/simple_example.py +0 -161
- django_cfg/apps/knowbase/examples/__init__.py +0 -3
- django_cfg/apps/knowbase/examples/external_data_usage.py +0 -191
- django_cfg/apps/knowbase/mixins/examples/vehicle_model_example.py +0 -199
- django_cfg/apps/payments/services/base.py +0 -68
- django_cfg/apps/payments/services/nowpayments.py +0 -78
- django_cfg/apps/payments/services/providers.py +0 -77
- django_cfg/apps/payments/services/redis_service.py +0 -215
- django_cfg/modules/django_currency/cache.py +0 -430
- django_cfg/modules/django_currency/converter.py +0 -324
- django_cfg/modules/django_currency/service.py +0 -277
- {django_cfg-1.2.22.dist-info → django_cfg-1.2.25.dist-info}/WHEEL +0 -0
- {django_cfg-1.2.22.dist-info → django_cfg-1.2.25.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.2.22.dist-info → django_cfg-1.2.25.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,228 @@
|
|
1
|
+
"""
|
2
|
+
Utilities for middleware processing.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from typing import Optional, List
|
6
|
+
from django.http import HttpRequest
|
7
|
+
from django.conf import settings
|
8
|
+
|
9
|
+
|
10
|
+
def get_client_ip(request: HttpRequest) -> Optional[str]:
|
11
|
+
"""
|
12
|
+
Get client IP address from request.
|
13
|
+
Handles various proxy headers and configurations.
|
14
|
+
"""
|
15
|
+
|
16
|
+
# Check for forwarded headers first
|
17
|
+
forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
18
|
+
if forwarded_for:
|
19
|
+
# Take first IP in chain (original client)
|
20
|
+
return forwarded_for.split(',')[0].strip()
|
21
|
+
|
22
|
+
# Check for real IP header (common with nginx)
|
23
|
+
real_ip = request.META.get('HTTP_X_REAL_IP')
|
24
|
+
if real_ip:
|
25
|
+
return real_ip.strip()
|
26
|
+
|
27
|
+
# Check for Cloudflare header
|
28
|
+
cf_ip = request.META.get('HTTP_CF_CONNECTING_IP')
|
29
|
+
if cf_ip:
|
30
|
+
return cf_ip.strip()
|
31
|
+
|
32
|
+
# Fallback to remote address
|
33
|
+
remote_addr = request.META.get('REMOTE_ADDR')
|
34
|
+
if remote_addr:
|
35
|
+
return remote_addr.strip()
|
36
|
+
|
37
|
+
return None
|
38
|
+
|
39
|
+
|
40
|
+
def is_api_request(request: HttpRequest, api_prefixes: Optional[List[str]] = None) -> bool:
|
41
|
+
"""
|
42
|
+
Check if request is an API request based on path prefixes.
|
43
|
+
|
44
|
+
Args:
|
45
|
+
request: Django HTTP request
|
46
|
+
api_prefixes: List of API prefixes to check (defaults to settings)
|
47
|
+
"""
|
48
|
+
|
49
|
+
if api_prefixes is None:
|
50
|
+
api_prefixes = getattr(settings, 'PAYMENTS_API_PREFIXES', ['/api/'])
|
51
|
+
|
52
|
+
path = request.path
|
53
|
+
return any(path.startswith(prefix) for prefix in api_prefixes)
|
54
|
+
|
55
|
+
|
56
|
+
def extract_api_key(request: HttpRequest) -> Optional[str]:
|
57
|
+
"""
|
58
|
+
Extract API key from request headers or query parameters.
|
59
|
+
Supports multiple authentication methods.
|
60
|
+
|
61
|
+
Priority:
|
62
|
+
1. Authorization header (Bearer token)
|
63
|
+
2. X-API-Key header
|
64
|
+
3. Query parameter (less secure, for testing)
|
65
|
+
"""
|
66
|
+
|
67
|
+
# Method 1: Authorization header (Bearer token)
|
68
|
+
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
|
69
|
+
if auth_header.startswith('Bearer '):
|
70
|
+
return auth_header[7:] # Remove 'Bearer ' prefix
|
71
|
+
|
72
|
+
# Method 2: X-API-Key header
|
73
|
+
api_key_header = request.META.get('HTTP_X_API_KEY')
|
74
|
+
if api_key_header:
|
75
|
+
return api_key_header.strip()
|
76
|
+
|
77
|
+
# Method 3: Custom header variations
|
78
|
+
custom_headers = [
|
79
|
+
'HTTP_X_API_TOKEN',
|
80
|
+
'HTTP_APIKEY',
|
81
|
+
'HTTP_API_TOKEN',
|
82
|
+
]
|
83
|
+
|
84
|
+
for header in custom_headers:
|
85
|
+
value = request.META.get(header)
|
86
|
+
if value:
|
87
|
+
return value.strip()
|
88
|
+
|
89
|
+
# Method 4: Query parameter (less secure, mainly for testing)
|
90
|
+
if getattr(settings, 'PAYMENTS_ALLOW_API_KEY_IN_QUERY', False):
|
91
|
+
query_key = request.GET.get('api_key') or request.GET.get('apikey')
|
92
|
+
if query_key:
|
93
|
+
return query_key.strip()
|
94
|
+
|
95
|
+
return None
|
96
|
+
|
97
|
+
|
98
|
+
def is_exempt_path(request: HttpRequest, exempt_paths: Optional[List[str]] = None) -> bool:
|
99
|
+
"""
|
100
|
+
Check if request path is exempt from API key requirements.
|
101
|
+
|
102
|
+
Args:
|
103
|
+
request: Django HTTP request
|
104
|
+
exempt_paths: List of exempt path prefixes (defaults to settings)
|
105
|
+
"""
|
106
|
+
|
107
|
+
if exempt_paths is None:
|
108
|
+
exempt_paths = getattr(settings, 'PAYMENTS_EXEMPT_PATHS', [
|
109
|
+
'/admin/',
|
110
|
+
'/cfg/',
|
111
|
+
'/api/v1/api-key/validate/',
|
112
|
+
])
|
113
|
+
|
114
|
+
path = request.path
|
115
|
+
return any(path.startswith(exempt) for exempt in exempt_paths)
|
116
|
+
|
117
|
+
|
118
|
+
def get_request_metadata(request: HttpRequest) -> dict:
|
119
|
+
"""
|
120
|
+
Extract useful metadata from request for logging and analytics.
|
121
|
+
"""
|
122
|
+
|
123
|
+
return {
|
124
|
+
'method': request.method,
|
125
|
+
'path': request.path,
|
126
|
+
'query_string': request.META.get('QUERY_STRING', ''),
|
127
|
+
'user_agent': request.META.get('HTTP_USER_AGENT', ''),
|
128
|
+
'referer': request.META.get('HTTP_REFERER', ''),
|
129
|
+
'ip_address': get_client_ip(request),
|
130
|
+
'content_type': request.META.get('CONTENT_TYPE', ''),
|
131
|
+
'content_length': request.META.get('CONTENT_LENGTH', 0),
|
132
|
+
'host': request.META.get('HTTP_HOST', ''),
|
133
|
+
'scheme': request.scheme,
|
134
|
+
'is_secure': request.is_secure(),
|
135
|
+
}
|
136
|
+
|
137
|
+
|
138
|
+
def should_track_request_body(request: HttpRequest, max_size: int = 10000) -> bool:
|
139
|
+
"""
|
140
|
+
Determine if request body should be tracked for analytics.
|
141
|
+
|
142
|
+
Args:
|
143
|
+
request: Django HTTP request
|
144
|
+
max_size: Maximum body size to track (bytes)
|
145
|
+
"""
|
146
|
+
|
147
|
+
# Check content length
|
148
|
+
content_length = request.META.get('CONTENT_LENGTH')
|
149
|
+
if content_length and int(content_length) > max_size:
|
150
|
+
return False
|
151
|
+
|
152
|
+
# Don't track file uploads
|
153
|
+
content_type = request.META.get('CONTENT_TYPE', '')
|
154
|
+
if 'multipart/form-data' in content_type:
|
155
|
+
return False
|
156
|
+
|
157
|
+
# Don't track binary content
|
158
|
+
if 'application/octet-stream' in content_type:
|
159
|
+
return False
|
160
|
+
|
161
|
+
# Don't track sensitive endpoints
|
162
|
+
sensitive_paths = getattr(settings, 'PAYMENTS_SENSITIVE_PATHS', [
|
163
|
+
'/api/v1/api-key/',
|
164
|
+
'/api/v1/payment/',
|
165
|
+
'/api/v1/subscription/',
|
166
|
+
])
|
167
|
+
|
168
|
+
path = request.path
|
169
|
+
if any(path.startswith(sensitive) for sensitive in sensitive_paths):
|
170
|
+
return False
|
171
|
+
|
172
|
+
return True
|
173
|
+
|
174
|
+
|
175
|
+
def should_track_response_body(response, max_size: int = 10000) -> bool:
|
176
|
+
"""
|
177
|
+
Determine if response body should be tracked for analytics.
|
178
|
+
|
179
|
+
Args:
|
180
|
+
response: Django HTTP response
|
181
|
+
max_size: Maximum body size to track (bytes)
|
182
|
+
"""
|
183
|
+
|
184
|
+
# Don't track large responses
|
185
|
+
if hasattr(response, 'content') and len(response.content) > max_size:
|
186
|
+
return False
|
187
|
+
|
188
|
+
# Only track successful JSON responses
|
189
|
+
if not (200 <= response.status_code < 300):
|
190
|
+
return False
|
191
|
+
|
192
|
+
# Check content type
|
193
|
+
content_type = response.get('Content-Type', '')
|
194
|
+
if 'application/json' not in content_type:
|
195
|
+
return False
|
196
|
+
|
197
|
+
return True
|
198
|
+
|
199
|
+
|
200
|
+
def format_error_response(error_code: str,
|
201
|
+
message: str,
|
202
|
+
status_code: int = 400,
|
203
|
+
additional_data: Optional[dict] = None) -> dict:
|
204
|
+
"""
|
205
|
+
Format standardized error response for middleware.
|
206
|
+
|
207
|
+
Args:
|
208
|
+
error_code: Machine-readable error code
|
209
|
+
message: Human-readable error message
|
210
|
+
status_code: HTTP status code
|
211
|
+
additional_data: Additional error data
|
212
|
+
"""
|
213
|
+
|
214
|
+
from django.utils import timezone
|
215
|
+
|
216
|
+
error_response = {
|
217
|
+
'error': {
|
218
|
+
'code': error_code,
|
219
|
+
'message': message,
|
220
|
+
'status_code': status_code,
|
221
|
+
'timestamp': timezone.now().isoformat(),
|
222
|
+
}
|
223
|
+
}
|
224
|
+
|
225
|
+
if additional_data:
|
226
|
+
error_response['error'].update(additional_data)
|
227
|
+
|
228
|
+
return error_response
|
@@ -0,0 +1,94 @@
|
|
1
|
+
"""
|
2
|
+
Validation utilities for payments module.
|
3
|
+
|
4
|
+
Basic validation functions for API keys and subscription access.
|
5
|
+
"""
|
6
|
+
|
7
|
+
import logging
|
8
|
+
from typing import Optional, Dict, Any
|
9
|
+
from django.contrib.auth import get_user_model
|
10
|
+
from django.utils import timezone
|
11
|
+
|
12
|
+
from ..models import APIKey, Subscription
|
13
|
+
|
14
|
+
User = get_user_model()
|
15
|
+
logger = logging.getLogger(__name__)
|
16
|
+
|
17
|
+
|
18
|
+
def validate_api_key(api_key: str) -> bool:
|
19
|
+
"""
|
20
|
+
Validate API key.
|
21
|
+
|
22
|
+
Args:
|
23
|
+
api_key: API key to validate
|
24
|
+
|
25
|
+
Returns:
|
26
|
+
True if valid, False otherwise
|
27
|
+
"""
|
28
|
+
try:
|
29
|
+
key = APIKey.objects.select_related('user').get(
|
30
|
+
key_value=api_key,
|
31
|
+
is_active=True
|
32
|
+
)
|
33
|
+
|
34
|
+
# Check if key is expired
|
35
|
+
if key.expires_at and key.expires_at < timezone.now():
|
36
|
+
return False
|
37
|
+
|
38
|
+
return True
|
39
|
+
|
40
|
+
except APIKey.DoesNotExist:
|
41
|
+
return False
|
42
|
+
except Exception as e:
|
43
|
+
logger.error(f"Error validating API key: {e}")
|
44
|
+
return False
|
45
|
+
|
46
|
+
|
47
|
+
def check_subscription_access(user_id: int, endpoint_group: str) -> Dict[str, Any]:
|
48
|
+
"""
|
49
|
+
Check subscription access for user and endpoint group.
|
50
|
+
|
51
|
+
Args:
|
52
|
+
user_id: User ID
|
53
|
+
endpoint_group: Endpoint group name
|
54
|
+
|
55
|
+
Returns:
|
56
|
+
Access check result dictionary
|
57
|
+
"""
|
58
|
+
try:
|
59
|
+
subscription = Subscription.objects.select_related('endpoint_group').get(
|
60
|
+
user_id=user_id,
|
61
|
+
endpoint_group__name=endpoint_group,
|
62
|
+
status='active',
|
63
|
+
expires_at__gt=timezone.now()
|
64
|
+
)
|
65
|
+
|
66
|
+
# Check usage limits
|
67
|
+
usage_percentage = (subscription.current_usage / subscription.monthly_limit) * 100
|
68
|
+
remaining_requests = subscription.monthly_limit - subscription.current_usage
|
69
|
+
|
70
|
+
return {
|
71
|
+
'allowed': remaining_requests > 0,
|
72
|
+
'subscription_id': str(subscription.id),
|
73
|
+
'remaining_requests': remaining_requests,
|
74
|
+
'usage_percentage': usage_percentage,
|
75
|
+
'reason': 'Active subscription' if remaining_requests > 0 else 'Usage limit exceeded'
|
76
|
+
}
|
77
|
+
|
78
|
+
except Subscription.DoesNotExist:
|
79
|
+
return {
|
80
|
+
'allowed': False,
|
81
|
+
'reason': 'No active subscription found',
|
82
|
+
'subscription_id': None,
|
83
|
+
'remaining_requests': 0,
|
84
|
+
'usage_percentage': 0
|
85
|
+
}
|
86
|
+
except Exception as e:
|
87
|
+
logger.error(f"Error checking subscription access: {e}")
|
88
|
+
return {
|
89
|
+
'allowed': False,
|
90
|
+
'reason': f'Access check failed: {str(e)}',
|
91
|
+
'subscription_id': None,
|
92
|
+
'remaining_requests': 0,
|
93
|
+
'usage_percentage': 0
|
94
|
+
}
|
@@ -46,8 +46,46 @@ class UserPaymentViewSet(viewsets.ModelViewSet):
|
|
46
46
|
def check_status(self, request, user_pk=None, pk=None):
|
47
47
|
"""Check payment status via provider API."""
|
48
48
|
payment = self.get_object()
|
49
|
-
|
50
|
-
|
49
|
+
|
50
|
+
# Import PaymentService to check status with provider
|
51
|
+
from ..services.core.payment_service import PaymentService
|
52
|
+
|
53
|
+
try:
|
54
|
+
payment_service = PaymentService()
|
55
|
+
status_result = payment_service.get_payment_status(str(payment.id))
|
56
|
+
|
57
|
+
if status_result.success:
|
58
|
+
# Update local payment status if it changed
|
59
|
+
if payment.status != status_result.status:
|
60
|
+
payment.status = status_result.status
|
61
|
+
payment.save(update_fields=['status', 'updated_at'])
|
62
|
+
|
63
|
+
return Response({
|
64
|
+
'payment_id': str(payment.id),
|
65
|
+
'status': status_result.status,
|
66
|
+
'provider_status': status_result.provider_status,
|
67
|
+
'updated': payment.status != status_result.status
|
68
|
+
})
|
69
|
+
else:
|
70
|
+
return Response({
|
71
|
+
'payment_id': str(payment.id),
|
72
|
+
'status': payment.status,
|
73
|
+
'error': status_result.error_message,
|
74
|
+
'provider_check_failed': True
|
75
|
+
}, status=status.HTTP_400_BAD_REQUEST)
|
76
|
+
|
77
|
+
except Exception as e:
|
78
|
+
# Log error but don't fail completely
|
79
|
+
import logging
|
80
|
+
logger = logging.getLogger(__name__)
|
81
|
+
logger.error(f"Payment status check failed for {payment.id}: {e}")
|
82
|
+
|
83
|
+
return Response({
|
84
|
+
'payment_id': str(payment.id),
|
85
|
+
'status': payment.status,
|
86
|
+
'error': 'Status check temporarily unavailable',
|
87
|
+
'provider_check_failed': True
|
88
|
+
})
|
51
89
|
|
52
90
|
@action(detail=False, methods=['get'])
|
53
91
|
def summary(self, request, user_pk=None):
|
@@ -0,0 +1,266 @@
|
|
1
|
+
"""
|
2
|
+
Webhook processing views with signature validation.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import json
|
6
|
+
import logging
|
7
|
+
from typing import Dict, Any
|
8
|
+
|
9
|
+
from django.http import JsonResponse, HttpResponse
|
10
|
+
from django.views.decorators.csrf import csrf_exempt
|
11
|
+
from django.views.decorators.http import require_http_methods
|
12
|
+
from django.utils.decorators import method_decorator
|
13
|
+
from rest_framework.decorators import api_view, permission_classes
|
14
|
+
from rest_framework.permissions import AllowAny
|
15
|
+
from rest_framework.response import Response
|
16
|
+
from rest_framework import status
|
17
|
+
|
18
|
+
from ..services.core.payment_service import PaymentService
|
19
|
+
from ..tasks.webhook_processing import process_webhook_with_fallback
|
20
|
+
from ..services.security.webhook_validator import webhook_validator
|
21
|
+
from ..services.security.error_handler import error_handler, SecurityError, ValidationError
|
22
|
+
|
23
|
+
logger = logging.getLogger(__name__)
|
24
|
+
|
25
|
+
|
26
|
+
@csrf_exempt
|
27
|
+
@require_http_methods(["POST"])
|
28
|
+
def webhook_handler(request, provider: str):
|
29
|
+
"""
|
30
|
+
Main webhook handler with signature validation.
|
31
|
+
|
32
|
+
Accepts webhooks from payment providers and processes them
|
33
|
+
with proper validation and fallback mechanisms.
|
34
|
+
"""
|
35
|
+
try:
|
36
|
+
# Parse webhook data
|
37
|
+
webhook_data = json.loads(request.body.decode('utf-8'))
|
38
|
+
|
39
|
+
# Extract request headers
|
40
|
+
request_headers = {
|
41
|
+
key: value for key, value in request.META.items()
|
42
|
+
if key.startswith('HTTP_')
|
43
|
+
}
|
44
|
+
|
45
|
+
# Generate idempotency key for deduplication
|
46
|
+
idempotency_key = _generate_idempotency_key(provider, webhook_data, request_headers)
|
47
|
+
|
48
|
+
logger.info(f"📥 Received webhook from {provider}, key: {idempotency_key}")
|
49
|
+
|
50
|
+
# Validate webhook with enhanced security
|
51
|
+
is_valid, validation_error = webhook_validator.validate_webhook(
|
52
|
+
provider=provider,
|
53
|
+
webhook_data=webhook_data,
|
54
|
+
request_headers=request_headers,
|
55
|
+
raw_body=request.body
|
56
|
+
)
|
57
|
+
|
58
|
+
if not is_valid:
|
59
|
+
security_error = SecurityError(
|
60
|
+
f"Webhook validation failed: {validation_error}",
|
61
|
+
details={'provider': provider, 'validation_error': validation_error}
|
62
|
+
)
|
63
|
+
error_handler.handle_error(security_error, {
|
64
|
+
'provider': provider,
|
65
|
+
'webhook_data_keys': list(webhook_data.keys()),
|
66
|
+
'headers_count': len(request_headers)
|
67
|
+
}, request)
|
68
|
+
|
69
|
+
return JsonResponse(
|
70
|
+
{'error': 'Webhook validation failed', 'code': 'INVALID_WEBHOOK'},
|
71
|
+
status=403
|
72
|
+
)
|
73
|
+
|
74
|
+
# Process webhook (async with fallback to sync)
|
75
|
+
result = process_webhook_with_fallback(
|
76
|
+
provider=provider,
|
77
|
+
webhook_data=webhook_data,
|
78
|
+
idempotency_key=idempotency_key,
|
79
|
+
request_headers=request_headers
|
80
|
+
)
|
81
|
+
|
82
|
+
if result.get('success'):
|
83
|
+
logger.info(f"✅ Webhook processed successfully: {idempotency_key}")
|
84
|
+
return JsonResponse({
|
85
|
+
'status': 'success',
|
86
|
+
'idempotency_key': idempotency_key,
|
87
|
+
'processing_mode': result.get('mode', 'unknown')
|
88
|
+
})
|
89
|
+
else:
|
90
|
+
logger.error(f"❌ Webhook processing failed: {result.get('error')}")
|
91
|
+
return JsonResponse({
|
92
|
+
'status': 'error',
|
93
|
+
'error': result.get('error', 'Processing failed'),
|
94
|
+
'idempotency_key': idempotency_key
|
95
|
+
}, status=400)
|
96
|
+
|
97
|
+
except json.JSONDecodeError as e:
|
98
|
+
validation_error = ValidationError(
|
99
|
+
f"Invalid JSON in webhook from {provider}",
|
100
|
+
details={'provider': provider, 'json_error': str(e)}
|
101
|
+
)
|
102
|
+
error_result = error_handler.handle_error(validation_error, {
|
103
|
+
'provider': provider,
|
104
|
+
'raw_body_length': len(request.body) if request.body else 0
|
105
|
+
}, request)
|
106
|
+
|
107
|
+
return JsonResponse({
|
108
|
+
'error': 'Invalid JSON',
|
109
|
+
'code': validation_error.error_code
|
110
|
+
}, status=400)
|
111
|
+
|
112
|
+
except Exception as e:
|
113
|
+
# Handle unexpected errors with centralized error handler
|
114
|
+
error_result = error_handler.handle_error(e, {
|
115
|
+
'provider': provider,
|
116
|
+
'operation': 'webhook_processing',
|
117
|
+
'webhook_data_available': 'webhook_data' in locals()
|
118
|
+
}, request)
|
119
|
+
|
120
|
+
return JsonResponse({
|
121
|
+
'error': 'Internal server error',
|
122
|
+
'code': error_result.error.error_code
|
123
|
+
}, status=500)
|
124
|
+
|
125
|
+
|
126
|
+
@api_view(['POST'])
|
127
|
+
@permission_classes([AllowAny])
|
128
|
+
def webhook_test(request):
|
129
|
+
"""
|
130
|
+
Test webhook endpoint for development.
|
131
|
+
|
132
|
+
Allows testing webhook processing without requiring
|
133
|
+
actual payment provider signatures.
|
134
|
+
"""
|
135
|
+
try:
|
136
|
+
provider = request.data.get('provider', 'test')
|
137
|
+
webhook_data = request.data.get('webhook_data', {})
|
138
|
+
|
139
|
+
# Add test marker
|
140
|
+
webhook_data['_test_webhook'] = True
|
141
|
+
|
142
|
+
# Generate test idempotency key
|
143
|
+
import uuid
|
144
|
+
idempotency_key = f"test_{uuid.uuid4().hex[:8]}"
|
145
|
+
|
146
|
+
logger.info(f"🧪 Processing test webhook: {provider}")
|
147
|
+
|
148
|
+
# Process with PaymentService directly (sync)
|
149
|
+
payment_service = PaymentService()
|
150
|
+
result = payment_service.process_webhook(
|
151
|
+
provider=provider,
|
152
|
+
webhook_data=webhook_data,
|
153
|
+
request_headers={'HTTP_X_TEST': 'true'}
|
154
|
+
)
|
155
|
+
|
156
|
+
return Response({
|
157
|
+
'status': 'success',
|
158
|
+
'test_mode': True,
|
159
|
+
'provider': provider,
|
160
|
+
'idempotency_key': idempotency_key,
|
161
|
+
'result': result.dict() if hasattr(result, 'dict') else result
|
162
|
+
})
|
163
|
+
|
164
|
+
except Exception as e:
|
165
|
+
logger.error(f"❌ Test webhook error: {e}")
|
166
|
+
return Response({
|
167
|
+
'status': 'error',
|
168
|
+
'error': str(e),
|
169
|
+
'test_mode': True
|
170
|
+
}, status=status.HTTP_400_BAD_REQUEST)
|
171
|
+
|
172
|
+
|
173
|
+
def _validate_webhook_signature(provider: str, webhook_data: Dict[str, Any],
|
174
|
+
request_headers: Dict[str, str]) -> bool:
|
175
|
+
"""
|
176
|
+
Validate webhook signature based on provider.
|
177
|
+
|
178
|
+
Each provider has different signature validation methods.
|
179
|
+
"""
|
180
|
+
try:
|
181
|
+
if provider == 'nowpayments':
|
182
|
+
return _validate_nowpayments_signature(webhook_data, request_headers)
|
183
|
+
elif provider == 'cryptapi':
|
184
|
+
return _validate_cryptapi_signature(webhook_data, request_headers)
|
185
|
+
elif provider == 'test':
|
186
|
+
return True # Allow test webhooks
|
187
|
+
else:
|
188
|
+
logger.warning(f"Unknown provider for signature validation: {provider}")
|
189
|
+
return False
|
190
|
+
|
191
|
+
except Exception as e:
|
192
|
+
logger.error(f"Signature validation error for {provider}: {e}")
|
193
|
+
return False
|
194
|
+
|
195
|
+
|
196
|
+
def _validate_nowpayments_signature(webhook_data: Dict[str, Any],
|
197
|
+
request_headers: Dict[str, str]) -> bool:
|
198
|
+
"""Validate NowPayments webhook signature."""
|
199
|
+
import hmac
|
200
|
+
import hashlib
|
201
|
+
from ..utils.config_utils import get_payments_config
|
202
|
+
|
203
|
+
# Get IPN secret from config
|
204
|
+
config = get_payments_config()
|
205
|
+
if not config or not hasattr(config, 'providers') or 'nowpayments' not in config.providers:
|
206
|
+
logger.warning("NowPayments IPN secret not configured, skipping validation")
|
207
|
+
return True # Allow if not configured (development mode)
|
208
|
+
|
209
|
+
nowpayments_config = config.providers['nowpayments']
|
210
|
+
ipn_secret = getattr(nowpayments_config, 'ipn_secret', None)
|
211
|
+
|
212
|
+
if not ipn_secret:
|
213
|
+
logger.warning("NowPayments IPN secret not configured, skipping validation")
|
214
|
+
return True
|
215
|
+
|
216
|
+
# Get signature from headers
|
217
|
+
signature = request_headers.get('HTTP_X_NOWPAYMENTS_SIG')
|
218
|
+
if not signature:
|
219
|
+
logger.warning("No NowPayments signature found in headers")
|
220
|
+
return False
|
221
|
+
|
222
|
+
# Calculate expected signature
|
223
|
+
payload = json.dumps(webhook_data, separators=(',', ':'), sort_keys=True)
|
224
|
+
expected_signature = hmac.new(
|
225
|
+
ipn_secret.encode(),
|
226
|
+
payload.encode(),
|
227
|
+
hashlib.sha512
|
228
|
+
).hexdigest()
|
229
|
+
|
230
|
+
return hmac.compare_digest(signature, expected_signature)
|
231
|
+
|
232
|
+
|
233
|
+
def _validate_cryptapi_signature(webhook_data: Dict[str, Any],
|
234
|
+
request_headers: Dict[str, str]) -> bool:
|
235
|
+
"""Validate CryptAPI webhook signature."""
|
236
|
+
# CryptAPI uses different validation method
|
237
|
+
# For now, implement basic validation
|
238
|
+
|
239
|
+
# Check if required fields are present
|
240
|
+
required_fields = ['address_in', 'address_out', 'txid_in', 'value_coin', 'coin', 'confirmations']
|
241
|
+
for field in required_fields:
|
242
|
+
if field not in webhook_data:
|
243
|
+
logger.warning(f"Missing required field in CryptAPI webhook: {field}")
|
244
|
+
return False
|
245
|
+
|
246
|
+
return True
|
247
|
+
|
248
|
+
|
249
|
+
def _generate_idempotency_key(provider: str, webhook_data: Dict[str, Any],
|
250
|
+
request_headers: Dict[str, str]) -> str:
|
251
|
+
"""Generate idempotency key for webhook deduplication."""
|
252
|
+
import hashlib
|
253
|
+
|
254
|
+
# Use provider + payment ID + timestamp for uniqueness
|
255
|
+
payment_id = (
|
256
|
+
webhook_data.get('payment_id') or
|
257
|
+
webhook_data.get('order_id') or
|
258
|
+
webhook_data.get('id') or
|
259
|
+
'unknown'
|
260
|
+
)
|
261
|
+
|
262
|
+
timestamp = webhook_data.get('created_at') or webhook_data.get('timestamp')
|
263
|
+
|
264
|
+
# Create hash from key components
|
265
|
+
key_data = f"{provider}:{payment_id}:{timestamp}"
|
266
|
+
return hashlib.md5(key_data.encode()).hexdigest()[:16]
|
@@ -0,0 +1,65 @@
|
|
1
|
+
"""
|
2
|
+
Payment system ViewSets router for easy integration.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from rest_framework.routers import DefaultRouter
|
6
|
+
from rest_framework_nested import routers
|
7
|
+
|
8
|
+
from .views import (
|
9
|
+
# Balance ViewSets
|
10
|
+
UserBalanceViewSet, TransactionViewSet,
|
11
|
+
|
12
|
+
# Payment ViewSets
|
13
|
+
UserPaymentViewSet, UniversalPaymentViewSet,
|
14
|
+
|
15
|
+
# Subscription ViewSets
|
16
|
+
UserSubscriptionViewSet, SubscriptionViewSet, EndpointGroupViewSet,
|
17
|
+
|
18
|
+
# API Key ViewSets
|
19
|
+
UserAPIKeyViewSet, APIKeyViewSet,
|
20
|
+
|
21
|
+
# Currency ViewSets
|
22
|
+
CurrencyViewSet, CurrencyNetworkViewSet,
|
23
|
+
|
24
|
+
# Tariff ViewSets
|
25
|
+
TariffViewSet, TariffEndpointGroupViewSet,
|
26
|
+
)
|
27
|
+
|
28
|
+
|
29
|
+
class PaymentSystemRouter:
|
30
|
+
"""Universal router with all payment endpoints"""
|
31
|
+
|
32
|
+
def __init__(self):
|
33
|
+
self.router = DefaultRouter()
|
34
|
+
self._setup_main_routes()
|
35
|
+
|
36
|
+
def _setup_main_routes(self):
|
37
|
+
"""Setup main resource routes"""
|
38
|
+
# Core payment resources
|
39
|
+
self.router.register(r'payments', UniversalPaymentViewSet, basename='payment')
|
40
|
+
self.router.register(r'balances', UserBalanceViewSet, basename='balance')
|
41
|
+
self.router.register(r'transactions', TransactionViewSet, basename='transaction')
|
42
|
+
|
43
|
+
# Subscription management
|
44
|
+
self.router.register(r'subscriptions', SubscriptionViewSet, basename='subscription')
|
45
|
+
self.router.register(r'endpoint-groups', EndpointGroupViewSet, basename='endpoint-group')
|
46
|
+
|
47
|
+
# API key management
|
48
|
+
self.router.register(r'api-keys', APIKeyViewSet, basename='api-key')
|
49
|
+
|
50
|
+
# Currency and pricing
|
51
|
+
self.router.register(r'currencies', CurrencyViewSet, basename='currency')
|
52
|
+
self.router.register(r'currency-networks', CurrencyNetworkViewSet, basename='currency-network')
|
53
|
+
self.router.register(r'tariffs', TariffViewSet, basename='tariff')
|
54
|
+
self.router.register(r'tariff-groups', TariffEndpointGroupViewSet, basename='tariff-group')
|
55
|
+
|
56
|
+
@property
|
57
|
+
def urls(self):
|
58
|
+
"""Get all URLs"""
|
59
|
+
return self.router.urls
|
60
|
+
|
61
|
+
|
62
|
+
# Create default router instance
|
63
|
+
payment_router = PaymentSystemRouter()
|
64
|
+
|
65
|
+
__all__ = ['PaymentSystemRouter', 'payment_router']
|