+ 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:
parent
bfff884603
commit
ef40e9f7e0
3
.env
3
.env
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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} \
|
||||
|
|
|
|||
|
|
@ -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} \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
0
account/__init__.py
Normal file
21
account/admin.py
Normal file
21
account/admin.py
Normal 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
9
account/apps.py
Normal 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
5
account/exceptions.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from rest_framework import exceptions, status
|
||||
|
||||
|
||||
class AuthError(exceptions.APIException):
|
||||
status_code = status.HTTP_401_UNAUTHORIZED
|
||||
71
account/migrations/0001_initial.py
Normal file
71
account/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
42
account/migrations/0002_initial.py
Normal file
42
account/migrations/0002_initial.py
Normal 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')},
|
||||
),
|
||||
]
|
||||
18
account/migrations/0003_alter_user_email.py
Normal file
18
account/migrations/0003_alter_user_email.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
19
account/migrations/0004_alter_user_phone.py
Normal file
19
account/migrations/0004_alter_user_phone.py
Normal 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='Телефон'),
|
||||
),
|
||||
]
|
||||
18
account/migrations/0005_alter_user_email.py
Normal file
18
account/migrations/0005_alter_user_email.py
Normal 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='Эл. почта'),
|
||||
),
|
||||
]
|
||||
18
account/migrations/0006_user_tg_user_id.py
Normal file
18
account/migrations/0006_user_tg_user_id.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Черновик пользователя'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
18
account/migrations/0010_bonusprogramtransaction_comment.py
Normal file
18
account/migrations/0010_bonusprogramtransaction_comment.py
Normal 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='Комментарий'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
]
|
||||
20
account/migrations/0012_referralrelationship_invited_at.py
Normal file
20
account/migrations/0012_referralrelationship_invited_at.py
Normal 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,
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
18
account/migrations/0014_alter_user_balance.py
Normal file
18
account/migrations/0014_alter_user_balance.py
Normal 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='Баланс, руб'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Тип транзакции'),
|
||||
),
|
||||
]
|
||||
18
account/migrations/0016_alter_user_role.py
Normal file
18
account/migrations/0016_alter_user_role.py
Normal 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='Роль'),
|
||||
),
|
||||
]
|
||||
0
account/migrations/__init__.py
Normal file
0
account/migrations/__init__.py
Normal file
4
account/models/__init__.py
Normal file
4
account/models/__init__.py
Normal 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
278
account/models/bonus.py
Normal 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
201
account/models/user.py
Normal 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
22
account/permissions.py
Normal 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
104
account/serializers.py
Normal 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
29
account/signals.py
Normal 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
3
account/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
13
account/urls.py
Normal file
13
account/urls.py
Normal 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
57
account/utils.py
Normal 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
153
account/views.py
Normal 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})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
37
run_tg_bot.py
Normal 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())
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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': 'Заказы',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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='Цена в юанях'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Менеджер'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Цена в юанях'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Идентификатор'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Статус заказа'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Статус заказа'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Метод оплаты'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
|
|
@ -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,
|
||||
),
|
||||
]
|
||||
|
|
@ -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,
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Скидка'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Название'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Размер'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Картинки'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
|
|
@ -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': 'Изображения'},
|
||||
),
|
||||
]
|
||||
|
|
@ -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,
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Дата обновления статуса заказа'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Подтверждение оплаты'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Статус заказа'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Оплата частями'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Промокод'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Активен'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Скидка'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Подтверждение оплаты'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Файл изображения'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Файл изображения'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Тип'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Тип доставки'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Реквизиты'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Подтверждение оплаты сплита'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Сохраненные цены'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Тип'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Минимальная цена в юанях'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Подарок'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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='Доступное количество'),
|
||||
),
|
||||
]
|
||||
192
store/models.py
192
store/models.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
queryset=PaymentMethod.objects.all(),
|
||||
required=False, allow_null=True)
|
||||
paymentprovement = Base64ImageField(source='payment_proof', required=False, allow_null=True)
|
||||
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)
|
||||
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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
205
store/views.py
205
store/views.py
|
|
@ -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
0
tg_bot/__init__.py
Normal file
3
tg_bot/admin.py
Normal file
3
tg_bot/admin.py
Normal 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
Loading…
Reference in New Issue
Block a user