+ Bonus system (TODO: spend bonuses)

+ Telegram bot: sign up, sign in, notifications

+ Anonymous users can't see yuan_rate_commission
* Only logged in customers can create/update orders
* Customer info migrated to separate User model
* Renamed legacy fields in serializers
* Cleanup in API classes
This commit is contained in:
Phil Zhitnikov 2024-04-27 21:29:50 +04:00
parent 7d9f13b7d5
commit fe24802831
116 changed files with 2182 additions and 1609 deletions

3
.env
View File

@ -3,6 +3,7 @@ APP_HOME=/var/www/poizonstore-stage
# === Keys ===
# Django
SECRET_KEY=""
ALLOWED_HOSTS=.crm-poizonstore.ru,127.0.0.1,localhost,45.84.227.72
# Telegram bot
TG_BOT_TOKEN=""
@ -15,5 +16,3 @@ CURRENCY_GETGEOIP_API_KEY=""
# Let's Encrypt
LETSENCRYPT_EMAIL="phzhitnikov@gmail.com"
ALLOWED_HOSTS=.crm-poizonstore.ru,127.0.0.1,localhost,45.84.227.72

View File

@ -5,7 +5,7 @@ CELERYD_NODES="w1"
#CELERYD_NODES="w1 w2 w3"
# Absolute or relative path to the 'celery' command:
CELERY_BIN="/var/www/poizonstore/env/bin/celery"
CELERY_BIN="/var/www/poizonstore-stage/env/bin/celery"
# App instance to use
CELERY_APP="poizonstore"
@ -19,10 +19,10 @@ CELERYD_OPTS=""
# - %n will be replaced with the first part of the nodename.
# - %I will be replaced with the current child process index
# and is important when using the prefork pool to avoid race conditions.
CELERYD_PID_FILE="/var/run/celery/%n.pid"
CELERYD_LOG_FILE="/var/log/celery/%n%I.log"
CELERYD_PID_FILE="/var/run/celery-stage/%n.pid"
CELERYD_LOG_FILE="/var/log/celery-stage/%n%I.log"
CELERYD_LOG_LEVEL="INFO"
# you may wish to add these options for Celery Beat
CELERYBEAT_PID_FILE="/var/run/celery/beat.pid"
CELERYBEAT_LOG_FILE="/var/log/celery/beat.log"
CELERYBEAT_PID_FILE="/var/run/celery-stage/beat.pid"
CELERYBEAT_LOG_FILE="/var/log/celery-stage/beat.log"

View File

@ -7,8 +7,8 @@ Requires=redis.service
Type=forking
User=poizon
Group=poizon
EnvironmentFile=/etc/default/celery
WorkingDirectory=/var/www/poizonstore
EnvironmentFile=/etc/default/celery-stage
WorkingDirectory=/var/www/poizonstore-stage
RuntimeDirectory=celery
ExecStart=/bin/sh -c '${CELERY_BIN} -A $CELERY_APP multi start $CELERYD_NODES \
--pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} \

View File

@ -6,8 +6,8 @@ After=network.target
Type=simple
User=poizon
Group=poizon
EnvironmentFile=/etc/default/celery
WorkingDirectory=/var/www/poizonstore
EnvironmentFile=/etc/default/celery-stage
WorkingDirectory=/var/www/poizonstore-stage
RuntimeDirectory=celery
ExecStart=/bin/sh -c '${CELERY_BIN} -A ${CELERY_APP} beat \
--pidfile=${CELERYBEAT_PID_FILE} \

View File

