Compare commits

...

6 Commits

Author SHA1 Message Date
dab4fbd0a4 * Query optimization
* Cleanup
2024-05-24 02:22:04 +04:00
cf5ab13fc8 * Oupsie 2024-05-24 02:20:10 +04:00
37983bbee7 * SENTRY_DSN address in env 2024-05-24 02:19:27 +04:00
e5c104bc11 + BonusProgramConfig
* Moved GlobalSettings to core app
* Moved bonus program logic from User to BonusProgram class
* Worked on error handling a bit
2024-05-24 02:19:00 +04:00
55f2e0b02e + CDEK webhook for instant status updates 2024-05-24 02:08:03 +04:00
99949dc318 + Filter orders by last N symbols of poizon_tracking 2024-05-22 22:32:00 +04:00
24 changed files with 338 additions and 125 deletions

View File

@ -1,4 +1,5 @@
APP_HOME="/var/www/poizonstore-stage" APP_HOME="/var/www/poizonstore-stage"
SITE_URL="https://crm-poizonstore.ru"
# === Keys === # === Keys ===
# Django # Django
@ -12,8 +13,12 @@ TG_BOT_TOKEN=""
# External API settings # External API settings
CDEK_CLIENT_ID="" CDEK_CLIENT_ID=""
CDEK_CLIENT_SECRET="" CDEK_CLIENT_SECRET=""
CDEK_WEBHOOK_URL_SALT=""
POIZON_TOKEN="" POIZON_TOKEN=""
CURRENCY_GETGEOIP_API_KEY="" CURRENCY_GETGEOIP_API_KEY=""
# Celery & Flower # Celery & Flower
FLOWER_BASIC_AUTH="login:pwd" FLOWER_BASIC_AUTH="login:pwd"
# Logging
SENTRY_DSN=""

View File

@ -15,6 +15,9 @@ class UserAdmin(admin.ModelAdmin):
class BonusProgramTransactionAdmin(admin.ModelAdmin): class BonusProgramTransactionAdmin(admin.ModelAdmin):
list_display = ('id', 'type', 'user', 'date', 'amount', 'comment', 'order', 'was_cancelled') list_display = ('id', 'type', 'user', 'date', 'amount', 'comment', 'order', 'was_cancelled')
def get_queryset(self, request):
return BonusProgramTransaction.objects.with_base_related()
def delete_queryset(self, request, queryset): def delete_queryset(self, request, queryset):
for obj in queryset: for obj in queryset:
obj.cancel() obj.cancel()

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 from .user import User, UserManager, UserQuerySet, ReferralRelationship

View File

