+ BonusProgramConfig

* Moved GlobalSettings to core app
* Moved bonus program logic from User to BonusProgram class
* Worked on error handling a bit
This commit is contained in:
Phil Zhitnikov 2024-05-24 02:19:00 +04:00
parent c47864106e
commit 00686e9dc4
19 changed files with 244 additions and 111 deletions

View File

@ -1,4 +1,5 @@
from .bonus import generate_referral_code, BonusType, BonusProgramMixin, BonusProgramTransaction, BonusProgramTransactionQuerySet from .bonus import (generate_referral_code, BonusType, BonusProgramMixin, BonusProgram,
BonusProgramTransaction, BonusProgramTransactionQuerySet)
from .user import User, UserManager, UserQuerySet, ReferralRelationship from .user import User, UserManager, UserQuerySet, ReferralRelationship

View File

@ -1,5 +1,4 @@
import logging import logging
from contextlib import suppress
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -10,7 +9,7 @@ from django.utils.crypto import get_random_string
from django.utils.formats import localize from django.utils.formats import localize
from django.utils.functional import cached_property from django.utils.functional import cached_property
from store.models import Checklist from core.models import BonusProgramConfig
from tg_bot.messages import TGBonusMessage from tg_bot.messages import TGBonusMessage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -60,7 +59,7 @@ 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}
@ -171,30 +170,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 +219,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 +251,54 @@ 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
# Check if eligible
if order.status != settings.BONUS_ELIGIBILITY_STATUS:
return return
self.update_balance(amount, bonus_type, order=order) self.update_balance(amount, bonus_type, order=order)
def add_referral_bonus(self, order: Checklist, for_inviter: bool): @staticmethod
amount = self._get_bonus_amount("referral") def add_referral_bonus(order: 'Checklist', for_inviter: bool):
amount = BonusProgramConfig.load().amount_referral
# Check if data is sufficient # Check if data is sufficient
if order.customer_id is None or order.customer.inviter is None: if order.customer_id is None or order.customer.inviter is None:
return return
# Check if eligible # Check if eligible
# Bonus is only for first purchase and only for orders that reached the CHINA_RUSSIA status # Bonus is only for first purchase and only for orders that reached the COMPLETED status
if order.status != Checklist.Status.CHINA_RUSSIA or order.customer.customer_orders.count() != 1: if order.status != settings.BONUS_ELIGIBILITY_STATUS or order.customer.customer_orders.count() != 1:
return return
user = order.customer.inviter if for_inviter else order.customer user = order.customer.inviter if for_inviter else order.customer
@ -294,16 +306,3 @@ class BonusProgramMixin(models.Model):
# Add bonuses # Add bonuses
user.update_balance(amount, bonus_type, order=order) user.update_balance(amount, bonus_type, order=order)
@staticmethod
def _get_bonus_amount(config_key) -> int:
amount = 0
with suppress(KeyError):
amount = settings.BONUS_PROGRAM_CONFIG["amounts"][config_key]
return amount
# TODO: move to custom logger
def _log(self, level, message: str):
message = f"[BonusProgram #{self.id}] {message}"
logger.log(level, message)

View File

@ -13,6 +13,7 @@ from phonenumber_field.modelfields import PhoneNumberField
from phonenumber_field.phonenumber import PhoneNumber from phonenumber_field.phonenumber import PhoneNumber
from account.models import BonusProgramMixin from account.models import BonusProgramMixin
from account.models.bonus import BonusProgram
from store.utils import concat_not_null_values from store.utils import concat_not_null_values
from tg_bot.tasks import send_tg_message from tg_bot.tasks import send_tg_message
@ -94,7 +95,7 @@ class UserManager(models.Manager.from_queryset(UserQuerySet), _UserManager):
# First-time binding Telegram <-> User ? # First-time binding Telegram <-> User ?
if freshly_created or user.tg_user_id is None: if freshly_created or user.tg_user_id is None:
# Add bonus for Telegram login # Add bonus for Telegram login
await sync_to_async(user.add_signup_bonus)() await sync_to_async(BonusProgram.add_signup_bonus)(user)
# Create referral relationship # Create referral relationship
# Only for fresh registration # Only for fresh registration

