From fe248028312ab0519d057866a26f7b74fb224f14 Mon Sep 17 00:00:00 2001 From: phzhik Date: Sat, 27 Apr 2024 21:29:50 +0400 Subject: [PATCH] + Bonus system (TODO: spend bonuses) + Telegram bot: sign up, sign in, notifications + Anonymous users can't see yuan_rate_commission * Only logged in customers can create/update orders * Customer info migrated to separate User model * Renamed legacy fields in serializers * Cleanup in API classes --- .env | 3 +- _deploy/celery | 10 +- _deploy/celery.service | 4 +- _deploy/celerybeat.service | 4 +- _deploy/nginx.conf | 14 +- _deploy/uwsgi.ini | 4 +- account/__init__.py | 0 account/admin.py | 21 ++ account/apps.py | 9 + account/exceptions.py | 5 + account/migrations/0001_initial.py | 71 +++++ account/migrations/0002_initial.py | 42 +++ account/migrations/0003_alter_user_email.py | 18 ++ account/migrations/0004_alter_user_phone.py | 19 ++ account/migrations/0005_alter_user_email.py | 18 ++ account/migrations/0006_user_tg_user_id.py | 18 ++ .../migrations/0007_user_is_draft_user.py | 10 +- ...r_bonusprogramtransaction_user_and_more.py | 23 ++ ...options_user_balance_user_referral_code.py | 30 ++ .../0010_bonusprogramtransaction_comment.py | 18 ++ ...1_alter_bonusprogramtransaction_options.py | 57 ++++ .../0012_referralrelationship_invited_at.py | 20 ++ ...r_referralrelationship_invited_and_more.py | 25 ++ account/migrations/0014_alter_user_balance.py | 18 ++ ...onusprogramtransaction_options_and_more.py | 34 +++ account/migrations/0016_alter_user_role.py | 18 ++ account/migrations/__init__.py | 0 account/models/__init__.py | 4 + account/models/bonus.py | 278 ++++++++++++++++++ account/models/user.py | 201 +++++++++++++ account/permissions.py | 22 ++ account/serializers.py | 104 +++++++ account/signals.py | 29 ++ account/tests.py | 3 + account/urls.py | 13 + account/utils.py | 57 ++++ account/views.py | 153 ++++++++++ external_api/poizon.py | 4 +- poizonstore/settings.py | 31 +- poizonstore/urls.py | 3 +- requirements.txt | 7 + run_tg_bot.py | 37 +++ store/admin.py | 7 +- store/management/commands/create_test_data.py | 17 +- store/migrations/0001_initial.py | 167 +++++++---- ...ons_remove_checklist_comission_and_more.py | 26 -- ...cklist_customer_alter_checklist_manager.py | 26 ++ ...ist_id_alter_checklist_manager_and_more.py | 51 ---- ...03_remove_checklist_buyer_name_and_more.py | 25 ++ ...4_alter_globalsettings_options_and_more.py | 22 -- ..._alter_globalsettings_delivery_price_cn.py | 18 -- .../0006_alter_checklist_price_yuan.py | 18 -- store/migrations/0007_alter_category_slug.py | 18 -- store/migrations/0008_alter_checklist_id.py | 19 -- .../migrations/0009_alter_checklist_status.py | 18 -- .../migrations/0010_alter_checklist_status.py | 18 -- ...lsettings_delivery_price_cn_ru_and_more.py | 22 -- ..._category_delivery_price_cn_ru_and_more.py | 33 --- ...enttype_alter_category_options_and_more.py | 39 --- ...name_paymenttype_paymentmethod_and_more.py | 22 -- store/migrations/0015_paymentmethod_name.py | 19 -- .../0016_paymentmethod_cardnumber.py | 19 -- ...tions_alter_paymentmethod_slug_and_more.py | 27 -- store/migrations/0018_alter_promocode_name.py | 18 -- ...ngs_pickup_address_alter_checklist_size.py | 23 -- ...0_image_remove_checklist_image_and_more.py | 34 --- ...1_rename_is_preview_image_needs_preview.py | 18 -- ...2_rename_needs_preview_image_is_preview.py | 18 -- ...r_checklist_options_alter_image_options.py | 21 -- ...remove_checklist_images_image_checklist.py | 24 -- .../0025_alter_checklist_status_updated_at.py | 18 -- ..._remove_checklist_track_number_and_more.py | 27 -- ...027_checklist_cdek_barcode_pdf_and_more.py | 28 -- .../0028_alter_checklist_cdek_barcode_pdf.py | 18 -- ...cheque_photo_checklist_receipt_and_more.py | 23 -- .../0030_checklist_is_split_payment.py | 18 -- .../0031_alter_checklist_promocode.py | 19 -- store/migrations/0032_promocode_is_active.py | 18 -- .../migrations/0033_remove_user_manager_id.py | 17 -- .../0034_alter_promocode_discount.py | 19 -- ...age_checklist_checklist_images_and_more.py | 31 -- ...ve_image_is_preview_image_type_and_more.py | 32 -- store/migrations/0037_alter_image_image.py | 19 -- ..._alter_checklist_payment_proof_and_more.py | 33 --- .../0039_alter_checklist_delivery.py | 18 -- ...alter_paymentmethod_cardnumber_and_more.py | 23 -- ...g_remove_checklist_subcategory_and_more.py | 134 --------- ..._checklist_split_payment_proof_and_more.py | 59 ---- ..._pricesnapshot_checklist_price_snapshot.py | 29 -- .../migrations/0045_gift_alter_image_type.py | 31 -- ...046_checklist_gift_alter_gift_min_price.py | 24 -- store/migrations/0047_alter_checklist_gift.py | 19 -- ...lsettings_yuan_rate_commission_and_more.py | 23 -- store/migrations/0049_gift_available_count.py | 18 -- store/models.py | 192 +++++++----- store/serializers.py | 149 ++++++---- store/urls.py | 7 +- store/views.py | 205 +++++++------ tg_bot/__init__.py | 0 tg_bot/admin.py | 3 + tg_bot/apps.py | 6 + tg_bot/bot.py | 13 + tg_bot/handlers/__init__.py | 11 + tg_bot/handlers/bonus_system.py | 3 + tg_bot/handlers/start.py | 133 +++++++++ tg_bot/keyboards.py | 8 + tg_bot/management/__init__.py | 0 tg_bot/management/commands/__init__.py | 0 tg_bot/messages.py | 81 +++++ tg_bot/migrations/__init__.py | 0 tg_bot/models.py | 1 + tg_bot/tasks.py | 14 + tg_bot/utils.py | 3 + tg_bot/views.py | 3 + {store => utils}/exceptions.py | 9 - utils/permissions.py | 6 - 116 files changed, 2182 insertions(+), 1609 deletions(-) create mode 100644 account/__init__.py create mode 100644 account/admin.py create mode 100644 account/apps.py create mode 100644 account/exceptions.py create mode 100644 account/migrations/0001_initial.py create mode 100644 account/migrations/0002_initial.py create mode 100644 account/migrations/0003_alter_user_email.py create mode 100644 account/migrations/0004_alter_user_phone.py create mode 100644 account/migrations/0005_alter_user_email.py create mode 100644 account/migrations/0006_user_tg_user_id.py rename store/migrations/0044_checklist_split_accepted.py => account/migrations/0007_user_is_draft_user.py (52%) create mode 100644 account/migrations/0008_alter_bonusprogramtransaction_user_and_more.py create mode 100644 account/migrations/0009_alter_user_options_user_balance_user_referral_code.py create mode 100644 account/migrations/0010_bonusprogramtransaction_comment.py create mode 100644 account/migrations/0011_alter_bonusprogramtransaction_options.py create mode 100644 account/migrations/0012_referralrelationship_invited_at.py create mode 100644 account/migrations/0013_alter_referralrelationship_invited_and_more.py create mode 100644 account/migrations/0014_alter_user_balance.py create mode 100644 account/migrations/0015_alter_bonusprogramtransaction_options_and_more.py create mode 100644 account/migrations/0016_alter_user_role.py create mode 100644 account/migrations/__init__.py create mode 100644 account/models/__init__.py create mode 100644 account/models/bonus.py create mode 100644 account/models/user.py create mode 100644 account/permissions.py create mode 100644 account/serializers.py create mode 100644 account/signals.py create mode 100644 account/tests.py create mode 100644 account/urls.py create mode 100644 account/utils.py create mode 100644 account/views.py create mode 100644 run_tg_bot.py delete mode 100644 store/migrations/0002_alter_category_options_remove_checklist_comission_and_more.py create mode 100644 store/migrations/0002_checklist_customer_alter_checklist_manager.py delete mode 100644 store/migrations/0003_alter_checklist_id_alter_checklist_manager_and_more.py create mode 100644 store/migrations/0003_remove_checklist_buyer_name_and_more.py delete mode 100644 store/migrations/0004_alter_globalsettings_options_and_more.py delete mode 100644 store/migrations/0005_alter_globalsettings_delivery_price_cn.py delete mode 100644 store/migrations/0006_alter_checklist_price_yuan.py delete mode 100644 store/migrations/0007_alter_category_slug.py delete mode 100644 store/migrations/0008_alter_checklist_id.py delete mode 100644 store/migrations/0009_alter_checklist_status.py delete mode 100644 store/migrations/0010_alter_checklist_status.py delete mode 100644 store/migrations/0011_remove_globalsettings_delivery_price_cn_ru_and_more.py delete mode 100644 store/migrations/0012_alter_category_delivery_price_cn_ru_and_more.py delete mode 100644 store/migrations/0013_paymenttype_alter_category_options_and_more.py delete mode 100644 store/migrations/0014_rename_paymenttype_paymentmethod_and_more.py delete mode 100644 store/migrations/0015_paymentmethod_name.py delete mode 100644 store/migrations/0016_paymentmethod_cardnumber.py delete mode 100644 store/migrations/0017_alter_promocode_options_alter_paymentmethod_slug_and_more.py delete mode 100644 store/migrations/0018_alter_promocode_name.py delete mode 100644 store/migrations/0019_globalsettings_pickup_address_alter_checklist_size.py delete mode 100644 store/migrations/0020_image_remove_checklist_image_and_more.py delete mode 100644 store/migrations/0021_rename_is_preview_image_needs_preview.py delete mode 100644 store/migrations/0022_rename_needs_preview_image_is_preview.py delete mode 100644 store/migrations/0023_alter_checklist_options_alter_image_options.py delete mode 100644 store/migrations/0024_remove_checklist_images_image_checklist.py delete mode 100644 store/migrations/0025_alter_checklist_status_updated_at.py delete mode 100644 store/migrations/0026_remove_checklist_track_number_and_more.py delete mode 100644 store/migrations/0027_checklist_cdek_barcode_pdf_and_more.py delete mode 100644 store/migrations/0028_alter_checklist_cdek_barcode_pdf.py delete mode 100644 store/migrations/0029_rename_cheque_photo_checklist_receipt_and_more.py delete mode 100644 store/migrations/0030_checklist_is_split_payment.py delete mode 100644 store/migrations/0031_alter_checklist_promocode.py delete mode 100644 store/migrations/0032_promocode_is_active.py delete mode 100644 store/migrations/0033_remove_user_manager_id.py delete mode 100644 store/migrations/0034_alter_promocode_discount.py delete mode 100644 store/migrations/0035_remove_image_checklist_checklist_images_and_more.py delete mode 100644 store/migrations/0036_remove_image_is_preview_image_type_and_more.py delete mode 100644 store/migrations/0037_alter_image_image.py delete mode 100644 store/migrations/0038_alter_checklist_images_alter_checklist_payment_proof_and_more.py delete mode 100644 store/migrations/0039_alter_checklist_delivery.py delete mode 100644 store/migrations/0040_alter_paymentmethod_cardnumber_and_more.py delete mode 100644 store/migrations/0041_remove_category_slug_remove_checklist_subcategory_and_more.py delete mode 100644 store/migrations/0042_oldchecklist_checklist_split_payment_proof_and_more.py delete mode 100644 store/migrations/0043_pricesnapshot_checklist_price_snapshot.py delete mode 100644 store/migrations/0045_gift_alter_image_type.py delete mode 100644 store/migrations/0046_checklist_gift_alter_gift_min_price.py delete mode 100644 store/migrations/0047_alter_checklist_gift.py delete mode 100644 store/migrations/0048_globalsettings_yuan_rate_commission_and_more.py delete mode 100644 store/migrations/0049_gift_available_count.py create mode 100644 tg_bot/__init__.py create mode 100644 tg_bot/admin.py create mode 100644 tg_bot/apps.py create mode 100644 tg_bot/bot.py create mode 100644 tg_bot/handlers/__init__.py create mode 100644 tg_bot/handlers/bonus_system.py create mode 100644 tg_bot/handlers/start.py create mode 100644 tg_bot/keyboards.py create mode 100644 tg_bot/management/__init__.py create mode 100644 tg_bot/management/commands/__init__.py create mode 100644 tg_bot/messages.py create mode 100644 tg_bot/migrations/__init__.py create mode 100644 tg_bot/models.py create mode 100644 tg_bot/tasks.py create mode 100644 tg_bot/utils.py create mode 100644 tg_bot/views.py rename {store => utils}/exceptions.py (56%) 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/", views.ChecklistAPI.as_view()), - path("category/", views.CategoryAPI.as_view()), path("category/", views.CategoryAPI.as_view()), path("payment/", views.PaymentMethodsAPI.as_view()), path("settings/", views.GlobalSettingsAPI.as_view()), - path("promo/", views.PromoCodeAPI.as_view()), - ] + router.urls diff --git a/store/views.py b/store/views.py index 7f75d30..e36c932 100644 --- a/store/views.py +++ b/store/views.py @@ -1,25 +1,39 @@ import calendar from datetime import timedelta +import requests from django.conf import settings from django.db.models import F, Count, Sum, OuterRef, Subquery from django_filters.rest_framework import DjangoFilterBackend from rest_framework import generics, permissions, mixins, status, viewsets from rest_framework.decorators import action +from rest_framework.exceptions import NotFound from rest_framework.filters import SearchFilter from rest_framework.generics import get_object_or_404 -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from external_api.cdek import CDEKClient from external_api.poizon import PoizonClient -from store.exceptions import CRMException +from utils.exceptions import CRMException from store.filters import GiftFilter, ChecklistFilter from store.models import Checklist, GlobalSettings, Category, PaymentMethod, Promocode, Gift from store.serializers import (ChecklistSerializer, CategorySerializer, CategoryFullSerializer, - PaymentMethodSerializer, GlobalSettingsSerializer, - PromocodeSerializer, AnonymousUserChecklistSerializer, GiftSerializer) -from utils.permissions import ReadOnly + PaymentMethodSerializer, AnonymousGlobalSettingsSerializer, GlobalSettingsSerializer, + PromocodeSerializer, ClientUpdateChecklistSerializer, GiftSerializer, + ClientCreateChecklistSerializer) +from account.permissions import ReadOnly, IsManager, IsAdmin + + +def prepare_external_response(r: requests.Response): + data = {"status_code": r.status_code, "response": None} + + try: + data["response"] = r.json() + except: + data["response"] = r.text + + return Response(data) class DisablePermissionsMixin(generics.GenericAPIView): @@ -29,13 +43,20 @@ class DisablePermissionsMixin(generics.GenericAPIView): return super().get_permissions() +""" +- managers can create/edit/delete orders -class ChecklistAPI(mixins.ListModelMixin, - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - mixins.DestroyModelMixin, - DisablePermissionsMixin): +- auth users can create/edit orders (managers and clients) +- client will have customer field auto-populated +- managers can set customer field manually + +- clients can edit orders with customer.id == self.id + +- anon users can only get order by id, but can't edit +""" + + +class ChecklistAPI(viewsets.ModelViewSet): serializer_class = ChecklistSerializer lookup_field = 'id' filterset_class = ChecklistFilter @@ -43,16 +64,34 @@ class ChecklistAPI(mixins.ListModelMixin, search_fields = ['id', 'poizon_tracking', 'buyer_phone'] # TODO: search by full_price + def permission_denied(self, request, **kwargs): + if request.user.is_authenticated and self.action in ["update", "partial_update", "list", "retrieve"]: + raise NotFound() + super().permission_denied(request, **kwargs) + def get_serializer_class(self): - if self.request.user.is_authenticated or settings.DISABLE_PERMISSIONS: + # Managers have a full set of fields + if getattr(self.request.user, 'is_manager', False) or self.action == 'retrieve': return ChecklistSerializer - # Anonymous users can edit only a certain set of fields - return AnonymousUserChecklistSerializer + # Clients can create drafts with small set of fields + if self.action == "create": + return ClientCreateChecklistSerializer + + # Then, clients can update small set of fields + elif self.action in ['update', 'partial_update']: + return ClientUpdateChecklistSerializer + + # Fallback to error + self.permission_denied(self.request, **self.kwargs) def get_permissions(self): - if self.request.method in ('GET', 'PATCH'): - return [permissions.AllowAny()] + if self.action in ['list', 'update', 'partial_update', 'destroy']: + self.permission_classes = [IsManager] + elif self.action == 'retrieve': + self.permission_classes = [AllowAny] + elif self.action == 'create': + self.permission_classes = [IsAuthenticated] return super().get_permissions() @@ -72,31 +111,10 @@ class ChecklistAPI(mixins.ListModelMixin, return obj - def post(self, request, *args, **kwargs): - return self.create(request, *args, **kwargs) - - def get(self, request, *args, **kwargs): - if 'id' in kwargs: - return self.retrieve(request, *args, **kwargs) - - if not request.user.is_authenticated and not settings.DISABLE_PERMISSIONS: - # Anonymous users can't list checklists - return Response([]) - - return self.list(request, *args, **kwargs) - - def perform_update(self, serializer): - serializer.save() - - def patch(self, request, *args, **kwargs): - return self.partial_update(request, *args, **kwargs) - - def delete(self, request, *args, **kwargs): - return self.destroy(request, *args, **kwargs) - class CategoryAPI(mixins.ListModelMixin, mixins.UpdateModelMixin, generics.GenericAPIView): serializer_class = CategorySerializer + permission_classes = [IsManager | ReadOnly] lookup_field = 'id' def get_queryset(self): @@ -110,29 +128,24 @@ class CategoryAPI(mixins.ListModelMixin, mixins.UpdateModelMixin, generics.Gener return self.partial_update(request, *args, **kwargs) -class GlobalSettingsAPI(generics.GenericAPIView): +class GlobalSettingsAPI(generics.RetrieveUpdateAPIView): serializer_class = GlobalSettingsSerializer - permission_classes = [IsAuthenticated | ReadOnly] + permission_classes = [IsManager | ReadOnly] + + def get_serializer_class(self): + if getattr(self.request.user, 'is_manager', False) or settings.DISABLE_PERMISSIONS: + return GlobalSettingsSerializer + + # Anonymous users can view only a certain set of fields + return AnonymousGlobalSettingsSerializer def get_object(self): return GlobalSettings.load() - def get(self, request, *args, **kwargs): - instance = self.get_object() - return Response(self.get_serializer(instance).data) - - def patch(self, request, *args, **kwargs): - instance = GlobalSettings.load() - serializer = self.get_serializer(instance, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - serializer.save() - - return Response(serializer.data) - class PaymentMethodsAPI(generics.GenericAPIView): serializer_class = PaymentMethodSerializer - permission_classes = [IsAuthenticated | ReadOnly] + permission_classes = [IsManager | ReadOnly] def get_queryset(self): return PaymentMethod.objects.all() @@ -158,29 +171,15 @@ class PaymentMethodsAPI(generics.GenericAPIView): return Response(serializer.data) -class PromoCodeAPI(mixins.CreateModelMixin, generics.GenericAPIView): +class PromoCodeAPI(viewsets.ModelViewSet): serializer_class = PromocodeSerializer + permission_classes = [IsManager] lookup_field = 'name' def get_queryset(self): return Promocode.objects.all() - def get(self, request, *args, **kwargs): - qs = self.get_queryset() - return Response( - {'promo': self.get_serializer(qs, many=True).data} - ) - - def post(self, request, *args, **kwargs): - self.create(request, *args, **kwargs) - return self.get(request, *args, **kwargs) - - def delete(self, request, *args, **kwargs): - data = request.data - if 'name' not in data: - raise CRMException('name is required') - - instance: Promocode = get_object_or_404(self.get_queryset(), name=data['name']) + def perform_destroy(self, instance): instance.is_active = False instance.save() return Response(status=status.HTTP_204_NO_CONTENT) @@ -188,11 +187,11 @@ class PromoCodeAPI(mixins.CreateModelMixin, generics.GenericAPIView): class GiftAPI(viewsets.ModelViewSet): serializer_class = GiftSerializer - permission_classes = [IsAuthenticated | ReadOnly] + permission_classes = [IsManager | ReadOnly] filterset_class = GiftFilter def get_queryset(self): - if self.request.user.is_authenticated or settings.DISABLE_PERMISSIONS: + if getattr(self.request.user, 'is_manager', False) or settings.DISABLE_PERMISSIONS: return Gift.objects.all() # For anonymous users, show only available gifts @@ -200,11 +199,15 @@ class GiftAPI(viewsets.ModelViewSet): class StatisticsAPI(viewsets.GenericViewSet): + permission_classes = [IsAdmin] + def get_queryset(self): return Checklist.objects.all() \ + .select_related('customer') \ .filter(status=Checklist.Status.COMPLETED) \ .annotate(month=F('status_updated_at__month')) + # FIXME: stats_by_orders is broken because of _commission_rub annotation @action(url_path='orders', detail=False, methods=['get']) def stats_by_orders(self, request, *args, **kwargs): global_settings = GlobalSettings.load() @@ -244,6 +247,7 @@ class StatisticsAPI(viewsets.GenericViewSet): return Response(result) + # FIXME: stats_by_categories is broken because of absence of Category.slug field @action(url_path='categories', detail=False, methods=['get']) def stats_by_categories(self, request, *args, **kwargs): all_categories = Category.objects.values_list('slug', flat=True) @@ -287,31 +291,21 @@ class StatisticsAPI(viewsets.GenericViewSet): return {k: [] for k in options.keys()} qs = self.get_queryset() \ - .filter(buyer_phone__isnull=False) \ - .values('month', 'buyer_phone') \ + .filter(customer__isnull=False) \ + .values('month', 'customer_id') \ .annotate(order_count=Count('id')) \ .filter(order_count__gt=1) \ .order_by('month') - # Temporary hack: collect the most recent info about client - # mapping buyer_phone -> buyer info (name, telegram) - client_mapping = {} + # mapping customer_id -> customer info + customer_mapping = {} - recent_created_at = Checklist.objects.all() \ - .filter(buyer_phone=OuterRef('buyer_phone')) \ - .order_by('-created_at') \ - .values('created_at')[:1] - - client_qs = Checklist.objects.filter( - created_at=Subquery(recent_created_at), - buyer_phone=F('buyer_phone') - ).distinct() - - for checklist in client_qs: - client_mapping[checklist.buyer_phone] = { - 'phone': checklist.buyer_phone, - 'name': checklist.buyer_name, - 'telegram': checklist.buyer_telegram} + for order in Checklist.objects.all().select_related('customer'): + customer_mapping[order.customer_id] = { + 'phone': str(order.customer.phone), + 'name': order.customer.first_name, + 'telegram': order.customer.telegram + } result = {} # Add empty stats @@ -320,18 +314,21 @@ class StatisticsAPI(viewsets.GenericViewSet): result[month_name] = _create_empty_stats() # Add actual stats - for stat in qs: - month_name = calendar.month_name[stat['month']] + for order in qs: + month_name = calendar.month_name[order['month']] + # Collect data for each order count: 1/2/3/4/5/10/25/50 for key, size in reversed(options.items()): - if stat['order_count'] > size: - client_info = client_mapping[stat['buyer_phone']] + if order['order_count'] > size: + client_info = customer_mapping[order['customer_id']] + client_info["order_count"] = order['order_count'] result[month_name][key].append(client_info) break return Response(result) +# TODO: review permissions class CDEKAPI(viewsets.GenericViewSet): client = CDEKClient(settings.CDEK_CLIENT_ID, settings.CDEK_CLIENT_SECRET) permission_classes = [permissions.AllowAny] @@ -343,7 +340,7 @@ class CDEKAPI(viewsets.GenericViewSet): raise CRMException('im_number is required') r = self.client.get_order_info(im_number) - return Response(r.json()) + return prepare_external_response(r) @get_order_info.mapping.post def create_order(self, request, *args, **kwargs): @@ -352,7 +349,7 @@ class CDEKAPI(viewsets.GenericViewSet): raise CRMException('json data is required') r = self.client.create_order(order_data) - return Response(r.json()) + return prepare_external_response(r) @get_order_info.mapping.patch def edit_order(self, request, *args, **kwargs): @@ -361,7 +358,7 @@ class CDEKAPI(viewsets.GenericViewSet): raise CRMException('json data is required') r = self.client.edit_order(order_data) - return Response(r.json()) + return prepare_external_response(r) @action(url_path='calculator/tariff', detail=False, methods=['post']) def calculate_tariff(self, request, *args, **kwargs): @@ -369,7 +366,7 @@ class CDEKAPI(viewsets.GenericViewSet): if not data: raise CRMException('json data is required') r = self.client.calculate_tariff(data) - return Response(r.json()) + return prepare_external_response(r) @action(url_path='calculator/tarifflist', detail=False, methods=['post']) def calculate_tarifflist(self, request, *args, **kwargs): @@ -377,11 +374,13 @@ class CDEKAPI(viewsets.GenericViewSet): if not data: raise CRMException('json data is required') r = self.client.calculate_tarifflist(data) - return Response(r.json()) + return prepare_external_response(r) +# TODO: review permissions class PoizonAPI(viewsets.GenericViewSet): client = PoizonClient(settings.POIZON_TOKEN) + permission_classes = [permissions.AllowAny] @action(url_path='good', detail=False, methods=['post']) def get_good_info(self, request, *args, **kwargs): @@ -394,5 +393,5 @@ class PoizonAPI(viewsets.GenericViewSet): if spu_id is None: raise CRMException('url or spuId is required') - data = self.client.get_good_info(spu_id) - return Response(data) + r = self.client.get_good_info(spu_id) + return prepare_external_response(r) diff --git a/tg_bot/__init__.py b/tg_bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tg_bot/admin.py b/tg_bot/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/tg_bot/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/tg_bot/apps.py b/tg_bot/apps.py new file mode 100644 index 0000000..5c425ff --- /dev/null +++ b/tg_bot/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TgBotConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'tg_bot' diff --git a/tg_bot/bot.py b/tg_bot/bot.py new file mode 100644 index 0000000..d07cd43 --- /dev/null +++ b/tg_bot/bot.py @@ -0,0 +1,13 @@ +from telebot import asyncio_storage, TeleBot, storage +from telebot.async_telebot import AsyncTeleBot +from telebot.types import BotCommand + +from poizonstore import settings + +# Global objects +bot = AsyncTeleBot(settings.TG_BOT_TOKEN, state_storage=asyncio_storage.StateRedisStorage()) +bot_sync = TeleBot(settings.TG_BOT_TOKEN, state_storage=storage.StateRedisStorage()) + +commands = [ + # BotCommand("add_phone", "Связать номер телефона с профилем на сайте") +] diff --git a/tg_bot/handlers/__init__.py b/tg_bot/handlers/__init__.py new file mode 100644 index 0000000..206da71 --- /dev/null +++ b/tg_bot/handlers/__init__.py @@ -0,0 +1,11 @@ +from tg_bot.handlers import start + + +def register_handlers(): + from tg_bot.bot import bot + + handlers = [] + handlers.extend(start.get_handlers()) + + for h in handlers: + bot.register_message_handler(h.callback, **h.kwargs, pass_bot=True) diff --git a/tg_bot/handlers/bonus_system.py b/tg_bot/handlers/bonus_system.py new file mode 100644 index 0000000..8121385 --- /dev/null +++ b/tg_bot/handlers/bonus_system.py @@ -0,0 +1,3 @@ +# TODO: implement +def get_handlers(): + return [] diff --git a/tg_bot/handlers/start.py b/tg_bot/handlers/start.py new file mode 100644 index 0000000..f956baf --- /dev/null +++ b/tg_bot/handlers/start.py @@ -0,0 +1,133 @@ +from telebot import types +from telebot.async_telebot import Handler, AsyncTeleBot +from telebot.asyncio_handler_backends import StatesGroup, State, ContinueHandling +from telebot.types import ReplyKeyboardRemove + +from account.models import User +from tg_bot.messages import TGCoreMessage +from tg_bot.keyboards import TGKeyboards +from tg_bot.tasks import send_tg_message +from tg_bot.utils import extract_deep_link + + +# TODO: step handlers (https://github.com/eternnoir/pyTelegramBotAPI/blob/master/examples/step_example.py) +# TODO: state filters +class BotStates(StatesGroup): + idle = State() + send_phone = State() + + +# Default message handler +async def default_handler(message: types.Message, bot: AsyncTeleBot): + cid = message.from_user.id + + # Set default state for sender + if await bot.get_state(cid) is None: + await bot.set_state(cid, BotStates.idle) + + return ContinueHandling() + + +# === Command /start === +async def start_handler(message: types.Message, bot: AsyncTeleBot): + cid = message.from_user.id + + await bot.set_state(cid, BotStates.idle) + + if not await is_tg_bound_to_user(cid): + # Send welcome message + await bot.send_message(cid, TGCoreMessage.START) + referral_code = extract_deep_link(message.text) + + # Save referral code in chat context for later usage + # TODO: use base64-encoded hash here for additional data maybe? + async with bot.retrieve_data(cid) as data: + data['referral_code'] = referral_code + + # Ask for phone number to bind to User + await request_phone(cid, TGCoreMessage.SHARE_PHONE, bot) + + else: + # TODO: if user is signed in, send help text or account info instead + pass + + +# TODO: implement sometime in the future +# === Command /add_phone === +# async def add_phone_handler(message: types.Message, bot: AsyncTeleBot): +# cid = message.from_user.id +# await request_phone(cid, bot, TGCoreMessage.SHARE_PHONE) + + +async def is_tg_bound_to_user(tg_user_id): + return await User.objects.filter(tg_user_id=tg_user_id, phone__isnull=False).aexists() + + +async def request_phone(user_id, text, bot: AsyncTeleBot): + """ Ask for phone number to bind to User """ + reply_markup = TGKeyboards.SHARE_PHONE + + await bot.set_state(user_id, BotStates.send_phone) + await bot.send_message(user_id, text, reply_markup=reply_markup) + + +def request_phone_sync(user_id, text): + state = BotStates.send_phone + reply_markup = TGKeyboards.SHARE_PHONE + + send_tg_message(user_id, text, state=state.name, reply_markup=reply_markup) + + +async def contact_handler(message: types.Message, bot: AsyncTeleBot): + """ User has sent his phone number to authenticate in bot """ + + cid = message.from_user.id + + async with bot.retrieve_data(cid) as data: + await User.objects.bind_tg_user( + tg_user_id=cid, + phone=message.contact.phone_number, + referral_code=data.pop('referral_code', None) + ) + await bot.send_message(cid, TGCoreMessage.AUTH_SUCCESS, reply_markup=ReplyKeyboardRemove()) + + await bot.set_state(cid, BotStates.idle) + + +def get_handlers(): + return [ + Handler(callback=default_handler), + Handler(callback=start_handler, commands=['start'], state=BotStates.idle), + # Handler(callback=add_phone_handler, commands=['add_phone']), + Handler(callback=contact_handler, + content_types=['contact'], + state=BotStates.send_phone, + func=lambda m: m.from_user.id == m.contact.user_id # Disallow contact forwarding + ), + ] + + +""" +- backend: try to find User with tg_user_id of Message +- no User found -> ask for phone + +Possible situations: +1) No user in db: +- tg: ask for phone +- backend: create draft user +- web: login via tg -> + +2) User exists in DB, but has no phone (phone deleted in web) +- tg: ask for phone +- find User with tg_user_id & add phone + +3) User exists in DB, but no tg_user_id or phone ??? + +""" + +""" +Options: +- login via telegram -> find User with tg_user_id -> if found, login +- send contact from bot -> find user with phone -> add tg_user_id to User +- +""" diff --git a/tg_bot/keyboards.py b/tg_bot/keyboards.py new file mode 100644 index 0000000..b4a0a40 --- /dev/null +++ b/tg_bot/keyboards.py @@ -0,0 +1,8 @@ +from telebot.types import ReplyKeyboardMarkup, KeyboardButton + +from tg_bot.messages import TGCoreMessage + + +class TGKeyboards: + SHARE_PHONE = ReplyKeyboardMarkup(resize_keyboard=True) \ + .add(KeyboardButton(TGCoreMessage.SHARE_PHONE_KEYBOARD, request_contact=True)) diff --git a/tg_bot/management/__init__.py b/tg_bot/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tg_bot/management/commands/__init__.py b/tg_bot/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tg_bot/messages.py b/tg_bot/messages.py new file mode 100644 index 0000000..5066865 --- /dev/null +++ b/tg_bot/messages.py @@ -0,0 +1,81 @@ +# TODO: implement translation through i18n _ + + +class TGBonusMessage: + SIGNUP = "Вам начислено {amount}Р баллов за привязку номера телефона 🎊" + PURCHASE_ADDED = "Ура! Вам начислено {amount}P баллов за оформление заказа №{order_id}} 🎉" + PURCHASE_SPENT = "Вы потратили {amount}P баллов на оформление заказа №{order_id}" + FOR_INVITER = """ +Спасибо, твой друг оформил заказ и получил {amount}Р бонусов ✅ +Ты тоже получаешь {amount}Р бонусов 🎉 +Продолжай делится ссылкой на наш сервис 🤝 +""" + INVITED_FIRST_PURCHASE = "Поздравляю, Вам начислено {amount}P баллов за оформление заказа №{order_id} 🎉" + + OTHER_DEPOSIT = """ +Поздравляю! Вам зачислено {amount}P баллов на баланс 🥳 +Причина: {comment} +""" + OTHER_WITHDRAWAL = """ +С баланса списано {amount}P баллов. +Причина: {comment} +""" + + +class TGOrderStatusMessage: + NEW = """ +Создан новый заказ №{order_id} +Ссылка: {order_link} + """ + PAYMENT = """""" + BUYING_NON_SPLIT = "Заказ №{order_id} оплачен, спасибо! Скоро оформим ✅" + + BUYING_SPLIT = """ +Заказ №{order_id} оплачен через сплит, спасибо +Остаток к оплате {amount_to_pay}₽. +Скоро оформим ✅ +""" + + BOUGHT = "Заказ №{order_id} оформлен, ожидаем доставку на склад в Китае 🇨🇳" + + CHINA = "Заказ №{order_id} на складе в Китае 🇨🇳, скоро отправим в Россию 🇷🇺" + CHINA_RUSSIA = "Заказ №{order_id} отправлен в Россию 🇷🇺" + + RUSSIA_PICKUP = """ +Заказ №{order_id} на складе в России 🇷🇺 и готов к выдаче. +Чтобы согласовать день и время забора заказа, напишите @noziop +""" + + RUSSIA_CDEK = "Заказ №{order_id} на складе в России 🇷🇺, скоро передадим в СДЭК 🚛" + + SPLIT_WAITING_PICKUP = """ +Заказ №{order_id} на складе в России 🇷🇺 и готов к выдаче. +Пожалуйста, оплатите вторую часть {amount_to_pay}₽. +После оплаты согласуйте день и время забора заказа через чат с @noziop +""" + SPLIT_WAITING_CDEK = """ +Заказ №{order_id} на складе в России 🇷🇺. +Пожалуйста, оплатите вторую часть {amount_to_pay}₽. После оплаты передадим заказ в СДЭК 🚛 +""" + SPLIT_PAID = "Заказ №{order_id} оплачен полностью, спасибо 🙌🏼" + + CDEK = "Заказ №{order_id} передан в СДЭК 🚛" + + COMPLETED = """ +Заказ №{order_id} доставлен 🎉 +Спасибо большое, что выбрали нас 💙 + +Пожалуйста, оставьте небольшой отзыв с фото в чате с @noziop, а мы подарим Вам скидку 150₽ на следующий заказ😉 +""" + + +class TGCoreMessage: + START = "Добро пожаловать в PoizonStore!" + + SHARE_PHONE_KEYBOARD = "Отправить номер телефона" + SHARE_PHONE = "Поделитесь своим номером, чтобы авторизоваться:" + SIGN_UP_SHARE_PHONE = """ + Добро пожаловать в PoizonStore! + Для завершения регистрации, поделитесь номером телефона: + """ + AUTH_SUCCESS = "Спасибо, теперь вы авторизованы и можете отслеживать заказы." diff --git a/tg_bot/migrations/__init__.py b/tg_bot/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tg_bot/models.py b/tg_bot/models.py new file mode 100644 index 0000000..137941f --- /dev/null +++ b/tg_bot/models.py @@ -0,0 +1 @@ +from django.db import models diff --git a/tg_bot/tasks.py b/tg_bot/tasks.py new file mode 100644 index 0000000..528d82f --- /dev/null +++ b/tg_bot/tasks.py @@ -0,0 +1,14 @@ +from celery import shared_task + + +@shared_task +def send_tg_message(user_id, message, **kwargs): + from tg_bot.bot import bot_sync as bot + + if user_id is None: + return + + if 'state' in kwargs: + bot.set_state(user_id, kwargs.pop('state')) + + bot.send_message(user_id, message, **kwargs) diff --git a/tg_bot/utils.py b/tg_bot/utils.py new file mode 100644 index 0000000..56a9251 --- /dev/null +++ b/tg_bot/utils.py @@ -0,0 +1,3 @@ +def extract_deep_link(text): + """ Extracts the unique_code from the sent /start command. """ + return text.split()[1] if len(text.split()) > 1 else None diff --git a/tg_bot/views.py b/tg_bot/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/tg_bot/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/store/exceptions.py b/utils/exceptions.py similarity index 56% rename from store/exceptions.py rename to utils/exceptions.py index 886e440..7701c81 100644 --- a/store/exceptions.py +++ b/utils/exceptions.py @@ -10,12 +10,3 @@ class CRMException(APIException): detail = self.default_detail self.detail = {'error': detail} - - -class UnauthorizedException(CRMException): - """Authentication exception error mixin.""" - status_code = status.HTTP_401_UNAUTHORIZED - - -class InvalidCredentialsException(UnauthorizedException): - default_detail = 'cannot find the worker' diff --git a/utils/permissions.py b/utils/permissions.py index 95bf53f..e69de29 100644 --- a/utils/permissions.py +++ b/utils/permissions.py @@ -1,6 +0,0 @@ -from rest_framework.permissions import BasePermission, SAFE_METHODS - - -class ReadOnly(BasePermission): - def has_permission(self, request, view): - return request.method in SAFE_METHODS