django-cfg 1.2.23__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/config/__init__.py +15 -37
- django_cfg/apps/payments/config/module.py +30 -122
- django_cfg/apps/payments/config/providers.py +22 -0
- django_cfg/apps/payments/config/settings.py +53 -93
- django_cfg/apps/payments/config/utils.py +10 -156
- 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 +33 -0
- django_cfg/apps/payments/migrations/0001_initial.py +94 -1
- django_cfg/apps/payments/models/payments.py +110 -0
- django_cfg/apps/payments/services/__init__.py +7 -1
- django_cfg/apps/payments/services/core/balance_service.py +14 -16
- django_cfg/apps/payments/services/core/fallback_service.py +432 -0
- django_cfg/apps/payments/services/core/payment_service.py +212 -29
- django_cfg/apps/payments/services/core/subscription_service.py +15 -17
- django_cfg/apps/payments/services/internal_types.py +31 -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 +3 -0
- django_cfg/apps/payments/services/providers/cryptapi.py +14 -3
- django_cfg/apps/payments/services/providers/cryptomus.py +310 -0
- django_cfg/apps/payments/services/providers/registry.py +4 -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/signals/api_key_signals.py +10 -0
- django_cfg/apps/payments/signals/payment_signals.py +3 -2
- django_cfg/apps/payments/tasks/__init__.py +12 -0
- django_cfg/apps/payments/tasks/webhook_processing.py +177 -0
- django_cfg/apps/payments/utils/__init__.py +7 -4
- django_cfg/apps/payments/utils/billing_utils.py +342 -0
- django_cfg/apps/payments/utils/config_utils.py +2 -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/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/tasks.py +51 -2
- django_cfg/modules/base.py +11 -5
- 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/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.23.dist-info → django_cfg-1.2.25.dist-info}/METADATA +10 -6
- {django_cfg-1.2.23.dist-info → django_cfg-1.2.25.dist-info}/RECORD +77 -51
- 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/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.23.dist-info → django_cfg-1.2.25.dist-info}/WHEEL +0 -0
- {django_cfg-1.2.23.dist-info → django_cfg-1.2.25.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.2.23.dist-info → django_cfg-1.2.25.dist-info}/licenses/LICENSE +0 -0
@@ -8,22 +8,27 @@ and payment lifecycle management.
|
|
8
8
|
import logging
|
9
9
|
from typing import Optional, List
|
10
10
|
from decimal import Decimal
|
11
|
-
from datetime import timezone
|
12
|
-
|
13
11
|
from django.db import transaction
|
14
12
|
from django.contrib.auth import get_user_model
|
13
|
+
from django.utils import timezone
|
15
14
|
from pydantic import BaseModel, Field, ValidationError
|
16
15
|
|
17
16
|
from .balance_service import BalanceService
|
17
|
+
from .fallback_service import get_fallback_service
|
18
18
|
from ...models import UniversalPayment, UserBalance, Transaction
|
19
19
|
from ...utils.config_utils import get_payments_config
|
20
20
|
from ..providers.registry import ProviderRegistry
|
21
|
+
from ..monitoring.provider_health import get_health_monitor
|
21
22
|
from ..internal_types import (
|
22
23
|
ProviderResponse, WebhookData, ServiceOperationResult,
|
23
24
|
BalanceUpdateRequest, AccessCheckRequest, AccessCheckResult,
|
24
|
-
PaymentCreationResult, WebhookProcessingResult, PaymentStatusResult
|
25
|
+
PaymentCreationResult, WebhookProcessingResult, PaymentStatusResult,
|
26
|
+
PaymentHistoryItem, ProviderInfo
|
25
27
|
)
|
26
28
|
|
29
|
+
# Import django_currency module for currency conversion
|
30
|
+
from django_cfg.modules.django_currency import convert_currency, CurrencyError
|
31
|
+
|
27
32
|
User = get_user_model()
|
28
33
|
logger = logging.getLogger(__name__)
|
29
34
|
|
@@ -281,7 +286,7 @@ class PaymentService:
|
|
281
286
|
status: Optional[str] = None,
|
282
287
|
limit: int = 50,
|
283
288
|
offset: int = 0
|
284
|
-
) -> List[
|
289
|
+
) -> List[PaymentHistoryItem]:
|
285
290
|
"""
|
286
291
|
Get user's payment history.
|
287
292
|
|
@@ -292,7 +297,7 @@ class PaymentService:
|
|
292
297
|
offset: Pagination offset
|
293
298
|
|
294
299
|
Returns:
|
295
|
-
List of
|
300
|
+
List of PaymentHistoryItem objects
|
296
301
|
"""
|
297
302
|
try:
|
298
303
|
queryset = UniversalPayment.objects.filter(user=user)
|
@@ -303,16 +308,18 @@ class PaymentService:
|
|
303
308
|
payments = queryset.order_by('-created_at')[offset:offset+limit]
|
304
309
|
|
305
310
|
return [
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
311
|
+
PaymentHistoryItem(
|
312
|
+
id=str(payment.id),
|
313
|
+
user_id=payment.user.id,
|
314
|
+
amount=payment.pay_amount if payment.pay_amount else payment.amount_usd,
|
315
|
+
currency=payment.currency_code,
|
316
|
+
status=payment.status,
|
317
|
+
provider=payment.provider.name if payment.provider else 'unknown',
|
318
|
+
provider_payment_id=payment.provider_payment_id,
|
319
|
+
created_at=payment.created_at,
|
320
|
+
updated_at=payment.updated_at,
|
321
|
+
metadata=payment.metadata or {}
|
322
|
+
)
|
316
323
|
for payment in payments
|
317
324
|
]
|
318
325
|
|
@@ -356,7 +363,7 @@ class PaymentService:
|
|
356
363
|
|
357
364
|
def _convert_to_usd(self, amount: Decimal, currency: str) -> Decimal:
|
358
365
|
"""
|
359
|
-
Convert amount to USD using
|
366
|
+
Convert amount to USD using django_currency module.
|
360
367
|
|
361
368
|
Args:
|
362
369
|
amount: Amount to convert
|
@@ -368,26 +375,202 @@ class PaymentService:
|
|
368
375
|
if currency == 'USD':
|
369
376
|
return amount
|
370
377
|
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
378
|
+
try:
|
379
|
+
# Use django_currency module for conversion
|
380
|
+
converted_amount = convert_currency(
|
381
|
+
amount=float(amount),
|
382
|
+
from_currency=currency,
|
383
|
+
to_currency='USD'
|
384
|
+
)
|
385
|
+
|
386
|
+
logger.info(f"Currency conversion: {amount} {currency} = {converted_amount} USD")
|
387
|
+
return Decimal(str(converted_amount))
|
388
|
+
|
389
|
+
except CurrencyError as e:
|
390
|
+
logger.error(f"Currency conversion failed for {amount} {currency} to USD: {e}")
|
391
|
+
# Fallback to 1:1 rate if conversion fails
|
392
|
+
logger.warning(f"Using 1:1 fallback rate for {currency} to USD")
|
393
|
+
return amount
|
394
|
+
|
395
|
+
except Exception as e:
|
396
|
+
logger.error(f"Unexpected error in currency conversion: {e}")
|
397
|
+
# Fallback to 1:1 rate for any other errors
|
398
|
+
logger.warning(f"Using 1:1 fallback rate for {currency} to USD due to error")
|
399
|
+
return amount
|
400
|
+
|
401
|
+
def process_webhook(self, provider: str, webhook_data: dict, headers: dict = None) -> 'WebhookProcessingResult':
|
402
|
+
"""
|
403
|
+
Process webhook from payment provider.
|
404
|
+
|
405
|
+
Args:
|
406
|
+
provider: Provider name
|
407
|
+
webhook_data: Webhook payload
|
408
|
+
headers: Request headers for validation
|
409
|
+
|
410
|
+
Returns:
|
411
|
+
WebhookProcessingResult with processing status
|
412
|
+
"""
|
413
|
+
try:
|
414
|
+
# Get provider instance for validation
|
415
|
+
provider_instance = self.provider_registry.get_provider(provider)
|
416
|
+
if not provider_instance:
|
417
|
+
return WebhookProcessingResult(
|
418
|
+
success=False,
|
419
|
+
error_message=f"Unknown provider: {provider}"
|
420
|
+
)
|
421
|
+
|
422
|
+
# Validate webhook
|
423
|
+
if hasattr(provider_instance, 'validate_webhook'):
|
424
|
+
is_valid = provider_instance.validate_webhook(webhook_data, headers)
|
425
|
+
if not is_valid:
|
426
|
+
logger.warning(f"Invalid webhook from {provider}: {webhook_data}")
|
427
|
+
return WebhookProcessingResult(
|
428
|
+
success=False,
|
429
|
+
error_message="Webhook validation failed"
|
430
|
+
)
|
431
|
+
|
432
|
+
# Process webhook data
|
433
|
+
processed_data = provider_instance.process_webhook(webhook_data)
|
434
|
+
|
435
|
+
# Find payment record
|
436
|
+
payment_id = processed_data.payment_id
|
437
|
+
if not payment_id:
|
438
|
+
return WebhookProcessingResult(
|
439
|
+
success=False,
|
440
|
+
error_message="No payment ID found in webhook"
|
441
|
+
)
|
442
|
+
|
443
|
+
# Update payment
|
444
|
+
with transaction.atomic():
|
445
|
+
try:
|
446
|
+
payment = UniversalPayment.objects.get(
|
447
|
+
provider_payment_id=payment_id,
|
448
|
+
provider=provider
|
449
|
+
)
|
450
|
+
|
451
|
+
# Update payment status and data
|
452
|
+
old_status = payment.status
|
453
|
+
payment.update_from_webhook(webhook_data)
|
454
|
+
|
455
|
+
# Create event for audit trail
|
456
|
+
self._create_payment_event(
|
457
|
+
payment=payment,
|
458
|
+
event_type='webhook_processed',
|
459
|
+
data={
|
460
|
+
'provider': provider,
|
461
|
+
'old_status': old_status,
|
462
|
+
'new_status': payment.status,
|
463
|
+
'webhook_data': webhook_data
|
464
|
+
}
|
465
|
+
)
|
466
|
+
|
467
|
+
# Process completion if needed
|
468
|
+
if payment.is_completed and old_status != payment.status:
|
469
|
+
success = self._process_payment_completion(payment)
|
470
|
+
if success:
|
471
|
+
payment.processed_at = timezone.now()
|
472
|
+
payment.save()
|
473
|
+
|
474
|
+
return WebhookProcessingResult(
|
475
|
+
success=True,
|
476
|
+
payment_id=str(payment.id),
|
477
|
+
new_status=payment.status
|
478
|
+
)
|
479
|
+
|
480
|
+
except UniversalPayment.DoesNotExist:
|
481
|
+
logger.error(f"Payment not found for webhook: provider={provider}, payment_id={payment_id}")
|
482
|
+
return WebhookProcessingResult(
|
483
|
+
success=False,
|
484
|
+
error_message="Payment not found"
|
485
|
+
)
|
486
|
+
|
487
|
+
except Exception as e:
|
488
|
+
logger.error(f"Error processing webhook from {provider}: {e}")
|
489
|
+
return WebhookProcessingResult(
|
490
|
+
success=False,
|
491
|
+
error_message=str(e)
|
492
|
+
)
|
375
493
|
|
494
|
+
def _create_payment_event(self, payment: UniversalPayment, event_type: str, data: dict):
|
495
|
+
"""
|
496
|
+
Create payment event for audit trail.
|
497
|
+
|
498
|
+
Args:
|
499
|
+
payment: Payment object
|
500
|
+
event_type: Type of event
|
501
|
+
data: Event data
|
502
|
+
"""
|
503
|
+
try:
|
504
|
+
from ...models.events import PaymentEvent
|
505
|
+
|
506
|
+
# Get next sequence number
|
507
|
+
last_event = PaymentEvent.objects.filter(
|
508
|
+
payment_id=str(payment.id)
|
509
|
+
).order_by('-sequence_number').first()
|
510
|
+
|
511
|
+
sequence_number = (last_event.sequence_number + 1) if last_event else 1
|
512
|
+
|
513
|
+
PaymentEvent.objects.create(
|
514
|
+
payment_id=str(payment.id),
|
515
|
+
event_type=event_type,
|
516
|
+
sequence_number=sequence_number,
|
517
|
+
event_data=data,
|
518
|
+
processed_by=f"payment_service_{timezone.now().timestamp()}",
|
519
|
+
correlation_id=data.get('correlation_id'),
|
520
|
+
idempotency_key=f"{payment.id}_{event_type}_{sequence_number}"
|
521
|
+
)
|
522
|
+
|
523
|
+
except Exception as e:
|
524
|
+
logger.error(f"Failed to create payment event: {e}")
|
376
525
|
|
377
|
-
def
|
526
|
+
def get_payment_events(self, payment_id: str) -> List[dict]:
|
527
|
+
"""
|
528
|
+
Get all events for a payment.
|
529
|
+
|
530
|
+
Args:
|
531
|
+
payment_id: Payment ID
|
532
|
+
|
533
|
+
Returns:
|
534
|
+
List of payment events
|
535
|
+
"""
|
536
|
+
try:
|
537
|
+
from ...models.events import PaymentEvent
|
538
|
+
|
539
|
+
events = PaymentEvent.objects.filter(
|
540
|
+
payment_id=payment_id
|
541
|
+
).order_by('sequence_number')
|
542
|
+
|
543
|
+
return [
|
544
|
+
{
|
545
|
+
'id': str(event.id),
|
546
|
+
'event_type': event.event_type,
|
547
|
+
'sequence_number': event.sequence_number,
|
548
|
+
'event_data': event.event_data,
|
549
|
+
'created_at': event.created_at,
|
550
|
+
'processed_by': event.processed_by
|
551
|
+
}
|
552
|
+
for event in events
|
553
|
+
]
|
554
|
+
|
555
|
+
except Exception as e:
|
556
|
+
logger.error(f"Error getting payment events for {payment_id}: {e}")
|
557
|
+
return []
|
558
|
+
|
559
|
+
|
560
|
+
def list_available_providers(self) -> List[ProviderInfo]:
|
378
561
|
"""
|
379
562
|
List all available payment providers.
|
380
563
|
|
381
564
|
Returns:
|
382
|
-
List of
|
565
|
+
List of ProviderInfo objects
|
383
566
|
"""
|
384
567
|
return [
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
'provider_type': provider.get_provider_type()
|
391
|
-
|
568
|
+
ProviderInfo(
|
569
|
+
name=name,
|
570
|
+
display_name=provider.get_display_name(),
|
571
|
+
supported_currencies=provider.get_supported_currencies(),
|
572
|
+
is_active=provider.is_active(),
|
573
|
+
features={'provider_type': provider.get_provider_type()}
|
574
|
+
)
|
392
575
|
for name, provider in self.provider_registry.get_all_providers().items()
|
393
576
|
]
|
@@ -91,7 +91,7 @@ class SubscriptionService:
|
|
91
91
|
existing = Subscription.objects.filter(
|
92
92
|
user=user,
|
93
93
|
endpoint_group=endpoint_group,
|
94
|
-
status=
|
94
|
+
status=Subscription.SubscriptionStatus.ACTIVE,
|
95
95
|
expires_at__gt=timezone.now()
|
96
96
|
).first()
|
97
97
|
|
@@ -105,8 +105,8 @@ class SubscriptionService:
|
|
105
105
|
subscription = Subscription.objects.create(
|
106
106
|
user=user,
|
107
107
|
endpoint_group=endpoint_group,
|
108
|
-
tier=
|
109
|
-
status=
|
108
|
+
tier=Subscription.SubscriptionTier.BASIC,
|
109
|
+
status=Subscription.SubscriptionStatus.ACTIVE,
|
110
110
|
monthly_price=endpoint_group.basic_price,
|
111
111
|
usage_limit=endpoint_group.basic_limit,
|
112
112
|
usage_current=0,
|
@@ -268,7 +268,7 @@ class SubscriptionService:
|
|
268
268
|
|
269
269
|
if active_only:
|
270
270
|
queryset = queryset.filter(
|
271
|
-
status=
|
271
|
+
status=Subscription.SubscriptionStatus.ACTIVE,
|
272
272
|
expires_at__gt=timezone.now()
|
273
273
|
)
|
274
274
|
|
@@ -393,20 +393,18 @@ class SubscriptionService:
|
|
393
393
|
error_message="Subscription not found"
|
394
394
|
)
|
395
395
|
|
396
|
-
#
|
397
|
-
if billing_period:
|
398
|
-
subscription.billing_period = billing_period
|
399
|
-
|
400
|
-
# Calculate new expiry
|
396
|
+
# Calculate new expiry based on billing period
|
401
397
|
now = timezone.now()
|
402
|
-
|
403
|
-
|
398
|
+
if billing_period == 'yearly':
|
399
|
+
new_expiry = now + timedelta(days=365)
|
400
|
+
else: # Default to monthly
|
401
|
+
new_expiry = now + timedelta(days=30)
|
404
402
|
|
405
|
-
# Update subscription
|
403
|
+
# Update subscription using correct enum
|
406
404
|
subscription.expires_at = new_expiry
|
407
|
-
subscription.next_billing = new_expiry
|
408
|
-
subscription.status =
|
409
|
-
subscription.usage_current = 0 # Reset usage
|
405
|
+
subscription.next_billing = new_expiry
|
406
|
+
subscription.status = subscription.SubscriptionStatus.ACTIVE # Use proper enum
|
407
|
+
subscription.usage_current = 0 # Reset usage counter
|
410
408
|
subscription.save()
|
411
409
|
|
412
410
|
|
@@ -527,7 +525,7 @@ class SubscriptionService:
|
|
527
525
|
subscription = Subscription.objects.select_related('endpoint_group').get(
|
528
526
|
user_id=user_id,
|
529
527
|
endpoint_group__name=endpoint_group,
|
530
|
-
status=
|
528
|
+
status=Subscription.SubscriptionStatus.ACTIVE,
|
531
529
|
expires_at__gt=timezone.now()
|
532
530
|
)
|
533
531
|
|
@@ -586,7 +584,7 @@ class SubscriptionService:
|
|
586
584
|
subscription = Subscription.objects.select_related('endpoint_group').get(
|
587
585
|
user_id=user_id,
|
588
586
|
endpoint_group__name=endpoint_group,
|
589
|
-
status=
|
587
|
+
status=Subscription.SubscriptionStatus.ACTIVE,
|
590
588
|
expires_at__gt=timezone.now()
|
591
589
|
)
|
592
590
|
|
@@ -264,3 +264,34 @@ class SubscriptionAnalytics(BaseModel):
|
|
264
264
|
new_subscriptions: int
|
265
265
|
churned_subscriptions: int
|
266
266
|
error: Optional[str] = None
|
267
|
+
|
268
|
+
|
269
|
+
# =============================================================================
|
270
|
+
# ADDITIONAL RESPONSE MODELS - Missing Pydantic models
|
271
|
+
# =============================================================================
|
272
|
+
|
273
|
+
class PaymentHistoryItem(BaseModel):
|
274
|
+
"""Single payment item for history lists"""
|
275
|
+
model_config = ConfigDict(validate_assignment=True, extra="forbid")
|
276
|
+
|
277
|
+
id: str
|
278
|
+
user_id: int
|
279
|
+
amount: Decimal
|
280
|
+
currency: str
|
281
|
+
status: str
|
282
|
+
provider: str
|
283
|
+
provider_payment_id: Optional[str] = None
|
284
|
+
created_at: datetime
|
285
|
+
updated_at: datetime
|
286
|
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|
287
|
+
|
288
|
+
|
289
|
+
class ProviderInfo(BaseModel):
|
290
|
+
"""Payment provider information"""
|
291
|
+
model_config = ConfigDict(validate_assignment=True, extra="forbid")
|
292
|
+
|
293
|
+
name: str
|
294
|
+
display_name: str
|
295
|
+
supported_currencies: list[str] = Field(default_factory=list)
|
296
|
+
is_active: bool
|
297
|
+
features: Dict[str, Any] = Field(default_factory=dict)
|
@@ -0,0 +1,22 @@
|
|
1
|
+
"""
|
2
|
+
Payment system monitoring services.
|
3
|
+
|
4
|
+
Provides health monitoring, alerting, and fallback mechanisms
|
5
|
+
for payment providers and system components.
|
6
|
+
"""
|
7
|
+
|
8
|
+
from .provider_health import (
|
9
|
+
ProviderHealthMonitor,
|
10
|
+
ProviderHealthCheck,
|
11
|
+
ProviderHealthSummary,
|
12
|
+
HealthStatus,
|
13
|
+
get_health_monitor
|
14
|
+
)
|
15
|
+
|
16
|
+
__all__ = [
|
17
|
+
'ProviderHealthMonitor',
|
18
|
+
'ProviderHealthCheck',
|
19
|
+
'ProviderHealthSummary',
|
20
|
+
'HealthStatus',
|
21
|
+
'get_health_monitor'
|
22
|
+
]
|
@@ -0,0 +1,222 @@
|
|
1
|
+
"""
|
2
|
+
Pydantic schemas for provider API responses.
|
3
|
+
|
4
|
+
Type-safe models for validating and parsing responses
|
5
|
+
from payment provider health check endpoints.
|
6
|
+
"""
|
7
|
+
|
8
|
+
from typing import Dict, Optional, Any
|
9
|
+
from decimal import Decimal
|
10
|
+
from datetime import datetime
|
11
|
+
from pydantic import BaseModel, Field, validator
|
12
|
+
|
13
|
+
|
14
|
+
class CryptAPIInfoResponse(BaseModel):
|
15
|
+
"""CryptAPI /btc/info/ response schema."""
|
16
|
+
|
17
|
+
coin: str = Field(..., description="Cryptocurrency name")
|
18
|
+
logo: str = Field(..., description="Logo URL")
|
19
|
+
ticker: str = Field(..., description="Currency ticker")
|
20
|
+
minimum_transaction: int = Field(..., description="Minimum transaction in satoshis")
|
21
|
+
minimum_transaction_coin: str = Field(..., description="Minimum transaction in coin units")
|
22
|
+
minimum_fee: int = Field(..., description="Minimum fee in satoshis")
|
23
|
+
minimum_fee_coin: str = Field(..., description="Minimum fee in coin units")
|
24
|
+
fee_percent: str = Field(..., description="Fee percentage")
|
25
|
+
network_fee_estimation: str = Field(..., description="Network fee estimation")
|
26
|
+
status: str = Field(..., description="API status")
|
27
|
+
prices: Dict[str, str] = Field(..., description="Prices in various fiat currencies")
|
28
|
+
prices_updated: str = Field(..., description="Prices last updated timestamp")
|
29
|
+
|
30
|
+
@validator('status')
|
31
|
+
def validate_status(cls, v):
|
32
|
+
"""Validate that status is success."""
|
33
|
+
if v != 'success':
|
34
|
+
raise ValueError(f"Expected status 'success', got '{v}'")
|
35
|
+
return v
|
36
|
+
|
37
|
+
@validator('prices')
|
38
|
+
def validate_prices_not_empty(cls, v):
|
39
|
+
"""Validate that prices dict is not empty."""
|
40
|
+
if not v:
|
41
|
+
raise ValueError("Prices dictionary cannot be empty")
|
42
|
+
return v
|
43
|
+
|
44
|
+
def get_usd_price(self) -> Optional[Decimal]:
|
45
|
+
"""Get USD price as Decimal."""
|
46
|
+
usd_price = self.prices.get('USD')
|
47
|
+
if usd_price:
|
48
|
+
try:
|
49
|
+
return Decimal(usd_price)
|
50
|
+
except:
|
51
|
+
return None
|
52
|
+
return None
|
53
|
+
|
54
|
+
|
55
|
+
class NowPaymentsStatusResponse(BaseModel):
|
56
|
+
"""NowPayments /v1/status response schema."""
|
57
|
+
|
58
|
+
message: str = Field(..., description="Status message")
|
59
|
+
|
60
|
+
@validator('message')
|
61
|
+
def validate_message_ok(cls, v):
|
62
|
+
"""Validate that message is OK."""
|
63
|
+
if v.upper() != 'OK':
|
64
|
+
raise ValueError(f"Expected message 'OK', got '{v}'")
|
65
|
+
return v
|
66
|
+
|
67
|
+
|
68
|
+
class StripeErrorResponse(BaseModel):
|
69
|
+
"""Stripe API error response schema."""
|
70
|
+
|
71
|
+
class StripeError(BaseModel):
|
72
|
+
message: str = Field(..., description="Error message")
|
73
|
+
type: str = Field(..., description="Error type")
|
74
|
+
|
75
|
+
error: StripeError = Field(..., description="Error details")
|
76
|
+
|
77
|
+
@validator('error')
|
78
|
+
def validate_auth_error(cls, v):
|
79
|
+
"""Validate this is an authentication error (meaning API is healthy)."""
|
80
|
+
if v.type != 'invalid_request_error':
|
81
|
+
raise ValueError(f"Expected auth error, got '{v.type}'")
|
82
|
+
return v
|
83
|
+
|
84
|
+
|
85
|
+
class CryptomusErrorResponse(BaseModel):
|
86
|
+
"""Cryptomus API error response schema."""
|
87
|
+
|
88
|
+
error: str = Field(..., description="Error message")
|
89
|
+
|
90
|
+
@validator('error')
|
91
|
+
def validate_not_found_error(cls, v):
|
92
|
+
"""Validate this is a not found error (meaning API is responding)."""
|
93
|
+
if v.lower() not in ['not found', 'unauthorized', 'forbidden']:
|
94
|
+
raise ValueError(f"Unexpected error: {v}")
|
95
|
+
return v
|
96
|
+
|
97
|
+
|
98
|
+
class GenericAPIHealthResponse(BaseModel):
|
99
|
+
"""Generic API health response for unknown formats."""
|
100
|
+
|
101
|
+
status_code: int = Field(..., description="HTTP status code")
|
102
|
+
response_body: str = Field(..., description="Raw response body")
|
103
|
+
response_time_ms: float = Field(..., description="Response time in milliseconds")
|
104
|
+
|
105
|
+
def is_healthy(self) -> bool:
|
106
|
+
"""Determine if API is healthy based on status code."""
|
107
|
+
# 2xx = healthy, 401/403 = healthy (auth required), 4xx = degraded, 5xx = unhealthy
|
108
|
+
if 200 <= self.status_code < 300:
|
109
|
+
return True
|
110
|
+
elif self.status_code in [401, 403]:
|
111
|
+
return True # Auth required but API responding
|
112
|
+
else:
|
113
|
+
return False
|
114
|
+
|
115
|
+
|
116
|
+
class ProviderHealthResponse(BaseModel):
|
117
|
+
"""Unified health response model for all providers."""
|
118
|
+
|
119
|
+
provider_name: str = Field(..., description="Provider name")
|
120
|
+
is_healthy: bool = Field(..., description="Is provider healthy")
|
121
|
+
status_code: int = Field(..., description="HTTP status code")
|
122
|
+
response_time_ms: float = Field(..., description="Response time in milliseconds")
|
123
|
+
error_message: Optional[str] = Field(None, description="Error message if unhealthy")
|
124
|
+
parsed_response: Optional[Dict[str, Any]] = Field(None, description="Parsed API response")
|
125
|
+
raw_response: Optional[str] = Field(None, description="Raw response body")
|
126
|
+
checked_at: datetime = Field(default_factory=datetime.now, description="Check timestamp")
|
127
|
+
|
128
|
+
class Config:
|
129
|
+
json_encoders = {
|
130
|
+
datetime: lambda v: v.isoformat()
|
131
|
+
}
|
132
|
+
|
133
|
+
|
134
|
+
def parse_provider_response(provider_name: str, status_code: int, response_body: str, response_time_ms: float) -> ProviderHealthResponse:
|
135
|
+
"""
|
136
|
+
Parse provider API response using appropriate schema.
|
137
|
+
|
138
|
+
Args:
|
139
|
+
provider_name: Name of the provider
|
140
|
+
status_code: HTTP status code
|
141
|
+
response_body: Raw response body
|
142
|
+
response_time_ms: Response time in milliseconds
|
143
|
+
|
144
|
+
Returns:
|
145
|
+
ProviderHealthResponse with parsed data
|
146
|
+
"""
|
147
|
+
parsed_response = None
|
148
|
+
error_message = None
|
149
|
+
is_healthy = False
|
150
|
+
|
151
|
+
try:
|
152
|
+
import json
|
153
|
+
response_json = json.loads(response_body) if response_body else {}
|
154
|
+
|
155
|
+
if provider_name == 'cryptapi':
|
156
|
+
if status_code == 200:
|
157
|
+
cryptapi_response = CryptAPIInfoResponse(**response_json)
|
158
|
+
parsed_response = cryptapi_response.dict()
|
159
|
+
is_healthy = True
|
160
|
+
else:
|
161
|
+
error_message = f"CryptAPI returned status {status_code}"
|
162
|
+
|
163
|
+
elif provider_name == 'nowpayments':
|
164
|
+
if status_code == 200:
|
165
|
+
nowpayments_response = NowPaymentsStatusResponse(**response_json)
|
166
|
+
parsed_response = nowpayments_response.dict()
|
167
|
+
is_healthy = True
|
168
|
+
else:
|
169
|
+
error_message = f"NowPayments returned status {status_code}"
|
170
|
+
|
171
|
+
elif provider_name == 'stripe':
|
172
|
+
if status_code == 401:
|
173
|
+
stripe_response = StripeErrorResponse(**response_json)
|
174
|
+
parsed_response = stripe_response.dict()
|
175
|
+
is_healthy = True # Auth error = API responding
|
176
|
+
elif 200 <= status_code < 300:
|
177
|
+
parsed_response = response_json
|
178
|
+
is_healthy = True
|
179
|
+
else:
|
180
|
+
error_message = f"Stripe returned unexpected status {status_code}"
|
181
|
+
|
182
|
+
elif provider_name == 'cryptomus':
|
183
|
+
if status_code == 404 and response_json.get('error') == 'Not found':
|
184
|
+
cryptomus_response = CryptomusErrorResponse(**response_json)
|
185
|
+
parsed_response = cryptomus_response.dict()
|
186
|
+
is_healthy = True # Not found = API responding
|
187
|
+
elif status_code == 204:
|
188
|
+
# No Content = API responding and healthy
|
189
|
+
parsed_response = {'status': 'no_content', 'message': 'API responding correctly'}
|
190
|
+
is_healthy = True
|
191
|
+
elif status_code in [401, 403]:
|
192
|
+
is_healthy = True # Auth required = API responding
|
193
|
+
parsed_response = response_json
|
194
|
+
elif 200 <= status_code < 300:
|
195
|
+
parsed_response = response_json
|
196
|
+
is_healthy = True
|
197
|
+
else:
|
198
|
+
error_message = f"Cryptomus returned status {status_code}"
|
199
|
+
|
200
|
+
else:
|
201
|
+
# Generic handling for unknown providers
|
202
|
+
generic_response = GenericAPIHealthResponse(
|
203
|
+
status_code=status_code,
|
204
|
+
response_body=response_body,
|
205
|
+
response_time_ms=response_time_ms
|
206
|
+
)
|
207
|
+
parsed_response = generic_response.dict()
|
208
|
+
is_healthy = generic_response.is_healthy()
|
209
|
+
|
210
|
+
except Exception as e:
|
211
|
+
error_message = f"Failed to parse {provider_name} response: {str(e)}"
|
212
|
+
is_healthy = False
|
213
|
+
|
214
|
+
return ProviderHealthResponse(
|
215
|
+
provider_name=provider_name,
|
216
|
+
is_healthy=is_healthy,
|
217
|
+
status_code=status_code,
|
218
|
+
response_time_ms=response_time_ms,
|
219
|
+
error_message=error_message,
|
220
|
+
parsed_response=parsed_response,
|
221
|
+
raw_response=response_body
|
222
|
+
)
|