Compare commits

...

59 Commits

Author SHA1 Message Date
ef40e9f7e0 + 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
2024-04-27 21:29:50 +04:00
bfff884603 * Store keys in env variables
* Cleanup
2024-04-27 19:54:30 +04:00
f2b506645b * Fix in deploy configs 2024-03-19 00:55:46 +04:00
baf63fc3c1 * Cleanup in deploy configs 2024-03-19 00:21:00 +04:00
9d05b2e1c2 * Cleanup in deploy configs 2024-03-19 00:14:41 +04:00
81fe6c34e5 + uwsgi stats 2024-03-13 17:27:21 +04:00
7d50cebe81 * Increased number of UWSGI processes 2024-03-11 02:01:09 +04:00
873672ea60 + CDEK API calculator/tarifflist endpoint 2024-01-03 16:10:23 +04:00
cd7d4489c8 * Typo 2024-01-03 01:40:09 +04:00
cbc2419bfe * Timeout for external API calls 2024-01-03 01:32:41 +04:00
b4a0b35008 * Updated celery systemd configs 2023-12-22 00:25:01 +04:00
bd3ea89166 * Disable in-memory cache GlobalSettings 2023-12-22 00:18:03 +04:00
8e49176351 * Better logs in check_cdek_status() task 2023-12-07 07:44:20 +04:00
683da45e7d * RuntimeDirectory for Celery services 2023-12-07 07:15:17 +04:00
ee49810e91 * Update yuan rate via Celery task 2023-12-07 07:07:41 +04:00
1eb3e5a238 * run_celery_flower.sh update 2023-12-07 06:40:08 +04:00
a2b92ab029 * Celery deployment configs 2023-12-07 06:23:11 +04:00
46ff6b9d8b * WORK_DIR in run_celery.sh 2023-12-02 17:47:15 +04:00
13333f7681 * Oupsie in run_celery.sh 2023-12-02 17:37:22 +04:00
29e76a5371 + Celery
+ Update CDEK status in background
2023-12-02 17:11:08 +04:00
8a7b53b069 * Renamed GlobalSettings.get_yuan_rate -> full_yuan_rate property
* In GlobalSettingsSerializer show full yuan rate
2023-11-24 17:58:42 +04:00
d22f492df0 * For anonymous users, show only available gifts 2023-11-23 03:55:58 +04:00
3e8b1f7c66 * Try-except for CDEKAPI calls 2023-11-23 03:41:54 +04:00
00ddded442 * Null-check for gifts 2023-11-23 03:39:06 +04:00
ccd9c60a97 + yuan_rate_last_updated in GlobalSettings serializer
* Show raw yuan_rate in GlobalSettings
* Use yuan_rate+yuan_rate_commission sum for Checklist calculations
2023-11-23 03:32:54 +04:00
ceeae24f69 * Cleanup 2023-11-23 02:46:45 +04:00
abaa11e9c6 * Added missing available_count field in GiftSerializer 2023-11-23 02:45:17 +04:00
93ab682c69 * Updated API key for CurrencyAPI 2023-11-23 02:44:58 +04:00
6903c2ff13 * available_count for Gift 2023-11-23 02:40:30 +04:00
db842d507c * Cleanup 2023-11-23 02:18:55 +04:00
3a0c24c4dc * Fixed retry mechanism in external API calls 2023-11-23 02:17:35 +04:00
2e79e579f7 + CurrencyAPIClient for fresh CNY rate
+ yuan_rate_last_updated, yuan_rate_commission fields in GlobalSettings
2023-11-23 02:16:53 +04:00
a4f8dfc27c * Filter Checklist by multiple status values 2023-11-23 02:13:05 +04:00
aa3aa00ae1 * Fixed wrong id for PriceSnapshot 2023-11-15 18:40:00 +04:00
0e5c0ce16f * WIP: annotate_commission_rub 2023-11-11 09:51:17 +04:00
9a2a630465 + Set Checklist.gift to NULL on Gift deletion 2023-11-11 09:49:30 +04:00
65b27d49d7 * annotate_price_rub fixed 2023-11-11 09:48:32 +04:00
520d9ebc0e * Disable Sentry for debug environment 2023-11-09 17:19:18 +04:00
8089947dcb + Centry logging 2023-11-03 22:42:03 +04:00
6f96eaaac7 * Check the Gifts that are being added to Checklist from unauthorized users 2023-11-03 00:55:07 +04:00
ca478a7e15 * Allow PoizonAPI for authorized users only 2023-11-03 00:39:53 +04:00
28b0adffc3 * Allow unauthorized users to edit Checklist.cdek_barcode_pdf field 2023-11-03 00:34:40 +04:00
48f24a2e4b * Option to directly invalidate cdek_barcode_pdf 2023-11-03 00:23:46 +04:00
d41fc46642 * CDEK barcode PDF invalidation 2023-11-03 00:13:35 +04:00
64423a1670 * Cleanup 2023-11-02 20:42:28 +04:00
e982cd3445 + Poizon API 2023-11-02 20:41:40 +04:00
dd9dfb3704 + Gift 2023-10-31 16:50:45 +04:00
b8e04067dd * Moved CDEK api to external_api module 2023-10-31 16:49:10 +04:00
5b0d0d205b + Recursive search of suitable commission price & delivery price 2023-10-15 10:45:43 +04:00
86a86e79f3 * Try to get delivery_price_CN_RU from parent category if it is zero 2023-10-12 15:26:09 +04:00
8a7e2865ed * Get max commission value 2023-10-12 08:50:13 +04:00
dbceacb606 * Fixed commission math 2023-10-12 02:14:14 +04:00
0bb89fc6f0 * Updated commission logic 2023-10-11 22:48:13 +04:00
0dcc98ab70 * Optional Checklist.size field 2023-10-11 22:32:22 +04:00
bdeb57971b * Updated preview layout 2023-10-11 22:28:17 +04:00
00e5b19b6a + Checklist.split_accepted field 2023-10-04 06:58:09 +04:00
69d386806a + PriceSnapshot
* Category commission inside a commission_rub field
2023-10-04 06:57:53 +04:00
88377ad4c0 * Preview image: format price by 3 digits with spaces 2023-08-27 18:07:40 +04:00
26f896ca4e * Fixed non-updatable children Categories 2023-08-26 17:28:21 +04:00
121 changed files with 3047 additions and 1607 deletions

