+ BonusProgramConfig

* Moved GlobalSettings to core app
* Moved bonus program logic from User to BonusProgram class
* Worked on error handling a bit
This commit is contained in:
Phil Zhitnikov 2024-05-24 02:19:00 +04:00
parent 55f2e0b02e
commit e5c104bc11
19 changed files with 244 additions and 111 deletions

View File

@ -1,4 +1,5 @@
from .bonus import generate_referral_code, BonusType, BonusProgramMixin, BonusProgramTransaction, BonusProgramTransactionQuerySet
from .bonus import (generate_referral_code, BonusType, BonusProgramMixin, BonusProgram,
BonusProgramTransaction, BonusProgramTransactionQuerySet)
from .user import User, UserManager, UserQuerySet, ReferralRelationship

View File

@ -1,5 +1,4 @@
import logging
from contextlib import suppress
from django.conf import settings
from django.core.exceptions import ValidationError
@ -10,7 +9,7 @@ from django.utils.crypto import get_random_string
from django.utils.formats import localize
from django.utils.functional import cached_property
from store.models import Checklist
from core.models import BonusProgramConfig
from tg_bot.messages import TGBonusMessage
logger = logging.getLogger(__name__)
@ -60,7 +59,7 @@ class BonusType:
SPENT_PURCHASE: 'SPENT_PURCHASE',
}
ONE_TIME_TYPES = {SIGNUP}
ONE_TIME_TYPES = {SIGNUP, FOR_INVITER, INVITED_FIRST_PURCHASE}
ORDER_TYPES = {DEFAULT_PURCHASE, FOR_INVITER, INVITED_FIRST_PURCHASE, SPENT_PURCHASE}
@ -171,30 +170,31 @@ class BonusProgramTransaction(models.Model):
if self.id:
qs = qs.exclude(id=self.id)
uniqueness_err = None
bonus_name = BonusType.LOG_NAMES.get(self.type, self.type)
match self.type:
case t if t in BonusType.ONE_TIME_TYPES:
if qs.exists():
uniqueness_err = f"User {self.user_id} already got {bonus_name} one-time bonus"
if self.type in BonusType.ONE_TIME_TYPES:
if qs.exists():
raise ValidationError(f"User {self.user_id} already got {bonus_name} one-time bonus")
case t if t in BonusType.ORDER_TYPES:
# Check that order is defined
if self.order_id is None:
raise ValidationError("Order is required for that type")
if self.type in BonusType.ORDER_TYPES:
# Check that order is defined
if self.order_id is None:
raise ValidationError("Order is required for that type")
# Check for duplicates for the same order
already_exists = qs.filter(order_id=self.order_id).exists()
if already_exists:
uniqueness_err = f"User {self.user_id} already got {bonus_name} bonus for order #{self.order_id}"
if uniqueness_err:
logger.info(uniqueness_err)
raise ValidationError(uniqueness_err)
# Check for duplicates for the same order
already_exists = qs.filter(order_id=self.order_id).exists()
if already_exists:
raise ValidationError(f"User {self.user_id} already got {bonus_name} bonus for order #{self.order_id}")
def save(self, *args, **kwargs):
self.full_clean()
try:
self.full_clean()
except Exception as e:
# Catch all validation errors here and log it
logger.error(f"Error during bonus saving: {e}")
return
self.user.recalculate_balance()
if self.id is None:
self._notify_user_about_new_transaction()
@ -219,6 +219,8 @@ def generate_referral_code():
class BonusProgramMixin(models.Model):
"""BonusProgram fields for User model"""
balance = models.PositiveSmallIntegerField('Баланс, руб', default=0, editable=False)
referral_code = models.CharField(max_length=settings.REFERRAL_CODE_LENGTH, default=generate_referral_code,
editable=False)
@ -249,44 +251,54 @@ class BonusProgramMixin(models.Model):
self.balance = max(0, total_balance)
self.save(update_fields=['balance'])
def spend_bonuses(self, order: Checklist):
class BonusProgram:
@staticmethod
def spend_bonuses(order: 'Checklist'):
# Check if data is sufficient
if order is None or order.customer_id is None:
return
# Always use fresh balance
self.recalculate_balance()
order.customer.recalculate_balance()
# Spend full_price bonuses or nothing
to_spend = min(self.balance, order.full_price)
to_spend = min(order.customer.balance, order.full_price)
order.customer.update_balance(-to_spend, BonusType.SPENT_PURCHASE, order=order)
def add_signup_bonus(self):
@staticmethod
def add_signup_bonus(user: 'User'):
bonus_type = BonusType.SIGNUP
amount = self._get_bonus_amount("signup")
amount = BonusProgramConfig.load().amount_signup
self.update_balance(amount, bonus_type)
def add_order_bonus(self, order):
from store.models import Checklist
user.update_balance(amount, bonus_type)
@staticmethod
def add_order_bonus(order: 'Checklist'):
bonus_type = BonusType.DEFAULT_PURCHASE
amount = self._get_bonus_amount("default_purchase")
amount = BonusProgramConfig.load().amount_default_purchase
if order.status not in Checklist.Status.BONUS_ORDER_STATUSES:
# Check if data is sufficient
if order is None or order.customer_id is None:
return
# Check if eligible
if order.status != settings.BONUS_ELIGIBILITY_STATUS:
return
self.update_balance(amount, bonus_type, order=order)
def add_referral_bonus(self, order: Checklist, for_inviter: bool):
amount = self._get_bonus_amount("referral")
@staticmethod
def add_referral_bonus(order: 'Checklist', for_inviter: bool):
amount = BonusProgramConfig.load().amount_referral
# Check if data is sufficient
if order.customer_id is None or order.customer.inviter is None:
return
# Check if eligible
# Bonus is only for first purchase and only for orders that reached the CHINA_RUSSIA status
if order.status != Checklist.Status.CHINA_RUSSIA or order.customer.customer_orders.count() != 1:
# Bonus is only for first purchase and only for orders that reached the COMPLETED status
if order.status != settings.BONUS_ELIGIBILITY_STATUS or order.customer.customer_orders.count() != 1:
return
user = order.customer.inviter if for_inviter else order.customer
@ -294,16 +306,3 @@ class BonusProgramMixin(models.Model):
# Add bonuses
user.update_balance(amount, bonus_type, order=order)
@staticmethod
def _get_bonus_amount(config_key) -> int:
amount = 0
with suppress(KeyError):
amount = settings.BONUS_PROGRAM_CONFIG["amounts"][config_key]
return amount
# TODO: move to custom logger
def _log(self, level, message: str):
message = f"[BonusProgram #{self.id}] {message}"
logger.log(level, message)

