Compare commits
No commits in common. "dev" and "master" have entirely different histories.
|
|
@ -1,27 +0,0 @@
|
|||
APP_HOME="/var/www/poizonstore-stage"
|
||||
SITE_URL="https://stage.crm-poizonstore.ru"
|
||||
|
||||
# === Keys ===
|
||||
# Django
|
||||
SECRET_KEY=""
|
||||
ALLOWED_HOSTS=".crm-poizonstore.ru,127.0.0.1,localhost,45.84.227.72"
|
||||
|
||||
# Telegram bot
|
||||
TG_BOT_BIN="/var/www/poizonstore-stage/env/bin/python run_tg_bot.py"
|
||||
TG_BOT_TOKEN=""
|
||||
|
||||
# External API settings
|
||||
CDEK_CLIENT_ID=""
|
||||
CDEK_CLIENT_SECRET=""
|
||||
CDEK_WEBHOOK_URL_SALT=""
|
||||
POIZON_TOKEN=""
|
||||
CURRENCY_GETGEOIP_API_KEY=""
|
||||
|
||||
# Celery & Flower
|
||||
FLOWER_BASIC_AUTH="login:pwd"
|
||||
|
||||
# Logging
|
||||
SENTRY_DSN=""
|
||||
|
||||
# production/stage
|
||||
SENTRY_ENVIRONMENT=""
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -7,7 +7,6 @@ media/**/*
|
|||
assets/**/*
|
||||
|
||||
env
|
||||
*.env
|
||||
.idea
|
||||
.DS_Store
|
||||
db.sqlite3
|
||||
|
|
@ -5,7 +5,7 @@ CELERYD_NODES="w1"
|
|||
#CELERYD_NODES="w1 w2 w3"
|
||||
|
||||
# Absolute or relative path to the 'celery' command:
|
||||
CELERY_BIN="/var/www/poizonstore-stage/env/bin/celery"
|
||||
CELERY_BIN="/var/www/poizonstore/env/bin/celery"
|
||||
|
||||
# App instance to use
|
||||
CELERY_APP="poizonstore"
|
||||
|
|
@ -19,10 +19,10 @@ CELERYD_OPTS=""
|
|||
# - %n will be replaced with the first part of the nodename.
|
||||
# - %I will be replaced with the current child process index
|
||||
# and is important when using the prefork pool to avoid race conditions.
|
||||
CELERYD_PID_FILE="/var/run/celery-stage/%n.pid"
|
||||
CELERYD_LOG_FILE="/var/log/celery-stage/%n%I.log"
|
||||
CELERYD_PID_FILE="/var/run/celery/%n.pid"
|
||||
CELERYD_LOG_FILE="/var/log/celery/%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"
|
||||
CELERYBEAT_PID_FILE="/var/run/celery/beat.pid"
|
||||
CELERYBEAT_LOG_FILE="/var/log/celery/beat.log"
|
||||
|
|
@ -7,9 +7,8 @@ Requires=redis.service
|
|||
Type=forking
|
||||
User=poizon
|
||||
Group=poizon
|
||||
EnvironmentFile=/etc/default/celery-stage
|
||||
EnvironmentFile=/var/www/poizonstore-stage/.env
|
||||
WorkingDirectory=/var/www/poizonstore-stage
|
||||
EnvironmentFile=/etc/default/celery
|
||||
WorkingDirectory=/var/www/poizonstore
|
||||
RuntimeDirectory=celery
|
||||
ExecStart=/bin/sh -c '${CELERY_BIN} -A $CELERY_APP multi start $CELERYD_NODES \
|
||||
--pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} \
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@ After=network.target
|
|||
Type=simple
|
||||
User=poizon
|
||||
Group=poizon
|
||||
EnvironmentFile=/etc/default/celery-stage
|
||||
EnvironmentFile=/var/www/poizonstore-stage/.env
|
||||
WorkingDirectory=/var/www/poizonstore-stage
|
||||
EnvironmentFile=/etc/default/celery
|
||||
WorkingDirectory=/var/www/poizonstore
|
||||
RuntimeDirectory=celery
|
||||
ExecStart=/bin/sh -c '${CELERY_BIN} -A ${CELERY_APP} beat \
|
||||
--pidfile=${CELERYBEAT_PID_FILE} \
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
[Unit]
|
||||
Description=Flower - Celery monitoring tool
|
||||
After=network.target
|
||||
Requires=celery-stage.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=poizon
|
||||
Group=poizon
|
||||
EnvironmentFile=/etc/default/celery-stage
|
||||
EnvironmentFile=/var/www/poizonstore-stage/.env
|
||||
WorkingDirectory=/var/www/poizonstore-stage
|
||||
RuntimeDirectory=celery
|
||||
|
||||
ExecStart=/bin/sh -c '${CELERY_BIN} -A ${CELERY_APP} --workdir=${APP_HOME} flower --port=5556 --url_prefix=/flower'
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -1,26 +1,21 @@
|
|||
upstream django_stage {
|
||||
server 127.0.0.1:8002;
|
||||
upstream django {
|
||||
server 127.0.0.1:8001;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name stage.crm-poizonstore.ru;
|
||||
server_name crm-poizonstore.ru;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
set $APP_HOME /var/www/poizonstore-stage;
|
||||
set $APP_HOME /var/www/poizonstore;
|
||||
|
||||
listen 443 ssl;
|
||||
server_name stage.crm-poizonstore.ru;
|
||||
server_name crm-poizonstore.ru;
|
||||
charset utf-8;
|
||||
|
||||
# === Add here SSL config ===
|
||||
ssl_certificate /etc/letsencrypt/live/crm-poizonstore.ru/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/crm-poizonstore.ru/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
|
||||
|
|
@ -37,7 +32,7 @@ server {
|
|||
}
|
||||
|
||||
location / {
|
||||
uwsgi_pass django_stage;
|
||||
uwsgi_pass django;
|
||||
uwsgi_read_timeout 300;
|
||||
keepalive_timeout 70;
|
||||
proxy_read_timeout 1200s;
|
||||
|
|
@ -47,6 +42,6 @@ server {
|
|||
}
|
||||
|
||||
location /flower/ {
|
||||
proxy_pass http://localhost:5556/flower/;
|
||||
proxy_pass http://localhost:5555/flower/;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
[Unit]
|
||||
Description=PoizonStore Telegram bot service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=poizon
|
||||
Group=poizon
|
||||
EnvironmentFile=/var/www/poizonstore-stage/.env
|
||||
WorkingDirectory=/var/www/poizonstore-stage
|
||||
ExecStart=/bin/sh -c '${TG_BOT_BIN}'
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
[uwsgi]
|
||||
project = poizonstore-stage
|
||||
project = poizonstore
|
||||
|
||||
uid = poizon
|
||||
gid = poizon
|
||||
|
|
@ -8,7 +8,7 @@ gid = poizon
|
|||
# the base directory (full path)
|
||||
chdir = /var/www/%(project)/
|
||||
# Django's wsgi file
|
||||
module = poizonstore:application
|
||||
module = %(project):application
|
||||
# the virtualenv (full path)
|
||||
virtualenv = /var/www/%(project)/env
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ master = true
|
|||
processes = 10
|
||||
# the socket (use the full path to be safe
|
||||
#socket = /var/www/%(project)/mysite.sock
|
||||
socket = :8002
|
||||
socket = :8001
|
||||
wsgi-file = /var/www/%(project)/poizonstore/wsgi.py
|
||||
pidfile = /tmp/uwsgi-%(project).pid
|
||||
stats = /tmp/uwsgi.stats.sock
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from .models import User
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'email', 'role', 'full_name', 'phone', 'telegram', 'balance')
|
||||
list_display_links = list_display
|
||||
|
||||
def get_queryset(self, request):
|
||||
return User.objects.with_base_related()
|
||||
|
||||
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'account'
|
||||
|
||||
def ready(self):
|
||||
import account.signals
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
from rest_framework import exceptions, status
|
||||
|
||||
|
||||
class AuthError(exceptions.APIException):
|
||||
status_code = status.HTTP_401_UNAUTHORIZED
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
# 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
|
||||
import bonus_program.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=bonus_program.models.generate_referral_code, editable=False, max_length=9)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
# 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')},
|
||||
),
|
||||
]
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# 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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
# 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='Телефон'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# 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='Эл. почта'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# Generated by Django 4.2.2 on 2024-04-05 21:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('account', '0005_alter_user_email'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='tg_user_id',
|
||||
field=models.BigIntegerField(blank=True, null=True, unique=True, verbose_name='id пользователя в Telegram'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
# 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',
|
||||
),
|
||||
]
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
# Generated by Django 4.2.2 on 2024-04-07 17:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import bonus_program.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=bonus_program.models.generate_referral_code, editable=False, max_length=10),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# 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='Комментарий'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
# 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),
|
||||
]
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
# 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,
|
||||
),
|
||||
]
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# 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='Баланс, руб'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
# 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='Тип транзакции'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# 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='Роль'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# Generated by Django 4.2.2 on 2024-05-20 17:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('account', '0016_alter_user_role'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='bonusprogramtransaction',
|
||||
name='type',
|
||||
field=models.PositiveSmallIntegerField(choices=[(0, 'Другое начисление'), (1, 'Бонус за регистрацию'), (2, 'Бонус за покупку'), (3, 'Бонус за первую покупку приглашенного'), (4, 'Бонус за первую покупку'), (10, 'Другое списание'), (11, 'Списание бонусов за заказ')], verbose_name='Тип транзакции'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
# Generated by Django 4.2.2 on 2024-05-20 21:01
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('account', '0017_alter_bonusprogramtransaction_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='bonusprogramtransaction',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bonus_transactions', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь транзакции'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
# Generated by Django 4.2.13 on 2024-05-26 21:28
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('account', '0018_alter_bonusprogramtransaction_user'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='BonusProgramTransaction',
|
||||
),
|
||||
]
|
||||
|
|
@ -1 +0,0 @@
|
|||
from .user import User, UserManager, UserQuerySet, ReferralRelationship
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
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, Count
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
from phonenumber_field.phonenumber import PhoneNumber
|
||||
|
||||
from bonus_program.models import BonusProgramMixin, BonusProgram
|
||||
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) -> bool:
|
||||
# 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(BonusProgram.add_signup_bonus)(user)
|
||||
|
||||
# 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}")
|
||||
|
||||
return freshly_created
|
||||
|
||||
|
||||
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 invited_users_with_orders(self):
|
||||
return (self.invited_users
|
||||
.annotate(_orders_count=Count('customer_orders'))
|
||||
.filter(_orders_count__gt=0))
|
||||
|
||||
@property
|
||||
def completed_orders_count(self):
|
||||
from store.models import Checklist
|
||||
return Checklist.objects.filter(customer_id=self.id, status=Checklist.Status.COMPLETED).count()
|
||||
|
||||
@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")
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
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 bonus_program.serializers import BonusProgramTransactionSerializer
|
||||
from .models import User
|
||||
from bonus_program.models import 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')
|
||||
invited_with_orders_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ('id', 'email', 'phone', 'role', 'name', 'lastname', 'surname',
|
||||
'balance', 'referral_code', 'is_draft_user', 'invited_with_orders_count')
|
||||
|
||||
def get_invited_with_orders_count(self, obj):
|
||||
return obj.invited_users_with_orders.count()
|
||||
|
||||
|
||||
class UserSimpleSerializer(UserSerializer):
|
||||
class Meta:
|
||||
model = UserSerializer.Meta.model
|
||||
fields = ('id', 'email', 'phone', 'role', 'name', 'lastname', 'surname')
|
||||
|
||||
|
||||
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
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import logging
|
||||
|
||||
from django.db.models.signals import post_save, post_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from account.models import User, ReferralRelationship
|
||||
from bonus_program.models import 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()
|
||||
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
from django.urls import path, include
|
||||
|
||||
from account import views
|
||||
from poizonstore.utils import get_drf_router
|
||||
|
||||
router = get_drf_router()
|
||||
router.register("users", views.UserViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('auth/', include('djoser.urls.authtoken')),
|
||||
path('auth/telegram/', views.TelegramLoginForm.as_view()),
|
||||
]
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
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.'
|
||||
)
|
||||
150
account/views.py
150
account/views.py
|
|
@ -1,150 +0,0 @@
|
|||
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, UserBalanceUpdateSerializer, TelegramCallbackSerializer
|
||||
from bonus_program.serializers import BonusProgramTransactionSerializer
|
||||
from tg_bot.handlers.start import request_phone_sync
|
||||
from tg_bot.messages import TGCoreMessage
|
||||
|
||||
|
||||
class UserViewSet(djoser_views.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.TG_AUTH_SHARE_PHONE)
|
||||
|
||||
token = login_user(request, user)
|
||||
return Response({"auth_token": token.key})
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from bonus_program.models import BonusProgramTransaction, BonusProgramLevel
|
||||
|
||||
|
||||
# Register your models here.
|
||||
@admin.register(BonusProgramTransaction)
|
||||
class BonusProgramTransactionAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'type', 'user', 'date', 'amount', 'comment', 'order', 'was_cancelled')
|
||||
|
||||
def get_queryset(self, request):
|
||||
return BonusProgramTransaction.objects.with_base_related()
|
||||
|
||||
def delete_queryset(self, request, queryset):
|
||||
for obj in queryset:
|
||||
obj.cancel()
|
||||
|
||||
|
||||
@admin.register(BonusProgramLevel)
|
||||
class BonusProgramLevelAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'name', 'slug', 'orders_count', 'amount_default_purchase')
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BonusProgramConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'bonus_program'
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
# Generated by Django 4.2.13 on 2024-05-25 14:03
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('store', '0005_delete_globalsettings'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
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='Количество, руб')),
|
||||
('comment', models.CharField(blank=True, max_length=200, null=True, verbose_name='Комментарий')),
|
||||
('was_cancelled', models.BooleanField(default=False, editable=False, verbose_name='Была отменена')),
|
||||
('order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.checklist', verbose_name='Связанный заказ')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bonus_transactions', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь транзакции')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'История баланса',
|
||||
'verbose_name_plural': 'История баланса',
|
||||
'ordering': ['-date'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
# Generated by Django 4.2.13 on 2024-05-26 21:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bonus_program', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BonusProgramLevel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('slug', models.SlugField(unique=True, verbose_name='Идентификатор')),
|
||||
('name', models.CharField(max_length=30, verbose_name='Название')),
|
||||
('orders_count', models.PositiveSmallIntegerField(unique=True, verbose_name='Минимальное количество заказов')),
|
||||
('amount_default_purchase', models.PositiveSmallIntegerField(verbose_name='Бонус за обычную покупку')),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -1,325 +0,0 @@
|
|||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
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 django.utils.functional import cached_property
|
||||
|
||||
from core.models import BonusProgramConfig
|
||||
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
|
||||
|
||||
CHOICES = (
|
||||
(OTHER_DEPOSIT, 'Другое начисление'),
|
||||
(SIGNUP, 'Бонус за регистрацию'),
|
||||
(DEFAULT_PURCHASE, 'Бонус за покупку'),
|
||||
(FOR_INVITER, 'Бонус за первую покупку приглашенного'),
|
||||
(INVITED_FIRST_PURCHASE, 'Бонус за первую покупку'),
|
||||
|
||||
(OTHER_WITHDRAWAL, 'Другое списание'),
|
||||
(SPENT_PURCHASE, 'Списание бонусов за заказ'),
|
||||
)
|
||||
|
||||
LOG_NAMES = {
|
||||
OTHER_DEPOSIT: 'OTHER_DEPOSIT',
|
||||
SIGNUP: 'SIGNUP',
|
||||
DEFAULT_PURCHASE: 'DEFAULT_PURCHASE',
|
||||
FOR_INVITER: 'FOR_INVITER',
|
||||
INVITED_FIRST_PURCHASE: 'INVITED_FIRST_PURCHASE',
|
||||
|
||||
OTHER_WITHDRAWAL: 'OTHER_WITHDRAWAL',
|
||||
SPENT_PURCHASE: 'SPENT_PURCHASE',
|
||||
}
|
||||
|
||||
ONE_TIME_TYPES = {SIGNUP, FOR_INVITER, INVITED_FIRST_PURCHASE}
|
||||
ORDER_TYPES = {DEFAULT_PURCHASE, FOR_INVITER, INVITED_FIRST_PURCHASE, SPENT_PURCHASE}
|
||||
|
||||
|
||||
class BonusProgramTransactionQuerySet(models.QuerySet):
|
||||
def with_base_related(self):
|
||||
return self.select_related('order', 'user')
|
||||
|
||||
def cancel(self):
|
||||
for t in self:
|
||||
t.cancel()
|
||||
|
||||
|
||||
class BonusProgramTransaction(models.Model):
|
||||
""" Represents the history of all bonus program transactions """
|
||||
|
||||
type = models.PositiveSmallIntegerField('Тип транзакции', choices=BonusType.CHOICES)
|
||||
user = models.ForeignKey('account.User', verbose_name='Пользователь транзакции', on_delete=models.CASCADE, related_name='bonus_transactions')
|
||||
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:
|
||||
comment = self.comment or "—"
|
||||
msg = TGBonusMessage.OTHER_DEPOSIT.format(amount=self.amount, comment=comment)
|
||||
|
||||
case BonusType.OTHER_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(self):
|
||||
# Skip transactions that refers to cancelled ones
|
||||
if self.was_cancelled:
|
||||
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(
|
||||
user_id=self.user_id,
|
||||
type=bonus_type,
|
||||
amount=self.amount * -1,
|
||||
comment=comment,
|
||||
order=self.order,
|
||||
was_cancelled=True
|
||||
)
|
||||
transaction.save()
|
||||
|
||||
self.was_cancelled = True
|
||||
self.save()
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
# Don't delete transaction, cancel it instead
|
||||
self.cancel()
|
||||
|
||||
def clean(self):
|
||||
# No underflow or dummy transactions allowed. Fail loudly
|
||||
if self.amount == 0 or (self.user.balance + self.amount) < 0:
|
||||
raise ValidationError("No underflow or dummy transactions allowed")
|
||||
|
||||
# Check for uniqueness for given user
|
||||
qs = self.user.bonus_history.filter(type=self.type)
|
||||
if self.id:
|
||||
qs = qs.exclude(id=self.id)
|
||||
|
||||
bonus_name = BonusType.LOG_NAMES.get(self.type, self.type)
|
||||
|
||||
if self.type in BonusType.ONE_TIME_TYPES:
|
||||
if qs.exists():
|
||||
raise ValidationError(f"User {self.user_id} already got {bonus_name} one-time bonus")
|
||||
|
||||
if self.type in BonusType.ORDER_TYPES:
|
||||
# Check that order is defined
|
||||
if self.order_id is None:
|
||||
raise ValidationError("Order is required for that type")
|
||||
|
||||
# Check for duplicates for the same order
|
||||
already_exists = qs.filter(order_id=self.order_id).exists()
|
||||
if already_exists:
|
||||
raise ValidationError(f"User {self.user_id} already got {bonus_name} bonus for order #{self.order_id}")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
try:
|
||||
self.full_clean()
|
||||
except Exception as e:
|
||||
# Catch all validation errors here and log it
|
||||
logger.error(f"Error during bonus saving: {e}")
|
||||
return
|
||||
|
||||
self.user.recalculate_balance()
|
||||
|
||||
if self.id is None:
|
||||
self._notify_user_about_new_transaction()
|
||||
|
||||
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):
|
||||
"""BonusProgram fields for User model"""
|
||||
|
||||
balance = models.PositiveSmallIntegerField('Баланс, руб', default=0, editable=False)
|
||||
referral_code = models.CharField(max_length=settings.REFERRAL_CODE_LENGTH, default=generate_referral_code,
|
||||
editable=False)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@cached_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, fail silently
|
||||
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()
|
||||
self.refresh_from_db(fields=['balance'])
|
||||
|
||||
def recalculate_balance(self):
|
||||
total_balance = self.bonus_history \
|
||||
.aggregate(total_balance=Sum('amount'))['total_balance'] or 0
|
||||
|
||||
self.balance = max(0, total_balance)
|
||||
self.save(update_fields=['balance'])
|
||||
|
||||
|
||||
class BonusProgram:
|
||||
@staticmethod
|
||||
def spend_bonuses(order: 'Checklist'):
|
||||
# Check if data is sufficient
|
||||
if order is None or order.customer_id is None:
|
||||
return
|
||||
|
||||
# Always use fresh balance
|
||||
order.customer.recalculate_balance()
|
||||
|
||||
# Spend full_price bonuses or nothing
|
||||
to_spend = min(order.customer.balance, order.full_price)
|
||||
order.customer.update_balance(-to_spend, BonusType.SPENT_PURCHASE, order=order)
|
||||
|
||||
@staticmethod
|
||||
def add_signup_bonus(user: 'User'):
|
||||
bonus_type = BonusType.SIGNUP
|
||||
amount = BonusProgramConfig.load().amount_signup
|
||||
|
||||
user.update_balance(amount, bonus_type)
|
||||
|
||||
@staticmethod
|
||||
def add_order_bonus(order: Checklist):
|
||||
bonus_type = BonusType.DEFAULT_PURCHASE
|
||||
|
||||
# Check if data is sufficient
|
||||
if order is None or order.customer_id is None:
|
||||
return
|
||||
|
||||
# Check if eligible
|
||||
if order.status != settings.BONUS_ELIGIBILITY_STATUS:
|
||||
return
|
||||
|
||||
level = BonusProgramLevel.objects.level_for_order_count(order.customer.completed_orders_count)
|
||||
amount = getattr(level, 'amount_default_purchase', 0)
|
||||
|
||||
# Add bonuses
|
||||
order.customer.update_balance(amount, bonus_type, order=order)
|
||||
|
||||
@staticmethod
|
||||
def add_referral_bonus(order: Checklist, for_inviter: bool):
|
||||
amount = BonusProgramConfig.load().amount_referral
|
||||
|
||||
# Check if data is sufficient
|
||||
if order.customer_id is None or order.customer.inviter is None:
|
||||
return
|
||||
|
||||
# Check if eligible
|
||||
# Bonus is only for first purchase and only for orders that reached the COMPLETED status
|
||||
if order.status != settings.BONUS_ELIGIBILITY_STATUS or order.customer.customer_orders.count() != 1:
|
||||
return
|
||||
|
||||
user = order.customer.inviter if for_inviter else order.customer
|
||||
bonus_type = BonusType.FOR_INVITER if for_inviter else BonusType.INVITED_FIRST_PURCHASE
|
||||
|
||||
# Add bonuses
|
||||
user.update_balance(amount, bonus_type, order=order)
|
||||
|
||||
|
||||
class BonusProgramLevelQuerySet(models.QuerySet):
|
||||
def level_for_order_count(self, count):
|
||||
return self.filter(orders_count__lt=count).order_by('-orders_count').first()
|
||||
|
||||
|
||||
class BonusProgramLevel(models.Model):
|
||||
slug = models.SlugField('Идентификатор', unique=True)
|
||||
name = models.CharField('Название', max_length=30)
|
||||
orders_count = models.PositiveSmallIntegerField('Минимальное количество заказов', unique=True)
|
||||
amount_default_purchase = models.PositiveSmallIntegerField('Бонус за обычную покупку')
|
||||
|
||||
objects = BonusProgramLevelQuerySet.as_manager()
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from bonus_program.models import BonusProgramTransaction
|
||||
|
||||
|
||||
class BonusProgramTransactionSerializer(serializers.ModelSerializer):
|
||||
order_id = serializers.StringRelatedField(source='order.id', allow_null=True)
|
||||
type = serializers.CharField(source='get_type_display')
|
||||
|
||||
class Meta:
|
||||
model = BonusProgramTransaction
|
||||
fields = ('id', 'type', 'date', 'amount', 'order_id', 'comment', 'was_cancelled')
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from .models import GlobalSettings, BonusProgramConfig
|
||||
|
||||
|
||||
@admin.register(GlobalSettings)
|
||||
class GlobalSettingsAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(BonusProgramConfig)
|
||||
class BonusProgramConfigAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'core'
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
# Generated by Django 4.2.13 on 2024-05-23 22:08
|
||||
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BonusProgramConfig',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('amount_signup', models.PositiveSmallIntegerField(default=150, verbose_name='Бонус за регистрацию')),
|
||||
('amount_default_purchase', models.PositiveSmallIntegerField(default=50, verbose_name='Бонус за обычную покупку')),
|
||||
('amount_referral', models.PositiveSmallIntegerField(default=500, verbose_name='Реферальный бонус')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Настройки бонусной программы',
|
||||
'verbose_name_plural': 'Настройки бонусной программы',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GlobalSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('yuan_rate', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Курс CNY/RUB')),
|
||||
('yuan_rate_last_updated', models.DateTimeField(default=None, null=True, verbose_name='Дата обновления курса CNY/RUB')),
|
||||
('yuan_rate_commission', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Наценка на курс юаня, руб')),
|
||||
('delivery_price_CN', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена доставки по Китаю')),
|
||||
('commission_rub', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Комиссия, руб')),
|
||||
('pickup_address', models.CharField(blank=True, max_length=200, null=True, verbose_name='Адрес пункта самовывоза')),
|
||||
('time_to_buy', models.DurationField(default=datetime.timedelta(seconds=10800), help_text="Через N времени заказ переходит из статуса 'Новый' в 'Черновик'", verbose_name='Время на покупку')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Глобальные настройки',
|
||||
'verbose_name_plural': 'Глобальные настройки',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from core.utils import CachedSingleton
|
||||
|
||||
|
||||
@CachedSingleton("global_settings")
|
||||
class GlobalSettings(models.Model):
|
||||
yuan_rate = models.DecimalField('Курс CNY/RUB', max_digits=10, decimal_places=2, default=0)
|
||||
yuan_rate_last_updated = models.DateTimeField('Дата обновления курса CNY/RUB', null=True, default=None)
|
||||
yuan_rate_commission = models.DecimalField('Наценка на курс юаня, руб', max_digits=10, decimal_places=2, default=0)
|
||||
delivery_price_CN = models.DecimalField('Цена доставки по Китаю', max_digits=10, decimal_places=2, default=0)
|
||||
commission_rub = models.DecimalField('Комиссия, руб', max_digits=10, decimal_places=2, default=0)
|
||||
pickup_address = models.CharField('Адрес пункта самовывоза', max_length=200, blank=True, null=True)
|
||||
time_to_buy = models.DurationField('Время на покупку',
|
||||
help_text="Через N времени заказ переходит из статуса 'Новый' в 'Черновик'",
|
||||
default=timedelta(hours=3))
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Глобальные настройки'
|
||||
verbose_name_plural = 'Глобальные настройки'
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'GlobalSettings <{self.id}>'
|
||||
|
||||
@property
|
||||
def full_yuan_rate(self):
|
||||
return self.yuan_rate + self.yuan_rate_commission
|
||||
|
||||
|
||||
DEFAULT_CONFIG = settings.BONUS_PROGRAM_DEFAULT_CONFIG
|
||||
|
||||
|
||||
@CachedSingleton("bonus_config")
|
||||
class BonusProgramConfig(models.Model):
|
||||
amount_signup = models.PositiveSmallIntegerField(
|
||||
'Бонус за регистрацию', default=DEFAULT_CONFIG['amounts']['signup'])
|
||||
amount_referral = models.PositiveSmallIntegerField(
|
||||
'Реферальный бонус', default=DEFAULT_CONFIG['amounts']['referral'])
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Настройки бонусной программы'
|
||||
verbose_name_plural = 'Настройки бонусной программы'
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from core.models import GlobalSettings
|
||||
from poizonstore.utils import PriceField
|
||||
|
||||
|
||||
class GlobalSettingsSerializer(serializers.ModelSerializer):
|
||||
yuan_rate = PriceField(source='full_yuan_rate', read_only=True)
|
||||
yuan_rate_commission = PriceField()
|
||||
delivery_price_CN = PriceField()
|
||||
commission_rub = PriceField()
|
||||
pickup_address = serializers.CharField()
|
||||
|
||||
class Meta:
|
||||
model = GlobalSettings
|
||||
fields = ('yuan_rate', 'yuan_rate_last_updated', 'yuan_rate_commission',
|
||||
'commission_rub', 'delivery_price_CN', 'pickup_address', 'time_to_buy')
|
||||
read_only_fields = ('yuan_rate_last_updated',)
|
||||
|
||||
|
||||
class AnonymousGlobalSettingsSerializer(GlobalSettingsSerializer):
|
||||
class Meta:
|
||||
model = GlobalSettingsSerializer.Meta.model
|
||||
fields = tuple(set(GlobalSettingsSerializer.Meta.fields) - {'yuan_rate_commission', 'yuan_rate_last_updated'})
|
||||
11
core/urls.py
11
core/urls.py
|
|
@ -1,11 +0,0 @@
|
|||
from django.urls import path
|
||||
|
||||
from poizonstore.utils import get_drf_router
|
||||
from . import views
|
||||
|
||||
router = get_drf_router()
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("settings/", views.GlobalSettingsAPI.as_view()),
|
||||
] + router.urls
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
from django.core.cache import cache
|
||||
|
||||
|
||||
class CachedSingleton:
|
||||
def __init__(self, cache_key):
|
||||
self._cache_key = cache_key
|
||||
|
||||
def __call__(self, cls):
|
||||
def save(_self, *args, **kwargs):
|
||||
# Store only one instance of model
|
||||
_self.id = 1
|
||||
cls.objects.exclude(id=_self.id).delete()
|
||||
|
||||
# Model's default save
|
||||
_self._model_save(*args, **kwargs)
|
||||
|
||||
# Store model instance in cache
|
||||
cache.set(self._cache_key, _self)
|
||||
|
||||
def load(_self) -> cls:
|
||||
"""Load instance from cache or create new one in DB"""
|
||||
obj = cache.get(self._cache_key)
|
||||
|
||||
if not obj:
|
||||
obj, _ = cls.objects.get_or_create(id=1)
|
||||
cache.set(self._cache_key, obj)
|
||||
return obj
|
||||
|
||||
# Save old Model.save() method first
|
||||
setattr(cls, '_model_save', cls.save)
|
||||
|
||||
# Then, override it with decorator's one
|
||||
setattr(cls, 'save', save)
|
||||
|
||||
# Set the singleton loading method
|
||||
setattr(cls, 'load', classmethod(load))
|
||||
return cls
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
from rest_framework import generics
|
||||
|
||||
from account.permissions import ReadOnly, IsAdmin
|
||||
from core.models import GlobalSettings
|
||||
from core.serializers import GlobalSettingsSerializer, AnonymousGlobalSettingsSerializer
|
||||
|
||||
|
||||
class GlobalSettingsAPI(generics.RetrieveUpdateAPIView):
|
||||
serializer_class = GlobalSettingsSerializer
|
||||
permission_classes = [IsAdmin | ReadOnly]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if getattr(self.request.user, 'is_manager', False):
|
||||
return GlobalSettingsSerializer
|
||||
|
||||
# Anonymous users can view only a certain set of fields
|
||||
return AnonymousGlobalSettingsSerializer
|
||||
|
||||
def get_object(self):
|
||||
return GlobalSettings.load()
|
||||
|
|
@ -9,8 +9,6 @@ import requests
|
|||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
from poizonstore.utils import deep_get
|
||||
from store.models import Checklist
|
||||
from store.utils import is_migration_running
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'poizonstore.settings')
|
||||
|
|
@ -81,23 +79,12 @@ class CDEKStatus:
|
|||
POSTOMAT_RECEIVED = "POSTOMAT_RECEIVED"
|
||||
|
||||
|
||||
class CDEKWebhookTypes:
|
||||
ORDER_STATUS = "ORDER_STATUS"
|
||||
|
||||
|
||||
CDEK_STATUS_TO_ORDER_STATUS = {
|
||||
CDEKStatus.DELIVERED: Checklist.Status.COMPLETED,
|
||||
CDEKStatus.READY_FOR_SHIPMENT_IN_SENDER_CITY: Checklist.Status.CDEK,
|
||||
}
|
||||
|
||||
|
||||
class CDEKClient:
|
||||
AUTH_ENDPOINT = 'oauth/token'
|
||||
ORDER_INFO_ENDPOINT = 'orders'
|
||||
CALCULATOR_TARIFF_ENDPOINT = 'calculator/tariff'
|
||||
CALCULATOR_TARIFF_LIST_ENDPOINT = 'calculator/tarifflist'
|
||||
BARCODE_ENDPOINT = 'print/barcodes'
|
||||
WEBHOOK_ENDPOINT = 'webhooks'
|
||||
|
||||
MAX_RETRIES = 2
|
||||
|
||||
|
|
@ -217,26 +204,8 @@ class CDEKClient:
|
|||
|
||||
return []
|
||||
|
||||
def setup_webhooks(self):
|
||||
if not settings.SITE_URL:
|
||||
return
|
||||
|
||||
request_data = {
|
||||
"type": CDEKWebhookTypes.ORDER_STATUS,
|
||||
"url": f"{settings.SITE_URL}/cdek/webhook/{settings.CDEK_WEBHOOK_URL_SALT}/"
|
||||
}
|
||||
return self.request('POST', self.WEBHOOK_ENDPOINT, json=request_data)
|
||||
|
||||
@staticmethod
|
||||
def process_orderstatus_webhook(data) -> tuple:
|
||||
""" Unpack CDEK request to data. Info: https://api-docs.cdek.ru/29924139.html """
|
||||
cdek_number = deep_get(data, "attributes", "cdek_number")
|
||||
cdek_status = deep_get(data, "attributes", "code")
|
||||
return cdek_number, cdek_status
|
||||
|
||||
|
||||
client = CDEKClient(settings.CDEK_CLIENT_ID, settings.CDEK_CLIENT_SECRET)
|
||||
|
||||
if not is_migration_running():
|
||||
client.authorize()
|
||||
client.setup_webhooks()
|
||||
|
|
|
|||
|
|
@ -49,4 +49,6 @@ class PoizonClient:
|
|||
|
||||
def get_good_info(self, spu_id):
|
||||
params = {'spuId': str(spu_id)}
|
||||
return self.request('GET', self.SPU_GET_DATA_ENDPOINT, params=params)
|
||||
r = self.request('GET', self.SPU_GET_DATA_ENDPOINT, params=params)
|
||||
return r.json()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
import logging
|
||||
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
|
||||
from rest_framework.exceptions import ValidationError as DRFValidationError
|
||||
from rest_framework.views import exception_handler as drf_exception_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def exception_handler(exc, context):
|
||||
""" Handle Django ValidationError as an accepted exception """
|
||||
logger.error(exc)
|
||||
|
||||
if isinstance(exc, DjangoValidationError):
|
||||
if hasattr(exc, 'message_dict'):
|
||||
exc = DRFValidationError(detail={'error': exc.message_dict})
|
||||
elif hasattr(exc, 'message'):
|
||||
exc = DRFValidationError(detail={'error': exc.message})
|
||||
elif hasattr(exc, 'messages'):
|
||||
exc = DRFValidationError(detail={'error': exc.messages})
|
||||
|
||||
return drf_exception_handler(exc, context)
|
||||
|
|
@ -13,50 +13,33 @@ import os
|
|||
from pathlib import Path
|
||||
|
||||
import sentry_sdk
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
|
||||
# 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 = get_secret("SECRET_KEY")
|
||||
SITE_URL = get_secret("SITE_URL")
|
||||
SECRET_KEY = '***REMOVED***'
|
||||
|
||||
# External API settings
|
||||
CDEK_CLIENT_ID = get_secret("CDEK_CLIENT_ID")
|
||||
CDEK_CLIENT_SECRET = get_secret("CDEK_CLIENT_SECRET")
|
||||
CDEK_WEBHOOK_URL_SALT = get_secret("CDEK_WEBHOOK_URL_SALT")
|
||||
CDEK_CLIENT_ID = '***REMOVED***'
|
||||
CDEK_CLIENT_SECRET = '***REMOVED***'
|
||||
|
||||
POIZON_TOKEN = get_secret("POIZON_TOKEN")
|
||||
POIZON_TOKEN = '***REMOVED***'
|
||||
|
||||
CURRENCY_GETGEOIP_API_KEY = get_secret("CURRENCY_GETGEOIP_API_KEY")
|
||||
CURRENCY_GETGEOIP_API_KEY = '***REMOVED***'
|
||||
|
||||
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 = get_secret('ALLOWED_HOSTS').split(',')
|
||||
ALLOWED_HOSTS = ["crm-poizonstore.ru", "127.0.0.1", "localhost", "45.84.227.72"]
|
||||
|
||||
INTERNAL_IPS = ["127.0.0.1", 'localhost']
|
||||
|
||||
|
|
@ -74,12 +57,7 @@ CORS_ALLOWED_ORIGINS = [
|
|||
if DISABLE_CORS:
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
|
||||
# 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'
|
||||
AUTH_USER_MODEL = 'store.User'
|
||||
|
||||
|
||||
# Application definition
|
||||
|
|
@ -100,13 +78,8 @@ INSTALLED_APPS = [
|
|||
'debug_toolbar',
|
||||
'django_filters',
|
||||
'mptt',
|
||||
'drf_spectacular',
|
||||
|
||||
'account',
|
||||
'store',
|
||||
'tg_bot',
|
||||
'core',
|
||||
'bonus_program'
|
||||
'store'
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
|
@ -152,12 +125,6 @@ DATABASES = {
|
|||
}
|
||||
}
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
|
||||
|
|
@ -185,25 +152,24 @@ REST_FRAMEWORK = {
|
|||
# or allow read-only access for unauthenticated users.
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticated'
|
||||
if not DISABLE_PERMISSIONS
|
||||
else
|
||||
'rest_framework.permissions.AllowAny'
|
||||
],
|
||||
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': ['rest_framework.authentication.TokenAuthentication'],
|
||||
|
||||
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
|
||||
'DEFAULT_PAGINATION_CLASS': 'utils.drf.StandardResultsSetPagination',
|
||||
|
||||
'EXCEPTION_HANDLER': 'poizonstore.exceptions.exception_handler',
|
||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||
'DEFAULT_PAGINATION_CLASS': 'utils.drf.StandardResultsSetPagination'
|
||||
}
|
||||
|
||||
DJOSER = {
|
||||
'LOGIN_FIELD': 'email',
|
||||
'TOKEN_MODEL': 'rest_framework.authtoken.models.Token',
|
||||
|
||||
'SERIALIZERS': {
|
||||
'user': 'account.serializers.UserSerializer',
|
||||
'current_user': 'account.serializers.UserSerializer',
|
||||
'user_create': 'account.serializers.UserCreateSerializer',
|
||||
'token_create': 'account.serializers.TokenCreateSerializer',
|
||||
'user': 'store.serializers.UserSerializer',
|
||||
'current_user': 'store.serializers.UserSerializer',
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -236,12 +202,11 @@ 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 = get_secret("SENTRY_DSN")
|
||||
if SENTRY_DSN:
|
||||
SENTRY_DSN = "***REMOVED***"
|
||||
if not DEBUG:
|
||||
sentry_sdk.init(
|
||||
dsn=SENTRY_DSN,
|
||||
# Set traces_sample_rate to 1.0 to capture 100%
|
||||
|
|
@ -251,33 +216,13 @@ if SENTRY_DSN:
|
|||
# of sampled transactions.
|
||||
# We recommend adjusting this value in production.
|
||||
profiles_sample_rate=1.0,
|
||||
environment=get_secret("SENTRY_ENVIRONMENT"),
|
||||
)
|
||||
|
||||
# Celery
|
||||
BROKER_URL = 'redis://localhost:6379/2'
|
||||
BROKER_URL = 'redis://localhost:6379/1'
|
||||
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
|
||||
BONUS_ELIGIBILITY_STATUS = 'completed'
|
||||
|
||||
BONUS_PROGRAM_DEFAULT_CONFIG = {
|
||||
"amounts": {
|
||||
"signup": 150,
|
||||
"referral": 500,
|
||||
},
|
||||
|
||||
"levels": [
|
||||
# slug, name, orders_count, amount_default_purchase
|
||||
("new", "Новичок", 0, 50),
|
||||
("fashion", "Модник", 3, 150),
|
||||
("pro", "Профессионал", 10, 250),
|
||||
("shopaholic", "Шопоголик", 20, 350),
|
||||
("killer", "Фэшн Киллер", 30, 500),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,23 +17,15 @@ Including another URLconf
|
|||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.decorators import permission_required
|
||||
from django.urls import path, include
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
|
||||
|
||||
from account.permissions import IsAdmin
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('__debug__/', include('debug_toolbar.urls')),
|
||||
path('', include('store.urls')),
|
||||
path('', include('account.urls')),
|
||||
path('', include('core.urls')),
|
||||
path('', include('djoser.urls')),
|
||||
path('auth/', include('djoser.urls.authtoken')),
|
||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \
|
||||
+ static(settings.STATIC_URL)
|
||||
|
||||
# API schema
|
||||
urlpatterns += [
|
||||
path('api/schema/', permission_required([IsAdmin])(SpectacularAPIView.as_view()), name='schema'),
|
||||
path('api/redoc/', permission_required([IsAdmin])(SpectacularRedocView.as_view(url_name='schema')), name='redoc'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
from functools import reduce
|
||||
|
||||
from django.conf import settings
|
||||
from django_filters import DateFromToRangeFilter as _DateFromToRangeFilter
|
||||
from rest_framework.fields import DecimalField
|
||||
from rest_framework.routers import SimpleRouter, DefaultRouter
|
||||
|
||||
|
||||
class PriceField(DecimalField):
|
||||
def __init__(self, *args, max_digits=10, decimal_places=2, min_value=0, **kwargs):
|
||||
super().__init__(*args, max_digits=max_digits, decimal_places=decimal_places, min_value=min_value, **kwargs)
|
||||
|
||||
|
||||
def deep_get(dictionary, *keys, default=None):
|
||||
"""Get value from a nested dictionary (JSON)"""
|
||||
return reduce(lambda d, key: d.get(key, None) if isinstance(d, dict) else default, keys, dictionary)
|
||||
|
||||
|
||||
class DateFromToRangeFilter(_DateFromToRangeFilter):
|
||||
""" DateFromToRangeFilter with replaced after/before suffixes to from/to """
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.field.widget.suffixes = ['from', 'to']
|
||||
|
||||
|
||||
def get_drf_router():
|
||||
return DefaultRouter() if settings.DEBUG else SimpleRouter()
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
# Core
|
||||
Django==4.2.13
|
||||
Django==4.2.2
|
||||
django-cleanup==8.0.0
|
||||
django-filter==23.2
|
||||
django-mptt==0.14.0
|
||||
|
|
@ -8,29 +8,20 @@ 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
|
||||
python-dotenv==1.0.1
|
||||
drf-spectacular==0.27.2
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
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())
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
|
@ -2,7 +2,12 @@ from django.contrib import admin
|
|||
from django.contrib.admin import display
|
||||
from mptt.admin import MPTTModelAdmin
|
||||
|
||||
from .models import Category, Checklist, PaymentMethod, Promocode, Image, Gift
|
||||
from .models import Category, Checklist, GlobalSettings, PaymentMethod, Promocode, User, Image, Gift
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(admin.ModelAdmin):
|
||||
list_display = ('email', 'job_title', 'full_name',)
|
||||
|
||||
|
||||
@admin.register(Category)
|
||||
|
|
@ -18,7 +23,7 @@ class ImageAdmin(admin.ModelAdmin):
|
|||
|
||||
@admin.register(Checklist)
|
||||
class ChecklistAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'brand', 'model', 'price_rub', 'commission_rub', 'full_price', 'date', 'status_display', 'customer')
|
||||
list_display = ('id', 'brand', 'model', 'price_rub', 'commission_rub', 'full_price', 'date', 'status_display')
|
||||
ordering = ('-status_updated_at', '-created_at')
|
||||
|
||||
@display(description='Статус')
|
||||
|
|
@ -29,7 +34,12 @@ class ChecklistAdmin(admin.ModelAdmin):
|
|||
return obj.status_updated_at or obj.created_at
|
||||
|
||||
def get_queryset(self, request):
|
||||
return Checklist.objects.with_base_related()
|
||||
return Checklist.objects.with_base_related().annotate_price_rub().annotate_commission_rub()
|
||||
|
||||
|
||||
@admin.register(GlobalSettings)
|
||||
class GlobalSettingsAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(PaymentMethod)
|
||||
|
|
@ -44,5 +54,8 @@ class PromoCodeAdmin(admin.ModelAdmin):
|
|||
|
||||
@admin.register(Gift)
|
||||
class GiftAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'min_price', 'available_count')
|
||||
list_display = ('name', 'min_price')
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ class CRMException(APIException):
|
|||
self.detail = {'error': detail}
|
||||
|
||||
|
||||
class Forbidden(CRMException):
|
||||
status_code = status.HTTP_403_FORBIDDEN
|
||||
class UnauthorizedException(CRMException):
|
||||
"""Authentication exception error mixin."""
|
||||
status_code = status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
# TODO: exceptions with a same template: ok / error_code / error_message
|
||||
|
||||
class InvalidCredentialsException(UnauthorizedException):
|
||||
default_detail = 'cannot find the worker'
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
from django_filters import rest_framework as filters
|
||||
|
||||
from poizonstore.utils import DateFromToRangeFilter
|
||||
from .models import Checklist, Gift
|
||||
|
||||
|
||||
|
|
@ -17,14 +16,7 @@ class GiftFilter(filters.FilterSet):
|
|||
|
||||
class ChecklistFilter(filters.FilterSet):
|
||||
status = filters.MultipleChoiceFilter(choices=Checklist.Status.CHOICES)
|
||||
delivery_code = filters.CharFilter(method='filter_delivery_code')
|
||||
|
||||
created_at = DateFromToRangeFilter()
|
||||
status_updated_at = DateFromToRangeFilter()
|
||||
|
||||
class Meta:
|
||||
model = Checklist
|
||||
fields = ('status', 'delivery_code', 'created_at', 'status_updated_at')
|
||||
|
||||
def filter_delivery_code(self, queryset, name, value):
|
||||
return queryset.filter(poizon_tracking__iendswith=value)
|
||||
fields = ('status',)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
from django.core.management import BaseCommand
|
||||
from django.conf import settings
|
||||
from tqdm import tqdm
|
||||
|
||||
from bonus_program.models import BonusProgramLevel
|
||||
from store.models import Category, PaymentMethod
|
||||
|
||||
|
||||
|
|
@ -136,23 +134,10 @@ def create_payment_types():
|
|||
PaymentMethod.objects.get_or_create(slug=slug, defaults=data)
|
||||
|
||||
|
||||
def create_bonus_program_levels():
|
||||
for cfg in settings.BONUS_PROGRAM_DEFAULT_CONFIG['levels']:
|
||||
slug, name, order_count, amount_default_purchase = cfg
|
||||
BonusProgramLevel.objects.get_or_create(
|
||||
slug=slug,
|
||||
defaults={
|
||||
'name': name,
|
||||
'orders_count': order_count,
|
||||
'amount_default_purchase': amount_default_purchase
|
||||
})
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = ''' Create root categories '''
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
create_categories()
|
||||
create_payment_types()
|
||||
create_bonus_program_levels()
|
||||
|
||||
|
|
|
|||
|
|
@ -2,33 +2,24 @@ from django.contrib.auth.hashers import make_password
|
|||
from django.core.management import BaseCommand
|
||||
from tqdm import tqdm
|
||||
|
||||
from account.models import User
|
||||
from store.models import User
|
||||
|
||||
users = [
|
||||
{
|
||||
"email": "poizonstore@mail.ru",
|
||||
"password": "219404Poizon",
|
||||
"first_name": "Илья",
|
||||
"middle_name": "Сергеевич",
|
||||
"last_name": "Савочкин",
|
||||
"role": User.ADMIN,
|
||||
"job_title": User.ADMIN,
|
||||
"is_staff": True
|
||||
},
|
||||
{
|
||||
"email": "poizonmanager1@mail.ru",
|
||||
"password": "poizonm1",
|
||||
"first_name": "Патрик",
|
||||
"middle_name": "Сергеевич",
|
||||
"last_name": "Стар",
|
||||
"role": User.PRODUCT_MANAGER
|
||||
"job_title": User.PRODUCT_MANAGER
|
||||
},
|
||||
{
|
||||
"email": "poizonorder1@mail.ru",
|
||||
"password": "2193071Po1",
|
||||
"first_name": "Гоша",
|
||||
"middle_name": "Альбах",
|
||||
"last_name": "Абызов",
|
||||
"role": User.ORDER_MANAGER
|
||||
"job_title": User.ORDER_MANAGER
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
# Generated by Django 4.2.2 on 2024-03-28 22:05
|
||||
# Generated by Django 4.2.2 on 2023-06-30 22:04
|
||||
|
||||
import datetime
|
||||
from django.conf import settings
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import mptt.fields
|
||||
import django.utils.timezone
|
||||
import store.models
|
||||
|
||||
|
||||
|
|
@ -14,149 +12,96 @@ class Migration(migrations.Migration):
|
|||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
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='Название')),
|
||||
('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='Родительская категория')),
|
||||
('slug', models.SlugField(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, 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='Комиссия, руб')),
|
||||
('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)),
|
||||
],
|
||||
),
|
||||
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, unique=True, verbose_name='Название')),
|
||||
('discount', models.PositiveIntegerField(verbose_name='Скидка в рублях')),
|
||||
('name', models.CharField(max_length=100, verbose_name='Название')),
|
||||
('discount', models.PositiveSmallIntegerField(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(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='Ссылка на товар')),
|
||||
('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='Подкатегория')),
|
||||
('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.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='Реальная цена')),
|
||||
('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='Промокод')),
|
||||
('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='Телефон получателя')),
|
||||
('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')),
|
||||
('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='Трек-номер')),
|
||||
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.category', verbose_name='Категория')),
|
||||
('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='Промокод')),
|
||||
('manager', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Заказ',
|
||||
'verbose_name_plural': 'Заказы',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
# 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='Цена в юанях'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# 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='Менеджер'),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
# Generated by Django 4.2.2 on 2024-04-21 03:21
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('store', '0002_checklist_customer_alter_checklist_manager'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='checklist',
|
||||
name='buyer_name',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='checklist',
|
||||
name='buyer_phone',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='checklist',
|
||||
name='buyer_telegram',
|
||||
),
|
||||
]
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# Generated by Django 4.2.2 on 2024-05-20 17:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('store', '0003_remove_checklist_buyer_name_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='checklist',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('deleted', 'Удален'), ('draft', 'Черновик'), ('neworder', 'Новый заказ'), ('payment', 'Проверка оплаты'), ('buying', 'На закупке'), ('bought', 'Закуплен'), ('china', 'На складе в Китае'), ('chinarush', 'Доставка на склад РФ'), ('rush', 'На складе в РФ'), ('split_waiting', 'Сплит: ожидание оплаты 2й части'), ('split_paid', 'Сплит: полностью оплачено'), ('cdek', 'Доставляется СДЭК'), ('completed', 'Завершен')], default='neworder', max_length=15, verbose_name='Статус заказа'),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 4.2.2 on 2023-07-01 17:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('store', '0004_alter_globalsettings_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='globalsettings',
|
||||
name='delivery_price_CN',
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=10),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
# Generated by Django 4.2.13 on 2024-05-22 21:30
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('store', '0004_alter_checklist_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='GlobalSettings',
|
||||
),
|
||||
]
|
||||
18
store/migrations/0006_alter_checklist_price_yuan.py
Normal file
18
store/migrations/0006_alter_checklist_price_yuan.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# 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='Цена в юанях'),
|
||||
),
|
||||
]
|
||||
18
store/migrations/0007_alter_category_slug.py
Normal file
18
store/migrations/0007_alter_category_slug.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# 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='Идентификатор'),
|
||||
),
|
||||
]
|
||||
19
store/migrations/0008_alter_checklist_id.py
Normal file
19
store/migrations/0008_alter_checklist_id.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
||||
18
store/migrations/0009_alter_checklist_status.py
Normal file
18
store/migrations/0009_alter_checklist_status.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# 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='Статус заказа'),
|
||||
),
|
||||
]
|
||||
18
store/migrations/0010_alter_checklist_status.py
Normal file
18
store/migrations/0010_alter_checklist_status.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# 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='Статус заказа'),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
# 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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# 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='Метод оплаты'),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# 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',
|
||||
),
|
||||
]
|
||||
19
store/migrations/0015_paymentmethod_name.py
Normal file
19
store/migrations/0015_paymentmethod_name.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# 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,
|
||||
),
|
||||
]
|
||||
19
store/migrations/0016_paymentmethod_cardnumber.py
Normal file
19
store/migrations/0016_paymentmethod_cardnumber.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# 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,
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# 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='Скидка'),
|
||||
),
|
||||
]
|
||||
18
store/migrations/0018_alter_promocode_name.py
Normal file
18
store/migrations/0018_alter_promocode_name.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# 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='Название'),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# 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='Размер'),
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user