django-cfg 1.2.21__py3-none-any.whl → 1.2.22__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/newsletter/signals.py +9 -8
- django_cfg/apps/payments/__init__.py +8 -0
- django_cfg/apps/payments/apps.py +22 -0
- django_cfg/apps/payments/managers/__init__.py +22 -0
- django_cfg/apps/payments/managers/api_key_manager.py +35 -0
- django_cfg/apps/payments/managers/balance_manager.py +361 -0
- django_cfg/apps/payments/managers/currency_manager.py +32 -0
- django_cfg/apps/payments/managers/payment_manager.py +44 -0
- django_cfg/apps/payments/managers/subscription_manager.py +37 -0
- django_cfg/apps/payments/managers/tariff_manager.py +29 -0
- django_cfg/apps/payments/middleware/__init__.py +13 -0
- django_cfg/apps/payments/migrations/0001_initial.py +982 -0
- django_cfg/apps/payments/migrations/__init__.py +1 -0
- django_cfg/apps/payments/models/__init__.py +49 -0
- django_cfg/apps/payments/models/api_keys.py +96 -0
- django_cfg/apps/payments/models/balance.py +209 -0
- django_cfg/apps/payments/models/base.py +14 -0
- django_cfg/apps/payments/models/currencies.py +138 -0
- django_cfg/apps/payments/models/events.py +73 -0
- django_cfg/apps/payments/models/payments.py +301 -0
- django_cfg/apps/payments/models/subscriptions.py +270 -0
- django_cfg/apps/payments/models/tariffs.py +102 -0
- django_cfg/apps/payments/serializers/__init__.py +56 -0
- django_cfg/apps/payments/serializers/api_keys.py +51 -0
- django_cfg/apps/payments/serializers/balance.py +59 -0
- django_cfg/apps/payments/serializers/currencies.py +55 -0
- django_cfg/apps/payments/serializers/payments.py +62 -0
- django_cfg/apps/payments/serializers/subscriptions.py +71 -0
- django_cfg/apps/payments/serializers/tariffs.py +56 -0
- django_cfg/apps/payments/services/__init__.py +14 -0
- django_cfg/apps/payments/services/base.py +68 -0
- django_cfg/apps/payments/services/nowpayments.py +78 -0
- django_cfg/apps/payments/services/providers.py +77 -0
- django_cfg/apps/payments/services/redis_service.py +215 -0
- django_cfg/apps/payments/urls.py +78 -0
- django_cfg/apps/payments/views/__init__.py +62 -0
- django_cfg/apps/payments/views/api_key_views.py +164 -0
- django_cfg/apps/payments/views/balance_views.py +75 -0
- django_cfg/apps/payments/views/currency_views.py +111 -0
- django_cfg/apps/payments/views/payment_views.py +111 -0
- django_cfg/apps/payments/views/subscription_views.py +135 -0
- django_cfg/apps/payments/views/tariff_views.py +131 -0
- django_cfg/core/config.py +6 -0
- django_cfg/models/revolution.py +14 -0
- django_cfg/modules/base.py +9 -0
- {django_cfg-1.2.21.dist-info → django_cfg-1.2.22.dist-info}/METADATA +1 -1
- {django_cfg-1.2.21.dist-info → django_cfg-1.2.22.dist-info}/RECORD +51 -10
- {django_cfg-1.2.21.dist-info → django_cfg-1.2.22.dist-info}/WHEEL +0 -0
- {django_cfg-1.2.21.dist-info → django_cfg-1.2.22.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.2.21.dist-info → django_cfg-1.2.22.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,215 @@
|
|
1
|
+
"""
|
2
|
+
Redis service for universal payments with circuit breaker.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import logging
|
6
|
+
import redis
|
7
|
+
import json
|
8
|
+
import time
|
9
|
+
from typing import Optional, Any, Dict, List
|
10
|
+
from django_cfg.modules import BaseCfgModule
|
11
|
+
from django.core.cache import cache
|
12
|
+
from django.db import transaction
|
13
|
+
|
14
|
+
logger = logging.getLogger(__name__)
|
15
|
+
|
16
|
+
|
17
|
+
class RedisCircuitBreaker:
|
18
|
+
"""Circuit breaker for Redis operations with database fallback."""
|
19
|
+
|
20
|
+
def __init__(self, failure_threshold: int = 5, recovery_timeout: int = 60):
|
21
|
+
self.failure_threshold = failure_threshold
|
22
|
+
self.recovery_timeout = recovery_timeout
|
23
|
+
self.failure_count = 0
|
24
|
+
self.last_failure_time = None
|
25
|
+
self.state = "CLOSED" # CLOSED, OPEN, HALF_OPEN
|
26
|
+
|
27
|
+
def call(self, redis_func, fallback_func, *args, **kwargs):
|
28
|
+
"""Execute function with circuit breaker pattern."""
|
29
|
+
if self.state == "OPEN":
|
30
|
+
if self._should_attempt_reset():
|
31
|
+
self.state = "HALF_OPEN"
|
32
|
+
else:
|
33
|
+
logger.warning("Circuit breaker OPEN, using fallback")
|
34
|
+
return fallback_func(*args, **kwargs)
|
35
|
+
|
36
|
+
try:
|
37
|
+
result = redis_func(*args, **kwargs)
|
38
|
+
self._on_success()
|
39
|
+
return result
|
40
|
+
except Exception as e:
|
41
|
+
self._on_failure()
|
42
|
+
logger.warning(f"Redis operation failed, using fallback: {e}")
|
43
|
+
return fallback_func(*args, **kwargs)
|
44
|
+
|
45
|
+
def _on_success(self):
|
46
|
+
"""Handle successful operation."""
|
47
|
+
self.failure_count = 0
|
48
|
+
self.state = "CLOSED"
|
49
|
+
|
50
|
+
def _on_failure(self):
|
51
|
+
"""Handle failed operation."""
|
52
|
+
self.failure_count += 1
|
53
|
+
self.last_failure_time = time.time()
|
54
|
+
if self.failure_count >= self.failure_threshold:
|
55
|
+
self.state = "OPEN"
|
56
|
+
|
57
|
+
def _should_attempt_reset(self):
|
58
|
+
"""Check if circuit breaker should attempt reset."""
|
59
|
+
import time
|
60
|
+
return (time.time() - self.last_failure_time) > self.recovery_timeout
|
61
|
+
|
62
|
+
|
63
|
+
class RedisService(BaseCfgModule):
|
64
|
+
"""Redis service with automatic configuration and circuit breaker."""
|
65
|
+
|
66
|
+
def __init__(self):
|
67
|
+
super().__init__()
|
68
|
+
self._client = None
|
69
|
+
self._circuit_breaker = RedisCircuitBreaker()
|
70
|
+
|
71
|
+
@property
|
72
|
+
def client(self) -> Optional[redis.Redis]:
|
73
|
+
"""Get Redis client with lazy initialization."""
|
74
|
+
if self._client is None:
|
75
|
+
self._client = self._create_client()
|
76
|
+
return self._client
|
77
|
+
|
78
|
+
def _create_client(self) -> Optional[redis.Redis]:
|
79
|
+
"""Create Redis client from configuration."""
|
80
|
+
try:
|
81
|
+
config = self.get_config()
|
82
|
+
if not config:
|
83
|
+
logger.warning("No config available, Redis disabled")
|
84
|
+
return None
|
85
|
+
|
86
|
+
# Get Redis config from main config
|
87
|
+
redis_config = getattr(config, 'cache', None)
|
88
|
+
if not redis_config:
|
89
|
+
logger.warning("No Redis config found, using default")
|
90
|
+
redis_url = "redis://localhost:6379/0"
|
91
|
+
else:
|
92
|
+
redis_url = getattr(redis_config, 'redis_url', "redis://localhost:6379/0")
|
93
|
+
|
94
|
+
return redis.Redis.from_url(redis_url, decode_responses=True)
|
95
|
+
|
96
|
+
except Exception as e:
|
97
|
+
logger.error(f"Failed to create Redis client: {e}")
|
98
|
+
return None
|
99
|
+
|
100
|
+
def get_cache(self, key: str) -> Any:
|
101
|
+
"""Get value from cache with circuit breaker."""
|
102
|
+
def redis_get():
|
103
|
+
if not self.client:
|
104
|
+
raise redis.ConnectionError("Redis client not available")
|
105
|
+
data = self.client.get(key)
|
106
|
+
return json.loads(data) if data else None
|
107
|
+
|
108
|
+
def fallback_get():
|
109
|
+
# Fallback to Django cache (database)
|
110
|
+
return cache.get(key)
|
111
|
+
|
112
|
+
return self._circuit_breaker.call(redis_get, fallback_get)
|
113
|
+
|
114
|
+
def set_cache(self, key: str, value: Any, ttl: int = 300) -> bool:
|
115
|
+
"""Set value in cache with circuit breaker."""
|
116
|
+
def redis_set():
|
117
|
+
if not self.client:
|
118
|
+
raise redis.ConnectionError("Redis client not available")
|
119
|
+
data = json.dumps(value) if value is not None else ""
|
120
|
+
return self.client.setex(key, ttl, data)
|
121
|
+
|
122
|
+
def fallback_set():
|
123
|
+
# Fallback to Django cache (database)
|
124
|
+
cache.set(key, value, ttl)
|
125
|
+
return True
|
126
|
+
|
127
|
+
return self._circuit_breaker.call(redis_set, fallback_set)
|
128
|
+
|
129
|
+
def delete_cache(self, key: str) -> bool:
|
130
|
+
"""Delete key from cache."""
|
131
|
+
def redis_delete():
|
132
|
+
if not self.client:
|
133
|
+
raise redis.ConnectionError("Redis client not available")
|
134
|
+
return self.client.delete(key)
|
135
|
+
|
136
|
+
def fallback_delete():
|
137
|
+
cache.delete(key)
|
138
|
+
return True
|
139
|
+
|
140
|
+
return self._circuit_breaker.call(redis_delete, fallback_delete)
|
141
|
+
|
142
|
+
def increment(self, key: str, amount: int = 1, ttl: Optional[int] = None) -> int:
|
143
|
+
"""Increment counter with circuit breaker."""
|
144
|
+
def redis_incr():
|
145
|
+
if not self.client:
|
146
|
+
raise redis.ConnectionError("Redis client not available")
|
147
|
+
result = self.client.incr(key, amount)
|
148
|
+
if ttl:
|
149
|
+
self.client.expire(key, ttl)
|
150
|
+
return result
|
151
|
+
|
152
|
+
def fallback_incr():
|
153
|
+
# Simple fallback - just return amount (no persistence)
|
154
|
+
logger.warning(f"Redis unavailable, counter increment for {key} not persisted")
|
155
|
+
return amount
|
156
|
+
|
157
|
+
return self._circuit_breaker.call(redis_incr, fallback_incr)
|
158
|
+
|
159
|
+
def get_user_access_cache(self, user_id: int, endpoint_group: str) -> Optional[Dict]:
|
160
|
+
"""Get cached user access info."""
|
161
|
+
key = f"access:{user_id}:{endpoint_group}"
|
162
|
+
return self.get_cache(key)
|
163
|
+
|
164
|
+
def set_user_access_cache(self, user_id: int, endpoint_group: str, access_info: Dict, ttl: int = 60) -> bool:
|
165
|
+
"""Set cached user access info."""
|
166
|
+
key = f"access:{user_id}:{endpoint_group}"
|
167
|
+
return self.set_cache(key, access_info, ttl)
|
168
|
+
|
169
|
+
def track_usage(self, user_id: int, endpoint_group: str, response_time_ms: Optional[int] = None) -> None:
|
170
|
+
"""Track API usage with rate limiting."""
|
171
|
+
# Increment request counter
|
172
|
+
usage_key = f"usage:{user_id}:{endpoint_group}"
|
173
|
+
self.increment(usage_key, ttl=3600) # 1 hour window
|
174
|
+
|
175
|
+
# Track response time if provided
|
176
|
+
if response_time_ms:
|
177
|
+
rt_key = f"response_time:{user_id}:{endpoint_group}"
|
178
|
+
def redis_track_rt():
|
179
|
+
if not self.client:
|
180
|
+
raise redis.ConnectionError("Redis client not available")
|
181
|
+
self.client.lpush(rt_key, response_time_ms)
|
182
|
+
self.client.ltrim(rt_key, 0, 99) # Keep last 100
|
183
|
+
self.client.expire(rt_key, 3600)
|
184
|
+
|
185
|
+
def fallback_track_rt():
|
186
|
+
pass # Skip response time tracking in fallback
|
187
|
+
|
188
|
+
self._circuit_breaker.call(redis_track_rt, fallback_track_rt)
|
189
|
+
|
190
|
+
def check_rate_limit(self, user_id: int, endpoint_group: str, limit: int, window: int = 3600) -> Dict:
|
191
|
+
"""Check rate limit for user."""
|
192
|
+
rate_key = f"rate:{user_id}:{endpoint_group}:{window}"
|
193
|
+
|
194
|
+
def redis_check():
|
195
|
+
if not self.client:
|
196
|
+
raise redis.ConnectionError("Redis client not available")
|
197
|
+
current = self.client.get(rate_key) or 0
|
198
|
+
return {
|
199
|
+
'allowed': int(current) < limit,
|
200
|
+
'current': int(current),
|
201
|
+
'limit': limit,
|
202
|
+
'window': window
|
203
|
+
}
|
204
|
+
|
205
|
+
def fallback_check():
|
206
|
+
# Fallback - always allow (no rate limiting)
|
207
|
+
logger.warning(f"Redis unavailable, rate limiting disabled for user {user_id}")
|
208
|
+
return {
|
209
|
+
'allowed': True,
|
210
|
+
'current': 0,
|
211
|
+
'limit': limit,
|
212
|
+
'window': window
|
213
|
+
}
|
214
|
+
|
215
|
+
return self._circuit_breaker.call(redis_check, fallback_check)
|
@@ -0,0 +1,78 @@
|
|
1
|
+
"""
|
2
|
+
URL routing for universal payments with nested routers.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from django.urls import path, include
|
6
|
+
from rest_framework.routers import DefaultRouter
|
7
|
+
from rest_framework_nested import routers
|
8
|
+
|
9
|
+
from . import views
|
10
|
+
|
11
|
+
app_name = 'payments'
|
12
|
+
|
13
|
+
# Main router for global endpoints
|
14
|
+
router = DefaultRouter()
|
15
|
+
|
16
|
+
# Global ViewSets (without user nesting)
|
17
|
+
router.register(r'payments', views.UniversalPaymentViewSet, basename='payment')
|
18
|
+
router.register(r'subscriptions', views.SubscriptionViewSet, basename='subscription')
|
19
|
+
router.register(r'api-keys', views.APIKeyViewSet, basename='apikey')
|
20
|
+
router.register(r'balances', views.UserBalanceViewSet, basename='balance')
|
21
|
+
router.register(r'transactions', views.TransactionViewSet, basename='transaction')
|
22
|
+
router.register(r'currencies', views.CurrencyViewSet, basename='currency')
|
23
|
+
router.register(r'currency-networks', views.CurrencyNetworkViewSet, basename='currencynetwork')
|
24
|
+
router.register(r'endpoint-groups', views.EndpointGroupViewSet, basename='endpointgroup')
|
25
|
+
router.register(r'tariffs', views.TariffViewSet, basename='tariff')
|
26
|
+
router.register(r'tariff-endpoint-groups', views.TariffEndpointGroupViewSet, basename='tariffendpointgroup')
|
27
|
+
|
28
|
+
# Nested routers for user-specific resources
|
29
|
+
# /users/{user_id}/payments/
|
30
|
+
users_router = routers.SimpleRouter()
|
31
|
+
users_router.register(r'users', views.UserPaymentViewSet, basename='user')
|
32
|
+
|
33
|
+
payments_router = routers.NestedSimpleRouter(users_router, r'users', lookup='user')
|
34
|
+
payments_router.register(r'payments', views.UserPaymentViewSet, basename='user-payment')
|
35
|
+
|
36
|
+
# /users/{user_id}/subscriptions/
|
37
|
+
subscriptions_router = routers.NestedSimpleRouter(users_router, r'users', lookup='user')
|
38
|
+
subscriptions_router.register(r'subscriptions', views.UserSubscriptionViewSet, basename='user-subscription')
|
39
|
+
|
40
|
+
# /users/{user_id}/api-keys/
|
41
|
+
apikeys_router = routers.NestedSimpleRouter(users_router, r'users', lookup='user')
|
42
|
+
apikeys_router.register(r'api-keys', views.UserAPIKeyViewSet, basename='user-apikey')
|
43
|
+
|
44
|
+
# Generic API endpoints
|
45
|
+
generic_patterns = [
|
46
|
+
# Payment endpoints
|
47
|
+
path('payment/create/', views.PaymentCreateView.as_view(), name='payment-create'),
|
48
|
+
path('payment/status/<str:internal_payment_id>/', views.PaymentStatusView.as_view(), name='payment-status'),
|
49
|
+
|
50
|
+
# Subscription endpoints
|
51
|
+
path('subscription/create/', views.SubscriptionCreateView.as_view(), name='subscription-create'),
|
52
|
+
path('subscriptions/active/', views.ActiveSubscriptionsView.as_view(), name='subscriptions-active'),
|
53
|
+
|
54
|
+
# API Key endpoints
|
55
|
+
path('api-key/create/', views.APIKeyCreateView.as_view(), name='apikey-create'),
|
56
|
+
path('api-key/validate/', views.APIKeyValidateView.as_view(), name='apikey-validate'),
|
57
|
+
|
58
|
+
# Currency endpoints
|
59
|
+
path('currencies/supported/', views.SupportedCurrenciesView.as_view(), name='currencies-supported'),
|
60
|
+
path('currencies/rates/', views.CurrencyRatesView.as_view(), name='currency-rates'),
|
61
|
+
|
62
|
+
# Tariff endpoints
|
63
|
+
path('tariffs/available/', views.AvailableTariffsView.as_view(), name='tariffs-available'),
|
64
|
+
path('tariffs/comparison/', views.TariffComparisonView.as_view(), name='tariff-comparison'),
|
65
|
+
]
|
66
|
+
|
67
|
+
urlpatterns = [
|
68
|
+
# Include all router URLs
|
69
|
+
path('api/v1/', include(router.urls)),
|
70
|
+
|
71
|
+
# Include nested router URLs
|
72
|
+
path('api/v1/', include(payments_router.urls)),
|
73
|
+
path('api/v1/', include(subscriptions_router.urls)),
|
74
|
+
path('api/v1/', include(apikeys_router.urls)),
|
75
|
+
|
76
|
+
# Include generic API endpoints
|
77
|
+
path('api/v1/', include(generic_patterns)),
|
78
|
+
]
|
@@ -0,0 +1,62 @@
|
|
1
|
+
"""
|
2
|
+
DRF ViewSets for universal payments.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from .balance_views import UserBalanceViewSet, TransactionViewSet
|
6
|
+
from .payment_views import (
|
7
|
+
UserPaymentViewSet, UniversalPaymentViewSet,
|
8
|
+
PaymentCreateView, PaymentStatusView
|
9
|
+
)
|
10
|
+
from .subscription_views import (
|
11
|
+
UserSubscriptionViewSet, SubscriptionViewSet, EndpointGroupViewSet,
|
12
|
+
SubscriptionCreateView, ActiveSubscriptionsView
|
13
|
+
)
|
14
|
+
from .api_key_views import (
|
15
|
+
UserAPIKeyViewSet, APIKeyViewSet,
|
16
|
+
APIKeyCreateView, APIKeyValidateView
|
17
|
+
)
|
18
|
+
from .currency_views import (
|
19
|
+
CurrencyViewSet, CurrencyNetworkViewSet,
|
20
|
+
SupportedCurrenciesView, CurrencyRatesView
|
21
|
+
)
|
22
|
+
from .tariff_views import (
|
23
|
+
TariffViewSet, TariffEndpointGroupViewSet,
|
24
|
+
AvailableTariffsView, TariffComparisonView
|
25
|
+
)
|
26
|
+
|
27
|
+
__all__ = [
|
28
|
+
# Balance ViewSets
|
29
|
+
'UserBalanceViewSet',
|
30
|
+
'TransactionViewSet',
|
31
|
+
|
32
|
+
# Payment ViewSets & Generics
|
33
|
+
'UserPaymentViewSet',
|
34
|
+
'UniversalPaymentViewSet',
|
35
|
+
'PaymentCreateView',
|
36
|
+
'PaymentStatusView',
|
37
|
+
|
38
|
+
# Subscription ViewSets & Generics
|
39
|
+
'UserSubscriptionViewSet',
|
40
|
+
'SubscriptionViewSet',
|
41
|
+
'EndpointGroupViewSet',
|
42
|
+
'SubscriptionCreateView',
|
43
|
+
'ActiveSubscriptionsView',
|
44
|
+
|
45
|
+
# API Key ViewSets & Generics
|
46
|
+
'UserAPIKeyViewSet',
|
47
|
+
'APIKeyViewSet',
|
48
|
+
'APIKeyCreateView',
|
49
|
+
'APIKeyValidateView',
|
50
|
+
|
51
|
+
# Currency ViewSets & Generics
|
52
|
+
'CurrencyViewSet',
|
53
|
+
'CurrencyNetworkViewSet',
|
54
|
+
'SupportedCurrenciesView',
|
55
|
+
'CurrencyRatesView',
|
56
|
+
|
57
|
+
# Tariff ViewSets & Generics
|
58
|
+
'TariffViewSet',
|
59
|
+
'TariffEndpointGroupViewSet',
|
60
|
+
'AvailableTariffsView',
|
61
|
+
'TariffComparisonView',
|
62
|
+
]
|
@@ -0,0 +1,164 @@
|
|
1
|
+
"""
|
2
|
+
API Key ViewSets with nested routing.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from rest_framework import viewsets, permissions, status, generics
|
6
|
+
from rest_framework.decorators import action
|
7
|
+
from rest_framework.response import Response
|
8
|
+
from django_filters.rest_framework import DjangoFilterBackend
|
9
|
+
from django.contrib.auth import get_user_model
|
10
|
+
from ..models import APIKey
|
11
|
+
from ..serializers import (
|
12
|
+
APIKeySerializer, APIKeyCreateSerializer, APIKeyListSerializer
|
13
|
+
)
|
14
|
+
|
15
|
+
User = get_user_model()
|
16
|
+
|
17
|
+
|
18
|
+
class UserAPIKeyViewSet(viewsets.ModelViewSet):
|
19
|
+
"""Nested ViewSet for user API keys: /users/{user_id}/api-keys/"""
|
20
|
+
|
21
|
+
serializer_class = APIKeySerializer
|
22
|
+
permission_classes = [permissions.IsAuthenticated]
|
23
|
+
filter_backends = [DjangoFilterBackend]
|
24
|
+
filterset_fields = ['is_active']
|
25
|
+
|
26
|
+
def get_queryset(self):
|
27
|
+
"""Filter by user from URL."""
|
28
|
+
user_id = self.kwargs.get('user_pk')
|
29
|
+
return APIKey.objects.filter(user_id=user_id).order_by('-created_at')
|
30
|
+
|
31
|
+
def get_serializer_class(self):
|
32
|
+
"""Use different serializers for different actions."""
|
33
|
+
if self.action == 'create':
|
34
|
+
return APIKeyCreateSerializer
|
35
|
+
elif self.action == 'list':
|
36
|
+
return APIKeyListSerializer
|
37
|
+
return APIKeySerializer
|
38
|
+
|
39
|
+
def perform_create(self, serializer):
|
40
|
+
"""Set user from URL when creating."""
|
41
|
+
user_id = self.kwargs.get('user_pk')
|
42
|
+
user = User.objects.get(id=user_id)
|
43
|
+
|
44
|
+
# Generate unique API key
|
45
|
+
import secrets
|
46
|
+
key_value = f"ak_{secrets.token_urlsafe(32)}"
|
47
|
+
|
48
|
+
serializer.save(user=user, key_value=key_value)
|
49
|
+
|
50
|
+
@action(detail=True, methods=['post'])
|
51
|
+
def regenerate(self, request, user_pk=None, pk=None):
|
52
|
+
"""Regenerate API key."""
|
53
|
+
api_key = self.get_object()
|
54
|
+
|
55
|
+
# Generate new key
|
56
|
+
import secrets
|
57
|
+
api_key.key_value = f"ak_{secrets.token_urlsafe(32)}"
|
58
|
+
api_key.usage_count = 0 # Reset usage
|
59
|
+
api_key.save()
|
60
|
+
|
61
|
+
serializer = self.get_serializer(api_key)
|
62
|
+
return Response(serializer.data)
|
63
|
+
|
64
|
+
@action(detail=True, methods=['post'])
|
65
|
+
def deactivate(self, request, user_pk=None, pk=None):
|
66
|
+
"""Deactivate API key."""
|
67
|
+
api_key = self.get_object()
|
68
|
+
api_key.is_active = False
|
69
|
+
api_key.save()
|
70
|
+
|
71
|
+
return Response({'message': 'API key deactivated'})
|
72
|
+
|
73
|
+
@action(detail=True, methods=['get'])
|
74
|
+
def usage_stats(self, request, user_pk=None, pk=None):
|
75
|
+
"""Get usage statistics for API key."""
|
76
|
+
api_key = self.get_object()
|
77
|
+
|
78
|
+
return Response({
|
79
|
+
'usage_count': api_key.usage_count,
|
80
|
+
'last_used': api_key.last_used,
|
81
|
+
'is_valid': api_key.is_valid(),
|
82
|
+
'expires_at': api_key.expires_at,
|
83
|
+
'is_active': api_key.is_active,
|
84
|
+
})
|
85
|
+
|
86
|
+
|
87
|
+
class APIKeyViewSet(viewsets.ReadOnlyModelViewSet):
|
88
|
+
"""Global API keys ViewSet: /api-keys/"""
|
89
|
+
|
90
|
+
queryset = APIKey.objects.all()
|
91
|
+
serializer_class = APIKeySerializer
|
92
|
+
permission_classes = [permissions.IsAuthenticated]
|
93
|
+
filter_backends = [DjangoFilterBackend]
|
94
|
+
filterset_fields = ['is_active']
|
95
|
+
|
96
|
+
def get_queryset(self):
|
97
|
+
"""Filter by current user for security."""
|
98
|
+
return APIKey.objects.filter(user=self.request.user).order_by('-created_at')
|
99
|
+
|
100
|
+
def get_serializer_class(self):
|
101
|
+
"""Use list serializer for list action."""
|
102
|
+
if self.action == 'list':
|
103
|
+
return APIKeyListSerializer
|
104
|
+
return APIKeySerializer
|
105
|
+
|
106
|
+
|
107
|
+
# Generic views for specific use cases
|
108
|
+
class APIKeyCreateView(generics.CreateAPIView):
|
109
|
+
"""Generic view to create API key."""
|
110
|
+
|
111
|
+
serializer_class = APIKeyCreateSerializer
|
112
|
+
permission_classes = [permissions.IsAuthenticated]
|
113
|
+
|
114
|
+
def perform_create(self, serializer):
|
115
|
+
"""Set current user and generate key when creating."""
|
116
|
+
import secrets
|
117
|
+
key_value = f"ak_{secrets.token_urlsafe(32)}"
|
118
|
+
serializer.save(user=self.request.user, key_value=key_value)
|
119
|
+
|
120
|
+
|
121
|
+
class APIKeyValidateView(generics.GenericAPIView):
|
122
|
+
"""Generic view to validate API key."""
|
123
|
+
|
124
|
+
serializer_class = APIKeySerializer # For schema generation
|
125
|
+
permission_classes = [permissions.AllowAny] # Public endpoint
|
126
|
+
|
127
|
+
def post(self, request):
|
128
|
+
"""Validate API key."""
|
129
|
+
key_value = request.data.get('api_key')
|
130
|
+
|
131
|
+
if not key_value:
|
132
|
+
return Response(
|
133
|
+
{'error': 'API key required'},
|
134
|
+
status=status.HTTP_400_BAD_REQUEST
|
135
|
+
)
|
136
|
+
|
137
|
+
try:
|
138
|
+
api_key = APIKey.objects.get(key_value=key_value, is_active=True)
|
139
|
+
|
140
|
+
# Check if expired
|
141
|
+
if api_key.is_expired:
|
142
|
+
return Response(
|
143
|
+
{'error': 'API key expired'},
|
144
|
+
status=status.HTTP_401_UNAUTHORIZED
|
145
|
+
)
|
146
|
+
|
147
|
+
# Update last used
|
148
|
+
from django.utils import timezone
|
149
|
+
api_key.last_used = timezone.now()
|
150
|
+
api_key.save()
|
151
|
+
|
152
|
+
return Response({
|
153
|
+
'valid': True,
|
154
|
+
'user_id': api_key.user.id,
|
155
|
+
'usage_count': api_key.usage_count,
|
156
|
+
'expires_at': api_key.expires_at,
|
157
|
+
'is_active': api_key.is_active,
|
158
|
+
})
|
159
|
+
|
160
|
+
except APIKey.DoesNotExist:
|
161
|
+
return Response(
|
162
|
+
{'valid': False, 'error': 'Invalid API key'},
|
163
|
+
status=status.HTTP_401_UNAUTHORIZED
|
164
|
+
)
|
@@ -0,0 +1,75 @@
|
|
1
|
+
"""
|
2
|
+
Balance ViewSets.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from rest_framework import viewsets, permissions, status
|
6
|
+
from rest_framework.decorators import action
|
7
|
+
from rest_framework.response import Response
|
8
|
+
from django_filters.rest_framework import DjangoFilterBackend
|
9
|
+
from ..models import UserBalance, Transaction
|
10
|
+
from ..serializers import (
|
11
|
+
UserBalanceSerializer, TransactionSerializer, TransactionListSerializer
|
12
|
+
)
|
13
|
+
|
14
|
+
|
15
|
+
class UserBalanceViewSet(viewsets.ReadOnlyModelViewSet):
|
16
|
+
"""User balance ViewSet - read only."""
|
17
|
+
|
18
|
+
queryset = UserBalance.objects.all()
|
19
|
+
serializer_class = UserBalanceSerializer
|
20
|
+
permission_classes = [permissions.IsAuthenticated]
|
21
|
+
|
22
|
+
def get_queryset(self):
|
23
|
+
"""Filter by current user."""
|
24
|
+
return UserBalance.objects.filter(user=self.request.user)
|
25
|
+
|
26
|
+
@action(detail=False, methods=['get'])
|
27
|
+
def current(self, request):
|
28
|
+
"""Get current user balance."""
|
29
|
+
balance, _ = UserBalance.objects.get_or_create(
|
30
|
+
user=request.user,
|
31
|
+
defaults={'amount_usd': 0.0, 'reserved_usd': 0.0}
|
32
|
+
)
|
33
|
+
serializer = self.get_serializer(balance)
|
34
|
+
return Response(serializer.data)
|
35
|
+
|
36
|
+
|
37
|
+
class TransactionViewSet(viewsets.ReadOnlyModelViewSet):
|
38
|
+
"""Transaction ViewSet - read only."""
|
39
|
+
|
40
|
+
queryset = Transaction.objects.all()
|
41
|
+
serializer_class = TransactionSerializer
|
42
|
+
permission_classes = [permissions.IsAuthenticated]
|
43
|
+
filter_backends = [DjangoFilterBackend]
|
44
|
+
filterset_fields = ['transaction_type', 'payment', 'subscription']
|
45
|
+
|
46
|
+
def get_queryset(self):
|
47
|
+
"""Filter by current user."""
|
48
|
+
return Transaction.objects.filter(user=self.request.user).order_by('-created_at')
|
49
|
+
|
50
|
+
def get_serializer_class(self):
|
51
|
+
"""Use list serializer for list action."""
|
52
|
+
if self.action == 'list':
|
53
|
+
return TransactionListSerializer
|
54
|
+
return TransactionSerializer
|
55
|
+
|
56
|
+
@action(detail=False, methods=['get'])
|
57
|
+
def summary(self, request):
|
58
|
+
"""Get transaction summary."""
|
59
|
+
queryset = self.get_queryset()
|
60
|
+
|
61
|
+
total_earned = sum(
|
62
|
+
t.amount_usd for t in queryset
|
63
|
+
if t.amount_usd > 0
|
64
|
+
)
|
65
|
+
total_spent = sum(
|
66
|
+
abs(t.amount_usd) for t in queryset
|
67
|
+
if t.amount_usd < 0
|
68
|
+
)
|
69
|
+
|
70
|
+
return Response({
|
71
|
+
'total_transactions': queryset.count(),
|
72
|
+
'total_earned': total_earned,
|
73
|
+
'total_spent': total_spent,
|
74
|
+
'net_balance': total_earned - total_spent
|
75
|
+
})
|