View File

@ -13,6 +13,7 @@ from phonenumber_field.modelfields import PhoneNumberField
from phonenumber_field.phonenumber import PhoneNumber
from account.models import BonusProgramMixin
from account.models.bonus import BonusProgram
from store.utils import concat_not_null_values
from tg_bot.tasks import send_tg_message
@ -94,7 +95,7 @@ class UserManager(models.Manager.from_queryset(UserQuerySet), _UserManager):
# First-time binding Telegram <-> User ?
if freshly_created or user.tg_user_id is None:
# Add bonus for Telegram login
await sync_to_async(user.add_signup_bonus)()
await sync_to_async(BonusProgram.add_signup_bonus)(user)
# Create referral relationship
# Only for fresh registration

0
core/__init__.py Normal file
View File

13
core/admin.py Normal file
View File

@ -0,0 +1,13 @@
from django.contrib import admin
from .models import GlobalSettings, BonusProgramConfig
@admin.register(GlobalSettings)
class GlobalSettingsAdmin(admin.ModelAdmin):
pass
@admin.register(BonusProgramConfig)
class BonusProgramConfigAdmin(admin.ModelAdmin):
pass

6
core/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'core'

View File

@ -0,0 +1,45 @@
# Generated by Django 4.2.13 on 2024-05-23 22:08
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='BonusProgramConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount_signup', models.PositiveSmallIntegerField(default=150, verbose_name='Бонус за регистрацию')),
('amount_default_purchase', models.PositiveSmallIntegerField(default=50, verbose_name='Бонус за обычную покупку')),
('amount_referral', models.PositiveSmallIntegerField(default=500, verbose_name='Реферальный бонус')),
],
options={
'verbose_name': 'Настройки бонусной программы',
'verbose_name_plural': 'Настройки бонусной программы',
},
),
migrations.CreateModel(
name='GlobalSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('yuan_rate', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Курс CNY/RUB')),
('yuan_rate_last_updated', models.DateTimeField(default=None, null=True, verbose_name='Дата обновления курса CNY/RUB')),
('yuan_rate_commission', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Наценка на курс юаня, руб')),
('delivery_price_CN', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена доставки по Китаю')),
('commission_rub', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Комиссия, руб')),
('pickup_address', models.CharField(blank=True, max_length=200, null=True, verbose_name='Адрес пункта самовывоза')),
('time_to_buy', models.DurationField(default=datetime.timedelta(seconds=10800), help_text="Через N времени заказ переходит из статуса 'Новый' в 'Черновик'", verbose_name='Время на покупку')),
],
options={
'verbose_name': 'Глобальные настройки',
'verbose_name_plural': 'Глобальные настройки',
},
),
]