19
.env
View File

@ -1 +1,18 @@
APP_HOME=/var/www/phzhik-poizonstore/
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=""
# External API settings
CDEK_CLIENT_ID=""
CDEK_CLIENT_SECRET=""
POIZON_TOKEN=""
CURRENCY_GETGEOIP_API_KEY=""
# Let's Encrypt
LETSENCRYPT_EMAIL="phzhitnikov@gmail.com"

1
.gitignore vendored
View File

@ -7,6 +7,7 @@ media/**/*
assets/**/*
env
*.env
.idea
.DS_Store
db.sqlite3

28
_deploy/celery Normal file
View File

@ -0,0 +1,28 @@
# Name of nodes to start
# here we have a single node
CELERYD_NODES="w1"
# or we could have three nodes:
#CELERYD_NODES="w1 w2 w3"
# Absolute or relative path to the 'celery' command:
CELERY_BIN="/var/www/poizonstore-stage/env/bin/celery"
# App instance to use
CELERY_APP="poizonstore"
# How to call manage.py
CELERYD_MULTI="multi"
# Extra command-line arguments to the worker
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-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-stage/beat.pid"
CELERYBEAT_LOG_FILE="/var/log/celery-stage/beat.log"

25
_deploy/celery.service Normal file
View File

@ -0,0 +1,25 @@
[Unit]
Description=Celery Service
After=network.target
Requires=redis.service
[Service]
Type=forking
User=poizon
Group=poizon
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} \
--loglevel="${CELERYD_LOG_LEVEL}" $CELERYD_OPTS'
ExecStop=/bin/sh -c '${CELERY_BIN} multi stopwait $CELERYD_NODES \
--pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} \
--loglevel="${CELERYD_LOG_LEVEL}"'
ExecReload=/bin/sh -c '${CELERY_BIN} -A $CELERY_APP multi restart $CELERYD_NODES \
--pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} \
--loglevel="${CELERYD_LOG_LEVEL}" $CELERYD_OPTS'
Restart=always
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,18 @@
[Unit]
Description=Celery Beat Service
After=network.target
[Service]
Type=simple
User=poizon
Group=poizon
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} \
--logfile=${CELERYBEAT_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL}'
Restart=always
[Install]
WantedBy=multi-user.target

View File

@ -1,21 +1,27 @@
upstream django {
server 127.0.0.1:8001;
server 127.0.0.1:8002;
}
server {
listen 80;
server_name crm-poizonstore.ru;
server_name stage.crm-poizonstore.ru;
return 301 https://$host$request_uri;
}
server {
set $APP_HOME /var/www/phzhik-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
@ -40,4 +46,8 @@ server {
include /etc/nginx/uwsgi_params;
include /etc/nginx/proxy_params;
}
location /flower/ {
proxy_pass http://localhost:5555/flower/;
}
}

14
_deploy/run_celery_flower.sh Executable file
View File

@ -0,0 +1,14 @@
#!/bin/sh
WORK_DIR="/var/www/poizonstore"
CELERY_BIN="/var/www/poizonstore/env/bin"
PROJECT_NAME="poizonstore"
# Wait for worker to start
until timeout 10s $CELERY_BIN/celery -A $PROJECT_NAME --workdir=$WORK_DIR inspect ping; do
>&2 echo "Celery workers not available"
done
# Run flower for Celery management
echo 'Starting Celery flower'
$CELERY_BIN/celery -A $PROJECT_NAME --workdir=$WORK_DIR flower --port=5555 --url_prefix=/flower --basic-auth=admin:meowmeow

View File

@ -1,25 +1,28 @@
[uwsgi]
project = poizonstore-stage
uid = poizon
gid = poizon
# Django-related settings
# the base directory (full path)
chdir = /var/www/phzhik-poizonstore/
chdir = /var/www/%(project)/
# Django's wsgi file
module = vba_portal:application
module = poizonstore:application
# the virtualenv (full path)
virtualenv = /var/www/phzhik-poizonstore/env
virtualenv = /var/www/%(project)/env
# process-related settings
# master
master = true
# maximum number of worker processes
processes = 1
processes = 10
# the socket (use the full path to be safe
#socket = /var/www/phzhik-poizonstore/mysite.sock
#socket = /var/www/%(project)/mysite.sock
socket = :8001
wsgi-file = /var/www/phzhik-poizonstore/poizonstore/wsgi.py
pidfile = /tmp/uwsgi.pid
wsgi-file = /var/www/%(project)/poizonstore/wsgi.py
pidfile = /tmp/uwsgi-%(project).pid
stats = /tmp/uwsgi.stats.sock
# ... with appropriate permissions - may be needed
chmod-socket = 664
# clear environment on exit

View File

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

21
account/admin.py Normal file
View File

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

9
account/apps.py Normal file
View File

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

