Compare commits

..

74 Commits
main ... dev

Author SHA1 Message Date
Arseniy Sitnikov
a91596484f front fix i hope
Some checks failed
CI/CD Pipeline / lint-sast (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
2025-06-09 21:50:30 +03:00
Arseniy Sitnikov
dffac459d3 8081:80
All checks were successful
CI/CD Pipeline / lint-sast (push) Successful in 2m56s
CI/CD Pipeline / deploy (push) Successful in 52s
2025-06-09 14:44:09 +03:00
Arseniy Sitnikov
2660873c1f 80:80801
Some checks failed
CI/CD Pipeline / lint-sast (push) Successful in 2m57s
CI/CD Pipeline / deploy (push) Has been cancelled
2025-06-09 14:40:19 +03:00
Arseniy Sitnikov
9d80ed29c3 8000:8000
All checks were successful
CI/CD Pipeline / lint-sast (push) Successful in 3m20s
CI/CD Pipeline / deploy (push) Successful in 49s
2025-06-09 14:31:10 +03:00
Arseniy Sitnikov
496edf1e7f fix for misha because he ignores unused libs
All checks were successful
CI/CD Pipeline / lint-sast (push) Successful in 3m32s
CI/CD Pipeline / deploy (push) Successful in 51s
2025-06-09 14:14:23 +03:00
0e4dfc890a Wrap energy decr in transaction
All checks were successful
CI/CD Pipeline / lint-sast (push) Successful in 2m47s
CI/CD Pipeline / deploy (push) Successful in 1m21s
2025-06-09 14:08:58 +03:00
Arseniy Sitnikov
42ab9eb8f4 bobus
All checks were successful
CI/CD Pipeline / lint-sast (push) Successful in 4m52s
CI/CD Pipeline / deploy (push) Successful in 1m28s
2025-06-09 12:40:19 +03:00
1e8acfd7df Обновить README.md
Some checks are pending
CI/CD Pipeline / lint-sast (push) Waiting to run
CI/CD Pipeline / deploy (push) Blocked by required conditions
2025-06-09 12:36:42 +03:00
cfbd90c0f3 Обновить docker-compose-prod.yml
All checks were successful
CI/CD Pipeline / lint-sast (push) Successful in 6m29s
CI/CD Pipeline / deploy (push) Successful in 15s
Это Сеня комитит, честно
2025-06-09 11:17:20 +03:00
Arseniy Sitnikov
71ffa2823a fix3.0
All checks were successful
CI/CD Pipeline / lint-sast (push) Successful in 6m21s
CI/CD Pipeline / deploy (push) Successful in 15s
2025-06-09 10:55:20 +03:00
Arseniy Sitnikov
503cd47fae fix2.0
Some checks failed
CI/CD Pipeline / lint-sast (push) Failing after 41s
CI/CD Pipeline / deploy (push) Has been skipped
2025-06-09 10:52:56 +03:00
Arseniy Sitnikov
ab05f74199 fix for misha
Some checks failed
CI/CD Pipeline / lint-sast (push) Failing after 42s
CI/CD Pipeline / deploy (push) Has been skipped
2025-06-09 10:50:28 +03:00
4890df9876 Merge pull request 'Finally fix setting delivery' (#3) from settings-refactoring into dev
Some checks failed
CI/CD Pipeline / lint-sast (push) Failing after 13m3s
CI/CD Pipeline / deploy (push) Has been skipped
Reviewed-on: http://79.137.198.26/danyadjan/db_kyc_project/pulls/3
2025-05-31 16:19:52 +03:00
269847c6a2 Finally fix setting delivery 2025-05-31 16:15:06 +03:00
b7e066b525 Merge pull request 'Refactor settings in batcher' (#2) from refactor-batcher-settings into dev
All checks were successful
CI/CD Pipeline / lint-sast (push) Successful in 2m35s
CI/CD Pipeline / deploy (push) Successful in 1m54s
Reviewed-on: http://79.137.198.26/danyadjan/db_kyc_project/pulls/2
2025-05-27 20:54:41 +03:00
Arseniy Sitnikov
d4eab71590 fix copying
All checks were successful
CI/CD Pipeline / lint-sast (push) Successful in 2m35s
CI/CD Pipeline / deploy (push) Successful in 28s
2025-05-18 14:57:51 +03:00
Arseniy Sitnikov
064e187f8a bobus-avtobus
Some checks failed
CI/CD Pipeline / lint-sast (push) Successful in 3m12s
CI/CD Pipeline / deploy (push) Failing after 7s
2025-05-18 14:51:38 +03:00
Arseniy Sitnikov
5a2dc140ce flake8 ignorance
Some checks failed
CI/CD Pipeline / lint-sast (push) Failing after 34s
CI/CD Pipeline / deploy (push) Has been skipped
2025-05-18 14:46:45 +03:00
Arseniy Sitnikov
020e23bcfb cringe fix
Some checks failed
CI/CD Pipeline / lint-sast (push) Failing after 33s
CI/CD Pipeline / deploy (push) Has been skipped
2025-05-18 14:43:19 +03:00
Arseniy Sitnikov
5bfe2fb01c venv fix
Some checks failed
CI/CD Pipeline / lint-sast (push) Failing after 59s
CI/CD Pipeline / deploy (push) Has been skipped
2025-05-18 14:41:38 +03:00
Arseniy Sitnikov
b5b6b8e64b first pipeline
Some checks failed
CI/CD Pipeline / lint-sast (push) Failing after 55s
CI/CD Pipeline / deploy (push) Has been skipped
2025-05-18 13:23:45 +03:00
9a052700dd Refactor settings in batcher 2025-05-11 10:49:39 +03:00
ac06b14776 Merge pull request 'Remove redis, refactor click batching' (#1) from refactor-redis-and-click-batching into dev
Reviewed-on: http://79.137.198.26/danyadjan/db_kyc_project/pulls/1
2025-05-11 10:48:10 +03:00
78f2ebe4d5 Remove redis, refactor click batching (tested on api level) 2025-03-23 19:13:24 +03:00
Arseniy Sitnikov
dc58c8db45
Added info about .env 2024-12-21 13:10:35 +03:00
Danya
ecedd2b7e5
Update README.md 2024-12-21 13:03:48 +03:00
Danya
310fe4d3dd
Add files via upload 2024-12-21 13:02:22 +03:00
Даня Вакуленков
71f7c4be26 Added some little fix 2024-12-21 11:53:50 +03:00
Arseniy Sitnikov
b0c0fecee9 changed imgs a bit 2024-12-21 11:45:46 +03:00
Даня Вакуленков
d16ab5fdc9 Added .env.example, fixed some bugs 2024-12-17 16:48:56 +03:00
Arseniy Sitnikov
3511d37fec
Update README.md 2024-12-17 16:34:00 +03:00
Arseniy Sitnikov
fcc869394c
Add files via upload 2024-12-17 16:33:03 +03:00
Arseniy Sitnikov
72451103bd
Add files via upload 2024-12-17 15:09:57 +03:00
Arseniy Sitnikov
da7b2b1b02
Added architecture to README 2024-12-17 15:08:33 +03:00
4cb758199d Alter batcher urls in frontend 2024-12-17 14:27:12 +03:00
aa5805e14d
Merge pull request #13 from Danya-Djan/frontend
Frontend
2024-12-17 14:20:44 +03:00
3c8cd2872a
Merge pull request #11 from Danya-Djan/issue/6
Various fixes
2024-12-17 14:20:29 +03:00
95e74edb8e
Merge branch 'dev' into issue/6 2024-12-17 14:20:20 +03:00
Arseniy Sitnikov
9253069a07 Merge branch 'frontend' of github.com:Danya-Djan/db_kyc_project into frontend 2024-12-17 13:54:35 +03:00
Arseniy Sitnikov
a75e958944 im a dumb bobus 2024-12-17 13:54:05 +03:00
Arseniy Sitnikov
41bcc33175
Delete frontend/src/.DS_Store 2024-12-17 13:51:18 +03:00
Arseniy Sitnikov
17d46952c2
Delete frontend/.DS_Store 2024-12-17 13:51:08 +03:00
Arseniy Sitnikov
7be3835d57 added static images 2024-12-17 12:49:30 +03:00
Даня Вакуленков
d5dfbe4ee1 Update README file 2024-12-17 12:15:51 +03:00
Arseniy Sitnikov
22ca4b177c added auctions and rating 2024-12-17 11:16:46 +03:00
Danya
7f5d87b031
Merge pull request #12 from Danya-Djan/issue/4 2024-12-16 19:51:21 +03:00
0a5f49b4db Fixed format_html in admin 2024-12-16 19:33:10 +03:00
b2fb2f9380 Various fixes
- cleaned up Dockefiles
- added healthchecks to docker-compose files
- moved celery & celery-beat to one container
- cleaned up nginx config
2024-12-16 19:13:27 +03:00
Даня Вакуленков
e80ec3730e Add dbm defs and remove memcache 2024-12-15 01:21:54 +03:00
df4e6a685b Add session cooldown to batcher 2024-12-14 11:38:23 +03:00
Даня Вакуленков
ff9c8c76b4 Fix bot and back problems 2024-12-14 08:52:01 +03:00
8bfd46988f Fix bot & batcher 2024-12-14 03:08:46 +03:00
Даня Вакуленков
572980f469 Fix bot requirements 2024-12-14 01:37:54 +03:00
c6e60fab39 Fix backend & batcher 2024-12-14 00:43:33 +03:00
Arseniy Sitnikov
c8a3049349 add win popup 2024-12-13 15:26:27 +03:00
Arseniy Sitnikov
0cb8a5c7a9 fixed auctions and opened them 2024-12-13 12:17:17 +03:00
Arseniy Sitnikov
6ae11e83d6 auctions and bugs fixed about top imgs 2024-12-13 02:46:21 +03:00
Arseniy Sitnikov
07915ca426 add autoclick saving 2024-12-13 00:51:45 +03:00
Arseniy Sitnikov
51f4599031 fix token 2024-12-12 23:04:42 +03:00
Arseniy Sitnikov
16932959d5 autoclick add & add ranks 2024-12-12 23:01:22 +03:00
c668cb5487 Merge remote-tracking branch 'origin/bot' into dev 2024-12-12 22:24:26 +03:00
ab39271e82 Merge remote-tracking branch 'origin/backend' into dev 2024-12-12 22:24:21 +03:00
a42428cb53 Merge remote-tracking branch 'origin/frontend' into dev 2024-12-12 22:23:09 +03:00
6c22f2b560 Added migrations 2024-12-12 22:12:00 +03:00
Arseniy Sitnikov
9e9fcc5f94 upd click request 2024-12-12 18:42:33 +03:00
Arseniy Sitnikov
86d3e9bbf4 fixes of bugs, loading animation, add max storage 2024-12-12 15:21:19 +03:00
Arseniy Sitnikov
26633b4b55 added energy, mult storage, button and profile | also fixed bugs 2024-12-11 05:02:21 +03:00
Даня Вакуленков
e23cd412dc Add new migrations 2024-12-11 01:05:50 +03:00
Даня Вакуленков
4a18a785e9 Add backend code 2024-12-10 23:31:35 +03:00
Arseniy Sitnikov
e9e0729a77 add clicks 2024-11-24 21:12:26 +03:00
Arseniy Sitnikov
4352fcd884 Initial frontend commit 2024-11-18 16:33:44 +03:00
Даня Вакуленков
42f95b6e9d Add bot functional 2024-10-28 00:04:35 +03:00
62cb52a8ae Fully implemented batcher (without testing) 2024-10-23 12:59:53 +03:00
504adfb263 Batcher repos & usecase for click 2024-10-20 13:27:14 +03:00
461 changed files with 42117 additions and 207 deletions

4
.env.example/dev/back Normal file
View File

@ -0,0 +1,4 @@
DEBUG=1
SECRET_KEY=SECRET_KEY
ENVIRONMENT=docker
DJANGO_SETTINGS_MODULE=clicker.settings

1
.env.example/dev/batcher Normal file
View File

@ -0,0 +1 @@
HTTP_PORT=8080

View File

@ -0,0 +1,8 @@
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

3
.env.example/dev/bot Normal file
View File

@ -0,0 +1,3 @@
TG_TOKEN=token
BACKEND_URL=http://backend:8000
PROD=0

8
.env.example/dev/pg Normal file
View File

@ -0,0 +1,8 @@
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

5
.env.example/dev/redis Normal file
View File

@ -0,0 +1,5 @@
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_USER=default
REDIS_PASSWORD=redis
REDIS_DB=0

4
.env.example/dev/rmq Normal file
View File

@ -0,0 +1,4 @@
RABBITMQ_HOST=rabbitmq
RABBITMQ_PORT=5672
RABBITMQ_DEFAULT_USER=rabbitmq
RABBITMQ_DEFAULT_PASS=rabbitmq

1
.env.example/dev/web Normal file
View File

@ -0,0 +1 @@
APP_URL=https://kyc-game.ru

61
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,61 @@
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
View File

@ -1,169 +1,10 @@
.idea
.env
.DS_Store
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
backend/static
backend/media
bot/logs.log
bot/logfile.log

View File

@ -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:
- **Frontend**: [React.js, Vue.js, etc.]
- **Frontend**: [React, Typescript]
- **Backend**: [Django, Flask, etc.]
- **Database**: [PostgreSQL, MongoDB, MySQL, etc.]
- **Database**: [PostgreSQL]
- **Authentication**: [JWT, OAuth, etc.]
- **Deployment**: [Docker, Kubernetes, AWS, etc.]
- **Deployment**: [Docker]
We have structured our architecture for flexibility and ease of integration with future features.
@ -29,71 +29,51 @@ We have structured our architecture for flexibility and ease of integration with
The project is a collaboration of skilled and passionate individuals:
2. **Michael Kostochka**: [Role: Backend Developer] - Ensures smooth operations behind the scenes with efficient APIs.
3. **Sitnikov Arseniy**: [Role: Database Architect] - Responsible for managing data flows and ensuring high performance.
4. **Vakulenkov Danila**: [Role: DevOps Engineer] - Orchestrates deployment, monitoring, and infrastructure management.
2. **Michael Kostochka**: [Role: Backend Developer, Database Architect]
3. **Sitnikov Arseniy**: [Role: Frontend Developer, Database Architect]
4. **Vakulenkov Danya**: [Role: Backend Engineer, Database Architect]
## 🎨 **Visuals**
![Clicker Game Mockup1](./mockups/Hamster.png)
![Clicker Game Mockup2](./mockups/Notcoin.png)
## **🏛️ Architecture**
![Architecture](./mockups/architecture.png)
## 📊 **DataBase Structure**
![ClickerDB1](./mockups/db_structure.svg)
![ClickerDB2](./mockups/db_batcher.svg)
## 🚀 **Getting Started**
Follow these steps to set up and run the project locally:
### **1. Clone the Repository**
```bash
git clone https://github.com/Danya-Djan/db_kyc_project.git
cd db_kyc_project
```
### **2. Install Dependencies**
### **2. Environment Setup**
Ensure you have [Node.js](https://nodejs.org/) and [Docker](https://www.docker.com/) installed.
Create a `.env` file in the root of the project and add the needed variables
The exmaple can be viewed at [./env.example/](https://github.com/Danya-Djan/db_kyc_project/tree/dev/.env.example/dev)
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**
### **3. Run the Project**
To start both frontend and backend servers in development mode:
For **Frontend and Backend**:
```bash
docker-compose up
docker-compose up --build
```
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**
This project is licensed under the [MIPT License](link_to_license_file).
This project is licensed under the [MIPT License](https://github.com/MIPT-ILab/MDSP/blob/master/LICENSE).
---
---

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

7
backend/Dockerfile Normal file
View File

@ -0,0 +1,7 @@
FROM python:3.11
COPY . /app
RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt
WORKDIR /app

View File

147
backend/auction/admin.py Normal file
View File

@ -0,0 +1,147 @@
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html, urlencode
from django_cte import With
from auction.models import Auction, Bet, Product
from users.models import TGUser
@admin.register(Auction)
class AuctionAdmin(admin.ModelAdmin):
list_display = [
'id',
'view_product_link',
'quantity',
'end_time',
'is_active',
'view_winners_link',
'view_betters_link',
'view_bets_link',
'initial_end_time',
'last_call_delta',
'times_postponed',
'initial_cost',
'commission',
]
list_display_links = [
'id',
]
autocomplete_fields = [
'product'
]
readonly_fields = ['times_postponed']
def end_time(self, obj):
return obj._end_time
end_time.admin_order_field = '_end_time'
end_time.short_description = 'Дата окончания'
def is_active(self, obj):
return obj._is_active
is_active.admin_order_field = '_is_active'
is_active.short_description = 'Активен ли'
is_active.boolean = True
def view_product_link(self, obj):
url = reverse("admin:auction_product_change", args=[obj.product_id])
return format_html('<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 = 'Транзакции'

6
backend/auction/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class AuctionConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "auction"

View File

@ -0,0 +1,2 @@
from .auction import AuctionFilter
from .bet import BetFilter

View File

@ -0,0 +1,24 @@
from django_filters import rest_framework as filters
from auction.models import Auction, Product
class AuctionFilter(filters.FilterSet):
order_by = filters.OrderingFilter(
fields=(
('_is_active', 'is_active'),
('_end_time', 'end_time'),
('id', 'id'),
),
distinct=True
)
is_active = filters.BooleanFilter(field_name='_is_active')
product = filters.ModelMultipleChoiceFilter(
queryset=Product.objects.all(),
field_name='product__pk',
to_field_name='pk',
distinct=True
)
class Meta:
model = Auction
fields = ('order_by', 'is_active', 'product')

View File

@ -0,0 +1,29 @@
from django_filters import rest_framework as filters
from users.models import TGUser
from auction.models import Auction, Bet
class BetFilter(filters.FilterSet):
order_by = filters.OrderingFilter(
fields=(
('_value', 'value'),
('created_at', 'created_at'),
('id', 'id'),
),
distinct=True
)
is_winning = filters.BooleanFilter(field_name='_is_winning')
auction = filters.ModelChoiceFilter(
queryset=Auction.objects.all(),
field_name='auction',
to_field_name='pk',
)
user = filters.ModelChoiceFilter(
queryset=TGUser.objects.all(),
field_name='user',
to_field_name='pk',
)
class Meta:
model = Bet
fields = ('order_by', 'auction', 'user')

View File

@ -0,0 +1,57 @@
# Generated by Django 5.0.4 on 2024-04-26 08:14
import django.db.models.deletion
from django.db import migrations, models
from django.utils import timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Auction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(verbose_name='Количество победителей')),
('initial_end_time', models.DateTimeField(verbose_name='Дата окончания')),
('last_call_delta', models.DurationField(verbose_name='Время последнего шага')),
('current_end_time', models.DateTimeField(default=timezone.now, verbose_name='Текущая дата окончания')),
('commission', models.DecimalField(decimal_places=2, help_text='Десятичная дробь', max_digits=5, verbose_name='Комиссия')),
('initial_cost', models.DecimalField(decimal_places=2, max_digits=102, verbose_name='Начальная стоимость')),
],
options={
'verbose_name': 'Аукцион',
'verbose_name_plural': 'Аукционы',
},
),
migrations.CreateModel(
name='Product',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=250, verbose_name='Название')),
('cover', models.FileField(upload_to='products/', verbose_name='Обложка')),
('description', models.TextField(verbose_name='Описание')),
],
options={
'verbose_name': 'Товар',
'verbose_name_plural': 'Товары',
},
),
migrations.CreateModel(
name='Bet',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время создания')),
('auction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bets', to='auction.auction', verbose_name='Аукцион')),
],
options={
'verbose_name': 'Ставка',
'verbose_name_plural': 'Ставки',
},
),
]

