From 86e8ac39c3c73edfb1a91f02e6f02586593465bb Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 01:08:28 +0300 Subject: [PATCH 01/28] Handle admin ticket view after block toggles --- app/handlers/admin/tickets.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/app/handlers/admin/tickets.py b/app/handlers/admin/tickets.py index c943a5db..b1949a54 100644 --- a/app/handlers/admin/tickets.py +++ b/app/handlers/admin/tickets.py @@ -1,5 +1,5 @@ import logging -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional from aiogram import Dispatcher, types, F, Bot from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -92,10 +92,23 @@ async def view_admin_ticket( callback: types.CallbackQuery, db_user: User, db: AsyncSession, - state: FSMContext + state: Optional[FSMContext] = None, + ticket_id: Optional[int] = None ): """Показать детали тикета для админа""" - ticket_id = int(callback.data.replace("admin_view_ticket_", "")) + if ticket_id is None: + try: + ticket_id = int((callback.data or "").split("_")[-1]) + except (ValueError, AttributeError): + texts = get_texts(db_user.language) + await callback.answer( + texts.t("TICKET_NOT_FOUND", "Тикет не найден."), + show_alert=True + ) + return + + if state is None: + state = FSMContext(callback.bot, callback.from_user.id) ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=True) @@ -171,7 +184,8 @@ async def view_admin_ticket( reply_markup=keyboard, ) # сохраняем id для дальнейших действий (ответ/статусы) - await state.update_data(ticket_id=ticket_id) + if state: + await state.update_data(ticket_id=ticket_id) await callback.answer() From aaea98049eb0c6dd08f5a50d0a656be965e352ad Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 01:15:26 +0300 Subject: [PATCH 02/28] Fix FSM context usage in admin ticket handlers --- app/handlers/admin/tickets.py | 36 ++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/app/handlers/admin/tickets.py b/app/handlers/admin/tickets.py index c943a5db..c82a470d 100644 --- a/app/handlers/admin/tickets.py +++ b/app/handlers/admin/tickets.py @@ -1,5 +1,5 @@ import logging -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional from aiogram import Dispatcher, types, F, Bot from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -92,11 +92,21 @@ async def view_admin_ticket( callback: types.CallbackQuery, db_user: User, db: AsyncSession, - state: FSMContext + state: Optional[FSMContext] = None, + ticket_id: Optional[int] = None ): """Показать детали тикета для админа""" - ticket_id = int(callback.data.replace("admin_view_ticket_", "")) - + if ticket_id is None: + try: + ticket_id = int((callback.data or "").split("_")[-1]) + except (ValueError, AttributeError): + texts = get_texts(db_user.language) + await callback.answer( + texts.t("TICKET_NOT_FOUND", "Тикет не найден."), + show_alert=True + ) + return + ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=True) if not ticket: @@ -171,7 +181,8 @@ async def view_admin_ticket( reply_markup=keyboard, ) # сохраняем id для дальнейших действий (ответ/статусы) - await state.update_data(ticket_id=ticket_id) + if state: + await state.update_data(ticket_id=ticket_id) await callback.answer() @@ -332,7 +343,8 @@ async def handle_admin_ticket_reply( async def mark_ticket_as_answered( callback: types.CallbackQuery, db_user: User, - db: AsyncSession + db: AsyncSession, + state: FSMContext ): """Отметить тикет как отвеченный""" ticket_id = int(callback.data.replace("admin_mark_answered_", "")) @@ -350,7 +362,7 @@ async def mark_ticket_as_answered( ) # Обновляем сообщение - await view_admin_ticket(callback, db_user, db) + await view_admin_ticket(callback, db_user, db, state) else: texts = get_texts(db_user.language) await callback.answer( @@ -513,13 +525,14 @@ async def handle_admin_block_duration_input( async def unblock_user_in_ticket( callback: types.CallbackQuery, db_user: User, - db: AsyncSession + db: AsyncSession, + state: FSMContext ): ticket_id = int(callback.data.replace("admin_unblock_user_ticket_", "")) ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=None) if ok: await callback.answer("✅ Блок снят") - await view_admin_ticket(callback, db_user, db, FSMContext(callback.bot, callback.from_user.id)) + await view_admin_ticket(callback, db_user, db, state) else: await callback.answer("❌ Ошибка", show_alert=True) @@ -527,13 +540,14 @@ async def unblock_user_in_ticket( async def block_user_permanently( callback: types.CallbackQuery, db_user: User, - db: AsyncSession + db: AsyncSession, + state: FSMContext ): ticket_id = int(callback.data.replace("admin_block_user_perm_ticket_", "")) ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=True, until=None) if ok: await callback.answer("✅ Пользователь заблокирован навсегда") - await view_admin_ticket(callback, db_user, db, FSMContext(callback.bot, callback.from_user.id)) + await view_admin_ticket(callback, db_user, db, state) else: await callback.answer("❌ Ошибка", show_alert=True) From 1c3f2c75006891299cfd4d843a85bfc978fcfbd9 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 01:21:47 +0300 Subject: [PATCH 03/28] Update texts.py --- app/localization/texts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/localization/texts.py b/app/localization/texts.py index c8d65666..ccd8eff1 100644 --- a/app/localization/texts.py +++ b/app/localization/texts.py @@ -44,7 +44,7 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]: "TRAFFIC_250GB": f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}", "TRAFFIC_UNLIMITED": f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}", "SUPPORT_INFO": ( - "\n🛟 Поддержка RemnaWave\n\n" + "\n🛟 Поддержка\n\n" "Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n" "• 🎫 Создать тикет — опишите проблему или вопрос\n" "• 📋 Мои тикеты — статус и переписка\n" From 85f59566a666ff588dbc3d0a9c515afcbfabf6f1 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 01:27:30 +0300 Subject: [PATCH 04/28] Add missing ticket management localization strings --- locales/en.json | 15 ++++++++++++++- locales/ru.json | 2 ++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/locales/en.json b/locales/en.json index 61ad8a8a..e72fd5e4 100644 --- a/locales/en.json +++ b/locales/en.json @@ -171,7 +171,7 @@ "NO_TICKETS_ADMIN": "No tickets to display.", "ADMIN_TICKETS_TITLE": "🎫 All support tickets:", "ADMIN_TICKET_REPLY_INPUT": "Enter support reply:", - + "ADMIN_TICKET_REPLY_SENT": "✅ Reply sent!", "TICKET_MARKED_ANSWERED": "✅ Ticket marked as answered.", "TICKET_UPDATE_ERROR": "❌ Error updating ticket.", @@ -179,6 +179,19 @@ "TICKET_REPLY_NOTIFICATION": "🎫 Reply received for ticket #{ticket_id}\n\n{reply_preview}\n\nClick the button below to go to the ticket:", "CLOSE_NOTIFICATION": "❌ Close notification", "NOTIFICATION_CLOSED": "Notification closed.", + "UNBLOCK": "✅ Unblock", + "BLOCK_FOREVER": "🚫 Block permanently", + "BLOCK_BY_TIME": "⏳ Temporary block", + "ENTER_BLOCK_MINUTES": "Enter the number of minutes to block the user (e.g., 15):", + "TICKET_ATTACHMENTS": "📎 Attachments", + "OPEN_TICKETS": "🔴 Open", + "CLOSED_TICKETS": "🟢 Closed", + "OPEN_TICKETS_HEADER": "🔴 Open tickets", + "CLOSED_TICKETS_HEADER": "🟢 Closed tickets", + "SENDING_ATTACHMENTS": "📎 Sending attachments...", + "NO_ATTACHMENTS": "No attachments.", + "ATTACHMENTS_SENT": "✅ Attachments sent.", + "DELETE_MESSAGE": "🗑 Delete", "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group", "ADMIN_USER_PROMO_GROUP_TITLE": "👥 User promo group", "ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}", diff --git a/locales/ru.json b/locales/ru.json index 328c0d33..2c6c839d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -70,9 +70,11 @@ "UNBLOCK": "✅ Разблокировать", "BLOCK_FOREVER": "🚫 Блок навсегда", "BLOCK_BY_TIME": "⏳ Блокировка по времени", + "ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):", "TICKET_ATTACHMENTS": "📎 Вложения", "OPEN_TICKETS": "🔴 Открытые", "CLOSED_TICKETS": "🟢 Закрытые", + "CLOSED_TICKETS_HEADER": "🟢 Закрытые тикеты", "OPEN_TICKETS_HEADER": "🔴 Открытые тикеты", "SENDING_ATTACHMENTS": "📎 Отправляю вложения...", "NO_ATTACHMENTS": "Вложений нет.", From bfb9c5d09c7ff1d73d74de6055bad18e3092af50 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 01:37:58 +0300 Subject: [PATCH 05/28] Create docker-compose.local.yml --- docker-compose.local.yml | 98 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 docker-compose.local.yml diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 00000000..b21a85c6 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,98 @@ +services: + postgres: + image: postgres:15-alpine + container_name: remnawave_bot_db + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot} + POSTGRES_USER: ${POSTGRES_USER:-remnawave_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123} + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - bot_network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + + redis: + image: redis:7-alpine + container_name: remnawave_bot_redis + restart: unless-stopped + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + volumes: + - redis_data:/data + networks: + - bot_network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 + + bot: + build: . + container_name: remnawave_bot + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + env_file: + - .env + environment: + DOCKER_ENV: "true" + DATABASE_MODE: "auto" + POSTGRES_HOST: "postgres" + POSTGRES_PORT: "5432" + POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}" + POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}" + + REDIS_URL: "redis://redis:6379/0" + + TZ: "Europe/Moscow" + LOCALES_PATH: "${LOCALES_PATH:-/app/locales}" + volumes: + # Логи + - ./logs:/app/logs:rw + # Данные приложения (для SQLite в случае переключения) + - ./data:/app/data:rw + - ./locales:/app/locales:rw + # Конфигурация приложения + # - ./app-config.json:/app/app-config.json:ro + # Timezone + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + # Логотип для сообщений + - ./vpn_logo.png:/app/vpn_logo.png:ro + ports: + - "${TRIBUTE_WEBHOOK_PORT:-8081}:8081" + - "${YOOKASSA_WEBHOOK_PORT:-8082}:8082" + networks: + - bot_network + healthcheck: + test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8081/health\", timeout=5)' || exit 1"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + +volumes: + postgres_data: + driver: local + redis_data: + driver: local + +networks: + bot_network: + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/16 + gateway: 172.20.0.1 From 555f76a4eca7681ac4a55de71a5d33ad3ef95eb1 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 02:09:22 +0300 Subject: [PATCH 06/28] feat: enhance server status view --- app/handlers/server_status.py | 15 +++++++++++++-- app/keyboards/inline.py | 9 ++++++++- app/localization/default_locales/en.yml | 2 ++ app/localization/default_locales/ru.yml | 2 ++ app/localization/locales/en.json | 2 ++ app/localization/locales/ru.json | 2 ++ 6 files changed, 29 insertions(+), 3 deletions(-) diff --git a/app/handlers/server_status.py b/app/handlers/server_status.py index bd0663b4..c223dc5a 100644 --- a/app/handlers/server_status.py +++ b/app/handlers/server_status.py @@ -1,4 +1,5 @@ import logging +from datetime import datetime from typing import List, Tuple from aiogram import Dispatcher, F, types @@ -105,7 +106,16 @@ def _build_status_message( offline=len(offline_servers), ) - lines.extend(["", summary, ""]) + updated_at = datetime.now().strftime("%H:%M:%S") + + lines.extend( + [ + "", + summary, + texts.t("SERVER_STATUS_UPDATED_AT", "⏱ Обновлено: {time}").format(time=updated_at), + "", + ] + ) if current_online: lines.append(texts.t("SERVER_STATUS_AVAILABLE", "✅ Доступны")) @@ -183,7 +193,8 @@ def _format_server_lines( name = server.display_name or server.name flag_prefix = f"{server.flag} " if server.flag else "" - lines.append(f"> {flag_prefix}{name} — {latency_text}") + server_line = f"{flag_prefix}{name} — {latency_text}" + lines.append(f"
{server_line}
") return lines diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 033884f4..55590709 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -215,7 +215,14 @@ def get_server_status_keyboard( total_pages: int, ) -> InlineKeyboardMarkup: texts = get_texts(language) - keyboard: list[list[InlineKeyboardButton]] = [] + keyboard: list[list[InlineKeyboardButton]] = [ + [ + InlineKeyboardButton( + text=texts.t("SERVER_STATUS_REFRESH", "🔄 Обновить"), + callback_data=f"server_status_page:{current_page}", + ) + ] + ] if total_pages > 1: nav_row: list[InlineKeyboardButton] = [] diff --git a/app/localization/default_locales/en.yml b/app/localization/default_locales/en.yml index 30db758d..95bb4709 100644 --- a/app/localization/default_locales/en.yml +++ b/app/localization/default_locales/en.yml @@ -21,8 +21,10 @@ SERVER_STATUS: PAGINATION: "Page {current} of {total}" PREV_PAGE: "⬅️ Back" NEXT_PAGE: "Next ➡️" + REFRESH: "🔄 Refresh" ERROR_SHORT: "Failed to fetch data" NOT_CONFIGURED: "Feature is not available." + UPDATED_AT: "⏱ Updated at: {time}" RULES_TEXT: | Remnawave service rules: diff --git a/app/localization/default_locales/ru.yml b/app/localization/default_locales/ru.yml index ca83ca7e..fc173e56 100644 --- a/app/localization/default_locales/ru.yml +++ b/app/localization/default_locales/ru.yml @@ -21,8 +21,10 @@ SERVER_STATUS: PAGINATION: "Страница {current} из {total}" PREV_PAGE: "⬅️ Назад" NEXT_PAGE: "Вперед ➡️" + REFRESH: "🔄 Обновить" ERROR_SHORT: "Не удалось получить данные" NOT_CONFIGURED: "Функция недоступна." + UPDATED_AT: "⏱ Обновлено: {time}" RULES_TEXT: | Правила сервиса Remnawave: diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 6995e761..49417771 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -251,8 +251,10 @@ "SERVER_STATUS_OFFLINE": "no response", "SERVER_STATUS_PAGINATION": "Page {current} of {total}", "SERVER_STATUS_PREV_PAGE": "⬅️ Back", + "SERVER_STATUS_REFRESH": "🔄 Refresh", "SERVER_STATUS_SUMMARY": "Total servers: {total} (online: {online}, offline: {offline})", "SERVER_STATUS_TITLE": "📊 Server status", + "SERVER_STATUS_UPDATED_AT": "⏱ Updated at: {time}", "SERVER_STATUS_UNAVAILABLE": "❌ Offline", "SWITCH_TRAFFIC_CONFIRM": "\n🔄 Confirm traffic change\n\nCurrent limit: {current_traffic}\nNew limit: {new_traffic}\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", "SWITCH_TRAFFIC_INFO": "\n🔄 Switch traffic limit\n\nCurrent limit: {current_traffic}\nChoose the new traffic amount:\n\n💡 Important:\n• Increasing — you pay the difference proportionally to the remaining time\n• Decreasing — payments are not refunded\n• The used traffic counter is NOT reset\n", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 0fbb106e..1dc7de10 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -236,8 +236,10 @@ "SERVER_STATUS_OFFLINE": "нет ответа", "SERVER_STATUS_PAGINATION": "Страница {current} из {total}", "SERVER_STATUS_PREV_PAGE": "⬅️ Назад", + "SERVER_STATUS_REFRESH": "🔄 Обновить", "SERVER_STATUS_SUMMARY": "Всего серверов: {total} (в сети: {online}, вне сети: {offline})", "SERVER_STATUS_TITLE": "📊 Статус серверов", + "SERVER_STATUS_UPDATED_AT": "⏱ Обновлено: {time}", "SERVER_STATUS_UNAVAILABLE": "❌ Недоступны", "SWITCH_TRAFFIC_BUTTON": "🔄 Переключить трафик", "SWITCH_TRAFFIC_CONFIRM": "\n🔄 Подтверждение переключения трафика\n\nТекущий лимит: {current_traffic}\nНовый лимит: {new_traffic}\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить переключение?\n", From 1758eabe0e9004a54277b2015131012a46940078 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 02:25:18 +0300 Subject: [PATCH 07/28] Fix 14-day period description --- app/utils/pricing_utils.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index cbf3a961..f6ca0d5a 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -66,6 +66,8 @@ def format_period_description(days: int, language: str = "ru") -> str: months = calculate_months_from_days(days) if language == "ru": + if days == 14: + return "14 дней" if days == 30: return "1 месяц" elif days == 60: @@ -79,7 +81,9 @@ def format_period_description(days: int, language: str = "ru") -> str: else: month_word = "месяц" if months == 1 else ("месяца" if 2 <= months <= 4 else "месяцев") return f"{days} дней ({months} {month_word})" - else: + else: + if days == 14: + return "14 days" month_word = "month" if months == 1 else "months" return f"{days} days ({months} {month_word})" From 3930564f9bd5235459cf1019bd776fdcb9319fb8 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 02:49:16 +0300 Subject: [PATCH 08/28] Fix duplicate campaign registration assignment --- app/database/crud/campaign.py | 130 ++++++++++++++++++++++++++++++- app/handlers/admin/campaigns.py | 30 +++++++ app/handlers/admin/users.py | 65 ++++++++++++++-- app/handlers/referral.py | 11 ++- app/localization/locales/en.json | 1 + app/localization/locales/ru.json | 1 + app/services/user_service.py | 7 +- app/utils/user_utils.py | 13 +++- locales/en.json | 1 + locales/ru.json | 1 + 10 files changed, 245 insertions(+), 15 deletions(-) diff --git a/app/database/crud/campaign.py b/app/database/crud/campaign.py index 40313434..137b6c09 100644 --- a/app/database/crud/campaign.py +++ b/app/database/crud/campaign.py @@ -9,6 +9,10 @@ from sqlalchemy.orm import selectinload from app.database.models import ( AdvertisingCampaign, AdvertisingCampaignRegistration, + Transaction, + TransactionType, + Subscription, + User, ) logger = logging.getLogger(__name__) @@ -197,7 +201,7 @@ async def get_campaign_statistics( db: AsyncSession, campaign_id: int, ) -> Dict[str, Optional[int]]: - result = await db.execute( + aggregate_result = await db.execute( select( func.count(AdvertisingCampaignRegistration.id), func.coalesce( @@ -206,7 +210,7 @@ async def get_campaign_statistics( func.max(AdvertisingCampaignRegistration.created_at), ).where(AdvertisingCampaignRegistration.campaign_id == campaign_id) ) - count, total_balance, last_registration = result.one() + registrations_count, total_balance, last_registration = aggregate_result.one() subscription_count_result = await db.execute( select(func.count(AdvertisingCampaignRegistration.id)).where( @@ -217,14 +221,134 @@ async def get_campaign_statistics( ) ) + campaign_users_subquery = ( + select(AdvertisingCampaignRegistration.user_id) + .where(AdvertisingCampaignRegistration.campaign_id == campaign_id) + ) + + revenue_filter = and_( + Transaction.user_id.in_(campaign_users_subquery), + Transaction.is_completed.is_(True), + Transaction.amount_kopeks > 0, + Transaction.type.in_( + [ + TransactionType.DEPOSIT.value, + TransactionType.SUBSCRIPTION_PAYMENT.value, + ] + ), + ) + + revenue_result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + revenue_filter + ) + ) + revenue_kopeks = revenue_result.scalar() or 0 + + transactions_count_result = await db.execute( + select(func.count(Transaction.id)).where(revenue_filter) + ) + transactions_count = transactions_count_result.scalar() or 0 + + paying_users_result = await db.execute( + select(func.count(func.distinct(Transaction.user_id))).where( + revenue_filter + ) + ) + paying_users = paying_users_result.scalar() or 0 + + trial_users_result = await db.execute( + select(func.count(func.distinct(Subscription.user_id))).where( + and_( + Subscription.user_id.in_(campaign_users_subquery), + Subscription.is_trial.is_(True), + ) + ) + ) + trial_users = trial_users_result.scalar() or 0 + + paid_flag_users_result = await db.execute( + select(func.count(func.distinct(User.id))).where( + and_( + User.id.in_(campaign_users_subquery), + User.has_had_paid_subscription.is_(True), + ) + ) + ) + paid_flag_users = paid_flag_users_result.scalar() or 0 + + registrations = registrations_count or 0 + conversion_base = registrations if registrations > 0 else None + effective_paying_users = max(paying_users, paid_flag_users or 0) + + conversion_rate = ( + round((effective_paying_users / registrations) * 100, 1) + if conversion_base + else 0.0 + ) + + trial_conversion_rate = ( + round((effective_paying_users / trial_users) * 100, 1) + if trial_users + else 0.0 + ) + + avg_revenue_per_user = ( + int(round(revenue_kopeks / registrations)) if registrations else 0 + ) + avg_revenue_per_paying_user = ( + int(round(revenue_kopeks / effective_paying_users)) + if effective_paying_users + else 0 + ) + return { - "registrations": count or 0, + "registrations": registrations, "balance_issued": total_balance or 0, "subscription_issued": subscription_count_result.scalar() or 0, "last_registration": last_registration, + "revenue_kopeks": revenue_kopeks, + "transactions_count": transactions_count, + "paying_users": effective_paying_users, + "trial_users": trial_users, + "conversion_rate": conversion_rate, + "trial_conversion_rate": trial_conversion_rate, + "avg_revenue_per_user": avg_revenue_per_user, + "avg_revenue_per_paying_user": avg_revenue_per_paying_user, } +async def get_campaign_registration_by_user( + db: AsyncSession, + user_id: int, +) -> Optional[AdvertisingCampaignRegistration]: + result = await db.execute( + select(AdvertisingCampaignRegistration) + .options(selectinload(AdvertisingCampaignRegistration.campaign)) + .where(AdvertisingCampaignRegistration.user_id == user_id) + .order_by(AdvertisingCampaignRegistration.created_at.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def get_campaign_registrations_for_users( + db: AsyncSession, + user_ids: List[int], +) -> Dict[int, AdvertisingCampaignRegistration]: + if not user_ids: + return {} + + result = await db.execute( + select(AdvertisingCampaignRegistration) + .options(selectinload(AdvertisingCampaignRegistration.campaign)) + .where(AdvertisingCampaignRegistration.user_id.in_(user_ids)) + ) + + registrations = result.scalars().all() + return {registration.user_id: registration for registration in registrations} + + async def get_campaigns_overview(db: AsyncSession) -> Dict[str, int]: total = await get_campaigns_count(db) active = await get_campaigns_count(db, is_active=True) diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py index 22ff8aac..3cc8f0c2 100644 --- a/app/handlers/admin/campaigns.py +++ b/app/handlers/admin/campaigns.py @@ -333,6 +333,24 @@ async def show_campaign_detail( f"• Выдано баланса: {texts.format_price(stats['balance_issued'])}" ) text.append(f"• Выдано подписок: {stats['subscription_issued']}") + text.append(f"• Транзакций: {stats['transactions_count']}") + text.append( + f"• Доход кампании: {texts.format_price(stats['revenue_kopeks'])}" + ) + text.append( + f"• Платящих пользователей: {stats['paying_users']}" + ) + text.append(f"• Взяли триал: {stats['trial_users']}") + text.append(f"• Конверсия в оплату: {stats['conversion_rate']}%") + text.append( + f"• Конверсия триала: {stats['trial_conversion_rate']}%" + ) + text.append( + f"• Средний доход на регистрацию: {texts.format_price(stats['avg_revenue_per_user'])}" + ) + text.append( + f"• Средний доход на платящего: {texts.format_price(stats['avg_revenue_per_paying_user'])}" + ) if stats["last_registration"]: text.append( f"• Последняя: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" @@ -1176,6 +1194,18 @@ async def show_campaign_stats( text.append(f"Регистраций: {stats['registrations']}") text.append(f"Выдано баланса: {texts.format_price(stats['balance_issued'])}") text.append(f"Выдано подписок: {stats['subscription_issued']}") + text.append(f"Транзакций: {stats['transactions_count']}") + text.append(f"Доход кампании: {texts.format_price(stats['revenue_kopeks'])}") + text.append(f"Платящих пользователей: {stats['paying_users']}") + text.append(f"Взяли триал: {stats['trial_users']}") + text.append(f"Конверсия в оплату: {stats['conversion_rate']}%") + text.append(f"Конверсия триала: {stats['trial_conversion_rate']}%") + text.append( + f"Средний доход на регистрацию: {texts.format_price(stats['avg_revenue_per_user'])}" + ) + text.append( + f"Средний доход на платящего: {texts.format_price(stats['avg_revenue_per_paying_user'])}" + ) if stats["last_registration"]: text.append( f"Последняя регистрация: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 620e4001..8f7e640a 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -17,6 +17,7 @@ from app.keyboards.admin import ( from app.localization.texts import get_texts from app.services.user_service import UserService from app.database.crud.promo_group import get_promo_groups_with_counts +from app.database.crud.campaign import get_campaign_registrations_for_users from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_datetime, format_time_ago from app.services.remnawave_service import RemnaWaveService @@ -427,6 +428,7 @@ async def _render_user_subscription_overview( user = profile["user"] subscription = profile["subscription"] + campaign_registration = profile.get("campaign_registration") text = "📱 Подписка и настройки пользователя\n\n" text += f"👤 {user.full_name} (ID: {user.telegram_id})\n\n" @@ -1236,16 +1238,49 @@ async def show_user_statistics( text += f"• Отсутствует\n" text += f"\nРеферальная программа:\n" - + + registration_lines = [] + if user.referred_by_id: referrer = await get_user_by_id(db, user.referred_by_id) if referrer: - text += f"• Пришел по реферальной ссылке от {referrer.full_name}\n" + registration_lines.append( + f"• Пришел по реферальной ссылке от {referrer.full_name}" + ) else: - text += f"• Пришел по реферальной ссылке (реферер не найден)\n" - else: - text += f"• Прямая регистрация\n" - + registration_lines.append( + "• Пришел по реферальной ссылке (реферер не найден)" + ) + + if campaign_registration and campaign_registration.campaign: + campaign = campaign_registration.campaign + registration_lines.append( + f"• Пришел через рекламную кампанию {campaign.name} (не прямая регистрация)" + ) + registration_lines.append( + f"• Участие в кампании: {format_datetime(campaign_registration.created_at)}" + ) + + if campaign_registration.bonus_type == "balance": + registration_lines.append( + f"• Бонус кампании: {settings.format_price(campaign_registration.balance_bonus_kopeks)} на баланс" + ) + elif campaign_registration.bonus_type == "subscription": + bonus_parts = [] + if campaign_registration.subscription_duration_days: + bonus_parts.append( + f"{campaign_registration.subscription_duration_days} дн." + ) + registration_lines.append( + "• Бонус кампании: Подписка" + + (f" ({', '.join(bonus_parts)})" if bonus_parts else "") + ) + + if not registration_lines: + registration_lines.append("• Прямая регистрация") + + text += "\n".join(registration_lines) + "\n" + text += f"• Реферальный код: {user.referral_code}\n\n" if referral_stats['invited_count'] > 0: @@ -1257,11 +1292,15 @@ async def show_user_statistics( if referral_stats['referrals_detail']: text += f"\nДетали по рефералам:\n" - for detail in referral_stats['referrals_detail'][:5]: + for detail in referral_stats['referrals_detail'][:5]: referral_name = detail['referral_name'] earned = settings.format_price(detail['total_earned_kopeks']) status = "🟢" if detail['is_active'] else "🔴" text += f"• {status} {referral_name}: {earned}\n" + if detail.get('campaign_name'): + text += ( + f" 📣 Кампания: {detail['campaign_name']} (не прямая регистрация)\n" + ) if len(referral_stats['referrals_detail']) > 5: text += f"• ... и еще {len(referral_stats['referrals_detail']) - 5} рефералов\n" @@ -1292,6 +1331,9 @@ async def get_detailed_referral_stats(db: AsyncSession, user_id: int) -> dict: referrals_result = await db.execute(referrals_query) referrals = referrals_result.scalars().all() + + referral_ids = [referral.id for referral in referrals] + campaign_registrations = await get_campaign_registrations_for_users(db, referral_ids) earnings_by_referral = {} all_earnings = await get_referral_earnings_by_user(db, user_id) @@ -1316,6 +1358,11 @@ async def get_detailed_referral_stats(db: AsyncSession, user_id: int) -> dict: referral.subscription.end_date > current_time ) + registration = campaign_registrations.get(referral.id) + campaign_name = None + if registration and registration.campaign: + campaign_name = registration.campaign.name + referrals_detail.append({ 'referral_id': referral.id, 'referral_name': referral.full_name, @@ -1323,7 +1370,9 @@ async def get_detailed_referral_stats(db: AsyncSession, user_id: int) -> dict: 'total_earned_kopeks': earned, 'is_active': is_active, 'registration_date': referral.created_at, - 'has_subscription': bool(referral.subscription) + 'has_subscription': bool(referral.subscription), + 'campaign_name': campaign_name, + 'registration_source': 'campaign' if campaign_name else 'referral' }) referrals_detail.sort(key=lambda x: x['total_earned_kopeks'], reverse=True) diff --git a/app/handlers/referral.py b/app/handlers/referral.py index fe3322a8..d1f94a06 100644 --- a/app/handlers/referral.py +++ b/app/handlers/referral.py @@ -297,7 +297,16 @@ async def show_detailed_referral_list( "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO", " 🕐 Активность: давно", ) + "\n" - + + if ( + referral.get('registration_source') == 'campaign' + and referral.get('campaign_name') + ): + text += texts.t( + "REFERRAL_LIST_ITEM_SOURCE_CAMPAIGN", + " 📣 Источник: рекламная кампания «{name}» (не прямой реферал)", + ).format(name=referral['campaign_name']) + "\n" + text += "\n" keyboard = [] diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 49417771..655c0662 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -368,6 +368,7 @@ "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago", "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago", "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago", + "REFERRAL_LIST_ITEM_SOURCE_CAMPAIGN": " 📣 Source: advertising campaign “{name}” (not a direct referral)", "REFERRAL_LIST_PREV_PAGE": "⬅️ Back", "REFERRAL_LIST_NEXT_PAGE": "Next ➡️", "REFERRAL_ANALYTICS_TITLE": "📊 Referral analytics", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 1dc7de10..037ea386 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -368,6 +368,7 @@ "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад", "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад", "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно", + "REFERRAL_LIST_ITEM_SOURCE_CAMPAIGN": " 📣 Источник: рекламная кампания «{name}» (не прямой реферал)", "REFERRAL_LIST_PREV_PAGE": "⬅️ Назад", "REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️", "REFERRAL_ANALYTICS_TITLE": "📊 Аналитика рефералов", diff --git a/app/services/user_service.py b/app/services/user_service.py index dedd672a..e7a884d0 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -10,6 +10,7 @@ from app.database.crud.user import ( get_users_count, get_users_statistics, get_inactive_users, add_user_balance, subtract_user_balance, update_user, delete_user ) +from app.database.crud.campaign import get_campaign_registration_by_user from app.database.crud.promo_group import get_promo_group_by_id from app.database.crud.transaction import get_user_transactions_count from app.database.crud.subscription import get_subscription_by_user_id @@ -91,13 +92,15 @@ class UserService: subscription = await get_subscription_by_user_id(db, user_id) transactions_count = await get_user_transactions_count(db, user_id) - + campaign_registration = await get_campaign_registration_by_user(db, user_id) + return { "user": user, "subscription": subscription, "transactions_count": transactions_count, "is_admin": settings.is_admin(user.telegram_id), - "registration_days": (datetime.utcnow() - user.created_at).days + "registration_days": (datetime.utcnow() - user.created_at).days, + "campaign_registration": campaign_registration, } except Exception as e: diff --git a/app/utils/user_utils.py b/app/utils/user_utils.py index 8292f5e1..f663b866 100644 --- a/app/utils/user_utils.py +++ b/app/utils/user_utils.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.database.models import User, ReferralEarning, Transaction, TransactionType +from app.database.crud.campaign import get_campaign_registrations_for_users logger = logging.getLogger(__name__) @@ -164,6 +165,9 @@ async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int .limit(limit) ) referrals = referrals_result.scalars().all() + + referral_ids = [referral.id for referral in referrals] + campaign_registrations = await get_campaign_registrations_for_users(db, referral_ids) total_count_result = await db.execute( select(func.count(User.id)).where(User.referred_by_id == user_id) @@ -201,6 +205,11 @@ async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int if referral.last_activity: days_since_activity = (datetime.utcnow() - referral.last_activity).days + registration = campaign_registrations.get(referral.id) + campaign_name = None + if registration and registration.campaign: + campaign_name = registration.campaign.name + detailed_referrals.append({ 'id': referral.id, 'telegram_id': referral.telegram_id, @@ -214,7 +223,9 @@ async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int 'topups_count': topups_count, 'days_since_registration': days_since_registration, 'days_since_activity': days_since_activity, - 'status': 'active' if days_since_activity is not None and days_since_activity <= 30 else 'inactive' + 'status': 'active' if days_since_activity is not None and days_since_activity <= 30 else 'inactive', + 'campaign_name': campaign_name, + 'registration_source': 'campaign' if campaign_name else 'referral' }) return { diff --git a/locales/en.json b/locales/en.json index e72fd5e4..0cd0cb46 100644 --- a/locales/en.json +++ b/locales/en.json @@ -414,6 +414,7 @@ "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago", "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago", "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago", + "REFERRAL_LIST_ITEM_SOURCE_CAMPAIGN": " 📣 Source: advertising campaign “{name}” (not a direct referral)", "REFERRAL_LIST_PREV_PAGE": "⬅️ Back", "REFERRAL_LIST_NEXT_PAGE": "Next ➡️", "REFERRAL_ANALYTICS_TITLE": "📊 Referral analytics", diff --git a/locales/ru.json b/locales/ru.json index 2c6c839d..f41d8cc3 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -414,6 +414,7 @@ "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад", "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад", "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно", + "REFERRAL_LIST_ITEM_SOURCE_CAMPAIGN": " 📣 Источник: рекламная кампания «{name}» (не прямой реферал)", "REFERRAL_LIST_PREV_PAGE": "⬅️ Назад", "REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️", "REFERRAL_ANALYTICS_TITLE": "📊 Аналитика рефералов", From 8dfe2f2934c75eca65a4f4118f82753de256e413 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 02:53:52 +0300 Subject: [PATCH 09/28] Revert "Enhance campaign analytics and registration visibility" --- app/database/crud/campaign.py | 130 +------------------------------ app/handlers/admin/campaigns.py | 30 ------- app/handlers/admin/users.py | 65 ++-------------- app/handlers/referral.py | 11 +-- app/localization/locales/en.json | 1 - app/localization/locales/ru.json | 1 - app/services/user_service.py | 7 +- app/utils/user_utils.py | 13 +--- locales/en.json | 1 - locales/ru.json | 1 - 10 files changed, 15 insertions(+), 245 deletions(-) diff --git a/app/database/crud/campaign.py b/app/database/crud/campaign.py index 137b6c09..40313434 100644 --- a/app/database/crud/campaign.py +++ b/app/database/crud/campaign.py @@ -9,10 +9,6 @@ from sqlalchemy.orm import selectinload from app.database.models import ( AdvertisingCampaign, AdvertisingCampaignRegistration, - Transaction, - TransactionType, - Subscription, - User, ) logger = logging.getLogger(__name__) @@ -201,7 +197,7 @@ async def get_campaign_statistics( db: AsyncSession, campaign_id: int, ) -> Dict[str, Optional[int]]: - aggregate_result = await db.execute( + result = await db.execute( select( func.count(AdvertisingCampaignRegistration.id), func.coalesce( @@ -210,7 +206,7 @@ async def get_campaign_statistics( func.max(AdvertisingCampaignRegistration.created_at), ).where(AdvertisingCampaignRegistration.campaign_id == campaign_id) ) - registrations_count, total_balance, last_registration = aggregate_result.one() + count, total_balance, last_registration = result.one() subscription_count_result = await db.execute( select(func.count(AdvertisingCampaignRegistration.id)).where( @@ -221,134 +217,14 @@ async def get_campaign_statistics( ) ) - campaign_users_subquery = ( - select(AdvertisingCampaignRegistration.user_id) - .where(AdvertisingCampaignRegistration.campaign_id == campaign_id) - ) - - revenue_filter = and_( - Transaction.user_id.in_(campaign_users_subquery), - Transaction.is_completed.is_(True), - Transaction.amount_kopeks > 0, - Transaction.type.in_( - [ - TransactionType.DEPOSIT.value, - TransactionType.SUBSCRIPTION_PAYMENT.value, - ] - ), - ) - - revenue_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( - revenue_filter - ) - ) - revenue_kopeks = revenue_result.scalar() or 0 - - transactions_count_result = await db.execute( - select(func.count(Transaction.id)).where(revenue_filter) - ) - transactions_count = transactions_count_result.scalar() or 0 - - paying_users_result = await db.execute( - select(func.count(func.distinct(Transaction.user_id))).where( - revenue_filter - ) - ) - paying_users = paying_users_result.scalar() or 0 - - trial_users_result = await db.execute( - select(func.count(func.distinct(Subscription.user_id))).where( - and_( - Subscription.user_id.in_(campaign_users_subquery), - Subscription.is_trial.is_(True), - ) - ) - ) - trial_users = trial_users_result.scalar() or 0 - - paid_flag_users_result = await db.execute( - select(func.count(func.distinct(User.id))).where( - and_( - User.id.in_(campaign_users_subquery), - User.has_had_paid_subscription.is_(True), - ) - ) - ) - paid_flag_users = paid_flag_users_result.scalar() or 0 - - registrations = registrations_count or 0 - conversion_base = registrations if registrations > 0 else None - effective_paying_users = max(paying_users, paid_flag_users or 0) - - conversion_rate = ( - round((effective_paying_users / registrations) * 100, 1) - if conversion_base - else 0.0 - ) - - trial_conversion_rate = ( - round((effective_paying_users / trial_users) * 100, 1) - if trial_users - else 0.0 - ) - - avg_revenue_per_user = ( - int(round(revenue_kopeks / registrations)) if registrations else 0 - ) - avg_revenue_per_paying_user = ( - int(round(revenue_kopeks / effective_paying_users)) - if effective_paying_users - else 0 - ) - return { - "registrations": registrations, + "registrations": count or 0, "balance_issued": total_balance or 0, "subscription_issued": subscription_count_result.scalar() or 0, "last_registration": last_registration, - "revenue_kopeks": revenue_kopeks, - "transactions_count": transactions_count, - "paying_users": effective_paying_users, - "trial_users": trial_users, - "conversion_rate": conversion_rate, - "trial_conversion_rate": trial_conversion_rate, - "avg_revenue_per_user": avg_revenue_per_user, - "avg_revenue_per_paying_user": avg_revenue_per_paying_user, } -async def get_campaign_registration_by_user( - db: AsyncSession, - user_id: int, -) -> Optional[AdvertisingCampaignRegistration]: - result = await db.execute( - select(AdvertisingCampaignRegistration) - .options(selectinload(AdvertisingCampaignRegistration.campaign)) - .where(AdvertisingCampaignRegistration.user_id == user_id) - .order_by(AdvertisingCampaignRegistration.created_at.desc()) - .limit(1) - ) - return result.scalar_one_or_none() - - -async def get_campaign_registrations_for_users( - db: AsyncSession, - user_ids: List[int], -) -> Dict[int, AdvertisingCampaignRegistration]: - if not user_ids: - return {} - - result = await db.execute( - select(AdvertisingCampaignRegistration) - .options(selectinload(AdvertisingCampaignRegistration.campaign)) - .where(AdvertisingCampaignRegistration.user_id.in_(user_ids)) - ) - - registrations = result.scalars().all() - return {registration.user_id: registration for registration in registrations} - - async def get_campaigns_overview(db: AsyncSession) -> Dict[str, int]: total = await get_campaigns_count(db) active = await get_campaigns_count(db, is_active=True) diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py index 3cc8f0c2..22ff8aac 100644 --- a/app/handlers/admin/campaigns.py +++ b/app/handlers/admin/campaigns.py @@ -333,24 +333,6 @@ async def show_campaign_detail( f"• Выдано баланса: {texts.format_price(stats['balance_issued'])}" ) text.append(f"• Выдано подписок: {stats['subscription_issued']}") - text.append(f"• Транзакций: {stats['transactions_count']}") - text.append( - f"• Доход кампании: {texts.format_price(stats['revenue_kopeks'])}" - ) - text.append( - f"• Платящих пользователей: {stats['paying_users']}" - ) - text.append(f"• Взяли триал: {stats['trial_users']}") - text.append(f"• Конверсия в оплату: {stats['conversion_rate']}%") - text.append( - f"• Конверсия триала: {stats['trial_conversion_rate']}%" - ) - text.append( - f"• Средний доход на регистрацию: {texts.format_price(stats['avg_revenue_per_user'])}" - ) - text.append( - f"• Средний доход на платящего: {texts.format_price(stats['avg_revenue_per_paying_user'])}" - ) if stats["last_registration"]: text.append( f"• Последняя: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" @@ -1194,18 +1176,6 @@ async def show_campaign_stats( text.append(f"Регистраций: {stats['registrations']}") text.append(f"Выдано баланса: {texts.format_price(stats['balance_issued'])}") text.append(f"Выдано подписок: {stats['subscription_issued']}") - text.append(f"Транзакций: {stats['transactions_count']}") - text.append(f"Доход кампании: {texts.format_price(stats['revenue_kopeks'])}") - text.append(f"Платящих пользователей: {stats['paying_users']}") - text.append(f"Взяли триал: {stats['trial_users']}") - text.append(f"Конверсия в оплату: {stats['conversion_rate']}%") - text.append(f"Конверсия триала: {stats['trial_conversion_rate']}%") - text.append( - f"Средний доход на регистрацию: {texts.format_price(stats['avg_revenue_per_user'])}" - ) - text.append( - f"Средний доход на платящего: {texts.format_price(stats['avg_revenue_per_paying_user'])}" - ) if stats["last_registration"]: text.append( f"Последняя регистрация: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 8f7e640a..620e4001 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -17,7 +17,6 @@ from app.keyboards.admin import ( from app.localization.texts import get_texts from app.services.user_service import UserService from app.database.crud.promo_group import get_promo_groups_with_counts -from app.database.crud.campaign import get_campaign_registrations_for_users from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_datetime, format_time_ago from app.services.remnawave_service import RemnaWaveService @@ -428,7 +427,6 @@ async def _render_user_subscription_overview( user = profile["user"] subscription = profile["subscription"] - campaign_registration = profile.get("campaign_registration") text = "📱 Подписка и настройки пользователя\n\n" text += f"👤 {user.full_name} (ID: {user.telegram_id})\n\n" @@ -1238,49 +1236,16 @@ async def show_user_statistics( text += f"• Отсутствует\n" text += f"\nРеферальная программа:\n" - - registration_lines = [] - + if user.referred_by_id: referrer = await get_user_by_id(db, user.referred_by_id) if referrer: - registration_lines.append( - f"• Пришел по реферальной ссылке от {referrer.full_name}" - ) + text += f"• Пришел по реферальной ссылке от {referrer.full_name}\n" else: - registration_lines.append( - "• Пришел по реферальной ссылке (реферер не найден)" - ) - - if campaign_registration and campaign_registration.campaign: - campaign = campaign_registration.campaign - registration_lines.append( - f"• Пришел через рекламную кампанию {campaign.name} (не прямая регистрация)" - ) - registration_lines.append( - f"• Участие в кампании: {format_datetime(campaign_registration.created_at)}" - ) - - if campaign_registration.bonus_type == "balance": - registration_lines.append( - f"• Бонус кампании: {settings.format_price(campaign_registration.balance_bonus_kopeks)} на баланс" - ) - elif campaign_registration.bonus_type == "subscription": - bonus_parts = [] - if campaign_registration.subscription_duration_days: - bonus_parts.append( - f"{campaign_registration.subscription_duration_days} дн." - ) - registration_lines.append( - "• Бонус кампании: Подписка" - + (f" ({', '.join(bonus_parts)})" if bonus_parts else "") - ) - - if not registration_lines: - registration_lines.append("• Прямая регистрация") - - text += "\n".join(registration_lines) + "\n" - + text += f"• Пришел по реферальной ссылке (реферер не найден)\n" + else: + text += f"• Прямая регистрация\n" + text += f"• Реферальный код: {user.referral_code}\n\n" if referral_stats['invited_count'] > 0: @@ -1292,15 +1257,11 @@ async def show_user_statistics( if referral_stats['referrals_detail']: text += f"\nДетали по рефералам:\n" - for detail in referral_stats['referrals_detail'][:5]: + for detail in referral_stats['referrals_detail'][:5]: referral_name = detail['referral_name'] earned = settings.format_price(detail['total_earned_kopeks']) status = "🟢" if detail['is_active'] else "🔴" text += f"• {status} {referral_name}: {earned}\n" - if detail.get('campaign_name'): - text += ( - f" 📣 Кампания: {detail['campaign_name']} (не прямая регистрация)\n" - ) if len(referral_stats['referrals_detail']) > 5: text += f"• ... и еще {len(referral_stats['referrals_detail']) - 5} рефералов\n" @@ -1331,9 +1292,6 @@ async def get_detailed_referral_stats(db: AsyncSession, user_id: int) -> dict: referrals_result = await db.execute(referrals_query) referrals = referrals_result.scalars().all() - - referral_ids = [referral.id for referral in referrals] - campaign_registrations = await get_campaign_registrations_for_users(db, referral_ids) earnings_by_referral = {} all_earnings = await get_referral_earnings_by_user(db, user_id) @@ -1358,11 +1316,6 @@ async def get_detailed_referral_stats(db: AsyncSession, user_id: int) -> dict: referral.subscription.end_date > current_time ) - registration = campaign_registrations.get(referral.id) - campaign_name = None - if registration and registration.campaign: - campaign_name = registration.campaign.name - referrals_detail.append({ 'referral_id': referral.id, 'referral_name': referral.full_name, @@ -1370,9 +1323,7 @@ async def get_detailed_referral_stats(db: AsyncSession, user_id: int) -> dict: 'total_earned_kopeks': earned, 'is_active': is_active, 'registration_date': referral.created_at, - 'has_subscription': bool(referral.subscription), - 'campaign_name': campaign_name, - 'registration_source': 'campaign' if campaign_name else 'referral' + 'has_subscription': bool(referral.subscription) }) referrals_detail.sort(key=lambda x: x['total_earned_kopeks'], reverse=True) diff --git a/app/handlers/referral.py b/app/handlers/referral.py index d1f94a06..fe3322a8 100644 --- a/app/handlers/referral.py +++ b/app/handlers/referral.py @@ -297,16 +297,7 @@ async def show_detailed_referral_list( "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO", " 🕐 Активность: давно", ) + "\n" - - if ( - referral.get('registration_source') == 'campaign' - and referral.get('campaign_name') - ): - text += texts.t( - "REFERRAL_LIST_ITEM_SOURCE_CAMPAIGN", - " 📣 Источник: рекламная кампания «{name}» (не прямой реферал)", - ).format(name=referral['campaign_name']) + "\n" - + text += "\n" keyboard = [] diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 655c0662..49417771 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -368,7 +368,6 @@ "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago", "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago", "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago", - "REFERRAL_LIST_ITEM_SOURCE_CAMPAIGN": " 📣 Source: advertising campaign “{name}” (not a direct referral)", "REFERRAL_LIST_PREV_PAGE": "⬅️ Back", "REFERRAL_LIST_NEXT_PAGE": "Next ➡️", "REFERRAL_ANALYTICS_TITLE": "📊 Referral analytics", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 037ea386..1dc7de10 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -368,7 +368,6 @@ "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад", "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад", "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно", - "REFERRAL_LIST_ITEM_SOURCE_CAMPAIGN": " 📣 Источник: рекламная кампания «{name}» (не прямой реферал)", "REFERRAL_LIST_PREV_PAGE": "⬅️ Назад", "REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️", "REFERRAL_ANALYTICS_TITLE": "📊 Аналитика рефералов", diff --git a/app/services/user_service.py b/app/services/user_service.py index e7a884d0..dedd672a 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -10,7 +10,6 @@ from app.database.crud.user import ( get_users_count, get_users_statistics, get_inactive_users, add_user_balance, subtract_user_balance, update_user, delete_user ) -from app.database.crud.campaign import get_campaign_registration_by_user from app.database.crud.promo_group import get_promo_group_by_id from app.database.crud.transaction import get_user_transactions_count from app.database.crud.subscription import get_subscription_by_user_id @@ -92,15 +91,13 @@ class UserService: subscription = await get_subscription_by_user_id(db, user_id) transactions_count = await get_user_transactions_count(db, user_id) - campaign_registration = await get_campaign_registration_by_user(db, user_id) - + return { "user": user, "subscription": subscription, "transactions_count": transactions_count, "is_admin": settings.is_admin(user.telegram_id), - "registration_days": (datetime.utcnow() - user.created_at).days, - "campaign_registration": campaign_registration, + "registration_days": (datetime.utcnow() - user.created_at).days } except Exception as e: diff --git a/app/utils/user_utils.py b/app/utils/user_utils.py index f663b866..8292f5e1 100644 --- a/app/utils/user_utils.py +++ b/app/utils/user_utils.py @@ -8,7 +8,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.database.models import User, ReferralEarning, Transaction, TransactionType -from app.database.crud.campaign import get_campaign_registrations_for_users logger = logging.getLogger(__name__) @@ -165,9 +164,6 @@ async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int .limit(limit) ) referrals = referrals_result.scalars().all() - - referral_ids = [referral.id for referral in referrals] - campaign_registrations = await get_campaign_registrations_for_users(db, referral_ids) total_count_result = await db.execute( select(func.count(User.id)).where(User.referred_by_id == user_id) @@ -205,11 +201,6 @@ async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int if referral.last_activity: days_since_activity = (datetime.utcnow() - referral.last_activity).days - registration = campaign_registrations.get(referral.id) - campaign_name = None - if registration and registration.campaign: - campaign_name = registration.campaign.name - detailed_referrals.append({ 'id': referral.id, 'telegram_id': referral.telegram_id, @@ -223,9 +214,7 @@ async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int 'topups_count': topups_count, 'days_since_registration': days_since_registration, 'days_since_activity': days_since_activity, - 'status': 'active' if days_since_activity is not None and days_since_activity <= 30 else 'inactive', - 'campaign_name': campaign_name, - 'registration_source': 'campaign' if campaign_name else 'referral' + 'status': 'active' if days_since_activity is not None and days_since_activity <= 30 else 'inactive' }) return { diff --git a/locales/en.json b/locales/en.json index 0cd0cb46..e72fd5e4 100644 --- a/locales/en.json +++ b/locales/en.json @@ -414,7 +414,6 @@ "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago", "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago", "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago", - "REFERRAL_LIST_ITEM_SOURCE_CAMPAIGN": " 📣 Source: advertising campaign “{name}” (not a direct referral)", "REFERRAL_LIST_PREV_PAGE": "⬅️ Back", "REFERRAL_LIST_NEXT_PAGE": "Next ➡️", "REFERRAL_ANALYTICS_TITLE": "📊 Referral analytics", diff --git a/locales/ru.json b/locales/ru.json index f41d8cc3..2c6c839d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -414,7 +414,6 @@ "REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад", "REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад", "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно", - "REFERRAL_LIST_ITEM_SOURCE_CAMPAIGN": " 📣 Источник: рекламная кампания «{name}» (не прямой реферал)", "REFERRAL_LIST_PREV_PAGE": "⬅️ Назад", "REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️", "REFERRAL_ANALYTICS_TITLE": "📊 Аналитика рефералов", From 97c8ddf4e1ee79dc7fda5a15b49231b0f3e3f2d3 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 02:54:19 +0300 Subject: [PATCH 10/28] Add advertising campaign stats to admin user info --- app/database/crud/campaign.py | 93 +++++++++++++++++++++++++++++++++ app/handlers/admin/campaigns.py | 29 ++++++++++ app/handlers/admin/users.py | 81 ++++++++++++++++++++++++++-- 3 files changed, 198 insertions(+), 5 deletions(-) diff --git a/app/database/crud/campaign.py b/app/database/crud/campaign.py index 40313434..b7c9e10d 100644 --- a/app/database/crud/campaign.py +++ b/app/database/crud/campaign.py @@ -9,6 +9,12 @@ from sqlalchemy.orm import selectinload from app.database.models import ( AdvertisingCampaign, AdvertisingCampaignRegistration, + Subscription, + SubscriptionConversion, + SubscriptionStatus, + Transaction, + TransactionType, + User, ) logger = logging.getLogger(__name__) @@ -157,6 +163,19 @@ async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bo return True +async def get_campaign_registration_by_user( + db: AsyncSession, + user_id: int, +) -> Optional[AdvertisingCampaignRegistration]: + result = await db.execute( + select(AdvertisingCampaignRegistration) + .options(selectinload(AdvertisingCampaignRegistration.campaign)) + .where(AdvertisingCampaignRegistration.user_id == user_id) + .limit(1) + ) + return result.scalar_one_or_none() + + async def record_campaign_registration( db: AsyncSession, *, @@ -197,6 +216,11 @@ async def get_campaign_statistics( db: AsyncSession, campaign_id: int, ) -> Dict[str, Optional[int]]: + registrations_query = select(AdvertisingCampaignRegistration.user_id).where( + AdvertisingCampaignRegistration.campaign_id == campaign_id + ) + registrations_subquery = registrations_query.subquery() + result = await db.execute( select( func.count(AdvertisingCampaignRegistration.id), @@ -217,11 +241,80 @@ async def get_campaign_statistics( ) ) + deposits_result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + Transaction.user_id.in_(select(registrations_subquery.c.user_id)), + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.is_completed.is_(True), + ) + ) + total_revenue = deposits_result.scalar() or 0 + + trials_result = await db.execute( + select(func.count(func.distinct(Subscription.user_id))).where( + Subscription.user_id.in_(select(registrations_subquery.c.user_id)), + Subscription.is_trial.is_(True), + ) + ) + trial_users_count = trials_result.scalar() or 0 + + active_trials_result = await db.execute( + select(func.count(func.distinct(Subscription.user_id))).where( + Subscription.user_id.in_(select(registrations_subquery.c.user_id)), + Subscription.is_trial.is_(True), + Subscription.status == SubscriptionStatus.ACTIVE.value, + ) + ) + active_trials_count = active_trials_result.scalar() or 0 + + conversions_result = await db.execute( + select(func.count(func.distinct(SubscriptionConversion.user_id))).where( + SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id)) + ) + ) + conversion_count = conversions_result.scalar() or 0 + + paid_users_result = await db.execute( + select(func.count(User.id)).where( + User.id.in_(select(registrations_subquery.c.user_id)), + User.has_had_paid_subscription.is_(True), + ) + ) + paid_users_count = paid_users_result.scalar() or 0 + + avg_first_payment_result = await db.execute( + select(func.coalesce(func.avg(SubscriptionConversion.first_payment_amount_kopeks), 0)).where( + SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id)) + ) + ) + avg_first_payment = int(avg_first_payment_result.scalar() or 0) + + conversion_rate = 0.0 + if count: + conversion_rate = round((paid_users_count / count) * 100, 1) + + trial_conversion_rate = 0.0 + if trial_users_count: + trial_conversion_rate = round((conversion_count / trial_users_count) * 100, 1) + + avg_revenue_per_user = 0 + if count: + avg_revenue_per_user = int(total_revenue / count) + return { "registrations": count or 0, "balance_issued": total_balance or 0, "subscription_issued": subscription_count_result.scalar() or 0, "last_registration": last_registration, + "total_revenue_kopeks": total_revenue, + "trial_users_count": trial_users_count, + "active_trials_count": active_trials_count, + "conversion_count": conversion_count, + "paid_users_count": paid_users_count, + "conversion_rate": conversion_rate, + "trial_conversion_rate": trial_conversion_rate, + "avg_revenue_per_user_kopeks": avg_revenue_per_user, + "avg_first_payment_kopeks": avg_first_payment, } diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py index 22ff8aac..f6b9b325 100644 --- a/app/handlers/admin/campaigns.py +++ b/app/handlers/admin/campaigns.py @@ -333,6 +333,35 @@ async def show_campaign_detail( f"• Выдано баланса: {texts.format_price(stats['balance_issued'])}" ) text.append(f"• Выдано подписок: {stats['subscription_issued']}") + text.append( + f"• Доход: {texts.format_price(stats['total_revenue_kopeks'])}" + ) + text.append( + "• Получили триал: " + f"{stats['trial_users_count']}" + f" (активно: {stats['active_trials_count']})" + ) + text.append( + "• Конверсий в оплату: " + f"{stats['conversion_count']}" + f" / пользователей с оплатой: {stats['paid_users_count']}" + ) + text.append( + "• Конверсия в оплату: " + f"{stats['conversion_rate']:.1f}%" + ) + text.append( + "• Конверсия триала: " + f"{stats['trial_conversion_rate']:.1f}%" + ) + text.append( + "• Средний доход на пользователя: " + f"{texts.format_price(stats['avg_revenue_per_user_kopeks'])}" + ) + text.append( + "• Средний первый платеж: " + f"{texts.format_price(stats['avg_first_payment_kopeks'])}" + ) if stats["last_registration"]: text.append( f"• Последняя: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 620e4001..45fe983f 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -8,7 +8,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.states import AdminStates from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType -from app.database.crud.user import get_user_by_id +from app.database.crud.user import get_user_by_id +from app.database.crud.campaign import ( + get_campaign_registration_by_user, + get_campaign_statistics, +) from app.keyboards.admin import ( get_admin_users_keyboard, get_user_management_keyboard, get_admin_pagination_keyboard, get_confirmation_keyboard, @@ -1214,6 +1218,10 @@ async def show_user_statistics( subscription = profile["subscription"] referral_stats = await get_detailed_referral_stats(db, user.id) + campaign_registration = await get_campaign_registration_by_user(db, user.id) + campaign_stats = None + if campaign_registration: + campaign_stats = await get_campaign_statistics(db, campaign_registration.campaign_id) text = f"📊 Статистика пользователя\n\n" text += f"👤 {user.full_name} (ID: {user.telegram_id})\n\n" @@ -1236,17 +1244,80 @@ async def show_user_statistics( text += f"• Отсутствует\n" text += f"\nРеферальная программа:\n" - + if user.referred_by_id: referrer = await get_user_by_id(db, user.referred_by_id) if referrer: text += f"• Пришел по реферальной ссылке от {referrer.full_name}\n" else: - text += f"• Пришел по реферальной ссылке (реферер не найден)\n" + text += "• Пришел по реферальной ссылке (реферер не найден)\n" + if campaign_registration and campaign_registration.campaign: + text += ( + "• Дополнительно зарегистрирован через кампанию " + f"{campaign_registration.campaign.name}\n" + ) + elif campaign_registration and campaign_registration.campaign: + text += ( + "• Регистрация через рекламную кампанию " + f"{campaign_registration.campaign.name}\n" + ) + if campaign_registration.created_at: + text += ( + "• Дата регистрации по кампании: " + f"{campaign_registration.created_at.strftime('%d.%m.%Y %H:%M')}\n" + ) else: - text += f"• Прямая регистрация\n" - + text += "• Прямая регистрация\n" + text += f"• Реферальный код: {user.referral_code}\n\n" + + if campaign_registration and campaign_registration.campaign and campaign_stats: + text += "Рекламная кампания:\n" + text += ( + "• Название: " + f"{campaign_registration.campaign.name}" + ) + if campaign_registration.campaign.start_parameter: + text += ( + " (параметр: " + f"{campaign_registration.campaign.start_parameter})" + ) + text += "\n" + text += ( + "• Всего регистраций: " + f"{campaign_stats['registrations']}\n" + ) + text += ( + "• Суммарный доход: " + f"{settings.format_price(campaign_stats['total_revenue_kopeks'])}\n" + ) + text += ( + "• Получили триал: " + f"{campaign_stats['trial_users_count']}" + f" (активно: {campaign_stats['active_trials_count']})\n" + ) + text += ( + "• Конверсий в оплату: " + f"{campaign_stats['conversion_count']}" + f" (оплативших пользователей: {campaign_stats['paid_users_count']})\n" + ) + text += ( + "• Конверсия в оплату: " + f"{campaign_stats['conversion_rate']:.1f}%\n" + ) + text += ( + "• Конверсия триала: " + f"{campaign_stats['trial_conversion_rate']:.1f}%\n" + ) + text += ( + "• Средний доход на пользователя: " + f"{settings.format_price(campaign_stats['avg_revenue_per_user_kopeks'])}\n" + ) + text += ( + "• Средний первый платеж: " + f"{settings.format_price(campaign_stats['avg_first_payment_kopeks'])}\n" + ) + text += "\n" if referral_stats['invited_count'] > 0: text += f"Доходы от рефералов:\n" From 17e39856710cf3aa3cee0269eef9da1a68a88a0c Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 03:08:15 +0300 Subject: [PATCH 11/28] Fix campaign stats to include subscription payments --- app/database/crud/campaign.py | 175 +++++++++++++++++++++++++++++++- app/handlers/admin/campaigns.py | 29 ++++++ app/handlers/admin/users.py | 81 ++++++++++++++- 3 files changed, 277 insertions(+), 8 deletions(-) diff --git a/app/database/crud/campaign.py b/app/database/crud/campaign.py index 40313434..54df3299 100644 --- a/app/database/crud/campaign.py +++ b/app/database/crud/campaign.py @@ -9,6 +9,12 @@ from sqlalchemy.orm import selectinload from app.database.models import ( AdvertisingCampaign, AdvertisingCampaignRegistration, + Subscription, + SubscriptionConversion, + SubscriptionStatus, + Transaction, + TransactionType, + User, ) logger = logging.getLogger(__name__) @@ -157,6 +163,19 @@ async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bo return True +async def get_campaign_registration_by_user( + db: AsyncSession, + user_id: int, +) -> Optional[AdvertisingCampaignRegistration]: + result = await db.execute( + select(AdvertisingCampaignRegistration) + .options(selectinload(AdvertisingCampaignRegistration.campaign)) + .where(AdvertisingCampaignRegistration.user_id == user_id) + .limit(1) + ) + return result.scalar_one_or_none() + + async def record_campaign_registration( db: AsyncSession, *, @@ -197,6 +216,11 @@ async def get_campaign_statistics( db: AsyncSession, campaign_id: int, ) -> Dict[str, Optional[int]]: + registrations_query = select(AdvertisingCampaignRegistration.user_id).where( + AdvertisingCampaignRegistration.campaign_id == campaign_id + ) + registrations_subquery = registrations_query.subquery() + result = await db.execute( select( func.count(AdvertisingCampaignRegistration.id), @@ -207,6 +231,8 @@ async def get_campaign_statistics( ).where(AdvertisingCampaignRegistration.campaign_id == campaign_id) ) count, total_balance, last_registration = result.one() + count = count or 0 + total_balance = total_balance or 0 subscription_count_result = await db.execute( select(func.count(AdvertisingCampaignRegistration.id)).where( @@ -216,12 +242,155 @@ async def get_campaign_statistics( ) ) ) + subscription_bonuses_issued = subscription_count_result.scalar() or 0 + + deposits_result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + Transaction.user_id.in_(select(registrations_subquery.c.user_id)), + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.is_completed.is_(True), + ) + ) + deposits_total = deposits_result.scalar() or 0 + + trials_result = await db.execute( + select(func.count(func.distinct(Subscription.user_id))).where( + Subscription.user_id.in_(select(registrations_subquery.c.user_id)), + Subscription.is_trial.is_(True), + ) + ) + trial_users_count = trials_result.scalar() or 0 + + active_trials_result = await db.execute( + select(func.count(func.distinct(Subscription.user_id))).where( + Subscription.user_id.in_(select(registrations_subquery.c.user_id)), + Subscription.is_trial.is_(True), + Subscription.status == SubscriptionStatus.ACTIVE.value, + ) + ) + active_trials_count = active_trials_result.scalar() or 0 + + conversions_result = await db.execute( + select(func.count(func.distinct(SubscriptionConversion.user_id))).where( + SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id)) + ) + ) + conversion_count = conversions_result.scalar() or 0 + + paid_users_result = await db.execute( + select(func.count(User.id)).where( + User.id.in_(select(registrations_subquery.c.user_id)), + User.has_had_paid_subscription.is_(True), + ) + ) + paid_users_from_flag = paid_users_result.scalar() or 0 + + conversions_rows = await db.execute( + select( + SubscriptionConversion.user_id, + SubscriptionConversion.first_payment_amount_kopeks, + SubscriptionConversion.converted_at, + ) + .where( + SubscriptionConversion.user_id.in_( + select(registrations_subquery.c.user_id) + ) + ) + .order_by(SubscriptionConversion.converted_at) + ) + conversion_entries = conversions_rows.all() + + subscription_payments_rows = await db.execute( + select( + Transaction.user_id, + Transaction.amount_kopeks, + Transaction.created_at, + ) + .where( + Transaction.user_id.in_(select(registrations_subquery.c.user_id)), + Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, + Transaction.is_completed.is_(True), + ) + .order_by(Transaction.user_id, Transaction.created_at) + ) + subscription_payments = subscription_payments_rows.all() + + subscription_payments_total = 0 + paid_users_from_transactions = set() + conversion_user_ids = set() + first_payment_amount_by_user: Dict[int, int] = {} + first_payment_time_by_user: Dict[int, Optional[datetime]] = {} + + for user_id, amount_kopeks, converted_at in conversion_entries: + conversion_user_ids.add(user_id) + amount_value = int(amount_kopeks or 0) + first_payment_amount_by_user[user_id] = amount_value + first_payment_time_by_user[user_id] = converted_at + + for user_id, amount_kopeks, created_at in subscription_payments: + amount_value = int(amount_kopeks or 0) + subscription_payments_total += amount_value + paid_users_from_transactions.add(user_id) + + if user_id not in first_payment_amount_by_user: + first_payment_amount_by_user[user_id] = amount_value + first_payment_time_by_user[user_id] = created_at + else: + existing_time = first_payment_time_by_user.get(user_id) + if existing_time is None and created_at is not None: + first_payment_amount_by_user[user_id] = amount_value + first_payment_time_by_user[user_id] = created_at + elif ( + existing_time is not None + and created_at is not None + and created_at < existing_time + ): + first_payment_amount_by_user[user_id] = amount_value + first_payment_time_by_user[user_id] = created_at + + total_revenue = deposits_total + subscription_payments_total + + paid_user_ids = set(paid_users_from_transactions) + paid_user_ids.update(conversion_user_ids) + paid_users_count = max(len(paid_user_ids), paid_users_from_flag) + + conversion_count = conversion_count or len(paid_user_ids) + if conversion_count < len(paid_user_ids): + conversion_count = len(paid_user_ids) + + avg_first_payment = 0 + if first_payment_amount_by_user: + avg_first_payment = int( + sum(first_payment_amount_by_user.values()) + / len(first_payment_amount_by_user) + ) + + conversion_rate = 0.0 + if count: + conversion_rate = round((paid_users_count / count) * 100, 1) + + trial_conversion_rate = 0.0 + if trial_users_count: + trial_conversion_rate = round((conversion_count / trial_users_count) * 100, 1) + + avg_revenue_per_user = 0 + if count: + avg_revenue_per_user = int(total_revenue / count) return { - "registrations": count or 0, - "balance_issued": total_balance or 0, - "subscription_issued": subscription_count_result.scalar() or 0, + "registrations": count, + "balance_issued": total_balance, + "subscription_issued": subscription_bonuses_issued, "last_registration": last_registration, + "total_revenue_kopeks": total_revenue, + "trial_users_count": trial_users_count, + "active_trials_count": active_trials_count, + "conversion_count": conversion_count, + "paid_users_count": paid_users_count, + "conversion_rate": conversion_rate, + "trial_conversion_rate": trial_conversion_rate, + "avg_revenue_per_user_kopeks": avg_revenue_per_user, + "avg_first_payment_kopeks": avg_first_payment, } diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py index 22ff8aac..f6b9b325 100644 --- a/app/handlers/admin/campaigns.py +++ b/app/handlers/admin/campaigns.py @@ -333,6 +333,35 @@ async def show_campaign_detail( f"• Выдано баланса: {texts.format_price(stats['balance_issued'])}" ) text.append(f"• Выдано подписок: {stats['subscription_issued']}") + text.append( + f"• Доход: {texts.format_price(stats['total_revenue_kopeks'])}" + ) + text.append( + "• Получили триал: " + f"{stats['trial_users_count']}" + f" (активно: {stats['active_trials_count']})" + ) + text.append( + "• Конверсий в оплату: " + f"{stats['conversion_count']}" + f" / пользователей с оплатой: {stats['paid_users_count']}" + ) + text.append( + "• Конверсия в оплату: " + f"{stats['conversion_rate']:.1f}%" + ) + text.append( + "• Конверсия триала: " + f"{stats['trial_conversion_rate']:.1f}%" + ) + text.append( + "• Средний доход на пользователя: " + f"{texts.format_price(stats['avg_revenue_per_user_kopeks'])}" + ) + text.append( + "• Средний первый платеж: " + f"{texts.format_price(stats['avg_first_payment_kopeks'])}" + ) if stats["last_registration"]: text.append( f"• Последняя: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 620e4001..45fe983f 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -8,7 +8,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.states import AdminStates from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType -from app.database.crud.user import get_user_by_id +from app.database.crud.user import get_user_by_id +from app.database.crud.campaign import ( + get_campaign_registration_by_user, + get_campaign_statistics, +) from app.keyboards.admin import ( get_admin_users_keyboard, get_user_management_keyboard, get_admin_pagination_keyboard, get_confirmation_keyboard, @@ -1214,6 +1218,10 @@ async def show_user_statistics( subscription = profile["subscription"] referral_stats = await get_detailed_referral_stats(db, user.id) + campaign_registration = await get_campaign_registration_by_user(db, user.id) + campaign_stats = None + if campaign_registration: + campaign_stats = await get_campaign_statistics(db, campaign_registration.campaign_id) text = f"📊 Статистика пользователя\n\n" text += f"👤 {user.full_name} (ID: {user.telegram_id})\n\n" @@ -1236,17 +1244,80 @@ async def show_user_statistics( text += f"• Отсутствует\n" text += f"\nРеферальная программа:\n" - + if user.referred_by_id: referrer = await get_user_by_id(db, user.referred_by_id) if referrer: text += f"• Пришел по реферальной ссылке от {referrer.full_name}\n" else: - text += f"• Пришел по реферальной ссылке (реферер не найден)\n" + text += "• Пришел по реферальной ссылке (реферер не найден)\n" + if campaign_registration and campaign_registration.campaign: + text += ( + "• Дополнительно зарегистрирован через кампанию " + f"{campaign_registration.campaign.name}\n" + ) + elif campaign_registration and campaign_registration.campaign: + text += ( + "• Регистрация через рекламную кампанию " + f"{campaign_registration.campaign.name}\n" + ) + if campaign_registration.created_at: + text += ( + "• Дата регистрации по кампании: " + f"{campaign_registration.created_at.strftime('%d.%m.%Y %H:%M')}\n" + ) else: - text += f"• Прямая регистрация\n" - + text += "• Прямая регистрация\n" + text += f"• Реферальный код: {user.referral_code}\n\n" + + if campaign_registration and campaign_registration.campaign and campaign_stats: + text += "Рекламная кампания:\n" + text += ( + "• Название: " + f"{campaign_registration.campaign.name}" + ) + if campaign_registration.campaign.start_parameter: + text += ( + " (параметр: " + f"{campaign_registration.campaign.start_parameter})" + ) + text += "\n" + text += ( + "• Всего регистраций: " + f"{campaign_stats['registrations']}\n" + ) + text += ( + "• Суммарный доход: " + f"{settings.format_price(campaign_stats['total_revenue_kopeks'])}\n" + ) + text += ( + "• Получили триал: " + f"{campaign_stats['trial_users_count']}" + f" (активно: {campaign_stats['active_trials_count']})\n" + ) + text += ( + "• Конверсий в оплату: " + f"{campaign_stats['conversion_count']}" + f" (оплативших пользователей: {campaign_stats['paid_users_count']})\n" + ) + text += ( + "• Конверсия в оплату: " + f"{campaign_stats['conversion_rate']:.1f}%\n" + ) + text += ( + "• Конверсия триала: " + f"{campaign_stats['trial_conversion_rate']:.1f}%\n" + ) + text += ( + "• Средний доход на пользователя: " + f"{settings.format_price(campaign_stats['avg_revenue_per_user_kopeks'])}\n" + ) + text += ( + "• Средний первый платеж: " + f"{settings.format_price(campaign_stats['avg_first_payment_kopeks'])}\n" + ) + text += "\n" if referral_stats['invited_count'] > 0: text += f"Доходы от рефералов:\n" From cdbeca245f03d1d9789d5b6b3fc1d7f9956b7c9f Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 03:21:02 +0300 Subject: [PATCH 12/28] Add broadcast filters for expired and zero traffic subscriptions --- app/handlers/admin/messages.py | 114 +++++++++++++++++++++++++++------ app/keyboards/admin.py | 7 +- 2 files changed, 100 insertions(+), 21 deletions(-) diff --git a/app/handlers/admin/messages.py b/app/handlers/admin/messages.py index 83880515..49036553 100644 --- a/app/handlers/admin/messages.py +++ b/app/handlers/admin/messages.py @@ -2,6 +2,7 @@ import logging import asyncio from datetime import datetime, timedelta from typing import Optional + from aiogram import Dispatcher, types, F from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -260,14 +261,18 @@ async def select_broadcast_target( state: FSMContext, db: AsyncSession ): - target = callback.data.split('_')[-1] - + target = callback.data.replace("broadcast_", "", 1) + target_names = { "all": "Всем пользователям", "active": "С активной подпиской", - "trial": "С триальной подпиской", + "trial": "С триальной подпиской", "no": "Без подписки", - "expiring": "С истекающей подпиской" + "no_sub": "Без подписки", + "expiring": "С истекающей подпиской", + "expired": "С истекшей подпиской", + "active_zero_traffic": "Активная подписка без трафика", + "trial_zero_traffic": "Триальная подписка без трафика", } user_count = await get_target_users_count(db, target) @@ -817,22 +822,87 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int: async def get_target_users(db: AsyncSession, target: str) -> list: - if target == "all": - return await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - elif target == "active": - users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - return [user for user in users if user.subscription and user.subscription.is_active and not user.subscription.is_trial] - elif target == "trial": - users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - return [user for user in users if user.subscription and user.subscription.is_trial] - elif target == "no": - users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - return [user for user in users if not user.subscription or not user.subscription.is_active] - elif target == "expiring": - expiring_subs = await get_expiring_subscriptions(db, 3) - return [sub.user for sub in expiring_subs if sub.user] - else: - return [] + active_users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) + + if target == "all": + return active_users + if target == "active": + return [ + user + for user in active_users + if user.subscription + and user.subscription.is_active + and not user.subscription.is_trial + ] + if target == "trial": + return [ + user + for user in active_users + if user.subscription + and user.subscription.is_trial + and not user.subscription.is_expired + ] + if target in {"no", "no_sub"}: + return [ + user + for user in active_users + if not user.subscription + or ( + not user.subscription.is_active + and not ( + user.subscription.is_trial and not user.subscription.is_expired + ) + ) + ] + if target == "expiring": + expiring_subs = await get_expiring_subscriptions(db, 3) + active_user_ids = {user.id for user in active_users} + return [ + sub.user + for sub in expiring_subs + if sub.user and sub.user.id in active_user_ids + ] + if target == "expired": + return [ + user + for user in active_users + if user.subscription and user.subscription.is_expired + ] + if target == "active_zero_traffic": + return [ + user + for user in active_users + if user.subscription + and user.subscription.is_active + and not user.subscription.is_trial + and _subscription_has_zero_traffic(user.subscription) + ] + if target == "trial_zero_traffic": + return [ + user + for user in active_users + if user.subscription + and user.subscription.is_trial + and not user.subscription.is_expired + and _subscription_has_zero_traffic(user.subscription) + ] + + return [] + + +ZERO_TRAFFIC_EPSILON = 0.01 + + +def _subscription_has_zero_traffic(subscription: Optional[Subscription]) -> bool: + if not subscription: + return False + + limit = subscription.traffic_limit_gb or 0 + if limit == 0: + return False + + used = subscription.traffic_used_gb or 0.0 + return used >= float(limit) - ZERO_TRAFFIC_EPSILON async def get_custom_users_count(db: AsyncSession, criteria: str) -> int: @@ -956,7 +1026,11 @@ def get_target_name(target_type: str) -> str: "active": "С активной подпиской", "trial": "С триальной подпиской", "no": "Без подписки", + "no_sub": "Без подписки", "expiring": "С истекающей подпиской", + "expired": "С истекшей подпиской", + "active_zero_traffic": "Активная подписка без трафика", + "trial_zero_traffic": "Триальная подписка без трафика", "custom_today": "Зарегистрированные сегодня", "custom_week": "Зарегистрированные за неделю", "custom_month": "Зарегистрированные за месяц", diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index c7a3a480..3727b693 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -554,7 +554,12 @@ def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup: InlineKeyboardButton(text="❌ Без подписки", callback_data="broadcast_no_sub") ], [ - InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring") + InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring"), + InlineKeyboardButton(text="⛔️ Истекшие", callback_data="broadcast_expired") + ], + [ + InlineKeyboardButton(text="🛑 Активные (0 ГБ)", callback_data="broadcast_active_zero_traffic"), + InlineKeyboardButton(text="🛑 Триал (0 ГБ)", callback_data="broadcast_trial_zero_traffic") ], [ InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages") From badf679bc3b63a727eed7df1b2c401372eecb975 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 03:23:12 +0300 Subject: [PATCH 13/28] Revert "Add broadcast filters for expired and zero traffic subscriptions" --- app/handlers/admin/messages.py | 114 ++++++--------------------------- app/keyboards/admin.py | 7 +- 2 files changed, 21 insertions(+), 100 deletions(-) diff --git a/app/handlers/admin/messages.py b/app/handlers/admin/messages.py index 49036553..83880515 100644 --- a/app/handlers/admin/messages.py +++ b/app/handlers/admin/messages.py @@ -2,7 +2,6 @@ import logging import asyncio from datetime import datetime, timedelta from typing import Optional - from aiogram import Dispatcher, types, F from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -261,18 +260,14 @@ async def select_broadcast_target( state: FSMContext, db: AsyncSession ): - target = callback.data.replace("broadcast_", "", 1) - + target = callback.data.split('_')[-1] + target_names = { "all": "Всем пользователям", "active": "С активной подпиской", - "trial": "С триальной подпиской", + "trial": "С триальной подпиской", "no": "Без подписки", - "no_sub": "Без подписки", - "expiring": "С истекающей подпиской", - "expired": "С истекшей подпиской", - "active_zero_traffic": "Активная подписка без трафика", - "trial_zero_traffic": "Триальная подписка без трафика", + "expiring": "С истекающей подпиской" } user_count = await get_target_users_count(db, target) @@ -822,87 +817,22 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int: async def get_target_users(db: AsyncSession, target: str) -> list: - active_users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - - if target == "all": - return active_users - if target == "active": - return [ - user - for user in active_users - if user.subscription - and user.subscription.is_active - and not user.subscription.is_trial - ] - if target == "trial": - return [ - user - for user in active_users - if user.subscription - and user.subscription.is_trial - and not user.subscription.is_expired - ] - if target in {"no", "no_sub"}: - return [ - user - for user in active_users - if not user.subscription - or ( - not user.subscription.is_active - and not ( - user.subscription.is_trial and not user.subscription.is_expired - ) - ) - ] - if target == "expiring": - expiring_subs = await get_expiring_subscriptions(db, 3) - active_user_ids = {user.id for user in active_users} - return [ - sub.user - for sub in expiring_subs - if sub.user and sub.user.id in active_user_ids - ] - if target == "expired": - return [ - user - for user in active_users - if user.subscription and user.subscription.is_expired - ] - if target == "active_zero_traffic": - return [ - user - for user in active_users - if user.subscription - and user.subscription.is_active - and not user.subscription.is_trial - and _subscription_has_zero_traffic(user.subscription) - ] - if target == "trial_zero_traffic": - return [ - user - for user in active_users - if user.subscription - and user.subscription.is_trial - and not user.subscription.is_expired - and _subscription_has_zero_traffic(user.subscription) - ] - - return [] - - -ZERO_TRAFFIC_EPSILON = 0.01 - - -def _subscription_has_zero_traffic(subscription: Optional[Subscription]) -> bool: - if not subscription: - return False - - limit = subscription.traffic_limit_gb or 0 - if limit == 0: - return False - - used = subscription.traffic_used_gb or 0.0 - return used >= float(limit) - ZERO_TRAFFIC_EPSILON + if target == "all": + return await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) + elif target == "active": + users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) + return [user for user in users if user.subscription and user.subscription.is_active and not user.subscription.is_trial] + elif target == "trial": + users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) + return [user for user in users if user.subscription and user.subscription.is_trial] + elif target == "no": + users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) + return [user for user in users if not user.subscription or not user.subscription.is_active] + elif target == "expiring": + expiring_subs = await get_expiring_subscriptions(db, 3) + return [sub.user for sub in expiring_subs if sub.user] + else: + return [] async def get_custom_users_count(db: AsyncSession, criteria: str) -> int: @@ -1026,11 +956,7 @@ def get_target_name(target_type: str) -> str: "active": "С активной подпиской", "trial": "С триальной подпиской", "no": "Без подписки", - "no_sub": "Без подписки", "expiring": "С истекающей подпиской", - "expired": "С истекшей подпиской", - "active_zero_traffic": "Активная подписка без трафика", - "trial_zero_traffic": "Триальная подписка без трафика", "custom_today": "Зарегистрированные сегодня", "custom_week": "Зарегистрированные за неделю", "custom_month": "Зарегистрированные за месяц", diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 3727b693..c7a3a480 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -554,12 +554,7 @@ def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup: InlineKeyboardButton(text="❌ Без подписки", callback_data="broadcast_no_sub") ], [ - InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring"), - InlineKeyboardButton(text="⛔️ Истекшие", callback_data="broadcast_expired") - ], - [ - InlineKeyboardButton(text="🛑 Активные (0 ГБ)", callback_data="broadcast_active_zero_traffic"), - InlineKeyboardButton(text="🛑 Триал (0 ГБ)", callback_data="broadcast_trial_zero_traffic") + InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring") ], [ InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages") From d6fb26d425c9abfb600ccea5027e5c989970101f Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 03:24:28 +0300 Subject: [PATCH 14/28] Add extended broadcast filters for subscription states --- app/handlers/admin/messages.py | 107 +++++++++++++++++++++++++++------ app/keyboards/admin.py | 7 ++- 2 files changed, 94 insertions(+), 20 deletions(-) diff --git a/app/handlers/admin/messages.py b/app/handlers/admin/messages.py index 83880515..6f6eef66 100644 --- a/app/handlers/admin/messages.py +++ b/app/handlers/admin/messages.py @@ -9,7 +9,13 @@ from sqlalchemy import select, func, and_, or_ from app.config import settings from app.states import AdminStates -from app.database.models import User, UserStatus, Subscription, BroadcastHistory +from app.database.models import ( + User, + UserStatus, + Subscription, + SubscriptionStatus, + BroadcastHistory, +) from app.keyboards.admin import ( get_admin_messages_keyboard, get_broadcast_target_keyboard, get_custom_criteria_keyboard, get_broadcast_history_keyboard, @@ -265,9 +271,12 @@ async def select_broadcast_target( target_names = { "all": "Всем пользователям", "active": "С активной подпиской", - "trial": "С триальной подпиской", + "trial": "С триальной подпиской", "no": "Без подписки", - "expiring": "С истекающей подпиской" + "expiring": "С истекающей подпиской", + "expired": "С истекшей подпиской", + "active_zero": "Активная подписка, трафик 0 ГБ", + "trial_zero": "Триальная подписка, трафик 0 ГБ", } user_count = await get_target_users_count(db, target) @@ -817,22 +826,79 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int: async def get_target_users(db: AsyncSession, target: str) -> list: - if target == "all": - return await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - elif target == "active": - users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - return [user for user in users if user.subscription and user.subscription.is_active and not user.subscription.is_trial] - elif target == "trial": - users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - return [user for user in users if user.subscription and user.subscription.is_trial] - elif target == "no": - users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - return [user for user in users if not user.subscription or not user.subscription.is_active] - elif target == "expiring": - expiring_subs = await get_expiring_subscriptions(db, 3) - return [sub.user for sub in expiring_subs if sub.user] - else: - return [] + users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) + + if target == "all": + return users + + if target == "active": + return [ + user + for user in users + if user.subscription + and user.subscription.is_active + and not user.subscription.is_trial + ] + + if target == "trial": + return [ + user + for user in users + if user.subscription and user.subscription.is_trial + ] + + if target == "no": + return [ + user + for user in users + if not user.subscription or not user.subscription.is_active + ] + + if target == "expiring": + expiring_subs = await get_expiring_subscriptions(db, 3) + return [sub.user for sub in expiring_subs if sub.user] + + if target == "expired": + 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 == "active_zero": + return [ + user + for user in users + if user.subscription + and not user.subscription.is_trial + and user.subscription.is_active + and (user.subscription.traffic_used_gb or 0) <= 0 + ] + + if target == "trial_zero": + return [ + user + for user in users + if user.subscription + and user.subscription.is_trial + and user.subscription.is_active + and (user.subscription.traffic_used_gb or 0) <= 0 + ] + + return [] async def get_custom_users_count(db: AsyncSession, criteria: str) -> int: @@ -957,6 +1023,9 @@ def get_target_name(target_type: str) -> str: "trial": "С триальной подпиской", "no": "Без подписки", "expiring": "С истекающей подпиской", + "expired": "С истекшей подпиской", + "active_zero": "Активная подписка, трафик 0 ГБ", + "trial_zero": "Триальная подписка, трафик 0 ГБ", "custom_today": "Зарегистрированные сегодня", "custom_week": "Зарегистрированные за неделю", "custom_month": "Зарегистрированные за месяц", diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index c7a3a480..df1db350 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -554,7 +554,12 @@ def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup: InlineKeyboardButton(text="❌ Без подписки", callback_data="broadcast_no_sub") ], [ - InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring") + InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring"), + InlineKeyboardButton(text="🔚 Истекшие", callback_data="broadcast_expired") + ], + [ + InlineKeyboardButton(text="🧊 Активна 0 ГБ", callback_data="broadcast_active_zero"), + InlineKeyboardButton(text="🥶 Триал 0 ГБ", callback_data="broadcast_trial_zero") ], [ InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages") From 2a7728a76ca8c12ec80e8706f9bc54472aed4856 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 03:30:37 +0300 Subject: [PATCH 15/28] Adjust zero-traffic audience filters --- app/handlers/admin/messages.py | 110 +++++++++++++++++++++++++++------ app/keyboards/admin.py | 7 ++- 2 files changed, 97 insertions(+), 20 deletions(-) diff --git a/app/handlers/admin/messages.py b/app/handlers/admin/messages.py index 83880515..2bb952ca 100644 --- a/app/handlers/admin/messages.py +++ b/app/handlers/admin/messages.py @@ -9,7 +9,13 @@ from sqlalchemy import select, func, and_, or_ from app.config import settings from app.states import AdminStates -from app.database.models import User, UserStatus, Subscription, BroadcastHistory +from app.database.models import ( + User, + UserStatus, + Subscription, + SubscriptionStatus, + BroadcastHistory, +) from app.keyboards.admin import ( get_admin_messages_keyboard, get_broadcast_target_keyboard, get_custom_criteria_keyboard, get_broadcast_history_keyboard, @@ -265,9 +271,12 @@ async def select_broadcast_target( target_names = { "all": "Всем пользователям", "active": "С активной подпиской", - "trial": "С триальной подпиской", + "trial": "С триальной подпиской", "no": "Без подписки", - "expiring": "С истекающей подпиской" + "expiring": "С истекающей подпиской", + "expired": "С истекшей подпиской", + "active_zero": "Активная подписка, трафик 0 ГБ", + "trial_zero": "Триальная подписка, трафик 0 ГБ", } user_count = await get_target_users_count(db, target) @@ -817,22 +826,82 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int: async def get_target_users(db: AsyncSession, target: str) -> list: - if target == "all": - return await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - elif target == "active": - users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - return [user for user in users if user.subscription and user.subscription.is_active and not user.subscription.is_trial] - elif target == "trial": - users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - return [user for user in users if user.subscription and user.subscription.is_trial] - elif target == "no": - users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - return [user for user in users if not user.subscription or not user.subscription.is_active] - elif target == "expiring": - expiring_subs = await get_expiring_subscriptions(db, 3) - return [sub.user for sub in expiring_subs if sub.user] - else: - return [] + users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) + now = datetime.utcnow() + zero_threshold = 0.01 + + if target == "all": + return users + + if target == "active": + return [ + user + for user in users + if user.subscription + and user.subscription.is_active + and not user.subscription.is_trial + ] + + if target == "trial": + return [ + user + for user in users + if user.subscription and user.subscription.is_trial + ] + + if target == "no": + return [ + user + for user in users + if not user.subscription or not user.subscription.is_active + ] + + if target == "expiring": + expiring_subs = await get_expiring_subscriptions(db, 3) + return [sub.user for sub in expiring_subs if sub.user] + + if target == "expired": + 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 == "active_zero": + return [ + user + for user in users + if user.subscription + and not user.subscription.is_trial + and user.subscription.status == SubscriptionStatus.ACTIVE.value + and user.subscription.end_date > now + and (user.subscription.traffic_used_gb or 0) <= zero_threshold + ] + + if target == "trial_zero": + return [ + user + for user in users + if user.subscription + and user.subscription.is_trial + and user.subscription.status == SubscriptionStatus.TRIAL.value + and user.subscription.end_date > now + and (user.subscription.traffic_used_gb or 0) <= zero_threshold + ] + + return [] async def get_custom_users_count(db: AsyncSession, criteria: str) -> int: @@ -957,6 +1026,9 @@ def get_target_name(target_type: str) -> str: "trial": "С триальной подпиской", "no": "Без подписки", "expiring": "С истекающей подпиской", + "expired": "С истекшей подпиской", + "active_zero": "Активная подписка, трафик 0 ГБ", + "trial_zero": "Триальная подписка, трафик 0 ГБ", "custom_today": "Зарегистрированные сегодня", "custom_week": "Зарегистрированные за неделю", "custom_month": "Зарегистрированные за месяц", diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index c7a3a480..df1db350 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -554,7 +554,12 @@ def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup: InlineKeyboardButton(text="❌ Без подписки", callback_data="broadcast_no_sub") ], [ - InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring") + InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring"), + InlineKeyboardButton(text="🔚 Истекшие", callback_data="broadcast_expired") + ], + [ + InlineKeyboardButton(text="🧊 Активна 0 ГБ", callback_data="broadcast_active_zero"), + InlineKeyboardButton(text="🥶 Триал 0 ГБ", callback_data="broadcast_trial_zero") ], [ InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages") From 0d218980f791aab51f8b60f748071f62c28d45ae Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 03:32:31 +0300 Subject: [PATCH 16/28] Revert "Adjust zero-traffic audience filters" --- app/handlers/admin/messages.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/app/handlers/admin/messages.py b/app/handlers/admin/messages.py index 8b6e1693..6f6eef66 100644 --- a/app/handlers/admin/messages.py +++ b/app/handlers/admin/messages.py @@ -827,8 +827,6 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int: async def get_target_users(db: AsyncSession, target: str) -> list: users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - now = datetime.utcnow() - zero_threshold = 0.01 if target == "all": return users @@ -886,9 +884,6 @@ async def get_target_users(db: AsyncSession, target: str) -> list: for user in users if user.subscription and not user.subscription.is_trial - and user.subscription.status == SubscriptionStatus.ACTIVE.value - and user.subscription.end_date > now - and (user.subscription.traffic_used_gb or 0) <= zero_threshold and user.subscription.is_active and (user.subscription.traffic_used_gb or 0) <= 0 ] @@ -899,9 +894,6 @@ async def get_target_users(db: AsyncSession, target: str) -> list: for user in users if user.subscription and user.subscription.is_trial - and user.subscription.status == SubscriptionStatus.TRIAL.value - and user.subscription.end_date > now - and (user.subscription.traffic_used_gb or 0) <= zero_threshold and user.subscription.is_active and (user.subscription.traffic_used_gb or 0) <= 0 ] From 0256203f444beb564a4f4fe702b4886f63711e62 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 03:33:16 +0300 Subject: [PATCH 17/28] Fix broadcast target parsing for zero-traffic filters --- app/handlers/admin/messages.py | 126 +++++++++++++++++++++++++++------ app/keyboards/admin.py | 7 +- 2 files changed, 111 insertions(+), 22 deletions(-) diff --git a/app/handlers/admin/messages.py b/app/handlers/admin/messages.py index 83880515..cc79292b 100644 --- a/app/handlers/admin/messages.py +++ b/app/handlers/admin/messages.py @@ -9,7 +9,13 @@ from sqlalchemy import select, func, and_, or_ from app.config import settings from app.states import AdminStates -from app.database.models import User, UserStatus, Subscription, BroadcastHistory +from app.database.models import ( + User, + UserStatus, + Subscription, + SubscriptionStatus, + BroadcastHistory, +) from app.keyboards.admin import ( get_admin_messages_keyboard, get_broadcast_target_keyboard, get_custom_criteria_keyboard, get_broadcast_history_keyboard, @@ -260,14 +266,21 @@ async def select_broadcast_target( state: FSMContext, db: AsyncSession ): - target = callback.data.split('_')[-1] - + raw_target = callback.data[len("broadcast_"):] + target_aliases = { + "no_sub": "no", + } + target = target_aliases.get(raw_target, raw_target) + target_names = { "all": "Всем пользователям", "active": "С активной подпиской", - "trial": "С триальной подпиской", + "trial": "С триальной подпиской", "no": "Без подписки", - "expiring": "С истекающей подпиской" + "expiring": "С истекающей подпиской", + "expired": "С истекшей подпиской", + "active_zero": "Активная подписка, трафик 0 ГБ", + "trial_zero": "Триальная подписка, трафик 0 ГБ", } user_count = await get_target_users_count(db, target) @@ -817,22 +830,88 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int: async def get_target_users(db: AsyncSession, target: str) -> list: - if target == "all": - return await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - elif target == "active": - users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - return [user for user in users if user.subscription and user.subscription.is_active and not user.subscription.is_trial] - elif target == "trial": - users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - return [user for user in users if user.subscription and user.subscription.is_trial] - elif target == "no": - users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - return [user for user in users if not user.subscription or not user.subscription.is_active] - elif target == "expiring": - expiring_subs = await get_expiring_subscriptions(db, 3) - return [sub.user for sub in expiring_subs if sub.user] - else: - return [] + users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) + + if target == "all": + return users + + if target == "active": + return [ + user + for user in users + if user.subscription + and user.subscription.is_active + and not user.subscription.is_trial + ] + + if target == "trial": + return [ + user + for user in users + if user.subscription and user.subscription.is_trial + ] + + if target == "no": + return [ + user + for user in users + if not user.subscription or not user.subscription.is_active + ] + + if target == "expiring": + expiring_subs = await get_expiring_subscriptions(db, 3) + return [sub.user for sub in expiring_subs if sub.user] + + if target == "expired": + 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 == "active_zero": + return [ + user + for user in users + if user.subscription + and not user.subscription.is_trial + and user.subscription.is_active + and (user.subscription.traffic_used_gb or 0) <= 0 + ] + + if target == "trial_zero": + return [ + user + for user in users + if user.subscription + and user.subscription.is_trial + and user.subscription.is_active + and (user.subscription.traffic_used_gb or 0) <= 0 + ] + + if target == "zero": + return [ + user + for user in users + if user.subscription + and user.subscription.is_active + and (user.subscription.traffic_used_gb or 0) <= 0 + ] + + return [] async def get_custom_users_count(db: AsyncSession, criteria: str) -> int: @@ -956,7 +1035,12 @@ def get_target_name(target_type: str) -> str: "active": "С активной подпиской", "trial": "С триальной подпиской", "no": "Без подписки", + "sub": "Без подписки", "expiring": "С истекающей подпиской", + "expired": "С истекшей подпиской", + "active_zero": "Активная подписка, трафик 0 ГБ", + "trial_zero": "Триальная подписка, трафик 0 ГБ", + "zero": "Подписка, трафик 0 ГБ", "custom_today": "Зарегистрированные сегодня", "custom_week": "Зарегистрированные за неделю", "custom_month": "Зарегистрированные за месяц", diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index c7a3a480..df1db350 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -554,7 +554,12 @@ def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup: InlineKeyboardButton(text="❌ Без подписки", callback_data="broadcast_no_sub") ], [ - InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring") + InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring"), + InlineKeyboardButton(text="🔚 Истекшие", callback_data="broadcast_expired") + ], + [ + InlineKeyboardButton(text="🧊 Активна 0 ГБ", callback_data="broadcast_active_zero"), + InlineKeyboardButton(text="🥶 Триал 0 ГБ", callback_data="broadcast_trial_zero") ], [ InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages") From 7f62af6955c2bfb58f64e8727fa3d5f78df7b292 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 03:53:00 +0300 Subject: [PATCH 18/28] feat: support auto promo group assignment by spending --- app/database/crud/promo_group.py | 23 +- app/database/crud/transaction.py | 17 +- app/database/models.py | 2 + app/database/universal_migration.py | 72 +++++ app/handlers/admin/promo_groups.py | 258 +++++++++++++++++- app/handlers/admin/users.py | 2 + app/handlers/subscription.py | 30 +- app/localization/locales/en.json | 8 + app/localization/locales/ru.json | 8 + app/services/promo_group_service.py | 63 +++++ app/states.py | 4 + locales/en.json | 8 + locales/ru.json | 8 + ...77e2a9d_add_auto_assign_to_promo_groups.py | 88 ++++++ 14 files changed, 577 insertions(+), 14 deletions(-) create mode 100644 app/services/promo_group_service.py create mode 100644 migrations/alembic/versions/b6b5c77e2a9d_add_auto_assign_to_promo_groups.py diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 3845f531..619cb494 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -40,12 +40,16 @@ async def create_promo_group( server_discount_percent: int, traffic_discount_percent: int, device_discount_percent: int, + auto_assign_enabled: bool = False, + spent_threshold_kopeks: int = 0, ) -> PromoGroup: promo_group = PromoGroup( name=name.strip(), server_discount_percent=max(0, min(100, server_discount_percent)), traffic_discount_percent=max(0, min(100, traffic_discount_percent)), device_discount_percent=max(0, min(100, device_discount_percent)), + auto_assign_enabled=auto_assign_enabled, + spent_threshold_kopeks=max(0, spent_threshold_kopeks), is_default=False, ) @@ -54,11 +58,13 @@ async def create_promo_group( await db.refresh(promo_group) logger.info( - "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%)", + "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%), авто=%s, порог=%s", promo_group.name, promo_group.server_discount_percent, promo_group.traffic_discount_percent, promo_group.device_discount_percent, + promo_group.auto_assign_enabled, + promo_group.spent_threshold_kopeks, ) return promo_group @@ -72,6 +78,8 @@ async def update_promo_group( server_discount_percent: Optional[int] = None, traffic_discount_percent: Optional[int] = None, device_discount_percent: Optional[int] = None, + auto_assign_enabled: Optional[bool] = None, + spent_threshold_kopeks: Optional[int] = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -81,6 +89,10 @@ async def update_promo_group( group.traffic_discount_percent = max(0, min(100, traffic_discount_percent)) if device_discount_percent is not None: group.device_discount_percent = max(0, min(100, device_discount_percent)) + if auto_assign_enabled is not None: + group.auto_assign_enabled = bool(auto_assign_enabled) + if spent_threshold_kopeks is not None: + group.spent_threshold_kopeks = max(0, spent_threshold_kopeks) await db.commit() await db.refresh(group) @@ -93,6 +105,15 @@ async def update_promo_group( return group +async def get_auto_assign_promo_groups(db: AsyncSession) -> List[PromoGroup]: + result = await db.execute( + select(PromoGroup) + .where(PromoGroup.auto_assign_enabled.is_(True)) + .order_by(PromoGroup.spent_threshold_kopeks.desc(), PromoGroup.id) + ) + return result.scalars().all() + + async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool: if group.is_default: logger.warning("Попытка удалить базовую промогруппу запрещена") diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index b258f1b2..3614b631 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -111,7 +111,7 @@ async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Tr async def get_pending_transactions(db: AsyncSession) -> List[Transaction]: - + result = await db.execute( select(Transaction) .options(selectinload(Transaction.user)) @@ -121,6 +121,21 @@ async def get_pending_transactions(db: AsyncSession) -> List[Transaction]: return result.scalars().all() +async def get_user_total_spent(db: AsyncSession, user_id: int) -> int: + result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where( + and_( + Transaction.user_id == user_id, + Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, + Transaction.is_completed == True, + ) + ) + ) + total = result.scalar() + return total or 0 + + async def get_transactions_statistics( db: AsyncSession, start_date: Optional[datetime] = None, diff --git a/app/database/models.py b/app/database/models.py index 2491eef2..e689b1da 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -163,6 +163,8 @@ class PromoGroup(Base): server_discount_percent = Column(Integer, nullable=False, default=0) traffic_discount_percent = Column(Integer, nullable=False, default=0) device_discount_percent = Column(Integer, nullable=False, default=0) + auto_assign_enabled = Column(Boolean, nullable=False, default=False) + spent_threshold_kopeks = Column(Integer, nullable=False, default=0) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 2b1136ca..aa68e45f 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -435,6 +435,68 @@ async def ensure_promo_groups_setup(): f"Не удалось добавить уникальное ограничение uq_promo_groups_name: {e}" ) + auto_assign_exists = await check_column_exists( + "promo_groups", "auto_assign_enabled" + ) + + if not auto_assign_exists: + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN auto_assign_enabled BOOLEAN NOT NULL DEFAULT 0" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN auto_assign_enabled BOOLEAN NOT NULL DEFAULT FALSE" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN auto_assign_enabled TINYINT(1) NOT NULL DEFAULT 0" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для promo_groups.auto_assign_enabled: {db_type}" + ) + return False + + logger.info("Добавлена колонка promo_groups.auto_assign_enabled") + + threshold_exists = await check_column_exists( + "promo_groups", "spent_threshold_kopeks" + ) + + if not threshold_exists: + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN spent_threshold_kopeks INTEGER NOT NULL DEFAULT 0" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN spent_threshold_kopeks INTEGER NOT NULL DEFAULT 0" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN spent_threshold_kopeks INT NOT NULL DEFAULT 0" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для promo_groups.spent_threshold_kopeks: {db_type}" + ) + return False + + logger.info("Добавлена колонка promo_groups.spent_threshold_kopeks") + column_exists = await check_column_exists("users", "promo_group_id") if not column_exists: @@ -1186,6 +1248,8 @@ async def check_migration_status(): "subscription_duplicates": False, "subscription_conversions_table": False, "promo_groups_table": False, + "promo_groups_auto_assign_column": False, + "promo_groups_spent_threshold_column": False, "users_promo_group_column": False } @@ -1196,6 +1260,12 @@ async def check_migration_status(): status["welcome_texts_table"] = await check_table_exists('welcome_texts') status["subscription_conversions_table"] = await check_table_exists('subscription_conversions') status["promo_groups_table"] = await check_table_exists('promo_groups') + status["promo_groups_auto_assign_column"] = await check_column_exists( + 'promo_groups', 'auto_assign_enabled' + ) + status["promo_groups_spent_threshold_column"] = await check_column_exists( + 'promo_groups', 'spent_threshold_kopeks' + ) status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled') status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') @@ -1230,6 +1300,8 @@ async def check_migration_status(): "subscription_conversions_table": "Таблица конверсий подписок", "subscription_duplicates": "Отсутствие дубликатов подписок", "promo_groups_table": "Таблица промо-групп", + "promo_groups_auto_assign_column": "Колонка auto_assign_enabled у промогрупп", + "promo_groups_spent_threshold_column": "Колонка spent_threshold_kopeks у промогрупп", "users_promo_group_column": "Колонка promo_group_id у пользователей" } diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index ef927f94..3ef83a03 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -1,4 +1,5 @@ import logging +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Optional from aiogram import Dispatcher, types, F @@ -37,6 +38,48 @@ def _format_discount_line(texts, group) -> str: ) +def _format_auto_assign_line(texts, group) -> str: + if getattr(group, "auto_assign_enabled", False): + amount = texts.format_price(getattr(group, "spent_threshold_kopeks", 0)) + return texts.t( + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLED", + "🤖 Автоназначение: при тратах от {amount}", + ).format(amount=amount) + return texts.t( + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED", + "🤖 Автоназначение: выключено", + ) + + +def _parse_bool_response(value: str) -> bool: + normalized = value.strip().lower() + truthy = {"да", "д", "yes", "y", "true", "1", "on"} + falsy = {"нет", "н", "no", "n", "false", "0", "off"} + + if normalized in truthy: + return True + if normalized in falsy: + return False + raise ValueError + + +def _parse_amount_to_kopeks(value: str) -> int: + normalized = value.replace(" ", "").replace(",", ".").strip() + if not normalized: + raise ValueError + + try: + amount = Decimal(normalized) + except (InvalidOperation, ValueError): + raise ValueError from None + + if amount < 0: + raise ValueError + + kopeks = (amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP) + return int(kopeks) + + @admin_required @error_handler async def show_promo_groups_menu( @@ -68,6 +111,7 @@ async def show_promo_groups_menu( [ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", _format_discount_line(texts, group), + _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", "Участников: {count}", @@ -139,6 +183,7 @@ async def show_promo_group_details( "💳 Промогруппа: {name}", ).format(name=group.name), _format_discount_line(texts, group), + _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", "Участников: {count}", @@ -299,13 +344,37 @@ async def process_create_group_devices( await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) return + await state.update_data(new_group_devices=devices_discount) + await state.set_state(AdminStates.creating_promo_group_auto_assign_enabled) + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT", + "Включить автоматическое назначение по сумме трат? (да/нет)", + ) + ) + + +async def _finalize_create_group( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, + *, + auto_assign_enabled: bool, + spent_threshold_kopeks: int, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + try: group = await create_promo_group( db, data["new_group_name"], traffic_discount_percent=data["new_group_traffic"], server_discount_percent=data["new_group_servers"], - device_discount_percent=devices_discount, + device_discount_percent=data["new_group_devices"], + auto_assign_enabled=auto_assign_enabled, + spent_threshold_kopeks=spent_threshold_kopeks, ) except Exception as e: logger.error(f"Не удалось создать промогруппу: {e}") @@ -334,6 +403,72 @@ async def process_create_group_devices( ) +@admin_required +@error_handler +async def process_create_group_auto_assign_enabled( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + auto_enabled = _parse_bool_response(message.text) + except ValueError: + await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_BOOL", "Введите «да» или «нет».")) + return + + await state.update_data(new_group_auto_assign_enabled=auto_enabled) + + if auto_enabled: + await state.set_state(AdminStates.creating_promo_group_auto_assign_threshold) + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT", + "Введите сумму трат в рублях для автоматического назначения:", + ) + ) + return + + await _finalize_create_group( + message, + state, + db_user, + db, + auto_assign_enabled=False, + spent_threshold_kopeks=0, + ) + + +@admin_required +@error_handler +async def process_create_group_auto_assign_threshold( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + threshold_kopeks = _parse_amount_to_kopeks(message.text) + except ValueError: + await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_AMOUNT", "Введите неотрицательное число.")) + return + + await _finalize_create_group( + message, + state, + db_user, + db, + auto_assign_enabled=data.get("new_group_auto_assign_enabled", False), + spent_threshold_kopeks=threshold_kopeks, + ) + + @admin_required @error_handler async def start_edit_promo_group( @@ -434,6 +569,40 @@ async def process_edit_group_devices( await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) return + group = await get_promo_group_by_id(db, data["edit_group_id"]) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + + await state.update_data( + edit_group_devices=devices_discount, + current_group_auto_assign_enabled=group.auto_assign_enabled, + current_group_spent_threshold=group.spent_threshold_kopeks, + ) + + await state.set_state(AdminStates.editing_promo_group_auto_assign_enabled) + status_text = _format_auto_assign_line(texts, group) + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT", + "Автоназначение сейчас: {status}. Включить автоматическое назначение? (да/нет)", + ).format(status=status_text) + ) + + +async def _finalize_edit_group( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, + *, + auto_assign_enabled: bool, + spent_threshold_kopeks: int, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + group = await get_promo_group_by_id(db, data["edit_group_id"]) if not group: await message.answer("❌ Промогруппа не найдена") @@ -446,7 +615,9 @@ async def process_edit_group_devices( name=data["edit_group_name"], traffic_discount_percent=data["edit_group_traffic"], server_discount_percent=data["edit_group_servers"], - device_discount_percent=devices_discount, + device_discount_percent=data["edit_group_devices"], + auto_assign_enabled=auto_assign_enabled, + spent_threshold_kopeks=spent_threshold_kopeks, ) await state.clear() @@ -455,6 +626,73 @@ async def process_edit_group_devices( ) +@admin_required +@error_handler +async def process_edit_group_auto_assign_enabled( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + auto_enabled = _parse_bool_response(message.text) + except ValueError: + await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_BOOL", "Введите «да» или «нет».")) + return + + await state.update_data(edit_group_auto_assign_enabled=auto_enabled) + + if auto_enabled: + await state.set_state(AdminStates.editing_promo_group_auto_assign_threshold) + current_amount = data.get("current_group_spent_threshold", 0) + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_THRESHOLD_PROMPT", + "Введите новую сумму трат в рублях (текущее значение: {amount}):", + ).format(amount=texts.format_price(current_amount)) + ) + return + + await _finalize_edit_group( + message, + state, + db_user, + db, + auto_assign_enabled=False, + spent_threshold_kopeks=0, + ) + + +@admin_required +@error_handler +async def process_edit_group_auto_assign_threshold( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + threshold_kopeks = _parse_amount_to_kopeks(message.text) + except ValueError: + await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_AMOUNT", "Введите неотрицательное число.")) + return + + await _finalize_edit_group( + message, + state, + db_user, + db, + auto_assign_enabled=data.get("edit_group_auto_assign_enabled", False), + spent_threshold_kopeks=threshold_kopeks, + ) + + @admin_required @error_handler async def show_promo_group_members( @@ -616,6 +854,14 @@ def register_handlers(dp: Dispatcher): process_create_group_devices, AdminStates.creating_promo_group_device_discount, ) + dp.message.register( + process_create_group_auto_assign_enabled, + AdminStates.creating_promo_group_auto_assign_enabled, + ) + dp.message.register( + process_create_group_auto_assign_threshold, + AdminStates.creating_promo_group_auto_assign_threshold, + ) dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name) dp.message.register( @@ -630,3 +876,11 @@ def register_handlers(dp: Dispatcher): process_edit_group_devices, AdminStates.editing_promo_group_device_discount, ) + dp.message.register( + process_edit_group_auto_assign_enabled, + AdminStates.editing_promo_group_auto_assign_enabled, + ) + dp.message.register( + process_edit_group_auto_assign_threshold, + AdminStates.editing_promo_group_auto_assign_threshold, + ) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 45fe983f..b8d26dc1 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -26,6 +26,7 @@ from app.utils.formatters import format_datetime, format_time_ago from app.services.remnawave_service import RemnaWaveService from app.external.remnawave_api import TrafficLimitStrategy from app.database.crud.server_squad import get_all_server_squads, get_server_squad_by_uuid, get_server_squad_by_id +from app.services.promo_group_service import auto_assign_promo_group_by_spent logger = logging.getLogger(__name__) @@ -2866,6 +2867,7 @@ async def admin_buy_subscription_execute( amount_kopeks=price_kopeks, description=f"Продление подписки на {period_days} дней (администратор)" ) + await auto_assign_promo_group_by_spent(db, target_user) try: from app.services.remnawave_service import RemnaWaveService diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 3c54f0a7..04ceffbd 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -20,9 +20,10 @@ from app.database.crud.subscription import ( from app.database.crud.user import subtract_user_balance from app.database.crud.transaction import create_transaction, get_user_transactions from app.database.models import ( - User, TransactionType, SubscriptionStatus, - SubscriptionServer, Subscription + User, TransactionType, SubscriptionStatus, + SubscriptionServer, Subscription ) +from app.services.promo_group_service import auto_assign_promo_group_by_spent from app.keyboards.inline import ( get_subscription_keyboard, get_trial_keyboard, get_subscription_period_keyboard, get_traffic_packages_keyboard, @@ -1191,13 +1192,13 @@ async def apply_countries_changes( try: if added and total_cost > 0: success = await subtract_user_balance( - db, db_user, total_cost, + db, db_user, total_cost, f"Добавление стран: {', '.join(added_names)} на {charged_months} мес" ) if not success: await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - + await create_transaction( db=db, user_id=db_user.id, @@ -1205,6 +1206,7 @@ async def apply_countries_changes( amount_kopeks=total_cost, description=f"Добавление стран к подписке: {', '.join(added_names)} на {charged_months} мес" ) + await auto_assign_promo_group_by_spent(db, db_user) if added: from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers @@ -1423,11 +1425,11 @@ async def execute_change_devices( db, db_user, price, f"Изменение количества устройств с {current_devices} до {new_devices_count}" ) - + if not success: await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - + charged_months = get_remaining_months(subscription.end_date) await create_transaction( db=db, @@ -1436,6 +1438,7 @@ async def execute_change_devices( amount_kopeks=price, description=f"Изменение устройств с {current_devices} до {new_devices_count} на {charged_months} мес" ) + await auto_assign_promo_group_by_spent(db, db_user) subscription.device_limit = new_devices_count subscription.updated_at = datetime.utcnow() @@ -1989,6 +1992,7 @@ async def confirm_add_devices( amount_kopeks=price, description=f"Добавление {devices_count} устройств на {charged_months} мес" ) + await auto_assign_promo_group_by_spent(db, db_user) await db.refresh(db_user) @@ -2204,6 +2208,7 @@ async def confirm_extend_subscription( amount_kopeks=price, description=f"Продление подписки на {days} дней ({months_in_period} мес)" ) + await auto_assign_promo_group_by_spent(db, db_user) try: notification_service = AdminNotificationService(callback.bot) @@ -2294,6 +2299,7 @@ async def confirm_reset_traffic( amount_kopeks=reset_price, description="Сброс трафика" ) + await auto_assign_promo_group_by_spent(db, db_user) await db.refresh(db_user) await db.refresh(subscription) @@ -2971,6 +2977,7 @@ async def confirm_purchase( amount_kopeks=final_price, description=f"Подписка на {data['period_days']} дней ({months_in_period} мес)" ) + await auto_assign_promo_group_by_spent(db, db_user) try: notification_service = AdminNotificationService(callback.bot) @@ -3161,6 +3168,7 @@ async def add_traffic( amount_kopeks=price, description=f"Добавление {traffic_gb} ГБ трафика" ) + await auto_assign_promo_group_by_spent(db, db_user) await db.refresh(db_user) @@ -3600,11 +3608,11 @@ async def confirm_add_countries_to_subscription( db, db_user, total_price, f"Добавление стран к подписке: {', '.join(new_countries_names)}" ) - + if not success: await callback.answer("❌ Ошибка списания средств", show_alert=True) return - + await create_transaction( db=db, user_id=db_user.id, @@ -3612,6 +3620,7 @@ async def confirm_add_countries_to_subscription( amount_kopeks=total_price, description=f"Добавление стран к подписке: {', '.join(new_countries_names)}" ) + await auto_assign_promo_group_by_spent(db, db_user) subscription.connected_squads = selected_countries subscription.updated_at = datetime.utcnow() @@ -4316,11 +4325,11 @@ async def execute_switch_traffic( db, db_user, price_difference, f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB" ) - + if not success: await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - + months_remaining = get_remaining_months(subscription.end_date) await create_transaction( db=db, @@ -4329,6 +4338,7 @@ async def execute_switch_traffic( amount_kopeks=price_difference, description=f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB на {months_remaining} мес" ) + await auto_assign_promo_group_by_spent(db, db_user) subscription.traffic_limit_gb = new_traffic_gb subscription.updated_at = datetime.utcnow() diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 49417771..c9f29fbc 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -143,6 +143,8 @@ "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user", "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Promo group: {name}", "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLED": "🤖 Auto assignment: after spending {amount}", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "🤖 Auto assignment: disabled", "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.", "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members", "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit", @@ -152,13 +154,19 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enable automatic assignment by total spending? (yes/no)", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Enter the spending amount in RUB required for automatic assignment:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", + "ADMIN_PROMO_GROUP_INVALID_BOOL": "Please answer \"yes\" or \"no\".", + "ADMIN_PROMO_GROUP_INVALID_AMOUNT": "Enter a non-negative number.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Auto assignment is currently: {status}. Enable automatic assignment? (yes/no)", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_THRESHOLD_PROMPT": "Enter the new spending amount in RUB (current: {amount}):", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 1dc7de10..2d3a49ca 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -31,6 +31,8 @@ "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю", "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Промогруппа: {name}", "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLED": "🤖 Автоназначение: при тратах от {amount}", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "🤖 Автоназначение: выключено", "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.", "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники", "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить", @@ -40,13 +42,19 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Включить автоматическое назначение по сумме трат? (да/нет)", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Введите сумму трат в рублях для автоматического назначения:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", + "ADMIN_PROMO_GROUP_INVALID_BOOL": "Введите «да» или «нет».", + "ADMIN_PROMO_GROUP_INVALID_AMOUNT": "Введите неотрицательное число.", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Автоназначение сейчас: {status}. Включить автоматическое назначение? (да/нет)", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_THRESHOLD_PROMPT": "Введите новую сумму трат в рублях (текущее значение: {amount}):", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", diff --git a/app/services/promo_group_service.py b/app/services/promo_group_service.py new file mode 100644 index 00000000..04c16579 --- /dev/null +++ b/app/services/promo_group_service.py @@ -0,0 +1,63 @@ +import logging +from datetime import datetime +from typing import Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import PromoGroup, User +from app.database.crud.promo_group import get_auto_assign_promo_groups +from app.database.crud.transaction import get_user_total_spent + + +logger = logging.getLogger(__name__) + + +async def auto_assign_promo_group_by_spent( + db: AsyncSession, + user: Optional[User], +) -> Optional[PromoGroup]: + if not user: + return None + + groups = await get_auto_assign_promo_groups(db) + if not groups: + return None + + total_spent = await get_user_total_spent(db, user.id) + + target_group: Optional[PromoGroup] = None + for group in groups: + threshold = max(0, group.spent_threshold_kopeks or 0) + if total_spent >= threshold: + target_group = group + break + + if not target_group or user.promo_group_id == target_group.id: + return None + + previous_group_id = user.promo_group_id + + user.promo_group_id = target_group.id + user.promo_group = target_group + user.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(user) + + logger.info( + "👥 Пользователь %s автоматически переведен в промогруппу '%s' (порог=%s, потрачено=%s)", + user.telegram_id, + target_group.name, + target_group.spent_threshold_kopeks, + total_spent, + ) + + if previous_group_id != target_group.id: + logger.debug( + "👥 Пользователь %s покинул промогруппу %s → %s", + user.telegram_id, + previous_group_id, + target_group.id, + ) + + return target_group diff --git a/app/states.py b/app/states.py index 2207c448..2a65887c 100644 --- a/app/states.py +++ b/app/states.py @@ -68,11 +68,15 @@ class AdminStates(StatesGroup): creating_promo_group_traffic_discount = State() creating_promo_group_server_discount = State() creating_promo_group_device_discount = State() + creating_promo_group_auto_assign_enabled = State() + creating_promo_group_auto_assign_threshold = State() editing_promo_group_name = State() editing_promo_group_traffic_discount = State() editing_promo_group_server_discount = State() editing_promo_group_device_discount = State() + editing_promo_group_auto_assign_enabled = State() + editing_promo_group_auto_assign_threshold = State() editing_squad_price = State() editing_traffic_price = State() diff --git a/locales/en.json b/locales/en.json index e72fd5e4..36b5a47e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -205,6 +205,8 @@ "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user", "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Promo group: {name}", "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLED": "🤖 Auto assignment: after spending {amount}", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "🤖 Auto assignment: disabled", "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.", "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members", "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit", @@ -214,13 +216,19 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enable automatic assignment by total spending? (yes/no)", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Enter the spending amount in RUB required for automatic assignment:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", + "ADMIN_PROMO_GROUP_INVALID_BOOL": "Please answer \"yes\" or \"no\".", + "ADMIN_PROMO_GROUP_INVALID_AMOUNT": "Enter a non-negative number.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Auto assignment is currently: {status}. Enable automatic assignment? (yes/no)", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_THRESHOLD_PROMPT": "Enter the new spending amount in RUB (current: {amount}):", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", diff --git a/locales/ru.json b/locales/ru.json index 2c6c839d..75480f09 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -93,6 +93,8 @@ "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю", "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Промогруппа: {name}", "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLED": "🤖 Автоназначение: при тратах от {amount}", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "🤖 Автоназначение: выключено", "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.", "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники", "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить", @@ -102,13 +104,19 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Включить автоматическое назначение по сумме трат? (да/нет)", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Введите сумму трат в рублях для автоматического назначения:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", + "ADMIN_PROMO_GROUP_INVALID_BOOL": "Введите «да» или «нет».", + "ADMIN_PROMO_GROUP_INVALID_AMOUNT": "Введите неотрицательное число.", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Автоназначение сейчас: {status}. Включить автоматическое назначение? (да/нет)", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_THRESHOLD_PROMPT": "Введите новую сумму трат в рублях (текущее значение: {amount}):", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", diff --git a/migrations/alembic/versions/b6b5c77e2a9d_add_auto_assign_to_promo_groups.py b/migrations/alembic/versions/b6b5c77e2a9d_add_auto_assign_to_promo_groups.py new file mode 100644 index 00000000..e50c00be --- /dev/null +++ b/migrations/alembic/versions/b6b5c77e2a9d_add_auto_assign_to_promo_groups.py @@ -0,0 +1,88 @@ +"""add auto assign fields to promo groups""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +PROMO_GROUPS_TABLE = "promo_groups" +AUTO_ASSIGN_COLUMN = "auto_assign_enabled" +SPENT_THRESHOLD_COLUMN = "spent_threshold_kopeks" + + +def _table_exists(inspector: sa.Inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool: + return any(col["name"] == column_name for col in inspector.get_columns(table_name)) + + +revision: str = "b6b5c77e2a9d" +down_revision: Union[str, None] = "1f5f3a3f5a4d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not _table_exists(inspector, PROMO_GROUPS_TABLE): + return + + if not _column_exists(inspector, PROMO_GROUPS_TABLE, AUTO_ASSIGN_COLUMN): + op.add_column( + PROMO_GROUPS_TABLE, + sa.Column( + AUTO_ASSIGN_COLUMN, + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + ) + + if not _column_exists(inspector, PROMO_GROUPS_TABLE, SPENT_THRESHOLD_COLUMN): + op.add_column( + PROMO_GROUPS_TABLE, + sa.Column( + SPENT_THRESHOLD_COLUMN, + sa.Integer(), + nullable=False, + server_default=sa.text("0"), + ), + ) + + op.execute( + sa.text( + f"UPDATE {PROMO_GROUPS_TABLE} " + f"SET {AUTO_ASSIGN_COLUMN} = false, {SPENT_THRESHOLD_COLUMN} = 0 " + f"WHERE {AUTO_ASSIGN_COLUMN} IS NULL OR {SPENT_THRESHOLD_COLUMN} IS NULL" + ) + ) + + op.alter_column( + PROMO_GROUPS_TABLE, + AUTO_ASSIGN_COLUMN, + server_default=None, + ) + op.alter_column( + PROMO_GROUPS_TABLE, + SPENT_THRESHOLD_COLUMN, + server_default=None, + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not _table_exists(inspector, PROMO_GROUPS_TABLE): + return + + if _column_exists(inspector, PROMO_GROUPS_TABLE, AUTO_ASSIGN_COLUMN): + op.drop_column(PROMO_GROUPS_TABLE, AUTO_ASSIGN_COLUMN) + + if _column_exists(inspector, PROMO_GROUPS_TABLE, SPENT_THRESHOLD_COLUMN): + op.drop_column(PROMO_GROUPS_TABLE, SPENT_THRESHOLD_COLUMN) From 7b0892e3797813f1d9cb4279fef0afa818723528 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 04:11:40 +0300 Subject: [PATCH 19/28] Add single-use promo group auto assignment --- app/database/models.py | 10 +++ app/database/universal_migration.py | 34 +++++++++- app/handlers/admin/promo_groups.py | 39 ++++++++--- app/localization/locales/en.json | 3 +- app/localization/locales/ru.json | 3 +- app/services/promo_group_service.py | 7 ++ locales/en.json | 3 +- locales/ru.json | 3 +- ..._add_auto_assigned_promo_group_to_users.py | 65 +++++++++++++++++++ 9 files changed, 151 insertions(+), 16 deletions(-) create mode 100644 migrations/alembic/versions/3e6c4d6db780_add_auto_assigned_promo_group_to_users.py diff --git a/app/database/models.py b/app/database/models.py index e689b1da..b017a26c 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -224,8 +224,18 @@ class User(Base): vless_uuid = Column(String(255), nullable=True) ss_password = Column(String(255), nullable=True) has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + auto_assigned_promo_group_id = Column( + Integer, + ForeignKey("promo_groups.id", ondelete="SET NULL"), + nullable=True, + ) promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True) promo_group = relationship("PromoGroup", back_populates="users") + auto_assigned_promo_group = relationship( + "PromoGroup", + foreign_keys=[auto_assigned_promo_group_id], + viewonly=True, + ) @property def balance_rubles(self) -> float: diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index aa68e45f..e0a61a46 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -532,6 +532,31 @@ async def ensure_promo_groups_setup(): except Exception as e: logger.warning(f"Не удалось создать индекс ix_users_promo_group_id: {e}") + auto_assigned_column_exists = await check_column_exists( + "users", "auto_assigned_promo_group_id" + ) + + if not auto_assigned_column_exists: + if db_type == "sqlite": + await conn.execute( + text("ALTER TABLE users ADD COLUMN auto_assigned_promo_group_id INTEGER") + ) + elif db_type == "postgresql": + await conn.execute( + text("ALTER TABLE users ADD COLUMN auto_assigned_promo_group_id INTEGER") + ) + elif db_type == "mysql": + await conn.execute( + text("ALTER TABLE users ADD COLUMN auto_assigned_promo_group_id INT") + ) + else: + logger.error( + f"Неподдерживаемый тип БД для users.auto_assigned_promo_group_id: {db_type}" + ) + return False + + logger.info("Добавлена колонка users.auto_assigned_promo_group_id") + default_group_name = "Базовый юзер" default_group_id = None @@ -1250,7 +1275,8 @@ async def check_migration_status(): "promo_groups_table": False, "promo_groups_auto_assign_column": False, "promo_groups_spent_threshold_column": False, - "users_promo_group_column": False + "users_promo_group_column": False, + "users_auto_assigned_promo_group_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -1269,6 +1295,9 @@ async def check_migration_status(): status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled') status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') + status["users_auto_assigned_promo_group_column"] = await check_column_exists( + 'users', 'auto_assigned_promo_group_id' + ) media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -1302,7 +1331,8 @@ async def check_migration_status(): "promo_groups_table": "Таблица промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_enabled у промогрупп", "promo_groups_spent_threshold_column": "Колонка spent_threshold_kopeks у промогрупп", - "users_promo_group_column": "Колонка promo_group_id у пользователей" + "users_promo_group_column": "Колонка promo_group_id у пользователей", + "users_auto_assigned_promo_group_column": "Колонка auto_assigned_promo_group_id у пользователей", } for check_key, check_status in status.items(): diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 3ef83a03..c89f77c1 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -349,8 +349,20 @@ async def process_create_group_devices( await message.answer( texts.t( "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT", - "Включить автоматическое назначение по сумме трат? (да/нет)", - ) + "Включить автоматическое назначение по сумме трат?", + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.YES, callback_data="promo_group_auto_assign_yes" + ), + types.InlineKeyboardButton( + text=texts.NO, callback_data="promo_group_auto_assign_no" + ), + ] + ] + ), ) @@ -406,7 +418,7 @@ async def _finalize_create_group( @admin_required @error_handler async def process_create_group_auto_assign_enabled( - message: types.Message, + callback: types.CallbackQuery, state: FSMContext, db_user, db: AsyncSession, @@ -414,32 +426,38 @@ async def process_create_group_auto_assign_enabled( data = await state.get_data() texts = get_texts(data.get("language", db_user.language)) - try: - auto_enabled = _parse_bool_response(message.text) - except ValueError: - await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_BOOL", "Введите «да» или «нет».")) + if callback.data not in { + "promo_group_auto_assign_yes", + "promo_group_auto_assign_no", + }: + await callback.answer(texts.t("ADMIN_ACTION_INVALID", "Недопустимое действие"), show_alert=True) return + auto_enabled = callback.data == "promo_group_auto_assign_yes" await state.update_data(new_group_auto_assign_enabled=auto_enabled) if auto_enabled: await state.set_state(AdminStates.creating_promo_group_auto_assign_threshold) - await message.answer( + await callback.message.edit_text( texts.t( "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT", "Введите сумму трат в рублях для автоматического назначения:", ) ) + await callback.answer() return + await callback.message.edit_reply_markup() + await _finalize_create_group( - message, + callback.message, state, db_user, db, auto_assign_enabled=False, spent_threshold_kopeks=0, ) + await callback.answer() @admin_required @@ -854,9 +872,10 @@ def register_handlers(dp: Dispatcher): process_create_group_devices, AdminStates.creating_promo_group_device_discount, ) - dp.message.register( + dp.callback_query.register( process_create_group_auto_assign_enabled, AdminStates.creating_promo_group_auto_assign_enabled, + F.data.in_({"promo_group_auto_assign_yes", "promo_group_auto_assign_no"}), ) dp.message.register( process_create_group_auto_assign_threshold, diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index c9f29fbc..29abcb8b 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -114,6 +114,7 @@ "WELCOME": "\n🎉 Welcome to VPN Service!\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 Advantages:\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n", "WELCOME_FALLBACK": "Welcome, {user_name}!", "YES": "✅ Yes", + "ADMIN_ACTION_INVALID": "Invalid action", "ACCESS_DENIED": "❌ Access denied", "ADMIN_MESSAGES": "📨 Broadcasts", "ADMIN_MONITORING": "🔍 Monitoring", @@ -154,7 +155,7 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enable automatic assignment by total spending? (yes/no)", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enable automatic assignment by total spending?", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Enter the spending amount in RUB required for automatic assignment:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", "ADMIN_PROMO_GROUP_INVALID_BOOL": "Please answer \"yes\" or \"no\".", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 2d3a49ca..a5d3f7ef 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -42,7 +42,7 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Включить автоматическое назначение по сумме трат? (да/нет)", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Включить автоматическое назначение по сумме трат?", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Введите сумму трат в рублях для автоматического назначения:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", "ADMIN_PROMO_GROUP_INVALID_BOOL": "Введите «да» или «нет».", @@ -288,6 +288,7 @@ "WELCOME": "\n🎉 Добро пожаловать в VPN сервис!\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 Преимущества:\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n", "WELCOME_FALLBACK": "Добро пожаловать, {user_name}!", "YES": "✅ Да", + "ADMIN_ACTION_INVALID": "Недопустимое действие", "SUBSCRIPTION_STATUS_EXPIRED": "Истекла", "SUBSCRIPTION_STATUS_TRIAL": "Тестовая", "SUBSCRIPTION_STATUS_ACTIVE": "Активна", diff --git a/app/services/promo_group_service.py b/app/services/promo_group_service.py index 04c16579..25e89c9b 100644 --- a/app/services/promo_group_service.py +++ b/app/services/promo_group_service.py @@ -35,11 +35,18 @@ async def auto_assign_promo_group_by_spent( if not target_group or user.promo_group_id == target_group.id: return None + if ( + getattr(user, "auto_assigned_promo_group_id", None) is not None + and user.auto_assigned_promo_group_id == target_group.id + ): + return None + previous_group_id = user.promo_group_id user.promo_group_id = target_group.id user.promo_group = target_group user.updated_at = datetime.utcnow() + user.auto_assigned_promo_group_id = target_group.id await db.commit() await db.refresh(user) diff --git a/locales/en.json b/locales/en.json index 36b5a47e..3c52bd12 100644 --- a/locales/en.json +++ b/locales/en.json @@ -114,6 +114,7 @@ "WELCOME": "\n🎉 Welcome to VPN Service!\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 Advantages:\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n", "WELCOME_FALLBACK": "Welcome, {user_name}!", "YES": "✅ Yes", + "ADMIN_ACTION_INVALID": "Invalid action", "ACCESS_DENIED": "❌ Access denied", "ADMIN_MESSAGES": "📨 Broadcasts", "ADMIN_MONITORING": "🔍 Monitoring", @@ -216,7 +217,7 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enable automatic assignment by total spending? (yes/no)", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enable automatic assignment by total spending?", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Enter the spending amount in RUB required for automatic assignment:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", "ADMIN_PROMO_GROUP_INVALID_BOOL": "Please answer \"yes\" or \"no\".", diff --git a/locales/ru.json b/locales/ru.json index 75480f09..c3e8f9bd 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -104,7 +104,7 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Включить автоматическое назначение по сумме трат? (да/нет)", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Включить автоматическое назначение по сумме трат?", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Введите сумму трат в рублях для автоматического назначения:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", "ADMIN_PROMO_GROUP_INVALID_BOOL": "Введите «да» или «нет».", @@ -334,6 +334,7 @@ "WELCOME": "\n🎉 Добро пожаловать в VPN сервис!\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 Преимущества:\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n", "WELCOME_FALLBACK": "Добро пожаловать, {user_name}!", "YES": "✅ Да", + "ADMIN_ACTION_INVALID": "Недопустимое действие", "SUBSCRIPTION_STATUS_EXPIRED": "Истекла", "SUBSCRIPTION_STATUS_TRIAL": "Тестовая", "SUBSCRIPTION_STATUS_ACTIVE": "Активна", diff --git a/migrations/alembic/versions/3e6c4d6db780_add_auto_assigned_promo_group_to_users.py b/migrations/alembic/versions/3e6c4d6db780_add_auto_assigned_promo_group_to_users.py new file mode 100644 index 00000000..a75f2077 --- /dev/null +++ b/migrations/alembic/versions/3e6c4d6db780_add_auto_assigned_promo_group_to_users.py @@ -0,0 +1,65 @@ +"""Add auto assigned promo group tracking to users + +Revision ID: 3e6c4d6db780 +Revises: b6b5c77e2a9d +Create Date: 2024-05-06 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "3e6c4d6db780" +down_revision: Union[str, None] = "b6b5c77e2a9d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +USERS_TABLE = "users" +COLUMN_NAME = "auto_assigned_promo_group_id" +PROMO_GROUPS_TABLE = "promo_groups" + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if USERS_TABLE not in inspector.get_table_names(): + return + + if COLUMN_NAME in [column["name"] for column in inspector.get_columns(USERS_TABLE)]: + return + + op.add_column( + USERS_TABLE, + sa.Column(COLUMN_NAME, sa.Integer(), nullable=True), + ) + op.create_foreign_key( + "fk_users_auto_assigned_promo_group_id", + USERS_TABLE, + PROMO_GROUPS_TABLE, + [COLUMN_NAME], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if USERS_TABLE not in inspector.get_table_names(): + return + + if COLUMN_NAME not in [column["name"] for column in inspector.get_columns(USERS_TABLE)]: + return + + fk_names = [fk["name"] for fk in inspector.get_foreign_keys(USERS_TABLE)] + if "fk_users_auto_assigned_promo_group_id" in fk_names: + op.drop_constraint( + "fk_users_auto_assigned_promo_group_id", + USERS_TABLE, + type_="foreignkey", + ) + op.drop_column(USERS_TABLE, COLUMN_NAME) From eaffaa11b784ecb2012552e7a8407f991e5e23a9 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 04:13:09 +0300 Subject: [PATCH 20/28] Revert "Add single-use promo group auto assignment" --- app/database/models.py | 10 --- app/database/universal_migration.py | 34 +--------- app/handlers/admin/promo_groups.py | 39 +++-------- app/localization/locales/en.json | 3 +- app/localization/locales/ru.json | 3 +- app/services/promo_group_service.py | 7 -- locales/en.json | 3 +- locales/ru.json | 3 +- ..._add_auto_assigned_promo_group_to_users.py | 65 ------------------- 9 files changed, 16 insertions(+), 151 deletions(-) delete mode 100644 migrations/alembic/versions/3e6c4d6db780_add_auto_assigned_promo_group_to_users.py diff --git a/app/database/models.py b/app/database/models.py index b017a26c..e689b1da 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -224,18 +224,8 @@ class User(Base): vless_uuid = Column(String(255), nullable=True) ss_password = Column(String(255), nullable=True) has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - auto_assigned_promo_group_id = Column( - Integer, - ForeignKey("promo_groups.id", ondelete="SET NULL"), - nullable=True, - ) promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True) promo_group = relationship("PromoGroup", back_populates="users") - auto_assigned_promo_group = relationship( - "PromoGroup", - foreign_keys=[auto_assigned_promo_group_id], - viewonly=True, - ) @property def balance_rubles(self) -> float: diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index e0a61a46..aa68e45f 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -532,31 +532,6 @@ async def ensure_promo_groups_setup(): except Exception as e: logger.warning(f"Не удалось создать индекс ix_users_promo_group_id: {e}") - auto_assigned_column_exists = await check_column_exists( - "users", "auto_assigned_promo_group_id" - ) - - if not auto_assigned_column_exists: - if db_type == "sqlite": - await conn.execute( - text("ALTER TABLE users ADD COLUMN auto_assigned_promo_group_id INTEGER") - ) - elif db_type == "postgresql": - await conn.execute( - text("ALTER TABLE users ADD COLUMN auto_assigned_promo_group_id INTEGER") - ) - elif db_type == "mysql": - await conn.execute( - text("ALTER TABLE users ADD COLUMN auto_assigned_promo_group_id INT") - ) - else: - logger.error( - f"Неподдерживаемый тип БД для users.auto_assigned_promo_group_id: {db_type}" - ) - return False - - logger.info("Добавлена колонка users.auto_assigned_promo_group_id") - default_group_name = "Базовый юзер" default_group_id = None @@ -1275,8 +1250,7 @@ async def check_migration_status(): "promo_groups_table": False, "promo_groups_auto_assign_column": False, "promo_groups_spent_threshold_column": False, - "users_promo_group_column": False, - "users_auto_assigned_promo_group_column": False, + "users_promo_group_column": False } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -1295,9 +1269,6 @@ async def check_migration_status(): status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled') status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') - status["users_auto_assigned_promo_group_column"] = await check_column_exists( - 'users', 'auto_assigned_promo_group_id' - ) media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -1331,8 +1302,7 @@ async def check_migration_status(): "promo_groups_table": "Таблица промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_enabled у промогрупп", "promo_groups_spent_threshold_column": "Колонка spent_threshold_kopeks у промогрупп", - "users_promo_group_column": "Колонка promo_group_id у пользователей", - "users_auto_assigned_promo_group_column": "Колонка auto_assigned_promo_group_id у пользователей", + "users_promo_group_column": "Колонка promo_group_id у пользователей" } for check_key, check_status in status.items(): diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index c89f77c1..3ef83a03 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -349,20 +349,8 @@ async def process_create_group_devices( await message.answer( texts.t( "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT", - "Включить автоматическое назначение по сумме трат?", - ), - reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.YES, callback_data="promo_group_auto_assign_yes" - ), - types.InlineKeyboardButton( - text=texts.NO, callback_data="promo_group_auto_assign_no" - ), - ] - ] - ), + "Включить автоматическое назначение по сумме трат? (да/нет)", + ) ) @@ -418,7 +406,7 @@ async def _finalize_create_group( @admin_required @error_handler async def process_create_group_auto_assign_enabled( - callback: types.CallbackQuery, + message: types.Message, state: FSMContext, db_user, db: AsyncSession, @@ -426,38 +414,32 @@ async def process_create_group_auto_assign_enabled( data = await state.get_data() texts = get_texts(data.get("language", db_user.language)) - if callback.data not in { - "promo_group_auto_assign_yes", - "promo_group_auto_assign_no", - }: - await callback.answer(texts.t("ADMIN_ACTION_INVALID", "Недопустимое действие"), show_alert=True) + try: + auto_enabled = _parse_bool_response(message.text) + except ValueError: + await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_BOOL", "Введите «да» или «нет».")) return - auto_enabled = callback.data == "promo_group_auto_assign_yes" await state.update_data(new_group_auto_assign_enabled=auto_enabled) if auto_enabled: await state.set_state(AdminStates.creating_promo_group_auto_assign_threshold) - await callback.message.edit_text( + await message.answer( texts.t( "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT", "Введите сумму трат в рублях для автоматического назначения:", ) ) - await callback.answer() return - await callback.message.edit_reply_markup() - await _finalize_create_group( - callback.message, + message, state, db_user, db, auto_assign_enabled=False, spent_threshold_kopeks=0, ) - await callback.answer() @admin_required @@ -872,10 +854,9 @@ def register_handlers(dp: Dispatcher): process_create_group_devices, AdminStates.creating_promo_group_device_discount, ) - dp.callback_query.register( + dp.message.register( process_create_group_auto_assign_enabled, AdminStates.creating_promo_group_auto_assign_enabled, - F.data.in_({"promo_group_auto_assign_yes", "promo_group_auto_assign_no"}), ) dp.message.register( process_create_group_auto_assign_threshold, diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 29abcb8b..c9f29fbc 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -114,7 +114,6 @@ "WELCOME": "\n🎉 Welcome to VPN Service!\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 Advantages:\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n", "WELCOME_FALLBACK": "Welcome, {user_name}!", "YES": "✅ Yes", - "ADMIN_ACTION_INVALID": "Invalid action", "ACCESS_DENIED": "❌ Access denied", "ADMIN_MESSAGES": "📨 Broadcasts", "ADMIN_MONITORING": "🔍 Monitoring", @@ -155,7 +154,7 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enable automatic assignment by total spending?", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enable automatic assignment by total spending? (yes/no)", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Enter the spending amount in RUB required for automatic assignment:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", "ADMIN_PROMO_GROUP_INVALID_BOOL": "Please answer \"yes\" or \"no\".", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index a5d3f7ef..2d3a49ca 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -42,7 +42,7 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Включить автоматическое назначение по сумме трат?", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Включить автоматическое назначение по сумме трат? (да/нет)", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Введите сумму трат в рублях для автоматического назначения:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", "ADMIN_PROMO_GROUP_INVALID_BOOL": "Введите «да» или «нет».", @@ -288,7 +288,6 @@ "WELCOME": "\n🎉 Добро пожаловать в VPN сервис!\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 Преимущества:\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n", "WELCOME_FALLBACK": "Добро пожаловать, {user_name}!", "YES": "✅ Да", - "ADMIN_ACTION_INVALID": "Недопустимое действие", "SUBSCRIPTION_STATUS_EXPIRED": "Истекла", "SUBSCRIPTION_STATUS_TRIAL": "Тестовая", "SUBSCRIPTION_STATUS_ACTIVE": "Активна", diff --git a/app/services/promo_group_service.py b/app/services/promo_group_service.py index 25e89c9b..04c16579 100644 --- a/app/services/promo_group_service.py +++ b/app/services/promo_group_service.py @@ -35,18 +35,11 @@ async def auto_assign_promo_group_by_spent( if not target_group or user.promo_group_id == target_group.id: return None - if ( - getattr(user, "auto_assigned_promo_group_id", None) is not None - and user.auto_assigned_promo_group_id == target_group.id - ): - return None - previous_group_id = user.promo_group_id user.promo_group_id = target_group.id user.promo_group = target_group user.updated_at = datetime.utcnow() - user.auto_assigned_promo_group_id = target_group.id await db.commit() await db.refresh(user) diff --git a/locales/en.json b/locales/en.json index 3c52bd12..36b5a47e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -114,7 +114,6 @@ "WELCOME": "\n🎉 Welcome to VPN Service!\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 Advantages:\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n", "WELCOME_FALLBACK": "Welcome, {user_name}!", "YES": "✅ Yes", - "ADMIN_ACTION_INVALID": "Invalid action", "ACCESS_DENIED": "❌ Access denied", "ADMIN_MESSAGES": "📨 Broadcasts", "ADMIN_MONITORING": "🔍 Monitoring", @@ -217,7 +216,7 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enable automatic assignment by total spending?", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enable automatic assignment by total spending? (yes/no)", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Enter the spending amount in RUB required for automatic assignment:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", "ADMIN_PROMO_GROUP_INVALID_BOOL": "Please answer \"yes\" or \"no\".", diff --git a/locales/ru.json b/locales/ru.json index c3e8f9bd..75480f09 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -104,7 +104,7 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Включить автоматическое назначение по сумме трат?", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Включить автоматическое назначение по сумме трат? (да/нет)", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Введите сумму трат в рублях для автоматического назначения:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", "ADMIN_PROMO_GROUP_INVALID_BOOL": "Введите «да» или «нет».", @@ -334,7 +334,6 @@ "WELCOME": "\n🎉 Добро пожаловать в VPN сервис!\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 Преимущества:\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n", "WELCOME_FALLBACK": "Добро пожаловать, {user_name}!", "YES": "✅ Да", - "ADMIN_ACTION_INVALID": "Недопустимое действие", "SUBSCRIPTION_STATUS_EXPIRED": "Истекла", "SUBSCRIPTION_STATUS_TRIAL": "Тестовая", "SUBSCRIPTION_STATUS_ACTIVE": "Активна", diff --git a/migrations/alembic/versions/3e6c4d6db780_add_auto_assigned_promo_group_to_users.py b/migrations/alembic/versions/3e6c4d6db780_add_auto_assigned_promo_group_to_users.py deleted file mode 100644 index a75f2077..00000000 --- a/migrations/alembic/versions/3e6c4d6db780_add_auto_assigned_promo_group_to_users.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Add auto assigned promo group tracking to users - -Revision ID: 3e6c4d6db780 -Revises: b6b5c77e2a9d -Create Date: 2024-05-06 00:00:00.000000 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -revision: str = "3e6c4d6db780" -down_revision: Union[str, None] = "b6b5c77e2a9d" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - -USERS_TABLE = "users" -COLUMN_NAME = "auto_assigned_promo_group_id" -PROMO_GROUPS_TABLE = "promo_groups" - - -def upgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if USERS_TABLE not in inspector.get_table_names(): - return - - if COLUMN_NAME in [column["name"] for column in inspector.get_columns(USERS_TABLE)]: - return - - op.add_column( - USERS_TABLE, - sa.Column(COLUMN_NAME, sa.Integer(), nullable=True), - ) - op.create_foreign_key( - "fk_users_auto_assigned_promo_group_id", - USERS_TABLE, - PROMO_GROUPS_TABLE, - [COLUMN_NAME], - ["id"], - ondelete="SET NULL", - ) - - -def downgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if USERS_TABLE not in inspector.get_table_names(): - return - - if COLUMN_NAME not in [column["name"] for column in inspector.get_columns(USERS_TABLE)]: - return - - fk_names = [fk["name"] for fk in inspector.get_foreign_keys(USERS_TABLE)] - if "fk_users_auto_assigned_promo_group_id" in fk_names: - op.drop_constraint( - "fk_users_auto_assigned_promo_group_id", - USERS_TABLE, - type_="foreignkey", - ) - op.drop_column(USERS_TABLE, COLUMN_NAME) From a1d4e9c7b3778e3afe95e31645a54b1b993e2d20 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 04:14:15 +0300 Subject: [PATCH 21/28] Add auto-assign controls and prevent repeated promo group reassignment --- .gitignore | 2 + app/database/models.py | 1 + app/database/universal_migration.py | 9 +- app/handlers/admin/promo_groups.py | 186 +++++++++++++++--- app/localization/locales/en.json | 8 +- app/localization/locales/ru.json | 8 +- app/services/promo_group_service.py | 13 ++ locales/en.json | 8 +- locales/ru.json | 8 +- ...be3d3ad12_add_last_auto_assigned_column.py | 53 +++++ 10 files changed, 256 insertions(+), 40 deletions(-) create mode 100644 migrations/alembic/versions/c2cbe3d3ad12_add_last_auto_assigned_column.py diff --git a/.gitignore b/.gitignore index 35826240..cc5ce62a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ # Разрешаем папку app/ и все её содержимое рекурсивно !app/ !app/** +!migrations/ +!migrations/** !locales/ !locales/** diff --git a/app/database/models.py b/app/database/models.py index e689b1da..810c4735 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -226,6 +226,7 @@ class User(Base): has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True) promo_group = relationship("PromoGroup", back_populates="users") + last_auto_assigned_promo_group_id = Column(Integer, nullable=True) @property def balance_rubles(self) -> float: diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index aa68e45f..39c04b9d 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1250,7 +1250,8 @@ async def check_migration_status(): "promo_groups_table": False, "promo_groups_auto_assign_column": False, "promo_groups_spent_threshold_column": False, - "users_promo_group_column": False + "users_promo_group_column": False, + "users_last_auto_assign_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -1269,6 +1270,9 @@ async def check_migration_status(): status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled') status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') + status["users_last_auto_assign_column"] = await check_column_exists( + 'users', 'last_auto_assigned_promo_group_id' + ) media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -1302,7 +1306,8 @@ async def check_migration_status(): "promo_groups_table": "Таблица промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_enabled у промогрупп", "promo_groups_spent_threshold_column": "Колонка spent_threshold_kopeks у промогрупп", - "users_promo_group_column": "Колонка promo_group_id у пользователей" + "users_promo_group_column": "Колонка promo_group_id у пользователей", + "users_last_auto_assign_column": "Колонка last_auto_assigned_promo_group_id у пользователей", } for check_key, check_status in status.items(): diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 3ef83a03..eab0dde1 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -24,6 +24,14 @@ from app.keyboards.admin import ( get_confirmation_keyboard, ) +CREATE_AUTO_ASSIGN_PREFIX = "promo_group_create_auto_assign" +EDIT_AUTO_ASSIGN_PREFIX = "promo_group_edit_auto_assign" + +CREATE_AUTO_ASSIGN_ENABLE = f"{CREATE_AUTO_ASSIGN_PREFIX}_yes" +CREATE_AUTO_ASSIGN_DISABLE = f"{CREATE_AUTO_ASSIGN_PREFIX}_no" +EDIT_AUTO_ASSIGN_ENABLE = f"{EDIT_AUTO_ASSIGN_PREFIX}_yes" +EDIT_AUTO_ASSIGN_DISABLE = f"{EDIT_AUTO_ASSIGN_PREFIX}_no" + logger = logging.getLogger(__name__) @@ -252,6 +260,29 @@ async def _prompt_for_discount( await message.answer(texts.t(prompt_key, default_text)) +def _get_auto_assign_keyboard(texts, prefix: str) -> types.InlineKeyboardMarkup: + return types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLE_BUTTON", + "✅ Включить", + ), + callback_data=f"{prefix}_yes", + ), + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLE_BUTTON", + "🚫 Оставить выключенным", + ), + callback_data=f"{prefix}_no", + ), + ] + ] + ) + + @admin_required @error_handler async def start_create_promo_group( @@ -349,8 +380,9 @@ async def process_create_group_devices( await message.answer( texts.t( "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT", - "Включить автоматическое назначение по сумме трат? (да/нет)", - ) + "Включить автоматическое назначение по сумме трат? Выберите вариант ниже.", + ), + reply_markup=_get_auto_assign_keyboard(texts, CREATE_AUTO_ASSIGN_PREFIX), ) @@ -403,26 +435,20 @@ async def _finalize_create_group( ) -@admin_required -@error_handler -async def process_create_group_auto_assign_enabled( +async def _apply_create_auto_assign_choice( message: types.Message, state: FSMContext, db_user, db: AsyncSession, + *, + auto_assign_enabled: bool, ): data = await state.get_data() texts = get_texts(data.get("language", db_user.language)) - try: - auto_enabled = _parse_bool_response(message.text) - except ValueError: - await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_BOOL", "Введите «да» или «нет».")) - return + await state.update_data(new_group_auto_assign_enabled=auto_assign_enabled) - await state.update_data(new_group_auto_assign_enabled=auto_enabled) - - if auto_enabled: + if auto_assign_enabled: await state.set_state(AdminStates.creating_promo_group_auto_assign_threshold) await message.answer( texts.t( @@ -442,6 +468,58 @@ async def process_create_group_auto_assign_enabled( ) +@admin_required +@error_handler +async def process_create_group_auto_assign_enabled( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + auto_enabled = _parse_bool_response(message.text) + except ValueError: + await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_BOOL", "Пожалуйста, воспользуйтесь кнопками ниже.")) + return + + await _apply_create_auto_assign_choice( + message, + state, + db_user, + db, + auto_assign_enabled=auto_enabled, + ) + + +@admin_required +@error_handler +async def process_create_group_auto_assign_enabled_callback( + callback: types.CallbackQuery, + state: FSMContext, + db_user, + db: AsyncSession, +): + current_state = await state.get_state() + if current_state != AdminStates.creating_promo_group_auto_assign_enabled.state: + await callback.answer() + return + + auto_enabled = callback.data == CREATE_AUTO_ASSIGN_ENABLE + + await callback.message.edit_reply_markup() + await _apply_create_auto_assign_choice( + callback.message, + state, + db_user, + db, + auto_assign_enabled=auto_enabled, + ) + await callback.answer() + + @admin_required @error_handler async def process_create_group_auto_assign_threshold( @@ -586,8 +664,9 @@ async def process_edit_group_devices( await message.answer( texts.t( "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT", - "Автоназначение сейчас: {status}. Включить автоматическое назначение? (да/нет)", - ).format(status=status_text) + "Автоназначение сейчас: {status}. Включить автоматическое назначение? Выберите вариант ниже.", + ).format(status=status_text), + reply_markup=_get_auto_assign_keyboard(texts, EDIT_AUTO_ASSIGN_PREFIX), ) @@ -626,26 +705,20 @@ async def _finalize_edit_group( ) -@admin_required -@error_handler -async def process_edit_group_auto_assign_enabled( +async def _apply_edit_auto_assign_choice( message: types.Message, state: FSMContext, db_user, db: AsyncSession, + *, + auto_assign_enabled: bool, ): data = await state.get_data() texts = get_texts(data.get("language", db_user.language)) - try: - auto_enabled = _parse_bool_response(message.text) - except ValueError: - await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_BOOL", "Введите «да» или «нет».")) - return + await state.update_data(edit_group_auto_assign_enabled=auto_assign_enabled) - await state.update_data(edit_group_auto_assign_enabled=auto_enabled) - - if auto_enabled: + if auto_assign_enabled: await state.set_state(AdminStates.editing_promo_group_auto_assign_threshold) current_amount = data.get("current_group_spent_threshold", 0) await message.answer( @@ -666,6 +739,58 @@ async def process_edit_group_auto_assign_enabled( ) +@admin_required +@error_handler +async def process_edit_group_auto_assign_enabled( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + auto_enabled = _parse_bool_response(message.text) + except ValueError: + await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_BOOL", "Пожалуйста, воспользуйтесь кнопками ниже.")) + return + + await _apply_edit_auto_assign_choice( + message, + state, + db_user, + db, + auto_assign_enabled=auto_enabled, + ) + + +@admin_required +@error_handler +async def process_edit_group_auto_assign_enabled_callback( + callback: types.CallbackQuery, + state: FSMContext, + db_user, + db: AsyncSession, +): + current_state = await state.get_state() + if current_state != AdminStates.editing_promo_group_auto_assign_enabled.state: + await callback.answer() + return + + auto_enabled = callback.data == EDIT_AUTO_ASSIGN_ENABLE + + await callback.message.edit_reply_markup() + await _apply_edit_auto_assign_choice( + callback.message, + state, + db_user, + db, + auto_assign_enabled=auto_enabled, + ) + await callback.answer() + + @admin_required @error_handler async def process_edit_group_auto_assign_threshold( @@ -841,6 +966,15 @@ def register_handlers(dp: Dispatcher): F.data.regexp(r"^promo_group_members_\d+_page_\d+$"), ) + dp.callback_query.register( + process_create_group_auto_assign_enabled_callback, + F.data.in_({CREATE_AUTO_ASSIGN_ENABLE, CREATE_AUTO_ASSIGN_DISABLE}), + ) + dp.callback_query.register( + process_edit_group_auto_assign_enabled_callback, + F.data.in_({EDIT_AUTO_ASSIGN_ENABLE, EDIT_AUTO_ASSIGN_DISABLE}), + ) + dp.message.register(process_create_group_name, AdminStates.creating_promo_group_name) dp.message.register( process_create_group_traffic, diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index c9f29fbc..b68cbd55 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -145,6 +145,8 @@ "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLED": "🤖 Auto assignment: after spending {amount}", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "🤖 Auto assignment: disabled", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLE_BUTTON": "✅ Enable", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLE_BUTTON": "🚫 Keep disabled", "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.", "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members", "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit", @@ -154,10 +156,10 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enable automatic assignment by total spending? (yes/no)", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enable automatic assignment based on total spending? Choose an option below.", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Enter the spending amount in RUB required for automatic assignment:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", - "ADMIN_PROMO_GROUP_INVALID_BOOL": "Please answer \"yes\" or \"no\".", + "ADMIN_PROMO_GROUP_INVALID_BOOL": "Please use the buttons below.", "ADMIN_PROMO_GROUP_INVALID_AMOUNT": "Enter a non-negative number.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", @@ -165,7 +167,7 @@ "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Auto assignment is currently: {status}. Enable automatic assignment? (yes/no)", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Auto assignment is currently: {status}. Enable automatic assignment? Choose an option below.", "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_THRESHOLD_PROMPT": "Enter the new spending amount in RUB (current: {amount}):", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 2d3a49ca..6057eb05 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -33,6 +33,8 @@ "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLED": "🤖 Автоназначение: при тратах от {amount}", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "🤖 Автоназначение: выключено", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLE_BUTTON": "✅ Включить", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLE_BUTTON": "🚫 Оставить выключенным", "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.", "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники", "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить", @@ -42,10 +44,10 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Включить автоматическое назначение по сумме трат? (да/нет)", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Включить автоматическое назначение по сумме трат? Выберите вариант ниже.", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Введите сумму трат в рублях для автоматического назначения:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", - "ADMIN_PROMO_GROUP_INVALID_BOOL": "Введите «да» или «нет».", + "ADMIN_PROMO_GROUP_INVALID_BOOL": "Пожалуйста, воспользуйтесь кнопками ниже.", "ADMIN_PROMO_GROUP_INVALID_AMOUNT": "Введите неотрицательное число.", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", @@ -53,7 +55,7 @@ "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Автоназначение сейчас: {status}. Включить автоматическое назначение? (да/нет)", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Автоназначение сейчас: {status}. Включить автоматическое назначение? Выберите вариант ниже.", "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_THRESHOLD_PROMPT": "Введите новую сумму трат в рублях (текущее значение: {amount}):", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", diff --git a/app/services/promo_group_service.py b/app/services/promo_group_service.py index 04c16579..5281d0b8 100644 --- a/app/services/promo_group_service.py +++ b/app/services/promo_group_service.py @@ -35,11 +35,24 @@ async def auto_assign_promo_group_by_spent( if not target_group or user.promo_group_id == target_group.id: return None + last_auto_assigned_id = getattr(user, "last_auto_assigned_promo_group_id", None) + if ( + last_auto_assigned_id == target_group.id + and user.promo_group_id != target_group.id + ): + logger.debug( + "👥 Пропущено автоназначение пользователя %s в промогруппу '%s' — уже было выполнено ранее", + user.telegram_id, + target_group.name, + ) + return None + previous_group_id = user.promo_group_id user.promo_group_id = target_group.id user.promo_group = target_group user.updated_at = datetime.utcnow() + user.last_auto_assigned_promo_group_id = target_group.id await db.commit() await db.refresh(user) diff --git a/locales/en.json b/locales/en.json index 36b5a47e..89b49f4c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -207,6 +207,8 @@ "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLED": "🤖 Auto assignment: after spending {amount}", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "🤖 Auto assignment: disabled", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLE_BUTTON": "✅ Enable", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLE_BUTTON": "🚫 Keep disabled", "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.", "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members", "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit", @@ -216,10 +218,10 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enable automatic assignment by total spending? (yes/no)", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enable automatic assignment based on total spending? Choose an option below.", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Enter the spending amount in RUB required for automatic assignment:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", - "ADMIN_PROMO_GROUP_INVALID_BOOL": "Please answer \"yes\" or \"no\".", + "ADMIN_PROMO_GROUP_INVALID_BOOL": "Please use the buttons below.", "ADMIN_PROMO_GROUP_INVALID_AMOUNT": "Enter a non-negative number.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", @@ -227,7 +229,7 @@ "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Auto assignment is currently: {status}. Enable automatic assignment? (yes/no)", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Auto assignment is currently: {status}. Enable automatic assignment? Choose an option below.", "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_THRESHOLD_PROMPT": "Enter the new spending amount in RUB (current: {amount}):", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", diff --git a/locales/ru.json b/locales/ru.json index 75480f09..ee551698 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -95,6 +95,8 @@ "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLED": "🤖 Автоназначение: при тратах от {amount}", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "🤖 Автоназначение: выключено", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLE_BUTTON": "✅ Включить", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLE_BUTTON": "🚫 Оставить выключенным", "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.", "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники", "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить", @@ -104,10 +106,10 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Включить автоматическое назначение по сумме трат? (да/нет)", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Включить автоматическое назначение по сумме трат? Выберите вариант ниже.", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Введите сумму трат в рублях для автоматического назначения:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", - "ADMIN_PROMO_GROUP_INVALID_BOOL": "Введите «да» или «нет».", + "ADMIN_PROMO_GROUP_INVALID_BOOL": "Пожалуйста, воспользуйтесь кнопками ниже.", "ADMIN_PROMO_GROUP_INVALID_AMOUNT": "Введите неотрицательное число.", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", @@ -115,7 +117,7 @@ "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Автоназначение сейчас: {status}. Включить автоматическое назначение? (да/нет)", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Автоназначение сейчас: {status}. Включить автоматическое назначение? Выберите вариант ниже.", "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_THRESHOLD_PROMPT": "Введите новую сумму трат в рублях (текущее значение: {amount}):", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", diff --git a/migrations/alembic/versions/c2cbe3d3ad12_add_last_auto_assigned_column.py b/migrations/alembic/versions/c2cbe3d3ad12_add_last_auto_assigned_column.py new file mode 100644 index 00000000..e697f97c --- /dev/null +++ b/migrations/alembic/versions/c2cbe3d3ad12_add_last_auto_assigned_column.py @@ -0,0 +1,53 @@ +"""Add last_auto_assigned_promo_group_id to users + +Revision ID: c2cbe3d3ad12 +Revises: b6b5c77e2a9d +Create Date: 2024-05-21 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "c2cbe3d3ad12" +down_revision: Union[str, None] = "b6b5c77e2a9d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +USERS_TABLE = "users" +LAST_AUTO_ASSIGN_COLUMN = "last_auto_assigned_promo_group_id" + + +def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool: + return any(column["name"] == column_name for column in inspector.get_columns(table_name)) + + +def _table_exists(inspector: sa.Inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not _table_exists(inspector, USERS_TABLE): + return + + if not _column_exists(inspector, USERS_TABLE, LAST_AUTO_ASSIGN_COLUMN): + op.add_column( + USERS_TABLE, + sa.Column(LAST_AUTO_ASSIGN_COLUMN, sa.Integer(), nullable=True), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not _table_exists(inspector, USERS_TABLE): + return + + if _column_exists(inspector, USERS_TABLE, LAST_AUTO_ASSIGN_COLUMN): + op.drop_column(USERS_TABLE, LAST_AUTO_ASSIGN_COLUMN) From 50c7eaa29a0c7a894f2c61017785acac150b5428 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 04:16:47 +0300 Subject: [PATCH 22/28] Revert "Add auto-assign controls and prevent repeated promo group reassignment" --- .gitignore | 2 - app/database/models.py | 1 - app/database/universal_migration.py | 9 +- app/handlers/admin/promo_groups.py | 186 +++--------------- app/localization/locales/en.json | 8 +- app/localization/locales/ru.json | 8 +- app/services/promo_group_service.py | 13 -- locales/en.json | 8 +- locales/ru.json | 8 +- ...be3d3ad12_add_last_auto_assigned_column.py | 53 ----- 10 files changed, 40 insertions(+), 256 deletions(-) delete mode 100644 migrations/alembic/versions/c2cbe3d3ad12_add_last_auto_assigned_column.py diff --git a/.gitignore b/.gitignore index cc5ce62a..35826240 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,6 @@ # Разрешаем папку app/ и все её содержимое рекурсивно !app/ !app/** -!migrations/ -!migrations/** !locales/ !locales/** diff --git a/app/database/models.py b/app/database/models.py index 810c4735..e689b1da 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -226,7 +226,6 @@ class User(Base): has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True) promo_group = relationship("PromoGroup", back_populates="users") - last_auto_assigned_promo_group_id = Column(Integer, nullable=True) @property def balance_rubles(self) -> float: diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 39c04b9d..aa68e45f 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1250,8 +1250,7 @@ async def check_migration_status(): "promo_groups_table": False, "promo_groups_auto_assign_column": False, "promo_groups_spent_threshold_column": False, - "users_promo_group_column": False, - "users_last_auto_assign_column": False, + "users_promo_group_column": False } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -1270,9 +1269,6 @@ async def check_migration_status(): status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled') status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') - status["users_last_auto_assign_column"] = await check_column_exists( - 'users', 'last_auto_assigned_promo_group_id' - ) media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -1306,8 +1302,7 @@ async def check_migration_status(): "promo_groups_table": "Таблица промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_enabled у промогрупп", "promo_groups_spent_threshold_column": "Колонка spent_threshold_kopeks у промогрупп", - "users_promo_group_column": "Колонка promo_group_id у пользователей", - "users_last_auto_assign_column": "Колонка last_auto_assigned_promo_group_id у пользователей", + "users_promo_group_column": "Колонка promo_group_id у пользователей" } for check_key, check_status in status.items(): diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index eab0dde1..3ef83a03 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -24,14 +24,6 @@ from app.keyboards.admin import ( get_confirmation_keyboard, ) -CREATE_AUTO_ASSIGN_PREFIX = "promo_group_create_auto_assign" -EDIT_AUTO_ASSIGN_PREFIX = "promo_group_edit_auto_assign" - -CREATE_AUTO_ASSIGN_ENABLE = f"{CREATE_AUTO_ASSIGN_PREFIX}_yes" -CREATE_AUTO_ASSIGN_DISABLE = f"{CREATE_AUTO_ASSIGN_PREFIX}_no" -EDIT_AUTO_ASSIGN_ENABLE = f"{EDIT_AUTO_ASSIGN_PREFIX}_yes" -EDIT_AUTO_ASSIGN_DISABLE = f"{EDIT_AUTO_ASSIGN_PREFIX}_no" - logger = logging.getLogger(__name__) @@ -260,29 +252,6 @@ async def _prompt_for_discount( await message.answer(texts.t(prompt_key, default_text)) -def _get_auto_assign_keyboard(texts, prefix: str) -> types.InlineKeyboardMarkup: - return types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t( - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLE_BUTTON", - "✅ Включить", - ), - callback_data=f"{prefix}_yes", - ), - types.InlineKeyboardButton( - text=texts.t( - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLE_BUTTON", - "🚫 Оставить выключенным", - ), - callback_data=f"{prefix}_no", - ), - ] - ] - ) - - @admin_required @error_handler async def start_create_promo_group( @@ -380,9 +349,8 @@ async def process_create_group_devices( await message.answer( texts.t( "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT", - "Включить автоматическое назначение по сумме трат? Выберите вариант ниже.", - ), - reply_markup=_get_auto_assign_keyboard(texts, CREATE_AUTO_ASSIGN_PREFIX), + "Включить автоматическое назначение по сумме трат? (да/нет)", + ) ) @@ -435,20 +403,26 @@ async def _finalize_create_group( ) -async def _apply_create_auto_assign_choice( +@admin_required +@error_handler +async def process_create_group_auto_assign_enabled( message: types.Message, state: FSMContext, db_user, db: AsyncSession, - *, - auto_assign_enabled: bool, ): data = await state.get_data() texts = get_texts(data.get("language", db_user.language)) - await state.update_data(new_group_auto_assign_enabled=auto_assign_enabled) + try: + auto_enabled = _parse_bool_response(message.text) + except ValueError: + await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_BOOL", "Введите «да» или «нет».")) + return - if auto_assign_enabled: + await state.update_data(new_group_auto_assign_enabled=auto_enabled) + + if auto_enabled: await state.set_state(AdminStates.creating_promo_group_auto_assign_threshold) await message.answer( texts.t( @@ -468,58 +442,6 @@ async def _apply_create_auto_assign_choice( ) -@admin_required -@error_handler -async def process_create_group_auto_assign_enabled( - message: types.Message, - state: FSMContext, - db_user, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) - - try: - auto_enabled = _parse_bool_response(message.text) - except ValueError: - await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_BOOL", "Пожалуйста, воспользуйтесь кнопками ниже.")) - return - - await _apply_create_auto_assign_choice( - message, - state, - db_user, - db, - auto_assign_enabled=auto_enabled, - ) - - -@admin_required -@error_handler -async def process_create_group_auto_assign_enabled_callback( - callback: types.CallbackQuery, - state: FSMContext, - db_user, - db: AsyncSession, -): - current_state = await state.get_state() - if current_state != AdminStates.creating_promo_group_auto_assign_enabled.state: - await callback.answer() - return - - auto_enabled = callback.data == CREATE_AUTO_ASSIGN_ENABLE - - await callback.message.edit_reply_markup() - await _apply_create_auto_assign_choice( - callback.message, - state, - db_user, - db, - auto_assign_enabled=auto_enabled, - ) - await callback.answer() - - @admin_required @error_handler async def process_create_group_auto_assign_threshold( @@ -664,9 +586,8 @@ async def process_edit_group_devices( await message.answer( texts.t( "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT", - "Автоназначение сейчас: {status}. Включить автоматическое назначение? Выберите вариант ниже.", - ).format(status=status_text), - reply_markup=_get_auto_assign_keyboard(texts, EDIT_AUTO_ASSIGN_PREFIX), + "Автоназначение сейчас: {status}. Включить автоматическое назначение? (да/нет)", + ).format(status=status_text) ) @@ -705,20 +626,26 @@ async def _finalize_edit_group( ) -async def _apply_edit_auto_assign_choice( +@admin_required +@error_handler +async def process_edit_group_auto_assign_enabled( message: types.Message, state: FSMContext, db_user, db: AsyncSession, - *, - auto_assign_enabled: bool, ): data = await state.get_data() texts = get_texts(data.get("language", db_user.language)) - await state.update_data(edit_group_auto_assign_enabled=auto_assign_enabled) + try: + auto_enabled = _parse_bool_response(message.text) + except ValueError: + await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_BOOL", "Введите «да» или «нет».")) + return - if auto_assign_enabled: + await state.update_data(edit_group_auto_assign_enabled=auto_enabled) + + if auto_enabled: await state.set_state(AdminStates.editing_promo_group_auto_assign_threshold) current_amount = data.get("current_group_spent_threshold", 0) await message.answer( @@ -739,58 +666,6 @@ async def _apply_edit_auto_assign_choice( ) -@admin_required -@error_handler -async def process_edit_group_auto_assign_enabled( - message: types.Message, - state: FSMContext, - db_user, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) - - try: - auto_enabled = _parse_bool_response(message.text) - except ValueError: - await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_BOOL", "Пожалуйста, воспользуйтесь кнопками ниже.")) - return - - await _apply_edit_auto_assign_choice( - message, - state, - db_user, - db, - auto_assign_enabled=auto_enabled, - ) - - -@admin_required -@error_handler -async def process_edit_group_auto_assign_enabled_callback( - callback: types.CallbackQuery, - state: FSMContext, - db_user, - db: AsyncSession, -): - current_state = await state.get_state() - if current_state != AdminStates.editing_promo_group_auto_assign_enabled.state: - await callback.answer() - return - - auto_enabled = callback.data == EDIT_AUTO_ASSIGN_ENABLE - - await callback.message.edit_reply_markup() - await _apply_edit_auto_assign_choice( - callback.message, - state, - db_user, - db, - auto_assign_enabled=auto_enabled, - ) - await callback.answer() - - @admin_required @error_handler async def process_edit_group_auto_assign_threshold( @@ -966,15 +841,6 @@ def register_handlers(dp: Dispatcher): F.data.regexp(r"^promo_group_members_\d+_page_\d+$"), ) - dp.callback_query.register( - process_create_group_auto_assign_enabled_callback, - F.data.in_({CREATE_AUTO_ASSIGN_ENABLE, CREATE_AUTO_ASSIGN_DISABLE}), - ) - dp.callback_query.register( - process_edit_group_auto_assign_enabled_callback, - F.data.in_({EDIT_AUTO_ASSIGN_ENABLE, EDIT_AUTO_ASSIGN_DISABLE}), - ) - dp.message.register(process_create_group_name, AdminStates.creating_promo_group_name) dp.message.register( process_create_group_traffic, diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index b68cbd55..c9f29fbc 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -145,8 +145,6 @@ "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLED": "🤖 Auto assignment: after spending {amount}", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "🤖 Auto assignment: disabled", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLE_BUTTON": "✅ Enable", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLE_BUTTON": "🚫 Keep disabled", "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.", "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members", "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit", @@ -156,10 +154,10 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enable automatic assignment based on total spending? Choose an option below.", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enable automatic assignment by total spending? (yes/no)", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Enter the spending amount in RUB required for automatic assignment:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", - "ADMIN_PROMO_GROUP_INVALID_BOOL": "Please use the buttons below.", + "ADMIN_PROMO_GROUP_INVALID_BOOL": "Please answer \"yes\" or \"no\".", "ADMIN_PROMO_GROUP_INVALID_AMOUNT": "Enter a non-negative number.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", @@ -167,7 +165,7 @@ "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Auto assignment is currently: {status}. Enable automatic assignment? Choose an option below.", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Auto assignment is currently: {status}. Enable automatic assignment? (yes/no)", "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_THRESHOLD_PROMPT": "Enter the new spending amount in RUB (current: {amount}):", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 6057eb05..2d3a49ca 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -33,8 +33,6 @@ "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLED": "🤖 Автоназначение: при тратах от {amount}", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "🤖 Автоназначение: выключено", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLE_BUTTON": "✅ Включить", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLE_BUTTON": "🚫 Оставить выключенным", "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.", "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники", "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить", @@ -44,10 +42,10 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Включить автоматическое назначение по сумме трат? Выберите вариант ниже.", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Включить автоматическое назначение по сумме трат? (да/нет)", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Введите сумму трат в рублях для автоматического назначения:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", - "ADMIN_PROMO_GROUP_INVALID_BOOL": "Пожалуйста, воспользуйтесь кнопками ниже.", + "ADMIN_PROMO_GROUP_INVALID_BOOL": "Введите «да» или «нет».", "ADMIN_PROMO_GROUP_INVALID_AMOUNT": "Введите неотрицательное число.", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", @@ -55,7 +53,7 @@ "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Автоназначение сейчас: {status}. Включить автоматическое назначение? Выберите вариант ниже.", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Автоназначение сейчас: {status}. Включить автоматическое назначение? (да/нет)", "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_THRESHOLD_PROMPT": "Введите новую сумму трат в рублях (текущее значение: {amount}):", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", diff --git a/app/services/promo_group_service.py b/app/services/promo_group_service.py index 5281d0b8..04c16579 100644 --- a/app/services/promo_group_service.py +++ b/app/services/promo_group_service.py @@ -35,24 +35,11 @@ async def auto_assign_promo_group_by_spent( if not target_group or user.promo_group_id == target_group.id: return None - last_auto_assigned_id = getattr(user, "last_auto_assigned_promo_group_id", None) - if ( - last_auto_assigned_id == target_group.id - and user.promo_group_id != target_group.id - ): - logger.debug( - "👥 Пропущено автоназначение пользователя %s в промогруппу '%s' — уже было выполнено ранее", - user.telegram_id, - target_group.name, - ) - return None - previous_group_id = user.promo_group_id user.promo_group_id = target_group.id user.promo_group = target_group user.updated_at = datetime.utcnow() - user.last_auto_assigned_promo_group_id = target_group.id await db.commit() await db.refresh(user) diff --git a/locales/en.json b/locales/en.json index 89b49f4c..36b5a47e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -207,8 +207,6 @@ "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLED": "🤖 Auto assignment: after spending {amount}", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "🤖 Auto assignment: disabled", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLE_BUTTON": "✅ Enable", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLE_BUTTON": "🚫 Keep disabled", "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.", "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members", "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit", @@ -218,10 +216,10 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enable automatic assignment based on total spending? Choose an option below.", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enable automatic assignment by total spending? (yes/no)", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Enter the spending amount in RUB required for automatic assignment:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", - "ADMIN_PROMO_GROUP_INVALID_BOOL": "Please use the buttons below.", + "ADMIN_PROMO_GROUP_INVALID_BOOL": "Please answer \"yes\" or \"no\".", "ADMIN_PROMO_GROUP_INVALID_AMOUNT": "Enter a non-negative number.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", @@ -229,7 +227,7 @@ "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Auto assignment is currently: {status}. Enable automatic assignment? Choose an option below.", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Auto assignment is currently: {status}. Enable automatic assignment? (yes/no)", "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_THRESHOLD_PROMPT": "Enter the new spending amount in RUB (current: {amount}):", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", diff --git a/locales/ru.json b/locales/ru.json index ee551698..75480f09 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -95,8 +95,6 @@ "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLED": "🤖 Автоназначение: при тратах от {amount}", "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "🤖 Автоназначение: выключено", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLE_BUTTON": "✅ Включить", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLE_BUTTON": "🚫 Оставить выключенным", "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.", "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники", "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить", @@ -106,10 +104,10 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Включить автоматическое назначение по сумме трат? Выберите вариант ниже.", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Включить автоматическое назначение по сумме трат? (да/нет)", "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Введите сумму трат в рублях для автоматического назначения:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", - "ADMIN_PROMO_GROUP_INVALID_BOOL": "Пожалуйста, воспользуйтесь кнопками ниже.", + "ADMIN_PROMO_GROUP_INVALID_BOOL": "Введите «да» или «нет».", "ADMIN_PROMO_GROUP_INVALID_AMOUNT": "Введите неотрицательное число.", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", @@ -117,7 +115,7 @@ "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Автоназначение сейчас: {status}. Включить автоматическое назначение? Выберите вариант ниже.", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Автоназначение сейчас: {status}. Включить автоматическое назначение? (да/нет)", "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_THRESHOLD_PROMPT": "Введите новую сумму трат в рублях (текущее значение: {amount}):", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", diff --git a/migrations/alembic/versions/c2cbe3d3ad12_add_last_auto_assigned_column.py b/migrations/alembic/versions/c2cbe3d3ad12_add_last_auto_assigned_column.py deleted file mode 100644 index e697f97c..00000000 --- a/migrations/alembic/versions/c2cbe3d3ad12_add_last_auto_assigned_column.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Add last_auto_assigned_promo_group_id to users - -Revision ID: c2cbe3d3ad12 -Revises: b6b5c77e2a9d -Create Date: 2024-05-21 00:00:00.000000 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -revision: str = "c2cbe3d3ad12" -down_revision: Union[str, None] = "b6b5c77e2a9d" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - -USERS_TABLE = "users" -LAST_AUTO_ASSIGN_COLUMN = "last_auto_assigned_promo_group_id" - - -def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool: - return any(column["name"] == column_name for column in inspector.get_columns(table_name)) - - -def _table_exists(inspector: sa.Inspector, table_name: str) -> bool: - return table_name in inspector.get_table_names() - - -def upgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if not _table_exists(inspector, USERS_TABLE): - return - - if not _column_exists(inspector, USERS_TABLE, LAST_AUTO_ASSIGN_COLUMN): - op.add_column( - USERS_TABLE, - sa.Column(LAST_AUTO_ASSIGN_COLUMN, sa.Integer(), nullable=True), - ) - - -def downgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if not _table_exists(inspector, USERS_TABLE): - return - - if _column_exists(inspector, USERS_TABLE, LAST_AUTO_ASSIGN_COLUMN): - op.drop_column(USERS_TABLE, LAST_AUTO_ASSIGN_COLUMN) From 44d249ad5b3c3eb754eb53b2e82ee66994410788 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 04:17:17 +0300 Subject: [PATCH 23/28] Revert "feat: support auto promo group assignment by spending" --- app/database/crud/promo_group.py | 23 +- app/database/crud/transaction.py | 17 +- app/database/models.py | 2 - app/database/universal_migration.py | 72 ----- app/handlers/admin/promo_groups.py | 258 +----------------- app/handlers/admin/users.py | 2 - app/handlers/subscription.py | 30 +- app/localization/locales/en.json | 8 - app/localization/locales/ru.json | 8 - app/services/promo_group_service.py | 63 ----- app/states.py | 4 - locales/en.json | 8 - locales/ru.json | 8 - ...77e2a9d_add_auto_assign_to_promo_groups.py | 88 ------ 14 files changed, 14 insertions(+), 577 deletions(-) delete mode 100644 app/services/promo_group_service.py delete mode 100644 migrations/alembic/versions/b6b5c77e2a9d_add_auto_assign_to_promo_groups.py diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 619cb494..3845f531 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -40,16 +40,12 @@ async def create_promo_group( server_discount_percent: int, traffic_discount_percent: int, device_discount_percent: int, - auto_assign_enabled: bool = False, - spent_threshold_kopeks: int = 0, ) -> PromoGroup: promo_group = PromoGroup( name=name.strip(), server_discount_percent=max(0, min(100, server_discount_percent)), traffic_discount_percent=max(0, min(100, traffic_discount_percent)), device_discount_percent=max(0, min(100, device_discount_percent)), - auto_assign_enabled=auto_assign_enabled, - spent_threshold_kopeks=max(0, spent_threshold_kopeks), is_default=False, ) @@ -58,13 +54,11 @@ async def create_promo_group( await db.refresh(promo_group) logger.info( - "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%), авто=%s, порог=%s", + "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%)", promo_group.name, promo_group.server_discount_percent, promo_group.traffic_discount_percent, promo_group.device_discount_percent, - promo_group.auto_assign_enabled, - promo_group.spent_threshold_kopeks, ) return promo_group @@ -78,8 +72,6 @@ async def update_promo_group( server_discount_percent: Optional[int] = None, traffic_discount_percent: Optional[int] = None, device_discount_percent: Optional[int] = None, - auto_assign_enabled: Optional[bool] = None, - spent_threshold_kopeks: Optional[int] = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -89,10 +81,6 @@ async def update_promo_group( group.traffic_discount_percent = max(0, min(100, traffic_discount_percent)) if device_discount_percent is not None: group.device_discount_percent = max(0, min(100, device_discount_percent)) - if auto_assign_enabled is not None: - group.auto_assign_enabled = bool(auto_assign_enabled) - if spent_threshold_kopeks is not None: - group.spent_threshold_kopeks = max(0, spent_threshold_kopeks) await db.commit() await db.refresh(group) @@ -105,15 +93,6 @@ async def update_promo_group( return group -async def get_auto_assign_promo_groups(db: AsyncSession) -> List[PromoGroup]: - result = await db.execute( - select(PromoGroup) - .where(PromoGroup.auto_assign_enabled.is_(True)) - .order_by(PromoGroup.spent_threshold_kopeks.desc(), PromoGroup.id) - ) - return result.scalars().all() - - async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool: if group.is_default: logger.warning("Попытка удалить базовую промогруппу запрещена") diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index 3614b631..b258f1b2 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -111,7 +111,7 @@ async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Tr async def get_pending_transactions(db: AsyncSession) -> List[Transaction]: - + result = await db.execute( select(Transaction) .options(selectinload(Transaction.user)) @@ -121,21 +121,6 @@ async def get_pending_transactions(db: AsyncSession) -> List[Transaction]: return result.scalars().all() -async def get_user_total_spent(db: AsyncSession, user_id: int) -> int: - result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where( - and_( - Transaction.user_id == user_id, - Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, - Transaction.is_completed == True, - ) - ) - ) - total = result.scalar() - return total or 0 - - async def get_transactions_statistics( db: AsyncSession, start_date: Optional[datetime] = None, diff --git a/app/database/models.py b/app/database/models.py index e689b1da..2491eef2 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -163,8 +163,6 @@ class PromoGroup(Base): server_discount_percent = Column(Integer, nullable=False, default=0) traffic_discount_percent = Column(Integer, nullable=False, default=0) device_discount_percent = Column(Integer, nullable=False, default=0) - auto_assign_enabled = Column(Boolean, nullable=False, default=False) - spent_threshold_kopeks = Column(Integer, nullable=False, default=0) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index aa68e45f..2b1136ca 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -435,68 +435,6 @@ async def ensure_promo_groups_setup(): f"Не удалось добавить уникальное ограничение uq_promo_groups_name: {e}" ) - auto_assign_exists = await check_column_exists( - "promo_groups", "auto_assign_enabled" - ) - - if not auto_assign_exists: - if db_type == "sqlite": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN auto_assign_enabled BOOLEAN NOT NULL DEFAULT 0" - ) - ) - elif db_type == "postgresql": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN auto_assign_enabled BOOLEAN NOT NULL DEFAULT FALSE" - ) - ) - elif db_type == "mysql": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN auto_assign_enabled TINYINT(1) NOT NULL DEFAULT 0" - ) - ) - else: - logger.error( - f"Неподдерживаемый тип БД для promo_groups.auto_assign_enabled: {db_type}" - ) - return False - - logger.info("Добавлена колонка promo_groups.auto_assign_enabled") - - threshold_exists = await check_column_exists( - "promo_groups", "spent_threshold_kopeks" - ) - - if not threshold_exists: - if db_type == "sqlite": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN spent_threshold_kopeks INTEGER NOT NULL DEFAULT 0" - ) - ) - elif db_type == "postgresql": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN spent_threshold_kopeks INTEGER NOT NULL DEFAULT 0" - ) - ) - elif db_type == "mysql": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN spent_threshold_kopeks INT NOT NULL DEFAULT 0" - ) - ) - else: - logger.error( - f"Неподдерживаемый тип БД для promo_groups.spent_threshold_kopeks: {db_type}" - ) - return False - - logger.info("Добавлена колонка promo_groups.spent_threshold_kopeks") - column_exists = await check_column_exists("users", "promo_group_id") if not column_exists: @@ -1248,8 +1186,6 @@ async def check_migration_status(): "subscription_duplicates": False, "subscription_conversions_table": False, "promo_groups_table": False, - "promo_groups_auto_assign_column": False, - "promo_groups_spent_threshold_column": False, "users_promo_group_column": False } @@ -1260,12 +1196,6 @@ async def check_migration_status(): status["welcome_texts_table"] = await check_table_exists('welcome_texts') status["subscription_conversions_table"] = await check_table_exists('subscription_conversions') status["promo_groups_table"] = await check_table_exists('promo_groups') - status["promo_groups_auto_assign_column"] = await check_column_exists( - 'promo_groups', 'auto_assign_enabled' - ) - status["promo_groups_spent_threshold_column"] = await check_column_exists( - 'promo_groups', 'spent_threshold_kopeks' - ) status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled') status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') @@ -1300,8 +1230,6 @@ async def check_migration_status(): "subscription_conversions_table": "Таблица конверсий подписок", "subscription_duplicates": "Отсутствие дубликатов подписок", "promo_groups_table": "Таблица промо-групп", - "promo_groups_auto_assign_column": "Колонка auto_assign_enabled у промогрупп", - "promo_groups_spent_threshold_column": "Колонка spent_threshold_kopeks у промогрупп", "users_promo_group_column": "Колонка promo_group_id у пользователей" } diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 3ef83a03..ef927f94 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -1,5 +1,4 @@ import logging -from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Optional from aiogram import Dispatcher, types, F @@ -38,48 +37,6 @@ def _format_discount_line(texts, group) -> str: ) -def _format_auto_assign_line(texts, group) -> str: - if getattr(group, "auto_assign_enabled", False): - amount = texts.format_price(getattr(group, "spent_threshold_kopeks", 0)) - return texts.t( - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLED", - "🤖 Автоназначение: при тратах от {amount}", - ).format(amount=amount) - return texts.t( - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED", - "🤖 Автоназначение: выключено", - ) - - -def _parse_bool_response(value: str) -> bool: - normalized = value.strip().lower() - truthy = {"да", "д", "yes", "y", "true", "1", "on"} - falsy = {"нет", "н", "no", "n", "false", "0", "off"} - - if normalized in truthy: - return True - if normalized in falsy: - return False - raise ValueError - - -def _parse_amount_to_kopeks(value: str) -> int: - normalized = value.replace(" ", "").replace(",", ".").strip() - if not normalized: - raise ValueError - - try: - amount = Decimal(normalized) - except (InvalidOperation, ValueError): - raise ValueError from None - - if amount < 0: - raise ValueError - - kopeks = (amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP) - return int(kopeks) - - @admin_required @error_handler async def show_promo_groups_menu( @@ -111,7 +68,6 @@ async def show_promo_groups_menu( [ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", _format_discount_line(texts, group), - _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", "Участников: {count}", @@ -183,7 +139,6 @@ async def show_promo_group_details( "💳 Промогруппа: {name}", ).format(name=group.name), _format_discount_line(texts, group), - _format_auto_assign_line(texts, group), texts.t( "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", "Участников: {count}", @@ -344,37 +299,13 @@ async def process_create_group_devices( await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) return - await state.update_data(new_group_devices=devices_discount) - await state.set_state(AdminStates.creating_promo_group_auto_assign_enabled) - await message.answer( - texts.t( - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT", - "Включить автоматическое назначение по сумме трат? (да/нет)", - ) - ) - - -async def _finalize_create_group( - message: types.Message, - state: FSMContext, - db_user, - db: AsyncSession, - *, - auto_assign_enabled: bool, - spent_threshold_kopeks: int, -): - data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) - try: group = await create_promo_group( db, data["new_group_name"], traffic_discount_percent=data["new_group_traffic"], server_discount_percent=data["new_group_servers"], - device_discount_percent=data["new_group_devices"], - auto_assign_enabled=auto_assign_enabled, - spent_threshold_kopeks=spent_threshold_kopeks, + device_discount_percent=devices_discount, ) except Exception as e: logger.error(f"Не удалось создать промогруппу: {e}") @@ -403,72 +334,6 @@ async def _finalize_create_group( ) -@admin_required -@error_handler -async def process_create_group_auto_assign_enabled( - message: types.Message, - state: FSMContext, - db_user, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) - - try: - auto_enabled = _parse_bool_response(message.text) - except ValueError: - await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_BOOL", "Введите «да» или «нет».")) - return - - await state.update_data(new_group_auto_assign_enabled=auto_enabled) - - if auto_enabled: - await state.set_state(AdminStates.creating_promo_group_auto_assign_threshold) - await message.answer( - texts.t( - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT", - "Введите сумму трат в рублях для автоматического назначения:", - ) - ) - return - - await _finalize_create_group( - message, - state, - db_user, - db, - auto_assign_enabled=False, - spent_threshold_kopeks=0, - ) - - -@admin_required -@error_handler -async def process_create_group_auto_assign_threshold( - message: types.Message, - state: FSMContext, - db_user, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) - - try: - threshold_kopeks = _parse_amount_to_kopeks(message.text) - except ValueError: - await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_AMOUNT", "Введите неотрицательное число.")) - return - - await _finalize_create_group( - message, - state, - db_user, - db, - auto_assign_enabled=data.get("new_group_auto_assign_enabled", False), - spent_threshold_kopeks=threshold_kopeks, - ) - - @admin_required @error_handler async def start_edit_promo_group( @@ -569,40 +434,6 @@ async def process_edit_group_devices( await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) return - group = await get_promo_group_by_id(db, data["edit_group_id"]) - if not group: - await message.answer("❌ Промогруппа не найдена") - await state.clear() - return - - await state.update_data( - edit_group_devices=devices_discount, - current_group_auto_assign_enabled=group.auto_assign_enabled, - current_group_spent_threshold=group.spent_threshold_kopeks, - ) - - await state.set_state(AdminStates.editing_promo_group_auto_assign_enabled) - status_text = _format_auto_assign_line(texts, group) - await message.answer( - texts.t( - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT", - "Автоназначение сейчас: {status}. Включить автоматическое назначение? (да/нет)", - ).format(status=status_text) - ) - - -async def _finalize_edit_group( - message: types.Message, - state: FSMContext, - db_user, - db: AsyncSession, - *, - auto_assign_enabled: bool, - spent_threshold_kopeks: int, -): - data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) - group = await get_promo_group_by_id(db, data["edit_group_id"]) if not group: await message.answer("❌ Промогруппа не найдена") @@ -615,9 +446,7 @@ async def _finalize_edit_group( name=data["edit_group_name"], traffic_discount_percent=data["edit_group_traffic"], server_discount_percent=data["edit_group_servers"], - device_discount_percent=data["edit_group_devices"], - auto_assign_enabled=auto_assign_enabled, - spent_threshold_kopeks=spent_threshold_kopeks, + device_discount_percent=devices_discount, ) await state.clear() @@ -626,73 +455,6 @@ async def _finalize_edit_group( ) -@admin_required -@error_handler -async def process_edit_group_auto_assign_enabled( - message: types.Message, - state: FSMContext, - db_user, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) - - try: - auto_enabled = _parse_bool_response(message.text) - except ValueError: - await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_BOOL", "Введите «да» или «нет».")) - return - - await state.update_data(edit_group_auto_assign_enabled=auto_enabled) - - if auto_enabled: - await state.set_state(AdminStates.editing_promo_group_auto_assign_threshold) - current_amount = data.get("current_group_spent_threshold", 0) - await message.answer( - texts.t( - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_THRESHOLD_PROMPT", - "Введите новую сумму трат в рублях (текущее значение: {amount}):", - ).format(amount=texts.format_price(current_amount)) - ) - return - - await _finalize_edit_group( - message, - state, - db_user, - db, - auto_assign_enabled=False, - spent_threshold_kopeks=0, - ) - - -@admin_required -@error_handler -async def process_edit_group_auto_assign_threshold( - message: types.Message, - state: FSMContext, - db_user, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) - - try: - threshold_kopeks = _parse_amount_to_kopeks(message.text) - except ValueError: - await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_AMOUNT", "Введите неотрицательное число.")) - return - - await _finalize_edit_group( - message, - state, - db_user, - db, - auto_assign_enabled=data.get("edit_group_auto_assign_enabled", False), - spent_threshold_kopeks=threshold_kopeks, - ) - - @admin_required @error_handler async def show_promo_group_members( @@ -854,14 +616,6 @@ def register_handlers(dp: Dispatcher): process_create_group_devices, AdminStates.creating_promo_group_device_discount, ) - dp.message.register( - process_create_group_auto_assign_enabled, - AdminStates.creating_promo_group_auto_assign_enabled, - ) - dp.message.register( - process_create_group_auto_assign_threshold, - AdminStates.creating_promo_group_auto_assign_threshold, - ) dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name) dp.message.register( @@ -876,11 +630,3 @@ def register_handlers(dp: Dispatcher): process_edit_group_devices, AdminStates.editing_promo_group_device_discount, ) - dp.message.register( - process_edit_group_auto_assign_enabled, - AdminStates.editing_promo_group_auto_assign_enabled, - ) - dp.message.register( - process_edit_group_auto_assign_threshold, - AdminStates.editing_promo_group_auto_assign_threshold, - ) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index b8d26dc1..45fe983f 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -26,7 +26,6 @@ from app.utils.formatters import format_datetime, format_time_ago from app.services.remnawave_service import RemnaWaveService from app.external.remnawave_api import TrafficLimitStrategy from app.database.crud.server_squad import get_all_server_squads, get_server_squad_by_uuid, get_server_squad_by_id -from app.services.promo_group_service import auto_assign_promo_group_by_spent logger = logging.getLogger(__name__) @@ -2867,7 +2866,6 @@ async def admin_buy_subscription_execute( amount_kopeks=price_kopeks, description=f"Продление подписки на {period_days} дней (администратор)" ) - await auto_assign_promo_group_by_spent(db, target_user) try: from app.services.remnawave_service import RemnaWaveService diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 04ceffbd..3c54f0a7 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -20,10 +20,9 @@ from app.database.crud.subscription import ( from app.database.crud.user import subtract_user_balance from app.database.crud.transaction import create_transaction, get_user_transactions from app.database.models import ( - User, TransactionType, SubscriptionStatus, - SubscriptionServer, Subscription + User, TransactionType, SubscriptionStatus, + SubscriptionServer, Subscription ) -from app.services.promo_group_service import auto_assign_promo_group_by_spent from app.keyboards.inline import ( get_subscription_keyboard, get_trial_keyboard, get_subscription_period_keyboard, get_traffic_packages_keyboard, @@ -1192,13 +1191,13 @@ async def apply_countries_changes( try: if added and total_cost > 0: success = await subtract_user_balance( - db, db_user, total_cost, + db, db_user, total_cost, f"Добавление стран: {', '.join(added_names)} на {charged_months} мес" ) if not success: await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - + await create_transaction( db=db, user_id=db_user.id, @@ -1206,7 +1205,6 @@ async def apply_countries_changes( amount_kopeks=total_cost, description=f"Добавление стран к подписке: {', '.join(added_names)} на {charged_months} мес" ) - await auto_assign_promo_group_by_spent(db, db_user) if added: from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers @@ -1425,11 +1423,11 @@ async def execute_change_devices( db, db_user, price, f"Изменение количества устройств с {current_devices} до {new_devices_count}" ) - + if not success: await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - + charged_months = get_remaining_months(subscription.end_date) await create_transaction( db=db, @@ -1438,7 +1436,6 @@ async def execute_change_devices( amount_kopeks=price, description=f"Изменение устройств с {current_devices} до {new_devices_count} на {charged_months} мес" ) - await auto_assign_promo_group_by_spent(db, db_user) subscription.device_limit = new_devices_count subscription.updated_at = datetime.utcnow() @@ -1992,7 +1989,6 @@ async def confirm_add_devices( amount_kopeks=price, description=f"Добавление {devices_count} устройств на {charged_months} мес" ) - await auto_assign_promo_group_by_spent(db, db_user) await db.refresh(db_user) @@ -2208,7 +2204,6 @@ async def confirm_extend_subscription( amount_kopeks=price, description=f"Продление подписки на {days} дней ({months_in_period} мес)" ) - await auto_assign_promo_group_by_spent(db, db_user) try: notification_service = AdminNotificationService(callback.bot) @@ -2299,7 +2294,6 @@ async def confirm_reset_traffic( amount_kopeks=reset_price, description="Сброс трафика" ) - await auto_assign_promo_group_by_spent(db, db_user) await db.refresh(db_user) await db.refresh(subscription) @@ -2977,7 +2971,6 @@ async def confirm_purchase( amount_kopeks=final_price, description=f"Подписка на {data['period_days']} дней ({months_in_period} мес)" ) - await auto_assign_promo_group_by_spent(db, db_user) try: notification_service = AdminNotificationService(callback.bot) @@ -3168,7 +3161,6 @@ async def add_traffic( amount_kopeks=price, description=f"Добавление {traffic_gb} ГБ трафика" ) - await auto_assign_promo_group_by_spent(db, db_user) await db.refresh(db_user) @@ -3608,11 +3600,11 @@ async def confirm_add_countries_to_subscription( db, db_user, total_price, f"Добавление стран к подписке: {', '.join(new_countries_names)}" ) - + if not success: await callback.answer("❌ Ошибка списания средств", show_alert=True) return - + await create_transaction( db=db, user_id=db_user.id, @@ -3620,7 +3612,6 @@ async def confirm_add_countries_to_subscription( amount_kopeks=total_price, description=f"Добавление стран к подписке: {', '.join(new_countries_names)}" ) - await auto_assign_promo_group_by_spent(db, db_user) subscription.connected_squads = selected_countries subscription.updated_at = datetime.utcnow() @@ -4325,11 +4316,11 @@ async def execute_switch_traffic( db, db_user, price_difference, f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB" ) - + if not success: await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - + months_remaining = get_remaining_months(subscription.end_date) await create_transaction( db=db, @@ -4338,7 +4329,6 @@ async def execute_switch_traffic( amount_kopeks=price_difference, description=f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB на {months_remaining} мес" ) - await auto_assign_promo_group_by_spent(db, db_user) subscription.traffic_limit_gb = new_traffic_gb subscription.updated_at = datetime.utcnow() diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index c9f29fbc..49417771 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -143,8 +143,6 @@ "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user", "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Promo group: {name}", "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLED": "🤖 Auto assignment: after spending {amount}", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "🤖 Auto assignment: disabled", "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.", "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members", "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit", @@ -154,19 +152,13 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enable automatic assignment by total spending? (yes/no)", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Enter the spending amount in RUB required for automatic assignment:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", - "ADMIN_PROMO_GROUP_INVALID_BOOL": "Please answer \"yes\" or \"no\".", - "ADMIN_PROMO_GROUP_INVALID_AMOUNT": "Enter a non-negative number.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Auto assignment is currently: {status}. Enable automatic assignment? (yes/no)", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_THRESHOLD_PROMPT": "Enter the new spending amount in RUB (current: {amount}):", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 2d3a49ca..1dc7de10 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -31,8 +31,6 @@ "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю", "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Промогруппа: {name}", "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLED": "🤖 Автоназначение: при тратах от {amount}", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "🤖 Автоназначение: выключено", "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.", "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники", "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить", @@ -42,19 +40,13 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Включить автоматическое назначение по сумме трат? (да/нет)", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Введите сумму трат в рублях для автоматического назначения:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", - "ADMIN_PROMO_GROUP_INVALID_BOOL": "Введите «да» или «нет».", - "ADMIN_PROMO_GROUP_INVALID_AMOUNT": "Введите неотрицательное число.", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Автоназначение сейчас: {status}. Включить автоматическое назначение? (да/нет)", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_THRESHOLD_PROMPT": "Введите новую сумму трат в рублях (текущее значение: {amount}):", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", diff --git a/app/services/promo_group_service.py b/app/services/promo_group_service.py deleted file mode 100644 index 04c16579..00000000 --- a/app/services/promo_group_service.py +++ /dev/null @@ -1,63 +0,0 @@ -import logging -from datetime import datetime -from typing import Optional - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.models import PromoGroup, User -from app.database.crud.promo_group import get_auto_assign_promo_groups -from app.database.crud.transaction import get_user_total_spent - - -logger = logging.getLogger(__name__) - - -async def auto_assign_promo_group_by_spent( - db: AsyncSession, - user: Optional[User], -) -> Optional[PromoGroup]: - if not user: - return None - - groups = await get_auto_assign_promo_groups(db) - if not groups: - return None - - total_spent = await get_user_total_spent(db, user.id) - - target_group: Optional[PromoGroup] = None - for group in groups: - threshold = max(0, group.spent_threshold_kopeks or 0) - if total_spent >= threshold: - target_group = group - break - - if not target_group or user.promo_group_id == target_group.id: - return None - - previous_group_id = user.promo_group_id - - user.promo_group_id = target_group.id - user.promo_group = target_group - user.updated_at = datetime.utcnow() - - await db.commit() - await db.refresh(user) - - logger.info( - "👥 Пользователь %s автоматически переведен в промогруппу '%s' (порог=%s, потрачено=%s)", - user.telegram_id, - target_group.name, - target_group.spent_threshold_kopeks, - total_spent, - ) - - if previous_group_id != target_group.id: - logger.debug( - "👥 Пользователь %s покинул промогруппу %s → %s", - user.telegram_id, - previous_group_id, - target_group.id, - ) - - return target_group diff --git a/app/states.py b/app/states.py index 2a65887c..2207c448 100644 --- a/app/states.py +++ b/app/states.py @@ -68,15 +68,11 @@ class AdminStates(StatesGroup): creating_promo_group_traffic_discount = State() creating_promo_group_server_discount = State() creating_promo_group_device_discount = State() - creating_promo_group_auto_assign_enabled = State() - creating_promo_group_auto_assign_threshold = State() editing_promo_group_name = State() editing_promo_group_traffic_discount = State() editing_promo_group_server_discount = State() editing_promo_group_device_discount = State() - editing_promo_group_auto_assign_enabled = State() - editing_promo_group_auto_assign_threshold = State() editing_squad_price = State() editing_traffic_price = State() diff --git a/locales/en.json b/locales/en.json index 36b5a47e..e72fd5e4 100644 --- a/locales/en.json +++ b/locales/en.json @@ -205,8 +205,6 @@ "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user", "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Promo group: {name}", "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLED": "🤖 Auto assignment: after spending {amount}", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "🤖 Auto assignment: disabled", "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.", "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members", "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit", @@ -216,19 +214,13 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enable automatic assignment by total spending? (yes/no)", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Enter the spending amount in RUB required for automatic assignment:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", - "ADMIN_PROMO_GROUP_INVALID_BOOL": "Please answer \"yes\" or \"no\".", - "ADMIN_PROMO_GROUP_INVALID_AMOUNT": "Enter a non-negative number.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Auto assignment is currently: {status}. Enable automatic assignment? (yes/no)", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_THRESHOLD_PROMPT": "Enter the new spending amount in RUB (current: {amount}):", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", diff --git a/locales/ru.json b/locales/ru.json index 75480f09..2c6c839d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -93,8 +93,6 @@ "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю", "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Промогруппа: {name}", "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_ENABLED": "🤖 Автоназначение: при тратах от {amount}", - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "🤖 Автоназначение: выключено", "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.", "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники", "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить", @@ -104,19 +102,13 @@ "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Включить автоматическое назначение по сумме трат? (да/нет)", - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_THRESHOLD_PROMPT": "Введите сумму трат в рублях для автоматического назначения:", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", - "ADMIN_PROMO_GROUP_INVALID_BOOL": "Введите «да» или «нет».", - "ADMIN_PROMO_GROUP_INVALID_AMOUNT": "Введите неотрицательное число.", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):", "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Автоназначение сейчас: {status}. Включить автоматическое назначение? (да/нет)", - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_THRESHOLD_PROMPT": "Введите новую сумму трат в рублях (текущее значение: {amount}):", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", diff --git a/migrations/alembic/versions/b6b5c77e2a9d_add_auto_assign_to_promo_groups.py b/migrations/alembic/versions/b6b5c77e2a9d_add_auto_assign_to_promo_groups.py deleted file mode 100644 index e50c00be..00000000 --- a/migrations/alembic/versions/b6b5c77e2a9d_add_auto_assign_to_promo_groups.py +++ /dev/null @@ -1,88 +0,0 @@ -"""add auto assign fields to promo groups""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -PROMO_GROUPS_TABLE = "promo_groups" -AUTO_ASSIGN_COLUMN = "auto_assign_enabled" -SPENT_THRESHOLD_COLUMN = "spent_threshold_kopeks" - - -def _table_exists(inspector: sa.Inspector, table_name: str) -> bool: - return table_name in inspector.get_table_names() - - -def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool: - return any(col["name"] == column_name for col in inspector.get_columns(table_name)) - - -revision: str = "b6b5c77e2a9d" -down_revision: Union[str, None] = "1f5f3a3f5a4d" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if not _table_exists(inspector, PROMO_GROUPS_TABLE): - return - - if not _column_exists(inspector, PROMO_GROUPS_TABLE, AUTO_ASSIGN_COLUMN): - op.add_column( - PROMO_GROUPS_TABLE, - sa.Column( - AUTO_ASSIGN_COLUMN, - sa.Boolean(), - nullable=False, - server_default=sa.text("false"), - ), - ) - - if not _column_exists(inspector, PROMO_GROUPS_TABLE, SPENT_THRESHOLD_COLUMN): - op.add_column( - PROMO_GROUPS_TABLE, - sa.Column( - SPENT_THRESHOLD_COLUMN, - sa.Integer(), - nullable=False, - server_default=sa.text("0"), - ), - ) - - op.execute( - sa.text( - f"UPDATE {PROMO_GROUPS_TABLE} " - f"SET {AUTO_ASSIGN_COLUMN} = false, {SPENT_THRESHOLD_COLUMN} = 0 " - f"WHERE {AUTO_ASSIGN_COLUMN} IS NULL OR {SPENT_THRESHOLD_COLUMN} IS NULL" - ) - ) - - op.alter_column( - PROMO_GROUPS_TABLE, - AUTO_ASSIGN_COLUMN, - server_default=None, - ) - op.alter_column( - PROMO_GROUPS_TABLE, - SPENT_THRESHOLD_COLUMN, - server_default=None, - ) - - -def downgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if not _table_exists(inspector, PROMO_GROUPS_TABLE): - return - - if _column_exists(inspector, PROMO_GROUPS_TABLE, AUTO_ASSIGN_COLUMN): - op.drop_column(PROMO_GROUPS_TABLE, AUTO_ASSIGN_COLUMN) - - if _column_exists(inspector, PROMO_GROUPS_TABLE, SPENT_THRESHOLD_COLUMN): - op.drop_column(PROMO_GROUPS_TABLE, SPENT_THRESHOLD_COLUMN) From 55a38a5e05154e7d7d6c3091e6ed6b4ced350b4d Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 04:33:36 +0300 Subject: [PATCH 24/28] Update docker-hub.yml --- .github/workflows/docker-hub.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index 219a42f9..aa4c23c1 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -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.3.7-$(git rev-parse --short HEAD)" + VERSION="v2.3.8-$(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.3.7-dev-$(git rev-parse --short HEAD)" + VERSION="v2.3.8-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.3.7-pr-$(git rev-parse --short HEAD)" + VERSION="v2.3.8-pr-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)" echo "🔀 Собираем PR версию: $VERSION" fi From d1bacf140cff9335f6ba235970c38f2b40f42e66 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 04:33:57 +0300 Subject: [PATCH 25/28] Update docker-registry.yml --- .github/workflows/docker-registry.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-registry.yml b/.github/workflows/docker-registry.yml index d6544884..12796fc9 100644 --- a/.github/workflows/docker-registry.yml +++ b/.github/workflows/docker-registry.yml @@ -49,13 +49,13 @@ jobs: VERSION=${GITHUB_REF#refs/tags/} echo "🏷️ Building release version: $VERSION" elif [[ $GITHUB_REF == refs/heads/main ]]; then - VERSION="v2.3.7-$(git rev-parse --short HEAD)" + VERSION="v2.3.8-$(git rev-parse --short HEAD)" echo "🚀 Building main version: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then - VERSION="v2.3.7-dev-$(git rev-parse --short HEAD)" + VERSION="v2.3.8-dev-$(git rev-parse --short HEAD)" echo "🧪 Building dev version: $VERSION" else - VERSION="v2.3.7-pr-$(git rev-parse --short HEAD)" + VERSION="v2.3.8-pr-$(git rev-parse --short HEAD)" echo "🔀 Building PR version: $VERSION" fi echo "version=$VERSION" >> $GITHUB_OUTPUT From 155482779ef74b48587d9a7b2295961f344898a4 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 04:34:08 +0300 Subject: [PATCH 26/28] Update Dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index e615be5b..fde6f796 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \ FROM python:3.13-slim -ARG VERSION="v2.3.7" +ARG VERSION="v2.3.8" ARG BUILD_DATE ARG VCS_REF From 7d5d89302b6daf06fab3709433e69b62716fb88e Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 05:38:59 +0300 Subject: [PATCH 27/28] Ignore topic messages --- app/middlewares/logging.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/middlewares/logging.py b/app/middlewares/logging.py index d7cd75b5..475342c3 100644 --- a/app/middlewares/logging.py +++ b/app/middlewares/logging.py @@ -20,6 +20,9 @@ class LoggingMiddleware(BaseMiddleware): try: if isinstance(event, Message): + if getattr(event, "is_topic_message", False): + # Игнорируем сообщения из топиков, чтобы бот не реагировал и не логировал их + return None user_info = f"@{event.from_user.username}" if event.from_user.username else f"ID:{event.from_user.id}" text = event.text or event.caption or "[медиа]" logger.info(f"📩 Сообщение от {user_info}: {text}") From 745ebf64efbab6551abe6488107dbb79c5c80ff0 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 23 Sep 2025 05:53:13 +0300 Subject: [PATCH 28/28] Revert "Dev5" --- app/middlewares/logging.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/middlewares/logging.py b/app/middlewares/logging.py index 475342c3..d7cd75b5 100644 --- a/app/middlewares/logging.py +++ b/app/middlewares/logging.py @@ -20,9 +20,6 @@ class LoggingMiddleware(BaseMiddleware): try: if isinstance(event, Message): - if getattr(event, "is_topic_message", False): - # Игнорируем сообщения из топиков, чтобы бот не реагировал и не логировал их - return None user_info = f"@{event.from_user.username}" if event.from_user.username else f"ID:{event.from_user.id}" text = event.text or event.caption or "[медиа]" logger.info(f"📩 Сообщение от {user_info}: {text}")