386 lines
16 KiB
Python
386 lines
16 KiB
Python
"""
|
||
Сериализаторы для подписок и платежей.
|
||
"""
|
||
from rest_framework import serializers
|
||
from django.utils import timezone
|
||
from datetime import timedelta
|
||
from .models import SubscriptionPlan, Subscription, Payment, PaymentHistory, SubscriptionUsageLog, PromoCode, BulkDiscount
|
||
from apps.users.mixins import TimezoneAwareSerializerMixin
|
||
|
||
|
||
class SubscriptionPlanSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор тарифного плана."""
|
||
|
||
features = serializers.SerializerMethodField()
|
||
bulk_discounts = serializers.SerializerMethodField()
|
||
duration_discounts = serializers.SerializerMethodField()
|
||
available_durations = serializers.SerializerMethodField()
|
||
promo_info = serializers.SerializerMethodField()
|
||
price = serializers.DecimalField(max_digits=10, decimal_places=2, coerce_to_string=False)
|
||
price_per_student = serializers.DecimalField(max_digits=10, decimal_places=2, coerce_to_string=False, allow_null=True)
|
||
|
||
class Meta:
|
||
model = SubscriptionPlan
|
||
fields = [
|
||
'id',
|
||
'name',
|
||
'slug',
|
||
'description',
|
||
'price',
|
||
'price_per_student',
|
||
'currency',
|
||
'billing_period',
|
||
'subscription_type',
|
||
'trial_days',
|
||
'max_clients',
|
||
'max_lessons_per_month',
|
||
'max_storage_mb',
|
||
'max_video_minutes_per_month',
|
||
'target_role',
|
||
'features',
|
||
'bulk_discounts',
|
||
'duration_discounts',
|
||
'available_durations',
|
||
'promo_info',
|
||
'is_active',
|
||
'is_featured',
|
||
'sort_order',
|
||
'subscribers_count'
|
||
]
|
||
read_only_fields = ['subscribers_count']
|
||
|
||
def to_representation(self, instance):
|
||
"""Преобразование в представление для API."""
|
||
data = super().to_representation(instance)
|
||
# Преобразуем Decimal в float для JSON
|
||
if data.get('price') is not None:
|
||
data['price'] = float(data['price'])
|
||
if data.get('price_per_student') is not None:
|
||
data['price_per_student'] = float(data['price_per_student'])
|
||
return data
|
||
|
||
def get_bulk_discounts(self, obj):
|
||
"""Получить прогрессирующие скидки для плана."""
|
||
if obj.subscription_type == 'per_student':
|
||
from decimal import Decimal
|
||
discounts = obj.bulk_discounts.all().order_by('min_students')
|
||
result = []
|
||
base_price = obj.price_per_student or obj.price
|
||
|
||
for discount in discounts:
|
||
# Рассчитываем цену за одного ученика в этом диапазоне
|
||
# total_price - это цена за минимальное количество учеников в диапазоне
|
||
# Например: для диапазона 5-9, total_price = 420 означает 420 руб за 5 учеников
|
||
# Значит цена за одного = 420/5 = 84 руб
|
||
students_for_calc = discount.min_students
|
||
|
||
price_per_student = discount.total_price / Decimal(str(students_for_calc))
|
||
result.append({
|
||
'min_students': discount.min_students,
|
||
'max_students': discount.max_students,
|
||
'total_price': float(discount.total_price),
|
||
'price_per_student': float(price_per_student),
|
||
'base_price': float(base_price) # Базовая цена для сравнения
|
||
})
|
||
return result
|
||
return []
|
||
|
||
def get_duration_discounts(self, obj):
|
||
"""
|
||
Получить скидки за длительность для плана (определяют доступные периоды оплаты).
|
||
|
||
ВАЖНО: Периоды, для которых указаны скидки, автоматически становятся доступными для оплаты.
|
||
Скидки применяются только к ежемесячным тарифам (subscription_type='monthly').
|
||
Для тарифов "За ученика" скидки не применяются, но периоды определяют доступные варианты оплаты.
|
||
"""
|
||
discounts = obj.duration_discounts.all().order_by('duration_days')
|
||
result = []
|
||
for discount in discounts:
|
||
result.append({
|
||
'duration_days': discount.duration_days,
|
||
'discount_percent': float(discount.discount_percent) if obj.subscription_type == 'monthly' else 0,
|
||
'months': discount.duration_days / 30,
|
||
'is_available': True, # Период доступен для оплаты
|
||
'applies_discount': obj.subscription_type == 'monthly', # Скидка применяется только для monthly
|
||
})
|
||
return result
|
||
|
||
def get_features(self, obj):
|
||
"""Список доступных функций."""
|
||
return {
|
||
'video_calls': obj.allow_video_calls,
|
||
'screen_sharing': obj.allow_screen_sharing,
|
||
'whiteboard': obj.allow_whiteboard,
|
||
'homework': obj.allow_homework,
|
||
'materials': obj.allow_materials,
|
||
'analytics': obj.allow_analytics,
|
||
'telegram_bot': obj.allow_telegram_bot,
|
||
'api_access': obj.allow_api_access,
|
||
}
|
||
|
||
def get_available_durations(self, obj):
|
||
"""Получить доступные периоды оплаты."""
|
||
return obj.get_available_durations()
|
||
|
||
def get_promo_info(self, obj):
|
||
"""Информация об акции."""
|
||
return {
|
||
'promo_type': obj.promo_type,
|
||
'promo_type_display': obj.get_promo_type_display(),
|
||
'max_uses': obj.max_uses,
|
||
'current_uses': obj.current_uses,
|
||
'remaining_uses': obj.max_uses - obj.current_uses if obj.max_uses else None,
|
||
'is_limited': obj.promo_type == 'limited_uses' and obj.max_uses is not None,
|
||
'is_first_time_only': obj.promo_type == 'first_time',
|
||
}
|
||
|
||
|
||
class SubscriptionSerializer(TimezoneAwareSerializerMixin, serializers.ModelSerializer):
|
||
"""Сериализатор подписки."""
|
||
|
||
plan = SubscriptionPlanSerializer(read_only=True)
|
||
is_active_now = serializers.SerializerMethodField()
|
||
days_left = serializers.SerializerMethodField()
|
||
usage = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = Subscription
|
||
fields = [
|
||
'id',
|
||
'user',
|
||
'plan',
|
||
'status',
|
||
'start_date',
|
||
'end_date',
|
||
'trial_end_date',
|
||
'cancelled_at',
|
||
'auto_renew',
|
||
'student_count',
|
||
'is_active_now',
|
||
'days_left',
|
||
'usage',
|
||
'created_at'
|
||
]
|
||
read_only_fields = ['user', 'created_at']
|
||
timezone_aware_fields = ['start_date', 'end_date', 'trial_end_date', 'cancelled_at', 'created_at']
|
||
|
||
def get_is_active_now(self, obj):
|
||
"""Активна ли подписка сейчас."""
|
||
return obj.is_active()
|
||
|
||
def get_days_left(self, obj):
|
||
"""Дней до истечения."""
|
||
return obj.days_until_expiration()
|
||
|
||
def get_usage(self, obj):
|
||
"""Статистика использования."""
|
||
plan = obj.plan
|
||
|
||
usage = {
|
||
'lessons': {
|
||
'used': obj.lessons_used,
|
||
'limit': plan.max_lessons_per_month,
|
||
'unlimited': plan.max_lessons_per_month is None
|
||
},
|
||
'storage': {
|
||
'used_mb': obj.storage_used_mb,
|
||
'limit_mb': plan.max_storage_mb,
|
||
'percentage': round((obj.storage_used_mb / plan.max_storage_mb) * 100, 2) if plan.max_storage_mb > 0 else 0
|
||
},
|
||
'video_minutes': {
|
||
'used': obj.video_minutes_used,
|
||
'limit': plan.max_video_minutes_per_month,
|
||
'unlimited': plan.max_video_minutes_per_month is None
|
||
}
|
||
}
|
||
|
||
return usage
|
||
|
||
|
||
class SubscriptionCreateSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор создания подписки."""
|
||
|
||
plan_id = serializers.IntegerField()
|
||
student_count = serializers.IntegerField(default=0, min_value=0)
|
||
duration_days = serializers.IntegerField(required=False, allow_null=True, min_value=1)
|
||
promo_code = serializers.CharField(required=False, allow_blank=True, allow_null=True)
|
||
start_date = serializers.DateTimeField(required=False, allow_null=True)
|
||
|
||
class Meta:
|
||
model = Subscription
|
||
fields = ['plan_id', 'student_count', 'duration_days', 'promo_code', 'start_date', 'auto_renew']
|
||
|
||
def validate_plan_id(self, value):
|
||
"""Валидация плана."""
|
||
try:
|
||
plan = SubscriptionPlan.objects.get(id=value, is_active=True)
|
||
except SubscriptionPlan.DoesNotExist:
|
||
raise serializers.ValidationError('Тарифный план не найден')
|
||
return value
|
||
|
||
def validate_duration_days(self, value):
|
||
"""Валидация длительности — любое положительное число дней."""
|
||
if value is not None and value < 1:
|
||
raise serializers.ValidationError('Длительность должна быть не менее 1 дня')
|
||
return value
|
||
|
||
def validate_student_count(self, value):
|
||
"""Валидация количества учеников."""
|
||
if value < 0:
|
||
raise serializers.ValidationError('Количество учеников не может быть отрицательным')
|
||
return value
|
||
|
||
def validate(self, attrs):
|
||
"""Валидация данных."""
|
||
plan_id = attrs.get('plan_id')
|
||
student_count = attrs.get('student_count', 0)
|
||
|
||
try:
|
||
plan = SubscriptionPlan.objects.get(id=plan_id)
|
||
except SubscriptionPlan.DoesNotExist:
|
||
raise serializers.ValidationError({'plan_id': 'Тарифный план не найден'})
|
||
|
||
# Если duration_days не передан — берём из плана
|
||
if not attrs.get('duration_days'):
|
||
attrs['duration_days'] = plan.get_duration_days()
|
||
|
||
# Проверяем доступность тарифа (акция)
|
||
user = self.context.get('request').user if self.context.get('request') else None
|
||
can_use, error_message = plan.can_be_used(user)
|
||
if not can_use:
|
||
raise serializers.ValidationError({'plan_id': error_message})
|
||
|
||
# Для типа "за ученика" количество учеников обязательно
|
||
if plan.subscription_type == 'per_student' and student_count == 0:
|
||
raise serializers.ValidationError({
|
||
'student_count': 'Для подписки "За ученика" необходимо указать количество учеников'
|
||
})
|
||
|
||
# Для ежемесячной подписки количество учеников должно быть 0
|
||
if plan.subscription_type == 'monthly' and student_count > 0:
|
||
raise serializers.ValidationError({
|
||
'student_count': 'Для ежемесячной подписки количество учеников не требуется'
|
||
})
|
||
|
||
return attrs
|
||
|
||
def create(self, validated_data):
|
||
"""Создание подписки."""
|
||
from .services import SubscriptionService, PromoCodeService
|
||
|
||
plan_id = validated_data.pop('plan_id')
|
||
student_count = validated_data.pop('student_count', 0)
|
||
duration_days = validated_data.pop('duration_days', 30)
|
||
promo_code_str = validated_data.pop('promo_code', None)
|
||
start_date = validated_data.pop('start_date', None)
|
||
user = self.context['request'].user
|
||
|
||
plan = SubscriptionPlan.objects.get(id=plan_id)
|
||
|
||
# Валидация и применение промокода
|
||
promo_code = None
|
||
if promo_code_str:
|
||
promo_result = PromoCodeService.validate_promo_code(promo_code_str, user)
|
||
if not promo_result['valid']:
|
||
raise serializers.ValidationError({
|
||
'promo_code': promo_result['error']
|
||
})
|
||
promo_code = promo_result['promo_code']
|
||
|
||
# Создаем подписку через сервис
|
||
subscription = SubscriptionService.create_subscription(
|
||
user=user,
|
||
plan=plan,
|
||
student_count=student_count,
|
||
duration_days=duration_days,
|
||
start_date=start_date,
|
||
promo_code=promo_code
|
||
)
|
||
|
||
return subscription
|
||
|
||
|
||
class PaymentSerializer(TimezoneAwareSerializerMixin, serializers.ModelSerializer):
|
||
"""Сериализатор платежа."""
|
||
|
||
subscription = SubscriptionSerializer(read_only=True)
|
||
|
||
class Meta:
|
||
model = Payment
|
||
fields = [
|
||
'id',
|
||
'uuid',
|
||
'user',
|
||
'subscription',
|
||
'amount',
|
||
'currency',
|
||
'status',
|
||
'payment_method',
|
||
'external_id',
|
||
'description',
|
||
'created_at',
|
||
'paid_at',
|
||
'failed_at',
|
||
'refunded_at'
|
||
]
|
||
read_only_fields = ['user', 'uuid', 'created_at']
|
||
timezone_aware_fields = ['created_at', 'paid_at', 'failed_at', 'refunded_at']
|
||
|
||
|
||
class PaymentCreateSerializer(serializers.Serializer):
|
||
"""Сериализатор создания платежа."""
|
||
|
||
subscription_id = serializers.IntegerField()
|
||
payment_method = serializers.ChoiceField(choices=Payment.PAYMENT_METHOD_CHOICES)
|
||
return_url = serializers.URLField(required=False)
|
||
|
||
def validate_subscription_id(self, value):
|
||
"""Валидация подписки."""
|
||
try:
|
||
subscription = Subscription.objects.get(id=value)
|
||
# Проверяем что подписка принадлежит пользователю
|
||
if subscription.user != self.context['request'].user:
|
||
raise serializers.ValidationError('Подписка не найдена')
|
||
except Subscription.DoesNotExist:
|
||
raise serializers.ValidationError('Подписка не найдена')
|
||
|
||
return value
|
||
|
||
def create(self, validated_data):
|
||
"""Создание платежа."""
|
||
user = self.context['request'].user
|
||
subscription_id = validated_data['subscription_id']
|
||
payment_method = validated_data['payment_method']
|
||
|
||
subscription = Subscription.objects.get(id=subscription_id)
|
||
|
||
# Создаем платеж
|
||
payment = Payment.objects.create(
|
||
user=user,
|
||
subscription=subscription,
|
||
amount=subscription.plan.price,
|
||
currency=subscription.plan.currency,
|
||
payment_method=payment_method,
|
||
description=f"Оплата подписки {subscription.plan.name}"
|
||
)
|
||
|
||
return payment
|
||
|
||
|
||
class PaymentHistorySerializer(serializers.ModelSerializer):
|
||
"""Сериализатор истории платежа."""
|
||
|
||
class Meta:
|
||
model = PaymentHistory
|
||
fields = ['id', 'status', 'message', 'data', 'created_at']
|
||
read_only_fields = ['created_at']
|
||
|
||
|
||
class SubscriptionUsageLogSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор лога использования."""
|
||
|
||
class Meta:
|
||
model = SubscriptionUsageLog
|
||
fields = ['id', 'usage_type', 'amount', 'description', 'created_at']
|
||
read_only_fields = ['created_at']
|