1574 lines
57 KiB
Python
1574 lines
57 KiB
Python
"""
|
||
Модели для подписок и платежей.
|
||
"""
|
||
from django.db import models
|
||
from django.core.validators import MinValueValidator
|
||
from django.utils import timezone
|
||
from datetime import timedelta
|
||
from decimal import Decimal
|
||
import uuid
|
||
|
||
|
||
class BulkDiscount(models.Model):
|
||
"""
|
||
Модель прогрессирующих скидок для подписки "за ученика".
|
||
|
||
Пример:
|
||
- 1-4 ученика: 100 руб за каждого
|
||
- 5-9 учеников: 420 руб за 5 (84 руб за каждого)
|
||
- 10+ учеников: 850 руб за 10 (85 руб за каждого)
|
||
"""
|
||
|
||
plan = models.ForeignKey(
|
||
'SubscriptionPlan',
|
||
on_delete=models.CASCADE,
|
||
related_name='bulk_discounts',
|
||
verbose_name='Тарифный план'
|
||
)
|
||
|
||
min_students = models.IntegerField(
|
||
validators=[MinValueValidator(1)],
|
||
verbose_name='Минимальное количество учеников',
|
||
help_text='От какого количества учеников действует эта цена'
|
||
)
|
||
|
||
max_students = models.IntegerField(
|
||
null=True,
|
||
blank=True,
|
||
validators=[MinValueValidator(1)],
|
||
verbose_name='Максимальное количество учеников',
|
||
help_text='До какого количества учеников действует эта цена. Если не указано - без ограничений'
|
||
)
|
||
|
||
price_per_student = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
validators=[MinValueValidator(0)],
|
||
verbose_name='Цена за ученика',
|
||
help_text='Цена за одного ученика в этом диапазоне'
|
||
)
|
||
|
||
total_price = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
validators=[MinValueValidator(0)],
|
||
verbose_name='Итоговая цена',
|
||
help_text='Автоматически рассчитывается как: цена за ученика × минимальное количество учеников',
|
||
editable=False
|
||
)
|
||
|
||
# Временные метки
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name='Дата создания'
|
||
)
|
||
|
||
updated_at = models.DateTimeField(
|
||
auto_now=True,
|
||
verbose_name='Дата обновления'
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'bulk_discounts'
|
||
verbose_name = 'Прогрессирующая скидка'
|
||
verbose_name_plural = 'Прогрессирующие скидки'
|
||
ordering = ['plan', 'min_students']
|
||
indexes = [
|
||
models.Index(fields=['plan', 'min_students']),
|
||
]
|
||
constraints = [
|
||
models.CheckConstraint(
|
||
check=models.Q(max_students__isnull=True) | models.Q(max_students__gte=models.F('min_students')),
|
||
name='max_students_gte_min_students'
|
||
)
|
||
]
|
||
|
||
def __str__(self):
|
||
if self.max_students:
|
||
return f"{self.plan.name}: {self.min_students}-{self.max_students} учеников = {self.price_per_student} руб/ученик"
|
||
else:
|
||
return f"{self.plan.name}: {self.min_students}+ учеников = {self.price_per_student} руб/ученик"
|
||
|
||
def save(self, *args, **kwargs):
|
||
"""Автоматически рассчитываем total_price из price_per_student × min_students."""
|
||
if self.price_per_student and self.min_students:
|
||
self.total_price = self.price_per_student * Decimal(str(self.min_students))
|
||
super().save(*args, **kwargs)
|
||
|
||
def matches(self, student_count):
|
||
"""
|
||
Проверить, подходит ли эта скидка для указанного количества учеников.
|
||
|
||
Args:
|
||
student_count: количество учеников
|
||
|
||
Returns:
|
||
bool
|
||
"""
|
||
if student_count < self.min_students:
|
||
return False
|
||
|
||
if self.max_students is None:
|
||
return True
|
||
|
||
return student_count <= self.max_students
|
||
|
||
|
||
class DurationDiscount(models.Model):
|
||
"""
|
||
Модель скидок за длительность подписки.
|
||
|
||
ВАЖНО: Периоды, для которых указаны скидки, автоматически становятся доступными для оплаты.
|
||
Если скидок нет, доступны все стандартные периоды (30, 90, 180, 365 дней).
|
||
|
||
Пример:
|
||
- 3 месяца (90 дней): 7% скидка
|
||
- 6 месяцев (180 дней): 12% скидка
|
||
- 12 месяцев (365 дней): 18% скидка
|
||
|
||
Применяется к ежемесячным тарифам (subscription_type='monthly').
|
||
Для тарифов "За ученика" (per_student) скидки не применяются, но периоды определяют доступные варианты оплаты.
|
||
"""
|
||
|
||
plan = models.ForeignKey(
|
||
'SubscriptionPlan',
|
||
on_delete=models.CASCADE,
|
||
related_name='duration_discounts',
|
||
verbose_name='Тарифный план'
|
||
)
|
||
|
||
duration_days = models.IntegerField(
|
||
validators=[MinValueValidator(1)],
|
||
verbose_name='Длительность в днях',
|
||
help_text='Количество дней подписки (30, 90, 180, 365)'
|
||
)
|
||
|
||
discount_percent = models.DecimalField(
|
||
max_digits=5,
|
||
decimal_places=2,
|
||
validators=[MinValueValidator(0)],
|
||
verbose_name='Процент скидки',
|
||
help_text='Процент скидки за эту длительность (например, 7.00 для 7%)'
|
||
)
|
||
|
||
# Временные метки
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name='Дата создания'
|
||
)
|
||
|
||
updated_at = models.DateTimeField(
|
||
auto_now=True,
|
||
verbose_name='Дата обновления'
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'duration_discounts'
|
||
verbose_name = 'Скидка за длительность'
|
||
verbose_name_plural = 'Скидки за длительность'
|
||
ordering = ['plan', 'duration_days']
|
||
unique_together = [['plan', 'duration_days']]
|
||
indexes = [
|
||
models.Index(fields=['plan', 'duration_days']),
|
||
]
|
||
|
||
def __str__(self):
|
||
months = self.duration_days / 30
|
||
return f"{self.plan.name}: {months:.0f} мес ({self.duration_days} дней) - {self.discount_percent}%"
|
||
|
||
def matches(self, days):
|
||
"""Проверить, подходит ли эта скидка для указанной длительности."""
|
||
return self.duration_days == days
|
||
|
||
|
||
class SubscriptionPlan(models.Model):
|
||
"""
|
||
Модель тарифного плана.
|
||
"""
|
||
|
||
BILLING_PERIOD_CHOICES = [
|
||
('monthly', 'Ежемесячно'),
|
||
('quarterly', 'Ежеквартально'),
|
||
('yearly', 'Ежегодно'),
|
||
('lifetime', 'Навсегда'),
|
||
]
|
||
|
||
SUBSCRIPTION_TYPE_CHOICES = [
|
||
('per_student', 'За ученика'),
|
||
('monthly', 'Ежемесячная'),
|
||
]
|
||
|
||
# Основная информация
|
||
name = models.CharField(
|
||
max_length=100,
|
||
unique=True,
|
||
verbose_name='Название'
|
||
)
|
||
|
||
slug = models.SlugField(
|
||
max_length=100,
|
||
unique=True,
|
||
verbose_name='Слаг'
|
||
)
|
||
|
||
description = models.TextField(
|
||
blank=True,
|
||
verbose_name='Описание'
|
||
)
|
||
|
||
# Стоимость
|
||
price = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
validators=[MinValueValidator(0)],
|
||
verbose_name='Цена'
|
||
)
|
||
|
||
currency = models.CharField(
|
||
max_length=3,
|
||
default='RUB',
|
||
verbose_name='Валюта'
|
||
)
|
||
|
||
billing_period = models.CharField(
|
||
max_length=20,
|
||
choices=BILLING_PERIOD_CHOICES,
|
||
default='monthly',
|
||
verbose_name='Период оплаты'
|
||
)
|
||
|
||
subscription_type = models.CharField(
|
||
max_length=20,
|
||
choices=SUBSCRIPTION_TYPE_CHOICES,
|
||
default='monthly',
|
||
verbose_name='Тип подписки'
|
||
)
|
||
|
||
price_per_student = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
null=True,
|
||
blank=True,
|
||
validators=[MinValueValidator(0)],
|
||
verbose_name='Цена за ученика',
|
||
help_text='Используется для типа "За ученика"'
|
||
)
|
||
|
||
trial_days = models.IntegerField(
|
||
default=0,
|
||
validators=[MinValueValidator(0)],
|
||
verbose_name='Пробный период (дней)'
|
||
)
|
||
|
||
# Лимиты
|
||
max_clients = models.IntegerField(
|
||
null=True,
|
||
blank=True,
|
||
validators=[MinValueValidator(1)],
|
||
verbose_name='Максимум клиентов'
|
||
)
|
||
|
||
max_lessons_per_month = models.IntegerField(
|
||
null=True,
|
||
blank=True,
|
||
validators=[MinValueValidator(1)],
|
||
verbose_name='Максимум занятий в месяц'
|
||
)
|
||
|
||
max_storage_mb = models.IntegerField(
|
||
default=1024, # 1 GB
|
||
validators=[MinValueValidator(1)],
|
||
verbose_name='Максимум хранилища (МБ)'
|
||
)
|
||
|
||
max_video_minutes_per_month = models.IntegerField(
|
||
null=True,
|
||
blank=True,
|
||
validators=[MinValueValidator(1)],
|
||
verbose_name='Максимум минут видео в месяц'
|
||
)
|
||
|
||
# Функциональность
|
||
allow_video_calls = models.BooleanField(
|
||
default=True,
|
||
verbose_name='Видеозвонки'
|
||
)
|
||
|
||
allow_screen_sharing = models.BooleanField(
|
||
default=True,
|
||
verbose_name='Демонстрация экрана'
|
||
)
|
||
|
||
allow_whiteboard = models.BooleanField(
|
||
default=True,
|
||
verbose_name='Интерактивная доска'
|
||
)
|
||
|
||
allow_homework = models.BooleanField(
|
||
default=True,
|
||
verbose_name='Домашние задания'
|
||
)
|
||
|
||
allow_materials = models.BooleanField(
|
||
default=True,
|
||
verbose_name='Материалы'
|
||
)
|
||
|
||
allow_analytics = models.BooleanField(
|
||
default=True,
|
||
verbose_name='Аналитика'
|
||
)
|
||
|
||
allow_telegram_bot = models.BooleanField(
|
||
default=False,
|
||
verbose_name='Telegram бот'
|
||
)
|
||
|
||
allow_api_access = models.BooleanField(
|
||
default=False,
|
||
verbose_name='API доступ'
|
||
)
|
||
|
||
# Тип акции
|
||
PROMO_TYPE_CHOICES = [
|
||
('none', 'Без акции'),
|
||
('first_time', 'Только для новых пользователей'),
|
||
('limited_uses', 'Ограниченное количество использований'),
|
||
]
|
||
|
||
promo_type = models.CharField(
|
||
max_length=20,
|
||
choices=PROMO_TYPE_CHOICES,
|
||
default='none',
|
||
verbose_name='Тип акции',
|
||
help_text='Тип акции для данного тарифа'
|
||
)
|
||
|
||
max_uses = models.IntegerField(
|
||
null=True,
|
||
blank=True,
|
||
validators=[MinValueValidator(1)],
|
||
verbose_name='Максимальное количество использований',
|
||
help_text='Максимальное количество раз, которое можно использовать этот тариф (для типа "Ограниченное количество использований")'
|
||
)
|
||
|
||
current_uses = models.IntegerField(
|
||
default=0,
|
||
validators=[MinValueValidator(0)],
|
||
verbose_name='Текущее количество использований',
|
||
help_text='Сколько раз уже использован этот тариф'
|
||
)
|
||
|
||
# Целевая аудитория
|
||
TARGET_ROLE_CHOICES = [
|
||
('all', 'Для всех'),
|
||
('mentor', 'Для менторов'),
|
||
('client', 'Для студентов'),
|
||
('parent', 'Для родителей'),
|
||
]
|
||
|
||
target_role = models.CharField(
|
||
max_length=20,
|
||
choices=TARGET_ROLE_CHOICES,
|
||
default='all',
|
||
verbose_name='Для кого предназначена подписка',
|
||
help_text='Роль пользователя, для которой предназначена эта подписка'
|
||
)
|
||
|
||
# Настройки
|
||
is_active = models.BooleanField(
|
||
default=True,
|
||
verbose_name='Активен',
|
||
db_index=True
|
||
)
|
||
|
||
is_featured = models.BooleanField(
|
||
default=False,
|
||
verbose_name='Рекомендуемый'
|
||
)
|
||
|
||
sort_order = models.IntegerField(
|
||
default=0,
|
||
verbose_name='Порядок сортировки'
|
||
)
|
||
|
||
# Статистика
|
||
subscribers_count = models.IntegerField(
|
||
default=0,
|
||
verbose_name='Количество подписчиков'
|
||
)
|
||
|
||
# Временные метки
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name='Дата создания'
|
||
)
|
||
|
||
updated_at = models.DateTimeField(
|
||
auto_now=True,
|
||
verbose_name='Дата обновления'
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'subscription_plans'
|
||
verbose_name = 'Тарифный план'
|
||
verbose_name_plural = 'Тарифные планы'
|
||
ordering = ['sort_order', 'price']
|
||
indexes = [
|
||
models.Index(fields=['is_active', 'sort_order']),
|
||
models.Index(fields=['slug']),
|
||
models.Index(fields=['subscription_type', 'is_active']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.name} - {self.price} {self.currency}/{self.get_billing_period_display()}"
|
||
|
||
def get_duration_days(self, custom_days=None):
|
||
"""
|
||
Получить длительность подписки в днях.
|
||
|
||
Args:
|
||
custom_days: кастомная длительность в днях (30, 90, 180, 365)
|
||
|
||
Returns:
|
||
int: количество дней
|
||
"""
|
||
if custom_days:
|
||
return custom_days
|
||
|
||
if self.billing_period == 'monthly':
|
||
return 30
|
||
elif self.billing_period == 'quarterly':
|
||
return 90
|
||
elif self.billing_period == 'yearly':
|
||
return 365
|
||
elif self.billing_period == 'lifetime':
|
||
return 36500 # 100 лет
|
||
return 30
|
||
|
||
def get_available_durations(self):
|
||
"""
|
||
Получить список доступных периодов оплаты из скидок за длительность.
|
||
|
||
Returns:
|
||
list: список доступных периодов в днях [30, 90, 180, 365]
|
||
"""
|
||
# Получаем периоды из скидок за длительность
|
||
durations = list(self.duration_discounts.values_list('duration_days', flat=True))
|
||
|
||
# Если есть скидки, возвращаем их периоды
|
||
if durations:
|
||
return sorted(durations)
|
||
|
||
# Если скидок нет, доступны все стандартные периоды
|
||
return [30, 90, 180, 365]
|
||
|
||
def is_duration_available(self, duration_days):
|
||
"""
|
||
Проверить, доступен ли указанный период оплаты.
|
||
|
||
Args:
|
||
duration_days: период в днях (30, 90, 180, 365)
|
||
|
||
Returns:
|
||
bool
|
||
"""
|
||
# Если есть скидки за длительность, проверяем по ним
|
||
if self.duration_discounts.exists():
|
||
return self.duration_discounts.filter(duration_days=duration_days).exists()
|
||
|
||
# Если скидок нет, доступны все стандартные периоды
|
||
return duration_days in [30, 90, 180, 365]
|
||
|
||
def can_be_used(self, user=None):
|
||
"""
|
||
Проверить, можно ли использовать этот тариф.
|
||
|
||
Args:
|
||
user: пользователь (для проверки типа акции "first_time")
|
||
|
||
Returns:
|
||
tuple: (can_use: bool, error_message: str)
|
||
"""
|
||
if self.promo_type == 'limited_uses':
|
||
if self.max_uses is not None and self.current_uses >= self.max_uses:
|
||
return False, f'Тариф можно использовать только {self.max_uses} раз(а). Лимит исчерпан.'
|
||
|
||
if self.promo_type == 'first_time' and user:
|
||
# Проверяем, есть ли у пользователя уже подписки
|
||
# Используем строку для избежания циклического импорта
|
||
from django.apps import apps
|
||
Subscription = apps.get_model('subscriptions', 'Subscription')
|
||
has_subscriptions = Subscription.objects.filter(user=user).exists()
|
||
if has_subscriptions:
|
||
return False, 'Этот тариф доступен только для новых пользователей.'
|
||
|
||
return True, ''
|
||
|
||
def calculate_price(self, student_count=0, duration_days=None, promo_code=None, remaining_days=None, is_add_students=False, current_student_count=0):
|
||
"""
|
||
Рассчитать цену подписки.
|
||
|
||
Args:
|
||
student_count: количество учеников (для типа per_student)
|
||
duration_days: длительность в днях (30, 90, 180, 365)
|
||
promo_code: промокод (опционально)
|
||
remaining_days: оставшиеся дни текущей подписки (для продления)
|
||
is_add_students: режим добавления учеников в текущий период
|
||
current_student_count: текущее количество учеников в подписке (для расчета доплаты)
|
||
|
||
Returns:
|
||
dict: {
|
||
'original_amount': исходная сумма,
|
||
'discount_amount': сумма скидки,
|
||
'final_amount': итоговая сумма,
|
||
'end_date': дата окончания,
|
||
'end_date_display': дата окончания для отображения,
|
||
'extra_students_payment': доплата за новых учеников за оставшиеся дни
|
||
}
|
||
"""
|
||
from decimal import Decimal
|
||
|
||
# Определяем базовую цену
|
||
if self.subscription_type == 'per_student':
|
||
# Проверяем прогрессирующие скидки
|
||
bulk_discount = None
|
||
if student_count > 0:
|
||
# Ищем подходящую прогрессирующую скидку
|
||
bulk_discounts = self.bulk_discounts.all().order_by('-min_students')
|
||
for discount in bulk_discounts:
|
||
if discount.matches(student_count):
|
||
bulk_discount = discount
|
||
break
|
||
|
||
if bulk_discount:
|
||
# Используем прогрессирующую скидку
|
||
# price_per_student - это цена за одного ученика в этом диапазоне
|
||
# Применяем цену за единицу к количеству учеников
|
||
monthly_price = bulk_discount.price_per_student * Decimal(str(student_count))
|
||
else:
|
||
# Обычная цена за ученика
|
||
price_per_student = self.price_per_student or self.price
|
||
monthly_price = price_per_student * Decimal(str(student_count))
|
||
|
||
# Учитываем длительность подписки
|
||
if duration_days:
|
||
days_in_month = 30
|
||
months = Decimal(str(duration_days)) / Decimal(str(days_in_month))
|
||
original_amount = monthly_price * months
|
||
else:
|
||
# По умолчанию 1 месяц
|
||
original_amount = monthly_price
|
||
else:
|
||
# Ежемесячная подписка
|
||
# Если указана кастомная длительность, пересчитываем цену
|
||
if duration_days:
|
||
days_in_month = 30
|
||
months = Decimal(str(duration_days)) / Decimal(str(days_in_month))
|
||
original_amount = self.price * months
|
||
else:
|
||
original_amount = self.price
|
||
|
||
# Применяем скидку за длительность для ежемесячных тарифов
|
||
duration_discount_amount = Decimal('0')
|
||
if self.subscription_type == 'monthly' and duration_days:
|
||
# Ищем скидку за длительность
|
||
duration_discount = self.duration_discounts.filter(duration_days=duration_days).first()
|
||
if duration_discount:
|
||
# Применяем процентную скидку
|
||
discount_percent = duration_discount.discount_percent / Decimal('100')
|
||
duration_discount_amount = original_amount * discount_percent
|
||
|
||
# Применяем промокод если есть (скидка от суммы после скидки за длительность)
|
||
promo_discount_amount = Decimal('0')
|
||
price_after_duration_discount = original_amount - duration_discount_amount
|
||
if promo_code:
|
||
is_valid, error = promo_code.is_valid()
|
||
if is_valid:
|
||
promo_discount_amount = promo_code.calculate_discount(price_after_duration_discount)
|
||
|
||
# Итоговая сумма скидки
|
||
discount_amount = duration_discount_amount + promo_discount_amount
|
||
final_amount = max(Decimal('0'), original_amount - discount_amount)
|
||
|
||
# Если это продление с добавлением учеников, рассчитываем доплату за новых учеников за оставшиеся дни
|
||
extra_students_payment = Decimal('0')
|
||
if (remaining_days and remaining_days > 0 and
|
||
self.subscription_type == 'per_student' and
|
||
student_count > current_student_count and current_student_count > 0):
|
||
# Количество новых учеников
|
||
new_students = student_count - current_student_count
|
||
|
||
# Рассчитываем цену за одного нового ученика в месяц
|
||
# Используем ту же логику прогрессивных скидок, но для нового количества
|
||
new_bulk_discount = None
|
||
if student_count > 0:
|
||
bulk_discounts = self.bulk_discounts.all().order_by('-min_students')
|
||
for discount in bulk_discounts:
|
||
if discount.matches(student_count):
|
||
new_bulk_discount = discount
|
||
break
|
||
|
||
if new_bulk_discount:
|
||
price_per_new_student_per_month = new_bulk_discount.price_per_student
|
||
else:
|
||
price_per_new_student_per_month = self.price_per_student or self.price
|
||
|
||
# Рассчитываем доплату за новых учеников за оставшиеся дни
|
||
days_in_month = 30
|
||
months_remaining = Decimal(str(remaining_days)) / Decimal(str(days_in_month))
|
||
extra_students_payment = price_per_new_student_per_month * Decimal(str(new_students)) * months_remaining
|
||
|
||
# Добавляем доплату к итоговой сумме
|
||
final_amount = final_amount + extra_students_payment
|
||
|
||
result = {
|
||
'original_amount': original_amount,
|
||
'discount_amount': discount_amount,
|
||
'final_amount': final_amount
|
||
}
|
||
|
||
# Добавляем информацию о доплате за новых учеников
|
||
if extra_students_payment > 0:
|
||
result['extra_students_payment'] = float(extra_students_payment)
|
||
result['extra_students_count'] = student_count - current_student_count
|
||
result['remaining_days_payment'] = remaining_days
|
||
|
||
# Добавляем информацию о скидке за длительность
|
||
if duration_discount_amount > 0:
|
||
result['duration_discount'] = {
|
||
'amount': float(duration_discount_amount),
|
||
'percent': float(duration_discount.discount_percent) if duration_discount else 0,
|
||
'duration_days': duration_days
|
||
}
|
||
|
||
# Добавляем дату окончания подписки
|
||
if duration_days:
|
||
# Если есть оставшиеся дни (продление), добавляем к текущей дате окончания
|
||
if remaining_days and remaining_days > 0:
|
||
# Для продления: берем текущую дату + оставшиеся дни + новые дни
|
||
base_date = timezone.now() + timedelta(days=remaining_days)
|
||
end_date = base_date + timedelta(days=duration_days)
|
||
else:
|
||
# Новая подписка: от текущей даты
|
||
end_date = timezone.now() + timedelta(days=duration_days)
|
||
|
||
result['end_date'] = end_date.strftime('%Y-%m-%d')
|
||
result['end_date_display'] = end_date.strftime('%d.%m.%Y')
|
||
result['total_duration_days'] = (remaining_days or 0) + duration_days
|
||
else:
|
||
# Если длительность не указана, используем по умолчанию
|
||
if remaining_days and remaining_days > 0:
|
||
base_date = timezone.now() + timedelta(days=remaining_days)
|
||
end_date = base_date + timedelta(days=30)
|
||
else:
|
||
end_date = timezone.now() + timedelta(days=30)
|
||
result['end_date'] = end_date.strftime('%Y-%m-%d')
|
||
result['end_date_display'] = end_date.strftime('%d.%m.%Y')
|
||
result['total_duration_days'] = (remaining_days or 0) + 30
|
||
|
||
return result
|
||
|
||
|
||
class Subscription(models.Model):
|
||
"""
|
||
Модель подписки пользователя.
|
||
"""
|
||
|
||
STATUS_CHOICES = [
|
||
('trial', 'Пробная'),
|
||
('active', 'Активна'),
|
||
('past_due', 'Просрочена'),
|
||
('cancelled', 'Отменена'),
|
||
('expired', 'Истекла'),
|
||
]
|
||
|
||
# Основная информация
|
||
user = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.CASCADE,
|
||
related_name='subscriptions',
|
||
verbose_name='Пользователь'
|
||
)
|
||
|
||
plan = models.ForeignKey(
|
||
SubscriptionPlan,
|
||
on_delete=models.PROTECT,
|
||
related_name='subscriptions',
|
||
verbose_name='Тарифный план'
|
||
)
|
||
|
||
status = models.CharField(
|
||
max_length=20,
|
||
choices=STATUS_CHOICES,
|
||
default='trial',
|
||
verbose_name='Статус',
|
||
db_index=True
|
||
)
|
||
|
||
# Промокод и скидка
|
||
promo_code = models.ForeignKey(
|
||
'PromoCode',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='subscriptions',
|
||
verbose_name='Промокод'
|
||
)
|
||
|
||
discount_amount = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
default=0,
|
||
validators=[MinValueValidator(0)],
|
||
verbose_name='Сумма скидки'
|
||
)
|
||
|
||
# Для подписки за ученика
|
||
student_count = models.IntegerField(
|
||
default=0,
|
||
validators=[MinValueValidator(0)],
|
||
verbose_name='Количество учеников',
|
||
help_text='Используется для типа подписки "За ученика"'
|
||
)
|
||
|
||
# Отслеживание неоплаченных учеников
|
||
unpaid_students_count = models.IntegerField(
|
||
default=0,
|
||
validators=[MinValueValidator(0)],
|
||
verbose_name='Количество неоплаченных учеников',
|
||
help_text='Количество учеников, добавленных сверх оплаченного количества'
|
||
)
|
||
|
||
pending_payment_amount = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
default=0,
|
||
validators=[MinValueValidator(0)],
|
||
verbose_name='Сумма ожидающей доплаты',
|
||
help_text='Сумма доплаты за неоплаченных учеников'
|
||
)
|
||
|
||
# Даты (можно указывать вручную)
|
||
start_date = models.DateTimeField(
|
||
verbose_name='Дата начала'
|
||
)
|
||
|
||
end_date = models.DateTimeField(
|
||
verbose_name='Дата окончания'
|
||
)
|
||
|
||
# Длительность в днях (30, 90, 180, 365)
|
||
duration_days = models.IntegerField(
|
||
default=30,
|
||
validators=[MinValueValidator(1)],
|
||
verbose_name='Длительность в днях',
|
||
help_text='30, 90, 180, 365 дней'
|
||
)
|
||
|
||
# Сумма с учетом скидки
|
||
original_amount = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
default=0,
|
||
validators=[MinValueValidator(0)],
|
||
verbose_name='Исходная сумма'
|
||
)
|
||
|
||
discount_amount = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
default=0,
|
||
validators=[MinValueValidator(0)],
|
||
verbose_name='Сумма скидки'
|
||
)
|
||
|
||
final_amount = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
default=0,
|
||
validators=[MinValueValidator(0)],
|
||
verbose_name='Итоговая сумма'
|
||
)
|
||
|
||
trial_end_date = models.DateTimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Дата окончания пробного периода'
|
||
)
|
||
|
||
cancelled_at = models.DateTimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Дата отмены'
|
||
)
|
||
|
||
# Автопродление
|
||
auto_renew = models.BooleanField(
|
||
default=True,
|
||
verbose_name='Автопродление'
|
||
)
|
||
|
||
# Статистика использования
|
||
lessons_used = models.IntegerField(
|
||
default=0,
|
||
verbose_name='Использовано занятий'
|
||
)
|
||
|
||
storage_used_mb = models.IntegerField(
|
||
default=0,
|
||
verbose_name='Использовано хранилища (МБ)'
|
||
)
|
||
|
||
video_minutes_used = models.IntegerField(
|
||
default=0,
|
||
verbose_name='Использовано минут видео'
|
||
)
|
||
|
||
# Временные метки
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name='Дата создания'
|
||
)
|
||
|
||
updated_at = models.DateTimeField(
|
||
auto_now=True,
|
||
verbose_name='Дата обновления'
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'subscriptions'
|
||
verbose_name = 'Подписка'
|
||
verbose_name_plural = 'Подписки'
|
||
ordering = ['-created_at']
|
||
indexes = [
|
||
models.Index(fields=['user', 'status']),
|
||
models.Index(fields=['end_date']),
|
||
models.Index(fields=['status']),
|
||
models.Index(fields=['user', 'end_date']),
|
||
models.Index(fields=['plan', 'status']),
|
||
models.Index(fields=['start_date', 'end_date']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.user.email} - {self.plan.name} ({self.get_status_display()})"
|
||
|
||
def is_active(self):
|
||
"""Проверка активности подписки."""
|
||
now = timezone.now()
|
||
return (
|
||
self.status in ['trial', 'active'] and
|
||
self.start_date <= now <= self.end_date
|
||
)
|
||
|
||
def is_trial(self):
|
||
"""Проверка пробного периода."""
|
||
if not self.trial_end_date:
|
||
return False
|
||
return timezone.now() <= self.trial_end_date and self.status == 'trial'
|
||
|
||
def days_until_expiration(self):
|
||
"""Дни до истечения."""
|
||
if not self.is_active():
|
||
return 0
|
||
delta = self.end_date - timezone.now()
|
||
return max(0, delta.days)
|
||
|
||
def renew(self, duration_days=None):
|
||
"""
|
||
Продление подписки.
|
||
|
||
Args:
|
||
duration_days: длительность в днях (если не указана, используется из плана)
|
||
"""
|
||
if duration_days:
|
||
days = duration_days
|
||
elif self.custom_duration_days:
|
||
days = self.custom_duration_days
|
||
else:
|
||
days = self.plan.get_duration_days()
|
||
|
||
self.end_date = self.end_date + timedelta(days=days)
|
||
self.status = 'active'
|
||
self.save()
|
||
|
||
def apply_extra_payment(self, new_student_count):
|
||
"""
|
||
Применить доплату за дополнительных учеников.
|
||
|
||
Обновляет student_count и сбрасывает счетчики неоплаченных учеников.
|
||
|
||
Args:
|
||
new_student_count: новое общее количество учеников
|
||
"""
|
||
if self.plan.subscription_type != 'per_student':
|
||
return
|
||
|
||
# Обновляем количество оплаченных учеников
|
||
self.student_count = new_student_count
|
||
|
||
# Сбрасываем счетчики неоплаченных
|
||
self.unpaid_students_count = 0
|
||
self.pending_payment_amount = Decimal('0')
|
||
|
||
self.save(update_fields=['student_count', 'unpaid_students_count', 'pending_payment_amount'])
|
||
|
||
def cancel(self):
|
||
"""Отмена подписки."""
|
||
self.status = 'cancelled'
|
||
self.cancelled_at = timezone.now()
|
||
self.auto_renew = False
|
||
self.save()
|
||
|
||
def check_expiration(self):
|
||
"""Проверка истечения срока."""
|
||
now = timezone.now()
|
||
|
||
# Проверка окончания пробного периода
|
||
if self.is_trial() and self.trial_end_date and now > self.trial_end_date:
|
||
self.status = 'active' if self.auto_renew else 'expired'
|
||
self.save()
|
||
|
||
# Проверка истечения подписки
|
||
if now > self.end_date:
|
||
if self.auto_renew and self.status == 'active':
|
||
self.status = 'past_due' # Ожидание оплаты
|
||
else:
|
||
self.status = 'expired'
|
||
self.save()
|
||
|
||
def has_feature(self, feature_name):
|
||
"""Проверка доступности функции."""
|
||
if not self.is_active():
|
||
return False
|
||
|
||
feature_map = {
|
||
'video_calls': self.plan.allow_video_calls,
|
||
'screen_sharing': self.plan.allow_screen_sharing,
|
||
'whiteboard': self.plan.allow_whiteboard,
|
||
'homework': self.plan.allow_homework,
|
||
'materials': self.plan.allow_materials,
|
||
'analytics': self.plan.allow_analytics,
|
||
'telegram_bot': self.plan.allow_telegram_bot,
|
||
'api_access': self.plan.allow_api_access,
|
||
}
|
||
|
||
return feature_map.get(feature_name, False)
|
||
|
||
def check_limit(self, limit_type):
|
||
"""Проверка лимитов."""
|
||
if not self.is_active():
|
||
return False
|
||
|
||
if limit_type == 'lessons':
|
||
max_lessons = self.plan.max_lessons_per_month
|
||
if max_lessons is None:
|
||
return True
|
||
return self.lessons_used < max_lessons
|
||
|
||
elif limit_type == 'storage':
|
||
return self.storage_used_mb < self.plan.max_storage_mb
|
||
|
||
elif limit_type == 'video_minutes':
|
||
max_minutes = self.plan.max_video_minutes_per_month
|
||
if max_minutes is None:
|
||
return True
|
||
return self.video_minutes_used < max_minutes
|
||
|
||
elif limit_type == 'clients':
|
||
# Для типа "За ученика" - лимит не применяется, но нужно платить за каждого
|
||
if self.plan.subscription_type == 'per_student':
|
||
return True # Можно добавлять учеников, но нужно будет доплатить
|
||
# Для ежемесячной подписки - проверяем лимит
|
||
max_clients = self.plan.max_clients
|
||
if max_clients is None:
|
||
return True
|
||
from apps.users.models import Client
|
||
current_clients_count = Client.objects.filter(mentors=self.user).count()
|
||
return current_clients_count < max_clients
|
||
|
||
return True
|
||
|
||
def calculate_monthly_fee(self):
|
||
"""Рассчитать ежемесячную плату."""
|
||
if self.plan.subscription_type == 'per_student':
|
||
if self.plan.price_per_student:
|
||
from apps.users.models import Client
|
||
current_clients_count = Client.objects.filter(mentors=self.user).count()
|
||
return current_clients_count * self.plan.price_per_student
|
||
return 0
|
||
else:
|
||
return self.plan.price
|
||
|
||
def update_monthly_fee(self):
|
||
"""Обновить ежемесячную плату на основе текущего количества учеников."""
|
||
if self.plan.subscription_type == 'per_student':
|
||
from apps.users.models import Client
|
||
self.monthly_fee = self.calculate_monthly_fee()
|
||
self.students_count = Client.objects.filter(mentor=self.user).count()
|
||
self.save(update_fields=['monthly_fee', 'students_count'])
|
||
|
||
def reset_monthly_usage(self):
|
||
"""Сброс месячного использования."""
|
||
self.lessons_used = 0
|
||
self.video_minutes_used = 0
|
||
self.save()
|
||
|
||
|
||
class Payment(models.Model):
|
||
"""
|
||
Модель платежа.
|
||
"""
|
||
|
||
STATUS_CHOICES = [
|
||
('pending', 'Ожидает'),
|
||
('processing', 'Обрабатывается'),
|
||
('succeeded', 'Успешно'),
|
||
('failed', 'Ошибка'),
|
||
('cancelled', 'Отменен'),
|
||
('refunded', 'Возврат'),
|
||
]
|
||
|
||
PAYMENT_METHOD_CHOICES = [
|
||
('card', 'Карта'),
|
||
('yookassa', 'ЮKassa'),
|
||
('stripe', 'Stripe'),
|
||
('paypal', 'PayPal'),
|
||
('other', 'Другое'),
|
||
]
|
||
|
||
# Основная информация
|
||
uuid = models.UUIDField(
|
||
default=uuid.uuid4,
|
||
unique=True,
|
||
editable=False,
|
||
verbose_name='UUID'
|
||
)
|
||
|
||
user = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.CASCADE,
|
||
related_name='payments',
|
||
verbose_name='Пользователь'
|
||
)
|
||
|
||
subscription = models.ForeignKey(
|
||
Subscription,
|
||
on_delete=models.CASCADE,
|
||
related_name='payments',
|
||
verbose_name='Подписка'
|
||
)
|
||
|
||
# Сумма
|
||
amount = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
validators=[MinValueValidator(0)],
|
||
verbose_name='Сумма'
|
||
)
|
||
|
||
currency = models.CharField(
|
||
max_length=3,
|
||
default='RUB',
|
||
verbose_name='Валюта'
|
||
)
|
||
|
||
# Статус
|
||
status = models.CharField(
|
||
max_length=20,
|
||
choices=STATUS_CHOICES,
|
||
default='pending',
|
||
verbose_name='Статус',
|
||
db_index=True
|
||
)
|
||
|
||
payment_method = models.CharField(
|
||
max_length=20,
|
||
choices=PAYMENT_METHOD_CHOICES,
|
||
verbose_name='Метод оплаты'
|
||
)
|
||
|
||
# Внешние данные
|
||
external_id = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
verbose_name='Внешний ID',
|
||
db_index=True
|
||
)
|
||
|
||
provider_response = models.JSONField(
|
||
default=dict,
|
||
blank=True,
|
||
verbose_name='Ответ провайдера'
|
||
)
|
||
|
||
# Описание
|
||
description = models.TextField(
|
||
blank=True,
|
||
verbose_name='Описание'
|
||
)
|
||
|
||
# Временные метки
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name='Дата создания',
|
||
db_index=True
|
||
)
|
||
|
||
paid_at = models.DateTimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Дата оплаты'
|
||
)
|
||
|
||
failed_at = models.DateTimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Дата ошибки'
|
||
)
|
||
|
||
refunded_at = models.DateTimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Дата возврата'
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'payments'
|
||
verbose_name = 'Платеж'
|
||
verbose_name_plural = 'Платежи'
|
||
ordering = ['-created_at']
|
||
indexes = [
|
||
models.Index(fields=['user', 'created_at']),
|
||
models.Index(fields=['subscription']),
|
||
models.Index(fields=['status']),
|
||
models.Index(fields=['external_id']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.user.email} - {self.amount} {self.currency} ({self.get_status_display()})"
|
||
|
||
def mark_as_succeeded(self):
|
||
"""Отметить платеж как успешный."""
|
||
self.status = 'succeeded'
|
||
self.paid_at = timezone.now()
|
||
self.save()
|
||
|
||
# Обновляем подписку
|
||
if self.subscription.status in ['trial', 'past_due']:
|
||
self.subscription.status = 'active'
|
||
self.subscription.save()
|
||
|
||
def mark_as_failed(self, reason=''):
|
||
"""Отметить платеж как неудачный."""
|
||
self.status = 'failed'
|
||
self.failed_at = timezone.now()
|
||
if reason:
|
||
self.description = reason
|
||
self.save()
|
||
|
||
def refund(self):
|
||
"""Возврат платежа."""
|
||
self.status = 'refunded'
|
||
self.refunded_at = timezone.now()
|
||
self.save()
|
||
|
||
|
||
class PaymentHistory(models.Model):
|
||
"""
|
||
История изменений платежа.
|
||
"""
|
||
|
||
payment = models.ForeignKey(
|
||
Payment,
|
||
on_delete=models.CASCADE,
|
||
related_name='history',
|
||
verbose_name='Платеж'
|
||
)
|
||
|
||
status = models.CharField(
|
||
max_length=20,
|
||
verbose_name='Статус'
|
||
)
|
||
|
||
message = models.TextField(
|
||
blank=True,
|
||
verbose_name='Сообщение'
|
||
)
|
||
|
||
data = models.JSONField(
|
||
default=dict,
|
||
blank=True,
|
||
verbose_name='Данные'
|
||
)
|
||
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name='Дата',
|
||
db_index=True
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'payment_history'
|
||
verbose_name = 'История платежа'
|
||
verbose_name_plural = 'История платежей'
|
||
ordering = ['-created_at']
|
||
|
||
def __str__(self):
|
||
return f"{self.payment.uuid} - {self.status}"
|
||
|
||
|
||
class SubscriptionUsageLog(models.Model):
|
||
"""
|
||
Лог использования подписки.
|
||
"""
|
||
|
||
USAGE_TYPE_CHOICES = [
|
||
('lesson', 'Занятие'),
|
||
('storage', 'Хранилище'),
|
||
('video_minutes', 'Минуты видео'),
|
||
]
|
||
|
||
subscription = models.ForeignKey(
|
||
Subscription,
|
||
on_delete=models.CASCADE,
|
||
related_name='usage_logs',
|
||
verbose_name='Подписка'
|
||
)
|
||
|
||
usage_type = models.CharField(
|
||
max_length=20,
|
||
choices=USAGE_TYPE_CHOICES,
|
||
verbose_name='Тип использования'
|
||
)
|
||
|
||
amount = models.IntegerField(
|
||
verbose_name='Количество'
|
||
)
|
||
|
||
description = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
verbose_name='Описание'
|
||
)
|
||
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name='Дата',
|
||
db_index=True
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'subscription_usage_logs'
|
||
verbose_name = 'Лог использования'
|
||
verbose_name_plural = 'Логи использования'
|
||
ordering = ['-created_at']
|
||
indexes = [
|
||
models.Index(fields=['subscription', 'created_at']),
|
||
models.Index(fields=['usage_type']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.subscription.user.email} - {self.get_usage_type_display()} ({self.amount})"
|
||
|
||
|
||
class PromoCode(models.Model):
|
||
"""
|
||
Модель промокода для скидок на подписки.
|
||
"""
|
||
|
||
DISCOUNT_TYPE_CHOICES = [
|
||
('percentage', 'Процент'),
|
||
('fixed', 'Фиксированная сумма'),
|
||
]
|
||
|
||
# Основная информация
|
||
code = models.CharField(
|
||
max_length=50,
|
||
unique=True,
|
||
db_index=True,
|
||
verbose_name='Код',
|
||
help_text='Уникальный код промокода'
|
||
)
|
||
|
||
description = models.TextField(
|
||
blank=True,
|
||
verbose_name='Описание'
|
||
)
|
||
|
||
# Тип скидки
|
||
discount_type = models.CharField(
|
||
max_length=20,
|
||
choices=DISCOUNT_TYPE_CHOICES,
|
||
default='percentage',
|
||
verbose_name='Тип скидки'
|
||
)
|
||
|
||
# Размер скидки
|
||
discount_value = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
validators=[MinValueValidator(0)],
|
||
verbose_name='Значение скидки',
|
||
help_text='Процент или фиксированная сумма'
|
||
)
|
||
|
||
# Ограничения
|
||
max_discount_amount = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
null=True,
|
||
blank=True,
|
||
validators=[MinValueValidator(0)],
|
||
verbose_name='Максимальная сумма скидки',
|
||
help_text='Для процентных скидок - ограничение максимальной суммы'
|
||
)
|
||
|
||
# Срок действия
|
||
valid_from = models.DateTimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Действителен с',
|
||
help_text='Если не указано, действует с момента создания'
|
||
)
|
||
|
||
valid_until = models.DateTimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Действителен до',
|
||
help_text='Если не указано, действует бессрочно'
|
||
)
|
||
|
||
# Лимиты использования
|
||
max_uses_total = models.IntegerField(
|
||
null=True,
|
||
blank=True,
|
||
validators=[MinValueValidator(1)],
|
||
verbose_name='Максимум использований (общий)',
|
||
help_text='Общее количество использований промокода'
|
||
)
|
||
|
||
max_uses_per_user = models.IntegerField(
|
||
default=1,
|
||
validators=[MinValueValidator(1)],
|
||
verbose_name='Максимум использований на пользователя',
|
||
help_text='Сколько раз один пользователь может использовать промокод'
|
||
)
|
||
|
||
# Статистика
|
||
uses_count = models.IntegerField(
|
||
default=0,
|
||
verbose_name='Количество использований'
|
||
)
|
||
|
||
# Ограничения
|
||
is_active = models.BooleanField(
|
||
default=True,
|
||
verbose_name='Активен',
|
||
db_index=True
|
||
)
|
||
|
||
# Применяется только к определенным планам (без промежуточной модели)
|
||
applicable_plans = models.ManyToManyField(
|
||
SubscriptionPlan,
|
||
blank=True,
|
||
related_name='promo_codes',
|
||
verbose_name='Применимые тарифы',
|
||
help_text='Если не выбрано, применяется ко всем тарифам'
|
||
)
|
||
|
||
# Временные метки
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name='Дата создания'
|
||
)
|
||
|
||
updated_at = models.DateTimeField(
|
||
auto_now=True,
|
||
verbose_name='Дата обновления'
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'promo_codes'
|
||
verbose_name = 'Промокод'
|
||
verbose_name_plural = 'Промокоды'
|
||
ordering = ['-created_at']
|
||
indexes = [
|
||
models.Index(fields=['code', 'is_active']),
|
||
models.Index(fields=['valid_until']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.code} - {self.get_discount_display()}"
|
||
|
||
def get_discount_display(self):
|
||
"""Отображение скидки."""
|
||
if self.discount_type == 'percentage':
|
||
return f"{self.discount_value}%"
|
||
return f"{self.discount_value} {self.currency if hasattr(self, 'currency') else 'RUB'}"
|
||
|
||
def is_valid(self, user=None, plan=None):
|
||
"""
|
||
Проверка валидности промокода.
|
||
|
||
Args:
|
||
user: Пользователь (для проверки лимитов на пользователя)
|
||
plan: Тарифный план (для проверки применимости)
|
||
|
||
Returns:
|
||
tuple: (is_valid: bool, error_message: str)
|
||
"""
|
||
# Проверка активности
|
||
if not self.is_active:
|
||
return False, "Промокод неактивен"
|
||
|
||
# Проверка срока действия
|
||
now = timezone.now()
|
||
if self.valid_from and now < self.valid_from:
|
||
return False, "Промокод еще не действует"
|
||
|
||
if self.valid_until and now > self.valid_until:
|
||
return False, "Промокод истек"
|
||
|
||
# Проверка общего лимита использований
|
||
if self.max_uses_total and self.uses_count >= self.max_uses_total:
|
||
return False, "Промокод исчерпан"
|
||
|
||
# Проверка лимита на пользователя
|
||
if user:
|
||
# Используем строку для избежания циклического импорта
|
||
from django.apps import apps
|
||
Subscription = apps.get_model('subscriptions', 'Subscription')
|
||
user_uses = Subscription.objects.filter(
|
||
user=user,
|
||
promo_code=self
|
||
).count()
|
||
|
||
if user_uses >= self.max_uses_per_user:
|
||
return False, f"Вы уже использовали этот промокод максимальное количество раз ({self.max_uses_per_user})"
|
||
|
||
# Проверка применимости к тарифу
|
||
if plan and self.applicable_plans.exists():
|
||
if plan not in self.applicable_plans.all():
|
||
return False, "Промокод не применим к выбранному тарифу"
|
||
|
||
return True, ""
|
||
|
||
def calculate_discount(self, amount):
|
||
"""
|
||
Рассчитать сумму скидки.
|
||
|
||
Args:
|
||
amount: Исходная сумма
|
||
|
||
Returns:
|
||
tuple: (discount_amount: Decimal, final_amount: Decimal)
|
||
"""
|
||
from decimal import Decimal
|
||
|
||
if self.discount_type == 'percentage':
|
||
discount_amount = (amount * self.discount_value) / Decimal('100')
|
||
|
||
# Применяем максимальную скидку, если указана
|
||
if self.max_discount_amount:
|
||
discount_amount = min(discount_amount, self.max_discount_amount)
|
||
else:
|
||
discount_amount = min(self.discount_value, amount)
|
||
|
||
final_amount = max(Decimal('0'), amount - discount_amount)
|
||
|
||
return discount_amount, final_amount
|
||
|
||
def apply(self, user, subscription):
|
||
"""
|
||
Применить промокод к подписке.
|
||
|
||
Args:
|
||
user: Пользователь
|
||
subscription: Подписка
|
||
|
||
Returns:
|
||
PromoCodeUsage: Объект использования промокода
|
||
"""
|
||
# Создаем запись об использовании
|
||
from django.apps import apps
|
||
PromoCodeUsage = apps.get_model('subscriptions', 'PromoCodeUsage')
|
||
usage = PromoCodeUsage.objects.create(
|
||
promo_code=self,
|
||
user=user,
|
||
subscription=subscription,
|
||
discount_amount=subscription.discount_amount or 0,
|
||
original_amount=subscription.original_amount or 0,
|
||
final_amount=subscription.final_amount or 0
|
||
)
|
||
|
||
# Увеличиваем счетчик использований
|
||
self.uses_count += 1
|
||
self.save(update_fields=['uses_count'])
|
||
|
||
return usage
|
||
|
||
|
||
class PromoCodeUsage(models.Model):
|
||
"""
|
||
История использования промокодов.
|
||
"""
|
||
|
||
promo_code = models.ForeignKey(
|
||
PromoCode,
|
||
on_delete=models.CASCADE,
|
||
related_name='usages',
|
||
verbose_name='Промокод'
|
||
)
|
||
|
||
user = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.CASCADE,
|
||
related_name='promo_code_usages',
|
||
verbose_name='Пользователь'
|
||
)
|
||
|
||
subscription = models.ForeignKey(
|
||
Subscription,
|
||
on_delete=models.CASCADE,
|
||
related_name='promo_code_usages',
|
||
verbose_name='Подписка'
|
||
)
|
||
|
||
discount_amount = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
verbose_name='Сумма скидки'
|
||
)
|
||
|
||
original_amount = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
verbose_name='Исходная сумма'
|
||
)
|
||
|
||
final_amount = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
verbose_name='Итоговая сумма'
|
||
)
|
||
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name='Дата использования',
|
||
db_index=True
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'promo_code_usages'
|
||
verbose_name = 'Использование промокода'
|
||
verbose_name_plural = 'Использования промокодов'
|
||
ordering = ['-created_at']
|
||
indexes = [
|
||
models.Index(fields=['user', 'created_at']),
|
||
models.Index(fields=['promo_code', 'user']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.user.email} - {self.promo_code.code} ({self.discount_amount})"
|