You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

510 lines
23 KiB

from datetime import timedelta, date, datetime
from decimal import Decimal
import arrow
import short_url
from django.db.models.functions import Coalesce
from paymentwall import Pingback
from polymorphic.models import PolymorphicModel
from polymorphic.managers import PolymorphicManager
from django.db.models import Func, F, Q, Max
from django.db import models
from django.contrib.auth import get_user_model
from django.contrib.postgres.fields import ArrayField, JSONField
from django.core.validators import RegexValidator, MaxValueValidator
from django.utils.timezone import now
from django.conf import settings
from apps.content.models import Package
from project.utils import weekdays_in_date_range
from apps.course.models import Course
from apps.config.models import Config
from apps.school.models import SchoolSchedule
from apps.notification.utils import send_email
from project.utils import dates_overlap
config = Config.load()
User = get_user_model()
CREDIT_CARD_RE = r'^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}' \
r'|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\\d{3})\d{11})$'
class AuthorBalance(models.Model):
IN = 0
OUT = 1
TYPE_CHOICES = (
(IN, 'In'),
(OUT, 'Out'),
)
PENDING = 0
ACCEPTED = 1
DECLINED = 2
STATUS_CHOICES = (
(PENDING, 'Pending'),
(ACCEPTED, 'Accepted'),
(DECLINED, 'Declined'),
)
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='Автор',
null=True, blank=True, related_name='balances')
type = models.PositiveSmallIntegerField('Тип', choices=TYPE_CHOICES, default=0)
amount = models.DecimalField('Итого', max_digits=8, decimal_places=2, default=0)
commission = models.DecimalField('Комиссия', max_digits=8, decimal_places=2, default=0)
status = models.PositiveSmallIntegerField('Статус', choices=STATUS_CHOICES, default=0)
payment = models.OneToOneField('Payment', on_delete=models.CASCADE, null=True, blank=True, verbose_name='Платёж')
cause = models.TextField('Причина отказа', null=True, blank=True)
card = models.CharField(max_length=20, validators=[RegexValidator(regex=CREDIT_CARD_RE)], null=True, blank=True)
declined_send_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
update_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = 'Баланс'
verbose_name_plural = 'Балансы'
def save(self, *args, **kwargs):
if self.type == self.IN and not self.id:
self.commission = self.calc_commission()
if self.type == self.OUT:
if self.status == self.DECLINED and not self.declined_send_at:
send_email(
'Отказ вывода средств',
self.author.email,
'notification/email/decline_withdrawal.html',
author_balance=self,
)
self.declined_send_at = now()
super().save(*args, **kwargs)
def calc_commission(self):
return self.amount * config.SERVICE_COMMISSION / 100
class PaymentManger(PolymorphicManager):
def all(self):
return self.filter(status__isnull=False)
def paid(self):
return self.filter(status__in=Payment.PW_PAID_STATUSES)
class Payment(PolymorphicModel):
PW_PAID_STATUSES = [
Pingback.PINGBACK_TYPE_REGULAR,
Pingback.PINGBACK_TYPE_GOODWILL,
Pingback.PINGBACK_TYPE_RISK_REVIEWED_ACCEPTED,
]
PW_STATUS_CHOICES = (
(Pingback.PINGBACK_TYPE_REGULAR, 'regular',),
(Pingback.PINGBACK_TYPE_GOODWILL, 'goodwill',),
(Pingback.PINGBACK_TYPE_NEGATIVE, 'negative',),
(Pingback.PINGBACK_TYPE_RISK_UNDER_REVIEW, 'risk under review',),
(Pingback.PINGBACK_TYPE_RISK_REVIEWED_ACCEPTED, 'risk reviewed accepted',),
(Pingback.PINGBACK_TYPE_RISK_REVIEWED_DECLINED, 'risk reviewed declined',),
(Pingback.PINGBACK_TYPE_RISK_AUTHORIZATION_VOIDED, 'risk authorization voided',),
(Pingback.PINGBACK_TYPE_SUBSCRIPTION_CANCELLATION, 'subscription cancelation',),
(Pingback.PINGBACK_TYPE_SUBSCRIPTION_EXPIRED, 'subscription expired',),
(Pingback.PINGBACK_TYPE_SUBSCRIPTION_PAYMENT_FAILED, 'subscription payment failed',),
)
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='Пользователь', related_name='payments')
amount = models.DecimalField('Итого', max_digits=8, decimal_places=2, default=0, editable=False)
status = models.PositiveSmallIntegerField('Статус платежа', choices=PW_STATUS_CHOICES, null=True)
data = JSONField('Данные платежа от провайдера', default={}, editable=False)
roistat_visit = models.PositiveIntegerField('Номер визита Roistat', null=True, editable=False)
created_at = models.DateTimeField(auto_now_add=True)
update_at = models.DateTimeField(auto_now=True)
bonus = models.ForeignKey('payment.UserBonus', blank=True, null=True, on_delete=models.SET_NULL,
related_name='purchase_payments')
objects = PaymentManger()
class Meta:
verbose_name = 'Платеж'
verbose_name_plural = 'Платежи'
ordering = ('created_at',)
@classmethod
def adjust_date_bounds(cls, date_start=None, date_end=None, is_camp=False):
school_start = date((date_start or date_end).year, 9, 1)
school_end = date((date_end or date_start).year, 5, 31)
camp_start = date((date_start or date_end).year, 6, 1)
camp_end = date((date_end or date_start).year, 8, 31)
if date_start:
if is_camp:
if date_start < camp_start:
date_start = camp_start
elif school_end < date_start < school_start:
date_start = school_start
if date_end:
if is_camp:
if date_end > camp_end:
date_end = camp_end
elif school_end < date_end < school_start:
date_end = school_end
if date_start and date_end:
return [date_start, date_end]
else:
return date_start or date_end
@classmethod
def date_add(cls, date_start, days=0, months=0, is_camp=False):
date_end = arrow.get(date_start + timedelta(days=days), settings.TIME_ZONE).shift(
months=months).date() - timedelta(1)
if months == 1:
if is_camp or (date_start.month == 2 and date_start.day >= 28) or (
date_start.day == 31 and date_end.day <= 30) \
or (date_start.month == 1 and date_start.day >= 29 and date_end.day == 28):
date_end = date_start.replace(day=1, month=date_start.month + 1) - timedelta(1)
return date_end
@classmethod
def get_date_range(cls, date_start=None, days=0, months=0, is_camp=False):
date_start = date_start or now().date()
if isinstance(date_start, datetime):
date_start = date_start.date()
date_start = cls.adjust_date_bounds(date_start=date_start, is_camp=is_camp)
if is_camp and date_start.month == 6 and date_start.day > 16:
date_start = date_start.replace(month=7, day=1)
date_end = cls.date_add(date_start, days, months, is_camp)
if is_camp:
date_end = cls.adjust_date_bounds(date_end, is_camp=is_camp)
return [date_start, date_end]
@classmethod
def calc_amount(cls, package=None, payment=None, user=None, course=None, date_start=None, weekdays=None, is_camp=False):
price = 0
discount = 0
referral_bonus = 0
referrer_bonus = 0
if isinstance(payment, CoursePayment):
course = payment.course
user = payment.user
if isinstance(payment, SchoolPayment):
user = payment.user
date_start = payment.date_start
package=payment.package
if isinstance(payment, DrawingCampPayment):
user = payment.user
date_start = payment.date_start
if issubclass(cls, DrawingCampPayment):
is_camp = True
date_start, date_end = Payment.get_date_range(date_start, months=package.duration if package else 1, is_camp=is_camp)
if hasattr(user, 'referral') and not user.referral.payment:
referral_bonus = user.referral.bonus
referrer_bonus = user.referral.referrer_bonus
if payment and payment.is_paid():
price = payment.amount
elif isinstance(payment, GiftCertificatePayment):
price = payment.gift_certificate.price
elif course:
paid_before = CoursePayment.objects.filter(user=user, course=course, status__in=Payment.PW_PAID_STATUSES).exists()
price = course.price / 2 if paid_before else course.price
elif is_camp:
if date_start.day == 1:
price = DrawingCampPayment.MONTH_PRICE
else:
weekdays_count = weekdays_in_date_range(date_start, date_end)
weekdays_count = sum(weekdays_count[wd] for wd in DrawingCampPayment.WEEKDAYS)
all_weekdays_count = weekdays_in_date_range(date_start.replace(day=1), date_end)
all_weekdays_count = sum(all_weekdays_count[wd] for wd in DrawingCampPayment.WEEKDAYS)
price = round(DrawingCampPayment.MONTH_PRICE / all_weekdays_count * weekdays_count)
else:
if user:
school_payments = SchoolPayment.objects.filter(
user=user,
date_start__lte=date_start,
date_end__gte=date_start,
status__in=Payment.PW_PAID_STATUSES,
)
prev_school_payment = school_payments.filter(add_days=False).last()
if prev_school_payment:
date_start, date_end = Payment.get_date_range(prev_school_payment.date_end + timedelta(1),
months=package.duration, is_camp=False)
school_schedules = SchoolSchedule.objects.filter(is_camp=False).exclude(title='')
weekdays = list(school_schedules.values_list('weekday', flat=True))
# FIXME после мая 2019 убрать?
# Если хотят купить школу в мае, то оплатить ее можно только до 31 мая, потом школа закроется
if date_start.month == 5:
weekdays_count = weekdays_in_date_range(date_start, date_end)
weekdays_count = sum(weekdays_count[wd] for wd in weekdays)
all_weekdays_count = weekdays_in_date_range(date_start.replace(day=1), date_end)
all_weekdays_count = sum(all_weekdays_count[wd] for wd in weekdays)
price = package.price // all_weekdays_count * weekdays_count
else:
price = package.price
amount = price - discount
referral_bonus = round(amount * referral_bonus / 100)
referrer_bonus = round(amount * referrer_bonus / 100)
return {
'price': price,
'amount': amount,
'referral_bonus': referral_bonus,
'referrer_bonus': referrer_bonus,
'discount': discount,
'date_start': date_start,
'date_end': date_end,
'weekdays': weekdays,
}
# TODO? change to property?
def calc_commission(self):
return self.amount * config.SERVICE_COMMISSION / 100
# TODO change to property
def is_paid(self):
return self.status in self.PW_PAID_STATUSES
# TODO? delete ? change to property
def is_deliverable(self):
return self.status in [
Pingback.PINGBACK_TYPE_REGULAR,
Pingback.PINGBACK_TYPE_GOODWILL,
Pingback.PINGBACK_TYPE_RISK_REVIEWED_ACCEPTED,
]
# TODO change to property
def is_under_review(self):
return self.status == Pingback.PINGBACK_TYPE_RISK_UNDER_REVIEW
# TODO change to property
def is_cancelable(self):
return self.status in [
Pingback.PINGBACK_TYPE_NEGATIVE,
Pingback.PINGBACK_TYPE_RISK_REVIEWED_DECLINED,
]
def save(self, *args, **kwargs):
amount_data = type(self).calc_amount(payment=self)
if not self.is_paid():
if not self.bonus:
self.amount = amount_data.get('amount')
if isinstance(self, SchoolPayment):
self.weekdays = amount_data.get('weekdays')
super().save(*args, **kwargs)
if self.is_paid():
if isinstance(self, CoursePayment):
if not getattr(self, 'authorbalance', None):
AuthorBalance.objects.create(
author=self.course.author,
amount=self.amount,
payment=self,
)
if isinstance(self, GiftCertificatePayment):
ugs, created = UserGiftCertificate.objects.get_or_create(user=self.user, gift_certificate=self.gift_certificate,
payment=self)
if created:
from apps.notification.tasks import send_gift_certificate
send_gift_certificate(ugs.id)
# Если это не первая покупка, - отправляем бонусы юзеру
if self.user.paid_one_more and not self.user.bonuses.filter(
is_service=True, action_name=UserBonus.ACTION_PAID_ONE_MORE).count():
UserBonus.objects.create(user=self.user, amount=UserBonus.AMOUNT_PAID_ONE_MORE, is_service=True,
action_name=UserBonus.ACTION_PAID_ONE_MORE)
# Если юзер реферал и нет платежа, где применялась скидка
# (после первой покупки реферала начисляются бонусы ему и пригласившему рефереру)
if hasattr(self.user, 'referral') and not self.user.referral.payment:
# Платеж - как сигнал, что была покупка и бонусы отправлены
self.user.referral.payment = self
self.user.referral.save()
# Отправляем кэшбэк
self.user.referral.send_bonuses(amount_data.get('referral_bonus'), amount_data.get('referrer_bonus'))
class CoursePayment(Payment):
course = models.ForeignKey(Course, on_delete=models.CASCADE, verbose_name='Курс', related_name='payments')
access_expire = models.DateField('Доступ к курсу до даты', null=True)
class Meta:
verbose_name = 'Платеж за курс'
verbose_name_plural = 'Платежи за курсы'
class SchoolPayment(Payment):
weekdays = ArrayField(models.IntegerField(), size=7, verbose_name='Дни недели')
add_days = models.BooleanField('Докупленные дни', default=False)
package = models.ForeignKey(Package, null=True, blank=True, on_delete=models.SET_NULL)
date_start = models.DateField('Дата начала подписки', null=True, blank=True)
date_end = models.DateField('Дата окончания подписки', null=True, blank=True)
class Meta:
verbose_name = 'Платеж за школу'
verbose_name_plural = 'Платежи за школу'
def __str__(self):
days = ', '.join([
dict(SchoolSchedule.WEEKDAY_CHOICES).get(weekday, '')
for weekday in sorted(self.weekdays)
])
return days
@property
def date_end_humanize(self):
return arrow.get(self.date_end, settings.TIME_ZONE).humanize(locale='ru')
class DrawingCampPayment(Payment):
MONTH_PRICE = Decimal(1990)
WEEKDAYS = {1, 3, 5}
date_start = models.DateField('Дата начала подписки', null=True, blank=True)
date_end = models.DateField('Дата окончания подписки', null=True, blank=True)
class Meta:
verbose_name = 'Платеж за рисовальный лагерь'
verbose_name_plural = 'Платежи за рисовальный лагерь'
def __str__(self):
return '%s - %s' % (self.date_start.strftime('%d/%m/%Y'), self.date_end.strftime('%d/%m/%Y'))
@property
def date_end_humanize(self):
return arrow.get(self.date_end, settings.TIME_ZONE).humanize(locale='ru')
class GiftCertificatePayment(Payment):
gift_certificate = models.ForeignKey('GiftCertificate', on_delete=models.CASCADE,
verbose_name='Подарочный сертификат', related_name='payments')
class Meta:
verbose_name = 'Платеж за подарочный сертификат'
verbose_name_plural = 'Платежи за подарочные сертификаты'
class UserBonus(models.Model):
ACTION_FILL_PROFILE = 'fill_profile'
ACTION_PAID_ONE_MORE = 'paid_one_more'
ACTION_HAVE_REVIEW = 'have_review'
ACTION_CHILD_BIRTHDAY = 'child_birthday'
AMOUNT_FILL_PROFILE = 50
AMOUNT_PAID_ONE_MORE = 100
AMOUNT_HAVE_REVIEW = 300
AMOUNT_CHILD_BIRTHDAY = 200
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='bonuses')
amount = models.DecimalField(max_digits=8, decimal_places=2, default=0)
payment = models.ForeignKey(Payment, on_delete=models.SET_NULL, null=True, blank=True)
referral = models.ForeignKey('user.Referral', on_delete=models.SET_NULL, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
is_service = models.BooleanField(default=False)
action_name = models.CharField(max_length=20, blank=True, default='')
notified_at = models.DateTimeField(blank=True, null=True)
class Meta:
ordering = ('created_at',)
def __str__(self):
return '%s %s' % (self.user_id, self.amount)
class GiftCertificate(models.Model):
price = models.DecimalField(max_digits=8, decimal_places=2, default=0)
cover = models.CharField(max_length=255, blank=True, default='')
class Meta:
ordering = ('price',)
class UserGiftCertificate(models.Model):
gift_certificate = models.ForeignKey(GiftCertificate, on_delete=models.CASCADE,)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='gift_certificates')
payment = models.ForeignKey(Payment, on_delete=models.SET_NULL, null=True)
bonuses_sent = models.ForeignKey(UserBonus, on_delete=models.CASCADE, blank=True, null=True)
@property
def code(self):
return short_url.encode_url(self.id) if self.id else None
class DiscountManager(models.Manager):
def get_actual_discounts(self, user=None):
now_dt = now()
qs = self.filter(start__lte=now_dt, end__gte=now_dt, is_active=True, promo_id__isnull=True)
if user:
qs = qs.filter(Q(client=user) | Q(client__isnull=True))
else:
qs = qs.filter(client__isnull=True)
return qs
def get_courses_discounts(self, user=None, course=None):
actual_discounts = self.get_actual_discounts(user=user)
all_courses_max_discount = actual_discounts.filter(product=Discount.PRODUCT_ALL_COURSES)\
.aggregate(max_discount=Max('value'))
if course:
actual_discounts = actual_discounts.filter(course=course)
courses_max_discounts = actual_discounts.filter(product=Discount.PRODUCT_ONE_COURSE).values('course')\
.annotate(max_discount=Max('value'))
return all_courses_max_discount, courses_max_discounts
def get_packages_discounts(self, user=None, package=None):
actual_discounts = self.get_actual_discounts(user=user)
all_packages_max_discount = actual_discounts.filter(product=Discount.PRODUCT_ALL_PACKAGES) \
.aggregate(max_discount=Max('value'))
if package:
actual_discounts = actual_discounts.filter(package=package)
packages_max_discounts = actual_discounts.filter(product=Discount.PRODUCT_ONE_PACKAGE).values('package') \
.annotate(max_discount=Max('value'))
return all_packages_max_discount, packages_max_discounts
class Discount(models.Model):
PRODUCT_ALL_COURSES = 0
PRODUCT_ALL_PACKAGES = 1
PRODUCT_ONE_COURSE = 2
PRODUCT_ONE_PACKAGE = 3
USAGE_TYPE_LIMIT = 0
USAGE_TYPE_UNLIMITED = 1
PRODUCT_CHOICES = (
(PRODUCT_ALL_COURSES, 'Все курсы'),
(PRODUCT_ALL_PACKAGES, 'Все подписки'),
(PRODUCT_ONE_COURSE, 'Один курс'),
(PRODUCT_ONE_PACKAGE, 'Одна подписка')
)
USAGE_TYPE_CHOICES = (
(USAGE_TYPE_LIMIT, 'Ограничен по количеству использований'),
(USAGE_TYPE_UNLIMITED, 'Не ограничен по количеству использований')
)
name = models.CharField(verbose_name='Название', max_length=200)
client = models.ForeignKey(User, on_delete=models.CASCADE, related_name='discounts', null=True, blank=True,
verbose_name='Пользователь скидки')
product = models.PositiveSmallIntegerField('На какие продукты распространяется скидка', choices=PRODUCT_CHOICES,
default=PRODUCT_ALL_COURSES)
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='course_discounts', null=True, blank=True,
verbose_name='Курс на который распространяется скидка')
package = models.ForeignKey(Package, verbose_name='Подписка на которую распространяется скидка',
on_delete=models.CASCADE, related_name='package_discounts', null=True, blank=True)
promo_id = models.CharField(verbose_name='Промокод', max_length=20, null=True, blank=True)
usage_type = models.PositiveSmallIntegerField(verbose_name='Тип использования',
choices=USAGE_TYPE_CHOICES, default=USAGE_TYPE_LIMIT)
usage_count_limit = models.PositiveIntegerField(verbose_name='Сколько раз можно использовать', null=True,
blank=True)
activate_count = models.PositiveIntegerField(verbose_name='Сколько раз использован', default=0)
value = models.PositiveIntegerField(verbose_name='Размер скидки в %', default=0,
validators=[MaxValueValidator(100, 'Размер скидки не может быть больше 100')])
start = models.DateTimeField(verbose_name='Дата начала действия скидки', default=now)
end = models.DateTimeField(verbose_name='Дата окончания скидки')
is_active = models.BooleanField(verbose_name='Активность', default=True)
author = models.ForeignKey(User, verbose_name='Автор', null=True, blank=True, related_name='author_discounts',
on_delete=models.CASCADE)
objects = DiscountManager()
def __str__(self):
return self.name
class Meta:
verbose_name = 'Скидка'
verbose_name_plural = 'Скидки'