0
core/__init__.py Normal file
View File

13
core/admin.py Normal file
View File

@ -0,0 +1,13 @@
from django.contrib import admin
from .models import GlobalSettings, BonusProgramConfig
@admin.register(GlobalSettings)
class GlobalSettingsAdmin(admin.ModelAdmin):
pass
@admin.register(BonusProgramConfig)
class BonusProgramConfigAdmin(admin.ModelAdmin):
pass

6
core/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'core'

View File

@ -0,0 +1,45 @@
# Generated by Django 4.2.13 on 2024-05-23 22:08
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='BonusProgramConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount_signup', models.PositiveSmallIntegerField(default=150, verbose_name='Бонус за регистрацию')),
('amount_default_purchase', models.PositiveSmallIntegerField(default=50, verbose_name='Бонус за обычную покупку')),
('amount_referral', models.PositiveSmallIntegerField(default=500, verbose_name='Реферальный бонус')),
],
options={
'verbose_name': 'Настройки бонусной программы',
'verbose_name_plural': 'Настройки бонусной программы',
},
),
migrations.CreateModel(
name='GlobalSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('yuan_rate', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Курс CNY/RUB')),
('yuan_rate_last_updated', models.DateTimeField(default=None, null=True, verbose_name='Дата обновления курса CNY/RUB')),
('yuan_rate_commission', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Наценка на курс юаня, руб')),
('delivery_price_CN', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена доставки по Китаю')),
('commission_rub', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Комиссия, руб')),
('pickup_address', models.CharField(blank=True, max_length=200, null=True, verbose_name='Адрес пункта самовывоза')),
('time_to_buy', models.DurationField(default=datetime.timedelta(seconds=10800), help_text="Через N времени заказ переходит из статуса 'Новый' в 'Черновик'", verbose_name='Время на покупку')),
],
options={
'verbose_name': 'Глобальные настройки',
'verbose_name_plural': 'Глобальные настройки',
},
),
]

View File

47
core/models.py Normal file
View File

@ -0,0 +1,47 @@
from datetime import timedelta
from django.conf import settings
from django.db import models
from core.utils import CachedSingleton
@CachedSingleton("global_settings")
class GlobalSettings(models.Model):
yuan_rate = models.DecimalField('Курс CNY/RUB', max_digits=10, decimal_places=2, default=0)
yuan_rate_last_updated = models.DateTimeField('Дата обновления курса CNY/RUB', null=True, default=None)
yuan_rate_commission = models.DecimalField('Наценка на курс юаня, руб', max_digits=10, decimal_places=2, default=0)
delivery_price_CN = models.DecimalField('Цена доставки по Китаю', max_digits=10, decimal_places=2, default=0)
commission_rub = models.DecimalField('Комиссия, руб', max_digits=10, decimal_places=2, default=0)
pickup_address = models.CharField('Адрес пункта самовывоза', max_length=200, blank=True, null=True)
time_to_buy = models.DurationField('Время на покупку',
help_text="Через N времени заказ переходит из статуса 'Новый' в 'Черновик'",
default=timedelta(hours=3))
class Meta:
verbose_name = 'Глобальные настройки'
verbose_name_plural = 'Глобальные настройки'
def __str__(self) -> str:
return f'GlobalSettings <{self.id}>'
@property
def full_yuan_rate(self):
return self.yuan_rate + self.yuan_rate_commission
DEFAULT_CONFIG = settings.BONUS_PROGRAM_DEFAULT_CONFIG
@CachedSingleton("bonus_config")
class BonusProgramConfig(models.Model):
amount_signup = models.PositiveSmallIntegerField(
'Бонус за регистрацию', default=DEFAULT_CONFIG['amounts']['signup'])
amount_default_purchase = models.PositiveSmallIntegerField(
'Бонус за обычную покупку', default=DEFAULT_CONFIG['amounts']['default_purchase'])
amount_referral = models.PositiveSmallIntegerField(
'Реферальный бонус', default=DEFAULT_CONFIG['amounts']['referral'])
class Meta:
verbose_name = 'Настройки бонусной программы'
verbose_name_plural = 'Настройки бонусной программы'