5
account/exceptions.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
# Generated by Django 4.2.2 on 2023-07-06 12:00
# 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', '0029_rename_cheque_photo_checklist_receipt_and_more'),
('account', '0006_user_tg_user_id'),
]
operations = [
migrations.AddField(
model_name='checklist',
name='is_split_payment',
field=models.BooleanField(default=False, verbose_name='Оплата частями'),
model_name='user',
name='is_draft_user',
field=models.BooleanField(default=False, verbose_name='Черновик пользователя'),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

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

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

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

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

22
account/permissions.py Normal file
View File

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

104
account/serializers.py Normal file
View File

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

29
account/signals.py Normal file
View File

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

3
account/tests.py Normal file
View File

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

13
account/urls.py Normal file
View File

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

57
account/utils.py Normal file
View File

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

153
account/views.py Normal file
View File

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

View File

@ -1,122 +0,0 @@
import http
import os
from time import sleep
from typing import Optional
from urllib.parse import urljoin
import requests
from django.conf import settings
from django.core.files.base import ContentFile
from requests import Request
from store.utils import is_migration_running
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'poizonstore.settings')
class CDEKClient:
AUTH_ENDPOINT = 'oauth/token'
ORDER_INFO_ENDPOINT = 'orders'
CALCULATOR_TARIFF_ENDPOINT = 'calculator/tariff'
BARCODE_ENDPOINT = 'print/barcodes'
MAX_RETRIES = 2
def __init__(self, client_id, client_secret, grant_type='client_credentials'):
self.api_url = 'https://api.cdek.ru/v2/'
self.client_id = client_id
self.client_secret = client_secret
self.grant_type = grant_type
self.session = requests.Session()
def request(self, method, url, *args, **kwargs):
joined_url = urljoin(self.api_url, url)
request = Request(method, joined_url, *args, **kwargs)
retries = 0
while retries < self.MAX_RETRIES:
prepared = self.session.prepare_request(request)
r = self.session.send(prepared)
# TODO: handle/log errors
if r.status_code == http.HTTPStatus.UNAUTHORIZED:
self.authorize()
continue
return r
def authorize(self):
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
'grant_type': self.grant_type
}
r = self.request('POST', self.AUTH_ENDPOINT, params=params)
if r:
data = r.json()
token = data['access_token']
self.session.headers.update({'Authorization': f'Bearer {token}'})
def get_order_info(self, im_number):
params = {
'im_number': str(im_number)
}
return self.request('GET', self.ORDER_INFO_ENDPOINT, params=params)
def create_order(self, order_data):
return self.request('POST', self.ORDER_INFO_ENDPOINT, json=order_data)
def edit_order(self, order_data):
return self.request('PATCH', self.ORDER_INFO_ENDPOINT, json=order_data)
def calculate_tariff(self, data):
return self.request('POST', self.CALCULATOR_TARIFF_ENDPOINT, json=data)
def generate_barcode(self, cdek_number, format="A6") -> Optional[str]:
request_data = {
"orders": [{"cdek_number": cdek_number}],
"copy_count": 1,
"format": format
}
r = self.request('POST', self.BARCODE_ENDPOINT, json=request_data)
if not r:
return None
resp_data = r.json()
if 'entity' not in resp_data:
return None
barcode_uuid = resp_data['entity']['uuid']
return barcode_uuid
def get_barcode_url(self, uuid) -> Optional[str]:
if not uuid:
return None
r = self.request('GET', f'{self.BARCODE_ENDPOINT}/{uuid}')
if not r:
return None
resp_data = r.json()
if 'entity' not in resp_data:
return None
url = resp_data['entity'].get('url')
return url
def get_barcode_file(self, cdek_number):
uuid = self.generate_barcode(cdek_number)
sleep(2) # Sometimes url are not yet created, so be prepared for this
url = self.get_barcode_url(uuid)
if not url:
return None
r = self.request('GET', url)
return ContentFile(r.content) if r and r.content else None
client = CDEKClient(settings.CDEK_CLIENT_ID, settings.CDEK_CLIENT_SECRET)
if not is_migration_running():
client.authorize()

0
external_api/__init__.py Normal file
View File

211
external_api/cdek.py Normal file
View File

