django-cfg 1.2.27__py3-none-any.whl → 1.2.31__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 (138) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/payments/admin/__init__.py +3 -2
  3. django_cfg/apps/payments/admin/balance_admin.py +18 -18
  4. django_cfg/apps/payments/admin/currencies_admin.py +319 -131
  5. django_cfg/apps/payments/admin/payments_admin.py +15 -4
  6. django_cfg/apps/payments/config/module.py +2 -2
  7. django_cfg/apps/payments/config/utils.py +2 -2
  8. django_cfg/apps/payments/decorators.py +2 -2
  9. django_cfg/apps/payments/management/commands/README.md +95 -127
  10. django_cfg/apps/payments/management/commands/currency_stats.py +5 -24
  11. django_cfg/apps/payments/management/commands/manage_currencies.py +229 -0
  12. django_cfg/apps/payments/management/commands/manage_providers.py +235 -0
  13. django_cfg/apps/payments/managers/__init__.py +3 -2
  14. django_cfg/apps/payments/managers/balance_manager.py +2 -2
  15. django_cfg/apps/payments/managers/currency_manager.py +272 -49
  16. django_cfg/apps/payments/managers/payment_manager.py +161 -13
  17. django_cfg/apps/payments/middleware/api_access.py +2 -2
  18. django_cfg/apps/payments/middleware/rate_limiting.py +8 -18
  19. django_cfg/apps/payments/middleware/usage_tracking.py +20 -17
  20. django_cfg/apps/payments/migrations/0002_network_providercurrency_and_more.py +241 -0
  21. django_cfg/apps/payments/migrations/0003_add_usd_rate_cache.py +30 -0
  22. django_cfg/apps/payments/models/__init__.py +3 -2
  23. django_cfg/apps/payments/models/currencies.py +187 -71
  24. django_cfg/apps/payments/models/payments.py +3 -2
  25. django_cfg/apps/payments/serializers/__init__.py +3 -2
  26. django_cfg/apps/payments/serializers/currencies.py +20 -12
  27. django_cfg/apps/payments/services/cache/simple_cache.py +2 -2
  28. django_cfg/apps/payments/services/core/balance_service.py +2 -2
  29. django_cfg/apps/payments/services/core/fallback_service.py +2 -2
  30. django_cfg/apps/payments/services/core/payment_service.py +3 -6
  31. django_cfg/apps/payments/services/core/subscription_service.py +4 -7
  32. django_cfg/apps/payments/services/internal_types.py +171 -7
  33. django_cfg/apps/payments/services/monitoring/api_schemas.py +58 -204
  34. django_cfg/apps/payments/services/monitoring/provider_health.py +2 -2
  35. django_cfg/apps/payments/services/providers/base.py +144 -43
  36. django_cfg/apps/payments/services/providers/cryptapi/__init__.py +4 -0
  37. django_cfg/apps/payments/services/providers/cryptapi/config.py +8 -0
  38. django_cfg/apps/payments/services/providers/cryptapi/models.py +192 -0
  39. django_cfg/apps/payments/services/providers/cryptapi/provider.py +439 -0
  40. django_cfg/apps/payments/services/providers/cryptomus/__init__.py +4 -0
  41. django_cfg/apps/payments/services/providers/cryptomus/models.py +176 -0
  42. django_cfg/apps/payments/services/providers/cryptomus/provider.py +429 -0
  43. django_cfg/apps/payments/services/providers/cryptomus/provider_v2.py +564 -0
  44. django_cfg/apps/payments/services/providers/models/__init__.py +34 -0
  45. django_cfg/apps/payments/services/providers/models/currencies.py +190 -0
  46. django_cfg/apps/payments/services/providers/nowpayments/__init__.py +4 -0
  47. django_cfg/apps/payments/services/providers/nowpayments/models.py +196 -0
  48. django_cfg/apps/payments/services/providers/nowpayments/provider.py +380 -0
  49. django_cfg/apps/payments/services/providers/registry.py +294 -11
  50. django_cfg/apps/payments/services/providers/stripe/__init__.py +4 -0
  51. django_cfg/apps/payments/services/providers/stripe/models.py +184 -0
  52. django_cfg/apps/payments/services/providers/stripe/provider.py +109 -0
  53. django_cfg/apps/payments/services/security/error_handler.py +6 -8
  54. django_cfg/apps/payments/services/security/payment_notifications.py +2 -2
  55. django_cfg/apps/payments/services/security/webhook_validator.py +3 -4
  56. django_cfg/apps/payments/signals/api_key_signals.py +2 -2
  57. django_cfg/apps/payments/signals/payment_signals.py +11 -5
  58. django_cfg/apps/payments/signals/subscription_signals.py +2 -2
  59. django_cfg/apps/payments/static/payments/css/payments.css +340 -0
  60. django_cfg/apps/payments/static/payments/js/notifications.js +202 -0
  61. django_cfg/apps/payments/static/payments/js/payment-utils.js +318 -0
  62. django_cfg/apps/payments/static/payments/js/theme.js +86 -0
  63. django_cfg/apps/payments/tasks/webhook_processing.py +2 -2
  64. django_cfg/apps/payments/templates/admin/payments/currency/change_list.html +50 -0
  65. django_cfg/apps/payments/templates/payments/base.html +182 -0
  66. django_cfg/apps/payments/templates/payments/components/payment_card.html +201 -0
  67. django_cfg/apps/payments/templates/payments/components/payment_qr_code.html +109 -0
  68. django_cfg/apps/payments/templates/payments/components/progress_bar.html +43 -0
  69. django_cfg/apps/payments/templates/payments/components/provider_stats.html +40 -0
  70. django_cfg/apps/payments/templates/payments/components/status_badge.html +34 -0
  71. django_cfg/apps/payments/templates/payments/components/status_overview.html +148 -0
  72. django_cfg/apps/payments/templates/payments/dashboard.html +258 -0
  73. django_cfg/apps/payments/templates/payments/dashboard_simple_test.html +35 -0
  74. django_cfg/apps/payments/templates/payments/payment_create.html +579 -0
  75. django_cfg/apps/payments/templates/payments/payment_detail.html +373 -0
  76. django_cfg/apps/payments/templates/payments/payment_list.html +354 -0
  77. django_cfg/apps/payments/templates/payments/stats.html +261 -0
  78. django_cfg/apps/payments/templates/payments/test.html +213 -0
  79. django_cfg/apps/payments/templatetags/__init__.py +1 -0
  80. django_cfg/apps/payments/templatetags/payments_tags.py +315 -0
  81. django_cfg/apps/payments/urls.py +3 -1
  82. django_cfg/apps/payments/urls_admin.py +58 -0
  83. django_cfg/apps/payments/utils/__init__.py +1 -3
  84. django_cfg/apps/payments/utils/billing_utils.py +2 -2
  85. django_cfg/apps/payments/utils/config_utils.py +2 -8
  86. django_cfg/apps/payments/utils/validation_utils.py +2 -2
  87. django_cfg/apps/payments/views/__init__.py +3 -2
  88. django_cfg/apps/payments/views/currency_views.py +31 -20
  89. django_cfg/apps/payments/views/payment_views.py +2 -2
  90. django_cfg/apps/payments/views/templates/__init__.py +25 -0
  91. django_cfg/apps/payments/views/templates/ajax.py +451 -0
  92. django_cfg/apps/payments/views/templates/base.py +212 -0
  93. django_cfg/apps/payments/views/templates/dashboard.py +60 -0
  94. django_cfg/apps/payments/views/templates/payment_detail.py +102 -0
  95. django_cfg/apps/payments/views/templates/payment_management.py +158 -0
  96. django_cfg/apps/payments/views/templates/qr_code.py +174 -0
  97. django_cfg/apps/payments/views/templates/stats.py +244 -0
  98. django_cfg/apps/payments/views/templates/utils.py +181 -0
  99. django_cfg/apps/payments/views/webhook_views.py +2 -2
  100. django_cfg/apps/payments/viewsets.py +3 -2
  101. django_cfg/apps/tasks/urls.py +0 -2
  102. django_cfg/apps/tasks/urls_admin.py +14 -0
  103. django_cfg/apps/urls.py +6 -3
  104. django_cfg/core/config.py +35 -0
  105. django_cfg/models/payments.py +2 -8
  106. django_cfg/modules/django_currency/__init__.py +16 -11
  107. django_cfg/modules/django_currency/clients/__init__.py +4 -4
  108. django_cfg/modules/django_currency/clients/coinpaprika_client.py +289 -0
  109. django_cfg/modules/django_currency/clients/yahoo_client.py +157 -0
  110. django_cfg/modules/django_currency/core/__init__.py +1 -7
  111. django_cfg/modules/django_currency/core/converter.py +18 -23
  112. django_cfg/modules/django_currency/core/models.py +122 -11
  113. django_cfg/modules/django_currency/database/__init__.py +4 -4
  114. django_cfg/modules/django_currency/database/database_loader.py +190 -309
  115. django_cfg/modules/django_unfold/dashboard.py +7 -2
  116. django_cfg/registry/core.py +1 -0
  117. django_cfg/template_archive/.gitignore +1 -0
  118. django_cfg/template_archive/django_sample.zip +0 -0
  119. django_cfg/templates/admin/components/action_grid.html +9 -9
  120. django_cfg/templates/admin/components/metric_card.html +5 -5
  121. django_cfg/templates/admin/components/status_badge.html +2 -2
  122. django_cfg/templates/admin/layouts/dashboard_with_tabs.html +152 -24
  123. django_cfg/templates/admin/snippets/components/quick_actions.html +3 -3
  124. django_cfg/templates/admin/snippets/components/system_health.html +1 -1
  125. django_cfg/templates/admin/snippets/tabs/overview_tab.html +49 -52
  126. {django_cfg-1.2.27.dist-info → django_cfg-1.2.31.dist-info}/METADATA +13 -18
  127. {django_cfg-1.2.27.dist-info → django_cfg-1.2.31.dist-info}/RECORD +130 -83
  128. django_cfg/apps/payments/management/commands/populate_currencies.py +0 -246
  129. django_cfg/apps/payments/management/commands/update_currencies.py +0 -336
  130. django_cfg/apps/payments/services/providers/cryptapi.py +0 -273
  131. django_cfg/apps/payments/services/providers/cryptomus.py +0 -310
  132. django_cfg/apps/payments/services/providers/nowpayments.py +0 -293
  133. django_cfg/apps/payments/services/validators/__init__.py +0 -8
  134. django_cfg/modules/django_currency/clients/coingecko_client.py +0 -257
  135. django_cfg/modules/django_currency/clients/yfinance_client.py +0 -246
  136. {django_cfg-1.2.27.dist-info → django_cfg-1.2.31.dist-info}/WHEEL +0 -0
  137. {django_cfg-1.2.27.dist-info → django_cfg-1.2.31.dist-info}/entry_points.txt +0 -0
  138. {django_cfg-1.2.27.dist-info → django_cfg-1.2.31.dist-info}/licenses/LICENSE +0 -0
