uchill/backend/apps/referrals/models.py

696 lines
21 KiB
Python
Raw 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.

"""
Модели реферальной системы и промокодов.
"""
import random
import string
from decimal import Decimal
from django.db import models
from django.core.validators import MinValueValidator, MaxValueValidator
from django.utils import timezone
from django.conf import settings
def generate_referral_code():
"""Генерация уникального реферального кода."""
return ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
class ReferralSettings(models.Model):
"""
Настройки реферальной программы (singleton).
"""
# Проценты комиссии
level1_commission = models.DecimalField(
max_digits=5,
decimal_places=2,
default=10.00,
validators=[MinValueValidator(0), MaxValueValidator(100)],
verbose_name='Комиссия 1 уровня (%)',
help_text='Процент от оплаты прямого реферала'
)
level2_commission = models.DecimalField(
max_digits=5,
decimal_places=2,
default=5.00,
validators=[MinValueValidator(0), MaxValueValidator(100)],
verbose_name='Комиссия 2 уровня (%)',
help_text='Процент от оплаты реферала реферала'
)
# Очки за рефералов
points_direct_referral = models.IntegerField(
default=5,
validators=[MinValueValidator(0)],
verbose_name='Очков за прямого реферала'
)
points_indirect_referral = models.IntegerField(
default=2,
validators=[MinValueValidator(0)],
verbose_name='Очков за реферала реферала'
)
# Обновление
updated_at = models.DateTimeField(
auto_now=True,
verbose_name='Обновлено'
)
class Meta:
verbose_name = 'Настройки реферальной программы'
verbose_name_plural = 'Настройки реферальной программы'
def __str__(self):
return f"Настройки реферальной программы (обновлено: {self.updated_at})"
def save(self, *args, **kwargs):
"""Гарантируем что существует только одна запись."""
self.pk = 1
super().save(*args, **kwargs)
@classmethod
def get_settings(cls):
"""Получить настройки (создать если не существует)."""
obj, created = cls.objects.get_or_create(pk=1)
return obj
class ReferralLevel(models.Model):
"""
Уровни реферальной программы.
"""
level = models.IntegerField(
unique=True,
verbose_name='Уровень'
)
name = models.CharField(
max_length=100,
verbose_name='Название'
)
points_required = models.IntegerField(
validators=[MinValueValidator(0)],
verbose_name='Требуется очков',
help_text='Минимальное количество очков для достижения уровня'
)
bonus_payment_percent = models.IntegerField(
validators=[MinValueValidator(0), MaxValueValidator(100)],
verbose_name='% оплаты бонусами',
help_text='Максимальный процент подписки, который можно оплатить бонусами'
)
icon = models.CharField(
max_length=50,
blank=True,
verbose_name='Иконка'
)
class Meta:
verbose_name = 'Уровень реферальной программы'
verbose_name_plural = 'Уровни реферальной программы'
ordering = ['level']
def __str__(self):
return f"Уровень {self.level}: {self.name} ({self.points_required} очков)"
class UserReferralProfile(models.Model):
"""
Реферальный профиль пользователя.
"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='referral_profile',
verbose_name='Пользователь'
)
referral_code = models.CharField(
max_length=20,
unique=True,
db_index=True,
verbose_name='Реферальный код'
)
referred_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='referrals',
verbose_name='Пригласил'
)
# Статистика
total_points = models.IntegerField(
default=0,
validators=[MinValueValidator(0)],
verbose_name='Всего очков'
)
current_level = models.ForeignKey(
ReferralLevel,
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name='Текущий уровень'
)
direct_referrals_count = models.IntegerField(
default=0,
validators=[MinValueValidator(0)],
verbose_name='Прямых рефералов'
)
indirect_referrals_count = models.IntegerField(
default=0,
validators=[MinValueValidator(0)],
verbose_name='Непрямых рефералов'
)
total_earned = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
validators=[MinValueValidator(0)],
verbose_name='Всего заработано (₽)'
)
# Даты
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name='Создан'
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name='Обновлен'
)
class Meta:
verbose_name = 'Реферальный профиль'
verbose_name_plural = 'Реферальные профили'
def __str__(self):
return f"{self.user.email} - {self.referral_code}"
def get_referral_link(self, base_url=''):
"""Получить реферальную ссылку."""
if not base_url:
base_url = settings.FRONTEND_URL
return f"{base_url}/register?ref={self.referral_code}"
def update_level(self):
"""Обновить уровень на основе очков."""
new_level = ReferralLevel.objects.filter(
points_required__lte=self.total_points
).order_by('-points_required').first()
if new_level and new_level != self.current_level:
self.current_level = new_level
self.save(update_fields=['current_level', 'updated_at'])
return True
return False
def add_points(self, points, reason=''):
"""Добавить очки."""
self.total_points += points
self.save(update_fields=['total_points', 'updated_at'])
self.update_level()
# Создаем запись в истории
PointsTransaction.objects.create(
user=self.user,
points=points,
reason=reason,
balance_after=self.total_points
)
def get_max_bonus_payment_percent(self):
"""Получить максимальный процент оплаты бонусами."""
if self.current_level:
return self.current_level.bonus_payment_percent
# Уровень 1 по умолчанию
level1 = ReferralLevel.objects.filter(level=1).first()
return level1.bonus_payment_percent if level1 else 60
class BonusAccount(models.Model):
"""
Бонусный счет пользователя.
"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='bonus_account',
verbose_name='Пользователь'
)
balance = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
validators=[MinValueValidator(0)],
verbose_name='Баланс (₽)'
)
total_earned = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
validators=[MinValueValidator(0)],
verbose_name='Всего заработано (₽)'
)
total_spent = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
validators=[MinValueValidator(0)],
verbose_name='Всего потрачено (₽)'
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name='Создан'
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name='Обновлен'
)
class Meta:
verbose_name = 'Бонусный счет'
verbose_name_plural = 'Бонусные счета'
def __str__(self):
return f"{self.user.email} - {self.balance}"
def add_bonus(self, amount, reason=''):
"""Добавить бонусы."""
amount = Decimal(str(amount))
self.balance += amount
self.total_earned += amount
self.save(update_fields=['balance', 'total_earned', 'updated_at'])
# Создаем транзакцию
BonusTransaction.objects.create(
user=self.user,
amount=amount,
transaction_type='earn',
reason=reason,
balance_after=self.balance
)
def spend_bonus(self, amount, reason=''):
"""Потратить бонусы."""
amount = Decimal(str(amount))
if amount > self.balance:
raise ValueError('Недостаточно бонусов')
self.balance -= amount
self.total_spent += amount
self.save(update_fields=['balance', 'total_spent', 'updated_at'])
# Создаем транзакцию
BonusTransaction.objects.create(
user=self.user,
amount=amount,
transaction_type='spend',
reason=reason,
balance_after=self.balance
)
class ReferralEarning(models.Model):
"""
История заработков с рефералов.
"""
referrer = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='referral_earnings',
verbose_name='Реферер'
)
referral = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='generated_earnings',
verbose_name='Реферал'
)
payment = models.ForeignKey(
'subscriptions.Payment',
on_delete=models.CASCADE,
verbose_name='Платеж'
)
level = models.IntegerField(
validators=[MinValueValidator(1), MaxValueValidator(2)],
verbose_name='Уровень',
help_text='1 - прямой реферал, 2 - реферал реферала'
)
payment_amount = models.DecimalField(
max_digits=10,
decimal_places=2,
validators=[MinValueValidator(0)],
verbose_name='Сумма платежа (₽)'
)
commission_percent = models.DecimalField(
max_digits=5,
decimal_places=2,
validators=[MinValueValidator(0), MaxValueValidator(100)],
verbose_name='Процент комиссии'
)
earned_amount = models.DecimalField(
max_digits=10,
decimal_places=2,
validators=[MinValueValidator(0)],
verbose_name='Заработано (₽)'
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name='Создан'
)
class Meta:
verbose_name = 'Заработок с реферала'
verbose_name_plural = 'Заработки с рефералов'
ordering = ['-created_at']
def __str__(self):
return f"{self.referrer.email} <- {self.referral.email}: {self.earned_amount}"
class PointsTransaction(models.Model):
"""
История начисления очков.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='points_transactions',
verbose_name='Пользователь'
)
points = models.IntegerField(
verbose_name='Очки'
)
reason = models.CharField(
max_length=255,
verbose_name='Причина'
)
balance_after = models.IntegerField(
validators=[MinValueValidator(0)],
verbose_name='Баланс после'
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name='Создан'
)
class Meta:
verbose_name = 'Транзакция очков'
verbose_name_plural = 'Транзакции очков'
ordering = ['-created_at']
def __str__(self):
return f"{self.user.email}: {self.points:+d} очков"
class BonusTransaction(models.Model):
"""
История транзакций бонусного счета.
"""
TRANSACTION_TYPES = [
('earn', 'Начисление'),
('spend', 'Списание'),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='bonus_transactions',
verbose_name='Пользователь'
)
amount = models.DecimalField(
max_digits=10,
decimal_places=2,
validators=[MinValueValidator(0)],
verbose_name='Сумма (₽)'
)
transaction_type = models.CharField(
max_length=10,
choices=TRANSACTION_TYPES,
verbose_name='Тип транзакции'
)
reason = models.CharField(
max_length=255,
verbose_name='Причина'
)
balance_after = models.DecimalField(
max_digits=10,
decimal_places=2,
validators=[MinValueValidator(0)],
verbose_name='Баланс после (₽)'
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name='Создан'
)
class Meta:
verbose_name = 'Транзакция бонусов'
verbose_name_plural = 'Транзакции бонусов'
ordering = ['-created_at']
def __str__(self):
sign = '+' if self.transaction_type == 'earn' else '-'
return f"{self.user.email}: {sign}{self.amount}"
class PromoCode(models.Model):
"""
Промокод для скидок на подписки.
"""
code = models.CharField(
max_length=50,
unique=True,
db_index=True,
verbose_name='Код'
)
name = models.CharField(
max_length=200,
verbose_name='Название'
)
description = models.TextField(
blank=True,
verbose_name='Описание'
)
discount_type = models.CharField(
max_length=10,
choices=[
('percent', 'Процент'),
('fixed', 'Фиксированная сумма'),
],
default='percent',
verbose_name='Тип скидки'
)
discount_value = models.DecimalField(
max_digits=10,
decimal_places=2,
validators=[MinValueValidator(0)],
verbose_name='Размер скидки',
help_text='Процент (0-100) или фиксированная сумма'
)
# Применимость
applicable_plans = models.ManyToManyField(
'subscriptions.SubscriptionPlan',
blank=True,
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='Текущих использований'
)
valid_until = models.DateTimeField(
null=True,
blank=True,
verbose_name='Действителен до'
)
# Статус
is_active = models.BooleanField(
default=True,
verbose_name='Активен'
)
# Статистика
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_promocodes',
verbose_name='Создал'
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name='Создан'
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name='Обновлен'
)
class Meta:
verbose_name = 'Промокод'
verbose_name_plural = 'Промокоды'
ordering = ['-created_at']
def __str__(self):
return f"{self.code} - {self.name}"
def is_valid(self):
"""Проверить валидность промокода."""
if not self.is_active:
return False, 'Промокод неактивен'
if self.valid_until and timezone.now() > self.valid_until:
return False, 'Промокод истек'
if self.max_uses and self.current_uses >= self.max_uses:
return False, 'Промокод исчерпан'
return True, 'OK'
def can_apply_to_plan(self, plan):
"""Проверить применимость к плану."""
if not self.applicable_plans.exists():
return True # Применим ко всем
return self.applicable_plans.filter(id=plan.id).exists()
def calculate_discount(self, amount):
"""Рассчитать размер скидки."""
amount = Decimal(str(amount))
if self.discount_type == 'percent':
discount = amount * (self.discount_value / 100)
else: # fixed
discount = min(self.discount_value, amount)
return discount
def use(self):
"""Использовать промокод."""
self.current_uses += 1
self.save(update_fields=['current_uses', 'updated_at'])
class PromoCodeUsage(models.Model):
"""
История использования промокодов.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='promocode_usages',
verbose_name='Пользователь'
)
promo_code = models.ForeignKey(
PromoCode,
on_delete=models.CASCADE,
related_name='usages',
verbose_name='Промокод'
)
payment = models.ForeignKey(
'subscriptions.Payment',
on_delete=models.CASCADE,
null=True,
blank=True,
verbose_name='Платеж'
)
original_amount = models.DecimalField(
max_digits=10,
decimal_places=2,
validators=[MinValueValidator(0)],
verbose_name='Исходная сумма (₽)'
)
discount_amount = models.DecimalField(
max_digits=10,
decimal_places=2,
validators=[MinValueValidator(0)],
verbose_name='Размер скидки (₽)'
)
final_amount = models.DecimalField(
max_digits=10,
decimal_places=2,
validators=[MinValueValidator(0)],
verbose_name='Итоговая сумма (₽)'
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name='Использован'
)
class Meta:
verbose_name = 'Использование промокода'
verbose_name_plural = 'Использования промокодов'
ordering = ['-created_at']
def __str__(self):
return f"{self.user.email} - {self.promo_code.code}: -{self.discount_amount}"