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"
SITE_URL="https://crm-poizonstore.ru"
# === Keys ===
# Django
@ -12,8 +13,12 @@ TG_BOT_TOKEN=""
# External API settings
CDEK_CLIENT_ID=""
CDEK_CLIENT_SECRET=""
CDEK_WEBHOOK_URL_SALT=""
POIZON_TOKEN=""
CURRENCY_GETGEOIP_API_KEY=""
# 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):
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):
for obj in queryset:
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

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,14 +59,13 @@ 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}
class BonusProgramTransactionQuerySet(models.QuerySet):
# TODO: optimize queries
def with_base_related(self):
return self.select_related('order')
return self.select_related('order', 'user')
def cancel(self):
for t in self:
@ -171,30 +169,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 +218,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 +250,55 @@ 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
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):
amount = self._get_bonus_amount("referral")
# Add bonuses
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
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

@ -9,6 +9,8 @@ import requests
from django.conf import settings
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
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'poizonstore.settings')
@ -79,12 +81,23 @@ class CDEKStatus:
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:
AUTH_ENDPOINT = 'oauth/token'
ORDER_INFO_ENDPOINT = 'orders'
CALCULATOR_TARIFF_ENDPOINT = 'calculator/tariff'
CALCULATOR_TARIFF_LIST_ENDPOINT = 'calculator/tarifflist'
BARCODE_ENDPOINT = 'print/barcodes'
WEBHOOK_ENDPOINT = 'webhooks'
MAX_RETRIES = 2
@ -204,8 +217,26 @@ class CDEKClient:
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)
if not is_migration_running():
client.authorize()
client.setup_webhooks()

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

@ -36,10 +36,12 @@ def get_secret(setting):
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = get_secret("SECRET_KEY")
SITE_URL = get_secret("SITE_URL")
# External API settings
CDEK_CLIENT_ID = get_secret("CDEK_CLIENT_ID")
CDEK_CLIENT_SECRET = get_secret("CDEK_CLIENT_SECRET")
CDEK_WEBHOOK_URL_SALT = get_secret("CDEK_WEBHOOK_URL_SALT")
POIZON_TOKEN = get_secret("POIZON_TOKEN")
@ -102,7 +104,8 @@ INSTALLED_APPS = [
'account',
'store',
'tg_bot'
'tg_bot',
'core'
]
MIDDLEWARE = [
@ -236,8 +239,8 @@ REFERRAL_CODE_LENGTH = 10
COMMISSION_OVER_150K = 1.1
# Logging
SENTRY_DSN = "https://96106e3f938badc86ecb2e502716e496@o4506163299418112.ingest.sentry.io/4506163300663296"
if not DEBUG:
SENTRY_DSN = get_secret("SENTRY_DSN")
if SENTRY_DSN:
sentry_sdk.init(
dsn=SENTRY_DSN,
# Set traces_sample_rate to 1.0 to capture 100%
@ -259,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

@ -1,6 +1,13 @@
from functools import reduce
from rest_framework.fields import DecimalField
class PriceField(DecimalField):
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)
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 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')
@ -49,8 +44,5 @@ class PromoCodeAdmin(admin.ModelAdmin):
@admin.register(Gift)
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):
status = filters.MultipleChoiceFilter(choices=Checklist.Status.CHOICES)
delivery_code = filters.CharFilter(method='filter_delivery_code')
class Meta:
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 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,11 +4,14 @@ 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
def check_cdek_status(order_id):
"""Manually check CDEK status of order"""
obj = Checklist.objects.filter(id=order_id).first()
if obj is None or obj.cdek_tracking is None or obj.status == Checklist.Status.COMPLETED:
return
@ -20,6 +23,7 @@ def check_cdek_status(order_id):
old_status = obj.status
new_status = obj.status
if CDEKStatus.DELIVERED in statuses:
new_status = Checklist.Status.COMPLETED
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.exceptions import NotFound
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.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 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,
@ -98,7 +98,7 @@ class ChecklistAPI(viewsets.ModelViewSet):
obj.cancel()
def get_queryset(self):
return Checklist.objects.all().with_base_related() \
return Checklist.objects.with_base_related() \
.annotate_bonus_used() \
.default_ordering()
@ -350,6 +350,35 @@ class CDEKAPI(viewsets.GenericViewSet):
r = self.client.calculate_tarifflist(data)
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
class PoizonAPI(viewsets.GenericViewSet):