@ -1,21 +1,27 @@
upstream django {
server 127.0.0.1:8001;
server 127.0.0.1:8002;
}
server {
listen 80;
server_name crm-poizonstore.ru;
server_name stage.crm-poizonstore.ru;
return 301 https://$host$request_uri;
}
server {
set $APP_HOME /var/www/poizonstore;
set $DOMAIN crm-poizonstore.ru;
set $APP_HOME /var/www/poizonstore-stage;
listen 443 ssl;
server_name crm-poizonstore.ru;
server_name $DOMAIN;
charset utf-8;
# === Add here SSL config ===
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# max upload size
client_max_body_size 75M; # adjust to taste

View File

@ -1,5 +1,5 @@
[uwsgi]
project = poizonstore
project = poizonstore-stage
uid = poizon
gid = poizon
@ -8,7 +8,7 @@ gid = poizon
# the base directory (full path)
chdir = /var/www/%(project)/
# Django's wsgi file
module = %(project):application
module = poizonstore:application
# the virtualenv (full path)
virtualenv = /var/www/%(project)/env

0
account/__init__.py Normal file
View File

21
account/admin.py Normal file
View File

@ -0,0 +1,21 @@
from django.contrib import admin
from .models import User, BonusProgramTransaction
@admin.register(User)
class UserAdmin(admin.ModelAdmin):
list_display = ('email', 'role', 'full_name', 'phone', 'telegram', 'balance')
def get_queryset(self, request):
return User.objects.with_base_related()
@admin.register(BonusProgramTransaction)
class BonusProgramTransactionAdmin(admin.ModelAdmin):
list_display = ('id', 'type', 'user', 'date', 'amount', 'comment', 'order', 'was_cancelled')
def delete_queryset(self, request, queryset):
for obj in queryset:
obj.cancel_transaction()

9
account/apps.py Normal file
View File

@ -0,0 +1,9 @@
from django.apps import AppConfig
class AccountConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'account'
def ready(self):
import account.signals

5
account/exceptions.py Normal file
View File

@ -0,0 +1,5 @@
from rest_framework import exceptions, status
class AuthError(exceptions.APIException):
status_code = status.HTTP_401_UNAUTHORIZED

View File

@ -0,0 +1,71 @@
# Generated by Django 4.2.2 on 2024-03-28 22:05
import account.models
from django.conf import settings
from django.db import migrations, models
import django.utils.timezone
import account.models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('email', models.EmailField(max_length=254, unique=True, verbose_name='Эл. почта')),
('first_name', models.CharField(blank=True, max_length=150, null=True, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, null=True, verbose_name='last name')),
('middle_name', models.CharField(blank=True, max_length=150, null=True, verbose_name='Отчество')),
('role', models.CharField(choices=[('admin', 'Администратор'), ('ordermanager', 'Менеджер по заказам'), ('productmanager', 'Менеджер по закупкам'), ('client', 'Клиент')], default='client', max_length=30, verbose_name='Роль')),
('phone', models.CharField(blank=True, max_length=100, null=True, unique=True, verbose_name='Телефон')),
('telegram', models.CharField(blank=True, max_length=100, null=True, verbose_name='Telegram')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', account.models.UserManager()),
],
),
migrations.CreateModel(
name='BonusProgramTransaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('type', models.PositiveSmallIntegerField(choices=[(0, 'Другое начисление'), (1, 'Бонус за регистрацию'), (2, 'Бонус за покупку'), (3, 'Бонус за первую покупку приглашенного'), (4, 'Бонус за первую покупку'), (10, 'Другое списание'), (11, 'Списание бонусов за заказ')], verbose_name='Тип транзакции')),
('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата транзакции')),
('amount', models.SmallIntegerField(verbose_name='Количество, руб')),
],
),
migrations.CreateModel(
name='ReferralRelationship',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('invited', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_invited', to=settings.AUTH_USER_MODEL)),
('inviter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_inviter', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='BonusProgramUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('balance', models.PositiveSmallIntegerField(default=0, verbose_name='Баланс, руб')),
('referral_code', models.CharField(default=account.models.generate_referral_code, editable=False, max_length=9)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -0,0 +1,42 @@
# Generated by Django 4.2.2 on 2024-03-28 22:05
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('store', '0001_initial'),
('auth', '0012_alter_user_first_name_max_length'),
('account', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='bonusprogramtransaction',
name='order',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.checklist'),
),
migrations.AddField(
model_name='bonusprogramtransaction',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='account.bonusprogramuser', verbose_name='Пользователь транзакции'),
),
migrations.AddField(
model_name='user',
name='groups',
field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups'),
),
migrations.AddField(
model_name='user',
name='user_permissions',
field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions'),
),
migrations.AlterUniqueTogether(
name='referralrelationship',
unique_together={('inviter', 'invited')},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.2 on 2024-04-03 20:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0002_initial'),
]
operations = [
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(blank=True, max_length=254, verbose_name='email address'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 4.2.2 on 2024-04-03 21:26
from django.db import migrations
import phonenumber_field.modelfields
class Migration(migrations.Migration):
dependencies = [
('account', '0003_alter_user_email'),
]
operations = [
migrations.AlterField(
model_name='user',
name='phone',
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, unique=True, verbose_name='Телефон'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.2 on 2024-04-03 21:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0004_alter_user_phone'),
]
operations = [
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='Эл. почта'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.2 on 2024-04-05 21:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0005_alter_user_email'),
]
operations = [
migrations.AddField(
model_name='user',
name='tg_user_id',
field=models.BigIntegerField(blank=True, null=True, unique=True, verbose_name='id пользователя в Telegram'),
),
]

View File

@ -1,4 +1,4 @@
# Generated by Django 4.2.2 on 2023-10-04 02:55
# Generated by Django 4.2.2 on 2024-04-05 21:35
from django.db import migrations, models
@ -6,13 +6,13 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0043_pricesnapshot_checklist_price_snapshot'),
('account', '0006_user_tg_user_id'),
]
operations = [
migrations.AddField(
model_name='checklist',
name='split_accepted',
field=models.BooleanField(default=False, verbose_name='Сплит принят'),
model_name='user',
name='is_draft_user',
field=models.BooleanField(default=False, verbose_name='Черновик пользователя'),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.2 on 2024-04-07 17:04
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('account', '0007_user_is_draft_user'),
]
operations = [
migrations.AlterField(
model_name='bonusprogramtransaction',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь транзакции'),
),
migrations.DeleteModel(
name='BonusProgramUser',
),
]

View File

@ -0,0 +1,30 @@
# Generated by Django 4.2.2 on 2024-04-07 17:36
import account.models
from django.db import migrations, models
import account.models
class Migration(migrations.Migration):
dependencies = [
('account', '0008_alter_bonusprogramtransaction_user_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='user',
options={},
),
migrations.AddField(
model_name='user',
name='balance',
field=models.PositiveSmallIntegerField(default=0, verbose_name='Баланс, руб'),
),
migrations.AddField(
model_name='user',
name='referral_code',
field=models.CharField(default=account.models.generate_referral_code, editable=False, max_length=10),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.2 on 2024-04-07 20:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0009_alter_user_options_user_balance_user_referral_code'),
]
operations = [
migrations.AddField(
model_name='bonusprogramtransaction',
name='comment',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Комментарий'),
),
]

View File

@ -0,0 +1,57 @@
# Generated by Django 4.2.2 on 2024-04-07 23:27
from django.contrib.auth.hashers import make_password
from django.db import migrations
from phonenumber_field.phonenumber import PhoneNumber
def move_buyer_info_to_account(apps, schema_editor):
Checklist = apps.get_model("store", "Checklist")
User = apps.get_model("account", "User")
# Normalize phone numbers first
for order in Checklist.objects.all():
if order.buyer_phone is None:
continue
old_phone = order.buyer_phone
new_phone = PhoneNumber.from_string(order.buyer_phone, region="RU").as_e164
if old_phone != new_phone:
print(f"{old_phone} -> {new_phone}")
order.buyer_phone = new_phone
order.save(update_fields=['buyer_phone'])
# Move buyer info to User
for order in Checklist.objects.all():
fields_to_copy = {
'first_name': order.buyer_name,
'telegram': order.buyer_telegram,
}
if order.buyer_phone is None:
User.objects.create(**fields_to_copy)
created = True
else:
obj, created = User.objects.update_or_create(phone=order.buyer_phone, defaults=fields_to_copy)
if created:
obj.is_draft_user = True
obj.password = make_password(None)
obj.save(update_fields=['password'])
# Bind customer to order
order.customer_id = obj.id
order.save(update_fields=['customer_id'])
class Migration(migrations.Migration):
dependencies = [
('account', '0010_bonusprogramtransaction_comment'),
]
operations = [
migrations.AlterModelOptions(
name='bonusprogramtransaction',
options={'ordering': ['-date']},
),
migrations.RunPython(move_buyer_info_to_account, migrations.RunPython.noop),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.2.2 on 2024-04-08 02:59
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('account', '0011_alter_bonusprogramtransaction_options'),
]
operations = [
migrations.AddField(
model_name='referralrelationship',
name='invited_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.2.2 on 2024-04-08 03:01
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('account', '0012_referralrelationship_invited_at'),
]
operations = [
migrations.AlterField(
model_name='referralrelationship',
name='invited',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_inviter', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='referralrelationship',
name='inviter',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_invited', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.2 on 2024-04-14 14:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0013_alter_referralrelationship_invited_and_more'),
]
operations = [
migrations.AlterField(
model_name='user',
name='balance',
field=models.PositiveSmallIntegerField(default=0, editable=False, verbose_name='Баланс, руб'),
),
]

View File

@ -0,0 +1,34 @@
# Generated by Django 4.2.2 on 2024-04-21 03:32
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('store', '0003_remove_checklist_buyer_name_and_more'),
('account', '0014_alter_user_balance'),
]
operations = [
migrations.AlterModelOptions(
name='bonusprogramtransaction',
options={'ordering': ['-date'], 'verbose_name': 'История баланса', 'verbose_name_plural': 'История баланса'},
),
migrations.AddField(
model_name='bonusprogramtransaction',
name='was_cancelled',
field=models.BooleanField(default=False, editable=False, verbose_name='Была отменена'),
),
migrations.AlterField(
model_name='bonusprogramtransaction',
name='order',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.checklist', verbose_name='Связанный заказ'),
),
migrations.AlterField(
model_name='bonusprogramtransaction',
name='type',
field=models.PositiveSmallIntegerField(choices=[(0, 'Другое начисление'), (1, 'Бонус за регистрацию'), (2, 'Бонус за покупку'), (3, 'Бонус за первую покупку приглашенного'), (4, 'Бонус за первую покупку'), (10, 'Другое списание'), (11, 'Списание бонусов за заказ'), (20, 'Отмена начисления'), (21, 'Отмена списания')], verbose_name='Тип транзакции'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.2 on 2024-04-23 09:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0015_alter_bonusprogramtransaction_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='user',
name='role',
field=models.CharField(choices=[('admin', 'Администратор'), ('ordermanager', 'Менеджер по заказам'), ('productmanager', 'Менеджер по закупкам'), ('client', 'Клиент')], default='client', editable=False, max_length=30, verbose_name='Роль'),
),
]

View File

View File

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

278
account/models/bonus.py Normal file
View File

@ -0,0 +1,278 @@
import logging
from contextlib import suppress
from django.conf import settings
from django.db import models
from django.db.models import Sum
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.formats import localize
from store.models import Checklist
from tg_bot.messages import TGBonusMessage
logger = logging.getLogger(__name__)
class BonusType:
# Другое начисление
OTHER_DEPOSIT = 0
# Клиент передал номер ТГ-боту
SIGNUP = 1
# Клиент сделал заказ
DEFAULT_PURCHASE = 2
# Приглашенный клиент сделал свою первую покупку, бонус реферреру
FOR_INVITER = 3
# Клиент сделал заказ и получил бонус за первую покупку от реферрера
INVITED_FIRST_PURCHASE = 4
# Другое списание
OTHER_WITHDRAWAL = 10
# Клиент потратил баллы на заказ
SPENT_PURCHASE = 11
# Отмена начисления
CANCELLED_DEPOSIT = 20
# Отмена списания
CANCELLED_WITHDRAWAL = 21
CHOICES = (
(OTHER_DEPOSIT, 'Другое начисление'),
(SIGNUP, 'Бонус за регистрацию'),
(DEFAULT_PURCHASE, 'Бонус за покупку'),
(FOR_INVITER, 'Бонус за первую покупку приглашенного'),
(INVITED_FIRST_PURCHASE, 'Бонус за первую покупку'),
(OTHER_WITHDRAWAL, 'Другое списание'),
(SPENT_PURCHASE, 'Списание бонусов за заказ'),
(CANCELLED_DEPOSIT, 'Отмена начисления'),
(CANCELLED_WITHDRAWAL, 'Отмена списания'),
)
class BonusProgramTransactionQuerySet(models.QuerySet):
# TODO: optimize queries
def with_base_related(self):
return self
class BonusProgramTransaction(models.Model):
""" Represents the history of all bonus program transactions """
type = models.PositiveSmallIntegerField('Тип транзакции', choices=BonusType.CHOICES)
user = models.ForeignKey('User', verbose_name='Пользователь транзакции', on_delete=models.CASCADE)
date = models.DateTimeField('Дата транзакции', auto_now_add=True)
amount = models.SmallIntegerField('Количество, руб')
comment = models.CharField('Комментарий', max_length=200, null=True, blank=True)
was_cancelled = models.BooleanField('Была отменена', editable=False, default=False)
# Bound objects
order = models.ForeignKey('store.Checklist', verbose_name="Связанный заказ", null=True, blank=True, on_delete=models.SET_NULL)
objects = BonusProgramTransactionQuerySet.as_manager()
class Meta:
ordering = ['-date']
verbose_name = "История баланса"
verbose_name_plural = "История баланса"
def _notify_user_about_new_transaction(self):
msg = None
match self.type:
case BonusType.SIGNUP:
msg = TGBonusMessage.SIGNUP.format(amount=self.amount)
case BonusType.DEFAULT_PURCHASE:
msg = TGBonusMessage.PURCHASE_ADDED.format(amount=self.amount, order_id=self.order.id)
case BonusType.FOR_INVITER:
msg = TGBonusMessage.FOR_INVITER.format(amount=self.amount)
case BonusType.INVITED_FIRST_PURCHASE:
msg = TGBonusMessage.INVITED_FIRST_PURCHASE.format(amount=self.amount, order_id=self.order.id)
case BonusType.OTHER_DEPOSIT | BonusType.CANCELLED_DEPOSIT:
comment = self.comment or ""
msg = TGBonusMessage.OTHER_DEPOSIT.format(amount=self.amount, comment=comment)
case BonusType.OTHER_WITHDRAWAL | BonusType.CANCELLED_WITHDRAWAL:
comment = self.comment or ""
msg = TGBonusMessage.OTHER_WITHDRAWAL.format(amount=abs(self.amount), comment=comment)
case BonusType.SPENT_PURCHASE:
msg = TGBonusMessage.PURCHASE_SPENT.format(amount=abs(self.amount), order_id=self.order_id)
case _:
pass
if msg is not None:
self.user.notify_tg_bot(msg)
def cancel_transaction(self):
# Skip already cancelled transactions
# TODO: if reverse transaction is being deleted, revert the source one?
if self.was_cancelled or self.type in (BonusType.OTHER_WITHDRAWAL, BonusType.OTHER_DEPOSIT):
return
date_formatted = localize(timezone.localtime(self.date))
if self.amount > 0:
comment = f"Отмена начисления #{self.id} от {date_formatted}"
bonus_type = BonusType.OTHER_WITHDRAWAL
elif self.amount < 0:
comment = f"Отмена списания #{self.id} от {date_formatted}"
bonus_type = BonusType.OTHER_DEPOSIT
else:
return
# Create reverse transaction, user's balance will be recalculated in post_save signal
transaction = BonusProgramTransaction()
transaction.user_id = self.user_id
transaction.type = bonus_type
transaction.amount = self.amount * -1
transaction.comment = comment
transaction.order = self.order
transaction.save()
self.was_cancelled = True
self.save()
def delete(self, *args, **kwargs):
# Don't delete transaction, cancel it instead
self.cancel_transaction()
def save(self, *args, **kwargs):
if self.id is None:
self._notify_user_about_new_transaction()
return super().save(*args, **kwargs)
def generate_referral_code():
""" Generate unique numeric referral code for User """
from account.models import User
while True:
allowed_chars = "0123456789"
code = get_random_string(settings.REFERRAL_CODE_LENGTH, allowed_chars)
# Hacky code for migrations
if "referral_code" not in User._meta.fields:
return code
if not User.objects.filter(referral_code=code).exists():
return code
class BonusProgramMixin(models.Model):
balance = models.PositiveSmallIntegerField('Баланс, руб', default=0, editable=False)
referral_code = models.CharField(max_length=settings.REFERRAL_CODE_LENGTH, default=generate_referral_code,
editable=False)
class Meta:
abstract = True
@property
def bonus_history(self):
return BonusProgramTransaction.objects.filter(user_id=self.id)
def update_balance(self, amount, bonus_type, comment=None, order=None):
# No underflow or dummy transactions allowed
if amount == 0 or (self.balance + amount) < 0:
return
# Create bonus transaction, user's balance will be recalculated in post_save signal
transaction = BonusProgramTransaction(user_id=self.id,
amount=amount, type=bonus_type,
comment=comment, order=order)
transaction.save()
def recalculate_balance(self):
# TODO: use this method when checking the available balance upon order creation
total_balance = BonusProgramTransaction.objects \
.filter(user_id=self.id) \
.aggregate(total_balance=Sum('amount'))['total_balance'] or 0
self.balance = max(0, total_balance)
self.save(update_fields=['balance'])
def add_signup_bonus(self):
bonus_type = BonusType.SIGNUP
amount = self._get_bonus_amount("signup")
already_exists = (BonusProgramTransaction.objects
.filter(user_id=self.id, type=bonus_type)
.exists())
if already_exists:
self._log(logging.INFO, "User already had signup bonus")
return
self.update_balance(amount, bonus_type)
def add_order_bonus(self, order):
from store.models import Checklist
bonus_type = BonusType.DEFAULT_PURCHASE
amount = self._get_bonus_amount("default_purchase")
if order.status != Checklist.Status.CHINA_RUSSIA:
return
already_exists = (BonusProgramTransaction.objects
.filter(user_id=self.id, type=bonus_type, order_id=order.id)
.exists())
if already_exists:
self._log(logging.INFO, f"User already got bonus for order #{order.id}")
return
self.update_balance(amount, bonus_type, order=order)
def add_referral_bonus(self, order: Checklist, for_inviter: bool):
amount = self._get_bonus_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:
return
user = order.customer.inviter if for_inviter else order.customer
bonus_type = BonusType.FOR_INVITER if for_inviter else BonusType.INVITED_FIRST_PURCHASE
# Check if user didn't receive bonus yet
already_exists = (BonusProgramTransaction.objects
.filter(user_id=user.id, type=bonus_type, order_id=order.id)
.exists())
if already_exists:
self._log(logging.INFO, f"User already got referral bonus for order #{order.id}")
return
# 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)

201
account/models/user.py Normal file
View File

@ -0,0 +1,201 @@
import logging
from asgiref.sync import sync_to_async
from django.contrib.admin import display
from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import UserManager as _UserManager, AbstractUser
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from phonenumber_field.phonenumber import PhoneNumber
from account.models import BonusProgramMixin
from store.utils import concat_not_null_values
from tg_bot.tasks import send_tg_message
logger = logging.getLogger(__name__)
class UserQuerySet(models.QuerySet):
# TODO: optimize queries
def with_base_related(self):
return self
class UserManager(models.Manager.from_queryset(UserQuerySet), _UserManager):
def _create_user(self, email, password, **extra_fields):
if email:
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.password = make_password(password)
user.save(using=self._db)
return user
def create_user(self, email=None, password=None, **extra_fields):
extra_fields.setdefault("is_staff", False)
return self._create_user(email, password, **extra_fields)
def create_draft_user(self, **extra_fields):
extra_fields.setdefault("is_staff", False)
extra_fields.setdefault("is_draft_user", True)
return self._create_user(email=None, password=None, **extra_fields)
def create_superuser(self, email=None, password=None, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("role", User.ADMIN)
if extra_fields.get("is_staff") is not True:
raise ValueError("Superuser must have is_staff=True.")
return self._create_user(email, password, **extra_fields)
def invite_user(self, referral_code, user_id):
inviter = User.objects.filter(referral_code=referral_code).first()
user_to_invite = User.objects.filter(id=user_id).first()
if inviter is None or user_to_invite is None:
return
if inviter.id == user_to_invite.id:
logger.warning(f"User #{inviter.id} tried to invite himself via referral code {referral_code}")
return
obj, created = ReferralRelationship.objects.get_or_create(inviter_id=inviter.id, invited_id=user_to_invite.id)
if not created:
logger.warning(f"User #{inviter.id} already invited user #{user_id}")
return
async def bind_tg_user(self, tg_user_id, phone, referral_code=None):
# Normalize phone: 79111234567 -> +79111234567
phone = PhoneNumber.from_string(phone).as_e164
"""
1) No user with given phone or tg_user_id -> create draft user, add tg_user_id & phone
2) User exists with given phone, but no tg_user_id -> add tg_user_id to User
3) User exists with tg_user_id, but no phone -> add phone to User
4) User exists with given tg_user_id & phone -> just authorize
"""
user = await User.objects.filter(
Q(phone=phone) | (Q(tg_user_id=tg_user_id))
).afirst()
freshly_created = False
# Sign up through Telegram bot
if user is None:
user = await sync_to_async(self.create_draft_user)(phone=phone, tg_user_id=tg_user_id)
logger.info(f"tgbot: Created draft user #{user.id} for phone [{phone}]")
freshly_created = True
# 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)()
# Create referral relationship
# Only for fresh registration
if freshly_created and referral_code is not None:
await sync_to_async(User.objects.invite_user)(referral_code, user.id)
# Bind Telegram chat to user
if not freshly_created:
user.phone = phone
user.tg_user_id = tg_user_id
await user.asave()
logger.info(f"tgbot: Telegram user #{tg_user_id} was bound to user #{user.id}")
class User(BonusProgramMixin, AbstractUser):
ADMIN = "admin"
ORDER_MANAGER = "ordermanager"
PRODUCT_MANAGER = "productmanager"
CLIENT = "client"
ROLE_CHOICES = (
(ADMIN, 'Администратор'),
(ORDER_MANAGER, 'Менеджер по заказам'),
(PRODUCT_MANAGER, 'Менеджер по закупкам'),
(CLIENT, 'Клиент'),
)
# Login by email
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ['phone']
username = None
email = models.EmailField("Эл. почта", blank=True, null=True, unique=True)
# Base info
first_name = models.CharField(_("first name"), max_length=150, blank=True, null=True)
last_name = models.CharField(_("last name"), max_length=150, blank=True, null=True)
middle_name = models.CharField("Отчество", max_length=150, blank=True, null=True)
role = models.CharField("Роль", max_length=30, choices=ROLE_CHOICES, default=CLIENT, editable=False)
# Contacts
phone = PhoneNumberField('Телефон', null=True, blank=True, unique=True)
telegram = models.CharField('Telegram', max_length=100, null=True, blank=True)
# Bot-related
# User is created via Telegram bot and has no password yet.
# User can set initial password via /users/set_initial_password/
is_draft_user = models.BooleanField("Черновик пользователя", default=False)
tg_user_id = models.BigIntegerField("id пользователя в Telegram", null=True, blank=True, unique=True)
objects = UserManager()
def __str__(self):
value = self.email or self.phone or self.id
return str(value)
@property
def is_superuser(self):
return self.role == self.ADMIN
@property
def is_manager(self):
return self.role in (self.ADMIN, self.ORDER_MANAGER, self.PRODUCT_MANAGER)
@display(description='ФИО')
def full_name(self):
return concat_not_null_values(self.last_name, self.first_name, self.middle_name)
@property
def invited_users(self):
return User.objects.filter(user_inviter__inviter=self.id)
@property
def inviter(self):
return User.objects.filter(user_invited__invited=self.id).first()
def notify_tg_bot(self, message, **kwargs):
if self.tg_user_id is None:
return
send_tg_message.delay(self.tg_user_id, message, **kwargs)
def save(self, *args, **kwargs):
# If password changed, it is no longer a draft User
if self._password is not None:
self.is_draft_user = False
super().save(*args, **kwargs)
class ReferralRelationship(models.Model):
invited_at = models.DateTimeField(auto_now_add=True)
inviter = models.ForeignKey('User', on_delete=models.CASCADE, related_name="user_invited")
invited = models.ForeignKey('User', on_delete=models.CASCADE, related_name="user_inviter")
class Meta:
unique_together = (('inviter', 'invited'),)
def clean(self):
if self.inviter_id == self.invited_id:
raise ValidationError("User can't invite himself")

22
account/permissions.py Normal file
View File

@ -0,0 +1,22 @@
from rest_framework.permissions import BasePermission, SAFE_METHODS
class ReadOnly(BasePermission):
def has_permission(self, request, view):
return request.method in SAFE_METHODS
class IsClient(BasePermission):
def has_permission(self, request, view):
from account.models import User
return request.user.is_authenticated and request.user.role == User.CLIENT
class IsManager(BasePermission):
def has_permission(self, request, view):
return request.user.is_authenticated and request.user.is_manager
class IsAdmin(BasePermission):
def has_permission(self, request, view):
return request.user.is_authenticated and request.user.is_superuser

104
account/serializers.py Normal file
View File

@ -0,0 +1,104 @@
from django.conf import settings
from django.db.models import Q
from djoser import serializers as djoser_serializers
from djoser.conf import settings as djoser_settings
from rest_framework import serializers
from rest_framework.exceptions import AuthenticationFailed
from .models import User, BonusProgramTransaction, BonusType
from .utils import verify_telegram_authentication
class UserSerializer(serializers.ModelSerializer):
name = serializers.CharField(source='first_name')
lastname = serializers.CharField(source='middle_name')
surname = serializers.CharField(source='last_name')
class Meta:
model = User
fields = ('id', 'email', 'phone', 'role', 'name', 'lastname', 'surname', 'balance', 'referral_code', 'is_draft_user')
class BonusProgramTransactionSerializer(serializers.ModelSerializer):
type = serializers.CharField(source='get_type_display')
class Meta:
model = BonusProgramTransaction
fields = ('id', 'type', 'date', 'amount', 'comment', 'was_cancelled')
def non_zero_validator(value):
if value == 0:
raise serializers.ValidationError("Value cannot be zero")
return value
class UserBalanceUpdateSerializer(BonusProgramTransactionSerializer):
amount = serializers.IntegerField(validators=[non_zero_validator])
type = serializers.SerializerMethodField()
class Meta:
model = BonusProgramTransactionSerializer.Meta.model
fields = BonusProgramTransactionSerializer.Meta.fields
read_only_fields = ('id', 'type', 'date')
def get_type(self, instance):
# Deposit or spent depending on value
if instance['amount'] < 0:
return BonusType.OTHER_WITHDRAWAL
elif instance['amount'] > 0:
return BonusType.OTHER_DEPOSIT
class SetInitialPasswordSerializer(djoser_serializers.PasswordSerializer):
def validate(self, attrs):
user = getattr(self, "user", None) or self.context["request"].user
# why assert? There are ValidationError / fail everywhere
assert user is not None
if not user.is_superuser and not user.is_draft_user:
raise serializers.ValidationError("To change password, use /users/change_password endpoint")
return super().validate(attrs)
class UserCreateSerializer(djoser_serializers.UserCreateSerializer):
email = serializers.EmailField(required=True)
class TokenCreateSerializer(serializers.Serializer):
email_or_phone = serializers.CharField()
password = serializers.CharField(required=False, style={"input_type": "password"})
default_error_messages = {
"invalid_credentials": djoser_settings.CONSTANTS.messages.INVALID_CREDENTIALS_ERROR,
"inactive_account": djoser_settings.CONSTANTS.messages.INACTIVE_ACCOUNT_ERROR,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user = None
def validate(self, attrs):
email_or_phone = attrs.get('email_or_phone')
password = attrs.get("password")
user = User.objects.filter(Q(email=email_or_phone) | Q(phone=email_or_phone)).first()
if not user or not user.check_password(password) or not user.is_active:
raise AuthenticationFailed()
self.user = user
return attrs
class TelegramCallbackSerializer(serializers.Serializer):
id = serializers.IntegerField()
first_name = serializers.CharField(allow_null=True)
username = serializers.CharField(allow_null=True)
photo_url = serializers.URLField(allow_null=True)
auth_date = serializers.IntegerField()
hash = serializers.CharField()
def validate(self, attrs):
verify_telegram_authentication(bot_token=settings.TG_BOT_TOKEN, request_data=attrs)
return attrs

29
account/signals.py Normal file
View File

@ -0,0 +1,29 @@
import logging
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from account.models import User, ReferralRelationship, BonusProgramTransaction
logger = logging.getLogger(__name__)
@receiver(post_save, sender=User)
def handle_user_save(sender, instance: User, created, **kwargs):
pass
@receiver(post_save, sender=ReferralRelationship)
def handle_invitation_save(sender, instance: ReferralRelationship, created, **kwargs):
if created:
logger.info(f"User {instance.inviter_id} invited {instance.invited_id}")
# TODO: notify about invitation
@receiver(post_save, sender=BonusProgramTransaction)
@receiver(post_delete, sender=BonusProgramTransaction)
def handle_bonus_transaction_savedelete(sender, instance: BonusProgramTransaction, **kwargs):
# Recalculate user's balance
if instance.user is not None:
instance.user.recalculate_balance()

3
account/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

13
account/urls.py Normal file
View File

@ -0,0 +1,13 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from account import views
router = DefaultRouter()
router.register("users", views.UserViewSet)
urlpatterns = [
path('', include(router.urls)),
path('auth/', include('djoser.urls.authtoken')),
path('auth/telegram/', views.TelegramLoginForm.as_view()),
]

57
account/utils.py Normal file
View File

@ -0,0 +1,57 @@
import hashlib
import hmac
import time
class NotTelegramDataError(Exception):
""" The verification algorithm did not authorize Telegram data. """
pass
class TelegramDataIsOutdatedError(Exception):
""" The Telegram data is outdated. """
pass
# Source: https://github.com/dmytrostriletskyi/django-telegram-login/blob/master/django_telegram_login/authentication.py
def verify_telegram_authentication(bot_token, request_data):
"""
Check if received data from Telegram is real.
Based on SHA and HMAC algothims.
Instructions - https://core.telegram.org/widgets/login#checking-authorization
"""
ONE_DAY_IN_SECONDS = 86400
request_data = request_data.copy()
received_hash = request_data['hash']
auth_date = request_data['auth_date']
request_data.pop('hash', None)
request_data_alphabetical_order = sorted(request_data.items(), key=lambda x: x[0])
data_check_string = []
for data_pair in request_data_alphabetical_order:
key, value = data_pair[0], str(data_pair[1])
data_check_string.append(key + '=' + value)
data_check_string = '\n'.join(data_check_string)
secret_key = hashlib.sha256(bot_token.encode()).digest()
_hash = hmac.new(secret_key, msg=data_check_string.encode(), digestmod=hashlib.sha256).hexdigest()
unix_time_now = int(time.time())
unix_time_auth_date = int(auth_date)
if unix_time_now - unix_time_auth_date > ONE_DAY_IN_SECONDS:
raise TelegramDataIsOutdatedError(
'Authentication data is outdated. Authentication was received more than day ago.'
)
if _hash != received_hash:
raise NotTelegramDataError(
'This is not a Telegram data. Hash from recieved authentication data does not match'
'with calculated hash based on bot token.'
)

153
account/views.py Normal file
View File

@ -0,0 +1,153 @@
from django.conf import settings
from djoser import views as djoser_views
from djoser.conf import settings as djoser_settings
from djoser.permissions import CurrentUserOrAdmin
from djoser.utils import login_user
from rest_framework import views, status
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound, ValidationError, MethodNotAllowed
from rest_framework.permissions import AllowAny
from rest_framework.renderers import StaticHTMLRenderer
from rest_framework.response import Response
from account.models import User
from account.serializers import SetInitialPasswordSerializer, BonusProgramTransactionSerializer, \
UserBalanceUpdateSerializer, TelegramCallbackSerializer
from tg_bot.handlers.start import request_phone_sync
from tg_bot.messages import TGCoreMessage
from tg_bot.bot import bot_sync
class UserViewSet(djoser_views.UserViewSet):
""" Replacement for Djoser's UserViewSet """
def permission_denied(self, request, **kwargs):
if (
djoser_settings.HIDE_USERS
and request.user.is_authenticated
and self.action in ["balance"]
):
raise NotFound()
super().permission_denied(request, **kwargs)
def get_permissions(self):
if self.action == "set_initial_password":
self.permission_classes = djoser_settings.PERMISSIONS.set_password
return super().get_permissions()
def get_serializer_class(self):
if self.action == "set_initial_password":
return SetInitialPasswordSerializer
return super().get_serializer_class()
@action(["post"], detail=False)
def set_initial_password(self, request, *args, **kwargs):
return super().set_password(request, *args, **kwargs)
@action(["get", "patch"], detail=True, permission_classes=[CurrentUserOrAdmin])
def balance(self, request, *args, **kwargs):
user = self.get_object()
if request.method == "GET":
serializer = BonusProgramTransactionSerializer(user.bonus_history, many=True)
return Response(serializer.data)
elif request.method == "PATCH":
if not request.user.is_superuser:
return self.permission_denied(request)
# No balance underflow or dummy transactions allowed, no error will be raised
serializer = UserBalanceUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.data
user.update_balance(amount=data['amount'], bonus_type=data['type'], comment=data['comment'])
list_serializer = BonusProgramTransactionSerializer(user.bonus_history, many=True)
return Response(list_serializer.data)
@action(["get"], url_path="me/balance", detail=False, permission_classes=[CurrentUserOrAdmin])
def me_balance(self, request, *args, **kwargs):
self.get_object = self.get_instance
return self.balance(request, *args, **kwargs)
class TelegramLoginForm(views.APIView):
permission_classes = [AllowAny]
def get_renderers(self):
if self.request.method == "GET" and settings.DEBUG:
return [StaticHTMLRenderer()]
return super().get_renderers()
def get(self, request, *args, **kwargs):
if not settings.DEBUG:
raise MethodNotAllowed(request.method)
source = """
<html>
<body>
<script async src="https://telegram.org/js/telegram-widget.js?22"
data-telegram-login="phzhik_dev_bot"
data-size="large"
data-onauth="onTelegramAuth(user)"
data-request-access="write"></script>
<script type="text/javascript">
function onTelegramAuth(user) {
console.log(user);
const request = new Request("/auth/telegram/", {
method: "post",
body: JSON.stringify(user),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
fetch(request)
.then(response => {
if (!response.ok) {
throw new Error('HTTP error ' + response.status);
}
return response.json();
})
.then(data => console.log(data))
.catch(error => console.error(error));
}
</script>
</body>
</html>
"""
return Response(source)
def post(self, request, *args, **kwargs):
serializer = TelegramCallbackSerializer(data=request.data)
try:
serializer.is_valid(raise_exception=True)
except ValidationError as e:
return Response(e.detail, status=status.HTTP_401_UNAUTHORIZED)
except:
return Response(status=status.HTTP_401_UNAUTHORIZED)
data = serializer.data
# Authenticate user with given tg_user_id
tg_user_id = data["id"]
user: User = User.objects.filter(tg_user_id=tg_user_id).first()
if not user:
# Sign up user
user = User.objects.create_draft_user(tg_user_id=tg_user_id)
# Request the phone through the bot
request_phone_sync(tg_user_id, TGCoreMessage.SIGN_UP_SHARE_PHONE)
token = login_user(request, user)
return Response({"auth_token": token.key})

View File

@ -49,6 +49,4 @@ class PoizonClient:
def get_good_info(self, spu_id):
params = {'spuId': str(spu_id)}
r = self.request('GET', self.SPU_GET_DATA_ENDPOINT, params=params)
return r.json()
return self.request('GET', self.SPU_GET_DATA_ENDPOINT, params=params)

View File

@ -71,7 +71,12 @@ CORS_ALLOWED_ORIGINS = [
if DISABLE_CORS:
CORS_ALLOW_ALL_ORIGINS = True
AUTH_USER_MODEL = 'store.User'
# Required for "Login via Telegram" popup
# Source: https://stackoverflow.com/a/73240366/24046062
SECURE_CROSS_ORIGIN_OPENER_POLICY = 'same-origin-allow-popups'
AUTH_USER_MODEL = 'account.User'
PHONENUMBER_DEFAULT_REGION = 'RU'
# Application definition
@ -93,7 +98,9 @@ INSTALLED_APPS = [
'django_filters',
'mptt',
'store'
'account',
'store',
'tg_bot'
]
MIDDLEWARE = [
@ -178,12 +185,13 @@ REST_FRAMEWORK = {
}
DJOSER = {
'LOGIN_FIELD': 'email',
'TOKEN_MODEL': 'rest_framework.authtoken.models.Token',
'SERIALIZERS': {
'user': 'store.serializers.UserSerializer',
'current_user': 'store.serializers.UserSerializer',
'user': 'account.serializers.UserSerializer',
'current_user': 'account.serializers.UserSerializer',
'user_create': 'account.serializers.UserCreateSerializer',
'token_create': 'account.serializers.TokenCreateSerializer',
},
}
@ -216,6 +224,7 @@ MEDIA_ROOT = os.path.join(BASE_DIR, "media")
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
CHECKLIST_ID_LENGTH = 10
REFERRAL_CODE_LENGTH = 10
COMMISSION_OVER_150K = 1.1
# Logging
@ -233,10 +242,20 @@ if not DEBUG:
)
# Celery
BROKER_URL = 'redis://localhost:6379/1'
BROKER_URL = 'redis://localhost:6379/2'
CELERY_RESULT_BACKEND = BROKER_URL
CELERY_BROKER_URL = BROKER_URL
CELERY_ACCEPT_CONTENT = ['application/json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = TIME_ZONE
# Bonus program
# TODO: move to GlobalSettings?
BONUS_PROGRAM_CONFIG = {
"amounts": {
"signup": 150,
"default_purchase": 50,
"referral": 500,
}
}

View File

@ -24,8 +24,7 @@ urlpatterns = [
path('admin/', admin.site.urls),
path('__debug__/', include('debug_toolbar.urls')),
path('', include('store.urls')),
path('', include('djoser.urls')),
path('auth/', include('djoser.urls.authtoken')),
path('', include('account.urls')),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \
+ static(settings.STATIC_URL)

View File

@ -8,12 +8,17 @@ django-cors-headers==4.1.0
djoser==2.2.0
drf-extra-fields==3.5.0
Pillow==9.5.0
django-phonenumber-field[phonenumberslite]
# Tasks
celery==5.3.6
redis==5.0.1
flower==2.0.1
# Telegram bot
pyTelegramBotAPI==4.17.0
aiohttp==3.9.4
# Misc
tqdm==4.65.0
django-debug-toolbar==4.1.0
@ -24,4 +29,6 @@ sentry-sdk==1.34.0
sentry-telegram-py3==0.6.1
# Deployment
# gunicorn==20.1.0
uWSGI==2.0.21
inotify==0.2.10

37
run_tg_bot.py Normal file
View File

@ -0,0 +1,37 @@
import os
import asyncio
import logging
import sys
import django
import telebot
from telebot import asyncio_filters
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'poizonstore.settings')
django.setup()
from tg_bot.bot import bot, commands
from tg_bot.handlers import register_handlers
logger = telebot.logger
telebot.logger.setLevel(logging.DEBUG)
async def setup():
await bot.delete_my_commands(scope=None, language_code=None)
bot.add_custom_filter(asyncio_filters.StateFilter(bot))
register_handlers()
await bot.set_my_commands(commands=commands)
async def main():
logger.info("bot starting...")
await setup()
await bot.infinity_polling()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, stream=sys.stdout)
asyncio.run(main())

View File

@ -2,12 +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, User, Image, Gift
@admin.register(User)
class UserAdmin(admin.ModelAdmin):
list_display = ('email', 'job_title', 'full_name',)
from .models import Category, Checklist, GlobalSettings, PaymentMethod, Promocode, Image, Gift
@admin.register(Category)

View File

@ -2,24 +2,33 @@ from django.contrib.auth.hashers import make_password
from django.core.management import BaseCommand
from tqdm import tqdm
from store.models import User
from account.models import User
users = [
{
"email": "poizonstore@mail.ru",
"password": "219404Poizon",
"job_title": User.ADMIN,
"first_name": "Илья",
"middle_name": "Сергеевич",
"last_name": "Савочкин",
"role": User.ADMIN,
"is_staff": True
},
{
"email": "poizonmanager1@mail.ru",
"password": "poizonm1",
"job_title": User.PRODUCT_MANAGER
"first_name": "Патрик",
"middle_name": "Сергеевич",
"last_name": "Стар",
"role": User.PRODUCT_MANAGER
},
{
"email": "poizonorder1@mail.ru",
"password": "2193071Po1",
"job_title": User.ORDER_MANAGER
"first_name": "Гоша",
"middle_name": "Альбах",
"last_name": "Абызов",
"role": User.ORDER_MANAGER
}
]

View File

@ -1,9 +1,11 @@
# Generated by Django 4.2.2 on 2023-06-30 22:04
# Generated by Django 4.2.2 on 2024-03-28 22:05
import datetime
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import mptt.fields
import store.models
@ -12,96 +14,149 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('email', models.EmailField(max_length=254, unique=True, verbose_name='Эл. почта')),
('first_name', models.CharField(blank=True, max_length=150, null=True, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, null=True, verbose_name='last name')),
('middle_name', models.CharField(blank=True, max_length=150, null=True, verbose_name='Отчество')),
('job_title', models.CharField(choices=[('admin', 'Администратор'), ('ordermanager', 'Менеджер по заказам'), ('productmanager', 'Менеджер по закупкам')], max_length=30, verbose_name='Должность')),
('manager_id', models.CharField(blank=True, max_length=5, null=True, verbose_name='ID менеджера')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', store.models.UserManager()),
],
),
migrations.CreateModel(
name='Category',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=20, verbose_name='Название')),
('slug', models.SlugField(verbose_name='Идентификатор')),
('delivery_price_CN_RU', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена доставки Китай-РФ')),
('commission', models.DecimalField(decimal_places=2, default=0, max_digits=10, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)], verbose_name='Дополнительная комиссия, %')),
('lft', models.PositiveIntegerField(editable=False)),
('rght', models.PositiveIntegerField(editable=False)),
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
('level', models.PositiveIntegerField(editable=False)),
('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='store.category', verbose_name='Родительская категория')),
],
options={
'verbose_name': 'Категория',
'verbose_name_plural': 'Категории',
},
),
migrations.CreateModel(
name='Gift',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='Название')),
('image', models.ImageField(blank=True, null=True, upload_to='gifts/', verbose_name='Фото')),
('min_price', models.DecimalField(decimal_places=2, default=0, help_text='от какой суммы доступен подарок', max_digits=10, verbose_name='Минимальная цена в юанях')),
('available_count', models.PositiveSmallIntegerField(default=0, 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, max_digits=10)),
('delivery_price_CN', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('delivery_price_CN_RU', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('commission_rub', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
('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': 'Глобальные настройки',
},
),
migrations.CreateModel(
name='Image',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to=store.models.image_upload_path, verbose_name='Файл изображения')),
('type', models.PositiveSmallIntegerField(choices=[(0, 'Изображение'), (1, 'Превью'), (2, 'Документ'), (3, 'Подарок')], default=0, verbose_name='Тип')),
],
options={
'verbose_name': 'Изображение',
'verbose_name_plural': 'Изображения',
},
),
migrations.CreateModel(
name='PaymentMethod',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30, verbose_name='Название')),
('slug', models.SlugField(unique=True, verbose_name='Идентификатор')),
('cardnumber', models.CharField(blank=True, max_length=30, null=True, verbose_name='Номер карты')),
('requisites', models.CharField(blank=True, max_length=200, null=True, verbose_name='Реквизиты')),
],
options={
'verbose_name': 'Метод оплаты',
'verbose_name_plural': 'Методы оплаты',
},
),
migrations.CreateModel(
name='PriceSnapshot',
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')),
('delivery_price_CN', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена доставки по Китаю')),
('delivery_price_CN_RU', 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='Комиссия, руб')),
],
),
migrations.CreateModel(
name='PromoCode',
name='Promocode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='Название')),
('discount', models.PositiveSmallIntegerField(verbose_name='Скидка')),
('name', models.CharField(max_length=100, unique=True, verbose_name='Название')),
('discount', models.PositiveIntegerField(verbose_name='Скидка в рублях')),
('free_delivery', models.BooleanField(default=False, verbose_name='Бесплатная доставка')),
('no_comission', models.BooleanField(default=False, verbose_name='Без комиссии')),
('is_active', models.BooleanField(default=True, verbose_name='Активен')),
],
options={
'verbose_name': 'Промокод',
'verbose_name_plural': 'Промокоды',
},
),
migrations.CreateModel(
name='Checklist',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('status_updated_at', models.DateTimeField()),
('status', models.CharField(choices=[('draft', 'Черновик'), ('neworder', 'Новый заказ'), ('payment', 'Проверка оплаты'), ('buying', 'На закупке'), ('china', 'На складе в Китае'), ('chinarush', 'Доставка на склад РФ'), ('rush', 'На складе в РФ'), ('completed', 'Завершен')], max_length=15, verbose_name='Статус заказа')),
('product_link', models.URLField(blank=True, null=True)),
('subcategory', models.CharField(blank=True, max_length=20, null=True, verbose_name='Подкатегория')),
('status_updated_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата обновления статуса заказа')),
('id', models.CharField(default=store.models.generate_checklist_id, editable=False, max_length=10, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('draft', 'Черновик'), ('neworder', 'Новый заказ'), ('payment', 'Проверка оплаты'), ('buying', 'На закупке'), ('bought', 'Закуплен'), ('china', 'На складе в Китае'), ('chinarush', 'Доставка на склад РФ'), ('rush', 'На складе в РФ'), ('split_waiting', 'Сплит: ожидание оплаты 2й части'), ('split_paid', 'Сплит: полностью оплачено'), ('cdek', 'Доставляется СДЭК'), ('completed', 'Завершен')], default='neworder', max_length=15, verbose_name='Статус заказа')),
('product_link', models.URLField(blank=True, null=True, verbose_name='Ссылка на товар')),
('brand', models.CharField(blank=True, max_length=100, null=True, verbose_name='Бренд')),
('model', models.CharField(blank=True, max_length=100, null=True, verbose_name='Модель')),
('size', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Размер')),
('image', models.ImageField(blank=True, null=True, upload_to='')),
('preview_image', models.ImageField(blank=True, null=True, upload_to='')),
('price_yuan', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('comission', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('real_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('promocode', models.CharField(blank=True, max_length=100, null=True, verbose_name='Промокод')),
('size', models.CharField(blank=True, max_length=30, null=True, verbose_name='Размер')),
('price_yuan', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена в юанях')),
('real_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Реальная цена')),
('comment', models.CharField(blank=True, max_length=200, null=True, verbose_name='Комментарий')),
('buyer_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='Имя покупателя')),
('buyer_phone', models.CharField(blank=True, max_length=100, null=True, verbose_name='Телефон покупателя')),
('buyer_telegram', models.CharField(blank=True, max_length=100, null=True, verbose_name='Telegram покупателя')),
('receiver_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='Имя получателя')),
('receiver_phone', models.CharField(blank=True, max_length=100, null=True, verbose_name='Телефон получателя')),
('payment_type', models.CharField(blank=True, choices=[('alfa', 'Альфа-Банк'), ('tink', 'Тинькофф Банк'), ('raif', 'Райффайзен Банк')], max_length=10, null=True, verbose_name='Метод оплаты')),
('payment_proof', models.ImageField(blank=True, null=True, upload_to='', verbose_name='Подтверждение оплаты')),
('cheque_photo', models.ImageField(blank=True, null=True, upload_to='', verbose_name='Фото чека')),
('delivery', models.CharField(blank=True, choices=[('pickup', 'Самовывоз из шоурума'), ('cdek', 'Пункт выдачи заказов CDEK')], max_length=10, null=True, verbose_name='Тип доставки')),
('track_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='Трек-номер')),
('is_split_payment', models.BooleanField(default=False, verbose_name='Оплата частями')),
('payment_proof', models.ImageField(blank=True, null=True, upload_to='docs/', verbose_name='Подтверждение оплаты')),
('split_payment_proof', models.ImageField(blank=True, null=True, upload_to='docs/', verbose_name='Подтверждение оплаты сплита')),
('split_accepted', models.BooleanField(default=False, verbose_name='Сплит принят')),
('receipt', models.ImageField(blank=True, null=True, upload_to='docs/', verbose_name='Фото чека')),
('delivery', models.CharField(blank=True, choices=[('pickup', 'Самовывоз из шоурума'), ('cdek', 'Пункт выдачи заказов CDEK'), ('cdek_courier', 'Курьерская доставка CDEK')], max_length=15, null=True, verbose_name='Тип доставки')),
('poizon_tracking', models.CharField(blank=True, max_length=100, null=True, verbose_name='Трек-номер Poizon')),
('cdek_tracking', models.CharField(blank=True, max_length=100, null=True, verbose_name='Трек-номер СДЭК')),
('cdek_barcode_pdf', models.FileField(blank=True, null=True, upload_to='docs', verbose_name='Штрих-код СДЭК в PDF')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.category', verbose_name='Категория')),
('manager', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
('gift', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.gift', verbose_name='Подарок')),
('images', models.ManyToManyField(blank=True, related_name='+', to='store.image', verbose_name='Изображения')),
('manager', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Менеджер')),
('payment_method', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.paymentmethod', verbose_name='Метод оплаты')),
('price_snapshot', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='checklist', to='store.pricesnapshot', verbose_name='Сохраненные цены')),
('promocode', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='store.promocode', verbose_name='Промокод')),
],
options={
'verbose_name': 'Заказ',
'verbose_name_plural': 'Заказы',
},
),
]

View File

@ -1,26 +0,0 @@
# Generated by Django 4.2.2 on 2023-06-30 22:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='category',
options={'verbose_name': 'Category', 'verbose_name_plural': 'Categories'},
),
migrations.RemoveField(
model_name='checklist',
name='comission',
),
migrations.AlterField(
model_name='checklist',
name='price_yuan',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Цена в юанях'),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.2 on 2024-04-07 23:27
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('store', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='checklist',
name='customer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='customer_orders', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='checklist',
name='manager',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='manager_orders', to=settings.AUTH_USER_MODEL, verbose_name='Менеджер'),
),
]

View File

@ -1,51 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-01 16:58
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('store', '0002_alter_category_options_remove_checklist_comission_and_more'),
]
operations = [
migrations.AlterField(
model_name='checklist',
name='id',
field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='checklist',
name='manager',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Менеджер'),
),
migrations.AlterField(
model_name='checklist',
name='product_link',
field=models.URLField(blank=True, null=True, verbose_name='Ссылка на товар'),
),
migrations.AlterField(
model_name='checklist',
name='real_price',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Реальная цена'),
),
migrations.AlterField(
model_name='checklist',
name='status_updated_at',
field=models.DateTimeField(verbose_name='Дата обновления статуса заказа'),
),
migrations.AlterField(
model_name='globalsettings',
name='delivery_price_CN',
field=models.JSONField(blank=True, null=True),
),
migrations.AlterField(
model_name='globalsettings',
name='delivery_price_CN_RU',
field=models.JSONField(blank=True, null=True),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.2.2 on 2024-04-21 03:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('store', '0002_checklist_customer_alter_checklist_manager'),
]
operations = [
migrations.RemoveField(
model_name='checklist',
name='buyer_name',
),
migrations.RemoveField(
model_name='checklist',
name='buyer_phone',
),
migrations.RemoveField(
model_name='checklist',
name='buyer_telegram',
),
]

View File

@ -1,22 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-01 17:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0003_alter_checklist_id_alter_checklist_manager_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='globalsettings',
options={'verbose_name': 'GlobalSettings', 'verbose_name_plural': 'GlobalSettings'},
),
migrations.AlterField(
model_name='globalsettings',
name='yuan_rate',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-01 17:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0004_alter_globalsettings_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='globalsettings',
name='delivery_price_CN',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-01 17:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0005_alter_globalsettings_delivery_price_cn'),
]
operations = [
migrations.AlterField(
model_name='checklist',
name='price_yuan',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена в юанях'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-01 17:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0006_alter_checklist_price_yuan'),
]
operations = [
migrations.AlterField(
model_name='category',
name='slug',
field=models.SlugField(unique=True, verbose_name='Идентификатор'),
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-01 17:33
from django.db import migrations, models
import store.models
class Migration(migrations.Migration):
dependencies = [
('store', '0007_alter_category_slug'),
]
operations = [
migrations.AlterField(
model_name='checklist',
name='id',
field=models.CharField(default=store.models.generate_checklist_id, editable=False, max_length=10, primary_key=True, serialize=False),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-01 20:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0008_alter_checklist_id'),
]
operations = [
migrations.AlterField(
model_name='checklist',
name='status',
field=models.CharField(choices=[('draft', 'Черновик'), ('neworder', 'Новый заказ'), ('payment', 'Проверка оплаты'), ('buying', 'На закупке'), ('china', 'На складе в Китае'), ('chinarush', 'Доставка на склад РФ'), ('rush', 'На складе в РФ'), ('completed', 'Завершен')], default='neworder', max_length=15, verbose_name='Статус заказа'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-01 21:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0009_alter_checklist_status'),
]
operations = [
migrations.AlterField(
model_name='checklist',
name='status',
field=models.CharField(choices=[('draft', 'Черновик'), ('neworder', 'Новый заказ'), ('payment', 'Проверка оплаты'), ('buying', 'На закупке'), ('bought', 'Закуплен'), ('china', 'На складе в Китае'), ('chinarush', 'Доставка на склад РФ'), ('rush', 'На складе в РФ'), ('cdek', 'Доставляется СДЭК'), ('completed', 'Завершен')], default='neworder', max_length=15, verbose_name='Статус заказа'),
),
]

View File

@ -1,22 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-01 21:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0010_alter_checklist_status'),
]
operations = [
migrations.RemoveField(
model_name='globalsettings',
name='delivery_price_CN_RU',
),
migrations.AddField(
model_name='category',
name='delivery_price_CN_RU',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10),
),
]

View File

@ -1,33 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-01 21:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0011_remove_globalsettings_delivery_price_cn_ru_and_more'),
]
operations = [
migrations.AlterField(
model_name='category',
name='delivery_price_CN_RU',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена доставки Китай-РФ'),
),
migrations.AlterField(
model_name='globalsettings',
name='commission_rub',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Комиссия, руб'),
),
migrations.AlterField(
model_name='globalsettings',
name='delivery_price_CN',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена доставки по Китаю'),
),
migrations.AlterField(
model_name='globalsettings',
name='yuan_rate',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Курс CNY/RUB'),
),
]

View File

@ -1,39 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-01 22:30
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('store', '0012_alter_category_delivery_price_cn_ru_and_more'),
]
operations = [
migrations.CreateModel(
name='PaymentType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(unique=True)),
('requisites', models.CharField(max_length=200, verbose_name='Реквизиты')),
],
options={
'verbose_name': 'Метод оплаты',
'verbose_name_plural': 'Методы оплаты',
},
),
migrations.AlterModelOptions(
name='category',
options={'verbose_name': 'Категория', 'verbose_name_plural': 'Категории'},
),
migrations.AlterModelOptions(
name='globalsettings',
options={'verbose_name': 'Глобальные настройки', 'verbose_name_plural': 'Глобальные настройки'},
),
migrations.AlterField(
model_name='checklist',
name='payment_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.paymenttype', verbose_name='Метод оплаты'),
),
]

View File

@ -1,22 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-01 22:32
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('store', '0013_paymenttype_alter_category_options_and_more'),
]
operations = [
migrations.RenameModel(
old_name='PaymentType',
new_name='PaymentMethod',
),
migrations.RenameField(
model_name='checklist',
old_name='payment_type',
new_name='payment_method',
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-01 22:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0014_rename_paymenttype_paymentmethod_and_more'),
]
operations = [
migrations.AddField(
model_name='paymentmethod',
name='name',
field=models.CharField(default='', max_length=30, verbose_name='Название'),
preserve_default=False,
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-01 22:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0015_paymentmethod_name'),
]
operations = [
migrations.AddField(
model_name='paymentmethod',
name='cardnumber',
field=models.CharField(default='', max_length=30, verbose_name='Номер карты'),
preserve_default=False,
),
]

View File

@ -1,27 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-01 23:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0016_paymentmethod_cardnumber'),
]
operations = [
migrations.AlterModelOptions(
name='promocode',
options={'verbose_name': 'Промокод', 'verbose_name_plural': 'Промокоды'},
),
migrations.AlterField(
model_name='paymentmethod',
name='slug',
field=models.SlugField(unique=True, verbose_name='Идентификатор'),
),
migrations.AlterField(
model_name='promocode',
name='discount',
field=models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Скидка'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-01 23:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0017_alter_promocode_options_alter_paymentmethod_slug_and_more'),
]
operations = [
migrations.AlterField(
model_name='promocode',
name='name',
field=models.CharField(max_length=100, unique=True, verbose_name='Название'),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-01 23:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0018_alter_promocode_name'),
]
operations = [
migrations.AddField(
model_name='globalsettings',
name='pickup_address',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Адрес пункта самовывоза'),
),
migrations.AlterField(
model_name='checklist',
name='size',
field=models.CharField(blank=True, max_length=30, null=True, verbose_name='Размер'),
),
]

View File

@ -1,34 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-02 15:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0019_globalsettings_pickup_address_alter_checklist_size'),
]
operations = [
migrations.CreateModel(
name='Image',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='checklist_images')),
('is_preview', models.BooleanField(default=False)),
],
),
migrations.RemoveField(
model_name='checklist',
name='image',
),
migrations.RemoveField(
model_name='checklist',
name='preview_image',
),
migrations.AddField(
model_name='checklist',
name='images',
field=models.ManyToManyField(blank=True, to='store.image', verbose_name='Картинки'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-02 17:10
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('store', '0020_image_remove_checklist_image_and_more'),
]
operations = [
migrations.RenameField(
model_name='image',
old_name='is_preview',
new_name='needs_preview',
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-02 17:11
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('store', '0021_rename_is_preview_image_needs_preview'),
]
operations = [
migrations.RenameField(
model_name='image',
old_name='needs_preview',
new_name='is_preview',
),
]

View File

@ -1,21 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-02 23:59
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('store', '0022_rename_needs_preview_image_is_preview'),
]
operations = [
migrations.AlterModelOptions(
name='checklist',
options={'verbose_name': 'Заказ', 'verbose_name_plural': 'Заказы'},
),
migrations.AlterModelOptions(
name='image',
options={'verbose_name': 'Изображение', 'verbose_name_plural': 'Изображения'},
),
]

View File

@ -1,24 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-03 10:17
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('store', '0023_alter_checklist_options_alter_image_options'),
]
operations = [
migrations.RemoveField(
model_name='checklist',
name='images',
),
migrations.AddField(
model_name='image',
name='checklist',
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='images', to='store.checklist'),
preserve_default=False,
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-03 19:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0024_remove_checklist_images_image_checklist'),
]
operations = [
migrations.AlterField(
model_name='checklist',
name='status_updated_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Дата обновления статуса заказа'),
),
]