@ -1,5 +1,4 @@
import logging import logging
from contextlib import suppress
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError 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.formats import localize
from django.utils.functional import cached_property from django.utils.functional import cached_property
from store.models import Checklist from core.models import BonusProgramConfig
from tg_bot.messages import TGBonusMessage from tg_bot.messages import TGBonusMessage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -60,14 +59,13 @@ class BonusType:
SPENT_PURCHASE: 'SPENT_PURCHASE', 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} ORDER_TYPES = {DEFAULT_PURCHASE, FOR_INVITER, INVITED_FIRST_PURCHASE, SPENT_PURCHASE}
class BonusProgramTransactionQuerySet(models.QuerySet): class BonusProgramTransactionQuerySet(models.QuerySet):
# TODO: optimize queries
def with_base_related(self): def with_base_related(self):
return self.select_related('order') return self.select_related('order', 'user')
def cancel(self): def cancel(self):
for t in self: for t in self:
@ -171,30 +169,31 @@ class BonusProgramTransaction(models.Model):
if self.id: if self.id:
qs = qs.exclude(id=self.id) qs = qs.exclude(id=self.id)
uniqueness_err = None
bonus_name = BonusType.LOG_NAMES.get(self.type, self.type) bonus_name = BonusType.LOG_NAMES.get(self.type, self.type)
match self.type: if self.type in BonusType.ONE_TIME_TYPES:
case t if t in BonusType.ONE_TIME_TYPES: if qs.exists():
if qs.exists(): raise ValidationError(f"User {self.user_id} already got {bonus_name} one-time bonus")
uniqueness_err = f"User {self.user_id} already got {bonus_name} one-time bonus"
case t if t in BonusType.ORDER_TYPES: if self.type in BonusType.ORDER_TYPES:
# Check that order is defined # Check that order is defined
if self.order_id is None: if self.order_id is None:
raise ValidationError("Order is required for that type") raise ValidationError("Order is required for that type")
# Check for duplicates for the same order # Check for duplicates for the same order
already_exists = qs.filter(order_id=self.order_id).exists() already_exists = qs.filter(order_id=self.order_id).exists()
if already_exists: if already_exists:
uniqueness_err = f"User {self.user_id} already got {bonus_name} bonus for order #{self.order_id}" raise ValidationError(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)
def save(self, *args, **kwargs): 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: if self.id is None:
self._notify_user_about_new_transaction() self._notify_user_about_new_transaction()
@ -219,6 +218,8 @@ def generate_referral_code():
class BonusProgramMixin(models.Model): class BonusProgramMixin(models.Model):
"""BonusProgram fields for User model"""
balance = models.PositiveSmallIntegerField('Баланс, руб', default=0, editable=False) balance = models.PositiveSmallIntegerField('Баланс, руб', default=0, editable=False)
referral_code = models.CharField(max_length=settings.REFERRAL_CODE_LENGTH, default=generate_referral_code, referral_code = models.CharField(max_length=settings.REFERRAL_CODE_LENGTH, default=generate_referral_code,
editable=False) editable=False)
@ -249,44 +250,55 @@ class BonusProgramMixin(models.Model):
self.balance = max(0, total_balance) self.balance = max(0, total_balance)
self.save(update_fields=['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: if order is None or order.customer_id is None:
return return
# Always use fresh balance # Always use fresh balance
self.recalculate_balance() order.customer.recalculate_balance()
# Spend full_price bonuses or nothing # 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) 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 bonus_type = BonusType.SIGNUP
amount = self._get_bonus_amount("signup") amount = BonusProgramConfig.load().amount_signup
self.update_balance(amount, bonus_type) user.update_balance(amount, bonus_type)
def add_order_bonus(self, order):
from store.models import Checklist
@staticmethod
def add_order_bonus(order: 'Checklist'):
bonus_type = BonusType.DEFAULT_PURCHASE 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 return
self.update_balance(amount, bonus_type, order=order) # Check if eligible
if order.status != settings.BONUS_ELIGIBILITY_STATUS:
return
def add_referral_bonus(self, order: Checklist, for_inviter: bool): # Add bonuses
amount = self._get_bonus_amount("referral") order.customer.update_balance(amount, bonus_type, order=order)
@staticmethod
def add_referral_bonus(order: 'Checklist', for_inviter: bool):
amount = BonusProgramConfig.load().amount_referral
# Check if data is sufficient # Check if data is sufficient
if order.customer_id is None or order.customer.inviter is None: if order.customer_id is None or order.customer.inviter is None:
return return
# Check if eligible # Check if eligible
# Bonus is only for first purchase and only for orders that reached the CHINA_RUSSIA status # Bonus is only for first purchase and only for orders that reached the COMPLETED status
if order.status != Checklist.Status.CHINA_RUSSIA or order.customer.customer_orders.count() != 1: if order.status != settings.BONUS_ELIGIBILITY_STATUS or order.customer.customer_orders.count() != 1:
return return
user = order.customer.inviter if for_inviter else order.customer user = order.customer.inviter if for_inviter else order.customer
@ -294,16 +306,3 @@ class BonusProgramMixin(models.Model):
# Add bonuses # Add bonuses
user.update_balance(amount, bonus_type, order=order) 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 phonenumber_field.phonenumber import PhoneNumber
from account.models import BonusProgramMixin from account.models import BonusProgramMixin
from account.models.bonus import BonusProgram
from store.utils import concat_not_null_values from store.utils import concat_not_null_values
from tg_bot.tasks import send_tg_message 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 ? # First-time binding Telegram <-> User ?
if freshly_created or user.tg_user_id is None: if freshly_created or user.tg_user_id is None:
# Add bonus for Telegram login # 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 # Create referral relationship
# Only for fresh registration # 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

@ -9,6 +9,8 @@ import requests
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from poizonstore.utils import deep_get
from store.models import Checklist
from store.utils import is_migration_running from store.utils import is_migration_running
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'poizonstore.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'poizonstore.settings')
@ -79,12 +81,23 @@ class CDEKStatus:
POSTOMAT_RECEIVED = "POSTOMAT_RECEIVED" POSTOMAT_RECEIVED = "POSTOMAT_RECEIVED"
class CDEKWebhookTypes:
ORDER_STATUS = "ORDER_STATUS"
CDEK_STATUS_TO_ORDER_STATUS = {
CDEKStatus.DELIVERED: Checklist.Status.COMPLETED,
CDEKStatus.READY_FOR_SHIPMENT_IN_SENDER_CITY: Checklist.Status.CDEK,
}
class CDEKClient: class CDEKClient:
AUTH_ENDPOINT = 'oauth/token' AUTH_ENDPOINT = 'oauth/token'
ORDER_INFO_ENDPOINT = 'orders' ORDER_INFO_ENDPOINT = 'orders'
CALCULATOR_TARIFF_ENDPOINT = 'calculator/tariff' CALCULATOR_TARIFF_ENDPOINT = 'calculator/tariff'
CALCULATOR_TARIFF_LIST_ENDPOINT = 'calculator/tarifflist' CALCULATOR_TARIFF_LIST_ENDPOINT = 'calculator/tarifflist'
BARCODE_ENDPOINT = 'print/barcodes' BARCODE_ENDPOINT = 'print/barcodes'
WEBHOOK_ENDPOINT = 'webhooks'
MAX_RETRIES = 2 MAX_RETRIES = 2
@ -204,8 +217,26 @@ class CDEKClient:
return [] return []
def setup_webhooks(self):
if not settings.SITE_URL:
return
request_data = {
"type": CDEKWebhookTypes.ORDER_STATUS,
"url": f"{settings.SITE_URL}/cdek/webhook/{settings.CDEK_WEBHOOK_URL_SALT}/"
}
return self.request('POST', self.WEBHOOK_ENDPOINT, json=request_data)
@staticmethod
def process_orderstatus_webhook(data) -> tuple:
""" Unpack CDEK request to data. Info: https://api-docs.cdek.ru/29924139.html """
cdek_number = deep_get(data, "attributes", "cdek_number")
cdek_status = deep_get(data, "attributes", "code")
return cdek_number, cdek_status
client = CDEKClient(settings.CDEK_CLIENT_ID, settings.CDEK_CLIENT_SECRET) client = CDEKClient(settings.CDEK_CLIENT_ID, settings.CDEK_CLIENT_SECRET)
if not is_migration_running(): if not is_migration_running():
client.authorize() client.authorize()
client.setup_webhooks()

View File

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

View File

@ -36,10 +36,12 @@ def get_secret(setting):
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = get_secret("SECRET_KEY") SECRET_KEY = get_secret("SECRET_KEY")
SITE_URL = get_secret("SITE_URL")
# External API settings # External API settings
CDEK_CLIENT_ID = get_secret("CDEK_CLIENT_ID") CDEK_CLIENT_ID = get_secret("CDEK_CLIENT_ID")
CDEK_CLIENT_SECRET = get_secret("CDEK_CLIENT_SECRET") CDEK_CLIENT_SECRET = get_secret("CDEK_CLIENT_SECRET")
CDEK_WEBHOOK_URL_SALT = get_secret("CDEK_WEBHOOK_URL_SALT")
POIZON_TOKEN = get_secret("POIZON_TOKEN") POIZON_TOKEN = get_secret("POIZON_TOKEN")
@ -102,7 +104,8 @@ INSTALLED_APPS = [
'account', 'account',
'store', 'store',
'tg_bot' 'tg_bot',
'core'
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -236,8 +239,8 @@ REFERRAL_CODE_LENGTH = 10
COMMISSION_OVER_150K = 1.1 COMMISSION_OVER_150K = 1.1
# Logging # Logging
SENTRY_DSN = "https://96106e3f938badc86ecb2e502716e496@o4506163299418112.ingest.sentry.io/4506163300663296" SENTRY_DSN = get_secret("SENTRY_DSN")
if not DEBUG: if SENTRY_DSN:
sentry_sdk.init( sentry_sdk.init(
dsn=SENTRY_DSN, dsn=SENTRY_DSN,
# Set traces_sample_rate to 1.0 to capture 100% # Set traces_sample_rate to 1.0 to capture 100%
@ -259,8 +262,9 @@ CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = TIME_ZONE CELERY_TIMEZONE = TIME_ZONE
# Bonus program # Bonus program
# TODO: move to GlobalSettings? BONUS_ELIGIBILITY_STATUS = 'completed'
BONUS_PROGRAM_CONFIG = {
BONUS_PROGRAM_DEFAULT_CONFIG = {
"amounts": { "amounts": {
"signup": 150, "signup": 150,
"default_purchase": 50, "default_purchase": 50,

View File

@ -1,6 +1,13 @@
from functools import reduce
from rest_framework.fields import DecimalField from rest_framework.fields import DecimalField
class PriceField(DecimalField): class PriceField(DecimalField):
def __init__(self, *args, max_digits=10, decimal_places=2, min_value=0, **kwargs): def __init__(self, *args, max_digits=10, decimal_places=2, min_value=0, **kwargs):
super().__init__(*args, max_digits=max_digits, decimal_places=decimal_places, min_value=min_value, **kwargs) super().__init__(*args, max_digits=max_digits, decimal_places=decimal_places, min_value=min_value, **kwargs)
def deep_get(dictionary, *keys, default=None):
"""Get value from a nested dictionary (JSON)"""
return reduce(lambda d, key: d.get(key, None) if isinstance(d, dict) else default, keys, dictionary)

View File

@ -2,7 +2,7 @@ from django.contrib import admin
from django.contrib.admin import display from django.contrib.admin import display
from mptt.admin import MPTTModelAdmin 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) @admin.register(Category)
@ -32,11 +32,6 @@ class ChecklistAdmin(admin.ModelAdmin):
return Checklist.objects.with_base_related() return Checklist.objects.with_base_related()
@admin.register(GlobalSettings)
class GlobalSettingsAdmin(admin.ModelAdmin):
pass
@admin.register(PaymentMethod) @admin.register(PaymentMethod)
class PaymentMethodAdmin(admin.ModelAdmin): class PaymentMethodAdmin(admin.ModelAdmin):
list_display = ('name', 'slug') list_display = ('name', 'slug')
@ -49,8 +44,5 @@ class PromoCodeAdmin(admin.ModelAdmin):
@admin.register(Gift) @admin.register(Gift)
class GiftAdmin(admin.ModelAdmin): class GiftAdmin(admin.ModelAdmin):
list_display = ('name', 'min_price') list_display = ('name', 'min_price', 'available_count')

View File

@ -16,7 +16,11 @@ class GiftFilter(filters.FilterSet):
class ChecklistFilter(filters.FilterSet): class ChecklistFilter(filters.FilterSet):
status = filters.MultipleChoiceFilter(choices=Checklist.Status.CHOICES) status = filters.MultipleChoiceFilter(choices=Checklist.Status.CHOICES)
delivery_code = filters.CharFilter(method='filter_delivery_code')
class Meta: class Meta:
model = Checklist model = Checklist
fields = ('status',) fields = ('status', 'delivery_code')
def filter_delivery_code(self, queryset, name, value):
return queryset.filter(poizon_tracking__iendswith=value)

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 typing import Optional
from django.conf import settings from django.conf import settings
from django.core.cache import cache
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.validators import MinValueValidator, MaxValueValidator from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models from django.db import models
@ -21,50 +20,11 @@ from django_cleanup import cleanup
from mptt.fields import TreeForeignKey from mptt.fields import TreeForeignKey
from mptt.models import MPTTModel from mptt.models import MPTTModel
from account.models import BonusProgram
from core.models import GlobalSettings
from store.utils import create_preview 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): class Category(MPTTModel):
name = models.CharField('Название', max_length=20) 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) 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) CDEK_READY_STATUSES = (RUSSIA, SPLIT_PAID, CDEK)
CANCELLABLE_ORDER_STATUSES = (DRAFT, NEW, PAYMENT, BUYING) CANCELLABLE_ORDER_STATUSES = (DRAFT, NEW, PAYMENT, BUYING)
BONUS_ORDER_STATUSES = (CHINA_RUSSIA, RUSSIA, SPLIT_WAITING, SPLIT_PAID, CDEK, COMPLETED)
CHOICES = ( CHOICES = (
(DELETED, 'Удален'), (DELETED, 'Удален'),
@ -643,7 +602,7 @@ class Checklist(models.Model):
if self.customer_id is None: if self.customer_id is None:
return return
if self.status != Checklist.Status.CHINA_RUSSIA: if self.status != settings.BONUS_ELIGIBILITY_STATUS:
return return
# Check if any BonusProgramTransaction bound to current order exists # 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 # Apply either referral bonus or order bonus, not both
if self.customer.inviter is not None and self.customer.customer_orders.count() == 1: if self.customer.inviter is not None and self.customer.customer_orders.count() == 1:
self.customer.add_referral_bonus(self, for_inviter=False) BonusProgram.add_referral_bonus(self, for_inviter=False)
self.customer.inviter.add_referral_bonus(self, for_inviter=True) BonusProgram.add_referral_bonus(self, for_inviter=True)
else: else:
self.customer.add_order_bonus(self) BonusProgram.add_order_bonus(self)
# TODO: split into sub-functions # TODO: split into sub-functions
def save(self, *args, **kwargs): 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 drf_extra_fields.fields import Base64ImageField
from rest_framework import serializers from rest_framework import serializers
from account.models.bonus import BonusProgram
from account.serializers import UserSerializer from account.serializers import UserSerializer
from utils.exceptions import CRMException 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 store.utils import get_primary_key_related_model
from poizonstore.utils import PriceField from poizonstore.utils import PriceField
@ -146,7 +148,7 @@ class ChecklistSerializer(serializers.ModelSerializer):
self._create_main_images(instance, images.get('main_images')) self._create_main_images(instance, images.get('main_images'))
if use_bonuses: if use_bonuses:
instance.customer.spend_bonuses(order=instance) BonusProgram.spend_bonuses(order=instance)
return instance return instance

View File

@ -4,11 +4,14 @@ from django.utils import timezone
from external_api.cdek import client as cdek_client, CDEKStatus from external_api.cdek import client as cdek_client, CDEKStatus
from external_api.currency import client as CurrencyAPIClient from external_api.currency import client as CurrencyAPIClient
from .models import Checklist, GlobalSettings from .models import Checklist
from core.models import GlobalSettings
@shared_task @shared_task
def check_cdek_status(order_id): def check_cdek_status(order_id):
"""Manually check CDEK status of order"""
obj = Checklist.objects.filter(id=order_id).first() obj = Checklist.objects.filter(id=order_id).first()
if obj is None or obj.cdek_tracking is None or obj.status == Checklist.Status.COMPLETED: if obj is None or obj.cdek_tracking is None or obj.status == Checklist.Status.COMPLETED:
return return
@ -20,6 +23,7 @@ def check_cdek_status(order_id):
old_status = obj.status old_status = obj.status
new_status = obj.status new_status = obj.status
if CDEKStatus.DELIVERED in statuses: if CDEKStatus.DELIVERED in statuses:
new_status = Checklist.Status.COMPLETED new_status = Checklist.Status.COMPLETED
elif CDEKStatus.READY_FOR_SHIPMENT_IN_SENDER_CITY in statuses: elif CDEKStatus.READY_FOR_SHIPMENT_IN_SENDER_CITY in statuses:

View File

@ -9,15 +9,15 @@ from rest_framework import generics, permissions, mixins, status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import NotFound from rest_framework.exceptions import NotFound
from rest_framework.filters import SearchFilter from rest_framework.filters import SearchFilter
from rest_framework.generics import get_object_or_404
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from external_api.cdek import CDEKClient from external_api.cdek import CDEKClient, CDEKWebhookTypes, CDEK_STATUS_TO_ORDER_STATUS
from external_api.poizon import PoizonClient from external_api.poizon import PoizonClient
from utils.exceptions import CRMException from utils.exceptions import CRMException
from store.filters import GiftFilter, ChecklistFilter 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, from store.serializers import (ChecklistSerializer, CategorySerializer, CategoryFullSerializer,
PaymentMethodSerializer, AnonymousGlobalSettingsSerializer, GlobalSettingsSerializer, PaymentMethodSerializer, AnonymousGlobalSettingsSerializer, GlobalSettingsSerializer,
PromocodeSerializer, ClientUpdateChecklistSerializer, GiftSerializer, PromocodeSerializer, ClientUpdateChecklistSerializer, GiftSerializer,
@ -98,7 +98,7 @@ class ChecklistAPI(viewsets.ModelViewSet):
obj.cancel() obj.cancel()
def get_queryset(self): def get_queryset(self):
return Checklist.objects.all().with_base_related() \ return Checklist.objects.with_base_related() \
.annotate_bonus_used() \ .annotate_bonus_used() \
.default_ordering() .default_ordering()
@ -350,6 +350,35 @@ class CDEKAPI(viewsets.GenericViewSet):
r = self.client.calculate_tarifflist(data) r = self.client.calculate_tarifflist(data)
return prepare_external_response(r) return prepare_external_response(r)
@action(url_path=f'webhook/{settings.CDEK_WEBHOOK_URL_SALT}', detail=False, methods=['post'])
def webhook(self, request, *args, **kwargs):
data = request.data
response = Response()
match data.get("type"):
case CDEKWebhookTypes.ORDER_STATUS:
cdek_number, cdek_status = self.client.process_orderstatus_webhook(data)
if cdek_number is None or cdek_status is None:
return response
order = Checklist.objects.filter(cdek_tracking=cdek_number).first()
if order is None:
return response
# New status or old one
new_order_status = CDEK_STATUS_TO_ORDER_STATUS.get(cdek_status, order.status)
# Update status
if order.status != new_order_status:
print(f'Order [{order.id}] status: {order.status} -> {new_order_status}')
order.status = new_order_status
order.save()
case _:
pass
return response
# TODO: review permissions # TODO: review permissions
class PoizonAPI(viewsets.GenericViewSet): class PoizonAPI(viewsets.GenericViewSet):