View File

47
core/models.py Normal file
View File

@ -0,0 +1,47 @@
from datetime import timedelta
from django.conf import settings
from django.db import models
from core.utils import CachedSingleton
@CachedSingleton("global_settings")
class GlobalSettings(models.Model):
yuan_rate = models.DecimalField('Курс CNY/RUB', max_digits=10, decimal_places=2, default=0)
yuan_rate_last_updated = models.DateTimeField('Дата обновления курса CNY/RUB', null=True, default=None)
yuan_rate_commission = models.DecimalField('Наценка на курс юаня, руб', max_digits=10, decimal_places=2, default=0)
delivery_price_CN = models.DecimalField('Цена доставки по Китаю', max_digits=10, decimal_places=2, default=0)
commission_rub = models.DecimalField('Комиссия, руб', max_digits=10, decimal_places=2, default=0)
pickup_address = models.CharField('Адрес пункта самовывоза', max_length=200, blank=True, null=True)
time_to_buy = models.DurationField('Время на покупку',
help_text="Через N времени заказ переходит из статуса 'Новый' в 'Черновик'",
default=timedelta(hours=3))
class Meta:
verbose_name = 'Глобальные настройки'
verbose_name_plural = 'Глобальные настройки'
def __str__(self) -> str:
return f'GlobalSettings <{self.id}>'
@property
def full_yuan_rate(self):
return self.yuan_rate + self.yuan_rate_commission
DEFAULT_CONFIG = settings.BONUS_PROGRAM_DEFAULT_CONFIG
@CachedSingleton("bonus_config")
class BonusProgramConfig(models.Model):
amount_signup = models.PositiveSmallIntegerField(
'Бонус за регистрацию', default=DEFAULT_CONFIG['amounts']['signup'])
amount_default_purchase = models.PositiveSmallIntegerField(
'Бонус за обычную покупку', default=DEFAULT_CONFIG['amounts']['default_purchase'])
amount_referral = models.PositiveSmallIntegerField(
'Реферальный бонус', default=DEFAULT_CONFIG['amounts']['referral'])
class Meta:
verbose_name = 'Настройки бонусной программы'
verbose_name_plural = 'Настройки бонусной программы'

37
core/utils.py Normal file
View File

@ -0,0 +1,37 @@
from django.core.cache import cache
class CachedSingleton:
def __init__(self, cache_key):
self._cache_key = cache_key
def __call__(self, cls):
def save(_self, *args, **kwargs):
# Store only one instance of model
_self.id = 1
cls.objects.exclude(id=_self.id).delete()
# Model's default save
_self._model_save(*args, **kwargs)
# Store model instance in cache
cache.set(self._cache_key, _self)
def load(_self) -> cls:
"""Load instance from cache or create new one in DB"""
obj = cache.get(self._cache_key)
if not obj:
obj, _ = cls.objects.get_or_create(id=1)
cache.set(self._cache_key, obj)
return obj
# Save old Model.save() method first
setattr(cls, '_model_save', cls.save)
# Then, override it with decorator's one
setattr(cls, 'save', save)
# Set the singleton loading method
setattr(cls, 'load', classmethod(load))
return cls

