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"
|
||||
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"
|
||||
|
||||
# Logging
|
||||
SENTRY_DSN=""
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
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.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()
|
||||
|
|
|
|||
|
|
@ -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'):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
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 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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user