View File

@ -0,0 +1,32 @@
# Generated by Django 5.0.4 on 2024-04-26 08:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auction', '0001_initial'),
('users', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='bet',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bets', to='users.tguser', verbose_name='Пользователь'),
),
migrations.AddField(
model_name='auction',
name='betters',
field=models.ManyToManyField(related_name='auctions', through='auction.Bet', to='users.tguser', verbose_name='Поставившие ставку'),
),
migrations.AddField(
model_name='auction',
name='product',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auctions', to='auction.product', verbose_name='Товар'),
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 5.0.4 on 2024-05-04 16:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('auction', '0002_initial'),
]
operations = [
migrations.RemoveField(
model_name='auction',
name='current_end_time',
),
migrations.AddField(
model_name='auction',
name='times_postponed',
field=models.IntegerField(default=0, verbose_name='Количество переносов'),
),
migrations.AlterField(
model_name='auction',
name='initial_end_time',
field=models.DateTimeField(verbose_name='Изначальная дата окончания'),
),
]

View File

View File

@ -0,0 +1,3 @@
from .auction import Auction
from .bet import Bet
from .product import Product

View File

@ -0,0 +1,66 @@
from datetime import datetime
from decimal import Decimal
from django.db import models
from django.utils import timezone
from django.db.models import F, Case, When, Max, ExpressionWrapper, Subquery, OuterRef
from users.models import BetTransaction
class AuctionManager(models.Manager):
def get_queryset(self):
return super().get_queryset().annotate(
_end_time=ExpressionWrapper(
F('initial_end_time') + F('last_call_delta') * F('times_postponed'),
output_field=models.DateTimeField()
)
).annotate(_is_active=Case(
When(_end_time__gt=timezone.now(), then=True),
default=False,
)).annotate(
_min_bet_value=Case(
When(bets__isnull=True, then=F('initial_cost')),
default=Subquery(BetTransaction.objects.filter(bet__auction=OuterRef('pk')).order_by('-value').values('value')[:1]) + Decimal('0.01')
)
).distinct()
class Auction(models.Model):
class Meta:
verbose_name = 'Аукцион'
verbose_name_plural = 'Аукционы'
product = models.ForeignKey('auction.Product', related_name='auctions', on_delete=models.CASCADE,
verbose_name='Товар')
quantity = models.PositiveIntegerField(verbose_name='Количество победителей')
betters = models.ManyToManyField('users.TGUser', through='auction.Bet', related_name='auctions',
verbose_name='Поставившие ставку')
initial_end_time = models.DateTimeField(verbose_name='Изначальная дата окончания')
last_call_delta = models.DurationField(verbose_name='Время последнего шага')
times_postponed = models.IntegerField(default=0, verbose_name='Количество переносов')
commission = models.DecimalField(decimal_places=2, max_digits=5, verbose_name='Комиссия',
help_text='Десятичная дробь')
initial_cost = models.DecimalField(decimal_places=2, max_digits=102, verbose_name='Начальная стоимость')
objects = AuctionManager()
def __str__(self):
return f'Аукцион №{self.pk}'
def check_postpone(self):
if timezone.now() - self.last_call_delta <= self.end_time <= timezone.now():
self.times_postponed = F('times_postponed') + 1
self.save()
@property
def end_time(self):
return self._end_time
@property
def is_active(self):
return self._is_active
@property
def min_bet_value(self):
return self._min_bet_value

