From ed66b2a3d810e7b86b0c58aba84a468b565ecbea Mon Sep 17 00:00:00 2001 From: Arseniy Sitnikov <74531830+arscoolik@users.noreply.github.com> Date: Sat, 7 Dec 2024 10:01:09 +0300 Subject: [PATCH 1/3] Update .gitignore --- .gitignore | 170 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 169 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 496ee2c..8c808d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,169 @@ -.DS_Store \ No newline at end of file +.DS_Store +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ From 4a18a785e92709971eeba4f6b54c158a91008ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B0=D0=BD=D1=8F=20=D0=92=D0=B0=D0=BA=D1=83=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BA=D0=BE=D0=B2?= Date: Tue, 10 Dec 2024 23:31:35 +0300 Subject: [PATCH 2/3] Add backend code --- backend/Dockerfile | 22 + backend/auction/__init__.py | 0 backend/auction/admin.py | 147 +++++++ backend/auction/apps.py | 6 + backend/auction/filters/__init__.py | 2 + backend/auction/filters/auction.py | 24 ++ backend/auction/filters/bet.py | 29 ++ backend/auction/migrations/0001_initial.py | 57 +++ backend/auction/migrations/__init__.py | 0 backend/auction/models/__init__.py | 3 + backend/auction/models/auction.py | 66 +++ backend/auction/models/bet.py | 55 +++ backend/auction/models/product.py | 14 + backend/auction/serializers/__init__.py | 3 + backend/auction/serializers/auction.py | 13 + backend/auction/serializers/bet.py | 12 + backend/auction/serializers/product.py | 8 + backend/auction/tests.py | 3 + backend/auction/urls.py | 10 + backend/auction/views/__init__.py | 3 + backend/auction/views/auction.py | 13 + backend/auction/views/bet.py | 12 + backend/auction/views/place_bet.py | 50 +++ backend/clicker/__init__.py | 0 backend/clicker/asgi.py | 16 + backend/clicker/celery.py | 9 + backend/clicker/settings.py | 210 ++++++++++ backend/clicker/storage_backends.py | 8 + backend/clicker/urls.py | 19 + backend/clicker/wsgi.py | 16 + backend/clicks/__init__.py | 0 backend/clicks/admin.py | 37 ++ backend/clicks/apps.py | 9 + backend/clicks/celery/__init__.py | 1 + backend/clicks/celery/click.py | 27 ++ backend/clicks/migrations/0001_initial.py | 26 ++ backend/clicks/migrations/__init__.py | 0 backend/clicks/models/__init__.py | 1 + backend/clicks/models/click.py | 15 + backend/clicks/tests.py | 3 + backend/clicks/views.py | 3 + backend/manage.py | 22 + backend/media/products/1003345981.jpg | Bin 0 -> 7922 bytes backend/misc/__init__.py | 0 backend/misc/admin.py | 41 ++ backend/misc/apps.py | 14 + backend/misc/celery/__init__.py | 1 + backend/misc/celery/deliver_setting.py | 22 + backend/misc/migrations/0001_initial.py | 99 +++++ backend/misc/migrations/__init__.py | 0 backend/misc/models/__init__.py | 5 + backend/misc/models/banner.py | 18 + backend/misc/models/button.py | 13 + backend/misc/models/popup.py | 32 ++ backend/misc/models/setting.py | 20 + backend/misc/models/style.py | 26 ++ backend/misc/signals/__init__.py | 1 + backend/misc/signals/setting.py | 9 + backend/misc/tests.py | 3 + backend/misc/views.py | 3 + backend/pytest.ini | 7 + backend/requirements.txt | 87 ++++ backend/scripts/entrypoint.sh | 32 ++ backend/scripts/gunicorn.sh | 9 + backend/scripts/start.sh | 10 + backend/scripts/start_celery.sh | 7 + backend/users/__init__.py | 0 backend/users/admin.py | 409 +++++++++++++++++++ backend/users/apps.py | 13 + backend/users/authentication.py | 86 ++++ backend/users/celery/__init__.py | 1 + backend/users/celery/mailing_list.py | 53 +++ backend/users/choices/__init__.py | 1 + backend/users/choices/mailing_list_status.py | 10 + backend/users/errors/__init__.py | 1 + backend/users/errors/not_enough_funds.py | 7 + backend/users/migrations/0001_initial.py | 139 +++++++ backend/users/migrations/__init__.py | 0 backend/users/models/__init__.py | 3 + backend/users/models/mailing_list.py | 42 ++ backend/users/models/tg_user.py | 43 ++ backend/users/models/transactions.py | 64 +++ backend/users/permissions.py | 6 + backend/users/serializers/__init__.py | 1 + backend/users/serializers/tg_user.py | 12 + backend/users/signals/__init__.py | 7 + backend/users/signals/mailing_list_signal.py | 12 + backend/users/signals/transactions.py | 42 ++ backend/users/signals/users.py | 26 ++ backend/users/tests/__init__.py | 0 backend/users/tests/test_ranking.py | 191 +++++++++ backend/users/tests/test_tg_user.py | 103 +++++ backend/users/urls/__init__.py | 0 backend/users/urls/internal_urls.py | 7 + backend/users/urls/urls.py | 17 + backend/users/views.py | 3 + backend/users/views/__init__.py | 6 + backend/users/views/check_registration.py | 11 + backend/users/views/empty_storage.py | 21 + backend/users/views/get_token.py | 42 ++ backend/users/views/ranking.py | 53 +++ backend/users/views/tg_user.py | 22 + backend/users/views/warn.py | 15 + 103 files changed, 2902 insertions(+) create mode 100644 backend/Dockerfile create mode 100644 backend/auction/__init__.py create mode 100644 backend/auction/admin.py create mode 100644 backend/auction/apps.py create mode 100644 backend/auction/filters/__init__.py create mode 100644 backend/auction/filters/auction.py create mode 100644 backend/auction/filters/bet.py create mode 100644 backend/auction/migrations/0001_initial.py create mode 100644 backend/auction/migrations/__init__.py create mode 100644 backend/auction/models/__init__.py create mode 100644 backend/auction/models/auction.py create mode 100644 backend/auction/models/bet.py create mode 100644 backend/auction/models/product.py create mode 100644 backend/auction/serializers/__init__.py create mode 100644 backend/auction/serializers/auction.py create mode 100644 backend/auction/serializers/bet.py create mode 100644 backend/auction/serializers/product.py create mode 100644 backend/auction/tests.py create mode 100644 backend/auction/urls.py create mode 100644 backend/auction/views/__init__.py create mode 100644 backend/auction/views/auction.py create mode 100644 backend/auction/views/bet.py create mode 100644 backend/auction/views/place_bet.py create mode 100644 backend/clicker/__init__.py create mode 100644 backend/clicker/asgi.py create mode 100644 backend/clicker/celery.py create mode 100644 backend/clicker/settings.py create mode 100644 backend/clicker/storage_backends.py create mode 100644 backend/clicker/urls.py create mode 100644 backend/clicker/wsgi.py create mode 100644 backend/clicks/__init__.py create mode 100644 backend/clicks/admin.py create mode 100644 backend/clicks/apps.py create mode 100644 backend/clicks/celery/__init__.py create mode 100644 backend/clicks/celery/click.py create mode 100644 backend/clicks/migrations/0001_initial.py create mode 100644 backend/clicks/migrations/__init__.py create mode 100644 backend/clicks/models/__init__.py create mode 100644 backend/clicks/models/click.py create mode 100644 backend/clicks/tests.py create mode 100644 backend/clicks/views.py create mode 100755 backend/manage.py create mode 100644 backend/media/products/1003345981.jpg create mode 100644 backend/misc/__init__.py create mode 100644 backend/misc/admin.py create mode 100644 backend/misc/apps.py create mode 100644 backend/misc/celery/__init__.py create mode 100644 backend/misc/celery/deliver_setting.py create mode 100644 backend/misc/migrations/0001_initial.py create mode 100644 backend/misc/migrations/__init__.py create mode 100644 backend/misc/models/__init__.py create mode 100644 backend/misc/models/banner.py create mode 100644 backend/misc/models/button.py create mode 100644 backend/misc/models/popup.py create mode 100644 backend/misc/models/setting.py create mode 100644 backend/misc/models/style.py create mode 100644 backend/misc/signals/__init__.py create mode 100644 backend/misc/signals/setting.py create mode 100644 backend/misc/tests.py create mode 100644 backend/misc/views.py create mode 100644 backend/pytest.ini create mode 100644 backend/requirements.txt create mode 100644 backend/scripts/entrypoint.sh create mode 100644 backend/scripts/gunicorn.sh create mode 100644 backend/scripts/start.sh create mode 100644 backend/scripts/start_celery.sh create mode 100644 backend/users/__init__.py create mode 100644 backend/users/admin.py create mode 100644 backend/users/apps.py create mode 100644 backend/users/authentication.py create mode 100644 backend/users/celery/__init__.py create mode 100644 backend/users/celery/mailing_list.py create mode 100644 backend/users/choices/__init__.py create mode 100644 backend/users/choices/mailing_list_status.py create mode 100644 backend/users/errors/__init__.py create mode 100644 backend/users/errors/not_enough_funds.py create mode 100644 backend/users/migrations/0001_initial.py create mode 100644 backend/users/migrations/__init__.py create mode 100644 backend/users/models/__init__.py create mode 100644 backend/users/models/mailing_list.py create mode 100644 backend/users/models/tg_user.py create mode 100644 backend/users/models/transactions.py create mode 100644 backend/users/permissions.py create mode 100644 backend/users/serializers/__init__.py create mode 100644 backend/users/serializers/tg_user.py create mode 100644 backend/users/signals/__init__.py create mode 100644 backend/users/signals/mailing_list_signal.py create mode 100644 backend/users/signals/transactions.py create mode 100644 backend/users/signals/users.py create mode 100644 backend/users/tests/__init__.py create mode 100644 backend/users/tests/test_ranking.py create mode 100644 backend/users/tests/test_tg_user.py create mode 100644 backend/users/urls/__init__.py create mode 100644 backend/users/urls/internal_urls.py create mode 100644 backend/users/urls/urls.py create mode 100644 backend/users/views.py create mode 100644 backend/users/views/__init__.py create mode 100644 backend/users/views/check_registration.py create mode 100644 backend/users/views/empty_storage.py create mode 100644 backend/users/views/get_token.py create mode 100644 backend/users/views/ranking.py create mode 100644 backend/users/views/tg_user.py create mode 100644 backend/users/views/warn.py diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..68f8685 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.11 + +# python envs +ENV PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=off \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=100 + +# python dependencies +COPY ./requirements.txt / +RUN pip install -r ./requirements.txt + +COPY ./scripts/entrypoint.sh ./scripts/start.sh ./scripts/gunicorn.sh ./scripts/start_celery.sh / +# upload scripts + +# Fix windows docker bug, convert CRLF to LF +RUN sed -i 's/\r$//g' /start.sh && chmod +x /start.sh && sed -i 's/\r$//g' /entrypoint.sh && chmod +x /entrypoint.sh &&\ + sed -i 's/\r$//g' /gunicorn.sh && chmod +x /gunicorn.sh && sed -i 's/\r$//g' /start_celery.sh && chmod +x /start_celery.sh + +WORKDIR /app diff --git a/backend/auction/__init__.py b/backend/auction/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/auction/admin.py b/backend/auction/admin.py new file mode 100644 index 0000000..21e50de --- /dev/null +++ b/backend/auction/admin.py @@ -0,0 +1,147 @@ +from django.contrib import admin +from django.urls import reverse +from django.utils.html import format_html, urlencode +from django_cte import With +from auction.models import Auction, Bet, Product +from users.models import TGUser + + +@admin.register(Auction) +class AuctionAdmin(admin.ModelAdmin): + list_display = [ + 'id', + 'view_product_link', + 'quantity', + 'end_time', + 'is_active', + 'view_winners_link', + 'view_betters_link', + 'view_bets_link', + 'initial_end_time', + 'last_call_delta', + 'times_postponed', + 'initial_cost', + 'commission', + ] + list_display_links = [ + 'id', + ] + autocomplete_fields = [ + 'product' + ] + readonly_fields = ['times_postponed'] + + def end_time(self, obj): + return obj._end_time + end_time.admin_order_field = '_end_time' + end_time.short_description = 'Дата окончания' + + def is_active(self, obj): + return obj._is_active + is_active.admin_order_field = '_is_active' + is_active.short_description = 'Активен ли' + is_active.boolean = True + + def view_product_link(self, obj): + url = reverse("admin:auction_product_change", args=[obj.product_id]) + return format_html(f'{obj.product.name} ({obj.product_id})') + view_product_link.short_description = 'Товар' + + def view_betters_link(self, obj): + count = obj.betters.distinct().count() + url = reverse('admin:users_tguser_changelist') + '?' + urlencode({'betters__pk': f'{obj.pk}'}) + return format_html(f' {count} users ') + view_betters_link.short_description = 'Пользователи, сделавшие ставки' + + def view_bets_link(self, obj): + count = obj.bets.count() + url = reverse('admin:auction_bet_changelist') + '?' + urlencode({'auction_id': f'{obj.pk}'}) + return format_html(f' {count} bets ') + view_bets_link.short_description = 'Ставки' + + def view_winners_link(self, obj): + winning_user_ids = obj.bets.filter(_is_winning=True).values_list('user_id', flat=True) + count = winning_user_ids.count() + url = reverse('admin:users_tguser_changelist') + '?' + urlencode({'pk__in': ','.join(map(str, winning_user_ids))}) + return format_html(f' {count} победителей ') + view_winners_link.short_description = 'Победители' + + +@admin.register(Product) +class ProductAdmin(admin.ModelAdmin): + list_display = [ + 'id', + 'view_auctions_link', + 'name', + 'cover', + 'description', + ] + list_display_links = [ + 'id' + ] + search_fields = [ + 'name' + ] + + def view_auctions_link(self, obj): + count = obj.auctions.count() + url = reverse('admin:auction_auction_changelist') + '?' + urlencode({'product_id': f'{obj.pk}'}) + return format_html(f' {count} auctions ') + view_auctions_link.short_description = 'Аукционы' + + +class IsWinningListFilter(admin.SimpleListFilter): + title = 'Является ли победной' + parameter_name = '_is_winning' + + def lookups(self, request, model_admin): + return ( + (True, 'Да'), + (False, 'Нет') + ) + + def queryset(self, request, queryset): + if self.value() is not None: + return queryset.filter(**{self.parameter_name: self.value()}) + return queryset + +@admin.register(Bet) +class BetAdmin(admin.ModelAdmin): + list_display = [ + 'id', + 'view_user_link', + 'view_auction_link', + 'created_at', + 'view_transactions_link', + ] + list_display_links = [ + 'id', + ] + list_filter = [ + IsWinningListFilter + ] + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + def view_user_link(self, obj): + url = reverse("admin:users_tguser_change", args=[obj.user_id]) + return format_html(f'{obj.user}') + view_user_link.short_description = 'Пользователь' + + def view_auction_link(self, obj): + url = reverse("admin:auction_auction_change", args=[obj.auction_id]) + return format_html(f'{obj.auction}') + view_auction_link.short_description = 'Аукцион' + + def view_transactions_link(self, obj): + count = obj.transactions.count() + url = reverse('admin:users_bettransaction_changelist') + '?' + urlencode({'bet_id': f'{obj.pk}'}) + return format_html(f' {count} transactions ') + view_transactions_link.short_description = 'Транзакции' diff --git a/backend/auction/apps.py b/backend/auction/apps.py new file mode 100644 index 0000000..a9df69d --- /dev/null +++ b/backend/auction/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuctionConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "auction" diff --git a/backend/auction/filters/__init__.py b/backend/auction/filters/__init__.py new file mode 100644 index 0000000..688d0c2 --- /dev/null +++ b/backend/auction/filters/__init__.py @@ -0,0 +1,2 @@ +from .auction import AuctionFilter +from .bet import BetFilter \ No newline at end of file diff --git a/backend/auction/filters/auction.py b/backend/auction/filters/auction.py new file mode 100644 index 0000000..98b0f92 --- /dev/null +++ b/backend/auction/filters/auction.py @@ -0,0 +1,24 @@ +from django_filters import rest_framework as filters +from auction.models import Auction, Product + + +class AuctionFilter(filters.FilterSet): + order_by = filters.OrderingFilter( + fields=( + ('_is_active', 'is_active'), + ('_end_time', 'end_time'), + ('id', 'id'), + ), + distinct=True + ) + is_active = filters.BooleanFilter(field_name='_is_active') + product = filters.ModelMultipleChoiceFilter( + queryset=Product.objects.all(), + field_name='product__pk', + to_field_name='pk', + distinct=True + ) + + class Meta: + model = Auction + fields = ('order_by', 'is_active', 'product') diff --git a/backend/auction/filters/bet.py b/backend/auction/filters/bet.py new file mode 100644 index 0000000..3980096 --- /dev/null +++ b/backend/auction/filters/bet.py @@ -0,0 +1,29 @@ +from django_filters import rest_framework as filters +from users.models import TGUser +from auction.models import Auction, Bet + + +class BetFilter(filters.FilterSet): + order_by = filters.OrderingFilter( + fields=( + ('_value', 'value'), + ('created_at', 'created_at'), + ('id', 'id'), + ), + distinct=True + ) + is_winning = filters.BooleanFilter(field_name='_is_winning') + auction = filters.ModelChoiceFilter( + queryset=Auction.objects.all(), + field_name='auction', + to_field_name='pk', + ) + user = filters.ModelChoiceFilter( + queryset=TGUser.objects.all(), + field_name='user', + to_field_name='pk', + ) + + class Meta: + model = Bet + fields = ('order_by', 'auction', 'user') diff --git a/backend/auction/migrations/0001_initial.py b/backend/auction/migrations/0001_initial.py new file mode 100644 index 0000000..e83dc01 --- /dev/null +++ b/backend/auction/migrations/0001_initial.py @@ -0,0 +1,57 @@ +# Generated by Django 5.0.4 on 2024-04-26 08:14 + +import django.db.models.deletion +from django.db import migrations, models +from django.utils import timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Auction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField(verbose_name='Количество победителей')), + ('initial_end_time', models.DateTimeField(verbose_name='Дата окончания')), + ('last_call_delta', models.DurationField(verbose_name='Время последнего шага')), + ('current_end_time', models.DateTimeField(default=timezone.now, verbose_name='Текущая дата окончания')), + ('commission', models.DecimalField(decimal_places=2, help_text='Десятичная дробь', max_digits=5, verbose_name='Комиссия')), + ('initial_cost', models.DecimalField(decimal_places=2, max_digits=102, verbose_name='Начальная стоимость')), + ], + options={ + 'verbose_name': 'Аукцион', + 'verbose_name_plural': 'Аукционы', + }, + ), + migrations.CreateModel( + name='Product', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=250, verbose_name='Название')), + ('cover', models.FileField(upload_to='products/', verbose_name='Обложка')), + ('description', models.TextField(verbose_name='Описание')), + ], + options={ + 'verbose_name': 'Товар', + 'verbose_name_plural': 'Товары', + }, + ), + migrations.CreateModel( + name='Bet', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время создания')), + ('auction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bets', to='auction.auction', verbose_name='Аукцион')), + ], + options={ + 'verbose_name': 'Ставка', + 'verbose_name_plural': 'Ставки', + }, + ), + ] diff --git a/backend/auction/migrations/__init__.py b/backend/auction/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/auction/models/__init__.py b/backend/auction/models/__init__.py new file mode 100644 index 0000000..ad1a88a --- /dev/null +++ b/backend/auction/models/__init__.py @@ -0,0 +1,3 @@ +from .auction import Auction +from .bet import Bet +from .product import Product \ No newline at end of file diff --git a/backend/auction/models/auction.py b/backend/auction/models/auction.py new file mode 100644 index 0000000..4587b85 --- /dev/null +++ b/backend/auction/models/auction.py @@ -0,0 +1,66 @@ +from datetime import datetime +from decimal import Decimal +from django.db import models +from django.utils import timezone +from django.db.models import F, Case, When, Max, ExpressionWrapper, Subquery, OuterRef +from users.models import BetTransaction + + +class AuctionManager(models.Manager): + def get_queryset(self): + return super().get_queryset().annotate( + _end_time=ExpressionWrapper( + F('initial_end_time') + F('last_call_delta') * F('times_postponed'), + output_field=models.DateTimeField() + ) + ).annotate(_is_active=Case( + When(_end_time__gt=timezone.now(), then=True), + default=False, + )).annotate( + _min_bet_value=Case( + When(bets__isnull=True, then=F('initial_cost')), + default=Subquery(BetTransaction.objects.filter(bet__auction=OuterRef('pk')).order_by('-value').values('value')[:1]) + Decimal('0.01') + ) + ).distinct() + + +class Auction(models.Model): + class Meta: + verbose_name = 'Аукцион' + verbose_name_plural = 'Аукционы' + + product = models.ForeignKey('auction.Product', related_name='auctions', on_delete=models.CASCADE, + verbose_name='Товар') + quantity = models.PositiveIntegerField(verbose_name='Количество победителей') + betters = models.ManyToManyField('users.TGUser', through='auction.Bet', related_name='auctions', + verbose_name='Поставившие ставку') + initial_end_time = models.DateTimeField(verbose_name='Изначальная дата окончания') + last_call_delta = models.DurationField(verbose_name='Время последнего шага') + times_postponed = models.IntegerField(default=0, verbose_name='Количество переносов') + commission = models.DecimalField(decimal_places=2, max_digits=5, verbose_name='Комиссия', + help_text='Десятичная дробь') + initial_cost = models.DecimalField(decimal_places=2, max_digits=102, verbose_name='Начальная стоимость') + + objects = AuctionManager() + + def __str__(self): + return f'Аукцион №{self.pk}' + + def check_postpone(self): + if timezone.now() - self.last_call_delta <= self.end_time <= timezone.now(): + self.times_postponed = F('times_postponed') + 1 + self.save() + + @property + def end_time(self): + return self._end_time + + @property + def is_active(self): + return self._is_active + + @property + def min_bet_value(self): + return self._min_bet_value + + diff --git a/backend/auction/models/bet.py b/backend/auction/models/bet.py new file mode 100644 index 0000000..30c457f --- /dev/null +++ b/backend/auction/models/bet.py @@ -0,0 +1,55 @@ +from django.db import models +from django.db.models import Subquery, OuterRef, Exists +from django_cte import CTEManager +from users.models import CommissionTransaction, BetTransaction + + +class BetManager(CTEManager): + def get_queryset(self): + return super().get_queryset().annotate( + _is_winning=Exists(BetTransaction.objects.filter(bet=OuterRef('pk'), refunded_by__isnull=True, refund_to__isnull=True)), + _value=-Subquery(BetTransaction.objects.filter(bet=OuterRef('pk')).order_by('date').values('value')[:1]) + ).distinct() + + def create_with_transaction_and_commission(self, auction, user, value, commission_value): + return CommissionTransaction.objects.create( + parent_transaction=BetTransaction.objects.create( + value=-value, + user=user, + bet=Bet.objects.create( + auction=auction, + user=user, + ) + ), + value=-commission_value, + user=user, + ).parent_transaction.bet + + +class Bet(models.Model): + class Meta: + verbose_name = 'Ставка' + verbose_name_plural = 'Ставки' + + auction = models.ForeignKey('auction.Auction', related_name='bets', on_delete=models.CASCADE, + verbose_name='Аукцион') + user = models.ForeignKey('users.TGUser', related_name='bets', on_delete=models.CASCADE, + verbose_name='Пользователь') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата и время создания') + + objects = BetManager() + + def __str__(self): + return f'Ставка {self.user} на {self.auction} от {self.created_at}' + + @property + def is_winning(self): + return self._is_winning + + @property + def value(self): + return self._value + + @property + def transaction(self): + return self.transactions.get(refunded_by__isnull=True) diff --git a/backend/auction/models/product.py b/backend/auction/models/product.py new file mode 100644 index 0000000..1d33522 --- /dev/null +++ b/backend/auction/models/product.py @@ -0,0 +1,14 @@ +from django.db import models + + +class Product(models.Model): + class Meta: + verbose_name = 'Товар' + verbose_name_plural = 'Товары' + + name = models.CharField(max_length=250, verbose_name='Название') + cover = models.FileField(upload_to='products/', verbose_name='Обложка') + description = models.TextField(verbose_name='Описание') + + def __str__(self): + return f'{self.name} ({self.pk})' diff --git a/backend/auction/serializers/__init__.py b/backend/auction/serializers/__init__.py new file mode 100644 index 0000000..e34e79f --- /dev/null +++ b/backend/auction/serializers/__init__.py @@ -0,0 +1,3 @@ +from .auction import AuctionSerializer +from .bet import BetSerializer +from .product import ProductSerializer \ No newline at end of file diff --git a/backend/auction/serializers/auction.py b/backend/auction/serializers/auction.py new file mode 100644 index 0000000..6408b98 --- /dev/null +++ b/backend/auction/serializers/auction.py @@ -0,0 +1,13 @@ +from rest_framework.serializers import ModelSerializer, DateTimeField, BooleanField +from .product import ProductSerializer +from auction.models import Auction + + +class AuctionSerializer(ModelSerializer): + product = ProductSerializer() + end_time = DateTimeField(read_only=True) + is_active = BooleanField(read_only=True) + + class Meta: + model = Auction + exclude = ('betters', 'initial_end_time', 'times_postponed', 'last_call_delta') diff --git a/backend/auction/serializers/bet.py b/backend/auction/serializers/bet.py new file mode 100644 index 0000000..cf255d2 --- /dev/null +++ b/backend/auction/serializers/bet.py @@ -0,0 +1,12 @@ +from rest_framework.serializers import ModelSerializer, DecimalField +from auction.models import Bet +from users.serializers import TGUserSerializer + + +class BetSerializer(ModelSerializer): + user = TGUserSerializer() + value = DecimalField(max_digits=102, decimal_places=2, read_only=True) + + class Meta: + model = Bet + fields = '__all__' diff --git a/backend/auction/serializers/product.py b/backend/auction/serializers/product.py new file mode 100644 index 0000000..20324a6 --- /dev/null +++ b/backend/auction/serializers/product.py @@ -0,0 +1,8 @@ +from rest_framework.serializers import ModelSerializer +from auction.models import Product + + +class ProductSerializer(ModelSerializer): + class Meta: + model = Product + fields = '__all__' diff --git a/backend/auction/tests.py b/backend/auction/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/auction/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/auction/urls.py b/backend/auction/urls.py new file mode 100644 index 0000000..1adb67f --- /dev/null +++ b/backend/auction/urls.py @@ -0,0 +1,10 @@ +from django.urls import include, path +from auction.views import ( + AuctionList, BetList, place_bet +) + +urlpatterns = [ + path('auction', AuctionList.as_view(), name='auction-list'), + path('bet', BetList.as_view(), name='bet-list'), + path('auction//place-bet/', place_bet, name='place-bet'), +] diff --git a/backend/auction/views/__init__.py b/backend/auction/views/__init__.py new file mode 100644 index 0000000..c93535d --- /dev/null +++ b/backend/auction/views/__init__.py @@ -0,0 +1,3 @@ +from .auction import AuctionList +from .bet import BetList +from .place_bet import place_bet \ No newline at end of file diff --git a/backend/auction/views/auction.py b/backend/auction/views/auction.py new file mode 100644 index 0000000..567b4bd --- /dev/null +++ b/backend/auction/views/auction.py @@ -0,0 +1,13 @@ +from django_filters import rest_framework as filters +from rest_framework.generics import ListAPIView +from auction.serializers import AuctionSerializer +from auction.models import Auction, Bet +from auction.filters import AuctionFilter + + +class AuctionList(ListAPIView): + queryset = Auction.objects.all() + serializer_class = AuctionSerializer + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = AuctionFilter + diff --git a/backend/auction/views/bet.py b/backend/auction/views/bet.py new file mode 100644 index 0000000..a416995 --- /dev/null +++ b/backend/auction/views/bet.py @@ -0,0 +1,12 @@ +from django_filters import rest_framework as filters +from rest_framework.generics import ListAPIView, GenericAPIView +from auction.serializers import BetSerializer +from auction.models import Bet +from auction.filters import BetFilter + + +class BetList(ListAPIView): + queryset = Bet.objects.all() + serializer_class = BetSerializer + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = BetFilter diff --git a/backend/auction/views/place_bet.py b/backend/auction/views/place_bet.py new file mode 100644 index 0000000..cde5e0f --- /dev/null +++ b/backend/auction/views/place_bet.py @@ -0,0 +1,50 @@ +from decimal import Decimal +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK +from rest_framework.decorators import api_view +from rest_framework.exceptions import ParseError +from django.shortcuts import get_object_or_404 +from django.db import transaction +from django.db.models import F +from django.utils import timezone +from django_cte import With +from auction.models import Auction, Bet + + +@api_view(['POST']) +def place_bet(request, pk): + auction = get_object_or_404(Auction, pk=pk) + if not auction.is_active: + raise ParseError('Аукцион уже закончился') + bet_value = Decimal(request.query_params.get('value', 0)) + if bet_value <= 0: + raise ParseError('Ставка должна быть положительная') + if bet_value < auction.min_bet_value: + raise ParseError('Ставка слишком маленькая') + tg_user = request.user.tg_user + auction_has_to_be_postponed = timezone.now() <= auction.end_time <= timezone.now() + auction.last_call_delta + with transaction.atomic(): + cte = With(auction.bets.filter(_is_winning=True).distinct('user').order_by('user_id')) + winning_bets = cte.join(Bet, id=cte.col.id).with_cte(cte).order_by('-_value') + commission_value = bet_value * auction.commission + + if winning_bets.filter(user_id=tg_user.pk).exists(): + user_bet = winning_bets.get(user_id=tg_user.pk) + delta = bet_value - user_bet.value + user_bet.transaction.refund() + commission_value = delta * auction.commission + elif winning_bets.count() >= auction.quantity: + for bet in winning_bets[auction.quantity - 1:]: + bet.transaction.refund() + + Bet.objects.create_with_transaction_and_commission( + auction=auction, + user=tg_user, + value=bet_value, + commission_value=commission_value + ) + if auction_has_to_be_postponed: + auction.times_postponed = F('times_postponed') + 1 + auction.save(update_fields=('times_postponed',)) + tg_user.refresh_from_db() + return Response(status=HTTP_200_OK, data={'remaining_points': tg_user.points}) diff --git a/backend/clicker/__init__.py b/backend/clicker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/clicker/asgi.py b/backend/clicker/asgi.py new file mode 100644 index 0000000..c1e4ff4 --- /dev/null +++ b/backend/clicker/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for clicker project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "clicker.settings") + +application = get_asgi_application() diff --git a/backend/clicker/celery.py b/backend/clicker/celery.py new file mode 100644 index 0000000..2113450 --- /dev/null +++ b/backend/clicker/celery.py @@ -0,0 +1,9 @@ +import os + +from celery import Celery + + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'clicker.settings') +app = Celery('clicker') +app.config_from_object('django.conf:settings', namespace='CELERY') +app.autodiscover_tasks() \ No newline at end of file diff --git a/backend/clicker/settings.py b/backend/clicker/settings.py new file mode 100644 index 0000000..6379648 --- /dev/null +++ b/backend/clicker/settings.py @@ -0,0 +1,210 @@ +""" +Django settings for clicker project. + +Generated by 'django-admin startproject' using Django 4.2.5. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" +import os +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-4nww@d-th@7^(chggt5q+$e*d_jvc#eb8tpiwkz6t+6rktj4r4') + +DEBUG = int(os.getenv('DEBUG', 0)) +PROD = 1 - DEBUG + +ALLOWED_HOSTS = ['crowngame.ru', 'backend', '127.0.0.1'] +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +USE_X_FORWARDED_HOST = True +USE_X_FORWARDED_PORT = True + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + + # 3rd party + 'rest_framework', + 'polymorphic', + 'corsheaders', + 'drf_spectacular', + 'drf_spectacular_sidecar', + 'storages', + + # local + 'users.apps.UsersConfig', + 'misc.apps.MiscConfig', + 'clicks.apps.ClicksConfig', + 'auction.apps.AuctionConfig', +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "clicker.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "clicker.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': os.getenv('POSTGRES_DB'), + 'USER': os.getenv('POSTGRES_USER'), + 'PASSWORD': os.getenv('POSTGRES_PASSWORD'), + 'HOST': os.getenv('POSTGRES_HOST'), + 'PORT': os.getenv('POSTGRES_PORT', '5432'), + }, +} + + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.Argon2PasswordHasher" +] + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = 'ru-RU' + +TIME_ZONE = 'Europe/Moscow' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +USE_S3 = int(os.getenv('USE_S3', 0)) + +if USE_S3: + # aws settings + AWS_ACCESS_KEY_ID = os.getenv('S3_KEY') + AWS_SECRET_ACCESS_KEY = os.getenv('S3_SECRET') + AWS_DEFAULT_ACL = None + AWS_STORAGE_BUCKET_NAME = os.getenv('S3_STORAGE_BUCKET_NAME') + AWS_S3_ENDPOINT_URL = os.getenv('S3_DOMAIN') + AWS_S3_OBJECT_PARAMETERS = {'CacheControl': 'max-age=86400'} + # s3 public media settings + PUBLIC_MEDIA_LOCATION = 'media' + MEDIA_URL = f'{AWS_S3_ENDPOINT_URL}/{PUBLIC_MEDIA_LOCATION}/' + DEFAULT_FILE_STORAGE = 'clicker.storage_backends.PublicMediaStorage' +else: + MEDIA_URL = 'media/' + MEDIA_ROOT = os.path.join(BASE_DIR, 'media/') + +STATIC_URL = 'static/' +STATIC_ROOT = os.path.join(BASE_DIR, 'static/') + + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +APPEND_SLASH = True + +TG_TOKEN = os.getenv('TG_TOKEN') + +REST_FRAMEWORK = { + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_METADATA_CLASS': 'rest_framework.metadata.SimpleMetadata', + 'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',), + 'DEFAULT_AUTHENTICATION_CLASSES': ('users.authentication.TelegramValidationAuthentication',), + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'PAGE_SIZE': 100, +} + +SPECTACULAR_SETTINGS = { + 'TITLE': 'Clicker API', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, + 'SWAGGER_UI_DIST': 'SIDECAR', + 'SWAGGER_UI_FAVICON_HREF': 'SIDECAR', + 'REDOC_DIST': 'SIDECAR', +} + +RABBITMQ = { + 'PROTOCOL': os.getenv('RABBITMQ_PROTOCOL', 'amqp'), + 'HOST': os.getenv('RABBITMQ_HOST', 'rabbitmq'), + 'PORT': os.getenv('RABBITMQ_PORT', 5672), + 'USER': os.getenv('RABBITMQ_DEFAULT_USER', 'rabbitmq'), + 'PASSWORD': os.getenv('RABBITMQ_DEFAULT_PASS', 'rabbitmq'), +} + +SETTINGS_QUEUE_NAME = 'settings' + +CELERY_BROKER_URL = f"{RABBITMQ['PROTOCOL']}://{RABBITMQ['USER']}:{RABBITMQ['PASSWORD']}@{RABBITMQ['HOST']}:{RABBITMQ['PORT']}" + +CELERY_BEAT_SCHEDULE = { + 'check-mailing-lists': { + 'task': 'users.celery.mailing_list.check_mailing_lists', + 'schedule': 3600, + }, +} + +DATA_UPLOAD_MAX_NUMBER_FIELDS = 100_000 diff --git a/backend/clicker/storage_backends.py b/backend/clicker/storage_backends.py new file mode 100644 index 0000000..cc69a6a --- /dev/null +++ b/backend/clicker/storage_backends.py @@ -0,0 +1,8 @@ +from django.conf import settings +from storages.backends.s3boto3 import S3Boto3Storage + + +class PublicMediaStorage(S3Boto3Storage): + location = 'media' + default_acl = 'public-read' + file_overwrite = False \ No newline at end of file diff --git a/backend/clicker/urls.py b/backend/clicker/urls.py new file mode 100644 index 0000000..351963c --- /dev/null +++ b/backend/clicker/urls.py @@ -0,0 +1,19 @@ +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView + + +urlpatterns = [ + path("admin/", admin.site.urls), + path('api/v1/users/', include('users.urls.urls')), + path('api/internal/users/', include('users.urls.internal_urls')), + path('api/v1/auction/', include('auction.urls')), + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), +] + +if int(settings.DEBUG): + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/backend/clicker/wsgi.py b/backend/clicker/wsgi.py new file mode 100644 index 0000000..fa99721 --- /dev/null +++ b/backend/clicker/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for clicker project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "clicker.settings") + +application = get_wsgi_application() diff --git a/backend/clicks/__init__.py b/backend/clicks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/clicks/admin.py b/backend/clicks/admin.py new file mode 100644 index 0000000..4657d44 --- /dev/null +++ b/backend/clicks/admin.py @@ -0,0 +1,37 @@ +from django.contrib import admin +from django.urls import reverse +from django.utils.html import format_html +from clicks.models import Click + + +@admin.register(Click) +class ClickAdmin(admin.ModelAdmin): + list_display = [ + 'id', + 'view_user_link', + 'value', + 'created_at', + 'view_transaction_link', + ] + list_display_links = [ + 'id', + ] + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + def view_user_link(self, obj): + url = reverse("admin:users_tguser_change", args=[obj.user_id]) + return format_html(f'{obj.user}') + view_user_link.short_description = 'Пользователь' + + def view_transaction_link(self, obj): + url = reverse("admin:users_clicktransaction_change", args=[obj.transaction.id]) + return format_html(f'{obj.transaction}') + view_transaction_link.short_description = 'Транзакция' diff --git a/backend/clicks/apps.py b/backend/clicks/apps.py new file mode 100644 index 0000000..0914727 --- /dev/null +++ b/backend/clicks/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class ClicksConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "clicks" + + def ready(self): + from .celery import handle_click \ No newline at end of file diff --git a/backend/clicks/celery/__init__.py b/backend/clicks/celery/__init__.py new file mode 100644 index 0000000..444829c --- /dev/null +++ b/backend/clicks/celery/__init__.py @@ -0,0 +1 @@ +from .click import handle_click \ No newline at end of file diff --git a/backend/clicks/celery/click.py b/backend/clicks/celery/click.py new file mode 100644 index 0000000..4b6a1eb --- /dev/null +++ b/backend/clicks/celery/click.py @@ -0,0 +1,27 @@ +from datetime import datetime +from decimal import Decimal +from clicker.celery import app +from clicks.models import Click +from users.models import ClickTransaction + + +@app.task +def handle_click(user_id, date_time, value_str, count=1): + date_time = datetime.fromtimestamp(date_time / 1000) + value = Decimal(value_str) + clicks = list() + for _ in range(count): + click = Click( + user_id=user_id, + value=value, + created_at=date_time + ) + clicks.append(click) + Click.objects.bulk_create(clicks) + for click in clicks: + ClickTransaction.objects.create( + user_id=user_id, + date=date_time, + value=value, + click=click + ) diff --git a/backend/clicks/migrations/0001_initial.py b/backend/clicks/migrations/0001_initial.py new file mode 100644 index 0000000..a6f5844 --- /dev/null +++ b/backend/clicks/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 5.0.4 on 2024-04-26 08:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Click', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.DecimalField(decimal_places=2, max_digits=102, verbose_name='Значение')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время')), + ], + options={ + 'verbose_name': 'Клик', + 'verbose_name_plural': 'Клики', + }, + ), + ] diff --git a/backend/clicks/migrations/__init__.py b/backend/clicks/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/clicks/models/__init__.py b/backend/clicks/models/__init__.py new file mode 100644 index 0000000..ffd108c --- /dev/null +++ b/backend/clicks/models/__init__.py @@ -0,0 +1 @@ +from .click import Click \ No newline at end of file diff --git a/backend/clicks/models/click.py b/backend/clicks/models/click.py new file mode 100644 index 0000000..2677050 --- /dev/null +++ b/backend/clicks/models/click.py @@ -0,0 +1,15 @@ +from django.db import models + + +class Click(models.Model): + class Meta: + verbose_name = 'Клик' + verbose_name_plural = 'Клики' + + user = models.ForeignKey('users.TGUser', related_name='clicks', on_delete=models.DO_NOTHING, db_constraint=False, + verbose_name='Пользователь') + value = models.DecimalField(decimal_places=2, max_digits=102, verbose_name='Значение') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата и время') + + def __str__(self): + return f'Клик {self.user} от {self.created_at.strftime("%d.%m.%Y %H:%M:%S")} ({self.pk})' diff --git a/backend/clicks/tests.py b/backend/clicks/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/clicks/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/clicks/views.py b/backend/clicks/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/backend/clicks/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/backend/manage.py b/backend/manage.py new file mode 100755 index 0000000..2c1339e --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "clicker.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/backend/media/products/1003345981.jpg b/backend/media/products/1003345981.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3113c7fa002e57eb1f987603c82fed140b255008 GIT binary patch literal 7922 zcmeHKc~nzZx4+CNtq2HK8D$iageedr1P6vf3;(kCHy-dG%_IHiyE)H(fR8KZ z1pr=&5AP6)+aNZ`X6tuz3OIP;Je@)JJ5Tpk(1mFM$fK56ets@gREFm?z$dL@Ap_!h z0e;YnuW9EQ3~v-1PV<8iNOV^+j7ax_2N1mB+Au9RU}O^DO(1%b849jscM8o|?QKbg zngWGntmcHkYGJ*Pkn{h|u&4q%jBqnlwKpkYT$5m&26oNA#t5GbnVL0>nvh zr861EYHHgRK2S3#ZhvwAEf0`}n+T9iLBR<6MA?!3=u{@e=0Kw`&^ku&e-i%*KlK+L zt*fO4*ZzS2RQ@N}`M&^vDhH#2Lt%Up*8eXF{K7;^$%A6nCY#u0A~z1ci?}{K&Wv4@7tG7J}bvx z()A@>pOt~n0)MHlFX{TM415;&OLcuo*Jowmv%vq4s_U=26PX5HnEb(;(PlSr3=rVs z=jZ1W03QMZ0)j$c2@8QuR77OkS7M^#;$os=ViHm^yCrt*l@t^EX5Tk^Wo6~%`Hq=5K%uc>Ck!122|a_d)2%FUZ3uz$+vSYK?XRyu3WT zyaFP;e8SK*61*w$^6?9Z?XwjW*FHj!*rljr=ks0conM=jj{4@jmQ>c&vv(!FQ}O$L zvkMT0SWSTS8(c%)9cObg2Bz6{j~-S~@y_cz9BnY#X~or51&w`=igZ0*&)?#mi@=TMudfQ`p?asB;px-)akfYSsa## zt?hJR;hi@+(b+5UEV-v#C%F&B(Q#yeZJ+AWuZtE1HvLX{D^3Qb-Z`yKg>-t2j8VOX zfo27=CYQ3&vk+4wb{Mj`9&vx~H%#vBcfTd1 zs~cKb+l8{ry0x~9+{6=<)At~2F6b?spL&z5^0K;X52nF$WCsQtJ zhOiE_IG*?1mJ#sY>v|jRVS&#T#X{fsPDS=ZMvC2peWoUhSd58WgqLVsu)bhzpX+;T z_<60(g+_%BWQ?bqdDeJq1E?37mCX$P08A%!iPdZNyA}wj@ zyNlg#bFFx#*ja;=*7@lh?=Pl%zGAJ9*fG8AHEi>O)7#i4=1*3#XV<>{*a5&lrFOEm zh249TnN4rzoXuV{5uds@9?%tS97KNH{^-nn+9nVfe;8usi+wcVK>3~Nlz&sxJBAU0 z@4q+HcL6(FysAQuo1(sgFx}o+#dD$#dO^8BKg*@+d(lBY*u%^~7xS)3`kIS_6a4V`Us`8GHy1Qs+<93rd z)>|ku7Fd4Fw&v;**6%D@hzi2^Vw;U!QA{~&c6vNw>=8Nmc$%}Jv!S)GZ$iqf*9`UP zKk{MG>a3LL@tM~1vlsGZeui5HW4bJGAT<2pxa6A--{N2^dbsz(&G&0bQ#CJ=7qJaB zW!;ENpKwWQ(+i~)cefZwD=Z#g5sbd?bpLhsoyTrZoJGlC+W6{U9et4Q6=!=zYL;Fk zkA5}3ZQL-oXgV9G5u>t9+W83+u&qn=j`1M`u8>(59y?O8f8}faqvL5_zIIpQd_2qK zE&-C(H^*HlVea-Y5qCeAx5Xx5vCf|o0KE0TmtnHiGF@Pz(VC0srS%Q+LqIsFit4Sm zs-uz@cjky+{3P0+G)j)ktueI578&%u{2KvJ;wWk|`g%>359K*$BZ3@^2Ktj?rU&YkemI@>p;Beht8sjCRh# zZi_Lg`Qe!^aSOfLb54fn%2Az!bTxJTq28(Tg>zhc{+C#|fr|Q3);ASBCT6k+P=_jm zb1&|U>502@yZ^b5zvWURJ?G6>P*{%ZCNNg7&j^#;1R|KfEKa^~-Ya<0fYIra&3e#4 zo^vr>VL70NYrowpNIva^J1YAN?xnn$b#Hncm7kR95LeJ^JI4a!IdLG_u&$T!+Uy-Z z%OuQo_4-d-S6G>p1p$fkGO}NT_erY|J@K400Ik&r@jyWC2C;x zcVWf&!;(AJEVif2#YcNH%|J)?qPtb>KaoL)V(nbeZX-yS;&I|*zbNc)Bzhqi&9WMfkh0V`CFn z6+D;aJky4{Qkut^#J3^n(Z<6NRN^sJC@KDN;mOLVzK()t+E;e%ll&$<7gMwc(vD7? z4yv!7IHD4=`}XpZq<*58UW-fCB}kYW{DlHcOc)f$;j^WJ0CHtk9A ziz@3@KR~cOQMh|)lBG$VFly18pK~fJ!!>Ay)ILN1I7kby=aRm52&%n2cQ6?94Bt9< z2@-1d@K>-SVz2dOAylS4S7fG>&a}4?oR~~_lGnx#?rf2@jv5T4E>dsIq|;|Y!*4@o zJZZMo&Zit(`u4A&=R535km#?t;+`?HGOQyLU3VZ%Z*lFXVoUdgcAZr9`)hF)Mi4;6 z!fbpt$TN?okFB`ef9*CT1MKq?L#}0ph<6$?Sw%C7kcj8^v9=T&-1Cspw&jc;x}w%H z(&9}#I!&_mxLUtvLTiim;3lv)46VFXLTttZX^)+$`KMR(eIX3cm(f@=j%-=*id;#2 z80x78^QDgmLW*xf!v}@du1kky$`5%#0$}@FOxVk&O@O@#=w5{cLJv|MJ1`0HD=BzS z=o`R2{&5)#m&4Db&-Pn!CEFXVGGDds6LrgDUM39={Y8*RtyMD};o(0NrovZ|tzaM{;m#a-ONBg$~Fw zI4Z}=Hm(QJ8w4RnCK9jymfnV|IO`d)>ZJ1kk^!>4R~*{g-04iFNmB4d>lo|%6NILp zSXl;ldua+{7 z*mO+fn&cU3azSmK^01+CxBFHh6f(rGapcU8oh@y3DuRzQ5o=+nrdbb6AQRQJ$T1;~ z-iCYul+U>h5u9IT;N;mt9d&YoIgxP*?rDY!z;@HwW;&E{@CAd%A-{*bwnLPs@PJ>gq zQGHHJc*!}^32vv0SjQe1+o7SlR>SuqIwG0Ar%DpOSQk?x8)|a=#dFLiu+QD8!`9lw zKBEL#_Ehjz@uLE1`-HKORtcnYkR0RQq*J9LZdf(7b!3PzOxy&bP5XmLm3D&iJzXd0NM$y0{BnB^Kx_#KoEbU_L0&4U*O;pp8RTNhQq7ySrFJAc>U0NGH-`+Z zS{~lT`ME!^{LZKrJ~M3hYuF}`N`LwK61X1ECF}h0hMDp%9j!asA8D?NK;|E~e>v*n zqvuJ7Ayz;+;qIVvV#wam;V`GBpa)K`EtuU0ih?VIpw&*c;a~lMRkvr|z@1s6%B2t1 z7s~`sxKaB@-z*Tz5)ykw6IkF7l{O}KZfhrH=fZR&JuDaX&Oy75D(hfP;2HnuZEJ4J zicL5r9JCcYr^eyLwu%_ofiAk2osjF3mS2{8d3^&tTL7*Th-Vj^_fAA63^iYMnOsqg z4wv~FUCIPc8TIeur$|qM1IfBALAe`E8AfuoLnWz<@;=2}07$+2X11Xskd_s}Szc1? z6t3EN;>t;)s%H>mZnD#Eky(zYOI*7v40K1txH+2cX9wAZma>9jr+%Iv z7_3UX(R6jG@MRa+6Sqlv1>3yGtg$h3)DV-dWtpdd+WADryiA)_kCU7}CT=ZnOs{>r zN3_Fpb1_RZ*bum)2%m09KARSCz#Tm2wVRMrO=|A`;W=vdyLYarTl6`pYu5;Y<#1Nw z`WeTKu|;@M>&LEmvUTP5oNBq-Py_3=DM~0APu|^-B-QeLX9}S2+C;N9-Jbz-#7@S} zeJySC$hz`mB?UW^d$C3J5&+0tiyecGsn=D&W2)IICVu~`qYmK307u^aI38!Ob-c;P z=q*YsZXLN|zX*H#qxZ)?0U?oeWYYbSBNu`sR!wrA3VL37c#9xhWMxB&8|Vxi8;Oxc z#i-MoYZB*DbITH#PeDUuvQm>$vQX@`1Yg&330!K-k5;$TaKqc4SJ?*Zy{gw~Y`R5P z`ffOJZaR4F(1&dk>JKs0B0V#iaq;0`p1S@shi=qJL4veRZ2<2UcxSzc<< zO)7Y2dUZVf;zPR`<4cf^+juw(2Uv&ZGDk4W)ncLkQ6T430jzrW45C;QpR*&2 echo "Postgres is unavailable - sleeping" + sleep 1 +done + +>&2 echo "Postgres is up - continuing..." +exec $cmd diff --git a/backend/scripts/gunicorn.sh b/backend/scripts/gunicorn.sh new file mode 100644 index 0000000..b8a9f9e --- /dev/null +++ b/backend/scripts/gunicorn.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -o errexit +set -o pipefail +set -o nounset + +python manage.py migrate +python manage.py collectstatic --noinput --verbosity 0 +gunicorn clicker.wsgi -b 0.0.0.0:8000 -w 17 --timeout 600 --chdir=/app --access-logfile - diff --git a/backend/scripts/start.sh b/backend/scripts/start.sh new file mode 100644 index 0000000..e8171a5 --- /dev/null +++ b/backend/scripts/start.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -o errexit +set -o pipefail +set -o nounset +set -o xtrace + +python manage.py migrate +python manage.py collectstatic --noinput --verbosity 0 +python manage.py runserver 0.0.0.0:8000 diff --git a/backend/scripts/start_celery.sh b/backend/scripts/start_celery.sh new file mode 100644 index 0000000..e5c9660 --- /dev/null +++ b/backend/scripts/start_celery.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +for i in {0..$CELERY_WORKER_COUNT} +do + celery -A clicker worker -l info --concurrency=10 -n worker$i@%h +done + diff --git a/backend/users/__init__.py b/backend/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/users/admin.py b/backend/users/admin.py new file mode 100644 index 0000000..c0ec8ec --- /dev/null +++ b/backend/users/admin.py @@ -0,0 +1,409 @@ +from django import forms +from django.apps import apps +from django.contrib import admin +from django.urls import reverse +from django.utils.html import format_html, urlencode +from django.http import HttpResponseRedirect +from django.core.exceptions import ObjectDoesNotExist +from polymorphic.admin import PolymorphicParentModelAdmin, PolymorphicChildModelAdmin, PolymorphicChildModelFilter +from users.models import ( + TGUser, MailingList, MailingListReceiverInfo, Transaction, ClickTransaction, BetTransaction, ReferralTransaction, + CommissionTransaction +) + + +@admin.register(TGUser) +class TGUserAdmin(admin.ModelAdmin): + model = TGUser + list_display = [ + 'view_user_link', + 'tg_id', + 'username', + 'points', + 'avatar', + 'warning_count', + 'is_blocked', + 'created_at', + 'view_referred_by_link', + 'view_referred_users_link', + 'view_transactions', + 'view_clicks_link', + ] + list_display_links = [ + 'tg_id' + ] + search_fields = [ + 'username', + 'tg_id' + ] + actions = ['create_mailing_list'] + + def get_readonly_fields(self, request, obj=None): + always = [ + 'points', + 'referral_storage', + 'warning_count', + 'created_at' + ] + when_editing = [ + 'tg_id', + 'user', + 'username', + 'referred_by', + ] + if obj: + return always + when_editing + else: + return always + + def view_user_link(self, obj): + url = reverse("admin:auth_user_change", args=[obj.user_id]) + return format_html(f'{obj.user.username} ({obj.user_id})') + view_user_link.short_description = 'Системный пользователь' + + def view_referred_by_link(self, obj): + if not obj.referred_by: + return + url = reverse("admin:users_tguser_change", args=[obj.referred_by.tg_id]) + return format_html(f'{obj.referred_by.username} ({obj.referred_by.tg_id})') + view_referred_by_link.short_description = 'Кем был приглашен' + + def view_referred_users_link(self, obj): + count = obj.referrees.count() + url = reverse('admin:users_tguser_changelist') + '?' + urlencode({'referred_by__tg_id': f'{obj.tg_id}'}) + return format_html(f' {count} users ') + view_referred_users_link.short_description = 'Приглашенные пользователи' + + def view_transactions(self, obj): + all_url = reverse('admin:users_transaction_changelist') + '?' + urlencode({'user_id': f'{obj.tg_id}'}) + click_url = reverse('admin:users_clicktransaction_changelist') + '?' + urlencode({'user_id': f'{obj.tg_id}'}) + bet_url = reverse('admin:users_bettransaction_changelist') + '?' + urlencode({'user_id': f'{obj.tg_id}'}) + commission_url = reverse('admin:users_commissiontransaction_changelist') + '?' + urlencode({'user_id': f'{obj.tg_id}'}) + referral_url = reverse('admin:users_referraltransaction_changelist') + '?' + urlencode({'user_id': f'{obj.tg_id}'}) + + return format_html( + f' все // ' + f' клики // ' + f' ставки // ' + f' комиссии // ' + f' реферальная программа ' + ) + view_transactions.short_description = 'Транзакции' + + def view_clicks_link(self, obj): + count = obj.clicks.count() + url = reverse('admin:clicks_click_changelist') + '?' + urlencode({'user_id': f'{obj.tg_id}'}) + return format_html(f' {count} clicks ') + view_clicks_link.short_description = 'Клики' + + @admin.action(description="Создать рассылку для выбранных пользователей") + def create_mailing_list(self, request, queryset): + request.session['user_ids'] = list(queryset.values_list('pk', flat=True)) + return HttpResponseRedirect( + f'/admin/users/mailinglist/add/' + ) + + +class MailingListAdminForm(forms.ModelForm): + users = forms.ModelMultipleChoiceField( + queryset=TGUser.objects.all(), + required=False, + label='Получатели' + ) + + class Meta: + model = MailingList + fields = '__all__' + + def __init__(self, *args, **kwargs): + super(MailingListAdminForm, self).__init__(*args, **kwargs) + + if self.instance.pk: + self.fields['users'].initial = self.instance.users.all() + + def save(self, commit=True): + mailing_list = super(MailingListAdminForm, self).save(commit=False) + MailingListReceiverInfo.objects.filter(mailing_list_id=mailing_list.pk).delete() + new_mailing_list_receiver_infos = list() + for user in self.cleaned_data['users']: + new_mailing_list_receiver_infos.append(MailingListReceiverInfo( + mailing_list_id=mailing_list.pk, + user_id=user.pk + )) + + if commit: + MailingListReceiverInfo.objects.bulk_create(new_mailing_list_receiver_infos) + + return mailing_list + + +@admin.register(MailingListReceiverInfo) +class MailingListReceiverInfoAdmin(admin.ModelAdmin): + list_display = [ + 'id', + 'view_mailing_list_link', + 'view_user_link', + 'sent', + 'clicked' + ] + list_display_links = [ + 'id' + ] + readonly_fields = [ + 'sent', + 'clicked' + ] + list_filter = [ + 'sent', + 'clicked' + ] + + def view_user_link(self, obj): + if not obj.user: + return None + link = reverse("admin:users_tguser_change", args=[obj.user.tg_id]) + return format_html(f'{obj.user}') + view_user_link.short_description = 'Пользователь' + + def view_mailing_list_link(self, obj): + if not obj.mailing_list: + return None + link = reverse("admin:users_mailinglist_change", args=[obj.mailing_list.pk]) + return format_html(f'{obj.mailing_list}') + view_mailing_list_link.short_description = 'Рассылка' + + +@admin.register(MailingList) +class MailingListAdmin(admin.ModelAdmin): + form = MailingListAdminForm + model = MailingList + list_display = [ + 'id', + 'name', + 'time', + 'text', + 'media', + 'view_users_link', + 'view_mailing_list_receiver_infos_link', + 'status', + 'view_main_button_link', + 'view_webapp_button_link', + ] + list_display_links = [ + 'id' + ] + + def get_readonly_fields(self, request, obj=None): + return ('status',) + + def get_changeform_initial_data(self, request): + if user_ids := request.session.get('user_ids'): + return {'users': TGUser.objects.filter(pk__in=user_ids)} + return None + + def view_users_link(self, obj): + count = obj.users.count() + url = reverse('admin:users_tguser_changelist') + '?' + urlencode( + {'mailing_lists__id': f'{obj.id}'}) + return format_html(f' {count} пользователей ') + view_users_link.short_description = 'Пользователи' + + def view_mailing_list_receiver_infos_link(self, obj): + count = obj.mailing_list_receiver_infos.count() + url = reverse('admin:users_mailinglistreceiverinfo_changelist') + '?' + urlencode( + {'mailing_list_id': f'{obj.id}'}) + return format_html(f' {count} получателей ') + view_mailing_list_receiver_infos_link.short_description = 'Информация о получателях' + + def view_main_button_link(self, obj): + if not obj.main_button: + return + url = reverse("admin:misc_button_change", args=[obj.main_button_id]) + return format_html(f' Кнопка №{obj.main_button_id}') + view_main_button_link.short_description = 'Основная кнопка' + + def view_webapp_button_link(self, obj): + if not obj.webapp_button: + return + url = reverse("admin:misc_button_change", args=[obj.webapp_button_id]) + return format_html(f' Кнопка №{obj.webapp_button_id}') + view_webapp_button_link.short_description = 'Кнопка, открывающая вебапп' + + +# TODO +class TransactionChildAdmin(PolymorphicChildModelAdmin): + base_model = Transaction + search_fields = [ + 'user__username', + 'user__tg_id' + ] + + def view_user_link(self, obj): + if not obj.user: + return + url = reverse("admin:users_tguser_change", args=[obj.user.tg_id]) + return format_html(f'{obj.user}') + view_user_link.short_description = 'Пользователь' + + def has_delete_permission(self, request, obj=None): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_add_permission(self, request, obj=None): + return False + + +@admin.register(ClickTransaction) +class ClickTransactionAdmin(TransactionChildAdmin): + base_model = ClickTransaction + show_in_index = True + list_display = [ + 'id', + 'value', + 'view_user_link', + 'date', + 'view_click_link' + ] + + def view_click_link(self, obj): + link = reverse("admin:clicks_click_change", args=[obj.click_id]) + return format_html(f' {obj.click}') + view_click_link.short_description = 'Клик' + + +@admin.register(BetTransaction) +class BetTransactionAdmin(TransactionChildAdmin): + base_model = BetTransaction + show_in_index = True + list_display = [ + 'id', + 'value', + 'view_user_link', + 'date', + 'view_bet_link', + 'view_commission_link', + 'view_refunded_by_link', + 'view_refund_to_link', + ] + list_display_links = [ + 'id' + ] + + def view_bet_link(self, obj): + link = reverse("admin:auction_bet_change", args=[obj.bet_id]) + return format_html(f'{obj.bet}') + view_bet_link.short_description = 'Ставка' + + def view_commission_link(self, obj): + try: + _ = obj.commission + except ObjectDoesNotExist: + return None + link = reverse("admin:users_commissiontransaction_change", args=[obj.commission.id]) + return format_html(f' {obj.commission} ') + view_commission_link.short_description = 'Комиссия' + + def view_refunded_by_link(self, obj): + try: + _ = obj.refunded_by + except ObjectDoesNotExist: + return None + link = reverse("admin:users_bettransaction_change", args=[obj.refunded_by.id]) + return format_html(f' {obj.refunded_by} ') + view_refunded_by_link.short_description = 'Чем компенсирована' + + def view_refund_to_link(self, obj): + if not obj.refund_to: + return None + link = reverse("admin:users_bettransaction_change", args=[obj.refund_to_id]) + return format_html(f' {obj.refund_to} ') + view_refund_to_link.short_description = 'Что компенсирует' + + +@admin.register(CommissionTransaction) +class CommissionTransactionAdmin(TransactionChildAdmin): + base_model = CommissionTransaction + show_in_index = True + list_display = [ + 'id', + 'value', + 'view_user_link', + 'date', + 'view_bet_transaction_link', + ] + list_display_links = [ + 'id' + ] + + def view_bet_transaction_link(self, obj): + link = reverse("admin:users_bettransaction_change", args=[obj.parent_transaction]) + return format_html(f' {obj.parent_transaction} ') + view_bet_transaction_link.short_description = 'Родительская транзакция' + + +@admin.register(ReferralTransaction) +class ReferralTransactionAdmin(TransactionChildAdmin): + base_model = ReferralTransaction + show_in_index = True + list_display = [ + 'id', + 'value', + 'view_user_link', + 'date', + ] + + +# оригинальный класс использовал "change" в lookups +class WorkingPolymorphicChildModelFilter(PolymorphicChildModelFilter): + def lookups(self, request, model_admin): + return model_admin.get_child_type_choices(request, "view") + + +@admin.register(Transaction) +class TransactionParentAdmin(PolymorphicParentModelAdmin): + base_model = Transaction + child_models = [ + ClickTransaction, BetTransaction, CommissionTransaction, ReferralTransaction + ] + list_filter = [ + WorkingPolymorphicChildModelFilter, + ] + search_fields = [ + 'user__username', + 'user__tg_id' + ] + list_display = [ + 'id', + 'view_user_link', + 'value', + 'date', + 'view_type' + ] + list_display_links = [ + 'id' + ] + + def view_user_link(self, obj): + if not obj.user: + return + url = reverse("admin:users_tguser_change", args=[obj.user.tg_id]) + return format_html(f'{obj.user}') + view_user_link.short_description = 'Пользователь' + + def view_type(self, obj): + return apps.get_model(obj.polymorphic_ctype.app_label, obj.polymorphic_ctype.model)._meta.verbose_name + view_type.short_description = 'Тип транзакции' + + def has_add_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_view_permission(self, request, obj=None): + return True diff --git a/backend/users/apps.py b/backend/users/apps.py new file mode 100644 index 0000000..e27785c --- /dev/null +++ b/backend/users/apps.py @@ -0,0 +1,13 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "users" + + def ready(self): + from .signals import ( + transaction_signal, transaction_pre_delete_signal, transaction_pre_save_signal, referral_signal, + mailing_list_signal, + ) + from .celery import handle_mailing_list, check_mailing_lists diff --git a/backend/users/authentication.py b/backend/users/authentication.py new file mode 100644 index 0000000..4d426b5 --- /dev/null +++ b/backend/users/authentication.py @@ -0,0 +1,86 @@ +import time +import hmac +import base64 +import hashlib +import json +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist +from django.contrib.auth.models import User +from rest_framework import authentication, exceptions +from users.models import TGUser + + +def validate_referred_by_id(referred_by_id): + if not referred_by_id: + return None + if not referred_by_id.isdigit(): + return None + referred_by_id = int(referred_by_id) + return referred_by_id if TGUser.objects.filter(pk=referred_by_id).exists() else None + + +class TelegramValidationAuthentication(authentication.BaseAuthentication): + def authenticate(self, request): + token = request.META.get('HTTP_AUTHORIZATION', '') + if not token: + return None, None + + if not token.startswith('TelegramToken '): + return None, None + + token = ' '.join(token.split()[1:]) + + split_res = base64.b64decode(token).decode('utf-8').split(':') + try: + data_check_string = ':'.join(split_res[:-1]).strip().replace('/', '\\/') + hash = split_res[-1] + except IndexError: + raise exceptions.AuthenticationFailed('Invalid token format') + secret = hmac.new( + 'WebAppData'.encode(), + settings.TG_TOKEN.encode('utf-8'), + digestmod=hashlib.sha256 + ).digest() + actual_hash = hmac.new( + secret, + msg=data_check_string.encode('utf-8'), + digestmod=hashlib.sha256 + ).hexdigest() + if hash != actual_hash: + raise exceptions.AuthenticationFailed('Invalid token (hash check failed)') + + data_dict = dict([x.split('=') for x in data_check_string.split('\n')]) + try: + auth_date = int(data_dict['auth_date']) + except KeyError: + raise exceptions.AuthenticationFailed('Invalid token (auth_date not found)') + except ValueError: + raise exceptions.AuthenticationFailed('Invalid token (auth_date is not an int)') + + if auth_date + 60 * 30 < int(time.time()): + raise exceptions.AuthenticationFailed('Token expired') + + user_info = json.loads(data_dict['user']) + try: + tg_user = TGUser.objects.get(pk=user_info['id']) + if tg_user.is_blocked: + raise exceptions.PermissionDenied('Пользователь заблокирован') + except ObjectDoesNotExist: + username = user_info.get('username', f'user-{user_info["id"]}') + pass_data = f'{username} ({user_info["id"]})'.encode() + referred_by_id = validate_referred_by_id(request.query_params.get('referred_by', None)) + if (user_qs := User.objects.filter(username=username)).exists(): + user = user_qs.first() + else: + user = User.objects.create_user( + username=username, + password=hashlib.md5(pass_data).hexdigest() + ) + tg_user = TGUser.objects.create( + user=user, + tg_id=user_info['id'], + username=username, + referred_by_id=referred_by_id, + ) + + return tg_user.user, token diff --git a/backend/users/celery/__init__.py b/backend/users/celery/__init__.py new file mode 100644 index 0000000..ab08ed1 --- /dev/null +++ b/backend/users/celery/__init__.py @@ -0,0 +1 @@ +from .mailing_list import check_mailing_lists, handle_mailing_list \ No newline at end of file diff --git a/backend/users/celery/mailing_list.py b/backend/users/celery/mailing_list.py new file mode 100644 index 0000000..eab0ef9 --- /dev/null +++ b/backend/users/celery/mailing_list.py @@ -0,0 +1,53 @@ +from clicker.celery import app +from django.conf import settings +from django.utils import timezone +from datetime import timedelta +import requests +from users.choices import MailingListStatus +from users.models import MailingList, MailingListReceiverInfo + + +@app.task +def handle_mailing_list(mailing_list_id): + mailing_list = MailingList.objects.select_related('main_button', 'webapp_button').get(pk=mailing_list_id) + mailing_list.status = MailingListStatus.PROCESSING + mailing_list.save(update_fields=('status',)) + mailing_list_receiver_infos = ( + MailingListReceiverInfo.objects + .filter(mailing_list_id=mailing_list.pk) + .exclude(sent=True) + ) + no_errors = True + for mailing_list_receiver_info in mailing_list_receiver_infos: + user = mailing_list_receiver_info.user + body = { + 'tg_id': user.tg_id, + 'text': mailing_list.text, + 'attachment_path': mailing_list.media.url, + 'button_name': mailing_list.main_button.text if mailing_list.main_button else '', + 'button_url': mailing_list.main_button.link if mailing_list.main_button else '', + 'web_app_button_name': mailing_list.webapp_button.text if mailing_list.webapp_button else '', + 'spam_id': mailing_list.pk, + } + response = requests.post('http://bot:7313/dispatch/', json=body) + if response.status_code == 200: + mailing_list_receiver_info.sent = True + mailing_list_receiver_info.save() + else: + no_errors = False + if no_errors: + mailing_list.status = MailingListStatus.FINISHED + else: + mailing_list.status = MailingListStatus.PARTLY_FINISHED + mailing_list.save(update_fields=('status',)) + + +@app.task +def check_mailing_lists(): + for mailing_list in MailingList.objects.filter(time__lte=timezone.now() + timedelta(hours=1), status=MailingListStatus.WAITING): + mailing_list.status = MailingListStatus.QUEUED + mailing_list.save(update_fields=('status',)) + handle_mailing_list.apply_async( + (mailing_list.pk,), + eta=mailing_list.time + ) diff --git a/backend/users/choices/__init__.py b/backend/users/choices/__init__.py new file mode 100644 index 0000000..256b848 --- /dev/null +++ b/backend/users/choices/__init__.py @@ -0,0 +1 @@ +from .mailing_list_status import MailingListStatus \ No newline at end of file diff --git a/backend/users/choices/mailing_list_status.py b/backend/users/choices/mailing_list_status.py new file mode 100644 index 0000000..9312a86 --- /dev/null +++ b/backend/users/choices/mailing_list_status.py @@ -0,0 +1,10 @@ +from django.db.models import TextChoices +from django.utils.translation import gettext_lazy as _ + + +class MailingListStatus(TextChoices): + WAITING = '1', _('Ожидание') + QUEUED = '2', _('В очереди') + PROCESSING = '3', _('В обработке') + PARTLY_FINISHED = '4', _('Окончена с ошибками') + FINISHED = '5', _('Завершена') diff --git a/backend/users/errors/__init__.py b/backend/users/errors/__init__.py new file mode 100644 index 0000000..3aaabec --- /dev/null +++ b/backend/users/errors/__init__.py @@ -0,0 +1 @@ +from .not_enough_funds import NotEnoughFundsError \ No newline at end of file diff --git a/backend/users/errors/not_enough_funds.py b/backend/users/errors/not_enough_funds.py new file mode 100644 index 0000000..c9fc5c9 --- /dev/null +++ b/backend/users/errors/not_enough_funds.py @@ -0,0 +1,7 @@ +from rest_framework.exceptions import APIException + + +class NotEnoughFundsError(APIException): + status_code = 400 + default_detail = 'Невозможно выполнить операцию, недостаточно баллов' + default_code = 'bad_request' diff --git a/backend/users/migrations/0001_initial.py b/backend/users/migrations/0001_initial.py new file mode 100644 index 0000000..b28a80b --- /dev/null +++ b/backend/users/migrations/0001_initial.py @@ -0,0 +1,139 @@ +# Generated by Django 5.0.4 on 2024-04-26 08:14 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Transaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время')), + ('value', models.DecimalField(decimal_places=5, max_digits=105, verbose_name='Значение')), + ('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')), + ], + options={ + 'verbose_name': 'Транзакция', + 'verbose_name_plural': 'Транзакции', + }, + ), + migrations.CreateModel( + name='TGUser', + fields=[ + ('tg_id', models.PositiveBigIntegerField(primary_key=True, serialize=False, verbose_name='Telegram ID')), + ('username', models.CharField(max_length=250, verbose_name='Telegram username')), + ('avatar', models.FileField(blank=True, null=True, upload_to='users/', verbose_name='Аватарка')), + ('points', models.DecimalField(decimal_places=2, default=0, max_digits=102, verbose_name='Баллы')), + ('referral_storage', models.DecimalField(decimal_places=5, default=0, max_digits=102, verbose_name='Реферальное хранилище')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время создания')), + ('referred_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='referrees', to='users.tguser', verbose_name='Кем был приглашен')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='tg_user', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'ТГ-пользователь', + 'verbose_name_plural': 'ТГ-пользователи', + }, + ), + migrations.CreateModel( + name='ReferralTransaction', + fields=[ + ('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='users.transaction')), + ], + options={ + 'verbose_name': 'Реферальная транзакция', + 'verbose_name_plural': 'Реферальные транзакции', + }, + bases=('users.transaction',), + ), + migrations.CreateModel( + name='MailingList', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=250, verbose_name='Название')), + ('time', models.DateTimeField(verbose_name='Дата и время публикации')), + ('text', models.TextField(verbose_name='Текст публикации')), + ('media', models.FileField(upload_to='mailing/', verbose_name='Вложение')), + ('status', models.CharField(choices=[('1', 'Ожидание'), ('2', 'В очереди'), ('3', 'В обработке'), ('4', 'Окончена с ошибками'), ('5', 'Завершена')], default='1', max_length=1, verbose_name='Статус')), + ('main_button', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='mailing_lists_for_main_button', to='misc.button', verbose_name='Кнопка')), + ('webapp_button', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='mailing_lists_for_webapp_button', to='misc.button', verbose_name='Кнопка с веб-аппом')), + ], + options={ + 'verbose_name': 'Рассылка', + 'verbose_name_plural': 'Рассылки', + }, + ), + migrations.AddField( + model_name='transaction', + name='user', + field=models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name='transactions', to='users.tguser', verbose_name='Пользователь'), + ), + migrations.CreateModel( + name='MailingListReceiverInfo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sent', models.BooleanField(default=False, verbose_name='Отправлена ли')), + ('clicked', models.BooleanField(default=False, verbose_name='Нажата ли')), + ('mailing_list', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mailing_list_receiver_infos', to='users.mailinglist', verbose_name='Рассылка')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mailing_list_receiver_infos', to='users.tguser', verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'Информация о получателе рассылки', + 'verbose_name_plural': 'Информация о получателях рассылки', + }, + ), + migrations.AddField( + model_name='mailinglist', + name='users', + field=models.ManyToManyField(related_name='mailing_lists', through='users.MailingListReceiverInfo', to='users.tguser', verbose_name='Пользователи'), + ), + migrations.CreateModel( + name='BetTransaction', + fields=[ + ('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='users.transaction')), + ('bet', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='transaction', to='auction.bet', verbose_name='Ставка')), + ('refund_to', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='refunded_by', to='users.bettransaction', verbose_name='Какую транзакцию отменяет')), + ], + options={ + 'verbose_name': 'Транзакция за ставку', + 'verbose_name_plural': 'Транзакции за ставки', + }, + bases=('users.transaction',), + ), + migrations.CreateModel( + name='ClickTransaction', + fields=[ + ('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='users.transaction')), + ('click', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='transaction', to='clicks.click', verbose_name='Клик')), + ], + options={ + 'verbose_name': 'Транзакция за клик', + 'verbose_name_plural': 'Транзакции за клики', + }, + bases=('users.transaction',), + ), + migrations.CreateModel( + name='CommissionTransaction', + fields=[ + ('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='users.transaction')), + ('parent_transaction', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='commission', to='users.bettransaction', verbose_name='Родительская транзакция')), + ], + options={ + 'verbose_name': 'Комиссионная транзакция', + 'verbose_name_plural': 'Комиссионные транзакции', + }, + bases=('users.transaction',), + ), + migrations.AddConstraint( + model_name='tguser', + constraint=models.UniqueConstraint(fields=('tg_id',), name='unique_tg_id'), + ), + ] diff --git a/backend/users/migrations/__init__.py b/backend/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/users/models/__init__.py b/backend/users/models/__init__.py new file mode 100644 index 0000000..3b41a0a --- /dev/null +++ b/backend/users/models/__init__.py @@ -0,0 +1,3 @@ +from .mailing_list import MailingList, MailingListReceiverInfo +from .tg_user import TGUser +from .transactions import Transaction, BetTransaction, ClickTransaction, ReferralTransaction, CommissionTransaction diff --git a/backend/users/models/mailing_list.py b/backend/users/models/mailing_list.py new file mode 100644 index 0000000..acd1bc0 --- /dev/null +++ b/backend/users/models/mailing_list.py @@ -0,0 +1,42 @@ +from django.db import models +from users.choices import MailingListStatus + + +class MailingListReceiverInfo(models.Model): + class Meta: + verbose_name = 'Информация о получателе рассылки' + verbose_name_plural = 'Информация о получателях рассылки' + + mailing_list = models.ForeignKey('users.MailingList', on_delete=models.CASCADE, + related_name='mailing_list_receiver_infos', verbose_name='Рассылка') + user = models.ForeignKey('users.TGUser', on_delete=models.CASCADE, + related_name='mailing_list_receiver_infos', verbose_name='Пользователь') + sent = models.BooleanField(default=False, verbose_name='Отправлена ли') + clicked = models.BooleanField(default=False, verbose_name='Нажата ли') + + +class MailingList(models.Model): + class Meta: + verbose_name = 'Рассылка' + verbose_name_plural = 'Рассылки' + + name = models.CharField(max_length=250, verbose_name='Название') + time = models.DateTimeField(verbose_name='Дата и время публикации') + text = models.TextField(verbose_name='Текст публикации') + media = models.FileField(upload_to='mailing/', verbose_name='Вложение') + users = models.ManyToManyField('users.TGUser', related_name='mailing_lists', + through='users.MailingListReceiverInfo', + verbose_name='Пользователи') + status = models.CharField(max_length=1, choices=MailingListStatus.choices, default=MailingListStatus.WAITING, + verbose_name='Статус') + main_button = models.ForeignKey('misc.Button', on_delete=models.CASCADE, + related_name='mailing_lists_for_main_button', + null=True, blank=True, + verbose_name='Кнопка') + webapp_button = models.ForeignKey('misc.Button', on_delete=models.CASCADE, + null=True, blank=True, + related_name='mailing_lists_for_webapp_button', verbose_name='Кнопка с веб-аппом') + + def __str__(self): + return f'Рассылка {self.name} от {self.time.strftime("%d.%m.%Y")} №{self.id}' + diff --git a/backend/users/models/tg_user.py b/backend/users/models/tg_user.py new file mode 100644 index 0000000..6bf5b2c --- /dev/null +++ b/backend/users/models/tg_user.py @@ -0,0 +1,43 @@ +from decimal import Decimal +from django.db.models import Sum, Case, When, F, OuterRef, Subquery +from django.db.models.functions import RowNumber +from django.db.models.expressions import Window +from django.db import models +from django.contrib.auth.models import User +from django_cte import CTEManager, With +from misc.models import Setting + + +class TGUser(models.Model): + class Meta: + verbose_name = 'ТГ-пользователь' + verbose_name_plural = 'ТГ-пользователи' + constraints = [ + models.UniqueConstraint(fields=('tg_id',), name='unique_tg_id') + ] + + user = models.OneToOneField(User, related_name='tg_user', on_delete=models.CASCADE, verbose_name='Пользователь') + tg_id = models.PositiveBigIntegerField(primary_key=True, verbose_name='Telegram ID') + username = models.CharField(max_length=250, verbose_name='Telegram username') + avatar = models.FileField(upload_to='users/', null=True, blank=True, verbose_name='Аватарка') + points = models.DecimalField(default=0, decimal_places=2, max_digits=102, verbose_name='Баллы') + referred_by = models.ForeignKey('users.TGUser', related_name='referrees', on_delete=models.SET_NULL, + null=True, blank=True, verbose_name='Кем был приглашен') + referral_storage = models.DecimalField(decimal_places=5, max_digits=102, default=0, verbose_name='Реферальное хранилище') + warning_count = models.IntegerField(default=0, verbose_name='Количество предупреждений') + is_blocked = models.BooleanField(default=False, verbose_name='Заблокирован ли') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата и время создания') + + objects = CTEManager() + + @property + def max_storage(self): + return Decimal(Setting.objects.get(name='MAX_STORAGE').value['value']) * (self.referrees.count() + 1) + + @property + def rank(self): + return getattr(self, 'row_number', -1) + + def __str__(self): + return f'{self.username} (№{self.tg_id})' + diff --git a/backend/users/models/transactions.py b/backend/users/models/transactions.py new file mode 100644 index 0000000..d8eb96f --- /dev/null +++ b/backend/users/models/transactions.py @@ -0,0 +1,64 @@ +from django.db import models +from polymorphic.models import PolymorphicModel + + +class Transaction(PolymorphicModel): + class Meta: + verbose_name = 'Транзакция' + verbose_name_plural = 'Транзакции' + + user = models.ForeignKey('users.TGUser', related_name='transactions', on_delete=models.DO_NOTHING, + db_constraint=False, verbose_name='Пользователь') + date = models.DateTimeField(auto_now_add=True, verbose_name='Дата и время') + value = models.DecimalField(decimal_places=5, max_digits=105, verbose_name='Значение') + + def __str__(self): + return f'{self._meta.verbose_name} {self.user} от {self.date.strftime("%d.%m.%Y %H:%M")}' + + +class ClickTransaction(Transaction): + class Meta: + verbose_name = 'Транзакция за клик' + verbose_name_plural = 'Транзакции за клики' + + click = models.OneToOneField('clicks.Click', related_name='transaction', on_delete=models.CASCADE, + verbose_name='Клик') + + +class BetTransaction(Transaction): + class Meta: + verbose_name = 'Транзакция за ставку' + verbose_name_plural = 'Транзакции за ставки' + + bet = models.ForeignKey('auction.Bet', related_name='transactions', on_delete=models.CASCADE, + verbose_name='Ставка') + refund_to = models.OneToOneField('users.BetTransaction', related_name='refunded_by', on_delete=models.CASCADE, + null=True, blank=True, + verbose_name='Какую транзакцию отменяет') + + def refund(self): + return BetTransaction.objects.create( + user_id=self.user_id, + value=-self.value, + bet_id=self.bet_id, + refund_to_id=self.pk, + ) + + +class CommissionTransaction(Transaction): + class Meta: + verbose_name = 'Комиссионная транзакция' + verbose_name_plural = 'Комиссионные транзакции' + + parent_transaction = models.OneToOneField('users.BetTransaction', related_name='commission', + on_delete=models.CASCADE, verbose_name='Родительская транзакция') + + +class ReferralTransaction(Transaction): + class Meta: + verbose_name = 'Реферальная транзакция' + verbose_name_plural = 'Реферальные транзакции' + + def __str__(self): + return f'Начисление баллов по реферальной программе {self.user}' + diff --git a/backend/users/permissions.py b/backend/users/permissions.py new file mode 100644 index 0000000..e4c1fb4 --- /dev/null +++ b/backend/users/permissions.py @@ -0,0 +1,6 @@ +from rest_framework.permissions import BasePermission + + +class IsAdminOrIsSelf(BasePermission): + def has_object_permission(self, request, view, obj): + return request.user.is_superuser or request.user.pk == obj.user_id diff --git a/backend/users/serializers/__init__.py b/backend/users/serializers/__init__.py new file mode 100644 index 0000000..74c560f --- /dev/null +++ b/backend/users/serializers/__init__.py @@ -0,0 +1 @@ +from .tg_user import TGUserSerializer \ No newline at end of file diff --git a/backend/users/serializers/tg_user.py b/backend/users/serializers/tg_user.py new file mode 100644 index 0000000..2017e69 --- /dev/null +++ b/backend/users/serializers/tg_user.py @@ -0,0 +1,12 @@ +from rest_framework.serializers import ModelSerializer, DecimalField, IntegerField +from users.models import TGUser + + +class TGUserSerializer(ModelSerializer): + max_storage = DecimalField(decimal_places=2, max_digits=102, read_only=True) + rank = IntegerField(read_only=True) + + class Meta: + model = TGUser + exclude = ('user',) + read_only_fields = ('created_at', 'points', 'username', 'referred_by', 'referral_storage', 'tg_id',) diff --git a/backend/users/signals/__init__.py b/backend/users/signals/__init__.py new file mode 100644 index 0000000..bbbc2ab --- /dev/null +++ b/backend/users/signals/__init__.py @@ -0,0 +1,7 @@ +from .transactions import ( + transaction_pre_save_signal, transaction_signal, transaction_pre_delete_signal +) +from .users import ( + referral_signal, +) +from .mailing_list_signal import mailing_list_signal diff --git a/backend/users/signals/mailing_list_signal.py b/backend/users/signals/mailing_list_signal.py new file mode 100644 index 0000000..c21b2c2 --- /dev/null +++ b/backend/users/signals/mailing_list_signal.py @@ -0,0 +1,12 @@ +from django.dispatch import receiver +from django.db.models.signals import post_save +from django.db import transaction +from users.models import MailingList +from users.celery import check_mailing_lists + + +@receiver(post_save, sender=MailingList, dispatch_uid='mailing_list_signal') +def mailing_list_signal(sender, instance, created, **kwargs): + if not created: + return + transaction.on_commit(lambda: check_mailing_lists.delay()) \ No newline at end of file diff --git a/backend/users/signals/transactions.py b/backend/users/signals/transactions.py new file mode 100644 index 0000000..4a1180e --- /dev/null +++ b/backend/users/signals/transactions.py @@ -0,0 +1,42 @@ +from decimal import Decimal +from django.dispatch import receiver +from django.db import transaction +from django.db.models import F +from django.db.models.signals import post_save, pre_save, pre_delete +from users.models import Transaction, ClickTransaction +from users.errors import NotEnoughFundsError +from misc.models import Setting + + +@receiver(pre_save, dispatch_uid='transaction_pre_save_signal') +def transaction_pre_save_signal(sender, instance, **kwargs): + if not isinstance(instance, Transaction) or not instance.pk: + return + transaction_instance = Transaction.objects.get(pk=instance.id) + instance._old_value = transaction_instance.value + + +@receiver(post_save, dispatch_uid='transaction_signal') +def transaction_signal(sender, instance, created, **kwargs): + if not issubclass(sender, Transaction): + return + with transaction.atomic(): + user_instance = instance.user + user_instance.points = F('points') + (instance.value - getattr(instance, '_old_value', 0)) + user_instance.save() + user_instance.refresh_from_db() + if user_instance.points < 0: + raise NotEnoughFundsError + + +@receiver(pre_delete, dispatch_uid='transaction_pre_delete_signal') +def transaction_pre_delete_signal(sender, instance, **kwargs): + if not issubclass(sender, Transaction): + return + with transaction.atomic(): + user_instance = instance.user + user_instance.points = F('points') - instance.value + user_instance.save() + user_instance.refresh_from_db() + if user_instance.points < 0: + raise NotEnoughFundsError diff --git a/backend/users/signals/users.py b/backend/users/signals/users.py new file mode 100644 index 0000000..4b43a98 --- /dev/null +++ b/backend/users/signals/users.py @@ -0,0 +1,26 @@ +from decimal import Decimal +from django.dispatch import receiver +from django.db.models.signals import pre_save, post_save +from django.db.models import F +from django.db.models.functions import Least +from users.models import TGUser, ClickTransaction +from misc.models import Setting + + +@receiver(post_save, sender=ClickTransaction, dispatch_uid='referral_transaction_signal') +def referral_signal(sender, instance, created, **kwargs): + if not created: + return + if referred_by_user := instance.user.referred_by: + referred_by_user.referral_storage = Least( + F('referral_storage') + instance.value * Decimal(Setting.objects.get(name='REFERRAL_PERCENT').value['value']), + referred_by_user.max_storage + ) + referred_by_user.save() + for referree in instance.user.referrees.all(): + referree.referral_storage = Least( + F('referral_storage') + instance.value * Decimal(Setting.objects.get(name='REVERSE_REFERRAL_PERCENT').value['value']), + referree.max_storage + ) + referree.save() + diff --git a/backend/users/tests/__init__.py b/backend/users/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/users/tests/test_ranking.py b/backend/users/tests/test_ranking.py new file mode 100644 index 0000000..1d98f56 --- /dev/null +++ b/backend/users/tests/test_ranking.py @@ -0,0 +1,191 @@ +import pytest +from decimal import Decimal +from pytest_drf import ( + ViewSetTest, + APIViewTest, + AsUser, + Returns200, + Returns403, + Returns204, + UsesGetMethod, + UsesDeleteMethod, + UsesDetailEndpoint, + UsesListEndpoint, + UsesPatchMethod, + UsesPostMethod, +) +from django.utils.html import urlencode +from pytest_drf.util import url_for +from pytest_lambda import lambda_fixture, static_fixture +from django.contrib.auth.models import User +from users.models import TGUser +from misc.models import Setting + +user = lambda_fixture(lambda: TGUser.objects.create( + user=User.objects.create_user( + username='test_user', + password='test_pass' + ), + tg_id=1, + points=250, + username='test_user', +).user) +other_users = lambda_fixture(lambda: list(TGUser.objects.create( + user=User.objects.create( + username=f'user-{i}', + password=f'user-{i}' + ), + tg_id=i * 100, + points=i * 100, + referred_by_id=1, + username=f'user-{i}' +) for i in range(1, 5))) +max_storage_setting = lambda_fixture(lambda: Setting.objects.create(name='MAX_STORAGE', value={'value': 200})) + + +@pytest.mark.django_db +class TestTop(APIViewTest, AsUser('user')): + url = lambda_fixture(lambda: url_for('rank-top') + '?' + urlencode({'limit': 3})) + top_setting = lambda_fixture(lambda: Setting.objects.create(name='DEFAULT_TOP_LIMIT', value={'value': 25})) + + def test_top(self, other_users, top_setting, max_storage_setting, json): + for user_data in json: + user_data.pop('created_at') + expected = [ + { + 'tg_id': 400, + 'username': 'user-4', + 'avatar': None, + 'referred_by': 1, + 'points': '400.00', + 'referral_storage': '0.00000', + 'max_storage': '200.00', + 'rank': 1, + }, + { + 'tg_id': 300, + 'username': 'user-3', + 'avatar': None, + 'referred_by': 1, + 'points': '300.00', + 'referral_storage': '0.00000', + 'max_storage': '200.00', + 'rank': 2, + }, + { + 'tg_id': 1, + 'username': 'test_user', + 'avatar': None, + 'referred_by': None, + 'points': '250.00', + 'referral_storage': '0.00000', + 'max_storage': '1000.00', + 'rank': 3, + } + ] + assert expected == json + + +@pytest.mark.django_db +class TestNeighbours(APIViewTest, AsUser('user')): + url = lambda_fixture(lambda: url_for('rank-neighbours') + '?' + urlencode({'limit': 1})) + neighbour_setting = lambda_fixture(lambda: Setting.objects.create(name='DEFAULT_NEIGHBOUR_LIMIT', value={'value': 25})) + + def test_neighbours(self, other_users, neighbour_setting, max_storage_setting, json): + for user_data in json: + user_data.pop('created_at') + expected = [ + { + 'tg_id': 300, + 'username': 'user-3', + 'avatar': None, + 'referred_by': 1, + 'points': '300.00', + 'referral_storage': '0.00000', + 'max_storage': '200.00', + 'rank': 2, + }, + { + 'tg_id': 1, + 'username': 'test_user', + 'avatar': None, + 'referred_by': None, + 'points': '250.00', + 'referral_storage': '0.00000', + 'max_storage': '1000.00', + 'rank': 3, + }, + { + 'tg_id': 200, + 'username': 'user-2', + 'avatar': None, + 'referred_by': 1, + 'points': '200.00', + 'referral_storage': '0.00000', + 'max_storage': '200.00', + 'rank': 4, + }, + ] + assert expected == json + + +@pytest.mark.django_db +class TestFriends(APIViewTest, AsUser('user')): + url = lambda_fixture(lambda: url_for('rank-friends')) + + def test_friends(self, other_users, max_storage_setting, json): + for user_data in json: + user_data.pop('created_at') + expected = [ + { + 'tg_id': 400, + 'username': 'user-4', + 'avatar': None, + 'referred_by': 1, + 'points': '400.00', + 'referral_storage': '0.00000', + 'max_storage': '200.00', + 'rank': 1, + }, + { + 'tg_id': 300, + 'username': 'user-3', + 'avatar': None, + 'referred_by': 1, + 'points': '300.00', + 'referral_storage': '0.00000', + 'max_storage': '200.00', + 'rank': 2, + }, + { + 'tg_id': 1, + 'username': 'test_user', + 'avatar': None, + 'referred_by': None, + 'points': '250.00', + 'referral_storage': '0.00000', + 'max_storage': '1000.00', + 'rank': 3, + }, + { + 'tg_id': 200, + 'username': 'user-2', + 'avatar': None, + 'referred_by': 1, + 'points': '200.00', + 'referral_storage': '0.00000', + 'max_storage': '200.00', + 'rank': 4, + }, + { + 'tg_id': 100, + 'username': 'user-1', + 'avatar': None, + 'referred_by': 1, + 'points': '100.00', + 'referral_storage': '0.00000', + 'max_storage': '200.00', + 'rank': 5, + }, + ] + assert expected == json diff --git a/backend/users/tests/test_tg_user.py b/backend/users/tests/test_tg_user.py new file mode 100644 index 0000000..31c06c4 --- /dev/null +++ b/backend/users/tests/test_tg_user.py @@ -0,0 +1,103 @@ +import pytest +from decimal import Decimal +from pytest_drf import ( + ViewSetTest, + AsUser, + Returns200, + Returns403, + Returns204, + UsesGetMethod, + UsesDeleteMethod, + UsesDetailEndpoint, + UsesListEndpoint, + UsesPatchMethod, + UsesPostMethod, +) +from pytest_drf.util import url_for +from pytest_lambda import lambda_fixture, static_fixture +from django.contrib.auth.models import User +from users.models import TGUser + +user = lambda_fixture(lambda: TGUser.objects.create( + user=User.objects.create_user( + username='test_user', + password='test_pass' + ), + tg_id=1, + username='test_user', +).user) + + +@pytest.mark.django_db +class TestTGUserViewSet(ViewSetTest): + detail_url = lambda_fixture( + lambda user: + url_for('user-detail', user.tg_user.pk) + ) + + class TestGet( + UsesDetailEndpoint, + UsesGetMethod, + Returns200, + AsUser('user') + ): + pass + + class TestUpdate( + UsesDetailEndpoint, + UsesPatchMethod, + Returns200, + AsUser('user') + ): + pass + + class TestUpdateDisallowed( + UsesPatchMethod, + Returns403, + AsUser('user') + ): + another_user = lambda_fixture( + lambda: TGUser.objects.create( + user=User.objects.create_user( + username='another_user', + password='another_pass' + ), + tg_id=2, + username='another_user', + ) + ) + url = lambda_fixture(lambda: url_for('user-detail', 2)) + data = static_fixture({ + 'username': 'new_name' + }) + + def test_it_returns_403(self, another_user, response, expected_status_code): + super().test_it_returns_403(response, expected_status_code) + + class TestDelete( + UsesDetailEndpoint, + UsesDeleteMethod, + Returns204, + AsUser('user'), + ): + pass + + class TestDeleteDisallowed( + UsesDeleteMethod, + Returns403, + AsUser('user'), + ): + another_user = lambda_fixture( + lambda: TGUser.objects.create( + user=User.objects.create_user( + username='another_user', + password='another_pass' + ), + tg_id=2, + username='another_user', + ) + ) + url = lambda_fixture(lambda: url_for('user-detail', 2)) + + def test_it_returns_403(self, another_user, response, expected_status_code): + super().test_it_returns_403(response, expected_status_code) diff --git a/backend/users/urls/__init__.py b/backend/users/urls/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/users/urls/internal_urls.py b/backend/users/urls/internal_urls.py new file mode 100644 index 0000000..cc90a68 --- /dev/null +++ b/backend/users/urls/internal_urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from users.views import get_token, check_registration + +urlpatterns = [ + path('get-token/', get_token), + path('check/', check_registration), +] diff --git a/backend/users/urls/urls.py b/backend/users/urls/urls.py new file mode 100644 index 0000000..362044f --- /dev/null +++ b/backend/users/urls/urls.py @@ -0,0 +1,17 @@ +from django.urls import include, path +from rest_framework.routers import SimpleRouter +from users.views import ( + TGUserViewSet, empty_storage, top as rank_top, neighbours as rank_neighbours, friends as rank_friends, warn +) + +router = SimpleRouter() +router.register('', TGUserViewSet, 'user') + +urlpatterns = [ + path('empty-storage/', empty_storage), + path('', include(router.urls)), + path('warn/', warn, name='warn'), + path('rank/top', rank_top, name='rank-top'), + path('rank/neighbours', rank_neighbours, name='rank-neighbours'), + path('rank/friends', rank_friends, name='rank-friends'), +] diff --git a/backend/users/views.py b/backend/users/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/backend/users/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/backend/users/views/__init__.py b/backend/users/views/__init__.py new file mode 100644 index 0000000..d9dd5ec --- /dev/null +++ b/backend/users/views/__init__.py @@ -0,0 +1,6 @@ +from .tg_user import TGUserViewSet +from .get_token import get_token +from .empty_storage import empty_storage +from .check_registration import check_registration +from .ranking import top, neighbours, friends +from .warn import warn \ No newline at end of file diff --git a/backend/users/views/check_registration.py b/backend/users/views/check_registration.py new file mode 100644 index 0000000..870b41c --- /dev/null +++ b/backend/users/views/check_registration.py @@ -0,0 +1,11 @@ +from rest_framework.decorators import api_view, permission_classes, authentication_classes +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND +from users.models import TGUser + + +@api_view() +@permission_classes([]) +@authentication_classes([]) +def check_registration(request, pk): + return Response(status=HTTP_200_OK) if TGUser.objects.filter(pk=pk).exists() else Response(status=HTTP_404_NOT_FOUND) \ No newline at end of file diff --git a/backend/users/views/empty_storage.py b/backend/users/views/empty_storage.py new file mode 100644 index 0000000..bbe62cc --- /dev/null +++ b/backend/users/views/empty_storage.py @@ -0,0 +1,21 @@ +from rest_framework.decorators import api_view +from rest_framework.response import Response +from django.db import transaction +from users.models import ReferralTransaction + + +@transaction.atomic +@api_view(['POST']) +def empty_storage(request): + tg_user = request.user.tg_user + if tg_user.referral_storage == 0: + return Response(data={'points': str(tg_user.points)}) + ReferralTransaction.objects.create( + user_id=tg_user.pk, + value=tg_user.referral_storage + ) + tg_user.refresh_from_db() + tg_user.referral_storage = 0 + tg_user.save() + tg_user.refresh_from_db() + return Response(data={'points': str(tg_user.points)}) diff --git a/backend/users/views/get_token.py b/backend/users/views/get_token.py new file mode 100644 index 0000000..73b530c --- /dev/null +++ b/backend/users/views/get_token.py @@ -0,0 +1,42 @@ +import base64 +import hashlib +import hmac +import json +import time +from rest_framework.decorators import api_view, schema, permission_classes, authentication_classes +from rest_framework.response import Response +from rest_framework.status import HTTP_403_FORBIDDEN +from django.conf import settings +from users.models import TGUser + + +@api_view(['GET']) +@permission_classes([]) +@authentication_classes([]) +@schema(None) +def get_token(request, pk): + auth_date = int(time.time()) + if TGUser.objects.filter(pk=pk).exists(): + user_info = { + 'id': pk, + 'username': TGUser.objects.get(pk=pk).username + } + else: + user_info = { + 'id': pk + } + data_check_string = f'auth_date={auth_date}\nuser={json.dumps(user_info)}' + secret = hmac.new( + 'WebAppData'.encode(), + settings.TG_TOKEN.encode('utf-8'), + digestmod=hashlib.sha256 + ).digest() + secret_hash = hmac.new( + secret, + msg=data_check_string.encode('utf-8'), + digestmod=hashlib.sha256 + ).hexdigest() + return Response({'token': base64.b64encode(f'{data_check_string}:{secret_hash}'.encode('utf-8'))}) + + + diff --git a/backend/users/views/ranking.py b/backend/users/views/ranking.py new file mode 100644 index 0000000..83deae9 --- /dev/null +++ b/backend/users/views/ranking.py @@ -0,0 +1,53 @@ +from django.db.models import F +from django.db.models.functions import RowNumber +from django.db.models.expressions import Window +from rest_framework.decorators import api_view +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK +from django_cte import With +from users.serializers import TGUserSerializer +from users.models import TGUser +from misc.models import Setting + + +@api_view(['GET']) +def top(request): + limit = int(request.query_params.get('limit', Setting.objects.get(name='DEFAULT_TOP_LIMIT').value['value'])) + qs = ( + TGUser.objects.order_by('-points', 'user_id') + .annotate(row_number=Window(expression=RowNumber(), order_by=[-F('points'), F('user_id')]))[:limit] + ) + serializer = TGUserSerializer(qs, many=True) + return Response(status=HTTP_200_OK, data=serializer.data) + + +@api_view(['GET']) +def neighbours(request): + limit = int(request.query_params.get('limit', Setting.objects.get(name='DEFAULT_NEIGHBOUR_LIMIT').value['value'])) + cte = With( + TGUser.objects.annotate(row_number=Window(expression=RowNumber(), order_by=[-F('points'), F('user_id')]))) + full_qs = cte.join(TGUser, tg_id=cte.col.tg_id).with_cte(cte).annotate(row_number=cte.col.row_number) + self = full_qs.get(pk=request.user.tg_user.pk) + qs = ( + full_qs.filter(pk=request.user.tg_user.pk) + .union(full_qs.filter(row_number__lt=self.rank).order_by('points', '-user_id')[:limit]) + .union(full_qs.filter(row_number__gt=self.rank).order_by('-points', 'user_id')[:limit]) + .order_by('-points', 'user_id') + ) + serializer = TGUserSerializer(qs, many=True) + return Response(status=HTTP_200_OK, data=serializer.data) + +@api_view(['GET']) +def friends(request): + cte = With( + TGUser.objects.annotate(row_number=Window(expression=RowNumber(), order_by=[-F('points'), F('user_id')]))) + full_qs = cte.join(TGUser, tg_id=cte.col.tg_id).with_cte(cte).annotate(row_number=cte.col.row_number) + self = full_qs.get(pk=request.user.tg_user.pk) + qs = ( + full_qs.filter(pk=request.user.tg_user.pk) + .union(full_qs.filter(referred_by_id=self.pk).order_by('points', '-user_id')) + .order_by('-points', 'user_id') + ) + serializer = TGUserSerializer(qs, many=True) + return Response(status=HTTP_200_OK, data=serializer.data) + diff --git a/backend/users/views/tg_user.py b/backend/users/views/tg_user.py new file mode 100644 index 0000000..c754afc --- /dev/null +++ b/backend/users/views/tg_user.py @@ -0,0 +1,22 @@ +from rest_framework import viewsets, mixins +from rest_framework.settings import api_settings +from users.serializers import TGUserSerializer +from users.models import TGUser +from users.permissions import IsAdminOrIsSelf + + +class TGUserViewSet( + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet +): + serializer_class = TGUserSerializer + queryset = TGUser.objects.all() + + def get_permissions(self): + if self.action in ('update', 'partial_update', 'destroy'): + permissions = [*api_settings.DEFAULT_PERMISSION_CLASSES, IsAdminOrIsSelf] + else: + permissions = api_settings.DEFAULT_PERMISSION_CLASSES + return [permission() for permission in permissions] \ No newline at end of file diff --git a/backend/users/views/warn.py b/backend/users/views/warn.py new file mode 100644 index 0000000..010a143 --- /dev/null +++ b/backend/users/views/warn.py @@ -0,0 +1,15 @@ +from django.db.models import F +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK +from rest_framework.decorators import api_view +from users.serializers import TGUserSerializer + + +@api_view(['POST']) +def warn(request): + tg_user = request.user.tg_user + tg_user.warning_count = F('warning_count') + 1 + tg_user.save(update_fields=('warning_count',)) + tg_user.refresh_from_db() + serializer = TGUserSerializer(tg_user).data + return Response(status=HTTP_200_OK, data=serializer.data) From e23cd412dc9da3255935acac539104a50b545515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B0=D0=BD=D1=8F=20=D0=92=D0=B0=D0=BA=D1=83=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BA=D0=BE=D0=B2?= Date: Wed, 11 Dec 2024 01:05:50 +0300 Subject: [PATCH 3/3] Add new migrations --- backend/auction/migrations/0002_initial.py | 32 +++++++++++++++++++ .../0003_user_penalties_and_auction_fixes.py | 27 ++++++++++++++++ backend/clicks/migrations/0002_initial.py | 22 +++++++++++++ backend/misc/migrations/0002_initial.py | 27 ++++++++++++++++ backend/users/migrations/0001_initial.py | 5 +++ .../0002_user_penalties_and_auction_fixes.py | 30 +++++++++++++++++ 6 files changed, 143 insertions(+) create mode 100644 backend/auction/migrations/0002_initial.py create mode 100644 backend/auction/migrations/0003_user_penalties_and_auction_fixes.py create mode 100644 backend/clicks/migrations/0002_initial.py create mode 100644 backend/misc/migrations/0002_initial.py create mode 100644 backend/users/migrations/0002_user_penalties_and_auction_fixes.py diff --git a/backend/auction/migrations/0002_initial.py b/backend/auction/migrations/0002_initial.py new file mode 100644 index 0000000..b094fb8 --- /dev/null +++ b/backend/auction/migrations/0002_initial.py @@ -0,0 +1,32 @@ +# Generated by Django 5.0.4 on 2024-04-26 08:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auction', '0001_initial'), + ('users', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='bet', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bets', to='users.tguser', verbose_name='Пользователь'), + ), + migrations.AddField( + model_name='auction', + name='betters', + field=models.ManyToManyField(related_name='auctions', through='auction.Bet', to='users.tguser', verbose_name='Поставившие ставку'), + ), + migrations.AddField( + model_name='auction', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auctions', to='auction.product', verbose_name='Товар'), + ), + ] diff --git a/backend/auction/migrations/0003_user_penalties_and_auction_fixes.py b/backend/auction/migrations/0003_user_penalties_and_auction_fixes.py new file mode 100644 index 0000000..7364669 --- /dev/null +++ b/backend/auction/migrations/0003_user_penalties_and_auction_fixes.py @@ -0,0 +1,27 @@ +# Generated by Django 5.0.4 on 2024-05-04 16:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auction', '0002_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='auction', + name='current_end_time', + ), + migrations.AddField( + model_name='auction', + name='times_postponed', + field=models.IntegerField(default=0, verbose_name='Количество переносов'), + ), + migrations.AlterField( + model_name='auction', + name='initial_end_time', + field=models.DateTimeField(verbose_name='Изначальная дата окончания'), + ), + ] diff --git a/backend/clicks/migrations/0002_initial.py b/backend/clicks/migrations/0002_initial.py new file mode 100644 index 0000000..a823675 --- /dev/null +++ b/backend/clicks/migrations/0002_initial.py @@ -0,0 +1,22 @@ +# Generated by Django 5.0.4 on 2024-04-26 08:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('clicks', '0001_initial'), + ('users', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='click', + name='user', + field=models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name='clicks', to='users.tguser', verbose_name='Пользователь'), + ), + ] diff --git a/backend/misc/migrations/0002_initial.py b/backend/misc/migrations/0002_initial.py new file mode 100644 index 0000000..5d1ad55 --- /dev/null +++ b/backend/misc/migrations/0002_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 5.0.4 on 2024-04-26 08:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('misc', '0001_initial'), + ('users', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='popupreceiverinfo', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='popup_receiver_infos', to='users.tguser', verbose_name='Пользователь'), + ), + migrations.AddField( + model_name='popup', + name='users', + field=models.ManyToManyField(related_name='popups', through='misc.PopupReceiverInfo', to='users.tguser', verbose_name='Пользователи'), + ), + ] diff --git a/backend/users/migrations/0001_initial.py b/backend/users/migrations/0001_initial.py index b28a80b..453d52d 100644 --- a/backend/users/migrations/0001_initial.py +++ b/backend/users/migrations/0001_initial.py @@ -10,6 +10,11 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ('auction', '0001_initial'), + ('clicks', '0001_initial'), + ('contenttypes', '0002_remove_content_type_name'), + ('misc', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ diff --git a/backend/users/migrations/0002_user_penalties_and_auction_fixes.py b/backend/users/migrations/0002_user_penalties_and_auction_fixes.py new file mode 100644 index 0000000..3a6e883 --- /dev/null +++ b/backend/users/migrations/0002_user_penalties_and_auction_fixes.py @@ -0,0 +1,30 @@ +# Generated by Django 5.0.4 on 2024-05-04 16:38 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auction', '0003_user_penalties_and_auction_fixes'), + ('users', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='tguser', + name='is_blocked', + field=models.BooleanField(default=False, verbose_name='Заблокирован ли'), + ), + migrations.AddField( + model_name='tguser', + name='warning_count', + field=models.IntegerField(default=0, verbose_name='Количество предупреждений'), + ), + migrations.AlterField( + model_name='bettransaction', + name='bet', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='auction.bet', verbose_name='Ставка'), + ), + ]