Compare commits

...

105 Commits

Author SHA1 Message Date
Egor 5ad9fd8ae7 Update Dockerfile 2025-12-21 08:25:31 +03:00
Egor f9cfaa4bc1 Update docker-registry.yml 2025-12-21 08:25:21 +03:00
Egor e1cc7a2e23 Update docker-hub.yml 2025-12-21 08:25:10 +03:00
Egor 7a0faa4b0a Merge pull request #2177 from BEDOLAGA-DEV/dev5
Remnawave 2.4.0+ Api Update, Menu layout Api, Partners Stats Api, Yookassa 22%
2025-12-21 08:13:51 +03:00
Egor a87d067133 Update exported_at to use timezone-aware datetime 2025-12-21 08:08:07 +03:00
Egor 42759854c2 Use timezone-aware datetime for calculations 2025-12-21 08:07:31 +03:00
Egor b2027df7f3 Replace deprecated datetime.utcnow() with _utcnow()
Replaced deprecated datetime.utcnow() with a custom _utcnow() function to return current UTC time as naive datetime.
2025-12-21 08:06:52 +03:00
Egor ce865a9b2a Update start.py 2025-12-21 08:05:53 +03:00
Egor 7dcd11b964 Update contests.py 2025-12-21 07:53:20 +03:00
Egor 6482284cd8 Use configured timezone for contest scheduling
Refactor time handling to use configured timezone for scheduling.
2025-12-21 07:52:35 +03:00
Egor 7f8fbf069b Update referral_contest_service.py 2025-12-21 07:52:00 +03:00
Egor 3e6064756e Update contests.py 2025-12-21 07:51:21 +03:00
Egor 1d1a9e0d78 Add tables for menu layout history and button clicks 2025-12-21 07:18:52 +03:00
Egor bc6e57765e Use UTC for registration days calculation 2025-12-21 07:18:31 +03:00
Egor 16031023cb Change datetime from now() to utcnow() 2025-12-21 07:17:52 +03:00
Egor 151ce092b9 Enhance button stats middleware with builtin callbacks
Added a set of known builtin callback data for button statistics logging.
2025-12-21 07:16:55 +03:00
Egor 01d3659ad2 Update stats_service.py 2025-12-21 07:16:24 +03:00
Egor 7dd0f5f7b0 Improve button configuration and logging
Refactor button construction and configuration handling.
2025-12-21 07:16:06 +03:00
Egor cfd79f4b23 Update partner_stats_service.py 2025-12-21 07:15:39 +03:00
PEDZEO 0cd355a878 Merge pull request #2175 from BEDOLAGA-DEV/buttons
Buttons
2025-12-21 05:02:45 +03:00
Egor 344ff65710 Merge pull request #2173 from Gy9vin/main
Юкасса сдк обновление
2025-12-21 04:52:41 +03:00
PEDZEO 1b47f2aa99 Remove unused imports from partner_stats_service.py to streamline the code and improve readability. 2025-12-21 04:38:42 +03:00
PEDZEO 1d01a77457 Add global partner statistics endpoints and enhance partner-related schemas; implement detailed stats retrieval for referrers and daily statistics. 2025-12-21 04:35:58 +03:00
PEDZEO aa669fa3cd Refactor logging in ButtonStatsMiddleware and cleanup debug endpoints in menu_layout; streamline button click logging and enhance error handling. 2025-12-21 04:05:26 +03:00
Egor e487776510 Update remnawave_service.py 2025-12-21 03:40:00 +03:00
Egor 9d038f7443 Update remnawave_api.py 2025-12-21 03:38:41 +03:00
PEDZEO 02ebcd368e Improve logging in get_top_users to include detailed user data and streamline response item creation 2025-12-21 03:15:25 +03:00
PEDZEO 7e1a2998af Add debug_stats endpoint to expose raw button click log data and enhance logging for user_id checks in get_top_users 2025-12-21 03:10:41 +03:00
Egor 0a5383eb80 Update LICENSE 2025-12-21 02:54:58 +03:00
PEDZEO 763d8f2aaa Enhance logging in get_stats_by_button_type to include total record count in button_click_logs and update stats logging to info level for better visibility. 2025-12-21 02:54:40 +03:00
PEDZEO ce11ec7c0e Enhance ButtonStatsMiddleware with improved logging and error handling; log middleware activation status based on MENU_LAYOUT_ENABLED setting. 2025-12-21 02:17:40 +03:00
PEDZEO 1bb0a5c000 Refactor MenuLayoutStatsService to return hourly statistics as a complete list for all 24 hours, ensuring counts default to 0 when no data exists for a given hour. 2025-12-21 01:14:04 +03:00
gy9vin 2cb6dfb4aa Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-12-21 00:38:14 +03:00
gy9vin 837473b274 Апи для детальной статистики по конкурсу рефералов 2025-12-21 00:38:08 +03:00
gy9vin aab32299ac Детальная статистика по конкурсу рефералов. 2025-12-21 00:19:13 +03:00
PEDZEO 2a3f1bac76 Refactor MenuLayoutStatsService to create a dictionary for quick access to weekday statistics, ensuring all weekdays are returned with a count of 0 if no data exists. 2025-12-20 23:33:35 +03:00
PEDZEO f4ed274623 Merge branch 'buttons' of https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot into buttons 2025-12-20 21:56:36 +03:00
PEDZEO 32c28aedea Enhance MenuLayoutStatsService to include daily, weekly, and monthly click statistics for buttons, improving analytics capabilities in the menu layout. 2025-12-20 21:56:33 +03:00
PEDZEO 69927a4db2 Improve error handling and logging in MenuLayout statistics endpoints; change button click log ordering to descending. 2025-12-20 21:50:13 +03:00
gy9vin bdc1df737b Фикс автопродления и Фиксы конкурсов 2025-12-20 14:57:38 +03:00
gy9vin c8c79ea807 Улучшение реферального конкурса! 2025-12-20 14:32:26 +03:00
PEDZEO d75fc0c60f Add statistics endpoints in MenuLayoutService for button clicks, including by type, hour, weekday, top users, period comparison, and user click sequences 2025-12-20 03:32:34 +03:00
PEDZEO 931b282f5b Enhance button handling in MenuLayoutService to improve connect button identification and URL management 2025-12-20 03:27:31 +03:00
PEDZEO 10bc00d429 Merge pull request #2174 from BEDOLAGA-DEV/buttons
Buttons
2025-12-20 02:55:59 +03:00
PEDZEO dd24b7ffde Add ButtonStatsMiddleware for automatic button click logging in bot setup 2025-12-20 02:42:40 +03:00
PEDZEO 5fa627bd7f Refactor subscription days calculation and update autopay property naming in main menu keyboard 2025-12-20 02:31:52 +03:00
PEDZEO b8671ef07d Refactor subscription days variable usage in MenuLayoutService to improve clarity and consistency 2025-12-20 02:29:41 +03:00
PEDZEO 7a30aa23dc Update subscription parameter naming in main menu keyboard function 2025-12-20 02:23:55 +03:00
PEDZEO a87d52f2bc Update subscription placeholder naming in menu layout and adjust context variable accordingly 2025-12-20 02:15:51 +03:00
PEDZEO 5919cfff16 Refactor referral data retrieval and update subscription placeholder naming in menu layout 2025-12-20 01:55:26 +03:00
PEDZEO e743689b34 Enhance main menu keyboard functionality by adding user data support and subscription details retrieval 2025-12-20 01:40:23 +03:00
PEDZEO 37dd5ede9f fix 2025-12-19 23:27:00 +03:00
Mikhail 930aabc166 Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-19 17:42:58 +03:00
gy9vin f70ad92efe Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-12-19 17:42:10 +03:00
gy9vin 01fd7a317d НДС 22 2025-12-19 17:42:03 +03:00
Egor 507df0af33 Merge pull request #2172 from BEDOLAGA-DEV/d2pxq4-bedolaga/fix-missing-localization-key-for-ru
Add missing Buy Traffic localization key
2025-12-19 07:28:16 +03:00
PEDZEO af91423dec Merge pull request #2171 from BEDOLAGA-DEV/buttons
Buttons
2025-12-19 07:27:44 +03:00
Egor 893ff0e8f1 Add missing Buy Traffic localization key 2025-12-19 07:27:27 +03:00
Egor cde838864f Merge pull request #2170 from BEDOLAGA-DEV/main
w
2025-12-19 07:23:40 +03:00
Egor 2eb2661848 Merge pull request #2167 from BEDOLAGA-DEV/dev5
Dev5
2025-12-19 07:23:00 +03:00
Egor d7a935906e Merge pull request #2168 from Gy9vin/main
Фиксы
2025-12-19 07:22:42 +03:00
PEDZEO b81400105f Add button open mode and webapp url 2025-12-19 04:02:58 +03:00
PEDZEO 8c3e71bdfd Fix menu layout update conversions 2025-12-19 03:22:44 +03:00
PEDZEO 025d50675f test 2025-12-19 02:14:57 +03:00
Mikhail 5f540d907e Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-18 17:51:05 +03:00
gy9vin c6175255a5 фикс кнопок конкурсов 2025-12-18 17:50:36 +03:00
gy9vin 71facd9624 Активация подписки после пополнения баланса! 2025-12-18 08:45:59 +03:00
Egor 89a5f5c6f7 Merge pull request #2166 from BEDOLAGA-DEV/j0zhkm-bedolaga/add-subscription-management-for-users-who-unsubscribed
Add toggle for trial deactivation on channel unsubscribe
2025-12-18 03:23:14 +03:00
Egor 7b60be1ec7 Add toggle for trial deactivation on channel unsubscribe 2025-12-18 03:04:13 +03:00
Egor ed314ca19b Merge pull request #2165 from Gy9vin/main
Фиксы, правки и хотелки
2025-12-18 00:16:25 +03:00
Mikhail 0b714893e7 Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-18 00:14:20 +03:00
gy9vin 5ca0f5fc27 Еще правки 2025-12-18 00:13:57 +03:00
Egor 31fa7b7072 Merge pull request #2164 from BEDOLAGA-DEV/main
ц
2025-12-17 20:23:29 +03:00
Egor 86d7003c33 Merge pull request #2161 from Gy9vin/main
Кнопка конкурсов + прототип налоговой. не трогайте налоговую еще!!!
2025-12-17 19:13:32 +03:00
Egor 753d77b4c2 Merge pull request #2162 from Vysokostnyi/main
Добавлено передача telegram_user_id в описание платежа
2025-12-17 19:13:05 +03:00
Vysokostnyi 4add298aab Добавлено передача telegram_user_id в описание платежа 2025-12-16 02:41:58 +03:00
gy9vin e76f2f3d50 Кнопка конкурсов + прототип налоговой. не трогайте налоговую еще!!! 2025-12-16 00:59:23 +03:00
Egor dcb0002f4a Merge pull request #2160 from Gy9vin/main
Конкурсные
2025-12-15 22:58:21 +03:00
gy9vin 332c20fc45 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-12-15 21:36:23 +03:00
gy9vin 305a3c4490 Фиксы по конкурсам. 2025-12-15 21:34:56 +03:00
Mikhail a9ecd5f620 Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-15 20:05:03 +03:00
gy9vin 2f0594e361 Конкурсная система. 2025-12-15 20:04:39 +03:00
Egor 65b36c3a5f Merge pull request #2159 from BEDOLAGA-DEV/revert-2158-c8yqa8-bedolaga/add-subscription-settings-for-happ_cryptolink
Revert "Add limited Happ cryptolink support"
2025-12-15 11:12:22 +03:00
Egor f8ef2b9f5a Revert "Add limited Happ cryptolink support" 2025-12-15 11:12:14 +03:00
Egor 70a283d1f1 Merge pull request #2158 from BEDOLAGA-DEV/c8yqa8-bedolaga/add-subscription-settings-for-happ_cryptolink
Add limited Happ cryptolink support
2025-12-15 11:00:54 +03:00
Egor f2a5032f15 Refresh Happ link before displaying subscription 2025-12-15 11:00:39 +03:00
Egor 0f5fcdd067 Merge pull request #2157 from BEDOLAGA-DEV/revert-2156-2gz1mq-bedolaga/add-subscription-settings-for-happ_cryptolink
Revert "Add limited Happ cryptolink support"
2025-12-15 11:00:26 +03:00
Egor 80388be54f Revert "Add limited Happ cryptolink support" 2025-12-15 11:00:17 +03:00
Egor 47f0b77138 Merge pull request #2156 from BEDOLAGA-DEV/2gz1mq-bedolaga/add-subscription-settings-for-happ_cryptolink
Add limited Happ cryptolink support
2025-12-15 10:41:18 +03:00
Egor 162e7da350 Add cooldown for Happ device reset links 2025-12-15 10:40:59 +03:00
Egor 07e1e08b1d Merge pull request #2154 from BEDOLAGA-DEV/revert-2153-49fvja-bedolaga/add-web-admin-button-for-admins
Revert "Add admin web panel miniapp toggle"
2025-12-15 07:24:51 +03:00
Egor fc955bd3b2 Revert "Add admin web panel miniapp toggle" 2025-12-15 07:24:41 +03:00
Egor 1b504063d2 Merge pull request #2153 from BEDOLAGA-DEV/49fvja-bedolaga/add-web-admin-button-for-admins
Add admin web panel miniapp toggle
2025-12-15 07:19:01 +03:00
Egor 174f9b8517 Add admin web panel miniapp toggle 2025-12-15 07:18:45 +03:00
Egor 3706d5ceb4 Merge pull request #2152 from BEDOLAGA-DEV/main
w
2025-12-14 19:35:39 +03:00
Egor 3577054d79 Merge pull request #2151 from Gy9vin/main
Коркурсная система + АПИ
2025-12-14 19:32:41 +03:00
gy9vin afd4fe8d1d Конкурсы +АПИ 2025-12-14 14:37:29 +03:00
gy9vin 1409a0ab8d Конкурсы 2025-12-14 01:38:22 +03:00
Egor 9df8ca52d6 Merge pull request #2150 from BEDOLAGA-DEV/dev5
Dev5
2025-12-12 08:54:47 +03:00
Egor 0902efc007 Merge pull request #2149 from BEDOLAGA-DEV/9ks456-bedolaga/fix-url-validation-error-in-bot
Restrict blacklist URL input to waiting state
2025-12-12 08:52:51 +03:00
Egor 0b4fea02a1 Restrict blacklist URL input to waiting state 2025-12-12 08:52:36 +03:00
Egor 01580aae74 Merge pull request #2148 from BEDOLAGA-DEV/dev5
Dev5
2025-12-12 06:58:03 +03:00
Egor 7e022a1eca Merge pull request #2147 from BEDOLAGA-DEV/om94g5-bedolaga/update-subscription-link-display
Wrap Happ CryptoLink subscription link in blockquote
2025-12-12 06:56:36 +03:00
Egor f31c64602c Wrap Happ CryptoLink subscription link in blockquote 2025-12-12 06:55:58 +03:00
Egor 1e56e4eaa6 Merge pull request #2146 from BEDOLAGA-DEV/main
w
2025-12-12 06:08:29 +03:00
67 changed files with 12547 additions and 270 deletions
+24 -4
View File
@@ -37,6 +37,7 @@ SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000 # Порог баланс
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
CHANNEL_LINK= # Опционально ссылка на канал
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
# ===== DATABASE CONFIGURATION =====
# Режим базы данных: "auto", "postgresql", "sqlite"
@@ -136,9 +137,8 @@ FIXED_TRAFFIC_LIMIT_GB=100
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
AVAILABLE_RENEWAL_PERIODS=30,90,180
# ===== НАСТРОЙКИ ПРОСТОЙ ПОКУПКИ =====
# Включить упрощённую покупку из меню
SIMPLE_SUBSCRIPTION_ENABLED=false
# ===== ПРОСТАЯ ПОКУПКА ПОДПИСКИ =====
SIMPLE_SUBSCRIPTION_ENABLED=true
# Стандартный период (должен совпадать с одним из AVAILABLE_SUBSCRIPTION_PERIODS)
SIMPLE_SUBSCRIPTION_PERIOD_DAYS=30
# Сколько устройств выдаётся в рамках простой подписки
@@ -173,6 +173,8 @@ PRICE_PER_DEVICE=10000
DEVICES_SELECTION_ENABLED=true
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
DEVICES_SELECTION_DISABLED_AMOUNT=0
# Отключение превью ссылок в сообщениях бота
DISABLE_WEB_PAGE_PREVIEW=false
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
REFERRAL_PROGRAM_ENABLED=true
@@ -181,6 +183,7 @@ REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
REFERRAL_INVITER_BONUS_KOPEKS=10000
REFERRAL_COMMISSION_PERCENT=25
# Уведомления
REFERRAL_NOTIFICATIONS_ENABLED=true
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
@@ -223,7 +226,7 @@ YOOKASSA_VAT_CODE=1
# 1 - НДС не облагается
# 2 - НДС 0%
# 3 - НДС 10%
# 4 - НДС 20%
# 4 - НДС 20%/22%
# 5 - НДС 10/110
# 6 - НДС 20/120
@@ -275,6 +278,15 @@ PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
# Интервал (в минутах) между автоматическими проверками пополнений
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=10
# ===== НАЛОГОВАЯ СЛУЖБА (NaloGO) =====
# Автоматическая отправка чеков в налоговую при пополнении баланса
NALOGO_ENABLED=false
NALOGO_RECEIPTS_ENABLED=false
NALOGO_INN= # ИНН самозанятого
NALOGO_PASSWORD= # Пароль от личного кабинета налоговой
NALOGO_DEVICE_ID= # Опционально: ID устройства для авторизации
NALOGO_STORAGE_PATH=./nalogo_tokens.json # Путь к файлу с токенами
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
# чтобы избежать блокировок платежных систем
@@ -494,6 +506,14 @@ WEBHOOK_ENQUEUE_TIMEOUT=0.1
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
BOT_RUN_MODE=polling # polling, webhook или both
# ===== КОНКУРСНАЯ СИСТЕМА =====
CONTESTS_ENABLED=false
CONTESTS_BUTTON_VISIBLE=false
# ===== КНОПКА АКТИВАЦИИ =====
ACTIVATE_BUTTON_VISIBLE=false
# ACTIVATE_BUTTON_TEXT=активировать
# ===== ЕДИНЫЙ ВЕБ-СЕРВЕР =====
WEB_API_ENABLED=false
WEB_API_HOST=0.0.0.0
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.9.1-$(git rev-parse --short HEAD)"
VERSION="v2.9.2-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.9.1-dev-$(git rev-parse --short HEAD)"
VERSION="v2.9.2-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.9.1-pr-$(git rev-parse --short HEAD)"
VERSION="v2.9.2-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.9.1-$(git rev-parse --short HEAD)"
VERSION="v2.9.2-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.9.1-dev-$(git rev-parse --short HEAD)"
VERSION="v2.9.2-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.9.1-pr-$(git rev-parse --short HEAD)"
VERSION="v2.9.2-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.9.1"
ARG VERSION="v2.9.2"
ARG BUILD_DATE
ARG VCS_REF
+25
View File
@@ -1,3 +1,28 @@
"Commons Clause" License Condition v1.0
The Software is provided to you by the Licensor under the License,
as defined below, subject to the following condition.
Without limiting other conditions in the License, the grant of rights
under the License will not include, and the License does not grant to
you, the right to Sell the Software.
For purposes of the foregoing, "Sell" means practicing any or all of
the rights granted to you under the License to provide to third parties,
for a fee or other consideration (including without limitation fees for
hosting or consulting/support services related to the Software), a
product or service whose value derives, entirely or substantially, from
the functionality of the Software.
Any license notice or attribution required by the License must also
include this Commons Clause License Condition notice.
Software: remnawave-bedolaga-telegram-bot
License: MIT
Licensor: Fr1ngg
---
MIT License
Copyright (c) 2025 Fr1ngg
+13
View File
@@ -12,6 +12,7 @@ from app.middlewares.throttling import ThrottlingMiddleware
from app.middlewares.subscription_checker import SubscriptionStatusMiddleware
from app.middlewares.maintenance import MaintenanceMiddleware
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
from app.middlewares.button_stats import ButtonStatsMiddleware
from app.services.maintenance_service import maintenance_service
from app.utils.cache import cache
@@ -47,6 +48,8 @@ from app.handlers.admin import (
maintenance as admin_maintenance,
promo_groups as admin_promo_groups,
campaigns as admin_campaigns,
contests as admin_contests,
daily_contests as admin_daily_contests,
promo_offers as admin_promo_offers,
user_messages as admin_user_messages,
updates as admin_updates,
@@ -63,6 +66,7 @@ from app.handlers.admin import (
payments as admin_payments,
trials as admin_trials,
)
from app.handlers import contests as user_contests
from app.handlers.stars_payments import register_stars_handlers
from app.utils.message_patch import patch_message_methods
@@ -124,6 +128,12 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.pre_checkout_query.middleware(display_name_middleware)
dp.message.middleware(ThrottlingMiddleware())
dp.callback_query.middleware(ThrottlingMiddleware())
# Middleware для автоматического логирования кликов по кнопкам
if settings.MENU_LAYOUT_ENABLED:
button_stats_middleware = ButtonStatsMiddleware()
dp.callback_query.middleware(button_stats_middleware)
logger.info("📊 ButtonStatsMiddleware активирован")
if settings.CHANNEL_IS_REQUIRED_SUB:
from app.middlewares.channel_checker import ChannelCheckerMiddleware
@@ -162,6 +172,8 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_polls.register_handlers(dp)
admin_promo_groups.register_handlers(dp)
admin_campaigns.register_handlers(dp)
admin_contests.register_handlers(dp)
admin_daily_contests.register_handlers(dp)
admin_promo_offers.register_handlers(dp)
admin_maintenance.register_handlers(dp)
admin_user_messages.register_handlers(dp)
@@ -182,6 +194,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_blacklist.register_blacklist_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_contests.register_handlers(dp)
user_polls.register_handlers(dp)
simple_subscription.register_simple_subscription_handlers(dp)
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
+55 -8
View File
@@ -54,6 +54,7 @@ class Settings(BaseSettings):
CHANNEL_SUB_ID: Optional[str] = None
CHANNEL_LINK: Optional[str] = None
CHANNEL_IS_REQUIRED_SUB: bool = False
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: bool = True
DATABASE_URL: Optional[str] = None
@@ -156,11 +157,27 @@ class Settings(BaseSettings):
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3
# Конкурсы (глобальный флаг, будет расширяться под разные типы)
CONTESTS_ENABLED: bool = False
CONTESTS_BUTTON_VISIBLE: bool = False
# Для обратной совместимости со старыми конфигами
REFERRAL_CONTESTS_ENABLED: bool = False
BLACKLIST_CHECK_ENABLED: bool = False
BLACKLIST_GITHUB_URL: Optional[str] = None
BLACKLIST_UPDATE_INTERVAL_HOURS: int = 24
BLACKLIST_IGNORE_ADMINS: bool = True
# Настройки простой покупки
SIMPLE_SUBSCRIPTION_ENABLED: bool = True
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT: int = 1
SIMPLE_SUBSCRIPTION_TRAFFIC_GB: int = 0 # 0 означает безлимит
SIMPLE_SUBSCRIPTION_SQUAD_UUID: Optional[str] = None
# Настройки конструктора меню (API)
MENU_LAYOUT_ENABLED: bool = False # Включить управление меню через API
# Настройки мониторинга трафика
TRAFFIC_MONITORING_ENABLED: bool = False
TRAFFIC_THRESHOLD_GB_PER_DAY: float = 10.0 # Порог трафика в ГБ за сутки
@@ -169,6 +186,8 @@ class Settings(BaseSettings):
AUTOPAY_WARNING_DAYS: str = "3,1"
ENABLE_AUTOPAY: bool = False
DEFAULT_AUTOPAY_ENABLED: bool = False
DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3
MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000
@@ -215,14 +234,19 @@ class Settings(BaseSettings):
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
NALOGO_ENABLED: bool = False
NALOGO_RECEIPTS_ENABLED: bool = False
NALOGO_INN: Optional[str] = None
NALOGO_PASSWORD: Optional[str] = None
NALOGO_DEVICE_ID: Optional[str] = None
NALOGO_STORAGE_PATH: str = "./nalogo_tokens.json"
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
# Настройки простой покупки
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT: int = 1
SIMPLE_SUBSCRIPTION_TRAFFIC_GB: int = 0 # 0 означает безлимит
SIMPLE_SUBSCRIPTION_SQUAD_UUID: Optional[str] = None
# Отключение превью ссылок в сообщениях бота
DISABLE_WEB_PAGE_PREVIEW: bool = False
ACTIVATE_BUTTON_VISIBLE: bool = False
ACTIVATE_BUTTON_TEXT: str = "активировать"
PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса"
PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки"
PAYMENT_SERVICE_NAME: str = "Интернет-сервис"
@@ -988,6 +1012,11 @@ class Settings(BaseSettings):
self.YOOKASSA_SHOP_ID is not None and
self.YOOKASSA_SECRET_KEY is not None)
def is_nalogo_enabled(self) -> bool:
return (self.NALOGO_ENABLED and
self.NALOGO_INN is not None and
self.NALOGO_PASSWORD is not None)
def is_support_topup_enabled(self) -> bool:
return bool(self.SUPPORT_TOPUP_ENABLED)
@@ -1190,6 +1219,16 @@ class Settings(BaseSettings):
return False
return self.HIDE_SUBSCRIPTION_LINK
def is_contests_enabled(self) -> bool:
if getattr(self, "CONTESTS_ENABLED", False):
return True
# legacy fallback
return bool(getattr(self, "REFERRAL_CONTESTS_ENABLED", False))
def is_referral_contests_enabled(self) -> bool:
# kept for backward compatibility
return self.is_contests_enabled()
def get_happ_cryptolink_redirect_template(self) -> Optional[str]:
template = (self.HAPP_CRYPTOLINK_REDIRECT_TEMPLATE or "").strip()
return template or None
@@ -1311,10 +1350,18 @@ class Settings(BaseSettings):
except (ValueError, AttributeError):
return [30, 90, 180]
def get_balance_payment_description(self, amount_kopeks: int) -> str:
def get_balance_payment_description(self, amount_kopeks: int, telegram_user_id: Optional[int] = None) -> str:
# Базовое описание
description = f"{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}"
# Если передан user_id, добавляем его
if telegram_user_id is not None:
description += f" (ID {telegram_user_id})"
# Формируем финальную строку по шаблону
return self.PAYMENT_BALANCE_TEMPLATE.format(
service_name=self.PAYMENT_SERVICE_NAME,
description=f"{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}"
description=description
)
def get_subscription_payment_description(self, period_days: int, amount_kopeks: int) -> str:
+210
View File
@@ -0,0 +1,210 @@
import logging
from datetime import datetime
from typing import List, Optional, Sequence, Tuple
from sqlalchemy import and_, delete, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import ContestTemplate, ContestRound, ContestAttempt, User
logger = logging.getLogger(__name__)
# Templates
async def get_template_by_id(db: AsyncSession, template_id: int) -> Optional[ContestTemplate]:
result = await db.execute(
select(ContestTemplate).where(ContestTemplate.id == template_id)
)
return result.scalar_one_or_none()
async def get_template_by_slug(db: AsyncSession, slug: str) -> Optional[ContestTemplate]:
result = await db.execute(
select(ContestTemplate).where(ContestTemplate.slug == slug)
)
return result.scalar_one_or_none()
async def list_templates(db: AsyncSession, enabled_only: bool = True) -> List[ContestTemplate]:
query = select(ContestTemplate).order_by(ContestTemplate.id)
if enabled_only:
query = query.where(ContestTemplate.is_enabled.is_(True))
result = await db.execute(query)
return list(result.scalars().all())
async def upsert_template(
db: AsyncSession,
*,
slug: str,
name: str,
description: str = "",
prize_days: int = 1,
max_winners: int = 1,
attempts_per_user: int = 1,
times_per_day: int = 1,
schedule_times: Optional[str] = None,
cooldown_hours: int = 24,
payload: Optional[dict] = None,
is_enabled: Optional[bool] = None,
) -> ContestTemplate:
template = await get_template_by_slug(db, slug)
if not template:
template = ContestTemplate(slug=slug)
db.add(template)
template.name = name
template.description = description
template.prize_days = prize_days
template.max_winners = max_winners
template.attempts_per_user = attempts_per_user
template.times_per_day = times_per_day
template.schedule_times = schedule_times
template.cooldown_hours = cooldown_hours
template.payload = payload or {}
if is_enabled is not None:
template.is_enabled = is_enabled
await db.commit()
await db.refresh(template)
return template
async def update_template_fields(
db: AsyncSession,
template: ContestTemplate,
**fields: object,
) -> ContestTemplate:
for key, value in fields.items():
if hasattr(template, key):
setattr(template, key, value)
await db.commit()
await db.refresh(template)
return template
# Rounds
async def create_round(
db: AsyncSession,
*,
template: ContestTemplate,
starts_at: datetime,
ends_at: datetime,
payload: dict,
) -> ContestRound:
round_obj = ContestRound(
template_id=template.id,
starts_at=starts_at,
ends_at=ends_at,
status="active",
payload=payload,
max_winners=template.max_winners,
attempts_per_user=template.attempts_per_user,
)
db.add(round_obj)
await db.commit()
await db.refresh(round_obj)
return round_obj
async def get_active_rounds(db: AsyncSession) -> List[ContestRound]:
now = datetime.utcnow()
result = await db.execute(
select(ContestRound)
.options(selectinload(ContestRound.template))
.where(
and_(
ContestRound.status == "active",
ContestRound.starts_at <= now,
ContestRound.ends_at >= now,
)
)
.order_by(ContestRound.starts_at)
)
return list(result.scalars().all())
async def get_active_round_by_template(db: AsyncSession, template_id: int) -> Optional[ContestRound]:
now = datetime.utcnow()
result = await db.execute(
select(ContestRound)
.options(selectinload(ContestRound.template))
.where(
and_(
ContestRound.template_id == template_id,
ContestRound.status == "active",
ContestRound.starts_at <= now,
ContestRound.ends_at >= now,
)
)
.order_by(desc(ContestRound.starts_at))
)
return result.scalars().first()
async def finish_round(db: AsyncSession, round_obj: ContestRound) -> ContestRound:
round_obj.status = "finished"
await db.commit()
await db.refresh(round_obj)
return round_obj
async def increment_winner_count(db: AsyncSession, round_obj: ContestRound) -> ContestRound:
round_obj.winners_count += 1
await db.commit()
await db.refresh(round_obj)
return round_obj
# Attempts
async def get_attempt(db: AsyncSession, round_id: int, user_id: int) -> Optional[ContestAttempt]:
result = await db.execute(
select(ContestAttempt).where(
and_(
ContestAttempt.round_id == round_id,
ContestAttempt.user_id == user_id,
)
)
)
return result.scalar_one_or_none()
async def create_attempt(
db: AsyncSession,
*,
round_id: int,
user_id: int,
answer: Optional[str],
is_winner: bool,
) -> ContestAttempt:
attempt = ContestAttempt(
round_id=round_id,
user_id=user_id,
answer=answer,
is_winner=is_winner,
)
db.add(attempt)
await db.commit()
await db.refresh(attempt)
return attempt
async def clear_attempts(db: AsyncSession, round_id: int) -> int:
result = await db.execute(delete(ContestAttempt).where(ContestAttempt.round_id == round_id))
deleted_count = result.rowcount
await db.commit()
return deleted_count
async def list_winners(db: AsyncSession, round_id: int) -> Sequence[Tuple[User, ContestAttempt]]:
result = await db.execute(
select(User, ContestAttempt)
.join(ContestAttempt, ContestAttempt.user_id == User.id)
.where(
and_(
ContestAttempt.round_id == round_id,
ContestAttempt.is_winner.is_(True),
)
)
)
return result.all()
+299
View File
@@ -0,0 +1,299 @@
import logging
from datetime import datetime, date, time, timezone
from typing import List, Optional, Sequence, Tuple
from sqlalchemy import and_, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, aliased
from app.database.models import (
ReferralContest,
ReferralContestEvent,
User,
Transaction,
TransactionType,
)
logger = logging.getLogger(__name__)
async def create_referral_contest(
db: AsyncSession,
*,
title: str,
description: Optional[str],
prize_text: Optional[str],
contest_type: str,
start_at: datetime,
end_at: datetime,
daily_summary_time: time,
daily_summary_times: Optional[str] = None,
timezone_name: str,
created_by: Optional[int] = None,
) -> ReferralContest:
contest = ReferralContest(
title=title,
description=description,
prize_text=prize_text,
contest_type=contest_type,
start_at=start_at,
end_at=end_at,
daily_summary_time=daily_summary_time,
daily_summary_times=daily_summary_times,
timezone=timezone_name or "UTC",
created_by=created_by,
)
db.add(contest)
await db.commit()
await db.refresh(contest)
return contest
async def list_referral_contests(
db: AsyncSession,
*,
limit: int = 10,
offset: int = 0,
contest_type: Optional[str] = None,
) -> List[ReferralContest]:
query = (
select(ReferralContest)
.options(selectinload(ReferralContest.creator))
.order_by(desc(ReferralContest.start_at))
.offset(offset)
.limit(limit)
)
if contest_type:
query = query.where(ReferralContest.contest_type == contest_type)
result = await db.execute(query)
return list(result.scalars().all())
async def get_referral_contests_count(db: AsyncSession, contest_type: Optional[str] = None) -> int:
query = select(func.count(ReferralContest.id))
if contest_type:
query = query.where(ReferralContest.contest_type == contest_type)
result = await db.execute(query)
return int(result.scalar_one())
async def get_referral_contest(db: AsyncSession, contest_id: int) -> Optional[ReferralContest]:
result = await db.execute(
select(ReferralContest)
.options(
selectinload(ReferralContest.creator),
selectinload(ReferralContest.events),
)
.where(ReferralContest.id == contest_id)
)
return result.scalar_one_or_none()
async def update_referral_contest(
db: AsyncSession,
contest: ReferralContest,
**fields: object,
) -> ReferralContest:
for key, value in fields.items():
if hasattr(contest, key):
setattr(contest, key, value)
await db.commit()
await db.refresh(contest)
return contest
async def toggle_referral_contest(
db: AsyncSession,
contest: ReferralContest,
is_active: bool,
) -> ReferralContest:
contest.is_active = is_active
return await update_referral_contest(db, contest)
async def get_contests_for_events(
db: AsyncSession,
now_utc: datetime,
*,
contest_types: Optional[List[str]] = None,
) -> List[ReferralContest]:
query = select(ReferralContest).where(
and_(
ReferralContest.is_active.is_(True),
ReferralContest.start_at <= now_utc,
ReferralContest.end_at >= now_utc,
)
)
if contest_types:
query = query.where(ReferralContest.contest_type.in_(contest_types))
result = await db.execute(query)
return list(result.scalars().all())
async def get_contests_for_summaries(db: AsyncSession) -> List[ReferralContest]:
result = await db.execute(
select(ReferralContest).where(ReferralContest.is_active.is_(True))
)
return list(result.scalars().all())
async def add_contest_event(
db: AsyncSession,
*,
contest_id: int,
referrer_id: int,
referral_id: int,
amount_kopeks: int = 0,
event_type: str = "subscription_purchase",
) -> Optional[ReferralContestEvent]:
existing = await db.execute(
select(ReferralContestEvent).where(
and_(
ReferralContestEvent.contest_id == contest_id,
ReferralContestEvent.referral_id == referral_id,
)
)
)
if existing.scalar_one_or_none():
return None
event = ReferralContestEvent(
contest_id=contest_id,
referrer_id=referrer_id,
referral_id=referral_id,
amount_kopeks=amount_kopeks,
event_type=event_type,
occurred_at=datetime.utcnow(),
)
db.add(event)
await db.commit()
await db.refresh(event)
return event
async def get_contest_leaderboard(
db: AsyncSession,
contest_id: int,
*,
limit: Optional[int] = None,
) -> Sequence[Tuple[User, int, int]]:
contest = await get_referral_contest(db, contest_id)
if not contest:
return []
query = (
select(
User,
func.count(ReferralContestEvent.id).label("referral_count"),
func.coalesce(func.sum(ReferralContestEvent.amount_kopeks), 0).label("total_amount"),
)
.join(User, User.id == ReferralContestEvent.referrer_id)
.where(ReferralContestEvent.contest_id == contest_id)
.group_by(User.id)
.order_by(desc("referral_count"), desc("total_amount"), User.id)
)
if limit:
query = query.limit(limit)
result = await db.execute(query)
leaderboard = result.all()
return leaderboard
async def get_contest_participants(
db: AsyncSession,
contest_id: int,
) -> Sequence[Tuple[User, int]]:
result = await db.execute(
select(User, func.count(ReferralContestEvent.id).label("referral_count"))
.join(User, User.id == ReferralContestEvent.referrer_id)
.where(ReferralContestEvent.contest_id == contest_id)
.group_by(User.id)
)
return result.all()
async def get_referrer_score(
db: AsyncSession,
contest_id: int,
referrer_id: int,
*,
start: Optional[datetime] = None,
end: Optional[datetime] = None,
) -> int:
query = select(func.count(ReferralContestEvent.id)).where(
and_(
ReferralContestEvent.contest_id == contest_id,
ReferralContestEvent.referrer_id == referrer_id,
)
)
if start:
query = query.where(ReferralContestEvent.occurred_at >= start)
if end:
query = query.where(ReferralContestEvent.occurred_at < end)
result = await db.execute(query)
return int(result.scalar_one())
async def get_contest_events_count(
db: AsyncSession,
contest_id: int,
*,
start: Optional[datetime] = None,
end: Optional[datetime] = None,
) -> int:
query = select(func.count(ReferralContestEvent.id)).where(
ReferralContestEvent.contest_id == contest_id
)
if start:
query = query.where(ReferralContestEvent.occurred_at >= start)
if end:
query = query.where(ReferralContestEvent.occurred_at < end)
result = await db.execute(query)
return int(result.scalar_one())
async def get_contest_events(
db: AsyncSession,
contest_id: int,
) -> List[ReferralContestEvent]:
result = await db.execute(
select(ReferralContestEvent).where(ReferralContestEvent.contest_id == contest_id)
)
return list(result.scalars().all())
async def mark_daily_summary_sent(
db: AsyncSession,
contest: ReferralContest,
summary_date: date,
summary_dt_utc: Optional[datetime] = None,
) -> ReferralContest:
contest.last_daily_summary_date = summary_date
if summary_dt_utc:
contest.last_daily_summary_at = summary_dt_utc
await db.commit()
await db.refresh(contest)
return contest
async def mark_final_summary_sent(
db: AsyncSession,
contest: ReferralContest,
) -> ReferralContest:
contest.final_summary_sent = True
contest.is_active = False
await db.commit()
await db.refresh(contest)
return contest
async def delete_referral_contest(
db: AsyncSession,
contest: ReferralContest,
) -> None:
await db.delete(contest)
await db.commit()
+15
View File
@@ -50,6 +50,21 @@ async def create_transaction(
user_id,
exc,
)
if type == TransactionType.SUBSCRIPTION_PAYMENT:
try:
from app.services.referral_contest_service import referral_contest_service
await referral_contest_service.on_subscription_payment(
db,
user_id,
amount_kopeks,
)
except Exception as exc:
logger.debug(
"Не удалось записать событие конкурса для пользователя %s: %s",
user_id,
exc,
)
return transaction
+175 -1
View File
@@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import datetime, timedelta, time, date
from typing import Optional, List, Dict, Any
from enum import Enum
@@ -7,6 +7,8 @@ from sqlalchemy import (
Integer,
String,
DateTime,
Date,
Time,
Boolean,
Text,
ForeignKey,
@@ -16,6 +18,7 @@ from sqlalchemy import (
UniqueConstraint,
Index,
Table,
SmallInteger,
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, Mapped, mapped_column
@@ -957,6 +960,132 @@ class ReferralEarning(Base):
return self.amount_kopeks / 100
class ReferralContest(Base):
__tablename__ = "referral_contests"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
prize_text = Column(Text, nullable=True)
contest_type = Column(String(50), nullable=False, default="referral_paid")
start_at = Column(DateTime, nullable=False)
end_at = Column(DateTime, nullable=False)
daily_summary_time = Column(Time, nullable=False, default=time(hour=12, minute=0))
daily_summary_times = Column(String(255), nullable=True) # CSV HH:MM
timezone = Column(String(64), nullable=False, default="UTC")
is_active = Column(Boolean, nullable=False, default=True)
last_daily_summary_date = Column(Date, nullable=True)
last_daily_summary_at = Column(DateTime, nullable=True)
final_summary_sent = Column(Boolean, nullable=False, default=False)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
creator = relationship("User", backref="created_referral_contests")
events = relationship(
"ReferralContestEvent",
back_populates="contest",
cascade="all, delete-orphan",
)
def __repr__(self):
return f"<ReferralContest id={self.id} title='{self.title}'>"
class ReferralContestEvent(Base):
__tablename__ = "referral_contest_events"
__table_args__ = (
UniqueConstraint(
"contest_id",
"referral_id",
name="uq_referral_contest_referral",
),
Index("idx_referral_contest_referrer", "contest_id", "referrer_id"),
)
id = Column(Integer, primary_key=True, index=True)
contest_id = Column(Integer, ForeignKey("referral_contests.id", ondelete="CASCADE"), nullable=False)
referrer_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
referral_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
event_type = Column(String(50), nullable=False)
amount_kopeks = Column(Integer, nullable=False, default=0)
occurred_at = Column(DateTime, nullable=False, default=func.now())
contest = relationship("ReferralContest", back_populates="events")
referrer = relationship("User", foreign_keys=[referrer_id])
referral = relationship("User", foreign_keys=[referral_id])
def __repr__(self):
return (
f"<ReferralContestEvent contest={self.contest_id} "
f"referrer={self.referrer_id} referral={self.referral_id}>"
)
class ContestTemplate(Base):
__tablename__ = "contest_templates"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
slug = Column(String(50), nullable=False, unique=True, index=True)
description = Column(Text, nullable=True)
prize_days = Column(Integer, nullable=False, default=1)
max_winners = Column(Integer, nullable=False, default=1)
attempts_per_user = Column(Integer, nullable=False, default=1)
times_per_day = Column(Integer, nullable=False, default=1)
schedule_times = Column(String(255), nullable=True) # CSV of HH:MM in local TZ
cooldown_hours = Column(Integer, nullable=False, default=24)
payload = Column(JSON, nullable=True)
is_enabled = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
rounds = relationship("ContestRound", back_populates="template")
class ContestRound(Base):
__tablename__ = "contest_rounds"
__table_args__ = (
Index("idx_contest_round_status", "status"),
Index("idx_contest_round_template", "template_id"),
)
id = Column(Integer, primary_key=True, index=True)
template_id = Column(Integer, ForeignKey("contest_templates.id", ondelete="CASCADE"), nullable=False)
starts_at = Column(DateTime, nullable=False)
ends_at = Column(DateTime, nullable=False)
status = Column(String(20), nullable=False, default="active") # active, finished
payload = Column(JSON, nullable=True)
winners_count = Column(Integer, nullable=False, default=0)
max_winners = Column(Integer, nullable=False, default=1)
attempts_per_user = Column(Integer, nullable=False, default=1)
message_id = Column(BigInteger, nullable=True)
chat_id = Column(BigInteger, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
template = relationship("ContestTemplate", back_populates="rounds")
attempts = relationship("ContestAttempt", back_populates="round", cascade="all, delete-orphan")
class ContestAttempt(Base):
__tablename__ = "contest_attempts"
__table_args__ = (
UniqueConstraint("round_id", "user_id", name="uq_round_user_attempt"),
Index("idx_contest_attempt_round", "round_id"),
)
id = Column(Integer, primary_key=True, index=True)
round_id = Column(Integer, ForeignKey("contest_rounds.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
answer = Column(Text, nullable=True)
is_winner = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime, default=func.now())
round = relationship("ContestRound", back_populates="attempts")
user = relationship("User")
class Squad(Base):
__tablename__ = "squads"
@@ -1653,3 +1782,48 @@ class MainMenuButton(Base):
f"<MainMenuButton id={self.id} text='{self.text}' "
f"action={self.action_type} visibility={self.visibility} active={self.is_active}>"
)
class MenuLayoutHistory(Base):
"""История изменений конфигурации меню."""
__tablename__ = "menu_layout_history"
id = Column(Integer, primary_key=True, index=True)
config_json = Column(Text, nullable=False) # Полная конфигурация в JSON
action = Column(String(50), nullable=False) # update, reset, import
changes_summary = Column(Text, nullable=True) # Краткое описание изменений
user_info = Column(String(255), nullable=True) # Информация о пользователе/токене
created_at = Column(DateTime, default=func.now(), index=True)
__table_args__ = (
Index("ix_menu_layout_history_created", "created_at"),
)
def __repr__(self) -> str:
return f"<MenuLayoutHistory id={self.id} action='{self.action}' created_at={self.created_at}>"
class ButtonClickLog(Base):
"""Логи кликов по кнопкам меню."""
__tablename__ = "button_click_logs"
id = Column(Integer, primary_key=True, index=True)
button_id = Column(String(100), nullable=False, index=True) # ID кнопки
user_id = Column(BigInteger, ForeignKey("users.telegram_id", ondelete="SET NULL"), nullable=True, index=True)
callback_data = Column(String(255), nullable=True) # callback_data кнопки
clicked_at = Column(DateTime, default=func.now(), index=True)
# Дополнительная информация
button_type = Column(String(20), nullable=True) # builtin, callback, url, mini_app
button_text = Column(String(255), nullable=True) # Текст кнопки на момент клика
__table_args__ = (
Index("ix_button_click_logs_button_date", "button_id", "clicked_at"),
Index("ix_button_click_logs_user_date", "user_id", "clicked_at"),
)
# Связи
user = relationship("User", foreign_keys=[user_id])
def __repr__(self) -> str:
return f"<ButtonClickLog id={self.id} button='{self.button_id}' user={self.user_id} at={self.clicked_at}>"
+666
View File
@@ -1383,6 +1383,464 @@ async def create_discount_offers_table():
return False
async def create_referral_contests_table() -> bool:
table_exists = await check_table_exists("referral_contests")
if table_exists:
logger.info("Таблица referral_contests уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
await conn.execute(text("""
CREATE TABLE referral_contests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title VARCHAR(255) NOT NULL,
description TEXT NULL,
prize_text TEXT NULL,
contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid',
start_at DATETIME NOT NULL,
end_at DATETIME NOT NULL,
daily_summary_time TIME NOT NULL DEFAULT '12:00:00',
daily_summary_times VARCHAR(255) NULL,
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
is_active BOOLEAN NOT NULL DEFAULT 1,
last_daily_summary_date DATE NULL,
last_daily_summary_at DATETIME NULL,
final_summary_sent BOOLEAN NOT NULL DEFAULT 0,
created_by INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""))
elif db_type == "postgresql":
await conn.execute(text("""
CREATE TABLE referral_contests (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT NULL,
prize_text TEXT NULL,
contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid',
start_at TIMESTAMP NOT NULL,
end_at TIMESTAMP NOT NULL,
daily_summary_time TIME NOT NULL DEFAULT '12:00:00',
daily_summary_times VARCHAR(255) NULL,
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_daily_summary_date DATE NULL,
last_daily_summary_at TIMESTAMP NULL,
final_summary_sent BOOLEAN NOT NULL DEFAULT FALSE,
created_by INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
elif db_type == "mysql":
await conn.execute(text("""
CREATE TABLE referral_contests (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(255) NOT NULL,
description TEXT NULL,
prize_text TEXT NULL,
contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid',
start_at DATETIME NOT NULL,
end_at DATETIME NOT NULL,
daily_summary_time TIME NOT NULL DEFAULT '12:00:00',
daily_summary_times VARCHAR(255) NULL,
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_daily_summary_date DATE NULL,
last_daily_summary_at DATETIME NULL,
final_summary_sent BOOLEAN NOT NULL DEFAULT FALSE,
created_by INTEGER NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_referral_contest_creator FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL
)
"""))
else:
raise ValueError(f"Unsupported database type: {db_type}")
logger.info("✅ Таблица referral_contests создана")
return True
except Exception as error:
logger.error(f"Ошибка создания таблицы referral_contests: {error}")
return False
async def create_referral_contest_events_table() -> bool:
table_exists = await check_table_exists("referral_contest_events")
if table_exists:
logger.info("Таблица referral_contest_events уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
await conn.execute(text("""
CREATE TABLE referral_contest_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contest_id INTEGER NOT NULL,
referrer_id INTEGER NOT NULL,
referral_id INTEGER NOT NULL,
event_type VARCHAR(50) NOT NULL,
amount_kopeks INTEGER NOT NULL DEFAULT 0,
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE,
FOREIGN KEY(referrer_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(referral_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(contest_id, referral_id)
)
"""))
await conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_referral_contest_referrer
ON referral_contest_events (contest_id, referrer_id)
"""))
elif db_type == "postgresql":
await conn.execute(text("""
CREATE TABLE referral_contest_events (
id SERIAL PRIMARY KEY,
contest_id INTEGER NOT NULL REFERENCES referral_contests(id) ON DELETE CASCADE,
referrer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
referral_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
event_type VARCHAR(50) NOT NULL,
amount_kopeks INTEGER NOT NULL DEFAULT 0,
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_referral_contest_referral UNIQUE (contest_id, referral_id)
)
"""))
await conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_referral_contest_referrer
ON referral_contest_events (contest_id, referrer_id)
"""))
elif db_type == "mysql":
await conn.execute(text("""
CREATE TABLE referral_contest_events (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
contest_id INTEGER NOT NULL,
referrer_id INTEGER NOT NULL,
referral_id INTEGER NOT NULL,
event_type VARCHAR(50) NOT NULL,
amount_kopeks INTEGER NOT NULL DEFAULT 0,
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_referral_contest FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE,
CONSTRAINT fk_referral_contest_referrer FOREIGN KEY(referrer_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_referral_contest_referral FOREIGN KEY(referral_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT uq_referral_contest_referral UNIQUE (contest_id, referral_id)
)
"""))
await conn.execute(text("""
CREATE INDEX idx_referral_contest_referrer
ON referral_contest_events (contest_id, referrer_id)
"""))
else:
raise ValueError(f"Unsupported database type: {db_type}")
logger.info("✅ Таблица referral_contest_events создана")
return True
except Exception as error:
logger.error(f"Ошибка создания таблицы referral_contest_events: {error}")
return False
async def ensure_referral_contest_summary_columns() -> bool:
ok = True
for column in ["daily_summary_times", "last_daily_summary_at"]:
exists = await check_column_exists("referral_contests", column)
if exists:
logger.info("Колонка %s в referral_contests уже существует", column)
continue
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "postgresql":
await conn.execute(
text(
f"ALTER TABLE referral_contests ADD COLUMN {column} "
+ ("VARCHAR(255)" if column == "daily_summary_times" else "TIMESTAMP")
)
)
else:
await conn.execute(
text(
f"ALTER TABLE referral_contests ADD COLUMN {column} "
+ ("VARCHAR(255)" if column == "daily_summary_times" else "DATETIME")
)
)
logger.info("✅ Колонка %s в referral_contests добавлена", column)
except Exception as error:
ok = False
logger.error("Ошибка добавления %s в referral_contests: %s", column, error)
return ok
async def create_contest_templates_table() -> bool:
table_exists = await check_table_exists("contest_templates")
if table_exists:
logger.info("Таблица contest_templates уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
await conn.execute(text("""
CREATE TABLE contest_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
slug VARCHAR(50) NOT NULL UNIQUE,
description TEXT NULL,
prize_days INTEGER NOT NULL DEFAULT 1,
max_winners INTEGER NOT NULL DEFAULT 1,
attempts_per_user INTEGER NOT NULL DEFAULT 1,
times_per_day INTEGER NOT NULL DEFAULT 1,
schedule_times VARCHAR(255) NULL,
cooldown_hours INTEGER NOT NULL DEFAULT 24,
payload TEXT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""))
elif db_type == "postgresql":
await conn.execute(text("""
CREATE TABLE contest_templates (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
slug VARCHAR(50) NOT NULL UNIQUE,
description TEXT NULL,
prize_days INTEGER NOT NULL DEFAULT 1,
max_winners INTEGER NOT NULL DEFAULT 1,
attempts_per_user INTEGER NOT NULL DEFAULT 1,
times_per_day INTEGER NOT NULL DEFAULT 1,
schedule_times VARCHAR(255) NULL,
cooldown_hours INTEGER NOT NULL DEFAULT 24,
payload JSON NULL,
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
elif db_type == "mysql":
await conn.execute(text("""
CREATE TABLE contest_templates (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
slug VARCHAR(50) NOT NULL UNIQUE,
description TEXT NULL,
prize_days INTEGER NOT NULL DEFAULT 1,
max_winners INTEGER NOT NULL DEFAULT 1,
attempts_per_user INTEGER NOT NULL DEFAULT 1,
times_per_day INTEGER NOT NULL DEFAULT 1,
schedule_times VARCHAR(255) NULL,
cooldown_hours INTEGER NOT NULL DEFAULT 24,
payload JSON NULL,
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
"""))
else:
raise ValueError(f"Unsupported database type: {db_type}")
logger.info("✅ Таблица contest_templates создана")
return True
except Exception as error:
logger.error(f"Ошибка создания таблицы contest_templates: {error}")
return False
async def create_contest_rounds_table() -> bool:
table_exists = await check_table_exists("contest_rounds")
if table_exists:
logger.info("Таблица contest_rounds уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
await conn.execute(text("""
CREATE TABLE contest_rounds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_id INTEGER NOT NULL,
starts_at DATETIME NOT NULL,
ends_at DATETIME NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
payload TEXT NULL,
winners_count INTEGER NOT NULL DEFAULT 0,
max_winners INTEGER NOT NULL DEFAULT 1,
attempts_per_user INTEGER NOT NULL DEFAULT 1,
message_id BIGINT NULL,
chat_id BIGINT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(template_id) REFERENCES contest_templates(id) ON DELETE CASCADE
)
"""))
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_round_status ON contest_rounds(status)"))
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_round_template ON contest_rounds(template_id)"))
elif db_type == "postgresql":
await conn.execute(text("""
CREATE TABLE contest_rounds (
id SERIAL PRIMARY KEY,
template_id INTEGER NOT NULL REFERENCES contest_templates(id) ON DELETE CASCADE,
starts_at TIMESTAMP NOT NULL,
ends_at TIMESTAMP NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
payload JSON NULL,
winners_count INTEGER NOT NULL DEFAULT 0,
max_winners INTEGER NOT NULL DEFAULT 1,
attempts_per_user INTEGER NOT NULL DEFAULT 1,
message_id BIGINT NULL,
chat_id BIGINT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_round_status ON contest_rounds(status)"))
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_round_template ON contest_rounds(template_id)"))
elif db_type == "mysql":
await conn.execute(text("""
CREATE TABLE contest_rounds (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
template_id INTEGER NOT NULL,
starts_at DATETIME NOT NULL,
ends_at DATETIME NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
payload JSON NULL,
winners_count INTEGER NOT NULL DEFAULT 0,
max_winners INTEGER NOT NULL DEFAULT 1,
attempts_per_user INTEGER NOT NULL DEFAULT 1,
message_id BIGINT NULL,
chat_id BIGINT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_contest_round_template FOREIGN KEY(template_id) REFERENCES contest_templates(id) ON DELETE CASCADE
)
"""))
await conn.execute(text("CREATE INDEX idx_contest_round_status ON contest_rounds(status)"))
await conn.execute(text("CREATE INDEX idx_contest_round_template ON contest_rounds(template_id)"))
else:
raise ValueError(f"Unsupported database type: {db_type}")
logger.info("✅ Таблица contest_rounds создана")
return True
except Exception as error:
logger.error(f"Ошибка создания таблицы contest_rounds: {error}")
return False
async def create_contest_attempts_table() -> bool:
table_exists = await check_table_exists("contest_attempts")
if table_exists:
logger.info("Таблица contest_attempts уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
await conn.execute(text("""
CREATE TABLE contest_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
round_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
answer TEXT NULL,
is_winner BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(round_id) REFERENCES contest_rounds(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(round_id, user_id)
)
"""))
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_attempt_round ON contest_attempts(round_id)"))
elif db_type == "postgresql":
await conn.execute(text("""
CREATE TABLE contest_attempts (
id SERIAL PRIMARY KEY,
round_id INTEGER NOT NULL REFERENCES contest_rounds(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
answer TEXT NULL,
is_winner BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_round_user_attempt UNIQUE(round_id, user_id)
)
"""))
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_attempt_round ON contest_attempts(round_id)"))
elif db_type == "mysql":
await conn.execute(text("""
CREATE TABLE contest_attempts (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
round_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
answer TEXT NULL,
is_winner BOOLEAN NOT NULL DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_contest_attempt_round FOREIGN KEY(round_id) REFERENCES contest_rounds(id) ON DELETE CASCADE,
CONSTRAINT fk_contest_attempt_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT uq_round_user_attempt UNIQUE(round_id, user_id)
)
"""))
await conn.execute(text("CREATE INDEX idx_contest_attempt_round ON contest_attempts(round_id)"))
else:
raise ValueError(f"Unsupported database type: {db_type}")
logger.info("✅ Таблица contest_attempts создана")
return True
except Exception as error:
logger.error(f"Ошибка создания таблицы contest_attempts: {error}")
return False
async def ensure_referral_contest_type_column() -> bool:
column_exists = await check_column_exists("referral_contests", "contest_type")
if column_exists:
logger.info("Колонка contest_type в referral_contests уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
await conn.execute(
text(
"ALTER TABLE referral_contests "
"ADD COLUMN contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid'"
)
)
elif db_type == "postgresql":
await conn.execute(
text(
"ALTER TABLE referral_contests "
"ADD COLUMN contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid'"
)
)
elif db_type == "mysql":
await conn.execute(
text(
"ALTER TABLE referral_contests "
"ADD COLUMN contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid'"
)
)
else:
raise ValueError(f"Unsupported database type: {db_type}")
logger.info("✅ Колонка contest_type в referral_contests добавлена")
return True
except Exception as error:
logger.error(f"Ошибка добавления contest_type в referral_contests: {error}")
return False
async def ensure_discount_offer_columns():
try:
effect_exists = await check_column_exists('discount_offers', 'effect_type')
@@ -3257,6 +3715,133 @@ async def create_system_settings_table() -> bool:
return False
async def create_menu_layout_history_table() -> bool:
"""Создаёт таблицу для хранения истории изменений конфигурации меню."""
table_exists = await check_table_exists("menu_layout_history")
if table_exists:
logger.info("️ Таблица menu_layout_history уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
create_table_sql = """
CREATE TABLE menu_layout_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
config_json TEXT NOT NULL,
action VARCHAR(50) NOT NULL,
changes_summary TEXT NULL,
user_info VARCHAR(255) NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""
elif db_type == "postgresql":
create_table_sql = """
CREATE TABLE menu_layout_history (
id SERIAL PRIMARY KEY,
config_json TEXT NOT NULL,
action VARCHAR(50) NOT NULL,
changes_summary TEXT NULL,
user_info VARCHAR(255) NULL,
created_at TIMESTAMP DEFAULT NOW()
)
"""
else:
create_table_sql = """
CREATE TABLE menu_layout_history (
id INT AUTO_INCREMENT PRIMARY KEY,
config_json TEXT NOT NULL,
action VARCHAR(50) NOT NULL,
changes_summary TEXT NULL,
user_info VARCHAR(255) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB
"""
await conn.execute(text(create_table_sql))
await conn.execute(text(
"CREATE INDEX ix_menu_layout_history_created ON menu_layout_history(created_at)"
))
logger.info("✅ Таблица menu_layout_history создана")
return True
except Exception as error:
logger.error(f"❌ Ошибка создания таблицы menu_layout_history: {error}")
return False
async def create_button_click_logs_table() -> bool:
"""Создаёт таблицу для логирования кликов по кнопкам меню."""
table_exists = await check_table_exists("button_click_logs")
if table_exists:
logger.info("️ Таблица button_click_logs уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
create_table_sql = """
CREATE TABLE button_click_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
button_id VARCHAR(100) NOT NULL,
user_id BIGINT NULL REFERENCES users(telegram_id) ON DELETE SET NULL,
callback_data VARCHAR(255) NULL,
clicked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
button_type VARCHAR(20) NULL,
button_text VARCHAR(255) NULL
)
"""
elif db_type == "postgresql":
create_table_sql = """
CREATE TABLE button_click_logs (
id SERIAL PRIMARY KEY,
button_id VARCHAR(100) NOT NULL,
user_id BIGINT NULL REFERENCES users(telegram_id) ON DELETE SET NULL,
callback_data VARCHAR(255) NULL,
clicked_at TIMESTAMP DEFAULT NOW(),
button_type VARCHAR(20) NULL,
button_text VARCHAR(255) NULL
)
"""
else:
create_table_sql = """
CREATE TABLE button_click_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
button_id VARCHAR(100) NOT NULL,
user_id BIGINT NULL,
callback_data VARCHAR(255) NULL,
clicked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
button_type VARCHAR(20) NULL,
button_text VARCHAR(255) NULL,
FOREIGN KEY (user_id) REFERENCES users(telegram_id) ON DELETE SET NULL
) ENGINE=InnoDB
"""
await conn.execute(text(create_table_sql))
# Создаём индексы отдельными запросами
index_statements = [
"CREATE INDEX ix_button_click_logs_button_id ON button_click_logs(button_id)",
"CREATE INDEX ix_button_click_logs_user_id ON button_click_logs(user_id)",
"CREATE INDEX ix_button_click_logs_clicked_at ON button_click_logs(clicked_at)",
"CREATE INDEX ix_button_click_logs_button_date ON button_click_logs(button_id, clicked_at)",
"CREATE INDEX ix_button_click_logs_user_date ON button_click_logs(user_id, clicked_at)",
]
for stmt in index_statements:
await conn.execute(text(stmt))
logger.info("✅ Таблица button_click_logs создана")
return True
except Exception as error:
logger.error(f"❌ Ошибка создания таблицы button_click_logs: {error}")
return False
async def create_web_api_tokens_table() -> bool:
table_exists = await check_table_exists("web_api_tokens")
if table_exists:
@@ -3858,6 +4443,20 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей web_api_tokens")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MENU_LAYOUT_HISTORY ===")
menu_layout_history_ready = await create_menu_layout_history_table()
if menu_layout_history_ready:
logger.info("✅ Таблица menu_layout_history готова")
else:
logger.warning("⚠️ Проблемы с таблицей menu_layout_history")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ BUTTON_CLICK_LOGS ===")
button_click_logs_ready = await create_button_click_logs_table()
if button_click_logs_ready:
logger.info("✅ Таблица button_click_logs готова")
else:
logger.warning("⚠️ Проблемы с таблицей button_click_logs")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ ДЛЯ ТРИАЛЬНЫХ СКВАДОВ ===")
trial_column_ready = await add_server_trial_flag_column()
if trial_column_ready:
@@ -3961,6 +4560,49 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Не удалось обновить колонки discount_offers")
logger.info("=== СОЗДАНИЕ ТАБЛИЦ ДЛЯ РЕФЕРАЛЬНЫХ КОНКУРСОВ ===")
contests_table_ready = await create_referral_contests_table()
if contests_table_ready:
logger.info("✅ Таблица referral_contests готова")
else:
logger.warning("⚠️ Проблемы с таблицей referral_contests")
contest_events_ready = await create_referral_contest_events_table()
if contest_events_ready:
logger.info("✅ Таблица referral_contest_events готова")
else:
logger.warning("⚠️ Проблемы с таблицей referral_contest_events")
contest_type_ready = await ensure_referral_contest_type_column()
if contest_type_ready:
logger.info("✅ Колонка contest_type для referral_contests готова")
else:
logger.warning("⚠️ Не удалось добавить contest_type в referral_contests")
contest_summary_ready = await ensure_referral_contest_summary_columns()
if contest_summary_ready:
logger.info("✅ Колонки daily_summary_times/last_daily_summary_at готовы")
else:
logger.warning("⚠️ Не удалось обновить колонки сводок для referral_contests")
contest_templates_ready = await create_contest_templates_table()
if contest_templates_ready:
logger.info("✅ Таблица contest_templates готова")
else:
logger.warning("⚠️ Проблемы с таблицей contest_templates")
contest_rounds_ready = await create_contest_rounds_table()
if contest_rounds_ready:
logger.info("✅ Таблица contest_rounds готова")
else:
logger.warning("⚠️ Проблемы с таблицей contest_rounds")
contest_attempts_ready = await create_contest_attempts_table()
if contest_attempts_ready:
logger.info("✅ Таблица contest_attempts готова")
else:
logger.warning("⚠️ Проблемы с таблицей contest_attempts")
user_discount_columns_ready = await ensure_user_promo_offer_discount_columns()
if user_discount_columns_ready:
logger.info("✅ Колонки пользовательских промо-скидок готовы")
@@ -4263,6 +4905,14 @@ async def check_migration_status():
"discount_offers_table": False,
"discount_offers_effect_column": False,
"discount_offers_extra_column": False,
"referral_contests_table": False,
"referral_contest_events_table": False,
"referral_contest_type_column": False,
"referral_contest_summary_times_column": False,
"referral_contest_last_summary_at_column": False,
"contest_templates_table": False,
"contest_rounds_table": False,
"contest_attempts_table": False,
"promo_offer_templates_table": False,
"promo_offer_templates_active_discount_column": False,
"promo_offer_logs_table": False,
@@ -4286,6 +4936,14 @@ async def check_migration_status():
status["discount_offers_table"] = await check_table_exists('discount_offers')
status["discount_offers_effect_column"] = await check_column_exists('discount_offers', 'effect_type')
status["discount_offers_extra_column"] = await check_column_exists('discount_offers', 'extra_data')
status["referral_contests_table"] = await check_table_exists('referral_contests')
status["referral_contest_events_table"] = await check_table_exists('referral_contest_events')
status["referral_contest_type_column"] = await check_column_exists('referral_contests', 'contest_type')
status["referral_contest_summary_times_column"] = await check_column_exists('referral_contests', 'daily_summary_times')
status["referral_contest_last_summary_at_column"] = await check_column_exists('referral_contests', 'last_daily_summary_at')
status["contest_templates_table"] = await check_table_exists('contest_templates')
status["contest_rounds_table"] = await check_table_exists('contest_rounds')
status["contest_attempts_table"] = await check_table_exists('contest_attempts')
status["promo_offer_templates_table"] = await check_table_exists('promo_offer_templates')
status["promo_offer_templates_active_discount_column"] = await check_column_exists('promo_offer_templates', 'active_discount_hours')
status["promo_offer_logs_table"] = await check_table_exists('promo_offer_logs')
@@ -4354,6 +5012,14 @@ async def check_migration_status():
"discount_offers_table": "Таблица discount_offers",
"discount_offers_effect_column": "Колонка effect_type в discount_offers",
"discount_offers_extra_column": "Колонка extra_data в discount_offers",
"referral_contests_table": "Таблица referral_contests",
"referral_contest_events_table": "Таблица referral_contest_events",
"referral_contest_type_column": "Колонка contest_type в referral_contests",
"referral_contest_summary_times_column": "Колонка daily_summary_times в referral_contests",
"referral_contest_last_summary_at_column": "Колонка last_daily_summary_at в referral_contests",
"contest_templates_table": "Таблица contest_templates",
"contest_rounds_table": "Таблица contest_rounds",
"contest_attempts_table": "Таблица contest_attempts",
"promo_offer_templates_table": "Таблица promo_offer_templates",
"promo_offer_templates_active_discount_column": "Колонка active_discount_hours в promo_offer_templates",
"promo_offer_logs_table": "Таблица promo_offer_logs",
+254 -31
View File
@@ -189,6 +189,33 @@ class SubscriptionInfo:
happ_crypto_link: Optional[str] = None
@dataclass
class SubscriptionPageConfig:
"""Конфигурация страницы подписки"""
uuid: str
name: str
view_position: int
config: Optional[Dict[str, Any]] = None
@dataclass
class RemnaWaveExternalSquad:
"""Структура External Squad"""
uuid: str
name: str
view_position: int
members_count: int
templates: List[Dict[str, str]]
subscription_settings: Optional[Dict[str, Any]] = None
host_overrides: Optional[Dict[str, Any]] = None
response_headers: Optional[Dict[str, str]] = None
hwid_settings: Optional[Dict[str, Any]] = None
custom_remarks: Optional[Dict[str, Any]] = None
subpage_config_uuid: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class RemnaWaveAPIError(Exception):
def __init__(self, message: str, status_code: int = None, response_data: dict = None):
self.message = message
@@ -497,15 +524,6 @@ class RemnaWaveAPI:
return await self.enrich_user_with_happ_link(user)
async def get_all_users(self, start: int = 0, size: int = 100, enrich_happ_links: bool = False) -> Dict[str, Any]:
"""
Получает список всех пользователей.
Args:
start: Смещение для пагинации
size: Размер страницы
enrich_happ_links: Если True, для каждого пользователя будет запрошена
зашифрованная happ ссылка (медленно для больших списков)
"""
params = {'start': start, 'size': size}
response = await self._make_request('GET', '/api/users', params=params)
@@ -590,6 +608,100 @@ class RemnaWaveAPI:
response = await self._make_request('POST', '/api/internal-squads/actions/reorder', data)
return [self._parse_internal_squad(squad) for squad in response['response']['internalSquads']]
# ============== External Squads API ==============
async def get_external_squads(self) -> List[RemnaWaveExternalSquad]:
"""Получает список всех External Squads"""
response = await self._make_request('GET', '/api/external-squads')
return [self._parse_external_squad(squad) for squad in response['response']['externalSquads']]
async def get_external_squad_by_uuid(self, uuid: str) -> Optional[RemnaWaveExternalSquad]:
"""Получает External Squad по UUID"""
try:
response = await self._make_request('GET', f'/api/external-squads/{uuid}')
return self._parse_external_squad(response['response'])
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
async def create_external_squad(self, name: str) -> RemnaWaveExternalSquad:
data = {'name': name}
response = await self._make_request('POST', '/api/external-squads', data)
return self._parse_external_squad(response['response'])
async def update_external_squad(
self,
uuid: str,
name: Optional[str] = None,
templates: Optional[List[Dict[str, str]]] = None,
subscription_settings: Optional[Dict[str, Any]] = None,
host_overrides: Optional[Dict[str, Any]] = None,
response_headers: Optional[Dict[str, str]] = None,
hwid_settings: Optional[Dict[str, Any]] = None,
custom_remarks: Optional[Dict[str, Any]] = None,
subpage_config_uuid: Optional[str] = None
) -> RemnaWaveExternalSquad:
data = {'uuid': uuid}
if name is not None:
data['name'] = name
if templates is not None:
data['templates'] = templates
if subscription_settings is not None:
data['subscriptionSettings'] = subscription_settings
if host_overrides is not None:
data['hostOverrides'] = host_overrides
if response_headers is not None:
data['responseHeaders'] = response_headers
if hwid_settings is not None:
data['hwidSettings'] = hwid_settings
if custom_remarks is not None:
data['customRemarks'] = custom_remarks
if subpage_config_uuid is not None:
data['subpageConfigUuid'] = subpage_config_uuid
response = await self._make_request('PATCH', '/api/external-squads', data)
return self._parse_external_squad(response['response'])
async def delete_external_squad(self, uuid: str) -> bool:
"""Удаляет External Squad"""
response = await self._make_request('DELETE', f'/api/external-squads/{uuid}')
return response['response']['isDeleted']
async def add_users_to_external_squad(self, uuid: str) -> bool:
"""Добавляет всех пользователей в External Squad (bulk action)"""
response = await self._make_request('POST', f'/api/external-squads/{uuid}/bulk-actions/add-users')
return response['response']['eventSent']
async def remove_users_from_external_squad(self, uuid: str) -> bool:
"""Удаляет всех пользователей из External Squad (bulk action)"""
response = await self._make_request('POST', f'/api/external-squads/{uuid}/bulk-actions/remove-users')
return response['response']['eventSent']
async def reorder_external_squads(self, items: List[Dict[str, Any]]) -> List[RemnaWaveExternalSquad]:
data = {'items': items}
response = await self._make_request('POST', '/api/external-squads/actions/reorder', data)
return [self._parse_external_squad(squad) for squad in response['response']['externalSquads']]
def _parse_external_squad(self, squad_data: Dict) -> RemnaWaveExternalSquad:
"""Парсит данные External Squad"""
info = squad_data.get('info', {})
return RemnaWaveExternalSquad(
uuid=squad_data['uuid'],
name=squad_data['name'],
view_position=squad_data.get('viewPosition', 0),
members_count=info.get('membersCount', 0),
templates=squad_data.get('templates', []),
subscription_settings=squad_data.get('subscriptionSettings'),
host_overrides=squad_data.get('hostOverrides'),
response_headers=squad_data.get('responseHeaders'),
hwid_settings=squad_data.get('hwidSettings'),
custom_remarks=squad_data.get('customRemarks'),
subpage_config_uuid=squad_data.get('subpageConfigUuid'),
created_at=self._parse_optional_datetime(squad_data.get('createdAt')),
updated_at=self._parse_optional_datetime(squad_data.get('updatedAt'))
)
async def get_all_nodes(self) -> List[RemnaWaveNode]:
response = await self._make_request('GET', '/api/nodes')
@@ -683,29 +795,148 @@ class RemnaWaveAPI:
return response['response']
async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]:
response = await self._make_request('GET', '/api/nodes/usage/realtime')
return response['response']
return await self.get_bandwidth_stats_nodes_realtime()
async def get_user_stats_usage(self, user_uuid: str, start_date: str, end_date: str) -> Dict[str, Any]:
"""
Получает статистику использования трафика пользователем за указанный период
return await self.get_bandwidth_stats_user_legacy(user_uuid, start_date, end_date)
Args:
user_uuid: UUID пользователя
start_date: Начальная дата в формате ISO (например, "2025-12-09T00:00:00.000Z")
end_date: Конечная дата в формате ISO (например, "2025-12-09T23:59:59.999Z")
# ============== Bandwidth Stats API ==============
Returns:
Словарь с информацией о трафике пользователя за указанный период
"""
async def get_bandwidth_stats_nodes(self, start_date: str, end_date: str) -> Dict[str, Any]:
params = {
'start': start_date,
'end': end_date
}
response = await self._make_request('GET', f'/api/users/stats/usage/{user_uuid}/range', params=params)
response = await self._make_request('GET', '/api/bandwidth-stats/nodes', params=params)
return response['response']
async def get_bandwidth_stats_nodes_realtime(self) -> List[Dict[str, Any]]:
response = await self._make_request('GET', '/api/bandwidth-stats/nodes/realtime')
return response['response']
async def get_bandwidth_stats_node_users(
self,
node_uuid: str,
start_date: str,
end_date: str,
top_users_limit: int = 10
) -> Dict[str, Any]:
params = {
'start': start_date,
'end': end_date,
'topUsersLimit': top_users_limit
}
response = await self._make_request('GET', f'/api/bandwidth-stats/nodes/{node_uuid}/users', params=params)
return response['response']
async def get_bandwidth_stats_node_users_legacy(
self,
node_uuid: str,
start_date: str,
end_date: str
) -> Dict[str, Any]:
params = {
'start': start_date,
'end': end_date
}
response = await self._make_request('GET', f'/api/bandwidth-stats/nodes/{node_uuid}/users/legacy', params=params)
return response['response']
async def get_bandwidth_stats_user(
self,
user_uuid: str,
start_date: str,
end_date: str
) -> Dict[str, Any]:
params = {
'start': start_date,
'end': end_date
}
response = await self._make_request('GET', f'/api/bandwidth-stats/users/{user_uuid}', params=params)
return response['response']
async def get_bandwidth_stats_user_legacy(
self,
user_uuid: str,
start_date: str,
end_date: str
) -> Dict[str, Any]:
params = {
'start': start_date,
'end': end_date
}
response = await self._make_request('GET', f'/api/bandwidth-stats/users/{user_uuid}/legacy', params=params)
return response
# ============== Subscription Page Configs API ==============
async def get_subscription_page_configs(self) -> List[SubscriptionPageConfig]:
response = await self._make_request('GET', '/api/subscription-page-configs')
configs_data = response['response'].get('configs', [])
return [self._parse_subscription_page_config(c) for c in configs_data]
async def get_subscription_page_config(self, uuid: str) -> Optional[SubscriptionPageConfig]:
try:
response = await self._make_request('GET', f'/api/subscription-page-configs/{uuid}')
return self._parse_subscription_page_config(response['response'])
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
async def create_subscription_page_config(self, name: str) -> SubscriptionPageConfig:
data = {'name': name}
response = await self._make_request('POST', '/api/subscription-page-configs', data)
return self._parse_subscription_page_config(response['response'])
async def update_subscription_page_config(
self,
uuid: str,
name: Optional[str] = None,
config: Optional[Dict[str, Any]] = None
) -> SubscriptionPageConfig:
data = {'uuid': uuid}
if name is not None:
data['name'] = name
if config is not None:
data['config'] = config
response = await self._make_request('PATCH', '/api/subscription-page-configs', data)
return self._parse_subscription_page_config(response['response'])
async def delete_subscription_page_config(self, uuid: str) -> bool:
response = await self._make_request('DELETE', f'/api/subscription-page-configs/{uuid}')
return response['response']['isDeleted']
async def reorder_subscription_page_configs(self, items: List[Dict[str, Any]]) -> List[SubscriptionPageConfig]:
data = {'items': items}
response = await self._make_request('POST', '/api/subscription-page-configs/actions/reorder', data)
configs_data = response['response'].get('configs', [])
return [self._parse_subscription_page_config(c) for c in configs_data]
async def clone_subscription_page_config(self, clone_from_uuid: str) -> SubscriptionPageConfig:
data = {'cloneFromUuid': clone_from_uuid}
response = await self._make_request('POST', '/api/subscription-page-configs/actions/clone', data)
return self._parse_subscription_page_config(response['response'])
async def get_subpage_config_by_short_uuid(self, short_uuid: str) -> Optional[Dict[str, Any]]:
try:
response = await self._make_request('GET', f'/api/subscriptions/subpage-config/{short_uuid}')
return response.get('response')
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
def _parse_subscription_page_config(self, data: Dict) -> SubscriptionPageConfig:
"""Парсит данные конфигурации страницы подписки"""
return SubscriptionPageConfig(
uuid=data['uuid'],
name=data['name'],
view_position=data['viewPosition'],
config=data.get('config')
)
async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]:
try:
response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}')
@@ -756,10 +987,6 @@ class RemnaWaveAPI:
return False
async def encrypt_happ_crypto_link(self, link_to_encrypt: str) -> Optional[str]:
"""
Шифрует ссылку подписки через API Remnawave.
Возвращает зашифрованную happ:// ссылку или None при ошибке.
"""
try:
data = {"linkToEncrypt": link_to_encrypt}
response = await self._make_request('POST', '/api/system/tools/happ/encrypt', data)
@@ -772,10 +999,6 @@ class RemnaWaveAPI:
return None
async def enrich_user_with_happ_link(self, user: RemnaWaveUser) -> RemnaWaveUser:
"""
Обогащает объект пользователя зашифрованной happ ссылкой,
если она отсутствует но есть subscription_url.
"""
if not user.happ_crypto_link and user.subscription_url:
encrypted = await self.encrypt_happ_crypto_link(user.subscription_url)
if encrypted:
+3 -1
View File
@@ -5,6 +5,8 @@ from . import (
bot_configuration,
bulk_ban,
campaigns,
daily_contests,
contests,
faq,
main,
maintenance,
@@ -33,4 +35,4 @@ from . import (
user_messages,
users,
welcome_text,
)
)
+8 -2
View File
@@ -3,12 +3,14 @@
"""
import logging
from aiogram import types
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.blacklist_service import blacklist_service
from app.states import BlacklistStates
from app.utils.decorators import admin_required, error_handler
from app.keyboards.admin import get_admin_users_keyboard
@@ -253,7 +255,7 @@ async def start_set_blacklist_url(
])
)
await state.set_state("waiting_for_blacklist_url")
await state.set_state(BlacklistStates.waiting_for_blacklist_url)
await callback.answer()
@@ -267,6 +269,10 @@ async def process_blacklist_url(
"""
Обрабатывает введенный URL к черному списку
"""
# Обрабатываем сообщение только если бот ожидает ввод URL
if await state.get_state() != BlacklistStates.waiting_for_blacklist_url.state:
return
url = message.text.strip()
# В реальной реализации нужно сохранить URL в систему настроек
@@ -361,5 +367,5 @@ def register_blacklist_handlers(dp):
# Обработчик сообщений для установки URL (работает только в нужном состоянии)
dp.message.register(
process_blacklist_url,
lambda m: True # Фильтр будет внутри функции
StateFilter(BlacklistStates.waiting_for_blacklist_url)
)
+1 -1
View File
@@ -1984,7 +1984,7 @@ async def test_payment_provider(
return
amount_kopeks = 10 * 100
description = settings.get_balance_payment_description(amount_kopeks)
description = settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),
payment_result = await payment_service.create_yookassa_payment(
db=db,
user_id=db_user.id,
+784
View File
@@ -0,0 +1,784 @@
import logging
import math
from datetime import datetime, timezone, time
from zoneinfo import ZoneInfo
from aiogram import Dispatcher, F, types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.referral_contest import (
create_referral_contest,
get_contest_events_count,
get_contest_leaderboard,
get_referral_contest,
get_referral_contests_count,
list_referral_contests,
toggle_referral_contest,
update_referral_contest,
delete_referral_contest,
)
from app.keyboards.admin import (
get_admin_contests_keyboard,
get_admin_contests_root_keyboard,
get_admin_pagination_keyboard,
get_contest_mode_keyboard,
get_referral_contest_manage_keyboard,
)
from app.localization.texts import get_texts
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
PAGE_SIZE = 5
def _ensure_timezone(tz_name: str) -> ZoneInfo:
try:
return ZoneInfo(tz_name)
except Exception: # noqa: BLE001
logger.warning("Не удалось загрузить TZ %s, используем UTC", tz_name)
return ZoneInfo("UTC")
def _format_contest_summary(contest, texts, tz: ZoneInfo) -> str:
start_local = contest.start_at if contest.start_at.tzinfo else contest.start_at.replace(tzinfo=timezone.utc)
end_local = contest.end_at if contest.end_at.tzinfo else contest.end_at.replace(tzinfo=timezone.utc)
start_local = start_local.astimezone(tz)
end_local = end_local.astimezone(tz)
status = texts.t("ADMIN_CONTEST_STATUS_ACTIVE", "🟢 Активен") if contest.is_active else texts.t(
"ADMIN_CONTEST_STATUS_INACTIVE", "⚪️ Выключен"
)
period = (
f"{start_local.strftime('%d.%m %H:%M')}"
f"{end_local.strftime('%d.%m %H:%M')} ({tz.key})"
)
summary_time = contest.daily_summary_time.strftime("%H:%M") if contest.daily_summary_time else "12:00"
summary_times = contest.daily_summary_times or summary_time
parts = [
f"{status}",
f"Период: <b>{period}</b>",
f"Дневная сводка: <b>{summary_times}</b>",
]
if contest.prize_text:
parts.append(texts.t("ADMIN_CONTEST_PRIZE", "Приз: {prize}").format(prize=contest.prize_text))
if contest.last_daily_summary_date:
parts.append(
texts.t("ADMIN_CONTEST_LAST_DAILY", "Последняя сводка: {date}").format(
date=contest.last_daily_summary_date.strftime("%d.%m")
)
)
return "\n".join(parts)
def _parse_local_datetime(value: str, tz: ZoneInfo) -> datetime | None:
try:
dt = datetime.strptime(value.strip(), "%d.%m.%Y %H:%M")
except ValueError:
return None
return dt.replace(tzinfo=tz)
def _parse_time(value: str):
try:
return datetime.strptime(value.strip(), "%H:%M").time()
except ValueError:
return None
def _parse_times(value: str) -> list[time]:
times: list[time] = []
for part in value.split(","):
part = part.strip()
if not part:
continue
parsed = _parse_time(part)
if parsed:
times.append(parsed)
return times
@admin_required
@error_handler
async def show_contests_menu(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
if not settings.is_contests_enabled():
await callback.message.edit_text(
texts.t(
"ADMIN_CONTESTS_DISABLED",
"Конкурсы отключены через переменную окружения CONTESTS_ENABLED.",
),
reply_markup=get_admin_contests_root_keyboard(db_user.language),
)
await callback.answer()
return
await callback.message.edit_text(
texts.t("ADMIN_CONTESTS_TITLE", "🏆 <b>Конкурсы</b>\n\nВыберите действие:"),
reply_markup=get_admin_contests_root_keyboard(db_user.language),
)
await callback.answer()
@admin_required
@error_handler
async def show_referral_contests_menu(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t("ADMIN_CONTESTS_TITLE", "🏆 <b>Конкурсы</b>\n\nВыберите действие:"),
reply_markup=get_admin_contests_keyboard(db_user.language),
)
await callback.answer()
@admin_required
@error_handler
async def list_contests(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
if not settings.is_contests_enabled():
await callback.answer(
get_texts(db_user.language).t(
"ADMIN_CONTESTS_DISABLED",
"Конкурсы отключены через переменную окружения CONTESTS_ENABLED.",
),
show_alert=True,
)
return
page = 1
if callback.data.startswith("admin_contests_list_page_"):
try:
page = int(callback.data.split("_")[-1])
except Exception:
page = 1
total = await get_referral_contests_count(db)
total_pages = max(1, math.ceil(total / PAGE_SIZE))
page = max(1, min(page, total_pages))
offset = (page - 1) * PAGE_SIZE
contests = await list_referral_contests(db, limit=PAGE_SIZE, offset=offset)
texts = get_texts(db_user.language)
lines = [texts.t("ADMIN_CONTESTS_LIST_HEADER", "🏆 <b>Конкурсы</b>\n")]
if not contests:
lines.append(texts.t("ADMIN_CONTESTS_EMPTY", "Пока нет созданных конкурсов."))
else:
for contest in contests:
lines.append(f"• <b>{contest.title}</b> (#{contest.id})")
contest_tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
lines.append(_format_contest_summary(contest, texts, contest_tz))
lines.append("")
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
for contest in contests:
title = contest.title if len(contest.title) <= 25 else contest.title[:22] + "..."
keyboard_rows.append(
[
types.InlineKeyboardButton(
text=f"🔎 {title}",
callback_data=f"admin_contest_view_{contest.id}",
)
]
)
pagination = get_admin_pagination_keyboard(
page,
total_pages,
"admin_contests_list",
back_callback="admin_contests",
language=db_user.language,
)
keyboard_rows.extend(pagination.inline_keyboard)
await callback.message.edit_text(
"\n".join(lines),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
await callback.answer()
@admin_required
@error_handler
async def show_contest_details(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
if not settings.is_contests_enabled():
await callback.answer(
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
show_alert=True,
)
return
contest_id = int(callback.data.split("_")[-1])
contest = await get_referral_contest(db, contest_id)
texts = get_texts(db_user.language)
if not contest:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
leaderboard = await get_contest_leaderboard(db, contest.id, limit=5)
total_events = await get_contest_events_count(db, contest.id)
lines = [
f"🏆 <b>{contest.title}</b>",
_format_contest_summary(contest, texts, tz),
texts.t("ADMIN_CONTEST_TOTAL_EVENTS", "Зачётов: <b>{count}</b>").format(count=total_events),
]
if contest.description:
lines.append("")
lines.append(contest.description)
if leaderboard:
lines.append("")
lines.append(texts.t("ADMIN_CONTEST_LEADERBOARD_TITLE", "📊 Топ участников:"))
for idx, (user, score, _) in enumerate(leaderboard, start=1):
lines.append(f"{idx}. {user.full_name}{score}")
await callback.message.edit_text(
"\n".join(lines),
reply_markup=get_referral_contest_manage_keyboard(
contest.id,
is_active=contest.is_active,
can_delete=(
not contest.is_active
and (contest.end_at.replace(tzinfo=timezone.utc) if contest.end_at.tzinfo is None else contest.end_at)
< datetime.now(timezone.utc)
),
language=db_user.language,
),
)
await callback.answer()
@admin_required
@error_handler
async def toggle_contest(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
if not settings.is_contests_enabled():
await callback.answer(
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
show_alert=True,
)
return
contest_id = int(callback.data.split("_")[-1])
contest = await get_referral_contest(db, contest_id)
if not contest:
await callback.answer("Конкурс не найден", show_alert=True)
return
await toggle_referral_contest(db, contest, not contest.is_active)
await show_contest_details(callback, db_user, db)
@admin_required
@error_handler
async def prompt_edit_summary_times(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
contest_id = int(callback.data.split("_")[-1])
contest = await get_referral_contest(db, contest_id)
if not contest:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
await state.set_state(AdminStates.editing_referral_contest_summary_times)
await state.update_data(contest_id=contest_id)
kb = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_contest_view_{contest_id}",
)
]
]
)
await callback.message.edit_text(
texts.t(
"ADMIN_CONTEST_ENTER_DAILY_TIME",
"Во сколько отправлять ежедневные итоги? Формат ЧЧ:ММ или несколько через запятую (12:00,18:00).",
),
reply_markup=kb,
)
await callback.answer()
@admin_required
@error_handler
async def process_edit_summary_times(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
data = await state.get_data()
contest_id = data.get("contest_id")
if not contest_id:
await message.answer(texts.ERROR)
await state.clear()
return
times = _parse_times(message.text or "")
summary_time = times[0] if times else _parse_time(message.text or "")
if not summary_time:
await message.answer(
texts.t("ADMIN_CONTEST_INVALID_TIME", "Не удалось распознать время. Формат: 12:00 или 12:00,18:00")
)
await state.clear()
return
contest = await get_referral_contest(db, int(contest_id))
if not contest:
await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."))
await state.clear()
return
await update_referral_contest(
db,
contest,
daily_summary_time=summary_time,
daily_summary_times=",".join(t.strftime("%H:%M") for t in times) if times else None,
)
await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"))
await state.clear()
@admin_required
@error_handler
async def delete_contest(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
contest_id = int(callback.data.split("_")[-1])
contest = await get_referral_contest(db, contest_id)
if not contest:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
now_utc = datetime.utcnow()
if contest.is_active or contest.end_at > now_utc:
await callback.answer(
texts.t("ADMIN_CONTEST_DELETE_RESTRICT", "Удалять можно только завершённые конкурсы."),
show_alert=True,
)
return
await delete_referral_contest(db, contest)
await callback.answer(texts.t("ADMIN_CONTEST_DELETED", "Конкурс удалён."), show_alert=True)
await list_contests(callback, db_user, db)
@admin_required
@error_handler
async def show_leaderboard(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
if not settings.is_contests_enabled():
await callback.answer(
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
show_alert=True,
)
return
contest_id = int(callback.data.split("_")[-1])
contest = await get_referral_contest(db, contest_id)
texts = get_texts(db_user.language)
if not contest:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
leaderboard = await get_contest_leaderboard(db, contest_id, limit=10)
if not leaderboard:
await callback.answer(texts.t("ADMIN_CONTEST_EMPTY_LEADERBOARD", "Пока нет участников."), show_alert=True)
return
lines = [
texts.t("ADMIN_CONTEST_LEADERBOARD_TITLE", "📊 Топ участников:"),
]
for idx, (user, score, _) in enumerate(leaderboard, start=1):
lines.append(f"{idx}. {user.full_name} ({user.telegram_id}) — {score}")
await callback.message.edit_text(
"\n".join(lines),
reply_markup=get_referral_contest_manage_keyboard(
contest_id, is_active=contest.is_active, language=db_user.language
),
)
await callback.answer()
@admin_required
@error_handler
async def start_contest_creation(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_contests_enabled():
await callback.answer(
texts.t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
show_alert=True,
)
return
await state.clear()
await state.set_state(AdminStates.creating_referral_contest_mode)
await callback.message.edit_text(
texts.t(
"ADMIN_CONTEST_MODE_PROMPT",
"Выберите условие зачёта: реферал должен купить подписку или достаточно регистрации.",
),
reply_markup=get_contest_mode_keyboard(db_user.language),
)
await callback.answer()
@admin_required
@error_handler
async def select_contest_mode(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
mode = "referral_paid" if callback.data == "admin_contest_mode_paid" else "referral_registered"
await state.update_data(contest_type=mode)
await state.set_state(AdminStates.creating_referral_contest_title)
await callback.message.edit_text(
texts.t("ADMIN_CONTEST_ENTER_TITLE", "Введите название конкурса:"),
reply_markup=None,
)
await callback.answer()
@admin_required
@error_handler
async def process_title(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
title = message.text.strip()
texts = get_texts(db_user.language)
await state.update_data(title=title)
await state.set_state(AdminStates.creating_referral_contest_description)
await message.answer(
texts.t("ADMIN_CONTEST_ENTER_DESCRIPTION", "Опишите конкурс (или отправьте '-' чтобы пропустить):")
)
@admin_required
@error_handler
async def process_description(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
description = message.text.strip()
if description in {"-", "skip", "пропустить"}:
description = None
await state.update_data(description=description)
await state.set_state(AdminStates.creating_referral_contest_prize)
texts = get_texts(db_user.language)
await message.answer(
texts.t("ADMIN_CONTEST_ENTER_PRIZE", "Укажите призы/выгоды конкурса (или '-' чтобы пропустить):")
)
@admin_required
@error_handler
async def process_prize(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
prize = message.text.strip()
if prize in {"-", "skip", "пропустить"}:
prize = None
await state.update_data(prize=prize)
await state.set_state(AdminStates.creating_referral_contest_start)
texts = get_texts(db_user.language)
await message.answer(
texts.t(
"ADMIN_CONTEST_ENTER_START",
"Введите дату и время старта (дд.мм.гггг чч:мм) по вашему часовому поясу:",
)
)
@admin_required
@error_handler
async def process_start_date(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
tz = _ensure_timezone(settings.TIMEZONE)
start_dt = _parse_local_datetime(message.text, tz)
texts = get_texts(db_user.language)
if not start_dt:
await message.answer(
texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату. Формат: 01.06.2024 12:00")
)
return
await state.update_data(start_at=start_dt.isoformat())
await state.set_state(AdminStates.creating_referral_contest_end)
await message.answer(
texts.t(
"ADMIN_CONTEST_ENTER_END",
"Введите дату и время окончания (дд.мм.гггг чч:мм) по вашему часовому поясу:",
)
)
@admin_required
@error_handler
async def process_end_date(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
tz = _ensure_timezone(settings.TIMEZONE)
end_dt = _parse_local_datetime(message.text, tz)
texts = get_texts(db_user.language)
if not end_dt:
await message.answer(
texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату. Формат: 01.06.2024 12:00")
)
return
data = await state.get_data()
start_raw = data.get("start_at")
start_dt = datetime.fromisoformat(start_raw) if start_raw else None
if start_dt and end_dt <= start_dt:
await message.answer(
texts.t(
"ADMIN_CONTEST_END_BEFORE_START",
"Дата окончания должна быть позже даты начала.",
)
)
return
await state.update_data(end_at=end_dt.isoformat())
await state.set_state(AdminStates.creating_referral_contest_time)
await message.answer(
texts.t(
"ADMIN_CONTEST_ENTER_DAILY_TIME",
"Во сколько отправлять ежедневные итоги? Укажите время в формате ЧЧ:ММ (например, 12:00).",
)
)
@admin_required
@error_handler
async def finalize_contest_creation(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
times = _parse_times(message.text or "")
summary_time = times[0] if times else _parse_time(message.text)
texts = get_texts(db_user.language)
if not summary_time:
await message.answer(
texts.t("ADMIN_CONTEST_INVALID_TIME", "Не удалось распознать время. Формат: 12:00 или 12:00,18:00")
)
return
data = await state.get_data()
tz = _ensure_timezone(settings.TIMEZONE)
start_at_raw = data.get("start_at")
end_at_raw = data.get("end_at")
if not start_at_raw or not end_at_raw:
await message.answer(texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату."))
return
start_at = (
datetime.fromisoformat(start_at_raw)
.astimezone(timezone.utc)
.replace(tzinfo=None)
)
end_at = (
datetime.fromisoformat(end_at_raw)
.astimezone(timezone.utc)
.replace(tzinfo=None)
)
contest_type = data.get("contest_type") or "referral_paid"
contest = await create_referral_contest(
db,
title=data.get("title"),
description=data.get("description"),
prize_text=data.get("prize"),
contest_type=contest_type,
start_at=start_at,
end_at=end_at,
daily_summary_time=summary_time,
daily_summary_times=",".join(t.strftime("%H:%M") for t in times) if times else None,
timezone_name=tz.key,
created_by=db_user.id,
)
await state.clear()
await message.answer(
texts.t("ADMIN_CONTEST_CREATED", "Конкурс создан!"),
reply_markup=get_referral_contest_manage_keyboard(
contest.id,
is_active=contest.is_active,
language=db_user.language,
),
)
@admin_required
@error_handler
async def show_detailed_stats(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
if not settings.is_contests_enabled():
await callback.answer(
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
show_alert=True,
)
return
contest_id = int(callback.data.split("_")[-1])
contest = await get_referral_contest(db, contest_id)
if not contest:
await callback.answer("Конкурс не найден.", show_alert=True)
return
from app.services.referral_contest_service import referral_contest_service
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
# Общее сообщение с основной статистикой
general_lines = [
"📈 <b>Статистика конкурса</b>",
f"🏆 {contest.title}",
"",
f"👥 Участников: <b>{stats['total_participants']}</b>",
f"📨 Приглашено рефералов: <b>{stats['total_invited']}</b>",
f"💰 Оплатили подписок: <b>{stats['total_paid_amount'] // 100} руб.</b>",
f"❌ Не оплатили: <b>{stats['total_unpaid']}</b>",
]
await callback.message.edit_text(
"\n".join(general_lines),
reply_markup=get_referral_contest_manage_keyboard(
contest_id, is_active=contest.is_active, language=db_user.language
),
)
await callback.answer()
@admin_required
@error_handler
async def show_detailed_stats_page(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
contest_id: int = None,
page: int = 1,
stats: dict = None,
):
if contest_id is None or stats is None:
# Парсим из callback.data: admin_contest_detailed_stats_page_{contest_id}_page_{page}
parts = callback.data.split("_")
contest_id = int(parts[5]) # contest_id после page
page = int(parts[7]) # page после второго page
# Получаем stats если не переданы
from app.services.referral_contest_service import referral_contest_service
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
participants = stats['participants']
total_participants = len(participants)
PAGE_SIZE = 10
total_pages = math.ceil(total_participants / PAGE_SIZE)
page = max(1, min(page, total_pages))
offset = (page - 1) * PAGE_SIZE
page_participants = participants[offset:offset + PAGE_SIZE]
lines = [f"📊 По участникам (страница {page}/{total_pages}):"]
for p in page_participants:
lines.extend([
f"• <b>{p['full_name']}</b>",
f" 📨 Приглашено: {p['total_referrals']}",
f" 💰 Оплатили: {p['paid_referrals']}",
f" ❌ Не оплатили: {p['unpaid_referrals']}",
f" 💵 Сумма: {p['total_paid_amount'] // 100} руб.",
"" # Пустая строка для разделения
])
pagination = get_admin_pagination_keyboard(
page,
total_pages,
f"admin_contest_detailed_stats_page_{contest_id}",
back_callback=f"admin_contest_view_{contest_id}",
language=db_user.language,
)
await callback.message.edit_text(
"\n".join(lines),
reply_markup=pagination,
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_contests_menu, F.data == "admin_contests")
dp.callback_query.register(show_referral_contests_menu, F.data == "admin_contests_referral")
dp.callback_query.register(list_contests, F.data == "admin_contests_list")
dp.callback_query.register(list_contests, F.data.startswith("admin_contests_list_page_"))
dp.callback_query.register(show_contest_details, F.data.startswith("admin_contest_view_"))
dp.callback_query.register(toggle_contest, F.data.startswith("admin_contest_toggle_"))
dp.callback_query.register(prompt_edit_summary_times, F.data.startswith("admin_contest_edit_times_"))
dp.callback_query.register(delete_contest, F.data.startswith("admin_contest_delete_"))
dp.callback_query.register(show_leaderboard, F.data.startswith("admin_contest_leaderboard_"))
dp.callback_query.register(show_detailed_stats, F.data.startswith("admin_contest_detailed_stats_"))
dp.callback_query.register(show_detailed_stats_page, F.data.startswith("admin_contest_detailed_stats_page_"))
dp.callback_query.register(start_contest_creation, F.data == "admin_contests_create")
dp.callback_query.register(select_contest_mode, F.data.in_(["admin_contest_mode_paid", "admin_contest_mode_registered"]))
dp.message.register(process_title, AdminStates.creating_referral_contest_title)
dp.message.register(process_description, AdminStates.creating_referral_contest_description)
dp.message.register(process_prize, AdminStates.creating_referral_contest_prize)
dp.message.register(process_start_date, AdminStates.creating_referral_contest_start)
dp.message.register(process_end_date, AdminStates.creating_referral_contest_end)
dp.message.register(finalize_contest_creation, AdminStates.creating_referral_contest_time)
dp.message.register(process_edit_summary_times, AdminStates.editing_referral_contest_summary_times)
+550
View File
@@ -0,0 +1,550 @@
import json
import logging
from datetime import datetime, timedelta
from typing import Dict
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.contest import (
get_template_by_id,
list_templates,
update_template_fields,
create_round,
)
from app.database.models import ContestTemplate
from app.keyboards.admin import (
get_admin_contests_keyboard,
get_admin_contests_root_keyboard,
get_daily_contest_manage_keyboard,
)
from app.localization.texts import get_texts
from app.services.contest_rotation_service import contest_rotation_service
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
EDITABLE_FIELDS: Dict[str, Dict] = {
"prize_days": {"type": int, "min": 1, "label": "приз (дни)"},
"max_winners": {"type": int, "min": 1, "label": "макс. победителей"},
"attempts_per_user": {"type": int, "min": 1, "label": "попыток на пользователя"},
"times_per_day": {"type": int, "min": 1, "label": "раундов в день"},
"schedule_times": {"type": str, "label": "расписание HH:MM через запятую"},
"cooldown_hours": {"type": int, "min": 1, "label": "длительность раунда (часы)"},
}
async def _get_template(db: AsyncSession, template_id: int) -> ContestTemplate | None:
return await get_template_by_id(db, template_id)
@admin_required
@error_handler
async def show_daily_contests(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
templates = await list_templates(db, enabled_only=False)
lines = [texts.t("ADMIN_DAILY_CONTESTS_TITLE", "📆 Ежедневные конкурсы")]
if not templates:
lines.append(texts.t("ADMIN_CONTESTS_EMPTY", "Пока нет созданных конкурсов."))
else:
for tpl in templates:
status = "🟢" if tpl.is_enabled else "⚪️"
lines.append(f"{status} <b>{tpl.name}</b> (slug: {tpl.slug}) — приз {tpl.prize_days}д, макс {tpl.max_winners}")
keyboard_rows = []
if templates:
keyboard_rows.append([types.InlineKeyboardButton(text="❌ Закрыть все активные раунды", callback_data="admin_daily_close_all")])
keyboard_rows.append([types.InlineKeyboardButton(text=" Сбросить попытки во всех активных раундах", callback_data="admin_daily_reset_all_attempts")])
keyboard_rows.append([types.InlineKeyboardButton(text=" Запустить все активные конкурсы", callback_data="admin_daily_start_all")])
for tpl in templates:
keyboard_rows.append(
[
types.InlineKeyboardButton(
text=f"⚙️ {tpl.name}",
callback_data=f"admin_daily_contest_{tpl.id}",
)
]
)
keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests")])
await callback.message.edit_text(
"\n".join(lines),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
await callback.answer()
@admin_required
@error_handler
async def show_daily_contest(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
try:
template_id = int(callback.data.split("_")[-1])
except Exception:
await callback.answer("Некорректный id", show_alert=True)
return
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
lines = [
f"🏷 <b>{tpl.name}</b> (slug: {tpl.slug})",
f"{texts.t('ADMIN_CONTEST_STATUS_ACTIVE','🟢 Активен') if tpl.is_enabled else texts.t('ADMIN_CONTEST_STATUS_INACTIVE','⚪️ Выключен')}",
f"Приз: {tpl.prize_days} дн. | Макс победителей: {tpl.max_winners}",
f"Попыток/польз: {tpl.attempts_per_user}",
f"Раундов в день: {tpl.times_per_day}",
f"Расписание: {tpl.schedule_times or '-'}",
f"Длительность раунда: {tpl.cooldown_hours} ч.",
]
await callback.message.edit_text(
"\n".join(lines),
reply_markup=get_daily_contest_manage_keyboard(tpl.id, tpl.is_enabled, db_user.language),
)
await callback.answer()
@admin_required
@error_handler
async def toggle_daily_contest(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split("_")[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
tpl.is_enabled = not tpl.is_enabled
await db.commit()
await callback.answer(texts.t("ADMIN_UPDATED", "Обновлено"))
await show_daily_contest(callback, db_user, db)
@admin_required
@error_handler
async def start_round_now(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split("_")[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
if not tpl.is_enabled:
tpl.is_enabled = True
await db.commit()
await db.refresh(tpl)
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
now = datetime.utcnow()
ends = now + timedelta(hours=tpl.cooldown_hours)
round_obj = await create_round(
db,
template=tpl,
starts_at=now,
ends_at=ends,
payload=payload,
)
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
tpl,
now.replace(tzinfo=None),
ends.replace(tzinfo=None),
)
await callback.answer(texts.t("ADMIN_ROUND_STARTED", "Раунд запущен"), show_alert=True)
await show_daily_contest(callback, db_user, db)
@admin_required
@error_handler
async def manual_start_round(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split("_")[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
# Проверяем, есть ли уже активный раунд для этого шаблона
from app.database.crud.contest import get_active_rounds
exists = await get_active_rounds(db, tpl.id)
if exists:
await callback.answer(texts.t("ADMIN_ROUND_ALREADY_ACTIVE", "Раунд уже активен."), show_alert=True)
await show_daily_contest(callback, db_user, db)
return
# Для ручного старта не включаем конкурс, если он выключен
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
now = datetime.utcnow()
ends = now + timedelta(hours=tpl.cooldown_hours)
round_obj = await create_round(
db,
template=tpl,
starts_at=now,
ends_at=ends,
payload=payload,
)
# Анонсируем всем пользователям (как тест)
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
tpl,
now.replace(tzinfo=None),
ends.replace(tzinfo=None),
)
await callback.answer(texts.t("ADMIN_ROUND_STARTED", "Тестовый раунд запущен"), show_alert=True)
await show_daily_contest(callback, db_user, db)
@admin_required
@error_handler
async def prompt_edit_field(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
parts = callback.data.split("_")
template_id = int(parts[3])
field = "_".join(parts[4:]) # поле может содержать подчеркивания
tpl = await _get_template(db, template_id)
if not tpl or field not in EDITABLE_FIELDS:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
meta = EDITABLE_FIELDS[field]
await state.set_state(AdminStates.editing_daily_contest_field)
await state.update_data(template_id=template_id, field=field)
kb = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_daily_contest_{template_id}",
)
]
]
)
await callback.message.edit_text(
texts.t(
"ADMIN_CONTEST_FIELD_PROMPT",
"Введите новое значение для {label}:",
).format(label=meta.get("label", field)),
reply_markup=kb,
)
await callback.answer()
@admin_required
@error_handler
async def process_edit_field(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
data = await state.get_data()
template_id = data.get("template_id")
field = data.get("field")
if not template_id or not field or field not in EDITABLE_FIELDS:
await message.answer(texts.ERROR)
await state.clear()
return
tpl = await _get_template(db, template_id)
if not tpl:
await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."))
await state.clear()
return
meta = EDITABLE_FIELDS[field]
raw = message.text or ""
try:
if meta["type"] is int:
value = int(raw)
if meta.get("min") is not None and value < meta["min"]:
raise ValueError("min")
else:
value = raw.strip()
except Exception:
await message.answer(texts.t("ADMIN_INVALID_NUMBER", "Некорректное число"))
await state.clear()
return
await update_template_fields(db, tpl, **{field: value})
back_kb = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_daily_contest_{template_id}",
)
]
]
)
await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"), reply_markup=back_kb)
await state.clear()
@admin_required
@error_handler
async def edit_payload(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split("_")[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
await state.set_state(AdminStates.editing_daily_contest_value)
await state.update_data(template_id=template_id, field="payload")
payload_json = json.dumps(tpl.payload or {}, ensure_ascii=False, indent=2)
kb = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_daily_contest_{template_id}",
)
]
]
)
await callback.message.edit_text(
texts.t("ADMIN_CONTEST_PAYLOAD_PROMPT", "Отправьте JSON payload для игры (словарь настроек):\n") + f"<code>{payload_json}</code>",
reply_markup=kb,
)
await callback.answer()
@admin_required
@error_handler
async def process_payload(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
data = await state.get_data()
template_id = data.get("template_id")
if not template_id:
await message.answer(texts.ERROR)
await state.clear()
return
try:
payload = json.loads(message.text or "{}")
if not isinstance(payload, dict):
raise ValueError
except Exception:
await message.answer(texts.t("ADMIN_INVALID_JSON", "Некорректный JSON"))
await state.clear()
return
tpl = await _get_template(db, template_id)
if not tpl:
await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."))
await state.clear()
return
await update_template_fields(db, tpl, payload=payload)
back_kb = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_daily_contest_{template_id}",
)
]
]
)
await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"), reply_markup=back_kb)
await state.clear()
@admin_required
@error_handler
async def start_all_contests(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
templates = await list_templates(db, enabled_only=True)
if not templates:
await callback.answer(texts.t("ADMIN_CONTESTS_EMPTY", "Нет активных конкурсов."), show_alert=True)
return
started_count = 0
for tpl in templates:
from app.database.crud.contest import get_active_round_by_template
exists = await get_active_round_by_template(db, tpl.id)
if exists:
continue # уже запущен
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
now = datetime.utcnow()
ends = now + timedelta(hours=tpl.cooldown_hours)
round_obj = await create_round(
db,
template=tpl,
starts_at=now,
ends_at=ends,
payload=payload,
)
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
tpl,
now.replace(tzinfo=None),
ends.replace(tzinfo=None),
)
started_count += 1
message = f"Запущено конкурсов: {started_count}"
await callback.answer(message, show_alert=True)
await show_daily_contests(callback, db_user, db)
@admin_required
@error_handler
async def close_all_rounds(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
from app.database.crud.contest import get_active_rounds
active_rounds = await get_active_rounds(db)
if not active_rounds:
await callback.answer("Нет активных раундов", show_alert=True)
return
for rnd in active_rounds:
rnd.status = "finished"
await db.commit()
await callback.answer(f"Закрыто раундов: {len(active_rounds)}", show_alert=True)
await show_daily_contests(callback, db_user, db)
@admin_required
@error_handler
async def reset_all_attempts(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
from app.database.crud.contest import get_active_rounds
active_rounds = await get_active_rounds(db)
if not active_rounds:
await callback.answer("Нет активных раундов", show_alert=True)
return
total_deleted = 0
for rnd in active_rounds:
deleted = await clear_attempts(db, rnd.id)
total_deleted += deleted
await callback.answer(f"Попытки сброшены: {total_deleted}", show_alert=True)
await show_daily_contests(callback, db_user, db)
@admin_required
@error_handler
async def reset_attempts(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split("_")[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
from app.database.crud.contest import get_active_round_by_template, clear_attempts
round_obj = await get_active_round_by_template(db, tpl.id)
if not round_obj:
await callback.answer("Нет активного раунда", show_alert=True)
return
deleted_count = await clear_attempts(db, round_obj.id)
await callback.answer(f"Попытки сброшены: {deleted_count}", show_alert=True)
await show_daily_contest(callback, db_user, db)
@admin_required
@error_handler
async def close_round(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split("_")[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
from app.database.crud.contest import get_active_round_by_template
round_obj = await get_active_round_by_template(db, tpl.id)
if not round_obj:
await callback.answer("Нет активного раунда", show_alert=True)
return
round_obj.status = "finished"
await db.commit()
await db.refresh(round_obj)
await callback.answer("Раунд закрыт", show_alert=True)
await show_daily_contest(callback, db_user, db)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_daily_contests, F.data == "admin_contests_daily")
dp.callback_query.register(show_daily_contest, F.data.startswith("admin_daily_contest_"))
dp.callback_query.register(toggle_daily_contest, F.data.startswith("admin_daily_toggle_"))
dp.callback_query.register(start_all_contests, F.data == "admin_daily_start_all")
dp.callback_query.register(start_round_now, F.data.startswith("admin_daily_start_"))
dp.callback_query.register(manual_start_round, F.data.startswith("admin_daily_manual_"))
dp.callback_query.register(close_all_rounds, F.data == "admin_daily_close_all")
dp.callback_query.register(reset_all_attempts, F.data == "admin_daily_reset_all_attempts")
dp.callback_query.register(reset_attempts, F.data.startswith("admin_daily_reset_attempts_"))
dp.callback_query.register(close_round, F.data.startswith("admin_daily_close_"))
dp.callback_query.register(prompt_edit_field, F.data.startswith("admin_daily_edit_"))
dp.callback_query.register(edit_payload, F.data.startswith("admin_daily_payload_"))
dp.message.register(process_edit_field, AdminStates.editing_daily_contest_field)
dp.message.register(process_payload, AdminStates.editing_daily_contest_value)
+100
View File
@@ -1047,6 +1047,106 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
and (user.subscription.traffic_used_gb or 0) <= 0
]
if target == "expiring_subscribers":
expiring_subs = await get_expiring_subscriptions(db, 7)
return [sub.user for sub in expiring_subs if sub.user]
if target == "expired_subscribers":
now = datetime.utcnow()
expired_statuses = {
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
}
expired_users = []
for user in users:
subscription = user.subscription
if subscription:
if subscription.status in expired_statuses:
expired_users.append(user)
continue
if subscription.end_date <= now and not subscription.is_active:
expired_users.append(user)
continue
elif user.has_had_paid_subscription:
expired_users.append(user)
return expired_users
if target == "canceled_subscribers":
return [
user
for user in users
if user.subscription
and user.subscription.status == SubscriptionStatus.DISABLED.value
]
if target == "trial_ending":
now = datetime.utcnow()
in_3_days = now + timedelta(days=3)
return [
user
for user in users
if user.subscription
and user.subscription.is_trial
and user.subscription.is_active
and user.subscription.end_date <= in_3_days
]
if target == "trial_expired":
now = datetime.utcnow()
return [
user
for user in users
if user.subscription
and user.subscription.is_trial
and user.subscription.end_date <= now
]
if target == "autopay_failed":
from app.database.models import SubscriptionEvent
week_ago = datetime.utcnow() - timedelta(days=7)
stmt = select(SubscriptionEvent.user_id).where(
and_(
SubscriptionEvent.event_type == "autopay_failed",
SubscriptionEvent.occurred_at >= week_ago,
)
).distinct()
result = await db.execute(stmt)
failed_user_ids = set(result.scalars().all())
return [user for user in users if user.id in failed_user_ids]
if target == "low_balance":
threshold_kopeks = 10000 # 100 рублей
return [
user
for user in users
if (user.balance_kopeks or 0) < threshold_kopeks
and (user.balance_kopeks or 0) > 0
]
if target == "inactive_30d":
threshold = datetime.utcnow() - timedelta(days=30)
return [
user
for user in users
if user.last_activity and user.last_activity < threshold
]
if target == "inactive_60d":
threshold = datetime.utcnow() - timedelta(days=60)
return [
user
for user in users
if user.last_activity and user.last_activity < threshold
]
if target == "inactive_90d":
threshold = datetime.utcnow() - timedelta(days=90)
return [
user
for user in users
if user.last_activity and user.last_activity < threshold
]
return []
+2 -2
View File
@@ -175,7 +175,7 @@ async def process_yookassa_payment_amount(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),
receipt_email=None,
receipt_phone=None,
metadata={
@@ -321,7 +321,7 @@ async def process_yookassa_sbp_payment_amount(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),
receipt_email=None,
receipt_phone=None,
metadata={
+414
View File
@@ -0,0 +1,414 @@
import logging
import random
from datetime import datetime, timedelta
from aiogram import Dispatcher, F, types
from aiogram.fsm.context import FSMContext
from aiogram.filters import Command
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.contest import (
get_active_rounds,
get_attempt,
create_attempt,
increment_winner_count,
)
from app.database.database import AsyncSessionLocal
from app.database.models import ContestRound, ContestTemplate, SubscriptionStatus
from app.localization.texts import get_texts
from app.services.contest_rotation_service import (
GAME_QUEST,
GAME_LOCKS,
GAME_CIPHER,
GAME_SERVER,
GAME_BLITZ,
GAME_EMOJI,
GAME_ANAGRAM,
)
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.crud.subscription import extend_subscription
from app.utils.decorators import auth_required, error_handler
from app.keyboards.inline import get_back_keyboard
from app.states import ContestStates
logger = logging.getLogger(__name__)
def _user_allowed(subscription) -> bool:
if not subscription:
return False
return subscription.status in {
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
}
async def _award_prize(db: AsyncSession, user_id: int, prize_days: int, language: str) -> str:
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if not user:
return ""
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
return ""
await extend_subscription(db, subscription, prize_days)
texts = get_texts(language)
return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=prize_days)
async def _reply_not_eligible(callback: types.CallbackQuery, language: str):
texts = get_texts(language)
await callback.answer(texts.t("CONTEST_NOT_ELIGIBLE", "Игры доступны только с активной или триальной подпиской."), show_alert=True)
# ---------- Handlers ----------
@auth_required
@error_handler
async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSession):
texts = get_texts(db_user.language)
subscription = await get_subscription_by_user_id(db, db_user.id)
if not _user_allowed(subscription):
await _reply_not_eligible(callback, db_user.language)
return
active_rounds = await get_active_rounds(db)
unique_templates = {}
for rnd in active_rounds:
if not rnd.template or not rnd.template.is_enabled:
continue
tpl_slug = rnd.template.slug if rnd.template else ""
if tpl_slug not in unique_templates:
unique_templates[tpl_slug] = rnd
buttons = []
for tpl_slug, rnd in unique_templates.items():
title = rnd.template.name if rnd.template else tpl_slug
buttons.append(
[
types.InlineKeyboardButton(
text=f"▶️ {title}",
callback_data=f"contest_play_{tpl_slug}_{rnd.id}",
)
]
)
if not buttons:
buttons.append(
[types.InlineKeyboardButton(text=texts.t("CONTEST_EMPTY", "Сейчас игр нет"), callback_data="noop")]
)
buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
await callback.message.edit_text(
texts.t("CONTEST_MENU_TITLE", "🎲 <b>Игры/Конкурсы</b>\nВыберите игру:"),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
)
await callback.answer()
@auth_required
@error_handler
async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user, db: AsyncSession):
texts = get_texts(db_user.language)
subscription = await get_subscription_by_user_id(db, db_user.id)
if not _user_allowed(subscription):
await _reply_not_eligible(callback, db_user.language)
return
parts = callback.data.split("_")
if len(parts) < 4 or parts[0] != "contest" or parts[1] != "play":
await callback.answer("Некорректные данные", show_alert=True)
return
round_id_str = parts[-1]
try:
round_id = int(round_id_str)
except ValueError:
await callback.answer("Некорректные данные", show_alert=True)
return
slug = "_".join(parts[2:-1])
# reload round with template
async with AsyncSessionLocal() as db2:
active_rounds = await get_active_rounds(db2)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён или недоступен."), show_alert=True)
return
if not round_obj.template or not round_obj.template.is_enabled:
await callback.answer(texts.t("CONTEST_DISABLED", "Игра отключена."), show_alert=True)
return
attempt = await get_attempt(db2, round_id, db_user.id)
if attempt:
await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка в этом раунде."), show_alert=True)
return
tpl = round_obj.template
if tpl.slug == GAME_QUEST:
await _render_quest(callback, db_user, round_obj, tpl)
elif tpl.slug == GAME_LOCKS:
await _render_locks(callback, db_user, round_obj, tpl)
elif tpl.slug == GAME_SERVER:
await _render_server_lottery(callback, db_user, round_obj, tpl)
elif tpl.slug == GAME_CIPHER:
await _render_cipher(callback, db_user, round_obj, tpl, state)
elif tpl.slug == GAME_EMOJI:
await _render_emoji(callback, db_user, round_obj, tpl, state)
elif tpl.slug == GAME_ANAGRAM:
await _render_anagram(callback, db_user, round_obj, tpl, state)
elif tpl.slug == GAME_BLITZ:
await _render_blitz(callback, db_user, round_obj, tpl)
else:
await callback.answer(texts.t("CONTEST_UNKNOWN", "Тип конкурса не поддерживается."), show_alert=True)
async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
texts = get_texts(db_user.language)
rows = round_obj.payload.get("rows", 3)
cols = round_obj.payload.get("cols", 3)
keyboard = []
for r in range(rows):
row_buttons = []
for c in range(cols):
idx = r * cols + c
row_buttons.append(
types.InlineKeyboardButton(
text="🎛",
callback_data=f"contest_pick_{round_obj.id}_quest_{idx}"
)
)
keyboard.append(row_buttons)
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
await callback.message.edit_text(
texts.t("CONTEST_QUEST_PROMPT", "Выбери один из узлов 3×3:"),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
async def _render_locks(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
texts = get_texts(db_user.language)
total = round_obj.payload.get("total", 20)
keyboard = []
row = []
for i in range(total):
row.append(types.InlineKeyboardButton(text="🔒", callback_data=f"contest_pick_{round_obj.id}_locks_{i}"))
if len(row) == 5:
keyboard.append(row)
row = []
if row:
keyboard.append(row)
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
await callback.message.edit_text(
texts.t("CONTEST_LOCKS_PROMPT", "Найди взломанную кнопку среди замков:"),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
async def _render_server_lottery(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
texts = get_texts(db_user.language)
flags = round_obj.payload.get("flags") or []
shuffled_flags = flags.copy()
random.shuffle(shuffled_flags)
keyboard = []
row = []
for flag in shuffled_flags:
row.append(types.InlineKeyboardButton(text=flag, callback_data=f"contest_pick_{round_obj.id}_{flag}"))
if len(row) == 5:
keyboard.append(row)
row = []
if row:
keyboard.append(row)
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
await callback.message.edit_text(
texts.t("CONTEST_SERVER_PROMPT", "Выбери сервер:"),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
async def _render_cipher(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
texts = get_texts(db_user.language)
question = round_obj.payload.get("question", "")
await state.set_state(ContestStates.waiting_for_answer)
await state.update_data(contest_round_id=round_obj.id)
await callback.message.edit_text(
texts.t("CONTEST_CIPHER_PROMPT", "Расшифруй: {q}").format(q=question),
reply_markup=get_back_keyboard(db_user.language),
)
await callback.answer()
async def _render_emoji(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
texts = get_texts(db_user.language)
question = round_obj.payload.get("question", "🤔")
emoji_list = question.split()
random.shuffle(emoji_list)
shuffled_question = " ".join(emoji_list)
await state.set_state(ContestStates.waiting_for_answer)
await state.update_data(contest_round_id=round_obj.id)
await callback.message.edit_text(
texts.t("CONTEST_EMOJI_PROMPT", "Угадай сервис по эмодзи: {q}").format(q=shuffled_question),
reply_markup=get_back_keyboard(db_user.language),
)
await callback.answer()
async def _render_anagram(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
texts = get_texts(db_user.language)
letters = round_obj.payload.get("letters", "")
await state.set_state(ContestStates.waiting_for_answer)
await state.update_data(contest_round_id=round_obj.id)
await callback.message.edit_text(
texts.t("CONTEST_ANAGRAM_PROMPT", "Составь слово: {letters}").format(letters=letters),
reply_markup=get_back_keyboard(db_user.language),
)
await callback.answer()
async def _render_blitz(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
texts = get_texts(db_user.language)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("CONTEST_BLITZ_BUTTON", "Я здесь!"), callback_data=f"contest_pick_{round_obj.id}_blitz")]
]
)
await callback.message.edit_text(
texts.t("CONTEST_BLITZ_PROMPT", "⚡️ Блиц! Нажми «Я здесь!»"),
reply_markup=keyboard,
)
await callback.answer()
@auth_required
@error_handler
async def handle_pick(callback: types.CallbackQuery, db_user, db: AsyncSession):
texts = get_texts(db_user.language)
parts = callback.data.split("_")
if len(parts) < 4 or parts[0] != "contest" or parts[1] != "pick":
await callback.answer("Некорректные данные", show_alert=True)
return
round_id_str = parts[2]
pick = "_".join(parts[3:])
try:
round_id = int(round_id_str)
except ValueError:
await callback.answer("Некорректные данные", show_alert=True)
return
async with AsyncSessionLocal() as db2:
active_rounds = await get_active_rounds(db2)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), show_alert=True)
return
tpl = round_obj.template
attempt = await get_attempt(db2, round_id, db_user.id)
if attempt:
await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), show_alert=True)
return
secret_idx = round_obj.payload.get("secret_idx")
correct_flag = ""
if tpl.slug == GAME_SERVER:
flags = round_obj.payload.get("flags") or []
correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ""
is_winner = False
if tpl.slug == GAME_SERVER:
is_winner = pick == correct_flag
elif tpl.slug == GAME_QUEST:
# Format: quest_{idx}
try:
if pick.startswith("quest_"):
idx = int(pick.split("_")[1])
is_winner = secret_idx is not None and idx == secret_idx
except (ValueError, IndexError):
is_winner = False
elif tpl.slug == GAME_LOCKS:
# Format: locks_{idx}
try:
if pick.startswith("locks_"):
idx = int(pick.split("_")[1])
is_winner = secret_idx is not None and idx == secret_idx
except (ValueError, IndexError):
is_winner = False
elif tpl.slug == GAME_BLITZ:
is_winner = pick == "blitz"
else:
is_winner = False
# Check if max winners already reached
if is_winner and round_obj.winners_count >= round_obj.max_winners:
is_winner = False # Too late, max winners already reached
await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=str(pick), is_winner=is_winner)
if is_winner:
await increment_winner_count(db2, round_obj)
prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language)
await callback.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), show_alert=True)
else:
responses = {
GAME_QUEST: ["Пусто", "Ложный сервер", "Найди другой узел"],
GAME_LOCKS: ["Заблокировано", "Попробуй ещё", "Нет доступа"],
GAME_SERVER: ["Сервер перегружен", "Нет ответа", "Попробуй завтра"],
}.get(tpl.slug, ["Неудача"])
await callback.answer(random.choice(responses), show_alert=True)
@auth_required
@error_handler
async def handle_text_answer(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
texts = get_texts(db_user.language)
data = await state.get_data()
round_id = data.get("contest_round_id")
if not round_id:
return
async with AsyncSessionLocal() as db2:
active_rounds = await get_active_rounds(db2)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
await message.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), reply_markup=get_back_keyboard(db_user.language))
await state.clear()
return
attempt = await get_attempt(db2, round_obj.id, db_user.id)
if attempt:
await message.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), reply_markup=get_back_keyboard(db_user.language))
await state.clear()
return
answer = (message.text or "").strip().upper()
tpl = round_obj.template
correct = (round_obj.payload.get("answer") or "").upper()
is_winner = correct and answer == correct
# Check if max winners already reached
if is_winner and round_obj.winners_count >= round_obj.max_winners:
is_winner = False # Too late, max winners already reached
await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=answer, is_winner=is_winner)
if is_winner:
await increment_winner_count(db2, round_obj)
prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language)
await message.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), reply_markup=get_back_keyboard(db_user.language))
else:
await message.answer(texts.t("CONTEST_LOSE", "Не верно, попробуй снова в следующем раунде."), reply_markup=get_back_keyboard(db_user.language))
await state.clear()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_contests_menu, F.data == "contests_menu")
dp.callback_query.register(play_contest, F.data.startswith("contest_play_"))
dp.callback_query.register(handle_pick, F.data.startswith("contest_pick_"))
dp.message.register(handle_text_answer, ContestStates.waiting_for_answer)
dp.message.register(lambda message: None, Command("contests")) # placeholder
+132 -30
View File
@@ -17,6 +17,7 @@ from app.database.crud.promo_group import (
from app.database.crud.transaction import get_user_total_spent_kopeks
from app.keyboards.inline import (
get_main_menu_keyboard,
get_main_menu_keyboard_async,
get_language_selection_keyboard,
get_info_menu_keyboard,
)
@@ -39,7 +40,7 @@ from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.public_offer_service import PublicOfferService
from app.services.faq_service import FaqService
from app.utils.timezone import format_local_datetime
from app.utils.pricing_utils import format_period_description
from app.handlers.subscription.traffic import handle_add_traffic
logger = logging.getLogger(__name__)
@@ -198,22 +199,26 @@ async def show_main_menu(
subscription_is_active=subscription_is_active,
)
keyboard = await get_main_menu_keyboard_async(
db=db,
user=db_user,
language=db_user.language,
is_admin=is_admin,
is_moderator=is_moderator,
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
has_saved_cart=has_saved_cart,
custom_buttons=custom_buttons,
)
await edit_or_answer_photo(
callback=callback,
caption=menu_text,
keyboard=get_main_menu_keyboard(
language=db_user.language,
is_admin=is_admin,
is_moderator=is_moderator,
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине
custom_buttons=custom_buttons,
),
keyboard=keyboard,
parse_mode="HTML",
force_text=settings.is_text_main_menu_mode(),
)
@@ -518,6 +523,7 @@ async def show_faq_pages(
await callback.message.edit_text(
caption,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
disable_web_page_preview=settings.DISABLE_WEB_PAGE_PREVIEW,
)
await callback.answer()
@@ -654,6 +660,7 @@ async def show_faq_page(
await callback.message.edit_text(
message_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
disable_web_page_preview=settings.DISABLE_WEB_PAGE_PREVIEW,
)
await callback.answer()
@@ -772,6 +779,7 @@ async def show_privacy_policy(
await callback.message.edit_text(
message_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
disable_web_page_preview=settings.DISABLE_WEB_PAGE_PREVIEW,
)
await callback.answer()
@@ -891,6 +899,7 @@ async def show_public_offer(
await callback.message.edit_text(
message_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
disable_web_page_preview=settings.DISABLE_WEB_PAGE_PREVIEW,
)
await callback.answer()
@@ -1057,22 +1066,26 @@ async def handle_back_to_menu(
subscription_is_active=subscription_is_active,
)
keyboard = await get_main_menu_keyboard_async(
db=db,
user=db_user,
language=db_user.language,
is_admin=is_admin,
is_moderator=is_moderator,
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
has_saved_cart=has_saved_cart,
custom_buttons=custom_buttons,
)
await edit_or_answer_photo(
callback=callback,
caption=menu_text,
keyboard=get_main_menu_keyboard(
language=db_user.language,
is_admin=is_admin,
is_moderator=is_moderator,
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине
custom_buttons=custom_buttons,
),
keyboard=keyboard,
parse_mode="HTML",
force_text=settings.is_text_main_menu_mode(),
)
@@ -1212,15 +1225,94 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
random_message = await get_random_active_message(db)
if random_message:
return _insert_random_message(base_text, random_message, action_prompt)
except Exception as e:
logger.error(f"Ошибка получения случайного сообщения: {e}")
return base_text
def register_handlers(dp: Dispatcher):
async def handle_activate_button(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
# Получить подписку пользователя
from app.database.crud.subscription import get_subscription_by_user_id
subscription = await get_subscription_by_user_id(db, db_user.id)
if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow():
await callback.answer(
texts.t("SUBSCRIPTION_ALREADY_ACTIVE", "✅ Подписка уже активна!"),
show_alert=True,
)
return
# Параметры из подписки или дефолтные
device_limit = subscription.device_limit if subscription else settings.DEFAULT_DEVICE_LIMIT
traffic_limit_gb = subscription.traffic_limit_gb if subscription else 0
connected_squads = subscription.connected_squads if subscription else []
# Получить IDs серверов из UUIDs
from app.database.crud.server_squad import get_server_ids_by_uuids
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
balance = db_user.balance_kopeks
available_periods = [int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS]
best_period = None
best_price = 0
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
# Найти максимальный период, цена которого <= баланса
for period in sorted(available_periods, reverse=True):
price, _ = await subscription_service.calculate_subscription_price_with_months(
period,
traffic_limit_gb,
server_ids,
device_limit,
db,
user=db_user
)
if price <= balance:
best_period = period
best_price = price
break
if best_period:
# Создать новую подписку
from app.database.crud.subscription import create_paid_subscription
new_subscription = await create_paid_subscription(
db,
db_user.id,
best_period,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads,
update_server_counters=True
)
# Списать деньги
db_user.balance_kopeks -= best_price
await db.commit()
await callback.answer(
texts.t("ACTIVATION_SUCCESS", f"✅ Подписка активирована на {best_period} дней за {best_price//100} руб!"),
show_alert=True,
)
else:
await callback.answer(
texts.t("INSUFFICIENT_FUNDS", "❌ Недостаточно средств для активации подписки"),
show_alert=True,
)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
handle_back_to_menu,
F.data == "back_to_menu"
@@ -1286,3 +1378,13 @@ def register_handlers(dp: Dispatcher):
F.data.startswith("language_select:"),
StateFilter(None)
)
dp.callback_query.register(
handle_add_traffic,
F.data == "buy_traffic"
)
dp.callback_query.register(
handle_activate_button,
F.data == "activate_button"
)
+185 -162
View File
@@ -2,6 +2,7 @@ import logging
from datetime import datetime
from aiogram import Dispatcher, types, F, Bot
from aiogram.enums import ChatMemberStatus
from aiogram.exceptions import TelegramForbiddenError
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
@@ -22,6 +23,7 @@ from app.keyboards.inline import (
get_rules_keyboard,
get_privacy_policy_keyboard,
get_main_menu_keyboard,
get_main_menu_keyboard_async,
get_post_registration_keyboard,
get_language_selection_keyboard,
)
@@ -264,10 +266,14 @@ async def _continue_registration_after_language(
return
rules_text = await get_rules(language)
await target_message.answer(
rules_text,
reply_markup=get_rules_keyboard(language)
)
try:
await target_message.answer(
rules_text,
reply_markup=get_rules_keyboard(language)
)
except TelegramForbiddenError:
logger.warning(f"⚠️ Пользователь {callback.from_user.id if callback else message.from_user.id} заблокировал бота, пропускаем отправку правил")
return
await state.set_state(RegistrationStates.waiting_for_rules_accept)
logger.info("📋 LANGUAGE: Правила отправлены после выбора языка")
@@ -319,7 +325,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
if referral_code:
await state.update_data(referral_code=referral_code)
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
if campaign and not campaign_notification_sent:
@@ -337,32 +343,32 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
campaign.id,
notify_error,
)
if user and user.status != UserStatus.DELETED.value:
logger.info(f"✅ Активный пользователь найден: {user.telegram_id}")
profile_updated = False
if user.username != message.from_user.username:
old_username = user.username
user.username = message.from_user.username
logger.info(f"📝 Username обновлен: '{old_username}''{user.username}'")
profile_updated = True
if user.first_name != message.from_user.first_name:
old_first_name = user.first_name
user.first_name = message.from_user.first_name
logger.info(f"📝 Имя обновлено: '{old_first_name}''{user.first_name}'")
profile_updated = True
if user.last_name != message.from_user.last_name:
old_last_name = user.last_name
user.last_name = message.from_user.last_name
logger.info(f"📝 Фамилия обновлена: '{old_last_name}''{user.last_name}'")
profile_updated = True
user.last_activity = datetime.utcnow()
if profile_updated:
user.updated_at = datetime.utcnow()
await db.commit()
@@ -370,7 +376,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
logger.info(f"💾 Профиль пользователя {user.telegram_id} обновлен")
else:
await db.commit()
texts = get_texts(user.language)
if referral_code and not user.referred_by_id:
@@ -393,11 +399,11 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
logger.error(
f"Ошибка отправки уведомления о рекламной кампании: {e}"
)
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
user.subscription
)
menu_text = await get_main_menu_text(user, texts, db)
is_admin = settings.is_admin(user.telegram_id)
@@ -414,35 +420,38 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
subscription_is_active=subscription_is_active,
)
keyboard = await get_main_menu_keyboard_async(
db=db,
user=user,
language=user.language,
is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
await message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
language=user.language,
is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
return
if user and user.status == UserStatus.DELETED.value:
logger.info(f"🔄 Удаленный пользователь {user.telegram_id} начинает повторную регистрацию")
try:
from app.services.user_service import UserService
from app.database.models import (
Subscription, Transaction, PromoCodeUse,
Subscription, Transaction, PromoCodeUse,
ReferralEarning, SubscriptionServer
)
from sqlalchemy import delete
if user.subscription:
await decrement_subscription_server_counts(db, user.subscription)
await db.execute(
@@ -451,51 +460,51 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
)
)
logger.info(f"🗑️ Удалены записи SubscriptionServer")
if user.subscription:
await db.delete(user.subscription)
logger.info(f"🗑️ Удалена подписка пользователя")
await db.execute(
delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id)
)
await db.execute(
delete(ReferralEarning).where(ReferralEarning.user_id == user.id)
)
await db.execute(
delete(ReferralEarning).where(ReferralEarning.referral_id == user.id)
)
await db.execute(
delete(Transaction).where(Transaction.user_id == user.id)
)
user.status = UserStatus.ACTIVE.value
user.balance_kopeks = 0
user.remnawave_uuid = None
user.has_had_paid_subscription = False
user.referred_by_id = None
user.username = message.from_user.username
user.first_name = message.from_user.first_name
user.last_name = message.from_user.last_name
user.updated_at = datetime.utcnow()
user.last_activity = datetime.utcnow()
from app.utils.user_utils import generate_unique_referral_code
user.referral_code = await generate_unique_referral_code(db, user.telegram_id)
await db.commit()
logger.info(f"✅ Пользователь {user.telegram_id} подготовлен к восстановлению")
except Exception as e:
logger.error(f"❌ Ошибка подготовки к восстановлению: {e}")
await db.rollback()
else:
logger.info(f"🆕 Новый пользователь, начинаем регистрацию")
data = await state.get_data() or {}
if not data.get('language'):
if settings.is_language_selection_enabled():
@@ -626,11 +635,11 @@ async def _show_privacy_policy_after_rules(
Возвращает True, если политика была показана, False если её нет или произошла ошибка.
"""
policy = await PrivacyPolicyService.get_policy(db, language, fallback=True)
if not policy or not policy.is_enabled:
logger.info("⚠️ Политика конфиденциальности не включена, пропускаем её показ")
return False
if not policy.content or not policy.content.strip():
privacy_policy_text = get_privacy_policy(language)
if not privacy_policy_text or not privacy_policy_text.strip():
@@ -640,7 +649,7 @@ async def _show_privacy_policy_after_rules(
else:
privacy_policy_text = policy.content
logger.info(f"🔒 Используется политика конфиденциальности из БД для языка {language}")
try:
await callback.message.edit_text(
privacy_policy_text,
@@ -675,7 +684,7 @@ async def _continue_registration_after_rules(
"""
data = await state.get_data() or {}
texts = get_texts(language)
if data.get('referral_code'):
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
@@ -717,10 +726,10 @@ async def process_rules_accept(
logger.info(f"📋 RULES: Начало обработки правил")
logger.info(f"📊 Callback data: {callback.data}")
logger.info(f"👤 User: {callback.from_user.id}")
current_state = await state.get_state()
logger.info(f"📊 Текущее состояние: {current_state}")
language = DEFAULT_LANGUAGE
texts = get_texts(language)
@@ -730,24 +739,24 @@ async def process_rules_accept(
data = await state.get_data() or {}
language = data.get('language', language)
texts = get_texts(language)
if callback.data == 'rules_accept':
logger.info(f"✅ Правила приняты пользователем {callback.from_user.id}")
# Пытаемся показать политику конфиденциальности
policy_shown = await _show_privacy_policy_after_rules(
callback, state, db, language
)
# Если политика не была показана, продолжаем регистрацию
if not policy_shown:
await _continue_registration_after_rules(
callback, state, db, language
)
else:
logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}")
rules_required_text = texts.t(
"RULES_REQUIRED",
"Для использования бота необходимо принять правила сервиса.",
@@ -767,9 +776,9 @@ async def process_rules_accept(
)
except:
pass
logger.info(f"✅ Правила обработаны для пользователя {callback.from_user.id}")
except Exception as e:
logger.error(f"❌ Ошибка обработки правил: {e}", exc_info=True)
await callback.answer(
@@ -798,14 +807,14 @@ async def process_privacy_policy_accept(
state: FSMContext,
db: AsyncSession
):
logger.info(f"🔒 PRIVACY POLICY: Начало обработки политики конфиденциальности")
logger.info(f"📊 Callback data: {callback.data}")
logger.info(f"👤 User: {callback.from_user.id}")
current_state = await state.get_state()
logger.info(f"📊 Текущее состояние: {current_state}")
language = DEFAULT_LANGUAGE
texts = get_texts(language)
@@ -815,10 +824,10 @@ async def process_privacy_policy_accept(
data = await state.get_data() or {}
language = data.get('language', language)
texts = get_texts(language)
if callback.data == 'privacy_policy_accept':
logger.info(f"✅ Политика конфиденциальности принята пользователем {callback.from_user.id}")
try:
await callback.message.delete()
logger.info(f"🗑️ Сообщение с политикой конфиденциальности удалено")
@@ -834,7 +843,7 @@ async def process_privacy_policy_accept(
)
except Exception:
pass
if data.get('referral_code'):
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
@@ -853,7 +862,7 @@ async def process_privacy_policy_accept(
try:
await state.set_data(data)
await state.set_state(RegistrationStates.waiting_for_referral_code)
await callback.bot.send_message(
chat_id=callback.from_user.id,
text=texts.t(
@@ -866,10 +875,10 @@ async def process_privacy_policy_accept(
except Exception as e:
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
await complete_registration_from_callback(callback, state, db)
else:
logger.info(f"❌ Политика конфиденциальности отклонена пользователем {callback.from_user.id}")
privacy_policy_required_text = texts.t(
"PRIVACY_POLICY_REQUIRED",
"Для использования бота необходимо принять политику конфиденциальности.",
@@ -889,9 +898,9 @@ async def process_privacy_policy_accept(
)
except:
pass
logger.info(f"✅ Политика конфиденциальности обработана для пользователя {callback.from_user.id}")
except Exception as e:
logger.error(f"❌ Ошибка обработки политики конфиденциальности: {e}", exc_info=True)
await callback.answer(
@@ -994,7 +1003,7 @@ async def process_referral_code_skip(
)
except:
pass
await complete_registration_from_callback(callback, state, db)
@@ -1029,11 +1038,11 @@ async def complete_registration_from_callback(
from sqlalchemy.orm import selectinload
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
logger.warning(f"⚠️ Пользователь {callback.from_user.id} уже активен! Показываем главное меню.")
texts = get_texts(existing_user.language)
data = await state.get_data() or {}
if data.get('referral_code') and not existing_user.referred_by_id:
await callback.message.answer(
@@ -1042,13 +1051,13 @@ async def complete_registration_from_callback(
"ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.",
)
)
await db.refresh(existing_user, ['subscription'])
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
existing_user.subscription
)
menu_text = await get_main_menu_text(existing_user, texts, db)
is_admin = settings.is_admin(existing_user.telegram_id)
@@ -1067,19 +1076,22 @@ async def complete_registration_from_callback(
)
try:
keyboard = await get_main_menu_keyboard_async(
db=db,
user=existing_user,
language=existing_user.language,
is_admin=is_admin,
has_had_paid_subscription=existing_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=existing_user.balance_kopeks,
subscription=existing_user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
await callback.message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
language=existing_user.language,
is_admin=is_admin,
has_had_paid_subscription=existing_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=existing_user.balance_kopeks,
subscription=existing_user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
reply_markup=keyboard,
parse_mode="HTML"
)
except Exception as e:
@@ -1090,10 +1102,10 @@ async def complete_registration_from_callback(
"Добро пожаловать, {user_name}!",
).format(user_name=existing_user.full_name)
)
await state.clear()
return
data = await state.get_data() or {}
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
@@ -1112,10 +1124,10 @@ async def complete_registration_from_callback(
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
referrer_id = referrer.id
if existing_user and existing_user.status == UserStatus.DELETED.value:
logger.info(f"🔄 Восстанавливаем удаленного пользователя {callback.from_user.id}")
existing_user.username = callback.from_user.username
existing_user.first_name = callback.from_user.first_name
existing_user.last_name = callback.from_user.last_name
@@ -1124,22 +1136,22 @@ async def complete_registration_from_callback(
existing_user.status = UserStatus.ACTIVE.value
existing_user.balance_kopeks = 0
existing_user.has_had_paid_subscription = False
from datetime import datetime
existing_user.updated_at = datetime.utcnow()
existing_user.last_activity = datetime.utcnow()
await db.commit()
await db.refresh(existing_user, ['subscription'])
user = existing_user
logger.info(f"✅ Пользователь {callback.from_user.id} восстановлен")
elif not existing_user:
logger.info(f"🆕 Создаем нового пользователя {callback.from_user.id}")
referral_code = await generate_unique_referral_code(db, callback.from_user.id)
user = await create_user(
db=db,
telegram_id=callback.from_user.id,
@@ -1148,7 +1160,7 @@ async def complete_registration_from_callback(
last_name=callback.from_user.last_name,
language=language,
referred_by_id=referrer_id,
referral_code=referral_code
referral_code=referral_code
)
await db.refresh(user, ['subscription'])
else:
@@ -1157,15 +1169,15 @@ async def complete_registration_from_callback(
existing_user.language = language
if referrer_id and not existing_user.referred_by_id:
existing_user.referred_by_id = referrer_id
from datetime import datetime
existing_user.updated_at = datetime.utcnow()
existing_user.last_activity = datetime.utcnow()
await db.commit()
await db.refresh(existing_user, ['subscription'])
user = existing_user
if referrer_id:
try:
await process_referral_registration(db, user.id, referrer_id, callback.bot)
@@ -1224,11 +1236,11 @@ async def complete_registration_from_callback(
logger.error(f"Ошибка при отправке приветственного сообщения: {e}")
else:
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
getattr(user, "subscription", None)
)
menu_text = await get_main_menu_text(user, texts, db)
is_admin = settings.is_admin(user.telegram_id)
@@ -1247,19 +1259,22 @@ async def complete_registration_from_callback(
)
try:
keyboard = await get_main_menu_keyboard_async(
db=db,
user=user,
language=user.language,
is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
await callback.message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
language=user.language,
is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
reply_markup=keyboard,
parse_mode="HTML"
)
logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}")
@@ -1303,11 +1318,11 @@ async def complete_registration(
return
existing_user = await get_user_by_telegram_id(db, message.from_user.id)
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
logger.warning(f"⚠️ Пользователь {message.from_user.id} уже активен! Показываем главное меню.")
texts = get_texts(existing_user.language)
data = await state.get_data() or {}
if data.get('referral_code') and not existing_user.referred_by_id:
await message.answer(
@@ -1316,13 +1331,13 @@ async def complete_registration(
"ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.",
)
)
await db.refresh(existing_user, ['subscription'])
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
existing_user.subscription
)
menu_text = await get_main_menu_text(existing_user, texts, db)
is_admin = settings.is_admin(existing_user.telegram_id)
@@ -1341,19 +1356,22 @@ async def complete_registration(
)
try:
keyboard = await get_main_menu_keyboard_async(
db=db,
user=existing_user,
language=existing_user.language,
is_admin=is_admin,
has_had_paid_subscription=existing_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=existing_user.balance_kopeks,
subscription=existing_user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
await message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
language=existing_user.language,
is_admin=is_admin,
has_had_paid_subscription=existing_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=existing_user.balance_kopeks,
subscription=existing_user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
reply_markup=keyboard,
parse_mode="HTML"
)
except Exception as e:
@@ -1364,10 +1382,10 @@ async def complete_registration(
"Добро пожаловать, {user_name}!",
).format(user_name=existing_user.full_name)
)
await state.clear()
return
data = await state.get_data() or {}
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
@@ -1386,10 +1404,10 @@ async def complete_registration(
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
referrer_id = referrer.id
if existing_user and existing_user.status == UserStatus.DELETED.value:
logger.info(f"🔄 Восстанавливаем удаленного пользователя {message.from_user.id}")
existing_user.username = message.from_user.username
existing_user.first_name = message.from_user.first_name
existing_user.last_name = message.from_user.last_name
@@ -1398,22 +1416,22 @@ async def complete_registration(
existing_user.status = UserStatus.ACTIVE.value
existing_user.balance_kopeks = 0
existing_user.has_had_paid_subscription = False
from datetime import datetime
existing_user.updated_at = datetime.utcnow()
existing_user.last_activity = datetime.utcnow()
await db.commit()
await db.refresh(existing_user, ['subscription'])
user = existing_user
logger.info(f"✅ Пользователь {message.from_user.id} восстановлен")
elif not existing_user:
logger.info(f"🆕 Создаем нового пользователя {message.from_user.id}")
referral_code = await generate_unique_referral_code(db, message.from_user.id)
user = await create_user(
db=db,
telegram_id=message.from_user.id,
@@ -1431,15 +1449,15 @@ async def complete_registration(
existing_user.language = language
if referrer_id and not existing_user.referred_by_id:
existing_user.referred_by_id = referrer_id
from datetime import datetime
existing_user.updated_at = datetime.utcnow()
existing_user.last_activity = datetime.utcnow()
await db.commit()
await db.refresh(existing_user, ['subscription'])
user = existing_user
if referrer_id:
try:
await process_referral_registration(db, user.id, referrer_id, message.bot)
@@ -1521,11 +1539,11 @@ async def complete_registration(
logger.error(f"Ошибка при отправке приветственного сообщения: {e}")
else:
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
getattr(user, "subscription", None)
)
menu_text = await get_main_menu_text(user, texts, db)
is_admin = settings.is_admin(user.telegram_id)
@@ -1544,19 +1562,22 @@ async def complete_registration(
)
try:
keyboard = await get_main_menu_keyboard_async(
db=db,
user=user,
language=user.language,
is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
await message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
language=user.language,
is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
reply_markup=keyboard,
parse_mode="HTML"
)
logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}")
@@ -1665,7 +1686,7 @@ def _insert_random_message(base_text: str, random_message: str, action_prompt: s
def get_referral_code_keyboard(language: str):
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
@@ -1875,7 +1896,9 @@ async def required_sub_channel_check(
subscription_is_active=subscription_is_active,
)
keyboard = get_main_menu_keyboard(
keyboard = await get_main_menu_keyboard_async(
db=db,
user=user,
language=user.language,
is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
@@ -1964,29 +1987,29 @@ async def required_sub_channel_check(
await query.answer(f"{texts.ERROR}!", show_alert=True)
def register_handlers(dp: Dispatcher):
logger.info("🔧 === НАЧАЛО регистрации обработчиков start.py ===")
dp.message.register(
cmd_start,
Command("start")
)
logger.info("✅ Зарегистрирован cmd_start")
dp.callback_query.register(
process_rules_accept,
F.data.in_(["rules_accept", "rules_decline"]),
StateFilter(RegistrationStates.waiting_for_rules_accept)
)
logger.info("✅ Зарегистрирован process_rules_accept")
dp.callback_query.register(
process_privacy_policy_accept,
F.data.in_(["privacy_policy_accept", "privacy_policy_decline"]),
StateFilter(RegistrationStates.waiting_for_privacy_policy_accept)
)
logger.info("✅ Зарегистрирован process_privacy_policy_accept")
dp.callback_query.register(
process_language_selection,
F.data.startswith("language_select:"),
@@ -2000,13 +2023,13 @@ def register_handlers(dp: Dispatcher):
StateFilter(RegistrationStates.waiting_for_referral_code)
)
logger.info("✅ Зарегистрирован process_referral_code_skip")
dp.message.register(
process_referral_code_input,
StateFilter(RegistrationStates.waiting_for_referral_code)
)
logger.info("✅ Зарегистрирован process_referral_code_input")
dp.message.register(
handle_potential_referral_code,
StateFilter(
@@ -2021,6 +2044,6 @@ def register_handlers(dp: Dispatcher):
F.data.in_(["sub_channel_check"])
)
logger.info("✅ Зарегистрирован required_sub_channel_check")
logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===")
+11 -2
View File
@@ -376,10 +376,19 @@ async def show_subscription_info(
and actual_status in ["trial_active", "paid_active"]
and not hide_subscription_link
):
subscription_link_display = subscription_link
if settings.is_happ_cryptolink_mode():
subscription_link_display = (
f"<blockquote expandable><code>{subscription_link}</code></blockquote>"
)
else:
subscription_link_display = f"<code>{subscription_link}</code>"
message += "\n\n" + texts.t(
"SUBSCRIPTION_CONNECT_LINK_SECTION",
"🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>",
).format(subscription_url=subscription_link)
"🔗 <b>Ссылка для подключения:</b>\n{subscription_url}",
).format(subscription_url=subscription_link_display)
message += "\n\n" + texts.t(
"SUBSCRIPTION_CONNECT_LINK_PROMPT",
"📱 Скопируйте ссылку и добавьте в ваше VPN приложение",
+181
View File
@@ -95,6 +95,12 @@ def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark
[
InlineKeyboardButton(text=texts.ADMIN_CAMPAIGNS, callback_data="admin_campaigns")
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_CONTESTS", "🏆 Конкурсы"),
callback_data="admin_contests",
)
],
[
InlineKeyboardButton(text=texts.ADMIN_PROMO_GROUPS, callback_data="admin_promo_groups")
],
@@ -471,6 +477,181 @@ def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
])
def get_admin_contests_root_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_CONTESTS_REFERRAL", "🤝 Реферальные конкурсы"),
callback_data="admin_contests_referral",
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_CONTESTS_DAILY", "📆 Ежедневные конкурсы"),
callback_data="admin_contests_daily",
)
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo"),
],
]
)
def get_admin_contests_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_CONTESTS_LIST", "📋 Текущие конкурсы"),
callback_data="admin_contests_list",
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_CONTESTS_CREATE", " Новый конкурс"),
callback_data="admin_contests_create",
),
],
[
InlineKeyboardButton(
text=texts.BACK,
callback_data="admin_contests",
)
],
]
)
def get_contest_mode_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_CONTEST_MODE_PAID", "💳 Реферал с покупкой"),
callback_data="admin_contest_mode_paid",
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_CONTEST_MODE_REGISTERED", "🧑‍🤝‍🧑 Просто реферал"),
callback_data="admin_contest_mode_registered",
)
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests_referral")
],
]
)
def get_daily_contest_manage_keyboard(
template_id: int,
is_enabled: bool,
language: str = "ru",
) -> InlineKeyboardMarkup:
texts = get_texts(language)
toggle_text = _t(texts, "ADMIN_CONTEST_DISABLE", "⏸️ Остановить") if is_enabled else _t(texts, "ADMIN_CONTEST_ENABLE", "▶️ Запустить")
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text=toggle_text, callback_data=f"admin_daily_toggle_{template_id}"),
InlineKeyboardButton(text=_t(texts, "ADMIN_CONTEST_START_NOW", "🚀 Запустить раунд"), callback_data=f"admin_daily_start_{template_id}"),
InlineKeyboardButton(text=_t(texts, "ADMIN_CONTEST_START_MANUAL", "🧪 Ручной старт"), callback_data=f"admin_daily_manual_{template_id}"),
],
[
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PRIZE", "🏅 Приз (дни)"), callback_data=f"admin_daily_edit_{template_id}_prize_days"),
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_MAX_WINNERS", "👥 Победителей"), callback_data=f"admin_daily_edit_{template_id}_max_winners"),
],
[
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_ATTEMPTS", "🔁 Попытки"), callback_data=f"admin_daily_edit_{template_id}_attempts_per_user"),
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_TIMES", "⏰ Раундов/день"), callback_data=f"admin_daily_edit_{template_id}_times_per_day"),
],
[
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_SCHEDULE", "🕒 Расписание"), callback_data=f"admin_daily_edit_{template_id}_schedule_times"),
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_COOLDOWN", "⌛ Длительность"), callback_data=f"admin_daily_edit_{template_id}_cooldown_hours"),
],
[
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PAYLOAD", "🧩 Payload"), callback_data=f"admin_daily_payload_{template_id}"),
],
[
InlineKeyboardButton(text=_t(texts, "ADMIN_RESET_ATTEMPTS", "🔄 Сбросить попытки"), callback_data=f"admin_daily_reset_attempts_{template_id}"),
],
[
InlineKeyboardButton(text=_t(texts, "ADMIN_CLOSE_ROUND", "❌ Закрыть раунд"), callback_data=f"admin_daily_close_{template_id}"),
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests_daily"),
],
]
)
def get_referral_contest_manage_keyboard(
contest_id: int,
*,
is_active: bool,
can_delete: bool = False,
language: str = "ru",
) -> InlineKeyboardMarkup:
texts = get_texts(language)
toggle_text = (
_t(texts, "ADMIN_CONTEST_DISABLE", "⏸️ Остановить")
if is_active
else _t(texts, "ADMIN_CONTEST_ENABLE", "▶️ Запустить")
)
rows = [
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_CONTEST_LEADERBOARD", "📊 Лидеры"),
callback_data=f"admin_contest_leaderboard_{contest_id}",
),
InlineKeyboardButton(
text=toggle_text,
callback_data=f"admin_contest_toggle_{contest_id}",
),
],
[
InlineKeyboardButton(
text="📈 Детальная статистика",
callback_data=f"admin_contest_detailed_stats_{contest_id}",
),
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_CONTEST_EDIT_SUMMARY_TIMES", "🕒 Итоги в день"),
callback_data=f"admin_contest_edit_times_{contest_id}",
),
],
]
if can_delete:
rows.append(
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_CONTEST_DELETE", "🗑 Удалить"),
callback_data=f"admin_contest_delete_{contest_id}",
)
]
)
rows.append(
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_BACK_TO_LIST", "⬅️ К списку"),
callback_data="admin_contests_list",
)
]
)
return InlineKeyboardMarkup(inline_keyboard=rows)
def get_campaign_management_keyboard(
campaign_id: int, is_active: bool, language: str = "ru"
) -> InlineKeyboardMarkup:
+151 -3
View File
@@ -1,7 +1,7 @@
from typing import List, Optional
from aiogram import types
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from datetime import datetime
from datetime import datetime, timezone
from app.database.models import User
from sqlalchemy.ext.asyncio import AsyncSession
@@ -23,6 +23,136 @@ import logging
logger = logging.getLogger(__name__)
async def get_main_menu_keyboard_async(
db: AsyncSession,
language: str = DEFAULT_LANGUAGE,
is_admin: bool = False,
has_had_paid_subscription: bool = False,
has_active_subscription: bool = False,
subscription_is_active: bool = False,
balance_kopeks: int = 0,
subscription=None,
show_resume_checkout: bool = False,
has_saved_cart: bool = False,
*,
is_moderator: bool = False,
custom_buttons: Optional[list[InlineKeyboardButton]] = None,
user=None, # Добавляем параметр пользователя для получения данных
) -> InlineKeyboardMarkup:
"""
Асинхронная версия get_main_menu_keyboard с поддержкой конструктора меню.
Если MENU_LAYOUT_ENABLED=True, использует конфигурацию из БД.
Иначе делегирует в синхронную версию.
"""
if settings.MENU_LAYOUT_ENABLED:
from app.services.menu_layout_service import MenuLayoutService, MenuContext
from datetime import datetime
# Получаем данные для плейсхолдеров
subscription_days_left = 0
traffic_used_gb = 0.0
traffic_left_gb = 0.0
referral_count = 0
referral_earnings_kopeks = 0
registration_days = 0
promo_group_id = None
has_autopay = False
username = ""
# Заполняем данными из подписки
if subscription:
# Дни до окончания подписки
if hasattr(subscription, 'days_left'):
# Используем свойство из модели, которое правильно вычисляет дни в UTC
subscription_days_left = subscription.days_left
elif hasattr(subscription, 'end_date') and subscription.end_date:
# Fallback: вычисляем вручную, используя UTC
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
days_left = (subscription.end_date - now_utc).days
subscription_days_left = max(0, days_left)
# Трафик
if hasattr(subscription, 'traffic_used_gb'):
traffic_used_gb = subscription.traffic_used_gb or 0.0
if hasattr(subscription, 'traffic_limit_gb') and subscription.traffic_limit_gb:
traffic_left_gb = max(0, subscription.traffic_limit_gb - (subscription.traffic_used_gb or 0))
# Автоплатеж
if hasattr(subscription, 'autopay_enabled'):
has_autopay = subscription.autopay_enabled
# Получаем данные пользователя
if user:
# Имя пользователя
if hasattr(user, 'username') and user.username:
username = user.username
elif hasattr(user, 'first_name') and user.first_name:
username = user.first_name
# Дни с регистрации
if hasattr(user, 'created_at') and user.created_at:
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
registration_days = (now_utc - user.created_at).days
# ID промо-группы
if hasattr(user, 'promo_group_id'):
promo_group_id = user.promo_group_id
# Получаем данные о рефералах из БД (если нужно)
try:
from app.database.crud.referral import get_user_referral_stats
if user and hasattr(user, 'id'):
referral_data = await get_user_referral_stats(db, user.id)
if referral_data:
referral_count = referral_data.get('invited_count', 0)
referral_earnings_kopeks = referral_data.get('total_earned_kopeks', 0)
except Exception as e:
logger.error(f"Error getting referral data: {e}")
context = MenuContext(
language=language,
is_admin=is_admin,
is_moderator=is_moderator,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
has_had_paid_subscription=has_had_paid_subscription,
balance_kopeks=balance_kopeks,
subscription=subscription,
show_resume_checkout=show_resume_checkout,
has_saved_cart=has_saved_cart,
custom_buttons=custom_buttons or [],
# Добавляем данные для плейсхолдеров
username=username,
subscription_days=subscription_days_left,
traffic_used_gb=traffic_used_gb,
traffic_left_gb=traffic_left_gb,
referral_count=referral_count,
referral_earnings_kopeks=referral_earnings_kopeks,
registration_days=registration_days,
promo_group_id=promo_group_id,
has_autopay=has_autopay,
)
return await MenuLayoutService.build_keyboard(db, context)
# Fallback на синхронную версию
return get_main_menu_keyboard(
language=language,
is_admin=is_admin,
has_had_paid_subscription=has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=balance_kopeks,
subscription=subscription,
show_resume_checkout=show_resume_checkout,
has_saved_cart=has_saved_cart,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
def _get_localized_value(values, language: str, default_language: str = "en") -> str:
if not isinstance(values, dict):
return ""
@@ -376,6 +506,12 @@ def get_main_menu_keyboard(
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
)
# Добавляем кнопку докупки трафика для лимитированных подписок
if subscription and not subscription.is_trial and subscription.traffic_limit_gb > 0:
paired_buttons.append(
InlineKeyboardButton(text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"), callback_data="buy_traffic")
)
keyboard.append([InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")])
show_trial = not has_had_paid_subscription and not has_active_subscription
@@ -432,23 +568,35 @@ def get_main_menu_keyboard(
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode")
)
# Добавляем кнопку рефералов только если программа включена
# Добавляем кнопку рефералов, только если программа включена
if settings.is_referral_program_enabled():
paired_buttons.append(
InlineKeyboardButton(text=texts.MENU_REFERRALS, callback_data="menu_referrals")
)
# Support button is configurable (runtime via service)
# Добавляем кнопку конкурсов
if settings.CONTESTS_ENABLED and settings.CONTESTS_BUTTON_VISIBLE:
paired_buttons.append(
InlineKeyboardButton(text=texts.t("CONTESTS_BUTTON", "🎲 Конкурсы"), callback_data="contests_menu")
)
try:
from app.services.support_settings_service import SupportSettingsService
support_enabled = SupportSettingsService.is_support_menu_enabled()
except Exception:
support_enabled = settings.SUPPORT_MENU_ENABLED
if support_enabled:
paired_buttons.append(
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
)
# Добавляем кнопку активации
if settings.ACTIVATE_BUTTON_VISIBLE:
paired_buttons.append(
InlineKeyboardButton(text=settings.ACTIVATE_BUTTON_TEXT, callback_data="activate_button")
)
paired_buttons.append(
InlineKeyboardButton(
text=texts.t("MENU_INFO", "️ Инфо"),
+72 -1
View File
@@ -3,6 +3,7 @@
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Insufficient funds</b>\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.",
"ADD_COUNTRIES_BUTTON": "🌐 Add countries",
"ADD_TRAFFIC_PROMPT": "📈 <b>Add traffic to your subscription</b>\n\nCurrent limit: {current_traffic}\nChoose extra traffic:",
"BUY_TRAFFIC_BUTTON": "📈 Buy more traffic",
"ADMIN_BACK_TO_ADMIN": "⬅️ Back to admin",
"ADMIN_BACK_TO_LIST": "⬅️ Back to list",
"ADMIN_BACK_TO_MAIN": "🏠 Back to main menu",
@@ -48,6 +49,76 @@
"ADMIN_CAMPAIGN_SERVERS": "🌍 Servers",
"ADMIN_CAMPAIGN_STATS": "📊 Statistics",
"ADMIN_CAMPAIGN_TRAFFIC": "🌐 Traffic",
"ADMIN_CONTESTS": "🏆 Contests",
"ADMIN_CONTESTS_CREATE": " New contest",
"ADMIN_CONTESTS_DISABLED": "Contests are disabled via CONTESTS_ENABLED.",
"ADMIN_CONTESTS_LIST": "📋 Current contests",
"ADMIN_CONTESTS_REFERRAL": "🤝 Referral contests",
"ADMIN_CONTESTS_DAILY": "📆 Daily contests",
"ADMIN_CONTESTS_COMING_SOON": "Coming soon.",
"ADMIN_CONTESTS_LIST_HEADER": "🏆 <b>Contests</b>\n",
"ADMIN_CONTESTS_EMPTY": "No contests yet.",
"ADMIN_CONTESTS_TITLE": "🏆 <b>Contests</b>\n\nChoose an action:",
"ADMIN_CONTEST_MODE_PAID": "💳 Referral with purchase",
"ADMIN_CONTEST_MODE_REGISTERED": "🧑‍🤝‍🧑 Referral registration",
"ADMIN_CONTEST_MODE_PROMPT": "Choose qualifying rule: referral must buy a subscription or just register.",
"ADMIN_CONTEST_ENTER_TITLE": "Enter contest title:",
"ADMIN_CONTEST_ENTER_DESCRIPTION": "Describe the contest (or send '-' to skip):",
"ADMIN_CONTEST_ENTER_PRIZE": "Specify prizes/rewards (or '-' to skip):",
"ADMIN_CONTEST_ENTER_START": "Enter start date/time (dd.mm.yyyy hh:mm) in your timezone:",
"ADMIN_CONTEST_ENTER_END": "Enter end date/time (dd.mm.yyyy hh:mm) in your timezone:",
"ADMIN_CONTEST_ENTER_DAILY_TIME": "What time to send daily results? Multiple times allowed, comma-separated HH:MM (e.g., 12:00 or 12:00,18:00).",
"ADMIN_CONTEST_INVALID_DATE": "Cannot parse date. Format: 01.06.2024 12:00",
"ADMIN_CONTEST_INVALID_TIME": "Cannot parse time. Format: 12:00",
"ADMIN_CONTEST_END_BEFORE_START": "End date must be after start date.",
"ADMIN_CONTEST_CREATED": "Contest created!",
"ADMIN_CONTEST_PRIZE": "Prize: {prize}",
"ADMIN_CONTEST_LAST_DAILY": "Last digest: {date}",
"ADMIN_CONTEST_STATUS_ACTIVE": "🟢 Active",
"ADMIN_CONTEST_STATUS_INACTIVE": "⚪️ Disabled",
"ADMIN_CONTEST_TOTAL_EVENTS": "Qualified: <b>{count}</b>",
"ADMIN_CONTEST_LEADERBOARD_TITLE": "📊 Top participants:",
"ADMIN_CONTEST_LEADERBOARD": "📊 Leaders",
"ADMIN_CONTEST_ENABLE": "▶️ Start",
"ADMIN_CONTEST_DISABLE": "⏸️ Pause",
"ADMIN_CONTEST_START_MANUAL": "🧪 Manual start",
"ADMIN_CONTEST_EDIT_SUMMARY_TIMES": "🕒 Daily summaries",
"ADMIN_CONTEST_DELETE": "🗑 Delete",
"ADMIN_CONTEST_DELETE_RESTRICT": "Only finished contests can be deleted.",
"ADMIN_CONTEST_DELETED": "Contest deleted.",
"ADMIN_CONTEST_NOT_FOUND": "Contest not found.",
"ADMIN_CONTEST_EMPTY_LEADERBOARD": "No participants yet.",
"ADMIN_DAILY_CONTESTS_TITLE": "📆 Daily contests",
"ADMIN_EDIT_PRIZE": "🏅 Prize (days)",
"ADMIN_EDIT_MAX_WINNERS": "👥 Winners",
"ADMIN_EDIT_ATTEMPTS": "🔁 Attempts",
"ADMIN_EDIT_TIMES": "⏰ Rounds/day",
"ADMIN_EDIT_SCHEDULE": "🕒 Schedule",
"ADMIN_EDIT_COOLDOWN": "⌛ Duration",
"ADMIN_EDIT_PAYLOAD": "🧩 Payload",
"ADMIN_CONTEST_FIELD_PROMPT": "Enter new value for {label}:",
"ADMIN_CONTEST_PAYLOAD_PROMPT": "Send JSON payload for this game:",
"ADMIN_ROUND_STARTED": "Round started",
"ADMIN_UPDATED": "Updated",
"ADMIN_INVALID_NUMBER": "Invalid number",
"ADMIN_INVALID_JSON": "Invalid JSON",
"CONTEST_MENU_TITLE": "🎲 <b>Games/Contests</b>\nChoose a game:",
"CONTEST_EMPTY": "No games right now",
"CONTEST_NOT_ELIGIBLE": "Games are available only with an active or trial subscription.",
"CONTEST_ROUND_FINISHED": "Round finished or unavailable.",
"CONTEST_ALREADY_PLAYED": "You already played this round.",
"CONTEST_UNKNOWN": "Contest type not supported.",
"CONTEST_QUEST_PROMPT": "Pick one of the 3×3 nodes:",
"CONTEST_LOCKS_PROMPT": "Find the hacked lock:",
"CONTEST_SERVER_PROMPT": "Choose a server:",
"CONTEST_CIPHER_PROMPT": "Decode: {q}",
"CONTEST_EMOJI_PROMPT": "Guess the service: {q}",
"CONTEST_ANAGRAM_PROMPT": "Make a word: {letters}",
"CONTEST_BLITZ_PROMPT": "⚡️ Blitz! Press “I'm here!”",
"CONTEST_BLITZ_BUTTON": "I'm here!",
"CONTEST_WIN": "🎉 You win! ",
"CONTEST_LOSE": "Wrong, try next round.",
"CONTEST_PRIZE_GRANTED": "Bonus {days} days credited!",
"ADMIN_CANCEL": "❌ Cancel",
"ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Menu messages",
"ADMIN_COMMUNICATIONS_PROMO_OFFERS": "🎯 Promo offers",
@@ -1290,7 +1361,7 @@
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Connect subscription</b>\n\n️ The subscription link is available via the buttons below or in the “My subscription” section.\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Connect subscription</b>\n\n🔗 Tap the button below to open the subscription link:",
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app",
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Connection link:</b>\n<code>{subscription_url}</code>",
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Connection link:</b>\n{subscription_url}",
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Connect subscription</b>\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:",
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device",
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Recommended app:</b> {app_name}",
+75 -1
View File
@@ -3,6 +3,7 @@
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Недостаточно средств</b>\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.",
"ADD_COUNTRIES_BUTTON": "🌐 Добавить страны",
"ADD_TRAFFIC_PROMPT": "📈 <b>Добавить трафик к подписке</b>\n\nТекущий лимит: {current_traffic}\nВыберите дополнительный трафик:",
"BUY_TRAFFIC_BUTTON": "📈 Докупить трафик",
"ADMIN_BACK_TO_ADMIN": "⬅️ Назад в админку",
"ADMIN_BACK_TO_LIST": "⬅️ К списку",
"ADMIN_BACK_TO_MAIN": "🏠 В главное меню",
@@ -48,6 +49,79 @@
"ADMIN_CAMPAIGN_SERVERS": "🌍 Серверы",
"ADMIN_CAMPAIGN_STATS": "📊 Статистика",
"ADMIN_CAMPAIGN_TRAFFIC": "🌐 Трафик",
"ADMIN_CONTESTS": "🏆 Конкурсы",
"ADMIN_CONTESTS_CREATE": "➕ Новый конкурс",
"ADMIN_CONTESTS_DISABLED": "Конкурсы отключены через переменную окружения CONTESTS_ENABLED.",
"ADMIN_CONTESTS_LIST": "📋 Текущие конкурсы",
"ADMIN_CONTESTS_REFERRAL": "🤝 Реферальные конкурсы",
"ADMIN_CONTESTS_DAILY": "📆 Ежедневные конкурсы",
"ADMIN_CONTESTS_COMING_SOON": "Функционал появится позже.",
"ADMIN_CONTESTS_LIST_HEADER": "🏆 <b>Конкурсы</b>\n",
"ADMIN_CONTESTS_EMPTY": "Пока нет созданных конкурсов.",
"ADMIN_CONTESTS_TITLE": "🏆 <b>Конкурсы</b>\n\nВыберите действие:",
"ADMIN_CONTEST_MODE_PAID": "💳 Реферал с покупкой",
"ADMIN_CONTEST_MODE_REGISTERED": "🧑‍🤝‍🧑 Просто реферал",
"ADMIN_CONTEST_MODE_PROMPT": "Выберите условие зачёта: реферал должен купить подписку или достаточно регистрации.",
"ADMIN_CONTEST_ENTER_TITLE": "Введите название конкурса:",
"ADMIN_CONTEST_ENTER_DESCRIPTION": "Опишите конкурс (или отправьте '-' чтобы пропустить):",
"ADMIN_CONTEST_ENTER_PRIZE": "Укажите призы/выгоды конкурса (или '-' чтобы пропустить):",
"ADMIN_CONTEST_ENTER_START": "Введите дату и время старта (дд.мм.гггг чч:мм) по вашему часовому поясу:",
"ADMIN_CONTEST_ENTER_END": "Введите дату и время окончания (дд.мм.гггг чч:мм) по вашему часовому поясу:",
"ADMIN_CONTEST_ENTER_DAILY_TIME": "Во сколько отправлять ежедневные итоги? Можно несколько через запятую, формат ЧЧ:ММ (например, 12:00 или 12:00,18:00).",
"ADMIN_CONTEST_INVALID_DATE": "Не удалось распознать дату. Формат: 01.06.2024 12:00",
"ADMIN_CONTEST_INVALID_TIME": "Не удалось распознать время. Формат: 12:00",
"ADMIN_CONTEST_END_BEFORE_START": "Дата окончания должна быть позже даты начала.",
"ADMIN_CONTEST_CREATED": "Конкурс создан!",
"ADMIN_CONTEST_PRIZE": "Приз: {prize}",
"ADMIN_CONTEST_LAST_DAILY": "Последняя сводка: {date}",
"ADMIN_CONTEST_STATUS_ACTIVE": "🟢 Активен",
"ADMIN_CONTEST_STATUS_INACTIVE": "⚪️ Выключен",
"ADMIN_CONTEST_TOTAL_EVENTS": "Зачётов: <b>{count}</b>",
"ADMIN_CONTEST_LEADERBOARD_TITLE": "📊 Топ участников:",
"ADMIN_CONTEST_LEADERBOARD": "📊 Лидеры",
"ADMIN_CONTEST_ENABLE": "▶️ Запустить",
"ADMIN_CONTEST_DISABLE": "⏸️ Остановить",
"ADMIN_CONTEST_START_MANUAL": "🧪 Ручной старт",
"ADMIN_CONTEST_EDIT_SUMMARY_TIMES": "🕒 Итоги в день",
"ADMIN_CONTEST_DELETE": "🗑 Удалить",
"ADMIN_CONTEST_DELETE_RESTRICT": "Удалять можно только завершённые конкурсы.",
"ADMIN_CONTEST_DELETED": "Конкурс удалён.",
"ADMIN_CONTEST_NOT_FOUND": "Конкурс не найден.",
"ADMIN_CONTEST_EMPTY_LEADERBOARD": "Пока нет участников.",
"ADMIN_DAILY_CONTESTS_TITLE": "📆 Ежедневные конкурсы",
"ADMIN_EDIT_PRIZE": "🏅 Приз (дни)",
"ADMIN_EDIT_MAX_WINNERS": "👥 Победителей",
"ADMIN_EDIT_ATTEMPTS": "🔁 Попытки",
"ADMIN_EDIT_TIMES": "⏰ Раундов/день",
"ADMIN_EDIT_SCHEDULE": "🕒 Расписание",
"ADMIN_EDIT_COOLDOWN": "⌛ Длительность",
"ADMIN_EDIT_PAYLOAD": "🧩 Payload",
"ADMIN_CONTEST_FIELD_PROMPT": "Введите новое значение для {label}:",
"ADMIN_CONTEST_START_NOW": "Запустить раунд сейчас",
"ADMIN_RESET_ATTEMPTS": "🔄 Сбросить попытки",
"ADMIN_CLOSE_ROUND": "❌ Закрыть раунд",
"ADMIN_ROUND_STARTED": "Раунд запущен",
"ADMIN_UPDATED": "Обновлено",
"ADMIN_INVALID_NUMBER": "Некорректное число",
"ADMIN_INVALID_JSON": "Некорректный JSON",
"CONTEST_MENU_TITLE": "🎲 <b>Игры/Конкурсы</b>\nВыберите игру:",
"CONTESTS_BUTTON": "🎲 Конкурсы",
"CONTEST_EMPTY": "Сейчас игр нет",
"CONTEST_NOT_ELIGIBLE": "Игры доступны только с активной или триальной подпиской.",
"CONTEST_ROUND_FINISHED": "Раунд завершён или недоступен.",
"CONTEST_ALREADY_PLAYED": "У вас уже была попытка в этом раунде.",
"CONTEST_UNKNOWN": "Тип конкурса не поддерживается.",
"CONTEST_QUEST_PROMPT": "Выбери один из узлов 3×3:",
"CONTEST_LOCKS_PROMPT": "Найди взломанную кнопку среди замков:",
"CONTEST_SERVER_PROMPT": "Выбери сервер:",
"CONTEST_CIPHER_PROMPT": "Расшифруй: {q}",
"CONTEST_EMOJI_PROMPT": "Угадай сервис по эмодзи: {q}",
"CONTEST_ANAGRAM_PROMPT": "Составь слово: {letters}",
"CONTEST_BLITZ_PROMPT": "⚡️ Блиц! Нажми «Я здесь!»",
"CONTEST_BLITZ_BUTTON": "Я здесь!",
"CONTEST_WIN": "🎉 Победа! ",
"CONTEST_LOSE": "Не верно, попробуй снова в следующем раунде.",
"CONTEST_PRIZE_GRANTED": "Бонус {days} дней зачислен!",
"ADMIN_CANCEL": "❌ Отмена",
"ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Сообщения в меню",
"ADMIN_COMMUNICATIONS_PROMO_OFFERS": "🎯 Промо-предложения",
@@ -1302,7 +1376,7 @@
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Подключить подписку</b>\n\nℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:",
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение",
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>",
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Ссылка для подключения:</b>\n{subscription_url}",
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:",
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены",
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Рекомендуемое приложение:</b> {app_name}",
+3 -1
View File
@@ -2,6 +2,8 @@
"ACCESS_DENIED": "❌ Доступ заборонено",
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Недостатньо коштів</b>\n\nВартість послуги: {required}\nНа балансі: {balance}\nНе вистачає: {missing}\n\nОберіть спосіб поповнення. Сума підставиться автоматично.",
"ADD_COUNTRIES_BUTTON": "🌐 Додати країни",
"ADD_TRAFFIC_PROMPT": "📈 <b>Додати трафік до підписки</b>\n\nПоточний ліміт: {current_traffic}\nВиберіть додатковий трафік:",
"BUY_TRAFFIC_BUTTON": "📈 Докупити трафік",
"ADMIN_BACK_TO_ADMIN": "⬅️ Назад до адмінки",
"ADMIN_BACK_TO_LIST": "⬅️ До списку",
"ADMIN_BACK_TO_MAIN": "🏠 В головне меню",
@@ -1293,7 +1295,7 @@
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Підключити підписку</b>\n\nℹ️ Посилання підписки доступне за кнопками нижче або в розділі «Моя підписка».\n\n💡 <b>Оберіть ваш пристрій</b> для отримання детальної інструкції з налаштування:",
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Підключити підписку</b>\n\n🔗 Натисніть кнопку нижче, щоб відкрити посилання підписки:",
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопіюйте посилання та додайте у ваш VPN додаток",
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Посилання для підключення:</b>\n<code>{subscription_url}</code>",
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Посилання для підключення:</b>\n{subscription_url}",
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Підключити підписку</b>\n\n🚀 Натисніть кнопку нижче, щоб відкрити підписку в міні-додатку Telegram:",
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Додатки для цього пристрою не знайдено",
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Рекомендований додаток:</b> {app_name}",
+3 -2
View File
@@ -2,6 +2,7 @@
"ACCESS_DENIED":"❌拒绝访问",
"ADDON_INSUFFICIENT_FUNDS_MESSAGE":"⚠️<b>资金不足</b>\n\n服务费用:{required}\n当前余额:{balance}\n缺少:{missing}\n\n请选择充值方式。金额将自动填入。",
"ADD_COUNTRIES_BUTTON":"🌐添加国家",
"BUY_TRAFFIC_BUTTON":"📈购买更多流量",
"ADMIN_BACK_TO_ADMIN":"⬅️返回后台管理",
"ADMIN_BACK_TO_LIST":"⬅️返回列表",
"ADMIN_BACK_TO_MAIN":"🏠返回主菜单",
@@ -1292,7 +1293,7 @@
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN":"📱<b>连接订阅</b>\n\nℹ️订阅链接在下方按钮中或“我的订阅”部分可用。\n\n💡<b>请选择您的设备</b>以获取详细设置说明:",
"SUBSCRIPTION_CONNECT_LINK_MESSAGE":"🚀<b>连接订阅</b>\n\n🔗点击下方按钮打开订阅链接:",
"SUBSCRIPTION_CONNECT_LINK_PROMPT":"📱复制链接并添加到您的VPN应用程序中",
"SUBSCRIPTION_CONNECT_LINK_SECTION":"🔗<b>连接链接:</b>\n<code>{subscription_url}</code>",
"SUBSCRIPTION_CONNECT_LINK_SECTION":"🔗<b>连接链接:</b>\n{subscription_url}",
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE":"📱<b>连接订阅</b>\n\n🚀点击下方按钮在Telegram小程序中打开订阅:",
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND":"❌未找到此设备的应用程序",
"SUBSCRIPTION_DEVICE_FEATURED_APP":"📋<b>推荐应用:</b>{app_name}",
@@ -1619,7 +1620,7 @@
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN":"📱<b>连接订阅</b>\n\nℹ️订阅链接在下方按钮中或“我的订阅”部分可用。\n\n💡<b>请选择您的设备</b>以获取详细设置说明:",
"SUBSCRIPTION_CONNECT_LINK_MESSAGE":"🚀<b>连接订阅</b>\n\n🔗点击下方按钮打开订阅链接:",
"SUBSCRIPTION_CONNECT_LINK_PROMPT":"📱复制链接并添加到您的VPN应用程序中",
"SUBSCRIPTION_CONNECT_LINK_SECTION":"🔗<b>连接链接:</b>\n<code>{subscription_url}</code>",
"SUBSCRIPTION_CONNECT_LINK_SECTION":"🔗<b>连接链接:</b>\n{subscription_url}",
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE":"📱<b>连接订阅</b>\n\n🚀点击下方按钮在Telegram小程序中打开订阅:",
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND":"❌未找到此设备的应用程序",
"SUBSCRIPTION_DEVICE_FEATURED_APP":"📋<b>推荐应用:</b>{app_name}",
+208
View File
@@ -0,0 +1,208 @@
"""Middleware для автоматического логирования кликов по кнопкам."""
import asyncio
import logging
from typing import Callable, Dict, Any, Awaitable, Set
from aiogram import BaseMiddleware
from aiogram.types import CallbackQuery, TelegramObject
from app.config import settings
from app.database.database import AsyncSessionLocal
logger = logging.getLogger(__name__)
# Известные builtin callback_data из меню
BUILTIN_CALLBACKS: Set[str] = {
# Основные кнопки меню
"subscription_connect",
"subscription_happ_download",
"menu_subscription",
"buy_traffic",
"menu_balance",
"menu_trial",
"menu_buy",
"simple_subscription_purchase",
"return_to_saved_cart",
"menu_promocode",
"menu_referrals",
"contests_menu",
"menu_support",
"menu_info",
"menu_language",
"admin_panel",
"moderator_panel",
# Навигация
"back_to_menu",
"menu_faq",
"menu_info_promo_groups",
"menu_privacy_policy",
"menu_public_offer",
"menu_rules",
"menu_server_status",
# Баланс
"balance_history",
"balance_topup",
# Подписка
"subscription_extend",
"subscription_autopay",
"subscription_settings",
"open_subscription_link",
"subscription_add_countries",
"subscription_reset_traffic",
"subscription_switch_traffic",
"subscription_change_devices",
"subscription_manage_devices",
"subscription_upgrade",
# Устройства
"device_guide_ios",
"device_guide_android",
"device_guide_windows",
"device_guide_mac",
"device_guide_tv",
"device_guide_appletv",
# Happ
"happ_download_ios",
"happ_download_android",
"happ_download_macos",
"happ_download_windows",
# Рефералы
"referral_create_invite",
"referral_show_qr",
"referral_list",
"referral_analytics",
# Поддержка
"create_ticket",
"my_tickets",
# Триал
"trial_activate",
# Покупка
"clear_saved_cart",
"subscription_confirm",
"subscription_cancel",
}
class ButtonStatsMiddleware(BaseMiddleware):
"""Middleware для автоматического логирования статистики кликов по кнопкам."""
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
"""Перехватывает CallbackQuery и логирует клики по кнопкам."""
# Обрабатываем только CallbackQuery
if not isinstance(event, CallbackQuery):
return await handler(event, data)
# Пропускаем, если статистика отключена
if not settings.MENU_LAYOUT_ENABLED:
return await handler(event, data)
# Логируем клик асинхронно, не блокируя обработку
try:
# Получаем callback_data
callback_data = event.data
if not callback_data:
return await handler(event, data)
# Получаем user_id
user_id = event.from_user.id if event.from_user else None
# Определяем тип кнопки по callback_data
button_type = self._determine_button_type(callback_data)
# Получаем текст кнопки, если возможно
button_text = None
if event.message and hasattr(event.message, 'reply_markup'):
button_text = self._extract_button_text(event.message.reply_markup, callback_data)
# Логируем в фоне, не блокируя обработку
asyncio.create_task(
self._log_button_click_async(
button_id=callback_data,
user_id=user_id,
callback_data=callback_data,
button_type=button_type,
button_text=button_text
)
)
except Exception as e:
# Не прерываем обработку при ошибке логирования
logger.error(f"Ошибка логирования клика по кнопке: {e}", exc_info=True)
# Продолжаем обработку
return await handler(event, data)
def _determine_button_type(self, callback_data: str) -> str:
"""Определяет тип кнопки по callback_data.
Примечание: URL и MiniApp кнопки не имеют callback_data,
поэтому они не отслеживаются через этот middleware.
Для их отслеживания нужен отдельный механизм на стороне клиента.
"""
# Проверяем по известному списку builtin кнопок
if callback_data in BUILTIN_CALLBACKS:
return "builtin"
# Дополнительная проверка по префиксам для динамических callback_data
builtin_prefixes = (
"menu_",
"admin_",
"subscription_",
"balance_",
"referral_",
"device_guide_",
"happ_download_",
)
if callback_data.startswith(builtin_prefixes):
return "builtin"
# Всё остальное - кастомные callback кнопки
return "callback"
def _extract_button_text(self, reply_markup, callback_data: str) -> str:
"""Извлекает текст кнопки из клавиатуры."""
try:
if not reply_markup or not hasattr(reply_markup, 'inline_keyboard'):
return None
for row in reply_markup.inline_keyboard:
for button in row:
if hasattr(button, 'callback_data') and button.callback_data == callback_data:
if hasattr(button, 'text'):
return button.text
except Exception:
pass
return None
async def _log_button_click_async(
self,
button_id: str,
user_id: int = None,
callback_data: str = None,
button_type: str = None,
button_text: str = None
):
"""Асинхронно логирует клик по кнопке."""
try:
async with AsyncSessionLocal() as db:
try:
from app.services.menu_layout_service import MenuLayoutService
await MenuLayoutService.log_button_click(
db,
button_id=button_id,
user_id=user_id,
callback_data=callback_data,
button_type=button_type,
button_text=button_text
)
except Exception as e:
logger.debug(f"Ошибка записи клика в БД {button_id}: {e}")
except Exception as e:
logger.debug(f"Ошибка создания сессии БД для логирования клика: {e}")
+8 -1
View File
@@ -108,7 +108,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
elif member.status in self.BAD_MEMBER_STATUS:
logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})")
if telegram_id:
if telegram_id and settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
await self._deactivate_trial_subscription(telegram_id)
await self._capture_start_payload(state, event, bot)
@@ -254,6 +254,13 @@ class ChannelCheckerMiddleware(BaseMiddleware):
break
async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
logger.debug(
"ℹ️ Пропускаем деактивацию подписки пользователя %s: отключение при отписке выключено",
telegram_id,
)
return
async for db in get_db():
try:
user = await get_user_by_telegram_id(db, telegram_id)
+389
View File
@@ -0,0 +1,389 @@
import asyncio
import logging
import random
from datetime import datetime, timedelta, time, timezone
from typing import Dict, List, Optional
from zoneinfo import ZoneInfo
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.contest import (
create_round,
get_active_round_by_template,
list_templates,
upsert_template,
)
from app.database.database import AsyncSessionLocal
from app.database.models import ContestTemplate, SubscriptionStatus, User
logger = logging.getLogger(__name__)
# Slugs for games
GAME_QUEST = "quest_buttons"
GAME_LOCKS = "lock_hack"
GAME_CIPHER = "letter_cipher"
GAME_SERVER = "server_lottery"
GAME_BLITZ = "blitz_reaction"
GAME_EMOJI = "emoji_guess"
GAME_ANAGRAM = "anagram"
DEFAULT_TEMPLATES = [
{
"slug": GAME_QUEST,
"name": "Квест-кнопки",
"description": "Найди секретную кнопку 3×3",
"prize_days": 1,
"max_winners": 3,
"attempts_per_user": 1,
"times_per_day": 2,
"schedule_times": "10:00,18:00",
"payload": {"rows": 3, "cols": 3},
"is_enabled": False,
},
{
"slug": GAME_LOCKS,
"name": "Кнопочный взлом",
"description": "Найди взломанную кнопку среди 20 замков",
"prize_days": 5,
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 2,
"schedule_times": "09:00,19:00",
"payload": {"buttons": 20},
"is_enabled": False,
},
{
"slug": GAME_CIPHER,
"name": "Шифр букв",
"description": "Расшифруй слово по номерам",
"prize_days": 1,
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 2,
"schedule_times": "12:00,20:00",
"payload": {"words": ["VPN", "SERVER", "PROXY", "XRAY"]},
"is_enabled": False,
},
{
"slug": GAME_SERVER,
"name": "Сервер-лотерея",
"description": "Угадай доступный сервер",
"prize_days": 7,
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 1,
"schedule_times": "15:00",
"payload": {"flags": ["🇸🇪","🇸🇬","🇺🇸","🇷🇺","🇩🇪","🇯🇵","🇧🇷","🇦🇺","🇨🇦","🇫🇷"]},
"is_enabled": False,
},
{
"slug": GAME_BLITZ,
"name": "Блиц-реакция",
"description": "Нажми кнопку за 10 секунд",
"prize_days": 1,
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 2,
"schedule_times": "11:00,21:00",
"payload": {"timeout_seconds": 10},
"is_enabled": False,
},
{
"slug": GAME_EMOJI,
"name": "Угадай сервис по эмодзи",
"description": "Определи сервис по эмодзи",
"prize_days": 1,
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 1,
"schedule_times": "13:00",
"payload": {"pairs": [{"question": "🔐📡🌐", "answer": "VPN"}]},
"is_enabled": False,
},
{
"slug": GAME_ANAGRAM,
"name": "Анаграмма дня",
"description": "Собери слово из букв",
"prize_days": 1,
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 1,
"schedule_times": "17:00",
"payload": {"words": ["SERVER", "XRAY", "VPN"]},
"is_enabled": False,
},
]
class ContestRotationService:
def __init__(self) -> None:
self.bot: Optional[Bot] = None
self._task: Optional[asyncio.Task] = None
self._interval_seconds = 60
def is_running(self) -> bool:
return self._task is not None and not self._task.done()
def set_bot(self, bot: Bot) -> None:
self.bot = bot
async def start(self) -> None:
await self.stop()
if not settings.is_contests_enabled():
logger.info("Сервис игр отключён настройками")
return
await self._ensure_default_templates()
self._task = asyncio.create_task(self._loop())
logger.info("🎲 Сервис ротационных конкурсов запущен")
async def stop(self) -> None:
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
async def _ensure_default_templates(self) -> None:
async with AsyncSessionLocal() as db:
for tpl in DEFAULT_TEMPLATES:
try:
await upsert_template(db, **tpl)
except Exception as exc:
logger.error("Не удалось создать шаблон %s: %s", tpl["slug"], exc)
async def _loop(self) -> None:
try:
while True:
try:
await self._tick()
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
logger.error("Ошибка в ротации конкурсов: %s", exc)
await asyncio.sleep(self._interval_seconds)
except asyncio.CancelledError:
logger.info("Сервис ротации конкурсов остановлен")
raise
def _parse_times(self, times_str: Optional[str]) -> List[time]:
if not times_str:
return []
times: List[time] = []
for part in times_str.split(","):
part = part.strip()
if not part:
continue
try:
hh, mm = part.split(":")
times.append(time(int(hh), int(mm)))
except Exception:
continue
return times
async def _tick(self) -> None:
async with AsyncSessionLocal() as db:
templates = await list_templates(db)
# Get current time in configured timezone
tz = self._get_timezone()
now_utc = datetime.now(timezone.utc)
now_local = now_utc.astimezone(tz)
for tpl in templates:
times = self._parse_times(tpl.schedule_times) or []
for slot in times[: tpl.times_per_day]:
# Apply schedule time to local date
starts_at_local = now_local.replace(
hour=slot.hour, minute=slot.minute, second=0, microsecond=0
)
if starts_at_local > now_local:
starts_at_local -= timedelta(days=1)
ends_at_local = starts_at_local + timedelta(hours=tpl.cooldown_hours)
if not (starts_at_local <= now_local <= ends_at_local):
continue
exists = await get_active_round_by_template(db, tpl.id)
if exists:
continue
# Convert to UTC for storage
starts_at_utc = starts_at_local.astimezone(timezone.utc).replace(tzinfo=None)
ends_at_utc = ends_at_local.astimezone(timezone.utc).replace(tzinfo=None)
# Анонс перед созданием раунда
await self._announce_round_start(tpl, starts_at_local, ends_at_local)
payload = self._build_payload_for_template(tpl)
round_obj = await create_round(
db,
template=tpl,
starts_at=starts_at_utc,
ends_at=ends_at_utc,
payload=payload,
)
logger.info("Создан раунд %s для шаблона %s", round_obj.id, tpl.slug)
def _get_timezone(self) -> ZoneInfo:
tz_name = settings.TIMEZONE or "UTC"
try:
return ZoneInfo(tz_name)
except Exception:
logger.warning("Не удалось загрузить TZ %s, используем UTC", tz_name)
return ZoneInfo("UTC")
def _build_payload_for_template(self, tpl: ContestTemplate) -> Dict:
payload = tpl.payload or {}
if tpl.slug == GAME_QUEST:
rows = payload.get("rows", 3)
cols = payload.get("cols", 3)
total = rows * cols
secret_idx = random.randint(0, total - 1)
return {"rows": rows, "cols": cols, "secret_idx": secret_idx}
if tpl.slug == GAME_LOCKS:
total = payload.get("buttons", 20)
secret_idx = random.randint(0, max(0, total - 1))
return {"total": total, "secret_idx": secret_idx}
if tpl.slug == GAME_CIPHER:
words = payload.get("words") or ["VPN"]
word = random.choice(words)
codes = [str(ord(ch.upper()) - 64) for ch in word if ch.isalpha()]
return {"question": "-".join(codes), "answer": word.upper()}
if tpl.slug == GAME_SERVER:
flags = payload.get("flags") or ["🇸🇪","🇸🇬","🇺🇸","🇷🇺","🇩🇪","🇯🇵","🇧🇷","🇦🇺","🇨🇦","🇫🇷"]
secret_idx = random.randint(0, len(flags) - 1)
return {"flags": flags, "secret_idx": secret_idx}
if tpl.slug == GAME_BLITZ:
return {"timeout_seconds": payload.get("timeout_seconds", 10)}
if tpl.slug == GAME_EMOJI:
pairs = payload.get("pairs") or [{"question": "🔐📡🌐", "answer": "VPN"}]
pair = random.choice(pairs)
return pair
if tpl.slug == GAME_ANAGRAM:
words = payload.get("words") or ["SERVER"]
word = random.choice(words).upper()
shuffled = "".join(random.sample(word, len(word)))
return {"letters": shuffled, "answer": word}
return payload
async def _announce_round_start(
self,
tpl: ContestTemplate,
starts_at_local: datetime,
ends_at_local: datetime,
) -> None:
if not self.bot:
return
text = (
f"🎲 Стартует игра: <b>{tpl.name}</b>\n"
f"Приз: {tpl.prize_days} дн. подписки • Победителей: {tpl.max_winners}\n"
f"Попыток/польз: {tpl.attempts_per_user}\n\n"
"Участвовать могут только с активной или триальной подпиской."
)
await asyncio.gather(
self._send_channel_announce(text),
self._broadcast_to_users(text),
return_exceptions=True,
)
async def _send_channel_announce(self, text: str) -> None:
if not self.bot:
return
channel_id_raw = settings.CHANNEL_SUB_ID
if not channel_id_raw:
return
try:
channel_id = int(channel_id_raw)
except Exception:
channel_id = channel_id_raw
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🎲 Играть", callback_data="contests_menu")]
])
try:
await self.bot.send_message(
chat_id=channel_id,
text=text,
disable_web_page_preview=True,
reply_markup=keyboard,
)
except Exception as exc: # noqa: BLE001
logger.error("Не удалось отправить анонс в канал %s: %s", channel_id_raw, exc)
async def _broadcast_to_users(self, text: str) -> None:
"""Отправляет анонс всем пользователям с активной/триальной подпиской."""
if not self.bot:
return
try:
batch_size = 500
offset = 0
sent = failed = 0
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🎲 Играть", callback_data="contests_menu")]
])
while True:
async with AsyncSessionLocal() as db:
users_batch = await self._load_users_batch(db, offset, batch_size)
if not users_batch:
break
offset += batch_size
tasks = []
semaphore = asyncio.Semaphore(15)
async def _send(u: User):
nonlocal sent, failed
async with semaphore:
try:
await self.bot.send_message(
chat_id=u.telegram_id,
text=text,
disable_web_page_preview=True,
reply_markup=keyboard,
)
sent += 1
except Exception:
failed += 1
await asyncio.sleep(0.02)
for user in users_batch:
tasks.append(asyncio.create_task(_send(user)))
await asyncio.gather(*tasks, return_exceptions=True)
logger.info("Анонс игр: отправлено=%s, ошибок=%s", sent, failed)
except Exception as exc: # noqa: BLE001
logger.error("Ошибка рассылки анонса игр пользователям: %s", exc)
async def _load_users_batch(self, db: AsyncSession, offset: int, limit: int) -> List[User]:
from app.database.crud.user import get_users_list
users = await get_users_list(
db,
offset=offset,
limit=limit,
status=None,
)
allowed: List[User] = []
for u in users:
sub = getattr(u, "subscription", None)
if not sub:
continue
if sub.status in {SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value}:
allowed.append(u)
return allowed
contest_rotation_service = ContestRotationService()
+36
View File
@@ -0,0 +1,36 @@
"""
Модуль конструктора меню.
Структура модуля:
- constants.py - константы и дефолтная конфигурация
- context.py - MenuContext для построения меню
- history_service.py - сервис истории изменений
- stats_service.py - сервис статистики кликов
- service.py - основной MenuLayoutService
"""
from .constants import (
MENU_LAYOUT_CONFIG_KEY,
DEFAULT_MENU_CONFIG,
BUILTIN_BUTTONS_INFO,
AVAILABLE_CALLBACKS,
DYNAMIC_PLACEHOLDERS,
)
from .context import MenuContext
from .history_service import MenuLayoutHistoryService
from .stats_service import MenuLayoutStatsService
from .service import MenuLayoutService
__all__ = [
# Константы
"MENU_LAYOUT_CONFIG_KEY",
"DEFAULT_MENU_CONFIG",
"BUILTIN_BUTTONS_INFO",
"AVAILABLE_CALLBACKS",
"DYNAMIC_PLACEHOLDERS",
# Классы
"MenuContext",
"MenuLayoutService",
"MenuLayoutHistoryService",
"MenuLayoutStatsService",
]
+501
View File
@@ -0,0 +1,501 @@
"""Константы для конструктора меню."""
from typing import Any, Dict, List
# Ключ для хранения конфигурации в SystemSetting
MENU_LAYOUT_CONFIG_KEY = "menu_layout_config"
# Дефолтная конфигурация меню
DEFAULT_MENU_CONFIG: Dict[str, Any] = {
"version": 1,
"rows": [
{
"id": "connect_row",
"buttons": ["connect"],
"conditions": {"has_active_subscription": True, "subscription_is_active": True},
"max_per_row": 1,
},
{
"id": "happ_row",
"buttons": ["happ_download"],
"conditions": {"has_active_subscription": True, "happ_enabled": True},
"max_per_row": 1,
},
{
"id": "subscription_traffic_row",
"buttons": ["subscription", "buy_traffic"],
"conditions": {"has_active_subscription": True},
"max_per_row": 2,
},
{
"id": "balance_row",
"buttons": ["balance"],
"conditions": None,
"max_per_row": 1,
},
{
"id": "trial_buy_row",
"buttons": ["trial", "buy_subscription"],
"conditions": None,
"max_per_row": 2,
},
{
"id": "simple_subscription_row",
"buttons": ["simple_subscription"],
"conditions": {"simple_subscription_enabled": True},
"max_per_row": 1,
},
{
"id": "resume_row",
"buttons": ["resume_checkout"],
"conditions": {"has_saved_cart": True},
"max_per_row": 1,
},
{
"id": "promo_referral_row",
"buttons": ["promocode", "referrals"],
"conditions": None,
"max_per_row": 2,
},
{
"id": "contests_row",
"buttons": ["contests"],
"conditions": {"contests_visible": True},
"max_per_row": 2,
},
{
"id": "support_info_row",
"buttons": ["support", "info"],
"conditions": None,
"max_per_row": 2,
},
{
"id": "language_row",
"buttons": ["language"],
"conditions": {"language_selection_enabled": True},
"max_per_row": 2,
},
{
"id": "admin_row",
"buttons": ["admin_panel"],
"conditions": {"is_admin": True},
"max_per_row": 1,
},
{
"id": "moderator_row",
"buttons": ["moderator_panel"],
"conditions": {"is_moderator": True},
"max_per_row": 1,
},
],
"buttons": {
"connect": {
"type": "builtin",
"builtin_id": "connect",
"text": {"ru": "🔗 Подключиться", "en": "🔗 Connect"},
"action": "subscription_connect",
"enabled": True,
"visibility": "subscribers",
"conditions": {"has_active_subscription": True, "subscription_is_active": True},
"dynamic_text": False,
"open_mode": "callback", # "callback" или "direct"
"webapp_url": None, # URL для Mini App при open_mode="direct"
},
"happ_download": {
"type": "builtin",
"builtin_id": "happ_download",
"text": {"ru": "⬇️ Скачать Happ", "en": "⬇️ Download Happ"},
"action": "subscription_happ_download",
"enabled": True,
"visibility": "subscribers",
"conditions": None,
"dynamic_text": False,
},
"subscription": {
"type": "builtin",
"builtin_id": "subscription",
"text": {"ru": "📊 Подписка", "en": "📊 Subscription"},
"action": "menu_subscription",
"enabled": True,
"visibility": "subscribers",
"conditions": None,
"dynamic_text": False,
},
"buy_traffic": {
"type": "builtin",
"builtin_id": "buy_traffic",
"text": {"ru": "📈 Докупить трафик", "en": "📈 Buy traffic"},
"action": "buy_traffic",
"enabled": True,
"visibility": "subscribers",
"conditions": {"has_traffic_limit": True},
"dynamic_text": False,
},
"balance": {
"type": "builtin",
"builtin_id": "balance",
"text": {"ru": "💰 Баланс: {balance}", "en": "💰 Balance: {balance}"},
"action": "menu_balance",
"enabled": True,
"visibility": "all",
"conditions": None,
"dynamic_text": True,
},
"trial": {
"type": "builtin",
"builtin_id": "trial",
"text": {"ru": "🎁 Пробный период", "en": "🎁 Free trial"},
"action": "menu_trial",
"enabled": True,
"visibility": "all",
"conditions": {"show_trial": True},
"dynamic_text": False,
},
"buy_subscription": {
"type": "builtin",
"builtin_id": "buy_subscription",
"text": {"ru": "🛒 Купить подписку", "en": "🛒 Buy subscription"},
"action": "menu_buy",
"enabled": True,
"visibility": "all",
"conditions": {"show_buy": True},
"dynamic_text": False,
},
"simple_subscription": {
"type": "builtin",
"builtin_id": "simple_subscription",
"text": {"ru": "💳 Простая подписка", "en": "💳 Simple subscription"},
"action": "simple_subscription_purchase",
"enabled": True,
"visibility": "all",
"conditions": None,
"dynamic_text": False,
},
"resume_checkout": {
"type": "builtin",
"builtin_id": "resume_checkout",
"text": {"ru": "↩️ Вернуться к оформлению", "en": "↩️ Resume checkout"},
"action": "return_to_saved_cart",
"enabled": True,
"visibility": "all",
"conditions": None,
"dynamic_text": False,
},
"promocode": {
"type": "builtin",
"builtin_id": "promocode",
"text": {"ru": "🎟️ Промокод", "en": "🎟️ Promo code"},
"action": "menu_promocode",
"enabled": True,
"visibility": "all",
"conditions": None,
"dynamic_text": False,
},
"referrals": {
"type": "builtin",
"builtin_id": "referrals",
"text": {"ru": "👥 Рефералы", "en": "👥 Referrals"},
"action": "menu_referrals",
"enabled": True,
"visibility": "all",
"conditions": {"referral_enabled": True},
"dynamic_text": False,
},
"contests": {
"type": "builtin",
"builtin_id": "contests",
"text": {"ru": "🎲 Конкурсы", "en": "🎲 Contests"},
"action": "contests_menu",
"enabled": True,
"visibility": "all",
"conditions": None,
"dynamic_text": False,
},
"support": {
"type": "builtin",
"builtin_id": "support",
"text": {"ru": "💬 Поддержка", "en": "💬 Support"},
"action": "menu_support",
"enabled": True,
"visibility": "all",
"conditions": {"support_enabled": True},
"dynamic_text": False,
},
"info": {
"type": "builtin",
"builtin_id": "info",
"text": {"ru": "️ Инфо", "en": "️ Info"},
"action": "menu_info",
"enabled": True,
"visibility": "all",
"conditions": None,
"dynamic_text": False,
},
"language": {
"type": "builtin",
"builtin_id": "language",
"text": {"ru": "🌐 Язык", "en": "🌐 Language"},
"action": "menu_language",
"enabled": True,
"visibility": "all",
"conditions": None,
"dynamic_text": False,
},
"admin_panel": {
"type": "builtin",
"builtin_id": "admin_panel",
"text": {"ru": "⚙️ Админ панель", "en": "⚙️ Admin panel"},
"action": "admin_panel",
"enabled": True,
"visibility": "admins",
"conditions": None,
"dynamic_text": False,
},
"moderator_panel": {
"type": "builtin",
"builtin_id": "moderator_panel",
"text": {"ru": "🧑‍⚖️ Модерация", "en": "🧑‍⚖️ Moderation"},
"action": "moderator_panel",
"enabled": True,
"visibility": "moderators",
"conditions": None,
"dynamic_text": False,
},
},
}
# Информация о встроенных кнопках для API
BUILTIN_BUTTONS_INFO: List[Dict[str, Any]] = [
{
"id": "connect",
"default_text": {"ru": "🔗 Подключиться", "en": "🔗 Connect"},
"callback_data": "subscription_connect",
"default_conditions": {"has_active_subscription": True, "subscription_is_active": True},
"supports_dynamic_text": False,
"supports_direct_open": True,
},
{
"id": "happ_download",
"default_text": {"ru": "⬇️ Скачать Happ", "en": "⬇️ Download Happ"},
"callback_data": "subscription_happ_download",
"default_conditions": {"happ_enabled": True},
"supports_dynamic_text": False,
},
{
"id": "subscription",
"default_text": {"ru": "📊 Подписка", "en": "📊 Subscription"},
"callback_data": "menu_subscription",
"default_conditions": {"has_active_subscription": True},
"supports_dynamic_text": False,
},
{
"id": "buy_traffic",
"default_text": {"ru": "📈 Докупить трафик", "en": "📈 Buy traffic"},
"callback_data": "buy_traffic",
"default_conditions": {"has_traffic_limit": True},
"supports_dynamic_text": False,
},
{
"id": "balance",
"default_text": {"ru": "💰 Баланс: {balance}", "en": "💰 Balance: {balance}"},
"callback_data": "menu_balance",
"default_conditions": None,
"supports_dynamic_text": True,
},
{
"id": "trial",
"default_text": {"ru": "🎁 Пробный период", "en": "🎁 Free trial"},
"callback_data": "menu_trial",
"default_conditions": {"show_trial": True},
"supports_dynamic_text": False,
},
{
"id": "buy_subscription",
"default_text": {"ru": "🛒 Купить подписку", "en": "🛒 Buy subscription"},
"callback_data": "menu_buy",
"default_conditions": {"show_buy": True},
"supports_dynamic_text": False,
},
{
"id": "simple_subscription",
"default_text": {"ru": "💳 Простая подписка", "en": "💳 Simple subscription"},
"callback_data": "simple_subscription_purchase",
"default_conditions": {"simple_subscription_enabled": True},
"supports_dynamic_text": False,
},
{
"id": "resume_checkout",
"default_text": {"ru": "↩️ Вернуться к оформлению", "en": "↩️ Resume checkout"},
"callback_data": "return_to_saved_cart",
"default_conditions": {"has_saved_cart": True},
"supports_dynamic_text": False,
},
{
"id": "promocode",
"default_text": {"ru": "🎟️ Промокод", "en": "🎟️ Promo code"},
"callback_data": "menu_promocode",
"default_conditions": None,
"supports_dynamic_text": False,
},
{
"id": "referrals",
"default_text": {"ru": "👥 Рефералы", "en": "👥 Referrals"},
"callback_data": "menu_referrals",
"default_conditions": {"referral_enabled": True},
"supports_dynamic_text": False,
},
{
"id": "contests",
"default_text": {"ru": "🎲 Конкурсы", "en": "🎲 Contests"},
"callback_data": "contests_menu",
"default_conditions": {"contests_visible": True},
"supports_dynamic_text": False,
},
{
"id": "support",
"default_text": {"ru": "💬 Поддержка", "en": "💬 Support"},
"callback_data": "menu_support",
"default_conditions": {"support_enabled": True},
"supports_dynamic_text": False,
},
{
"id": "info",
"default_text": {"ru": "️ Инфо", "en": "️ Info"},
"callback_data": "menu_info",
"default_conditions": None,
"supports_dynamic_text": False,
},
{
"id": "language",
"default_text": {"ru": "🌐 Язык", "en": "🌐 Language"},
"callback_data": "menu_language",
"default_conditions": {"language_selection_enabled": True},
"supports_dynamic_text": False,
},
{
"id": "admin_panel",
"default_text": {"ru": "⚙️ Админ панель", "en": "⚙️ Admin panel"},
"callback_data": "admin_panel",
"default_conditions": {"is_admin": True},
"supports_dynamic_text": False,
},
{
"id": "moderator_panel",
"default_text": {"ru": "🧑‍⚖️ Модерация", "en": "🧑‍⚖️ Moderation"},
"callback_data": "moderator_panel",
"default_conditions": {"is_moderator": True},
"supports_dynamic_text": False,
},
]
# Все доступные callback_data в боте (для добавления кастомных кнопок)
AVAILABLE_CALLBACKS: List[Dict[str, Any]] = [
# Меню
{"callback_data": "back_to_menu", "name": "Назад в меню", "category": "menu", "icon": "⬅️",
"text": {"ru": "⬅️ Назад", "en": "⬅️ Back"}},
{"callback_data": "menu_faq", "name": "FAQ", "category": "menu", "icon": "",
"text": {"ru": "❓ FAQ", "en": "❓ FAQ"}},
{"callback_data": "menu_info_promo_groups", "name": "Промо-группы", "category": "menu", "icon": "👥",
"text": {"ru": "👥 Промо-группы", "en": "👥 Promo groups"}},
{"callback_data": "menu_privacy_policy", "name": "Политика конфиденциальности", "category": "menu", "icon": "🔒",
"text": {"ru": "🔒 Политика конфиденциальности", "en": "🔒 Privacy Policy"}},
{"callback_data": "menu_public_offer", "name": "Публичная оферта", "category": "menu", "icon": "📜",
"text": {"ru": "📜 Публичная оферта", "en": "📜 Public Offer"}},
{"callback_data": "menu_rules", "name": "Правила", "category": "menu", "icon": "📋",
"text": {"ru": "📋 Правила", "en": "📋 Rules"}},
{"callback_data": "menu_server_status", "name": "Статус серверов", "category": "menu", "icon": "🖥️",
"text": {"ru": "🖥️ Статус серверов", "en": "🖥️ Server Status"}},
# Баланс
{"callback_data": "balance_history", "name": "История баланса", "category": "balance", "icon": "📜",
"text": {"ru": "📜 История", "en": "📜 History"}},
{"callback_data": "balance_topup", "name": "Пополнить баланс", "category": "balance", "icon": "💳",
"text": {"ru": "💳 Пополнить", "en": "💳 Top up"}},
# Подписка
{"callback_data": "subscription_extend", "name": "Продлить подписку", "category": "subscription", "icon": "📅",
"text": {"ru": "📅 Продлить", "en": "📅 Extend"}, "requires_subscription": True},
{"callback_data": "subscription_autopay", "name": "Автоплатёж", "category": "subscription", "icon": "🔄",
"text": {"ru": "🔄 Автоплатёж", "en": "🔄 Autopay"}, "requires_subscription": True},
{"callback_data": "subscription_settings", "name": "Настройки подписки", "category": "subscription", "icon": "⚙️",
"text": {"ru": "⚙️ Настройки", "en": "⚙️ Settings"}, "requires_subscription": True},
{"callback_data": "open_subscription_link", "name": "Показать ссылку подписки", "category": "subscription", "icon": "🔗",
"text": {"ru": "🔗 Показать ссылку", "en": "🔗 Show link"}, "requires_subscription": True},
{"callback_data": "subscription_add_countries", "name": "Добавить страны", "category": "subscription", "icon": "🌍",
"text": {"ru": "🌍 Добавить страны", "en": "🌍 Add countries"}, "requires_subscription": True},
{"callback_data": "subscription_reset_traffic", "name": "Сбросить трафик", "category": "subscription", "icon": "🔄",
"text": {"ru": "🔄 Сбросить трафик", "en": "🔄 Reset traffic"}, "requires_subscription": True},
{"callback_data": "subscription_switch_traffic", "name": "Переключить трафик", "category": "subscription", "icon": "🔀",
"text": {"ru": "🔀 Переключить трафик", "en": "🔀 Switch traffic"}, "requires_subscription": True},
{"callback_data": "subscription_change_devices", "name": "Изменить устройства", "category": "subscription", "icon": "📱",
"text": {"ru": "📱 Изменить устройства", "en": "📱 Change devices"}, "requires_subscription": True},
{"callback_data": "subscription_manage_devices", "name": "Управление устройствами", "category": "subscription", "icon": "📲",
"text": {"ru": "📲 Управление устройствами", "en": "📲 Manage devices"}, "requires_subscription": True},
{"callback_data": "subscription_upgrade", "name": "Улучшить подписку", "category": "subscription", "icon": "⬆️",
"text": {"ru": "⬆️ Улучшить", "en": "⬆️ Upgrade"}, "requires_subscription": True},
# Подключение устройств
{"callback_data": "device_guide_ios", "name": "Инструкция iOS", "category": "devices", "icon": "📱",
"text": {"ru": "📱 iOS", "en": "📱 iOS"}, "requires_subscription": True},
{"callback_data": "device_guide_android", "name": "Инструкция Android", "category": "devices", "icon": "🤖",
"text": {"ru": "🤖 Android", "en": "🤖 Android"}, "requires_subscription": True},
{"callback_data": "device_guide_windows", "name": "Инструкция Windows", "category": "devices", "icon": "💻",
"text": {"ru": "💻 Windows", "en": "💻 Windows"}, "requires_subscription": True},
{"callback_data": "device_guide_mac", "name": "Инструкция macOS", "category": "devices", "icon": "🎯",
"text": {"ru": "🎯 macOS", "en": "🎯 macOS"}, "requires_subscription": True},
{"callback_data": "device_guide_tv", "name": "Инструкция Android TV", "category": "devices", "icon": "📺",
"text": {"ru": "📺 Android TV", "en": "📺 Android TV"}, "requires_subscription": True},
{"callback_data": "device_guide_appletv", "name": "Инструкция Apple TV", "category": "devices", "icon": "📺",
"text": {"ru": "📺 Apple TV", "en": "📺 Apple TV"}, "requires_subscription": True},
# Happ
{"callback_data": "happ_download_ios", "name": "Скачать Happ iOS", "category": "happ", "icon": "🍎",
"text": {"ru": "🍎 iOS", "en": "🍎 iOS"}},
{"callback_data": "happ_download_android", "name": "Скачать Happ Android", "category": "happ", "icon": "🤖",
"text": {"ru": "🤖 Android", "en": "🤖 Android"}},
{"callback_data": "happ_download_macos", "name": "Скачать Happ macOS", "category": "happ", "icon": "🖥️",
"text": {"ru": "🖥️ macOS", "en": "🖥️ macOS"}},
{"callback_data": "happ_download_windows", "name": "Скачать Happ Windows", "category": "happ", "icon": "💻",
"text": {"ru": "💻 Windows", "en": "💻 Windows"}},
# Рефералы
{"callback_data": "referral_create_invite", "name": "Создать инвайт", "category": "referral", "icon": "✉️",
"text": {"ru": "✉️ Создать инвайт", "en": "✉️ Create invite"}},
{"callback_data": "referral_show_qr", "name": "QR код реферала", "category": "referral", "icon": "📱",
"text": {"ru": "📱 QR код", "en": "📱 QR code"}},
{"callback_data": "referral_list", "name": "Список рефералов", "category": "referral", "icon": "👥",
"text": {"ru": "👥 Мои рефералы", "en": "👥 My referrals"}},
{"callback_data": "referral_analytics", "name": "Аналитика рефералов", "category": "referral", "icon": "📊",
"text": {"ru": "📊 Аналитика", "en": "📊 Analytics"}},
# Поддержка
{"callback_data": "create_ticket", "name": "Создать тикет", "category": "support", "icon": "✏️",
"text": {"ru": "✏️ Создать тикет", "en": "✏️ Create ticket"}},
{"callback_data": "my_tickets", "name": "Мои тикеты", "category": "support", "icon": "📋",
"text": {"ru": "📋 Мои тикеты", "en": "📋 My tickets"}},
# Триал
{"callback_data": "trial_activate", "name": "Активировать триал", "category": "trial", "icon": "🎁",
"text": {"ru": "🎁 Активировать", "en": "🎁 Activate"}},
# Покупка
{"callback_data": "clear_saved_cart", "name": "Очистить корзину", "category": "purchase", "icon": "🗑️",
"text": {"ru": "🗑️ Очистить корзину", "en": "🗑️ Clear cart"}},
{"callback_data": "subscription_confirm", "name": "Подтвердить покупку", "category": "purchase", "icon": "",
"text": {"ru": "✅ Подтвердить", "en": "✅ Confirm"}},
{"callback_data": "subscription_cancel", "name": "Отменить покупку", "category": "purchase", "icon": "",
"text": {"ru": "❌ Отменить", "en": "❌ Cancel"}},
]
# Динамические плейсхолдеры для текста кнопок
DYNAMIC_PLACEHOLDERS: List[Dict[str, str]] = [
{"placeholder": "{balance}", "description": "Баланс пользователя", "example": "1 500 ₽", "category": "user"},
{"placeholder": "{username}", "description": "Имя пользователя", "example": "John", "category": "user"},
{"placeholder": "{subscription_days}", "description": "Дней до окончания подписки", "example": "14", "category": "subscription"},
{"placeholder": "{traffic_used}", "description": "Использованный трафик", "example": "5.2 GB", "category": "subscription"},
{"placeholder": "{traffic_left}", "description": "Оставшийся трафик", "example": "94.8 GB", "category": "subscription"},
{"placeholder": "{referral_count}", "description": "Количество рефералов", "example": "12", "category": "referral"},
{"placeholder": "{referral_earnings}", "description": "Заработок с рефералов", "example": "500 ₽", "category": "referral"},
]
+35
View File
@@ -0,0 +1,35 @@
"""Контекст меню для построения кнопок."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, List, Optional
from aiogram.types import InlineKeyboardButton
@dataclass
class MenuContext:
"""Контекст пользователя для построения меню."""
language: str = "ru"
is_admin: bool = False
is_moderator: bool = False
has_active_subscription: bool = False
subscription_is_active: bool = False
has_had_paid_subscription: bool = False
balance_kopeks: int = 0
subscription: Optional[Any] = None
show_resume_checkout: bool = False
has_saved_cart: bool = False
custom_buttons: List[InlineKeyboardButton] = field(default_factory=list)
# Расширенные поля для плейсхолдеров и условий
username: str = ""
subscription_days: int = 0
traffic_used_gb: float = 0.0
traffic_left_gb: float = 0.0
referral_count: int = 0
referral_earnings_kopeks: int = 0
registration_days: int = 0
promo_group_id: Optional[str] = None
has_autopay: bool = False
+141
View File
@@ -0,0 +1,141 @@
"""Сервис истории изменений конфигурации меню."""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy import select, func, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import MenuLayoutHistory
class MenuLayoutHistoryService:
"""Сервис для управления историей изменений меню."""
@classmethod
async def save_history(
cls,
db: AsyncSession,
config: Dict[str, Any],
action: str,
changes_summary: Optional[str] = None,
user_info: Optional[str] = None,
) -> MenuLayoutHistory:
"""Сохранить запись в историю изменений."""
history = MenuLayoutHistory(
config_json=json.dumps(config, ensure_ascii=False),
action=action,
changes_summary=changes_summary or f"Action: {action}",
user_info=user_info,
)
db.add(history)
await db.commit()
await db.refresh(history)
return history
@classmethod
async def get_history(
cls,
db: AsyncSession,
limit: int = 50,
offset: int = 0,
) -> List[Dict[str, Any]]:
"""Получить историю изменений."""
result = await db.execute(
select(MenuLayoutHistory)
.order_by(desc(MenuLayoutHistory.created_at))
.limit(limit)
.offset(offset)
)
entries = result.scalars().all()
return [
{
"id": entry.id,
"action": entry.action,
"changes_summary": entry.changes_summary,
"user_info": entry.user_info,
"created_at": entry.created_at,
}
for entry in entries
]
@classmethod
async def get_history_count(cls, db: AsyncSession) -> int:
"""Получить общее количество записей истории."""
result = await db.execute(
select(func.count(MenuLayoutHistory.id))
)
return result.scalar() or 0
@classmethod
async def get_history_entry(
cls,
db: AsyncSession,
history_id: int,
) -> Optional[Dict[str, Any]]:
"""Получить конкретную запись истории с конфигурацией."""
result = await db.execute(
select(MenuLayoutHistory).where(MenuLayoutHistory.id == history_id)
)
entry = result.scalar_one_or_none()
if not entry:
return None
return {
"id": entry.id,
"action": entry.action,
"changes_summary": entry.changes_summary,
"user_info": entry.user_info,
"created_at": entry.created_at,
"config": json.loads(entry.config_json),
}
@classmethod
async def rollback_to_history(
cls,
db: AsyncSession,
history_id: int,
get_config_func,
save_config_func,
user_info: Optional[str] = None,
) -> Dict[str, Any]:
"""
Откатить конфигурацию к записи из истории.
Args:
db: Сессия базы данных
history_id: ID записи истории
get_config_func: Функция для получения текущей конфигурации
save_config_func: Функция для сохранения конфигурации
user_info: Информация о пользователе
"""
entry = await cls.get_history_entry(db, history_id)
if not entry:
raise KeyError(f"History entry {history_id} not found")
config = entry["config"]
# Сохраняем текущую конфигурацию в историю перед откатом
current_config = await get_config_func(db)
await cls.save_history(
db, current_config, "rollback_backup",
f"Backup before rollback to history #{history_id}",
user_info
)
# Применяем конфигурацию из истории
await save_config_func(db, config)
# Сохраняем запись об откате
await cls.save_history(
db, config, "rollback",
f"Rollback to history #{history_id}",
user_info
)
return config
File diff suppressed because it is too large Load Diff
+500
View File
@@ -0,0 +1,500 @@
"""Сервис статистики кликов по кнопкам меню."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy import select, func, and_, desc, case, Integer
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import ButtonClickLog
def _utcnow() -> datetime:
"""Возвращает текущее UTC время как naive datetime (без timezone).
Замена deprecated datetime.utcnow().
"""
return datetime.now(timezone.utc).replace(tzinfo=None)
class MenuLayoutStatsService:
"""Сервис для сбора и анализа статистики кликов по кнопкам."""
@classmethod
def _is_sqlite(cls) -> bool:
"""Проверить, используется ли SQLite."""
return settings.is_sqlite()
@classmethod
def _get_hour_expr(cls, column):
"""Получить выражение для извлечения часа (совместимо с SQLite и PostgreSQL)."""
if cls._is_sqlite():
# SQLite: strftime('%H', column) возвращает строку
return func.cast(func.strftime('%H', column), Integer)
else:
# PostgreSQL: EXTRACT(hour FROM column)
return func.extract('hour', column)
@classmethod
def _get_weekday_expr(cls, column):
"""Получить выражение для дня недели (0=Пн, 6=Вс) совместимо с SQLite и PostgreSQL."""
if cls._is_sqlite():
# SQLite: strftime('%w', column) возвращает 0=воскресенье, 1-6=пн-сб
# Преобразуем: 0->6, 1->0, 2->1, ..., 6->5
dow = func.cast(func.strftime('%w', column), Integer)
return case(
(dow == 0, 6),
else_=dow - 1
)
else:
# PostgreSQL: EXTRACT(dow FROM column) возвращает 0=воскресенье, 1-6=пн-сб
dow = func.extract('dow', column)
return case(
(dow == 0, 6),
else_=dow - 1
)
@classmethod
async def log_button_click(
cls,
db: AsyncSession,
button_id: str,
user_id: Optional[int] = None,
callback_data: Optional[str] = None,
button_type: Optional[str] = None,
button_text: Optional[str] = None,
) -> ButtonClickLog:
"""Записать клик по кнопке."""
click_log = ButtonClickLog(
button_id=button_id,
user_id=user_id,
callback_data=callback_data,
button_type=button_type,
button_text=button_text,
)
db.add(click_log)
await db.commit()
return click_log
@classmethod
async def get_button_stats(
cls,
db: AsyncSession,
button_id: str,
days: int = 30,
) -> Dict[str, Any]:
"""Получить статистику кликов по конкретной кнопке."""
now = _utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=days)
# Общее количество кликов
total_result = await db.execute(
select(func.count(ButtonClickLog.id))
.where(ButtonClickLog.button_id == button_id)
)
clicks_total = total_result.scalar() or 0
# Клики сегодня
today_result = await db.execute(
select(func.count(ButtonClickLog.id))
.where(and_(
ButtonClickLog.button_id == button_id,
ButtonClickLog.clicked_at >= today_start
))
)
clicks_today = today_result.scalar() or 0
# Клики за неделю
week_result = await db.execute(
select(func.count(ButtonClickLog.id))
.where(and_(
ButtonClickLog.button_id == button_id,
ButtonClickLog.clicked_at >= week_ago
))
)
clicks_week = week_result.scalar() or 0
# Клики за месяц
month_result = await db.execute(
select(func.count(ButtonClickLog.id))
.where(and_(
ButtonClickLog.button_id == button_id,
ButtonClickLog.clicked_at >= month_ago
))
)
clicks_month = month_result.scalar() or 0
# Уникальные пользователи
unique_result = await db.execute(
select(func.count(func.distinct(ButtonClickLog.user_id)))
.where(ButtonClickLog.button_id == button_id)
)
unique_users = unique_result.scalar() or 0
# Последний клик
last_click_result = await db.execute(
select(ButtonClickLog.clicked_at)
.where(ButtonClickLog.button_id == button_id)
.order_by(desc(ButtonClickLog.clicked_at))
.limit(1)
)
last_click = last_click_result.scalar_one_or_none()
return {
"button_id": button_id,
"clicks_total": clicks_total,
"clicks_today": clicks_today,
"clicks_week": clicks_week,
"clicks_month": clicks_month,
"unique_users": unique_users,
"last_click_at": last_click,
}
@classmethod
async def get_button_clicks_by_day(
cls,
db: AsyncSession,
button_id: str,
days: int = 30,
) -> List[Dict[str, Any]]:
"""Получить статистику кликов по дням."""
start_date = _utcnow() - timedelta(days=days)
# Группировка по дате
result = await db.execute(
select(
func.date(ButtonClickLog.clicked_at).label("date"),
func.count(ButtonClickLog.id).label("count")
)
.where(and_(
ButtonClickLog.button_id == button_id,
ButtonClickLog.clicked_at >= start_date
))
.group_by(func.date(ButtonClickLog.clicked_at))
.order_by(func.date(ButtonClickLog.clicked_at))
)
return [
{"date": str(row.date), "count": row.count}
for row in result.all()
]
@classmethod
async def get_all_buttons_stats(
cls,
db: AsyncSession,
days: int = 30,
) -> List[Dict[str, Any]]:
"""Получить статистику по всем кнопкам."""
now = _utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=days)
# Для производительности используем один запрос с подзапросами через CASE
result = await db.execute(
select(
ButtonClickLog.button_id,
# Общее количество кликов (все клики без фильтра по датам)
func.count(ButtonClickLog.id).label("clicks_total"),
# Уникальные пользователи (все время)
func.count(func.distinct(ButtonClickLog.user_id)).label("unique_users"),
# Последний клик (все время)
func.max(ButtonClickLog.clicked_at).label("last_click_at"),
# Подсчет кликов за сегодня
func.sum(
case((ButtonClickLog.clicked_at >= today_start, 1), else_=0)
).label("clicks_today"),
# Подсчет кликов за неделю
func.sum(
case((ButtonClickLog.clicked_at >= week_ago, 1), else_=0)
).label("clicks_week"),
# Подсчет кликов за месяц
func.sum(
case((ButtonClickLog.clicked_at >= month_ago, 1), else_=0)
).label("clicks_month"),
)
.group_by(ButtonClickLog.button_id)
.order_by(desc(func.count(ButtonClickLog.id)))
)
return [
{
"button_id": row.button_id,
"clicks_total": row.clicks_total,
"clicks_today": row.clicks_today or 0,
"clicks_week": row.clicks_week or 0,
"clicks_month": row.clicks_month or 0,
"unique_users": row.unique_users,
"last_click_at": row.last_click_at,
}
for row in result.all()
]
@classmethod
async def get_total_clicks(
cls,
db: AsyncSession,
days: int = 30,
) -> int:
"""Получить общее количество кликов за период."""
start_date = _utcnow() - timedelta(days=days)
result = await db.execute(
select(func.count(ButtonClickLog.id))
.where(ButtonClickLog.clicked_at >= start_date)
)
return result.scalar() or 0
@classmethod
async def get_stats_by_button_type(
cls,
db: AsyncSession,
days: int = 30,
) -> List[Dict[str, Any]]:
"""Получить статистику кликов по типам кнопок."""
start_date = _utcnow() - timedelta(days=days)
result = await db.execute(
select(
ButtonClickLog.button_type,
func.count(ButtonClickLog.id).label("clicks_total"),
func.count(func.distinct(ButtonClickLog.user_id)).label("unique_users"),
)
.where(and_(
ButtonClickLog.clicked_at >= start_date,
ButtonClickLog.button_type.isnot(None)
))
.group_by(ButtonClickLog.button_type)
.order_by(desc(func.count(ButtonClickLog.id)))
)
return [
{
"button_type": row.button_type or "unknown",
"clicks_total": row.clicks_total,
"unique_users": row.unique_users,
}
for row in result.all()
]
@classmethod
async def get_clicks_by_hour(
cls,
db: AsyncSession,
button_id: Optional[str] = None,
days: int = 30,
) -> List[Dict[str, Any]]:
"""Получить статистику кликов по часам дня."""
start_date = _utcnow() - timedelta(days=days)
# Используем helper-метод для совместимости с SQLite и PostgreSQL
hour_expr = cls._get_hour_expr(ButtonClickLog.clicked_at).label("hour")
query = select(
hour_expr,
func.count(ButtonClickLog.id).label("count")
).where(ButtonClickLog.clicked_at >= start_date)
if button_id:
query = query.where(ButtonClickLog.button_id == button_id)
result = await db.execute(
query
.group_by(hour_expr)
.order_by(hour_expr)
)
# Создаем словарь для быстрого доступа по часу
stats_dict = {
int(row.hour): row.count
for row in result.all()
}
# Возвращаем все 24 часа, даже если count = 0
return [
{"hour": hour, "count": stats_dict.get(hour, 0)}
for hour in range(24)
]
@classmethod
async def get_clicks_by_weekday(
cls,
db: AsyncSession,
button_id: Optional[str] = None,
days: int = 30,
) -> List[Dict[str, Any]]:
"""Получить статистику кликов по дням недели.
Возвращает 0=понедельник, 6=воскресенье.
Поддерживает как PostgreSQL, так и SQLite.
"""
start_date = _utcnow() - timedelta(days=days)
# Используем helper-метод для совместимости с SQLite и PostgreSQL
weekday_expr = cls._get_weekday_expr(ButtonClickLog.clicked_at).label("weekday")
query = select(
weekday_expr,
func.count(ButtonClickLog.id).label("count")
).where(ButtonClickLog.clicked_at >= start_date)
if button_id:
query = query.where(ButtonClickLog.button_id == button_id)
result = await db.execute(
query
.group_by(weekday_expr)
.order_by(weekday_expr)
)
weekday_names = ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье"]
# Создаем словарь для быстрого доступа по weekday
stats_dict = {
int(row.weekday): row.count
for row in result.all()
}
# Возвращаем все дни недели, даже если count = 0
return [
{
"weekday": weekday,
"weekday_name": weekday_names[weekday],
"count": stats_dict.get(weekday, 0)
}
for weekday in range(7)
]
@classmethod
async def get_top_users(
cls,
db: AsyncSession,
button_id: Optional[str] = None,
limit: int = 10,
days: int = 30,
) -> List[Dict[str, Any]]:
"""Получить топ пользователей по количеству кликов."""
start_date = _utcnow() - timedelta(days=days)
query = select(
ButtonClickLog.user_id,
func.count(ButtonClickLog.id).label("clicks_count"),
func.max(ButtonClickLog.clicked_at).label("last_click_at")
).where(and_(
ButtonClickLog.clicked_at >= start_date,
ButtonClickLog.user_id.isnot(None)
))
if button_id:
query = query.where(ButtonClickLog.button_id == button_id)
result = await db.execute(
query
.group_by(ButtonClickLog.user_id)
.order_by(desc(func.count(ButtonClickLog.id)))
.limit(limit)
)
return [
{
"user_id": row.user_id,
"clicks_count": row.clicks_count,
"last_click_at": row.last_click_at,
}
for row in result.all()
]
@classmethod
async def get_period_comparison(
cls,
db: AsyncSession,
button_id: Optional[str] = None,
current_days: int = 7,
previous_days: int = 7,
) -> Dict[str, Any]:
"""Сравнить статистику текущего и предыдущего периода."""
now = _utcnow()
current_start = now - timedelta(days=current_days)
previous_start = current_start - timedelta(days=previous_days)
previous_end = current_start
query_current = select(func.count(ButtonClickLog.id))
query_previous = select(func.count(ButtonClickLog.id))
if button_id:
query_current = query_current.where(ButtonClickLog.button_id == button_id)
query_previous = query_previous.where(ButtonClickLog.button_id == button_id)
query_current = query_current.where(
ButtonClickLog.clicked_at >= current_start
)
query_previous = query_previous.where(
and_(
ButtonClickLog.clicked_at >= previous_start,
ButtonClickLog.clicked_at < previous_end
)
)
current_result = await db.execute(query_current)
previous_result = await db.execute(query_previous)
current_count = current_result.scalar() or 0
previous_count = previous_result.scalar() or 0
change_percent = 0
if previous_count > 0:
change_percent = ((current_count - previous_count) / previous_count) * 100
return {
"current_period": {
"clicks": current_count,
"days": current_days,
"start": current_start,
"end": now,
},
"previous_period": {
"clicks": previous_count,
"days": previous_days,
"start": previous_start,
"end": previous_end,
},
"change": {
"absolute": current_count - previous_count,
"percent": round(change_percent, 2),
"trend": "up" if change_percent > 0 else "down" if change_percent < 0 else "stable",
},
}
@classmethod
async def get_click_sequences(
cls,
db: AsyncSession,
user_id: int,
limit: int = 50,
) -> List[Dict[str, Any]]:
"""Получить последовательности кликов пользователя."""
result = await db.execute(
select(
ButtonClickLog.button_id,
ButtonClickLog.button_text,
ButtonClickLog.clicked_at,
)
.where(ButtonClickLog.user_id == user_id)
.order_by(desc(ButtonClickLog.clicked_at))
.limit(limit)
)
return [
{
"button_id": row.button_id,
"button_text": row.button_text,
"clicked_at": row.clicked_at,
}
for row in result.all()
]
+40
View File
@@ -0,0 +1,40 @@
"""
Сервис конструктора меню - управление конфигурацией через API.
ВНИМАНИЕ: Этот файл оставлен для обратной совместимости.
Фактическая реализация находится в app/services/menu_layout/
Структура модуля:
- app/services/menu_layout/constants.py - константы
- app/services/menu_layout/context.py - MenuContext
- app/services/menu_layout/history_service.py - история изменений
- app/services/menu_layout/stats_service.py - статистика кликов
- app/services/menu_layout/service.py - основной сервис
"""
# Реэкспорт для обратной совместимости
from app.services.menu_layout import (
# Константы
MENU_LAYOUT_CONFIG_KEY,
DEFAULT_MENU_CONFIG,
BUILTIN_BUTTONS_INFO,
AVAILABLE_CALLBACKS,
DYNAMIC_PLACEHOLDERS,
# Классы
MenuContext,
MenuLayoutService,
MenuLayoutHistoryService,
MenuLayoutStatsService,
)
__all__ = [
"MENU_LAYOUT_CONFIG_KEY",
"DEFAULT_MENU_CONFIG",
"BUILTIN_BUTTONS_INFO",
"AVAILABLE_CALLBACKS",
"DYNAMIC_PLACEHOLDERS",
"MenuContext",
"MenuLayoutService",
"MenuLayoutHistoryService",
"MenuLayoutStatsService",
]
+17 -6
View File
@@ -214,7 +214,8 @@ class MonitoringService:
await self._check_trial_inactivity_notifications(db)
await self._check_trial_channel_subscriptions(db)
await self._check_expired_subscription_followups(db)
await self._process_autopayments(db)
if settings.ENABLE_AUTOPAY:
await self._process_autopayments(db)
await self._cleanup_inactive_users(db)
await self._sync_with_remnawave(db)
@@ -507,6 +508,12 @@ class MonitoringService:
if not settings.CHANNEL_IS_REQUIRED_SUB:
return
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
logger.debug(
"ℹ️ Проверка отписок от канала отключена — деактивация триальных подписок не требуется"
)
return
channel_id = settings.CHANNEL_SUB_ID
if not channel_id:
return
@@ -931,7 +938,7 @@ class MonitoringService:
autopay_subscriptions = []
for sub in all_autopay_subscriptions:
days_before_expiry = (sub.end_date - current_time).days
if days_before_expiry <= sub.autopay_days_before:
if days_before_expiry <= min(sub.autopay_days_before, 3):
autopay_subscriptions.append(sub)
processed_count = 0
@@ -1059,12 +1066,16 @@ class MonitoringService:
texts = get_texts(user.language)
days_text = format_days_declension(days, user.language)
if subscription.autopay_enabled:
autopay_status = "✅ Включен - подписка продлится автоматически"
action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}"
if settings.ENABLE_AUTOPAY:
if subscription.autopay_enabled:
autopay_status = "✅ Включен - подписка продлится автоматически"
action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}"
else:
autopay_status = "❌ Отключен - не забудьте продлить вручную!"
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
else:
autopay_status = "❌ Отключен - не забудьте продлить вручную!"
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
action_text = "💡 Продлите подписку вручную"
message = f"""
<b>Подписка истекает через {days_text}!</b>
+118
View File
@@ -0,0 +1,118 @@
import logging
from typing import Optional, Dict, Any
from decimal import Decimal
from nalogo import Client
from nalogo.dto.income import IncomeClient, IncomeType
from app.config import settings
logger = logging.getLogger(__name__)
class NaloGoService:
"""Сервис для работы с API NaloGO (налоговая служба самозанятых)."""
def __init__(self,
inn: Optional[str] = None,
password: Optional[str] = None,
device_id: Optional[str] = None,
storage_path: Optional[str] = None):
inn = inn or getattr(settings, 'NALOGO_INN', None)
password = password or getattr(settings, 'NALOGO_PASSWORD', None)
device_id = device_id or getattr(settings, 'NALOGO_DEVICE_ID', None)
storage_path = storage_path or getattr(settings, 'NALOGO_STORAGE_PATH', './nalogo_tokens.json')
self.configured = False
if not inn or not password:
logger.warning(
"NaloGO INN или PASSWORD не настроены в settings. "
"Функционал чеков будет ОТКЛЮЧЕН.")
else:
try:
self.client = Client(
base_url="https://lknpd.nalog.ru/api",
storage_path=storage_path,
device_id=device_id or "bot-device-123"
)
self.inn = inn
self.password = password
self.configured = True
logger.info(f"NaloGO клиент инициализирован для ИНН: {inn[:5]}...")
except Exception as error:
logger.error(
"Ошибка инициализации NaloGO клиента: %s",
error,
exc_info=True,
)
self.configured = False
async def authenticate(self) -> bool:
"""Аутентификация в сервисе NaloGO."""
if not self.configured:
return False
try:
token = await self.client.create_new_access_token(self.inn, self.password)
await self.client.authenticate(token)
logger.info("Успешная аутентификация в NaloGO")
return True
except Exception as error:
logger.error("Ошибка аутентификации в NaloGO: %s", error, exc_info=True)
return False
async def create_receipt(self, name: str, amount: float, quantity: int = 1, client_info: Optional[Dict[str, Any]] = None) -> Optional[str]:
"""Создание чека о доходе.
Args:
name: Название услуги
amount: Сумма в рублях
quantity: Количество
client_info: Информация о клиенте (опционально)
Returns:
UUID чека или None при ошибке
"""
if not self.configured:
logger.warning("NaloGO не настроен, чек не создан")
return None
try:
# Аутентифицируемся, если нужно
if not hasattr(self.client, '_access_token') or not self.client._access_token:
auth_success = await self.authenticate()
if not auth_success:
return None
income_api = self.client.income()
# Создаем клиента, если передана информация
income_client = None
if client_info:
income_client = IncomeClient(
contact_phone=client_info.get("phone"),
display_name=client_info.get("name"),
income_type=client_info.get("income_type", IncomeType.FROM_INDIVIDUAL),
inn=client_info.get("inn")
)
result = await income_api.create(
name=name,
amount=Decimal(str(amount)),
quantity=quantity,
client=income_client
)
receipt_uuid = result.get("approvedReceiptUuid")
if receipt_uuid:
logger.info(f"Чек создан успешно: {receipt_uuid} на сумму {amount}")
return receipt_uuid
else:
logger.error(f"Ошибка создания чека: {result}")
return None
except Exception as error:
logger.error("Ошибка создания чека в NaloGO: %s", error, exc_info=True)
return None
+625
View File
@@ -0,0 +1,625 @@
"""Сервис расширенной статистики партнёров (рефереров)."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, case, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import (
ReferralEarning,
Subscription,
SubscriptionStatus,
User,
)
logger = logging.getLogger(__name__)
class PartnerStatsService:
"""Сервис для детальной статистики партнёров."""
@classmethod
async def get_referrer_detailed_stats(
cls,
db: AsyncSession,
user_id: int,
) -> Dict[str, Any]:
"""Получить детальную статистику реферера."""
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
year_ago = now - timedelta(days=365)
# Базовые данные о рефералах
referrals_query = select(User).where(User.referred_by_id == user_id)
referrals_result = await db.execute(referrals_query)
referrals = referrals_result.scalars().all()
referral_ids = [r.id for r in referrals]
total_referrals = len(referrals)
# Сколько сделали первое пополнение (has_made_first_topup)
paid_referrals = sum(1 for r in referrals if r.has_made_first_topup)
# Активные рефералы (с активной подпиской)
if referral_ids:
active_result = await db.execute(
select(func.count(func.distinct(User.id)))
.join(Subscription, User.id == Subscription.user_id)
.where(
and_(
User.id.in_(referral_ids),
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > now,
)
)
)
active_referrals = active_result.scalar() or 0
else:
active_referrals = 0
# Заработки по периодам - один запрос с CASE WHEN
earnings_result = await db.execute(
select(
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label("all_time"),
func.coalesce(func.sum(
case((ReferralEarning.created_at >= today_start, ReferralEarning.amount_kopeks), else_=0)
), 0).label("today"),
func.coalesce(func.sum(
case((ReferralEarning.created_at >= week_ago, ReferralEarning.amount_kopeks), else_=0)
), 0).label("week"),
func.coalesce(func.sum(
case((ReferralEarning.created_at >= month_ago, ReferralEarning.amount_kopeks), else_=0)
), 0).label("month"),
func.coalesce(func.sum(
case((ReferralEarning.created_at >= year_ago, ReferralEarning.amount_kopeks), else_=0)
), 0).label("year"),
).where(ReferralEarning.user_id == user_id)
)
earnings_row = earnings_result.one()
earnings_all_time = int(earnings_row.all_time)
earnings_today = int(earnings_row.today)
earnings_week = int(earnings_row.week)
earnings_month = int(earnings_row.month)
earnings_year = int(earnings_row.year)
# Рефералы по периодам
referrals_today = sum(1 for r in referrals if r.created_at >= today_start)
referrals_week = sum(1 for r in referrals if r.created_at >= week_ago)
referrals_month = sum(1 for r in referrals if r.created_at >= month_ago)
referrals_year = sum(1 for r in referrals if r.created_at >= year_ago)
# Конверсии
conversion_to_paid = round((paid_referrals / total_referrals * 100), 2) if total_referrals > 0 else 0
conversion_to_active = round((active_referrals / total_referrals * 100), 2) if total_referrals > 0 else 0
# Средний доход с реферала
avg_earnings_per_referral = round(earnings_all_time / paid_referrals, 2) if paid_referrals > 0 else 0
return {
"user_id": user_id,
"summary": {
"total_referrals": total_referrals,
"paid_referrals": paid_referrals,
"active_referrals": active_referrals,
"conversion_to_paid_percent": conversion_to_paid,
"conversion_to_active_percent": conversion_to_active,
"avg_earnings_per_referral_kopeks": avg_earnings_per_referral,
},
"earnings": {
"all_time_kopeks": earnings_all_time,
"year_kopeks": earnings_year,
"month_kopeks": earnings_month,
"week_kopeks": earnings_week,
"today_kopeks": earnings_today,
},
"referrals_count": {
"all_time": total_referrals,
"year": referrals_year,
"month": referrals_month,
"week": referrals_week,
"today": referrals_today,
},
}
@classmethod
async def get_referrer_daily_stats(
cls,
db: AsyncSession,
user_id: int,
days: int = 30,
) -> List[Dict[str, Any]]:
"""Получить статистику реферера по дням."""
now = datetime.utcnow()
start_date = now - timedelta(days=days)
# Рефералы по дням
referrals_by_day = await db.execute(
select(
func.date(User.created_at).label("date"),
func.count(User.id).label("referrals_count"),
)
.where(
and_(
User.referred_by_id == user_id,
User.created_at >= start_date,
)
)
.group_by(func.date(User.created_at))
.order_by(func.date(User.created_at))
)
referrals_dict = {str(row.date): row.referrals_count for row in referrals_by_day.all()}
# Заработки по дням (из ReferralEarning)
earnings_by_day = await db.execute(
select(
func.date(ReferralEarning.created_at).label("date"),
func.sum(ReferralEarning.amount_kopeks).label("earnings"),
)
.where(
and_(
ReferralEarning.user_id == user_id,
ReferralEarning.created_at >= start_date,
)
)
.group_by(func.date(ReferralEarning.created_at))
)
earnings_dict = {str(row.date): int(row.earnings or 0) for row in earnings_by_day.all()}
# Формируем массив за все дни
result = []
for i in range(days):
date = (start_date + timedelta(days=i)).date()
date_str = str(date)
result.append({
"date": date_str,
"referrals_count": referrals_dict.get(date_str, 0),
"earnings_kopeks": earnings_dict.get(date_str, 0),
})
return result
@classmethod
async def get_referrer_top_referrals(
cls,
db: AsyncSession,
user_id: int,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Получить топ рефералов по доходу для реферера."""
now = datetime.utcnow()
# Получаем рефералов с их доходами
result = await db.execute(
select(
User.id,
User.telegram_id,
User.username,
User.first_name,
User.last_name,
User.created_at,
User.has_made_first_topup,
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label("total_earnings"),
)
.outerjoin(ReferralEarning, ReferralEarning.referral_id == User.id)
.where(User.referred_by_id == user_id)
.group_by(User.id)
.order_by(desc(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)))
.limit(limit)
)
rows = result.all()
if not rows:
return []
# Собираем user_id для проверки активных подписок одним запросом
user_ids = [row.id for row in rows]
# Получаем все активные подписки для этих пользователей одним запросом
active_subs_result = await db.execute(
select(Subscription.user_id)
.where(
and_(
Subscription.user_id.in_(user_ids),
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > now,
)
)
)
active_user_ids = {row.user_id for row in active_subs_result.all()}
referrals = []
for row in rows:
referrals.append({
"id": row.id,
"telegram_id": row.telegram_id,
"username": row.username,
"first_name": row.first_name,
"last_name": row.last_name,
"full_name": f"{row.first_name or ''} {row.last_name or ''}".strip() or f"User {row.telegram_id}",
"created_at": row.created_at,
"has_made_first_topup": row.has_made_first_topup,
"is_active": row.id in active_user_ids,
"total_earnings_kopeks": int(row.total_earnings),
})
return referrals
@classmethod
async def get_referrer_period_comparison(
cls,
db: AsyncSession,
user_id: int,
current_days: int = 7,
previous_days: int = 7,
) -> Dict[str, Any]:
"""Сравнить текущий и предыдущий период."""
now = datetime.utcnow()
current_start = now - timedelta(days=current_days)
previous_start = current_start - timedelta(days=previous_days)
previous_end = current_start
# Рефералы за текущий период
current_referrals = await db.execute(
select(func.count(User.id))
.where(
and_(
User.referred_by_id == user_id,
User.created_at >= current_start,
)
)
)
current_referrals_count = current_referrals.scalar() or 0
# Рефералы за предыдущий период
previous_referrals = await db.execute(
select(func.count(User.id))
.where(
and_(
User.referred_by_id == user_id,
User.created_at >= previous_start,
User.created_at < previous_end,
)
)
)
previous_referrals_count = previous_referrals.scalar() or 0
# Заработки за текущий период
current_earnings = await cls._get_earnings_for_period(db, user_id, current_start)
# Заработки за предыдущий период
previous_earnings = await cls._get_earnings_for_period(
db, user_id, previous_start, previous_end
)
# Расчёт изменений
referrals_change = current_referrals_count - previous_referrals_count
referrals_change_percent = (
round((referrals_change / previous_referrals_count * 100), 2)
if previous_referrals_count > 0
else 0
)
earnings_change = current_earnings - previous_earnings
earnings_change_percent = (
round((earnings_change / previous_earnings * 100), 2)
if previous_earnings > 0
else 0
)
return {
"current_period": {
"days": current_days,
"start": current_start.isoformat(),
"end": now.isoformat(),
"referrals_count": current_referrals_count,
"earnings_kopeks": current_earnings,
},
"previous_period": {
"days": previous_days,
"start": previous_start.isoformat(),
"end": previous_end.isoformat(),
"referrals_count": previous_referrals_count,
"earnings_kopeks": previous_earnings,
},
"change": {
"referrals": {
"absolute": referrals_change,
"percent": referrals_change_percent,
"trend": "up" if referrals_change > 0 else "down" if referrals_change < 0 else "stable",
},
"earnings": {
"absolute": earnings_change,
"percent": earnings_change_percent,
"trend": "up" if earnings_change > 0 else "down" if earnings_change < 0 else "stable",
},
},
}
@classmethod
async def get_global_partner_stats(
cls,
db: AsyncSession,
days: int = 30,
) -> Dict[str, Any]:
"""Глобальная статистика партнёрской программы."""
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
year_ago = now - timedelta(days=365)
start_date = now - timedelta(days=days)
# Всего рефереров (у кого есть рефералы)
total_referrers = await db.execute(
select(func.count(func.distinct(User.referred_by_id)))
.where(User.referred_by_id.isnot(None))
)
total_referrers_count = total_referrers.scalar() or 0
# Всего рефералов
total_referrals = await db.execute(
select(func.count(User.id))
.where(User.referred_by_id.isnot(None))
)
total_referrals_count = total_referrals.scalar() or 0
# Рефералы которые заплатили
paid_referrals = await db.execute(
select(func.count(User.id))
.where(
and_(
User.referred_by_id.isnot(None),
User.has_made_first_topup.is_(True),
)
)
)
paid_referrals_count = paid_referrals.scalar() or 0
# Всего выплачено - один запрос с CASE WHEN
payouts_result = await db.execute(
select(
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label("all_time"),
func.coalesce(func.sum(
case((ReferralEarning.created_at >= today_start, ReferralEarning.amount_kopeks), else_=0)
), 0).label("today"),
func.coalesce(func.sum(
case((ReferralEarning.created_at >= week_ago, ReferralEarning.amount_kopeks), else_=0)
), 0).label("week"),
func.coalesce(func.sum(
case((ReferralEarning.created_at >= month_ago, ReferralEarning.amount_kopeks), else_=0)
), 0).label("month"),
func.coalesce(func.sum(
case((ReferralEarning.created_at >= year_ago, ReferralEarning.amount_kopeks), else_=0)
), 0).label("year"),
)
)
payouts_row = payouts_result.one()
total_paid = int(payouts_row.all_time)
today_paid = int(payouts_row.today)
week_paid = int(payouts_row.week)
month_paid = int(payouts_row.month)
year_paid = int(payouts_row.year)
# Новые рефералы по периодам - один запрос с CASE WHEN
new_referrals_result = await db.execute(
select(
func.sum(case((User.created_at >= today_start, 1), else_=0)).label("today"),
func.sum(case((User.created_at >= week_ago, 1), else_=0)).label("week"),
func.sum(case((User.created_at >= month_ago, 1), else_=0)).label("month"),
).where(User.referred_by_id.isnot(None))
)
new_referrals_row = new_referrals_result.one()
new_referrals_today_count = int(new_referrals_row.today or 0)
new_referrals_week_count = int(new_referrals_row.week or 0)
new_referrals_month_count = int(new_referrals_row.month or 0)
# Конверсия
conversion_rate = (
round((paid_referrals_count / total_referrals_count * 100), 2)
if total_referrals_count > 0
else 0
)
# Средний доход с реферала
avg_per_referral = (
round(total_paid / paid_referrals_count, 2)
if paid_referrals_count > 0
else 0
)
return {
"summary": {
"total_referrers": total_referrers_count,
"total_referrals": total_referrals_count,
"paid_referrals": paid_referrals_count,
"conversion_rate_percent": conversion_rate,
"avg_earnings_per_referral_kopeks": avg_per_referral,
},
"payouts": {
"all_time_kopeks": total_paid,
"year_kopeks": year_paid,
"month_kopeks": month_paid,
"week_kopeks": week_paid,
"today_kopeks": today_paid,
},
"new_referrals": {
"today": new_referrals_today_count,
"week": new_referrals_week_count,
"month": new_referrals_month_count,
},
}
@classmethod
async def get_global_daily_stats(
cls,
db: AsyncSession,
days: int = 30,
) -> List[Dict[str, Any]]:
"""Глобальная статистика по дням."""
now = datetime.utcnow()
start_date = now - timedelta(days=days)
# Рефералы по дням
referrals_by_day = await db.execute(
select(
func.date(User.created_at).label("date"),
func.count(User.id).label("referrals_count"),
)
.where(
and_(
User.referred_by_id.isnot(None),
User.created_at >= start_date,
)
)
.group_by(func.date(User.created_at))
)
referrals_dict = {str(row.date): row.referrals_count for row in referrals_by_day.all()}
# Выплаты по дням
earnings_by_day = await db.execute(
select(
func.date(ReferralEarning.created_at).label("date"),
func.sum(ReferralEarning.amount_kopeks).label("earnings"),
)
.where(ReferralEarning.created_at >= start_date)
.group_by(func.date(ReferralEarning.created_at))
)
earnings_dict = {str(row.date): int(row.earnings or 0) for row in earnings_by_day.all()}
result = []
for i in range(days):
date = (start_date + timedelta(days=i)).date()
date_str = str(date)
result.append({
"date": date_str,
"referrals_count": referrals_dict.get(date_str, 0),
"earnings_kopeks": earnings_dict.get(date_str, 0),
})
return result
@classmethod
async def get_top_referrers(
cls,
db: AsyncSession,
limit: int = 10,
days: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""Получить топ рефереров."""
now = datetime.utcnow()
start_date = now - timedelta(days=days) if days else None
# Подсчёт рефералов и заработков
earnings_query = (
select(
ReferralEarning.user_id,
func.sum(ReferralEarning.amount_kopeks).label("total_earnings"),
)
.group_by(ReferralEarning.user_id)
)
if start_date:
earnings_query = earnings_query.where(ReferralEarning.created_at >= start_date)
earnings_result = await db.execute(earnings_query)
earnings_dict = {row.user_id: int(row.total_earnings or 0) for row in earnings_result.all()}
# Подсчёт рефералов
referrals_query = (
select(
User.referred_by_id,
func.count(User.id).label("referrals_count"),
)
.where(User.referred_by_id.isnot(None))
.group_by(User.referred_by_id)
)
if start_date:
referrals_query = referrals_query.where(User.created_at >= start_date)
referrals_result = await db.execute(referrals_query)
referrals_dict = {row.referred_by_id: row.referrals_count for row in referrals_result.all()}
# Объединяем данные
all_referrer_ids = set(earnings_dict.keys()) | set(referrals_dict.keys())
referrers_data = []
for referrer_id in all_referrer_ids:
referrers_data.append({
"user_id": referrer_id,
"referrals_count": referrals_dict.get(referrer_id, 0),
"total_earnings": earnings_dict.get(referrer_id, 0),
})
# Сортируем по заработку
referrers_data.sort(key=lambda x: x["total_earnings"], reverse=True)
top_referrers = referrers_data[:limit]
if not top_referrers:
return []
# Получаем данные всех пользователей одним запросом
top_user_ids = [data["user_id"] for data in top_referrers]
users_result = await db.execute(
select(User).where(User.id.in_(top_user_ids))
)
users_dict = {user.id: user for user in users_result.scalars().all()}
# Формируем результат с сохранением порядка сортировки
result = []
for data in top_referrers:
user = users_dict.get(data["user_id"])
if user:
result.append({
"id": user.id,
"telegram_id": user.telegram_id,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"full_name": f"{user.first_name or ''} {user.last_name or ''}".strip() or f"User {user.telegram_id}",
"referral_code": user.referral_code,
"referrals_count": data["referrals_count"],
"total_earnings_kopeks": data["total_earnings"],
})
return result
@classmethod
async def _get_earnings_for_period(
cls,
db: AsyncSession,
user_id: int,
start_date: Optional[datetime],
end_date: Optional[datetime] = None,
) -> int:
"""Получить заработки за период."""
query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
ReferralEarning.user_id == user_id
)
if start_date:
query = query.where(ReferralEarning.created_at >= start_date)
if end_date:
query = query.where(ReferralEarning.created_at < end_date)
result = await db.execute(query)
return int(result.scalar() or 0)
@classmethod
async def _get_total_earnings(
cls,
db: AsyncSession,
start_date: Optional[datetime],
) -> int:
"""Получить общие выплаты за период."""
query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
if start_date:
query = query.where(ReferralEarning.created_at >= start_date)
result = await db.execute(query)
return int(result.scalar() or 0)
+43
View File
@@ -952,6 +952,17 @@ class YooKassaPaymentMixin:
payment.amount_kopeks / 100,
)
# Создаем чек через NaloGO для всех платежей
if hasattr(self, "nalogo_service") and self.nalogo_service:
try:
from app.services.support_settings_service import SupportSettingsService
if SupportSettingsService.is_nalogo_receipts_enabled():
await self._create_nalogo_receipt(payment)
except Exception as error:
logger.error("Ошибка проверки настройки NALOGO_RECEIPTS_ENABLED: %s", error)
if settings.NALOGO_RECEIPTS_ENABLED:
await self._create_nalogo_receipt(payment)
return True
except Exception as error:
@@ -1002,6 +1013,38 @@ class YooKassaPaymentMixin:
return updated_metadata
async def _create_nalogo_receipt(
self,
payment: "YooKassaPayment",
) -> None:
"""Создание чека через NaloGO для успешного платежа."""
if not hasattr(self, "nalogo_service") or not self.nalogo_service:
logger.debug("NaloGO сервис не инициализирован, чек не создан")
return
try:
amount_rubles = payment.amount_kopeks / 100
receipt_name = "Интернет-сервис - Пополнение баланса"
receipt_uuid = await self.nalogo_service.create_receipt(
name=receipt_name,
amount=amount_rubles,
quantity=1
)
if receipt_uuid:
logger.info(f"Чек NaloGO создан для платежа {payment.yookassa_payment_id}: {receipt_uuid}")
else:
logger.warning(f"Не удалось создать чек NaloGO для платежа {payment.yookassa_payment_id}")
except Exception as error:
logger.error(
"Ошибка создания чека NaloGO для платежа %s: %s",
payment.yookassa_payment_id,
error,
exc_info=True,
)
async def process_yookassa_webhook(
self,
db: AsyncSession,
+2
View File
@@ -30,6 +30,7 @@ from app.services.payment import (
)
from app.services.yookassa_service import YooKassaService
from app.services.wata_service import WataService
from app.services.nalogo_service import NaloGoService
logger = logging.getLogger(__name__)
@@ -300,6 +301,7 @@ class PaymentService(
PlategaService() if settings.is_platega_enabled() else None
)
self.wata_service = WataService() if settings.is_wata_enabled() else None
self.nalogo_service = NaloGoService() if settings.is_nalogo_enabled() else None
mulenpay_name = settings.get_mulenpay_display_name()
logger.debug(
+561
View File
@@ -0,0 +1,561 @@
import asyncio
import logging
from datetime import datetime, date, time, timedelta, timezone
from typing import Optional, Sequence, Tuple
from zoneinfo import ZoneInfo
from aiogram import Bot
from aiogram.exceptions import TelegramForbiddenError, TelegramNotFound
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.referral_contest import (
add_contest_event,
get_contest_events_count,
get_contest_leaderboard,
get_contests_for_events,
get_contests_for_summaries,
get_referrer_score,
mark_daily_summary_sent,
mark_final_summary_sent,
)
from app.database.crud.user import get_user_by_id
from app.database.database import AsyncSessionLocal
from app.database.models import ReferralContest, User
logger = logging.getLogger(__name__)
class ReferralContestService:
def __init__(self) -> None:
self.bot: Optional[Bot] = None
self._task: Optional[asyncio.Task] = None
self._poll_interval_seconds = 60
def set_bot(self, bot: Bot) -> None:
self.bot = bot
def is_running(self) -> bool:
return self._task is not None and not self._task.done()
async def start(self) -> None:
await self.stop()
if not settings.is_contests_enabled():
logger.info("Сервис конкурсов отключен настройками")
return
if not self.bot:
logger.warning("Невозможно запустить сервис конкурсов без экземпляра бота")
return
self._task = asyncio.create_task(self._run_loop())
logger.info("🏆 Сервис конкурсов запущен")
async def stop(self) -> None:
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
async def _run_loop(self) -> None:
try:
while True:
try:
await self._process_summaries()
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
logger.error("Ошибка сервиса конкурсов: %s", exc)
await asyncio.sleep(self._poll_interval_seconds)
except asyncio.CancelledError:
logger.info("Сервис конкурсов остановлен")
raise
async def _process_summaries(self) -> None:
if not self.bot:
return
async with AsyncSessionLocal() as db:
contests = await get_contests_for_summaries(db)
now_utc = datetime.utcnow()
for contest in contests:
try:
await self._maybe_send_daily_summary(db, contest, now_utc)
await self._maybe_send_final_summary(db, contest, now_utc)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
logger.error(
"Ошибка обработки конкурса %s (%s): %s",
contest.id,
contest.title,
exc,
)
async def _maybe_send_daily_summary(
self,
db: AsyncSession,
contest: ReferralContest,
now_utc: datetime,
) -> None:
tz = self._get_timezone(contest)
now_local = now_utc.replace(tzinfo=timezone.utc).astimezone(tz)
start_local = self._to_local(contest.start_at, tz)
end_local = self._to_local(contest.end_at, tz)
if now_local.date() < start_local.date() or now_local.date() > end_local.date():
return
summary_times = self._get_summary_times(contest)
for summary_time in summary_times:
summary_dt = datetime.combine(now_local.date(), summary_time, tzinfo=tz)
summary_dt_utc = summary_dt.astimezone(timezone.utc).replace(tzinfo=None)
if now_utc < summary_dt_utc:
continue
last_sent = contest.last_daily_summary_at
if last_sent and last_sent >= summary_dt_utc:
continue
await self._send_summary(
db,
contest,
now_utc,
now_local.date(),
is_final=False,
summary_dt_utc=summary_dt_utc,
)
async def _maybe_send_final_summary(
self,
db: AsyncSession,
contest: ReferralContest,
now_utc: datetime,
) -> None:
if contest.final_summary_sent:
return
tz = self._get_timezone(contest)
end_local = self._to_local(contest.end_at, tz)
summary_times = self._get_summary_times(contest)
summary_time = summary_times[-1] if summary_times else time(hour=12, minute=0)
summary_dt = datetime.combine(end_local.date(), summary_time, tzinfo=tz)
summary_dt_utc = summary_dt.astimezone(timezone.utc).replace(tzinfo=None)
if now_utc < contest.end_at:
return
if now_utc < summary_dt_utc:
return
await self._send_summary(db, contest, now_utc, end_local.date(), is_final=True)
async def _send_summary(
self,
db: AsyncSession,
contest: ReferralContest,
now_utc: datetime,
target_date: date,
*,
is_final: bool,
summary_dt_utc: Optional[datetime] = None,
) -> None:
tz = self._get_timezone(contest)
day_start_local = datetime.combine(target_date, time.min, tzinfo=tz)
day_end_local = day_start_local + timedelta(days=1)
day_start_utc = day_start_local.astimezone(timezone.utc).replace(tzinfo=None)
day_end_utc = day_end_local.astimezone(timezone.utc).replace(tzinfo=None)
leaderboard = list(await get_contest_leaderboard(db, contest.id))
total_events = await get_contest_events_count(db, contest.id)
today_events = await get_contest_events_count(
db,
contest.id,
start=day_start_utc,
end=day_end_utc,
)
await self._notify_admins(
contest=contest,
leaderboard=leaderboard,
total_events=total_events,
today_events=today_events,
is_final=is_final,
tz=tz,
)
await self._notify_public_channel(
contest=contest,
leaderboard=leaderboard,
total_events=total_events,
today_events=today_events,
is_final=is_final,
tz=tz,
)
if not leaderboard:
logger.info("Конкурс %s: пока нет участников", contest.id)
if is_final:
await mark_final_summary_sent(db, contest)
else:
await mark_daily_summary_sent(db, contest, target_date, summary_dt_utc)
async def _notify_participants(
self,
db: AsyncSession,
*,
contest: ReferralContest,
leaderboard: Sequence[Tuple[User, int, int]],
total_events: int,
today_events: int,
day_start_utc: datetime,
day_end_utc: datetime,
is_final: bool,
) -> None:
if not self.bot:
return
# leaderboard already sorted by helper
score_map = {user.id: (idx + 1, score) for idx, (user, score, _) in enumerate(leaderboard)}
for user, score, _ in leaderboard:
rank = score_map.get(user.id, (None, score))[0]
today_score = await get_referrer_score(
db=db,
contest_id=contest.id,
referrer_id=user.id,
start=day_start_utc,
end=day_end_utc,
) if score else 0
text = self._build_participant_message(
contest=contest,
rank=rank or 0,
score=score,
today_score=today_score,
total_events=total_events,
today_events=today_events,
is_final=is_final,
)
try:
await self.bot.send_message(user.telegram_id, text, disable_web_page_preview=True)
except (TelegramForbiddenError, TelegramNotFound):
logger.info(
"Не удалось отправить сообщение участнику %s (вероятно, блокировка)",
user.telegram_id,
)
except Exception as exc: # noqa: BLE001
logger.error("Ошибка отправки участнику конкурса %s: %s", user.telegram_id, exc)
async def _notify_admins(
self,
*,
contest: ReferralContest,
leaderboard: Sequence[Tuple[User, int, int]],
total_events: int,
today_events: int,
is_final: bool,
tz: ZoneInfo,
) -> None:
if not self.bot:
return
chat_id = settings.ADMIN_NOTIFICATIONS_CHAT_ID
if not chat_id:
return
lines = [
"🏆 <b>Конкурс рефералов</b>",
f"Название: <b>{contest.title}</b>",
f"Статус: {'финал' if is_final else 'дневная сводка'}",
f"Временная зона: <code>{tz.key}</code>",
f"Всего рефералов: <b>{total_events}</b>",
"",
"Топ участников:",
]
if leaderboard:
for idx, (user, score, _) in enumerate(leaderboard[:5], start=1):
name = user.full_name
lines.append(f"{idx}. {name} ({user.telegram_id}) — {score}")
else:
lines.append("Пока нет участников.")
if contest.prize_text:
lines.append("")
lines.append(f"Приз: {contest.prize_text}")
try:
await self.bot.send_message(
chat_id=chat_id,
text="\n".join(lines),
disable_web_page_preview=True,
message_thread_id=settings.ADMIN_NOTIFICATIONS_TOPIC_ID,
)
except Exception as exc: # noqa: BLE001
logger.error("Не удалось отправить админскую сводку конкурса: %s", exc)
async def _notify_public_channel(
self,
*,
contest: ReferralContest,
leaderboard: Sequence[Tuple[User, int, int]],
total_events: int,
today_events: int,
is_final: bool,
tz: ZoneInfo,
) -> None:
if not self.bot:
return
channel_id_raw = settings.CHANNEL_SUB_ID
if not channel_id_raw:
return
try:
channel_id = int(channel_id_raw)
except Exception:
channel_id = channel_id_raw
lines = [
f"🏆 {contest.title}",
"🏁 Итоги конкурса" if is_final else "📊 Промежуточные итоги",
f"Время зоны: {tz.key}",
f"Всего участников: <b>{len(leaderboard)}</b>",
"",
"Топ участников:",
]
if leaderboard:
for idx, (user, score, _) in enumerate(leaderboard[:5], start=1):
lines.append(f"{idx}. {user.full_name}{score}")
else:
lines.append("Пока нет участников.")
if contest.prize_text:
lines.append("")
lines.append(f"Приз: {contest.prize_text}")
try:
await self.bot.send_message(
chat_id=channel_id,
text="\n".join(lines),
disable_web_page_preview=True,
)
except (TelegramForbiddenError, TelegramNotFound):
logger.info("Не удалось отправить сводку конкурса в канал %s", channel_id_raw)
except Exception as exc: # noqa: BLE001
logger.error("Ошибка отправки сводки конкурса в канал %s: %s", channel_id_raw, exc)
def _build_participant_message(
self,
*,
contest: ReferralContest,
rank: int,
score: int,
today_score: int,
total_events: int,
today_events: int,
is_final: bool,
) -> str:
status_line = "🏁 Итоги конкурса" if is_final else "📊 Промежуточные итоги"
lines = [
f"🏆 {contest.title}",
status_line,
"",
f"Ваше место: <b>{rank}</b>",
f"Зачётов за всё время: <b>{score}</b>",
f"За сегодня: <b>{today_score}</b>",
f"Общий пул зачётов: <b>{total_events}</b> (сегодня {today_events})",
]
if contest.prize_text:
lines.append("")
lines.append(f"Призовой фонд: {contest.prize_text}")
if not is_final:
remaining = contest.end_at - datetime.now(timezone.utc)
if remaining.total_seconds() > 0:
hours_left = int(remaining.total_seconds() // 3600)
lines.append("")
lines.append(f"До окончания: ~{hours_left} ч.")
return "\n".join(lines)
async def get_detailed_contest_stats(self, db: AsyncSession, contest_id: int) -> dict:
from app.database.crud.referral_contest import get_contest_leaderboard, get_referral_contest
contest = await get_referral_contest(db, contest_id)
if not contest:
return {
'total_participants': 0,
'total_invited': 0,
'total_paid_amount': 0,
'total_unpaid': 0,
'participants': [],
}
# Get leaderboard - already includes User objects
leaderboard = await get_contest_leaderboard(db, contest_id)
if not leaderboard:
return {
'total_participants': 0,
'total_invited': 0,
'total_paid_amount': 0,
'total_unpaid': 0,
'participants': [],
}
total_participants = len(leaderboard)
total_invited = sum(score for _, score, _ in leaderboard)
total_paid_amount = sum(amount for _, _, amount in leaderboard)
total_unpaid = 0
# Build participants stats directly from leaderboard (already has User objects)
participants_stats = []
for user, score, amount in leaderboard:
participants_stats.append({
'referrer_id': user.id,
'full_name': user.full_name,
'total_referrals': score,
'paid_referrals': score,
'unpaid_referrals': 0,
'total_paid_amount': amount,
})
return {
'total_participants': total_participants,
'total_invited': total_invited,
'total_paid_amount': total_paid_amount,
'total_unpaid': total_unpaid,
'participants': participants_stats,
}
def _get_timezone(self, contest: ReferralContest) -> ZoneInfo:
tz_name = contest.timezone or settings.TIMEZONE
try:
return ZoneInfo(tz_name)
except Exception: # noqa: BLE001
logger.warning("Не удалось загрузить TZ %s, используем UTC", tz_name)
return ZoneInfo("UTC")
def _parse_times(self, times_str: Optional[str]) -> list[time]:
if not times_str:
return []
parsed: list[time] = []
for part in times_str.split(","):
part = part.strip()
if not part:
continue
try:
parsed.append(datetime.strptime(part, "%H:%M").time())
except Exception:
continue
return parsed
def _get_summary_times(self, contest: ReferralContest) -> list[time]:
times = self._parse_times(contest.daily_summary_times)
if not times and contest.daily_summary_time:
times.append(contest.daily_summary_time)
if not times:
times.append(time(hour=12, minute=0))
return sorted(times)
def _to_local(self, dt_value: datetime, tz: ZoneInfo) -> datetime:
base = dt_value
if dt_value.tzinfo is None:
base = dt_value.replace(tzinfo=timezone.utc)
return base.astimezone(tz)
async def on_subscription_payment(
self,
db: AsyncSession,
user_id: int,
amount_kopeks: int = 0,
) -> None:
if not settings.is_contests_enabled():
return
user = await get_user_by_id(db, user_id)
if not user or not user.referred_by_id:
return
now_utc = datetime.utcnow()
contests = await get_contests_for_events(
db,
now_utc,
contest_types=["referral_paid"],
)
if not contests:
return
for contest in contests:
try:
event = await add_contest_event(
db,
contest_id=contest.id,
referrer_id=user.referred_by_id,
referral_id=user.id,
amount_kopeks=amount_kopeks,
event_type="subscription_purchase",
)
if event:
logger.info(
"Записан зачёт конкурса %s: реферер %s, реферал %s",
contest.id,
user.referred_by_id,
user.id,
)
except Exception as exc: # noqa: BLE001
logger.error("Не удалось записать зачёт конкурса %s: %s", contest.id, exc)
async def on_referral_registration(
self,
db: AsyncSession,
user_id: int,
) -> None:
if not settings.is_contests_enabled():
return
user = await get_user_by_id(db, user_id)
if not user or not user.referred_by_id:
return
now_utc = datetime.utcnow()
contests = await get_contests_for_events(
db,
now_utc,
contest_types=["referral_registered"],
)
if not contests:
return
for contest in contests:
try:
event = await add_contest_event(
db,
contest_id=contest.id,
referrer_id=user.referred_by_id,
referral_id=user.id,
amount_kopeks=0,
event_type="referral_registration",
)
if event:
logger.info(
"Записан зачёт конкурса регистрации %s: реферер %s, реферал %s",
contest.id,
user.referred_by_id,
user.id,
)
except Exception as exc: # noqa: BLE001
logger.error("Не удалось записать зачёт регистрации для конкурса %s: %s", contest.id, exc)
referral_contest_service = ReferralContestService()
+7
View File
@@ -50,6 +50,13 @@ async def process_referral_registration(
reason="referral_registration_pending"
)
try:
from app.services.referral_contest_service import referral_contest_service
await referral_contest_service.on_referral_registration(db, new_user_id)
except Exception as exc:
logger.debug("Не удалось записать конкурсную регистрацию: %s", exc)
if bot:
commission_percent = get_effective_referral_commission_percent(referrer)
referral_notification = (
+2 -2
View File
@@ -1945,8 +1945,8 @@ class RemnaWaveService:
}
usage_data = await api._make_request(
'GET',
f'/api/nodes/usage/{node_uuid}/users/range',
'GET',
f'/api/bandwidth-stats/nodes/{node_uuid}/users/legacy',
params=params
)
+14
View File
@@ -219,3 +219,17 @@ class SupportSettingsService:
return cls._save()
return True
# NaloGO receipts
@classmethod
def is_nalogo_receipts_enabled(cls) -> bool:
cls._load()
if "nalogo_receipts_enabled" in cls._data:
return bool(cls._data["nalogo_receipts_enabled"])
return bool(settings.NALOGO_RECEIPTS_ENABLED)
@classmethod
def set_nalogo_receipts_enabled(cls, enabled: bool) -> bool:
cls._load()
cls._data["nalogo_receipts_enabled"] = bool(enabled)
return cls._save()
+18
View File
@@ -90,6 +90,17 @@ class AdminStates(StatesGroup):
editing_promo_group_device_discount = State()
editing_promo_group_period_discount = State()
editing_promo_group_auto_assign = State()
creating_referral_contest_title = State()
creating_referral_contest_description = State()
creating_referral_contest_prize = State()
creating_referral_contest_mode = State()
creating_referral_contest_start = State()
creating_referral_contest_end = State()
creating_referral_contest_time = State()
editing_referral_contest_summary_times = State()
editing_daily_contest_field = State()
editing_daily_contest_value = State()
editing_squad_price = State()
editing_traffic_price = State()
@@ -190,6 +201,9 @@ class SquadMigrationStates(StatesGroup):
class RemnaWaveSyncStates(StatesGroup):
waiting_for_schedule = State()
class ContestStates(StatesGroup):
waiting_for_answer = State()
class AdminSubmenuStates(StatesGroup):
in_users_submenu = State()
@@ -197,3 +211,7 @@ class AdminSubmenuStates(StatesGroup):
in_communications_submenu = State()
in_settings_submenu = State()
in_system_submenu = State()
class BlacklistStates(StatesGroup):
waiting_for_blacklist_url = State()
+17
View File
@@ -45,6 +45,23 @@ def admin_required(func: Callable) -> Callable:
return wrapper
def auth_required(func: Callable) -> Callable:
"""
Простая проверка на наличие пользователя в апдейте. Middleware уже подтягивает db_user,
но здесь страхуемся от вызовов без from_user.
"""
@functools.wraps(func)
async def wrapper(event: types.Update, *args, **kwargs) -> Any:
user = None
if isinstance(event, (types.Message, types.CallbackQuery)):
user = event.from_user
if not user:
logger.warning("auth_required: нет from_user, пропускаем")
return
return await func(event, *args, **kwargs)
return wrapper
def error_handler(func: Callable) -> Callable:
@functools.wraps(func)
+18
View File
@@ -15,6 +15,7 @@ from .routes import (
health,
main_menu_buttons,
media,
menu_layout,
miniapp,
partners,
polls,
@@ -25,7 +26,9 @@ from .routes import (
welcome_texts,
pages,
remnawave,
contests,
servers,
support_settings,
subscription_events,
stats,
subscriptions,
@@ -54,6 +57,10 @@ OPENAPI_TAGS = [
"name": "main-menu",
"description": "Управление кнопками и сообщениями главного меню Telegram-бота.",
},
{
"name": "menu-layout",
"description": "API конструктор меню: управление расположением и настройками кнопок.",
},
{
"name": "welcome-texts",
"description": "Создание, редактирование и управление приветственными текстами.",
@@ -134,6 +141,10 @@ OPENAPI_TAGS = [
"сменах промогрупп пользователей для административной панели."
),
},
{
"name": "contests",
"description": "Управление конкурсами: реферальными и ежедневными играми/раундами.",
},
]
@@ -175,15 +186,22 @@ def create_web_api_app() -> FastAPI:
app.include_router(users.router, prefix="/users", tags=["users"])
app.include_router(subscriptions.router, prefix="/subscriptions", tags=["subscriptions"])
app.include_router(tickets.router, prefix="/tickets", tags=["support"])
app.include_router(support_settings.router, prefix="/settings/support", tags=["support-settings"])
app.include_router(transactions.router, prefix="/transactions", tags=["transactions"])
app.include_router(promo_groups.router, prefix="/promo-groups", tags=["promo-groups"])
app.include_router(promo_offers.router, prefix="/promo-offers", tags=["promo-offers"])
app.include_router(servers.router, prefix="/servers", tags=["servers"])
app.include_router(contests.router, prefix="/contests", tags=["contests"])
app.include_router(
main_menu_buttons.router,
prefix="/main-menu/buttons",
tags=["main-menu"],
)
app.include_router(
menu_layout.router,
prefix="/menu-layout",
tags=["menu-layout"],
)
app.include_router(
user_messages.router,
prefix="/main-menu/messages",
+2
View File
@@ -13,6 +13,7 @@ from . import (
promo_groups,
servers,
remnawave,
contests,
subscription_events,
stats,
subscriptions,
@@ -38,6 +39,7 @@ __all__ = [
"promo_groups",
"servers",
"remnawave",
"contests",
"subscription_events",
"stats",
"subscriptions",
+699
View File
@@ -0,0 +1,699 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone, time
from typing import Any, Dict, List, Optional
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status, Response
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased, selectinload
from app.config import settings
from app.database.crud.contest import (
create_round,
finish_round,
get_active_round_by_template,
get_active_rounds,
list_templates,
update_template_fields,
get_template_by_id,
)
from app.database.crud.referral_contest import (
create_referral_contest,
get_contest_events_count,
get_contest_leaderboard,
get_referral_contest,
get_referral_contests_count,
list_referral_contests,
toggle_referral_contest,
update_referral_contest,
delete_referral_contest,
)
from app.database.models import (
ContestAttempt,
ContestRound,
ContestTemplate,
ReferralContest,
ReferralContestEvent,
User,
)
from app.services.contest_rotation_service import contest_rotation_service
from app.webapi.dependencies import get_db_session, require_api_token
from app.webapi.schemas.contests import (
ContestAttemptListResponse,
ContestAttemptResponse,
ContestAttemptUser,
ContestRoundListResponse,
ContestRoundResponse,
ContestTemplateListResponse,
ContestTemplateResponse,
ContestTemplateUpdateRequest,
ReferralContestCreateRequest,
ReferralContestDetailResponse,
ReferralContestDetailedStatsResponse,
ReferralContestEventListResponse,
ReferralContestEventResponse,
ReferralContestEventUser,
ReferralContestLeaderboardItem,
ReferralContestListResponse,
ReferralContestParticipant,
ReferralContestResponse,
ReferralContestUpdateRequest,
StartRoundRequest,
)
router = APIRouter()
# --------- Helpers ----------
def _to_utc_naive(dt: datetime, tz_name: Optional[str] = None) -> datetime:
tz = ZoneInfo(tz_name or settings.TIMEZONE)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=tz)
return dt.astimezone(timezone.utc).replace(tzinfo=None)
def _serialize_template(tpl: ContestTemplate) -> ContestTemplateResponse:
return ContestTemplateResponse(
id=tpl.id,
name=tpl.name,
slug=tpl.slug,
description=tpl.description,
prize_days=tpl.prize_days,
max_winners=tpl.max_winners,
attempts_per_user=tpl.attempts_per_user,
times_per_day=tpl.times_per_day,
schedule_times=tpl.schedule_times,
cooldown_hours=tpl.cooldown_hours,
payload=tpl.payload or {},
is_enabled=tpl.is_enabled,
created_at=tpl.created_at,
updated_at=tpl.updated_at,
)
def _serialize_round(round_obj: ContestRound) -> ContestRoundResponse:
tpl = round_obj.template
return ContestRoundResponse(
id=round_obj.id,
template_id=round_obj.template_id,
template_slug=tpl.slug if tpl else "",
template_name=tpl.name if tpl else None,
starts_at=round_obj.starts_at,
ends_at=round_obj.ends_at,
status=round_obj.status,
payload=round_obj.payload or {},
winners_count=round_obj.winners_count,
max_winners=round_obj.max_winners,
attempts_per_user=round_obj.attempts_per_user,
created_at=round_obj.created_at,
updated_at=round_obj.updated_at,
)
def _serialize_attempt(attempt: ContestAttempt, user: User) -> ContestAttemptResponse:
return ContestAttemptResponse(
id=attempt.id,
round_id=attempt.round_id,
user=ContestAttemptUser(
id=user.id,
telegram_id=user.telegram_id,
username=user.username,
full_name=user.full_name,
),
answer=attempt.answer,
is_winner=attempt.is_winner,
created_at=attempt.created_at,
)
def _serialize_referral_contest(contest: ReferralContest) -> ReferralContestResponse:
return ReferralContestResponse(
id=contest.id,
title=contest.title,
description=contest.description,
prize_text=contest.prize_text,
contest_type=contest.contest_type,
start_at=contest.start_at,
end_at=contest.end_at,
daily_summary_time=contest.daily_summary_time,
daily_summary_times=contest.daily_summary_times,
timezone=contest.timezone,
is_active=contest.is_active,
last_daily_summary_date=contest.last_daily_summary_date,
last_daily_summary_at=contest.last_daily_summary_at,
final_summary_sent=contest.final_summary_sent,
created_by=contest.created_by,
created_at=contest.created_at,
updated_at=contest.updated_at,
)
def _parse_times_str(times_str: Optional[str]) -> List[time]:
if not times_str:
return []
parsed: List[time] = []
for part in times_str.split(","):
part = part.strip()
if not part:
continue
try:
parsed.append(datetime.strptime(part, "%H:%M").time())
except Exception:
continue
return parsed
def _primary_time(times_str: Optional[str], fallback: Optional[time]) -> time:
parsed = _parse_times_str(times_str)
if parsed:
return parsed[0]
return fallback or time(hour=12)
def _serialize_leaderboard_item(row) -> ReferralContestLeaderboardItem:
user, referrals_count, total_amount = row
total_amount_kopeks = int(total_amount or 0)
return ReferralContestLeaderboardItem(
user_id=user.id,
telegram_id=user.telegram_id,
username=user.username,
full_name=user.full_name,
referrals_count=int(referrals_count or 0),
total_amount_kopeks=total_amount_kopeks,
total_amount_rubles=round(total_amount_kopeks / 100, 2),
)
def _serialize_event(
event: ReferralContestEvent,
referrer: User,
referral: User,
) -> ReferralContestEventResponse:
amount_kopeks = int(event.amount_kopeks or 0)
return ReferralContestEventResponse(
id=event.id,
contest_id=event.contest_id,
referrer=ReferralContestEventUser(
id=referrer.id,
telegram_id=referrer.telegram_id,
username=referrer.username,
full_name=referrer.full_name,
),
referral=ReferralContestEventUser(
id=referral.id,
telegram_id=referral.telegram_id,
username=referral.username,
full_name=referral.full_name,
),
event_type=event.event_type,
amount_kopeks=amount_kopeks,
amount_rubles=round(amount_kopeks / 100, 2),
occurred_at=event.occurred_at,
)
# --------- Daily contests (мини-игры) ----------
@router.get(
"/daily/templates",
response_model=ContestTemplateListResponse,
tags=["contests"],
)
async def list_daily_templates(
enabled_only: bool = Query(False, description="Показывать только включенные игры"),
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ContestTemplateListResponse:
templates = await list_templates(db, enabled_only=enabled_only)
return ContestTemplateListResponse(items=[_serialize_template(tpl) for tpl in templates])
@router.get(
"/daily/templates/{template_id}",
response_model=ContestTemplateResponse,
tags=["contests"],
)
async def get_daily_template(
template_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ContestTemplateResponse:
tpl = await get_template_by_id(db, template_id)
if not tpl:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Template not found")
return _serialize_template(tpl)
@router.patch(
"/daily/templates/{template_id}",
response_model=ContestTemplateResponse,
tags=["contests"],
)
async def update_daily_template(
template_id: int,
payload: ContestTemplateUpdateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ContestTemplateResponse:
tpl = await get_template_by_id(db, template_id)
if not tpl:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Template not found")
update_fields = payload.model_dump(exclude_none=True)
if not update_fields:
return _serialize_template(tpl)
tpl = await update_template_fields(db, tpl, **update_fields)
return _serialize_template(tpl)
@router.post(
"/daily/templates/{template_id}/start-round",
response_model=ContestRoundResponse,
status_code=status.HTTP_201_CREATED,
tags=["contests"],
)
async def start_round_now(
template_id: int,
payload: StartRoundRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ContestRoundResponse:
tpl = await get_template_by_id(db, template_id)
if not tpl:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Template not found")
if not tpl.is_enabled:
tpl = await update_template_fields(db, tpl, is_enabled=True)
existing = await get_active_round_by_template(db, tpl.id)
if existing and not payload.force:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Active round already exists for this template. Set force=true to start a new one.",
)
if existing and payload.force:
await finish_round(db, existing)
starts_at = payload.starts_at or datetime.utcnow()
ends_at = payload.ends_at
if payload.cooldown_hours and not ends_at:
ends_at = starts_at + timedelta(hours=payload.cooldown_hours)
elif not ends_at:
ends_at = starts_at + timedelta(hours=tpl.cooldown_hours)
starts_at = _to_utc_naive(starts_at, settings.TIMEZONE)
ends_at = _to_utc_naive(ends_at, settings.TIMEZONE)
round_payload: Dict[str, Any] = payload.payload or contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
round_obj = await create_round(
db,
template=tpl,
starts_at=starts_at,
ends_at=ends_at,
payload=round_payload,
)
round_obj.template = tpl
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
tpl,
starts_at,
ends_at,
)
return _serialize_round(round_obj)
@router.get(
"/daily/rounds",
response_model=ContestRoundListResponse,
tags=["contests"],
)
async def list_rounds(
status_filter: str = Query("active", regex="^(active|finished|any)$"),
template_id: Optional[int] = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ContestRoundListResponse:
if status_filter == "active":
rounds = await get_active_rounds(db)
if template_id:
rounds = [r for r in rounds if r.template_id == template_id]
total = len(rounds)
items = rounds[offset : offset + limit]
return ContestRoundListResponse(
items=[_serialize_round(r) for r in items],
total=total,
limit=limit,
offset=offset,
)
query = (
select(ContestRound)
.options(selectinload(ContestRound.template))
.order_by(ContestRound.starts_at.desc())
)
count_query = select(func.count(ContestRound.id))
if status_filter != "any":
query = query.where(ContestRound.status == status_filter)
count_query = count_query.where(ContestRound.status == status_filter)
if template_id:
query = query.where(ContestRound.template_id == template_id)
count_query = count_query.where(ContestRound.template_id == template_id)
total = await db.scalar(count_query) or 0
result = await db.execute(query.offset(offset).limit(limit))
rounds = result.scalars().unique().all()
return ContestRoundListResponse(
items=[_serialize_round(r) for r in rounds],
total=int(total),
limit=limit,
offset=offset,
)
@router.get(
"/daily/rounds/{round_id}",
response_model=ContestRoundResponse,
tags=["contests"],
)
async def get_round(
round_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ContestRoundResponse:
result = await db.execute(
select(ContestRound)
.options(selectinload(ContestRound.template))
.where(ContestRound.id == round_id)
)
round_obj = result.scalar_one_or_none()
if not round_obj:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Round not found")
return _serialize_round(round_obj)
@router.post(
"/daily/rounds/{round_id}/finish",
response_model=ContestRoundResponse,
tags=["contests"],
)
async def finish_round_now(
round_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ContestRoundResponse:
result = await db.execute(
select(ContestRound)
.options(selectinload(ContestRound.template))
.where(ContestRound.id == round_id)
)
round_obj = result.scalar_one_or_none()
if not round_obj:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Round not found")
if round_obj.status != "finished":
round_obj = await finish_round(db, round_obj)
return _serialize_round(round_obj)
@router.get(
"/daily/rounds/{round_id}/attempts",
response_model=ContestAttemptListResponse,
tags=["contests"],
)
async def list_attempts(
round_id: int,
winners_only: bool = Query(False, description="Вернуть только победителей"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ContestAttemptListResponse:
conditions = [ContestAttempt.round_id == round_id]
if winners_only:
conditions.append(ContestAttempt.is_winner.is_(True))
total = await db.scalar(
select(func.count(ContestAttempt.id)).where(and_(*conditions))
) or 0
query = (
select(ContestAttempt, User)
.join(User, User.id == ContestAttempt.user_id)
.where(and_(*conditions))
.order_by(ContestAttempt.created_at)
.offset(offset)
.limit(limit)
)
result = await db.execute(query)
rows = result.all()
return ContestAttemptListResponse(
items=[_serialize_attempt(attempt, user) for attempt, user in rows],
total=int(total),
limit=limit,
offset=offset,
)
# --------- Referral contests ----------
@router.get(
"/referral",
response_model=ReferralContestListResponse,
tags=["contests"],
)
async def list_referral(
contest_type: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ReferralContestListResponse:
contests = await list_referral_contests(
db,
limit=limit,
offset=offset,
contest_type=contest_type,
)
total = await get_referral_contests_count(db, contest_type=contest_type)
return ReferralContestListResponse(
items=[_serialize_referral_contest(c) for c in contests],
total=int(total),
limit=limit,
offset=offset,
)
@router.post(
"/contests/referral",
response_model=ReferralContestResponse,
status_code=status.HTTP_201_CREATED,
tags=["contests"],
)
async def create_referral(
payload: ReferralContestCreateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ReferralContestResponse:
start_at = _to_utc_naive(payload.start_at, payload.timezone)
end_at = _to_utc_naive(payload.end_at, payload.timezone)
if end_at <= start_at:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "end_at must be after start_at")
summary_time = _primary_time(payload.daily_summary_times, payload.daily_summary_time)
contest = await create_referral_contest(
db,
title=payload.title,
description=payload.description,
prize_text=payload.prize_text,
contest_type=payload.contest_type,
start_at=start_at,
end_at=end_at,
daily_summary_time=summary_time,
daily_summary_times=payload.daily_summary_times,
timezone_name=payload.timezone,
created_by=payload.created_by,
)
if payload.is_active is False:
contest = await toggle_referral_contest(db, contest, False)
return _serialize_referral_contest(contest)
@router.get(
"/referral/{contest_id}",
response_model=ReferralContestDetailResponse,
tags=["contests"],
)
async def get_referral(
contest_id: int,
leaderboard_limit: int = Query(5, ge=1, le=50),
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ReferralContestDetailResponse:
contest = await get_referral_contest(db, contest_id)
if not contest:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found")
total_events = await get_contest_events_count(db, contest.id)
leaderboard_rows = await get_contest_leaderboard(db, contest.id, limit=leaderboard_limit)
leaderboard = [_serialize_leaderboard_item(row) for row in leaderboard_rows]
return ReferralContestDetailResponse(
**_serialize_referral_contest(contest).model_dump(),
total_events=int(total_events),
leaderboard=leaderboard,
)
@router.patch(
"/referral/{contest_id}",
response_model=ReferralContestResponse,
tags=["contests"],
)
async def update_referral(
contest_id: int,
payload: ReferralContestUpdateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ReferralContestResponse:
contest = await get_referral_contest(db, contest_id)
if not contest:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found")
fields = payload.model_dump(exclude_none=True)
if "start_at" in fields:
fields["start_at"] = _to_utc_naive(fields["start_at"], fields.get("timezone") or contest.timezone)
if "end_at" in fields:
fields["end_at"] = _to_utc_naive(fields["end_at"], fields.get("timezone") or contest.timezone)
if "daily_summary_times" in fields:
fields["daily_summary_time"] = _primary_time(fields["daily_summary_times"], fields.get("daily_summary_time") or contest.daily_summary_time)
elif "daily_summary_time" in fields:
# ensure type is time (pydantic provides time)
pass
new_start = fields.get("start_at", contest.start_at)
new_end = fields.get("end_at", contest.end_at)
if new_end <= new_start:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "end_at must be after start_at")
if fields:
contest = await update_referral_contest(db, contest, **fields)
return _serialize_referral_contest(contest)
@router.post(
"/referral/{contest_id}/toggle",
response_model=ReferralContestResponse,
tags=["contests"],
)
async def toggle_referral(
contest_id: int,
is_active: bool = Query(..., description="Активировать или остановить конкурс"),
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ReferralContestResponse:
contest = await get_referral_contest(db, contest_id)
if not contest:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found")
contest = await toggle_referral_contest(db, contest, is_active)
return _serialize_referral_contest(contest)
@router.delete(
"/referral/{contest_id}",
status_code=status.HTTP_200_OK,
tags=["contests"],
)
async def delete_referral(
contest_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> Dict[str, str]:
contest = await get_referral_contest(db, contest_id)
if not contest:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found")
now_utc = datetime.utcnow()
if contest.is_active or contest.end_at > now_utc:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Можно удалять только завершённые конкурсы",
)
await delete_referral_contest(db, contest)
return {"status": "deleted"}
@router.get(
"/referral/{contest_id}/events",
response_model=ReferralContestEventListResponse,
tags=["contests"],
)
async def list_referral_events(
contest_id: int,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ReferralContestEventListResponse:
contest = await get_referral_contest(db, contest_id)
if not contest:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found")
referrer_user = aliased(User)
referral_user = aliased(User)
base_conditions = [ReferralContestEvent.contest_id == contest_id]
total = await db.scalar(
select(func.count(ReferralContestEvent.id)).where(and_(*base_conditions))
) or 0
query = (
select(ReferralContestEvent, referrer_user, referral_user)
.join(referrer_user, referrer_user.id == ReferralContestEvent.referrer_id)
.join(referral_user, referral_user.id == ReferralContestEvent.referral_id)
.where(and_(*base_conditions))
.order_by(ReferralContestEvent.occurred_at.desc())
.offset(offset)
.limit(limit)
)
result = await db.execute(query)
rows = result.all()
return ReferralContestEventListResponse(
items=[_serialize_event(event, referrer, referral) for event, referrer, referral in rows],
total=int(total),
limit=limit,
offset=offset,
)
@router.get(
"/referral/{contest_id}/detailed-stats",
response_model=ReferralContestDetailedStatsResponse,
tags=["contests"],
)
async def get_referral_detailed_stats(
contest_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ReferralContestDetailedStatsResponse:
contest = await get_referral_contest(db, contest_id)
if not contest:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found")
from app.services.referral_contest_service import referral_contest_service
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
return ReferralContestDetailedStatsResponse(**stats)
+991
View File
@@ -0,0 +1,991 @@
"""API эндпоинты для конструктора меню."""
from __future__ import annotations
import logging
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Response, Security, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.services.menu_layout_service import (
MenuContext,
MenuLayoutService,
)
logger = logging.getLogger(__name__)
from ..dependencies import get_db_session, require_api_token
from ..schemas.menu_layout import (
AddCustomButtonRequest,
AddRowRequest,
AvailableCallback,
AvailableCallbacksResponse,
BuiltinButtonInfo,
BuiltinButtonsListResponse,
ButtonClickStats,
ButtonClickStatsResponse,
ButtonConditions,
ButtonTypeStats,
ButtonTypeStatsResponse,
ButtonUpdateRequest,
DynamicPlaceholder,
DynamicPlaceholdersResponse,
HourlyStats,
HourlyStatsResponse,
MenuButtonConfig,
MenuClickStatsResponse,
MenuLayoutConfig,
MenuLayoutExportResponse,
MenuLayoutHistoryEntry,
MenuLayoutHistoryResponse,
MenuLayoutImportRequest,
MenuLayoutImportResponse,
MenuLayoutResponse,
MenuLayoutRollbackRequest,
MenuLayoutUpdateRequest,
MenuLayoutValidateRequest,
MenuLayoutValidateResponse,
MenuPreviewButton,
MenuPreviewRequest,
MenuPreviewResponse,
MenuPreviewRow,
MenuRowConfig,
MoveButtonResponse,
MoveButtonToRowRequest,
PeriodComparisonResponse,
ReorderButtonsInRowRequest,
TopUserStats,
TopUsersResponse,
UserClickSequence,
UserClickSequencesResponse,
WeekdayStats,
WeekdayStatsResponse,
ReorderButtonsResponse,
RowsReorderRequest,
SwapButtonsRequest,
SwapButtonsResponse,
ValidationError,
)
router = APIRouter()
def _serialize_config(config: dict, is_enabled: bool, updated_at) -> MenuLayoutResponse:
"""Сериализовать конфигурацию в response."""
rows = []
for row_data in config.get("rows", []):
rows.append(
MenuRowConfig(
id=row_data["id"],
buttons=row_data.get("buttons", []),
conditions=ButtonConditions(**row_data["conditions"])
if row_data.get("conditions")
else None,
max_per_row=row_data.get("max_per_row", 2),
)
)
buttons = {}
for btn_id, btn_data in config.get("buttons", {}).items():
buttons[btn_id] = MenuButtonConfig(
type=btn_data["type"],
builtin_id=btn_data.get("builtin_id"),
text=btn_data.get("text", {}),
icon=btn_data.get("icon"),
action=btn_data.get("action", ""),
enabled=btn_data.get("enabled", True),
visibility=btn_data.get("visibility", "all"),
conditions=ButtonConditions(**btn_data["conditions"])
if btn_data.get("conditions")
else None,
dynamic_text=btn_data.get("dynamic_text", False),
open_mode=btn_data.get("open_mode", "callback"),
webapp_url=btn_data.get("webapp_url"),
description=btn_data.get("description"),
sort_order=btn_data.get("sort_order"),
)
return MenuLayoutResponse(
version=config.get("version", 1),
rows=rows,
buttons=buttons,
is_enabled=is_enabled,
updated_at=updated_at,
)
@router.get("", response_model=MenuLayoutResponse)
async def get_menu_layout(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MenuLayoutResponse:
"""Получить текущую конфигурацию меню."""
config = await MenuLayoutService.get_config(db)
updated_at = await MenuLayoutService.get_config_updated_at(db)
return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at)
@router.put("", response_model=MenuLayoutResponse)
async def update_menu_layout(
payload: MenuLayoutUpdateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MenuLayoutResponse:
"""Обновить конфигурацию меню полностью."""
config = await MenuLayoutService.get_config(db)
config = config.copy()
if payload.rows is not None:
config["rows"] = [row.model_dump() for row in payload.rows]
if payload.buttons is not None:
buttons_config = {}
for btn_id, btn in payload.buttons.items():
btn_dict = btn.model_dump()
# Автоматически определяем наличие плейсхолдеров, если dynamic_text не установлен
if not btn_dict.get("dynamic_text", False):
from app.services.menu_layout.service import MenuLayoutService
btn_dict["dynamic_text"] = MenuLayoutService._text_has_placeholders(btn_dict.get("text", {}))
buttons_config[btn_id] = btn_dict
config["buttons"] = buttons_config
await MenuLayoutService.save_config(db, config)
updated_at = await MenuLayoutService.get_config_updated_at(db)
return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at)
@router.post("/reset", response_model=MenuLayoutResponse)
async def reset_menu_layout(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MenuLayoutResponse:
"""Сбросить конфигурацию к дефолтной."""
config = await MenuLayoutService.reset_to_default(db)
updated_at = await MenuLayoutService.get_config_updated_at(db)
return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at)
@router.get("/builtin-buttons", response_model=BuiltinButtonsListResponse)
async def list_builtin_buttons(
_: Any = Security(require_api_token),
) -> BuiltinButtonsListResponse:
"""Получить список встроенных кнопок."""
items = []
for btn_info in MenuLayoutService.get_builtin_buttons_info():
items.append(
BuiltinButtonInfo(
id=btn_info["id"],
default_text=btn_info["default_text"],
callback_data=btn_info["callback_data"],
default_conditions=ButtonConditions(**btn_info["default_conditions"])
if btn_info.get("default_conditions")
else None,
supports_dynamic_text=btn_info.get("supports_dynamic_text", False),
supports_direct_open=btn_info.get("supports_direct_open", False),
)
)
return BuiltinButtonsListResponse(items=items, total=len(items))
@router.patch("/buttons/{button_id}")
async def update_button(
button_id: str,
payload: ButtonUpdateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MenuButtonConfig:
"""Обновить конфигурацию отдельной кнопки."""
try:
updates = payload.model_dump(exclude_unset=True)
# Конвертируем visibility в строку если есть
if "visibility" in updates and updates["visibility"] is not None:
if hasattr(updates["visibility"], "value"):
updates["visibility"] = updates["visibility"].value
# Конвертируем open_mode в строку если есть
if "open_mode" in updates and updates["open_mode"] is not None:
if hasattr(updates["open_mode"], "value"):
updates["open_mode"] = updates["open_mode"].value
# Конвертируем conditions - убираем None значения если это dict
if "conditions" in updates and updates["conditions"] is not None:
if isinstance(updates["conditions"], dict):
updates["conditions"] = {k: v for k, v in updates["conditions"].items() if v is not None}
elif hasattr(updates["conditions"], "model_dump"):
updates["conditions"] = updates["conditions"].model_dump(exclude_none=True)
button = await MenuLayoutService.update_button(db, button_id, updates)
return MenuButtonConfig(
type=button["type"],
builtin_id=button.get("builtin_id"),
text=button.get("text", {}),
icon=button.get("icon"),
action=button.get("action", ""),
enabled=button.get("enabled", True),
visibility=button.get("visibility", "all"),
conditions=ButtonConditions(**button["conditions"])
if button.get("conditions")
else None,
dynamic_text=button.get("dynamic_text", False),
open_mode=button.get("open_mode", "callback"),
webapp_url=button.get("webapp_url"),
description=button.get("description"),
)
except KeyError as e:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
@router.post("/rows/reorder", response_model=List[MenuRowConfig])
async def reorder_rows(
payload: RowsReorderRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> List[MenuRowConfig]:
"""Изменить порядок строк."""
try:
rows = await MenuLayoutService.reorder_rows(db, payload.ordered_ids)
return [
MenuRowConfig(
id=row["id"],
buttons=row.get("buttons", []),
conditions=ButtonConditions(**row["conditions"])
if row.get("conditions")
else None,
max_per_row=row.get("max_per_row", 2),
)
for row in rows
]
except KeyError as e:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
@router.post("/rows", response_model=MenuRowConfig, status_code=status.HTTP_201_CREATED)
async def add_row(
payload: AddRowRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MenuRowConfig:
"""Добавить новую строку."""
try:
row_config = {
"id": payload.id,
"buttons": payload.buttons,
"conditions": payload.conditions.model_dump(exclude_none=True)
if payload.conditions
else None,
"max_per_row": payload.max_per_row,
}
row = await MenuLayoutService.add_row(db, row_config, payload.position)
return MenuRowConfig(
id=row["id"],
buttons=row.get("buttons", []),
conditions=ButtonConditions(**row["conditions"])
if row.get("conditions")
else None,
max_per_row=row.get("max_per_row", 2),
)
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
@router.delete("/rows/{row_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
async def delete_row(
row_id: str,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> Response:
"""Удалить строку."""
try:
await MenuLayoutService.delete_row(db, row_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
except KeyError as e:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
@router.post(
"/buttons", response_model=MenuButtonConfig, status_code=status.HTTP_201_CREATED
)
async def add_custom_button(
payload: AddCustomButtonRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MenuButtonConfig:
"""Добавить кастомную кнопку (URL, MiniApp или callback)."""
try:
# Автоматически определяем наличие плейсхолдеров, если dynamic_text не установлен
dynamic_text = payload.dynamic_text
if not dynamic_text:
from app.services.menu_layout.service import MenuLayoutService
dynamic_text = MenuLayoutService._text_has_placeholders(payload.text)
button_config = {
"type": payload.type.value,
"text": payload.text,
"icon": payload.icon,
"action": payload.action,
"visibility": payload.visibility.value,
"conditions": payload.conditions.model_dump(exclude_none=True)
if payload.conditions
else None,
"dynamic_text": dynamic_text,
"description": payload.description,
}
button = await MenuLayoutService.add_custom_button(
db, payload.id, button_config, payload.row_id
)
return MenuButtonConfig(
type=button["type"],
builtin_id=button.get("builtin_id"),
text=button.get("text", {}),
icon=button.get("icon"),
action=button.get("action", ""),
enabled=button.get("enabled", True),
visibility=button.get("visibility", "all"),
conditions=ButtonConditions(**button["conditions"])
if button.get("conditions")
else None,
dynamic_text=button.get("dynamic_text", False),
open_mode=button.get("open_mode", "callback"),
webapp_url=button.get("webapp_url"),
description=button.get("description"),
)
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
@router.delete("/buttons/{button_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
async def delete_custom_button(
button_id: str,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> Response:
"""Удалить кастомную кнопку."""
try:
await MenuLayoutService.delete_custom_button(db, button_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
except KeyError as e:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
@router.post("/preview", response_model=MenuPreviewResponse)
async def preview_menu(
payload: MenuPreviewRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MenuPreviewResponse:
"""Предпросмотр меню для указанного контекста пользователя."""
context = MenuContext(
language=payload.language,
is_admin=payload.is_admin,
is_moderator=payload.is_moderator,
has_active_subscription=payload.has_active_subscription,
subscription_is_active=payload.subscription_is_active,
balance_kopeks=payload.balance_kopeks,
)
preview_rows = await MenuLayoutService.preview_keyboard(db, context)
rows = []
total_buttons = 0
for row_data in preview_rows:
buttons = [
MenuPreviewButton(
text=btn["text"],
action=btn["action"],
type=btn["type"],
)
for btn in row_data["buttons"]
]
total_buttons += len(buttons)
rows.append(MenuPreviewRow(buttons=buttons))
return MenuPreviewResponse(rows=rows, total_buttons=total_buttons)
# --- Эндпоинты для перемещения кнопок ---
@router.post("/buttons/{button_id}/move-up", response_model=MoveButtonResponse)
async def move_button_up(
button_id: str,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MoveButtonResponse:
"""Переместить кнопку вверх (в предыдущую строку или на позицию выше в текущей строке)."""
try:
result = await MenuLayoutService.move_button_up(db, button_id)
return MoveButtonResponse(
button_id=button_id,
new_row_index=result.get("new_row_index"),
position=result.get("new_position"),
)
except KeyError as e:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
@router.post("/buttons/{button_id}/move-down", response_model=MoveButtonResponse)
async def move_button_down(
button_id: str,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MoveButtonResponse:
"""Переместить кнопку вниз (в следующую строку или на позицию ниже в текущей строке)."""
try:
result = await MenuLayoutService.move_button_down(db, button_id)
return MoveButtonResponse(
button_id=button_id,
new_row_index=result.get("new_row_index"),
position=result.get("new_position"),
)
except KeyError as e:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
@router.post("/buttons/{button_id}/move-to-row", response_model=MoveButtonResponse)
async def move_button_to_row(
button_id: str,
payload: MoveButtonToRowRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MoveButtonResponse:
"""Переместить кнопку в указанную строку."""
try:
result = await MenuLayoutService.move_button_to_row(
db, button_id, payload.target_row_id, payload.position
)
return MoveButtonResponse(
button_id=button_id,
target_row_id=payload.target_row_id,
position=result.get("new_position"),
)
except KeyError as e:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
@router.post("/rows/{row_id}/reorder-buttons", response_model=ReorderButtonsResponse)
async def reorder_buttons_in_row(
row_id: str,
payload: ReorderButtonsInRowRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ReorderButtonsResponse:
"""Изменить порядок кнопок в строке."""
try:
result = await MenuLayoutService.reorder_buttons_in_row(
db, row_id, payload.ordered_button_ids
)
return ReorderButtonsResponse(
row_id=row_id,
buttons=result["buttons"],
)
except KeyError as e:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
@router.post("/buttons/swap", response_model=SwapButtonsResponse)
async def swap_buttons(
payload: SwapButtonsRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> SwapButtonsResponse:
"""Обменять местами две кнопки (даже из разных строк)."""
try:
result = await MenuLayoutService.swap_buttons(
db, payload.button_id_1, payload.button_id_2
)
return SwapButtonsResponse(
button_1=result["button_1"],
button_2=result["button_2"],
)
except KeyError as e:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
# --- Новые эндпоинты ---
@router.get("/available-callbacks", response_model=AvailableCallbacksResponse)
async def list_available_callbacks(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> AvailableCallbacksResponse:
"""Получить список всех доступных callback_data для создания кнопок."""
callbacks = await MenuLayoutService.get_available_callbacks(db)
items = [
AvailableCallback(
callback_data=cb["callback_data"],
name=cb["name"],
description=cb.get("description"),
category=cb["category"],
default_text=cb.get("default_text"),
default_icon=cb.get("default_icon"),
requires_subscription=cb.get("requires_subscription", False),
is_in_menu=cb.get("is_in_menu", False),
)
for cb in callbacks
]
categories = list(set(cb["category"] for cb in callbacks))
return AvailableCallbacksResponse(
items=items,
total=len(items),
categories=sorted(categories),
)
@router.get("/placeholders", response_model=DynamicPlaceholdersResponse)
async def list_dynamic_placeholders(
_: Any = Security(require_api_token),
) -> DynamicPlaceholdersResponse:
"""Получить список доступных динамических плейсхолдеров для текста кнопок."""
placeholders = MenuLayoutService.get_dynamic_placeholders()
items = [
DynamicPlaceholder(
placeholder=p["placeholder"],
description=p["description"],
example=p["example"],
category=p["category"],
)
for p in placeholders
]
return DynamicPlaceholdersResponse(items=items, total=len(items))
@router.get("/export", response_model=MenuLayoutExportResponse)
async def export_menu_layout(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MenuLayoutExportResponse:
"""Экспортировать конфигурацию меню."""
from datetime import datetime
export_data = await MenuLayoutService.export_config(db)
rows = []
for row_data in export_data.get("rows", []):
rows.append(
MenuRowConfig(
id=row_data["id"],
buttons=row_data.get("buttons", []),
conditions=ButtonConditions(**row_data["conditions"])
if row_data.get("conditions")
else None,
max_per_row=row_data.get("max_per_row", 2),
)
)
buttons = {}
for btn_id, btn_data in export_data.get("buttons", {}).items():
buttons[btn_id] = MenuButtonConfig(
type=btn_data["type"],
builtin_id=btn_data.get("builtin_id"),
text=btn_data.get("text", {}),
icon=btn_data.get("icon"),
action=btn_data.get("action", ""),
enabled=btn_data.get("enabled", True),
visibility=btn_data.get("visibility", "all"),
conditions=ButtonConditions(**btn_data["conditions"])
if btn_data.get("conditions")
else None,
dynamic_text=btn_data.get("dynamic_text", False),
open_mode=btn_data.get("open_mode", "callback"),
webapp_url=btn_data.get("webapp_url"),
description=btn_data.get("description"),
)
return MenuLayoutExportResponse(
version=export_data.get("version", 1),
rows=rows,
buttons=buttons,
exported_at=datetime.utcnow(),
)
@router.post("/import", response_model=MenuLayoutImportResponse)
async def import_menu_layout(
payload: MenuLayoutImportRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MenuLayoutImportResponse:
"""Импортировать конфигурацию меню."""
import_data = {
"version": payload.version,
"rows": [row.model_dump() for row in payload.rows],
"buttons": {btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items()},
}
result = await MenuLayoutService.import_config(db, import_data, payload.merge_mode)
return MenuLayoutImportResponse(
success=result["success"],
imported_rows=result["imported_rows"],
imported_buttons=result["imported_buttons"],
warnings=result["warnings"],
)
@router.post("/validate", response_model=MenuLayoutValidateResponse)
async def validate_menu_layout(
payload: MenuLayoutValidateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MenuLayoutValidateResponse:
"""Валидировать конфигурацию меню без сохранения."""
# Если данные не переданы, валидируем текущую конфигурацию
if payload.rows is None and payload.buttons is None:
config = await MenuLayoutService.get_config(db)
else:
config = {
"rows": [row.model_dump() for row in payload.rows] if payload.rows else [],
"buttons": {btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items()}
if payload.buttons
else {},
}
result = MenuLayoutService.validate_config(config)
return MenuLayoutValidateResponse(
is_valid=result["is_valid"],
errors=[
ValidationError(
field=e["field"],
message=e["message"],
severity=e["severity"],
)
for e in result["errors"]
],
warnings=[
ValidationError(
field=w["field"],
message=w["message"],
severity=w["severity"],
)
for w in result["warnings"]
],
)
# --- Эндпоинты истории изменений ---
@router.get("/history", response_model=MenuLayoutHistoryResponse)
async def get_menu_layout_history(
limit: int = 50,
offset: int = 0,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MenuLayoutHistoryResponse:
"""Получить историю изменений меню."""
entries = await MenuLayoutService.get_history(db, limit, offset)
total = await MenuLayoutService.get_history_count(db)
return MenuLayoutHistoryResponse(
items=[
MenuLayoutHistoryEntry(
id=entry["id"],
created_at=entry["created_at"],
action=entry["action"],
changes_summary=entry["changes_summary"] or "",
user_info=entry["user_info"],
)
for entry in entries
],
total=total,
)
@router.get("/history/{history_id}")
async def get_history_entry(
history_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> dict:
"""Получить конкретную запись истории с полной конфигурацией."""
entry = await MenuLayoutService.get_history_entry(db, history_id)
if not entry:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"History entry {history_id} not found")
return {
"id": entry["id"],
"action": entry["action"],
"changes_summary": entry["changes_summary"],
"user_info": entry["user_info"],
"created_at": entry["created_at"].isoformat() if entry["created_at"] else None,
"config": entry["config"],
}
@router.post("/history/{history_id}/rollback", response_model=MenuLayoutResponse)
async def rollback_to_history(
history_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MenuLayoutResponse:
"""Откатить конфигурацию к записи из истории."""
try:
config = await MenuLayoutService.rollback_to_history(db, history_id)
updated_at = await MenuLayoutService.get_config_updated_at(db)
return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at)
except KeyError as e:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
# --- Эндпоинты статистики кликов ---
@router.get("/stats", response_model=MenuClickStatsResponse)
async def get_menu_click_stats(
days: int = 30,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MenuClickStatsResponse:
"""Получить общую статистику кликов по всем кнопкам."""
from datetime import datetime, timedelta
stats = await MenuLayoutService.get_all_buttons_stats(db, days)
total_clicks = await MenuLayoutService.get_total_clicks(db, days)
now = datetime.utcnow()
period_start = now - timedelta(days=days)
return MenuClickStatsResponse(
items=[
ButtonClickStats(
button_id=s["button_id"],
clicks_total=s["clicks_total"],
clicks_today=s.get("clicks_today", 0),
clicks_week=s.get("clicks_week", 0),
clicks_month=s.get("clicks_month", 0),
unique_users=s["unique_users"],
last_click_at=s["last_click_at"],
)
for s in stats
],
total_clicks=total_clicks,
period_start=period_start,
period_end=now,
)
@router.get("/stats/buttons/{button_id}", response_model=ButtonClickStatsResponse)
async def get_button_click_stats(
button_id: str,
days: int = 30,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ButtonClickStatsResponse:
"""Получить статистику кликов по конкретной кнопке."""
stats = await MenuLayoutService.get_button_stats(db, button_id, days)
clicks_by_day = await MenuLayoutService.get_button_clicks_by_day(db, button_id, days)
return ButtonClickStatsResponse(
button_id=button_id,
stats=ButtonClickStats(
button_id=stats["button_id"],
clicks_total=stats["clicks_total"],
clicks_today=stats["clicks_today"],
clicks_week=stats["clicks_week"],
clicks_month=stats["clicks_month"],
unique_users=stats["unique_users"],
last_click_at=stats["last_click_at"],
),
clicks_by_day=clicks_by_day,
)
@router.post("/stats/log-click")
async def log_button_click(
button_id: str,
user_id: Optional[int] = None,
callback_data: Optional[str] = None,
button_type: Optional[str] = None,
button_text: Optional[str] = None,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> dict:
"""Записать клик по кнопке (для внешней интеграции)."""
await MenuLayoutService.log_button_click(
db,
button_id=button_id,
user_id=user_id,
callback_data=callback_data,
button_type=button_type,
button_text=button_text,
)
return {"success": True}
@router.get("/stats/by-type", response_model=ButtonTypeStatsResponse)
async def get_stats_by_button_type(
days: int = 30,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ButtonTypeStatsResponse:
"""Получить статистику кликов по типам кнопок (builtin, callback, url, mini_app)."""
try:
stats = await MenuLayoutService.get_stats_by_button_type(db, days)
total_clicks = sum(s["clicks_total"] for s in stats)
return ButtonTypeStatsResponse(
items=[
ButtonTypeStats(
button_type=s["button_type"],
clicks_total=s["clicks_total"],
unique_users=s["unique_users"],
)
for s in stats
],
total_clicks=total_clicks,
)
except Exception as e:
logger.error(f"Error getting stats by type: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@router.get("/stats/by-hour", response_model=HourlyStatsResponse)
async def get_clicks_by_hour(
button_id: Optional[str] = None,
days: int = 30,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> HourlyStatsResponse:
"""Получить статистику кликов по часам дня (0-23)."""
stats = await MenuLayoutService.get_clicks_by_hour(db, button_id, days)
return HourlyStatsResponse(
items=[
HourlyStats(hour=s["hour"], count=s["count"])
for s in stats
],
button_id=button_id,
)
@router.get("/stats/by-weekday", response_model=WeekdayStatsResponse)
async def get_clicks_by_weekday(
button_id: Optional[str] = None,
days: int = 30,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> WeekdayStatsResponse:
"""Получить статистику кликов по дням недели."""
stats = await MenuLayoutService.get_clicks_by_weekday(db, button_id, days)
return WeekdayStatsResponse(
items=[
WeekdayStats(
weekday=s["weekday"],
weekday_name=s["weekday_name"],
count=s["count"]
)
for s in stats
],
button_id=button_id,
)
@router.get("/stats/top-users", response_model=TopUsersResponse)
async def get_top_users(
button_id: Optional[str] = None,
limit: int = 10,
days: int = 30,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TopUsersResponse:
"""Получить топ пользователей по количеству кликов."""
try:
stats = await MenuLayoutService.get_top_users(db, button_id, limit, days)
return TopUsersResponse(
items=[
TopUserStats(
user_id=s["user_id"],
clicks_count=s["clicks_count"],
last_click_at=s["last_click_at"],
)
for s in stats
],
button_id=button_id,
limit=limit,
)
except Exception as e:
logger.error(f"Error getting top users: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@router.get("/stats/compare", response_model=PeriodComparisonResponse)
async def get_period_comparison(
button_id: Optional[str] = None,
current_days: int = 7,
previous_days: int = 7,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PeriodComparisonResponse:
"""Сравнить статистику текущего и предыдущего периода."""
try:
comparison = await MenuLayoutService.get_period_comparison(
db, button_id, current_days, previous_days
)
logger.debug(f"Period comparison: button_id={button_id}, current_days={current_days}, previous_days={previous_days}, trend={comparison.get('change', {}).get('trend')}")
return PeriodComparisonResponse(
current_period=comparison["current_period"],
previous_period=comparison["previous_period"],
change=comparison["change"],
button_id=button_id,
)
except Exception as e:
logger.error(f"Error getting period comparison: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@router.get("/stats/users/{user_id}/sequences", response_model=UserClickSequencesResponse)
async def get_user_click_sequences(
user_id: int,
limit: int = 50,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> UserClickSequencesResponse:
"""Получить последовательности кликов пользователя."""
try:
sequences = await MenuLayoutService.get_user_click_sequences(db, user_id, limit)
logger.debug(f"User sequences: user_id={user_id}, limit={limit}, found={len(sequences)} sequences")
return UserClickSequencesResponse(
user_id=user_id,
items=[
UserClickSequence(
button_id=s["button_id"],
button_text=s["button_text"],
clicked_at=s["clicked_at"],
)
for s in sequences
],
total=len(sequences),
)
except Exception as e:
logger.error(f"Error getting user sequences: user_id={user_id}, error={e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
+178 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import logging
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
@@ -14,6 +15,7 @@ from app.database.crud.user import (
update_user,
)
from app.database.models import User
from app.services.partner_stats_service import PartnerStatsService
from app.utils.user_utils import (
get_detailed_referral_list,
get_effective_referral_commission_percent,
@@ -21,14 +23,34 @@ from app.utils.user_utils import (
from ..dependencies import get_db_session, require_api_token
from ..schemas.partners import (
ChangeData,
DailyStats,
DailyStatsResponse,
EarningsByPeriod,
GlobalPartnerStats,
GlobalPartnerSummary,
NewReferralsByPeriod,
PartnerReferralCommissionUpdate,
PartnerReferralItem,
PartnerReferralList,
PartnerReferralCommissionUpdate,
PartnerReferrerDetail,
PartnerReferrerItem,
PartnerReferrerListResponse,
PayoutsByPeriod,
PeriodChange,
PeriodComparisonResponse,
PeriodData,
ReferralsCountByPeriod,
ReferrerDetailedStats,
ReferrerSummary,
TopReferralItem,
TopReferralsResponse,
TopReferrerItem,
TopReferrersResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -197,3 +219,158 @@ async def update_referrer_commission(
stats = await get_user_referral_stats(db, user.id)
return _serialize_referrer(user, stats)
# ============================================================================
# РАСШИРЕННАЯ СТАТИСТИКА ПАРТНЁРОВ
# ============================================================================
@router.get("/stats", response_model=GlobalPartnerStats)
async def get_global_partner_stats(
days: int = Query(30, ge=1, le=365),
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> GlobalPartnerStats:
"""Глобальная статистика партнёрской программы."""
data = await PartnerStatsService.get_global_partner_stats(db, days)
return GlobalPartnerStats(
summary=GlobalPartnerSummary(**data["summary"]),
payouts=PayoutsByPeriod(**data["payouts"]),
new_referrals=NewReferralsByPeriod(**data["new_referrals"]),
)
@router.get("/stats/daily", response_model=DailyStatsResponse)
async def get_global_daily_stats(
days: int = Query(30, ge=1, le=365),
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> DailyStatsResponse:
"""Глобальная статистика по дням."""
data = await PartnerStatsService.get_global_daily_stats(db, days)
return DailyStatsResponse(
items=[DailyStats(**item) for item in data],
days=days,
user_id=None,
)
@router.get("/stats/top-referrers", response_model=TopReferrersResponse)
async def get_top_referrers(
limit: int = Query(10, ge=1, le=100),
days: Optional[int] = Query(None, ge=1, le=365),
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TopReferrersResponse:
"""Топ рефереров по заработку."""
data = await PartnerStatsService.get_top_referrers(db, limit, days)
return TopReferrersResponse(
items=[TopReferrerItem(**item) for item in data],
days=days,
)
@router.get("/referrers/{user_id}/stats", response_model=ReferrerDetailedStats)
async def get_referrer_detailed_stats(
user_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ReferrerDetailedStats:
"""Детальная статистика реферера."""
user = await get_user_by_telegram_id(db, user_id)
if not user:
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
data = await PartnerStatsService.get_referrer_detailed_stats(db, user.id)
return ReferrerDetailedStats(
user_id=data["user_id"],
summary=ReferrerSummary(**data["summary"]),
earnings=EarningsByPeriod(**data["earnings"]),
referrals_count=ReferralsCountByPeriod(**data["referrals_count"]),
)
@router.get("/referrers/{user_id}/stats/daily", response_model=DailyStatsResponse)
async def get_referrer_daily_stats(
user_id: int,
days: int = Query(30, ge=1, le=365),
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> DailyStatsResponse:
"""Статистика реферера по дням."""
user = await get_user_by_telegram_id(db, user_id)
if not user:
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
data = await PartnerStatsService.get_referrer_daily_stats(db, user.id, days)
return DailyStatsResponse(
items=[DailyStats(**item) for item in data],
days=days,
user_id=user.id,
)
@router.get("/referrers/{user_id}/stats/top-referrals", response_model=TopReferralsResponse)
async def get_referrer_top_referrals(
user_id: int,
limit: int = Query(10, ge=1, le=100),
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TopReferralsResponse:
"""Топ рефералов реферера по принесённому доходу."""
user = await get_user_by_telegram_id(db, user_id)
if not user:
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
data = await PartnerStatsService.get_referrer_top_referrals(db, user.id, limit)
return TopReferralsResponse(
items=[TopReferralItem(**item) for item in data],
user_id=user.id,
)
@router.get("/referrers/{user_id}/stats/compare", response_model=PeriodComparisonResponse)
async def get_referrer_period_comparison(
user_id: int,
current_days: int = Query(7, ge=1, le=365),
previous_days: int = Query(7, ge=1, le=365),
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PeriodComparisonResponse:
"""Сравнение периодов для реферера."""
user = await get_user_by_telegram_id(db, user_id)
if not user:
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
data = await PartnerStatsService.get_referrer_period_comparison(
db, user.id, current_days, previous_days
)
return PeriodComparisonResponse(
current_period=PeriodData(**data["current_period"]),
previous_period=PeriodData(**data["previous_period"]),
change=PeriodChange(
referrals=ChangeData(**data["change"]["referrals"]),
earnings=ChangeData(**data["change"]["earnings"]),
),
user_id=user.id,
)
View File
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Security
from app.services.support_settings_service import SupportSettingsService
from ..dependencies import require_api_token
router = APIRouter()
@router.get("/nalogo_receipts_enabled")
async def get_nalogo_receipts_enabled(
_: Any = Security(require_api_token),
) -> bool:
return SupportSettingsService.is_nalogo_receipts_enabled()
@router.put("/nalogo_receipts_enabled")
async def set_nalogo_receipts_enabled(
enabled: bool,
_: Any = Security(require_api_token),
) -> bool:
return SupportSettingsService.set_nalogo_receipts_enabled(enabled)
+211
View File
@@ -0,0 +1,211 @@
from __future__ import annotations
from datetime import date, datetime, time
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class ContestTemplateResponse(BaseModel):
id: int
name: str
slug: str
description: Optional[str] = None
prize_days: int
max_winners: int
attempts_per_user: int
times_per_day: int
schedule_times: Optional[str] = None
cooldown_hours: int
payload: Dict[str, Any] = Field(default_factory=dict)
is_enabled: bool
created_at: datetime
updated_at: datetime
class ContestTemplateListResponse(BaseModel):
items: List[ContestTemplateResponse]
class ContestTemplateUpdateRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
prize_days: Optional[int] = Field(None, ge=1)
max_winners: Optional[int] = Field(None, ge=1)
attempts_per_user: Optional[int] = Field(None, ge=1)
times_per_day: Optional[int] = Field(None, ge=1)
schedule_times: Optional[str] = None
cooldown_hours: Optional[int] = Field(None, ge=1)
payload: Optional[Dict[str, Any]] = None
is_enabled: Optional[bool] = None
class StartRoundRequest(BaseModel):
starts_at: Optional[datetime] = None
ends_at: Optional[datetime] = None
cooldown_hours: Optional[int] = Field(None, ge=1)
payload: Optional[Dict[str, Any]] = None
force: bool = False
class ContestRoundResponse(BaseModel):
id: int
template_id: int
template_slug: str
template_name: Optional[str] = None
starts_at: datetime
ends_at: datetime
status: str
payload: Dict[str, Any] = Field(default_factory=dict)
winners_count: int
max_winners: int
attempts_per_user: int
created_at: datetime
updated_at: datetime
class ContestRoundListResponse(BaseModel):
items: List[ContestRoundResponse]
total: int
limit: int
offset: int
class ContestAttemptUser(BaseModel):
id: int
telegram_id: Optional[int] = None
username: Optional[str] = None
full_name: Optional[str] = None
class ContestAttemptResponse(BaseModel):
id: int
round_id: int
user: ContestAttemptUser
answer: Optional[str] = None
is_winner: bool
created_at: datetime
class ContestAttemptListResponse(BaseModel):
items: List[ContestAttemptResponse]
total: int
limit: int
offset: int
class ReferralContestResponse(BaseModel):
id: int
title: str
description: Optional[str] = None
prize_text: Optional[str] = None
contest_type: str
start_at: datetime
end_at: datetime
daily_summary_time: time
daily_summary_times: Optional[str] = None
timezone: str
is_active: bool
last_daily_summary_date: Optional[date] = None
last_daily_summary_at: Optional[datetime] = None
final_summary_sent: bool
created_by: Optional[int] = None
created_at: datetime
updated_at: datetime
class ReferralContestListResponse(BaseModel):
items: List[ReferralContestResponse]
total: int
limit: int
offset: int
class ReferralContestCreateRequest(BaseModel):
title: str
description: Optional[str] = None
prize_text: Optional[str] = None
contest_type: str = Field("referral_paid", min_length=1)
start_at: datetime
end_at: datetime
daily_summary_time: time = Field(default=time(hour=12))
daily_summary_times: Optional[str] = Field(
default=None, description="Список времён ЧЧ:ММ через запятую (например, 12:00,18:00)"
)
timezone: str = Field(default="UTC")
is_active: bool = True
created_by: Optional[int] = None
class ReferralContestUpdateRequest(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
prize_text: Optional[str] = None
contest_type: Optional[str] = Field(None, min_length=1)
start_at: Optional[datetime] = None
end_at: Optional[datetime] = None
daily_summary_time: Optional[time] = None
daily_summary_times: Optional[str] = Field(
default=None, description="Список времён ЧЧ:ММ через запятую"
)
timezone: Optional[str] = None
is_active: Optional[bool] = None
final_summary_sent: Optional[bool] = None
created_by: Optional[int] = None
class ReferralContestLeaderboardItem(BaseModel):
user_id: int
telegram_id: Optional[int] = None
username: Optional[str] = None
full_name: Optional[str] = None
referrals_count: int
total_amount_kopeks: int
total_amount_rubles: float
class ReferralContestDetailResponse(ReferralContestResponse):
total_events: Optional[int] = None
leaderboard: Optional[List[ReferralContestLeaderboardItem]] = None
class ReferralContestEventUser(BaseModel):
id: int
telegram_id: Optional[int] = None
username: Optional[str] = None
full_name: Optional[str] = None
class ReferralContestEventResponse(BaseModel):
id: int
contest_id: int
referrer: ReferralContestEventUser
referral: ReferralContestEventUser
event_type: str
amount_kopeks: int
amount_rubles: float
occurred_at: datetime
class ReferralContestEventListResponse(BaseModel):
items: List[ReferralContestEventResponse]
total: int
limit: int
offset: int
class ReferralContestParticipant(BaseModel):
referrer_id: int
full_name: str
total_referrals: int
paid_referrals: int
unpaid_referrals: int
total_paid_amount: int
class ReferralContestDetailedStatsResponse(BaseModel):
total_participants: int
total_invited: int
total_paid_amount: int
total_unpaid: int
participants: List[ReferralContestParticipant]
+691
View File
@@ -0,0 +1,691 @@
"""Pydantic схемы для API конструктора меню."""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field
class ButtonType(str, Enum):
"""Тип кнопки меню."""
BUILTIN = "builtin" # Встроенная кнопка с callback_data
URL = "url" # Внешняя ссылка
MINI_APP = "mini_app" # Telegram Mini App
CALLBACK = "callback" # Кастомная кнопка с любым callback_data
class ButtonVisibility(str, Enum):
"""Видимость кнопки."""
ALL = "all" # Видна всем
ADMINS = "admins" # Только админам
MODERATORS = "moderators" # Только модераторам
SUBSCRIBERS = "subscribers" # Только подписчикам
class ButtonOpenMode(str, Enum):
"""Режим открытия кнопки."""
CALLBACK = "callback" # Отправляет callback_data боту (по умолчанию)
DIRECT = "direct" # Сразу открывает Mini App через WebAppInfo
class ButtonConditions(BaseModel):
"""Условия показа кнопки."""
# Существующие условия
has_active_subscription: Optional[bool] = Field(
default=None, description="Требуется активная подписка"
)
subscription_is_active: Optional[bool] = Field(
default=None, description="Подписка должна быть активна (не приостановлена)"
)
has_traffic_limit: Optional[bool] = Field(
default=None, description="Подписка с лимитом трафика"
)
is_admin: Optional[bool] = Field(default=None, description="Пользователь - админ")
is_moderator: Optional[bool] = Field(
default=None, description="Пользователь - модератор"
)
referral_enabled: Optional[bool] = Field(
default=None, description="Реферальная программа включена"
)
contests_visible: Optional[bool] = Field(
default=None, description="Конкурсы видимы"
)
support_enabled: Optional[bool] = Field(
default=None, description="Поддержка включена"
)
language_selection_enabled: Optional[bool] = Field(
default=None, description="Выбор языка включен"
)
happ_enabled: Optional[bool] = Field(
default=None, description="Кнопка Happ включена"
)
simple_subscription_enabled: Optional[bool] = Field(
default=None, description="Простая подписка включена"
)
show_trial: Optional[bool] = Field(
default=None, description="Показать пробный период"
)
show_buy: Optional[bool] = Field(
default=None, description="Показать кнопку покупки"
)
has_saved_cart: Optional[bool] = Field(
default=None, description="Есть сохраненная корзина"
)
# Расширенные условия
min_balance_kopeks: Optional[int] = Field(
default=None, ge=0, description="Минимальный баланс в копейках"
)
max_balance_kopeks: Optional[int] = Field(
default=None, ge=0, description="Максимальный баланс в копейках"
)
min_registration_days: Optional[int] = Field(
default=None, ge=0, description="Минимум дней с регистрации"
)
max_registration_days: Optional[int] = Field(
default=None, ge=0, description="Максимум дней с регистрации"
)
min_referrals: Optional[int] = Field(
default=None, ge=0, description="Минимальное количество рефералов"
)
has_referrals: Optional[bool] = Field(
default=None, description="Есть рефералы"
)
promo_group_ids: Optional[List[str]] = Field(
default=None, description="Список ID промо-групп (пользователь должен быть в одной из них)"
)
exclude_promo_group_ids: Optional[List[str]] = Field(
default=None, description="Исключить пользователей из этих промо-групп"
)
has_subscription_days_left: Optional[int] = Field(
default=None, ge=0, description="Минимум дней до окончания подписки"
)
max_subscription_days_left: Optional[int] = Field(
default=None, ge=0, description="Максимум дней до окончания подписки"
)
is_trial_user: Optional[bool] = Field(
default=None, description="Пользователь на пробном периоде"
)
has_autopay: Optional[bool] = Field(
default=None, description="Автоплатёж включён"
)
model_config = ConfigDict(extra="forbid")
class MenuButtonConfig(BaseModel):
"""Конфигурация отдельной кнопки."""
type: ButtonType = Field(..., description="Тип кнопки")
builtin_id: Optional[str] = Field(
default=None, description="ID встроенной кнопки (для type=builtin)"
)
text: Dict[str, str] = Field(
..., description="Локализованные тексты кнопки: {lang_code: text}"
)
icon: Optional[str] = Field(
default=None, max_length=10, description="Эмодзи/иконка кнопки (отдельно от текста)"
)
action: str = Field(
..., description="callback_data или URL в зависимости от типа"
)
enabled: bool = Field(default=True, description="Кнопка активна")
visibility: ButtonVisibility = Field(
default=ButtonVisibility.ALL, description="Видимость кнопки"
)
conditions: Optional[ButtonConditions] = Field(
default=None, description="Дополнительные условия показа"
)
dynamic_text: bool = Field(
default=False, description="Текст содержит плейсхолдеры ({balance}, {username} и т.д.)"
)
open_mode: ButtonOpenMode = Field(
default=ButtonOpenMode.CALLBACK,
description="Режим открытия: callback (через бота) или direct (сразу Mini App)",
)
webapp_url: Optional[str] = Field(
default=None,
description="URL для Mini App при open_mode=direct",
)
description: Optional[str] = Field(
default=None, max_length=200, description="Описание кнопки для админ-панели"
)
sort_order: Optional[int] = Field(
default=None, description="Порядок сортировки (для отображения в админке)"
)
model_config = ConfigDict(extra="forbid")
class MenuRowConfig(BaseModel):
"""Конфигурация строки меню."""
id: str = Field(..., min_length=1, max_length=50, description="Уникальный ID строки")
buttons: List[str] = Field(
..., description="Список ID кнопок в строке"
)
conditions: Optional[ButtonConditions] = Field(
default=None, description="Условия показа всей строки"
)
max_per_row: int = Field(
default=2, ge=1, le=4, description="Максимум кнопок в строке"
)
model_config = ConfigDict(extra="forbid")
class MenuLayoutConfig(BaseModel):
"""Полная конфигурация меню."""
version: int = Field(default=1, description="Версия формата конфигурации")
rows: List[MenuRowConfig] = Field(
default_factory=list, description="Строки меню"
)
buttons: Dict[str, MenuButtonConfig] = Field(
default_factory=dict, description="Конфигурации кнопок"
)
model_config = ConfigDict(extra="forbid")
# --- Response schemas ---
class MenuLayoutResponse(BaseModel):
"""Ответ с конфигурацией меню."""
version: int
rows: List[MenuRowConfig]
buttons: Dict[str, MenuButtonConfig]
is_enabled: bool = Field(description="Включен ли конструктор меню")
updated_at: Optional[datetime] = None
class BuiltinButtonInfo(BaseModel):
"""Информация о встроенной кнопке."""
id: str = Field(description="Идентификатор кнопки")
default_text: Dict[str, str] = Field(description="Текст по умолчанию")
callback_data: str = Field(description="callback_data кнопки")
default_conditions: Optional[ButtonConditions] = Field(
default=None, description="Условия показа по умолчанию"
)
supports_dynamic_text: bool = Field(
default=False, description="Поддерживает ли динамический текст"
)
supports_direct_open: bool = Field(
default=False, description="Поддерживает ли прямое открытие Mini App"
)
class BuiltinButtonsListResponse(BaseModel):
"""Список встроенных кнопок."""
items: List[BuiltinButtonInfo]
total: int
# --- Request schemas ---
class MenuLayoutUpdateRequest(BaseModel):
"""Запрос на обновление конфигурации меню."""
rows: Optional[List[MenuRowConfig]] = Field(
default=None, description="Новая конфигурация строк"
)
buttons: Optional[Dict[str, MenuButtonConfig]] = Field(
default=None, description="Новая конфигурация кнопок"
)
model_config = ConfigDict(extra="forbid")
class ButtonUpdateRequest(BaseModel):
"""Запрос на обновление отдельной кнопки."""
text: Optional[Dict[str, str]] = Field(
default=None, description="Новые локализованные тексты"
)
icon: Optional[str] = Field(
default=None, max_length=10, description="Эмодзи/иконка кнопки"
)
enabled: Optional[bool] = Field(default=None, description="Включить/выключить")
visibility: Optional[ButtonVisibility] = Field(
default=None, description="Новая видимость"
)
conditions: Optional[ButtonConditions] = Field(
default=None, description="Новые условия показа"
)
action: Optional[str] = Field(
default=None, description="Новый action (callback_data или URL)"
)
dynamic_text: Optional[bool] = Field(
default=None, description="Текст содержит плейсхолдеры"
)
open_mode: Optional[ButtonOpenMode] = Field(
default=None, description="Режим открытия: callback или direct"
)
webapp_url: Optional[str] = Field(
default=None, description="URL для Mini App при open_mode=direct"
)
description: Optional[str] = Field(
default=None, max_length=200, description="Описание кнопки"
)
sort_order: Optional[int] = Field(
default=None, description="Порядок сортировки"
)
model_config = ConfigDict(extra="forbid")
class RowsReorderRequest(BaseModel):
"""Запрос на изменение порядка строк."""
ordered_ids: List[str] = Field(
..., min_length=1, description="Список ID строк в новом порядке"
)
model_config = ConfigDict(extra="forbid")
class AddRowRequest(BaseModel):
"""Запрос на добавление новой строки."""
id: str = Field(..., min_length=1, max_length=50, description="ID новой строки")
buttons: List[str] = Field(..., description="Список ID кнопок")
conditions: Optional[ButtonConditions] = Field(
default=None, description="Условия показа"
)
max_per_row: int = Field(default=2, ge=1, le=4, description="Макс. кнопок в строке")
position: Optional[int] = Field(
default=None, ge=0, description="Позиция вставки (по умолчанию - в конец)"
)
model_config = ConfigDict(extra="forbid")
class AddCustomButtonRequest(BaseModel):
"""Запрос на добавление кастомной кнопки."""
id: str = Field(
..., min_length=1, max_length=50, description="ID кнопки (уникальный)"
)
type: ButtonType = Field(..., description="Тип кнопки (url, mini_app или callback)")
text: Dict[str, str] = Field(..., description="Локализованные тексты")
icon: Optional[str] = Field(
default=None, max_length=10, description="Эмодзи/иконка кнопки"
)
action: str = Field(..., min_length=1, description="URL или callback_data")
visibility: ButtonVisibility = Field(
default=ButtonVisibility.ALL, description="Видимость"
)
conditions: Optional[ButtonConditions] = Field(
default=None, description="Условия показа"
)
dynamic_text: bool = Field(
default=False, description="Текст содержит плейсхолдеры"
)
row_id: Optional[str] = Field(
default=None, description="ID строки для добавления кнопки"
)
description: Optional[str] = Field(
default=None, max_length=200, description="Описание кнопки для админ-панели"
)
model_config = ConfigDict(extra="forbid")
class MenuPreviewRequest(BaseModel):
"""Запрос на предпросмотр меню."""
language: str = Field(default="ru", description="Язык для предпросмотра")
is_admin: bool = Field(default=False, description="Режим админа")
is_moderator: bool = Field(default=False, description="Режим модератора")
has_active_subscription: bool = Field(
default=False, description="Есть активная подписка"
)
subscription_is_active: bool = Field(
default=False, description="Подписка активна"
)
balance_kopeks: int = Field(default=0, ge=0, description="Баланс в копейках")
model_config = ConfigDict(extra="forbid")
class MenuPreviewButton(BaseModel):
"""Кнопка в предпросмотре."""
text: str
action: str
type: ButtonType
class MenuPreviewRow(BaseModel):
"""Строка в предпросмотре."""
buttons: List[MenuPreviewButton]
class MenuPreviewResponse(BaseModel):
"""Ответ с предпросмотром меню."""
rows: List[MenuPreviewRow]
total_buttons: int
# --- Схемы для перемещения кнопок ---
class MoveButtonToRowRequest(BaseModel):
"""Запрос на перемещение кнопки в другую строку."""
target_row_id: str = Field(..., description="ID целевой строки")
position: Optional[int] = Field(
default=None, ge=0, description="Позиция в строке (по умолчанию - в конец)"
)
model_config = ConfigDict(extra="forbid")
class ReorderButtonsInRowRequest(BaseModel):
"""Запрос на изменение порядка кнопок в строке."""
ordered_button_ids: List[str] = Field(
..., min_length=1, description="Список ID кнопок в новом порядке"
)
model_config = ConfigDict(extra="forbid")
class SwapButtonsRequest(BaseModel):
"""Запрос на обмен местами двух кнопок."""
button_id_1: str = Field(..., description="ID первой кнопки")
button_id_2: str = Field(..., description="ID второй кнопки")
model_config = ConfigDict(extra="forbid")
class MoveButtonResponse(BaseModel):
"""Ответ на перемещение кнопки."""
button_id: str
new_row_index: Optional[int] = None
target_row_id: Optional[str] = None
position: Optional[int] = None
class SwapButtonsResponse(BaseModel):
"""Ответ на обмен кнопок."""
button_1: Dict[str, Any]
button_2: Dict[str, Any]
class ReorderButtonsResponse(BaseModel):
"""Ответ на изменение порядка кнопок."""
row_id: str
buttons: List[str]
# --- Схемы для доступных callback_data ---
class AvailableCallback(BaseModel):
"""Информация о доступном callback_data."""
callback_data: str = Field(description="callback_data для кнопки")
name: str = Field(description="Человекочитаемое название")
description: Optional[str] = Field(default=None, description="Описание действия")
category: str = Field(description="Категория: menu, subscription, balance, referral, support, etc.")
default_text: Optional[Dict[str, str]] = Field(default=None, description="Текст по умолчанию")
default_icon: Optional[str] = Field(default=None, description="Иконка по умолчанию")
requires_subscription: bool = Field(default=False, description="Требует активную подписку")
is_in_menu: bool = Field(default=False, description="Уже добавлена в меню")
class AvailableCallbacksResponse(BaseModel):
"""Список всех доступных callback_data."""
items: List[AvailableCallback]
total: int
categories: List[str] = Field(description="Список всех категорий")
# --- Схемы для импорта/экспорта ---
class MenuLayoutExportResponse(BaseModel):
"""Экспорт конфигурации меню."""
version: int
rows: List[MenuRowConfig]
buttons: Dict[str, MenuButtonConfig]
exported_at: datetime
bot_version: Optional[str] = None
class MenuLayoutImportRequest(BaseModel):
"""Импорт конфигурации меню."""
version: int
rows: List[MenuRowConfig]
buttons: Dict[str, MenuButtonConfig]
merge_mode: str = Field(
default="replace",
description="Режим импорта: replace (заменить всё), merge (объединить)"
)
model_config = ConfigDict(extra="forbid")
class MenuLayoutImportResponse(BaseModel):
"""Результат импорта."""
success: bool
imported_rows: int
imported_buttons: int
warnings: List[str] = Field(default_factory=list)
# --- Схемы для истории изменений ---
class MenuLayoutHistoryEntry(BaseModel):
"""Запись в истории изменений."""
id: int
created_at: datetime
action: str = Field(description="Тип действия: update, reset, import")
changes_summary: str = Field(description="Краткое описание изменений")
user_info: Optional[str] = Field(default=None, description="Информация о пользователе")
class MenuLayoutHistoryResponse(BaseModel):
"""История изменений."""
items: List[MenuLayoutHistoryEntry]
total: int
class MenuLayoutRollbackRequest(BaseModel):
"""Запрос на откат к предыдущей версии."""
history_id: int = Field(description="ID записи в истории для отката")
model_config = ConfigDict(extra="forbid")
# --- Схемы для валидации ---
class ValidationError(BaseModel):
"""Ошибка валидации."""
field: str
message: str
severity: str = Field(description="error или warning")
class MenuLayoutValidateRequest(BaseModel):
"""Запрос на валидацию конфигурации."""
rows: Optional[List[MenuRowConfig]] = None
buttons: Optional[Dict[str, MenuButtonConfig]] = None
model_config = ConfigDict(extra="forbid")
class MenuLayoutValidateResponse(BaseModel):
"""Результат валидации."""
is_valid: bool
errors: List[ValidationError] = Field(default_factory=list)
warnings: List[ValidationError] = Field(default_factory=list)
# --- Схемы для статистики кликов ---
class ButtonClickStats(BaseModel):
"""Статистика кликов по кнопке."""
button_id: str
clicks_total: int = Field(default=0)
clicks_today: int = Field(default=0)
clicks_week: int = Field(default=0)
clicks_month: int = Field(default=0)
last_click_at: Optional[datetime] = None
unique_users: int = Field(default=0, description="Уникальные пользователи")
class ButtonClickStatsResponse(BaseModel):
"""Статистика кликов для одной кнопки."""
button_id: str
stats: ButtonClickStats
clicks_by_day: List[Dict[str, Any]] = Field(
default_factory=list, description="Клики по дням [{date, count}]"
)
class MenuClickStatsResponse(BaseModel):
"""Общая статистика кликов по всем кнопкам."""
items: List[ButtonClickStats]
total_clicks: int
period_start: datetime
period_end: datetime
class ButtonTypeStats(BaseModel):
"""Статистика по типу кнопки."""
button_type: str
clicks_total: int
unique_users: int
class ButtonTypeStatsResponse(BaseModel):
"""Статистика кликов по типам кнопок."""
items: List[ButtonTypeStats]
total_clicks: int
class HourlyStats(BaseModel):
"""Статистика по часам."""
hour: int
count: int
class HourlyStatsResponse(BaseModel):
"""Статистика кликов по часам дня."""
items: List[HourlyStats]
button_id: Optional[str] = None
class WeekdayStats(BaseModel):
"""Статистика по дням недели."""
weekday: int
weekday_name: str
count: int
class WeekdayStatsResponse(BaseModel):
"""Статистика кликов по дням недели."""
items: List[WeekdayStats]
button_id: Optional[str] = None
class TopUserStats(BaseModel):
"""Статистика пользователя."""
user_id: int
clicks_count: int
last_click_at: Optional[datetime] = None
class TopUsersResponse(BaseModel):
"""Топ пользователей по кликам."""
items: List[TopUserStats]
button_id: Optional[str] = None
limit: int
class PeriodComparisonResponse(BaseModel):
"""Сравнение периодов."""
current_period: Dict[str, Any]
previous_period: Dict[str, Any]
change: Dict[str, Any]
button_id: Optional[str] = None
class UserClickSequence(BaseModel):
"""Последовательность кликов пользователя."""
button_id: str
button_text: Optional[str] = None
clicked_at: datetime
class UserClickSequencesResponse(BaseModel):
"""Последовательности кликов пользователя."""
user_id: int
items: List[UserClickSequence]
total: int
# --- Схемы для плейсхолдеров ---
class DynamicPlaceholder(BaseModel):
"""Информация о динамическом плейсхолдере."""
placeholder: str = Field(description="Плейсхолдер, например {balance}")
description: str = Field(description="Описание")
example: str = Field(description="Пример значения")
category: str = Field(description="Категория: user, subscription, referral, etc.")
class DynamicPlaceholdersResponse(BaseModel):
"""Список доступных плейсхолдеров."""
items: List[DynamicPlaceholder]
total: int
+156
View File
@@ -73,3 +73,159 @@ class PartnerReferralCommissionUpdate(BaseModel):
le=100,
description="Индивидуальный процент реферальной комиссии для пользователя",
)
# ============================================================================
# РАСШИРЕННАЯ СТАТИСТИКА ПАРТНЁРОВ
# ============================================================================
class EarningsByPeriod(BaseModel):
"""Заработки по периодам."""
all_time_kopeks: int
year_kopeks: int
month_kopeks: int
week_kopeks: int
today_kopeks: int
class ReferralsCountByPeriod(BaseModel):
"""Количество рефералов по периодам."""
all_time: int
year: int
month: int
week: int
today: int
class ReferrerSummary(BaseModel):
"""Сводка по рефереру."""
total_referrals: int
paid_referrals: int
active_referrals: int
conversion_to_paid_percent: float
conversion_to_active_percent: float
avg_earnings_per_referral_kopeks: float
class ReferrerDetailedStats(BaseModel):
"""Детальная статистика реферера."""
user_id: int
summary: ReferrerSummary
earnings: EarningsByPeriod
referrals_count: ReferralsCountByPeriod
class DailyStats(BaseModel):
"""Статистика за день."""
date: str
referrals_count: int
earnings_kopeks: int
class DailyStatsResponse(BaseModel):
"""Ответ со статистикой по дням."""
items: List[DailyStats]
days: int
user_id: Optional[int] = None
class TopReferralItem(BaseModel):
"""Топ реферал."""
id: int
telegram_id: int
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
full_name: str
created_at: datetime
has_made_first_topup: bool
is_active: bool
total_earnings_kopeks: int
class TopReferralsResponse(BaseModel):
"""Топ рефералов реферера."""
items: List[TopReferralItem]
user_id: int
class PeriodData(BaseModel):
"""Данные за период."""
days: int
start: str
end: str
referrals_count: int
earnings_kopeks: int
class ChangeData(BaseModel):
"""Данные об изменении."""
absolute: int
percent: float
trend: str # up, down, stable
class PeriodChange(BaseModel):
"""Изменения между периодами."""
referrals: ChangeData
earnings: ChangeData
class PeriodComparisonResponse(BaseModel):
"""Сравнение периодов."""
current_period: PeriodData
previous_period: PeriodData
change: PeriodChange
user_id: Optional[int] = None
class GlobalPartnerSummary(BaseModel):
"""Глобальная сводка партнёрской программы."""
total_referrers: int
total_referrals: int
paid_referrals: int
conversion_rate_percent: float
avg_earnings_per_referral_kopeks: float
class PayoutsByPeriod(BaseModel):
"""Выплаты по периодам."""
all_time_kopeks: int
year_kopeks: int
month_kopeks: int
week_kopeks: int
today_kopeks: int
class NewReferralsByPeriod(BaseModel):
"""Новые рефералы по периодам."""
today: int
week: int
month: int
class GlobalPartnerStats(BaseModel):
"""Глобальная статистика партнёрской программы."""
summary: GlobalPartnerSummary
payouts: PayoutsByPeriod
new_referrals: NewReferralsByPeriod
class TopReferrerItem(BaseModel):
"""Топ реферер."""
id: int
telegram_id: int
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
full_name: str
referral_code: Optional[str] = None
referrals_count: int
total_earnings_kopeks: int
class TopReferrersResponse(BaseModel):
"""Топ рефереров."""
items: List[TopReferrerItem]
days: Optional[int] = None
+13
View File
@@ -74,12 +74,22 @@ class PromoOfferBroadcastRequest(PromoOfferCreateRequest):
"all",
"active",
"trial",
"trial_ending",
"trial_expired",
"no",
"expiring",
"expiring_subscribers",
"expired",
"expired_subscribers",
"canceled_subscribers",
"active_zero",
"trial_zero",
"zero",
"autopay_failed",
"low_balance",
"inactive_30d",
"inactive_60d",
"inactive_90d",
}
_CUSTOM_TARGETS: ClassVar[set[str]] = {
"today",
@@ -93,6 +103,9 @@ class PromoOfferBroadcastRequest(PromoOfferCreateRequest):
}
_TARGET_ALIASES: ClassVar[dict[str, str]] = {
"no_sub": "no",
"all_users": "all",
"active_subscribers": "active",
"trial_users": "trial",
}
@validator("target")
+56
View File
@@ -0,0 +1,56 @@
# Contests API (admin)
Админский REST API для конкурсов: ежедневные игры и реферальные конкурсы. Авторизация как в остальных методах — `X-API-Key` или Bearer.
## Ежедневные игры (`/contests/daily`)
- `GET /contests/daily/templates?enabled_only=false` — список шаблонов игр.
- `GET /contests/daily/templates/{id}` — получить шаблон.
- `PATCH /contests/daily/templates/{id}` — обновить поля: `name`, `description`, `prize_days`, `max_winners`, `attempts_per_user`, `times_per_day`, `schedule_times`, `cooldown_hours`, `payload` (dict), `is_enabled`.
- `POST /contests/daily/templates/{id}/start-round` — запустить раунд вручную. Тело:
```json
{
"starts_at": "2025-12-15T09:00:00+03:00",
"ends_at": "2025-12-15T13:00:00+03:00",
"cooldown_hours": 4,
"payload": {"secret_idx": 3},
"force": true
}
```
Если `force=true`, активный раунд этого шаблона завершается перед созданием нового.
- `GET /contests/daily/rounds?status_filter=active|finished|any&template_id&limit&offset` — список раундов.
- `GET /contests/daily/rounds/{id}` — получить раунд.
- `POST /contests/daily/rounds/{id}/finish` — завершить раунд.
- `GET /contests/daily/rounds/{id}/attempts?winners_only=false&limit&offset` — попытки (с данными пользователя).
## Реферальные конкурсы (`/contests/referral`)
- `GET /contests/referral?contest_type&limit&offset` — список конкурсов.
- `POST /contests/referral` — создать конкурс:
```json
{
"title": "Рефералы декабрь",
"contest_type": "referral_paid",
"start_at": "2025-12-20T10:00:00+03:00",
"end_at": "2025-12-27T10:00:00+03:00",
"daily_summary_time": "12:00:00",
"timezone": "Europe/Moscow",
"prize_text": "🥇 5000 ₽, 🥈 3000 ₽",
"is_active": true,
"created_by": 1
}
```
- `GET /contests/referral/{id}/detailed-stats` — детальная статистика конкурса с разбивкой по участникам (total_participants, total_invited, total_paid_amount, total_unpaid, participants).
- `PATCH /contests/referral/{id}` — частичное обновление (те же поля + `final_summary_sent`, `is_active`, `daily_summary_times` с несколькими временами через запятую).
- `POST /contests/referral/{id}/toggle?is_active=true|false` — быстро включить/остановить.
- `GET /contests/referral/{id}/events?limit&offset` — события (referrer/referral, тип, суммы).
- `DELETE /contests/referral/{id}` — удалить завершённый конкурс.
## Даты и часовые пояса
- Поля `datetime` можно передавать с TZ; сервер переводит в UTC (tzinfo убирается).
- Если TZ не указан, используется `settings.TIMEZONE`.
## Тег в OpenAPI
Все методы сгруппированы под тегом `contests` в Swagger/Redoc после перезапуска web-api.
+259
View File
@@ -0,0 +1,259 @@
# Использование API статистики кнопок меню
## Обзор
Система статистики кликов по кнопкам меню позволяет отслеживать, какие кнопки чаще всего нажимают пользователи.
## API Эндпоинты
### 1. Логирование клика по кнопке
**POST** `/menu-layout/stats/log-click`
**Параметры:**
- `button_id` (str) - ID кнопки
- `user_id` (int, optional) - ID пользователя (telegram_id)
- `callback_data` (str, optional) - callback_data кнопки
- `button_type` (str, optional) - тип кнопки: `builtin`, `callback`, `url`, `mini_app`
- `button_text` (str, optional) - текст кнопки на момент клика
**Пример:**
```python
await MenuLayoutService.log_button_click(
db,
button_id="menu_balance",
user_id=123456789,
callback_data="menu_balance",
button_type="builtin",
button_text="💰 Баланс"
)
```
### 2. Получение статистики по конкретной кнопке
**GET** `/menu-layout/stats/buttons/{button_id}?days=30`
**Возвращает:**
- `clicks_total` - общее количество кликов
- `clicks_today` - клики сегодня
- `clicks_week` - клики за неделю
- `clicks_month` - клики за месяц
- `unique_users` - уникальные пользователи
- `last_click_at` - последний клик
- `clicks_by_day` - клики по дням
**Пример:**
```python
stats = await MenuLayoutService.get_button_stats(db, "menu_balance", days=30)
# Возвращает:
# {
# "button_id": "menu_balance",
# "clicks_total": 150,
# "clicks_today": 5,
# "clicks_week": 25,
# "clicks_month": 150,
# "unique_users": 45,
# "last_click_at": datetime(...)
# }
```
### 3. Получение общей статистики по всем кнопкам
**GET** `/menu-layout/stats?days=30`
**Возвращает:**
- `items` - список статистики по каждой кнопке
- `total_clicks` - общее количество кликов
- `period_start` - начало периода
- `period_end` - конец периода
**Пример:**
```python
all_stats = await MenuLayoutService.get_all_buttons_stats(db, days=30)
total = await MenuLayoutService.get_total_clicks(db, days=30)
```
## Автоматическое логирование
✅ **Логирование кликов происходит автоматически!**
Все клики по кнопкам автоматически логируются через `ButtonStatsMiddleware`. Middleware перехватывает все `CallbackQuery` события и логирует их в базу данных.
### Как это работает
1. При каждом клике по кнопке middleware автоматически:
- Извлекает `callback_data` (используется как `button_id`)
- Получает `user_id` из события
- Определяет тип кнопки (`builtin`, `callback`, `url`)
- Извлекает текст кнопки из клавиатуры (если доступен)
- Логирует в базу данных асинхронно (не блокирует обработку)
2. Middleware активируется автоматически, если `MENU_LAYOUT_ENABLED=True`
3. Логирование происходит в фоновом режиме и не влияет на производительность
### Ручное логирование (опционально)
Если нужно логировать клики вручную (например, для внешних интеграций), можно использовать API:
```python
# Через сервис
await MenuLayoutService.log_button_click(
db,
button_id="custom_button",
user_id=user_id,
callback_data="custom_callback",
button_type="callback",
button_text="Кастомная кнопка"
)
# Или через API эндпоинт
POST /menu-layout/stats/log-click
{
"button_id": "custom_button",
"user_id": 123456789,
"callback_data": "custom_callback",
"button_type": "callback",
"button_text": "Кастомная кнопка"
}
```
### 4. Статистика по типам кнопок
**GET** `/menu-layout/stats/by-type?days=30`
**Возвращает:**
- Статистику кликов по каждому типу кнопок (builtin, callback, url, mini_app)
- Общее количество кликов по типам
**Пример:**
```python
stats = await MenuLayoutService.get_stats_by_button_type(db, days=30)
# Возвращает:
# [
# {"button_type": "builtin", "clicks_total": 500, "unique_users": 100},
# {"button_type": "callback", "clicks_total": 200, "unique_users": 50},
# ...
# ]
```
### 5. Статистика по часам дня
**GET** `/menu-layout/stats/by-hour?button_id=menu_balance&days=30`
**Параметры:**
- `button_id` (optional) - ID кнопки для фильтрации
- `days` (default: 30) - период в днях
**Возвращает:**
- Распределение кликов по часам дня (0-23)
**Пример:**
```python
stats = await MenuLayoutService.get_clicks_by_hour(db, button_id="menu_balance", days=30)
# Возвращает:
# [
# {"hour": 9, "count": 50},
# {"hour": 10, "count": 75},
# ...
# ]
```
### 6. Статистика по дням недели
**GET** `/menu-layout/stats/by-weekday?button_id=menu_balance&days=30`
**Возвращает:**
- Распределение кликов по дням недели (0=понедельник, 6=воскресенье)
**Пример:**
```python
stats = await MenuLayoutService.get_clicks_by_weekday(db, button_id="menu_balance", days=30)
# Возвращает:
# [
# {"weekday": 0, "weekday_name": "Понедельник", "count": 100},
# {"weekday": 1, "weekday_name": "Вторник", "count": 120},
# ...
# ]
```
### 7. Топ пользователей по кликам
**GET** `/menu-layout/stats/top-users?button_id=menu_balance&limit=10&days=30`
**Параметры:**
- `button_id` (optional) - ID кнопки для фильтрации
- `limit` (default: 10) - количество пользователей
- `days` (default: 30) - период в днях
**Возвращает:**
- Список пользователей с наибольшим количеством кликов
**Пример:**
```python
top_users = await MenuLayoutService.get_top_users(db, button_id="menu_balance", limit=10, days=30)
# Возвращает:
# [
# {"user_id": 123456789, "clicks_count": 50, "last_click_at": datetime(...)},
# ...
# ]
```
### 8. Сравнение периодов
**GET** `/menu-layout/stats/compare?button_id=menu_balance&current_days=7&previous_days=7`
**Параметры:**
- `button_id` (optional) - ID кнопки для фильтрации
- `current_days` (default: 7) - период текущего сравнения
- `previous_days` (default: 7) - период предыдущего сравнения
**Возвращает:**
- Сравнение текущего и предыдущего периода
- Изменение в абсолютных числах и процентах
- Тренд (up/down/stable)
**Пример:**
```python
comparison = await MenuLayoutService.get_period_comparison(
db, button_id="menu_balance", current_days=7, previous_days=7
)
# Возвращает:
# {
# "current_period": {"clicks": 100, "days": 7, ...},
# "previous_period": {"clicks": 80, "days": 7, ...},
# "change": {"absolute": 20, "percent": 25.0, "trend": "up"}
# }
```
### 9. Последовательности кликов пользователя
**GET** `/menu-layout/stats/users/{user_id}/sequences?limit=50`
**Параметры:**
- `user_id` (path) - ID пользователя
- `limit` (default: 50) - максимальное количество записей
**Возвращает:**
- Хронологическую последовательность кликов пользователя
**Пример:**
```python
sequences = await MenuLayoutService.get_user_click_sequences(db, user_id=123456789, limit=50)
# Возвращает:
# [
# {"button_id": "menu_balance", "button_text": "💰 Баланс", "clicked_at": datetime(...)},
# {"button_id": "menu_subscription", "button_text": "📊 Подписка", "clicked_at": datetime(...)},
# ...
# ]
```
## Важные замечания
1. **Автоматическое логирование**: Все клики по кнопкам логируются автоматически через `ButtonStatsMiddleware`
2. **Требуется авторизация**: API эндпоинты для получения статистики требуют токен авторизации (`require_api_token`)
3. **button_id**: Используется `callback_data` кнопки как идентификатор
4. **Производительность**: Логирование выполняется асинхронно в фоне и не блокирует обработку запросов
5. **Активация**: Middleware работает только если `MENU_LAYOUT_ENABLED=True` в настройках
6. **Временные зоны**: Все временные метрики используют локальное время сервера
+46
View File
@@ -32,6 +32,8 @@ from app.localization.loader import ensure_locale_templates
from app.services.system_settings_service import bot_configuration_service
from app.services.external_admin_service import ensure_external_admin_token
from app.services.broadcast_service import broadcast_service
from app.services.referral_contest_service import referral_contest_service
from app.services.contest_rotation_service import contest_rotation_service
from app.utils.startup_timeline import StartupTimeline
from app.utils.timezone import TimezoneAwareFormatter
@@ -174,6 +176,7 @@ async def main():
admin_notification_service = AdminNotificationService(bot)
version_service.bot = bot
version_service.set_notification_service(admin_notification_service)
referral_contest_service.set_bot(bot)
stage.log(f"Репозиторий версий: {version_service.repo}")
stage.log(f"Текущая версия: {version_service.current_version}")
stage.success("Мониторинг, уведомления и рассылки подключены")
@@ -211,6 +214,37 @@ async def main():
stage.warning(f"Ошибка запуска сервиса отчетов: {e}")
logger.error(f"❌ Ошибка запуска сервиса отчетов: {e}")
async with timeline.stage(
"Реферальные конкурсы",
"🏆",
success_message="Сервис конкурсов готов",
) as stage:
try:
await referral_contest_service.start()
if referral_contest_service.is_running():
stage.log("Автосводки по конкурсам запущены")
else:
stage.skip("Сервис конкурсов выключен настройками")
except Exception as e:
stage.warning(f"Ошибка запуска сервиса конкурсов: {e}")
logger.error(f"❌ Ошибка запуска сервиса конкурсов: {e}")
async with timeline.stage(
"Ротация игр",
"🎲",
success_message="Мини-игры готовы",
) as stage:
try:
contest_rotation_service.set_bot(bot)
await contest_rotation_service.start()
if contest_rotation_service.is_running():
stage.log("Ротационные игры запущены")
else:
stage.skip("Ротация игр выключена настройками")
except Exception as e:
stage.warning(f"Ошибка запуска ротации игр: {e}")
logger.error(f"❌ Ошибка запуска ротации игр: {e}")
async with timeline.stage(
"Автосинхронизация RemnaWave",
"🔄",
@@ -594,12 +628,24 @@ async def main():
except Exception as e:
logger.error(f"Ошибка остановки сервиса отчетов: {e}")
logger.info("ℹ️ Остановка сервиса конкурсов...")
try:
await referral_contest_service.stop()
except Exception as e:
logger.error(f"Ошибка остановки сервиса конкурсов: {e}")
logger.info("ℹ️ Остановка сервиса автосинхронизации RemnaWave...")
try:
await remnawave_sync_service.stop()
except Exception as e:
logger.error(f"Ошибка остановки автосинхронизации RemnaWave: {e}")
logger.info("ℹ️ Остановка ротации игр...")
try:
await contest_rotation_service.stop()
except Exception as e:
logger.error(f"Ошибка остановки ротации игр: {e}")
logger.info("ℹ️ Остановка сервиса бекапов...")
try:
await backup_service.stop_auto_backup()
+4 -1
View File
@@ -17,7 +17,10 @@ uvicorn==0.32.1
python-multipart==0.0.9
# YooKassa SDK
yookassa==3.7.0
yookassa==3.9.0
# NaloGO для чеков в налоговую
nalogo
# Логирование и мониторинг
structlog==23.2.0
+139
View File
@@ -0,0 +1,139 @@
"""Тесты для MenuLayoutService."""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from aiogram.types import InlineKeyboardButton
from app.services.menu_layout.service import MenuLayoutService
from app.services.menu_layout.context import MenuContext
@pytest.mark.anyio
async def test_build_button_connect_direct_mode_with_url():
"""Тест: кнопка connect с open_mode=direct и валидным URL должна создавать WebAppInfo."""
button_config = {
"type": "builtin",
"builtin_id": "connect",
"text": {"ru": "🔗 Подключиться"},
"action": "subscription_connect",
"open_mode": "direct",
"webapp_url": "https://example.com/miniapp",
}
context = MenuContext(
language="ru",
has_active_subscription=True,
subscription_is_active=True,
)
texts = MagicMock()
texts.t = lambda key, default: default
button = MenuLayoutService._build_button(button_config, context, texts)
assert button is not None
assert isinstance(button, InlineKeyboardButton)
assert button.web_app is not None
assert button.web_app.url == "https://example.com/miniapp"
assert button.callback_data is None
@pytest.mark.anyio
async def test_build_button_connect_direct_mode_with_subscription_url():
"""Тест: кнопка connect с open_mode=direct должна получать URL из подписки."""
button_config = {
"type": "builtin",
"builtin_id": "connect",
"text": {"ru": "🔗 Подключиться"},
"action": "subscription_connect",
"open_mode": "direct",
"webapp_url": None,
}
# Мокаем подписку с URL
mock_subscription = MagicMock()
mock_subscription.subscription_url = "https://subscription.example.com/link"
mock_subscription.subscription_crypto_link = None
context = MenuContext(
language="ru",
has_active_subscription=True,
subscription_is_active=True,
subscription=mock_subscription,
)
texts = MagicMock()
texts.t = lambda key, default: default
with patch('app.utils.subscription_utils.get_display_subscription_link') as mock_get_link:
mock_get_link.return_value = "https://subscription.example.com/link"
button = MenuLayoutService._build_button(button_config, context, texts)
assert button is not None
assert isinstance(button, InlineKeyboardButton)
assert button.web_app is not None
assert button.web_app.url == "https://subscription.example.com/link"
@pytest.mark.anyio
async def test_build_button_connect_callback_mode():
"""Тест: кнопка connect с open_mode=callback должна создавать callback кнопку."""
button_config = {
"type": "builtin",
"builtin_id": "connect",
"text": {"ru": "🔗 Подключиться"},
"action": "subscription_connect",
"open_mode": "callback",
"webapp_url": None,
}
context = MenuContext(
language="ru",
has_active_subscription=True,
subscription_is_active=True,
)
texts = MagicMock()
texts.t = lambda key, default: default
button = MenuLayoutService._build_button(button_config, context, texts)
assert button is not None
assert isinstance(button, InlineKeyboardButton)
assert button.callback_data == "subscription_connect"
assert button.web_app is None
@pytest.mark.anyio
async def test_build_button_connect_direct_mode_fallback_to_callback():
"""Тест: кнопка connect с open_mode=direct без URL должна fallback на callback."""
button_config = {
"type": "builtin",
"builtin_id": "connect",
"text": {"ru": "🔗 Подключиться"},
"action": "subscription_connect",
"open_mode": "direct",
"webapp_url": None,
}
context = MenuContext(
language="ru",
has_active_subscription=True,
subscription_is_active=True,
subscription=None, # Нет подписки
)
texts = MagicMock()
texts.t = lambda key, default: default
with patch('app.services.menu_layout.service.settings') as mock_settings:
mock_settings.MINIAPP_CUSTOM_URL = None
button = MenuLayoutService._build_button(button_config, context, texts)
assert button is not None
assert isinstance(button, InlineKeyboardButton)
# Должен fallback на callback_data, так как URL не найден
assert button.callback_data == "subscription_connect"