View File

@ -0,0 +1,55 @@
from django.db import models
from django.db.models import Subquery, OuterRef, Exists
from django_cte import CTEManager
from users.models import CommissionTransaction, BetTransaction
class BetManager(CTEManager):
def get_queryset(self):
return super().get_queryset().annotate(
_is_winning=Exists(BetTransaction.objects.filter(bet=OuterRef('pk'), refunded_by__isnull=True, refund_to__isnull=True)),
_value=-Subquery(BetTransaction.objects.filter(bet=OuterRef('pk')).order_by('date').values('value')[:1])
).distinct()
def create_with_transaction_and_commission(self, auction, user, value, commission_value):
return CommissionTransaction.objects.create(
parent_transaction=BetTransaction.objects.create(
value=-value,
user=user,
bet=Bet.objects.create(
auction=auction,
user=user,
)
),
value=-commission_value,
user=user,
).parent_transaction.bet
class Bet(models.Model):
class Meta:
verbose_name = 'Ставка'
verbose_name_plural = 'Ставки'
auction = models.ForeignKey('auction.Auction', related_name='bets', on_delete=models.CASCADE,
verbose_name='Аукцион')
user = models.ForeignKey('users.TGUser', related_name='bets', on_delete=models.CASCADE,
verbose_name='Пользователь')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата и время создания')
objects = BetManager()
def __str__(self):
return f'Ставка {self.user} на {self.auction} от {self.created_at}'
@property
def is_winning(self):
return self._is_winning
@property
def value(self):
return self._value
@property
def transaction(self):
return self.transactions.get(refunded_by__isnull=True)