37
core/utils.py Normal file
View File

@ -0,0 +1,37 @@
from django.core.cache import cache
class CachedSingleton:
def __init__(self, cache_key):
self._cache_key = cache_key
def __call__(self, cls):
def save(_self, *args, **kwargs):
# Store only one instance of model
_self.id = 1
cls.objects.exclude(id=_self.id).delete()
# Model's default save
_self._model_save(*args, **kwargs)
# Store model instance in cache
cache.set(self._cache_key, _self)
def load(_self) -> cls:
"""Load instance from cache or create new one in DB"""
obj = cache.get(self._cache_key)
if not obj:
obj, _ = cls.objects.get_or_create(id=1)
cache.set(self._cache_key, obj)
return obj
# Save old Model.save() method first
setattr(cls, '_model_save', cls.save)
# Then, override it with decorator's one
setattr(cls, 'save', save)
# Set the singleton loading method
setattr(cls, 'load', classmethod(load))
return cls

3
core/views.py Normal file
View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

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

View File

@ -104,7 +104,8 @@ INSTALLED_APPS = [
'account', 'account',
'store', 'store',
'tg_bot' 'tg_bot',
'core'
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -261,8 +262,9 @@ CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = TIME_ZONE CELERY_TIMEZONE = TIME_ZONE
# Bonus program # Bonus program
# TODO: move to GlobalSettings? BONUS_ELIGIBILITY_STATUS = 'completed'
BONUS_PROGRAM_CONFIG = {
BONUS_PROGRAM_DEFAULT_CONFIG = {
"amounts": { "amounts": {
"signup": 150, "signup": 150,
"default_purchase": 50, "default_purchase": 50,

View File

@ -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')

View File

@ -0,0 +1,16 @@
# Generated by Django 4.2.13 on 2024-05-22 21:30
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('store', '0004_alter_checklist_status'),
]
operations = [
migrations.DeleteModel(
name='GlobalSettings',
),
]

View File

@ -8,7 +8,6 @@ from io import BytesIO
from typing import Optional from typing import Optional
from django.conf import settings from django.conf import settings
from django.core.cache import cache
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.validators import MinValueValidator, MaxValueValidator from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models from django.db import models
@ -21,50 +20,11 @@ from django_cleanup import cleanup
from mptt.fields import TreeForeignKey from mptt.fields import TreeForeignKey
from mptt.models import MPTTModel from mptt.models import MPTTModel
from account.models import BonusProgram
from core.models import GlobalSettings
from store.utils import create_preview from store.utils import create_preview
class GlobalSettings(models.Model):
# currency
yuan_rate = models.DecimalField('Курс CNY/RUB', max_digits=10, decimal_places=2, default=0)
yuan_rate_last_updated = models.DateTimeField('Дата обновления курса CNY/RUB', null=True, default=None)
yuan_rate_commission = models.DecimalField('Наценка на курс юаня, руб', max_digits=10, decimal_places=2, default=0)
# Chinadelivery
delivery_price_CN = models.DecimalField('Цена доставки по Китаю', max_digits=10, decimal_places=2, default=0)
commission_rub = models.DecimalField('Комиссия, руб', max_digits=10, decimal_places=2, default=0)
pickup_address = models.CharField('Адрес пункта самовывоза', max_length=200, blank=True, null=True)
time_to_buy = models.DurationField('Время на покупку',
help_text="Через N времени заказ переходит из статуса 'Новый' в 'Черновик'",
default=timedelta(hours=3))
class Meta:
verbose_name = 'Глобальные настройки'
verbose_name_plural = 'Глобальные настройки'
def save(self, *args, **kwargs):
# Store only one instance of GlobalSettings
self.id = 1
self.__class__.objects.exclude(id=self.id).delete()
super().save(*args, **kwargs)
cache.set('global_settings', self)
def __str__(self) -> str:
return f'GlobalSettings <{self.id}>'
@classmethod
def load(cls) -> 'GlobalSettings':
obj = cache.get('global_settings')
if not obj:
obj, _ = cls.objects.get_or_create(id=1)
cache.set('global_settings', obj)
return obj
@property
def full_yuan_rate(self):
return self.yuan_rate + self.yuan_rate_commission
class Category(MPTTModel): class Category(MPTTModel):
name = models.CharField('Название', max_length=20) name = models.CharField('Название', max_length=20)
parent = TreeForeignKey('self', verbose_name='Родительская категория', on_delete=models.SET_NULL, blank=True, null=True, related_name='children', db_index=True) parent = TreeForeignKey('self', verbose_name='Родительская категория', on_delete=models.SET_NULL, blank=True, null=True, related_name='children', db_index=True)
@ -290,7 +250,6 @@ class Checklist(models.Model):
CDEK_READY_STATUSES = (RUSSIA, SPLIT_PAID, CDEK) CDEK_READY_STATUSES = (RUSSIA, SPLIT_PAID, CDEK)
CANCELLABLE_ORDER_STATUSES = (DRAFT, NEW, PAYMENT, BUYING) CANCELLABLE_ORDER_STATUSES = (DRAFT, NEW, PAYMENT, BUYING)
BONUS_ORDER_STATUSES = (CHINA_RUSSIA, RUSSIA, SPLIT_WAITING, SPLIT_PAID, CDEK, COMPLETED)
CHOICES = ( CHOICES = (
(DELETED, 'Удален'), (DELETED, 'Удален'),
@ -643,7 +602,7 @@ class Checklist(models.Model):
if self.customer_id is None: if self.customer_id is None:
return return
if self.status != Checklist.Status.CHINA_RUSSIA: if self.status != settings.BONUS_ELIGIBILITY_STATUS:
return return
# Check if any BonusProgramTransaction bound to current order exists # Check if any BonusProgramTransaction bound to current order exists
@ -653,10 +612,10 @@ class Checklist(models.Model):
# Apply either referral bonus or order bonus, not both # Apply either referral bonus or order bonus, not both
if self.customer.inviter is not None and self.customer.customer_orders.count() == 1: if self.customer.inviter is not None and self.customer.customer_orders.count() == 1:
self.customer.add_referral_bonus(self, for_inviter=False) BonusProgram.add_referral_bonus(self, for_inviter=False)
self.customer.inviter.add_referral_bonus(self, for_inviter=True) BonusProgram.add_referral_bonus(self, for_inviter=True)
else: else:
self.customer.add_order_bonus(self) BonusProgram.add_order_bonus(self)
# TODO: split into sub-functions # TODO: split into sub-functions
def save(self, *args, **kwargs): def save(self, *args, **kwargs):

View File

@ -2,9 +2,11 @@ from django.db.transaction import atomic
from drf_extra_fields.fields import Base64ImageField from drf_extra_fields.fields import Base64ImageField
from rest_framework import serializers from rest_framework import serializers
from account.models.bonus import BonusProgram
from account.serializers import UserSerializer from account.serializers import UserSerializer
from utils.exceptions import CRMException from utils.exceptions import CRMException
from store.models import Checklist, GlobalSettings, Category, PaymentMethod, Promocode, Image, Gift from store.models import Checklist, Category, PaymentMethod, Promocode, Image, Gift
from core.models import GlobalSettings
from store.utils import get_primary_key_related_model from store.utils import get_primary_key_related_model
from poizonstore.utils import PriceField from poizonstore.utils import PriceField
@ -146,7 +148,7 @@ class ChecklistSerializer(serializers.ModelSerializer):
self._create_main_images(instance, images.get('main_images')) self._create_main_images(instance, images.get('main_images'))
if use_bonuses: if use_bonuses:
instance.customer.spend_bonuses(order=instance) BonusProgram.spend_bonuses(order=instance)
return instance return instance

View File

@ -4,7 +4,8 @@ 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

View File

@ -17,7 +17,8 @@ from external_api.cdek import CDEKClient, CDEKWebhookTypes, CDEK_STATUS_TO_ORDER
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,