@ -0,0 +1,211 @@
import http
import os
from contextlib import suppress
from time import sleep
from typing import Optional
from urllib.parse import urljoin
import requests
from django.conf import settings
from django.core.files.base import ContentFile
from store.utils import is_migration_running
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'poizonstore.settings')
class CDEKStatus:
# Принят
ACCEPTED = "ACCEPTED"
# Создан
CREATED = "CREATED"
# Принят на склад отправителя
RECEIVED_AT_SHIPMENT_WAREHOUSE = "RECEIVED_AT_SHIPMENT_WAREHOUSE"
# Выдан на отправку в г. отправителе
READY_FOR_SHIPMENT_IN_SENDER_CITY = "READY_FOR_SHIPMENT_IN_SENDER_CITY"
# Возвращен на склад отправителя
RETURNED_TO_SENDER_CITY_WAREHOUSE = "RETURNED_TO_SENDER_CITY_WAREHOUSE"
# Сдан перевозчику в г. отправителе
TAKEN_BY_TRANSPORTER_FROM_SENDER_CITY = "TAKEN_BY_TRANSPORTER_FROM_SENDER_CITY"
# Отправлен в г. транзит
SENT_TO_TRANSIT_CITY = "SENT_TO_TRANSIT_CITY"
# Встречен в г. транзите
ACCEPTED_IN_TRANSIT_CITY = "ACCEPTED_IN_TRANSIT_CITY"
# Принят на склад транзита
ACCEPTED_AT_TRANSIT_WAREHOUSE = "ACCEPTED_AT_TRANSIT_WAREHOUSE"
# Возвращен на склад транзита
RETURNED_TO_TRANSIT_WAREHOUSE = "RETURNED_TO_TRANSIT_WAREHOUSE"
# Выдан на отправку в г. транзите
READY_FOR_SHIPMENT_IN_TRANSIT_CITY = "READY_FOR_SHIPMENT_IN_TRANSIT_CITY"
# Сдан перевозчику в г. транзите
TAKEN_BY_TRANSPORTER_FROM_TRANSIT_CITY = "TAKEN_BY_TRANSPORTER_FROM_TRANSIT_CITY"
# Отправлен в г. отправитель
SENT_TO_SENDER_CITY = "SENT_TO_SENDER_CITY"
# Отправлен в г. получатель
SENT_TO_RECIPIENT_CITY = "SENT_TO_RECIPIENT_CITY"
# Встречен в г. отправителе
ACCEPTED_IN_SENDER_CITY = "ACCEPTED_IN_SENDER_CITY"
# Встречен в г. получателе
ACCEPTED_IN_RECIPIENT_CITY = "ACCEPTED_IN_RECIPIENT_CITY"
# Принят на склад доставки
ACCEPTED_AT_RECIPIENT_CITY_WAREHOUSE = "ACCEPTED_AT_RECIPIENT_CITY_WAREHOUSE"
# Принят на склад до востребования
ACCEPTED_AT_PICK_UP_POINT = "ACCEPTED_AT_PICK_UP_POINT"
# Выдан на доставку
TAKEN_BY_COURIER = "TAKEN_BY_COURIER"
# Возвращен на склад доставки
RETURNED_TO_RECIPIENT_CITY_WAREHOUSE = "RETURNED_TO_RECIPIENT_CITY_WAREHOUSE"
# Вручен
DELIVERED = "DELIVERED"
# Не вручен
NOT_DELIVERED = "NOT_DELIVERED"
# Некорректный заказ
INVALID = "INVALID"
# Таможенное оформление в стране отправления
IN_CUSTOMS_INTERNATIONAL = "IN_CUSTOMS_INTERNATIONAL"
# Отправлено в страну назначения
SHIPPED_TO_DESTINATION = "SHIPPED_TO_DESTINATION"
# Передано транзитному перевозчику
PASSED_TO_TRANSIT_CARRIER = "PASSED_TO_TRANSIT_CARRIER"
# Таможенное оформление в стране назначения
IN_CUSTOMS_LOCAL = "IN_CUSTOMS_LOCAL"
# Таможенное оформление завершено
CUSTOMS_COMPLETE = "CUSTOMS_COMPLETE"
# Заложен в постамат
POSTOMAT_POSTED = "POSTOMAT_POSTED"
# Изъят из постамата курьером
POSTOMAT_SEIZED = "POSTOMAT_SEIZED"
# Изъят из постамата клиентом
POSTOMAT_RECEIVED = "POSTOMAT_RECEIVED"
class CDEKClient:
AUTH_ENDPOINT = 'oauth/token'
ORDER_INFO_ENDPOINT = 'orders'
CALCULATOR_TARIFF_ENDPOINT = 'calculator/tariff'
CALCULATOR_TARIFF_LIST_ENDPOINT = 'calculator/tarifflist'
BARCODE_ENDPOINT = 'print/barcodes'
MAX_RETRIES = 2
def __init__(self, client_id, client_secret, grant_type='client_credentials'):
self.api_url = 'https://api.cdek.ru/v2/'
self.client_id = client_id
self.client_secret = client_secret
self.grant_type = grant_type
self.session = requests.Session()
def request(self, method, url, *args, **kwargs):
joined_url = urljoin(self.api_url, url)
request = requests.Request(method, joined_url, *args, **kwargs)
retries = 0
while retries < self.MAX_RETRIES:
retries += 1
prepared = self.session.prepare_request(request)
try:
r = self.session.send(prepared, timeout=settings.EXTERNAL_API_TIMEOUT_SEC)
except:
continue
# TODO: handle/log errors
if r.status_code == http.HTTPStatus.UNAUTHORIZED:
self.authorize()
continue
return r
def authorize(self):
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
'grant_type': self.grant_type
}
r = self.request('POST', self.AUTH_ENDPOINT, params=params)
if r:
data = r.json()
token = data['access_token']
self.session.headers.update({'Authorization': f'Bearer {token}'})
def get_order_info(self, im_number):
params = {
'im_number': str(im_number)
}
return self.request('GET', self.ORDER_INFO_ENDPOINT, params=params)
def create_order(self, order_data):
return self.request('POST', self.ORDER_INFO_ENDPOINT, json=order_data)
def edit_order(self, order_data):
return self.request('PATCH', self.ORDER_INFO_ENDPOINT, json=order_data)
def calculate_tariff(self, data):
return self.request('POST', self.CALCULATOR_TARIFF_ENDPOINT, json=data)
def calculate_tarifflist(self, data):
return self.request('POST', self.CALCULATOR_TARIFF_LIST_ENDPOINT, json=data)
def generate_barcode(self, cdek_number, format="A6") -> Optional[str]:
request_data = {
"orders": [{"cdek_number": cdek_number}],
"copy_count": 1,
"format": format
}
r = self.request('POST', self.BARCODE_ENDPOINT, json=request_data)
if not r:
return None
resp_data = r.json()
if 'entity' not in resp_data:
return None
barcode_uuid = resp_data['entity']['uuid']
return barcode_uuid
def get_barcode_url(self, uuid) -> Optional[str]:
if not uuid:
return None
r = self.request('GET', f'{self.BARCODE_ENDPOINT}/{uuid}')
if not r:
return None
resp_data = r.json()
if 'entity' not in resp_data:
return None
url = resp_data['entity'].get('url')
return url
def get_barcode_file(self, cdek_number):
uuid = self.generate_barcode(cdek_number)
sleep(2) # Sometimes url are not yet created, so be prepared for this
url = self.get_barcode_url(uuid)
if not url:
return None
r = self.request('GET', url)
return ContentFile(r.content) if r and r.content else None
def get_order_statuses(self, cdek_number):
params = {
'cdek_number': str(cdek_number)
}
r = self.request('GET', self.ORDER_INFO_ENDPOINT, params=params)
if not r:
return []
with suppress(KeyError):
statuses = r.json()['entity']['statuses']
statuses = [s.get('code') for s in statuses]
return statuses
return []
client = CDEKClient(settings.CDEK_CLIENT_ID, settings.CDEK_CLIENT_SECRET)
if not is_migration_running():
client.authorize()

57
external_api/currency.py Normal file
View File

@ -0,0 +1,57 @@
from decimal import Decimal
from contextlib import suppress
from urllib.parse import urljoin
import requests
from django.conf import settings
class CurrencyAPIClient:
CONVERT_ENDPOINT = 'currency/convert'
MAX_RETRIES = 2
def __init__(self, api_key: str):
self.api_url = 'https://api.getgeoapi.com/v2/'
self.api_key = api_key
self.session = requests.Session()
def request(self, method, url, *args, **kwargs):
params = kwargs.pop('params', {})
params.update({"api_key": self.api_key})
joined_url = urljoin(self.api_url, url)
request = requests.Request(method, joined_url, params=params, *args, **kwargs)
retries = 0
while retries < self.MAX_RETRIES:
retries += 1
prepared = self.session.prepare_request(request)
r = self.session.send(prepared, timeout=settings.EXTERNAL_API_TIMEOUT_SEC)
# TODO: handle/log errors
return r
def get_rate(self, currency1: str, currency2: str, amount=1):
params = {
'from': currency1,
'to': currency2,
'amount': amount,
'format': 'json'
}
r = self.request('GET', self.CONVERT_ENDPOINT, params=params)
if not r or r.json().get('status') == 'failed':
return None
with suppress(KeyError):
rate = r.json()['rates'][currency2.upper()]['rate']
return Decimal(rate)
return None
def get_cny_rate(self):
return self.get_rate('cny', 'rub')
client = CurrencyAPIClient(settings.CURRENCY_GETGEOIP_API_KEY)

52
external_api/poizon.py Normal file
View File