3
core/views.py Normal file
View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

@ -1,11 +1,16 @@
import logging
from django.core.exceptions import ValidationError as DjangoValidationError
from rest_framework.exceptions import ValidationError as DRFValidationError
from rest_framework.views import exception_handler as drf_exception_handler
logger = logging.getLogger(__name__)
def exception_handler(exc, context):
""" Handle Django ValidationError as an accepted exception """
logger.error(exc)
if isinstance(exc, DjangoValidationError):
if hasattr(exc, 'message_dict'):

View File

@ -104,7 +104,8 @@ INSTALLED_APPS = [
'account',
'store',
'tg_bot'
'tg_bot',
'core'
]
MIDDLEWARE = [
@ -261,8 +262,9 @@ CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = TIME_ZONE
# Bonus program
# TODO: move to GlobalSettings?
BONUS_PROGRAM_CONFIG = {
BONUS_ELIGIBILITY_STATUS = 'completed'
BONUS_PROGRAM_DEFAULT_CONFIG = {
"amounts": {
"signup": 150,
"default_purchase": 50,

View File

@ -2,7 +2,7 @@ from django.contrib import admin
from django.contrib.admin import display
from mptt.admin import MPTTModelAdmin
from .models import Category, Checklist, GlobalSettings, PaymentMethod, Promocode, Image, Gift
from .models import Category, Checklist, PaymentMethod, Promocode, Image, Gift
@admin.register(Category)
@ -32,11 +32,6 @@ class ChecklistAdmin(admin.ModelAdmin):
return Checklist.objects.with_base_related()
@admin.register(GlobalSettings)
class GlobalSettingsAdmin(admin.ModelAdmin):
pass
@admin.register(PaymentMethod)
class PaymentMethodAdmin(admin.ModelAdmin):
list_display = ('name', 'slug')

View File

@ -0,0 +1,16 @@
# Generated by Django 4.2.13 on 2024-05-22 21:30
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('store', '0004_alter_checklist_status'),
]
operations = [
migrations.DeleteModel(
name='GlobalSettings',
),
]

View File

@ -8,7 +8,6 @@ from io import BytesIO
from typing import Optional
from django.conf import settings
from django.core.cache import cache
from django.core.files.base import ContentFile
from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models
@ -21,50 +20,11 @@ from django_cleanup import cleanup
from mptt.fields import TreeForeignKey
from mptt.models import MPTTModel
from account.models import BonusProgram
from core.models import GlobalSettings
from store.utils import create_preview
class GlobalSettings(models.Model):
# currency
yuan_rate = models.DecimalField('Курс CNY/RUB', max_digits=10, decimal_places=2, default=0)
yuan_rate_last_updated = models.DateTimeField('Дата обновления курса CNY/RUB', null=True, default=None)
yuan_rate_commission = models.DecimalField('Наценка на курс юаня, руб', max_digits=10, decimal_places=2, default=0)
# Chinadelivery
delivery_price_CN = models.DecimalField('Цена доставки по Китаю', max_digits=10, decimal_places=2, default=0)
commission_rub = models.DecimalField('Комиссия, руб', max_digits=10, decimal_places=2, default=0)
pickup_address = models.CharField('Адрес пункта самовывоза', max_length=200, blank=True, null=True)
time_to_buy = models.DurationField('Время на покупку',
help_text="Через N времени заказ переходит из статуса 'Новый' в 'Черновик'",
default=timedelta(hours=3))
class Meta:
verbose_name = 'Глобальные настройки'
verbose_name_plural = 'Глобальные настройки'
def save(self, *args, **kwargs):
# Store only one instance of GlobalSettings
self.id = 1
self.__class__.objects.exclude(id=self.id).delete()
super().save(*args, **kwargs)
cache.set('global_settings', self)
def __str__(self) -> str:
return f'GlobalSettings <{self.id}>'
@classmethod
def load(cls) -> 'GlobalSettings':
obj = cache.get('global_settings')
if not obj:
obj, _ = cls.objects.get_or_create(id=1)
cache.set('global_settings', obj)
return obj
@property
def full_yuan_rate(self):
return self.yuan_rate + self.yuan_rate_commission
class Category(MPTTModel):
name = models.CharField('Название', max_length=20)
parent = TreeForeignKey('self', verbose_name='Родительская категория', on_delete=models.SET_NULL, blank=True, null=True, related_name='children', db_index=True)
@ -290,7 +250,6 @@ class Checklist(models.Model):
CDEK_READY_STATUSES = (RUSSIA, SPLIT_PAID, CDEK)
CANCELLABLE_ORDER_STATUSES = (DRAFT, NEW, PAYMENT, BUYING)
BONUS_ORDER_STATUSES = (CHINA_RUSSIA, RUSSIA, SPLIT_WAITING, SPLIT_PAID, CDEK, COMPLETED)
CHOICES = (
(DELETED, 'Удален'),
@ -643,7 +602,7 @@ class Checklist(models.Model):
if self.customer_id is None:
return
if self.status != Checklist.Status.CHINA_RUSSIA:
if self.status != settings.BONUS_ELIGIBILITY_STATUS:
return
# Check if any BonusProgramTransaction bound to current order exists
@ -653,10 +612,10 @@ class Checklist(models.Model):
# Apply either referral bonus or order bonus, not both
if self.customer.inviter is not None and self.customer.customer_orders.count() == 1:
self.customer.add_referral_bonus(self, for_inviter=False)
self.customer.inviter.add_referral_bonus(self, for_inviter=True)
BonusProgram.add_referral_bonus(self, for_inviter=False)
BonusProgram.add_referral_bonus(self, for_inviter=True)
else:
self.customer.add_order_bonus(self)
BonusProgram.add_order_bonus(self)
# TODO: split into sub-functions
def save(self, *args, **kwargs):

View File

@ -2,9 +2,11 @@ from django.db.transaction import atomic
from drf_extra_fields.fields import Base64ImageField
from rest_framework import serializers
from account.models.bonus import BonusProgram
from account.serializers import UserSerializer
from utils.exceptions import CRMException
from store.models import Checklist, GlobalSettings, Category, PaymentMethod, Promocode, Image, Gift
from store.models import Checklist, Category, PaymentMethod, Promocode, Image, Gift
from core.models import GlobalSettings
from store.utils import get_primary_key_related_model
from poizonstore.utils import PriceField
@ -146,7 +148,7 @@ class ChecklistSerializer(serializers.ModelSerializer):
self._create_main_images(instance, images.get('main_images'))
if use_bonuses:
instance.customer.spend_bonuses(order=instance)
BonusProgram.spend_bonuses(order=instance)
return instance

View File

@ -4,7 +4,8 @@ from django.utils import timezone
from external_api.cdek import client as cdek_client, CDEKStatus
from external_api.currency import client as CurrencyAPIClient
from .models import Checklist, GlobalSettings
from .models import Checklist
from core.models import GlobalSettings
@shared_task

View File

@ -17,7 +17,8 @@ from external_api.cdek import CDEKClient, CDEKWebhookTypes, CDEK_STATUS_TO_ORDER
from external_api.poizon import PoizonClient
from utils.exceptions import CRMException
from store.filters import GiftFilter, ChecklistFilter
from store.models import Checklist, GlobalSettings, Category, PaymentMethod, Promocode, Gift
from store.models import Checklist, Category, PaymentMethod, Promocode, Gift
from core.models import GlobalSettings
from store.serializers import (ChecklistSerializer, CategorySerializer, CategoryFullSerializer,
PaymentMethodSerializer, AnonymousGlobalSettingsSerializer, GlobalSettingsSerializer,
PromocodeSerializer, ClientUpdateChecklistSerializer, GiftSerializer,