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,614 @@
|
|
1
|
+
"""
|
2
|
+
Subscription Service - Core subscription management and access control.
|
3
|
+
|
4
|
+
This service handles subscription creation, renewal, access validation,
|
5
|
+
and usage tracking with Redis caching.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import logging
|
9
|
+
from typing import Dict, Any, Optional, List
|
10
|
+
from datetime import datetime, timedelta, timezone as dt_timezone
|
11
|
+
|
12
|
+
from django.db import transaction
|
13
|
+
from django.contrib.auth import get_user_model
|
14
|
+
from django.utils import timezone
|
15
|
+
from pydantic import BaseModel, Field, ValidationError
|
16
|
+
|
17
|
+
|
18
|
+
from ...models import Subscription, EndpointGroup, Tariff
|
19
|
+
from ..internal_types import ServiceOperationResult
|
20
|
+
|
21
|
+
User = get_user_model()
|
22
|
+
logger = logging.getLogger(__name__)
|
23
|
+
|
24
|
+
|
25
|
+
class SubscriptionRequest(BaseModel):
|
26
|
+
"""Type-safe subscription request"""
|
27
|
+
user_id: int = Field(gt=0, description="User ID")
|
28
|
+
endpoint_group_name: str = Field(min_length=1, description="Endpoint group name")
|
29
|
+
tariff_id: Optional[str] = Field(None, description="Specific tariff ID")
|
30
|
+
billing_period: str = Field(default='monthly', pattern='^(monthly|yearly)$', description="Billing period")
|
31
|
+
auto_renew: bool = Field(default=True, description="Auto-renewal setting")
|
32
|
+
metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
|
33
|
+
|
34
|
+
|
35
|
+
class SubscriptionResult(BaseModel):
|
36
|
+
"""Type-safe subscription operation result"""
|
37
|
+
success: bool
|
38
|
+
subscription_id: Optional[str] = None
|
39
|
+
endpoint_group_id: Optional[str] = None
|
40
|
+
expires_at: Optional[datetime] = None
|
41
|
+
error_message: Optional[str] = None
|
42
|
+
error_code: Optional[str] = None
|
43
|
+
|
44
|
+
|
45
|
+
class AccessCheck(BaseModel):
|
46
|
+
"""Type-safe access check result"""
|
47
|
+
allowed: bool
|
48
|
+
subscription_id: Optional[str] = None
|
49
|
+
reason: Optional[str] = None
|
50
|
+
remaining_requests: Optional[int] = None
|
51
|
+
usage_percentage: Optional[float] = None
|
52
|
+
required_subscription: Optional[str] = None
|
53
|
+
current_usage: Optional[int] = None
|
54
|
+
monthly_limit: Optional[int] = None
|
55
|
+
|
56
|
+
|
57
|
+
class SubscriptionService:
|
58
|
+
"""
|
59
|
+
Universal subscription management service.
|
60
|
+
|
61
|
+
Handles subscription lifecycle, access control, and usage tracking
|
62
|
+
with support for multiple active subscriptions per user.
|
63
|
+
"""
|
64
|
+
|
65
|
+
def __init__(self):
|
66
|
+
"""Initialize subscription service with dependencies"""
|
67
|
+
pass
|
68
|
+
|
69
|
+
def create_subscription(self, subscription_data: dict) -> 'ServiceOperationResult':
|
70
|
+
"""
|
71
|
+
Create new subscription for user.
|
72
|
+
|
73
|
+
Args:
|
74
|
+
subscription_data: Dictionary with subscription details
|
75
|
+
|
76
|
+
Returns:
|
77
|
+
ServiceOperationResult with subscription details
|
78
|
+
"""
|
79
|
+
try:
|
80
|
+
# Get user
|
81
|
+
user = User.objects.get(id=subscription_data['user_id'])
|
82
|
+
|
83
|
+
# Get endpoint group
|
84
|
+
endpoint_group = EndpointGroup.objects.get(
|
85
|
+
name=subscription_data['endpoint_group_name'],
|
86
|
+
is_active=True
|
87
|
+
)
|
88
|
+
|
89
|
+
with transaction.atomic():
|
90
|
+
# Check for existing active subscription
|
91
|
+
existing = Subscription.objects.filter(
|
92
|
+
user=user,
|
93
|
+
endpoint_group=endpoint_group,
|
94
|
+
status=Subscription.SubscriptionStatus.ACTIVE,
|
95
|
+
expires_at__gt=timezone.now()
|
96
|
+
).first()
|
97
|
+
|
98
|
+
if existing:
|
99
|
+
return ServiceOperationResult(
|
100
|
+
success=False,
|
101
|
+
error_message=f"User already has active subscription for '{subscription_data['endpoint_group_name']}'"
|
102
|
+
)
|
103
|
+
|
104
|
+
# Create subscription
|
105
|
+
subscription = Subscription.objects.create(
|
106
|
+
user=user,
|
107
|
+
endpoint_group=endpoint_group,
|
108
|
+
tier=Subscription.SubscriptionTier.BASIC,
|
109
|
+
status=Subscription.SubscriptionStatus.ACTIVE,
|
110
|
+
monthly_price=endpoint_group.basic_price,
|
111
|
+
usage_limit=endpoint_group.basic_limit,
|
112
|
+
usage_current=0,
|
113
|
+
expires_at=timezone.now() + timedelta(days=30),
|
114
|
+
next_billing=timezone.now() + timedelta(days=30)
|
115
|
+
)
|
116
|
+
|
117
|
+
# Log subscription creation
|
118
|
+
logger.info(
|
119
|
+
f"New subscription created: {subscription_data['endpoint_group_name']} "
|
120
|
+
f"for user {user.email} (expires: {subscription.expires_at})"
|
121
|
+
)
|
122
|
+
|
123
|
+
|
124
|
+
return ServiceOperationResult(
|
125
|
+
success=True,
|
126
|
+
data={'subscription_id': str(subscription.id)}
|
127
|
+
)
|
128
|
+
|
129
|
+
except Exception as e:
|
130
|
+
logger.error(f"Subscription creation failed: {e}", exc_info=True)
|
131
|
+
|
132
|
+
return ServiceOperationResult(
|
133
|
+
success=False,
|
134
|
+
error_message=f"Internal error: {str(e)}"
|
135
|
+
)
|
136
|
+
|
137
|
+
def check_endpoint_access(
|
138
|
+
self,
|
139
|
+
user: User,
|
140
|
+
endpoint_group_name: str,
|
141
|
+
use_cache: bool = True
|
142
|
+
) -> AccessCheck:
|
143
|
+
"""
|
144
|
+
Check if user has access to endpoint group.
|
145
|
+
|
146
|
+
Args:
|
147
|
+
user: User object
|
148
|
+
endpoint_group_name: Name of endpoint group
|
149
|
+
use_cache: Whether to use Redis cache
|
150
|
+
|
151
|
+
Returns:
|
152
|
+
AccessCheck with access status and details
|
153
|
+
"""
|
154
|
+
try:
|
155
|
+
# Try cache first
|
156
|
+
if use_cache:
|
157
|
+
cache_key = f"access:{user.id}:{endpoint_group_name}"
|
158
|
+
cached = self.cache.get_cache(cache_key)
|
159
|
+
if cached:
|
160
|
+
return AccessCheck(**cached)
|
161
|
+
|
162
|
+
# Check active subscription
|
163
|
+
subscription = Subscription.objects.filter(
|
164
|
+
user=user,
|
165
|
+
endpoint_group__name=endpoint_group_name,
|
166
|
+
status=Subscription.Status.ACTIVE,
|
167
|
+
expires_at__gt=timezone.now()
|
168
|
+
).select_related('endpoint_group', 'tariff').first()
|
169
|
+
|
170
|
+
if not subscription:
|
171
|
+
result = AccessCheck(
|
172
|
+
allowed=False,
|
173
|
+
reason='no_active_subscription',
|
174
|
+
required_subscription=endpoint_group_name
|
175
|
+
)
|
176
|
+
elif not subscription.can_make_request():
|
177
|
+
result = AccessCheck(
|
178
|
+
allowed=False,
|
179
|
+
reason='usage_limit_exceeded',
|
180
|
+
subscription_id=str(subscription.id),
|
181
|
+
current_usage=subscription.current_usage,
|
182
|
+
monthly_limit=subscription.get_monthly_limit()
|
183
|
+
)
|
184
|
+
else:
|
185
|
+
result = AccessCheck(
|
186
|
+
allowed=True,
|
187
|
+
subscription_id=str(subscription.id),
|
188
|
+
remaining_requests=subscription.remaining_requests(),
|
189
|
+
usage_percentage=subscription.usage_percentage
|
190
|
+
)
|
191
|
+
|
192
|
+
# Cache result for 1 minute
|
193
|
+
if use_cache:
|
194
|
+
cache_data = result.dict()
|
195
|
+
self.cache.set_cache(f"access:{user.id}:{endpoint_group_name}", cache_data, ttl=60)
|
196
|
+
|
197
|
+
return result
|
198
|
+
|
199
|
+
except Exception as e:
|
200
|
+
logger.error(f"Access check failed for user {user.id}, endpoint {endpoint_group_name}: {e}")
|
201
|
+
return AccessCheck(
|
202
|
+
allowed=False,
|
203
|
+
reason='check_failed'
|
204
|
+
)
|
205
|
+
|
206
|
+
def record_api_usage(
|
207
|
+
self,
|
208
|
+
user: User,
|
209
|
+
endpoint_group_name: str,
|
210
|
+
usage_count: int = 1
|
211
|
+
) -> bool:
|
212
|
+
"""
|
213
|
+
Record API usage for user's subscription.
|
214
|
+
|
215
|
+
Args:
|
216
|
+
user: User object
|
217
|
+
endpoint_group_name: Name of endpoint group
|
218
|
+
usage_count: Number of requests to record
|
219
|
+
|
220
|
+
Returns:
|
221
|
+
True if usage was recorded, False otherwise
|
222
|
+
"""
|
223
|
+
try:
|
224
|
+
with transaction.atomic():
|
225
|
+
subscription = Subscription.objects.filter(
|
226
|
+
user=user,
|
227
|
+
endpoint_group__name=endpoint_group_name,
|
228
|
+
status=Subscription.Status.ACTIVE,
|
229
|
+
expires_at__gt=timezone.now()
|
230
|
+
).first()
|
231
|
+
|
232
|
+
if not subscription:
|
233
|
+
logger.warning(f"No active subscription found for user {user.id}, endpoint {endpoint_group_name}")
|
234
|
+
return False
|
235
|
+
|
236
|
+
# Update usage
|
237
|
+
subscription.current_usage += usage_count
|
238
|
+
subscription.save(update_fields=['current_usage', 'updated_at'])
|
239
|
+
|
240
|
+
# Invalidate access cache
|
241
|
+
self.cache.delete_key(f"access:{user.id}:{endpoint_group_name}")
|
242
|
+
|
243
|
+
return True
|
244
|
+
|
245
|
+
except Exception as e:
|
246
|
+
logger.error(f"Usage recording failed for user {user.id}: {e}")
|
247
|
+
return False
|
248
|
+
|
249
|
+
def get_user_subscriptions(
|
250
|
+
self,
|
251
|
+
user_id: int,
|
252
|
+
active_only: bool = True
|
253
|
+
) -> List['SubscriptionInfo']:
|
254
|
+
"""
|
255
|
+
Get user's subscriptions.
|
256
|
+
|
257
|
+
Args:
|
258
|
+
user_id: User ID
|
259
|
+
active_only: Return only active subscriptions
|
260
|
+
|
261
|
+
Returns:
|
262
|
+
List of subscription dictionaries
|
263
|
+
"""
|
264
|
+
try:
|
265
|
+
|
266
|
+
# Query subscriptions
|
267
|
+
queryset = Subscription.objects.filter(user_id=user_id)
|
268
|
+
|
269
|
+
if active_only:
|
270
|
+
queryset = queryset.filter(
|
271
|
+
status=Subscription.SubscriptionStatus.ACTIVE,
|
272
|
+
expires_at__gt=timezone.now()
|
273
|
+
)
|
274
|
+
|
275
|
+
subscriptions = queryset.select_related(
|
276
|
+
'endpoint_group'
|
277
|
+
).order_by('-created_at')
|
278
|
+
|
279
|
+
from ..internal_types import SubscriptionInfo, EndpointGroupInfo
|
280
|
+
from decimal import Decimal
|
281
|
+
|
282
|
+
result = [
|
283
|
+
SubscriptionInfo(
|
284
|
+
id=str(sub.id),
|
285
|
+
endpoint_group=EndpointGroupInfo(
|
286
|
+
id=str(sub.endpoint_group.id),
|
287
|
+
name=sub.endpoint_group.name,
|
288
|
+
display_name=sub.endpoint_group.display_name
|
289
|
+
),
|
290
|
+
status=sub.status,
|
291
|
+
tier=sub.tier,
|
292
|
+
monthly_price=Decimal(str(sub.monthly_price)),
|
293
|
+
usage_current=sub.usage_current,
|
294
|
+
usage_limit=sub.usage_limit,
|
295
|
+
usage_percentage=sub.usage_current / sub.usage_limit if sub.usage_limit else 0.0,
|
296
|
+
remaining_requests=sub.usage_limit - sub.usage_current if sub.usage_limit else 0,
|
297
|
+
expires_at=sub.expires_at,
|
298
|
+
next_billing=sub.next_billing,
|
299
|
+
created_at=sub.created_at
|
300
|
+
)
|
301
|
+
for sub in subscriptions
|
302
|
+
]
|
303
|
+
|
304
|
+
|
305
|
+
return result
|
306
|
+
|
307
|
+
except Exception as e:
|
308
|
+
logger.error(f"Error getting subscriptions for user {user_id}: {e}")
|
309
|
+
return []
|
310
|
+
|
311
|
+
def cancel_subscription(
|
312
|
+
self,
|
313
|
+
user: User,
|
314
|
+
subscription_id: str,
|
315
|
+
reason: str = 'user_request'
|
316
|
+
) -> SubscriptionResult:
|
317
|
+
"""
|
318
|
+
Cancel user subscription.
|
319
|
+
|
320
|
+
Args:
|
321
|
+
user: User object
|
322
|
+
subscription_id: Subscription UUID
|
323
|
+
reason: Cancellation reason
|
324
|
+
|
325
|
+
Returns:
|
326
|
+
SubscriptionResult with cancellation status
|
327
|
+
"""
|
328
|
+
try:
|
329
|
+
with transaction.atomic():
|
330
|
+
subscription = Subscription.objects.filter(
|
331
|
+
id=subscription_id,
|
332
|
+
user=user,
|
333
|
+
status=Subscription.Status.ACTIVE
|
334
|
+
).first()
|
335
|
+
|
336
|
+
if not subscription:
|
337
|
+
return SubscriptionResult(
|
338
|
+
success=False,
|
339
|
+
error_code='SUBSCRIPTION_NOT_FOUND',
|
340
|
+
error_message="Active subscription not found"
|
341
|
+
)
|
342
|
+
|
343
|
+
# Cancel subscription
|
344
|
+
subscription.status = Subscription.Status.CANCELLED
|
345
|
+
subscription.auto_renew = False
|
346
|
+
subscription.next_billing_at = None
|
347
|
+
subscription.metadata = {
|
348
|
+
**subscription.metadata,
|
349
|
+
'cancellation_reason': reason,
|
350
|
+
'cancelled_at': timezone.now().isoformat()
|
351
|
+
}
|
352
|
+
subscription.save()
|
353
|
+
|
354
|
+
|
355
|
+
return SubscriptionResult(
|
356
|
+
success=True,
|
357
|
+
subscription_id=str(subscription.id)
|
358
|
+
)
|
359
|
+
|
360
|
+
except Exception as e:
|
361
|
+
logger.error(f"Subscription cancellation failed: {e}", exc_info=True)
|
362
|
+
return SubscriptionResult(
|
363
|
+
success=False,
|
364
|
+
error_code='INTERNAL_ERROR',
|
365
|
+
error_message=f"Cancellation failed: {str(e)}"
|
366
|
+
)
|
367
|
+
|
368
|
+
def renew_subscription(
|
369
|
+
self,
|
370
|
+
subscription_id: str,
|
371
|
+
billing_period: Optional[str] = None
|
372
|
+
) -> SubscriptionResult:
|
373
|
+
"""
|
374
|
+
Renew expired or expiring subscription.
|
375
|
+
|
376
|
+
Args:
|
377
|
+
subscription_id: Subscription UUID
|
378
|
+
billing_period: New billing period (optional)
|
379
|
+
|
380
|
+
Returns:
|
381
|
+
SubscriptionResult with renewal status
|
382
|
+
"""
|
383
|
+
try:
|
384
|
+
with transaction.atomic():
|
385
|
+
subscription = Subscription.objects.filter(
|
386
|
+
id=subscription_id
|
387
|
+
).first()
|
388
|
+
|
389
|
+
if not subscription:
|
390
|
+
return SubscriptionResult(
|
391
|
+
success=False,
|
392
|
+
error_code='SUBSCRIPTION_NOT_FOUND',
|
393
|
+
error_message="Subscription not found"
|
394
|
+
)
|
395
|
+
|
396
|
+
# Calculate new expiry based on billing period
|
397
|
+
now = timezone.now()
|
398
|
+
if billing_period == 'yearly':
|
399
|
+
new_expiry = now + timedelta(days=365)
|
400
|
+
else: # Default to monthly
|
401
|
+
new_expiry = now + timedelta(days=30)
|
402
|
+
|
403
|
+
# Update subscription using correct enum
|
404
|
+
subscription.expires_at = new_expiry
|
405
|
+
subscription.next_billing = new_expiry
|
406
|
+
subscription.status = subscription.SubscriptionStatus.ACTIVE # Use proper enum
|
407
|
+
subscription.usage_current = 0 # Reset usage counter
|
408
|
+
subscription.save()
|
409
|
+
|
410
|
+
|
411
|
+
return SubscriptionResult(
|
412
|
+
success=True,
|
413
|
+
subscription_id=str(subscription.id),
|
414
|
+
expires_at=subscription.expires_at
|
415
|
+
)
|
416
|
+
|
417
|
+
except Exception as e:
|
418
|
+
logger.error(f"Subscription renewal failed: {e}", exc_info=True)
|
419
|
+
return SubscriptionResult(
|
420
|
+
success=False,
|
421
|
+
error_code='INTERNAL_ERROR',
|
422
|
+
error_message=f"Renewal failed: {str(e)}"
|
423
|
+
)
|
424
|
+
|
425
|
+
|
426
|
+
def get_subscription_analytics(
|
427
|
+
self,
|
428
|
+
user: User,
|
429
|
+
start_date: Optional[datetime] = None,
|
430
|
+
end_date: Optional[datetime] = None
|
431
|
+
) -> Dict[str, Any]:
|
432
|
+
"""
|
433
|
+
Get subscription analytics for user.
|
434
|
+
|
435
|
+
Args:
|
436
|
+
user: User object
|
437
|
+
start_date: Analytics start date
|
438
|
+
end_date: Analytics end date
|
439
|
+
|
440
|
+
Returns:
|
441
|
+
Analytics data dictionary
|
442
|
+
"""
|
443
|
+
try:
|
444
|
+
if not start_date:
|
445
|
+
start_date = timezone.now() - timedelta(days=30)
|
446
|
+
if not end_date:
|
447
|
+
end_date = timezone.now()
|
448
|
+
|
449
|
+
# Get subscriptions in date range
|
450
|
+
subscriptions = Subscription.objects.filter(
|
451
|
+
user=user,
|
452
|
+
created_at__gte=start_date,
|
453
|
+
created_at__lte=end_date
|
454
|
+
).select_related('endpoint_group')
|
455
|
+
|
456
|
+
# Calculate analytics
|
457
|
+
total_subscriptions = subscriptions.count()
|
458
|
+
active_subscriptions = subscriptions.filter(
|
459
|
+
status=Subscription.Status.ACTIVE,
|
460
|
+
expires_at__gt=timezone.now()
|
461
|
+
).count()
|
462
|
+
|
463
|
+
usage_by_endpoint = {}
|
464
|
+
for sub in subscriptions:
|
465
|
+
endpoint_name = sub.endpoint_group.name
|
466
|
+
if endpoint_name not in usage_by_endpoint:
|
467
|
+
usage_by_endpoint[endpoint_name] = {
|
468
|
+
'usage': 0,
|
469
|
+
'limit': 0,
|
470
|
+
'percentage': 0
|
471
|
+
}
|
472
|
+
usage_by_endpoint[endpoint_name]['usage'] += sub.current_usage
|
473
|
+
usage_by_endpoint[endpoint_name]['limit'] += sub.get_monthly_limit()
|
474
|
+
|
475
|
+
# Calculate usage percentages
|
476
|
+
for endpoint_data in usage_by_endpoint.values():
|
477
|
+
if endpoint_data['limit'] > 0:
|
478
|
+
endpoint_data['percentage'] = (endpoint_data['usage'] / endpoint_data['limit']) * 100
|
479
|
+
|
480
|
+
return {
|
481
|
+
'period': {
|
482
|
+
'start_date': start_date.isoformat(),
|
483
|
+
'end_date': end_date.isoformat()
|
484
|
+
},
|
485
|
+
'summary': {
|
486
|
+
'total_subscriptions': total_subscriptions,
|
487
|
+
'active_subscriptions': active_subscriptions,
|
488
|
+
'cancelled_subscriptions': subscriptions.filter(
|
489
|
+
status=Subscription.Status.CANCELLED
|
490
|
+
).count()
|
491
|
+
},
|
492
|
+
'usage_by_endpoint': usage_by_endpoint,
|
493
|
+
'total_usage': sum(data['usage'] for data in usage_by_endpoint.values()),
|
494
|
+
'total_limit': sum(data['limit'] for data in usage_by_endpoint.values())
|
495
|
+
}
|
496
|
+
|
497
|
+
except Exception as e:
|
498
|
+
logger.error(f"Analytics calculation failed for user {user.id}: {e}")
|
499
|
+
return {
|
500
|
+
'error': str(e),
|
501
|
+
'period': {
|
502
|
+
'start_date': start_date.isoformat() if start_date else None,
|
503
|
+
'end_date': end_date.isoformat() if end_date else None
|
504
|
+
}
|
505
|
+
}
|
506
|
+
|
507
|
+
def check_access(
|
508
|
+
self,
|
509
|
+
user_id: int,
|
510
|
+
endpoint_group: str,
|
511
|
+
increment_usage: bool = False
|
512
|
+
) -> Dict[str, Any]:
|
513
|
+
"""
|
514
|
+
Check if user has access to endpoint group.
|
515
|
+
|
516
|
+
Args:
|
517
|
+
user_id: User ID
|
518
|
+
endpoint_group: Endpoint group name
|
519
|
+
increment_usage: Whether to increment usage count
|
520
|
+
|
521
|
+
Returns:
|
522
|
+
Access check result
|
523
|
+
"""
|
524
|
+
try:
|
525
|
+
subscription = Subscription.objects.select_related('endpoint_group').get(
|
526
|
+
user_id=user_id,
|
527
|
+
endpoint_group__name=endpoint_group,
|
528
|
+
status=Subscription.SubscriptionStatus.ACTIVE,
|
529
|
+
expires_at__gt=timezone.now()
|
530
|
+
)
|
531
|
+
|
532
|
+
# Check usage limit
|
533
|
+
if subscription.usage_limit and subscription.usage_current >= subscription.usage_limit:
|
534
|
+
return {
|
535
|
+
'has_access': False,
|
536
|
+
'reason': 'usage_limit_exceeded',
|
537
|
+
'usage_current': subscription.usage_current,
|
538
|
+
'usage_limit': subscription.usage_limit
|
539
|
+
}
|
540
|
+
|
541
|
+
# Increment usage if requested
|
542
|
+
if increment_usage:
|
543
|
+
subscription.usage_current += 1
|
544
|
+
subscription.save(update_fields=['usage_current'])
|
545
|
+
|
546
|
+
return {
|
547
|
+
'has_access': True,
|
548
|
+
'subscription_id': str(subscription.id),
|
549
|
+
'usage_current': subscription.usage_current,
|
550
|
+
'usage_limit': subscription.usage_limit,
|
551
|
+
'remaining_requests': subscription.usage_limit - subscription.usage_current if subscription.usage_limit else None
|
552
|
+
}
|
553
|
+
|
554
|
+
except Subscription.DoesNotExist:
|
555
|
+
return {
|
556
|
+
'has_access': False,
|
557
|
+
'reason': 'no_active_subscription'
|
558
|
+
}
|
559
|
+
except Exception as e:
|
560
|
+
logger.error(f"Error checking access for user {user_id}, endpoint {endpoint_group}: {e}")
|
561
|
+
return {
|
562
|
+
'has_access': False,
|
563
|
+
'reason': 'internal_error'
|
564
|
+
}
|
565
|
+
|
566
|
+
def increment_usage(
|
567
|
+
self,
|
568
|
+
user_id: int,
|
569
|
+
endpoint_group: str,
|
570
|
+
amount: int = 1
|
571
|
+
) -> 'ServiceOperationResult':
|
572
|
+
"""
|
573
|
+
Increment usage for user's subscription.
|
574
|
+
|
575
|
+
Args:
|
576
|
+
user_id: User ID
|
577
|
+
endpoint_group: Endpoint group name
|
578
|
+
amount: Amount to increment
|
579
|
+
|
580
|
+
Returns:
|
581
|
+
Usage increment result
|
582
|
+
"""
|
583
|
+
try:
|
584
|
+
subscription = Subscription.objects.select_related('endpoint_group').get(
|
585
|
+
user_id=user_id,
|
586
|
+
endpoint_group__name=endpoint_group,
|
587
|
+
status=Subscription.SubscriptionStatus.ACTIVE,
|
588
|
+
expires_at__gt=timezone.now()
|
589
|
+
)
|
590
|
+
|
591
|
+
subscription.usage_current += amount
|
592
|
+
subscription.save(update_fields=['usage_current'])
|
593
|
+
|
594
|
+
|
595
|
+
return ServiceOperationResult(
|
596
|
+
success=True,
|
597
|
+
data={
|
598
|
+
'usage_current': subscription.usage_current,
|
599
|
+
'usage_limit': subscription.usage_limit,
|
600
|
+
'remaining_requests': subscription.usage_limit - subscription.usage_current if subscription.usage_limit else None
|
601
|
+
}
|
602
|
+
)
|
603
|
+
|
604
|
+
except Subscription.DoesNotExist:
|
605
|
+
return ServiceOperationResult(
|
606
|
+
success=False,
|
607
|
+
error_message='no_active_subscription'
|
608
|
+
)
|
609
|
+
except Exception as e:
|
610
|
+
logger.error(f"Error incrementing usage for user {user_id}, endpoint {endpoint_group}: {e}")
|
611
|
+
return ServiceOperationResult(
|
612
|
+
success=False,
|
613
|
+
error_message='internal_error'
|
614
|
+
)
|