uchill/backend/apps/subscriptions/serializers.py

386 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Сериализаторы для подписок и платежей.
"""
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']