View File

@ -0,0 +1,14 @@
from django.db import models
class Product(models.Model):
class Meta:
verbose_name = 'Товар'
verbose_name_plural = 'Товары'
name = models.CharField(max_length=250, verbose_name='Название')
cover = models.FileField(upload_to='products/', verbose_name='Обложка')
description = models.TextField(verbose_name='Описание')
def __str__(self):
return f'{self.name} ({self.pk})'

View File

@ -0,0 +1,3 @@
from .auction import AuctionSerializer
from .bet import BetSerializer
from .product import ProductSerializer

View File

@ -0,0 +1,13 @@
from rest_framework.serializers import ModelSerializer, DateTimeField, BooleanField
from .product import ProductSerializer
from auction.models import Auction
class AuctionSerializer(ModelSerializer):
product = ProductSerializer()
end_time = DateTimeField(read_only=True)
is_active = BooleanField(read_only=True)
class Meta:
model = Auction
exclude = ('betters', 'initial_end_time', 'times_postponed', 'last_call_delta')

View File

@ -0,0 +1,12 @@
from rest_framework.serializers import ModelSerializer, DecimalField
from auction.models import Bet
from users.serializers import TGUserSerializer
class BetSerializer(ModelSerializer):
user = TGUserSerializer()
value = DecimalField(max_digits=102, decimal_places=2, read_only=True)
class Meta:
model = Bet
fields = '__all__'

View File

@ -0,0 +1,8 @@
from rest_framework.serializers import ModelSerializer
from auction.models import Product
class ProductSerializer(ModelSerializer):
class Meta:
model = Product
fields = '__all__'

3
backend/auction/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

10
backend/auction/urls.py Normal file
View File

@ -0,0 +1,10 @@
from django.urls import include, path
from auction.views import (
AuctionList, BetList, place_bet
)
urlpatterns = [
path('auction', AuctionList.as_view(), name='auction-list'),
path('bet', BetList.as_view(), name='bet-list'),
path('auction/<int:pk>/place-bet/', place_bet, name='place-bet'),
]

View File

@ -0,0 +1,3 @@
from .auction import AuctionList
from .bet import BetList
from .place_bet import place_bet

View File

@ -0,0 +1,13 @@
from django_filters import rest_framework as filters
from rest_framework.generics import ListAPIView
from auction.serializers import AuctionSerializer
from auction.models import Auction, Bet
from auction.filters import AuctionFilter
class AuctionList(ListAPIView):
queryset = Auction.objects.all()
serializer_class = AuctionSerializer
filter_backends = (filters.DjangoFilterBackend,)
filterset_class = AuctionFilter

View File

@ -0,0 +1,12 @@
from django_filters import rest_framework as filters
from rest_framework.generics import ListAPIView, GenericAPIView
from auction.serializers import BetSerializer
from auction.models import Bet
from auction.filters import BetFilter
class BetList(ListAPIView):
queryset = Bet.objects.all()
serializer_class = BetSerializer
filter_backends = (filters.DjangoFilterBackend,)
filterset_class = BetFilter

View File

@ -0,0 +1,50 @@
from decimal import Decimal
from rest_framework.response import Response
from rest_framework.status import HTTP_200_OK
from rest_framework.decorators import api_view
from rest_framework.exceptions import ParseError
from django.shortcuts import get_object_or_404
from django.db import transaction
from django.db.models import F
from django.utils import timezone
from django_cte import With
from auction.models import Auction, Bet
@api_view(['POST'])
def place_bet(request, pk):
auction = get_object_or_404(Auction, pk=pk)
if not auction.is_active:
raise ParseError('Аукцион уже закончился')
bet_value = Decimal(request.query_params.get('value', 0))
if bet_value <= 0:
raise ParseError('Ставка должна быть положительная')
if bet_value < auction.min_bet_value:
raise ParseError('Ставка слишком маленькая')
tg_user = request.user.tg_user
auction_has_to_be_postponed = timezone.now() <= auction.end_time <= timezone.now() + auction.last_call_delta
with transaction.atomic():
cte = With(auction.bets.filter(_is_winning=True).distinct('user').order_by('user_id'))
winning_bets = cte.join(Bet, id=cte.col.id).with_cte(cte).order_by('-_value')
commission_value = bet_value * auction.commission
if winning_bets.filter(user_id=tg_user.pk).exists():
user_bet = winning_bets.get(user_id=tg_user.pk)
delta = bet_value - user_bet.value
user_bet.transaction.refund()
commission_value = delta * auction.commission
elif winning_bets.count() >= auction.quantity:
for bet in winning_bets[auction.quantity - 1:]:
bet.transaction.refund()
Bet.objects.create_with_transaction_and_commission(
auction=auction,
user=tg_user,
value=bet_value,
commission_value=commission_value
)
if auction_has_to_be_postponed:
auction.times_postponed = F('times_postponed') + 1
auction.save(update_fields=('times_postponed',))
tg_user.refresh_from_db()
return Response(status=HTTP_200_OK, data={'remaining_points': tg_user.points})