View File

@ -1,27 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-04 21:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0025_alter_checklist_status_updated_at'),
]
operations = [
migrations.RemoveField(
model_name='checklist',
name='track_number',
),
migrations.AddField(
model_name='checklist',
name='cdek_tracking',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Трек-номер СДЭК'),
),
migrations.AddField(
model_name='checklist',
name='poizon_tracking',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Трек-номер Poizon'),
),
]

View File

@ -1,28 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-05 01:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0026_remove_checklist_track_number_and_more'),
]
operations = [
migrations.AddField(
model_name='checklist',
name='cdek_barcode_pdf',
field=models.ImageField(blank=True, null=True, upload_to='docs', verbose_name='Штрих-код СДЭК в PDF'),
),
migrations.AlterField(
model_name='checklist',
name='cheque_photo',
field=models.ImageField(blank=True, null=True, upload_to='docs', verbose_name='Фото чека'),
),
migrations.AlterField(
model_name='checklist',
name='payment_proof',
field=models.ImageField(blank=True, null=True, upload_to='docs', verbose_name='Подтверждение оплаты'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-05 02:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0027_checklist_cdek_barcode_pdf_and_more'),
]
operations = [
migrations.AlterField(
model_name='checklist',
name='cdek_barcode_pdf',
field=models.FileField(blank=True, null=True, upload_to='docs', verbose_name='Штрих-код СДЭК в PDF'),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-06 11:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0028_alter_checklist_cdek_barcode_pdf'),
]
operations = [
migrations.RenameField(
model_name='checklist',
old_name='cheque_photo',
new_name='receipt',
),
migrations.AlterField(
model_name='checklist',
name='status',
field=models.CharField(choices=[('draft', 'Черновик'), ('neworder', 'Новый заказ'), ('payment', 'Проверка оплаты'), ('buying', 'На закупке'), ('bought', 'Закуплен'), ('china', 'На складе в Китае'), ('chinarush', 'Доставка на склад РФ'), ('rush', 'На складе в РФ'), ('split_waiting', 'Сплит: ожидание оплаты 2й части'), ('split_paid', 'Сплит: полностью оплачено'), ('cdek', 'Доставляется СДЭК'), ('completed', 'Завершен')], default='neworder', max_length=15, verbose_name='Статус заказа'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-06 12:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0029_rename_cheque_photo_checklist_receipt_and_more'),
]
operations = [
migrations.AddField(
model_name='checklist',
name='is_split_payment',
field=models.BooleanField(default=False, verbose_name='Оплата частями'),
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-06 12:02
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('store', '0030_checklist_is_split_payment'),
]
operations = [
migrations.AlterField(
model_name='checklist',
name='promocode',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='store.promocode', verbose_name='Промокод'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-06 12:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0031_alter_checklist_promocode'),
]
operations = [
migrations.AddField(
model_name='promocode',
name='is_active',
field=models.BooleanField(default=True, verbose_name='Активен'),
),
]

View File

@ -1,17 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-06 13:44
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('store', '0032_promocode_is_active'),
]
operations = [
migrations.RemoveField(
model_name='user',
name='manager_id',
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-06 21:32
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0033_remove_user_manager_id'),
]
operations = [
migrations.AlterField(
model_name='promocode',
name='discount',
field=models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)], verbose_name='Скидка'),
),
]

View File

@ -1,31 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-07 03:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0034_alter_promocode_discount'),
]
operations = [
migrations.RemoveField(
model_name='image',
name='checklist',
),
migrations.AddField(
model_name='checklist',
name='images',
field=models.ManyToManyField(related_name='+', to='store.image', verbose_name='Изображения'),
),
migrations.RemoveField(
model_name='checklist',
name='payment_proof',
),
migrations.AddField(
model_name='checklist',
name='payment_proof',
field=models.ManyToManyField(related_name='+', to='store.image', verbose_name='Подтверждение оплаты'),
),
]

View File

@ -1,32 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-07 03:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0035_remove_image_checklist_checklist_images_and_more'),
]
operations = [
migrations.RemoveField(
model_name='image',
name='is_preview',
),
migrations.AddField(
model_name='image',
name='type',
field=models.PositiveSmallIntegerField(choices=[(0, 'Изображение'), (1, 'Превью')], default=0, verbose_name='Тип'),
),
migrations.AlterField(
model_name='checklist',
name='receipt',
field=models.ImageField(blank=True, null=True, upload_to='', verbose_name='Фото чека'),
),
migrations.AlterField(
model_name='image',
name='image',
field=models.ImageField(upload_to='', verbose_name='Файл изображения'),
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-07 03:23
from django.db import migrations, models
import store.models
class Migration(migrations.Migration):
dependencies = [
('store', '0036_remove_image_is_preview_image_type_and_more'),
]
operations = [
migrations.AlterField(
model_name='image',
name='image',
field=models.ImageField(upload_to=store.models.image_upload_path, verbose_name='Файл изображения'),
),
]

View File

@ -1,33 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-07 14:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0037_alter_image_image'),
]
operations = [
migrations.AlterField(
model_name='checklist',
name='images',
field=models.ManyToManyField(blank=True, related_name='+', to='store.image', verbose_name='Изображения'),
),
migrations.AlterField(
model_name='checklist',
name='payment_proof',
field=models.ManyToManyField(blank=True, related_name='+', to='store.image', verbose_name='Подтверждение оплаты'),
),
migrations.AlterField(
model_name='checklist',
name='receipt',
field=models.ImageField(blank=True, null=True, upload_to='docs/', verbose_name='Фото чека'),
),
migrations.AlterField(
model_name='image',
name='type',
field=models.PositiveSmallIntegerField(choices=[(0, 'Изображение'), (1, 'Превью'), (2, 'Документ')], default=0, verbose_name='Тип'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-10 17:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0038_alter_checklist_images_alter_checklist_payment_proof_and_more'),
]
operations = [
migrations.AlterField(
model_name='checklist',
name='delivery',
field=models.CharField(blank=True, choices=[('pickup', 'Самовывоз из шоурума'), ('cdek', 'Пункт выдачи заказов CDEK'), ('cdek_courier', 'Курьерская доставка CDEK')], max_length=15, null=True, verbose_name='Тип доставки'),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 4.2.2 on 2023-07-12 20:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0039_alter_checklist_delivery'),
]
operations = [
migrations.AlterField(
model_name='paymentmethod',
name='cardnumber',
field=models.CharField(blank=True, max_length=30, null=True, verbose_name='Номер карты'),
),
migrations.AlterField(
model_name='paymentmethod',
name='requisites',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Реквизиты'),
),
]

View File

@ -1,134 +0,0 @@
# Generated by Django 4.2.2 on 2023-08-19 16:45
import datetime
import django.core.validators
from django.conf import settings
from django.db import migrations, models, transaction
import django.db.models.deletion
import mptt.fields
from mptt import register, managers
from store.management.commands.create_initial_data import create_categories
def create_initial_categories(apps, schema_editor):
create_categories()
# Dummy model with the removed field(s)
class OldChecklist(models.Model):
id = models.CharField(primary_key=True, max_length=settings.CHECKLIST_ID_LENGTH)
category_id = models.PositiveIntegerField()
subcategory = models.CharField('Подкатегория', max_length=20, blank=True, null=True)
class Meta:
managed = False
db_table = 'store_checklist'
# Add mptt fields to Category
# Loop through orders, save subcategory names and category ids
# Create subcategories, link to parent category
# Loop through orders, if subcategory was present, set it as category
# Perform migration - remove subcategory field
def create_subcategories(apps, schema_editor):
Checklist = apps.get_model("store", "Checklist")
Category = apps.get_model("store", "Category")
# Loop through orders, save subcategory names and category ids
# Create subcategories, link to parent category
# If Checklist had subcategory, set it as category
with transaction.atomic():
for checklist in OldChecklist.objects.all():
if checklist.subcategory is None or checklist.category_id is None:
continue
category_data = {'name': checklist.subcategory, 'parent_id': checklist.category_id}
# just to overcome not-null constraint errors for mptt
mptt_data = {'level': 0, 'lft': 0, 'rght': 0, 'tree_id': 0}
subcat_obj, _ = Category.objects.get_or_create(**category_data, defaults=mptt_data)
# To really update the Checklist, we must use a real model instead of the dummy OldChecklist one
Checklist.objects.filter(id=checklist.id).update(category_id=subcat_obj.id)
def rebuild_tree(apps, schema_editor):
model = apps.get_model('store', 'Category')
manager = managers.TreeManager()
manager.model = model
register(model)
manager.contribute_to_class(model, 'objects')
manager.rebuild()
class Migration(migrations.Migration):
dependencies = [
('store', '0040_alter_paymentmethod_cardnumber_and_more'),
]
operations = [
migrations.RemoveField(
model_name='category',
name='slug',
),
migrations.AddField(
model_name='category',
name='commission',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)], verbose_name='Дополнительная комиссия, %'),
),
migrations.AddField(
model_name='category',
name='level',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='category',
name='lft',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='category',
name='parent',
field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='store.category', verbose_name='Родительская категория'),
),
migrations.AddField(
model_name='category',
name='rght',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='category',
name='tree_id',
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
preserve_default=False,
),
migrations.RunPython(code=create_initial_categories),
migrations.RunPython(code=create_subcategories),
migrations.RunPython(code=rebuild_tree),
migrations.AddField(
model_name='globalsettings',
name='time_to_buy',
field=models.DurationField(default=datetime.timedelta(seconds=10800), help_text="Через N времени заказ переходит из статуса 'Новый' в 'Черновик'", verbose_name='Время на покупку'),
),
migrations.AlterField(
model_name='promocode',
name='discount',
field=models.PositiveIntegerField(verbose_name='Скидка в рублях'),
),
migrations.RemoveField(
model_name='checklist',
name='subcategory',
),
]

View File

@ -1,59 +0,0 @@
# Generated by Django 4.2.2 on 2023-08-21 10:41
from django.db import migrations, models
import django.db.models.deletion
def move_m2m_payment_proof_to_image(apps, schema_editor):
Checklist = apps.get_model('store', 'Checklist')
for checklist in Checklist.objects.all():
img_obj = checklist.payment_proof.all().first()
if img_obj:
checklist._payment_proof = img_obj.image
checklist.save()
class Migration(migrations.Migration):
dependencies = [
('store', '0041_remove_category_slug_remove_checklist_subcategory_and_more'),
]
operations = [
migrations.CreateModel(
name='OldChecklist',
fields=[
('id', models.CharField(max_length=10, primary_key=True, serialize=False)),
('category_id', models.PositiveIntegerField()),
('subcategory', models.CharField(blank=True, max_length=20, null=True, verbose_name='Подкатегория')),
],
options={
'db_table': 'store_checklist',
'managed': False,
},
),
migrations.AddField(
model_name='checklist',
name='_payment_proof',
field=models.ImageField(blank=True, null=True, upload_to='docs/', verbose_name='Подтверждение оплаты'),
),
migrations.RunPython(code=move_m2m_payment_proof_to_image),
migrations.RemoveField(
model_name='checklist',
name='payment_proof',
),
migrations.RenameField(
model_name='checklist',
old_name='_payment_proof',
new_name='payment_proof',
),
migrations.AddField(
model_name='checklist',
name='split_payment_proof',
field=models.ImageField(blank=True, null=True, upload_to='docs/', verbose_name='Подтверждение оплаты сплита'),
),
]

View File

@ -1,29 +0,0 @@
# Generated by Django 4.2.2 on 2023-10-04 02:53
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('store', '0042_oldchecklist_checklist_split_payment_proof_and_more'),
]
operations = [
migrations.CreateModel(
name='PriceSnapshot',
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')),
('delivery_price_CN', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена доставки по Китаю')),
('delivery_price_CN_RU', 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='Комиссия, руб')),
],
),
migrations.AddField(
model_name='checklist',
name='price_snapshot',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='checklist', to='store.pricesnapshot', verbose_name='Сохраненные цены'),
),
]

