diff --git a/.gitignore b/.gitignore
index 4a974b4..7617c11 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
.idea
.env
.DS_Store
+__pycache__/
+*.py[cod]
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/0002_initial.py b/backend/auction/migrations/0002_initial.py
new file mode 100644
index 0000000..b094fb8
--- /dev/null
+++ b/backend/auction/migrations/0002_initial.py
@@ -0,0 +1,32 @@
+# Generated by Django 5.0.4 on 2024-04-26 08:14
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('auction', '0001_initial'),
+ ('users', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='bet',
+ name='user',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bets', to='users.tguser', verbose_name='Пользователь'),
+ ),
+ migrations.AddField(
+ model_name='auction',
+ name='betters',
+ field=models.ManyToManyField(related_name='auctions', through='auction.Bet', to='users.tguser', verbose_name='Поставившие ставку'),
+ ),
+ migrations.AddField(
+ model_name='auction',
+ name='product',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auctions', to='auction.product', verbose_name='Товар'),
+ ),
+ ]
diff --git a/backend/auction/migrations/0003_user_penalties_and_auction_fixes.py b/backend/auction/migrations/0003_user_penalties_and_auction_fixes.py
new file mode 100644
index 0000000..7364669
--- /dev/null
+++ b/backend/auction/migrations/0003_user_penalties_and_auction_fixes.py
@@ -0,0 +1,27 @@
+# Generated by Django 5.0.4 on 2024-05-04 16:38
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('auction', '0002_initial'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='auction',
+ name='current_end_time',
+ ),
+ migrations.AddField(
+ model_name='auction',
+ name='times_postponed',
+ field=models.IntegerField(default=0, verbose_name='Количество переносов'),
+ ),
+ migrations.AlterField(
+ model_name='auction',
+ name='initial_end_time',
+ field=models.DateTimeField(verbose_name='Изначальная дата окончания'),
+ ),
+ ]
diff --git a/backend/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/0002_initial.py b/backend/clicks/migrations/0002_initial.py
new file mode 100644
index 0000000..a823675
--- /dev/null
+++ b/backend/clicks/migrations/0002_initial.py
@@ -0,0 +1,22 @@
+# Generated by Django 5.0.4 on 2024-04-26 08:14
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('clicks', '0001_initial'),
+ ('users', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='click',
+ name='user',
+ field=models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name='clicks', to='users.tguser', verbose_name='Пользователь'),
+ ),
+ ]
diff --git a/backend/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/0002_initial.py b/backend/misc/migrations/0002_initial.py
new file mode 100644
index 0000000..5d1ad55
--- /dev/null
+++ b/backend/misc/migrations/0002_initial.py
@@ -0,0 +1,27 @@
+# Generated by Django 5.0.4 on 2024-04-26 08:14
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('misc', '0001_initial'),
+ ('users', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='popupreceiverinfo',
+ name='user',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='popup_receiver_infos', to='users.tguser', verbose_name='Пользователь'),
+ ),
+ migrations.AddField(
+ model_name='popup',
+ name='users',
+ field=models.ManyToManyField(related_name='popups', through='misc.PopupReceiverInfo', to='users.tguser', verbose_name='Пользователи'),
+ ),
+ ]
diff --git a/backend/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..453d52d
--- /dev/null
+++ b/backend/users/migrations/0001_initial.py
@@ -0,0 +1,144 @@
+# 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 = [
+ ('auction', '0001_initial'),
+ ('clicks', '0001_initial'),
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('misc', '0001_initial'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ 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/0002_user_penalties_and_auction_fixes.py b/backend/users/migrations/0002_user_penalties_and_auction_fixes.py
new file mode 100644
index 0000000..3a6e883
--- /dev/null
+++ b/backend/users/migrations/0002_user_penalties_and_auction_fixes.py
@@ -0,0 +1,30 @@
+# Generated by Django 5.0.4 on 2024-05-04 16:38
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('auction', '0003_user_penalties_and_auction_fixes'),
+ ('users', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='tguser',
+ name='is_blocked',
+ field=models.BooleanField(default=False, verbose_name='Заблокирован ли'),
+ ),
+ migrations.AddField(
+ model_name='tguser',
+ name='warning_count',
+ field=models.IntegerField(default=0, verbose_name='Количество предупреждений'),
+ ),
+ migrations.AlterField(
+ model_name='bettransaction',
+ name='bet',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='auction.bet', verbose_name='Ставка'),
+ ),
+ ]
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)