View File

16
backend/clicker/asgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
ASGI config for clicker project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "clicker.settings")
application = get_asgi_application()

View File

@ -0,0 +1,9 @@
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'clicker.settings')
app = Celery('clicker')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()

219
backend/clicker/settings.py Normal file
View File

@ -0,0 +1,219 @@
"""
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

View File

@ -0,0 +1,8 @@
from django.conf import settings
from storages.backends.s3boto3 import S3Boto3Storage
class PublicMediaStorage(S3Boto3Storage):
location = 'media'
default_acl = 'public-read'
file_overwrite = False

19
backend/clicker/urls.py Normal file
View File

@ -0,0 +1,19 @@
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
urlpatterns = [
path("admin/", admin.site.urls),
path('api/v1/users/', include('users.urls.urls')),
path('api/internal/users/', include('users.urls.internal_urls')),
path('api/v1/auction/', include('auction.urls')),
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
]
if int(settings.DEBUG):
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

16
backend/clicker/wsgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
WSGI config for clicker project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "clicker.settings")
application = get_wsgi_application()

View File

37
backend/clicks/admin.py Normal file
View File

@ -0,0 +1,37 @@
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from clicks.models import Click
@admin.register(Click)
class ClickAdmin(admin.ModelAdmin):
list_display = [
'id',
'view_user_link',
'value',
'created_at',
'view_transaction_link',
]
list_display_links = [
'id',
]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
def view_user_link(self, obj):
url = reverse("admin:users_tguser_change", args=[obj.user_id])
return format_html('<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 = 'Транзакция'

9
backend/clicks/apps.py Normal file
View File

@ -0,0 +1,9 @@
from django.apps import AppConfig
class ClicksConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "clicks"
def ready(self):
from .celery import handle_click

View File

@ -0,0 +1 @@
from .click import handle_click

View File

@ -0,0 +1,27 @@
from datetime import datetime
from decimal import Decimal
from clicker.celery import app
from clicks.models import Click
from users.models import ClickTransaction
@app.task
def handle_click(user_id, date_time, value_str, count=1):
date_time = datetime.fromtimestamp(date_time / 1000)
value = Decimal(value_str)
clicks = list()
for _ in range(count):
click = Click(
user_id=user_id,
value=value,
created_at=date_time
)
clicks.append(click)
Click.objects.bulk_create(clicks)
for click in clicks:
ClickTransaction.objects.create(
user_id=user_id,
date=date_time,
value=value,
click=click
)

View File

@ -0,0 +1,26 @@
# Generated by Django 5.0.4 on 2024-04-26 08:14
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Click',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('value', models.DecimalField(decimal_places=2, max_digits=102, verbose_name='Значение')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время')),
],
options={
'verbose_name': 'Клик',
'verbose_name_plural': 'Клики',
},
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 5.0.4 on 2024-04-26 08:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('clicks', '0001_initial'),
('users', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='click',
name='user',
field=models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name='clicks', to='users.tguser', verbose_name='Пользователь'),
),
]

View File

View File

@ -0,0 +1 @@
from .click import Click

View File

@ -0,0 +1,15 @@
from django.db import models
class Click(models.Model):
class Meta:
verbose_name = 'Клик'
verbose_name_plural = 'Клики'
user = models.ForeignKey('users.TGUser', related_name='clicks', on_delete=models.DO_NOTHING, db_constraint=False,
verbose_name='Пользователь')
value = models.DecimalField(decimal_places=2, max_digits=102, verbose_name='Значение')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата и время')
def __str__(self):
return f'Клик {self.user} от {self.created_at.strftime("%d.%m.%Y %H:%M:%S")} ({self.pk})'

3
backend/clicks/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
backend/clicks/views.py Normal file
View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

22
backend/manage.py Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "clicker.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

0
backend/misc/__init__.py Normal file
View File

41
backend/misc/admin.py Normal file
View File

@ -0,0 +1,41 @@
from decimal import Decimal
from django.contrib import admin
from django.forms import ModelForm, CharField
from misc.models import Setting
class SettingForm(ModelForm):
value = CharField(label='Значение')
class Meta:
model = Setting
exclude = ('value',)
def save(self, commit=True):
setting = super(SettingForm, self).save(commit=False)
setting.value = dict()
if self.cleaned_data['value'].isdigit():
setting.value['value'] = int(self.cleaned_data['value'])
elif self.cleaned_data['value'] in ('true', 'false'):
setting.value['value'] = self.cleaned_data['value'] == 'true'
else:
setting.value['value'] = self.cleaned_data['value']
if commit:
setting.save()
return setting
@admin.register(Setting)
class SettingAdmin(admin.ModelAdmin):
form = SettingForm
list_display = [
'id',
'name',
'description',
'display_value'
]
list_display_links = [
'id'
]

10
backend/misc/apps.py Normal file
View File

@ -0,0 +1,10 @@
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

View File

@ -0,0 +1 @@
from .deliver_setting import deliver_setting

View File

@ -0,0 +1,20 @@
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],
)

View File

@ -0,0 +1,90 @@
[
{
"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
}
}
}
]

View File

View File

@ -0,0 +1,10 @@
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']})

View File

@ -0,0 +1,99 @@
# Generated by Django 5.0.4 on 2024-04-26 08:14
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Button',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.TextField(verbose_name='Текст на кнопке')),
('link', models.CharField(max_length=250, verbose_name='Ссылка на кнопке')),
],
options={
'verbose_name': 'Кнопка',
'verbose_name_plural': 'Кнопки',
},
),
migrations.CreateModel(
name='Setting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Желательно в формате: SAMPLE_NAME', max_length=250, verbose_name='Машиночитаемое название')),
('description', models.TextField(verbose_name='Описание')),
('value', models.JSONField(verbose_name='Значение')),
],
options={
'verbose_name': 'Настройка',
'verbose_name_plural': 'Настройки',
},
),
migrations.CreateModel(
name='Style',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=250, verbose_name='Название')),
('color_1', models.CharField(max_length=7, validators=[django.core.validators.RegexValidator(message='Пожалуйста введите название текста в формате #11AA11', regex='#[0-9A-F]{6}')], verbose_name='Основной цвет')),
('color_2', models.CharField(max_length=7, validators=[django.core.validators.RegexValidator(message='Пожалуйста введите название текста в формате #11AA11', regex='#[0-9A-F]{6}')], verbose_name='Цвет №2')),
('color_3', models.CharField(max_length=7, validators=[django.core.validators.RegexValidator(message='Пожалуйста введите название текста в формате #11AA11', regex='#[0-9A-F]{6}')], verbose_name='Цвет №3')),
('color_4', models.CharField(max_length=7, validators=[django.core.validators.RegexValidator(message='Пожалуйста введите название текста в формате #11AA11', regex='#[0-9A-F]{6}')], verbose_name='Цвет №4')),
('description', models.TextField(verbose_name='Описание')),
('is_available', models.BooleanField(verbose_name='Доступен ли')),
('background', models.FileField(upload_to='styles/', verbose_name='Фон')),
],
options={
'verbose_name': 'Стиль',
'verbose_name_plural': 'Стили',
},
),
migrations.CreateModel(
name='Banner',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=250, verbose_name='Название')),
('media', models.FileField(upload_to='banners/', verbose_name='Обложка')),
('is_available', models.BooleanField(verbose_name='Доступен ли')),
('button', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='banners', to='misc.button', verbose_name='Кнопка')),
],
options={
'verbose_name': 'Баннер',
'verbose_name_plural': 'Баннеры',
},
),
migrations.CreateModel(
name='Popup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=250, verbose_name='Название')),
('text', models.TextField(verbose_name='Текст публикации')),
('media', models.FileField(upload_to='popup/', verbose_name='Обложка')),
('button', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='popups', to='misc.button', verbose_name='Кнопка')),
],
options={
'verbose_name': 'Попап',
'verbose_name_plural': 'Попап',
},
),
migrations.CreateModel(
name='PopupReceiverInfo',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('viewed', models.BooleanField(default=False, verbose_name='Просмотрен ли')),
('popup', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='popup_receiver_infos', to='misc.popup', verbose_name='Рассылка')),
],
options={
'verbose_name': 'Информация о получателе попапа',
'verbose_name_plural': 'Информация о получателях рассылки',
},
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 5.0.4 on 2024-04-26 08:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('misc', '0001_initial'),
('users', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='popupreceiverinfo',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='popup_receiver_infos', to='users.tguser', verbose_name='Пользователь'),
),
migrations.AddField(
model_name='popup',
name='users',
field=models.ManyToManyField(related_name='popups', through='misc.PopupReceiverInfo', to='users.tguser', verbose_name='Пользователи'),
),
]

View File

View File

@ -0,0 +1,5 @@
from .button import Button
from .banner import Banner
from .popup import Popup
from .setting import Setting
from .style import Style

View File

@ -0,0 +1,18 @@
from django.db import models
class Banner(models.Model):
class Meta:
verbose_name = 'Баннер'
verbose_name_plural = 'Баннеры'
name = models.CharField(max_length=250, verbose_name='Название')
media = models.FileField(upload_to='banners/', verbose_name='Обложка')
button = models.ForeignKey('misc.Button', related_name='banners', on_delete=models.CASCADE,
null=True, blank=True,
verbose_name='Кнопка')
is_available = models.BooleanField(verbose_name='Доступен ли')
def __str__(self):
return f'{self.name} ({self.pk})'

View File

@ -0,0 +1,13 @@
from django.db import models
class Button(models.Model):
class Meta:
verbose_name = 'Кнопка'
verbose_name_plural = 'Кнопки'
text = models.TextField(verbose_name='Текст на кнопке')
link = models.CharField(max_length=250, verbose_name='Ссылка на кнопке')
def __str__(self):
return f'Кнопка №{self.pk}'

View File

@ -0,0 +1,32 @@
from django.db import models
class PopupReceiverInfo(models.Model):
class Meta:
verbose_name = 'Информация о получателе попапа'
verbose_name_plural = 'Информация о получателях рассылки'
popup = models.ForeignKey('misc.Popup', on_delete=models.CASCADE,
related_name='popup_receiver_infos', verbose_name='Рассылка')
user = models.ForeignKey('users.TGUser', on_delete=models.CASCADE,
related_name='popup_receiver_infos', verbose_name='Пользователь')
viewed = models.BooleanField(default=False, verbose_name='Просмотрен ли')
class Popup(models.Model):
class Meta:
verbose_name = 'Попап'
verbose_name_plural = 'Попап'
name = models.CharField(max_length=250, verbose_name='Название')
text = models.TextField(verbose_name='Текст публикации')
media = models.FileField(upload_to='popup/', verbose_name='Обложка')
users = models.ManyToManyField('users.TGUser', related_name='popups', through='misc.PopupReceiverInfo',
verbose_name='Пользователи')
button = models.ForeignKey('misc.Button', related_name='popups', on_delete=models.CASCADE,
null=True, blank=True,
verbose_name='Кнопка')
def __str__(self):
return f'Попап {self.name} от {self.time.strftime("%d.%m.%Y")}{self.id}'

View File

@ -0,0 +1,20 @@
from django.db import models
class Setting(models.Model):
class Meta:
verbose_name = 'Настройка'
verbose_name_plural = 'Настройки'
name = models.CharField(max_length=250, verbose_name='Машиночитаемое название',
help_text='Желательно в формате: SAMPLE_NAME')
description = models.TextField(verbose_name='Описание')
value = models.JSONField(verbose_name='Значение')
@property
def display_value(self):
return self.value['value']
display_value.fget.short_description = 'Значение'
def __str__(self):
return f'{self.name} ({self.pk})'

View File

@ -0,0 +1,26 @@
from django.db import models
from django.core.validators import RegexValidator
_color_validator = RegexValidator(
regex=r'#[0-9A-F]{6}',
message='Пожалуйста введите название текста в формате #11AA11'
)
class Style(models.Model):
class Meta:
verbose_name = 'Стиль'
verbose_name_plural = 'Стили'
name = models.CharField(max_length=250, verbose_name='Название')
color_1 = models.CharField(max_length=7, validators=[_color_validator], verbose_name='Основной цвет')
color_2 = models.CharField(max_length=7, validators=[_color_validator], verbose_name='Цвет №2')
color_3 = models.CharField(max_length=7, validators=[_color_validator], verbose_name='Цвет №3')
color_4 = models.CharField(max_length=7, validators=[_color_validator], verbose_name='Цвет №4')
description = models.TextField(verbose_name='Описание')
is_available = models.BooleanField(verbose_name='Доступен ли')
background = models.FileField(upload_to='styles/', verbose_name='Фон')
def __str__(self):
return f'{self.name} ({self.pk})'

View File

@ -0,0 +1 @@
from .setting import deliver_setting

View File

@ -0,0 +1,9 @@
from django.dispatch import receiver
from django.db.models.signals import post_save
from misc.models import Setting
from misc.celery import deliver_setting as deliver_setting_celery
@receiver(post_save, sender=Setting, dispatch_uid='deliver_setting')
def deliver_setting(sender, instance, **kwargs):
deliver_setting_celery.delay({instance.name: instance.value['value']})

3
backend/misc/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
backend/misc/views.py Normal file
View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

7
backend/pytest.ini Normal file
View File

@ -0,0 +1,7 @@
[pytest]
python_files = tests.py test_*.py *_tests.py
filterwarnings =
ignore::RuntimeWarning
env=
DJANGO_SETTINGS_MODULE=clicker.test_settings
addopts=--nomigrations

87
backend/requirements.txt Normal file
View File

@ -0,0 +1,87 @@
amqp==5.2.0
argon2-cffi==23.1.0
argon2-cffi-bindings==21.2.0
asgiref==3.8.1
asttokens==2.4.1
attrs==23.2.0
billiard==4.2.0
boto3==1.34.95
botocore==1.34.95
celery==5.3.6
certifi==2024.2.2
cffi==1.16.0
charset-normalizer==3.3.2
click==8.1.7
click-didyoumean==0.3.1
click-plugins==1.1.1
click-repl==0.3.0
coreapi==2.3.3
coreschema==0.0.4
cron-descriptor==1.4.3
decorator==5.1.1
Django==5.0.4
django-celery-beat==2.6.0
django-cors-headers==4.3.1
django-cte==1.3.2
django-filter==24.2
django-polymorphic==3.1.0
django-rest-framework==0.1.0
django-storages==1.14.2
django-timezone-field==6.1.0
djangorestframework==3.15.1
drf-spectacular==0.27.2
drf-spectacular-sidecar==2024.4.1
executing==2.0.1
gunicorn==22.0.0
idna==3.7
inflection==0.3.1
iniconfig==2.0.0
ipython==8.24.0
itypes==1.2.0
jedi==0.19.1
Jinja2==3.1.3
jmespath==1.0.1
jsonschema==4.21.1
jsonschema-specifications==2023.12.1
kombu==5.3.6
lazy-object-proxy==1.10.0
MarkupSafe==2.1.5
matplotlib-inline==0.1.7
openapi-codec==1.3.2
packaging==24.0
parso==0.8.4
pexpect==4.9.0
pika==1.3.2
pluggy==1.4.0
prompt-toolkit==3.0.43
psycopg2==2.9.9
ptyprocess==0.7.0
pure-eval==0.2.2
pycparser==2.22
Pygments==2.17.2
pytest==7.4.4
pytest-assert-utils==0.3.1
pytest-common-subject==1.0.6
pytest-django==4.8.0
pytest-drf==1.1.3
pytest-fixture-order==0.1.4
pytest-lambda==1.3.0
python-crontab==3.0.0
python-dateutil==2.9.0.post0
PyYAML==6.0.1
referencing==0.34.0
requests==2.31.0
rpds-py==0.18.0
s3transfer==0.10.1
simplejson==3.19.2
six==1.16.0
sqlparse==0.4.4
stack-data==0.6.3
traitlets==5.14.3
typing_extensions==4.11.0
tzdata==2024.1
uritemplate==4.1.1
urllib3==2.2.1
vine==5.1.0
wcwidth==0.2.13
wrapt==1.16.0

11
backend/scripts/dev.sh Executable file
View File

@ -0,0 +1,11 @@
#!/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

10
backend/scripts/prod.sh Executable file
View File

@ -0,0 +1,10 @@
#!/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 -

View File

@ -0,0 +1,7 @@
#!/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

View File

408
backend/users/admin.py Normal file
View File

@ -0,0 +1,408 @@
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

13
backend/users/apps.py Normal file
View File

@ -0,0 +1,13 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "users"
def ready(self):
from .signals import (
transaction_signal, transaction_pre_delete_signal, transaction_pre_save_signal, referral_signal,
mailing_list_signal,
)
from .celery import handle_mailing_list, check_mailing_lists

View File

@ -0,0 +1,86 @@
import time
import hmac
import base64
import hashlib
import json
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.contrib.auth.models import User
from rest_framework import authentication, exceptions
from users.models import TGUser
def validate_referred_by_id(referred_by_id):
if not referred_by_id:
return None
if not referred_by_id.isdigit():
return None
referred_by_id = int(referred_by_id)
return referred_by_id if TGUser.objects.filter(pk=referred_by_id).exists() else None
class TelegramValidationAuthentication(authentication.BaseAuthentication):
def authenticate(self, request):
token = request.META.get('HTTP_AUTHORIZATION', '')
if not token:
return None, None
if not token.startswith('TelegramToken '):
return None, None
token = ' '.join(token.split()[1:])
split_res = base64.b64decode(token).decode('utf-8').split(':')
try:
data_check_string = ':'.join(split_res[:-1]).strip().replace('/', '\\/')
_hash = split_res[-1]
except IndexError:
raise exceptions.AuthenticationFailed('Invalid token format')
secret = hmac.new(
'WebAppData'.encode(),
settings.TG_TOKEN.encode('utf-8'),
digestmod=hashlib.sha256
).digest()
actual_hash = hmac.new(
secret,
msg=data_check_string.encode('utf-8'),
digestmod=hashlib.sha256
).hexdigest()
if _hash != actual_hash:
raise exceptions.AuthenticationFailed('Invalid token (hash check failed)')
data_dict = dict([x.split('=') for x in data_check_string.split('\n')])
try:
auth_date = int(data_dict['auth_date'])
except KeyError:
raise exceptions.AuthenticationFailed('Invalid token (auth_date not found)')
except ValueError:
raise exceptions.AuthenticationFailed('Invalid token (auth_date is not an int)')
if auth_date + 60 * 30 < int(time.time()):
raise exceptions.AuthenticationFailed('Token expired')
user_info = json.loads(data_dict['user'])
try:
tg_user = TGUser.objects.get(pk=user_info['id'])
if tg_user.is_blocked:
raise exceptions.PermissionDenied('Пользователь заблокирован')
except ObjectDoesNotExist:
username = user_info.get('username', f'user-{user_info["id"]}')
pass_data = f'{username} ({user_info["id"]})'.encode()
referred_by_id = validate_referred_by_id(request.query_params.get('referred_by', None))
if (user_qs := User.objects.filter(username=username)).exists():
user = user_qs.first()
else:
user = User.objects.create_user(
username=username,
password=hashlib.md5(pass_data).hexdigest()
)
tg_user = TGUser.objects.create(
user=user,
tg_id=user_info['id'],
username=username,
referred_by_id=referred_by_id,
)
return tg_user.user, token

View File

@ -0,0 +1 @@
from .mailing_list import check_mailing_lists, handle_mailing_list

View File

@ -0,0 +1,55 @@
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
)

View File

@ -0,0 +1 @@
from .mailing_list_status import MailingListStatus

View File

@ -0,0 +1,10 @@
from django.db.models import TextChoices
from django.utils.translation import gettext_lazy as _
class MailingListStatus(TextChoices):
WAITING = '1', _('Ожидание')
QUEUED = '2', _('В очереди')
PROCESSING = '3', _('В обработке')
PARTLY_FINISHED = '4', _('Окончена с ошибками')
FINISHED = '5', _('Завершена')

View File

@ -0,0 +1 @@
from .not_enough_funds import NotEnoughFundsError

View File

@ -0,0 +1,7 @@
from rest_framework.exceptions import APIException
class NotEnoughFundsError(APIException):
status_code = 400
default_detail = 'Невозможно выполнить операцию, недостаточно баллов'
default_code = 'bad_request'

View File

@ -0,0 +1,144 @@
# Generated by Django 5.0.4 on 2024-04-26 08:14
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auction', '0001_initial'),
('clicks', '0001_initial'),
('contenttypes', '0002_remove_content_type_name'),
('misc', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Transaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время')),
('value', models.DecimalField(decimal_places=5, max_digits=105, verbose_name='Значение')),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
],
options={
'verbose_name': 'Транзакция',
'verbose_name_plural': 'Транзакции',
},
),
migrations.CreateModel(
name='TGUser',
fields=[
('tg_id', models.PositiveBigIntegerField(primary_key=True, serialize=False, verbose_name='Telegram ID')),
('username', models.CharField(max_length=250, verbose_name='Telegram username')),
('avatar', models.FileField(blank=True, null=True, upload_to='users/', verbose_name='Аватарка')),
('points', models.DecimalField(decimal_places=2, default=0, max_digits=102, verbose_name='Баллы')),
('referral_storage', models.DecimalField(decimal_places=5, default=0, max_digits=102, verbose_name='Реферальное хранилище')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время создания')),
('referred_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='referrees', to='users.tguser', verbose_name='Кем был приглашен')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='tg_user', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
],
options={
'verbose_name': 'ТГ-пользователь',
'verbose_name_plural': 'ТГ-пользователи',
},
),
migrations.CreateModel(
name='ReferralTransaction',
fields=[
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='users.transaction')),
],
options={
'verbose_name': 'Реферальная транзакция',
'verbose_name_plural': 'Реферальные транзакции',
},
bases=('users.transaction',),
),
migrations.CreateModel(
name='MailingList',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=250, verbose_name='Название')),
('time', models.DateTimeField(verbose_name='Дата и время публикации')),
('text', models.TextField(verbose_name='Текст публикации')),
('media', models.FileField(upload_to='mailing/', verbose_name='Вложение')),
('status', models.CharField(choices=[('1', 'Ожидание'), ('2', 'В очереди'), ('3', 'В обработке'), ('4', 'Окончена с ошибками'), ('5', 'Завершена')], default='1', max_length=1, verbose_name='Статус')),
('main_button', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='mailing_lists_for_main_button', to='misc.button', verbose_name='Кнопка')),
('webapp_button', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='mailing_lists_for_webapp_button', to='misc.button', verbose_name='Кнопка с веб-аппом')),
],
options={
'verbose_name': 'Рассылка',
'verbose_name_plural': 'Рассылки',
},
),
migrations.AddField(
model_name='transaction',
name='user',
field=models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name='transactions', to='users.tguser', verbose_name='Пользователь'),
),
migrations.CreateModel(
name='MailingListReceiverInfo',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sent', models.BooleanField(default=False, verbose_name='Отправлена ли')),
('clicked', models.BooleanField(default=False, verbose_name='Нажата ли')),
('mailing_list', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mailing_list_receiver_infos', to='users.mailinglist', verbose_name='Рассылка')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mailing_list_receiver_infos', to='users.tguser', verbose_name='Пользователь')),
],
options={
'verbose_name': 'Информация о получателе рассылки',
'verbose_name_plural': 'Информация о получателях рассылки',
},
),
migrations.AddField(
model_name='mailinglist',
name='users',
field=models.ManyToManyField(related_name='mailing_lists', through='users.MailingListReceiverInfo', to='users.tguser', verbose_name='Пользователи'),
),
migrations.CreateModel(
name='BetTransaction',
fields=[
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='users.transaction')),
('bet', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='transaction', to='auction.bet', verbose_name='Ставка')),
('refund_to', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='refunded_by', to='users.bettransaction', verbose_name='Какую транзакцию отменяет')),
],
options={
'verbose_name': 'Транзакция за ставку',
'verbose_name_plural': 'Транзакции за ставки',
},
bases=('users.transaction',),
),
migrations.CreateModel(
name='ClickTransaction',
fields=[
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='users.transaction')),
('click', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='transaction', to='clicks.click', verbose_name='Клик')),
],
options={
'verbose_name': 'Транзакция за клик',
'verbose_name_plural': 'Транзакции за клики',
},
bases=('users.transaction',),
),
migrations.CreateModel(
name='CommissionTransaction',
fields=[
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='users.transaction')),
('parent_transaction', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='commission', to='users.bettransaction', verbose_name='Родительская транзакция')),
],
options={
'verbose_name': 'Комиссионная транзакция',
'verbose_name_plural': 'Комиссионные транзакции',
},
bases=('users.transaction',),
),
migrations.AddConstraint(
model_name='tguser',
constraint=models.UniqueConstraint(fields=('tg_id',), name='unique_tg_id'),
),
]

View File

@ -0,0 +1,30 @@
# Generated by Django 5.0.4 on 2024-05-04 16:38
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('auction', '0003_user_penalties_and_auction_fixes'),
('users', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='tguser',
name='is_blocked',
field=models.BooleanField(default=False, verbose_name='Заблокирован ли'),
),
migrations.AddField(
model_name='tguser',
name='warning_count',
field=models.IntegerField(default=0, verbose_name='Количество предупреждений'),
),
migrations.AlterField(
model_name='bettransaction',
name='bet',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='auction.bet', verbose_name='Ставка'),
),
]

View File

View File

@ -0,0 +1,3 @@
from .mailing_list import MailingList, MailingListReceiverInfo
from .tg_user import TGUser
from .transactions import Transaction, BetTransaction, ClickTransaction, ReferralTransaction, CommissionTransaction

View File

@ -0,0 +1,42 @@
from django.db import models
from users.choices import MailingListStatus
class MailingListReceiverInfo(models.Model):
class Meta:
verbose_name = 'Информация о получателе рассылки'
verbose_name_plural = 'Информация о получателях рассылки'
mailing_list = models.ForeignKey('users.MailingList', on_delete=models.CASCADE,
related_name='mailing_list_receiver_infos', verbose_name='Рассылка')
user = models.ForeignKey('users.TGUser', on_delete=models.CASCADE,
related_name='mailing_list_receiver_infos', verbose_name='Пользователь')
sent = models.BooleanField(default=False, verbose_name='Отправлена ли')
clicked = models.BooleanField(default=False, verbose_name='Нажата ли')
class MailingList(models.Model):
class Meta:
verbose_name = 'Рассылка'
verbose_name_plural = 'Рассылки'
name = models.CharField(max_length=250, verbose_name='Название')
time = models.DateTimeField(verbose_name='Дата и время публикации')
text = models.TextField(verbose_name='Текст публикации')
media = models.FileField(upload_to='mailing/', verbose_name='Вложение')
users = models.ManyToManyField('users.TGUser', related_name='mailing_lists',
through='users.MailingListReceiverInfo',
verbose_name='Пользователи')
status = models.CharField(max_length=1, choices=MailingListStatus.choices, default=MailingListStatus.WAITING,
verbose_name='Статус')
main_button = models.ForeignKey('misc.Button', on_delete=models.CASCADE,
related_name='mailing_lists_for_main_button',
null=True, blank=True,
verbose_name='Кнопка')
webapp_button = models.ForeignKey('misc.Button', on_delete=models.CASCADE,
null=True, blank=True,
related_name='mailing_lists_for_webapp_button', verbose_name='Кнопка с веб-аппом')
def __str__(self):
return f'Рассылка {self.name} от {self.time.strftime("%d.%m.%Y")}{self.id}'

View File

@ -0,0 +1,43 @@
from decimal import Decimal
from django.db.models import Sum, Case, When, F, OuterRef, Subquery
from django.db.models.functions import RowNumber
from django.db.models.expressions import Window
from django.db import models
from django.contrib.auth.models import User
from django_cte import CTEManager, With
from misc.models import Setting
class TGUser(models.Model):
class Meta:
verbose_name = 'ТГ-пользователь'
verbose_name_plural = 'ТГ-пользователи'
constraints = [
models.UniqueConstraint(fields=('tg_id',), name='unique_tg_id')
]
user = models.OneToOneField(User, related_name='tg_user', on_delete=models.CASCADE, verbose_name='Пользователь')
tg_id = models.PositiveBigIntegerField(primary_key=True, verbose_name='Telegram ID')
username = models.CharField(max_length=250, verbose_name='Telegram username')
avatar = models.FileField(upload_to='users/', null=True, blank=True, verbose_name='Аватарка')
points = models.DecimalField(default=0, decimal_places=2, max_digits=102, verbose_name='Баллы')
referred_by = models.ForeignKey('users.TGUser', related_name='referrees', on_delete=models.SET_NULL,
null=True, blank=True, verbose_name='Кем был приглашен')
referral_storage = models.DecimalField(decimal_places=5, max_digits=102, default=0, verbose_name='Реферальное хранилище')
warning_count = models.IntegerField(default=0, verbose_name='Количество предупреждений')
is_blocked = models.BooleanField(default=False, verbose_name='Заблокирован ли')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата и время создания')
objects = CTEManager()
@property
def max_storage(self):
return Decimal(Setting.objects.get(name='MAX_STORAGE').value['value']) * (self.referrees.count() + 1)
@property
def rank(self):
return getattr(self, 'row_number', -1)
def __str__(self):
return f'{self.username} (№{self.tg_id})'

Some files were not shown because too many files have changed in this diff Show More