View File

@ -1,31 +0,0 @@
# Generated by Django 4.2.2 on 2023-10-25 15:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0044_checklist_split_accepted'),
]
operations = [
migrations.CreateModel(
name='Gift',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='Название')),
('image', models.ImageField(blank=True, null=True, upload_to='gifts/', verbose_name='Фото')),
('min_price', models.DecimalField(decimal_places=2, default=0, help_text='от какой суммы доступен подарок', max_digits=10, verbose_name='Минимальная цена')),
],
options={
'verbose_name': 'Подарок',
'verbose_name_plural': 'Подарки',
},
),
migrations.AlterField(
model_name='image',
name='type',
field=models.PositiveSmallIntegerField(choices=[(0, 'Изображение'), (1, 'Превью'), (2, 'Документ'), (3, 'Подарок')], default=0, verbose_name='Тип'),
),
]

View File

@ -1,24 +0,0 @@
# Generated by Django 4.2.2 on 2023-10-25 15:43
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('store', '0045_gift_alter_image_type'),
]
operations = [
migrations.AddField(
model_name='checklist',
name='gift',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='store.gift', verbose_name='Подарок'),
),
migrations.AlterField(
model_name='gift',
name='min_price',
field=models.DecimalField(decimal_places=2, default=0, help_text='от какой суммы доступен подарок', max_digits=10, verbose_name='Минимальная цена в юанях'),
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 4.2.2 on 2023-11-11 05:48
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('store', '0046_checklist_gift_alter_gift_min_price'),
]
operations = [
migrations.AlterField(
model_name='checklist',
name='gift',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.gift', verbose_name='Подарок'),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 4.2.2 on 2023-11-22 22:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0047_alter_checklist_gift'),
]
operations = [
migrations.AddField(
model_name='globalsettings',
name='yuan_rate_commission',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Наценка на курс юаня, руб'),
),
migrations.AddField(
model_name='globalsettings',
name='yuan_rate_last_updated',
field=models.DateTimeField(default=None, null=True, verbose_name='Дата обновления курса CNY/RUB'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.2.2 on 2023-11-22 22:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0048_globalsettings_yuan_rate_commission_and_more'),
]
operations = [
migrations.AddField(
model_name='gift',
name='available_count',
field=models.PositiveSmallIntegerField(default=0, verbose_name='Доступное количество'),
),
]

View File

@ -1,29 +1,25 @@
import math
import posixpath
from datetime import timedelta
from decimal import Decimal
import random
import string
from datetime import timedelta
from decimal import Decimal
from io import BytesIO
from typing import Optional
from django.conf import settings
from django.contrib.admin import display
from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import AbstractUser, UserManager as _UserManager
from django.core.files.base import ContentFile
from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models
from django.db.models import F, Case, When, DecimalField, Prefetch, Max, Q, ExpressionWrapper
from django.db.models import F, Case, When, DecimalField, Prefetch, Max, Q
from django.db.models.functions import Ceil
from django.db.models.lookups import GreaterThan
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django_cleanup import cleanup
from mptt.fields import TreeForeignKey
from mptt.models import MPTTModel
from store.utils import create_preview, concat_not_null_values
from store.utils import create_preview
class GlobalSettings(models.Model):
@ -97,67 +93,6 @@ class Category(MPTTModel):
return self.commission
class UserQuerySet(models.QuerySet):
pass
class UserManager(models.Manager.from_queryset(UserQuerySet), _UserManager):
def _create_user(self, email, password, **extra_fields):
if not email:
raise ValueError("The given email must be set")
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.password = make_password(password)
user.save(using=self._db)
return user
def create_user(self, email=None, password=None, **extra_fields):
extra_fields.setdefault("is_staff", False)
return self._create_user(email, password, **extra_fields)
def create_superuser(self, email=None, password=None, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("job_title", User.ADMIN)
if extra_fields.get("is_staff") is not True:
raise ValueError("Superuser must have is_staff=True.")
return self._create_user(email, password, **extra_fields)
class User(AbstractUser):
ADMIN = "admin"
ORDER_MANAGER = "ordermanager"
PRODUCT_MANAGER = "productmanager"
JOB_CHOICES = (
(ADMIN, 'Администратор'),
(ORDER_MANAGER, 'Менеджер по заказам'),
(PRODUCT_MANAGER, 'Менеджер по закупкам'),
)
# Login by email
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = []
username = None
email = models.EmailField("Эл. почта", unique=True)
first_name = models.CharField(_("first name"), max_length=150, blank=True, null=True)
last_name = models.CharField(_("last name"), max_length=150, blank=True, null=True)
middle_name = models.CharField("Отчество", max_length=150, blank=True, null=True)
job_title = models.CharField("Должность", max_length=30, choices=JOB_CHOICES)
objects = UserManager()
@property
def is_superuser(self):
return self.job_title == self.ADMIN
@display(description='ФИО')
def full_name(self):
return concat_not_null_values(self.last_name, self.first_name, self.middle_name)
class PromocodeQuerySet(models.QuerySet):
def active(self):
return self.filter(is_active=True)
@ -260,7 +195,7 @@ def generate_checklist_id():
class ChecklistQuerySet(models.QuerySet):
def with_base_related(self):
return self.select_related('manager', 'category', 'payment_method',
'promocode', 'price_snapshot', 'gift') \
'promocode', 'price_snapshot', 'gift', 'customer') \
.prefetch_related(Prefetch('images', to_attr='_images'))
def default_ordering(self):
@ -347,6 +282,63 @@ class Checklist(models.Model):
(COMPLETED, 'Завершен'),
)
def get_tg_notification(self):
from tg_bot.messages import TGOrderStatusMessage as msg
match self.status:
case Checklist.Status.NEW:
return msg.NEW.format(order_id=self.id, order_link=self.order_link)
case Checklist.Status.BUYING:
if not self.is_split_payment:
return msg.BUYING_NON_SPLIT.format(order_id=self.id)
else:
return msg.BUYING_SPLIT.format(order_id=self.id, amount_to_pay=self.split_amount_to_pay())
case Checklist.Status.BOUGHT:
return msg.BOUGHT.format(order_id=self.id)
case Checklist.Status.CHINA:
return msg.CHINA.format(order_id=self.id)
case Checklist.Status.CHINA_RUSSIA:
return msg.CHINA_RUSSIA.format(order_id=self.id)
case Checklist.Status.RUSSIA:
if self.delivery == Checklist.DeliveryType.PICKUP:
return msg.RUSSIA_PICKUP.format(order_id=self.id)
elif self.delivery in Checklist.DeliveryType.CDEK_TYPES:
return msg.RUSSIA_CDEK.format(order_id=self.id)
case Checklist.Status.SPLIT_WAITING:
if self.delivery == Checklist.DeliveryType.PICKUP:
return msg.SPLIT_WAITING_PICKUP.format(order_id=self.id, amount_to_pay=self.split_amount_to_pay)
elif self.delivery in Checklist.DeliveryType.CDEK_TYPES:
return msg.SPLIT_WAITING_CDEK.format(order_id=self.id, amount_to_pay=self.split_amount_to_pay)
# FIXME: split_accepted ?
case Checklist.Status.SPLIT_PAID:
return msg.SPLIT_PAID.format(order_id=self.id)
case Checklist.Status.CDEK:
return msg.CDEK.format(order_id=self.id)
case Checklist.Status.COMPLETED:
return msg.COMPLETED.format(order_id=self.id)
case _:
return None
@property
def order_link(self):
return f"https://poizonstore.com/orderpageinprogress/{self.id}"
def split_amount_to_pay(self):
# FIXME: it's stupid, create PaymentInfo model or something
return self.full_price // 2
# Delivery
class DeliveryType:
PICKUP = "pickup"
@ -359,13 +351,16 @@ class Checklist(models.Model):
(CDEK_COURIER, 'Курьерская доставка CDEK'),
)
CDEK_TYPES = (CDEK, CDEK_COURIER)
created_at = models.DateTimeField(auto_now_add=True)
status_updated_at = models.DateTimeField('Дата обновления статуса заказа', auto_now_add=True)
status_updated_at = models.DateTimeField('Дата обновления статуса заказа', auto_now_add=True, editable=False)
id = models.CharField(primary_key=True, max_length=settings.CHECKLIST_ID_LENGTH,
default=generate_checklist_id, editable=False)
status = models.CharField('Статус заказа', max_length=15, choices=Status.CHOICES, default=Status.NEW)
# managerid
manager = models.ForeignKey('User', verbose_name='Менеджер', on_delete=models.SET_NULL, blank=True, null=True)
manager = models.ForeignKey('account.User', verbose_name='Менеджер', related_name='manager_orders',
on_delete=models.SET_NULL, blank=True, null=True)
product_link = models.URLField('Ссылка на товар', null=True, blank=True)
category = models.ForeignKey('Category', verbose_name="Категория", blank=True, null=True, on_delete=models.SET_NULL)
@ -383,12 +378,8 @@ class Checklist(models.Model):
gift = models.ForeignKey('Gift', verbose_name='Подарок', on_delete=models.SET_NULL, null=True, blank=True)
comment = models.CharField('Комментарий', max_length=200, null=True, blank=True)
# buyername
buyer_name = models.CharField('Имя покупателя', max_length=100, null=True, blank=True)
# buyerphone
buyer_phone = models.CharField('Телефон покупателя', max_length=100, null=True, blank=True)
# tg
buyer_telegram = models.CharField('Telegram покупателя', max_length=100, null=True, blank=True)
customer = models.ForeignKey('account.User', on_delete=models.CASCADE, related_name='customer_orders', blank=True,
null=True)
# receivername
receiver_name = models.CharField('Имя получателя', max_length=100, null=True, blank=True)
@ -410,7 +401,8 @@ class Checklist(models.Model):
null=True, blank=True)
split_accepted = models.BooleanField('Сплит принят', default=False)
receipt = models.ImageField('Фото чека', upload_to=Image.TYPE_TO_UPLOAD_PATH[Image.DOC], null=True, blank=True) # checkphoto
receipt = models.ImageField('Фото чека', upload_to=Image.TYPE_TO_UPLOAD_PATH[Image.DOC], null=True,
blank=True) # checkphoto
delivery = models.CharField('Тип доставки', max_length=15, choices=DeliveryType.CHOICES, null=True, blank=True)
# trackid
@ -589,14 +581,47 @@ class Checklist(models.Model):
# Restore snapshot
self.price_snapshot = snapshot
def _notify_about_status_change(self):
if self.customer_id is None:
return
tg_message = self.get_tg_notification()
if tg_message:
self.customer.notify_tg_bot(tg_message)
def _check_eligible_for_order_bonus(self):
if self.customer_id is None:
return
if self.status != Checklist.Status.CHINA_RUSSIA:
return
# Check if any BonusProgramTransaction bound to current order exists
from account.models import BonusProgramTransaction
if BonusProgramTransaction.objects.filter(order_id=self.id).exists():
return
# 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)
else:
self.customer.add_order_bonus(self)
# TODO: split into sub-functions
def save(self, *args, **kwargs):
if self.id:
old_obj = Checklist.objects.filter(id=self.id).first()
self._check_eligible_for_order_bonus()
# If status was updated, update status_updated_at field
if old_obj and self.status != old_obj.status:
if old_obj is not None and self.status != old_obj.status:
self.status_updated_at = timezone.now()
self._notify_about_status_change()
# TODO: remove bonuses if order is canceled?
# Invalidate old CDEK barcode PDF
if not self.cdek_barcode_pdf or self.cdek_tracking != old_obj.cdek_tracking:
self.cdek_barcode_pdf.delete(save=False)
@ -609,8 +634,13 @@ class Checklist(models.Model):
if pdf_file:
self.cdek_barcode_pdf.save(f'{self.id}_barcode.pdf', pdf_file, save=False)
# Invalidate old preview_image if full_price changed
price_changed = old_obj is not None and self.full_price != old_obj.full_price
if price_changed:
self.preview_image.delete(save=False)
# Create preview image
if not self.preview_image:
if self.preview_image is None:
self.generate_preview()
# Update available gifts count