@ -0,0 +1,52 @@
from urllib3.util import parse_url
from urllib.parse import urljoin, parse_qs
from django.conf import settings
import requests
class PoizonClient:
SPU_GET_DATA_ENDPOINT = 'spu_get_data'
MAX_RETRIES = 2
def __init__(self, token: str):
self.api_url = 'http://124.222.99.75/'
self.token = token
self.session = requests.Session()
def request(self, method, url, *args, **kwargs):
params = kwargs.pop('params', {})
params.update({"token": self.token})
joined_url = urljoin(self.api_url, url)
request = requests.Request(method, joined_url, params=params, *args, **kwargs)
retries = 0
while retries < self.MAX_RETRIES:
retries += 1
prepared = self.session.prepare_request(request)
r = self.session.send(prepared, timeout=settings.EXTERNAL_API_TIMEOUT_SEC)
# TODO: handle/log errors
return r
@staticmethod
def get_spu_id(url):
try:
# Go to short dw4.co url to get the full one from redirect
if 'dw4.co' in url:
r = requests.get(url)
url = r.url
url = parse_url(url)
qs = parse_qs(url.query)
spu_id = qs.get('spuId')
return spu_id.pop(0) if spu_id else None
except Exception as exc:
# TODO: handle/log errors
return None
def get_good_info(self, spu_id):
params = {'spuId': str(spu_id)}
return self.request('GET', self.SPU_GET_DATA_ENDPOINT, params=params)

View File

@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ('celery_app',)

27
poizonstore/celery.py Normal file
View File

@ -0,0 +1,27 @@
import os
from datetime import timedelta
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'poizonstore.settings')
app = Celery('poizonstore')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
app.conf.beat_schedule = {
'update-cdek-status-every-hour': {
'task': 'store.tasks.schedule_cdek_status_update',
'schedule': timedelta(hours=1),
},
'update-yuan-rate-every-hour': {
'task': 'store.tasks.update_yuan_rate',
'schedule': timedelta(hours=1),
},
}
@app.task()
def debug_task():
print(f'Task complete')

View File

@ -12,25 +12,48 @@ https://docs.djangoproject.com/en/4.2/ref/settings/
import os
from pathlib import Path
import sentry_sdk
from django.core.exceptions import ImproperlyConfigured
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
def get_secret(setting):
"""Get the secret variable or return explicit exception."""
try:
return os.environ[setting]
except KeyError:
error_msg = f'Set the {setting} environment variable'
raise ImproperlyConfigured(error_msg)
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-e&9j(^9z7p7qs-@d)vftjz4%xqu0#3mmn@+$wzwh!%-dwjecm-'
SECRET_KEY = get_secret("SECRET_KEY")
CDEK_CLIENT_ID = 'wZWtjnWtkX7Fin2tvDdUE6eqYz1t1GND'
CDEK_CLIENT_SECRET = 'lc2gmrmK5s1Kk6FhZbNqpQCaATQRlsOy'
# External API settings
CDEK_CLIENT_ID = get_secret("CDEK_CLIENT_ID")
CDEK_CLIENT_SECRET = get_secret("CDEK_CLIENT_SECRET")
POIZON_TOKEN = get_secret("POIZON_TOKEN")
CURRENCY_GETGEOIP_API_KEY = get_secret("CURRENCY_GETGEOIP_API_KEY")
EXTERNAL_API_TIMEOUT_SEC = 60
# Telegram bot
TG_BOT_TOKEN = get_secret("TG_BOT_TOKEN")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = bool(int(os.environ.get("DJANGO_DEBUG") or 0))
DISABLE_PERMISSIONS = False
DISABLE_CORS = True
ALLOWED_HOSTS = ["crm-poizonstore.ru", "127.0.0.1", "localhost", "45.84.227.72"]
ALLOWED_HOSTS = get_secret('ALLOWED_HOSTS').split(',')
INTERNAL_IPS = ["127.0.0.1", 'localhost']
@ -48,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
@ -70,7 +98,9 @@ INSTALLED_APPS = [
'django_filters',
'mptt',
'store'
'account',
'store',
'tg_bot'
]
MIDDLEWARE = [
@ -155,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',
},
}
@ -193,4 +224,38 @@ 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
SENTRY_DSN = "https://96106e3f938badc86ecb2e502716e496@o4506163299418112.ingest.sentry.io/4506163300663296"
if not DEBUG:
sentry_sdk.init(
dsn=SENTRY_DSN,
# Set traces_sample_rate to 1.0 to capture 100%
# of transactions for performance monitoring.
traces_sample_rate=1.0,
# Set profiles_sample_rate to 1.0 to profile 100%
# of sampled transactions.
# We recommend adjusting this value in production.
profiles_sample_rate=1.0,
)
# Celery
BROKER_URL = 'redis://localhost:6379/2'
CELERY_RESULT_BACKEND = BROKER_URL
CELERY_BROKER_URL = BROKER_URL
CELERY_ACCEPT_CONTENT = ['application/json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = TIME_ZONE
# Bonus program
# TODO: move to GlobalSettings?
BONUS_PROGRAM_CONFIG = {
"amounts": {
"signup": 150,
"default_purchase": 50,
"referral": 500,
}
}

View File

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

View File

@ -8,11 +8,27 @@ 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
requests==2.31.0
# Logging
sentry-sdk==1.34.0
sentry-telegram-py3==0.6.1
# Deployment
# gunicorn==20.1.0
uWSGI==2.0.21
inotify==0.2.10

37
run_tg_bot.py Normal file
View File

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

View File

@ -2,12 +2,7 @@ from django.contrib import admin
from django.contrib.admin import display
from mptt.admin import MPTTModelAdmin
from .models import Category, Checklist, GlobalSettings, PaymentMethod, Promocode, User, Image
@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)
@ -52,5 +47,10 @@ class PromoCodeAdmin(admin.ModelAdmin):
list_display = ('name', 'discount', 'free_delivery', 'no_comission')
@admin.register(Gift)
class GiftAdmin(admin.ModelAdmin):
list_display = ('name', 'min_price')

22
store/filters.py Normal file
View File

