uchill/backend/apps/subscriptions/models.py

1574 lines
57 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 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})"