diff --git a/.env b/.env index 1a58188..429a4ca 100644 --- a/.env +++ b/.env @@ -3,6 +3,7 @@ APP_HOME=/var/www/poizonstore-stage # === Keys === # Django SECRET_KEY="" +ALLOWED_HOSTS=.crm-poizonstore.ru,127.0.0.1,localhost,45.84.227.72 # Telegram bot TG_BOT_TOKEN="" @@ -15,5 +16,3 @@ CURRENCY_GETGEOIP_API_KEY="" # Let's Encrypt LETSENCRYPT_EMAIL="phzhitnikov@gmail.com" - -ALLOWED_HOSTS=.crm-poizonstore.ru,127.0.0.1,localhost,45.84.227.72 \ No newline at end of file diff --git a/_deploy/celery b/_deploy/celery index f7580bf..903d876 100644 --- a/_deploy/celery +++ b/_deploy/celery @@ -5,7 +5,7 @@ CELERYD_NODES="w1" #CELERYD_NODES="w1 w2 w3" # Absolute or relative path to the 'celery' command: -CELERY_BIN="/var/www/poizonstore/env/bin/celery" +CELERY_BIN="/var/www/poizonstore-stage/env/bin/celery" # App instance to use CELERY_APP="poizonstore" @@ -19,10 +19,10 @@ CELERYD_OPTS="" # - %n will be replaced with the first part of the nodename. # - %I will be replaced with the current child process index # and is important when using the prefork pool to avoid race conditions. -CELERYD_PID_FILE="/var/run/celery/%n.pid" -CELERYD_LOG_FILE="/var/log/celery/%n%I.log" +CELERYD_PID_FILE="/var/run/celery-stage/%n.pid" +CELERYD_LOG_FILE="/var/log/celery-stage/%n%I.log" CELERYD_LOG_LEVEL="INFO" # you may wish to add these options for Celery Beat -CELERYBEAT_PID_FILE="/var/run/celery/beat.pid" -CELERYBEAT_LOG_FILE="/var/log/celery/beat.log" \ No newline at end of file +CELERYBEAT_PID_FILE="/var/run/celery-stage/beat.pid" +CELERYBEAT_LOG_FILE="/var/log/celery-stage/beat.log" \ No newline at end of file diff --git a/_deploy/celery.service b/_deploy/celery.service index ed67b87..d308851 100644 --- a/_deploy/celery.service +++ b/_deploy/celery.service @@ -7,8 +7,8 @@ Requires=redis.service Type=forking User=poizon Group=poizon -EnvironmentFile=/etc/default/celery -WorkingDirectory=/var/www/poizonstore +EnvironmentFile=/etc/default/celery-stage +WorkingDirectory=/var/www/poizonstore-stage RuntimeDirectory=celery ExecStart=/bin/sh -c '${CELERY_BIN} -A $CELERY_APP multi start $CELERYD_NODES \ --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} \ diff --git a/_deploy/celerybeat.service b/_deploy/celerybeat.service index 7df9058..86ca25b 100644 --- a/_deploy/celerybeat.service +++ b/_deploy/celerybeat.service @@ -6,8 +6,8 @@ After=network.target Type=simple User=poizon Group=poizon -EnvironmentFile=/etc/default/celery -WorkingDirectory=/var/www/poizonstore +EnvironmentFile=/etc/default/celery-stage +WorkingDirectory=/var/www/poizonstore-stage RuntimeDirectory=celery ExecStart=/bin/sh -c '${CELERY_BIN} -A ${CELERY_APP} beat \ --pidfile=${CELERYBEAT_PID_FILE} \ diff --git a/_deploy/nginx.conf b/_deploy/nginx.conf index d561bae..2120df6 100644 --- a/_deploy/nginx.conf +++ b/_deploy/nginx.conf @@ -1,21 +1,27 @@ upstream django { - server 127.0.0.1:8001; + server 127.0.0.1:8002; } server { listen 80; - server_name crm-poizonstore.ru; + server_name stage.crm-poizonstore.ru; return 301 https://$host$request_uri; } server { - set $APP_HOME /var/www/poizonstore; + set $DOMAIN crm-poizonstore.ru; + set $APP_HOME /var/www/poizonstore-stage; listen 443 ssl; - server_name crm-poizonstore.ru; + server_name $DOMAIN; charset utf-8; # === Add here SSL config === + ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem; + + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # max upload size client_max_body_size 75M; # adjust to taste diff --git a/_deploy/uwsgi.ini b/_deploy/uwsgi.ini index 163d558..37998e8 100644 --- a/_deploy/uwsgi.ini +++ b/_deploy/uwsgi.ini @@ -1,5 +1,5 @@ [uwsgi] -project = poizonstore +project = poizonstore-stage uid = poizon gid = poizon @@ -8,7 +8,7 @@ gid = poizon # the base directory (full path) chdir = /var/www/%(project)/ # Django's wsgi file -module = %(project):application +module = poizonstore:application # the virtualenv (full path) virtualenv = /var/www/%(project)/env diff --git a/account/__init__.py b/account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/account/admin.py b/account/admin.py new file mode 100644 index 0000000..52eccfe --- /dev/null +++ b/account/admin.py @@ -0,0 +1,21 @@ +from django.contrib import admin + +from .models import User, BonusProgramTransaction + + +@admin.register(User) +class UserAdmin(admin.ModelAdmin): + list_display = ('email', 'role', 'full_name', 'phone', 'telegram', 'balance') + + def get_queryset(self, request): + return User.objects.with_base_related() + + +@admin.register(BonusProgramTransaction) +class BonusProgramTransactionAdmin(admin.ModelAdmin): + list_display = ('id', 'type', 'user', 'date', 'amount', 'comment', 'order', 'was_cancelled') + + def delete_queryset(self, request, queryset): + for obj in queryset: + obj.cancel_transaction() + diff --git a/account/apps.py b/account/apps.py new file mode 100644 index 0000000..362302d --- /dev/null +++ b/account/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class AccountConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'account' + + def ready(self): + import account.signals diff --git a/account/exceptions.py b/account/exceptions.py new file mode 100644 index 0000000..626351c --- /dev/null +++ b/account/exceptions.py @@ -0,0 +1,5 @@ +from rest_framework import exceptions, status + + +class AuthError(exceptions.APIException): + status_code = status.HTTP_401_UNAUTHORIZED diff --git a/account/migrations/0001_initial.py b/account/migrations/0001_initial.py new file mode 100644 index 0000000..3e752b4 --- /dev/null +++ b/account/migrations/0001_initial.py @@ -0,0 +1,71 @@ +# Generated by Django 4.2.2 on 2024-03-28 22:05 + +import account.models +from django.conf import settings +from django.db import migrations, models +import django.utils.timezone + +import account.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('email', models.EmailField(max_length=254, unique=True, verbose_name='Эл. почта')), + ('first_name', models.CharField(blank=True, max_length=150, null=True, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, null=True, verbose_name='last name')), + ('middle_name', models.CharField(blank=True, max_length=150, null=True, verbose_name='Отчество')), + ('role', models.CharField(choices=[('admin', 'Администратор'), ('ordermanager', 'Менеджер по заказам'), ('productmanager', 'Менеджер по закупкам'), ('client', 'Клиент')], default='client', max_length=30, verbose_name='Роль')), + ('phone', models.CharField(blank=True, max_length=100, null=True, unique=True, verbose_name='Телефон')), + ('telegram', models.CharField(blank=True, max_length=100, null=True, verbose_name='Telegram')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', account.models.UserManager()), + ], + ), + migrations.CreateModel( + name='BonusProgramTransaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.PositiveSmallIntegerField(choices=[(0, 'Другое начисление'), (1, 'Бонус за регистрацию'), (2, 'Бонус за покупку'), (3, 'Бонус за первую покупку приглашенного'), (4, 'Бонус за первую покупку'), (10, 'Другое списание'), (11, 'Списание бонусов за заказ')], verbose_name='Тип транзакции')), + ('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата транзакции')), + ('amount', models.SmallIntegerField(verbose_name='Количество, руб')), + ], + ), + migrations.CreateModel( + name='ReferralRelationship', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('invited', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_invited', to=settings.AUTH_USER_MODEL)), + ('inviter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_inviter', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='BonusProgramUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('balance', models.PositiveSmallIntegerField(default=0, verbose_name='Баланс, руб')), + ('referral_code', models.CharField(default=account.models.generate_referral_code, editable=False, max_length=9)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/account/migrations/0002_initial.py b/account/migrations/0002_initial.py new file mode 100644 index 0000000..348d282 --- /dev/null +++ b/account/migrations/0002_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.2 on 2024-03-28 22:05 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('store', '0001_initial'), + ('auth', '0012_alter_user_first_name_max_length'), + ('account', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='bonusprogramtransaction', + name='order', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.checklist'), + ), + migrations.AddField( + model_name='bonusprogramtransaction', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='account.bonusprogramuser', verbose_name='Пользователь транзакции'), + ), + migrations.AddField( + model_name='user', + name='groups', + field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups'), + ), + migrations.AddField( + model_name='user', + name='user_permissions', + field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions'), + ), + migrations.AlterUniqueTogether( + name='referralrelationship', + unique_together={('inviter', 'invited')}, + ), + ] diff --git a/account/migrations/0003_alter_user_email.py b/account/migrations/0003_alter_user_email.py new file mode 100644 index 0000000..e2efb60 --- /dev/null +++ b/account/migrations/0003_alter_user_email.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.2 on 2024-04-03 20:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0002_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='email', + field=models.EmailField(blank=True, max_length=254, verbose_name='email address'), + ), + ] diff --git a/account/migrations/0004_alter_user_phone.py b/account/migrations/0004_alter_user_phone.py new file mode 100644 index 0000000..69ffa52 --- /dev/null +++ b/account/migrations/0004_alter_user_phone.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.2 on 2024-04-03 21:26 + +from django.db import migrations +import phonenumber_field.modelfields + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0003_alter_user_email'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='phone', + field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, unique=True, verbose_name='Телефон'), + ), + ] diff --git a/account/migrations/0005_alter_user_email.py b/account/migrations/0005_alter_user_email.py new file mode 100644 index 0000000..119e308 --- /dev/null +++ b/account/migrations/0005_alter_user_email.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.2 on 2024-04-03 21:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0004_alter_user_phone'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='email', + field=models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='Эл. почта'), + ), + ] diff --git a/account/migrations/0006_user_tg_user_id.py b/account/migrations/0006_user_tg_user_id.py new file mode 100644 index 0000000..8cbc1cf --- /dev/null +++ b/account/migrations/0006_user_tg_user_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.2 on 2024-04-05 21:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0005_alter_user_email'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='tg_user_id', + field=models.BigIntegerField(blank=True, null=True, unique=True, verbose_name='id пользователя в Telegram'), + ), + ] diff --git a/store/migrations/0044_checklist_split_accepted.py b/account/migrations/0007_user_is_draft_user.py similarity index 52% rename from store/migrations/0044_checklist_split_accepted.py rename to account/migrations/0007_user_is_draft_user.py index 6538e83..1bf19ac 100644 --- a/store/migrations/0044_checklist_split_accepted.py +++ b/account/migrations/0007_user_is_draft_user.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.2 on 2023-10-04 02:55 +# Generated by Django 4.2.2 on 2024-04-05 21:35 from django.db import migrations, models @@ -6,13 +6,13 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('store', '0043_pricesnapshot_checklist_price_snapshot'), + ('account', '0006_user_tg_user_id'), ] operations = [ migrations.AddField( - model_name='checklist', - name='split_accepted', - field=models.BooleanField(default=False, verbose_name='Сплит принят'), + model_name='user', + name='is_draft_user', + field=models.BooleanField(default=False, verbose_name='Черновик пользователя'), ), ] diff --git a/account/migrations/0008_alter_bonusprogramtransaction_user_and_more.py b/account/migrations/0008_alter_bonusprogramtransaction_user_and_more.py new file mode 100644 index 0000000..d0c1047 --- /dev/null +++ b/account/migrations/0008_alter_bonusprogramtransaction_user_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.2 on 2024-04-07 17:04 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0007_user_is_draft_user'), + ] + + operations = [ + migrations.AlterField( + model_name='bonusprogramtransaction', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь транзакции'), + ), + migrations.DeleteModel( + name='BonusProgramUser', + ), + ] diff --git a/account/migrations/0009_alter_user_options_user_balance_user_referral_code.py b/account/migrations/0009_alter_user_options_user_balance_user_referral_code.py new file mode 100644 index 0000000..e8c180c --- /dev/null +++ b/account/migrations/0009_alter_user_options_user_balance_user_referral_code.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.2 on 2024-04-07 17:36 + +import account.models +from django.db import migrations, models + +import account.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0008_alter_bonusprogramtransaction_user_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='user', + options={}, + ), + migrations.AddField( + model_name='user', + name='balance', + field=models.PositiveSmallIntegerField(default=0, verbose_name='Баланс, руб'), + ), + migrations.AddField( + model_name='user', + name='referral_code', + field=models.CharField(default=account.models.generate_referral_code, editable=False, max_length=10), + ), + ] diff --git a/account/migrations/0010_bonusprogramtransaction_comment.py b/account/migrations/0010_bonusprogramtransaction_comment.py new file mode 100644 index 0000000..eb0ca9a --- /dev/null +++ b/account/migrations/0010_bonusprogramtransaction_comment.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.2 on 2024-04-07 20:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0009_alter_user_options_user_balance_user_referral_code'), + ] + + operations = [ + migrations.AddField( + model_name='bonusprogramtransaction', + name='comment', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Комментарий'), + ), + ] diff --git a/account/migrations/0011_alter_bonusprogramtransaction_options.py b/account/migrations/0011_alter_bonusprogramtransaction_options.py new file mode 100644 index 0000000..8c90613 --- /dev/null +++ b/account/migrations/0011_alter_bonusprogramtransaction_options.py @@ -0,0 +1,57 @@ +# Generated by Django 4.2.2 on 2024-04-07 23:27 +from django.contrib.auth.hashers import make_password +from django.db import migrations +from phonenumber_field.phonenumber import PhoneNumber + + +def move_buyer_info_to_account(apps, schema_editor): + Checklist = apps.get_model("store", "Checklist") + User = apps.get_model("account", "User") + + # Normalize phone numbers first + for order in Checklist.objects.all(): + if order.buyer_phone is None: + continue + + old_phone = order.buyer_phone + new_phone = PhoneNumber.from_string(order.buyer_phone, region="RU").as_e164 + if old_phone != new_phone: + print(f"{old_phone} -> {new_phone}") + order.buyer_phone = new_phone + order.save(update_fields=['buyer_phone']) + + # Move buyer info to User + for order in Checklist.objects.all(): + fields_to_copy = { + 'first_name': order.buyer_name, + 'telegram': order.buyer_telegram, + } + + if order.buyer_phone is None: + User.objects.create(**fields_to_copy) + created = True + else: + obj, created = User.objects.update_or_create(phone=order.buyer_phone, defaults=fields_to_copy) + + if created: + obj.is_draft_user = True + obj.password = make_password(None) + obj.save(update_fields=['password']) + + # Bind customer to order + order.customer_id = obj.id + order.save(update_fields=['customer_id']) + + +class Migration(migrations.Migration): + dependencies = [ + ('account', '0010_bonusprogramtransaction_comment'), + ] + + operations = [ + migrations.AlterModelOptions( + name='bonusprogramtransaction', + options={'ordering': ['-date']}, + ), + migrations.RunPython(move_buyer_info_to_account, migrations.RunPython.noop), + ] diff --git a/account/migrations/0012_referralrelationship_invited_at.py b/account/migrations/0012_referralrelationship_invited_at.py new file mode 100644 index 0000000..bf01d2b --- /dev/null +++ b/account/migrations/0012_referralrelationship_invited_at.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.2 on 2024-04-08 02:59 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0011_alter_bonusprogramtransaction_options'), + ] + + operations = [ + migrations.AddField( + model_name='referralrelationship', + name='invited_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + ] diff --git a/account/migrations/0013_alter_referralrelationship_invited_and_more.py b/account/migrations/0013_alter_referralrelationship_invited_and_more.py new file mode 100644 index 0000000..f9c8ffb --- /dev/null +++ b/account/migrations/0013_alter_referralrelationship_invited_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.2 on 2024-04-08 03:01 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0012_referralrelationship_invited_at'), + ] + + operations = [ + migrations.AlterField( + model_name='referralrelationship', + name='invited', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_inviter', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='referralrelationship', + name='inviter', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_invited', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/account/migrations/0014_alter_user_balance.py b/account/migrations/0014_alter_user_balance.py new file mode 100644 index 0000000..be588f1 --- /dev/null +++ b/account/migrations/0014_alter_user_balance.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.2 on 2024-04-14 14:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0013_alter_referralrelationship_invited_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='balance', + field=models.PositiveSmallIntegerField(default=0, editable=False, verbose_name='Баланс, руб'), + ), + ] diff --git a/account/migrations/0015_alter_bonusprogramtransaction_options_and_more.py b/account/migrations/0015_alter_bonusprogramtransaction_options_and_more.py new file mode 100644 index 0000000..0c137d7 --- /dev/null +++ b/account/migrations/0015_alter_bonusprogramtransaction_options_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.2 on 2024-04-21 03:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('store', '0003_remove_checklist_buyer_name_and_more'), + ('account', '0014_alter_user_balance'), + ] + + operations = [ + migrations.AlterModelOptions( + name='bonusprogramtransaction', + options={'ordering': ['-date'], 'verbose_name': 'История баланса', 'verbose_name_plural': 'История баланса'}, + ), + migrations.AddField( + model_name='bonusprogramtransaction', + name='was_cancelled', + field=models.BooleanField(default=False, editable=False, verbose_name='Была отменена'), + ), + migrations.AlterField( + model_name='bonusprogramtransaction', + name='order', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.checklist', verbose_name='Связанный заказ'), + ), + migrations.AlterField( + model_name='bonusprogramtransaction', + name='type', + field=models.PositiveSmallIntegerField(choices=[(0, 'Другое начисление'), (1, 'Бонус за регистрацию'), (2, 'Бонус за покупку'), (3, 'Бонус за первую покупку приглашенного'), (4, 'Бонус за первую покупку'), (10, 'Другое списание'), (11, 'Списание бонусов за заказ'), (20, 'Отмена начисления'), (21, 'Отмена списания')], verbose_name='Тип транзакции'), + ), + ] diff --git a/account/migrations/0016_alter_user_role.py b/account/migrations/0016_alter_user_role.py new file mode 100644 index 0000000..6296623 --- /dev/null +++ b/account/migrations/0016_alter_user_role.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.2 on 2024-04-23 09:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0015_alter_bonusprogramtransaction_options_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='role', + field=models.CharField(choices=[('admin', 'Администратор'), ('ordermanager', 'Менеджер по заказам'), ('productmanager', 'Менеджер по закупкам'), ('client', 'Клиент')], default='client', editable=False, max_length=30, verbose_name='Роль'), + ), + ] diff --git a/account/migrations/__init__.py b/account/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/account/models/__init__.py b/account/models/__init__.py new file mode 100644 index 0000000..359d530 --- /dev/null +++ b/account/models/__init__.py @@ -0,0 +1,4 @@ +from .bonus import generate_referral_code, BonusType, BonusProgramMixin, BonusProgramTransaction, BonusProgramTransactionQuerySet +from .user import User, UserManager, UserQuerySet, ReferralRelationship + + diff --git a/account/models/bonus.py b/account/models/bonus.py new file mode 100644 index 0000000..8a9eda2 --- /dev/null +++ b/account/models/bonus.py @@ -0,0 +1,278 @@ +import logging +from contextlib import suppress + +from django.conf import settings +from django.db import models +from django.db.models import Sum +from django.utils import timezone +from django.utils.crypto import get_random_string +from django.utils.formats import localize + +from store.models import Checklist +from tg_bot.messages import TGBonusMessage + +logger = logging.getLogger(__name__) + + +class BonusType: + # Другое начисление + OTHER_DEPOSIT = 0 + + # Клиент передал номер ТГ-боту + SIGNUP = 1 + + # Клиент сделал заказ + DEFAULT_PURCHASE = 2 + + # Приглашенный клиент сделал свою первую покупку, бонус реферреру + FOR_INVITER = 3 + + # Клиент сделал заказ и получил бонус за первую покупку от реферрера + INVITED_FIRST_PURCHASE = 4 + + # Другое списание + OTHER_WITHDRAWAL = 10 + + # Клиент потратил баллы на заказ + SPENT_PURCHASE = 11 + + # Отмена начисления + CANCELLED_DEPOSIT = 20 + + # Отмена списания + CANCELLED_WITHDRAWAL = 21 + + CHOICES = ( + (OTHER_DEPOSIT, 'Другое начисление'), + (SIGNUP, 'Бонус за регистрацию'), + (DEFAULT_PURCHASE, 'Бонус за покупку'), + (FOR_INVITER, 'Бонус за первую покупку приглашенного'), + (INVITED_FIRST_PURCHASE, 'Бонус за первую покупку'), + + (OTHER_WITHDRAWAL, 'Другое списание'), + (SPENT_PURCHASE, 'Списание бонусов за заказ'), + + (CANCELLED_DEPOSIT, 'Отмена начисления'), + (CANCELLED_WITHDRAWAL, 'Отмена списания'), + ) + + +class BonusProgramTransactionQuerySet(models.QuerySet): + # TODO: optimize queries + def with_base_related(self): + return self + + +class BonusProgramTransaction(models.Model): + """ Represents the history of all bonus program transactions """ + + type = models.PositiveSmallIntegerField('Тип транзакции', choices=BonusType.CHOICES) + user = models.ForeignKey('User', verbose_name='Пользователь транзакции', on_delete=models.CASCADE) + date = models.DateTimeField('Дата транзакции', auto_now_add=True) + amount = models.SmallIntegerField('Количество, руб') + comment = models.CharField('Комментарий', max_length=200, null=True, blank=True) + was_cancelled = models.BooleanField('Была отменена', editable=False, default=False) + + # Bound objects + order = models.ForeignKey('store.Checklist', verbose_name="Связанный заказ", null=True, blank=True, on_delete=models.SET_NULL) + + objects = BonusProgramTransactionQuerySet.as_manager() + + class Meta: + ordering = ['-date'] + verbose_name = "История баланса" + verbose_name_plural = "История баланса" + + def _notify_user_about_new_transaction(self): + msg = None + + match self.type: + case BonusType.SIGNUP: + msg = TGBonusMessage.SIGNUP.format(amount=self.amount) + + case BonusType.DEFAULT_PURCHASE: + msg = TGBonusMessage.PURCHASE_ADDED.format(amount=self.amount, order_id=self.order.id) + + case BonusType.FOR_INVITER: + msg = TGBonusMessage.FOR_INVITER.format(amount=self.amount) + + case BonusType.INVITED_FIRST_PURCHASE: + msg = TGBonusMessage.INVITED_FIRST_PURCHASE.format(amount=self.amount, order_id=self.order.id) + + case BonusType.OTHER_DEPOSIT | BonusType.CANCELLED_DEPOSIT: + comment = self.comment or "—" + msg = TGBonusMessage.OTHER_DEPOSIT.format(amount=self.amount, comment=comment) + + case BonusType.OTHER_WITHDRAWAL | BonusType.CANCELLED_WITHDRAWAL: + comment = self.comment or "—" + msg = TGBonusMessage.OTHER_WITHDRAWAL.format(amount=abs(self.amount), comment=comment) + + case BonusType.SPENT_PURCHASE: + msg = TGBonusMessage.PURCHASE_SPENT.format(amount=abs(self.amount), order_id=self.order_id) + + case _: + pass + + if msg is not None: + self.user.notify_tg_bot(msg) + + def cancel_transaction(self): + # Skip already cancelled transactions + # TODO: if reverse transaction is being deleted, revert the source one? + if self.was_cancelled or self.type in (BonusType.OTHER_WITHDRAWAL, BonusType.OTHER_DEPOSIT): + return + + date_formatted = localize(timezone.localtime(self.date)) + + if self.amount > 0: + comment = f"Отмена начисления #{self.id} от {date_formatted}" + bonus_type = BonusType.OTHER_WITHDRAWAL + elif self.amount < 0: + comment = f"Отмена списания #{self.id} от {date_formatted}" + bonus_type = BonusType.OTHER_DEPOSIT + else: + return + + # Create reverse transaction, user's balance will be recalculated in post_save signal + transaction = BonusProgramTransaction() + + transaction.user_id = self.user_id + transaction.type = bonus_type + transaction.amount = self.amount * -1 + transaction.comment = comment + transaction.order = self.order + transaction.save() + + self.was_cancelled = True + self.save() + + def delete(self, *args, **kwargs): + # Don't delete transaction, cancel it instead + self.cancel_transaction() + + def save(self, *args, **kwargs): + if self.id is None: + self._notify_user_about_new_transaction() + + return super().save(*args, **kwargs) + + +def generate_referral_code(): + """ Generate unique numeric referral code for User """ + from account.models import User + + while True: + allowed_chars = "0123456789" + code = get_random_string(settings.REFERRAL_CODE_LENGTH, allowed_chars) + + # Hacky code for migrations + if "referral_code" not in User._meta.fields: + return code + + if not User.objects.filter(referral_code=code).exists(): + return code + + +class BonusProgramMixin(models.Model): + balance = models.PositiveSmallIntegerField('Баланс, руб', default=0, editable=False) + referral_code = models.CharField(max_length=settings.REFERRAL_CODE_LENGTH, default=generate_referral_code, + editable=False) + + class Meta: + abstract = True + + @property + def bonus_history(self): + return BonusProgramTransaction.objects.filter(user_id=self.id) + + def update_balance(self, amount, bonus_type, comment=None, order=None): + # No underflow or dummy transactions allowed + if amount == 0 or (self.balance + amount) < 0: + return + + # Create bonus transaction, user's balance will be recalculated in post_save signal + transaction = BonusProgramTransaction(user_id=self.id, + amount=amount, type=bonus_type, + comment=comment, order=order) + transaction.save() + + def recalculate_balance(self): + # TODO: use this method when checking the available balance upon order creation + total_balance = BonusProgramTransaction.objects \ + .filter(user_id=self.id) \ + .aggregate(total_balance=Sum('amount'))['total_balance'] or 0 + + self.balance = max(0, total_balance) + self.save(update_fields=['balance']) + + def add_signup_bonus(self): + bonus_type = BonusType.SIGNUP + amount = self._get_bonus_amount("signup") + + already_exists = (BonusProgramTransaction.objects + .filter(user_id=self.id, type=bonus_type) + .exists()) + if already_exists: + self._log(logging.INFO, "User already had signup bonus") + return + + self.update_balance(amount, bonus_type) + + def add_order_bonus(self, order): + from store.models import Checklist + + bonus_type = BonusType.DEFAULT_PURCHASE + amount = self._get_bonus_amount("default_purchase") + + if order.status != Checklist.Status.CHINA_RUSSIA: + return + + already_exists = (BonusProgramTransaction.objects + .filter(user_id=self.id, type=bonus_type, order_id=order.id) + .exists()) + + if already_exists: + self._log(logging.INFO, f"User already got bonus for order #{order.id}") + return + + self.update_balance(amount, bonus_type, order=order) + + def add_referral_bonus(self, order: Checklist, for_inviter: bool): + amount = self._get_bonus_amount("referral") + + # Check if data is sufficient + if order.customer_id is None or order.customer.inviter is None: + return + + # Check if eligible + # Bonus is only for first purchase and only for orders that reached the CHINA_RUSSIA status + if order.status != Checklist.Status.CHINA_RUSSIA or order.customer.customer_orders.count() != 1: + return + + user = order.customer.inviter if for_inviter else order.customer + bonus_type = BonusType.FOR_INVITER if for_inviter else BonusType.INVITED_FIRST_PURCHASE + + # Check if user didn't receive bonus yet + already_exists = (BonusProgramTransaction.objects + .filter(user_id=user.id, type=bonus_type, order_id=order.id) + .exists()) + + if already_exists: + self._log(logging.INFO, f"User already got referral bonus for order #{order.id}") + return + + # Add bonuses + user.update_balance(amount, bonus_type, order=order) + + @staticmethod + def _get_bonus_amount(config_key) -> int: + amount = 0 + with suppress(KeyError): + amount = settings.BONUS_PROGRAM_CONFIG["amounts"][config_key] + + return amount + + # TODO: move to custom logger + def _log(self, level, message: str): + message = f"[BonusProgram #{self.id}] {message}" + logger.log(level, message) diff --git a/account/models/user.py b/account/models/user.py new file mode 100644 index 0000000..ea2b92c --- /dev/null +++ b/account/models/user.py @@ -0,0 +1,201 @@ +import logging + +from asgiref.sync import sync_to_async +from django.contrib.admin import display +from django.contrib.auth.hashers import make_password +from django.contrib.auth.models import UserManager as _UserManager, AbstractUser +from django.core.exceptions import ValidationError + +from django.db import models +from django.db.models import Q +from django.utils.translation import gettext_lazy as _ +from phonenumber_field.modelfields import PhoneNumberField +from phonenumber_field.phonenumber import PhoneNumber + +from account.models import BonusProgramMixin +from store.utils import concat_not_null_values +from tg_bot.tasks import send_tg_message + +logger = logging.getLogger(__name__) + + +class UserQuerySet(models.QuerySet): + # TODO: optimize queries + def with_base_related(self): + return self + + +class UserManager(models.Manager.from_queryset(UserQuerySet), _UserManager): + def _create_user(self, email, password, **extra_fields): + if email: + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.password = make_password(password) + user.save(using=self._db) + return user + + def create_user(self, email=None, password=None, **extra_fields): + extra_fields.setdefault("is_staff", False) + return self._create_user(email, password, **extra_fields) + + def create_draft_user(self, **extra_fields): + extra_fields.setdefault("is_staff", False) + extra_fields.setdefault("is_draft_user", True) + + return self._create_user(email=None, password=None, **extra_fields) + + def create_superuser(self, email=None, password=None, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("role", User.ADMIN) + + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True.") + + return self._create_user(email, password, **extra_fields) + + def invite_user(self, referral_code, user_id): + inviter = User.objects.filter(referral_code=referral_code).first() + user_to_invite = User.objects.filter(id=user_id).first() + if inviter is None or user_to_invite is None: + return + + if inviter.id == user_to_invite.id: + logger.warning(f"User #{inviter.id} tried to invite himself via referral code {referral_code}") + return + + obj, created = ReferralRelationship.objects.get_or_create(inviter_id=inviter.id, invited_id=user_to_invite.id) + if not created: + logger.warning(f"User #{inviter.id} already invited user #{user_id}") + return + + async def bind_tg_user(self, tg_user_id, phone, referral_code=None): + # Normalize phone: 79111234567 -> +79111234567 + phone = PhoneNumber.from_string(phone).as_e164 + + """ + 1) No user with given phone or tg_user_id -> create draft user, add tg_user_id & phone + 2) User exists with given phone, but no tg_user_id -> add tg_user_id to User + 3) User exists with tg_user_id, but no phone -> add phone to User + 4) User exists with given tg_user_id & phone -> just authorize + """ + + user = await User.objects.filter( + Q(phone=phone) | (Q(tg_user_id=tg_user_id)) + ).afirst() + + freshly_created = False + + # Sign up through Telegram bot + if user is None: + user = await sync_to_async(self.create_draft_user)(phone=phone, tg_user_id=tg_user_id) + logger.info(f"tgbot: Created draft user #{user.id} for phone [{phone}]") + freshly_created = True + + # First-time binding Telegram <-> User ? + if freshly_created or user.tg_user_id is None: + # Add bonus for Telegram login + await sync_to_async(user.add_signup_bonus)() + + # Create referral relationship + # Only for fresh registration + if freshly_created and referral_code is not None: + await sync_to_async(User.objects.invite_user)(referral_code, user.id) + + # Bind Telegram chat to user + if not freshly_created: + user.phone = phone + user.tg_user_id = tg_user_id + await user.asave() + + logger.info(f"tgbot: Telegram user #{tg_user_id} was bound to user #{user.id}") + + +class User(BonusProgramMixin, AbstractUser): + ADMIN = "admin" + ORDER_MANAGER = "ordermanager" + PRODUCT_MANAGER = "productmanager" + CLIENT = "client" + + ROLE_CHOICES = ( + (ADMIN, 'Администратор'), + (ORDER_MANAGER, 'Менеджер по заказам'), + (PRODUCT_MANAGER, 'Менеджер по закупкам'), + (CLIENT, 'Клиент'), + ) + + # Login by email + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = ['phone'] + username = None + + email = models.EmailField("Эл. почта", blank=True, null=True, unique=True) + + # Base info + first_name = models.CharField(_("first name"), max_length=150, blank=True, null=True) + last_name = models.CharField(_("last name"), max_length=150, blank=True, null=True) + middle_name = models.CharField("Отчество", max_length=150, blank=True, null=True) + role = models.CharField("Роль", max_length=30, choices=ROLE_CHOICES, default=CLIENT, editable=False) + + # Contacts + phone = PhoneNumberField('Телефон', null=True, blank=True, unique=True) + telegram = models.CharField('Telegram', max_length=100, null=True, blank=True) + + # Bot-related + + # User is created via Telegram bot and has no password yet. + # User can set initial password via /users/set_initial_password/ + is_draft_user = models.BooleanField("Черновик пользователя", default=False) + + tg_user_id = models.BigIntegerField("id пользователя в Telegram", null=True, blank=True, unique=True) + + objects = UserManager() + + def __str__(self): + value = self.email or self.phone or self.id + return str(value) + + @property + def is_superuser(self): + return self.role == self.ADMIN + + @property + def is_manager(self): + return self.role in (self.ADMIN, self.ORDER_MANAGER, self.PRODUCT_MANAGER) + + @display(description='ФИО') + def full_name(self): + return concat_not_null_values(self.last_name, self.first_name, self.middle_name) + + @property + def invited_users(self): + return User.objects.filter(user_inviter__inviter=self.id) + + @property + def inviter(self): + return User.objects.filter(user_invited__invited=self.id).first() + + def notify_tg_bot(self, message, **kwargs): + if self.tg_user_id is None: + return + + send_tg_message.delay(self.tg_user_id, message, **kwargs) + + def save(self, *args, **kwargs): + # If password changed, it is no longer a draft User + if self._password is not None: + self.is_draft_user = False + + super().save(*args, **kwargs) + + +class ReferralRelationship(models.Model): + invited_at = models.DateTimeField(auto_now_add=True) + inviter = models.ForeignKey('User', on_delete=models.CASCADE, related_name="user_invited") + invited = models.ForeignKey('User', on_delete=models.CASCADE, related_name="user_inviter") + + class Meta: + unique_together = (('inviter', 'invited'),) + + def clean(self): + if self.inviter_id == self.invited_id: + raise ValidationError("User can't invite himself") diff --git a/account/permissions.py b/account/permissions.py new file mode 100644 index 0000000..5c6e4c7 --- /dev/null +++ b/account/permissions.py @@ -0,0 +1,22 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +class ReadOnly(BasePermission): + def has_permission(self, request, view): + return request.method in SAFE_METHODS + + +class IsClient(BasePermission): + def has_permission(self, request, view): + from account.models import User + return request.user.is_authenticated and request.user.role == User.CLIENT + + +class IsManager(BasePermission): + def has_permission(self, request, view): + return request.user.is_authenticated and request.user.is_manager + + +class IsAdmin(BasePermission): + def has_permission(self, request, view): + return request.user.is_authenticated and request.user.is_superuser diff --git a/account/serializers.py b/account/serializers.py new file mode 100644 index 0000000..fd6624c --- /dev/null +++ b/account/serializers.py @@ -0,0 +1,104 @@ +from django.conf import settings +from django.db.models import Q +from djoser import serializers as djoser_serializers +from djoser.conf import settings as djoser_settings +from rest_framework import serializers +from rest_framework.exceptions import AuthenticationFailed + +from .models import User, BonusProgramTransaction, BonusType +from .utils import verify_telegram_authentication + + +class UserSerializer(serializers.ModelSerializer): + name = serializers.CharField(source='first_name') + lastname = serializers.CharField(source='middle_name') + surname = serializers.CharField(source='last_name') + + class Meta: + model = User + fields = ('id', 'email', 'phone', 'role', 'name', 'lastname', 'surname', 'balance', 'referral_code', 'is_draft_user') + + +class BonusProgramTransactionSerializer(serializers.ModelSerializer): + type = serializers.CharField(source='get_type_display') + + class Meta: + model = BonusProgramTransaction + fields = ('id', 'type', 'date', 'amount', 'comment', 'was_cancelled') + + +def non_zero_validator(value): + if value == 0: + raise serializers.ValidationError("Value cannot be zero") + return value + + +class UserBalanceUpdateSerializer(BonusProgramTransactionSerializer): + amount = serializers.IntegerField(validators=[non_zero_validator]) + type = serializers.SerializerMethodField() + + class Meta: + model = BonusProgramTransactionSerializer.Meta.model + fields = BonusProgramTransactionSerializer.Meta.fields + read_only_fields = ('id', 'type', 'date') + + def get_type(self, instance): + # Deposit or spent depending on value + if instance['amount'] < 0: + return BonusType.OTHER_WITHDRAWAL + elif instance['amount'] > 0: + return BonusType.OTHER_DEPOSIT + + +class SetInitialPasswordSerializer(djoser_serializers.PasswordSerializer): + def validate(self, attrs): + user = getattr(self, "user", None) or self.context["request"].user + # why assert? There are ValidationError / fail everywhere + assert user is not None + + if not user.is_superuser and not user.is_draft_user: + raise serializers.ValidationError("To change password, use /users/change_password endpoint") + + return super().validate(attrs) + + +class UserCreateSerializer(djoser_serializers.UserCreateSerializer): + email = serializers.EmailField(required=True) + + +class TokenCreateSerializer(serializers.Serializer): + email_or_phone = serializers.CharField() + password = serializers.CharField(required=False, style={"input_type": "password"}) + + default_error_messages = { + "invalid_credentials": djoser_settings.CONSTANTS.messages.INVALID_CREDENTIALS_ERROR, + "inactive_account": djoser_settings.CONSTANTS.messages.INACTIVE_ACCOUNT_ERROR, + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.user = None + + def validate(self, attrs): + email_or_phone = attrs.get('email_or_phone') + password = attrs.get("password") + + user = User.objects.filter(Q(email=email_or_phone) | Q(phone=email_or_phone)).first() + if not user or not user.check_password(password) or not user.is_active: + raise AuthenticationFailed() + + self.user = user + return attrs + + +class TelegramCallbackSerializer(serializers.Serializer): + id = serializers.IntegerField() + first_name = serializers.CharField(allow_null=True) + username = serializers.CharField(allow_null=True) + photo_url = serializers.URLField(allow_null=True) + auth_date = serializers.IntegerField() + hash = serializers.CharField() + + def validate(self, attrs): + verify_telegram_authentication(bot_token=settings.TG_BOT_TOKEN, request_data=attrs) + return attrs diff --git a/account/signals.py b/account/signals.py new file mode 100644 index 0000000..f10f176 --- /dev/null +++ b/account/signals.py @@ -0,0 +1,29 @@ +import logging + +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver + +from account.models import User, ReferralRelationship, BonusProgramTransaction + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender=User) +def handle_user_save(sender, instance: User, created, **kwargs): + pass + + +@receiver(post_save, sender=ReferralRelationship) +def handle_invitation_save(sender, instance: ReferralRelationship, created, **kwargs): + if created: + logger.info(f"User {instance.inviter_id} invited {instance.invited_id}") + # TODO: notify about invitation + + +@receiver(post_save, sender=BonusProgramTransaction) +@receiver(post_delete, sender=BonusProgramTransaction) +def handle_bonus_transaction_savedelete(sender, instance: BonusProgramTransaction, **kwargs): + # Recalculate user's balance + if instance.user is not None: + instance.user.recalculate_balance() + diff --git a/account/tests.py b/account/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/account/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/account/urls.py b/account/urls.py new file mode 100644 index 0000000..5ac638b --- /dev/null +++ b/account/urls.py @@ -0,0 +1,13 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from account import views + +router = DefaultRouter() +router.register("users", views.UserViewSet) + +urlpatterns = [ + path('', include(router.urls)), + path('auth/', include('djoser.urls.authtoken')), + path('auth/telegram/', views.TelegramLoginForm.as_view()), +] \ No newline at end of file diff --git a/account/utils.py b/account/utils.py new file mode 100644 index 0000000..abd6e20 --- /dev/null +++ b/account/utils.py @@ -0,0 +1,57 @@ +import hashlib +import hmac +import time + + +class NotTelegramDataError(Exception): + """ The verification algorithm did not authorize Telegram data. """ + pass + + +class TelegramDataIsOutdatedError(Exception): + """ The Telegram data is outdated. """ + pass + + +# Source: https://github.com/dmytrostriletskyi/django-telegram-login/blob/master/django_telegram_login/authentication.py +def verify_telegram_authentication(bot_token, request_data): + """ + Check if received data from Telegram is real. + + Based on SHA and HMAC algothims. + Instructions - https://core.telegram.org/widgets/login#checking-authorization + """ + ONE_DAY_IN_SECONDS = 86400 + + request_data = request_data.copy() + + received_hash = request_data['hash'] + auth_date = request_data['auth_date'] + + request_data.pop('hash', None) + request_data_alphabetical_order = sorted(request_data.items(), key=lambda x: x[0]) + + data_check_string = [] + + for data_pair in request_data_alphabetical_order: + key, value = data_pair[0], str(data_pair[1]) + data_check_string.append(key + '=' + value) + + data_check_string = '\n'.join(data_check_string) + + secret_key = hashlib.sha256(bot_token.encode()).digest() + _hash = hmac.new(secret_key, msg=data_check_string.encode(), digestmod=hashlib.sha256).hexdigest() + + unix_time_now = int(time.time()) + unix_time_auth_date = int(auth_date) + + if unix_time_now - unix_time_auth_date > ONE_DAY_IN_SECONDS: + raise TelegramDataIsOutdatedError( + 'Authentication data is outdated. Authentication was received more than day ago.' + ) + + if _hash != received_hash: + raise NotTelegramDataError( + 'This is not a Telegram data. Hash from recieved authentication data does not match' + 'with calculated hash based on bot token.' + ) diff --git a/account/views.py b/account/views.py new file mode 100644 index 0000000..1c4263e --- /dev/null +++ b/account/views.py @@ -0,0 +1,153 @@ +from django.conf import settings +from djoser import views as djoser_views +from djoser.conf import settings as djoser_settings +from djoser.permissions import CurrentUserOrAdmin +from djoser.utils import login_user +from rest_framework import views, status +from rest_framework.decorators import action +from rest_framework.exceptions import NotFound, ValidationError, MethodNotAllowed +from rest_framework.permissions import AllowAny +from rest_framework.renderers import StaticHTMLRenderer +from rest_framework.response import Response + +from account.models import User +from account.serializers import SetInitialPasswordSerializer, BonusProgramTransactionSerializer, \ + UserBalanceUpdateSerializer, TelegramCallbackSerializer +from tg_bot.handlers.start import request_phone_sync +from tg_bot.messages import TGCoreMessage +from tg_bot.bot import bot_sync + + +class UserViewSet(djoser_views.UserViewSet): + """ Replacement for Djoser's UserViewSet """ + + def permission_denied(self, request, **kwargs): + if ( + djoser_settings.HIDE_USERS + and request.user.is_authenticated + and self.action in ["balance"] + ): + raise NotFound() + super().permission_denied(request, **kwargs) + + def get_permissions(self): + if self.action == "set_initial_password": + self.permission_classes = djoser_settings.PERMISSIONS.set_password + + return super().get_permissions() + + def get_serializer_class(self): + if self.action == "set_initial_password": + return SetInitialPasswordSerializer + + return super().get_serializer_class() + + @action(["post"], detail=False) + def set_initial_password(self, request, *args, **kwargs): + return super().set_password(request, *args, **kwargs) + + @action(["get", "patch"], detail=True, permission_classes=[CurrentUserOrAdmin]) + def balance(self, request, *args, **kwargs): + user = self.get_object() + + if request.method == "GET": + serializer = BonusProgramTransactionSerializer(user.bonus_history, many=True) + return Response(serializer.data) + + elif request.method == "PATCH": + if not request.user.is_superuser: + return self.permission_denied(request) + + # No balance underflow or dummy transactions allowed, no error will be raised + serializer = UserBalanceUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.data + user.update_balance(amount=data['amount'], bonus_type=data['type'], comment=data['comment']) + + list_serializer = BonusProgramTransactionSerializer(user.bonus_history, many=True) + return Response(list_serializer.data) + + @action(["get"], url_path="me/balance", detail=False, permission_classes=[CurrentUserOrAdmin]) + def me_balance(self, request, *args, **kwargs): + self.get_object = self.get_instance + return self.balance(request, *args, **kwargs) + + +class TelegramLoginForm(views.APIView): + permission_classes = [AllowAny] + + def get_renderers(self): + if self.request.method == "GET" and settings.DEBUG: + return [StaticHTMLRenderer()] + + return super().get_renderers() + + def get(self, request, *args, **kwargs): + if not settings.DEBUG: + raise MethodNotAllowed(request.method) + + source = """ + +
+ + + + + + """ + + return Response(source) + + def post(self, request, *args, **kwargs): + serializer = TelegramCallbackSerializer(data=request.data) + + try: + serializer.is_valid(raise_exception=True) + + except ValidationError as e: + return Response(e.detail, status=status.HTTP_401_UNAUTHORIZED) + + except: + return Response(status=status.HTTP_401_UNAUTHORIZED) + + data = serializer.data + + # Authenticate user with given tg_user_id + tg_user_id = data["id"] + + user: User = User.objects.filter(tg_user_id=tg_user_id).first() + if not user: + # Sign up user + user = User.objects.create_draft_user(tg_user_id=tg_user_id) + # Request the phone through the bot + request_phone_sync(tg_user_id, TGCoreMessage.SIGN_UP_SHARE_PHONE) + + token = login_user(request, user) + return Response({"auth_token": token.key}) diff --git a/external_api/poizon.py b/external_api/poizon.py index 7379fde..24858ca 100644 --- a/external_api/poizon.py +++ b/external_api/poizon.py @@ -49,6 +49,4 @@ class PoizonClient: def get_good_info(self, spu_id): params = {'spuId': str(spu_id)} - r = self.request('GET', self.SPU_GET_DATA_ENDPOINT, params=params) - return r.json() - + return self.request('GET', self.SPU_GET_DATA_ENDPOINT, params=params) diff --git a/poizonstore/settings.py b/poizonstore/settings.py index 8bd183c..759aa49 100644 --- a/poizonstore/settings.py +++ b/poizonstore/settings.py @@ -71,7 +71,12 @@ CORS_ALLOWED_ORIGINS = [ if DISABLE_CORS: CORS_ALLOW_ALL_ORIGINS = True -AUTH_USER_MODEL = 'store.User' +# Required for "Login via Telegram" popup +# Source: https://stackoverflow.com/a/73240366/24046062 +SECURE_CROSS_ORIGIN_OPENER_POLICY = 'same-origin-allow-popups' + +AUTH_USER_MODEL = 'account.User' +PHONENUMBER_DEFAULT_REGION = 'RU' # Application definition @@ -93,7 +98,9 @@ INSTALLED_APPS = [ 'django_filters', 'mptt', - 'store' + 'account', + 'store', + 'tg_bot' ] MIDDLEWARE = [ @@ -178,12 +185,13 @@ REST_FRAMEWORK = { } DJOSER = { - 'LOGIN_FIELD': 'email', 'TOKEN_MODEL': 'rest_framework.authtoken.models.Token', 'SERIALIZERS': { - 'user': 'store.serializers.UserSerializer', - 'current_user': 'store.serializers.UserSerializer', + 'user': 'account.serializers.UserSerializer', + 'current_user': 'account.serializers.UserSerializer', + 'user_create': 'account.serializers.UserCreateSerializer', + 'token_create': 'account.serializers.TokenCreateSerializer', }, } @@ -216,6 +224,7 @@ MEDIA_ROOT = os.path.join(BASE_DIR, "media") DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' CHECKLIST_ID_LENGTH = 10 +REFERRAL_CODE_LENGTH = 10 COMMISSION_OVER_150K = 1.1 # Logging @@ -233,10 +242,20 @@ if not DEBUG: ) # Celery -BROKER_URL = 'redis://localhost:6379/1' +BROKER_URL = 'redis://localhost:6379/2' CELERY_RESULT_BACKEND = BROKER_URL CELERY_BROKER_URL = BROKER_URL CELERY_ACCEPT_CONTENT = ['application/json'] CELERY_TASK_SERIALIZER = 'json' CELERY_RESULT_SERIALIZER = 'json' CELERY_TIMEZONE = TIME_ZONE + +# Bonus program +# TODO: move to GlobalSettings? +BONUS_PROGRAM_CONFIG = { + "amounts": { + "signup": 150, + "default_purchase": 50, + "referral": 500, + } +} diff --git a/poizonstore/urls.py b/poizonstore/urls.py index 500551e..2cf3d1f 100644 --- a/poizonstore/urls.py +++ b/poizonstore/urls.py @@ -24,8 +24,7 @@ urlpatterns = [ path('admin/', admin.site.urls), path('__debug__/', include('debug_toolbar.urls')), path('', include('store.urls')), - path('', include('djoser.urls')), - path('auth/', include('djoser.urls.authtoken')), + path('', include('account.urls')), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ + static(settings.STATIC_URL) diff --git a/requirements.txt b/requirements.txt index f8c6ae0..1c18fcb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,12 +8,17 @@ django-cors-headers==4.1.0 djoser==2.2.0 drf-extra-fields==3.5.0 Pillow==9.5.0 +django-phonenumber-field[phonenumberslite] # Tasks celery==5.3.6 redis==5.0.1 flower==2.0.1 +# Telegram bot +pyTelegramBotAPI==4.17.0 +aiohttp==3.9.4 + # Misc tqdm==4.65.0 django-debug-toolbar==4.1.0 @@ -24,4 +29,6 @@ sentry-sdk==1.34.0 sentry-telegram-py3==0.6.1 # Deployment +# gunicorn==20.1.0 uWSGI==2.0.21 +inotify==0.2.10 diff --git a/run_tg_bot.py b/run_tg_bot.py new file mode 100644 index 0000000..93f84d7 --- /dev/null +++ b/run_tg_bot.py @@ -0,0 +1,37 @@ +import os +import asyncio +import logging +import sys + +import django +import telebot +from telebot import asyncio_filters + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'poizonstore.settings') +django.setup() + +from tg_bot.bot import bot, commands +from tg_bot.handlers import register_handlers + + +logger = telebot.logger +telebot.logger.setLevel(logging.DEBUG) + + +async def setup(): + await bot.delete_my_commands(scope=None, language_code=None) + + bot.add_custom_filter(asyncio_filters.StateFilter(bot)) + register_handlers() + await bot.set_my_commands(commands=commands) + + +async def main(): + logger.info("bot starting...") + await setup() + await bot.infinity_polling() + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, stream=sys.stdout) + asyncio.run(main()) diff --git a/store/admin.py b/store/admin.py index 8d1a1c6..6d1be39 100644 --- a/store/admin.py +++ b/store/admin.py @@ -2,12 +2,7 @@ from django.contrib import admin from django.contrib.admin import display from mptt.admin import MPTTModelAdmin -from .models import Category, Checklist, GlobalSettings, PaymentMethod, Promocode, User, Image, Gift - - -@admin.register(User) -class UserAdmin(admin.ModelAdmin): - list_display = ('email', 'job_title', 'full_name',) +from .models import Category, Checklist, GlobalSettings, PaymentMethod, Promocode, Image, Gift @admin.register(Category) diff --git a/store/management/commands/create_test_data.py b/store/management/commands/create_test_data.py index 5fd7d8a..a76a16c 100644 --- a/store/management/commands/create_test_data.py +++ b/store/management/commands/create_test_data.py @@ -2,24 +2,33 @@ from django.contrib.auth.hashers import make_password from django.core.management import BaseCommand from tqdm import tqdm -from store.models import User +from account.models import User users = [ { "email": "poizonstore@mail.ru", "password": "219404Poizon", - "job_title": User.ADMIN, + "first_name": "Илья", + "middle_name": "Сергеевич", + "last_name": "Савочкин", + "role": User.ADMIN, "is_staff": True }, { "email": "poizonmanager1@mail.ru", "password": "poizonm1", - "job_title": User.PRODUCT_MANAGER + "first_name": "Патрик", + "middle_name": "Сергеевич", + "last_name": "Стар", + "role": User.PRODUCT_MANAGER }, { "email": "poizonorder1@mail.ru", "password": "2193071Po1", - "job_title": User.ORDER_MANAGER + "first_name": "Гоша", + "middle_name": "Альбах", + "last_name": "Абызов", + "role": User.ORDER_MANAGER } ] diff --git a/store/migrations/0001_initial.py b/store/migrations/0001_initial.py index bf3a2f7..d0c844a 100644 --- a/store/migrations/0001_initial.py +++ b/store/migrations/0001_initial.py @@ -1,9 +1,11 @@ -# Generated by Django 4.2.2 on 2023-06-30 22:04 +# Generated by Django 4.2.2 on 2024-03-28 22:05 +import datetime from django.conf import settings +import django.core.validators from django.db import migrations, models import django.db.models.deletion -import django.utils.timezone +import mptt.fields import store.models @@ -12,96 +14,149 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ - migrations.CreateModel( - name='User', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('email', models.EmailField(max_length=254, unique=True, verbose_name='Эл. почта')), - ('first_name', models.CharField(blank=True, max_length=150, null=True, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, null=True, verbose_name='last name')), - ('middle_name', models.CharField(blank=True, max_length=150, null=True, verbose_name='Отчество')), - ('job_title', models.CharField(choices=[('admin', 'Администратор'), ('ordermanager', 'Менеджер по заказам'), ('productmanager', 'Менеджер по закупкам')], max_length=30, verbose_name='Должность')), - ('manager_id', models.CharField(blank=True, max_length=5, null=True, verbose_name='ID менеджера')), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), - ], - options={ - 'verbose_name': 'user', - 'verbose_name_plural': 'users', - 'abstract': False, - }, - managers=[ - ('objects', store.models.UserManager()), - ], - ), migrations.CreateModel( name='Category', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=20, verbose_name='Название')), - ('slug', models.SlugField(verbose_name='Идентификатор')), + ('delivery_price_CN_RU', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена доставки Китай-РФ')), + ('commission', models.DecimalField(decimal_places=2, default=0, max_digits=10, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)], verbose_name='Дополнительная комиссия, %')), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='store.category', verbose_name='Родительская категория')), ], + options={ + 'verbose_name': 'Категория', + 'verbose_name_plural': 'Категории', + }, + ), + migrations.CreateModel( + name='Gift', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='Название')), + ('image', models.ImageField(blank=True, null=True, upload_to='gifts/', verbose_name='Фото')), + ('min_price', models.DecimalField(decimal_places=2, default=0, help_text='от какой суммы доступен подарок', max_digits=10, verbose_name='Минимальная цена в юанях')), + ('available_count', models.PositiveSmallIntegerField(default=0, verbose_name='Доступное количество')), + ], + options={ + 'verbose_name': 'Подарок', + 'verbose_name_plural': 'Подарки', + }, ), migrations.CreateModel( name='GlobalSettings', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('yuan_rate', models.DecimalField(decimal_places=2, max_digits=10)), - ('delivery_price_CN', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), - ('delivery_price_CN_RU', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), - ('commission_rub', models.DecimalField(decimal_places=2, default=0, max_digits=10)), + ('yuan_rate', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Курс CNY/RUB')), + ('yuan_rate_last_updated', models.DateTimeField(default=None, null=True, verbose_name='Дата обновления курса CNY/RUB')), + ('yuan_rate_commission', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Наценка на курс юаня, руб')), + ('delivery_price_CN', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена доставки по Китаю')), + ('commission_rub', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Комиссия, руб')), + ('pickup_address', models.CharField(blank=True, max_length=200, null=True, verbose_name='Адрес пункта самовывоза')), + ('time_to_buy', models.DurationField(default=datetime.timedelta(seconds=10800), help_text="Через N времени заказ переходит из статуса 'Новый' в 'Черновик'", verbose_name='Время на покупку')), + ], + options={ + 'verbose_name': 'Глобальные настройки', + 'verbose_name_plural': 'Глобальные настройки', + }, + ), + migrations.CreateModel( + name='Image', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.ImageField(upload_to=store.models.image_upload_path, verbose_name='Файл изображения')), + ('type', models.PositiveSmallIntegerField(choices=[(0, 'Изображение'), (1, 'Превью'), (2, 'Документ'), (3, 'Подарок')], default=0, verbose_name='Тип')), + ], + options={ + 'verbose_name': 'Изображение', + 'verbose_name_plural': 'Изображения', + }, + ), + migrations.CreateModel( + name='PaymentMethod', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30, verbose_name='Название')), + ('slug', models.SlugField(unique=True, verbose_name='Идентификатор')), + ('cardnumber', models.CharField(blank=True, max_length=30, null=True, verbose_name='Номер карты')), + ('requisites', models.CharField(blank=True, max_length=200, null=True, verbose_name='Реквизиты')), + ], + options={ + 'verbose_name': 'Метод оплаты', + 'verbose_name_plural': 'Методы оплаты', + }, + ), + migrations.CreateModel( + name='PriceSnapshot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('yuan_rate', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Курс CNY/RUB')), + ('delivery_price_CN', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена доставки по Китаю')), + ('delivery_price_CN_RU', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена доставки Китай-РФ')), + ('commission_rub', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Комиссия, руб')), ], ), migrations.CreateModel( - name='PromoCode', + name='Promocode', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100, verbose_name='Название')), - ('discount', models.PositiveSmallIntegerField(verbose_name='Скидка')), + ('name', models.CharField(max_length=100, unique=True, verbose_name='Название')), + ('discount', models.PositiveIntegerField(verbose_name='Скидка в рублях')), ('free_delivery', models.BooleanField(default=False, verbose_name='Бесплатная доставка')), ('no_comission', models.BooleanField(default=False, verbose_name='Без комиссии')), + ('is_active', models.BooleanField(default=True, verbose_name='Активен')), ], + options={ + 'verbose_name': 'Промокод', + 'verbose_name_plural': 'Промокоды', + }, ), migrations.CreateModel( name='Checklist', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created_at', models.DateTimeField(auto_now_add=True)), - ('status_updated_at', models.DateTimeField()), - ('status', models.CharField(choices=[('draft', 'Черновик'), ('neworder', 'Новый заказ'), ('payment', 'Проверка оплаты'), ('buying', 'На закупке'), ('china', 'На складе в Китае'), ('chinarush', 'Доставка на склад РФ'), ('rush', 'На складе в РФ'), ('completed', 'Завершен')], max_length=15, verbose_name='Статус заказа')), - ('product_link', models.URLField(blank=True, null=True)), - ('subcategory', models.CharField(blank=True, max_length=20, null=True, verbose_name='Подкатегория')), + ('status_updated_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата обновления статуса заказа')), + ('id', models.CharField(default=store.models.generate_checklist_id, editable=False, max_length=10, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('draft', 'Черновик'), ('neworder', 'Новый заказ'), ('payment', 'Проверка оплаты'), ('buying', 'На закупке'), ('bought', 'Закуплен'), ('china', 'На складе в Китае'), ('chinarush', 'Доставка на склад РФ'), ('rush', 'На складе в РФ'), ('split_waiting', 'Сплит: ожидание оплаты 2й части'), ('split_paid', 'Сплит: полностью оплачено'), ('cdek', 'Доставляется СДЭК'), ('completed', 'Завершен')], default='neworder', max_length=15, verbose_name='Статус заказа')), + ('product_link', models.URLField(blank=True, null=True, verbose_name='Ссылка на товар')), ('brand', models.CharField(blank=True, max_length=100, null=True, verbose_name='Бренд')), ('model', models.CharField(blank=True, max_length=100, null=True, verbose_name='Модель')), - ('size', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Размер')), - ('image', models.ImageField(blank=True, null=True, upload_to='')), - ('preview_image', models.ImageField(blank=True, null=True, upload_to='')), - ('price_yuan', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), - ('comission', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), - ('real_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), - ('promocode', models.CharField(blank=True, max_length=100, null=True, verbose_name='Промокод')), + ('size', models.CharField(blank=True, max_length=30, null=True, verbose_name='Размер')), + ('price_yuan', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена в юанях')), + ('real_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Реальная цена')), ('comment', models.CharField(blank=True, max_length=200, null=True, verbose_name='Комментарий')), ('buyer_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='Имя покупателя')), ('buyer_phone', models.CharField(blank=True, max_length=100, null=True, verbose_name='Телефон покупателя')), ('buyer_telegram', models.CharField(blank=True, max_length=100, null=True, verbose_name='Telegram покупателя')), ('receiver_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='Имя получателя')), ('receiver_phone', models.CharField(blank=True, max_length=100, null=True, verbose_name='Телефон получателя')), - ('payment_type', models.CharField(blank=True, choices=[('alfa', 'Альфа-Банк'), ('tink', 'Тинькофф Банк'), ('raif', 'Райффайзен Банк')], max_length=10, null=True, verbose_name='Метод оплаты')), - ('payment_proof', models.ImageField(blank=True, null=True, upload_to='', verbose_name='Подтверждение оплаты')), - ('cheque_photo', models.ImageField(blank=True, null=True, upload_to='', verbose_name='Фото чека')), - ('delivery', models.CharField(blank=True, choices=[('pickup', 'Самовывоз из шоурума'), ('cdek', 'Пункт выдачи заказов CDEK')], max_length=10, null=True, verbose_name='Тип доставки')), - ('track_number', models.CharField(blank=True, max_length=100, null=True, verbose_name='Трек-номер')), + ('is_split_payment', models.BooleanField(default=False, verbose_name='Оплата частями')), + ('payment_proof', models.ImageField(blank=True, null=True, upload_to='docs/', verbose_name='Подтверждение оплаты')), + ('split_payment_proof', models.ImageField(blank=True, null=True, upload_to='docs/', verbose_name='Подтверждение оплаты сплита')), + ('split_accepted', models.BooleanField(default=False, verbose_name='Сплит принят')), + ('receipt', models.ImageField(blank=True, null=True, upload_to='docs/', verbose_name='Фото чека')), + ('delivery', models.CharField(blank=True, choices=[('pickup', 'Самовывоз из шоурума'), ('cdek', 'Пункт выдачи заказов CDEK'), ('cdek_courier', 'Курьерская доставка CDEK')], max_length=15, null=True, verbose_name='Тип доставки')), + ('poizon_tracking', models.CharField(blank=True, max_length=100, null=True, verbose_name='Трек-номер Poizon')), + ('cdek_tracking', models.CharField(blank=True, max_length=100, null=True, verbose_name='Трек-номер СДЭК')), + ('cdek_barcode_pdf', models.FileField(blank=True, null=True, upload_to='docs', verbose_name='Штрих-код СДЭК в PDF')), ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.category', verbose_name='Категория')), - ('manager', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('gift', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.gift', verbose_name='Подарок')), + ('images', models.ManyToManyField(blank=True, related_name='+', to='store.image', verbose_name='Изображения')), + ('manager', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Менеджер')), + ('payment_method', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.paymentmethod', verbose_name='Метод оплаты')), + ('price_snapshot', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='checklist', to='store.pricesnapshot', verbose_name='Сохраненные цены')), + ('promocode', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='store.promocode', verbose_name='Промокод')), ], + options={ + 'verbose_name': 'Заказ', + 'verbose_name_plural': 'Заказы', + }, ), ] diff --git a/store/migrations/0002_alter_category_options_remove_checklist_comission_and_more.py b/store/migrations/0002_alter_category_options_remove_checklist_comission_and_more.py deleted file mode 100644 index 8e3fcfc..0000000 --- a/store/migrations/0002_alter_category_options_remove_checklist_comission_and_more.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 4.2.2 on 2023-06-30 22:11 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0001_initial'), - ] - - operations = [ - migrations.AlterModelOptions( - name='category', - options={'verbose_name': 'Category', 'verbose_name_plural': 'Categories'}, - ), - migrations.RemoveField( - model_name='checklist', - name='comission', - ), - migrations.AlterField( - model_name='checklist', - name='price_yuan', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Цена в юанях'), - ), - ] diff --git a/store/migrations/0002_checklist_customer_alter_checklist_manager.py b/store/migrations/0002_checklist_customer_alter_checklist_manager.py new file mode 100644 index 0000000..895440e --- /dev/null +++ b/store/migrations/0002_checklist_customer_alter_checklist_manager.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.2 on 2024-04-07 23:27 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('store', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='checklist', + name='customer', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='customer_orders', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='checklist', + name='manager', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='manager_orders', to=settings.AUTH_USER_MODEL, verbose_name='Менеджер'), + ), + ] diff --git a/store/migrations/0003_alter_checklist_id_alter_checklist_manager_and_more.py b/store/migrations/0003_alter_checklist_id_alter_checklist_manager_and_more.py deleted file mode 100644 index e7d14c7..0000000 --- a/store/migrations/0003_alter_checklist_id_alter_checklist_manager_and_more.py +++ /dev/null @@ -1,51 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-01 16:58 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0002_alter_category_options_remove_checklist_comission_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='checklist', - name='id', - field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='checklist', - name='manager', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Менеджер'), - ), - migrations.AlterField( - model_name='checklist', - name='product_link', - field=models.URLField(blank=True, null=True, verbose_name='Ссылка на товар'), - ), - migrations.AlterField( - model_name='checklist', - name='real_price', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Реальная цена'), - ), - migrations.AlterField( - model_name='checklist', - name='status_updated_at', - field=models.DateTimeField(verbose_name='Дата обновления статуса заказа'), - ), - migrations.AlterField( - model_name='globalsettings', - name='delivery_price_CN', - field=models.JSONField(blank=True, null=True), - ), - migrations.AlterField( - model_name='globalsettings', - name='delivery_price_CN_RU', - field=models.JSONField(blank=True, null=True), - ), - ] diff --git a/store/migrations/0003_remove_checklist_buyer_name_and_more.py b/store/migrations/0003_remove_checklist_buyer_name_and_more.py new file mode 100644 index 0000000..c92ae18 --- /dev/null +++ b/store/migrations/0003_remove_checklist_buyer_name_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.2 on 2024-04-21 03:21 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('store', '0002_checklist_customer_alter_checklist_manager'), + ] + + operations = [ + migrations.RemoveField( + model_name='checklist', + name='buyer_name', + ), + migrations.RemoveField( + model_name='checklist', + name='buyer_phone', + ), + migrations.RemoveField( + model_name='checklist', + name='buyer_telegram', + ), + ] diff --git a/store/migrations/0004_alter_globalsettings_options_and_more.py b/store/migrations/0004_alter_globalsettings_options_and_more.py deleted file mode 100644 index 2a50e6d..0000000 --- a/store/migrations/0004_alter_globalsettings_options_and_more.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-01 17:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0003_alter_checklist_id_alter_checklist_manager_and_more'), - ] - - operations = [ - migrations.AlterModelOptions( - name='globalsettings', - options={'verbose_name': 'GlobalSettings', 'verbose_name_plural': 'GlobalSettings'}, - ), - migrations.AlterField( - model_name='globalsettings', - name='yuan_rate', - field=models.DecimalField(decimal_places=2, default=0, max_digits=10), - ), - ] diff --git a/store/migrations/0005_alter_globalsettings_delivery_price_cn.py b/store/migrations/0005_alter_globalsettings_delivery_price_cn.py deleted file mode 100644 index 4232b73..0000000 --- a/store/migrations/0005_alter_globalsettings_delivery_price_cn.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-01 17:05 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0004_alter_globalsettings_options_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='globalsettings', - name='delivery_price_CN', - field=models.DecimalField(decimal_places=2, default=0, max_digits=10), - ), - ] diff --git a/store/migrations/0006_alter_checklist_price_yuan.py b/store/migrations/0006_alter_checklist_price_yuan.py deleted file mode 100644 index c4cf67b..0000000 --- a/store/migrations/0006_alter_checklist_price_yuan.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-01 17:11 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0005_alter_globalsettings_delivery_price_cn'), - ] - - operations = [ - migrations.AlterField( - model_name='checklist', - name='price_yuan', - field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена в юанях'), - ), - ] diff --git a/store/migrations/0007_alter_category_slug.py b/store/migrations/0007_alter_category_slug.py deleted file mode 100644 index c4fa133..0000000 --- a/store/migrations/0007_alter_category_slug.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-01 17:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0006_alter_checklist_price_yuan'), - ] - - operations = [ - migrations.AlterField( - model_name='category', - name='slug', - field=models.SlugField(unique=True, verbose_name='Идентификатор'), - ), - ] diff --git a/store/migrations/0008_alter_checklist_id.py b/store/migrations/0008_alter_checklist_id.py deleted file mode 100644 index 852880d..0000000 --- a/store/migrations/0008_alter_checklist_id.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-01 17:33 - -from django.db import migrations, models -import store.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0007_alter_category_slug'), - ] - - operations = [ - migrations.AlterField( - model_name='checklist', - name='id', - field=models.CharField(default=store.models.generate_checklist_id, editable=False, max_length=10, primary_key=True, serialize=False), - ), - ] diff --git a/store/migrations/0009_alter_checklist_status.py b/store/migrations/0009_alter_checklist_status.py deleted file mode 100644 index 54ab903..0000000 --- a/store/migrations/0009_alter_checklist_status.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-01 20:55 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0008_alter_checklist_id'), - ] - - operations = [ - migrations.AlterField( - model_name='checklist', - name='status', - field=models.CharField(choices=[('draft', 'Черновик'), ('neworder', 'Новый заказ'), ('payment', 'Проверка оплаты'), ('buying', 'На закупке'), ('china', 'На складе в Китае'), ('chinarush', 'Доставка на склад РФ'), ('rush', 'На складе в РФ'), ('completed', 'Завершен')], default='neworder', max_length=15, verbose_name='Статус заказа'), - ), - ] diff --git a/store/migrations/0010_alter_checklist_status.py b/store/migrations/0010_alter_checklist_status.py deleted file mode 100644 index d0b476b..0000000 --- a/store/migrations/0010_alter_checklist_status.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-01 21:04 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0009_alter_checklist_status'), - ] - - operations = [ - migrations.AlterField( - model_name='checklist', - name='status', - field=models.CharField(choices=[('draft', 'Черновик'), ('neworder', 'Новый заказ'), ('payment', 'Проверка оплаты'), ('buying', 'На закупке'), ('bought', 'Закуплен'), ('china', 'На складе в Китае'), ('chinarush', 'Доставка на склад РФ'), ('rush', 'На складе в РФ'), ('cdek', 'Доставляется СДЭК'), ('completed', 'Завершен')], default='neworder', max_length=15, verbose_name='Статус заказа'), - ), - ] diff --git a/store/migrations/0011_remove_globalsettings_delivery_price_cn_ru_and_more.py b/store/migrations/0011_remove_globalsettings_delivery_price_cn_ru_and_more.py deleted file mode 100644 index 29426be..0000000 --- a/store/migrations/0011_remove_globalsettings_delivery_price_cn_ru_and_more.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-01 21:43 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0010_alter_checklist_status'), - ] - - operations = [ - migrations.RemoveField( - model_name='globalsettings', - name='delivery_price_CN_RU', - ), - migrations.AddField( - model_name='category', - name='delivery_price_CN_RU', - field=models.DecimalField(decimal_places=2, default=0, max_digits=10), - ), - ] diff --git a/store/migrations/0012_alter_category_delivery_price_cn_ru_and_more.py b/store/migrations/0012_alter_category_delivery_price_cn_ru_and_more.py deleted file mode 100644 index 811e4b8..0000000 --- a/store/migrations/0012_alter_category_delivery_price_cn_ru_and_more.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-01 21:46 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0011_remove_globalsettings_delivery_price_cn_ru_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='category', - name='delivery_price_CN_RU', - field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена доставки Китай-РФ'), - ), - migrations.AlterField( - model_name='globalsettings', - name='commission_rub', - field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Комиссия, руб'), - ), - migrations.AlterField( - model_name='globalsettings', - name='delivery_price_CN', - field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена доставки по Китаю'), - ), - migrations.AlterField( - model_name='globalsettings', - name='yuan_rate', - field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Курс CNY/RUB'), - ), - ] diff --git a/store/migrations/0013_paymenttype_alter_category_options_and_more.py b/store/migrations/0013_paymenttype_alter_category_options_and_more.py deleted file mode 100644 index 3a6057e..0000000 --- a/store/migrations/0013_paymenttype_alter_category_options_and_more.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-01 22:30 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0012_alter_category_delivery_price_cn_ru_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='PaymentType', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('slug', models.SlugField(unique=True)), - ('requisites', models.CharField(max_length=200, verbose_name='Реквизиты')), - ], - options={ - 'verbose_name': 'Метод оплаты', - 'verbose_name_plural': 'Методы оплаты', - }, - ), - migrations.AlterModelOptions( - name='category', - options={'verbose_name': 'Категория', 'verbose_name_plural': 'Категории'}, - ), - migrations.AlterModelOptions( - name='globalsettings', - options={'verbose_name': 'Глобальные настройки', 'verbose_name_plural': 'Глобальные настройки'}, - ), - migrations.AlterField( - model_name='checklist', - name='payment_type', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.paymenttype', verbose_name='Метод оплаты'), - ), - ] diff --git a/store/migrations/0014_rename_paymenttype_paymentmethod_and_more.py b/store/migrations/0014_rename_paymenttype_paymentmethod_and_more.py deleted file mode 100644 index e703fec..0000000 --- a/store/migrations/0014_rename_paymenttype_paymentmethod_and_more.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-01 22:32 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0013_paymenttype_alter_category_options_and_more'), - ] - - operations = [ - migrations.RenameModel( - old_name='PaymentType', - new_name='PaymentMethod', - ), - migrations.RenameField( - model_name='checklist', - old_name='payment_type', - new_name='payment_method', - ), - ] diff --git a/store/migrations/0015_paymentmethod_name.py b/store/migrations/0015_paymentmethod_name.py deleted file mode 100644 index 94aeeae..0000000 --- a/store/migrations/0015_paymentmethod_name.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-01 22:34 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0014_rename_paymenttype_paymentmethod_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='paymentmethod', - name='name', - field=models.CharField(default='', max_length=30, verbose_name='Название'), - preserve_default=False, - ), - ] diff --git a/store/migrations/0016_paymentmethod_cardnumber.py b/store/migrations/0016_paymentmethod_cardnumber.py deleted file mode 100644 index 31d6a09..0000000 --- a/store/migrations/0016_paymentmethod_cardnumber.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-01 22:45 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0015_paymentmethod_name'), - ] - - operations = [ - migrations.AddField( - model_name='paymentmethod', - name='cardnumber', - field=models.CharField(default='', max_length=30, verbose_name='Номер карты'), - preserve_default=False, - ), - ] diff --git a/store/migrations/0017_alter_promocode_options_alter_paymentmethod_slug_and_more.py b/store/migrations/0017_alter_promocode_options_alter_paymentmethod_slug_and_more.py deleted file mode 100644 index e1d0178..0000000 --- a/store/migrations/0017_alter_promocode_options_alter_paymentmethod_slug_and_more.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-01 23:30 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0016_paymentmethod_cardnumber'), - ] - - operations = [ - migrations.AlterModelOptions( - name='promocode', - options={'verbose_name': 'Промокод', 'verbose_name_plural': 'Промокоды'}, - ), - migrations.AlterField( - model_name='paymentmethod', - name='slug', - field=models.SlugField(unique=True, verbose_name='Идентификатор'), - ), - migrations.AlterField( - model_name='promocode', - name='discount', - field=models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Скидка'), - ), - ] diff --git a/store/migrations/0018_alter_promocode_name.py b/store/migrations/0018_alter_promocode_name.py deleted file mode 100644 index 08477f4..0000000 --- a/store/migrations/0018_alter_promocode_name.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-01 23:33 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0017_alter_promocode_options_alter_paymentmethod_slug_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='promocode', - name='name', - field=models.CharField(max_length=100, unique=True, verbose_name='Название'), - ), - ] diff --git a/store/migrations/0019_globalsettings_pickup_address_alter_checklist_size.py b/store/migrations/0019_globalsettings_pickup_address_alter_checklist_size.py deleted file mode 100644 index b3f98bc..0000000 --- a/store/migrations/0019_globalsettings_pickup_address_alter_checklist_size.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-01 23:47 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0018_alter_promocode_name'), - ] - - operations = [ - migrations.AddField( - model_name='globalsettings', - name='pickup_address', - field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Адрес пункта самовывоза'), - ), - migrations.AlterField( - model_name='checklist', - name='size', - field=models.CharField(blank=True, max_length=30, null=True, verbose_name='Размер'), - ), - ] diff --git a/store/migrations/0020_image_remove_checklist_image_and_more.py b/store/migrations/0020_image_remove_checklist_image_and_more.py deleted file mode 100644 index 2b8b087..0000000 --- a/store/migrations/0020_image_remove_checklist_image_and_more.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-02 15:54 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0019_globalsettings_pickup_address_alter_checklist_size'), - ] - - operations = [ - migrations.CreateModel( - name='Image', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('image', models.ImageField(upload_to='checklist_images')), - ('is_preview', models.BooleanField(default=False)), - ], - ), - migrations.RemoveField( - model_name='checklist', - name='image', - ), - migrations.RemoveField( - model_name='checklist', - name='preview_image', - ), - migrations.AddField( - model_name='checklist', - name='images', - field=models.ManyToManyField(blank=True, to='store.image', verbose_name='Картинки'), - ), - ] diff --git a/store/migrations/0021_rename_is_preview_image_needs_preview.py b/store/migrations/0021_rename_is_preview_image_needs_preview.py deleted file mode 100644 index f76fab2..0000000 --- a/store/migrations/0021_rename_is_preview_image_needs_preview.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-02 17:10 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0020_image_remove_checklist_image_and_more'), - ] - - operations = [ - migrations.RenameField( - model_name='image', - old_name='is_preview', - new_name='needs_preview', - ), - ] diff --git a/store/migrations/0022_rename_needs_preview_image_is_preview.py b/store/migrations/0022_rename_needs_preview_image_is_preview.py deleted file mode 100644 index 5c69096..0000000 --- a/store/migrations/0022_rename_needs_preview_image_is_preview.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-02 17:11 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0021_rename_is_preview_image_needs_preview'), - ] - - operations = [ - migrations.RenameField( - model_name='image', - old_name='needs_preview', - new_name='is_preview', - ), - ] diff --git a/store/migrations/0023_alter_checklist_options_alter_image_options.py b/store/migrations/0023_alter_checklist_options_alter_image_options.py deleted file mode 100644 index d7ba8f8..0000000 --- a/store/migrations/0023_alter_checklist_options_alter_image_options.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-02 23:59 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0022_rename_needs_preview_image_is_preview'), - ] - - operations = [ - migrations.AlterModelOptions( - name='checklist', - options={'verbose_name': 'Заказ', 'verbose_name_plural': 'Заказы'}, - ), - migrations.AlterModelOptions( - name='image', - options={'verbose_name': 'Изображение', 'verbose_name_plural': 'Изображения'}, - ), - ] diff --git a/store/migrations/0024_remove_checklist_images_image_checklist.py b/store/migrations/0024_remove_checklist_images_image_checklist.py deleted file mode 100644 index 61fb5de..0000000 --- a/store/migrations/0024_remove_checklist_images_image_checklist.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-03 10:17 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0023_alter_checklist_options_alter_image_options'), - ] - - operations = [ - migrations.RemoveField( - model_name='checklist', - name='images', - ), - migrations.AddField( - model_name='image', - name='checklist', - field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='images', to='store.checklist'), - preserve_default=False, - ), - ] diff --git a/store/migrations/0025_alter_checklist_status_updated_at.py b/store/migrations/0025_alter_checklist_status_updated_at.py deleted file mode 100644 index 181cf78..0000000 --- a/store/migrations/0025_alter_checklist_status_updated_at.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-03 19:02 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0024_remove_checklist_images_image_checklist'), - ] - - operations = [ - migrations.AlterField( - model_name='checklist', - name='status_updated_at', - field=models.DateTimeField(auto_now_add=True, verbose_name='Дата обновления статуса заказа'), - ), - ] diff --git a/store/migrations/0026_remove_checklist_track_number_and_more.py b/store/migrations/0026_remove_checklist_track_number_and_more.py deleted file mode 100644 index 9472528..0000000 --- a/store/migrations/0026_remove_checklist_track_number_and_more.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-04 21:38 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0025_alter_checklist_status_updated_at'), - ] - - operations = [ - migrations.RemoveField( - model_name='checklist', - name='track_number', - ), - migrations.AddField( - model_name='checklist', - name='cdek_tracking', - field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Трек-номер СДЭК'), - ), - migrations.AddField( - model_name='checklist', - name='poizon_tracking', - field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Трек-номер Poizon'), - ), - ] diff --git a/store/migrations/0027_checklist_cdek_barcode_pdf_and_more.py b/store/migrations/0027_checklist_cdek_barcode_pdf_and_more.py deleted file mode 100644 index 1db8c79..0000000 --- a/store/migrations/0027_checklist_cdek_barcode_pdf_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-05 01:56 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0026_remove_checklist_track_number_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='checklist', - name='cdek_barcode_pdf', - field=models.ImageField(blank=True, null=True, upload_to='docs', verbose_name='Штрих-код СДЭК в PDF'), - ), - migrations.AlterField( - model_name='checklist', - name='cheque_photo', - field=models.ImageField(blank=True, null=True, upload_to='docs', verbose_name='Фото чека'), - ), - migrations.AlterField( - model_name='checklist', - name='payment_proof', - field=models.ImageField(blank=True, null=True, upload_to='docs', verbose_name='Подтверждение оплаты'), - ), - ] diff --git a/store/migrations/0028_alter_checklist_cdek_barcode_pdf.py b/store/migrations/0028_alter_checklist_cdek_barcode_pdf.py deleted file mode 100644 index a9e1ecc..0000000 --- a/store/migrations/0028_alter_checklist_cdek_barcode_pdf.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-05 02:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0027_checklist_cdek_barcode_pdf_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='checklist', - name='cdek_barcode_pdf', - field=models.FileField(blank=True, null=True, upload_to='docs', verbose_name='Штрих-код СДЭК в PDF'), - ), - ] diff --git a/store/migrations/0029_rename_cheque_photo_checklist_receipt_and_more.py b/store/migrations/0029_rename_cheque_photo_checklist_receipt_and_more.py deleted file mode 100644 index 84b4840..0000000 --- a/store/migrations/0029_rename_cheque_photo_checklist_receipt_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-06 11:51 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0028_alter_checklist_cdek_barcode_pdf'), - ] - - operations = [ - migrations.RenameField( - model_name='checklist', - old_name='cheque_photo', - new_name='receipt', - ), - migrations.AlterField( - model_name='checklist', - name='status', - field=models.CharField(choices=[('draft', 'Черновик'), ('neworder', 'Новый заказ'), ('payment', 'Проверка оплаты'), ('buying', 'На закупке'), ('bought', 'Закуплен'), ('china', 'На складе в Китае'), ('chinarush', 'Доставка на склад РФ'), ('rush', 'На складе в РФ'), ('split_waiting', 'Сплит: ожидание оплаты 2й части'), ('split_paid', 'Сплит: полностью оплачено'), ('cdek', 'Доставляется СДЭК'), ('completed', 'Завершен')], default='neworder', max_length=15, verbose_name='Статус заказа'), - ), - ] diff --git a/store/migrations/0030_checklist_is_split_payment.py b/store/migrations/0030_checklist_is_split_payment.py deleted file mode 100644 index a17d006..0000000 --- a/store/migrations/0030_checklist_is_split_payment.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-06 12:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0029_rename_cheque_photo_checklist_receipt_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='checklist', - name='is_split_payment', - field=models.BooleanField(default=False, verbose_name='Оплата частями'), - ), - ] diff --git a/store/migrations/0031_alter_checklist_promocode.py b/store/migrations/0031_alter_checklist_promocode.py deleted file mode 100644 index 073048f..0000000 --- a/store/migrations/0031_alter_checklist_promocode.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-06 12:02 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0030_checklist_is_split_payment'), - ] - - operations = [ - migrations.AlterField( - model_name='checklist', - name='promocode', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='store.promocode', verbose_name='Промокод'), - ), - ] diff --git a/store/migrations/0032_promocode_is_active.py b/store/migrations/0032_promocode_is_active.py deleted file mode 100644 index 2ef2d57..0000000 --- a/store/migrations/0032_promocode_is_active.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-06 12:05 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0031_alter_checklist_promocode'), - ] - - operations = [ - migrations.AddField( - model_name='promocode', - name='is_active', - field=models.BooleanField(default=True, verbose_name='Активен'), - ), - ] diff --git a/store/migrations/0033_remove_user_manager_id.py b/store/migrations/0033_remove_user_manager_id.py deleted file mode 100644 index 786fe5f..0000000 --- a/store/migrations/0033_remove_user_manager_id.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-06 13:44 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0032_promocode_is_active'), - ] - - operations = [ - migrations.RemoveField( - model_name='user', - name='manager_id', - ), - ] diff --git a/store/migrations/0034_alter_promocode_discount.py b/store/migrations/0034_alter_promocode_discount.py deleted file mode 100644 index f1fbe6d..0000000 --- a/store/migrations/0034_alter_promocode_discount.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-06 21:32 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0033_remove_user_manager_id'), - ] - - operations = [ - migrations.AlterField( - model_name='promocode', - name='discount', - field=models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)], verbose_name='Скидка'), - ), - ] diff --git a/store/migrations/0035_remove_image_checklist_checklist_images_and_more.py b/store/migrations/0035_remove_image_checklist_checklist_images_and_more.py deleted file mode 100644 index 1c87fb0..0000000 --- a/store/migrations/0035_remove_image_checklist_checklist_images_and_more.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-07 03:08 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0034_alter_promocode_discount'), - ] - - operations = [ - migrations.RemoveField( - model_name='image', - name='checklist', - ), - migrations.AddField( - model_name='checklist', - name='images', - field=models.ManyToManyField(related_name='+', to='store.image', verbose_name='Изображения'), - ), - migrations.RemoveField( - model_name='checklist', - name='payment_proof', - ), - migrations.AddField( - model_name='checklist', - name='payment_proof', - field=models.ManyToManyField(related_name='+', to='store.image', verbose_name='Подтверждение оплаты'), - ), - ] diff --git a/store/migrations/0036_remove_image_is_preview_image_type_and_more.py b/store/migrations/0036_remove_image_is_preview_image_type_and_more.py deleted file mode 100644 index 40df54a..0000000 --- a/store/migrations/0036_remove_image_is_preview_image_type_and_more.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-07 03:17 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0035_remove_image_checklist_checklist_images_and_more'), - ] - - operations = [ - migrations.RemoveField( - model_name='image', - name='is_preview', - ), - migrations.AddField( - model_name='image', - name='type', - field=models.PositiveSmallIntegerField(choices=[(0, 'Изображение'), (1, 'Превью')], default=0, verbose_name='Тип'), - ), - migrations.AlterField( - model_name='checklist', - name='receipt', - field=models.ImageField(blank=True, null=True, upload_to='', verbose_name='Фото чека'), - ), - migrations.AlterField( - model_name='image', - name='image', - field=models.ImageField(upload_to='', verbose_name='Файл изображения'), - ), - ] diff --git a/store/migrations/0037_alter_image_image.py b/store/migrations/0037_alter_image_image.py deleted file mode 100644 index d068f01..0000000 --- a/store/migrations/0037_alter_image_image.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-07 03:23 - -from django.db import migrations, models -import store.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0036_remove_image_is_preview_image_type_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='image', - name='image', - field=models.ImageField(upload_to=store.models.image_upload_path, verbose_name='Файл изображения'), - ), - ] diff --git a/store/migrations/0038_alter_checklist_images_alter_checklist_payment_proof_and_more.py b/store/migrations/0038_alter_checklist_images_alter_checklist_payment_proof_and_more.py deleted file mode 100644 index 3339ed6..0000000 --- a/store/migrations/0038_alter_checklist_images_alter_checklist_payment_proof_and_more.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-07 14:36 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0037_alter_image_image'), - ] - - operations = [ - migrations.AlterField( - model_name='checklist', - name='images', - field=models.ManyToManyField(blank=True, related_name='+', to='store.image', verbose_name='Изображения'), - ), - migrations.AlterField( - model_name='checklist', - name='payment_proof', - field=models.ManyToManyField(blank=True, related_name='+', to='store.image', verbose_name='Подтверждение оплаты'), - ), - migrations.AlterField( - model_name='checklist', - name='receipt', - field=models.ImageField(blank=True, null=True, upload_to='docs/', verbose_name='Фото чека'), - ), - migrations.AlterField( - model_name='image', - name='type', - field=models.PositiveSmallIntegerField(choices=[(0, 'Изображение'), (1, 'Превью'), (2, 'Документ')], default=0, verbose_name='Тип'), - ), - ] diff --git a/store/migrations/0039_alter_checklist_delivery.py b/store/migrations/0039_alter_checklist_delivery.py deleted file mode 100644 index 262d528..0000000 --- a/store/migrations/0039_alter_checklist_delivery.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-10 17:24 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0038_alter_checklist_images_alter_checklist_payment_proof_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='checklist', - name='delivery', - field=models.CharField(blank=True, choices=[('pickup', 'Самовывоз из шоурума'), ('cdek', 'Пункт выдачи заказов CDEK'), ('cdek_courier', 'Курьерская доставка CDEK')], max_length=15, null=True, verbose_name='Тип доставки'), - ), - ] diff --git a/store/migrations/0040_alter_paymentmethod_cardnumber_and_more.py b/store/migrations/0040_alter_paymentmethod_cardnumber_and_more.py deleted file mode 100644 index 2206606..0000000 --- a/store/migrations/0040_alter_paymentmethod_cardnumber_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.2 on 2023-07-12 20:22 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0039_alter_checklist_delivery'), - ] - - operations = [ - migrations.AlterField( - model_name='paymentmethod', - name='cardnumber', - field=models.CharField(blank=True, max_length=30, null=True, verbose_name='Номер карты'), - ), - migrations.AlterField( - model_name='paymentmethod', - name='requisites', - field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Реквизиты'), - ), - ] diff --git a/store/migrations/0041_remove_category_slug_remove_checklist_subcategory_and_more.py b/store/migrations/0041_remove_category_slug_remove_checklist_subcategory_and_more.py deleted file mode 100644 index 18d2880..0000000 --- a/store/migrations/0041_remove_category_slug_remove_checklist_subcategory_and_more.py +++ /dev/null @@ -1,134 +0,0 @@ -# Generated by Django 4.2.2 on 2023-08-19 16:45 - -import datetime - -import django.core.validators -from django.conf import settings -from django.db import migrations, models, transaction -import django.db.models.deletion -import mptt.fields -from mptt import register, managers - -from store.management.commands.create_initial_data import create_categories - - -def create_initial_categories(apps, schema_editor): - create_categories() - - -# Dummy model with the removed field(s) -class OldChecklist(models.Model): - id = models.CharField(primary_key=True, max_length=settings.CHECKLIST_ID_LENGTH) - category_id = models.PositiveIntegerField() - subcategory = models.CharField('Подкатегория', max_length=20, blank=True, null=True) - - class Meta: - managed = False - db_table = 'store_checklist' - - -# Add mptt fields to Category -# Loop through orders, save subcategory names and category ids -# Create subcategories, link to parent category -# Loop through orders, if subcategory was present, set it as category -# Perform migration - remove subcategory field -def create_subcategories(apps, schema_editor): - Checklist = apps.get_model("store", "Checklist") - Category = apps.get_model("store", "Category") - - # Loop through orders, save subcategory names and category ids - # Create subcategories, link to parent category - # If Checklist had subcategory, set it as category - with transaction.atomic(): - for checklist in OldChecklist.objects.all(): - if checklist.subcategory is None or checklist.category_id is None: - continue - - category_data = {'name': checklist.subcategory, 'parent_id': checklist.category_id} - # just to overcome not-null constraint errors for mptt - mptt_data = {'level': 0, 'lft': 0, 'rght': 0, 'tree_id': 0} - - subcat_obj, _ = Category.objects.get_or_create(**category_data, defaults=mptt_data) - - # To really update the Checklist, we must use a real model instead of the dummy OldChecklist one - Checklist.objects.filter(id=checklist.id).update(category_id=subcat_obj.id) - - -def rebuild_tree(apps, schema_editor): - model = apps.get_model('store', 'Category') - - manager = managers.TreeManager() - manager.model = model - - register(model) - manager.contribute_to_class(model, 'objects') - manager.rebuild() - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0040_alter_paymentmethod_cardnumber_and_more'), - ] - - operations = [ - migrations.RemoveField( - model_name='category', - name='slug', - ), - migrations.AddField( - model_name='category', - name='commission', - field=models.DecimalField(decimal_places=2, default=0, max_digits=10, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)], verbose_name='Дополнительная комиссия, %'), - ), - - migrations.AddField( - model_name='category', - name='level', - field=models.PositiveIntegerField(default=0, editable=False), - preserve_default=False, - ), - migrations.AddField( - model_name='category', - name='lft', - field=models.PositiveIntegerField(default=0, editable=False), - preserve_default=False, - ), - migrations.AddField( - model_name='category', - name='parent', - field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='store.category', verbose_name='Родительская категория'), - ), - migrations.AddField( - model_name='category', - name='rght', - field=models.PositiveIntegerField(default=0, editable=False), - preserve_default=False, - ), - migrations.AddField( - model_name='category', - name='tree_id', - field=models.PositiveIntegerField(db_index=True, default=0, editable=False), - preserve_default=False, - ), - - migrations.RunPython(code=create_initial_categories), - migrations.RunPython(code=create_subcategories), - migrations.RunPython(code=rebuild_tree), - - migrations.AddField( - model_name='globalsettings', - name='time_to_buy', - field=models.DurationField(default=datetime.timedelta(seconds=10800), help_text="Через N времени заказ переходит из статуса 'Новый' в 'Черновик'", verbose_name='Время на покупку'), - ), - migrations.AlterField( - model_name='promocode', - name='discount', - field=models.PositiveIntegerField(verbose_name='Скидка в рублях'), - ), - - migrations.RemoveField( - model_name='checklist', - name='subcategory', - ), - ] diff --git a/store/migrations/0042_oldchecklist_checklist_split_payment_proof_and_more.py b/store/migrations/0042_oldchecklist_checklist_split_payment_proof_and_more.py deleted file mode 100644 index 58b1f27..0000000 --- a/store/migrations/0042_oldchecklist_checklist_split_payment_proof_and_more.py +++ /dev/null @@ -1,59 +0,0 @@ -# Generated by Django 4.2.2 on 2023-08-21 10:41 - -from django.db import migrations, models -import django.db.models.deletion - - -def move_m2m_payment_proof_to_image(apps, schema_editor): - Checklist = apps.get_model('store', 'Checklist') - for checklist in Checklist.objects.all(): - img_obj = checklist.payment_proof.all().first() - if img_obj: - checklist._payment_proof = img_obj.image - checklist.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0041_remove_category_slug_remove_checklist_subcategory_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='OldChecklist', - fields=[ - ('id', models.CharField(max_length=10, primary_key=True, serialize=False)), - ('category_id', models.PositiveIntegerField()), - ('subcategory', models.CharField(blank=True, max_length=20, null=True, verbose_name='Подкатегория')), - ], - options={ - 'db_table': 'store_checklist', - 'managed': False, - }, - ), - migrations.AddField( - model_name='checklist', - name='_payment_proof', - field=models.ImageField(blank=True, null=True, upload_to='docs/', verbose_name='Подтверждение оплаты'), - ), - - migrations.RunPython(code=move_m2m_payment_proof_to_image), - - migrations.RemoveField( - model_name='checklist', - name='payment_proof', - ), - - migrations.RenameField( - model_name='checklist', - old_name='_payment_proof', - new_name='payment_proof', - ), - - migrations.AddField( - model_name='checklist', - name='split_payment_proof', - field=models.ImageField(blank=True, null=True, upload_to='docs/', verbose_name='Подтверждение оплаты сплита'), - ), - ] diff --git a/store/migrations/0043_pricesnapshot_checklist_price_snapshot.py b/store/migrations/0043_pricesnapshot_checklist_price_snapshot.py deleted file mode 100644 index 3a335d7..0000000 --- a/store/migrations/0043_pricesnapshot_checklist_price_snapshot.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 4.2.2 on 2023-10-04 02:53 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0042_oldchecklist_checklist_split_payment_proof_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='PriceSnapshot', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('yuan_rate', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Курс CNY/RUB')), - ('delivery_price_CN', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена доставки по Китаю')), - ('delivery_price_CN_RU', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Цена доставки Китай-РФ')), - ('commission_rub', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Комиссия, руб')), - ], - ), - migrations.AddField( - model_name='checklist', - name='price_snapshot', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='checklist', to='store.pricesnapshot', verbose_name='Сохраненные цены'), - ), - ] diff --git a/store/migrations/0045_gift_alter_image_type.py b/store/migrations/0045_gift_alter_image_type.py deleted file mode 100644 index 6db0e2e..0000000 --- a/store/migrations/0045_gift_alter_image_type.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 4.2.2 on 2023-10-25 15:33 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0044_checklist_split_accepted'), - ] - - operations = [ - migrations.CreateModel( - name='Gift', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100, verbose_name='Название')), - ('image', models.ImageField(blank=True, null=True, upload_to='gifts/', verbose_name='Фото')), - ('min_price', models.DecimalField(decimal_places=2, default=0, help_text='от какой суммы доступен подарок', max_digits=10, verbose_name='Минимальная цена')), - ], - options={ - 'verbose_name': 'Подарок', - 'verbose_name_plural': 'Подарки', - }, - ), - migrations.AlterField( - model_name='image', - name='type', - field=models.PositiveSmallIntegerField(choices=[(0, 'Изображение'), (1, 'Превью'), (2, 'Документ'), (3, 'Подарок')], default=0, verbose_name='Тип'), - ), - ] diff --git a/store/migrations/0046_checklist_gift_alter_gift_min_price.py b/store/migrations/0046_checklist_gift_alter_gift_min_price.py deleted file mode 100644 index 55c2abe..0000000 --- a/store/migrations/0046_checklist_gift_alter_gift_min_price.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 4.2.2 on 2023-10-25 15:43 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0045_gift_alter_image_type'), - ] - - operations = [ - migrations.AddField( - model_name='checklist', - name='gift', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='store.gift', verbose_name='Подарок'), - ), - migrations.AlterField( - model_name='gift', - name='min_price', - field=models.DecimalField(decimal_places=2, default=0, help_text='от какой суммы доступен подарок', max_digits=10, verbose_name='Минимальная цена в юанях'), - ), - ] diff --git a/store/migrations/0047_alter_checklist_gift.py b/store/migrations/0047_alter_checklist_gift.py deleted file mode 100644 index 65efac3..0000000 --- a/store/migrations/0047_alter_checklist_gift.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.2.2 on 2023-11-11 05:48 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0046_checklist_gift_alter_gift_min_price'), - ] - - operations = [ - migrations.AlterField( - model_name='checklist', - name='gift', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='store.gift', verbose_name='Подарок'), - ), - ] diff --git a/store/migrations/0048_globalsettings_yuan_rate_commission_and_more.py b/store/migrations/0048_globalsettings_yuan_rate_commission_and_more.py deleted file mode 100644 index 25578d8..0000000 --- a/store/migrations/0048_globalsettings_yuan_rate_commission_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.2 on 2023-11-22 22:15 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0047_alter_checklist_gift'), - ] - - operations = [ - migrations.AddField( - model_name='globalsettings', - name='yuan_rate_commission', - field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Наценка на курс юаня, руб'), - ), - migrations.AddField( - model_name='globalsettings', - name='yuan_rate_last_updated', - field=models.DateTimeField(default=None, null=True, verbose_name='Дата обновления курса CNY/RUB'), - ), - ] diff --git a/store/migrations/0049_gift_available_count.py b/store/migrations/0049_gift_available_count.py deleted file mode 100644 index fc99734..0000000 --- a/store/migrations/0049_gift_available_count.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.2 on 2023-11-22 22:28 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('store', '0048_globalsettings_yuan_rate_commission_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='gift', - name='available_count', - field=models.PositiveSmallIntegerField(default=0, verbose_name='Доступное количество'), - ), - ] diff --git a/store/models.py b/store/models.py index 6cdf705..25e9200 100644 --- a/store/models.py +++ b/store/models.py @@ -1,29 +1,25 @@ import math import posixpath -from datetime import timedelta -from decimal import Decimal import random import string +from datetime import timedelta +from decimal import Decimal from io import BytesIO from typing import Optional from django.conf import settings -from django.contrib.admin import display -from django.contrib.auth.hashers import make_password -from django.contrib.auth.models import AbstractUser, UserManager as _UserManager from django.core.files.base import ContentFile from django.core.validators import MinValueValidator, MaxValueValidator from django.db import models -from django.db.models import F, Case, When, DecimalField, Prefetch, Max, Q, ExpressionWrapper +from django.db.models import F, Case, When, DecimalField, Prefetch, Max, Q from django.db.models.functions import Ceil from django.db.models.lookups import GreaterThan from django.utils import timezone -from django.utils.translation import gettext_lazy as _ from django_cleanup import cleanup from mptt.fields import TreeForeignKey from mptt.models import MPTTModel -from store.utils import create_preview, concat_not_null_values +from store.utils import create_preview class GlobalSettings(models.Model): @@ -97,67 +93,6 @@ class Category(MPTTModel): return self.commission -class UserQuerySet(models.QuerySet): - pass - - -class UserManager(models.Manager.from_queryset(UserQuerySet), _UserManager): - def _create_user(self, email, password, **extra_fields): - if not email: - raise ValueError("The given email must be set") - email = self.normalize_email(email) - user = self.model(email=email, **extra_fields) - user.password = make_password(password) - user.save(using=self._db) - return user - - def create_user(self, email=None, password=None, **extra_fields): - extra_fields.setdefault("is_staff", False) - return self._create_user(email, password, **extra_fields) - - def create_superuser(self, email=None, password=None, **extra_fields): - extra_fields.setdefault("is_staff", True) - extra_fields.setdefault("job_title", User.ADMIN) - - if extra_fields.get("is_staff") is not True: - raise ValueError("Superuser must have is_staff=True.") - - return self._create_user(email, password, **extra_fields) - - -class User(AbstractUser): - ADMIN = "admin" - ORDER_MANAGER = "ordermanager" - PRODUCT_MANAGER = "productmanager" - - JOB_CHOICES = ( - (ADMIN, 'Администратор'), - (ORDER_MANAGER, 'Менеджер по заказам'), - (PRODUCT_MANAGER, 'Менеджер по закупкам'), - ) - - # Login by email - USERNAME_FIELD = 'email' - REQUIRED_FIELDS = [] - username = None - email = models.EmailField("Эл. почта", unique=True) - - first_name = models.CharField(_("first name"), max_length=150, blank=True, null=True) - last_name = models.CharField(_("last name"), max_length=150, blank=True, null=True) - middle_name = models.CharField("Отчество", max_length=150, blank=True, null=True) - job_title = models.CharField("Должность", max_length=30, choices=JOB_CHOICES) - - objects = UserManager() - - @property - def is_superuser(self): - return self.job_title == self.ADMIN - - @display(description='ФИО') - def full_name(self): - return concat_not_null_values(self.last_name, self.first_name, self.middle_name) - - class PromocodeQuerySet(models.QuerySet): def active(self): return self.filter(is_active=True) @@ -260,7 +195,7 @@ def generate_checklist_id(): class ChecklistQuerySet(models.QuerySet): def with_base_related(self): return self.select_related('manager', 'category', 'payment_method', - 'promocode', 'price_snapshot', 'gift') \ + 'promocode', 'price_snapshot', 'gift', 'customer') \ .prefetch_related(Prefetch('images', to_attr='_images')) def default_ordering(self): @@ -347,6 +282,63 @@ class Checklist(models.Model): (COMPLETED, 'Завершен'), ) + def get_tg_notification(self): + from tg_bot.messages import TGOrderStatusMessage as msg + + match self.status: + case Checklist.Status.NEW: + return msg.NEW.format(order_id=self.id, order_link=self.order_link) + + case Checklist.Status.BUYING: + if not self.is_split_payment: + return msg.BUYING_NON_SPLIT.format(order_id=self.id) + else: + return msg.BUYING_SPLIT.format(order_id=self.id, amount_to_pay=self.split_amount_to_pay()) + + case Checklist.Status.BOUGHT: + return msg.BOUGHT.format(order_id=self.id) + + case Checklist.Status.CHINA: + return msg.CHINA.format(order_id=self.id) + + case Checklist.Status.CHINA_RUSSIA: + return msg.CHINA_RUSSIA.format(order_id=self.id) + + case Checklist.Status.RUSSIA: + if self.delivery == Checklist.DeliveryType.PICKUP: + return msg.RUSSIA_PICKUP.format(order_id=self.id) + + elif self.delivery in Checklist.DeliveryType.CDEK_TYPES: + return msg.RUSSIA_CDEK.format(order_id=self.id) + + case Checklist.Status.SPLIT_WAITING: + if self.delivery == Checklist.DeliveryType.PICKUP: + return msg.SPLIT_WAITING_PICKUP.format(order_id=self.id, amount_to_pay=self.split_amount_to_pay) + + elif self.delivery in Checklist.DeliveryType.CDEK_TYPES: + return msg.SPLIT_WAITING_CDEK.format(order_id=self.id, amount_to_pay=self.split_amount_to_pay) + + # FIXME: split_accepted ? + case Checklist.Status.SPLIT_PAID: + return msg.SPLIT_PAID.format(order_id=self.id) + + case Checklist.Status.CDEK: + return msg.CDEK.format(order_id=self.id) + + case Checklist.Status.COMPLETED: + return msg.COMPLETED.format(order_id=self.id) + + case _: + return None + + @property + def order_link(self): + return f"https://poizonstore.com/orderpageinprogress/{self.id}" + + def split_amount_to_pay(self): + # FIXME: it's stupid, create PaymentInfo model or something + return self.full_price // 2 + # Delivery class DeliveryType: PICKUP = "pickup" @@ -359,13 +351,16 @@ class Checklist(models.Model): (CDEK_COURIER, 'Курьерская доставка CDEK'), ) + CDEK_TYPES = (CDEK, CDEK_COURIER) + created_at = models.DateTimeField(auto_now_add=True) - status_updated_at = models.DateTimeField('Дата обновления статуса заказа', auto_now_add=True) + status_updated_at = models.DateTimeField('Дата обновления статуса заказа', auto_now_add=True, editable=False) id = models.CharField(primary_key=True, max_length=settings.CHECKLIST_ID_LENGTH, default=generate_checklist_id, editable=False) status = models.CharField('Статус заказа', max_length=15, choices=Status.CHOICES, default=Status.NEW) # managerid - manager = models.ForeignKey('User', verbose_name='Менеджер', on_delete=models.SET_NULL, blank=True, null=True) + manager = models.ForeignKey('account.User', verbose_name='Менеджер', related_name='manager_orders', + on_delete=models.SET_NULL, blank=True, null=True) product_link = models.URLField('Ссылка на товар', null=True, blank=True) category = models.ForeignKey('Category', verbose_name="Категория", blank=True, null=True, on_delete=models.SET_NULL) @@ -383,12 +378,8 @@ class Checklist(models.Model): gift = models.ForeignKey('Gift', verbose_name='Подарок', on_delete=models.SET_NULL, null=True, blank=True) comment = models.CharField('Комментарий', max_length=200, null=True, blank=True) - # buyername - buyer_name = models.CharField('Имя покупателя', max_length=100, null=True, blank=True) - # buyerphone - buyer_phone = models.CharField('Телефон покупателя', max_length=100, null=True, blank=True) - # tg - buyer_telegram = models.CharField('Telegram покупателя', max_length=100, null=True, blank=True) + customer = models.ForeignKey('account.User', on_delete=models.CASCADE, related_name='customer_orders', blank=True, + null=True) # receivername receiver_name = models.CharField('Имя получателя', max_length=100, null=True, blank=True) @@ -410,7 +401,8 @@ class Checklist(models.Model): null=True, blank=True) split_accepted = models.BooleanField('Сплит принят', default=False) - receipt = models.ImageField('Фото чека', upload_to=Image.TYPE_TO_UPLOAD_PATH[Image.DOC], null=True, blank=True) # checkphoto + receipt = models.ImageField('Фото чека', upload_to=Image.TYPE_TO_UPLOAD_PATH[Image.DOC], null=True, + blank=True) # checkphoto delivery = models.CharField('Тип доставки', max_length=15, choices=DeliveryType.CHOICES, null=True, blank=True) # trackid @@ -589,14 +581,47 @@ class Checklist(models.Model): # Restore snapshot self.price_snapshot = snapshot + def _notify_about_status_change(self): + if self.customer_id is None: + return + + tg_message = self.get_tg_notification() + if tg_message: + self.customer.notify_tg_bot(tg_message) + + def _check_eligible_for_order_bonus(self): + if self.customer_id is None: + return + + if self.status != Checklist.Status.CHINA_RUSSIA: + return + + # Check if any BonusProgramTransaction bound to current order exists + from account.models import BonusProgramTransaction + if BonusProgramTransaction.objects.filter(order_id=self.id).exists(): + return + + # Apply either referral bonus or order bonus, not both + if self.customer.inviter is not None and self.customer.customer_orders.count() == 1: + self.customer.add_referral_bonus(self, for_inviter=False) + self.customer.inviter.add_referral_bonus(self, for_inviter=True) + else: + self.customer.add_order_bonus(self) + + # TODO: split into sub-functions def save(self, *args, **kwargs): if self.id: old_obj = Checklist.objects.filter(id=self.id).first() + self._check_eligible_for_order_bonus() + # If status was updated, update status_updated_at field - if old_obj and self.status != old_obj.status: + if old_obj is not None and self.status != old_obj.status: self.status_updated_at = timezone.now() + self._notify_about_status_change() + # TODO: remove bonuses if order is canceled? + # Invalidate old CDEK barcode PDF if not self.cdek_barcode_pdf or self.cdek_tracking != old_obj.cdek_tracking: self.cdek_barcode_pdf.delete(save=False) @@ -609,8 +634,13 @@ class Checklist(models.Model): if pdf_file: self.cdek_barcode_pdf.save(f'{self.id}_barcode.pdf', pdf_file, save=False) + # Invalidate old preview_image if full_price changed + price_changed = old_obj is not None and self.full_price != old_obj.full_price + if price_changed: + self.preview_image.delete(save=False) + # Create preview image - if not self.preview_image: + if self.preview_image is None: self.generate_preview() # Update available gifts count diff --git a/store/serializers.py b/store/serializers.py index 1759fe8..7e1a927 100644 --- a/store/serializers.py +++ b/store/serializers.py @@ -1,23 +1,12 @@ from drf_extra_fields.fields import Base64ImageField from rest_framework import serializers -from store.exceptions import CRMException -from store.models import User, Checklist, GlobalSettings, Category, PaymentMethod, Promocode, Image, Gift +from account.serializers import UserSerializer +from utils.exceptions import CRMException +from store.models import Checklist, GlobalSettings, Category, PaymentMethod, Promocode, Image, Gift from store.utils import get_primary_key_related_model -class UserSerializer(serializers.ModelSerializer): - login = serializers.CharField(source='email', required=False) - job = serializers.CharField(source='job_title', required=False) - name = serializers.CharField(source='first_name', required=False) - lastname = serializers.CharField(source='middle_name', required=False) - surname = serializers.CharField(source='last_name', required=False) - - class Meta: - model = User - fields = ('id', 'login', 'job', 'name', 'lastname', 'surname',) - - class ImageSerializer(serializers.ModelSerializer): image = Base64ImageField() @@ -74,52 +63,51 @@ class GiftSerializer(serializers.ModelSerializer): class ChecklistSerializer(serializers.ModelSerializer): id = serializers.CharField(read_only=True) - managerid = serializers.PrimaryKeyRelatedField(source='manager_id', read_only=True, allow_null=True) + manager_id = serializers.PrimaryKeyRelatedField(read_only=True, allow_null=True) link = serializers.URLField(source='product_link', required=False) category = get_primary_key_related_model(CategoryChecklistSerializer, required=False, allow_null=True) size = serializers.CharField(required=False, allow_null=True) image = ImageListSerializer(source='main_images', required=False) - previewimage = serializers.ImageField(source='preview_image_url', read_only=True) + preview_image_url = serializers.ImageField(read_only=True) - promo = serializers.SlugRelatedField(source='promocode', slug_field='name', - queryset=Promocode.objects.active(), required=False, allow_null=True) + promocode = serializers.SlugRelatedField(slug_field='name', queryset=Promocode.objects.active(), + required=False, allow_null=True) gift = get_primary_key_related_model(GiftSerializer, required=False, allow_null=True) - currency = serializers.DecimalField(source='yuan_rate', read_only=True, max_digits=10, decimal_places=2) - curencycurency2 = serializers.DecimalField(source='price_yuan', required=False, max_digits=10, decimal_places=2) - currency3 = serializers.IntegerField(source='price_rub', read_only=True) - chinadelivery = serializers.DecimalField(source='delivery_price_CN', read_only=True, max_digits=10, decimal_places=2) - chinadelivery2 = serializers.DecimalField(source='delivery_price_CN_RU', read_only=True, - max_digits=10, decimal_places=2) - fullprice = serializers.IntegerField(source='full_price', read_only=True) - realprice = serializers.DecimalField(source='real_price', required=False, allow_null=True, max_digits=10, - decimal_places=2) - commission = serializers.DecimalField(source='commission_rub', read_only=True, max_digits=10, decimal_places=2) + yuan_rate = serializers.DecimalField(read_only=True, max_digits=10, decimal_places=2) + price_yuan = serializers.DecimalField(required=False, max_digits=10, decimal_places=2) + price_rub = serializers.IntegerField(read_only=True) - buyername = serializers.CharField(source='buyer_name', required=False, allow_null=True) - buyerphone = serializers.CharField(source='buyer_phone', required=False, allow_null=True) - tg = serializers.CharField(source='buyer_telegram', required=False, allow_null=True) + delivery_price_CN = serializers.DecimalField(read_only=True, max_digits=10, decimal_places=2) + delivery_price_CN_RU = serializers.DecimalField(read_only=True, max_digits=10, decimal_places=2) - receivername = serializers.CharField(source='receiver_name', required=False, allow_null=True) - reveiverphone = serializers.CharField(source='receiver_phone', required=False, allow_null=True) + full_price = serializers.IntegerField(read_only=True) + real_price = serializers.DecimalField(required=False, allow_null=True, max_digits=10, decimal_places=2) - split = serializers.BooleanField(source='is_split_payment', required=False) - paymenttype = serializers.SlugRelatedField(source='payment_method', slug_field='slug', - queryset=PaymentMethod.objects.all(), - required=False, allow_null=True) - paymentprovement = Base64ImageField(source='payment_proof', required=False, allow_null=True) + commission_rub = serializers.DecimalField(read_only=True, max_digits=10, decimal_places=2) + + customer = get_primary_key_related_model(UserSerializer, required=False, allow_null=True) + + receiver_name = serializers.CharField(required=False, allow_null=True) + receiver_phone = serializers.CharField(required=False, allow_null=True) + + is_split_payment = serializers.BooleanField(required=False) + payment_method = serializers.SlugRelatedField(slug_field='slug', + queryset=PaymentMethod.objects.all(), + required=False, allow_null=True) + payment_proof = Base64ImageField(required=False, allow_null=True) split_payment_proof = Base64ImageField(required=False, allow_null=True) - checkphoto = Base64ImageField(source='receipt', required=False, allow_null=True) - trackid = serializers.CharField(source='poizon_tracking', required=False, allow_null=True) + receipt = Base64ImageField(required=False, allow_null=True) + poizon_tracking = serializers.CharField(required=False, allow_null=True) cdek_tracking = serializers.CharField(required=False, allow_null=True) delivery = serializers.ChoiceField(choices=Checklist.DeliveryType.CHOICES, required=False, allow_null=True) delivery_display = serializers.CharField(source='get_delivery_display', read_only=True) - startDate = serializers.DateTimeField(source='created_at', read_only=True) - currentDate = serializers.DateTimeField(source='status_updated_at', read_only=True) + created_at = serializers.DateTimeField(read_only=True) + status_updated_at = serializers.DateTimeField(read_only=True) def _collect_images_by_fields(self, validated_data): images = {} @@ -141,6 +129,12 @@ class ChecklistSerializer(serializers.ModelSerializer): def create(self, validated_data): images = self._collect_images_by_fields(validated_data) + # Managers can create orders with arbitrary customers + # Client orders are created with client's account + user = self.context['request'].user + if not user.is_manager or validated_data.get('customer') is None: + validated_data['customer'] = user + instance = super().create(validated_data) self._create_main_images(instance, images.get('main_images')) return instance @@ -170,34 +164,24 @@ class ChecklistSerializer(serializers.ModelSerializer): class Meta: model = Checklist - fields = ('id', 'status', 'managerid', 'link', + fields = ('id', 'status', 'manager_id', 'link', 'category', 'brand', 'model', 'size', 'image', - 'previewimage', - 'currency', 'curencycurency2', 'currency3', 'chinadelivery', 'chinadelivery2', 'commission', - 'promo', 'gift', + 'preview_image_url', + 'yuan_rate', 'price_yuan', 'price_rub', 'delivery_price_CN', 'delivery_price_CN_RU', 'commission_rub', + 'promocode', 'gift', 'comment', - 'fullprice', 'realprice', - 'buyername', 'buyerphone', 'tg', - 'receivername', 'reveiverphone', - 'split', 'paymenttype', 'paymentprovement', 'split_payment_proof', 'split_accepted', 'checkphoto', - 'trackid', 'cdek_tracking', 'cdek_barcode_pdf', 'delivery', 'delivery_display', - 'startDate', 'currentDate', 'buy_time_remaining' + 'full_price', 'real_price', + 'customer', + 'receiver_name', 'receiver_phone', + 'is_split_payment', 'payment_method', 'payment_proof', 'split_payment_proof', 'split_accepted', 'receipt', + 'poizon_tracking', 'cdek_tracking', 'cdek_barcode_pdf', 'delivery', 'delivery_display', + 'created_at', 'status_updated_at', 'buy_time_remaining' ) -class AnonymousUserChecklistSerializer(ChecklistSerializer): - class Meta: - model = ChecklistSerializer.Meta.model - fields = ChecklistSerializer.Meta.fields - read_only_fields = tuple(set(ChecklistSerializer.Meta.fields) - - {'paymentprovement', 'paymenttype', - 'buyername', 'buyerphone', - 'delivery', - 'recievername', 'recieverphone', 'tg', - 'gift', 'cdek_barcode_pdf'}) - +class ClientChecklistSerializerMixin: def validate(self, attrs): gift = attrs.get('gift') if gift is not None: @@ -210,6 +194,39 @@ class AnonymousUserChecklistSerializer(ChecklistSerializer): return attrs +class ClientCreateChecklistSerializer(ClientChecklistSerializerMixin, ChecklistSerializer): + status = serializers.SerializerMethodField() + + class Meta: + model = ChecklistSerializer.Meta.model + fields = ChecklistSerializer.Meta.fields + writable_fields = { + 'link', + 'brand', 'model', 'size', 'category', + 'price_yuan', + 'comment', + } + read_only_fields = tuple(set(ChecklistSerializer.Meta.fields) - writable_fields) + + def get_status(self, obj): + return Checklist.Status.DRAFT + + +class ClientUpdateChecklistSerializer(ClientChecklistSerializerMixin, ChecklistSerializer): + class Meta: + model = ChecklistSerializer.Meta.model + fields = ChecklistSerializer.Meta.fields + + writable_fields = { + 'comment', + 'payment_proof', 'payment_method', + 'delivery', + 'receiver_name', 'receiver_phone', + 'gift', 'cdek_barcode_pdf' + } + read_only_fields = tuple(set(ChecklistSerializer.Meta.fields) - writable_fields) + + class GlobalSettingsSerializer(serializers.ModelSerializer): currency = serializers.DecimalField(source='full_yuan_rate', read_only=True, max_digits=10, decimal_places=2) yuan_rate_last_updated = serializers.DateTimeField(read_only=True) @@ -222,6 +239,12 @@ class GlobalSettingsSerializer(serializers.ModelSerializer): fields = ('currency', 'yuan_rate_last_updated', 'yuan_rate_commission', 'commission', 'chinadelivery', 'pickup', 'time_to_buy') +class AnonymousGlobalSettingsSerializer(GlobalSettingsSerializer): + class Meta: + model = GlobalSettingsSerializer.Meta.model + fields = tuple(set(GlobalSettingsSerializer.Meta.fields) - {'yuan_rate_commission', 'yuan_rate_last_updated'}) + + class PaymentMethodSerializer(serializers.ModelSerializer): type = serializers.CharField(source='slug') diff --git a/store/urls.py b/store/urls.py index 55e0801..16a06e2 100644 --- a/store/urls.py +++ b/store/urls.py @@ -6,21 +6,18 @@ from store import views router = DefaultRouter() +router.register(r'checklist', views.ChecklistAPI, basename='checklist') router.register(r'statistics', views.StatisticsAPI, basename='statistics') router.register(r'cdek', views.CDEKAPI, basename='cdek') router.register(r'gifts', views.GiftAPI, basename='gifts') router.register(r'poizon', views.PoizonAPI, basename='poizon') +router.register(r'promo', views.PromoCodeAPI, basename='promo') urlpatterns = [ - path("checklist/", views.ChecklistAPI.as_view()), - path("checklist/