@ -0,0 +1,22 @@
from django_filters import rest_framework as filters
from .models import Checklist, Gift
class GiftFilter(filters.FilterSet):
for_price = filters.NumberFilter(method='filter_for_price')
class Meta:
model = Gift
fields = ('for_price',)
def filter_for_price(self, queryset, name, value):
return queryset.filter(min_price__lte=value)
class ChecklistFilter(filters.FilterSet):
status = filters.MultipleChoiceFilter(choices=Checklist.Status.CHOICES)
class Meta:
model = Checklist
fields = ('status',)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,34 +1,32 @@
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
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 utils.cache import InMemoryCache
from store.utils import create_preview
class GlobalSettings(models.Model):
# currency
yuan_rate = models.DecimalField('Курс CNY/RUB', max_digits=10, decimal_places=2, default=0)
yuan_rate_last_updated = models.DateTimeField('Дата обновления курса CNY/RUB', null=True, default=None)
yuan_rate_commission = models.DecimalField('Наценка на курс юаня, руб', max_digits=10, decimal_places=2, default=0)
# Chinadelivery
delivery_price_CN = models.DecimalField('Цена доставки по Китаю', max_digits=10, decimal_places=2, default=0)
commission_rub = models.DecimalField('Комиссия, руб', max_digits=10, decimal_places=2, default=0)
@ -47,21 +45,18 @@ class GlobalSettings(models.Model):
self.__class__.objects.exclude(id=self.id).delete()
super().save(*args, **kwargs)
InMemoryCache.set('GlobalSettings', self)
def __str__(self) -> str:
return f'GlobalSettings <{self.id}>'
@classmethod
def load(cls) -> 'GlobalSettings':
cached = InMemoryCache.get('GlobalSettings')
if cached:
return cached
obj, _ = cls.objects.get_or_create(id=1)
InMemoryCache.set('GlobalSettings', obj)
return obj
@property
def full_yuan_rate(self):
return self.yuan_rate + self.yuan_rate_commission
class Category(MPTTModel):
name = models.CharField('Название', max_length=20)
@ -82,66 +77,20 @@ class Category(MPTTModel):
verbose_name = 'Категория'
verbose_name_plural = 'Категории'
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 delivery_price(self):
if not self.delivery_price_CN_RU and self.parent_id:
return self.parent.delivery_price
else:
return self.delivery_price_CN_RU
@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)
def commission_price(self):
""" Get commission from object or from its parent """
if not self.commission and self.parent_id:
return self.parent.commission_price
else:
return self.commission
class PromocodeQuerySet(models.QuerySet):
@ -190,17 +139,20 @@ class Image(models.Model):
DEFAULT = 0
PREVIEW = 1
DOC = 2
GIFT = 3
TYPE_CHOICES = (
(DEFAULT, 'Изображение'),
(PREVIEW, 'Превью'),
(DOC, 'Документ'),
(GIFT, 'Подарок'),
)
TYPE_TO_UPLOAD_PATH = {
DEFAULT: 'checklist_images/',
PREVIEW: 'checklist_images/',
DOC: 'docs/',
GIFT: 'gifts/',
}
image = models.ImageField('Файл изображения', upload_to=image_upload_path)
@ -214,6 +166,20 @@ class Image(models.Model):
return f"{self.get_type_display()}: {getattr(self.image, 'name', '')}"
class Gift(models.Model):
name = models.CharField('Название', max_length=100)
image = models.ImageField('Фото', upload_to=Image.TYPE_TO_UPLOAD_PATH[Image.GIFT], null=True, blank=True)
min_price = models.DecimalField('Минимальная цена в юанях', help_text='от какой суммы доступен подарок', max_digits=10, decimal_places=2, default=0)
available_count = models.PositiveSmallIntegerField('Доступное количество', default=0)
def __str__(self):
return self.name
class Meta:
verbose_name = 'Подарок'
verbose_name_plural = 'Подарки'
def generate_checklist_id():
""" Generate unique id for Checklist """
@ -228,23 +194,57 @@ def generate_checklist_id():
class ChecklistQuerySet(models.QuerySet):
def with_base_related(self):
return self.select_related('manager', 'category', 'payment_method', 'promocode')\
return self.select_related('manager', 'category', 'payment_method',
'promocode', 'price_snapshot', 'gift', 'customer') \
.prefetch_related(Prefetch('images', to_attr='_images'))
def default_ordering(self):
return self.order_by(F('status_updated_at').desc(nulls_last=True))
def annotate_price_rub(self):
yuan_rate = GlobalSettings.load().yuan_rate
return self.annotate(_price_rub=F('price_yuan') * yuan_rate)
return self.annotate(
_yuan_rate=Case(
When(price_snapshot_id__isnull=False, then=F('price_snapshot__yuan_rate')),
default=GlobalSettings.load().full_yuan_rate
),
_price_rub=Ceil(F('_yuan_rate') * F('price_yuan'))
)
def annotate_commission_rub(self):
commission = GlobalSettings.load().commission_rub
return self.annotate(_commission_rub=Case(
When(GreaterThan(F("_price_rub"), 150_000), then=F("_price_rub") * settings.COMMISSION_OVER_150K),
default=commission,
default_commission = GlobalSettings.load().commission_rub
over_150k_commission = F('_price_rub') * settings.COMMISSION_OVER_150K
category_commission_is_zero_and_parent_present = (
(Q(category__commission__isnull=True) | Q(category__commission=0)) & Q(category__parent__isnull=False)
)
return self.annotate(
_category_commission_percent=Case(
When(category_commission_is_zero_and_parent_present, then=F('category__parent__commission')),
default=F('category__commission')
),
_category_commission=F('_category_commission_percent') * F('_price_rub') / 100,
_over_150k_commission=Case(
When(GreaterThan(F("_price_rub"), 150_000), then=over_150k_commission),
default=0,
output_field=DecimalField()
))
),
_commission_rub=Case(
When(price_snapshot_id__isnull=False, then=F('price_snapshot__commission_rub')),
default=Max(default_commission, F('_over_150k_commission'), F('_category_commission')),
output_field=DecimalField()
),
)
class PriceSnapshot(models.Model):
yuan_rate = models.DecimalField('Курс CNY/RUB', max_digits=10, decimal_places=2, default=0)
delivery_price_CN = models.DecimalField('Цена доставки по Китаю', max_digits=10, decimal_places=2, default=0)
delivery_price_CN_RU = models.DecimalField('Цена доставки Китай-РФ', max_digits=10, decimal_places=2, default=0)
commission_rub = models.DecimalField('Комиссия, руб', max_digits=10, decimal_places=2, default=0)
@cleanup.select
@ -265,6 +265,7 @@ class Checklist(models.Model):
COMPLETED = "completed"
PDF_AVAILABLE_STATUSES = (RUSSIA, SPLIT_WAITING, SPLIT_PAID, CDEK, COMPLETED)
CDEK_READY_STATUSES = (RUSSIA, SPLIT_PAID, CDEK)
CHOICES = (
(DRAFT, 'Черновик'),
@ -281,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"
@ -293,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)
@ -314,14 +375,11 @@ class Checklist(models.Model):
# promo
promocode = models.ForeignKey('Promocode', verbose_name='Промокод', on_delete=models.PROTECT, null=True, blank=True)
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)
@ -341,8 +399,10 @@ class Checklist(models.Model):
split_payment_proof = models.ImageField('Подтверждение оплаты сплита',
upload_to=Image.TYPE_TO_UPLOAD_PATH[Image.DOC],
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
@ -350,6 +410,10 @@ class Checklist(models.Model):
cdek_tracking = models.CharField('Трек-номер СДЭК', max_length=100, null=True, blank=True)
cdek_barcode_pdf = models.FileField('Штрих-код СДЭК в PDF', upload_to='docs', null=True, blank=True)
price_snapshot = models.ForeignKey('PriceSnapshot', verbose_name='Сохраненные цены',
related_name='checklist',
on_delete=models.SET_NULL, null=True, blank=True)
objects = ChecklistQuerySet.as_manager()
class Meta:
@ -368,11 +432,17 @@ class Checklist(models.Model):
@property
def price_rub(self) -> int:
# Prefer annotated field
# Prefer annotated field for calculation
if hasattr(self, '_price_rub'):
return self._price_rub
return math.ceil(GlobalSettings.load().yuan_rate * self.price_yuan)
# Get saved prices
if self.price_snapshot_id:
yuan_rate = self.price_snapshot.yuan_rate
else:
yuan_rate = GlobalSettings.load().full_yuan_rate
return math.ceil(yuan_rate * self.price_yuan)
@property
def full_price(self) -> int:
@ -392,22 +462,36 @@ class Checklist(models.Model):
no_comission = promocode.no_comission
if not free_delivery:
price += GlobalSettings.load().delivery_price_CN + self.delivery_price_CN_RU
price += self.delivery_price_CN + self.delivery_price_CN_RU
if not no_comission:
price += self.commission_rub
# Add commission of bottom-most category
if self.category:
category = self.category.get_ancestors(ascending=True, include_self=True).first()
category_commission = getattr(category, 'commission', 0)
price += category_commission * self.price_rub / 100
return max(0, math.ceil(price))
@property
def yuan_rate(self) -> Decimal:
# Get saved value if exists
if self.price_snapshot_id:
return self.price_snapshot.yuan_rate
else:
return GlobalSettings.load().full_yuan_rate
@property
def delivery_price_CN(self) -> Decimal:
# Get saved value if exists
if self.price_snapshot_id:
return self.price_snapshot.delivery_price_CN
else:
return GlobalSettings.load().delivery_price_CN
@property
def delivery_price_CN_RU(self) -> Decimal:
return getattr(self.category, 'delivery_price_CN_RU', Decimal(0))
# Get saved value if exists
if self.price_snapshot_id:
return self.price_snapshot.delivery_price_CN_RU
else:
return getattr(self.category, 'delivery_price', Decimal(0))
@property
def commission_rub(self) -> Decimal:
@ -415,9 +499,22 @@ class Checklist(models.Model):
if hasattr(self, '_commission_rub'):
return self._commission_rub
return (self.price_rub * Decimal(settings.COMMISSION_OVER_150K)
if self.price_rub > 150_000
else GlobalSettings.load().commission_rub)
# Prefer saved value
if self.price_snapshot_id:
return self.price_snapshot.commission_rub
# Default commission
commission = GlobalSettings.load().commission_rub
if self.price_rub > 150_000:
commission = max(commission, self.price_rub * Decimal(settings.COMMISSION_OVER_150K))
if self.category_id:
# Add commission of bottom-most category
category_commission = getattr(self.category, 'commission_price', 0)
commission = max(commission, category_commission * self.price_rub / 100)
return commission
@property
def preview_image(self):
@ -467,23 +564,107 @@ class Checklist(models.Model):
self.images.add(image_obj)
def save_prices(self):
# Temporarily remove snapshot from object
self.price_snapshot = None
snapshot, _ = PriceSnapshot.objects.get_or_create(
checklist__id=self.id,
defaults={
'yuan_rate': self.yuan_rate,
'delivery_price_CN': self.delivery_price_CN,
'delivery_price_CN_RU': self.delivery_price_CN_RU,
'commission_rub': self.commission_rub,
}
)
# 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)
self.cdek_barcode_pdf = None
# Try to get CDEK barcode PDF
if not self.cdek_barcode_pdf and self.cdek_tracking and self.status in Checklist.Status.PDF_AVAILABLE_STATUSES:
from store.views import CDEKAPI
pdf_file = CDEKAPI.client.get_barcode_file(self.cdek_tracking)
if pdf_file:
self.cdek_barcode_pdf.save(f'{self.id}_barcode.pdf', 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
old_gift = getattr(old_obj, 'gift', None)
if self.gift != old_gift:
# Decrement new gift
if self.gift:
self.gift.available_count = max(0, self.gift.available_count - 1)
self.gift.save()
# Increment new gift
if old_gift:
old_gift.available_count = max(0, old_gift.available_count + 1)
old_gift.save()
# Save price details to snapshot
if self.price_snapshot_id:
# Status updated from other statuses back to DRAFT
if self.status == Checklist.Status.DRAFT:
self.price_snapshot.delete()
self.price_snapshot = None
elif self.status != Checklist.Status.DRAFT:
self.save_prices()
super().save(*args, **kwargs)

View File

@ -1,22 +1,12 @@
from drf_extra_fields.fields import Base64ImageField
from rest_framework import serializers
from store.models import User, Checklist, GlobalSettings, Category, PaymentMethod, Promocode, Image
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()
@ -63,50 +53,61 @@ class CategoryFullSerializer(CategorySerializer):
fields = CategorySerializer.Meta.fields + ('children',)
class GiftSerializer(serializers.ModelSerializer):
image = Base64ImageField(required=False, allow_null=True)
class Meta:
model = Gift
fields = ('id', 'name', 'image', 'min_price', 'available_count')
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)
currency = serializers.SerializerMethodField('get_yuan_rate')
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.SerializerMethodField('get_delivery_price_CN', read_only=True)
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.SerializerMethodField('get_commission', read_only=True)
gift = get_primary_key_related_model(GiftSerializer, required=False, allow_null=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)
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)
receivername = serializers.CharField(source='receiver_name', required=False, allow_null=True)
reveiverphone = serializers.CharField(source='receiver_phone', 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)
split = serializers.BooleanField(source='is_split_payment', required=False)
paymenttype = serializers.SlugRelatedField(source='payment_method', slug_field='slug',
full_price = serializers.IntegerField(read_only=True)
real_price = serializers.DecimalField(required=False, allow_null=True, max_digits=10, decimal_places=2)
commission_rub = serializers.DecimalField(read_only=True, max_digits=10, decimal_places=2)
customer = get_primary_key_related_model(UserSerializer, required=False, allow_null=True)
receiver_name = serializers.CharField(required=False, allow_null=True)
receiver_phone = serializers.CharField(required=False, allow_null=True)
is_split_payment = serializers.BooleanField(required=False)
payment_method = serializers.SlugRelatedField(slug_field='slug',
queryset=PaymentMethod.objects.all(),
required=False, allow_null=True)
paymentprovement = Base64ImageField(source='payment_proof', required=False, allow_null=True)
payment_proof = Base64ImageField(required=False, allow_null=True)
split_payment_proof = Base64ImageField(required=False, allow_null=True)
checkphoto = Base64ImageField(source='receipt', required=False, allow_null=True)
trackid = serializers.CharField(source='poizon_tracking', required=False, allow_null=True)
receipt = Base64ImageField(required=False, allow_null=True)
poizon_tracking = serializers.CharField(required=False, allow_null=True)
cdek_tracking = serializers.CharField(required=False, allow_null=True)
delivery = serializers.ChoiceField(choices=Checklist.DeliveryType.CHOICES, required=False, allow_null=True)
delivery_display = serializers.CharField(source='get_delivery_display', read_only=True)
startDate = serializers.DateTimeField(source='created_at', read_only=True)
currentDate = serializers.DateTimeField(source='status_updated_at', read_only=True)
created_at = serializers.DateTimeField(read_only=True)
status_updated_at = serializers.DateTimeField(read_only=True)
def _collect_images_by_fields(self, validated_data):
images = {}
@ -128,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
@ -151,61 +158,91 @@ class ChecklistSerializer(serializers.ModelSerializer):
return instance
@staticmethod
def get_yuan_rate(obj: Checklist):
return GlobalSettings.load().yuan_rate
@staticmethod
def get_image(obj: Checklist):
return obj.images.all()
@staticmethod
def get_delivery_price_CN(obj: Checklist):
return GlobalSettings.load().delivery_price_CN
@staticmethod
def get_commission(obj: Checklist):
return GlobalSettings.load().commission_rub
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',
'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', '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 ClientChecklistSerializerMixin:
def validate(self, attrs):
gift = attrs.get('gift')
if gift is not None:
if self.instance.price_yuan < gift.min_price:
raise CRMException("Can't add gift: price of order < min_price of gift")
if gift.available_count == 0:
raise CRMException("Gift is not available")
return attrs
class ClientCreateChecklistSerializer(ClientChecklistSerializerMixin, ChecklistSerializer):
status = serializers.SerializerMethodField()
class Meta:
model = ChecklistSerializer.Meta.model
fields = ChecklistSerializer.Meta.fields
read_only_fields = tuple(set(ChecklistSerializer.Meta.fields) -
{'paymentprovement', 'paymenttype',
'buyername', 'buyerphone',
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',
'recievername', 'recieverphone', 'tg'})
'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='yuan_rate', max_digits=10, decimal_places=2)
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)
chinadelivery = serializers.DecimalField(source='delivery_price_CN', max_digits=10, decimal_places=2)
commission = serializers.DecimalField(source='commission_rub', max_digits=10, decimal_places=2)
pickup = serializers.CharField(source='pickup_address')
class Meta:
model = GlobalSettings
fields = ('currency', 'commission', 'chinadelivery', 'pickup', 'time_to_buy')
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):

