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.
Files changed (51) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/newsletter/signals.py +9 -8
  3. django_cfg/apps/payments/__init__.py +8 -0
  4. django_cfg/apps/payments/apps.py +22 -0
  5. django_cfg/apps/payments/managers/__init__.py +22 -0
  6. django_cfg/apps/payments/managers/api_key_manager.py +35 -0
  7. django_cfg/apps/payments/managers/balance_manager.py +361 -0
  8. django_cfg/apps/payments/managers/currency_manager.py +32 -0
  9. django_cfg/apps/payments/managers/payment_manager.py +44 -0
  10. django_cfg/apps/payments/managers/subscription_manager.py +37 -0
  11. django_cfg/apps/payments/managers/tariff_manager.py +29 -0
  12. django_cfg/apps/payments/middleware/__init__.py +13 -0
  13. django_cfg/apps/payments/migrations/0001_initial.py +982 -0
  14. django_cfg/apps/payments/migrations/__init__.py +1 -0
  15. django_cfg/apps/payments/models/__init__.py +49 -0
  16. django_cfg/apps/payments/models/api_keys.py +96 -0
  17. django_cfg/apps/payments/models/balance.py +209 -0
  18. django_cfg/apps/payments/models/base.py +14 -0
  19. django_cfg/apps/payments/models/currencies.py +138 -0
  20. django_cfg/apps/payments/models/events.py +73 -0
  21. django_cfg/apps/payments/models/payments.py +301 -0
  22. django_cfg/apps/payments/models/subscriptions.py +270 -0
  23. django_cfg/apps/payments/models/tariffs.py +102 -0
  24. django_cfg/apps/payments/serializers/__init__.py +56 -0
  25. django_cfg/apps/payments/serializers/api_keys.py +51 -0
  26. django_cfg/apps/payments/serializers/balance.py +59 -0
  27. django_cfg/apps/payments/serializers/currencies.py +55 -0
  28. django_cfg/apps/payments/serializers/payments.py +62 -0
  29. django_cfg/apps/payments/serializers/subscriptions.py +71 -0
  30. django_cfg/apps/payments/serializers/tariffs.py +56 -0
  31. django_cfg/apps/payments/services/__init__.py +14 -0
  32. django_cfg/apps/payments/services/base.py +68 -0
  33. django_cfg/apps/payments/services/nowpayments.py +78 -0
  34. django_cfg/apps/payments/services/providers.py +77 -0
  35. django_cfg/apps/payments/services/redis_service.py +215 -0
  36. django_cfg/apps/payments/urls.py +78 -0
  37. django_cfg/apps/payments/views/__init__.py +62 -0
  38. django_cfg/apps/payments/views/api_key_views.py +164 -0
  39. django_cfg/apps/payments/views/balance_views.py +75 -0
  40. django_cfg/apps/payments/views/currency_views.py +111 -0
  41. django_cfg/apps/payments/views/payment_views.py +111 -0
  42. django_cfg/apps/payments/views/subscription_views.py +135 -0
  43. django_cfg/apps/payments/views/tariff_views.py +131 -0
  44. django_cfg/core/config.py +6 -0
  45. django_cfg/models/revolution.py +14 -0
  46. django_cfg/modules/base.py +9 -0
  47. {django_cfg-1.2.21.dist-info → django_cfg-1.2.22.dist-info}/METADATA +1 -1
  48. {django_cfg-1.2.21.dist-info → django_cfg-1.2.22.dist-info}/RECORD +51 -10
  49. {django_cfg-1.2.21.dist-info → django_cfg-1.2.22.dist-info}/WHEEL +0 -0
  50. {django_cfg-1.2.21.dist-info → django_cfg-1.2.22.dist-info}/entry_points.txt +0 -0
  51. {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
+ })