@@ -1,293 +0,0 @@
1
- """
2
- NowPayments provider implementation.
3
-
4
- Enhanced crypto payment provider with minimal typing.
5
- """
6
-
7
- import logging
8
- import requests
9
- import hashlib
10
- import hmac
11
- from typing import Optional, List
12
- from decimal import Decimal
13
- from pydantic import BaseModel, Field
14
-
15
- from .base import PaymentProvider
16
- from ..internal_types import ProviderResponse, WebhookData
17
-
18
- logger = logging.getLogger(__name__)
19
-
20
-
21
- class NowPaymentsConfig(BaseModel):
22
- """NowPayments provider configuration."""
23
- api_key: str = Field(..., description="NowPayments API key")
24
- sandbox: bool = Field(default=False, description="Use sandbox mode")
25
- ipn_secret: Optional[str] = Field(default=None, description="IPN secret for webhook validation")
26
- callback_url: Optional[str] = Field(default=None, description="Webhook callback URL")
27
- success_url: Optional[str] = Field(default=None, description="Payment success redirect URL")
28
- cancel_url: Optional[str] = Field(default=None, description="Payment cancel redirect URL")
29
- enabled: bool = Field(default=True, description="Provider enabled")
30
-
31
-
32
- class NowPaymentsProvider(PaymentProvider):
33
- """NowPayments cryptocurrency payment provider."""
34
-
35
- def __init__(self, config: NowPaymentsConfig):
36
- """Initialize NowPayments provider."""
37
- super().__init__(config.dict())
38
- self.config = config
39
- self.api_key = config.api_key
40
- self.sandbox = config.sandbox
41
- self.ipn_secret = config.ipn_secret or ''
42
- self.base_url = self._get_base_url()
43
-
44
- # Configurable URLs
45
- self.callback_url = config.callback_url
46
- self.success_url = config.success_url
47
- self.cancel_url = config.cancel_url
48
-
49
- self.headers = {
50
- 'x-api-key': self.api_key,
51
- 'Content-Type': 'application/json'
52
- }
53
-
54
- def _get_base_url(self) -> str:
55
- """Get base URL based on sandbox mode."""
56
- if self.sandbox:
57
- return 'https://api-sandbox.nowpayments.io/v1'
58
- return 'https://api.nowpayments.io/v1'
59
-
60
- def _make_request(self, method: str, endpoint: str, data: Optional[dict] = None) -> Optional[dict]:
61
- """Make HTTP request to NowPayments API with error handling."""
62
- try:
63
- url = f"{self.base_url}/{endpoint}"
64
-
65
- response = requests.request(
66
- method=method,
67
- url=url,
68
- headers=self.headers,
69
- json=data,
70
- timeout=30
71
- )
72
-
73
- response.raise_for_status()
74
- return response.json()
75
-
76
- except requests.exceptions.RequestException as e:
77
- logger.error(f"NowPayments API request failed: {e}")
78
- return None
79
- except Exception as e:
80
- logger.error(f"Unexpected error in NowPayments request: {e}")
81
- return None
82
-
83
- def create_payment(self, payment_data: dict) -> ProviderResponse:
84
- """Create payment via NowPayments API."""
85
- try:
86
- amount = Decimal(str(payment_data['amount']))
87
- currency = payment_data['currency']
88
- order_id = payment_data.get('order_id', f'payment_{int(amount * 100)}_{currency}')
89
-
90
- payment_request = {
91
- 'price_amount': float(amount),
92
- 'price_currency': 'usd', # Base currency
93
- 'pay_currency': currency,
94
- 'order_id': order_id,
95
- 'order_description': payment_data.get('description', f'Payment {order_id}'),
96
- }
97
-
98
- # Add optional URLs
99
- if self.success_url:
100
- payment_request['success_url'] = self.success_url
101
- if self.cancel_url:
102
- payment_request['cancel_url'] = self.cancel_url
103
- if self.callback_url:
104
- payment_request['ipn_callback_url'] = self.callback_url
105
-
106
- response = self._make_request('POST', 'payment', payment_request)
107
-
108
- if response:
109
- return ProviderResponse(
110
- success=True,
111
- provider_payment_id=response.get('payment_id'),
112
- payment_url=response.get('invoice_url'),
113
- pay_address=response.get('pay_address'),
114
- amount=Decimal(str(response.get('pay_amount', 0))),
115
- currency=response.get('pay_currency'),
116
- status='pending'
117
- )
118
- else:
119
- return ProviderResponse(
120
- success=False,
121
- error_message='Failed to create payment'
122
- )
123
-
124
- except Exception as e:
125
- logger.error(f"NowPayments create_payment error: {e}")
126
- return ProviderResponse(
127
- success=False,
128
- error_message=str(e)
129
- )
130
-
131
- def check_payment_status(self, payment_id: str) -> ProviderResponse:
132
- """Check payment status via NowPayments API."""
133
- try:
134
- response = self._make_request('GET', f'payment/{payment_id}')
135
-
136
- if response:
137
- # Map NowPayments status to universal status
138
- status_mapping = {
139
- 'waiting': 'pending',
140
- 'confirming': 'processing',
141
- 'confirmed': 'completed',
142
- 'sending': 'processing',
143
- 'partially_paid': 'pending',
144
- 'finished': 'completed',
145
- 'failed': 'failed',
146
- 'refunded': 'refunded',
147
- 'expired': 'expired'
148
- }
149
-
150
- provider_status = response.get('payment_status', 'unknown')
151
- universal_status = status_mapping.get(provider_status, 'unknown')
152
-
153
- return ProviderResponse(
154
- success=True,
155
- provider_payment_id=response.get('payment_id'),
156
- status=universal_status,
157
- pay_address=response.get('pay_address'),
158
- amount=Decimal(str(response.get('pay_amount', 0))),
159
- currency=response.get('pay_currency')
160
- )
161
- else:
162
- return ProviderResponse(
163
- success=False,
164
- error_message='Payment not found'
165
- )
166
-
167
- except Exception as e:
168
- logger.error(f"NowPayments check_payment_status error: {e}")
169
- return ProviderResponse(
170
- success=False,
171
- error_message=str(e)
172
- )
173
-
174
- def process_webhook(self, payload: dict) -> WebhookData:
175
- """Process NowPayments webhook."""
176
- try:
177
- # Map status
178
- status_mapping = {
179
- 'waiting': 'pending',
180
- 'confirming': 'processing',
181
- 'confirmed': 'completed',
182
- 'sending': 'processing',
183
- 'partially_paid': 'pending',
184
- 'finished': 'completed',
185
- 'failed': 'failed',
186
- 'refunded': 'refunded',
187
- 'expired': 'expired'
188
- }
189
-
190
- provider_status = payload.get('payment_status', 'unknown')
191
- universal_status = status_mapping.get(provider_status, 'unknown')
192
-
193
- return WebhookData(
194
- provider_payment_id=str(payload.get('payment_id', '')),
195
- status=universal_status,
196
- pay_amount=Decimal(str(payload.get('pay_amount', 0))),
197
- actually_paid=Decimal(str(payload.get('actually_paid', 0))),
198
- order_id=payload.get('order_id'),
199
- signature=payload.get('signature')
200
- )
201
-
202
- except Exception as e:
203
- logger.error(f"NowPayments webhook processing error: {e}")
204
- raise
205
-
206
- def get_supported_currencies(self) -> List[str]:
207
- """Get list of supported currencies."""
208
- try:
209
- response = self._make_request('GET', 'currencies')
210
-
211
- if response and 'currencies' in response:
212
- return response['currencies']
213
- else:
214
- # Fallback currencies
215
- return ['BTC', 'ETH', 'LTC', 'BCH', 'XMR', 'TRX', 'BNB']
216
-
217
- except Exception as e:
218
- logger.error(f"Error getting supported currencies: {e}")
219
- return ['BTC', 'ETH', 'LTC'] # Minimal fallback
220
-
221
- def get_minimum_payment_amount(self, currency_from: str, currency_to: str = 'usd') -> Optional[Decimal]:
222
- """Get minimum payment amount for currency pair."""
223
- try:
224
- response = self._make_request('GET', 'min-amount', {
225
- 'currency_from': currency_from,
226
- 'currency_to': currency_to
227
- })
228
-
229
- if response and 'min_amount' in response:
230
- return Decimal(str(response['min_amount']))
231
-
232
- return None
233
-
234
- except Exception as e:
235
- logger.error(f"Error getting minimum amount: {e}")
236
- return None
237
-
238
- def estimate_payment_amount(self, amount: Decimal, currency_code: str) -> Optional[dict]:
239
- """Estimate payment amount in target currency."""
240
- try:
241
- response = self._make_request('GET', 'estimate', {
242
- 'amount': float(amount),
243
- 'currency_from': 'usd',
244
- 'currency_to': currency_code
245
- })
246
-
247
- if response and 'estimated_amount' in response:
248
- return {
249
- 'estimated_amount': Decimal(str(response['estimated_amount'])),
250
- 'currency_from': response.get('currency_from'),
251
- 'currency_to': response.get('currency_to'),
252
- 'fee_amount': Decimal(str(response.get('fee_amount', 0)))
253
- }
254
-
255
- return None
256
-
257
- except Exception as e:
258
- logger.error(f"Error estimating payment amount: {e}")
259
- return None
260
-
261
- def validate_webhook(self, payload: dict, headers: Optional[dict] = None) -> bool:
262
- """Validate NowPayments webhook signature."""
263
- try:
264
- if not self.ipn_secret:
265
- logger.warning("IPN secret not configured, skipping webhook validation")
266
- return True
267
-
268
- if not headers:
269
- logger.warning("No headers provided for webhook validation")
270
- return False
271
-
272
- # Get signature from headers
273
- signature = headers.get('x-nowpayments-sig')
274
- if not signature:
275
- logger.warning("No signature found in webhook headers")
276
- return False
277
-
278
- # TODO: Implement proper HMAC signature validation
279
- # This requires the raw payload body for proper validation
280
- logger.info("Webhook signature validation placeholder")
281
- return True
282
-
283
- except Exception as e:
284
- logger.error(f"Webhook validation error: {e}")
285
- return False
286
-
287
- def check_api_status(self) -> bool:
288
- """Check if NowPayments API is available."""
289
- try:
290
- response = self._make_request('GET', 'status')
291
- return response is not None and response.get('message') == 'OK'
292
- except:
293
- return False
@@ -1,8 +0,0 @@
1
- """
2
- Validators for payment services.
3
-
4
- TODO: Implement payment validators when needed.
5
- """
6
-
7
- # Placeholder for future validators
8
- __all__ = []
@@ -1,257 +0,0 @@
1
- """
2
- CoinGecko client for crypto rates only.
3
- """
4
-
5
- import logging
6
- import time
7
- from datetime import datetime
8
- from typing import Dict, Set, Optional
9
- from cachetools import TTLCache
10
- from pycoingecko import CoinGeckoAPI
11
- from concurrent.futures import ThreadPoolExecutor, as_completed
12
- from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
13
-
14
- from ..core.models import Rate
15
- from ..core.exceptions import RateFetchError
16
-
17
- logger = logging.getLogger(__name__)
18
-
19
-
20
- class CoinGeckoClient:
21
- """Client for fetching crypto rates from CoinGecko."""
22
-
23
- def __init__(self, cache_ttl: int = 3600, rate_limit_delay: float = 1.2):
24
- """Initialize CoinGecko client with TTL cache and rate limiting."""
25
- self.client = CoinGeckoAPI()
26
- self._crypto_cache = TTLCache(maxsize=2, ttl=cache_ttl) # Cache crypto data for 1 hour
27
- self._rate_cache = TTLCache(maxsize=1000, ttl=600) # Cache rates for 10 minutes
28
- self._last_request_time = 0.0
29
- self._rate_limit_delay = rate_limit_delay # Delay between requests to avoid 429
30
-
31
- def fetch_rate(self, base: str, quote: str) -> Rate:
32
- """
33
- Fetch crypto exchange rate from CoinGecko with caching.
34
-
35
- Args:
36
- base: Base currency code (crypto)
37
- quote: Quote currency code
38
-
39
- Returns:
40
- Rate object with exchange rate data
41
-
42
- Raises:
43
- RateFetchError: If rate fetch fails
44
- """
45
- cache_key = f"{base}_{quote}"
46
-
47
- # Try cache first
48
- if cache_key in self._rate_cache:
49
- logger.debug(f"Retrieved rate {base}/{quote} from cache")
50
- return self._rate_cache[cache_key]
51
-
52
- try:
53
- rate = self._fetch_rate_with_retry(base, quote)
54
-
55
- # Cache the result
56
- self._rate_cache[cache_key] = rate
57
-
58
- return rate
59
-
60
- except Exception as e:
61
- logger.error(f"Failed to fetch rate for {base}/{quote}: {e}")
62
- raise RateFetchError(f"CoinGecko fetch failed: {e}")
63
-
64
- @retry(
65
- stop=stop_after_attempt(4), # More retries for CoinGecko due to rate limits
66
- wait=wait_exponential(multiplier=2, min=2, max=30), # Longer waits for rate-limited API
67
- retry=retry_if_exception_type((ConnectionError, TimeoutError, Exception)),
68
- reraise=True
69
- )
70
- def _fetch_rate_with_retry(self, base: str, quote: str) -> Rate:
71
- """
72
- Fetch rate with retry logic and exponential backoff.
73
-
74
- Args:
75
- base: Base currency code (crypto)
76
- quote: Quote currency code
77
-
78
- Returns:
79
- Rate object with exchange rate data
80
- """
81
- base_id = self._get_crypto_id(base)
82
- quote_currency = quote.lower()
83
-
84
- vs_currencies = self.get_vs_currencies()
85
- if quote_currency not in vs_currencies:
86
- raise RateFetchError(f"Quote currency {quote} not supported by CoinGecko")
87
-
88
- logger.debug(f"Fetching rate for {base_id} vs {quote_currency}")
89
-
90
- # Fetch price from CoinGecko with rate limiting
91
- self._rate_limit()
92
- price_data = self.client.get_price(
93
- ids=base_id,
94
- vs_currencies=quote_currency,
95
- include_last_updated_at=True
96
- )
97
-
98
- if base_id not in price_data:
99
- raise RateFetchError(f"No data for {base}")
100
-
101
- rate_value = price_data[base_id][quote_currency]
102
-
103
- return Rate(
104
- source="coingecko",
105
- base_currency=base.upper(),
106
- quote_currency=quote.upper(),
107
- rate=float(rate_value),
108
- timestamp=datetime.now()
109
- )
110
-
111
- def get_crypto_ids(self) -> Dict[str, str]:
112
- """Get all supported cryptocurrencies dynamically with caching."""
113
- cache_key = "crypto_ids"
114
-
115
- # Try cache first
116
- if cache_key in self._crypto_cache:
117
- logger.debug("Retrieved crypto IDs from cache")
118
- return self._crypto_cache[cache_key]
119
-
120
- try:
121
- crypto_ids = self._get_coins_list_with_retry()
122
-
123
- # Cache the result
124
- self._crypto_cache[cache_key] = crypto_ids
125
- logger.info(f"Loaded and cached {len(crypto_ids)} cryptocurrencies from CoinGecko")
126
-
127
- return crypto_ids
128
-
129
- except Exception as e:
130
- logger.error(f"Failed to load cryptocurrencies: {e}")
131
- raise RateFetchError(f"Failed to load cryptocurrencies from CoinGecko: {e}")
132
-
133
- def get_vs_currencies(self) -> Set[str]:
134
- """Get all supported quote currencies dynamically with caching."""
135
- cache_key = "vs_currencies"
136
-
137
- # Try cache first
138
- if cache_key in self._crypto_cache:
139
- logger.debug("Retrieved vs_currencies from cache")
140
- return self._crypto_cache[cache_key]
141
-
142
- try:
143
- vs_currencies_set = self._get_vs_currencies_with_retry()
144
-
145
- # Cache the result
146
- self._crypto_cache[cache_key] = vs_currencies_set
147
- logger.info(f"Loaded and cached {len(vs_currencies_set)} vs_currencies from CoinGecko")
148
-
149
- return vs_currencies_set
150
-
151
- except Exception as e:
152
- logger.error(f"Failed to load vs_currencies: {e}")
153
- raise RateFetchError(f"Failed to load vs_currencies from CoinGecko: {e}")
154
-
155
- def _get_crypto_id(self, currency: str) -> str:
156
- """Get CoinGecko crypto ID from currency code."""
157
- currency = currency.upper()
158
- crypto_ids = self.get_crypto_ids()
159
-
160
- if currency in crypto_ids:
161
- return crypto_ids[currency]
162
-
163
- raise RateFetchError(f"Unknown cryptocurrency: {currency}")
164
-
165
- def _rate_limit(self):
166
- """Enforce rate limiting to prevent API throttling."""
167
- current_time = time.time()
168
- time_since_last = current_time - self._last_request_time
169
-
170
- if time_since_last < self._rate_limit_delay:
171
- sleep_time = self._rate_limit_delay - time_since_last
172
- logger.debug(f"Rate limiting: sleeping for {sleep_time:.2f}s")
173
- time.sleep(sleep_time)
174
-
175
- self._last_request_time = time.time()
176
-
177
- @retry(
178
- stop=stop_after_attempt(3),
179
- wait=wait_exponential(multiplier=2, min=2, max=15),
180
- retry=retry_if_exception_type((ConnectionError, TimeoutError, Exception)),
181
- reraise=True
182
- )
183
- def _get_coins_list_with_retry(self) -> Dict[str, str]:
184
- """Get coins list with retry logic."""
185
- self._rate_limit()
186
- coins_list = self.client.get_coins_list()
187
- crypto_ids = {}
188
-
189
- for coin in coins_list:
190
- symbol = coin['symbol'].upper()
191
- crypto_ids[symbol] = coin['id']
192
-
193
- return crypto_ids
194
-
195
- @retry(
196
- stop=stop_after_attempt(3),
197
- wait=wait_exponential(multiplier=2, min=2, max=15),
198
- retry=retry_if_exception_type((ConnectionError, TimeoutError, Exception)),
199
- reraise=True
200
- )
201
- def _get_vs_currencies_with_retry(self) -> Set[str]:
202
- """Get vs currencies with retry logic."""
203
- self._rate_limit()
204
- vs_currencies = self.client.get_supported_vs_currencies()
205
- return set(vs_currencies)
206
-
207
- def fetch_multiple_rates(self, pairs: list) -> Dict[str, Rate]:
208
- """
209
- Fetch multiple currency rates in parallel.
210
-
211
- Args:
212
- pairs: List of tuples (base, quote) to fetch
213
-
214
- Returns:
215
- Dictionary mapping "BASE_QUOTE" to Rate objects
216
- """
217
- results = {}
218
-
219
- def fetch_single_rate(pair):
220
- base, quote = pair
221
- try:
222
- rate = self.fetch_rate(base, quote)
223
- return f"{base}_{quote}", rate
224
- except Exception as e:
225
- logger.warning(f"Failed to fetch {base}/{quote}: {e}")
226
- return f"{base}_{quote}", None
227
-
228
- # Use ThreadPoolExecutor for parallel fetching with rate limiting
229
- with ThreadPoolExecutor(max_workers=3) as executor: # Limited workers to respect rate limits
230
- future_to_pair = {executor.submit(fetch_single_rate, pair): pair for pair in pairs}
231
-
232
- for future in as_completed(future_to_pair):
233
- try:
234
- key, rate = future.result(timeout=30)
235
- if rate:
236
- results[key] = rate
237
- except Exception as e:
238
- pair = future_to_pair[future]
239
- logger.error(f"Failed to fetch rate for {pair}: {e}")
240
-
241
- logger.info(f"Successfully fetched {len(results)}/{len(pairs)} rates")
242
- return results
243
-
244
- def supports_pair(self, base: str, quote: str) -> bool:
245
- """Check if crypto currency pair is supported."""
246
- try:
247
- # Base must be a crypto
248
- crypto_ids = self.get_crypto_ids()
249
- if base.upper() not in crypto_ids:
250
- return False
251
-
252
- # Quote must be a supported vs_currency
253
- vs_currencies = self.get_vs_currencies()
254
- return quote.lower() in vs_currencies
255
-
256
- except Exception:
257
- return False