View File

@ -1,23 +1,12 @@
from drf_extra_fields.fields import Base64ImageField
from rest_framework import serializers
from store.exceptions import CRMException
from store.models import User, Checklist, GlobalSettings, Category, PaymentMethod, Promocode, Image, Gift
from account.serializers import UserSerializer
from utils.exceptions import CRMException
from store.models import Checklist, GlobalSettings, Category, PaymentMethod, Promocode, Image, Gift
from store.utils import get_primary_key_related_model
class UserSerializer(serializers.ModelSerializer):
login = serializers.CharField(source='email', required=False)
job = serializers.CharField(source='job_title', required=False)
name = serializers.CharField(source='first_name', required=False)
lastname = serializers.CharField(source='middle_name', required=False)
surname = serializers.CharField(source='last_name', required=False)
class Meta:
model = User
fields = ('id', 'login', 'job', 'name', 'lastname', 'surname',)
class ImageSerializer(serializers.ModelSerializer):
image = Base64ImageField()
@ -74,52 +63,51 @@ class GiftSerializer(serializers.ModelSerializer):
class ChecklistSerializer(serializers.ModelSerializer):
id = serializers.CharField(read_only=True)
managerid = serializers.PrimaryKeyRelatedField(source='manager_id', read_only=True, allow_null=True)
manager_id = serializers.PrimaryKeyRelatedField(read_only=True, allow_null=True)
link = serializers.URLField(source='product_link', required=False)
category = get_primary_key_related_model(CategoryChecklistSerializer, required=False, allow_null=True)
size = serializers.CharField(required=False, allow_null=True)
image = ImageListSerializer(source='main_images', required=False)
previewimage = serializers.ImageField(source='preview_image_url', read_only=True)
preview_image_url = serializers.ImageField(read_only=True)
promo = serializers.SlugRelatedField(source='promocode', slug_field='name',
queryset=Promocode.objects.active(), required=False, allow_null=True)
promocode = serializers.SlugRelatedField(slug_field='name', queryset=Promocode.objects.active(),
required=False, allow_null=True)
gift = get_primary_key_related_model(GiftSerializer, required=False, allow_null=True)
currency = serializers.DecimalField(source='yuan_rate', read_only=True, max_digits=10, decimal_places=2)
curencycurency2 = serializers.DecimalField(source='price_yuan', required=False, max_digits=10, decimal_places=2)
currency3 = serializers.IntegerField(source='price_rub', read_only=True)
chinadelivery = serializers.DecimalField(source='delivery_price_CN', read_only=True, max_digits=10, decimal_places=2)
chinadelivery2 = serializers.DecimalField(source='delivery_price_CN_RU', read_only=True,
max_digits=10, decimal_places=2)
fullprice = serializers.IntegerField(source='full_price', read_only=True)
realprice = serializers.DecimalField(source='real_price', required=False, allow_null=True, max_digits=10,
decimal_places=2)
commission = serializers.DecimalField(source='commission_rub', read_only=True, max_digits=10, decimal_places=2)
yuan_rate = serializers.DecimalField(read_only=True, max_digits=10, decimal_places=2)
price_yuan = serializers.DecimalField(required=False, max_digits=10, decimal_places=2)
price_rub = serializers.IntegerField(read_only=True)
buyername = serializers.CharField(source='buyer_name', required=False, allow_null=True)
buyerphone = serializers.CharField(source='buyer_phone', required=False, allow_null=True)
tg = serializers.CharField(source='buyer_telegram', required=False, allow_null=True)
delivery_price_CN = serializers.DecimalField(read_only=True, max_digits=10, decimal_places=2)
delivery_price_CN_RU = serializers.DecimalField(read_only=True, max_digits=10, decimal_places=2)
receivername = serializers.CharField(source='receiver_name', required=False, allow_null=True)
reveiverphone = serializers.CharField(source='receiver_phone', required=False, allow_null=True)
full_price = serializers.IntegerField(read_only=True)
real_price = serializers.DecimalField(required=False, allow_null=True, max_digits=10, decimal_places=2)
split = serializers.BooleanField(source='is_split_payment', required=False)
paymenttype = serializers.SlugRelatedField(source='payment_method', slug_field='slug',
commission_rub = serializers.DecimalField(read_only=True, max_digits=10, decimal_places=2)
customer = get_primary_key_related_model(UserSerializer, required=False, allow_null=True)
receiver_name = serializers.CharField(required=False, allow_null=True)
receiver_phone = serializers.CharField(required=False, allow_null=True)
is_split_payment = serializers.BooleanField(required=False)
payment_method = serializers.SlugRelatedField(slug_field='slug',
queryset=PaymentMethod.objects.all(),
required=False, allow_null=True)
paymentprovement = Base64ImageField(source='payment_proof', required=False, allow_null=True)
payment_proof = Base64ImageField(required=False, allow_null=True)
split_payment_proof = Base64ImageField(required=False, allow_null=True)
checkphoto = Base64ImageField(source='receipt', required=False, allow_null=True)
trackid = serializers.CharField(source='poizon_tracking', required=False, allow_null=True)
receipt = Base64ImageField(required=False, allow_null=True)
poizon_tracking = serializers.CharField(required=False, allow_null=True)
cdek_tracking = serializers.CharField(required=False, allow_null=True)
delivery = serializers.ChoiceField(choices=Checklist.DeliveryType.CHOICES, required=False, allow_null=True)
delivery_display = serializers.CharField(source='get_delivery_display', read_only=True)
startDate = serializers.DateTimeField(source='created_at', read_only=True)
currentDate = serializers.DateTimeField(source='status_updated_at', read_only=True)
created_at = serializers.DateTimeField(read_only=True)
status_updated_at = serializers.DateTimeField(read_only=True)
def _collect_images_by_fields(self, validated_data):
images = {}
@ -141,6 +129,12 @@ class ChecklistSerializer(serializers.ModelSerializer):
def create(self, validated_data):
images = self._collect_images_by_fields(validated_data)
# Managers can create orders with arbitrary customers
# Client orders are created with client's account
user = self.context['request'].user
if not user.is_manager or validated_data.get('customer') is None:
validated_data['customer'] = user
instance = super().create(validated_data)
self._create_main_images(instance, images.get('main_images'))
return instance
@ -170,34 +164,24 @@ class ChecklistSerializer(serializers.ModelSerializer):
class Meta:
model = Checklist
fields = ('id', 'status', 'managerid', 'link',
fields = ('id', 'status', 'manager_id', 'link',
'category',
'brand', 'model', 'size',
'image',
'previewimage',
'currency', 'curencycurency2', 'currency3', 'chinadelivery', 'chinadelivery2', 'commission',
'promo', 'gift',
'preview_image_url',
'yuan_rate', 'price_yuan', 'price_rub', 'delivery_price_CN', 'delivery_price_CN_RU', 'commission_rub',
'promocode', 'gift',
'comment',
'fullprice', 'realprice',
'buyername', 'buyerphone', 'tg',
'receivername', 'reveiverphone',
'split', 'paymenttype', 'paymentprovement', 'split_payment_proof', 'split_accepted', 'checkphoto',
'trackid', 'cdek_tracking', 'cdek_barcode_pdf', 'delivery', 'delivery_display',
'startDate', 'currentDate', 'buy_time_remaining'
'full_price', 'real_price',
'customer',
'receiver_name', 'receiver_phone',
'is_split_payment', 'payment_method', 'payment_proof', 'split_payment_proof', 'split_accepted', 'receipt',
'poizon_tracking', 'cdek_tracking', 'cdek_barcode_pdf', 'delivery', 'delivery_display',
'created_at', 'status_updated_at', 'buy_time_remaining'
)
class AnonymousUserChecklistSerializer(ChecklistSerializer):
class Meta:
model = ChecklistSerializer.Meta.model
fields = ChecklistSerializer.Meta.fields
read_only_fields = tuple(set(ChecklistSerializer.Meta.fields) -
{'paymentprovement', 'paymenttype',
'buyername', 'buyerphone',
'delivery',
'recievername', 'recieverphone', 'tg',
'gift', 'cdek_barcode_pdf'})
class ClientChecklistSerializerMixin:
def validate(self, attrs):
gift = attrs.get('gift')
if gift is not None:
@ -210,6 +194,39 @@ class AnonymousUserChecklistSerializer(ChecklistSerializer):
return attrs
class ClientCreateChecklistSerializer(ClientChecklistSerializerMixin, ChecklistSerializer):
status = serializers.SerializerMethodField()
class Meta:
model = ChecklistSerializer.Meta.model
fields = ChecklistSerializer.Meta.fields
writable_fields = {
'link',
'brand', 'model', 'size', 'category',
'price_yuan',
'comment',
}
read_only_fields = tuple(set(ChecklistSerializer.Meta.fields) - writable_fields)
def get_status(self, obj):
return Checklist.Status.DRAFT
class ClientUpdateChecklistSerializer(ClientChecklistSerializerMixin, ChecklistSerializer):
class Meta:
model = ChecklistSerializer.Meta.model
fields = ChecklistSerializer.Meta.fields
writable_fields = {
'comment',
'payment_proof', 'payment_method',
'delivery',
'receiver_name', 'receiver_phone',
'gift', 'cdek_barcode_pdf'
}
read_only_fields = tuple(set(ChecklistSerializer.Meta.fields) - writable_fields)
class GlobalSettingsSerializer(serializers.ModelSerializer):
currency = serializers.DecimalField(source='full_yuan_rate', read_only=True, max_digits=10, decimal_places=2)
yuan_rate_last_updated = serializers.DateTimeField(read_only=True)
@ -222,6 +239,12 @@ class GlobalSettingsSerializer(serializers.ModelSerializer):
fields = ('currency', 'yuan_rate_last_updated', 'yuan_rate_commission', 'commission', 'chinadelivery', 'pickup', 'time_to_buy')
class AnonymousGlobalSettingsSerializer(GlobalSettingsSerializer):
class Meta:
model = GlobalSettingsSerializer.Meta.model
fields = tuple(set(GlobalSettingsSerializer.Meta.fields) - {'yuan_rate_commission', 'yuan_rate_last_updated'})
class PaymentMethodSerializer(serializers.ModelSerializer):
type = serializers.CharField(source='slug')

View File

@ -6,21 +6,18 @@ from store import views
router = DefaultRouter()
router.register(r'checklist', views.ChecklistAPI, basename='checklist')
router.register(r'statistics', views.StatisticsAPI, basename='statistics')
router.register(r'cdek', views.CDEKAPI, basename='cdek')
router.register(r'gifts', views.GiftAPI, basename='gifts')
router.register(r'poizon', views.PoizonAPI, basename='poizon')
router.register(r'promo', views.PromoCodeAPI, basename='promo')
urlpatterns = [
path("checklist/", views.ChecklistAPI.as_view()),
path("checklist/<str:id>", views.ChecklistAPI.as_view()),
path("category/", views.CategoryAPI.as_view()),
path("category/<int:id>", views.CategoryAPI.as_view()),
path("payment/", views.PaymentMethodsAPI.as_view()),
path("settings/", views.GlobalSettingsAPI.as_view()),
path("promo/", views.PromoCodeAPI.as_view()),
] + router.urls

View File

@ -1,25 +1,39 @@
import calendar
from datetime import timedelta
import requests
from django.conf import settings
from django.db.models import F, Count, Sum, OuterRef, Subquery
from django_filters.rest_framework import DjangoFilterBackend
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 IsAuthenticated
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from external_api.cdek import CDEKClient
from external_api.poizon import PoizonClient
from store.exceptions import CRMException
from utils.exceptions import CRMException
from store.filters import GiftFilter, ChecklistFilter
from store.models import Checklist, GlobalSettings, Category, PaymentMethod, Promocode, Gift
from store.serializers import (ChecklistSerializer, CategorySerializer, CategoryFullSerializer,
PaymentMethodSerializer, GlobalSettingsSerializer,
PromocodeSerializer, AnonymousUserChecklistSerializer, GiftSerializer)
from utils.permissions import ReadOnly
PaymentMethodSerializer, AnonymousGlobalSettingsSerializer, GlobalSettingsSerializer,
PromocodeSerializer, ClientUpdateChecklistSerializer, GiftSerializer,
ClientCreateChecklistSerializer)
from account.permissions import ReadOnly, IsManager, IsAdmin
def prepare_external_response(r: requests.Response):
data = {"status_code": r.status_code, "response": None}
try:
data["response"] = r.json()
except:
data["response"] = r.text
return Response(data)
class DisablePermissionsMixin(generics.GenericAPIView):
@ -29,13 +43,20 @@ class DisablePermissionsMixin(generics.GenericAPIView):
return super().get_permissions()
"""
- managers can create/edit/delete orders
class ChecklistAPI(mixins.ListModelMixin,
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
DisablePermissionsMixin):
- auth users can create/edit orders (managers and clients)
- client will have customer field auto-populated
- managers can set customer field manually
- clients can edit orders with customer.id == self.id
- anon users can only get order by id, but can't edit
"""
class ChecklistAPI(viewsets.ModelViewSet):
serializer_class = ChecklistSerializer
lookup_field = 'id'
filterset_class = ChecklistFilter
@ -43,16 +64,34 @@ class ChecklistAPI(mixins.ListModelMixin,
search_fields = ['id', 'poizon_tracking', 'buyer_phone']
# TODO: search by full_price
def permission_denied(self, request, **kwargs):
if request.user.is_authenticated and self.action in ["update", "partial_update", "list", "retrieve"]:
raise NotFound()
super().permission_denied(request, **kwargs)
def get_serializer_class(self):
if self.request.user.is_authenticated or settings.DISABLE_PERMISSIONS:
# Managers have a full set of fields
if getattr(self.request.user, 'is_manager', False) or self.action == 'retrieve':
return ChecklistSerializer
# Anonymous users can edit only a certain set of fields
return AnonymousUserChecklistSerializer
# Clients can create drafts with small set of fields
if self.action == "create":
return ClientCreateChecklistSerializer
# Then, clients can update small set of fields
elif self.action in ['update', 'partial_update']:
return ClientUpdateChecklistSerializer
# Fallback to error
self.permission_denied(self.request, **self.kwargs)
def get_permissions(self):
if self.request.method in ('GET', 'PATCH'):
return [permissions.AllowAny()]
if self.action in ['list', 'update', 'partial_update', 'destroy']:
self.permission_classes = [IsManager]
elif self.action == 'retrieve':
self.permission_classes = [AllowAny]
elif self.action == 'create':
self.permission_classes = [IsAuthenticated]
return super().get_permissions()
@ -72,31 +111,10 @@ class ChecklistAPI(mixins.ListModelMixin,
return obj
def post(self, request, *args, **kwargs):
return self.create(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
if 'id' in kwargs:
return self.retrieve(request, *args, **kwargs)
if not request.user.is_authenticated and not settings.DISABLE_PERMISSIONS:
# Anonymous users can't list checklists
return Response([])
return self.list(request, *args, **kwargs)
def perform_update(self, serializer):
serializer.save()
def patch(self, request, *args, **kwargs):
return self.partial_update(request, *args, **kwargs)
def delete(self, request, *args, **kwargs):
return self.destroy(request, *args, **kwargs)
class CategoryAPI(mixins.ListModelMixin, mixins.UpdateModelMixin, generics.GenericAPIView):
serializer_class = CategorySerializer
permission_classes = [IsManager | ReadOnly]
lookup_field = 'id'
def get_queryset(self):
@ -110,29 +128,24 @@ class CategoryAPI(mixins.ListModelMixin, mixins.UpdateModelMixin, generics.Gener
return self.partial_update(request, *args, **kwargs)
class GlobalSettingsAPI(generics.GenericAPIView):
class GlobalSettingsAPI(generics.RetrieveUpdateAPIView):
serializer_class = GlobalSettingsSerializer
permission_classes = [IsAuthenticated | ReadOnly]
permission_classes = [IsManager | ReadOnly]
def get_serializer_class(self):
if getattr(self.request.user, 'is_manager', False) or settings.DISABLE_PERMISSIONS:
return GlobalSettingsSerializer
# Anonymous users can view only a certain set of fields
return AnonymousGlobalSettingsSerializer
def get_object(self):
return GlobalSettings.load()
def get(self, request, *args, **kwargs):
instance = self.get_object()
return Response(self.get_serializer(instance).data)
def patch(self, request, *args, **kwargs):
instance = GlobalSettings.load()
serializer = self.get_serializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
class PaymentMethodsAPI(generics.GenericAPIView):
serializer_class = PaymentMethodSerializer
permission_classes = [IsAuthenticated | ReadOnly]
permission_classes = [IsManager | ReadOnly]
def get_queryset(self):
return PaymentMethod.objects.all()
@ -158,29 +171,15 @@ class PaymentMethodsAPI(generics.GenericAPIView):
return Response(serializer.data)
class PromoCodeAPI(mixins.CreateModelMixin, generics.GenericAPIView):
class PromoCodeAPI(viewsets.ModelViewSet):
serializer_class = PromocodeSerializer
permission_classes = [IsManager]
lookup_field = 'name'
def get_queryset(self):
return Promocode.objects.all()
def get(self, request, *args, **kwargs):
qs = self.get_queryset()
return Response(
{'promo': self.get_serializer(qs, many=True).data}
)
def post(self, request, *args, **kwargs):
self.create(request, *args, **kwargs)
return self.get(request, *args, **kwargs)
def delete(self, request, *args, **kwargs):
data = request.data
if 'name' not in data:
raise CRMException('name is required')
instance: Promocode = get_object_or_404(self.get_queryset(), name=data['name'])
def perform_destroy(self, instance):
instance.is_active = False
instance.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@ -188,11 +187,11 @@ class PromoCodeAPI(mixins.CreateModelMixin, generics.GenericAPIView):
class GiftAPI(viewsets.ModelViewSet):
serializer_class = GiftSerializer
permission_classes = [IsAuthenticated | ReadOnly]
permission_classes = [IsManager | ReadOnly]
filterset_class = GiftFilter
def get_queryset(self):
if self.request.user.is_authenticated or settings.DISABLE_PERMISSIONS:
if getattr(self.request.user, 'is_manager', False) or settings.DISABLE_PERMISSIONS:
return Gift.objects.all()
# For anonymous users, show only available gifts
@ -200,11 +199,15 @@ class GiftAPI(viewsets.ModelViewSet):
class StatisticsAPI(viewsets.GenericViewSet):
permission_classes = [IsAdmin]
def get_queryset(self):
return Checklist.objects.all() \
.select_related('customer') \
.filter(status=Checklist.Status.COMPLETED) \
.annotate(month=F('status_updated_at__month'))
# FIXME: stats_by_orders is broken because of _commission_rub annotation
@action(url_path='orders', detail=False, methods=['get'])
def stats_by_orders(self, request, *args, **kwargs):
global_settings = GlobalSettings.load()
@ -244,6 +247,7 @@ class StatisticsAPI(viewsets.GenericViewSet):
return Response(result)
# FIXME: stats_by_categories is broken because of absence of Category.slug field
@action(url_path='categories', detail=False, methods=['get'])
def stats_by_categories(self, request, *args, **kwargs):
all_categories = Category.objects.values_list('slug', flat=True)
@ -287,31 +291,21 @@ class StatisticsAPI(viewsets.GenericViewSet):
return {k: [] for k in options.keys()}
qs = self.get_queryset() \
.filter(buyer_phone__isnull=False) \
.values('month', 'buyer_phone') \
.filter(customer__isnull=False) \
.values('month', 'customer_id') \
.annotate(order_count=Count('id')) \
.filter(order_count__gt=1) \
.order_by('month')
# Temporary hack: collect the most recent info about client
# mapping buyer_phone -> buyer info (name, telegram)
client_mapping = {}
# mapping customer_id -> customer info
customer_mapping = {}
recent_created_at = Checklist.objects.all() \
.filter(buyer_phone=OuterRef('buyer_phone')) \
.order_by('-created_at') \
.values('created_at')[:1]
client_qs = Checklist.objects.filter(
created_at=Subquery(recent_created_at),
buyer_phone=F('buyer_phone')
).distinct()
for checklist in client_qs:
client_mapping[checklist.buyer_phone] = {
'phone': checklist.buyer_phone,
'name': checklist.buyer_name,
'telegram': checklist.buyer_telegram}
for order in Checklist.objects.all().select_related('customer'):
customer_mapping[order.customer_id] = {
'phone': str(order.customer.phone),
'name': order.customer.first_name,
'telegram': order.customer.telegram
}
result = {}
# Add empty stats
@ -320,18 +314,21 @@ class StatisticsAPI(viewsets.GenericViewSet):
result[month_name] = _create_empty_stats()
# Add actual stats
for stat in qs:
month_name = calendar.month_name[stat['month']]
for order in qs:
month_name = calendar.month_name[order['month']]
# Collect data for each order count: 1/2/3/4/5/10/25/50
for key, size in reversed(options.items()):
if stat['order_count'] > size:
client_info = client_mapping[stat['buyer_phone']]
if order['order_count'] > size:
client_info = customer_mapping[order['customer_id']]
client_info["order_count"] = order['order_count']
result[month_name][key].append(client_info)
break
return Response(result)
# TODO: review permissions
class CDEKAPI(viewsets.GenericViewSet):
client = CDEKClient(settings.CDEK_CLIENT_ID, settings.CDEK_CLIENT_SECRET)
permission_classes = [permissions.AllowAny]
@ -343,7 +340,7 @@ class CDEKAPI(viewsets.GenericViewSet):
raise CRMException('im_number is required')
r = self.client.get_order_info(im_number)
return Response(r.json())
return prepare_external_response(r)
@get_order_info.mapping.post
def create_order(self, request, *args, **kwargs):
@ -352,7 +349,7 @@ class CDEKAPI(viewsets.GenericViewSet):
raise CRMException('json data is required')
r = self.client.create_order(order_data)
return Response(r.json())
return prepare_external_response(r)
@get_order_info.mapping.patch
def edit_order(self, request, *args, **kwargs):
@ -361,7 +358,7 @@ class CDEKAPI(viewsets.GenericViewSet):
raise CRMException('json data is required')
r = self.client.edit_order(order_data)
return Response(r.json())
return prepare_external_response(r)
@action(url_path='calculator/tariff', detail=False, methods=['post'])
def calculate_tariff(self, request, *args, **kwargs):
@ -369,7 +366,7 @@ class CDEKAPI(viewsets.GenericViewSet):
if not data:
raise CRMException('json data is required')
r = self.client.calculate_tariff(data)
return Response(r.json())
return prepare_external_response(r)
@action(url_path='calculator/tarifflist', detail=False, methods=['post'])
def calculate_tarifflist(self, request, *args, **kwargs):
@ -377,11 +374,13 @@ class CDEKAPI(viewsets.GenericViewSet):
if not data:
raise CRMException('json data is required')
r = self.client.calculate_tarifflist(data)
return Response(r.json())
return prepare_external_response(r)
# TODO: review permissions
class PoizonAPI(viewsets.GenericViewSet):
client = PoizonClient(settings.POIZON_TOKEN)
permission_classes = [permissions.AllowAny]
@action(url_path='good', detail=False, methods=['post'])
def get_good_info(self, request, *args, **kwargs):
@ -394,5 +393,5 @@ class PoizonAPI(viewsets.GenericViewSet):
if spu_id is None:
raise CRMException('url or spuId is required')
data = self.client.get_good_info(spu_id)
return Response(data)
r = self.client.get_good_info(spu_id)
return prepare_external_response(r)

0
tg_bot/__init__.py Normal file
View File

3
tg_bot/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

Some files were not shown because too many files have changed in this diff Show More