68
store/tasks.py Normal file
View File

@ -0,0 +1,68 @@
from celery import shared_task
from django.db.models import Q
from django.utils import timezone
from external_api.cdek import client as cdek_client, CDEKStatus
from external_api.currency import client as CurrencyAPIClient
from .models import Checklist, GlobalSettings
@shared_task
def check_cdek_status(order_id):
obj = Checklist.objects.filter(id=order_id).first()
if obj is None or obj.cdek_tracking is None or obj.status == Checklist.Status.COMPLETED:
return
# Get CDEK statuses
statuses = cdek_client.get_order_statuses(obj.cdek_tracking)
if not statuses:
return
old_status = obj.status
new_status = obj.status
if CDEKStatus.DELIVERED in statuses:
new_status = Checklist.Status.COMPLETED
elif CDEKStatus.READY_FOR_SHIPMENT_IN_SENDER_CITY in statuses:
new_status = Checklist.Status.CDEK
# Update status
if old_status != new_status:
print(f'Order [{obj.id}] status: {old_status} -> {new_status}')
obj.status = new_status
obj.save()
return f"{old_status} —> {new_status}"
@shared_task
def schedule_cdek_status_update():
qs = Checklist.objects.filter(
Q(cdek_tracking__isnull=False) & Q(status__in=Checklist.Status.CDEK_READY_STATUSES)
)
order_count = len(qs)
print(f'Scheduled to update {order_count} orders')
# Spawn a sub-task for every order
for obj in qs:
check_cdek_status.delay(order_id=obj.id)
return order_count
@shared_task(autoretry_for=(Exception,), retry_backoff=True, retry_kwargs={'max_retries': 5})
def update_yuan_rate():
# Get fresh rate from API
rate = CurrencyAPIClient.get_cny_rate()
if not rate:
raise Exception("Failed to get yuan rate")
print(f"Fetched new yuan rate: {rate}")
# Save rate in DB for future usage
settings = GlobalSettings.load()
settings.yuan_rate = rate
settings.yuan_rate_last_updated = timezone.now()
settings.save()
return rate

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