Compare commits
1 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
44e2d34be8 |
|
@ -1,4 +0,0 @@
|
||||||
DEBUG=1
|
|
||||||
SECRET_KEY=SECRET_KEY
|
|
||||||
ENVIRONMENT=docker
|
|
||||||
DJANGO_SETTINGS_MODULE=clicker.settings
|
|
|
@ -1 +0,0 @@
|
||||||
HTTP_PORT=8080
|
|
|
@ -1,8 +0,0 @@
|
||||||
POSTGRES_PASSWORD=postgres
|
|
||||||
POSTGRES_USER=postgres
|
|
||||||
POSTGRES_DB=postgres
|
|
||||||
POSTGRES_HOST=batcher-postgres
|
|
||||||
POSTGRES_PORT=5432
|
|
||||||
LANG=ru_RU.UTF-8
|
|
||||||
LANGUAGE=ru_RU.UTF-8
|
|
||||||
LC_ALL=ru_RU.UTF-8
|
|
|
@ -1,3 +0,0 @@
|
||||||
TG_TOKEN=token
|
|
||||||
BACKEND_URL=http://backend:8000
|
|
||||||
PROD=0
|
|
|
@ -1,8 +0,0 @@
|
||||||
POSTGRES_PASSWORD=postgres
|
|
||||||
POSTGRES_USER=postgres
|
|
||||||
POSTGRES_DB=postgres
|
|
||||||
POSTGRES_HOST=postgres
|
|
||||||
POSTGRES_PORT=5432
|
|
||||||
LANG=ru_RU.UTF-8
|
|
||||||
LANGUAGE=ru_RU.UTF-8
|
|
||||||
LC_ALL=ru_RU.UTF-8
|
|
|
@ -1,5 +0,0 @@
|
||||||
REDIS_HOST=redis
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_USER=default
|
|
||||||
REDIS_PASSWORD=redis
|
|
||||||
REDIS_DB=0
|
|
|
@ -1,4 +0,0 @@
|
||||||
RABBITMQ_HOST=rabbitmq
|
|
||||||
RABBITMQ_PORT=5672
|
|
||||||
RABBITMQ_DEFAULT_USER=rabbitmq
|
|
||||||
RABBITMQ_DEFAULT_PASS=rabbitmq
|
|
|
@ -1 +0,0 @@
|
||||||
APP_URL=https://kyc-game.ru
|
|
|
@ -1,61 +0,0 @@
|
||||||
name: CI/CD Pipeline
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [dev]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint-sast:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: '3.12'
|
|
||||||
|
|
||||||
- name: Run linter (flake8)
|
|
||||||
run: |
|
|
||||||
python -m venv venv
|
|
||||||
source venv/bin/activate
|
|
||||||
pip install flake8
|
|
||||||
flake8 . --ignore=E501,F401 --exclude=venv
|
|
||||||
continue-on-error: true
|
|
||||||
- name: Run Semgrep (SAST)
|
|
||||||
run: |
|
|
||||||
pip install semgrep
|
|
||||||
semgrep --config p/python --jobs 1 --max-memory 4000
|
|
||||||
deploy:
|
|
||||||
needs: lint-sast
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Set up SSH
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
|
|
||||||
chmod 600 ~/.ssh/id_rsa
|
|
||||||
ssh-keyscan -H ${{ secrets.SERVER_HOST }} >> ~/.ssh/known_hosts
|
|
||||||
|
|
||||||
- name: Copy app to server
|
|
||||||
run: |
|
|
||||||
TARGET=${{ secrets.TEST_SERVER }}
|
|
||||||
if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
|
|
||||||
TARGET=${{ secrets.PROD_SERVER }}
|
|
||||||
fi
|
|
||||||
scp -r . "$TARGET:~/app"
|
|
||||||
|
|
||||||
- name: Deploy app remotely
|
|
||||||
run: |
|
|
||||||
TARGET=${{ secrets.TEST_SERVER }}
|
|
||||||
if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
|
|
||||||
TARGET=${{ secrets.PROD_SERVER }}
|
|
||||||
fi
|
|
||||||
ssh "$TARGET" 'cd ~/app && bash deploy.sh'
|
|
171
.gitignore
vendored
171
.gitignore
vendored
|
@ -1,10 +1,169 @@
|
||||||
.idea
|
|
||||||
.env
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
#uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
celerybeat-schedule
|
celerybeat-schedule
|
||||||
backend/static
|
celerybeat.pid
|
||||||
backend/media
|
|
||||||
bot/logs.log
|
# SageMath parsed files
|
||||||
bot/logfile.log
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
60
README.md
60
README.md
|
@ -17,11 +17,11 @@ This clicker bot combines fun and strategy, making it a unique experience for Te
|
||||||
|
|
||||||
Our project leverages a modern technology stack to ensure scalability, performance, and seamless user interactions:
|
Our project leverages a modern technology stack to ensure scalability, performance, and seamless user interactions:
|
||||||
|
|
||||||
- **Frontend**: [React, Typescript]
|
- **Frontend**: [React.js, Vue.js, etc.]
|
||||||
- **Backend**: [Django, Flask, etc.]
|
- **Backend**: [Django, Flask, etc.]
|
||||||
- **Database**: [PostgreSQL]
|
- **Database**: [PostgreSQL, MongoDB, MySQL, etc.]
|
||||||
- **Authentication**: [JWT, OAuth, etc.]
|
- **Authentication**: [JWT, OAuth, etc.]
|
||||||
- **Deployment**: [Docker]
|
- **Deployment**: [Docker, Kubernetes, AWS, etc.]
|
||||||
|
|
||||||
We have structured our architecture for flexibility and ease of integration with future features.
|
We have structured our architecture for flexibility and ease of integration with future features.
|
||||||
|
|
||||||
|
@ -29,23 +29,15 @@ We have structured our architecture for flexibility and ease of integration with
|
||||||
|
|
||||||
The project is a collaboration of skilled and passionate individuals:
|
The project is a collaboration of skilled and passionate individuals:
|
||||||
|
|
||||||
2. **Michael Kostochka**: [Role: Backend Developer, Database Architect]
|
2. **Michael Kostochka**: [Role: Backend Developer] - Ensures smooth operations behind the scenes with efficient APIs.
|
||||||
3. **Sitnikov Arseniy**: [Role: Frontend Developer, Database Architect]
|
3. **Sitnikov Arseniy**: [Role: Database Architect] - Responsible for managing data flows and ensuring high performance.
|
||||||
4. **Vakulenkov Danya**: [Role: Backend Engineer, Database Architect]
|
4. **Vakulenkov Danila**: [Role: DevOps Engineer] - Orchestrates deployment, monitoring, and infrastructure management.
|
||||||
|
|
||||||
## 🎨 **Visuals**
|
## 🎨 **Visuals**
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
## **🏛️ Architecture**
|
|
||||||

