django-cfg 1.2.21__py3-none-any.whl → 1.2.23__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) 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/admin/__init__.py +23 -0
  5. django_cfg/apps/payments/admin/api_keys_admin.py +347 -0
  6. django_cfg/apps/payments/admin/balance_admin.py +434 -0
  7. django_cfg/apps/payments/admin/currencies_admin.py +186 -0
  8. django_cfg/apps/payments/admin/filters.py +259 -0
  9. django_cfg/apps/payments/admin/payments_admin.py +142 -0
  10. django_cfg/apps/payments/admin/subscriptions_admin.py +227 -0
  11. django_cfg/apps/payments/admin/tariffs_admin.py +199 -0
  12. django_cfg/apps/payments/apps.py +22 -0
  13. django_cfg/apps/payments/config/__init__.py +87 -0
  14. django_cfg/apps/payments/config/module.py +162 -0
  15. django_cfg/apps/payments/config/providers.py +93 -0
  16. django_cfg/apps/payments/config/settings.py +136 -0
  17. django_cfg/apps/payments/config/utils.py +198 -0
  18. django_cfg/apps/payments/decorators.py +291 -0
  19. django_cfg/apps/payments/managers/__init__.py +22 -0
  20. django_cfg/apps/payments/managers/api_key_manager.py +35 -0
  21. django_cfg/apps/payments/managers/balance_manager.py +361 -0
  22. django_cfg/apps/payments/managers/currency_manager.py +32 -0
  23. django_cfg/apps/payments/managers/payment_manager.py +44 -0
  24. django_cfg/apps/payments/managers/subscription_manager.py +37 -0
  25. django_cfg/apps/payments/managers/tariff_manager.py +29 -0
  26. django_cfg/apps/payments/middleware/__init__.py +13 -0
  27. django_cfg/apps/payments/middleware/api_access.py +261 -0
  28. django_cfg/apps/payments/middleware/rate_limiting.py +216 -0
  29. django_cfg/apps/payments/middleware/usage_tracking.py +296 -0
  30. django_cfg/apps/payments/migrations/0001_initial.py +1003 -0
  31. django_cfg/apps/payments/migrations/__init__.py +1 -0
  32. django_cfg/apps/payments/models/__init__.py +67 -0
  33. django_cfg/apps/payments/models/api_keys.py +96 -0
  34. django_cfg/apps/payments/models/balance.py +209 -0
  35. django_cfg/apps/payments/models/base.py +30 -0
  36. django_cfg/apps/payments/models/currencies.py +138 -0
  37. django_cfg/apps/payments/models/events.py +73 -0
  38. django_cfg/apps/payments/models/payments.py +301 -0
  39. django_cfg/apps/payments/models/subscriptions.py +270 -0
  40. django_cfg/apps/payments/models/tariffs.py +102 -0
  41. django_cfg/apps/payments/serializers/__init__.py +56 -0
  42. django_cfg/apps/payments/serializers/api_keys.py +51 -0
  43. django_cfg/apps/payments/serializers/balance.py +59 -0
  44. django_cfg/apps/payments/serializers/currencies.py +55 -0
  45. django_cfg/apps/payments/serializers/payments.py +62 -0
  46. django_cfg/apps/payments/serializers/subscriptions.py +71 -0
  47. django_cfg/apps/payments/serializers/tariffs.py +56 -0
  48. django_cfg/apps/payments/services/__init__.py +65 -0
  49. django_cfg/apps/payments/services/billing/__init__.py +8 -0
  50. django_cfg/apps/payments/services/cache/__init__.py +15 -0
  51. django_cfg/apps/payments/services/cache/base.py +30 -0
  52. django_cfg/apps/payments/services/cache/simple_cache.py +135 -0
  53. django_cfg/apps/payments/services/core/__init__.py +17 -0
  54. django_cfg/apps/payments/services/core/balance_service.py +449 -0
  55. django_cfg/apps/payments/services/core/payment_service.py +393 -0
  56. django_cfg/apps/payments/services/core/subscription_service.py +616 -0
  57. django_cfg/apps/payments/services/internal_types.py +266 -0
  58. django_cfg/apps/payments/services/middleware/__init__.py +8 -0
  59. django_cfg/apps/payments/services/providers/__init__.py +19 -0
  60. django_cfg/apps/payments/services/providers/base.py +137 -0
  61. django_cfg/apps/payments/services/providers/cryptapi.py +262 -0
  62. django_cfg/apps/payments/services/providers/nowpayments.py +293 -0
  63. django_cfg/apps/payments/services/providers/registry.py +99 -0
  64. django_cfg/apps/payments/services/validators/__init__.py +8 -0
  65. django_cfg/apps/payments/signals/__init__.py +13 -0
  66. django_cfg/apps/payments/signals/api_key_signals.py +150 -0
  67. django_cfg/apps/payments/signals/payment_signals.py +127 -0
  68. django_cfg/apps/payments/signals/subscription_signals.py +196 -0
  69. django_cfg/apps/payments/urls.py +78 -0
  70. django_cfg/apps/payments/utils/__init__.py +42 -0
  71. django_cfg/apps/payments/utils/config_utils.py +243 -0
  72. django_cfg/apps/payments/utils/middleware_utils.py +228 -0
  73. django_cfg/apps/payments/utils/validation_utils.py +94 -0
  74. django_cfg/apps/payments/views/__init__.py +62 -0
  75. django_cfg/apps/payments/views/api_key_views.py +164 -0
  76. django_cfg/apps/payments/views/balance_views.py +75 -0
  77. django_cfg/apps/payments/views/currency_views.py +111 -0
  78. django_cfg/apps/payments/views/payment_views.py +111 -0
  79. django_cfg/apps/payments/views/subscription_views.py +135 -0
  80. django_cfg/apps/payments/views/tariff_views.py +131 -0
  81. django_cfg/apps/support/signals.py +16 -4
  82. django_cfg/apps/support/templates/support/chat/ticket_chat.html +1 -1
  83. django_cfg/core/config.py +6 -0
  84. django_cfg/models/revolution.py +14 -0
  85. django_cfg/modules/base.py +9 -0
  86. django_cfg/modules/django_email.py +42 -4
  87. django_cfg/modules/django_unfold/dashboard.py +20 -0
  88. {django_cfg-1.2.21.dist-info → django_cfg-1.2.23.dist-info}/METADATA +2 -1
  89. {django_cfg-1.2.21.dist-info → django_cfg-1.2.23.dist-info}/RECORD +92 -14
  90. {django_cfg-1.2.21.dist-info → django_cfg-1.2.23.dist-info}/WHEEL +0 -0
  91. {django_cfg-1.2.21.dist-info → django_cfg-1.2.23.dist-info}/entry_points.txt +0 -0
  92. {django_cfg-1.2.21.dist-info → django_cfg-1.2.23.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,616 @@
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='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='basic',
109
+ status='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='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
+ # Update billing period if provided
397
+ if billing_period:
398
+ subscription.billing_period = billing_period
399
+
400
+ # Calculate new expiry
401
+ now = timezone.now()
402
+ # Always renew for monthly (30 days) as we don't have billing_period field
403
+ new_expiry = now + timedelta(days=30)
404
+
405
+ # Update subscription
406
+ subscription.expires_at = new_expiry
407
+ subscription.next_billing = new_expiry # Model uses next_billing, not next_billing_at
408
+ subscription.status = 'active' # Use string instead of enum
409
+ subscription.usage_current = 0 # Reset usage - correct field name
410
+ subscription.save()
411
+
412
+
413
+ return SubscriptionResult(
414
+ success=True,
415
+ subscription_id=str(subscription.id),
416
+ expires_at=subscription.expires_at
417
+ )
418
+
419
+ except Exception as e:
420
+ logger.error(f"Subscription renewal failed: {e}", exc_info=True)
421
+ return SubscriptionResult(
422
+ success=False,
423
+ error_code='INTERNAL_ERROR',
424
+ error_message=f"Renewal failed: {str(e)}"
425
+ )
426
+
427
+
428
+ def get_subscription_analytics(
429
+ self,
430
+ user: User,
431
+ start_date: Optional[datetime] = None,
432
+ end_date: Optional[datetime] = None
433
+ ) -> Dict[str, Any]:
434
+ """
435
+ Get subscription analytics for user.
436
+
437
+ Args:
438
+ user: User object
439
+ start_date: Analytics start date
440
+ end_date: Analytics end date
441
+
442
+ Returns:
443
+ Analytics data dictionary
444
+ """
445
+ try:
446
+ if not start_date:
447
+ start_date = timezone.now() - timedelta(days=30)
448
+ if not end_date:
449
+ end_date = timezone.now()
450
+
451
+ # Get subscriptions in date range
452
+ subscriptions = Subscription.objects.filter(
453
+ user=user,
454
+ created_at__gte=start_date,
455
+ created_at__lte=end_date
456
+ ).select_related('endpoint_group')
457
+
458
+ # Calculate analytics
459
+ total_subscriptions = subscriptions.count()
460
+ active_subscriptions = subscriptions.filter(
461
+ status=Subscription.Status.ACTIVE,
462
+ expires_at__gt=timezone.now()
463
+ ).count()
464
+
465
+ usage_by_endpoint = {}
466
+ for sub in subscriptions:
467
+ endpoint_name = sub.endpoint_group.name
468
+ if endpoint_name not in usage_by_endpoint:
469
+ usage_by_endpoint[endpoint_name] = {
470
+ 'usage': 0,
471
+ 'limit': 0,
472
+ 'percentage': 0
473
+ }
474
+ usage_by_endpoint[endpoint_name]['usage'] += sub.current_usage
475
+ usage_by_endpoint[endpoint_name]['limit'] += sub.get_monthly_limit()
476
+
477
+ # Calculate usage percentages
478
+ for endpoint_data in usage_by_endpoint.values():
479
+ if endpoint_data['limit'] > 0:
480
+ endpoint_data['percentage'] = (endpoint_data['usage'] / endpoint_data['limit']) * 100
481
+
482
+ return {
483
+ 'period': {
484
+ 'start_date': start_date.isoformat(),
485
+ 'end_date': end_date.isoformat()
486
+ },
487
+ 'summary': {
488
+ 'total_subscriptions': total_subscriptions,
489
+ 'active_subscriptions': active_subscriptions,
490
+ 'cancelled_subscriptions': subscriptions.filter(
491
+ status=Subscription.Status.CANCELLED
492
+ ).count()
493
+ },
494
+ 'usage_by_endpoint': usage_by_endpoint,
495
+ 'total_usage': sum(data['usage'] for data in usage_by_endpoint.values()),
496
+ 'total_limit': sum(data['limit'] for data in usage_by_endpoint.values())
497
+ }
498
+
499
+ except Exception as e:
500
+ logger.error(f"Analytics calculation failed for user {user.id}: {e}")
501
+ return {
502
+ 'error': str(e),
503
+ 'period': {
504
+ 'start_date': start_date.isoformat() if start_date else None,
505
+ 'end_date': end_date.isoformat() if end_date else None
506
+ }
507
+ }
508
+
509
+ def check_access(
510
+ self,
511
+ user_id: int,
512
+ endpoint_group: str,
513
+ increment_usage: bool = False
514
+ ) -> Dict[str, Any]:
515
+ """
516
+ Check if user has access to endpoint group.
517
+
518
+ Args:
519
+ user_id: User ID
520
+ endpoint_group: Endpoint group name
521
+ increment_usage: Whether to increment usage count
522
+
523
+ Returns:
524
+ Access check result
525
+ """
526
+ try:
527
+ subscription = Subscription.objects.select_related('endpoint_group').get(
528
+ user_id=user_id,
529
+ endpoint_group__name=endpoint_group,
530
+ status='active',
531
+ expires_at__gt=timezone.now()
532
+ )
533
+
534
+ # Check usage limit
535
+ if subscription.usage_limit and subscription.usage_current >= subscription.usage_limit:
536
+ return {
537
+ 'has_access': False,
538
+ 'reason': 'usage_limit_exceeded',
539
+ 'usage_current': subscription.usage_current,
540
+ 'usage_limit': subscription.usage_limit
541
+ }
542
+
543
+ # Increment usage if requested
544
+ if increment_usage:
545
+ subscription.usage_current += 1
546
+ subscription.save(update_fields=['usage_current'])
547
+
548
+ return {
549
+ 'has_access': True,
550
+ 'subscription_id': str(subscription.id),
551
+ 'usage_current': subscription.usage_current,
552
+ 'usage_limit': subscription.usage_limit,
553
+ 'remaining_requests': subscription.usage_limit - subscription.usage_current if subscription.usage_limit else None
554
+ }
555
+
556
+ except Subscription.DoesNotExist:
557
+ return {
558
+ 'has_access': False,
559
+ 'reason': 'no_active_subscription'
560
+ }
561
+ except Exception as e:
562
+ logger.error(f"Error checking access for user {user_id}, endpoint {endpoint_group}: {e}")
563
+ return {
564
+ 'has_access': False,
565
+ 'reason': 'internal_error'
566
+ }
567
+
568
+ def increment_usage(
569
+ self,
570
+ user_id: int,
571
+ endpoint_group: str,
572
+ amount: int = 1
573
+ ) -> 'ServiceOperationResult':
574
+ """
575
+ Increment usage for user's subscription.
576
+
577
+ Args:
578
+ user_id: User ID
579
+ endpoint_group: Endpoint group name
580
+ amount: Amount to increment
581
+
582
+ Returns:
583
+ Usage increment result
584
+ """
585
+ try:
586
+ subscription = Subscription.objects.select_related('endpoint_group').get(
587
+ user_id=user_id,
588
+ endpoint_group__name=endpoint_group,
589
+ status='active',
590
+ expires_at__gt=timezone.now()
591
+ )
592
+
593
+ subscription.usage_current += amount
594
+ subscription.save(update_fields=['usage_current'])
595
+
596
+
597
+ return ServiceOperationResult(
598
+ success=True,
599
+ data={
600
+ 'usage_current': subscription.usage_current,
601
+ 'usage_limit': subscription.usage_limit,
602
+ 'remaining_requests': subscription.usage_limit - subscription.usage_current if subscription.usage_limit else None
603
+ }
604
+ )
605
+
606
+ except Subscription.DoesNotExist:
607
+ return ServiceOperationResult(
608
+ success=False,
609
+ error_message='no_active_subscription'
610
+ )
611
+ except Exception as e:
612
+ logger.error(f"Error incrementing usage for user {user_id}, endpoint {endpoint_group}: {e}")
613
+ return ServiceOperationResult(
614
+ success=False,
615
+ error_message='internal_error'
616
+ )