Add backend code
This commit is contained in:
parent
ed66b2a3d8
commit
4a18a785e9
22
backend/Dockerfile
Normal file
22
backend/Dockerfile
Normal file
|
@ -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
|
0
backend/auction/__init__.py
Normal file
0
backend/auction/__init__.py
Normal file
147
backend/auction/admin.py
Normal file
147
backend/auction/admin.py
Normal file
|
@ -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'<a href="{url}">{obj.product.name} ({obj.product_id})</a>')
|
||||
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'<a href="{url}"> {count} users </a>')
|
||||
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'<a href="{url}"> {count} bets </a>')
|
||||
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'<a href="{url}"> {count} победителей </a>')
|
||||
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'<a href="{url}"> {count} auctions </a>')
|
||||
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'<a href="{url}">{obj.user}</a>')
|
||||
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'<a href="{url}">{obj.auction}</a>')
|
||||
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'<a href="{url}"> {count} transactions </a>')
|
||||
view_transactions_link.short_description = 'Транзакции'
|
6
backend/auction/apps.py
Normal file
6
backend/auction/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuctionConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "auction"
|
2
backend/auction/filters/__init__.py
Normal file
2
backend/auction/filters/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
from .auction import AuctionFilter
|
||||
from .bet import BetFilter
|
24
backend/auction/filters/auction.py
Normal file
24
backend/auction/filters/auction.py
Normal file
|
@ -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')
|
29
backend/auction/filters/bet.py
Normal file
29
backend/auction/filters/bet.py
Normal file
|
@ -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')
|
57
backend/auction/migrations/0001_initial.py
Normal file
57
backend/auction/migrations/0001_initial.py
Normal file
|
@ -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': 'Ставки',
|
||||
},
|
||||
),
|
||||
]
|
0
backend/auction/migrations/__init__.py
Normal file
0
backend/auction/migrations/__init__.py
Normal file
3
backend/auction/models/__init__.py
Normal file
3
backend/auction/models/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from .auction import Auction
|
||||
from .bet import Bet
|
||||
from .product import Product
|
66
backend/auction/models/auction.py
Normal file
66
backend/auction/models/auction.py
Normal file
|
@ -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
|
||||
|
||||
|
55
backend/auction/models/bet.py
Normal file
55
backend/auction/models/bet.py
Normal file
|
@ -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)
|
14
backend/auction/models/product.py
Normal file
14
backend/auction/models/product.py
Normal file
|
@ -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})'
|
3
backend/auction/serializers/__init__.py
Normal file
3
backend/auction/serializers/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from .auction import AuctionSerializer
|
||||
from .bet import BetSerializer
|
||||
from .product import ProductSerializer
|
13
backend/auction/serializers/auction.py
Normal file
13
backend/auction/serializers/auction.py
Normal file
|
@ -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')
|
12
backend/auction/serializers/bet.py
Normal file
12
backend/auction/serializers/bet.py
Normal file
|
@ -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__'
|
8
backend/auction/serializers/product.py
Normal file
8
backend/auction/serializers/product.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from rest_framework.serializers import ModelSerializer
|
||||
from auction.models import Product
|
||||
|
||||
|
||||
class ProductSerializer(ModelSerializer):
|
||||
class Meta:
|
||||
model = Product
|
||||
fields = '__all__'
|
3
backend/auction/tests.py
Normal file
3
backend/auction/tests.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
10
backend/auction/urls.py
Normal file
10
backend/auction/urls.py
Normal file
|
@ -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/<int:pk>/place-bet/', place_bet, name='place-bet'),
|
||||
]
|
3
backend/auction/views/__init__.py
Normal file
3
backend/auction/views/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from .auction import AuctionList
|
||||
from .bet import BetList
|
||||
from .place_bet import place_bet
|
13
backend/auction/views/auction.py
Normal file
13
backend/auction/views/auction.py
Normal file
|
@ -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
|
||||
|
12
backend/auction/views/bet.py
Normal file
12
backend/auction/views/bet.py
Normal file
|
@ -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
|
50
backend/auction/views/place_bet.py
Normal file
50
backend/auction/views/place_bet.py
Normal file
|
@ -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})
|
0
backend/clicker/__init__.py
Normal file
0
backend/clicker/__init__.py
Normal file
16
backend/clicker/asgi.py
Normal file
16
backend/clicker/asgi.py
Normal file
|
@ -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()
|
9
backend/clicker/celery.py
Normal file
9
backend/clicker/celery.py
Normal file
|
@ -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()
|
210
backend/clicker/settings.py
Normal file
210
backend/clicker/settings.py
Normal file
|
@ -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
|
8
backend/clicker/storage_backends.py
Normal file
8
backend/clicker/storage_backends.py
Normal file
|
@ -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
|
19
backend/clicker/urls.py
Normal file
19
backend/clicker/urls.py
Normal file
|
@ -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)
|
16
backend/clicker/wsgi.py
Normal file
16
backend/clicker/wsgi.py
Normal file
|
@ -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()
|
0
backend/clicks/__init__.py
Normal file
0
backend/clicks/__init__.py
Normal file
37
backend/clicks/admin.py
Normal file
37
backend/clicks/admin.py
Normal file
|
@ -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'<a href="{url}">{obj.user}</a>')
|
||||
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'<a href="{url}">{obj.transaction}</a>')
|
||||
view_transaction_link.short_description = 'Транзакция'
|
9
backend/clicks/apps.py
Normal file
9
backend/clicks/apps.py
Normal file
|
@ -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
|
1
backend/clicks/celery/__init__.py
Normal file
1
backend/clicks/celery/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .click import handle_click
|
27
backend/clicks/celery/click.py
Normal file
27
backend/clicks/celery/click.py
Normal file
|
@ -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
|
||||
)
|
26
backend/clicks/migrations/0001_initial.py
Normal file
26
backend/clicks/migrations/0001_initial.py
Normal file
|
@ -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': 'Клики',
|
||||
},
|
||||
),
|
||||
]
|
0
backend/clicks/migrations/__init__.py
Normal file
0
backend/clicks/migrations/__init__.py
Normal file
1
backend/clicks/models/__init__.py
Normal file
1
backend/clicks/models/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .click import Click
|
15
backend/clicks/models/click.py
Normal file
15
backend/clicks/models/click.py
Normal file
|
@ -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})'
|
3
backend/clicks/tests.py
Normal file
3
backend/clicks/tests.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
3
backend/clicks/views.py
Normal file
3
backend/clicks/views.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
22
backend/manage.py
Executable file
22
backend/manage.py
Executable file
|
@ -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()
|
BIN
backend/media/products/1003345981.jpg
Normal file
BIN
backend/media/products/1003345981.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.7 KiB |
0
backend/misc/__init__.py
Normal file
0
backend/misc/__init__.py
Normal file
41
backend/misc/admin.py
Normal file
41
backend/misc/admin.py
Normal file
|
@ -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'
|
||||
]
|
14
backend/misc/apps.py
Normal file
14
backend/misc/apps.py
Normal file
|
@ -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)
|
1
backend/misc/celery/__init__.py
Normal file
1
backend/misc/celery/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .deliver_setting import deliver_setting
|
22
backend/misc/celery/deliver_setting.py
Normal file
22
backend/misc/celery/deliver_setting.py
Normal file
|
@ -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]
|
||||
)
|
||||
|
99
backend/misc/migrations/0001_initial.py
Normal file
99
backend/misc/migrations/0001_initial.py
Normal file
|
@ -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': 'Информация о получателях рассылки',
|
||||
},
|
||||
),
|
||||
]
|
0
backend/misc/migrations/__init__.py
Normal file
0
backend/misc/migrations/__init__.py
Normal file
5
backend/misc/models/__init__.py
Normal file
5
backend/misc/models/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from .button import Button
|
||||
from .banner import Banner
|
||||
from .popup import Popup
|
||||
from .setting import Setting
|
||||
from .style import Style
|
18
backend/misc/models/banner.py
Normal file
18
backend/misc/models/banner.py
Normal file
|
@ -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})'
|
||||
|
13
backend/misc/models/button.py
Normal file
13
backend/misc/models/button.py
Normal file
|
@ -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}'
|
32
backend/misc/models/popup.py
Normal file
32
backend/misc/models/popup.py
Normal file
|
@ -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}'
|
||||
|
20
backend/misc/models/setting.py
Normal file
20
backend/misc/models/setting.py
Normal file
|
@ -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})'
|
26
backend/misc/models/style.py
Normal file
26
backend/misc/models/style.py
Normal file
|
@ -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})'
|
1
backend/misc/signals/__init__.py
Normal file
1
backend/misc/signals/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .setting import deliver_setting
|
9
backend/misc/signals/setting.py
Normal file
9
backend/misc/signals/setting.py
Normal file
|
@ -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)
|
3
backend/misc/tests.py
Normal file
3
backend/misc/tests.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
3
backend/misc/views.py
Normal file
3
backend/misc/views.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
7
backend/pytest.ini
Normal file
7
backend/pytest.ini
Normal file
|
@ -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
|
87
backend/requirements.txt
Normal file
87
backend/requirements.txt
Normal file
|
@ -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
|
32
backend/scripts/entrypoint.sh
Normal file
32
backend/scripts/entrypoint.sh
Normal file
|
@ -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
|
9
backend/scripts/gunicorn.sh
Normal file
9
backend/scripts/gunicorn.sh
Normal file
|
@ -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 -
|
10
backend/scripts/start.sh
Normal file
10
backend/scripts/start.sh
Normal file
|
@ -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
|
7
backend/scripts/start_celery.sh
Normal file
7
backend/scripts/start_celery.sh
Normal file
|
@ -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
|
||||
|
0
backend/users/__init__.py
Normal file
0
backend/users/__init__.py
Normal file
409
backend/users/admin.py
Normal file
409
backend/users/admin.py
Normal file
|
@ -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'<a href="{url}">{obj.user.username} ({obj.user_id})</a>')
|
||||
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'<a href="{url}">{obj.referred_by.username} ({obj.referred_by.tg_id})</a>')
|
||||
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'<a href="{url}"> {count} users </a>')
|
||||
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'<a href="{all_url}"> все </a> // '
|
||||
f'<a href="{click_url}"> клики </a> // '
|
||||
f'<a href="{bet_url}"> ставки </a> // '
|
||||
f'<a href="{commission_url}"> комиссии </a> // '
|
||||
f'<a href="{referral_url}"> реферальная программа </a>'
|
||||
)
|
||||
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'<a href="{url}"> {count} clicks </a>')
|
||||
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'<a href="{link}">{obj.user}</a>')
|
||||
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'<a href="{link}">{obj.mailing_list}</a>')
|
||||
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'<a href="{url}"> {count} пользователей </a>')
|
||||
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'<a href="{url}"> {count} получателей </a>')
|
||||
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'<a href="{url}"> Кнопка №{obj.main_button_id}</a>')
|
||||
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'<a href="{url}"> Кнопка №{obj.webapp_button_id}</a>')
|
||||
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'<a href="{url}">{obj.user}</a>')
|
||||
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'<a href="{link}"> {obj.click}</a>')
|
||||
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'<a href="{link}">{obj.bet}</a>')
|
||||
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'<a href="{link}"> {obj.commission} </a>')
|
||||
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'<a href="{link}"> {obj.refunded_by} </a>')
|
||||
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'<a href="{link}"> {obj.refund_to} </a>')
|
||||
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'<a href="{link}"> {obj.parent_transaction} </a>')
|
||||
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'<a href="{url}">{obj.user}</a>')
|
||||
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
|
13
backend/users/apps.py
Normal file
13
backend/users/apps.py
Normal file
|
@ -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
|
86
backend/users/authentication.py
Normal file
86
backend/users/authentication.py
Normal file
|
@ -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
|
1
backend/users/celery/__init__.py
Normal file
1
backend/users/celery/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .mailing_list import check_mailing_lists, handle_mailing_list
|
53
backend/users/celery/mailing_list.py
Normal file
53
backend/users/celery/mailing_list.py
Normal file
|
@ -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
|
||||
)
|
1
backend/users/choices/__init__.py
Normal file
1
backend/users/choices/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .mailing_list_status import MailingListStatus
|
10
backend/users/choices/mailing_list_status.py
Normal file
10
backend/users/choices/mailing_list_status.py
Normal file
|
@ -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', _('Завершена')
|
1
backend/users/errors/__init__.py
Normal file
1
backend/users/errors/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .not_enough_funds import NotEnoughFundsError
|
7
backend/users/errors/not_enough_funds.py
Normal file
7
backend/users/errors/not_enough_funds.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
from rest_framework.exceptions import APIException
|
||||
|
||||
|
||||
class NotEnoughFundsError(APIException):
|
||||
status_code = 400
|
||||
default_detail = 'Невозможно выполнить операцию, недостаточно баллов'
|
||||
default_code = 'bad_request'
|
139
backend/users/migrations/0001_initial.py
Normal file
139
backend/users/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,139 @@
|
|||
# Generated by Django 5.0.4 on 2024-04-26 08:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Transaction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время')),
|
||||
('value', models.DecimalField(decimal_places=5, max_digits=105, verbose_name='Значение')),
|
||||
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Транзакция',
|
||||
'verbose_name_plural': 'Транзакции',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TGUser',
|
||||
fields=[
|
||||
('tg_id', models.PositiveBigIntegerField(primary_key=True, serialize=False, verbose_name='Telegram ID')),
|
||||
('username', models.CharField(max_length=250, verbose_name='Telegram username')),
|
||||
('avatar', models.FileField(blank=True, null=True, upload_to='users/', verbose_name='Аватарка')),
|
||||
('points', models.DecimalField(decimal_places=2, default=0, max_digits=102, verbose_name='Баллы')),
|
||||
('referral_storage', models.DecimalField(decimal_places=5, default=0, max_digits=102, verbose_name='Реферальное хранилище')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время создания')),
|
||||
('referred_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='referrees', to='users.tguser', verbose_name='Кем был приглашен')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='tg_user', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'ТГ-пользователь',
|
||||
'verbose_name_plural': 'ТГ-пользователи',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReferralTransaction',
|
||||
fields=[
|
||||
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='users.transaction')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Реферальная транзакция',
|
||||
'verbose_name_plural': 'Реферальные транзакции',
|
||||
},
|
||||
bases=('users.transaction',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MailingList',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=250, verbose_name='Название')),
|
||||
('time', models.DateTimeField(verbose_name='Дата и время публикации')),
|
||||
('text', models.TextField(verbose_name='Текст публикации')),
|
||||
('media', models.FileField(upload_to='mailing/', verbose_name='Вложение')),
|
||||
('status', models.CharField(choices=[('1', 'Ожидание'), ('2', 'В очереди'), ('3', 'В обработке'), ('4', 'Окончена с ошибками'), ('5', 'Завершена')], default='1', max_length=1, verbose_name='Статус')),
|
||||
('main_button', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='mailing_lists_for_main_button', to='misc.button', verbose_name='Кнопка')),
|
||||
('webapp_button', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='mailing_lists_for_webapp_button', to='misc.button', verbose_name='Кнопка с веб-аппом')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Рассылка',
|
||||
'verbose_name_plural': 'Рассылки',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='user',
|
||||
field=models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name='transactions', to='users.tguser', verbose_name='Пользователь'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MailingListReceiverInfo',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('sent', models.BooleanField(default=False, verbose_name='Отправлена ли')),
|
||||
('clicked', models.BooleanField(default=False, verbose_name='Нажата ли')),
|
||||
('mailing_list', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mailing_list_receiver_infos', to='users.mailinglist', verbose_name='Рассылка')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mailing_list_receiver_infos', to='users.tguser', verbose_name='Пользователь')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Информация о получателе рассылки',
|
||||
'verbose_name_plural': 'Информация о получателях рассылки',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='mailinglist',
|
||||
name='users',
|
||||
field=models.ManyToManyField(related_name='mailing_lists', through='users.MailingListReceiverInfo', to='users.tguser', verbose_name='Пользователи'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BetTransaction',
|
||||
fields=[
|
||||
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='users.transaction')),
|
||||
('bet', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='transaction', to='auction.bet', verbose_name='Ставка')),
|
||||
('refund_to', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='refunded_by', to='users.bettransaction', verbose_name='Какую транзакцию отменяет')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Транзакция за ставку',
|
||||
'verbose_name_plural': 'Транзакции за ставки',
|
||||
},
|
||||
bases=('users.transaction',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ClickTransaction',
|
||||
fields=[
|
||||
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='users.transaction')),
|
||||
('click', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='transaction', to='clicks.click', verbose_name='Клик')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Транзакция за клик',
|
||||
'verbose_name_plural': 'Транзакции за клики',
|
||||
},
|
||||
bases=('users.transaction',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CommissionTransaction',
|
||||
fields=[
|
||||
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='users.transaction')),
|
||||
('parent_transaction', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='commission', to='users.bettransaction', verbose_name='Родительская транзакция')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Комиссионная транзакция',
|
||||
'verbose_name_plural': 'Комиссионные транзакции',
|
||||
},
|
||||
bases=('users.transaction',),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='tguser',
|
||||
constraint=models.UniqueConstraint(fields=('tg_id',), name='unique_tg_id'),
|
||||
),
|
||||
]
|
0
backend/users/migrations/__init__.py
Normal file
0
backend/users/migrations/__init__.py
Normal file
3
backend/users/models/__init__.py
Normal file
3
backend/users/models/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from .mailing_list import MailingList, MailingListReceiverInfo
|
||||
from .tg_user import TGUser
|
||||
from .transactions import Transaction, BetTransaction, ClickTransaction, ReferralTransaction, CommissionTransaction
|
42
backend/users/models/mailing_list.py
Normal file
42
backend/users/models/mailing_list.py
Normal file
|
@ -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}'
|
||||
|
43
backend/users/models/tg_user.py
Normal file
43
backend/users/models/tg_user.py
Normal file
|
@ -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})'
|
||||
|
64
backend/users/models/transactions.py
Normal file
64
backend/users/models/transactions.py
Normal file
|
@ -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}'
|
||||
|
6
backend/users/permissions.py
Normal file
6
backend/users/permissions.py
Normal file
|
@ -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
|
1
backend/users/serializers/__init__.py
Normal file
1
backend/users/serializers/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .tg_user import TGUserSerializer
|
12
backend/users/serializers/tg_user.py
Normal file
12
backend/users/serializers/tg_user.py
Normal file
|
@ -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',)
|
7
backend/users/signals/__init__.py
Normal file
7
backend/users/signals/__init__.py
Normal file
|
@ -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
|
12
backend/users/signals/mailing_list_signal.py
Normal file
12
backend/users/signals/mailing_list_signal.py
Normal file
|
@ -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())
|
42
backend/users/signals/transactions.py
Normal file
42
backend/users/signals/transactions.py
Normal file
|
@ -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
|
26
backend/users/signals/users.py
Normal file
26
backend/users/signals/users.py
Normal file
|
@ -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()
|
||||
|
0
backend/users/tests/__init__.py
Normal file
0
backend/users/tests/__init__.py
Normal file
191
backend/users/tests/test_ranking.py
Normal file
191
backend/users/tests/test_ranking.py
Normal file
|
@ -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
|
103
backend/users/tests/test_tg_user.py
Normal file
103
backend/users/tests/test_tg_user.py
Normal file
|
@ -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)
|
0
backend/users/urls/__init__.py
Normal file
0
backend/users/urls/__init__.py
Normal file
7
backend/users/urls/internal_urls.py
Normal file
7
backend/users/urls/internal_urls.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
from django.urls import path
|
||||
from users.views import get_token, check_registration
|
||||
|
||||
urlpatterns = [
|
||||
path('get-token/<int:pk>', get_token),
|
||||
path('check/<int:pk>', check_registration),
|
||||
]
|
17
backend/users/urls/urls.py
Normal file
17
backend/users/urls/urls.py
Normal file
|
@ -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'),
|
||||
]
|
3
backend/users/views.py
Normal file
3
backend/users/views.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
6
backend/users/views/__init__.py
Normal file
6
backend/users/views/__init__.py
Normal file
|
@ -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
|
11
backend/users/views/check_registration.py
Normal file
11
backend/users/views/check_registration.py
Normal file
|
@ -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)
|
21
backend/users/views/empty_storage.py
Normal file
21
backend/users/views/empty_storage.py
Normal file
|
@ -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)})
|
42
backend/users/views/get_token.py
Normal file
42
backend/users/views/get_token.py
Normal file
|
@ -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'))})
|
||||
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user