|
|
||||||
|
|
||||||
## 📊 **DataBase Structure**
|
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
## 🚀 **Getting Started**
|
## 🚀 **Getting Started**
|
||||||
|
|
||||||
Follow these steps to set up and run the project locally:
|
Follow these steps to set up and run the project locally:
|
||||||
|
@ -57,23 +49,51 @@ git clone https://github.com/Danya-Djan/db_kyc_project.git
|
||||||
cd db_kyc_project
|
cd db_kyc_project
|
||||||
```
|
```
|
||||||
|
|
||||||
### **2. Environment Setup**
|
### **2. Install Dependencies**
|
||||||
|
|
||||||
Create a `.env` file in the root of the project and add the needed variables
|
Ensure you have [Node.js](https://nodejs.org/) and [Docker](https://www.docker.com/) installed.
|
||||||
The exmaple can be viewed at [./env.example/](https://github.com/Danya-Djan/db_kyc_project/tree/dev/.env.example/dev)
|
|
||||||
|
|
||||||
### **3. Run the Project**
|
For **Frontend**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
For **Backend**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### **3. Environment Setup**
|
||||||
|
|
||||||
|
Create a `.env` file in the root of the project and add the following variables:
|
||||||
|
|
||||||
|
```
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_USER=[your-db-user]
|
||||||
|
DB_PASS=[your-db-password]
|
||||||
|
JWT_SECRET=[your-secret]
|
||||||
|
```
|
||||||
|
|
||||||
|
### **4. Run the Project**
|
||||||
|
|
||||||
To start both frontend and backend servers in development mode:
|
To start both frontend and backend servers in development mode:
|
||||||
|
|
||||||
For **Frontend and Backend**:
|
For **Frontend and Backend**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up --build
|
docker-compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
|
You can access the application at `http://localhost:3000`.
|
||||||
|
|
||||||
|
## 🤝 **Contributing**
|
||||||
|
|
||||||
|
We welcome contributions! Please read our [CONTRIBUTING.md](link_to_contributing_file) for details on our code of conduct and the process for submitting pull requests.
|
||||||
|
|
||||||
## 📜 **License**
|
## 📜 **License**
|
||||||
|
|
||||||
This project is licensed under the [MIPT License](https://github.com/MIPT-ILab/MDSP/blob/master/LICENSE).
|
This project is licensed under the [MIPT License](link_to_license_file).
|
||||||
|
|
||||||
---
|
---
|
BIN
architecture.png
Normal file
BIN
architecture.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 35 KiB |
|
@ -1,7 +0,0 @@
|
||||||
FROM python:3.11
|
|
||||||
|
|
||||||
COPY . /app
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
|
@ -1,147 +0,0 @@
|
||||||
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('<a href="{}">{} ({})</a>', url, obj.product.name, obj.product_id)
|
|
||||||
view_product_link.short_description = 'Товар'
|
|
||||||
|
|
||||||
def view_betters_link(self, obj):
|
|
||||||
count = obj.betters.distinct().count()
|
|
||||||
url = reverse('admin:users_tguser_changelist') + '?' + urlencode({'betters__pk': f'{obj.pk}'})
|
|
||||||
return format_html('<a href="{}"> {} users </a>', url, count)
|
|
||||||
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('<a href="{}"> {} bets </a>', url, count)
|
|
||||||
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('<a href="{}"> {} победителей </a>', url, count)
|
|
||||||
view_winners_link.short_description = 'Победители'
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Product)
|
|
||||||
class ProductAdmin(admin.ModelAdmin):
|
|
||||||
list_display = [
|
|
||||||
'id',
|
|
||||||
'view_auctions_link',
|
|
||||||
'name',
|
|
||||||
'cover',
|
|
||||||
'description',
|
|
||||||
]
|
|
||||||
list_display_links = [
|
|
||||||
'id'
|
|
||||||
]
|
|
||||||
search_fields = [
|
|
||||||
'name'
|
|
||||||
]
|
|
||||||
|
|
||||||
def view_auctions_link(self, obj):
|
|
||||||
count = obj.auctions.count()
|
|
||||||
url = reverse('admin:auction_auction_changelist') + '?' + urlencode({'product_id': f'{obj.pk}'})
|
|
||||||
return format_html('<a href="{}"> {} auctions </a>', url, count)
|
|
||||||
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('<a href="{}">{}</a>', url, obj.user)
|
|
||||||
view_user_link.short_description = 'Пользователь'
|
|
||||||
|
|
||||||
def view_auction_link(self, obj):
|
|
||||||
url = reverse("admin:auction_auction_change", args=[obj.auction_id])
|
|
||||||
return format_html('<a href="{}">{}</a>', url, obj.auction)
|
|
||||||
view_auction_link.short_description = 'Аукцион'
|
|
||||||
|
|
||||||
def view_transactions_link(self, obj):
|
|
||||||
count = obj.transactions.count()
|
|
||||||
url = reverse('admin:users_bettransaction_changelist') + '?' + urlencode({'bet_id': f'{obj.pk}'})
|
|
||||||
return format_html('<a href="{}"> {} transactions </a>', url, count)
|
|
||||||
view_transactions_link.short_description = 'Транзакции'
|
|
|
@ -1,6 +0,0 @@
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class AuctionConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "auction"
|
|
|
@ -1,2 +0,0 @@
|
||||||
from .auction import AuctionFilter
|
|
||||||
from .bet import BetFilter
|
|
|
@ -1,24 +0,0 @@
|
||||||
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')
|
|
|
@ -1,29 +0,0 @@
|
||||||
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')
|
|
|
@ -1,57 +0,0 @@
|
||||||
# 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': 'Ставки',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,32 +0,0 @@
|
||||||
# Generated by Django 5.0.4 on 2024-04-26 08:14
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('auction', '0001_initial'),
|
|
||||||
('users', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='bet',
|
|
||||||
name='user',
|
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bets', to='users.tguser', verbose_name='Пользователь'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='auction',
|
|
||||||
name='betters',
|
|
||||||
field=models.ManyToManyField(related_name='auctions', through='auction.Bet', to='users.tguser', verbose_name='Поставившие ставку'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='auction',
|
|
||||||
name='product',
|
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auctions', to='auction.product', verbose_name='Товар'),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,27 +0,0 @@
|
||||||
# Generated by Django 5.0.4 on 2024-05-04 16:38
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('auction', '0002_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='auction',
|
|
||||||
name='current_end_time',
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='auction',
|
|
||||||
name='times_postponed',
|
|
||||||
field=models.IntegerField(default=0, verbose_name='Количество переносов'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='auction',
|
|
||||||
name='initial_end_time',
|
|
||||||
field=models.DateTimeField(verbose_name='Изначальная дата окончания'),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,3 +0,0 @@
|
||||||
from .auction import Auction
|
|
||||||
from .bet import Bet
|
|
||||||
from .product import Product
|
|
|
@ -1,66 +0,0 @@
|
||||||
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
|
|
||||||
|
|
||||||
|
|
|
@ -1,55 +0,0 @@
|
||||||
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)
|
|
|
@ -1,14 +0,0 @@
|
||||||
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})'
|
|
|
@ -1,3 +0,0 @@
|
||||||
from .auction import AuctionSerializer
|
|
||||||
from .bet import BetSerializer
|
|
||||||
from .product import ProductSerializer
|
|
|
@ -1,13 +0,0 @@
|
||||||
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')
|
|
|
@ -1,12 +0,0 @@
|
||||||
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__'
|
|
|
@ -1,8 +0,0 @@
|
||||||
from rest_framework.serializers import ModelSerializer
|
|
||||||
from auction.models import Product
|
|
||||||
|
|
||||||
|
|
||||||
class ProductSerializer(ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = Product
|
|
||||||
fields = '__all__'
|
|
|
@ -1,3 +0,0 @@
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
|
@ -1,10 +0,0 @@
|
||||||
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'),
|
|
||||||
]
|
|
|
@ -1,3 +0,0 @@
|
||||||
from .auction import AuctionList
|
|
||||||
from .bet import BetList
|
|
||||||
from .place_bet import place_bet
|
|
|
@ -1,13 +0,0 @@
|
||||||
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
|
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
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
|
|
|
@ -1,50 +0,0 @@
|
||||||
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})
|
|
|
@ -1,16 +0,0 @@
|
||||||
"""
|
|
||||||
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()
|
|
|
@ -1,9 +0,0 @@
|
||||||
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()
|
|
|
@ -1,219 +0,0 @@
|
||||||
"""
|
|
||||||
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
|
|
||||||
import re
|
|
||||||
|
|
||||||
# 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 = ['backend', '127.0.0.1']
|
|
||||||
CSRF_TRUSTED_ORIGINS = []
|
|
||||||
if app_url := os.getenv('APP_URL', None):
|
|
||||||
CSRF_TRUSTED_ORIGINS.append(app_url)
|
|
||||||
url_re = re.compile(r"https?://(www\.)?")
|
|
||||||
app_url_strippped = url_re.sub('', app_url).strip().strip('/')
|
|
||||||
ALLOWED_HOSTS.append(app_url_strippped)
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
|
@ -1,8 +0,0 @@
|
||||||
from django.conf import settings
|
|
||||||
from storages.backends.s3boto3 import S3Boto3Storage
|
|
||||||
|
|
||||||
|
|
||||||
class PublicMediaStorage(S3Boto3Storage):
|
|
||||||
location = 'media'
|
|
||||||
default_acl = 'public-read'
|
|
||||||
file_overwrite = False
|
|
|
@ -1,19 +0,0 @@
|
||||||
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)
|
|
|
@ -1,16 +0,0 @@
|
||||||
"""
|
|
||||||
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()
|
|
|
@ -1,37 +0,0 @@
|
||||||
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('<a href="{}">{}</a>', url, obj.user)
|
|
||||||
view_user_link.short_description = 'Пользователь'
|
|
||||||
|
|
||||||
def view_transaction_link(self, obj):
|
|
||||||
url = reverse("admin:users_clicktransaction_change", args=[obj.transaction.id])
|
|
||||||
return format_html('<a href="{}">{}</a>', url, obj.transaction)
|
|
||||||
view_transaction_link.short_description = 'Транзакция'
|
|
|
@ -1,9 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
from .click import handle_click
|
|
|
@ -1,27 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
|
@ -1,26 +0,0 @@
|
||||||
# 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': 'Клики',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,22 +0,0 @@
|
||||||
# Generated by Django 5.0.4 on 2024-04-26 08:14
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('clicks', '0001_initial'),
|
|
||||||
('users', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='click',
|
|
||||||
name='user',
|
|
||||||
field=models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name='clicks', to='users.tguser', verbose_name='Пользователь'),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1 +0,0 @@
|
||||||
from .click import Click
|
|
|
@ -1,15 +0,0 @@
|
||||||
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})'
|
|
|
@ -1,3 +0,0 @@
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
|
@ -1,3 +0,0 @@
|
||||||
from django.shortcuts import render
|
|
||||||
|
|
||||||
# Create your views here.
|
|
|
@ -1,22 +0,0 @@
|
||||||
#!/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()
|
|
|
@ -1,41 +0,0 @@
|
||||||
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'
|
|
||||||
]
|
|
|
@ -1,10 +0,0 @@
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class MiscConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "misc"
|
|
||||||
|
|
||||||
def ready(self):
|
|
||||||
from .signals import deliver_setting
|
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
from .deliver_setting import deliver_setting
|
|
|
@ -1,20 +0,0 @@
|
||||||
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):
|
|
||||||
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, durable=True)
|
|
||||||
with Connection(dsn) as conn:
|
|
||||||
with conn.channel() as channel:
|
|
||||||
producer = Producer(channel)
|
|
||||||
producer.publish(
|
|
||||||
setting,
|
|
||||||
exchange='',
|
|
||||||
routing_key=settings.SETTINGS_QUEUE_NAME,
|
|
||||||
declare=[queue],
|
|
||||||
)
|
|
|
@ -1,90 +0,0 @@
|
||||||
[
|
|
||||||
{
|
|
||||||
"model": "misc.setting",
|
|
||||||
"pk": 1,
|
|
||||||
"fields": {
|
|
||||||
"name": "SESSION_ENERGY",
|
|
||||||
"description": "Энергия на сессию",
|
|
||||||
"value": {
|
|
||||||
"value": 300
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "misc.setting",
|
|
||||||
"pk": 2,
|
|
||||||
"fields": {
|
|
||||||
"name": "PRICE_PER_CLICK",
|
|
||||||
"description": "Награда за клик",
|
|
||||||
"value": {
|
|
||||||
"value": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "misc.setting",
|
|
||||||
"pk": 3,
|
|
||||||
"fields": {
|
|
||||||
"name": "DAY_MULT",
|
|
||||||
"description": "Дневной мультипликатор",
|
|
||||||
"value": {
|
|
||||||
"value": "1.5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "misc.setting",
|
|
||||||
"pk": 4,
|
|
||||||
"fields": {
|
|
||||||
"name": "WEEK_MULT",
|
|
||||||
"description": "Недельный мультипликатор",
|
|
||||||
"value": {
|
|
||||||
"value": "1.5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "misc.setting",
|
|
||||||
"pk": 5,
|
|
||||||
"fields": {
|
|
||||||
"name": "PROGRESS_MULT",
|
|
||||||
"description": "Мультипликатор прогресса",
|
|
||||||
"value": {
|
|
||||||
"value": "1.5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "misc.setting",
|
|
||||||
"pk": 6,
|
|
||||||
"fields": {
|
|
||||||
"name": "SESSION_COOLDOWN",
|
|
||||||
"description": "Кулдаун сессии",
|
|
||||||
"value": {
|
|
||||||
"value": 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "misc.setting",
|
|
||||||
"pk": 7,
|
|
||||||
"fields": {
|
|
||||||
"name": "DEFAULT_TOP_LIMIT",
|
|
||||||
"description": "Количество пользователей в топе",
|
|
||||||
"value": {
|
|
||||||
"value": 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "misc.setting",
|
|
||||||
"pk": 8,
|
|
||||||
"fields": {
|
|
||||||
"name": "DEFAULT_NEIGHBOUR_LIMIT",
|
|
||||||
"description": "Количество соседей",
|
|
||||||
"value": {
|
|
||||||
"value": 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
|
@ -1,10 +0,0 @@
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
|
||||||
from misc.celery import deliver_setting
|
|
||||||
from misc.models import Setting
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = 'Sends all settings to rmq for batcher to consume'
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
for setting in Setting.objects.all():
|
|
||||||
deliver_setting.delay({setting.name: setting.value['value']})
|
|
|
@ -1,99 +0,0 @@
|
||||||
# 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': 'Информация о получателях рассылки',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,27 +0,0 @@
|
||||||
# Generated by Django 5.0.4 on 2024-04-26 08:14
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('misc', '0001_initial'),
|
|
||||||
('users', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='popupreceiverinfo',
|
|
||||||
name='user',
|
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='popup_receiver_infos', to='users.tguser', verbose_name='Пользователь'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='popup',
|
|
||||||
name='users',
|
|
||||||
field=models.ManyToManyField(related_name='popups', through='misc.PopupReceiverInfo', to='users.tguser', verbose_name='Пользователи'),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,5 +0,0 @@
|
||||||
from .button import Button
|
|
||||||
from .banner import Banner
|
|
||||||
from .popup import Popup
|
|
||||||
from .setting import Setting
|
|
||||||
from .style import Style
|
|
|
@ -1,18 +0,0 @@
|
||||||
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})'
|
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
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}'
|
|
|
@ -1,32 +0,0 @@
|
||||||
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}'
|
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
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})'
|
|
|
@ -1,26 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
from .setting import deliver_setting
|
|
|
@ -1,9 +0,0 @@
|
||||||
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: instance.value['value']})
|
|
|
@ -1,3 +0,0 @@
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
|
@ -1,3 +0,0 @@
|
||||||
from django.shortcuts import render
|
|
||||||
|
|
||||||
# Create your views here.
|
|
|
@ -1,7 +0,0 @@
|
||||||
[pytest]
|
|
||||||
python_files = tests.py test_*.py *_tests.py
|
|
||||||
filterwarnings =
|
|
||||||
ignore::RuntimeWarning
|
|
||||||
env=
|
|
||||||
DJANGO_SETTINGS_MODULE=clicker.test_settings
|
|
||||||
addopts=--nomigrations
|
|
|
@ -1,87 +0,0 @@
|
||||||
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
|
|
|
@ -1,11 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -o errexit
|
|
||||||
set -o pipefail
|
|
||||||
set -o nounset
|
|
||||||
set -o xtrace
|
|
||||||
|
|
||||||
python manage.py migrate
|
|
||||||
python manage.py send_settings
|
|
||||||
python manage.py collectstatic --noinput --verbosity 0
|
|
||||||
python manage.py runserver 0.0.0.0:8000
|
|
|
@ -1,10 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -o errexit
|
|
||||||
set -o pipefail
|
|
||||||
set -o nounset
|
|
||||||
|
|
||||||
python manage.py migrate
|
|
||||||
python manage.py send_settings
|
|
||||||
python manage.py collectstatic --noinput --verbosity 0
|
|
||||||
gunicorn clicker.wsgi -b 0.0.0.0:8000 -w 17 --timeout 600 --chdir=/app --access-logfile -
|
|
|
@ -1,7 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
|
|
||||||
for i in $(seq 1 "${CELERY_WORKER_COUNT}"); do
|
|
||||||
celery -A clicker worker -l info --concurrency=10 -n "worker${i}"
|
|
||||||
done
|
|
||||||
|
|
||||||
celery -A clicker beat -l info
|
|
|
@ -1,408 +0,0 @@
|
||||||
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('<a href="{}">{} ({})</a>', url, obj.user.username, obj.user_id)
|
|
||||||
view_user_link.short_description = 'Системный пользователь'
|
|
||||||
|
|
||||||
def view_referred_by_link(self, obj):
|
|
||||||
if not obj.referred_by:
|
|
||||||
return
|
|
||||||
url = reverse("admin:users_tguser_change", args=[obj.referred_by.tg_id])
|
|
||||||
return format_html('<a href="{}">{} ({})</a>', url, obj.referred_by.username, obj.referred_by.tg_id)
|
|
||||||
view_referred_by_link.short_description = 'Кем был приглашен'
|
|
||||||
|
|
||||||
def view_referred_users_link(self, obj):
|
|
||||||
count = obj.referrees.count()
|
|
||||||
url = reverse('admin:users_tguser_changelist') + '?' + urlencode({'referred_by__tg_id': f'{obj.tg_id}'})
|
|
||||||
return format_html('<a href="{}"> {} users </a>', url, count)
|
|
||||||
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(
|
|
||||||
'<a href="{}"> все </a> // '
|
|
||||||
'<a href="{}"> клики </a> // '
|
|
||||||
'<a href="{}"> ставки </a> // '
|
|
||||||
'<a href="{}"> комиссии </a> // '
|
|
||||||
'<a href="{}"> реферальная программа </a>',
|
|
||||||
all_url, click_url, bet_url, commission_url, referral_url
|
|
||||||
)
|
|
||||||
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('<a href="{}"> {} clicks </a>', url, count)
|
|
||||||
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(reverse('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('<a href="{}">{}</a>', link, obj.user)
|
|
||||||
view_user_link.short_description = 'Пользователь'
|
|
||||||
|
|
||||||
def view_mailing_list_link(self, obj):
|
|
||||||
if not obj.mailing_list:
|
|
||||||
return None
|
|
||||||
link = reverse("admin:users_mailinglist_change", args=[obj.mailing_list.pk])
|
|
||||||
return format_html('<a href="{}">{}</a>', link, obj.mailing_list)
|
|
||||||
view_mailing_list_link.short_description = 'Рассылка'
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(MailingList)
|
|
||||||
class MailingListAdmin(admin.ModelAdmin):
|
|
||||||
form = MailingListAdminForm
|
|
||||||
model = MailingList
|
|
||||||
list_display = [
|
|
||||||
'id',
|
|
||||||
'name',
|
|
||||||
'time',
|
|
||||||
'text',
|
|
||||||
'media',
|
|
||||||
'view_users_link',
|
|
||||||
'view_mailing_list_receiver_infos_link',
|
|
||||||
'status',
|
|
||||||
'view_main_button_link',
|
|
||||||
'view_webapp_button_link',
|
|
||||||
]
|
|
||||||
list_display_links = [
|
|
||||||
'id'
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_readonly_fields(self, request, obj=None):
|
|
||||||
return ('status',)
|
|
||||||
|
|
||||||
def get_changeform_initial_data(self, request):
|
|
||||||
if user_ids := request.session.get('user_ids'):
|
|
||||||
return {'users': TGUser.objects.filter(pk__in=user_ids)}
|
|
||||||
return None
|
|
||||||
|
|
||||||
def view_users_link(self, obj):
|
|
||||||
count = obj.users.count()
|
|
||||||
url = reverse('admin:users_tguser_changelist') + '?' + urlencode(
|
|
||||||
{'mailing_lists__id': f'{obj.id}'})
|
|
||||||
return format_html('<a href="{}"> {} пользователей </a>', url, count)
|
|
||||||
view_users_link.short_description = 'Пользователи'
|
|
||||||
|
|
||||||
def view_mailing_list_receiver_infos_link(self, obj):
|
|
||||||
count = obj.mailing_list_receiver_infos.count()
|
|
||||||
url = reverse('admin:users_mailinglistreceiverinfo_changelist') + '?' + urlencode(
|
|
||||||
{'mailing_list_id': f'{obj.id}'})
|
|
||||||
return format_html('<a href="{}"> {} получателей </a>', url, count)
|
|
||||||
view_mailing_list_receiver_infos_link.short_description = 'Информация о получателях'
|
|
||||||
|
|
||||||
def view_main_button_link(self, obj):
|
|
||||||
if not obj.main_button:
|
|
||||||
return
|
|
||||||
url = reverse("admin:misc_button_change", args=[obj.main_button_id])
|
|
||||||
return format_html('<a href="{}"> Кнопка №{}</a>', url, obj.main_button_id)
|
|
||||||
view_main_button_link.short_description = 'Основная кнопка'
|
|
||||||
|
|
||||||
def view_webapp_button_link(self, obj):
|
|
||||||
if not obj.webapp_button:
|
|
||||||
return
|
|
||||||
url = reverse("admin:misc_button_change", args=[obj.webapp_button_id])
|
|
||||||
return format_html('<a href="{}"> Кнопка №{}</a>', url, obj.webapp_button_id)
|
|
||||||
view_webapp_button_link.short_description = 'Кнопка, открывающая вебапп'
|
|
||||||
|
|
||||||
|
|
||||||
# TODO
|
|
||||||
class TransactionChildAdmin(PolymorphicChildModelAdmin):
|
|
||||||
base_model = Transaction
|
|
||||||
search_fields = [
|
|
||||||
'user__username',
|
|
||||||
'user__tg_id'
|
|
||||||
]
|
|
||||||
|
|
||||||
def view_user_link(self, obj):
|
|
||||||
if not obj.user:
|
|
||||||
return
|
|
||||||
url = reverse("admin:users_tguser_change", args=[obj.user.tg_id])
|
|
||||||
return format_html('<a href="{}">{}</a>', url, obj.user)
|
|
||||||
view_user_link.short_description = 'Пользователь'
|
|
||||||
|
|
||||||
def has_delete_permission(self, request, obj=None):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def has_change_permission(self, request, obj=None):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def has_add_permission(self, request, obj=None):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ClickTransaction)
|
|
||||||
class ClickTransactionAdmin(TransactionChildAdmin):
|
|
||||||
base_model = ClickTransaction
|
|
||||||
show_in_index = True
|
|
||||||
list_display = [
|
|
||||||
'id',
|
|
||||||
'value',
|
|
||||||
'view_user_link',
|
|
||||||
'date',
|
|
||||||
'view_click_link'
|
|
||||||
]
|
|
||||||
|
|
||||||
def view_click_link(self, obj):
|
|
||||||
link = reverse("admin:clicks_click_change", args=[obj.click_id])
|
|
||||||
return format_html('<a href="{}"> {}</a>', link, obj.click)
|
|
||||||
view_click_link.short_description = 'Клик'
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(BetTransaction)
|
|
||||||
class BetTransactionAdmin(TransactionChildAdmin):
|
|
||||||
base_model = BetTransaction
|
|
||||||
show_in_index = True
|
|
||||||
list_display = [
|
|
||||||
'id',
|
|
||||||
'value',
|
|
||||||
'view_user_link',
|
|
||||||
'date',
|
|
||||||
'view_bet_link',
|
|
||||||
'view_commission_link',
|
|
||||||
'view_refunded_by_link',
|
|
||||||
'view_refund_to_link',
|
|
||||||
]
|
|
||||||
list_display_links = [
|
|
||||||
'id'
|
|
||||||
]
|
|
||||||
|
|
||||||
def view_bet_link(self, obj):
|
|
||||||
link = reverse("admin:auction_bet_change", args=[obj.bet_id])
|
|
||||||
return format_html('<a href="{}">{}</a>', link, obj.bet)
|
|
||||||
view_bet_link.short_description = 'Ставка'
|
|
||||||
|
|
||||||
def view_commission_link(self, obj):
|
|
||||||
try:
|
|
||||||
_ = obj.commission
|
|
||||||
except ObjectDoesNotExist:
|
|
||||||
return None
|
|
||||||
link = reverse("admin:users_commissiontransaction_change", args=[obj.commission.id])
|
|
||||||
return format_html('<a href="{}"> {} </a>', link, obj.commission)
|
|
||||||
view_commission_link.short_description = 'Комиссия'
|
|
||||||
|
|
||||||
def view_refunded_by_link(self, obj):
|
|
||||||
try:
|
|
||||||
_ = obj.refunded_by
|
|
||||||
except ObjectDoesNotExist:
|
|
||||||
return None
|
|
||||||
link = reverse("admin:users_bettransaction_change", args=[obj.refunded_by.id])
|
|
||||||
return format_html('<a href="{}"> {} </a>', link, obj.refunded_by)
|
|
||||||
view_refunded_by_link.short_description = 'Чем компенсирована'
|
|
||||||
|
|
||||||
def view_refund_to_link(self, obj):
|
|
||||||
if not obj.refund_to:
|
|
||||||
return None
|
|
||||||
link = reverse("admin:users_bettransaction_change", args=[obj.refund_to_id])
|
|
||||||
return format_html('<a href="{}"> {} </a>', link, obj.refund_to)
|
|
||||||
view_refund_to_link.short_description = 'Что компенсирует'
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CommissionTransaction)
|
|
||||||
class CommissionTransactionAdmin(TransactionChildAdmin):
|
|
||||||
base_model = CommissionTransaction
|
|
||||||
show_in_index = True
|
|
||||||
list_display = [
|
|
||||||
'id',
|
|
||||||
'value',
|
|
||||||
'view_user_link',
|
|
||||||
'date',
|
|
||||||
'view_bet_transaction_link',
|
|
||||||
]
|
|
||||||
list_display_links = [
|
|
||||||
'id'
|
|
||||||
]
|
|
||||||
|
|
||||||
def view_bet_transaction_link(self, obj):
|
|
||||||
link = reverse("admin:users_bettransaction_change", args=[obj.parent_transaction])
|
|
||||||
return format_html('<a href="{}"> {} </a>', link, obj.parent_transaction)
|
|
||||||
view_bet_transaction_link.short_description = 'Родительская транзакция'
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ReferralTransaction)
|
|
||||||
class ReferralTransactionAdmin(TransactionChildAdmin):
|
|
||||||
base_model = ReferralTransaction
|
|
||||||
show_in_index = True
|
|
||||||
list_display = [
|
|
||||||
'id',
|
|
||||||
'value',
|
|
||||||
'view_user_link',
|
|
||||||
'date',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# оригинальный класс использовал "change" в lookups
|
|
||||||
class WorkingPolymorphicChildModelFilter(PolymorphicChildModelFilter):
|
|
||||||
def lookups(self, request, model_admin):
|
|
||||||
return model_admin.get_child_type_choices(request, "view")
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Transaction)
|
|
||||||
class TransactionParentAdmin(PolymorphicParentModelAdmin):
|
|
||||||
base_model = Transaction
|
|
||||||
child_models = [
|
|
||||||
ClickTransaction, BetTransaction, CommissionTransaction, ReferralTransaction
|
|
||||||
]
|
|
||||||
list_filter = [
|
|
||||||
WorkingPolymorphicChildModelFilter,
|
|
||||||
]
|
|
||||||
search_fields = [
|
|
||||||
'user__username',
|
|
||||||
'user__tg_id'
|
|
||||||
]
|
|
||||||
list_display = [
|
|
||||||
'id',
|
|
||||||
'view_user_link',
|
|
||||||
'value',
|
|
||||||
'date',
|
|
||||||
'view_type'
|
|
||||||
]
|
|
||||||
list_display_links = [
|
|
||||||
'id'
|
|
||||||
]
|
|
||||||
|
|
||||||
def view_user_link(self, obj):
|
|
||||||
if not obj.user:
|
|
||||||
return
|
|
||||||
url = reverse("admin:users_tguser_change", args=[obj.user.tg_id])
|
|
||||||
return format_html('<a href="{}">{}</a>', url, obj.user)
|
|
||||||
view_user_link.short_description = 'Пользователь'
|
|
||||||
|
|
||||||
def view_type(self, obj):
|
|
||||||
return apps.get_model(obj.polymorphic_ctype.app_label, obj.polymorphic_ctype.model)._meta.verbose_name
|
|
||||||
view_type.short_description = 'Тип транзакции'
|
|
||||||
|
|
||||||
def has_add_permission(self, request, obj=None):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def has_delete_permission(self, request, obj=None):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def has_change_permission(self, request, obj=None):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def has_view_permission(self, request, obj=None):
|
|
||||||
return True
|
|
|
@ -1,13 +0,0 @@
|
||||||
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
|
|
|
@ -1,86 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
from .mailing_list import check_mailing_lists, handle_mailing_list
|
|
|
@ -1,55 +0,0 @@
|
||||||
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:
|
|
||||||
if mailing_list_receiver_info.sent:
|
|
||||||
continue
|
|
||||||
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__in=[MailingListStatus.WAITING, MailingListStatus.PARTLY_FINISHED]):
|
|
||||||
mailing_list.status = MailingListStatus.QUEUED
|
|
||||||
mailing_list.save(update_fields=('status',))
|
|
||||||
handle_mailing_list.apply_async(
|
|
||||||
(mailing_list.pk,),
|
|
||||||
eta=mailing_list.time
|
|
||||||
)
|
|
|
@ -1 +0,0 @@
|
||||||
from .mailing_list_status import MailingListStatus
|
|
|
@ -1,10 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
from .not_enough_funds import NotEnoughFundsError
|
|
|
@ -1,7 +0,0 @@
|
||||||
from rest_framework.exceptions import APIException
|
|
||||||
|
|
||||||
|
|
||||||
class NotEnoughFundsError(APIException):
|
|
||||||
status_code = 400
|
|
||||||
default_detail = 'Невозможно выполнить операцию, недостаточно баллов'
|
|
||||||
default_code = 'bad_request'
|
|
|
@ -1,144 +0,0 @@
|
||||||
# Generated by Django 5.0.4 on 2024-04-26 08:14
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('auction', '0001_initial'),
|
|
||||||
('clicks', '0001_initial'),
|
|
||||||
('contenttypes', '0002_remove_content_type_name'),
|
|
||||||
('misc', '0001_initial'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Transaction',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время')),
|
|
||||||
('value', models.DecimalField(decimal_places=5, max_digits=105, verbose_name='Значение')),
|
|
||||||
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Транзакция',
|
|
||||||
'verbose_name_plural': 'Транзакции',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='TGUser',
|
|
||||||
fields=[
|
|
||||||
('tg_id', models.PositiveBigIntegerField(primary_key=True, serialize=False, verbose_name='Telegram ID')),
|
|
||||||
('username', models.CharField(max_length=250, verbose_name='Telegram username')),
|
|
||||||
('avatar', models.FileField(blank=True, null=True, upload_to='users/', verbose_name='Аватарка')),
|
|
||||||
('points', models.DecimalField(decimal_places=2, default=0, max_digits=102, verbose_name='Баллы')),
|
|
||||||
('referral_storage', models.DecimalField(decimal_places=5, default=0, max_digits=102, verbose_name='Реферальное хранилище')),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время создания')),
|
|
||||||
('referred_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='referrees', to='users.tguser', verbose_name='Кем был приглашен')),
|
|
||||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='tg_user', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'ТГ-пользователь',
|
|
||||||
'verbose_name_plural': 'ТГ-пользователи',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ReferralTransaction',
|
|
||||||
fields=[
|
|
||||||
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='users.transaction')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Реферальная транзакция',
|
|
||||||
'verbose_name_plural': 'Реферальные транзакции',
|
|
||||||
},
|
|
||||||
bases=('users.transaction',),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='MailingList',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('name', models.CharField(max_length=250, verbose_name='Название')),
|
|
||||||
('time', models.DateTimeField(verbose_name='Дата и время публикации')),
|
|
||||||
('text', models.TextField(verbose_name='Текст публикации')),
|
|
||||||
('media', models.FileField(upload_to='mailing/', verbose_name='Вложение')),
|
|
||||||
('status', models.CharField(choices=[('1', 'Ожидание'), ('2', 'В очереди'), ('3', 'В обработке'), ('4', 'Окончена с ошибками'), ('5', 'Завершена')], default='1', max_length=1, verbose_name='Статус')),
|
|
||||||
('main_button', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='mailing_lists_for_main_button', to='misc.button', verbose_name='Кнопка')),
|
|
||||||
('webapp_button', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='mailing_lists_for_webapp_button', to='misc.button', verbose_name='Кнопка с веб-аппом')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Рассылка',
|
|
||||||
'verbose_name_plural': 'Рассылки',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='transaction',
|
|
||||||
name='user',
|
|
||||||
field=models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name='transactions', to='users.tguser', verbose_name='Пользователь'),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='MailingListReceiverInfo',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('sent', models.BooleanField(default=False, verbose_name='Отправлена ли')),
|
|
||||||
('clicked', models.BooleanField(default=False, verbose_name='Нажата ли')),
|
|
||||||
('mailing_list', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mailing_list_receiver_infos', to='users.mailinglist', verbose_name='Рассылка')),
|
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mailing_list_receiver_infos', to='users.tguser', verbose_name='Пользователь')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Информация о получателе рассылки',
|
|
||||||
'verbose_name_plural': 'Информация о получателях рассылки',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='mailinglist',
|
|
||||||
name='users',
|
|
||||||
field=models.ManyToManyField(related_name='mailing_lists', through='users.MailingListReceiverInfo', to='users.tguser', verbose_name='Пользователи'),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='BetTransaction',
|
|
||||||
fields=[
|
|
||||||
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='users.transaction')),
|
|
||||||
('bet', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='transaction', to='auction.bet', verbose_name='Ставка')),
|
|
||||||
('refund_to', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='refunded_by', to='users.bettransaction', verbose_name='Какую транзакцию отменяет')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Транзакция за ставку',
|
|
||||||
'verbose_name_plural': 'Транзакции за ставки',
|
|
||||||
},
|
|
||||||
bases=('users.transaction',),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ClickTransaction',
|
|
||||||
fields=[
|
|
||||||
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='users.transaction')),
|
|
||||||
('click', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='transaction', to='clicks.click', verbose_name='Клик')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Транзакция за клик',
|
|
||||||
'verbose_name_plural': 'Транзакции за клики',
|
|
||||||
},
|
|
||||||
bases=('users.transaction',),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='CommissionTransaction',
|
|
||||||
fields=[
|
|
||||||
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='users.transaction')),
|
|
||||||
('parent_transaction', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='commission', to='users.bettransaction', verbose_name='Родительская транзакция')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Комиссионная транзакция',
|
|
||||||
'verbose_name_plural': 'Комиссионные транзакции',
|
|
||||||
},
|
|
||||||
bases=('users.transaction',),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='tguser',
|
|
||||||
constraint=models.UniqueConstraint(fields=('tg_id',), name='unique_tg_id'),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,30 +0,0 @@
|
||||||
# Generated by Django 5.0.4 on 2024-05-04 16:38
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('auction', '0003_user_penalties_and_auction_fixes'),
|
|
||||||
('users', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='tguser',
|
|
||||||
name='is_blocked',
|
|
||||||
field=models.BooleanField(default=False, verbose_name='Заблокирован ли'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='tguser',
|
|
||||||
name='warning_count',
|
|
||||||
field=models.IntegerField(default=0, verbose_name='Количество предупреждений'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='bettransaction',
|
|
||||||
name='bet',
|
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='auction.bet', verbose_name='Ставка'),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,3 +0,0 @@
|
||||||
from .mailing_list import MailingList, MailingListReceiverInfo
|
|
||||||
from .tg_user import TGUser
|
|
||||||
from .transactions import Transaction, BetTransaction, ClickTransaction, ReferralTransaction, CommissionTransaction
|
|
|
@ -1,42 +0,0 @@
|
||||||
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}'
|
|
||||||
|
|
|
@ -1,43 +0,0 @@
|
||||||
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})'
|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user