Compare commits
6 Commits
a592a1fb94
...
dab4fbd0a4
| Author | SHA1 | Date | |
|---|---|---|---|
| dab4fbd0a4 | |||
| cf5ab13fc8 | |||
| 37983bbee7 | |||
| e5c104bc11 | |||
| 55f2e0b02e | |||
| 99949dc318 |
|
|
@ -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=""
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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
0
core/__init__.py
Normal file
13
core/admin.py
Normal file
13
core/admin.py
Normal 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
6
core/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CoreConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'core'
|
||||||
45
core/migrations/0001_initial.py
Normal file
45
core/migrations/0001_initial.py
Normal 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': 'Глобальные настройки',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
core/migrations/__init__.py
Normal file
0
core/migrations/__init__.py
Normal file
47
core/models.py
Normal file
47
core/models.py
Normal 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
37
core/utils.py
Normal 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
3
core/views.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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'):
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
16
store/migrations/0005_delete_globalsettings.py
Normal file
16
store/migrations/0005_delete_globalsettings.py
Normal 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',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user