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
@@ -1,215 +0,0 @@
|
|
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)
|
@@ -1,430 +0,0 @@
|
|
1
|
-
"""
|
2
|
-
Currency Cache Management for django_currency.
|
3
|
-
|
4
|
-
Handles file-based and memory caching of currency rates with TTL support.
|
5
|
-
Uses YAML format for better readability and human-friendly configuration.
|
6
|
-
"""
|
7
|
-
|
8
|
-
import logging
|
9
|
-
import yaml
|
10
|
-
from datetime import datetime, timedelta
|
11
|
-
from pathlib import Path
|
12
|
-
from typing import Dict, Optional, Any
|
13
|
-
from cachetools import TTLCache
|
14
|
-
|
15
|
-
logger = logging.getLogger(__name__)
|
16
|
-
|
17
|
-
|
18
|
-
class CurrencyCache:
|
19
|
-
"""
|
20
|
-
Currency cache manager with file and memory caching.
|
21
|
-
|
22
|
-
Features:
|
23
|
-
- TTL-based memory cache (24 hours default)
|
24
|
-
- File-based persistent cache
|
25
|
-
- Automatic cache invalidation
|
26
|
-
- Thread-safe operations
|
27
|
-
"""
|
28
|
-
|
29
|
-
DEFAULT_TTL = 86400 # 24 hours in seconds
|
30
|
-
DEFAULT_CACHE_SIZE = 1000
|
31
|
-
CACHE_FILENAME = "currency_rates.yaml"
|
32
|
-
|
33
|
-
def __init__(
|
34
|
-
self,
|
35
|
-
cache_dir: Optional[Path] = None,
|
36
|
-
ttl: int = DEFAULT_TTL,
|
37
|
-
max_size: int = DEFAULT_CACHE_SIZE
|
38
|
-
):
|
39
|
-
"""
|
40
|
-
Initialize currency cache.
|
41
|
-
|
42
|
-
Args:
|
43
|
-
cache_dir: Directory for file cache
|
44
|
-
ttl: Time-to-live for memory cache in seconds
|
45
|
-
max_size: Maximum number of items in memory cache
|
46
|
-
"""
|
47
|
-
# Default cache directory inside django-cfg structure
|
48
|
-
if cache_dir is None:
|
49
|
-
def make_cache_dir(cache_dir=Path.cwd()):
|
50
|
-
return Path(cache_dir) / ".cache" / "currency"
|
51
|
-
default_cache_dir = make_cache_dir()
|
52
|
-
|
53
|
-
try:
|
54
|
-
from django.conf import settings
|
55
|
-
# Check if Django is configured before accessing settings
|
56
|
-
if settings.configured and hasattr(settings, 'BASE_DIR'):
|
57
|
-
default_cache_dir = make_cache_dir(settings.BASE_DIR)
|
58
|
-
except (ImportError, Exception):
|
59
|
-
pass
|
60
|
-
else:
|
61
|
-
default_cache_dir = Path(cache_dir)
|
62
|
-
|
63
|
-
self.cache_dir = default_cache_dir
|
64
|
-
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
65
|
-
self.cache_file = self.cache_dir / self.CACHE_FILENAME
|
66
|
-
|
67
|
-
# TTL Cache for in-memory storage
|
68
|
-
self._memory_cache = TTLCache(maxsize=max_size, ttl=ttl)
|
69
|
-
|
70
|
-
# File cache metadata
|
71
|
-
self._file_cache_data = None
|
72
|
-
self._file_cache_timestamp = None
|
73
|
-
|
74
|
-
logger.info(f"Currency cache initialized: {self.cache_dir}")
|
75
|
-
|
76
|
-
def get_rates(self, source: str = "cbr") -> Optional[Dict[str, float]]:
|
77
|
-
"""
|
78
|
-
Get currency rates from cache.
|
79
|
-
|
80
|
-
Args:
|
81
|
-
source: Rate source identifier (e.g., 'cbr', 'ecb')
|
82
|
-
|
83
|
-
Returns:
|
84
|
-
Dictionary of currency rates or None if not cached
|
85
|
-
"""
|
86
|
-
cache_key = f"rates_{source}"
|
87
|
-
|
88
|
-
# Try memory cache first
|
89
|
-
if cache_key in self._memory_cache:
|
90
|
-
logger.debug(f"Retrieved rates from memory cache: {source}")
|
91
|
-
return self._memory_cache[cache_key]
|
92
|
-
|
93
|
-
# Try file cache
|
94
|
-
file_rates = self._load_from_file(source)
|
95
|
-
if file_rates:
|
96
|
-
# Update memory cache
|
97
|
-
self._memory_cache[cache_key] = file_rates
|
98
|
-
logger.debug(f"Retrieved rates from file cache: {source}")
|
99
|
-
return file_rates
|
100
|
-
|
101
|
-
logger.debug(f"No cached rates found for source: {source}")
|
102
|
-
return None
|
103
|
-
|
104
|
-
def set_rates(
|
105
|
-
self,
|
106
|
-
rates: Dict[str, float],
|
107
|
-
source: str = "cbr",
|
108
|
-
save_to_file: bool = True
|
109
|
-
) -> bool:
|
110
|
-
"""
|
111
|
-
Store currency rates in cache.
|
112
|
-
|
113
|
-
Args:
|
114
|
-
rates: Dictionary of currency rates
|
115
|
-
source: Rate source identifier
|
116
|
-
save_to_file: Whether to persist to file
|
117
|
-
|
118
|
-
Returns:
|
119
|
-
True if successfully cached, False otherwise
|
120
|
-
"""
|
121
|
-
try:
|
122
|
-
cache_key = f"rates_{source}"
|
123
|
-
|
124
|
-
# Store in memory cache
|
125
|
-
self._memory_cache[cache_key] = rates
|
126
|
-
|
127
|
-
# Store in file cache if requested
|
128
|
-
if save_to_file:
|
129
|
-
self._save_to_file(rates, source)
|
130
|
-
|
131
|
-
logger.info(f"Cached {len(rates)} rates for source: {source}")
|
132
|
-
return True
|
133
|
-
|
134
|
-
except Exception as e:
|
135
|
-
logger.error(f"Failed to cache rates for {source}: {e}")
|
136
|
-
return False
|
137
|
-
|
138
|
-
def _load_from_file(self, source: str) -> Optional[Dict[str, float]]:
|
139
|
-
"""Load rates from file cache."""
|
140
|
-
try:
|
141
|
-
if not self.cache_file.exists():
|
142
|
-
return None
|
143
|
-
|
144
|
-
with open(self.cache_file, 'r', encoding='utf-8') as f:
|
145
|
-
data = yaml.safe_load(f)
|
146
|
-
|
147
|
-
# Check if data is for the requested source
|
148
|
-
if data.get('source') != source:
|
149
|
-
return None
|
150
|
-
|
151
|
-
# Check if data is still valid (not expired)
|
152
|
-
timestamp_str = data.get('timestamp')
|
153
|
-
if timestamp_str:
|
154
|
-
timestamp = datetime.fromisoformat(timestamp_str)
|
155
|
-
if datetime.now() - timestamp > timedelta(seconds=self.DEFAULT_TTL):
|
156
|
-
logger.debug(f"File cache expired for source: {source}")
|
157
|
-
return None
|
158
|
-
|
159
|
-
rates = data.get('rates', {})
|
160
|
-
if rates:
|
161
|
-
self._file_cache_data = data
|
162
|
-
self._file_cache_timestamp = timestamp if timestamp_str else None
|
163
|
-
return rates
|
164
|
-
|
165
|
-
except Exception as e:
|
166
|
-
logger.error(f"Failed to load file cache for {source}: {e}")
|
167
|
-
|
168
|
-
return None
|
169
|
-
|
170
|
-
def _save_to_file(self, rates: Dict[str, float], source: str) -> bool:
|
171
|
-
"""Save rates to file cache."""
|
172
|
-
try:
|
173
|
-
now = datetime.now()
|
174
|
-
data = {
|
175
|
-
'source': source,
|
176
|
-
'timestamp': now.isoformat(),
|
177
|
-
'rates': rates,
|
178
|
-
'metadata': {
|
179
|
-
'count': len(rates),
|
180
|
-
'cache_version': '1.0',
|
181
|
-
'format': 'YAML',
|
182
|
-
'description': f'Currency rates from {source.upper()} API',
|
183
|
-
'updated_at': now.strftime('%Y-%m-%d %H:%M:%S UTC'),
|
184
|
-
'ttl_hours': self.DEFAULT_TTL // 3600,
|
185
|
-
'next_update': (now + timedelta(seconds=self.DEFAULT_TTL)).strftime('%Y-%m-%d %H:%M:%S UTC')
|
186
|
-
}
|
187
|
-
}
|
188
|
-
|
189
|
-
# Atomic write using temporary file
|
190
|
-
temp_file = self.cache_file.with_suffix('.tmp')
|
191
|
-
with open(temp_file, 'w', encoding='utf-8') as f:
|
192
|
-
# Write header comment
|
193
|
-
f.write(f"# Currency Rates Cache - Django CFG\n")
|
194
|
-
f.write(f"# Generated: {now.strftime('%Y-%m-%d %H:%M:%S UTC')}\n")
|
195
|
-
f.write(f"# Source: {source.upper()} API\n")
|
196
|
-
f.write(f"# Rates count: {len(rates)}\n")
|
197
|
-
f.write(f"# TTL: {self.DEFAULT_TTL // 3600} hours\n")
|
198
|
-
f.write(f"# Auto-generated - do not edit manually\n\n")
|
199
|
-
|
200
|
-
yaml.safe_dump(
|
201
|
-
data,
|
202
|
-
f,
|
203
|
-
default_flow_style=False,
|
204
|
-
allow_unicode=True,
|
205
|
-
sort_keys=False,
|
206
|
-
indent=2,
|
207
|
-
width=120
|
208
|
-
)
|
209
|
-
|
210
|
-
# Move temp file to final location
|
211
|
-
temp_file.replace(self.cache_file)
|
212
|
-
|
213
|
-
self._file_cache_data = data
|
214
|
-
self._file_cache_timestamp = datetime.now()
|
215
|
-
|
216
|
-
logger.debug(f"Saved {len(rates)} rates to file cache: {source}")
|
217
|
-
return True
|
218
|
-
|
219
|
-
except Exception as e:
|
220
|
-
logger.error(f"Failed to save file cache for {source}: {e}")
|
221
|
-
return False
|
222
|
-
|
223
|
-
def clear_cache(self, source: Optional[str] = None) -> bool:
|
224
|
-
"""
|
225
|
-
Clear cache for specific source or all sources.
|
226
|
-
|
227
|
-
Args:
|
228
|
-
source: Source to clear (None for all)
|
229
|
-
|
230
|
-
Returns:
|
231
|
-
True if successfully cleared
|
232
|
-
"""
|
233
|
-
try:
|
234
|
-
if source:
|
235
|
-
# Clear specific source from memory
|
236
|
-
cache_key = f"rates_{source}"
|
237
|
-
self._memory_cache.pop(cache_key, None)
|
238
|
-
|
239
|
-
# Clear file cache if it matches the source
|
240
|
-
if (self._file_cache_data and
|
241
|
-
self._file_cache_data.get('source') == source):
|
242
|
-
if self.cache_file.exists():
|
243
|
-
self.cache_file.unlink()
|
244
|
-
self._file_cache_data = None
|
245
|
-
self._file_cache_timestamp = None
|
246
|
-
|
247
|
-
logger.info(f"Cleared cache for source: {source}")
|
248
|
-
else:
|
249
|
-
# Clear all caches
|
250
|
-
self._memory_cache.clear()
|
251
|
-
if self.cache_file.exists():
|
252
|
-
self.cache_file.unlink()
|
253
|
-
self._file_cache_data = None
|
254
|
-
self._file_cache_timestamp = None
|
255
|
-
|
256
|
-
logger.info("Cleared all currency caches")
|
257
|
-
|
258
|
-
return True
|
259
|
-
|
260
|
-
except Exception as e:
|
261
|
-
logger.error(f"Failed to clear cache: {e}")
|
262
|
-
return False
|
263
|
-
|
264
|
-
def get_cache_info(self) -> Dict[str, Any]:
|
265
|
-
"""Get information about cache status."""
|
266
|
-
try:
|
267
|
-
memory_info = {
|
268
|
-
'size': len(self._memory_cache),
|
269
|
-
'max_size': self._memory_cache.maxsize,
|
270
|
-
'ttl': self._memory_cache.ttl,
|
271
|
-
'keys': list(self._memory_cache.keys())
|
272
|
-
}
|
273
|
-
|
274
|
-
file_info = {
|
275
|
-
'exists': self.cache_file.exists(),
|
276
|
-
'path': str(self.cache_file),
|
277
|
-
'size_bytes': self.cache_file.stat().st_size if self.cache_file.exists() else 0,
|
278
|
-
}
|
279
|
-
|
280
|
-
if self._file_cache_data:
|
281
|
-
file_info.update({
|
282
|
-
'source': self._file_cache_data.get('source'),
|
283
|
-
'timestamp': self._file_cache_data.get('timestamp'),
|
284
|
-
'rates_count': len(self._file_cache_data.get('rates', {}))
|
285
|
-
})
|
286
|
-
|
287
|
-
return {
|
288
|
-
'cache_directory': str(self.cache_dir),
|
289
|
-
'memory_cache': memory_info,
|
290
|
-
'file_cache': file_info,
|
291
|
-
'status': 'active'
|
292
|
-
}
|
293
|
-
|
294
|
-
except Exception as e:
|
295
|
-
logger.error(f"Failed to get cache info: {e}")
|
296
|
-
return {
|
297
|
-
'status': 'error',
|
298
|
-
'error': str(e)
|
299
|
-
}
|
300
|
-
|
301
|
-
def is_cache_valid(self, source: str = "cbr") -> bool:
|
302
|
-
"""Check if cache is valid and not expired."""
|
303
|
-
cache_key = f"rates_{source}"
|
304
|
-
|
305
|
-
# Check memory cache
|
306
|
-
if cache_key in self._memory_cache:
|
307
|
-
return True
|
308
|
-
|
309
|
-
# Check file cache
|
310
|
-
if self._file_cache_timestamp:
|
311
|
-
age = datetime.now() - self._file_cache_timestamp
|
312
|
-
return age.total_seconds() < self.DEFAULT_TTL
|
313
|
-
|
314
|
-
return False
|
315
|
-
|
316
|
-
def get_cache_age(self, source: str = "cbr") -> Optional[timedelta]:
|
317
|
-
"""Get age of cached data."""
|
318
|
-
if self._file_cache_timestamp:
|
319
|
-
return datetime.now() - self._file_cache_timestamp
|
320
|
-
return None
|
321
|
-
|
322
|
-
def _get_currency_description(self, currency_code: str) -> str:
|
323
|
-
"""Get human-readable description for currency code."""
|
324
|
-
currency_names = {
|
325
|
-
'USD': 'US Dollar',
|
326
|
-
'EUR': 'Euro',
|
327
|
-
'GBP': 'British Pound',
|
328
|
-
'JPY': 'Japanese Yen',
|
329
|
-
'CNY': 'Chinese Yuan',
|
330
|
-
'KRW': 'South Korean Won',
|
331
|
-
'RUB': 'Russian Ruble',
|
332
|
-
'CAD': 'Canadian Dollar',
|
333
|
-
'AUD': 'Australian Dollar',
|
334
|
-
'CHF': 'Swiss Franc',
|
335
|
-
'SEK': 'Swedish Krona',
|
336
|
-
'NOK': 'Norwegian Krone',
|
337
|
-
'DKK': 'Danish Krone',
|
338
|
-
'PLN': 'Polish Zloty',
|
339
|
-
'CZK': 'Czech Koruna',
|
340
|
-
'HUF': 'Hungarian Forint',
|
341
|
-
'TRY': 'Turkish Lira',
|
342
|
-
'BRL': 'Brazilian Real',
|
343
|
-
'MXN': 'Mexican Peso',
|
344
|
-
'INR': 'Indian Rupee',
|
345
|
-
'SGD': 'Singapore Dollar',
|
346
|
-
'HKD': 'Hong Kong Dollar',
|
347
|
-
'NZD': 'New Zealand Dollar',
|
348
|
-
'ZAR': 'South African Rand',
|
349
|
-
'THB': 'Thai Baht',
|
350
|
-
'MYR': 'Malaysian Ringgit',
|
351
|
-
'PHP': 'Philippine Peso',
|
352
|
-
'IDR': 'Indonesian Rupiah',
|
353
|
-
'VND': 'Vietnamese Dong',
|
354
|
-
}
|
355
|
-
return currency_names.get(currency_code, currency_code)
|
356
|
-
|
357
|
-
def export_rates_yaml(self, source: str = "cbr", output_file: Optional[Path] = None) -> str:
|
358
|
-
"""
|
359
|
-
Export rates to a formatted YAML file with comments.
|
360
|
-
|
361
|
-
Args:
|
362
|
-
source: Rate source to export
|
363
|
-
output_file: Optional output file path
|
364
|
-
|
365
|
-
Returns:
|
366
|
-
YAML content as string
|
367
|
-
"""
|
368
|
-
rates = self.get_rates(source)
|
369
|
-
if not rates:
|
370
|
-
return "# No rates available for export"
|
371
|
-
|
372
|
-
now = datetime.now()
|
373
|
-
|
374
|
-
# Create structured data with comments
|
375
|
-
yaml_content = f"""# Currency Exchange Rates - Django CFG
|
376
|
-
# Source: {source.upper()} API
|
377
|
-
# Generated: {now.strftime('%Y-%m-%d %H:%M:%S UTC')}
|
378
|
-
# Total currencies: {len(rates)}
|
379
|
-
# Cache TTL: {self.DEFAULT_TTL // 3600} hours
|
380
|
-
#
|
381
|
-
# Format: currency_code: rate_to_base_currency
|
382
|
-
# Base currency for CBR: RUB (Russian Ruble)
|
383
|
-
# Base currency for ECB: EUR (Euro)
|
384
|
-
|
385
|
-
source: {source}
|
386
|
-
timestamp: {now.isoformat()}
|
387
|
-
|
388
|
-
metadata:
|
389
|
-
count: {len(rates)}
|
390
|
-
cache_version: '1.0'
|
391
|
-
format: 'YAML'
|
392
|
-
description: 'Currency rates from {source.upper()} API'
|
393
|
-
updated_at: '{now.strftime('%Y-%m-%d %H:%M:%S UTC')}'
|
394
|
-
ttl_hours: {self.DEFAULT_TTL // 3600}
|
395
|
-
next_update: '{(now + timedelta(seconds=self.DEFAULT_TTL)).strftime('%Y-%m-%d %H:%M:%S UTC')}'
|
396
|
-
|
397
|
-
# Currency Rates
|
398
|
-
rates:
|
399
|
-
"""
|
400
|
-
|
401
|
-
# Sort currencies: major currencies first, then alphabetically
|
402
|
-
major_currencies = ['USD', 'EUR', 'GBP', 'JPY', 'CNY', 'KRW', 'RUB', 'CAD', 'AUD', 'CHF']
|
403
|
-
sorted_currencies = []
|
404
|
-
|
405
|
-
# Add major currencies first (if they exist)
|
406
|
-
for curr in major_currencies:
|
407
|
-
if curr in rates:
|
408
|
-
sorted_currencies.append(curr)
|
409
|
-
|
410
|
-
# Add remaining currencies alphabetically
|
411
|
-
remaining = sorted([curr for curr in rates.keys() if curr not in major_currencies])
|
412
|
-
sorted_currencies.extend(remaining)
|
413
|
-
|
414
|
-
# Add rates with comments
|
415
|
-
for currency in sorted_currencies:
|
416
|
-
rate = rates[currency]
|
417
|
-
description = self._get_currency_description(currency)
|
418
|
-
yaml_content += f" {currency}: {rate:<12.6f} # {description}\n"
|
419
|
-
|
420
|
-
# Save to file if requested
|
421
|
-
if output_file:
|
422
|
-
try:
|
423
|
-
output_file.parent.mkdir(parents=True, exist_ok=True)
|
424
|
-
with open(output_file, 'w', encoding='utf-8') as f:
|
425
|
-
f.write(yaml_content)
|
426
|
-
logger.info(f"Exported rates to: {output_file}")
|
427
|
-
except Exception as e:
|
428
|
-
logger.error(f"Failed to export rates to file: {e}")
|
429
|
-
|
430
|
-
return yaml_content
|