From 00686e9dc494e1f302f68f34bcce707756840fd5 Mon Sep 17 00:00:00 2001 From: phzhik Date: Fri, 24 May 2024 02:19:00 +0400 Subject: [PATCH] + BonusProgramConfig * Moved GlobalSettings to core app * Moved bonus program logic from User to BonusProgram class * Worked on error handling a bit --- account/models/__init__.py | 3 +- account/models/bonus.py | 97 +++++++++---------- account/models/user.py | 3 +- core/__init__.py | 0 core/admin.py | 13 +++ core/apps.py | 6 ++ core/migrations/0001_initial.py | 45 +++++++++ core/migrations/__init__.py | 0 core/models.py | 47 +++++++++ core/utils.py | 37 +++++++ core/views.py | 3 + poizonstore/exceptions.py | 5 + poizonstore/settings.py | 8 +- store/admin.py | 7 +- .../migrations/0005_delete_globalsettings.py | 16 +++ store/models.py | 53 ++-------- store/serializers.py | 6 +- store/tasks.py | 3 +- store/views.py | 3 +- 19 files changed, 244 insertions(+), 111 deletions(-) create mode 100644 core/__init__.py create mode 100644 core/admin.py create mode 100644 core/apps.py create mode 100644 core/migrations/0001_initial.py create mode 100644 core/migrations/__init__.py create mode 100644 core/models.py create mode 100644 core/utils.py create mode 100644 core/views.py create mode 100644 store/migrations/0005_delete_globalsettings.py diff --git a/account/models/__init__.py b/account/models/__init__.py index 359d530..72b0ff9 100644 --- a/account/models/__init__.py +++ b/account/models/__init__.py @@ -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 diff --git a/account/models/bonus.py b/account/models/bonus.py index 28d5f1b..b9ef10e 100644 --- a/account/models/bonus.py +++ b/account/models/bonus.py @@ -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) diff --git a/account/models/user.py b/account/models/user.py index 29626df..2726cc5 100644 --- a/account/models/user.py +++ b/account/models/user.py @@ -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 diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/admin.py b/core/admin.py new file mode 100644 index 0000000..2ff5a52 --- /dev/null +++ b/core/admin.py @@ -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 diff --git a/core/apps.py b/core/apps.py new file mode 100644 index 0000000..8115ae6 --- /dev/null +++ b/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'core' diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..17bbbc0 --- /dev/null +++ b/core/migrations/0001_initial.py @@ -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': 'Глобальные настройки', + }, + ), + ] diff --git a/core/migrations/__init__.py b/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/models.py b/core/models.py new file mode 100644 index 0000000..a337f7e --- /dev/null +++ b/core/models.py @@ -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 = 'Настройки бонусной программы' diff --git a/core/utils.py b/core/utils.py new file mode 100644 index 0000000..c65a2e0 --- /dev/null +++ b/core/utils.py @@ -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 diff --git a/core/views.py b/core/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/core/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/poizonstore/exceptions.py b/poizonstore/exceptions.py index 2baf5fa..ea1d60d 100644 --- a/poizonstore/exceptions.py +++ b/poizonstore/exceptions.py @@ -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'): diff --git a/poizonstore/settings.py b/poizonstore/settings.py index ef7ccfb..23c82f6 100644 --- a/poizonstore/settings.py +++ b/poizonstore/settings.py @@ -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, diff --git a/store/admin.py b/store/admin.py index 71ded69..de187c7 100644 --- a/store/admin.py +++ b/store/admin.py @@ -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') diff --git a/store/migrations/0005_delete_globalsettings.py b/store/migrations/0005_delete_globalsettings.py new file mode 100644 index 0000000..7924b38 --- /dev/null +++ b/store/migrations/0005_delete_globalsettings.py @@ -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', + ), + ] diff --git a/store/models.py b/store/models.py index 7cb59e6..b8b620e 100644 --- a/store/models.py +++ b/store/models.py @@ -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): diff --git a/store/serializers.py b/store/serializers.py index ad21628..b646124 100644 --- a/store/serializers.py +++ b/store/serializers.py @@ -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 diff --git a/store/tasks.py b/store/tasks.py index c67c4b1..988b15e 100644 --- a/store/tasks.py +++ b/store/tasks.py @@ -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 diff --git a/store/views.py b/store/views.py index 1b51073..e0d77fe 100644 --- a/store/views.py +++ b/store/views.py @@ -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,