Compare commits

..

No commits in common. "dev" and "master" have entirely different histories.
dev ... master

152 changed files with 1742 additions and 2998 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
APP_HOME=/var/www/phzhik-poizonstore/

View File

@ -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
View File

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

View File

@ -1 +0,0 @@
# Telegram для связи: @phzhik

View File

@ -5,7 +5,7 @@ CELERYD_NODES="w1"
#CELERYD_NODES="w1 w2 w3"
# Absolute or relative path to the 'celery' command:
CELERY_BIN="/var/www/poizonstore-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"

View File

@ -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} \

View 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} \

View 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

View File

@ -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/;
}
}

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

@ -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)),
],
),
]

View File

@ -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')},
),
]

View File

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

View File

@ -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='Телефон'),
),
]

View File

@ -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='Эл. почта'),
),
]

View File

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

View File

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

View File

@ -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),
),
]

View File

@ -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='Комментарий'),
),
]

View File

@ -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),
]

View File

@ -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,
),
]

View File

@ -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),
),
]

View File

@ -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='Баланс, руб'),
),
]

View File

@ -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='Тип транзакции'),
),
]

View File

@ -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='Роль'),
),
]

View File

@ -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='Тип транзакции'),
),
]

View File

@ -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='Пользователь транзакции'),
),
]

View File

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

View File

@ -1 +0,0 @@
from .user import User, UserManager, UserQuerySet, ReferralRelationship

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()),
]

View File

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

View File

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

View File

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

View File

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

View File

@ -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'],
},
),
]

View File

@ -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='Бонус за обычную покупку')),
],
),
]

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

@ -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': 'Глобальные настройки',
},
),
]

View File

@ -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 = 'Настройки бонусной программы'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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),
]
}

View File

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

View File

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

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

View File

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

View File

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

View File

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

View File

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

View File

@ -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
}
]

View File

@ -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': 'Заказы',
},
),
]

View File

@ -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='Цена в юанях'),
),
]

View File

@ -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='Менеджер'),
),
]

View File

@ -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),
),
]

View File

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

View File

@ -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='Статус заказа'),
),
]

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

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

View 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='Цена в юанях'),
),
]

View 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='Идентификатор'),
),
]

View 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),
),
]

View 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='Статус заказа'),
),
]

View 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='Статус заказа'),
),
]

View File

@ -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),
),
]

View File

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

View File

@ -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='Метод оплаты'),
),
]

View File

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

View 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,
),
]

View 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,
),
]

View File

@ -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='Скидка'),
),
]

View 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='Название'),
),
]

View File

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