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 0000000..3113c7f Binary files /dev/null and b/backend/media/products/1003345981.jpg differ diff --git a/backend/misc/__init__.py b/backend/misc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/misc/admin.py b/backend/misc/admin.py new file mode 100644 index 0000000..e62ba44 --- /dev/null +++ b/backend/misc/admin.py @@ -0,0 +1,41 @@ +from decimal import Decimal +from django.contrib import admin +from django.forms import ModelForm, CharField +from misc.models import Setting + + +class SettingForm(ModelForm): + value = CharField(label='Значение') + + class Meta: + model = Setting + exclude = ('value',) + + def save(self, commit=True): + setting = super(SettingForm, self).save(commit=False) + setting.value = dict() + if self.cleaned_data['value'].isdigit(): + setting.value['value'] = int(self.cleaned_data['value']) + elif self.cleaned_data['value'] in ('true', 'false'): + setting.value['value'] = self.cleaned_data['value'] == 'true' + else: + setting.value['value'] = self.cleaned_data['value'] + + if commit: + setting.save() + + return setting + + +@admin.register(Setting) +class SettingAdmin(admin.ModelAdmin): + form = SettingForm + list_display = [ + 'id', + 'name', + 'description', + 'display_value' + ] + list_display_links = [ + 'id' + ] \ No newline at end of file diff --git a/backend/misc/apps.py b/backend/misc/apps.py new file mode 100644 index 0000000..2528402 --- /dev/null +++ b/backend/misc/apps.py @@ -0,0 +1,14 @@ +from django.apps import AppConfig + + +class MiscConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "misc" + + def ready(self): + from .celery import deliver_setting as deliver_setting_celery + from .signals import deliver_setting + from misc.models import Setting + + for setting in Setting.objects.all(): + deliver_setting_celery.delay(setting.name) diff --git a/backend/misc/celery/__init__.py b/backend/misc/celery/__init__.py new file mode 100644 index 0000000..e493b50 --- /dev/null +++ b/backend/misc/celery/__init__.py @@ -0,0 +1 @@ +from .deliver_setting import deliver_setting \ No newline at end of file diff --git a/backend/misc/celery/deliver_setting.py b/backend/misc/celery/deliver_setting.py new file mode 100644 index 0000000..5b8213f --- /dev/null +++ b/backend/misc/celery/deliver_setting.py @@ -0,0 +1,22 @@ +from kombu import Connection, Producer, Queue +from django.conf import settings +from clicker.celery import app +from misc.models import Setting + + +@app.task(autoretry_for=(Exception,), retry_backoff=True) +def deliver_setting(setting_name): + setting = Setting.objects.get(name=setting_name) + rabbitmq_conf = settings.RABBITMQ + dsn = f'{rabbitmq_conf["PROTOCOL"]}://{rabbitmq_conf["USER"]}:{rabbitmq_conf["PASSWORD"]}@{rabbitmq_conf["HOST"]}:{rabbitmq_conf["PORT"]}/' + queue = Queue(settings.SETTINGS_QUEUE_NAME, exchange='', routing_key=settings.SETTINGS_QUEUE_NAME) + with Connection(dsn) as conn: + with conn.channel() as channel: + producer = Producer(channel) + producer.publish( + {setting.name: setting.value['value']}, + exchange='', + routing_key=settings.SETTINGS_QUEUE_NAME, + declare=[queue] + ) + diff --git a/backend/misc/migrations/0001_initial.py b/backend/misc/migrations/0001_initial.py new file mode 100644 index 0000000..82ac774 --- /dev/null +++ b/backend/misc/migrations/0001_initial.py @@ -0,0 +1,99 @@ +# Generated by Django 5.0.4 on 2024-04-26 08:14 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Button', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField(verbose_name='Текст на кнопке')), + ('link', models.CharField(max_length=250, verbose_name='Ссылка на кнопке')), + ], + options={ + 'verbose_name': 'Кнопка', + 'verbose_name_plural': 'Кнопки', + }, + ), + migrations.CreateModel( + name='Setting', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Желательно в формате: SAMPLE_NAME', max_length=250, verbose_name='Машиночитаемое название')), + ('description', models.TextField(verbose_name='Описание')), + ('value', models.JSONField(verbose_name='Значение')), + ], + options={ + 'verbose_name': 'Настройка', + 'verbose_name_plural': 'Настройки', + }, + ), + migrations.CreateModel( + name='Style', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=250, verbose_name='Название')), + ('color_1', models.CharField(max_length=7, validators=[django.core.validators.RegexValidator(message='Пожалуйста введите название текста в формате #11AA11', regex='#[0-9A-F]{6}')], verbose_name='Основной цвет')), + ('color_2', models.CharField(max_length=7, validators=[django.core.validators.RegexValidator(message='Пожалуйста введите название текста в формате #11AA11', regex='#[0-9A-F]{6}')], verbose_name='Цвет №2')), + ('color_3', models.CharField(max_length=7, validators=[django.core.validators.RegexValidator(message='Пожалуйста введите название текста в формате #11AA11', regex='#[0-9A-F]{6}')], verbose_name='Цвет №3')), + ('color_4', models.CharField(max_length=7, validators=[django.core.validators.RegexValidator(message='Пожалуйста введите название текста в формате #11AA11', regex='#[0-9A-F]{6}')], verbose_name='Цвет №4')), + ('description', models.TextField(verbose_name='Описание')), + ('is_available', models.BooleanField(verbose_name='Доступен ли')), + ('background', models.FileField(upload_to='styles/', verbose_name='Фон')), + ], + options={ + 'verbose_name': 'Стиль', + 'verbose_name_plural': 'Стили', + }, + ), + migrations.CreateModel( + name='Banner', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=250, verbose_name='Название')), + ('media', models.FileField(upload_to='banners/', verbose_name='Обложка')), + ('is_available', models.BooleanField(verbose_name='Доступен ли')), + ('button', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='banners', to='misc.button', verbose_name='Кнопка')), + ], + options={ + 'verbose_name': 'Баннер', + 'verbose_name_plural': 'Баннеры', + }, + ), + migrations.CreateModel( + name='Popup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=250, verbose_name='Название')), + ('text', models.TextField(verbose_name='Текст публикации')), + ('media', models.FileField(upload_to='popup/', verbose_name='Обложка')), + ('button', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='popups', to='misc.button', verbose_name='Кнопка')), + ], + options={ + 'verbose_name': 'Попап', + 'verbose_name_plural': 'Попап', + }, + ), + migrations.CreateModel( + name='PopupReceiverInfo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('viewed', models.BooleanField(default=False, verbose_name='Просмотрен ли')), + ('popup', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='popup_receiver_infos', to='misc.popup', verbose_name='Рассылка')), + ], + options={ + 'verbose_name': 'Информация о получателе попапа', + 'verbose_name_plural': 'Информация о получателях рассылки', + }, + ), + ] diff --git a/backend/misc/migrations/__init__.py b/backend/misc/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/misc/models/__init__.py b/backend/misc/models/__init__.py new file mode 100644 index 0000000..33123db --- /dev/null +++ b/backend/misc/models/__init__.py @@ -0,0 +1,5 @@ +from .button import Button +from .banner import Banner +from .popup import Popup +from .setting import Setting +from .style import Style \ No newline at end of file diff --git a/backend/misc/models/banner.py b/backend/misc/models/banner.py new file mode 100644 index 0000000..8b729c9 --- /dev/null +++ b/backend/misc/models/banner.py @@ -0,0 +1,18 @@ +from django.db import models + + +class Banner(models.Model): + class Meta: + verbose_name = 'Баннер' + verbose_name_plural = 'Баннеры' + + name = models.CharField(max_length=250, verbose_name='Название') + media = models.FileField(upload_to='banners/', verbose_name='Обложка') + button = models.ForeignKey('misc.Button', related_name='banners', on_delete=models.CASCADE, + null=True, blank=True, + verbose_name='Кнопка') + is_available = models.BooleanField(verbose_name='Доступен ли') + + def __str__(self): + return f'{self.name} ({self.pk})' + diff --git a/backend/misc/models/button.py b/backend/misc/models/button.py new file mode 100644 index 0000000..af37995 --- /dev/null +++ b/backend/misc/models/button.py @@ -0,0 +1,13 @@ +from django.db import models + + +class Button(models.Model): + class Meta: + verbose_name = 'Кнопка' + verbose_name_plural = 'Кнопки' + + text = models.TextField(verbose_name='Текст на кнопке') + link = models.CharField(max_length=250, verbose_name='Ссылка на кнопке') + + def __str__(self): + return f'Кнопка №{self.pk}' diff --git a/backend/misc/models/popup.py b/backend/misc/models/popup.py new file mode 100644 index 0000000..6b62121 --- /dev/null +++ b/backend/misc/models/popup.py @@ -0,0 +1,32 @@ +from django.db import models + + +class PopupReceiverInfo(models.Model): + class Meta: + verbose_name = 'Информация о получателе попапа' + verbose_name_plural = 'Информация о получателях рассылки' + + popup = models.ForeignKey('misc.Popup', on_delete=models.CASCADE, + related_name='popup_receiver_infos', verbose_name='Рассылка') + user = models.ForeignKey('users.TGUser', on_delete=models.CASCADE, + related_name='popup_receiver_infos', verbose_name='Пользователь') + viewed = models.BooleanField(default=False, verbose_name='Просмотрен ли') + + +class Popup(models.Model): + class Meta: + verbose_name = 'Попап' + verbose_name_plural = 'Попап' + + name = models.CharField(max_length=250, verbose_name='Название') + text = models.TextField(verbose_name='Текст публикации') + media = models.FileField(upload_to='popup/', verbose_name='Обложка') + users = models.ManyToManyField('users.TGUser', related_name='popups', through='misc.PopupReceiverInfo', + verbose_name='Пользователи') + button = models.ForeignKey('misc.Button', related_name='popups', on_delete=models.CASCADE, + null=True, blank=True, + verbose_name='Кнопка') + + def __str__(self): + return f'Попап {self.name} от {self.time.strftime("%d.%m.%Y")} №{self.id}' + diff --git a/backend/misc/models/setting.py b/backend/misc/models/setting.py new file mode 100644 index 0000000..8586aab --- /dev/null +++ b/backend/misc/models/setting.py @@ -0,0 +1,20 @@ +from django.db import models + + +class Setting(models.Model): + class Meta: + verbose_name = 'Настройка' + verbose_name_plural = 'Настройки' + + name = models.CharField(max_length=250, verbose_name='Машиночитаемое название', + help_text='Желательно в формате: SAMPLE_NAME') + description = models.TextField(verbose_name='Описание') + value = models.JSONField(verbose_name='Значение') + + @property + def display_value(self): + return self.value['value'] + display_value.fget.short_description = 'Значение' + + def __str__(self): + return f'{self.name} ({self.pk})' diff --git a/backend/misc/models/style.py b/backend/misc/models/style.py new file mode 100644 index 0000000..06153ea --- /dev/null +++ b/backend/misc/models/style.py @@ -0,0 +1,26 @@ +from django.db import models +from django.core.validators import RegexValidator + + +_color_validator = RegexValidator( + regex=r'#[0-9A-F]{6}', + message='Пожалуйста введите название текста в формате #11AA11' +) + + +class Style(models.Model): + class Meta: + verbose_name = 'Стиль' + verbose_name_plural = 'Стили' + + name = models.CharField(max_length=250, verbose_name='Название') + color_1 = models.CharField(max_length=7, validators=[_color_validator], verbose_name='Основной цвет') + color_2 = models.CharField(max_length=7, validators=[_color_validator], verbose_name='Цвет №2') + color_3 = models.CharField(max_length=7, validators=[_color_validator], verbose_name='Цвет №3') + color_4 = models.CharField(max_length=7, validators=[_color_validator], verbose_name='Цвет №4') + description = models.TextField(verbose_name='Описание') + is_available = models.BooleanField(verbose_name='Доступен ли') + background = models.FileField(upload_to='styles/', verbose_name='Фон') + + def __str__(self): + return f'{self.name} ({self.pk})' diff --git a/backend/misc/signals/__init__.py b/backend/misc/signals/__init__.py new file mode 100644 index 0000000..876c013 --- /dev/null +++ b/backend/misc/signals/__init__.py @@ -0,0 +1 @@ +from .setting import deliver_setting \ No newline at end of file diff --git a/backend/misc/signals/setting.py b/backend/misc/signals/setting.py new file mode 100644 index 0000000..cea0ee4 --- /dev/null +++ b/backend/misc/signals/setting.py @@ -0,0 +1,9 @@ +from django.dispatch import receiver +from django.db.models.signals import post_save +from misc.models import Setting +from misc.celery import deliver_setting as deliver_setting_celery + + +@receiver(post_save, sender=Setting, dispatch_uid='deliver_setting') +def deliver_setting(sender, instance, **kwargs): + deliver_setting_celery.delay(instance.name) diff --git a/backend/misc/tests.py b/backend/misc/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/misc/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/misc/views.py b/backend/misc/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/backend/misc/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..99067c4 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +python_files = tests.py test_*.py *_tests.py +filterwarnings = + ignore::RuntimeWarning +env= + DJANGO_SETTINGS_MODULE=clicker.test_settings +addopts=--nomigrations \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..be3a649 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,87 @@ +amqp==5.2.0 +argon2-cffi==23.1.0 +argon2-cffi-bindings==21.2.0 +asgiref==3.8.1 +asttokens==2.4.1 +attrs==23.2.0 +billiard==4.2.0 +boto3==1.34.95 +botocore==1.34.95 +celery==5.3.6 +certifi==2024.2.2 +cffi==1.16.0 +charset-normalizer==3.3.2 +click==8.1.7 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 +coreapi==2.3.3 +coreschema==0.0.4 +cron-descriptor==1.4.3 +decorator==5.1.1 +Django==5.0.4 +django-celery-beat==2.6.0 +django-cors-headers==4.3.1 +django-cte==1.3.2 +django-filter==24.2 +django-polymorphic==3.1.0 +django-rest-framework==0.1.0 +django-storages==1.14.2 +django-timezone-field==6.1.0 +djangorestframework==3.15.1 +drf-spectacular==0.27.2 +drf-spectacular-sidecar==2024.4.1 +executing==2.0.1 +gunicorn==22.0.0 +idna==3.7 +inflection==0.3.1 +iniconfig==2.0.0 +ipython==8.24.0 +itypes==1.2.0 +jedi==0.19.1 +Jinja2==3.1.3 +jmespath==1.0.1 +jsonschema==4.21.1 +jsonschema-specifications==2023.12.1 +kombu==5.3.6 +lazy-object-proxy==1.10.0 +MarkupSafe==2.1.5 +matplotlib-inline==0.1.7 +openapi-codec==1.3.2 +packaging==24.0 +parso==0.8.4 +pexpect==4.9.0 +pika==1.3.2 +pluggy==1.4.0 +prompt-toolkit==3.0.43 +psycopg2==2.9.9 +ptyprocess==0.7.0 +pure-eval==0.2.2 +pycparser==2.22 +Pygments==2.17.2 +pytest==7.4.4 +pytest-assert-utils==0.3.1 +pytest-common-subject==1.0.6 +pytest-django==4.8.0 +pytest-drf==1.1.3 +pytest-fixture-order==0.1.4 +pytest-lambda==1.3.0 +python-crontab==3.0.0 +python-dateutil==2.9.0.post0 +PyYAML==6.0.1 +referencing==0.34.0 +requests==2.31.0 +rpds-py==0.18.0 +s3transfer==0.10.1 +simplejson==3.19.2 +six==1.16.0 +sqlparse==0.4.4 +stack-data==0.6.3 +traitlets==5.14.3 +typing_extensions==4.11.0 +tzdata==2024.1 +uritemplate==4.1.1 +urllib3==2.2.1 +vine==5.1.0 +wcwidth==0.2.13 +wrapt==1.16.0 diff --git a/backend/scripts/entrypoint.sh b/backend/scripts/entrypoint.sh new file mode 100644 index 0000000..117bb25 --- /dev/null +++ b/backend/scripts/entrypoint.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -o errexit +set -o pipefail +cmd="$@" + +function postgres_ready(){ +python << END +import sys +import os +import psycopg2 + +try: + dbname = os.getenv('POSTGRES_DB') + user = os.getenv('POSTGRES_USER') + password = os.getenv('POSTGRES_PASSWORD') + host = os.getenv('DB_HOST', 'postgres') + port = os.getenv('POSTGRES_PORT', '5432') + conn = psycopg2.connect(dbname=dbname, user=user, password=password, host=host, port=port) +except psycopg2.OperationalError: + sys.exit(-1) +sys.exit(0) +END +} + +until postgres_ready; do + >&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)