From 025d50675f926abf4a69c6c2fd7e4b5d776d4149 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Fri, 19 Dec 2025 02:14:57 +0300 Subject: [PATCH 01/47] test --- app/config.py | 3 + app/handlers/menu.py | 65 +- app/handlers/start.py | 328 ++++---- app/keyboards/inline.py | 56 ++ app/services/menu_layout_service.py | 1155 +++++++++++++++++++++++++++ app/webapi/app.py | 10 + app/webapi/routes/menu_layout.py | 449 +++++++++++ app/webapi/schemas/menu_layout.py | 343 ++++++++ 8 files changed, 2222 insertions(+), 187 deletions(-) create mode 100644 app/services/menu_layout_service.py create mode 100644 app/webapi/routes/menu_layout.py create mode 100644 app/webapi/schemas/menu_layout.py diff --git a/app/config.py b/app/config.py index d6e70776..00c8dfe7 100644 --- a/app/config.py +++ b/app/config.py @@ -174,6 +174,9 @@ class Settings(BaseSettings): SIMPLE_SUBSCRIPTION_TRAFFIC_GB: int = 0 # 0 означает безлимит SIMPLE_SUBSCRIPTION_SQUAD_UUID: Optional[str] = None + # Настройки конструктора меню (API) + MENU_LAYOUT_ENABLED: bool = False # Включить управление меню через API + # Настройки мониторинга трафика TRAFFIC_MONITORING_ENABLED: bool = False TRAFFIC_THRESHOLD_GB_PER_DAY: float = 10.0 # Порог трафика в ГБ за сутки diff --git a/app/handlers/menu.py b/app/handlers/menu.py index a4301d73..f18f3d29 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -17,6 +17,7 @@ from app.database.crud.promo_group import ( from app.database.crud.transaction import get_user_total_spent_kopeks from app.keyboards.inline import ( get_main_menu_keyboard, + get_main_menu_keyboard_async, get_language_selection_keyboard, get_info_menu_keyboard, ) @@ -198,22 +199,25 @@ async def show_main_menu( subscription_is_active=subscription_is_active, ) + keyboard = await get_main_menu_keyboard_async( + db=db, + language=db_user.language, + is_admin=is_admin, + is_moderator=is_moderator, + has_had_paid_subscription=db_user.has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active, + balance_kopeks=db_user.balance_kopeks, + subscription=db_user.subscription, + show_resume_checkout=show_resume_checkout, + has_saved_cart=has_saved_cart, + custom_buttons=custom_buttons, + ) + await edit_or_answer_photo( callback=callback, caption=menu_text, - keyboard=get_main_menu_keyboard( - language=db_user.language, - is_admin=is_admin, - is_moderator=is_moderator, - has_had_paid_subscription=db_user.has_had_paid_subscription, - has_active_subscription=has_active_subscription, - subscription_is_active=subscription_is_active, - balance_kopeks=db_user.balance_kopeks, - subscription=db_user.subscription, - show_resume_checkout=show_resume_checkout, - has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине - custom_buttons=custom_buttons, - ), + keyboard=keyboard, parse_mode="HTML", force_text=settings.is_text_main_menu_mode(), ) @@ -1061,22 +1065,25 @@ async def handle_back_to_menu( subscription_is_active=subscription_is_active, ) + keyboard = await get_main_menu_keyboard_async( + db=db, + language=db_user.language, + is_admin=is_admin, + is_moderator=is_moderator, + has_had_paid_subscription=db_user.has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active, + balance_kopeks=db_user.balance_kopeks, + subscription=db_user.subscription, + show_resume_checkout=show_resume_checkout, + has_saved_cart=has_saved_cart, + custom_buttons=custom_buttons, + ) + await edit_or_answer_photo( callback=callback, caption=menu_text, - keyboard=get_main_menu_keyboard( - language=db_user.language, - is_admin=is_admin, - is_moderator=is_moderator, - has_had_paid_subscription=db_user.has_had_paid_subscription, - has_active_subscription=has_active_subscription, - subscription_is_active=subscription_is_active, - balance_kopeks=db_user.balance_kopeks, - subscription=db_user.subscription, - show_resume_checkout=show_resume_checkout, - has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине - custom_buttons=custom_buttons, - ), + keyboard=keyboard, parse_mode="HTML", force_text=settings.is_text_main_menu_mode(), ) @@ -1216,15 +1223,15 @@ async def get_main_menu_text(user, texts, db: AsyncSession): random_message = await get_random_active_message(db) if random_message: return _insert_random_message(base_text, random_message, action_prompt) - + except Exception as e: logger.error(f"Ошибка получения случайного сообщения: {e}") - + return base_text def register_handlers(dp: Dispatcher): - + dp.callback_query.register( handle_back_to_menu, F.data == "back_to_menu" diff --git a/app/handlers/start.py b/app/handlers/start.py index 9fce80cc..3a12a828 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -22,6 +22,7 @@ from app.keyboards.inline import ( get_rules_keyboard, get_privacy_policy_keyboard, get_main_menu_keyboard, + get_main_menu_keyboard_async, get_post_registration_keyboard, get_language_selection_keyboard, ) @@ -319,7 +320,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, if referral_code: await state.update_data(referral_code=referral_code) - + user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id) if campaign and not campaign_notification_sent: @@ -337,32 +338,32 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, campaign.id, notify_error, ) - + if user and user.status != UserStatus.DELETED.value: logger.info(f"✅ Активный пользователь найден: {user.telegram_id}") - + profile_updated = False - + if user.username != message.from_user.username: old_username = user.username user.username = message.from_user.username logger.info(f"📝 Username обновлен: '{old_username}' → '{user.username}'") profile_updated = True - + if user.first_name != message.from_user.first_name: old_first_name = user.first_name user.first_name = message.from_user.first_name logger.info(f"📝 Имя обновлено: '{old_first_name}' → '{user.first_name}'") profile_updated = True - + if user.last_name != message.from_user.last_name: old_last_name = user.last_name user.last_name = message.from_user.last_name logger.info(f"📝 Фамилия обновлена: '{old_last_name}' → '{user.last_name}'") profile_updated = True - + user.last_activity = datetime.utcnow() - + if profile_updated: user.updated_at = datetime.utcnow() await db.commit() @@ -370,7 +371,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, logger.info(f"💾 Профиль пользователя {user.telegram_id} обновлен") else: await db.commit() - + texts = get_texts(user.language) if referral_code and not user.referred_by_id: @@ -393,11 +394,11 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, logger.error( f"Ошибка отправки уведомления о рекламной кампании: {e}" ) - + has_active_subscription, subscription_is_active = _calculate_subscription_flags( user.subscription ) - + menu_text = await get_main_menu_text(user, texts, db) is_admin = settings.is_admin(user.telegram_id) @@ -414,35 +415,37 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, subscription_is_active=subscription_is_active, ) + keyboard = await get_main_menu_keyboard_async( + db=db, + language=user.language, + is_admin=is_admin, + has_had_paid_subscription=user.has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active, + balance_kopeks=user.balance_kopeks, + subscription=user.subscription, + is_moderator=is_moderator, + custom_buttons=custom_buttons, + ) await message.answer( menu_text, - reply_markup=get_main_menu_keyboard( - language=user.language, - is_admin=is_admin, - has_had_paid_subscription=user.has_had_paid_subscription, - has_active_subscription=has_active_subscription, - subscription_is_active=subscription_is_active, - balance_kopeks=user.balance_kopeks, - subscription=user.subscription, - is_moderator=is_moderator, - custom_buttons=custom_buttons, - ), + reply_markup=keyboard, parse_mode="HTML" ) await state.clear() return - + if user and user.status == UserStatus.DELETED.value: logger.info(f"🔄 Удаленный пользователь {user.telegram_id} начинает повторную регистрацию") - + try: from app.services.user_service import UserService from app.database.models import ( - Subscription, Transaction, PromoCodeUse, + Subscription, Transaction, PromoCodeUse, ReferralEarning, SubscriptionServer ) from sqlalchemy import delete - + if user.subscription: await decrement_subscription_server_counts(db, user.subscription) await db.execute( @@ -451,51 +454,51 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, ) ) logger.info(f"🗑️ Удалены записи SubscriptionServer") - + if user.subscription: await db.delete(user.subscription) logger.info(f"🗑️ Удалена подписка пользователя") - + await db.execute( delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id) ) - + await db.execute( delete(ReferralEarning).where(ReferralEarning.user_id == user.id) ) await db.execute( delete(ReferralEarning).where(ReferralEarning.referral_id == user.id) ) - + await db.execute( delete(Transaction).where(Transaction.user_id == user.id) ) - + user.status = UserStatus.ACTIVE.value user.balance_kopeks = 0 user.remnawave_uuid = None user.has_had_paid_subscription = False user.referred_by_id = None - + user.username = message.from_user.username user.first_name = message.from_user.first_name user.last_name = message.from_user.last_name user.updated_at = datetime.utcnow() user.last_activity = datetime.utcnow() - + from app.utils.user_utils import generate_unique_referral_code user.referral_code = await generate_unique_referral_code(db, user.telegram_id) - + await db.commit() - + logger.info(f"✅ Пользователь {user.telegram_id} подготовлен к восстановлению") - + except Exception as e: logger.error(f"❌ Ошибка подготовки к восстановлению: {e}") await db.rollback() else: logger.info(f"🆕 Новый пользователь, начинаем регистрацию") - + data = await state.get_data() or {} if not data.get('language'): if settings.is_language_selection_enabled(): @@ -626,11 +629,11 @@ async def _show_privacy_policy_after_rules( Возвращает True, если политика была показана, False если её нет или произошла ошибка. """ policy = await PrivacyPolicyService.get_policy(db, language, fallback=True) - + if not policy or not policy.is_enabled: logger.info("⚠️ Политика конфиденциальности не включена, пропускаем её показ") return False - + if not policy.content or not policy.content.strip(): privacy_policy_text = get_privacy_policy(language) if not privacy_policy_text or not privacy_policy_text.strip(): @@ -640,7 +643,7 @@ async def _show_privacy_policy_after_rules( else: privacy_policy_text = policy.content logger.info(f"🔒 Используется политика конфиденциальности из БД для языка {language}") - + try: await callback.message.edit_text( privacy_policy_text, @@ -675,7 +678,7 @@ async def _continue_registration_after_rules( """ data = await state.get_data() or {} texts = get_texts(language) - + if data.get('referral_code'): logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}") @@ -717,10 +720,10 @@ async def process_rules_accept( logger.info(f"📋 RULES: Начало обработки правил") logger.info(f"📊 Callback data: {callback.data}") logger.info(f"👤 User: {callback.from_user.id}") - + current_state = await state.get_state() logger.info(f"📊 Текущее состояние: {current_state}") - + language = DEFAULT_LANGUAGE texts = get_texts(language) @@ -730,24 +733,24 @@ async def process_rules_accept( data = await state.get_data() or {} language = data.get('language', language) texts = get_texts(language) - + if callback.data == 'rules_accept': logger.info(f"✅ Правила приняты пользователем {callback.from_user.id}") - + # Пытаемся показать политику конфиденциальности policy_shown = await _show_privacy_policy_after_rules( callback, state, db, language ) - + # Если политика не была показана, продолжаем регистрацию if not policy_shown: await _continue_registration_after_rules( callback, state, db, language ) - + else: logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}") - + rules_required_text = texts.t( "RULES_REQUIRED", "Для использования бота необходимо принять правила сервиса.", @@ -767,9 +770,9 @@ async def process_rules_accept( ) except: pass - + logger.info(f"✅ Правила обработаны для пользователя {callback.from_user.id}") - + except Exception as e: logger.error(f"❌ Ошибка обработки правил: {e}", exc_info=True) await callback.answer( @@ -798,14 +801,14 @@ async def process_privacy_policy_accept( state: FSMContext, db: AsyncSession ): - + logger.info(f"🔒 PRIVACY POLICY: Начало обработки политики конфиденциальности") logger.info(f"📊 Callback data: {callback.data}") logger.info(f"👤 User: {callback.from_user.id}") - + current_state = await state.get_state() logger.info(f"📊 Текущее состояние: {current_state}") - + language = DEFAULT_LANGUAGE texts = get_texts(language) @@ -815,10 +818,10 @@ async def process_privacy_policy_accept( data = await state.get_data() or {} language = data.get('language', language) texts = get_texts(language) - + if callback.data == 'privacy_policy_accept': logger.info(f"✅ Политика конфиденциальности принята пользователем {callback.from_user.id}") - + try: await callback.message.delete() logger.info(f"🗑️ Сообщение с политикой конфиденциальности удалено") @@ -834,7 +837,7 @@ async def process_privacy_policy_accept( ) except Exception: pass - + if data.get('referral_code'): logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}") @@ -853,7 +856,7 @@ async def process_privacy_policy_accept( try: await state.set_data(data) await state.set_state(RegistrationStates.waiting_for_referral_code) - + await callback.bot.send_message( chat_id=callback.from_user.id, text=texts.t( @@ -866,10 +869,10 @@ async def process_privacy_policy_accept( except Exception as e: logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}") await complete_registration_from_callback(callback, state, db) - + else: logger.info(f"❌ Политика конфиденциальности отклонена пользователем {callback.from_user.id}") - + privacy_policy_required_text = texts.t( "PRIVACY_POLICY_REQUIRED", "Для использования бота необходимо принять политику конфиденциальности.", @@ -889,9 +892,9 @@ async def process_privacy_policy_accept( ) except: pass - + logger.info(f"✅ Политика конфиденциальности обработана для пользователя {callback.from_user.id}") - + except Exception as e: logger.error(f"❌ Ошибка обработки политики конфиденциальности: {e}", exc_info=True) await callback.answer( @@ -994,7 +997,7 @@ async def process_referral_code_skip( ) except: pass - + await complete_registration_from_callback(callback, state, db) @@ -1029,11 +1032,11 @@ async def complete_registration_from_callback( from sqlalchemy.orm import selectinload existing_user = await get_user_by_telegram_id(db, callback.from_user.id) - + if existing_user and existing_user.status == UserStatus.ACTIVE.value: logger.warning(f"⚠️ Пользователь {callback.from_user.id} уже активен! Показываем главное меню.") texts = get_texts(existing_user.language) - + data = await state.get_data() or {} if data.get('referral_code') and not existing_user.referred_by_id: await callback.message.answer( @@ -1042,13 +1045,13 @@ async def complete_registration_from_callback( "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.", ) ) - + await db.refresh(existing_user, ['subscription']) - + has_active_subscription, subscription_is_active = _calculate_subscription_flags( existing_user.subscription ) - + menu_text = await get_main_menu_text(existing_user, texts, db) is_admin = settings.is_admin(existing_user.telegram_id) @@ -1067,19 +1070,21 @@ async def complete_registration_from_callback( ) try: + keyboard = await get_main_menu_keyboard_async( + db=db, + language=existing_user.language, + is_admin=is_admin, + has_had_paid_subscription=existing_user.has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active, + balance_kopeks=existing_user.balance_kopeks, + subscription=existing_user.subscription, + is_moderator=is_moderator, + custom_buttons=custom_buttons, + ) await callback.message.answer( menu_text, - reply_markup=get_main_menu_keyboard( - language=existing_user.language, - is_admin=is_admin, - has_had_paid_subscription=existing_user.has_had_paid_subscription, - has_active_subscription=has_active_subscription, - subscription_is_active=subscription_is_active, - balance_kopeks=existing_user.balance_kopeks, - subscription=existing_user.subscription, - is_moderator=is_moderator, - custom_buttons=custom_buttons, - ), + reply_markup=keyboard, parse_mode="HTML" ) except Exception as e: @@ -1090,10 +1095,10 @@ async def complete_registration_from_callback( "Добро пожаловать, {user_name}!", ).format(user_name=existing_user.full_name) ) - + await state.clear() return - + data = await state.get_data() or {} language = data.get('language', DEFAULT_LANGUAGE) texts = get_texts(language) @@ -1112,10 +1117,10 @@ async def complete_registration_from_callback( referrer = await get_user_by_referral_code(db, data['referral_code']) if referrer: referrer_id = referrer.id - + if existing_user and existing_user.status == UserStatus.DELETED.value: logger.info(f"🔄 Восстанавливаем удаленного пользователя {callback.from_user.id}") - + existing_user.username = callback.from_user.username existing_user.first_name = callback.from_user.first_name existing_user.last_name = callback.from_user.last_name @@ -1124,22 +1129,22 @@ async def complete_registration_from_callback( existing_user.status = UserStatus.ACTIVE.value existing_user.balance_kopeks = 0 existing_user.has_had_paid_subscription = False - + from datetime import datetime existing_user.updated_at = datetime.utcnow() existing_user.last_activity = datetime.utcnow() - + await db.commit() await db.refresh(existing_user, ['subscription']) - + user = existing_user logger.info(f"✅ Пользователь {callback.from_user.id} восстановлен") - + elif not existing_user: logger.info(f"🆕 Создаем нового пользователя {callback.from_user.id}") - + referral_code = await generate_unique_referral_code(db, callback.from_user.id) - + user = await create_user( db=db, telegram_id=callback.from_user.id, @@ -1148,7 +1153,7 @@ async def complete_registration_from_callback( last_name=callback.from_user.last_name, language=language, referred_by_id=referrer_id, - referral_code=referral_code + referral_code=referral_code ) await db.refresh(user, ['subscription']) else: @@ -1157,15 +1162,15 @@ async def complete_registration_from_callback( existing_user.language = language if referrer_id and not existing_user.referred_by_id: existing_user.referred_by_id = referrer_id - + from datetime import datetime existing_user.updated_at = datetime.utcnow() existing_user.last_activity = datetime.utcnow() - + await db.commit() await db.refresh(existing_user, ['subscription']) user = existing_user - + if referrer_id: try: await process_referral_registration(db, user.id, referrer_id, callback.bot) @@ -1224,11 +1229,11 @@ async def complete_registration_from_callback( logger.error(f"Ошибка при отправке приветственного сообщения: {e}") else: logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}") - + has_active_subscription, subscription_is_active = _calculate_subscription_flags( getattr(user, "subscription", None) ) - + menu_text = await get_main_menu_text(user, texts, db) is_admin = settings.is_admin(user.telegram_id) @@ -1247,19 +1252,21 @@ async def complete_registration_from_callback( ) try: + keyboard = await get_main_menu_keyboard_async( + db=db, + language=user.language, + is_admin=is_admin, + has_had_paid_subscription=user.has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active, + balance_kopeks=user.balance_kopeks, + subscription=user.subscription, + is_moderator=is_moderator, + custom_buttons=custom_buttons, + ) await callback.message.answer( menu_text, - reply_markup=get_main_menu_keyboard( - language=user.language, - is_admin=is_admin, - has_had_paid_subscription=user.has_had_paid_subscription, - has_active_subscription=has_active_subscription, - subscription_is_active=subscription_is_active, - balance_kopeks=user.balance_kopeks, - subscription=user.subscription, - is_moderator=is_moderator, - custom_buttons=custom_buttons, - ), + reply_markup=keyboard, parse_mode="HTML" ) logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}") @@ -1303,11 +1310,11 @@ async def complete_registration( return existing_user = await get_user_by_telegram_id(db, message.from_user.id) - + if existing_user and existing_user.status == UserStatus.ACTIVE.value: logger.warning(f"⚠️ Пользователь {message.from_user.id} уже активен! Показываем главное меню.") texts = get_texts(existing_user.language) - + data = await state.get_data() or {} if data.get('referral_code') and not existing_user.referred_by_id: await message.answer( @@ -1316,13 +1323,13 @@ async def complete_registration( "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.", ) ) - + await db.refresh(existing_user, ['subscription']) - + has_active_subscription, subscription_is_active = _calculate_subscription_flags( existing_user.subscription ) - + menu_text = await get_main_menu_text(existing_user, texts, db) is_admin = settings.is_admin(existing_user.telegram_id) @@ -1341,19 +1348,21 @@ async def complete_registration( ) try: + keyboard = await get_main_menu_keyboard_async( + db=db, + language=existing_user.language, + is_admin=is_admin, + has_had_paid_subscription=existing_user.has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active, + balance_kopeks=existing_user.balance_kopeks, + subscription=existing_user.subscription, + is_moderator=is_moderator, + custom_buttons=custom_buttons, + ) await message.answer( menu_text, - reply_markup=get_main_menu_keyboard( - language=existing_user.language, - is_admin=is_admin, - has_had_paid_subscription=existing_user.has_had_paid_subscription, - has_active_subscription=has_active_subscription, - subscription_is_active=subscription_is_active, - balance_kopeks=existing_user.balance_kopeks, - subscription=existing_user.subscription, - is_moderator=is_moderator, - custom_buttons=custom_buttons, - ), + reply_markup=keyboard, parse_mode="HTML" ) except Exception as e: @@ -1364,10 +1373,10 @@ async def complete_registration( "Добро пожаловать, {user_name}!", ).format(user_name=existing_user.full_name) ) - + await state.clear() return - + data = await state.get_data() or {} language = data.get('language', DEFAULT_LANGUAGE) texts = get_texts(language) @@ -1386,10 +1395,10 @@ async def complete_registration( referrer = await get_user_by_referral_code(db, data['referral_code']) if referrer: referrer_id = referrer.id - + if existing_user and existing_user.status == UserStatus.DELETED.value: logger.info(f"🔄 Восстанавливаем удаленного пользователя {message.from_user.id}") - + existing_user.username = message.from_user.username existing_user.first_name = message.from_user.first_name existing_user.last_name = message.from_user.last_name @@ -1398,22 +1407,22 @@ async def complete_registration( existing_user.status = UserStatus.ACTIVE.value existing_user.balance_kopeks = 0 existing_user.has_had_paid_subscription = False - + from datetime import datetime existing_user.updated_at = datetime.utcnow() existing_user.last_activity = datetime.utcnow() - + await db.commit() await db.refresh(existing_user, ['subscription']) - + user = existing_user logger.info(f"✅ Пользователь {message.from_user.id} восстановлен") - + elif not existing_user: logger.info(f"🆕 Создаем нового пользователя {message.from_user.id}") - + referral_code = await generate_unique_referral_code(db, message.from_user.id) - + user = await create_user( db=db, telegram_id=message.from_user.id, @@ -1431,15 +1440,15 @@ async def complete_registration( existing_user.language = language if referrer_id and not existing_user.referred_by_id: existing_user.referred_by_id = referrer_id - + from datetime import datetime existing_user.updated_at = datetime.utcnow() existing_user.last_activity = datetime.utcnow() - + await db.commit() await db.refresh(existing_user, ['subscription']) user = existing_user - + if referrer_id: try: await process_referral_registration(db, user.id, referrer_id, message.bot) @@ -1521,11 +1530,11 @@ async def complete_registration( logger.error(f"Ошибка при отправке приветственного сообщения: {e}") else: logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}") - + has_active_subscription, subscription_is_active = _calculate_subscription_flags( getattr(user, "subscription", None) ) - + menu_text = await get_main_menu_text(user, texts, db) is_admin = settings.is_admin(user.telegram_id) @@ -1544,19 +1553,21 @@ async def complete_registration( ) try: + keyboard = await get_main_menu_keyboard_async( + db=db, + language=user.language, + is_admin=is_admin, + has_had_paid_subscription=user.has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active, + balance_kopeks=user.balance_kopeks, + subscription=user.subscription, + is_moderator=is_moderator, + custom_buttons=custom_buttons, + ) await message.answer( menu_text, - reply_markup=get_main_menu_keyboard( - language=user.language, - is_admin=is_admin, - has_had_paid_subscription=user.has_had_paid_subscription, - has_active_subscription=has_active_subscription, - subscription_is_active=subscription_is_active, - balance_kopeks=user.balance_kopeks, - subscription=user.subscription, - is_moderator=is_moderator, - custom_buttons=custom_buttons, - ), + reply_markup=keyboard, parse_mode="HTML" ) logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}") @@ -1665,7 +1676,7 @@ def _insert_random_message(base_text: str, random_message: str, action_prompt: s def get_referral_code_keyboard(language: str): from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - + texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton( @@ -1875,7 +1886,8 @@ async def required_sub_channel_check( subscription_is_active=subscription_is_active, ) - keyboard = get_main_menu_keyboard( + keyboard = await get_main_menu_keyboard_async( + db=db, language=user.language, is_admin=is_admin, has_had_paid_subscription=user.has_had_paid_subscription, @@ -1964,29 +1976,29 @@ async def required_sub_channel_check( await query.answer(f"{texts.ERROR}!", show_alert=True) def register_handlers(dp: Dispatcher): - + logger.info("🔧 === НАЧАЛО регистрации обработчиков start.py ===") - + dp.message.register( cmd_start, Command("start") ) logger.info("✅ Зарегистрирован cmd_start") - + dp.callback_query.register( process_rules_accept, F.data.in_(["rules_accept", "rules_decline"]), StateFilter(RegistrationStates.waiting_for_rules_accept) ) logger.info("✅ Зарегистрирован process_rules_accept") - + dp.callback_query.register( process_privacy_policy_accept, F.data.in_(["privacy_policy_accept", "privacy_policy_decline"]), StateFilter(RegistrationStates.waiting_for_privacy_policy_accept) ) logger.info("✅ Зарегистрирован process_privacy_policy_accept") - + dp.callback_query.register( process_language_selection, F.data.startswith("language_select:"), @@ -2000,13 +2012,13 @@ def register_handlers(dp: Dispatcher): StateFilter(RegistrationStates.waiting_for_referral_code) ) logger.info("✅ Зарегистрирован process_referral_code_skip") - + dp.message.register( process_referral_code_input, StateFilter(RegistrationStates.waiting_for_referral_code) ) logger.info("✅ Зарегистрирован process_referral_code_input") - + dp.message.register( handle_potential_referral_code, StateFilter( @@ -2021,6 +2033,6 @@ def register_handlers(dp: Dispatcher): F.data.in_(["sub_channel_check"]) ) logger.info("✅ Зарегистрирован required_sub_channel_check") - + logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===") - + diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index f9db008b..e28940a2 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -23,6 +23,62 @@ import logging logger = logging.getLogger(__name__) +async def get_main_menu_keyboard_async( + db: AsyncSession, + language: str = DEFAULT_LANGUAGE, + is_admin: bool = False, + has_had_paid_subscription: bool = False, + has_active_subscription: bool = False, + subscription_is_active: bool = False, + balance_kopeks: int = 0, + subscription=None, + show_resume_checkout: bool = False, + has_saved_cart: bool = False, + *, + is_moderator: bool = False, + custom_buttons: Optional[list[InlineKeyboardButton]] = None, +) -> InlineKeyboardMarkup: + """ + Асинхронная версия get_main_menu_keyboard с поддержкой конструктора меню. + + Если MENU_LAYOUT_ENABLED=True, использует конфигурацию из БД. + Иначе делегирует в синхронную версию. + """ + if settings.MENU_LAYOUT_ENABLED: + from app.services.menu_layout_service import MenuLayoutService, MenuContext + + context = MenuContext( + language=language, + is_admin=is_admin, + is_moderator=is_moderator, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active, + has_had_paid_subscription=has_had_paid_subscription, + balance_kopeks=balance_kopeks, + subscription=subscription, + show_resume_checkout=show_resume_checkout, + has_saved_cart=has_saved_cart, + custom_buttons=custom_buttons or [], + ) + + return await MenuLayoutService.build_keyboard(db, context) + + # Fallback на синхронную версию + return get_main_menu_keyboard( + language=language, + is_admin=is_admin, + has_had_paid_subscription=has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active, + balance_kopeks=balance_kopeks, + subscription=subscription, + show_resume_checkout=show_resume_checkout, + has_saved_cart=has_saved_cart, + is_moderator=is_moderator, + custom_buttons=custom_buttons, + ) + + def _get_localized_value(values, language: str, default_language: str = "en") -> str: if not isinstance(values, dict): return "" diff --git a/app/services/menu_layout_service.py b/app/services/menu_layout_service.py new file mode 100644 index 00000000..cd4b9764 --- /dev/null +++ b/app/services/menu_layout_service.py @@ -0,0 +1,1155 @@ +"""Сервис конструктора меню - управление конфигурацией через API.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional + +from aiogram import types +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import SystemSetting +from app.database.crud.system_setting import upsert_system_setting +from app.localization.texts import get_texts + +logger = logging.getLogger(__name__) + +# Ключ для хранения конфигурации в SystemSetting +MENU_LAYOUT_CONFIG_KEY = "menu_layout_config" + + +@dataclass +class MenuContext: + """Контекст пользователя для построения меню.""" + + language: str = "ru" + is_admin: bool = False + is_moderator: bool = False + has_active_subscription: bool = False + subscription_is_active: bool = False + has_had_paid_subscription: bool = False + balance_kopeks: int = 0 + subscription: Optional[Any] = None + show_resume_checkout: bool = False + has_saved_cart: bool = False + custom_buttons: List[InlineKeyboardButton] = field(default_factory=list) + + +# Дефолтная конфигурация меню +DEFAULT_MENU_CONFIG: Dict[str, Any] = { + "version": 1, + "rows": [ + { + "id": "connect_row", + "buttons": ["connect"], + "conditions": {"has_active_subscription": True, "subscription_is_active": True}, + "max_per_row": 1, + }, + { + "id": "happ_row", + "buttons": ["happ_download"], + "conditions": {"has_active_subscription": True, "happ_enabled": True}, + "max_per_row": 1, + }, + { + "id": "subscription_traffic_row", + "buttons": ["subscription", "buy_traffic"], + "conditions": {"has_active_subscription": True}, + "max_per_row": 2, + }, + { + "id": "balance_row", + "buttons": ["balance"], + "conditions": None, + "max_per_row": 1, + }, + { + "id": "trial_buy_row", + "buttons": ["trial", "buy_subscription"], + "conditions": None, + "max_per_row": 2, + }, + { + "id": "simple_subscription_row", + "buttons": ["simple_subscription"], + "conditions": {"simple_subscription_enabled": True}, + "max_per_row": 1, + }, + { + "id": "resume_row", + "buttons": ["resume_checkout"], + "conditions": {"has_saved_cart": True}, + "max_per_row": 1, + }, + { + "id": "promo_referral_row", + "buttons": ["promocode", "referrals"], + "conditions": None, + "max_per_row": 2, + }, + { + "id": "contests_row", + "buttons": ["contests"], + "conditions": {"contests_visible": True}, + "max_per_row": 2, + }, + { + "id": "support_info_row", + "buttons": ["support", "info"], + "conditions": None, + "max_per_row": 2, + }, + { + "id": "language_row", + "buttons": ["language"], + "conditions": {"language_selection_enabled": True}, + "max_per_row": 2, + }, + { + "id": "admin_row", + "buttons": ["admin_panel"], + "conditions": {"is_admin": True}, + "max_per_row": 1, + }, + { + "id": "moderator_row", + "buttons": ["moderator_panel"], + "conditions": {"is_moderator": True}, + "max_per_row": 1, + }, + ], + "buttons": { + "connect": { + "type": "builtin", + "builtin_id": "connect", + "text": {"ru": "🔗 Подключиться", "en": "🔗 Connect"}, + "action": "subscription_connect", + "enabled": True, + "visibility": "subscribers", + "conditions": None, + "dynamic_text": False, + }, + "happ_download": { + "type": "builtin", + "builtin_id": "happ_download", + "text": {"ru": "⬇️ Скачать Happ", "en": "⬇️ Download Happ"}, + "action": "subscription_happ_download", + "enabled": True, + "visibility": "subscribers", + "conditions": None, + "dynamic_text": False, + }, + "subscription": { + "type": "builtin", + "builtin_id": "subscription", + "text": {"ru": "📊 Подписка", "en": "📊 Subscription"}, + "action": "menu_subscription", + "enabled": True, + "visibility": "subscribers", + "conditions": None, + "dynamic_text": False, + }, + "buy_traffic": { + "type": "builtin", + "builtin_id": "buy_traffic", + "text": {"ru": "📈 Докупить трафик", "en": "📈 Buy traffic"}, + "action": "buy_traffic", + "enabled": True, + "visibility": "subscribers", + "conditions": {"has_traffic_limit": True}, + "dynamic_text": False, + }, + "balance": { + "type": "builtin", + "builtin_id": "balance", + "text": {"ru": "💰 Баланс: {balance}", "en": "💰 Balance: {balance}"}, + "action": "menu_balance", + "enabled": True, + "visibility": "all", + "conditions": None, + "dynamic_text": True, + }, + "trial": { + "type": "builtin", + "builtin_id": "trial", + "text": {"ru": "🎁 Пробный период", "en": "🎁 Free trial"}, + "action": "menu_trial", + "enabled": True, + "visibility": "all", + "conditions": {"show_trial": True}, + "dynamic_text": False, + }, + "buy_subscription": { + "type": "builtin", + "builtin_id": "buy_subscription", + "text": {"ru": "🛒 Купить подписку", "en": "🛒 Buy subscription"}, + "action": "menu_buy", + "enabled": True, + "visibility": "all", + "conditions": {"show_buy": True}, + "dynamic_text": False, + }, + "simple_subscription": { + "type": "builtin", + "builtin_id": "simple_subscription", + "text": {"ru": "💳 Простая подписка", "en": "💳 Simple subscription"}, + "action": "simple_subscription_purchase", + "enabled": True, + "visibility": "all", + "conditions": None, + "dynamic_text": False, + }, + "resume_checkout": { + "type": "builtin", + "builtin_id": "resume_checkout", + "text": {"ru": "↩️ Вернуться к оформлению", "en": "↩️ Resume checkout"}, + "action": "return_to_saved_cart", + "enabled": True, + "visibility": "all", + "conditions": None, + "dynamic_text": False, + }, + "promocode": { + "type": "builtin", + "builtin_id": "promocode", + "text": {"ru": "🎟️ Промокод", "en": "🎟️ Promo code"}, + "action": "menu_promocode", + "enabled": True, + "visibility": "all", + "conditions": None, + "dynamic_text": False, + }, + "referrals": { + "type": "builtin", + "builtin_id": "referrals", + "text": {"ru": "👥 Рефералы", "en": "👥 Referrals"}, + "action": "menu_referrals", + "enabled": True, + "visibility": "all", + "conditions": {"referral_enabled": True}, + "dynamic_text": False, + }, + "contests": { + "type": "builtin", + "builtin_id": "contests", + "text": {"ru": "🎲 Конкурсы", "en": "🎲 Contests"}, + "action": "contests_menu", + "enabled": True, + "visibility": "all", + "conditions": None, + "dynamic_text": False, + }, + "support": { + "type": "builtin", + "builtin_id": "support", + "text": {"ru": "💬 Поддержка", "en": "💬 Support"}, + "action": "menu_support", + "enabled": True, + "visibility": "all", + "conditions": {"support_enabled": True}, + "dynamic_text": False, + }, + "info": { + "type": "builtin", + "builtin_id": "info", + "text": {"ru": "ℹ️ Инфо", "en": "ℹ️ Info"}, + "action": "menu_info", + "enabled": True, + "visibility": "all", + "conditions": None, + "dynamic_text": False, + }, + "language": { + "type": "builtin", + "builtin_id": "language", + "text": {"ru": "🌐 Язык", "en": "🌐 Language"}, + "action": "menu_language", + "enabled": True, + "visibility": "all", + "conditions": None, + "dynamic_text": False, + }, + "admin_panel": { + "type": "builtin", + "builtin_id": "admin_panel", + "text": {"ru": "⚙️ Админ панель", "en": "⚙️ Admin panel"}, + "action": "admin_panel", + "enabled": True, + "visibility": "admins", + "conditions": None, + "dynamic_text": False, + }, + "moderator_panel": { + "type": "builtin", + "builtin_id": "moderator_panel", + "text": {"ru": "🧑‍⚖️ Модерация", "en": "🧑‍⚖️ Moderation"}, + "action": "moderator_panel", + "enabled": True, + "visibility": "moderators", + "conditions": None, + "dynamic_text": False, + }, + }, +} + + +# Информация о встроенных кнопках для API +BUILTIN_BUTTONS_INFO = [ + { + "id": "connect", + "default_text": {"ru": "🔗 Подключиться", "en": "🔗 Connect"}, + "callback_data": "subscription_connect", + "default_conditions": {"has_active_subscription": True, "subscription_is_active": True}, + "supports_dynamic_text": False, + }, + { + "id": "happ_download", + "default_text": {"ru": "⬇️ Скачать Happ", "en": "⬇️ Download Happ"}, + "callback_data": "subscription_happ_download", + "default_conditions": {"happ_enabled": True}, + "supports_dynamic_text": False, + }, + { + "id": "subscription", + "default_text": {"ru": "📊 Подписка", "en": "📊 Subscription"}, + "callback_data": "menu_subscription", + "default_conditions": {"has_active_subscription": True}, + "supports_dynamic_text": False, + }, + { + "id": "buy_traffic", + "default_text": {"ru": "📈 Докупить трафик", "en": "📈 Buy traffic"}, + "callback_data": "buy_traffic", + "default_conditions": {"has_traffic_limit": True}, + "supports_dynamic_text": False, + }, + { + "id": "balance", + "default_text": {"ru": "💰 Баланс: {balance}", "en": "💰 Balance: {balance}"}, + "callback_data": "menu_balance", + "default_conditions": None, + "supports_dynamic_text": True, + }, + { + "id": "trial", + "default_text": {"ru": "🎁 Пробный период", "en": "🎁 Free trial"}, + "callback_data": "menu_trial", + "default_conditions": {"show_trial": True}, + "supports_dynamic_text": False, + }, + { + "id": "buy_subscription", + "default_text": {"ru": "🛒 Купить подписку", "en": "🛒 Buy subscription"}, + "callback_data": "menu_buy", + "default_conditions": {"show_buy": True}, + "supports_dynamic_text": False, + }, + { + "id": "simple_subscription", + "default_text": {"ru": "💳 Простая подписка", "en": "💳 Simple subscription"}, + "callback_data": "simple_subscription_purchase", + "default_conditions": {"simple_subscription_enabled": True}, + "supports_dynamic_text": False, + }, + { + "id": "resume_checkout", + "default_text": {"ru": "↩️ Вернуться к оформлению", "en": "↩️ Resume checkout"}, + "callback_data": "return_to_saved_cart", + "default_conditions": {"has_saved_cart": True}, + "supports_dynamic_text": False, + }, + { + "id": "promocode", + "default_text": {"ru": "🎟️ Промокод", "en": "🎟️ Promo code"}, + "callback_data": "menu_promocode", + "default_conditions": None, + "supports_dynamic_text": False, + }, + { + "id": "referrals", + "default_text": {"ru": "👥 Рефералы", "en": "👥 Referrals"}, + "callback_data": "menu_referrals", + "default_conditions": {"referral_enabled": True}, + "supports_dynamic_text": False, + }, + { + "id": "contests", + "default_text": {"ru": "🎲 Конкурсы", "en": "🎲 Contests"}, + "callback_data": "contests_menu", + "default_conditions": {"contests_visible": True}, + "supports_dynamic_text": False, + }, + { + "id": "support", + "default_text": {"ru": "💬 Поддержка", "en": "💬 Support"}, + "callback_data": "menu_support", + "default_conditions": {"support_enabled": True}, + "supports_dynamic_text": False, + }, + { + "id": "info", + "default_text": {"ru": "ℹ️ Инфо", "en": "ℹ️ Info"}, + "callback_data": "menu_info", + "default_conditions": None, + "supports_dynamic_text": False, + }, + { + "id": "language", + "default_text": {"ru": "🌐 Язык", "en": "🌐 Language"}, + "callback_data": "menu_language", + "default_conditions": {"language_selection_enabled": True}, + "supports_dynamic_text": False, + }, + { + "id": "admin_panel", + "default_text": {"ru": "⚙️ Админ панель", "en": "⚙️ Admin panel"}, + "callback_data": "admin_panel", + "default_conditions": {"is_admin": True}, + "supports_dynamic_text": False, + }, + { + "id": "moderator_panel", + "default_text": {"ru": "🧑‍⚖️ Модерация", "en": "🧑‍⚖️ Moderation"}, + "callback_data": "moderator_panel", + "default_conditions": {"is_moderator": True}, + "supports_dynamic_text": False, + }, +] + + +class MenuLayoutService: + """Сервис для управления конфигурацией меню.""" + + _cache: Optional[Dict[str, Any]] = None + _cache_updated_at: Optional[datetime] = None + _lock: asyncio.Lock = asyncio.Lock() + + @classmethod + def invalidate_cache(cls) -> None: + """Инвалидировать кеш конфигурации.""" + cls._cache = None + cls._cache_updated_at = None + + @classmethod + def get_default_config(cls) -> Dict[str, Any]: + """Получить дефолтную конфигурацию.""" + return DEFAULT_MENU_CONFIG.copy() + + @classmethod + def get_builtin_buttons_info(cls) -> List[Dict[str, Any]]: + """Получить информацию о встроенных кнопках.""" + return BUILTIN_BUTTONS_INFO.copy() + + @classmethod + async def get_config(cls, db: AsyncSession) -> Dict[str, Any]: + """Получить конфигурацию меню.""" + if cls._cache is not None: + return cls._cache + + async with cls._lock: + if cls._cache is not None: + return cls._cache + + result = await db.execute( + select(SystemSetting).where(SystemSetting.key == MENU_LAYOUT_CONFIG_KEY) + ) + setting = result.scalar_one_or_none() + + if setting and setting.value: + try: + cls._cache = json.loads(setting.value) + cls._cache_updated_at = setting.updated_at + except json.JSONDecodeError: + logger.warning("Invalid menu layout config JSON, using default") + cls._cache = cls.get_default_config() + cls._cache_updated_at = None + else: + cls._cache = cls.get_default_config() + cls._cache_updated_at = None + + return cls._cache + + @classmethod + async def get_config_updated_at(cls, db: AsyncSession) -> Optional[datetime]: + """Получить время последнего обновления конфигурации.""" + await cls.get_config(db) # Ensure cache is loaded + return cls._cache_updated_at + + @classmethod + async def save_config(cls, db: AsyncSession, config: Dict[str, Any]) -> None: + """Сохранить конфигурацию меню.""" + config_json = json.dumps(config, ensure_ascii=False, indent=2) + await upsert_system_setting( + db, + MENU_LAYOUT_CONFIG_KEY, + config_json, + description="Конфигурация конструктора меню", + ) + await db.commit() + cls.invalidate_cache() + + @classmethod + async def reset_to_default(cls, db: AsyncSession) -> Dict[str, Any]: + """Сбросить конфигурацию к дефолтной.""" + default_config = cls.get_default_config() + await cls.save_config(db, default_config) + return default_config + + @classmethod + async def update_button( + cls, + db: AsyncSession, + button_id: str, + updates: Dict[str, Any], + ) -> Dict[str, Any]: + """Обновить конфигурацию кнопки.""" + config = await cls.get_config(db) + config = config.copy() + buttons = config.get("buttons", {}) + + if button_id not in buttons: + raise KeyError(f"Button '{button_id}' not found") + + button = buttons[button_id].copy() + + # Применяем обновления + if "text" in updates and updates["text"] is not None: + button["text"] = updates["text"] + if "enabled" in updates and updates["enabled"] is not None: + button["enabled"] = updates["enabled"] + if "visibility" in updates and updates["visibility"] is not None: + button["visibility"] = updates["visibility"] + if "conditions" in updates: + button["conditions"] = updates["conditions"] + if "action" in updates and updates["action"] is not None: + # Только для URL/MiniApp кнопок + if button.get("type") in ("url", "mini_app"): + button["action"] = updates["action"] + + buttons[button_id] = button + config["buttons"] = buttons + + await cls.save_config(db, config) + return button + + @classmethod + async def reorder_rows( + cls, + db: AsyncSession, + ordered_ids: List[str], + ) -> List[Dict[str, Any]]: + """Изменить порядок строк.""" + config = await cls.get_config(db) + config = config.copy() + rows = config.get("rows", []) + + # Создаем словарь для быстрого поиска + rows_map = {row["id"]: row for row in rows} + + # Проверяем что все ID существуют + for row_id in ordered_ids: + if row_id not in rows_map: + raise KeyError(f"Row '{row_id}' not found") + + # Переупорядочиваем + new_rows = [rows_map[row_id] for row_id in ordered_ids] + + # Добавляем строки которые не были в списке (в конец) + for row in rows: + if row["id"] not in ordered_ids: + new_rows.append(row) + + config["rows"] = new_rows + await cls.save_config(db, config) + return new_rows + + @classmethod + async def add_row( + cls, + db: AsyncSession, + row_config: Dict[str, Any], + position: Optional[int] = None, + ) -> Dict[str, Any]: + """Добавить новую строку.""" + config = await cls.get_config(db) + config = config.copy() + rows = config.get("rows", []) + + # Проверяем уникальность ID + existing_ids = {row["id"] for row in rows} + if row_config["id"] in existing_ids: + raise ValueError(f"Row with id '{row_config['id']}' already exists") + + new_row = { + "id": row_config["id"], + "buttons": row_config.get("buttons", []), + "conditions": row_config.get("conditions"), + "max_per_row": row_config.get("max_per_row", 2), + } + + if position is not None and 0 <= position < len(rows): + rows.insert(position, new_row) + else: + rows.append(new_row) + + config["rows"] = rows + await cls.save_config(db, config) + return new_row + + @classmethod + async def delete_row(cls, db: AsyncSession, row_id: str) -> None: + """Удалить строку.""" + config = await cls.get_config(db) + config = config.copy() + rows = config.get("rows", []) + + new_rows = [row for row in rows if row["id"] != row_id] + if len(new_rows) == len(rows): + raise KeyError(f"Row '{row_id}' not found") + + config["rows"] = new_rows + await cls.save_config(db, config) + + @classmethod + async def add_custom_button( + cls, + db: AsyncSession, + button_id: str, + button_config: Dict[str, Any], + row_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Добавить кастомную кнопку.""" + config = await cls.get_config(db) + config = config.copy() + buttons = config.get("buttons", {}) + + if button_id in buttons: + raise ValueError(f"Button with id '{button_id}' already exists") + + # Только URL и MiniApp кнопки могут быть добавлены + if button_config.get("type") not in ("url", "mini_app"): + raise ValueError("Only 'url' and 'mini_app' buttons can be added") + + buttons[button_id] = { + "type": button_config["type"], + "builtin_id": None, + "text": button_config["text"], + "action": button_config["action"], + "enabled": button_config.get("enabled", True), + "visibility": button_config.get("visibility", "all"), + "conditions": button_config.get("conditions"), + "dynamic_text": False, + } + + config["buttons"] = buttons + + # Добавляем в строку если указана + if row_id: + rows = config.get("rows", []) + for row in rows: + if row["id"] == row_id: + row["buttons"].append(button_id) + break + + await cls.save_config(db, config) + return buttons[button_id] + + @classmethod + async def delete_custom_button(cls, db: AsyncSession, button_id: str) -> None: + """Удалить кастомную кнопку.""" + config = await cls.get_config(db) + config = config.copy() + buttons = config.get("buttons", {}) + + if button_id not in buttons: + raise KeyError(f"Button '{button_id}' not found") + + # Нельзя удалять встроенные кнопки + if buttons[button_id].get("type") == "builtin": + raise ValueError("Cannot delete builtin buttons") + + del buttons[button_id] + config["buttons"] = buttons + + # Удаляем из всех строк + rows = config.get("rows", []) + for row in rows: + if button_id in row.get("buttons", []): + row["buttons"].remove(button_id) + + await cls.save_config(db, config) + + @classmethod + def _find_button_row(cls, config: Dict[str, Any], button_id: str) -> Optional[int]: + """Найти индекс строки содержащей кнопку.""" + rows = config.get("rows", []) + for i, row in enumerate(rows): + if button_id in row.get("buttons", []): + return i + return None + + @classmethod + async def move_button_up(cls, db: AsyncSession, button_id: str) -> Dict[str, Any]: + """Переместить кнопку на строку выше.""" + config = await cls.get_config(db) + config = config.copy() + rows = config.get("rows", []) + + current_row_idx = cls._find_button_row(config, button_id) + if current_row_idx is None: + raise KeyError(f"Button '{button_id}' not found in any row") + + if current_row_idx == 0: + raise ValueError("Button is already in the top row") + + # Удаляем из текущей строки + rows[current_row_idx]["buttons"].remove(button_id) + + # Добавляем в строку выше + rows[current_row_idx - 1]["buttons"].append(button_id) + + # Удаляем пустые строки + config["rows"] = [row for row in rows if row.get("buttons")] + + await cls.save_config(db, config) + return {"button_id": button_id, "new_row_index": current_row_idx - 1} + + @classmethod + async def move_button_down(cls, db: AsyncSession, button_id: str) -> Dict[str, Any]: + """Переместить кнопку на строку ниже.""" + config = await cls.get_config(db) + config = config.copy() + rows = config.get("rows", []) + + current_row_idx = cls._find_button_row(config, button_id) + if current_row_idx is None: + raise KeyError(f"Button '{button_id}' not found in any row") + + if current_row_idx >= len(rows) - 1: + raise ValueError("Button is already in the bottom row") + + # Удаляем из текущей строки + rows[current_row_idx]["buttons"].remove(button_id) + + # Добавляем в строку ниже + rows[current_row_idx + 1]["buttons"].append(button_id) + + # Удаляем пустые строки + config["rows"] = [row for row in rows if row.get("buttons")] + + await cls.save_config(db, config) + return {"button_id": button_id, "new_row_index": current_row_idx + 1} + + @classmethod + async def move_button_to_row( + cls, + db: AsyncSession, + button_id: str, + target_row_id: str, + position: Optional[int] = None, + ) -> Dict[str, Any]: + """Переместить кнопку в указанную строку.""" + config = await cls.get_config(db) + config = config.copy() + rows = config.get("rows", []) + + # Находим целевую строку + target_row_idx = None + for i, row in enumerate(rows): + if row["id"] == target_row_id: + target_row_idx = i + break + + if target_row_idx is None: + raise KeyError(f"Row '{target_row_id}' not found") + + # Удаляем кнопку из текущей строки + current_row_idx = cls._find_button_row(config, button_id) + if current_row_idx is not None: + rows[current_row_idx]["buttons"].remove(button_id) + + # Добавляем в целевую строку + target_buttons = rows[target_row_idx]["buttons"] + if position is not None and 0 <= position <= len(target_buttons): + target_buttons.insert(position, button_id) + else: + target_buttons.append(button_id) + + # Удаляем пустые строки + config["rows"] = [row for row in rows if row.get("buttons")] + + await cls.save_config(db, config) + return {"button_id": button_id, "target_row_id": target_row_id, "position": position} + + @classmethod + async def reorder_buttons_in_row( + cls, + db: AsyncSession, + row_id: str, + ordered_button_ids: List[str], + ) -> Dict[str, Any]: + """Изменить порядок кнопок внутри строки.""" + config = await cls.get_config(db) + config = config.copy() + rows = config.get("rows", []) + + # Находим строку + target_row = None + for row in rows: + if row["id"] == row_id: + target_row = row + break + + if target_row is None: + raise KeyError(f"Row '{row_id}' not found") + + # Проверяем что все кнопки принадлежат строке + current_buttons = set(target_row["buttons"]) + ordered_buttons = set(ordered_button_ids) + + if current_buttons != ordered_buttons: + missing = current_buttons - ordered_buttons + extra = ordered_buttons - current_buttons + errors = [] + if missing: + errors.append(f"missing: {missing}") + if extra: + errors.append(f"extra: {extra}") + raise ValueError(f"Button mismatch: {', '.join(errors)}") + + target_row["buttons"] = ordered_button_ids + + await cls.save_config(db, config) + return {"row_id": row_id, "buttons": ordered_button_ids} + + @classmethod + async def swap_buttons( + cls, + db: AsyncSession, + button_id_1: str, + button_id_2: str, + ) -> Dict[str, Any]: + """Поменять две кнопки местами.""" + config = await cls.get_config(db) + config = config.copy() + rows = config.get("rows", []) + + # Находим позиции обеих кнопок + pos1 = None + pos2 = None + + for row_idx, row in enumerate(rows): + buttons = row.get("buttons", []) + for btn_idx, btn_id in enumerate(buttons): + if btn_id == button_id_1: + pos1 = (row_idx, btn_idx) + elif btn_id == button_id_2: + pos2 = (row_idx, btn_idx) + + if pos1 is None: + raise KeyError(f"Button '{button_id_1}' not found") + if pos2 is None: + raise KeyError(f"Button '{button_id_2}' not found") + + # Меняем местами + rows[pos1[0]]["buttons"][pos1[1]] = button_id_2 + rows[pos2[0]]["buttons"][pos2[1]] = button_id_1 + + await cls.save_config(db, config) + return { + "button_1": {"id": button_id_1, "new_row": pos2[0], "new_position": pos2[1]}, + "button_2": {"id": button_id_2, "new_row": pos1[0], "new_position": pos1[1]}, + } + + @classmethod + def _evaluate_conditions( + cls, + conditions: Optional[Dict[str, Any]], + context: MenuContext, + ) -> bool: + """Проверить условия показа.""" + if not conditions: + return True + + # has_active_subscription + if conditions.get("has_active_subscription") is True: + if not context.has_active_subscription: + return False + + # subscription_is_active + if conditions.get("subscription_is_active") is True: + if not context.subscription_is_active: + return False + + # has_traffic_limit - подписка с лимитом трафика + if conditions.get("has_traffic_limit") is True: + if not context.subscription: + return False + traffic_limit = getattr(context.subscription, "traffic_limit_gb", 0) + is_trial = getattr(context.subscription, "is_trial", False) + if is_trial or traffic_limit <= 0: + return False + + # is_admin + if conditions.get("is_admin") is True: + if not context.is_admin: + return False + + # is_moderator + if conditions.get("is_moderator") is True: + if context.is_admin: # Админ не показывает кнопку модератора + return False + if not context.is_moderator: + return False + + # referral_enabled + if conditions.get("referral_enabled") is True: + if not settings.is_referral_program_enabled(): + return False + + # contests_visible + if conditions.get("contests_visible") is True: + if not settings.CONTESTS_BUTTON_VISIBLE: + return False + + # support_enabled + if conditions.get("support_enabled") is True: + try: + from app.services.support_settings_service import SupportSettingsService + if not SupportSettingsService.is_support_menu_enabled(): + return False + except Exception: + if not settings.SUPPORT_MENU_ENABLED: + return False + + # language_selection_enabled + if conditions.get("language_selection_enabled") is True: + if not settings.is_language_selection_enabled(): + return False + + # happ_enabled + if conditions.get("happ_enabled") is True: + if not settings.is_happ_download_button_enabled(): + return False + + # simple_subscription_enabled + if conditions.get("simple_subscription_enabled") is True: + if not settings.SIMPLE_SUBSCRIPTION_ENABLED: + return False + + # show_trial + if conditions.get("show_trial") is True: + if context.has_had_paid_subscription or context.has_active_subscription: + return False + + # show_buy + if conditions.get("show_buy") is True: + if context.has_active_subscription and context.subscription_is_active: + return False + + # has_saved_cart + if conditions.get("has_saved_cart") is True: + if not context.has_saved_cart and not context.show_resume_checkout: + return False + + return True + + @classmethod + def _check_visibility( + cls, + visibility: str, + context: MenuContext, + ) -> bool: + """Проверить видимость кнопки.""" + if visibility == "all": + return True + if visibility == "admins": + return context.is_admin + if visibility == "moderators": + return context.is_moderator and not context.is_admin + if visibility == "subscribers": + return context.has_active_subscription and context.subscription_is_active + return True + + @classmethod + def _get_localized_text( + cls, + text_config: Dict[str, str], + language: str, + fallback_language: str = "en", + ) -> str: + """Получить локализованный текст.""" + # Пробуем запрошенный язык + if language in text_config: + return text_config[language] + # Пробуем fallback + if fallback_language in text_config: + return text_config[fallback_language] + # Возвращаем первый доступный + if text_config: + return next(iter(text_config.values())) + return "" + + @classmethod + def _format_dynamic_text( + cls, + text: str, + context: MenuContext, + texts: Any, + ) -> str: + """Форматировать динамический текст.""" + if "{balance}" in text: + formatted_balance = texts.format_price(context.balance_kopeks) + text = text.replace("{balance}", formatted_balance) + return text + + @classmethod + def _build_button( + cls, + button_config: Dict[str, Any], + context: MenuContext, + texts: Any, + ) -> Optional[InlineKeyboardButton]: + """Построить кнопку из конфигурации.""" + button_type = button_config.get("type", "builtin") + text_config = button_config.get("text", {}) + action = button_config.get("action", "") + + # Получаем текст + text = cls._get_localized_text(text_config, context.language) + if not text: + return None + + # Форматируем динамический текст + if button_config.get("dynamic_text"): + text = cls._format_dynamic_text(text, context, texts) + + # Строим кнопку в зависимости от типа + if button_type == "url": + return InlineKeyboardButton(text=text, url=action) + elif button_type == "mini_app": + return InlineKeyboardButton( + text=text, web_app=types.WebAppInfo(url=action) + ) + else: + # builtin - callback_data + return InlineKeyboardButton(text=text, callback_data=action) + + @classmethod + async def build_keyboard( + cls, + db: AsyncSession, + context: MenuContext, + ) -> InlineKeyboardMarkup: + """Построить клавиатуру меню на основе конфигурации.""" + config = await cls.get_config(db) + texts = get_texts(context.language) + + keyboard_rows: List[List[InlineKeyboardButton]] = [] + rows_config = config.get("rows", []) + buttons_config = config.get("buttons", {}) + + for row_config in rows_config: + # Проверяем условия строки + row_conditions = row_config.get("conditions") + if not cls._evaluate_conditions(row_conditions, context): + continue + + row_buttons: List[InlineKeyboardButton] = [] + max_per_row = row_config.get("max_per_row", 2) + + for button_id in row_config.get("buttons", []): + if button_id not in buttons_config: + continue + + button_cfg = buttons_config[button_id] + + # Проверяем включена ли кнопка + if not button_cfg.get("enabled", True): + continue + + # Проверяем видимость + visibility = button_cfg.get("visibility", "all") + if not cls._check_visibility(visibility, context): + continue + + # Проверяем условия кнопки + button_conditions = button_cfg.get("conditions") + if not cls._evaluate_conditions(button_conditions, context): + continue + + # Строим кнопку + button = cls._build_button(button_cfg, context, texts) + if button: + row_buttons.append(button) + + # Добавляем кнопки с учетом max_per_row + if row_buttons: + for i in range(0, len(row_buttons), max_per_row): + keyboard_rows.append(row_buttons[i : i + max_per_row]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + + @classmethod + async def preview_keyboard( + cls, + db: AsyncSession, + context: MenuContext, + ) -> List[Dict[str, Any]]: + """Предпросмотр меню (возвращает структуру для API).""" + config = await cls.get_config(db) + texts = get_texts(context.language) + + preview_rows: List[Dict[str, Any]] = [] + rows_config = config.get("rows", []) + buttons_config = config.get("buttons", {}) + + for row_config in rows_config: + row_conditions = row_config.get("conditions") + if not cls._evaluate_conditions(row_conditions, context): + continue + + row_buttons: List[Dict[str, Any]] = [] + max_per_row = row_config.get("max_per_row", 2) + + for button_id in row_config.get("buttons", []): + if button_id not in buttons_config: + continue + + button_cfg = buttons_config[button_id] + + if not button_cfg.get("enabled", True): + continue + + visibility = button_cfg.get("visibility", "all") + if not cls._check_visibility(visibility, context): + continue + + button_conditions = button_cfg.get("conditions") + if not cls._evaluate_conditions(button_conditions, context): + continue + + text_config = button_cfg.get("text", {}) + text = cls._get_localized_text(text_config, context.language) + + if button_cfg.get("dynamic_text"): + text = cls._format_dynamic_text(text, context, texts) + + row_buttons.append({ + "text": text, + "action": button_cfg.get("action", ""), + "type": button_cfg.get("type", "builtin"), + }) + + if row_buttons: + for i in range(0, len(row_buttons), max_per_row): + preview_rows.append({"buttons": row_buttons[i : i + max_per_row]}) + + return preview_rows diff --git a/app/webapi/app.py b/app/webapi/app.py index 941a07e9..e4d5d372 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -15,6 +15,7 @@ from .routes import ( health, main_menu_buttons, media, + menu_layout, miniapp, partners, polls, @@ -56,6 +57,10 @@ OPENAPI_TAGS = [ "name": "main-menu", "description": "Управление кнопками и сообщениями главного меню Telegram-бота.", }, + { + "name": "menu-layout", + "description": "API конструктор меню: управление расположением и настройками кнопок.", + }, { "name": "welcome-texts", "description": "Создание, редактирование и управление приветственными текстами.", @@ -192,6 +197,11 @@ def create_web_api_app() -> FastAPI: prefix="/main-menu/buttons", tags=["main-menu"], ) + app.include_router( + menu_layout.router, + prefix="/menu-layout", + tags=["menu-layout"], + ) app.include_router( user_messages.router, prefix="/main-menu/messages", diff --git a/app/webapi/routes/menu_layout.py b/app/webapi/routes/menu_layout.py new file mode 100644 index 00000000..1cd398aa --- /dev/null +++ b/app/webapi/routes/menu_layout.py @@ -0,0 +1,449 @@ +"""API эндпоинты для конструктора меню.""" + +from __future__ import annotations + +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException, Security, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.services.menu_layout_service import ( + MenuContext, + MenuLayoutService, +) + +from ..dependencies import get_db_session, require_api_token +from ..schemas.menu_layout import ( + AddCustomButtonRequest, + AddRowRequest, + BuiltinButtonInfo, + BuiltinButtonsListResponse, + ButtonConditions, + ButtonUpdateRequest, + MenuButtonConfig, + MenuLayoutConfig, + MenuLayoutResponse, + MenuLayoutUpdateRequest, + MenuPreviewButton, + MenuPreviewRequest, + MenuPreviewResponse, + MenuPreviewRow, + MenuRowConfig, + MoveButtonResponse, + MoveButtonToRowRequest, + ReorderButtonsInRowRequest, + ReorderButtonsResponse, + RowsReorderRequest, + SwapButtonsRequest, + SwapButtonsResponse, +) + + +router = APIRouter() + + +def _serialize_config(config: dict, is_enabled: bool, updated_at) -> MenuLayoutResponse: + """Сериализовать конфигурацию в response.""" + rows = [] + for row_data in config.get("rows", []): + rows.append( + MenuRowConfig( + id=row_data["id"], + buttons=row_data.get("buttons", []), + conditions=ButtonConditions(**row_data["conditions"]) + if row_data.get("conditions") + else None, + max_per_row=row_data.get("max_per_row", 2), + ) + ) + + buttons = {} + for btn_id, btn_data in config.get("buttons", {}).items(): + buttons[btn_id] = MenuButtonConfig( + type=btn_data["type"], + builtin_id=btn_data.get("builtin_id"), + text=btn_data.get("text", {}), + action=btn_data.get("action", ""), + enabled=btn_data.get("enabled", True), + visibility=btn_data.get("visibility", "all"), + conditions=ButtonConditions(**btn_data["conditions"]) + if btn_data.get("conditions") + else None, + dynamic_text=btn_data.get("dynamic_text", False), + ) + + return MenuLayoutResponse( + version=config.get("version", 1), + rows=rows, + buttons=buttons, + is_enabled=is_enabled, + updated_at=updated_at, + ) + + +@router.get("", response_model=MenuLayoutResponse) +async def get_menu_layout( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuLayoutResponse: + """Получить текущую конфигурацию меню.""" + config = await MenuLayoutService.get_config(db) + updated_at = await MenuLayoutService.get_config_updated_at(db) + return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at) + + +@router.put("", response_model=MenuLayoutResponse) +async def update_menu_layout( + payload: MenuLayoutUpdateRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuLayoutResponse: + """Обновить конфигурацию меню полностью.""" + config = await MenuLayoutService.get_config(db) + config = config.copy() + + if payload.rows is not None: + config["rows"] = [row.model_dump() for row in payload.rows] + + if payload.buttons is not None: + config["buttons"] = { + btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items() + } + + await MenuLayoutService.save_config(db, config) + updated_at = await MenuLayoutService.get_config_updated_at(db) + return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at) + + +@router.post("/reset", response_model=MenuLayoutResponse) +async def reset_menu_layout( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuLayoutResponse: + """Сбросить конфигурацию к дефолтной.""" + config = await MenuLayoutService.reset_to_default(db) + updated_at = await MenuLayoutService.get_config_updated_at(db) + return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at) + + +@router.get("/builtin-buttons", response_model=BuiltinButtonsListResponse) +async def list_builtin_buttons( + _: Any = Security(require_api_token), +) -> BuiltinButtonsListResponse: + """Получить список встроенных кнопок.""" + items = [] + for btn_info in MenuLayoutService.get_builtin_buttons_info(): + items.append( + BuiltinButtonInfo( + id=btn_info["id"], + default_text=btn_info["default_text"], + callback_data=btn_info["callback_data"], + default_conditions=ButtonConditions(**btn_info["default_conditions"]) + if btn_info.get("default_conditions") + else None, + supports_dynamic_text=btn_info.get("supports_dynamic_text", False), + ) + ) + + return BuiltinButtonsListResponse(items=items, total=len(items)) + + +@router.patch("/buttons/{button_id}") +async def update_button( + button_id: str, + payload: ButtonUpdateRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuButtonConfig: + """Обновить конфигурацию отдельной кнопки.""" + try: + updates = payload.model_dump(exclude_unset=True) + # Конвертируем visibility в строку если есть + if "visibility" in updates and updates["visibility"] is not None: + updates["visibility"] = updates["visibility"].value + # Конвертируем conditions в dict если есть + if "conditions" in updates and updates["conditions"] is not None: + updates["conditions"] = updates["conditions"].model_dump(exclude_none=True) + + button = await MenuLayoutService.update_button(db, button_id, updates) + + return MenuButtonConfig( + type=button["type"], + builtin_id=button.get("builtin_id"), + text=button.get("text", {}), + action=button.get("action", ""), + enabled=button.get("enabled", True), + visibility=button.get("visibility", "all"), + conditions=ButtonConditions(**button["conditions"]) + if button.get("conditions") + else None, + dynamic_text=button.get("dynamic_text", False), + ) + except KeyError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + + +@router.post("/rows/reorder", response_model=List[MenuRowConfig]) +async def reorder_rows( + payload: RowsReorderRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> List[MenuRowConfig]: + """Изменить порядок строк.""" + try: + rows = await MenuLayoutService.reorder_rows(db, payload.ordered_ids) + return [ + MenuRowConfig( + id=row["id"], + buttons=row.get("buttons", []), + conditions=ButtonConditions(**row["conditions"]) + if row.get("conditions") + else None, + max_per_row=row.get("max_per_row", 2), + ) + for row in rows + ] + except KeyError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + + +@router.post("/rows", response_model=MenuRowConfig, status_code=status.HTTP_201_CREATED) +async def add_row( + payload: AddRowRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuRowConfig: + """Добавить новую строку.""" + try: + row_config = { + "id": payload.id, + "buttons": payload.buttons, + "conditions": payload.conditions.model_dump(exclude_none=True) + if payload.conditions + else None, + "max_per_row": payload.max_per_row, + } + row = await MenuLayoutService.add_row(db, row_config, payload.position) + + return MenuRowConfig( + id=row["id"], + buttons=row.get("buttons", []), + conditions=ButtonConditions(**row["conditions"]) + if row.get("conditions") + else None, + max_per_row=row.get("max_per_row", 2), + ) + except ValueError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + + +@router.delete("/rows/{row_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_row( + row_id: str, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> None: + """Удалить строку.""" + try: + await MenuLayoutService.delete_row(db, row_id) + except KeyError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + + +@router.post( + "/buttons", response_model=MenuButtonConfig, status_code=status.HTTP_201_CREATED +) +async def add_custom_button( + payload: AddCustomButtonRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuButtonConfig: + """Добавить кастомную кнопку (URL или MiniApp).""" + try: + button_config = { + "type": payload.type.value, + "text": payload.text, + "action": payload.action, + "visibility": payload.visibility.value, + "conditions": payload.conditions.model_dump(exclude_none=True) + if payload.conditions + else None, + } + button = await MenuLayoutService.add_custom_button( + db, payload.id, button_config, payload.row_id + ) + + return MenuButtonConfig( + type=button["type"], + builtin_id=button.get("builtin_id"), + text=button.get("text", {}), + action=button.get("action", ""), + enabled=button.get("enabled", True), + visibility=button.get("visibility", "all"), + conditions=ButtonConditions(**button["conditions"]) + if button.get("conditions") + else None, + dynamic_text=button.get("dynamic_text", False), + ) + except ValueError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + + +@router.delete("/buttons/{button_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_custom_button( + button_id: str, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> None: + """Удалить кастомную кнопку.""" + try: + await MenuLayoutService.delete_custom_button(db, button_id) + except KeyError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + except ValueError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + + +@router.post("/preview", response_model=MenuPreviewResponse) +async def preview_menu( + payload: MenuPreviewRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuPreviewResponse: + """Предпросмотр меню для указанного контекста пользователя.""" + context = MenuContext( + language=payload.language, + is_admin=payload.is_admin, + is_moderator=payload.is_moderator, + has_active_subscription=payload.has_active_subscription, + subscription_is_active=payload.subscription_is_active, + balance_kopeks=payload.balance_kopeks, + ) + + preview_rows = await MenuLayoutService.preview_keyboard(db, context) + + rows = [] + total_buttons = 0 + for row_data in preview_rows: + buttons = [ + MenuPreviewButton( + text=btn["text"], + action=btn["action"], + type=btn["type"], + ) + for btn in row_data["buttons"] + ] + total_buttons += len(buttons) + rows.append(MenuPreviewRow(buttons=buttons)) + + return MenuPreviewResponse(rows=rows, total_buttons=total_buttons) + + +# --- Эндпоинты для перемещения кнопок --- + + +@router.post("/buttons/{button_id}/move-up", response_model=MoveButtonResponse) +async def move_button_up( + button_id: str, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MoveButtonResponse: + """Переместить кнопку вверх (в предыдущую строку или на позицию выше в текущей строке).""" + try: + result = await MenuLayoutService.move_button_up(db, button_id) + return MoveButtonResponse( + button_id=button_id, + new_row_index=result.get("new_row_index"), + position=result.get("new_position"), + ) + except KeyError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + except ValueError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + + +@router.post("/buttons/{button_id}/move-down", response_model=MoveButtonResponse) +async def move_button_down( + button_id: str, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MoveButtonResponse: + """Переместить кнопку вниз (в следующую строку или на позицию ниже в текущей строке).""" + try: + result = await MenuLayoutService.move_button_down(db, button_id) + return MoveButtonResponse( + button_id=button_id, + new_row_index=result.get("new_row_index"), + position=result.get("new_position"), + ) + except KeyError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + except ValueError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + + +@router.post("/buttons/{button_id}/move-to-row", response_model=MoveButtonResponse) +async def move_button_to_row( + button_id: str, + payload: MoveButtonToRowRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MoveButtonResponse: + """Переместить кнопку в указанную строку.""" + try: + result = await MenuLayoutService.move_button_to_row( + db, button_id, payload.target_row_id, payload.position + ) + return MoveButtonResponse( + button_id=button_id, + target_row_id=payload.target_row_id, + position=result.get("new_position"), + ) + except KeyError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + except ValueError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + + +@router.post("/rows/{row_id}/reorder-buttons", response_model=ReorderButtonsResponse) +async def reorder_buttons_in_row( + row_id: str, + payload: ReorderButtonsInRowRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> ReorderButtonsResponse: + """Изменить порядок кнопок в строке.""" + try: + result = await MenuLayoutService.reorder_buttons_in_row( + db, row_id, payload.ordered_button_ids + ) + return ReorderButtonsResponse( + row_id=row_id, + buttons=result["buttons"], + ) + except KeyError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + except ValueError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + + +@router.post("/buttons/swap", response_model=SwapButtonsResponse) +async def swap_buttons( + payload: SwapButtonsRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> SwapButtonsResponse: + """Обменять местами две кнопки (даже из разных строк).""" + try: + result = await MenuLayoutService.swap_buttons( + db, payload.button_id_1, payload.button_id_2 + ) + return SwapButtonsResponse( + button_1=result["button_1"], + button_2=result["button_2"], + ) + except KeyError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + except ValueError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e diff --git a/app/webapi/schemas/menu_layout.py b/app/webapi/schemas/menu_layout.py new file mode 100644 index 00000000..100aacaa --- /dev/null +++ b/app/webapi/schemas/menu_layout.py @@ -0,0 +1,343 @@ +"""Pydantic схемы для API конструктора меню.""" + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class ButtonType(str, Enum): + """Тип кнопки меню.""" + + BUILTIN = "builtin" # Встроенная кнопка с callback_data + URL = "url" # Внешняя ссылка + MINI_APP = "mini_app" # Telegram Mini App + + +class ButtonVisibility(str, Enum): + """Видимость кнопки.""" + + ALL = "all" # Видна всем + ADMINS = "admins" # Только админам + MODERATORS = "moderators" # Только модераторам + SUBSCRIBERS = "subscribers" # Только подписчикам + + +class ButtonConditions(BaseModel): + """Условия показа кнопки.""" + + has_active_subscription: Optional[bool] = Field( + default=None, description="Требуется активная подписка" + ) + subscription_is_active: Optional[bool] = Field( + default=None, description="Подписка должна быть активна (не приостановлена)" + ) + has_traffic_limit: Optional[bool] = Field( + default=None, description="Подписка с лимитом трафика" + ) + is_admin: Optional[bool] = Field(default=None, description="Пользователь - админ") + is_moderator: Optional[bool] = Field( + default=None, description="Пользователь - модератор" + ) + referral_enabled: Optional[bool] = Field( + default=None, description="Реферальная программа включена" + ) + contests_visible: Optional[bool] = Field( + default=None, description="Конкурсы видимы" + ) + support_enabled: Optional[bool] = Field( + default=None, description="Поддержка включена" + ) + language_selection_enabled: Optional[bool] = Field( + default=None, description="Выбор языка включен" + ) + happ_enabled: Optional[bool] = Field( + default=None, description="Кнопка Happ включена" + ) + simple_subscription_enabled: Optional[bool] = Field( + default=None, description="Простая подписка включена" + ) + show_trial: Optional[bool] = Field( + default=None, description="Показать пробный период" + ) + show_buy: Optional[bool] = Field( + default=None, description="Показать кнопку покупки" + ) + has_saved_cart: Optional[bool] = Field( + default=None, description="Есть сохраненная корзина" + ) + + model_config = ConfigDict(extra="forbid") + + +class MenuButtonConfig(BaseModel): + """Конфигурация отдельной кнопки.""" + + type: ButtonType = Field(..., description="Тип кнопки") + builtin_id: Optional[str] = Field( + default=None, description="ID встроенной кнопки (для type=builtin)" + ) + text: Dict[str, str] = Field( + ..., description="Локализованные тексты кнопки: {lang_code: text}" + ) + action: str = Field( + ..., description="callback_data или URL в зависимости от типа" + ) + enabled: bool = Field(default=True, description="Кнопка активна") + visibility: ButtonVisibility = Field( + default=ButtonVisibility.ALL, description="Видимость кнопки" + ) + conditions: Optional[ButtonConditions] = Field( + default=None, description="Дополнительные условия показа" + ) + dynamic_text: bool = Field( + default=False, description="Текст содержит плейсхолдеры ({balance} и т.д.)" + ) + + model_config = ConfigDict(extra="forbid") + + +class MenuRowConfig(BaseModel): + """Конфигурация строки меню.""" + + id: str = Field(..., min_length=1, max_length=50, description="Уникальный ID строки") + buttons: List[str] = Field( + ..., description="Список ID кнопок в строке" + ) + conditions: Optional[ButtonConditions] = Field( + default=None, description="Условия показа всей строки" + ) + max_per_row: int = Field( + default=2, ge=1, le=4, description="Максимум кнопок в строке" + ) + + model_config = ConfigDict(extra="forbid") + + +class MenuLayoutConfig(BaseModel): + """Полная конфигурация меню.""" + + version: int = Field(default=1, description="Версия формата конфигурации") + rows: List[MenuRowConfig] = Field( + default_factory=list, description="Строки меню" + ) + buttons: Dict[str, MenuButtonConfig] = Field( + default_factory=dict, description="Конфигурации кнопок" + ) + + model_config = ConfigDict(extra="forbid") + + +# --- Response schemas --- + + +class MenuLayoutResponse(BaseModel): + """Ответ с конфигурацией меню.""" + + version: int + rows: List[MenuRowConfig] + buttons: Dict[str, MenuButtonConfig] + is_enabled: bool = Field(description="Включен ли конструктор меню") + updated_at: Optional[datetime] = None + + +class BuiltinButtonInfo(BaseModel): + """Информация о встроенной кнопке.""" + + id: str = Field(description="Идентификатор кнопки") + default_text: Dict[str, str] = Field(description="Текст по умолчанию") + callback_data: str = Field(description="callback_data кнопки") + default_conditions: Optional[ButtonConditions] = Field( + default=None, description="Условия показа по умолчанию" + ) + supports_dynamic_text: bool = Field( + default=False, description="Поддерживает ли динамический текст" + ) + + +class BuiltinButtonsListResponse(BaseModel): + """Список встроенных кнопок.""" + + items: List[BuiltinButtonInfo] + total: int + + +# --- Request schemas --- + + +class MenuLayoutUpdateRequest(BaseModel): + """Запрос на обновление конфигурации меню.""" + + rows: Optional[List[MenuRowConfig]] = Field( + default=None, description="Новая конфигурация строк" + ) + buttons: Optional[Dict[str, MenuButtonConfig]] = Field( + default=None, description="Новая конфигурация кнопок" + ) + + model_config = ConfigDict(extra="forbid") + + +class ButtonUpdateRequest(BaseModel): + """Запрос на обновление отдельной кнопки.""" + + text: Optional[Dict[str, str]] = Field( + default=None, description="Новые локализованные тексты" + ) + enabled: Optional[bool] = Field(default=None, description="Включить/выключить") + visibility: Optional[ButtonVisibility] = Field( + default=None, description="Новая видимость" + ) + conditions: Optional[ButtonConditions] = Field( + default=None, description="Новые условия показа" + ) + action: Optional[str] = Field( + default=None, description="Новый action (для URL/MiniApp кнопок)" + ) + + model_config = ConfigDict(extra="forbid") + + +class RowsReorderRequest(BaseModel): + """Запрос на изменение порядка строк.""" + + ordered_ids: List[str] = Field( + ..., min_length=1, description="Список ID строк в новом порядке" + ) + + model_config = ConfigDict(extra="forbid") + + +class AddRowRequest(BaseModel): + """Запрос на добавление новой строки.""" + + id: str = Field(..., min_length=1, max_length=50, description="ID новой строки") + buttons: List[str] = Field(..., description="Список ID кнопок") + conditions: Optional[ButtonConditions] = Field( + default=None, description="Условия показа" + ) + max_per_row: int = Field(default=2, ge=1, le=4, description="Макс. кнопок в строке") + position: Optional[int] = Field( + default=None, ge=0, description="Позиция вставки (по умолчанию - в конец)" + ) + + model_config = ConfigDict(extra="forbid") + + +class AddCustomButtonRequest(BaseModel): + """Запрос на добавление кастомной кнопки.""" + + id: str = Field( + ..., min_length=1, max_length=50, description="ID кнопки (уникальный)" + ) + type: ButtonType = Field(..., description="Тип кнопки (url или mini_app)") + text: Dict[str, str] = Field(..., description="Локализованные тексты") + action: str = Field(..., min_length=1, description="URL или callback_data") + visibility: ButtonVisibility = Field( + default=ButtonVisibility.ALL, description="Видимость" + ) + conditions: Optional[ButtonConditions] = Field( + default=None, description="Условия показа" + ) + row_id: Optional[str] = Field( + default=None, description="ID строки для добавления кнопки" + ) + + model_config = ConfigDict(extra="forbid") + + +class MenuPreviewRequest(BaseModel): + """Запрос на предпросмотр меню.""" + + language: str = Field(default="ru", description="Язык для предпросмотра") + is_admin: bool = Field(default=False, description="Режим админа") + is_moderator: bool = Field(default=False, description="Режим модератора") + has_active_subscription: bool = Field( + default=False, description="Есть активная подписка" + ) + subscription_is_active: bool = Field( + default=False, description="Подписка активна" + ) + balance_kopeks: int = Field(default=0, ge=0, description="Баланс в копейках") + + model_config = ConfigDict(extra="forbid") + + +class MenuPreviewButton(BaseModel): + """Кнопка в предпросмотре.""" + + text: str + action: str + type: ButtonType + + +class MenuPreviewRow(BaseModel): + """Строка в предпросмотре.""" + + buttons: List[MenuPreviewButton] + + +class MenuPreviewResponse(BaseModel): + """Ответ с предпросмотром меню.""" + + rows: List[MenuPreviewRow] + total_buttons: int + + +# --- Схемы для перемещения кнопок --- + + +class MoveButtonToRowRequest(BaseModel): + """Запрос на перемещение кнопки в другую строку.""" + + target_row_id: str = Field(..., description="ID целевой строки") + position: Optional[int] = Field( + default=None, ge=0, description="Позиция в строке (по умолчанию - в конец)" + ) + + model_config = ConfigDict(extra="forbid") + + +class ReorderButtonsInRowRequest(BaseModel): + """Запрос на изменение порядка кнопок в строке.""" + + ordered_button_ids: List[str] = Field( + ..., min_length=1, description="Список ID кнопок в новом порядке" + ) + + model_config = ConfigDict(extra="forbid") + + +class SwapButtonsRequest(BaseModel): + """Запрос на обмен местами двух кнопок.""" + + button_id_1: str = Field(..., description="ID первой кнопки") + button_id_2: str = Field(..., description="ID второй кнопки") + + model_config = ConfigDict(extra="forbid") + + +class MoveButtonResponse(BaseModel): + """Ответ на перемещение кнопки.""" + + button_id: str + new_row_index: Optional[int] = None + target_row_id: Optional[str] = None + position: Optional[int] = None + + +class SwapButtonsResponse(BaseModel): + """Ответ на обмен кнопок.""" + + button_1: Dict[str, Any] + button_2: Dict[str, Any] + + +class ReorderButtonsResponse(BaseModel): + """Ответ на изменение порядка кнопок.""" + + row_id: str + buttons: List[str] From 8c3e71bdfdca7d01359eb69e1ae870e41f1620f2 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Fri, 19 Dec 2025 03:22:44 +0300 Subject: [PATCH 02/47] Fix menu layout update conversions --- app/webapi/routes/menu_layout.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/app/webapi/routes/menu_layout.py b/app/webapi/routes/menu_layout.py index 1cd398aa..cd87e5a5 100644 --- a/app/webapi/routes/menu_layout.py +++ b/app/webapi/routes/menu_layout.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any, List -from fastapi import APIRouter, Depends, HTTPException, Security, status +from fastapi import APIRouter, Depends, HTTPException, Response, Security, status from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -161,10 +161,14 @@ async def update_button( updates = payload.model_dump(exclude_unset=True) # Конвертируем visibility в строку если есть if "visibility" in updates and updates["visibility"] is not None: - updates["visibility"] = updates["visibility"].value - # Конвертируем conditions в dict если есть + if hasattr(updates["visibility"], "value"): + updates["visibility"] = updates["visibility"].value + # Конвертируем conditions - убираем None значения если это dict if "conditions" in updates and updates["conditions"] is not None: - updates["conditions"] = updates["conditions"].model_dump(exclude_none=True) + if isinstance(updates["conditions"], dict): + updates["conditions"] = {k: v for k, v in updates["conditions"].items() if v is not None} + elif hasattr(updates["conditions"], "model_dump"): + updates["conditions"] = updates["conditions"].model_dump(exclude_none=True) button = await MenuLayoutService.update_button(db, button_id, updates) @@ -238,15 +242,16 @@ async def add_row( raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e -@router.delete("/rows/{row_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/rows/{row_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) async def delete_row( row_id: str, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), -) -> None: +) -> Response: """Удалить строку.""" try: await MenuLayoutService.delete_row(db, row_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) except KeyError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e @@ -290,15 +295,16 @@ async def add_custom_button( raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e -@router.delete("/buttons/{button_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/buttons/{button_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) async def delete_custom_button( button_id: str, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), -) -> None: +) -> Response: """Удалить кастомную кнопку.""" try: await MenuLayoutService.delete_custom_button(db, button_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) except KeyError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e except ValueError as e: From b81400105fd2d06affbbc23be003918d7ef30508 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Fri, 19 Dec 2025 04:02:58 +0300 Subject: [PATCH 03/47] Add button open mode and webapp url --- app/services/menu_layout_service.py | 16 ++++++++++++++-- app/webapi/routes/menu_layout.py | 11 +++++++++++ app/webapi/schemas/menu_layout.py | 24 ++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/app/services/menu_layout_service.py b/app/services/menu_layout_service.py index cd4b9764..50886b8c 100644 --- a/app/services/menu_layout_service.py +++ b/app/services/menu_layout_service.py @@ -135,6 +135,8 @@ DEFAULT_MENU_CONFIG: Dict[str, Any] = { "visibility": "subscribers", "conditions": None, "dynamic_text": False, + "open_mode": "callback", # "callback" или "direct" + "webapp_url": None, # URL для Mini App при open_mode="direct" }, "happ_download": { "type": "builtin", @@ -308,6 +310,7 @@ BUILTIN_BUTTONS_INFO = [ "callback_data": "subscription_connect", "default_conditions": {"has_active_subscription": True, "subscription_is_active": True}, "supports_dynamic_text": False, + "supports_direct_open": True, # Может открывать Mini App напрямую }, { "id": "happ_download", @@ -1021,6 +1024,8 @@ class MenuLayoutService: button_type = button_config.get("type", "builtin") text_config = button_config.get("text", {}) action = button_config.get("action", "") + open_mode = button_config.get("open_mode", "callback") + webapp_url = button_config.get("webapp_url") # Получаем текст text = cls._get_localized_text(text_config, context.language) @@ -1039,8 +1044,15 @@ class MenuLayoutService: text=text, web_app=types.WebAppInfo(url=action) ) else: - # builtin - callback_data - return InlineKeyboardButton(text=text, callback_data=action) + # builtin - проверяем open_mode + if open_mode == "direct" and webapp_url: + # Прямое открытие Mini App через WebAppInfo + return InlineKeyboardButton( + text=text, web_app=types.WebAppInfo(url=webapp_url) + ) + else: + # Стандартный callback_data + return InlineKeyboardButton(text=text, callback_data=action) @classmethod async def build_keyboard( diff --git a/app/webapi/routes/menu_layout.py b/app/webapi/routes/menu_layout.py index cd87e5a5..8eb7a6aa 100644 --- a/app/webapi/routes/menu_layout.py +++ b/app/webapi/routes/menu_layout.py @@ -71,6 +71,8 @@ def _serialize_config(config: dict, is_enabled: bool, updated_at) -> MenuLayoutR if btn_data.get("conditions") else None, dynamic_text=btn_data.get("dynamic_text", False), + open_mode=btn_data.get("open_mode", "callback"), + webapp_url=btn_data.get("webapp_url"), ) return MenuLayoutResponse( @@ -143,6 +145,7 @@ async def list_builtin_buttons( if btn_info.get("default_conditions") else None, supports_dynamic_text=btn_info.get("supports_dynamic_text", False), + supports_direct_open=btn_info.get("supports_direct_open", False), ) ) @@ -163,6 +166,10 @@ async def update_button( if "visibility" in updates and updates["visibility"] is not None: if hasattr(updates["visibility"], "value"): updates["visibility"] = updates["visibility"].value + # Конвертируем open_mode в строку если есть + if "open_mode" in updates and updates["open_mode"] is not None: + if hasattr(updates["open_mode"], "value"): + updates["open_mode"] = updates["open_mode"].value # Конвертируем conditions - убираем None значения если это dict if "conditions" in updates and updates["conditions"] is not None: if isinstance(updates["conditions"], dict): @@ -183,6 +190,8 @@ async def update_button( if button.get("conditions") else None, dynamic_text=button.get("dynamic_text", False), + open_mode=button.get("open_mode", "callback"), + webapp_url=button.get("webapp_url"), ) except KeyError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e @@ -290,6 +299,8 @@ async def add_custom_button( if button.get("conditions") else None, dynamic_text=button.get("dynamic_text", False), + open_mode=button.get("open_mode", "callback"), + webapp_url=button.get("webapp_url"), ) except ValueError as e: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e diff --git a/app/webapi/schemas/menu_layout.py b/app/webapi/schemas/menu_layout.py index 100aacaa..bb413534 100644 --- a/app/webapi/schemas/menu_layout.py +++ b/app/webapi/schemas/menu_layout.py @@ -26,6 +26,13 @@ class ButtonVisibility(str, Enum): SUBSCRIBERS = "subscribers" # Только подписчикам +class ButtonOpenMode(str, Enum): + """Режим открытия кнопки.""" + + CALLBACK = "callback" # Отправляет callback_data боту (по умолчанию) + DIRECT = "direct" # Сразу открывает Mini App через WebAppInfo + + class ButtonConditions(BaseModel): """Условия показа кнопки.""" @@ -96,6 +103,14 @@ class MenuButtonConfig(BaseModel): dynamic_text: bool = Field( default=False, description="Текст содержит плейсхолдеры ({balance} и т.д.)" ) + open_mode: ButtonOpenMode = Field( + default=ButtonOpenMode.CALLBACK, + description="Режим открытия: callback (через бота) или direct (сразу Mini App)", + ) + webapp_url: Optional[str] = Field( + default=None, + description="URL для Mini App при open_mode=direct", + ) model_config = ConfigDict(extra="forbid") @@ -156,6 +171,9 @@ class BuiltinButtonInfo(BaseModel): supports_dynamic_text: bool = Field( default=False, description="Поддерживает ли динамический текст" ) + supports_direct_open: bool = Field( + default=False, description="Поддерживает ли прямое открытие Mini App" + ) class BuiltinButtonsListResponse(BaseModel): @@ -197,6 +215,12 @@ class ButtonUpdateRequest(BaseModel): action: Optional[str] = Field( default=None, description="Новый action (для URL/MiniApp кнопок)" ) + open_mode: Optional[ButtonOpenMode] = Field( + default=None, description="Режим открытия: callback или direct" + ) + webapp_url: Optional[str] = Field( + default=None, description="URL для Mini App при open_mode=direct" + ) model_config = ConfigDict(extra="forbid") From 893ff0e8f1c112b8251e858c1109737172874c2e Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Dec 2025 07:27:27 +0300 Subject: [PATCH 04/47] Add missing Buy Traffic localization key --- app/localization/locales/en.json | 1 + app/localization/locales/ru.json | 1 + app/localization/locales/ua.json | 2 ++ app/localization/locales/zh.json | 1 + 4 files changed, 5 insertions(+) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 4390a7de..1c6bc309 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -3,6 +3,7 @@ "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Insufficient funds\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.", "ADD_COUNTRIES_BUTTON": "🌐 Add countries", "ADD_TRAFFIC_PROMPT": "📈 Add traffic to your subscription\n\nCurrent limit: {current_traffic}\nChoose extra traffic:", + "BUY_TRAFFIC_BUTTON": "📈 Buy more traffic", "ADMIN_BACK_TO_ADMIN": "⬅️ Back to admin", "ADMIN_BACK_TO_LIST": "⬅️ Back to list", "ADMIN_BACK_TO_MAIN": "🏠 Back to main menu", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 0e7385ed..64d4729e 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -3,6 +3,7 @@ "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Недостаточно средств\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.", "ADD_COUNTRIES_BUTTON": "🌐 Добавить страны", "ADD_TRAFFIC_PROMPT": "📈 Добавить трафик к подписке\n\nТекущий лимит: {current_traffic}\nВыберите дополнительный трафик:", + "BUY_TRAFFIC_BUTTON": "📈 Докупить трафик", "ADMIN_BACK_TO_ADMIN": "⬅️ Назад в админку", "ADMIN_BACK_TO_LIST": "⬅️ К списку", "ADMIN_BACK_TO_MAIN": "🏠 В главное меню", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 69139498..dadb5a5e 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -2,6 +2,8 @@ "ACCESS_DENIED": "❌ Доступ заборонено", "ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ Недостатньо коштів\n\nВартість послуги: {required}\nНа балансі: {balance}\nНе вистачає: {missing}\n\nОберіть спосіб поповнення. Сума підставиться автоматично.", "ADD_COUNTRIES_BUTTON": "🌐 Додати країни", + "ADD_TRAFFIC_PROMPT": "📈 Додати трафік до підписки\n\nПоточний ліміт: {current_traffic}\nВиберіть додатковий трафік:", + "BUY_TRAFFIC_BUTTON": "📈 Докупити трафік", "ADMIN_BACK_TO_ADMIN": "⬅️ Назад до адмінки", "ADMIN_BACK_TO_LIST": "⬅️ До списку", "ADMIN_BACK_TO_MAIN": "🏠 В головне меню", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index a107bf81..44124ed5 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -2,6 +2,7 @@ "ACCESS_DENIED":"❌拒绝访问", "ADDON_INSUFFICIENT_FUNDS_MESSAGE":"⚠️资金不足\n\n服务费用:{required}\n当前余额:{balance}\n缺少:{missing}\n\n请选择充值方式。金额将自动填入。", "ADD_COUNTRIES_BUTTON":"🌐添加国家", +"BUY_TRAFFIC_BUTTON":"📈购买更多流量", "ADMIN_BACK_TO_ADMIN":"⬅️返回后台管理", "ADMIN_BACK_TO_LIST":"⬅️返回列表", "ADMIN_BACK_TO_MAIN":"🏠返回主菜单", From 01fd7a317db70f72a0e92b0c64515a65e0a3dfca Mon Sep 17 00:00:00 2001 From: gy9vin Date: Fri, 19 Dec 2025 17:42:03 +0300 Subject: [PATCH 05/47] =?UTF-8?q?=D0=9D=D0=94=D0=A1=2022?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index a8d57a98..b78355c5 100644 --- a/.env.example +++ b/.env.example @@ -225,7 +225,7 @@ YOOKASSA_VAT_CODE=1 # 1 - НДС не облагается # 2 - НДС 0% # 3 - НДС 10% -# 4 - НДС 20% +# 4 - НДС 20%/22% # 5 - НДС 10/110 # 6 - НДС 20/120 diff --git a/requirements.txt b/requirements.txt index 97845d40..af50155d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,7 +17,7 @@ uvicorn==0.32.1 python-multipart==0.0.9 # YooKassa SDK -yookassa==3.7.0 +yookassa==3.9.0 # NaloGO для чеков в налоговую nalogo From 37dd5ede9f7cfaf5d1f5a55e394041590f742cd7 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Fri, 19 Dec 2025 23:27:00 +0300 Subject: [PATCH 06/47] fix --- app/database/models.py | 45 + app/handlers/admin/messages.py | 100 ++ app/services/menu_layout/__init__.py | 36 + app/services/menu_layout/constants.py | 501 ++++++++ app/services/menu_layout/context.py | 35 + app/services/menu_layout/history_service.py | 141 +++ app/services/menu_layout/service.py | 1167 ++++++++++++++++++ app/services/menu_layout/stats_service.py | 188 +++ app/services/menu_layout_service.py | 1197 +------------------ app/webapi/routes/menu_layout.py | 345 +++++- app/webapi/schemas/menu_layout.py | 245 +++- app/webapi/schemas/promo_offers.py | 13 + 12 files changed, 2846 insertions(+), 1167 deletions(-) create mode 100644 app/services/menu_layout/__init__.py create mode 100644 app/services/menu_layout/constants.py create mode 100644 app/services/menu_layout/context.py create mode 100644 app/services/menu_layout/history_service.py create mode 100644 app/services/menu_layout/service.py create mode 100644 app/services/menu_layout/stats_service.py diff --git a/app/database/models.py b/app/database/models.py index 0bce4c31..13cdfd0b 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1782,3 +1782,48 @@ class MainMenuButton(Base): f"" ) + + +class MenuLayoutHistory(Base): + """История изменений конфигурации меню.""" + __tablename__ = "menu_layout_history" + + id = Column(Integer, primary_key=True, index=True) + config_json = Column(Text, nullable=False) # Полная конфигурация в JSON + action = Column(String(50), nullable=False) # update, reset, import + changes_summary = Column(Text, nullable=True) # Краткое описание изменений + user_info = Column(String(255), nullable=True) # Информация о пользователе/токене + created_at = Column(DateTime, default=func.now(), index=True) + + __table_args__ = ( + Index("ix_menu_layout_history_created", "created_at"), + ) + + def __repr__(self) -> str: + return f"" + + +class ButtonClickLog(Base): + """Логи кликов по кнопкам меню.""" + __tablename__ = "button_click_logs" + + id = Column(Integer, primary_key=True, index=True) + button_id = Column(String(100), nullable=False, index=True) # ID кнопки + user_id = Column(BigInteger, ForeignKey("users.telegram_id", ondelete="SET NULL"), nullable=True, index=True) + callback_data = Column(String(255), nullable=True) # callback_data кнопки + clicked_at = Column(DateTime, default=func.now(), index=True) + + # Дополнительная информация + button_type = Column(String(20), nullable=True) # builtin, callback, url, mini_app + button_text = Column(String(255), nullable=True) # Текст кнопки на момент клика + + __table_args__ = ( + Index("ix_button_click_logs_button_date", "button_id", "clicked_at"), + Index("ix_button_click_logs_user_date", "user_id", "clicked_at"), + ) + + # Связи + user = relationship("User", foreign_keys=[user_id]) + + def __repr__(self) -> str: + return f"" diff --git a/app/handlers/admin/messages.py b/app/handlers/admin/messages.py index e7cbc802..3bf0210f 100644 --- a/app/handlers/admin/messages.py +++ b/app/handlers/admin/messages.py @@ -1047,6 +1047,106 @@ async def get_target_users(db: AsyncSession, target: str) -> list: and (user.subscription.traffic_used_gb or 0) <= 0 ] + if target == "expiring_subscribers": + expiring_subs = await get_expiring_subscriptions(db, 7) + return [sub.user for sub in expiring_subs if sub.user] + + if target == "expired_subscribers": + now = datetime.utcnow() + expired_statuses = { + SubscriptionStatus.EXPIRED.value, + SubscriptionStatus.DISABLED.value, + } + expired_users = [] + for user in users: + subscription = user.subscription + if subscription: + if subscription.status in expired_statuses: + expired_users.append(user) + continue + if subscription.end_date <= now and not subscription.is_active: + expired_users.append(user) + continue + elif user.has_had_paid_subscription: + expired_users.append(user) + return expired_users + + if target == "canceled_subscribers": + return [ + user + for user in users + if user.subscription + and user.subscription.status == SubscriptionStatus.DISABLED.value + ] + + if target == "trial_ending": + now = datetime.utcnow() + in_3_days = now + timedelta(days=3) + return [ + user + for user in users + if user.subscription + and user.subscription.is_trial + and user.subscription.is_active + and user.subscription.end_date <= in_3_days + ] + + if target == "trial_expired": + now = datetime.utcnow() + return [ + user + for user in users + if user.subscription + and user.subscription.is_trial + and user.subscription.end_date <= now + ] + + if target == "autopay_failed": + from app.database.models import SubscriptionEvent + week_ago = datetime.utcnow() - timedelta(days=7) + stmt = select(SubscriptionEvent.user_id).where( + and_( + SubscriptionEvent.event_type == "autopay_failed", + SubscriptionEvent.occurred_at >= week_ago, + ) + ).distinct() + result = await db.execute(stmt) + failed_user_ids = set(result.scalars().all()) + return [user for user in users if user.id in failed_user_ids] + + if target == "low_balance": + threshold_kopeks = 10000 # 100 рублей + return [ + user + for user in users + if (user.balance_kopeks or 0) < threshold_kopeks + and (user.balance_kopeks or 0) > 0 + ] + + if target == "inactive_30d": + threshold = datetime.utcnow() - timedelta(days=30) + return [ + user + for user in users + if user.last_activity and user.last_activity < threshold + ] + + if target == "inactive_60d": + threshold = datetime.utcnow() - timedelta(days=60) + return [ + user + for user in users + if user.last_activity and user.last_activity < threshold + ] + + if target == "inactive_90d": + threshold = datetime.utcnow() - timedelta(days=90) + return [ + user + for user in users + if user.last_activity and user.last_activity < threshold + ] + return [] diff --git a/app/services/menu_layout/__init__.py b/app/services/menu_layout/__init__.py new file mode 100644 index 00000000..967c3d61 --- /dev/null +++ b/app/services/menu_layout/__init__.py @@ -0,0 +1,36 @@ +""" +Модуль конструктора меню. + +Структура модуля: +- constants.py - константы и дефолтная конфигурация +- context.py - MenuContext для построения меню +- history_service.py - сервис истории изменений +- stats_service.py - сервис статистики кликов +- service.py - основной MenuLayoutService +""" + +from .constants import ( + MENU_LAYOUT_CONFIG_KEY, + DEFAULT_MENU_CONFIG, + BUILTIN_BUTTONS_INFO, + AVAILABLE_CALLBACKS, + DYNAMIC_PLACEHOLDERS, +) +from .context import MenuContext +from .history_service import MenuLayoutHistoryService +from .stats_service import MenuLayoutStatsService +from .service import MenuLayoutService + +__all__ = [ + # Константы + "MENU_LAYOUT_CONFIG_KEY", + "DEFAULT_MENU_CONFIG", + "BUILTIN_BUTTONS_INFO", + "AVAILABLE_CALLBACKS", + "DYNAMIC_PLACEHOLDERS", + # Классы + "MenuContext", + "MenuLayoutService", + "MenuLayoutHistoryService", + "MenuLayoutStatsService", +] diff --git a/app/services/menu_layout/constants.py b/app/services/menu_layout/constants.py new file mode 100644 index 00000000..8bd96980 --- /dev/null +++ b/app/services/menu_layout/constants.py @@ -0,0 +1,501 @@ +"""Константы для конструктора меню.""" + +from typing import Any, Dict, List + +# Ключ для хранения конфигурации в SystemSetting +MENU_LAYOUT_CONFIG_KEY = "menu_layout_config" + +# Дефолтная конфигурация меню +DEFAULT_MENU_CONFIG: Dict[str, Any] = { + "version": 1, + "rows": [ + { + "id": "connect_row", + "buttons": ["connect"], + "conditions": {"has_active_subscription": True, "subscription_is_active": True}, + "max_per_row": 1, + }, + { + "id": "happ_row", + "buttons": ["happ_download"], + "conditions": {"has_active_subscription": True, "happ_enabled": True}, + "max_per_row": 1, + }, + { + "id": "subscription_traffic_row", + "buttons": ["subscription", "buy_traffic"], + "conditions": {"has_active_subscription": True}, + "max_per_row": 2, + }, + { + "id": "balance_row", + "buttons": ["balance"], + "conditions": None, + "max_per_row": 1, + }, + { + "id": "trial_buy_row", + "buttons": ["trial", "buy_subscription"], + "conditions": None, + "max_per_row": 2, + }, + { + "id": "simple_subscription_row", + "buttons": ["simple_subscription"], + "conditions": {"simple_subscription_enabled": True}, + "max_per_row": 1, + }, + { + "id": "resume_row", + "buttons": ["resume_checkout"], + "conditions": {"has_saved_cart": True}, + "max_per_row": 1, + }, + { + "id": "promo_referral_row", + "buttons": ["promocode", "referrals"], + "conditions": None, + "max_per_row": 2, + }, + { + "id": "contests_row", + "buttons": ["contests"], + "conditions": {"contests_visible": True}, + "max_per_row": 2, + }, + { + "id": "support_info_row", + "buttons": ["support", "info"], + "conditions": None, + "max_per_row": 2, + }, + { + "id": "language_row", + "buttons": ["language"], + "conditions": {"language_selection_enabled": True}, + "max_per_row": 2, + }, + { + "id": "admin_row", + "buttons": ["admin_panel"], + "conditions": {"is_admin": True}, + "max_per_row": 1, + }, + { + "id": "moderator_row", + "buttons": ["moderator_panel"], + "conditions": {"is_moderator": True}, + "max_per_row": 1, + }, + ], + "buttons": { + "connect": { + "type": "builtin", + "builtin_id": "connect", + "text": {"ru": "🔗 Подключиться", "en": "🔗 Connect"}, + "action": "subscription_connect", + "enabled": True, + "visibility": "subscribers", + "conditions": {"has_active_subscription": True, "subscription_is_active": True}, + "dynamic_text": False, + "open_mode": "callback", # "callback" или "direct" + "webapp_url": None, # URL для Mini App при open_mode="direct" + }, + "happ_download": { + "type": "builtin", + "builtin_id": "happ_download", + "text": {"ru": "⬇️ Скачать Happ", "en": "⬇️ Download Happ"}, + "action": "subscription_happ_download", + "enabled": True, + "visibility": "subscribers", + "conditions": None, + "dynamic_text": False, + }, + "subscription": { + "type": "builtin", + "builtin_id": "subscription", + "text": {"ru": "📊 Подписка", "en": "📊 Subscription"}, + "action": "menu_subscription", + "enabled": True, + "visibility": "subscribers", + "conditions": None, + "dynamic_text": False, + }, + "buy_traffic": { + "type": "builtin", + "builtin_id": "buy_traffic", + "text": {"ru": "📈 Докупить трафик", "en": "📈 Buy traffic"}, + "action": "buy_traffic", + "enabled": True, + "visibility": "subscribers", + "conditions": {"has_traffic_limit": True}, + "dynamic_text": False, + }, + "balance": { + "type": "builtin", + "builtin_id": "balance", + "text": {"ru": "💰 Баланс: {balance}", "en": "💰 Balance: {balance}"}, + "action": "menu_balance", + "enabled": True, + "visibility": "all", + "conditions": None, + "dynamic_text": True, + }, + "trial": { + "type": "builtin", + "builtin_id": "trial", + "text": {"ru": "🎁 Пробный период", "en": "🎁 Free trial"}, + "action": "menu_trial", + "enabled": True, + "visibility": "all", + "conditions": {"show_trial": True}, + "dynamic_text": False, + }, + "buy_subscription": { + "type": "builtin", + "builtin_id": "buy_subscription", + "text": {"ru": "🛒 Купить подписку", "en": "🛒 Buy subscription"}, + "action": "menu_buy", + "enabled": True, + "visibility": "all", + "conditions": {"show_buy": True}, + "dynamic_text": False, + }, + "simple_subscription": { + "type": "builtin", + "builtin_id": "simple_subscription", + "text": {"ru": "💳 Простая подписка", "en": "💳 Simple subscription"}, + "action": "simple_subscription_purchase", + "enabled": True, + "visibility": "all", + "conditions": None, + "dynamic_text": False, + }, + "resume_checkout": { + "type": "builtin", + "builtin_id": "resume_checkout", + "text": {"ru": "↩️ Вернуться к оформлению", "en": "↩️ Resume checkout"}, + "action": "return_to_saved_cart", + "enabled": True, + "visibility": "all", + "conditions": None, + "dynamic_text": False, + }, + "promocode": { + "type": "builtin", + "builtin_id": "promocode", + "text": {"ru": "🎟️ Промокод", "en": "🎟️ Promo code"}, + "action": "menu_promocode", + "enabled": True, + "visibility": "all", + "conditions": None, + "dynamic_text": False, + }, + "referrals": { + "type": "builtin", + "builtin_id": "referrals", + "text": {"ru": "👥 Рефералы", "en": "👥 Referrals"}, + "action": "menu_referrals", + "enabled": True, + "visibility": "all", + "conditions": {"referral_enabled": True}, + "dynamic_text": False, + }, + "contests": { + "type": "builtin", + "builtin_id": "contests", + "text": {"ru": "🎲 Конкурсы", "en": "🎲 Contests"}, + "action": "contests_menu", + "enabled": True, + "visibility": "all", + "conditions": None, + "dynamic_text": False, + }, + "support": { + "type": "builtin", + "builtin_id": "support", + "text": {"ru": "💬 Поддержка", "en": "💬 Support"}, + "action": "menu_support", + "enabled": True, + "visibility": "all", + "conditions": {"support_enabled": True}, + "dynamic_text": False, + }, + "info": { + "type": "builtin", + "builtin_id": "info", + "text": {"ru": "ℹ️ Инфо", "en": "ℹ️ Info"}, + "action": "menu_info", + "enabled": True, + "visibility": "all", + "conditions": None, + "dynamic_text": False, + }, + "language": { + "type": "builtin", + "builtin_id": "language", + "text": {"ru": "🌐 Язык", "en": "🌐 Language"}, + "action": "menu_language", + "enabled": True, + "visibility": "all", + "conditions": None, + "dynamic_text": False, + }, + "admin_panel": { + "type": "builtin", + "builtin_id": "admin_panel", + "text": {"ru": "⚙️ Админ панель", "en": "⚙️ Admin panel"}, + "action": "admin_panel", + "enabled": True, + "visibility": "admins", + "conditions": None, + "dynamic_text": False, + }, + "moderator_panel": { + "type": "builtin", + "builtin_id": "moderator_panel", + "text": {"ru": "🧑‍⚖️ Модерация", "en": "🧑‍⚖️ Moderation"}, + "action": "moderator_panel", + "enabled": True, + "visibility": "moderators", + "conditions": None, + "dynamic_text": False, + }, + }, +} + + +# Информация о встроенных кнопках для API +BUILTIN_BUTTONS_INFO: List[Dict[str, Any]] = [ + { + "id": "connect", + "default_text": {"ru": "🔗 Подключиться", "en": "🔗 Connect"}, + "callback_data": "subscription_connect", + "default_conditions": {"has_active_subscription": True, "subscription_is_active": True}, + "supports_dynamic_text": False, + "supports_direct_open": True, + }, + { + "id": "happ_download", + "default_text": {"ru": "⬇️ Скачать Happ", "en": "⬇️ Download Happ"}, + "callback_data": "subscription_happ_download", + "default_conditions": {"happ_enabled": True}, + "supports_dynamic_text": False, + }, + { + "id": "subscription", + "default_text": {"ru": "📊 Подписка", "en": "📊 Subscription"}, + "callback_data": "menu_subscription", + "default_conditions": {"has_active_subscription": True}, + "supports_dynamic_text": False, + }, + { + "id": "buy_traffic", + "default_text": {"ru": "📈 Докупить трафик", "en": "📈 Buy traffic"}, + "callback_data": "buy_traffic", + "default_conditions": {"has_traffic_limit": True}, + "supports_dynamic_text": False, + }, + { + "id": "balance", + "default_text": {"ru": "💰 Баланс: {balance}", "en": "💰 Balance: {balance}"}, + "callback_data": "menu_balance", + "default_conditions": None, + "supports_dynamic_text": True, + }, + { + "id": "trial", + "default_text": {"ru": "🎁 Пробный период", "en": "🎁 Free trial"}, + "callback_data": "menu_trial", + "default_conditions": {"show_trial": True}, + "supports_dynamic_text": False, + }, + { + "id": "buy_subscription", + "default_text": {"ru": "🛒 Купить подписку", "en": "🛒 Buy subscription"}, + "callback_data": "menu_buy", + "default_conditions": {"show_buy": True}, + "supports_dynamic_text": False, + }, + { + "id": "simple_subscription", + "default_text": {"ru": "💳 Простая подписка", "en": "💳 Simple subscription"}, + "callback_data": "simple_subscription_purchase", + "default_conditions": {"simple_subscription_enabled": True}, + "supports_dynamic_text": False, + }, + { + "id": "resume_checkout", + "default_text": {"ru": "↩️ Вернуться к оформлению", "en": "↩️ Resume checkout"}, + "callback_data": "return_to_saved_cart", + "default_conditions": {"has_saved_cart": True}, + "supports_dynamic_text": False, + }, + { + "id": "promocode", + "default_text": {"ru": "🎟️ Промокод", "en": "🎟️ Promo code"}, + "callback_data": "menu_promocode", + "default_conditions": None, + "supports_dynamic_text": False, + }, + { + "id": "referrals", + "default_text": {"ru": "👥 Рефералы", "en": "👥 Referrals"}, + "callback_data": "menu_referrals", + "default_conditions": {"referral_enabled": True}, + "supports_dynamic_text": False, + }, + { + "id": "contests", + "default_text": {"ru": "🎲 Конкурсы", "en": "🎲 Contests"}, + "callback_data": "contests_menu", + "default_conditions": {"contests_visible": True}, + "supports_dynamic_text": False, + }, + { + "id": "support", + "default_text": {"ru": "💬 Поддержка", "en": "💬 Support"}, + "callback_data": "menu_support", + "default_conditions": {"support_enabled": True}, + "supports_dynamic_text": False, + }, + { + "id": "info", + "default_text": {"ru": "ℹ️ Инфо", "en": "ℹ️ Info"}, + "callback_data": "menu_info", + "default_conditions": None, + "supports_dynamic_text": False, + }, + { + "id": "language", + "default_text": {"ru": "🌐 Язык", "en": "🌐 Language"}, + "callback_data": "menu_language", + "default_conditions": {"language_selection_enabled": True}, + "supports_dynamic_text": False, + }, + { + "id": "admin_panel", + "default_text": {"ru": "⚙️ Админ панель", "en": "⚙️ Admin panel"}, + "callback_data": "admin_panel", + "default_conditions": {"is_admin": True}, + "supports_dynamic_text": False, + }, + { + "id": "moderator_panel", + "default_text": {"ru": "🧑‍⚖️ Модерация", "en": "🧑‍⚖️ Moderation"}, + "callback_data": "moderator_panel", + "default_conditions": {"is_moderator": True}, + "supports_dynamic_text": False, + }, +] + + +# Все доступные callback_data в боте (для добавления кастомных кнопок) +AVAILABLE_CALLBACKS: List[Dict[str, Any]] = [ + # Меню + {"callback_data": "back_to_menu", "name": "Назад в меню", "category": "menu", "icon": "⬅️", + "text": {"ru": "⬅️ Назад", "en": "⬅️ Back"}}, + {"callback_data": "menu_faq", "name": "FAQ", "category": "menu", "icon": "❓", + "text": {"ru": "❓ FAQ", "en": "❓ FAQ"}}, + {"callback_data": "menu_info_promo_groups", "name": "Промо-группы", "category": "menu", "icon": "👥", + "text": {"ru": "👥 Промо-группы", "en": "👥 Promo groups"}}, + {"callback_data": "menu_privacy_policy", "name": "Политика конфиденциальности", "category": "menu", "icon": "🔒", + "text": {"ru": "🔒 Политика конфиденциальности", "en": "🔒 Privacy Policy"}}, + {"callback_data": "menu_public_offer", "name": "Публичная оферта", "category": "menu", "icon": "📜", + "text": {"ru": "📜 Публичная оферта", "en": "📜 Public Offer"}}, + {"callback_data": "menu_rules", "name": "Правила", "category": "menu", "icon": "📋", + "text": {"ru": "📋 Правила", "en": "📋 Rules"}}, + {"callback_data": "menu_server_status", "name": "Статус серверов", "category": "menu", "icon": "🖥️", + "text": {"ru": "🖥️ Статус серверов", "en": "🖥️ Server Status"}}, + + # Баланс + {"callback_data": "balance_history", "name": "История баланса", "category": "balance", "icon": "📜", + "text": {"ru": "📜 История", "en": "📜 History"}}, + {"callback_data": "balance_topup", "name": "Пополнить баланс", "category": "balance", "icon": "💳", + "text": {"ru": "💳 Пополнить", "en": "💳 Top up"}}, + + # Подписка + {"callback_data": "subscription_extend", "name": "Продлить подписку", "category": "subscription", "icon": "📅", + "text": {"ru": "📅 Продлить", "en": "📅 Extend"}, "requires_subscription": True}, + {"callback_data": "subscription_autopay", "name": "Автоплатёж", "category": "subscription", "icon": "🔄", + "text": {"ru": "🔄 Автоплатёж", "en": "🔄 Autopay"}, "requires_subscription": True}, + {"callback_data": "subscription_settings", "name": "Настройки подписки", "category": "subscription", "icon": "⚙️", + "text": {"ru": "⚙️ Настройки", "en": "⚙️ Settings"}, "requires_subscription": True}, + {"callback_data": "open_subscription_link", "name": "Показать ссылку подписки", "category": "subscription", "icon": "🔗", + "text": {"ru": "🔗 Показать ссылку", "en": "🔗 Show link"}, "requires_subscription": True}, + {"callback_data": "subscription_add_countries", "name": "Добавить страны", "category": "subscription", "icon": "🌍", + "text": {"ru": "🌍 Добавить страны", "en": "🌍 Add countries"}, "requires_subscription": True}, + {"callback_data": "subscription_reset_traffic", "name": "Сбросить трафик", "category": "subscription", "icon": "🔄", + "text": {"ru": "🔄 Сбросить трафик", "en": "🔄 Reset traffic"}, "requires_subscription": True}, + {"callback_data": "subscription_switch_traffic", "name": "Переключить трафик", "category": "subscription", "icon": "🔀", + "text": {"ru": "🔀 Переключить трафик", "en": "🔀 Switch traffic"}, "requires_subscription": True}, + {"callback_data": "subscription_change_devices", "name": "Изменить устройства", "category": "subscription", "icon": "📱", + "text": {"ru": "📱 Изменить устройства", "en": "📱 Change devices"}, "requires_subscription": True}, + {"callback_data": "subscription_manage_devices", "name": "Управление устройствами", "category": "subscription", "icon": "📲", + "text": {"ru": "📲 Управление устройствами", "en": "📲 Manage devices"}, "requires_subscription": True}, + {"callback_data": "subscription_upgrade", "name": "Улучшить подписку", "category": "subscription", "icon": "⬆️", + "text": {"ru": "⬆️ Улучшить", "en": "⬆️ Upgrade"}, "requires_subscription": True}, + + # Подключение устройств + {"callback_data": "device_guide_ios", "name": "Инструкция iOS", "category": "devices", "icon": "📱", + "text": {"ru": "📱 iOS", "en": "📱 iOS"}, "requires_subscription": True}, + {"callback_data": "device_guide_android", "name": "Инструкция Android", "category": "devices", "icon": "🤖", + "text": {"ru": "🤖 Android", "en": "🤖 Android"}, "requires_subscription": True}, + {"callback_data": "device_guide_windows", "name": "Инструкция Windows", "category": "devices", "icon": "💻", + "text": {"ru": "💻 Windows", "en": "💻 Windows"}, "requires_subscription": True}, + {"callback_data": "device_guide_mac", "name": "Инструкция macOS", "category": "devices", "icon": "🎯", + "text": {"ru": "🎯 macOS", "en": "🎯 macOS"}, "requires_subscription": True}, + {"callback_data": "device_guide_tv", "name": "Инструкция Android TV", "category": "devices", "icon": "📺", + "text": {"ru": "📺 Android TV", "en": "📺 Android TV"}, "requires_subscription": True}, + {"callback_data": "device_guide_appletv", "name": "Инструкция Apple TV", "category": "devices", "icon": "📺", + "text": {"ru": "📺 Apple TV", "en": "📺 Apple TV"}, "requires_subscription": True}, + + # Happ + {"callback_data": "happ_download_ios", "name": "Скачать Happ iOS", "category": "happ", "icon": "🍎", + "text": {"ru": "🍎 iOS", "en": "🍎 iOS"}}, + {"callback_data": "happ_download_android", "name": "Скачать Happ Android", "category": "happ", "icon": "🤖", + "text": {"ru": "🤖 Android", "en": "🤖 Android"}}, + {"callback_data": "happ_download_macos", "name": "Скачать Happ macOS", "category": "happ", "icon": "🖥️", + "text": {"ru": "🖥️ macOS", "en": "🖥️ macOS"}}, + {"callback_data": "happ_download_windows", "name": "Скачать Happ Windows", "category": "happ", "icon": "💻", + "text": {"ru": "💻 Windows", "en": "💻 Windows"}}, + + # Рефералы + {"callback_data": "referral_create_invite", "name": "Создать инвайт", "category": "referral", "icon": "✉️", + "text": {"ru": "✉️ Создать инвайт", "en": "✉️ Create invite"}}, + {"callback_data": "referral_show_qr", "name": "QR код реферала", "category": "referral", "icon": "📱", + "text": {"ru": "📱 QR код", "en": "📱 QR code"}}, + {"callback_data": "referral_list", "name": "Список рефералов", "category": "referral", "icon": "👥", + "text": {"ru": "👥 Мои рефералы", "en": "👥 My referrals"}}, + {"callback_data": "referral_analytics", "name": "Аналитика рефералов", "category": "referral", "icon": "📊", + "text": {"ru": "📊 Аналитика", "en": "📊 Analytics"}}, + + # Поддержка + {"callback_data": "create_ticket", "name": "Создать тикет", "category": "support", "icon": "✏️", + "text": {"ru": "✏️ Создать тикет", "en": "✏️ Create ticket"}}, + {"callback_data": "my_tickets", "name": "Мои тикеты", "category": "support", "icon": "📋", + "text": {"ru": "📋 Мои тикеты", "en": "📋 My tickets"}}, + + # Триал + {"callback_data": "trial_activate", "name": "Активировать триал", "category": "trial", "icon": "🎁", + "text": {"ru": "🎁 Активировать", "en": "🎁 Activate"}}, + + # Покупка + {"callback_data": "clear_saved_cart", "name": "Очистить корзину", "category": "purchase", "icon": "🗑️", + "text": {"ru": "🗑️ Очистить корзину", "en": "🗑️ Clear cart"}}, + {"callback_data": "subscription_confirm", "name": "Подтвердить покупку", "category": "purchase", "icon": "✅", + "text": {"ru": "✅ Подтвердить", "en": "✅ Confirm"}}, + {"callback_data": "subscription_cancel", "name": "Отменить покупку", "category": "purchase", "icon": "❌", + "text": {"ru": "❌ Отменить", "en": "❌ Cancel"}}, +] + +# Динамические плейсхолдеры для текста кнопок +DYNAMIC_PLACEHOLDERS: List[Dict[str, str]] = [ + {"placeholder": "{balance}", "description": "Баланс пользователя", "example": "1 500 ₽", "category": "user"}, + {"placeholder": "{username}", "description": "Имя пользователя", "example": "John", "category": "user"}, + {"placeholder": "{subscription_days}", "description": "Дней до окончания подписки", "example": "14", "category": "subscription"}, + {"placeholder": "{traffic_used}", "description": "Использованный трафик", "example": "5.2 GB", "category": "subscription"}, + {"placeholder": "{traffic_left}", "description": "Оставшийся трафик", "example": "94.8 GB", "category": "subscription"}, + {"placeholder": "{referral_count}", "description": "Количество рефералов", "example": "12", "category": "referral"}, + {"placeholder": "{referral_earnings}", "description": "Заработок с рефералов", "example": "500 ₽", "category": "referral"}, +] diff --git a/app/services/menu_layout/context.py b/app/services/menu_layout/context.py new file mode 100644 index 00000000..fc6aeb13 --- /dev/null +++ b/app/services/menu_layout/context.py @@ -0,0 +1,35 @@ +"""Контекст меню для построения кнопок.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, List, Optional + +from aiogram.types import InlineKeyboardButton + + +@dataclass +class MenuContext: + """Контекст пользователя для построения меню.""" + + language: str = "ru" + is_admin: bool = False + is_moderator: bool = False + has_active_subscription: bool = False + subscription_is_active: bool = False + has_had_paid_subscription: bool = False + balance_kopeks: int = 0 + subscription: Optional[Any] = None + show_resume_checkout: bool = False + has_saved_cart: bool = False + custom_buttons: List[InlineKeyboardButton] = field(default_factory=list) + # Расширенные поля для плейсхолдеров и условий + username: str = "" + subscription_days_left: int = 0 + traffic_used_gb: float = 0.0 + traffic_left_gb: float = 0.0 + referral_count: int = 0 + referral_earnings_kopeks: int = 0 + registration_days: int = 0 + promo_group_id: Optional[str] = None + has_autopay: bool = False diff --git a/app/services/menu_layout/history_service.py b/app/services/menu_layout/history_service.py new file mode 100644 index 00000000..4e94047f --- /dev/null +++ b/app/services/menu_layout/history_service.py @@ -0,0 +1,141 @@ +"""Сервис истории изменений конфигурации меню.""" + +from __future__ import annotations + +import json +from datetime import datetime +from typing import Any, Dict, List, Optional + +from sqlalchemy import select, func, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import MenuLayoutHistory + + +class MenuLayoutHistoryService: + """Сервис для управления историей изменений меню.""" + + @classmethod + async def save_history( + cls, + db: AsyncSession, + config: Dict[str, Any], + action: str, + changes_summary: Optional[str] = None, + user_info: Optional[str] = None, + ) -> MenuLayoutHistory: + """Сохранить запись в историю изменений.""" + history = MenuLayoutHistory( + config_json=json.dumps(config, ensure_ascii=False), + action=action, + changes_summary=changes_summary or f"Action: {action}", + user_info=user_info, + ) + db.add(history) + await db.commit() + await db.refresh(history) + return history + + @classmethod + async def get_history( + cls, + db: AsyncSession, + limit: int = 50, + offset: int = 0, + ) -> List[Dict[str, Any]]: + """Получить историю изменений.""" + result = await db.execute( + select(MenuLayoutHistory) + .order_by(desc(MenuLayoutHistory.created_at)) + .limit(limit) + .offset(offset) + ) + entries = result.scalars().all() + + return [ + { + "id": entry.id, + "action": entry.action, + "changes_summary": entry.changes_summary, + "user_info": entry.user_info, + "created_at": entry.created_at, + } + for entry in entries + ] + + @classmethod + async def get_history_count(cls, db: AsyncSession) -> int: + """Получить общее количество записей истории.""" + result = await db.execute( + select(func.count(MenuLayoutHistory.id)) + ) + return result.scalar() or 0 + + @classmethod + async def get_history_entry( + cls, + db: AsyncSession, + history_id: int, + ) -> Optional[Dict[str, Any]]: + """Получить конкретную запись истории с конфигурацией.""" + result = await db.execute( + select(MenuLayoutHistory).where(MenuLayoutHistory.id == history_id) + ) + entry = result.scalar_one_or_none() + + if not entry: + return None + + return { + "id": entry.id, + "action": entry.action, + "changes_summary": entry.changes_summary, + "user_info": entry.user_info, + "created_at": entry.created_at, + "config": json.loads(entry.config_json), + } + + @classmethod + async def rollback_to_history( + cls, + db: AsyncSession, + history_id: int, + get_config_func, + save_config_func, + user_info: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Откатить конфигурацию к записи из истории. + + Args: + db: Сессия базы данных + history_id: ID записи истории + get_config_func: Функция для получения текущей конфигурации + save_config_func: Функция для сохранения конфигурации + user_info: Информация о пользователе + """ + entry = await cls.get_history_entry(db, history_id) + if not entry: + raise KeyError(f"History entry {history_id} not found") + + config = entry["config"] + + # Сохраняем текущую конфигурацию в историю перед откатом + current_config = await get_config_func(db) + await cls.save_history( + db, current_config, "rollback_backup", + f"Backup before rollback to history #{history_id}", + user_info + ) + + # Применяем конфигурацию из истории + await save_config_func(db, config) + + # Сохраняем запись об откате + await cls.save_history( + db, config, "rollback", + f"Rollback to history #{history_id}", + user_info + ) + + return config diff --git a/app/services/menu_layout/service.py b/app/services/menu_layout/service.py new file mode 100644 index 00000000..d7d73323 --- /dev/null +++ b/app/services/menu_layout/service.py @@ -0,0 +1,1167 @@ +"""Основной сервис конструктора меню.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from datetime import datetime +from typing import Any, Dict, List, Optional + +from aiogram import types +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import SystemSetting +from app.database.crud.system_setting import upsert_system_setting +from app.localization.texts import get_texts + +from .constants import ( + MENU_LAYOUT_CONFIG_KEY, + DEFAULT_MENU_CONFIG, + BUILTIN_BUTTONS_INFO, + AVAILABLE_CALLBACKS, + DYNAMIC_PLACEHOLDERS, +) +from .context import MenuContext +from .history_service import MenuLayoutHistoryService +from .stats_service import MenuLayoutStatsService + +logger = logging.getLogger(__name__) + + +class MenuLayoutService: + """Сервис для управления конфигурацией меню.""" + + _cache: Optional[Dict[str, Any]] = None + _cache_updated_at: Optional[datetime] = None + _lock: asyncio.Lock = asyncio.Lock() + + # --- Управление кешем --- + + @classmethod + def invalidate_cache(cls) -> None: + """Инвалидировать кеш конфигурации.""" + cls._cache = None + cls._cache_updated_at = None + + # --- Получение констант и информации --- + + @classmethod + def get_default_config(cls) -> Dict[str, Any]: + """Получить дефолтную конфигурацию.""" + return DEFAULT_MENU_CONFIG.copy() + + @classmethod + def get_builtin_buttons_info(cls) -> List[Dict[str, Any]]: + """Получить информацию о встроенных кнопках.""" + return BUILTIN_BUTTONS_INFO.copy() + + @classmethod + async def get_available_callbacks(cls, db: AsyncSession) -> List[Dict[str, Any]]: + """Получить список всех доступных callback_data.""" + config = await cls.get_config(db) + buttons_in_menu = set(config.get("buttons", {}).keys()) + + # Добавляем встроенные callback_data + builtin_callbacks = {btn["callback_data"] for btn in BUILTIN_BUTTONS_INFO} + + result = [] + for cb in AVAILABLE_CALLBACKS: + is_in_menu = cb["callback_data"] in buttons_in_menu or cb["callback_data"] in builtin_callbacks + result.append({ + **cb, + "is_in_menu": is_in_menu, + "default_text": cb.get("text"), + "default_icon": cb.get("icon"), + }) + + return result + + @classmethod + def get_dynamic_placeholders(cls) -> List[Dict[str, Any]]: + """Получить список доступных динамических плейсхолдеров.""" + return DYNAMIC_PLACEHOLDERS.copy() + + # --- Экспорт/импорт --- + + @classmethod + async def export_config(cls, db: AsyncSession) -> Dict[str, Any]: + """Экспортировать конфигурацию меню.""" + config = await cls.get_config(db) + return { + "version": config.get("version", 1), + "rows": config.get("rows", []), + "buttons": config.get("buttons", {}), + "exported_at": datetime.now().isoformat(), + } + + @classmethod + async def import_config( + cls, + db: AsyncSession, + import_data: Dict[str, Any], + merge_mode: str = "replace", + ) -> Dict[str, Any]: + """Импортировать конфигурацию меню.""" + warnings = [] + + if merge_mode == "replace": + # Полная замена + new_config = { + "version": import_data.get("version", 1), + "rows": import_data.get("rows", []), + "buttons": import_data.get("buttons", {}), + } + else: + # Объединение + current_config = await cls.get_config(db) + new_config = current_config.copy() + + # Добавляем новые кнопки (не перезаписываем существующие) + for btn_id, btn_config in import_data.get("buttons", {}).items(): + if btn_id not in new_config["buttons"]: + new_config["buttons"][btn_id] = btn_config + else: + warnings.append(f"Button '{btn_id}' already exists, skipped") + + # Добавляем новые строки + existing_row_ids = {row["id"] for row in new_config.get("rows", [])} + for row in import_data.get("rows", []): + if row["id"] not in existing_row_ids: + new_config["rows"].append(row) + else: + warnings.append(f"Row '{row['id']}' already exists, skipped") + + await cls.save_config(db, new_config) + + return { + "success": True, + "imported_rows": len(import_data.get("rows", [])), + "imported_buttons": len(import_data.get("buttons", {})), + "warnings": warnings, + } + + # --- Валидация --- + + @classmethod + def validate_config(cls, config: Dict[str, Any]) -> Dict[str, Any]: + """Валидировать конфигурацию меню.""" + errors = [] + warnings = [] + + rows = config.get("rows", []) + buttons = config.get("buttons", {}) + + # Проверяем уникальность ID строк + row_ids = [row.get("id") for row in rows] + duplicate_rows = [rid for rid in row_ids if row_ids.count(rid) > 1] + if duplicate_rows: + errors.append({ + "field": "rows", + "message": f"Duplicate row IDs: {set(duplicate_rows)}", + "severity": "error", + }) + + # Проверяем ссылки на кнопки + for row in rows: + for btn_id in row.get("buttons", []): + if btn_id not in buttons: + errors.append({ + "field": f"rows.{row.get('id')}.buttons", + "message": f"Button '{btn_id}' not found", + "severity": "error", + }) + + # Проверяем пустые строки + for row in rows: + if not row.get("buttons"): + warnings.append({ + "field": f"rows.{row.get('id')}", + "message": "Row has no buttons", + "severity": "warning", + }) + + # Проверяем отключенные кнопки + disabled_count = sum(1 for btn in buttons.values() if not btn.get("enabled", True)) + if disabled_count > 0: + warnings.append({ + "field": "buttons", + "message": f"{disabled_count} buttons are disabled", + "severity": "warning", + }) + + return { + "is_valid": len(errors) == 0, + "errors": errors, + "warnings": warnings, + } + + # --- Работа с конфигурацией --- + + @classmethod + async def get_config(cls, db: AsyncSession) -> Dict[str, Any]: + """Получить конфигурацию меню.""" + if cls._cache is not None: + return cls._cache + + async with cls._lock: + if cls._cache is not None: + return cls._cache + + result = await db.execute( + select(SystemSetting).where(SystemSetting.key == MENU_LAYOUT_CONFIG_KEY) + ) + setting = result.scalar_one_or_none() + + if setting and setting.value: + try: + cls._cache = json.loads(setting.value) + cls._cache_updated_at = setting.updated_at + except json.JSONDecodeError: + logger.warning("Invalid menu layout config JSON, using default") + cls._cache = cls.get_default_config() + cls._cache_updated_at = None + else: + cls._cache = cls.get_default_config() + cls._cache_updated_at = None + + return cls._cache + + @classmethod + async def get_config_updated_at(cls, db: AsyncSession) -> Optional[datetime]: + """Получить время последнего обновления конфигурации.""" + await cls.get_config(db) # Ensure cache is loaded + return cls._cache_updated_at + + @classmethod + async def save_config(cls, db: AsyncSession, config: Dict[str, Any]) -> None: + """Сохранить конфигурацию меню.""" + config_json = json.dumps(config, ensure_ascii=False, indent=2) + await upsert_system_setting( + db, + MENU_LAYOUT_CONFIG_KEY, + config_json, + description="Конфигурация конструктора меню", + ) + await db.commit() + cls.invalidate_cache() + + @classmethod + async def reset_to_default(cls, db: AsyncSession) -> Dict[str, Any]: + """Сбросить конфигурацию к дефолтной.""" + default_config = cls.get_default_config() + await cls.save_config(db, default_config) + return default_config + + # --- Обновление кнопок --- + + @classmethod + async def update_button( + cls, + db: AsyncSession, + button_id: str, + updates: Dict[str, Any], + ) -> Dict[str, Any]: + """Обновить конфигурацию кнопки.""" + config = await cls.get_config(db) + config = config.copy() + buttons = config.get("buttons", {}) + + if button_id not in buttons: + raise KeyError(f"Button '{button_id}' not found") + + button = buttons[button_id].copy() + + # Применяем обновления + if "text" in updates and updates["text"] is not None: + button["text"] = updates["text"] + if "icon" in updates: + button["icon"] = updates["icon"] + if "enabled" in updates and updates["enabled"] is not None: + button["enabled"] = updates["enabled"] + if "visibility" in updates and updates["visibility"] is not None: + button["visibility"] = updates["visibility"] + if "conditions" in updates: + button["conditions"] = updates["conditions"] + if "dynamic_text" in updates and updates["dynamic_text"] is not None: + button["dynamic_text"] = updates["dynamic_text"] + if "description" in updates: + button["description"] = updates["description"] + if "sort_order" in updates: + button["sort_order"] = updates["sort_order"] + if "action" in updates and updates["action"] is not None: + # Для URL/MiniApp/callback кнопок можно менять action + if button.get("type") in ("url", "mini_app", "callback"): + button["action"] = updates["action"] + if "open_mode" in updates and updates["open_mode"] is not None: + button["open_mode"] = updates["open_mode"] + if "webapp_url" in updates: + button["webapp_url"] = updates["webapp_url"] + + buttons[button_id] = button + config["buttons"] = buttons + + await cls.save_config(db, config) + return button + + # --- Управление строками --- + + @classmethod + async def reorder_rows( + cls, + db: AsyncSession, + ordered_ids: List[str], + ) -> List[Dict[str, Any]]: + """Изменить порядок строк.""" + config = await cls.get_config(db) + config = config.copy() + rows = config.get("rows", []) + + # Создаем словарь для быстрого поиска + rows_map = {row["id"]: row for row in rows} + + # Проверяем что все ID существуют + for row_id in ordered_ids: + if row_id not in rows_map: + raise KeyError(f"Row '{row_id}' not found") + + # Переупорядочиваем + new_rows = [rows_map[row_id] for row_id in ordered_ids] + + # Добавляем строки которые не были в списке (в конец) + for row in rows: + if row["id"] not in ordered_ids: + new_rows.append(row) + + config["rows"] = new_rows + await cls.save_config(db, config) + return new_rows + + @classmethod + async def add_row( + cls, + db: AsyncSession, + row_config: Dict[str, Any], + position: Optional[int] = None, + ) -> Dict[str, Any]: + """Добавить новую строку.""" + config = await cls.get_config(db) + config = config.copy() + rows = config.get("rows", []) + + # Проверяем уникальность ID + existing_ids = {row["id"] for row in rows} + if row_config["id"] in existing_ids: + raise ValueError(f"Row with id '{row_config['id']}' already exists") + + new_row = { + "id": row_config["id"], + "buttons": row_config.get("buttons", []), + "conditions": row_config.get("conditions"), + "max_per_row": row_config.get("max_per_row", 2), + } + + if position is not None and 0 <= position < len(rows): + rows.insert(position, new_row) + else: + rows.append(new_row) + + config["rows"] = rows + await cls.save_config(db, config) + return new_row + + @classmethod + async def delete_row(cls, db: AsyncSession, row_id: str) -> None: + """Удалить строку.""" + config = await cls.get_config(db) + config = config.copy() + rows = config.get("rows", []) + + new_rows = [row for row in rows if row["id"] != row_id] + if len(new_rows) == len(rows): + raise KeyError(f"Row '{row_id}' not found") + + config["rows"] = new_rows + await cls.save_config(db, config) + + # --- Кастомные кнопки --- + + @classmethod + async def add_custom_button( + cls, + db: AsyncSession, + button_id: str, + button_config: Dict[str, Any], + row_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Добавить кастомную кнопку.""" + config = await cls.get_config(db) + config = config.copy() + buttons = config.get("buttons", {}) + + if button_id in buttons: + raise ValueError(f"Button with id '{button_id}' already exists") + + # URL, MiniApp и callback кнопки могут быть добавлены + allowed_types = ("url", "mini_app", "callback") + if button_config.get("type") not in allowed_types: + raise ValueError(f"Only {allowed_types} buttons can be added") + + buttons[button_id] = { + "type": button_config["type"], + "builtin_id": None, + "text": button_config["text"], + "icon": button_config.get("icon"), + "action": button_config["action"], + "enabled": button_config.get("enabled", True), + "visibility": button_config.get("visibility", "all"), + "conditions": button_config.get("conditions"), + "dynamic_text": button_config.get("dynamic_text", False), + "description": button_config.get("description"), + } + + config["buttons"] = buttons + + # Добавляем в строку если указана + if row_id: + rows = config.get("rows", []) + for row in rows: + if row["id"] == row_id: + row["buttons"].append(button_id) + break + + await cls.save_config(db, config) + return buttons[button_id] + + @classmethod + async def delete_custom_button(cls, db: AsyncSession, button_id: str) -> None: + """Удалить кастомную кнопку.""" + config = await cls.get_config(db) + config = config.copy() + buttons = config.get("buttons", {}) + + if button_id not in buttons: + raise KeyError(f"Button '{button_id}' not found") + + # Нельзя удалять встроенные кнопки + if buttons[button_id].get("type") == "builtin": + raise ValueError("Cannot delete builtin buttons") + + del buttons[button_id] + config["buttons"] = buttons + + # Удаляем из всех строк + rows = config.get("rows", []) + for row in rows: + if button_id in row.get("buttons", []): + row["buttons"].remove(button_id) + + await cls.save_config(db, config) + + # --- Перемещение кнопок --- + + @classmethod + def _find_button_row(cls, config: Dict[str, Any], button_id: str) -> Optional[int]: + """Найти индекс строки содержащей кнопку.""" + rows = config.get("rows", []) + for i, row in enumerate(rows): + if button_id in row.get("buttons", []): + return i + return None + + @classmethod + async def move_button_up(cls, db: AsyncSession, button_id: str) -> Dict[str, Any]: + """Переместить кнопку на строку выше.""" + config = await cls.get_config(db) + config = config.copy() + rows = config.get("rows", []) + + current_row_idx = cls._find_button_row(config, button_id) + if current_row_idx is None: + raise KeyError(f"Button '{button_id}' not found in any row") + + if current_row_idx == 0: + raise ValueError("Button is already in the top row") + + # Удаляем из текущей строки + rows[current_row_idx]["buttons"].remove(button_id) + + # Добавляем в строку выше + rows[current_row_idx - 1]["buttons"].append(button_id) + + # Удаляем пустые строки + config["rows"] = [row for row in rows if row.get("buttons")] + + await cls.save_config(db, config) + return {"button_id": button_id, "new_row_index": current_row_idx - 1} + + @classmethod + async def move_button_down(cls, db: AsyncSession, button_id: str) -> Dict[str, Any]: + """Переместить кнопку на строку ниже.""" + config = await cls.get_config(db) + config = config.copy() + rows = config.get("rows", []) + + current_row_idx = cls._find_button_row(config, button_id) + if current_row_idx is None: + raise KeyError(f"Button '{button_id}' not found in any row") + + if current_row_idx >= len(rows) - 1: + raise ValueError("Button is already in the bottom row") + + # Удаляем из текущей строки + rows[current_row_idx]["buttons"].remove(button_id) + + # Добавляем в строку ниже + rows[current_row_idx + 1]["buttons"].append(button_id) + + # Удаляем пустые строки + config["rows"] = [row for row in rows if row.get("buttons")] + + await cls.save_config(db, config) + return {"button_id": button_id, "new_row_index": current_row_idx + 1} + + @classmethod + async def move_button_to_row( + cls, + db: AsyncSession, + button_id: str, + target_row_id: str, + position: Optional[int] = None, + ) -> Dict[str, Any]: + """Переместить кнопку в указанную строку.""" + config = await cls.get_config(db) + config = config.copy() + rows = config.get("rows", []) + + # Находим целевую строку + target_row_idx = None + for i, row in enumerate(rows): + if row["id"] == target_row_id: + target_row_idx = i + break + + if target_row_idx is None: + raise KeyError(f"Row '{target_row_id}' not found") + + # Удаляем кнопку из текущей строки + current_row_idx = cls._find_button_row(config, button_id) + if current_row_idx is not None: + rows[current_row_idx]["buttons"].remove(button_id) + + # Добавляем в целевую строку + target_buttons = rows[target_row_idx]["buttons"] + if position is not None and 0 <= position <= len(target_buttons): + target_buttons.insert(position, button_id) + else: + target_buttons.append(button_id) + + # Удаляем пустые строки + config["rows"] = [row for row in rows if row.get("buttons")] + + await cls.save_config(db, config) + return {"button_id": button_id, "target_row_id": target_row_id, "position": position} + + @classmethod + async def reorder_buttons_in_row( + cls, + db: AsyncSession, + row_id: str, + ordered_button_ids: List[str], + ) -> Dict[str, Any]: + """Изменить порядок кнопок внутри строки.""" + config = await cls.get_config(db) + config = config.copy() + rows = config.get("rows", []) + + # Находим строку + target_row = None + for row in rows: + if row["id"] == row_id: + target_row = row + break + + if target_row is None: + raise KeyError(f"Row '{row_id}' not found") + + # Проверяем что все кнопки принадлежат строке + current_buttons = set(target_row["buttons"]) + ordered_buttons = set(ordered_button_ids) + + if current_buttons != ordered_buttons: + missing = current_buttons - ordered_buttons + extra = ordered_buttons - current_buttons + errors = [] + if missing: + errors.append(f"missing: {missing}") + if extra: + errors.append(f"extra: {extra}") + raise ValueError(f"Button mismatch: {', '.join(errors)}") + + target_row["buttons"] = ordered_button_ids + + await cls.save_config(db, config) + return {"row_id": row_id, "buttons": ordered_button_ids} + + @classmethod + async def swap_buttons( + cls, + db: AsyncSession, + button_id_1: str, + button_id_2: str, + ) -> Dict[str, Any]: + """Поменять две кнопки местами.""" + config = await cls.get_config(db) + config = config.copy() + rows = config.get("rows", []) + + # Находим позиции обеих кнопок + pos1 = None + pos2 = None + + for row_idx, row in enumerate(rows): + buttons = row.get("buttons", []) + for btn_idx, btn_id in enumerate(buttons): + if btn_id == button_id_1: + pos1 = (row_idx, btn_idx) + elif btn_id == button_id_2: + pos2 = (row_idx, btn_idx) + + if pos1 is None: + raise KeyError(f"Button '{button_id_1}' not found") + if pos2 is None: + raise KeyError(f"Button '{button_id_2}' not found") + + # Меняем местами + rows[pos1[0]]["buttons"][pos1[1]] = button_id_2 + rows[pos2[0]]["buttons"][pos2[1]] = button_id_1 + + await cls.save_config(db, config) + return { + "button_1": {"id": button_id_1, "new_row": pos2[0], "new_position": pos2[1]}, + "button_2": {"id": button_id_2, "new_row": pos1[0], "new_position": pos1[1]}, + } + + # --- Проверка условий --- + + @classmethod + def _evaluate_conditions( + cls, + conditions: Optional[Dict[str, Any]], + context: MenuContext, + ) -> bool: + """Проверить условия показа.""" + if not conditions: + return True + + # has_active_subscription + if conditions.get("has_active_subscription") is True: + if not context.has_active_subscription: + return False + + # subscription_is_active + if conditions.get("subscription_is_active") is True: + if not context.subscription_is_active: + return False + + # has_traffic_limit - подписка с лимитом трафика + if conditions.get("has_traffic_limit") is True: + if not context.subscription: + return False + traffic_limit = getattr(context.subscription, "traffic_limit_gb", 0) + is_trial = getattr(context.subscription, "is_trial", False) + if is_trial or traffic_limit <= 0: + return False + + # is_admin + if conditions.get("is_admin") is True: + if not context.is_admin: + return False + + # is_moderator + if conditions.get("is_moderator") is True: + if context.is_admin: # Админ не показывает кнопку модератора + return False + if not context.is_moderator: + return False + + # referral_enabled + if conditions.get("referral_enabled") is True: + if not settings.is_referral_program_enabled(): + return False + + # contests_visible + if conditions.get("contests_visible") is True: + if not settings.CONTESTS_BUTTON_VISIBLE: + return False + + # support_enabled + if conditions.get("support_enabled") is True: + try: + from app.services.support_settings_service import SupportSettingsService + if not SupportSettingsService.is_support_menu_enabled(): + return False + except Exception: + if not settings.SUPPORT_MENU_ENABLED: + return False + + # language_selection_enabled + if conditions.get("language_selection_enabled") is True: + if not settings.is_language_selection_enabled(): + return False + + # happ_enabled + if conditions.get("happ_enabled") is True: + if not settings.is_happ_download_button_enabled(): + return False + + # simple_subscription_enabled + if conditions.get("simple_subscription_enabled") is True: + if not settings.SIMPLE_SUBSCRIPTION_ENABLED: + return False + + # show_trial + if conditions.get("show_trial") is True: + if context.has_had_paid_subscription or context.has_active_subscription: + return False + + # show_buy + if conditions.get("show_buy") is True: + if context.has_active_subscription and context.subscription_is_active: + return False + + # has_saved_cart + if conditions.get("has_saved_cart") is True: + if not context.has_saved_cart and not context.show_resume_checkout: + return False + + # --- Расширенные условия --- + + # min_balance_kopeks + min_balance = conditions.get("min_balance_kopeks") + if min_balance is not None: + if context.balance_kopeks < min_balance: + return False + + # max_balance_kopeks + max_balance = conditions.get("max_balance_kopeks") + if max_balance is not None: + if context.balance_kopeks > max_balance: + return False + + # min_registration_days + min_reg_days = conditions.get("min_registration_days") + if min_reg_days is not None: + if context.registration_days < min_reg_days: + return False + + # max_registration_days + max_reg_days = conditions.get("max_registration_days") + if max_reg_days is not None: + if context.registration_days > max_reg_days: + return False + + # min_referrals + min_refs = conditions.get("min_referrals") + if min_refs is not None: + if context.referral_count < min_refs: + return False + + # has_referrals + if conditions.get("has_referrals") is True: + if context.referral_count <= 0: + return False + + # promo_group_ids - пользователь должен быть в одной из указанных групп + promo_groups = conditions.get("promo_group_ids") + if promo_groups and isinstance(promo_groups, list): + if context.promo_group_id not in promo_groups: + return False + + # exclude_promo_group_ids - исключить пользователей из этих групп + exclude_groups = conditions.get("exclude_promo_group_ids") + if exclude_groups and isinstance(exclude_groups, list): + if context.promo_group_id in exclude_groups: + return False + + # has_subscription_days_left - минимум дней до окончания + min_sub_days = conditions.get("has_subscription_days_left") + if min_sub_days is not None: + if context.subscription_days_left < min_sub_days: + return False + + # max_subscription_days_left + max_sub_days = conditions.get("max_subscription_days_left") + if max_sub_days is not None: + if context.subscription_days_left > max_sub_days: + return False + + # is_trial_user + if conditions.get("is_trial_user") is True: + if not context.subscription: + return False + is_trial = getattr(context.subscription, "is_trial", False) + if not is_trial: + return False + + # has_autopay + if conditions.get("has_autopay") is True: + if not context.has_autopay: + return False + + return True + + @classmethod + def _check_visibility( + cls, + visibility: str, + context: MenuContext, + ) -> bool: + """Проверить видимость кнопки.""" + if visibility == "all": + return True + if visibility == "admins": + return context.is_admin + if visibility == "moderators": + return context.is_moderator and not context.is_admin + if visibility == "subscribers": + return context.has_active_subscription and context.subscription_is_active + return True + + # --- Форматирование текста --- + + @classmethod + def _get_localized_text( + cls, + text_config: Dict[str, str], + language: str, + fallback_language: str = "en", + ) -> str: + """Получить локализованный текст.""" + # Пробуем запрошенный язык + if language in text_config: + return text_config[language] + # Пробуем fallback + if fallback_language in text_config: + return text_config[fallback_language] + # Возвращаем первый доступный + if text_config: + return next(iter(text_config.values())) + return "" + + @classmethod + def _format_dynamic_text( + cls, + text: str, + context: MenuContext, + texts: Any, + ) -> str: + """Форматировать динамический текст с плейсхолдерами.""" + # Баланс + if "{balance}" in text: + formatted_balance = texts.format_price(context.balance_kopeks) + text = text.replace("{balance}", formatted_balance) + + # Имя пользователя + if "{username}" in text: + text = text.replace("{username}", context.username or "User") + + # Дней до окончания подписки + if "{subscription_days}" in text: + text = text.replace("{subscription_days}", str(context.subscription_days_left)) + + # Использованный трафик + if "{traffic_used}" in text: + traffic = f"{context.traffic_used_gb:.1f} GB" + text = text.replace("{traffic_used}", traffic) + + # Оставшийся трафик + if "{traffic_left}" in text: + traffic = f"{context.traffic_left_gb:.1f} GB" + text = text.replace("{traffic_left}", traffic) + + # Количество рефералов + if "{referral_count}" in text: + text = text.replace("{referral_count}", str(context.referral_count)) + + # Заработок с рефералов + if "{referral_earnings}" in text: + formatted_earnings = texts.format_price(context.referral_earnings_kopeks) + text = text.replace("{referral_earnings}", formatted_earnings) + + return text + + # --- Построение кнопок --- + + @classmethod + def _build_button( + cls, + button_config: Dict[str, Any], + context: MenuContext, + texts: Any, + ) -> Optional[InlineKeyboardButton]: + """Построить кнопку из конфигурации.""" + button_type = button_config.get("type", "builtin") + text_config = button_config.get("text", {}) + action = button_config.get("action", "") + open_mode = button_config.get("open_mode", "callback") + webapp_url = button_config.get("webapp_url") + icon = button_config.get("icon", "") + + # Получаем текст + text = cls._get_localized_text(text_config, context.language) + if not text: + return None + + # Добавляем иконку если есть и текст не начинается с неё + if icon and not text.startswith(icon): + text = f"{icon} {text}" + + # Форматируем динамический текст + if button_config.get("dynamic_text"): + text = cls._format_dynamic_text(text, context, texts) + + # Строим кнопку в зависимости от типа + if button_type == "url": + return InlineKeyboardButton(text=text, url=action) + elif button_type == "mini_app": + return InlineKeyboardButton( + text=text, web_app=types.WebAppInfo(url=action) + ) + elif button_type == "callback": + # Кастомная кнопка с callback_data + return InlineKeyboardButton(text=text, callback_data=action) + else: + # builtin - проверяем open_mode + if open_mode == "direct" and webapp_url: + # Прямое открытие Mini App через WebAppInfo + return InlineKeyboardButton( + text=text, web_app=types.WebAppInfo(url=webapp_url) + ) + else: + # Стандартный callback_data + return InlineKeyboardButton(text=text, callback_data=action) + + # --- Построение клавиатуры --- + + @classmethod + async def build_keyboard( + cls, + db: AsyncSession, + context: MenuContext, + ) -> InlineKeyboardMarkup: + """Построить клавиатуру меню на основе конфигурации.""" + config = await cls.get_config(db) + texts = get_texts(context.language) + + keyboard_rows: List[List[InlineKeyboardButton]] = [] + rows_config = config.get("rows", []) + buttons_config = config.get("buttons", {}) + + for row_config in rows_config: + # Проверяем условия строки + row_conditions = row_config.get("conditions") + if not cls._evaluate_conditions(row_conditions, context): + continue + + row_buttons: List[InlineKeyboardButton] = [] + max_per_row = row_config.get("max_per_row", 2) + + for button_id in row_config.get("buttons", []): + if button_id not in buttons_config: + continue + + button_cfg = buttons_config[button_id] + + # Проверяем включена ли кнопка + if not button_cfg.get("enabled", True): + continue + + # Проверяем видимость + visibility = button_cfg.get("visibility", "all") + if not cls._check_visibility(visibility, context): + continue + + # Проверяем условия кнопки + button_conditions = button_cfg.get("conditions") + if not cls._evaluate_conditions(button_conditions, context): + continue + + # Строим кнопку + button = cls._build_button(button_cfg, context, texts) + if button: + row_buttons.append(button) + + # Добавляем кнопки с учетом max_per_row + if row_buttons: + for i in range(0, len(row_buttons), max_per_row): + keyboard_rows.append(row_buttons[i : i + max_per_row]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + + @classmethod + async def preview_keyboard( + cls, + db: AsyncSession, + context: MenuContext, + ) -> List[Dict[str, Any]]: + """Предпросмотр меню (возвращает структуру для API).""" + config = await cls.get_config(db) + texts = get_texts(context.language) + + preview_rows: List[Dict[str, Any]] = [] + rows_config = config.get("rows", []) + buttons_config = config.get("buttons", {}) + + for row_config in rows_config: + row_conditions = row_config.get("conditions") + if not cls._evaluate_conditions(row_conditions, context): + continue + + row_buttons: List[Dict[str, Any]] = [] + max_per_row = row_config.get("max_per_row", 2) + + for button_id in row_config.get("buttons", []): + if button_id not in buttons_config: + continue + + button_cfg = buttons_config[button_id] + + if not button_cfg.get("enabled", True): + continue + + visibility = button_cfg.get("visibility", "all") + if not cls._check_visibility(visibility, context): + continue + + button_conditions = button_cfg.get("conditions") + if not cls._evaluate_conditions(button_conditions, context): + continue + + text_config = button_cfg.get("text", {}) + text = cls._get_localized_text(text_config, context.language) + + if button_cfg.get("dynamic_text"): + text = cls._format_dynamic_text(text, context, texts) + + row_buttons.append({ + "text": text, + "action": button_cfg.get("action", ""), + "type": button_cfg.get("type", "builtin"), + }) + + if row_buttons: + for i in range(0, len(row_buttons), max_per_row): + preview_rows.append({"buttons": row_buttons[i : i + max_per_row]}) + + return preview_rows + + # --- История изменений (делегируем в MenuLayoutHistoryService) --- + + @classmethod + async def save_history( + cls, + db: AsyncSession, + config: Dict[str, Any], + action: str, + changes_summary: Optional[str] = None, + user_info: Optional[str] = None, + ): + """Сохранить запись в историю изменений.""" + return await MenuLayoutHistoryService.save_history( + db, config, action, changes_summary, user_info + ) + + @classmethod + async def get_history( + cls, + db: AsyncSession, + limit: int = 50, + offset: int = 0, + ) -> List[Dict[str, Any]]: + """Получить историю изменений.""" + return await MenuLayoutHistoryService.get_history(db, limit, offset) + + @classmethod + async def get_history_count(cls, db: AsyncSession) -> int: + """Получить общее количество записей истории.""" + return await MenuLayoutHistoryService.get_history_count(db) + + @classmethod + async def get_history_entry( + cls, + db: AsyncSession, + history_id: int, + ) -> Optional[Dict[str, Any]]: + """Получить конкретную запись истории с конфигурацией.""" + return await MenuLayoutHistoryService.get_history_entry(db, history_id) + + @classmethod + async def rollback_to_history( + cls, + db: AsyncSession, + history_id: int, + user_info: Optional[str] = None, + ) -> Dict[str, Any]: + """Откатить конфигурацию к записи из истории.""" + return await MenuLayoutHistoryService.rollback_to_history( + db, history_id, cls.get_config, cls.save_config, user_info + ) + + # --- Статистика кликов (делегируем в MenuLayoutStatsService) --- + + @classmethod + async def log_button_click( + cls, + db: AsyncSession, + button_id: str, + user_id: Optional[int] = None, + callback_data: Optional[str] = None, + button_type: Optional[str] = None, + button_text: Optional[str] = None, + ): + """Записать клик по кнопке.""" + return await MenuLayoutStatsService.log_button_click( + db, button_id, user_id, callback_data, button_type, button_text + ) + + @classmethod + async def get_button_stats( + cls, + db: AsyncSession, + button_id: str, + days: int = 30, + ) -> Dict[str, Any]: + """Получить статистику кликов по конкретной кнопке.""" + return await MenuLayoutStatsService.get_button_stats(db, button_id, days) + + @classmethod + async def get_button_clicks_by_day( + cls, + db: AsyncSession, + button_id: str, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Получить статистику кликов по дням.""" + return await MenuLayoutStatsService.get_button_clicks_by_day(db, button_id, days) + + @classmethod + async def get_all_buttons_stats( + cls, + db: AsyncSession, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Получить статистику по всем кнопкам.""" + return await MenuLayoutStatsService.get_all_buttons_stats(db, days) + + @classmethod + async def get_total_clicks( + cls, + db: AsyncSession, + days: int = 30, + ) -> int: + """Получить общее количество кликов за период.""" + return await MenuLayoutStatsService.get_total_clicks(db, days) diff --git a/app/services/menu_layout/stats_service.py b/app/services/menu_layout/stats_service.py new file mode 100644 index 00000000..dd0f0159 --- /dev/null +++ b/app/services/menu_layout/stats_service.py @@ -0,0 +1,188 @@ +"""Сервис статистики кликов по кнопкам меню.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +from sqlalchemy import select, func, and_, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import ButtonClickLog + + +class MenuLayoutStatsService: + """Сервис для сбора и анализа статистики кликов по кнопкам.""" + + @classmethod + async def log_button_click( + cls, + db: AsyncSession, + button_id: str, + user_id: Optional[int] = None, + callback_data: Optional[str] = None, + button_type: Optional[str] = None, + button_text: Optional[str] = None, + ) -> ButtonClickLog: + """Записать клик по кнопке.""" + click_log = ButtonClickLog( + button_id=button_id, + user_id=user_id, + callback_data=callback_data, + button_type=button_type, + button_text=button_text, + ) + db.add(click_log) + await db.commit() + return click_log + + @classmethod + async def get_button_stats( + cls, + db: AsyncSession, + button_id: str, + days: int = 30, + ) -> Dict[str, Any]: + """Получить статистику кликов по конкретной кнопке.""" + now = datetime.now() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=days) + + # Общее количество кликов + total_result = await db.execute( + select(func.count(ButtonClickLog.id)) + .where(ButtonClickLog.button_id == button_id) + ) + clicks_total = total_result.scalar() or 0 + + # Клики сегодня + today_result = await db.execute( + select(func.count(ButtonClickLog.id)) + .where(and_( + ButtonClickLog.button_id == button_id, + ButtonClickLog.clicked_at >= today_start + )) + ) + clicks_today = today_result.scalar() or 0 + + # Клики за неделю + week_result = await db.execute( + select(func.count(ButtonClickLog.id)) + .where(and_( + ButtonClickLog.button_id == button_id, + ButtonClickLog.clicked_at >= week_ago + )) + ) + clicks_week = week_result.scalar() or 0 + + # Клики за месяц + month_result = await db.execute( + select(func.count(ButtonClickLog.id)) + .where(and_( + ButtonClickLog.button_id == button_id, + ButtonClickLog.clicked_at >= month_ago + )) + ) + clicks_month = month_result.scalar() or 0 + + # Уникальные пользователи + unique_result = await db.execute( + select(func.count(func.distinct(ButtonClickLog.user_id))) + .where(ButtonClickLog.button_id == button_id) + ) + unique_users = unique_result.scalar() or 0 + + # Последний клик + last_click_result = await db.execute( + select(ButtonClickLog.clicked_at) + .where(ButtonClickLog.button_id == button_id) + .order_by(desc(ButtonClickLog.clicked_at)) + .limit(1) + ) + last_click = last_click_result.scalar_one_or_none() + + return { + "button_id": button_id, + "clicks_total": clicks_total, + "clicks_today": clicks_today, + "clicks_week": clicks_week, + "clicks_month": clicks_month, + "unique_users": unique_users, + "last_click_at": last_click, + } + + @classmethod + async def get_button_clicks_by_day( + cls, + db: AsyncSession, + button_id: str, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Получить статистику кликов по дням.""" + start_date = datetime.now() - timedelta(days=days) + + # Группировка по дате + result = await db.execute( + select( + func.date(ButtonClickLog.clicked_at).label("date"), + func.count(ButtonClickLog.id).label("count") + ) + .where(and_( + ButtonClickLog.button_id == button_id, + ButtonClickLog.clicked_at >= start_date + )) + .group_by(func.date(ButtonClickLog.clicked_at)) + .order_by(func.date(ButtonClickLog.clicked_at)) + ) + + return [ + {"date": str(row.date), "count": row.count} + for row in result.all() + ] + + @classmethod + async def get_all_buttons_stats( + cls, + db: AsyncSession, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Получить статистику по всем кнопкам.""" + start_date = datetime.now() - timedelta(days=days) + + result = await db.execute( + select( + ButtonClickLog.button_id, + func.count(ButtonClickLog.id).label("clicks_total"), + func.count(func.distinct(ButtonClickLog.user_id)).label("unique_users"), + func.max(ButtonClickLog.clicked_at).label("last_click_at") + ) + .where(ButtonClickLog.clicked_at >= start_date) + .group_by(ButtonClickLog.button_id) + .order_by(desc(func.count(ButtonClickLog.id))) + ) + + return [ + { + "button_id": row.button_id, + "clicks_total": row.clicks_total, + "unique_users": row.unique_users, + "last_click_at": row.last_click_at, + } + for row in result.all() + ] + + @classmethod + async def get_total_clicks( + cls, + db: AsyncSession, + days: int = 30, + ) -> int: + """Получить общее количество кликов за период.""" + start_date = datetime.now() - timedelta(days=days) + + result = await db.execute( + select(func.count(ButtonClickLog.id)) + .where(ButtonClickLog.clicked_at >= start_date) + ) + return result.scalar() or 0 diff --git a/app/services/menu_layout_service.py b/app/services/menu_layout_service.py index 50886b8c..90ed0584 100644 --- a/app/services/menu_layout_service.py +++ b/app/services/menu_layout_service.py @@ -1,1167 +1,40 @@ -"""Сервис конструктора меню - управление конфигурацией через API.""" +""" +Сервис конструктора меню - управление конфигурацией через API. -from __future__ import annotations +ВНИМАНИЕ: Этот файл оставлен для обратной совместимости. +Фактическая реализация находится в app/services/menu_layout/ -import asyncio -import json -import logging -from dataclasses import dataclass, field -from datetime import datetime -from typing import Any, Dict, List, Optional +Структура модуля: +- app/services/menu_layout/constants.py - константы +- app/services/menu_layout/context.py - MenuContext +- app/services/menu_layout/history_service.py - история изменений +- app/services/menu_layout/stats_service.py - статистика кликов +- app/services/menu_layout/service.py - основной сервис +""" -from aiogram import types -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession +# Реэкспорт для обратной совместимости +from app.services.menu_layout import ( + # Константы + MENU_LAYOUT_CONFIG_KEY, + DEFAULT_MENU_CONFIG, + BUILTIN_BUTTONS_INFO, + AVAILABLE_CALLBACKS, + DYNAMIC_PLACEHOLDERS, + # Классы + MenuContext, + MenuLayoutService, + MenuLayoutHistoryService, + MenuLayoutStatsService, +) -from app.config import settings -from app.database.models import SystemSetting -from app.database.crud.system_setting import upsert_system_setting -from app.localization.texts import get_texts - -logger = logging.getLogger(__name__) - -# Ключ для хранения конфигурации в SystemSetting -MENU_LAYOUT_CONFIG_KEY = "menu_layout_config" - - -@dataclass -class MenuContext: - """Контекст пользователя для построения меню.""" - - language: str = "ru" - is_admin: bool = False - is_moderator: bool = False - has_active_subscription: bool = False - subscription_is_active: bool = False - has_had_paid_subscription: bool = False - balance_kopeks: int = 0 - subscription: Optional[Any] = None - show_resume_checkout: bool = False - has_saved_cart: bool = False - custom_buttons: List[InlineKeyboardButton] = field(default_factory=list) - - -# Дефолтная конфигурация меню -DEFAULT_MENU_CONFIG: Dict[str, Any] = { - "version": 1, - "rows": [ - { - "id": "connect_row", - "buttons": ["connect"], - "conditions": {"has_active_subscription": True, "subscription_is_active": True}, - "max_per_row": 1, - }, - { - "id": "happ_row", - "buttons": ["happ_download"], - "conditions": {"has_active_subscription": True, "happ_enabled": True}, - "max_per_row": 1, - }, - { - "id": "subscription_traffic_row", - "buttons": ["subscription", "buy_traffic"], - "conditions": {"has_active_subscription": True}, - "max_per_row": 2, - }, - { - "id": "balance_row", - "buttons": ["balance"], - "conditions": None, - "max_per_row": 1, - }, - { - "id": "trial_buy_row", - "buttons": ["trial", "buy_subscription"], - "conditions": None, - "max_per_row": 2, - }, - { - "id": "simple_subscription_row", - "buttons": ["simple_subscription"], - "conditions": {"simple_subscription_enabled": True}, - "max_per_row": 1, - }, - { - "id": "resume_row", - "buttons": ["resume_checkout"], - "conditions": {"has_saved_cart": True}, - "max_per_row": 1, - }, - { - "id": "promo_referral_row", - "buttons": ["promocode", "referrals"], - "conditions": None, - "max_per_row": 2, - }, - { - "id": "contests_row", - "buttons": ["contests"], - "conditions": {"contests_visible": True}, - "max_per_row": 2, - }, - { - "id": "support_info_row", - "buttons": ["support", "info"], - "conditions": None, - "max_per_row": 2, - }, - { - "id": "language_row", - "buttons": ["language"], - "conditions": {"language_selection_enabled": True}, - "max_per_row": 2, - }, - { - "id": "admin_row", - "buttons": ["admin_panel"], - "conditions": {"is_admin": True}, - "max_per_row": 1, - }, - { - "id": "moderator_row", - "buttons": ["moderator_panel"], - "conditions": {"is_moderator": True}, - "max_per_row": 1, - }, - ], - "buttons": { - "connect": { - "type": "builtin", - "builtin_id": "connect", - "text": {"ru": "🔗 Подключиться", "en": "🔗 Connect"}, - "action": "subscription_connect", - "enabled": True, - "visibility": "subscribers", - "conditions": None, - "dynamic_text": False, - "open_mode": "callback", # "callback" или "direct" - "webapp_url": None, # URL для Mini App при open_mode="direct" - }, - "happ_download": { - "type": "builtin", - "builtin_id": "happ_download", - "text": {"ru": "⬇️ Скачать Happ", "en": "⬇️ Download Happ"}, - "action": "subscription_happ_download", - "enabled": True, - "visibility": "subscribers", - "conditions": None, - "dynamic_text": False, - }, - "subscription": { - "type": "builtin", - "builtin_id": "subscription", - "text": {"ru": "📊 Подписка", "en": "📊 Subscription"}, - "action": "menu_subscription", - "enabled": True, - "visibility": "subscribers", - "conditions": None, - "dynamic_text": False, - }, - "buy_traffic": { - "type": "builtin", - "builtin_id": "buy_traffic", - "text": {"ru": "📈 Докупить трафик", "en": "📈 Buy traffic"}, - "action": "buy_traffic", - "enabled": True, - "visibility": "subscribers", - "conditions": {"has_traffic_limit": True}, - "dynamic_text": False, - }, - "balance": { - "type": "builtin", - "builtin_id": "balance", - "text": {"ru": "💰 Баланс: {balance}", "en": "💰 Balance: {balance}"}, - "action": "menu_balance", - "enabled": True, - "visibility": "all", - "conditions": None, - "dynamic_text": True, - }, - "trial": { - "type": "builtin", - "builtin_id": "trial", - "text": {"ru": "🎁 Пробный период", "en": "🎁 Free trial"}, - "action": "menu_trial", - "enabled": True, - "visibility": "all", - "conditions": {"show_trial": True}, - "dynamic_text": False, - }, - "buy_subscription": { - "type": "builtin", - "builtin_id": "buy_subscription", - "text": {"ru": "🛒 Купить подписку", "en": "🛒 Buy subscription"}, - "action": "menu_buy", - "enabled": True, - "visibility": "all", - "conditions": {"show_buy": True}, - "dynamic_text": False, - }, - "simple_subscription": { - "type": "builtin", - "builtin_id": "simple_subscription", - "text": {"ru": "💳 Простая подписка", "en": "💳 Simple subscription"}, - "action": "simple_subscription_purchase", - "enabled": True, - "visibility": "all", - "conditions": None, - "dynamic_text": False, - }, - "resume_checkout": { - "type": "builtin", - "builtin_id": "resume_checkout", - "text": {"ru": "↩️ Вернуться к оформлению", "en": "↩️ Resume checkout"}, - "action": "return_to_saved_cart", - "enabled": True, - "visibility": "all", - "conditions": None, - "dynamic_text": False, - }, - "promocode": { - "type": "builtin", - "builtin_id": "promocode", - "text": {"ru": "🎟️ Промокод", "en": "🎟️ Promo code"}, - "action": "menu_promocode", - "enabled": True, - "visibility": "all", - "conditions": None, - "dynamic_text": False, - }, - "referrals": { - "type": "builtin", - "builtin_id": "referrals", - "text": {"ru": "👥 Рефералы", "en": "👥 Referrals"}, - "action": "menu_referrals", - "enabled": True, - "visibility": "all", - "conditions": {"referral_enabled": True}, - "dynamic_text": False, - }, - "contests": { - "type": "builtin", - "builtin_id": "contests", - "text": {"ru": "🎲 Конкурсы", "en": "🎲 Contests"}, - "action": "contests_menu", - "enabled": True, - "visibility": "all", - "conditions": None, - "dynamic_text": False, - }, - "support": { - "type": "builtin", - "builtin_id": "support", - "text": {"ru": "💬 Поддержка", "en": "💬 Support"}, - "action": "menu_support", - "enabled": True, - "visibility": "all", - "conditions": {"support_enabled": True}, - "dynamic_text": False, - }, - "info": { - "type": "builtin", - "builtin_id": "info", - "text": {"ru": "ℹ️ Инфо", "en": "ℹ️ Info"}, - "action": "menu_info", - "enabled": True, - "visibility": "all", - "conditions": None, - "dynamic_text": False, - }, - "language": { - "type": "builtin", - "builtin_id": "language", - "text": {"ru": "🌐 Язык", "en": "🌐 Language"}, - "action": "menu_language", - "enabled": True, - "visibility": "all", - "conditions": None, - "dynamic_text": False, - }, - "admin_panel": { - "type": "builtin", - "builtin_id": "admin_panel", - "text": {"ru": "⚙️ Админ панель", "en": "⚙️ Admin panel"}, - "action": "admin_panel", - "enabled": True, - "visibility": "admins", - "conditions": None, - "dynamic_text": False, - }, - "moderator_panel": { - "type": "builtin", - "builtin_id": "moderator_panel", - "text": {"ru": "🧑‍⚖️ Модерация", "en": "🧑‍⚖️ Moderation"}, - "action": "moderator_panel", - "enabled": True, - "visibility": "moderators", - "conditions": None, - "dynamic_text": False, - }, - }, -} - - -# Информация о встроенных кнопках для API -BUILTIN_BUTTONS_INFO = [ - { - "id": "connect", - "default_text": {"ru": "🔗 Подключиться", "en": "🔗 Connect"}, - "callback_data": "subscription_connect", - "default_conditions": {"has_active_subscription": True, "subscription_is_active": True}, - "supports_dynamic_text": False, - "supports_direct_open": True, # Может открывать Mini App напрямую - }, - { - "id": "happ_download", - "default_text": {"ru": "⬇️ Скачать Happ", "en": "⬇️ Download Happ"}, - "callback_data": "subscription_happ_download", - "default_conditions": {"happ_enabled": True}, - "supports_dynamic_text": False, - }, - { - "id": "subscription", - "default_text": {"ru": "📊 Подписка", "en": "📊 Subscription"}, - "callback_data": "menu_subscription", - "default_conditions": {"has_active_subscription": True}, - "supports_dynamic_text": False, - }, - { - "id": "buy_traffic", - "default_text": {"ru": "📈 Докупить трафик", "en": "📈 Buy traffic"}, - "callback_data": "buy_traffic", - "default_conditions": {"has_traffic_limit": True}, - "supports_dynamic_text": False, - }, - { - "id": "balance", - "default_text": {"ru": "💰 Баланс: {balance}", "en": "💰 Balance: {balance}"}, - "callback_data": "menu_balance", - "default_conditions": None, - "supports_dynamic_text": True, - }, - { - "id": "trial", - "default_text": {"ru": "🎁 Пробный период", "en": "🎁 Free trial"}, - "callback_data": "menu_trial", - "default_conditions": {"show_trial": True}, - "supports_dynamic_text": False, - }, - { - "id": "buy_subscription", - "default_text": {"ru": "🛒 Купить подписку", "en": "🛒 Buy subscription"}, - "callback_data": "menu_buy", - "default_conditions": {"show_buy": True}, - "supports_dynamic_text": False, - }, - { - "id": "simple_subscription", - "default_text": {"ru": "💳 Простая подписка", "en": "💳 Simple subscription"}, - "callback_data": "simple_subscription_purchase", - "default_conditions": {"simple_subscription_enabled": True}, - "supports_dynamic_text": False, - }, - { - "id": "resume_checkout", - "default_text": {"ru": "↩️ Вернуться к оформлению", "en": "↩️ Resume checkout"}, - "callback_data": "return_to_saved_cart", - "default_conditions": {"has_saved_cart": True}, - "supports_dynamic_text": False, - }, - { - "id": "promocode", - "default_text": {"ru": "🎟️ Промокод", "en": "🎟️ Promo code"}, - "callback_data": "menu_promocode", - "default_conditions": None, - "supports_dynamic_text": False, - }, - { - "id": "referrals", - "default_text": {"ru": "👥 Рефералы", "en": "👥 Referrals"}, - "callback_data": "menu_referrals", - "default_conditions": {"referral_enabled": True}, - "supports_dynamic_text": False, - }, - { - "id": "contests", - "default_text": {"ru": "🎲 Конкурсы", "en": "🎲 Contests"}, - "callback_data": "contests_menu", - "default_conditions": {"contests_visible": True}, - "supports_dynamic_text": False, - }, - { - "id": "support", - "default_text": {"ru": "💬 Поддержка", "en": "💬 Support"}, - "callback_data": "menu_support", - "default_conditions": {"support_enabled": True}, - "supports_dynamic_text": False, - }, - { - "id": "info", - "default_text": {"ru": "ℹ️ Инфо", "en": "ℹ️ Info"}, - "callback_data": "menu_info", - "default_conditions": None, - "supports_dynamic_text": False, - }, - { - "id": "language", - "default_text": {"ru": "🌐 Язык", "en": "🌐 Language"}, - "callback_data": "menu_language", - "default_conditions": {"language_selection_enabled": True}, - "supports_dynamic_text": False, - }, - { - "id": "admin_panel", - "default_text": {"ru": "⚙️ Админ панель", "en": "⚙️ Admin panel"}, - "callback_data": "admin_panel", - "default_conditions": {"is_admin": True}, - "supports_dynamic_text": False, - }, - { - "id": "moderator_panel", - "default_text": {"ru": "🧑‍⚖️ Модерация", "en": "🧑‍⚖️ Moderation"}, - "callback_data": "moderator_panel", - "default_conditions": {"is_moderator": True}, - "supports_dynamic_text": False, - }, +__all__ = [ + "MENU_LAYOUT_CONFIG_KEY", + "DEFAULT_MENU_CONFIG", + "BUILTIN_BUTTONS_INFO", + "AVAILABLE_CALLBACKS", + "DYNAMIC_PLACEHOLDERS", + "MenuContext", + "MenuLayoutService", + "MenuLayoutHistoryService", + "MenuLayoutStatsService", ] - - -class MenuLayoutService: - """Сервис для управления конфигурацией меню.""" - - _cache: Optional[Dict[str, Any]] = None - _cache_updated_at: Optional[datetime] = None - _lock: asyncio.Lock = asyncio.Lock() - - @classmethod - def invalidate_cache(cls) -> None: - """Инвалидировать кеш конфигурации.""" - cls._cache = None - cls._cache_updated_at = None - - @classmethod - def get_default_config(cls) -> Dict[str, Any]: - """Получить дефолтную конфигурацию.""" - return DEFAULT_MENU_CONFIG.copy() - - @classmethod - def get_builtin_buttons_info(cls) -> List[Dict[str, Any]]: - """Получить информацию о встроенных кнопках.""" - return BUILTIN_BUTTONS_INFO.copy() - - @classmethod - async def get_config(cls, db: AsyncSession) -> Dict[str, Any]: - """Получить конфигурацию меню.""" - if cls._cache is not None: - return cls._cache - - async with cls._lock: - if cls._cache is not None: - return cls._cache - - result = await db.execute( - select(SystemSetting).where(SystemSetting.key == MENU_LAYOUT_CONFIG_KEY) - ) - setting = result.scalar_one_or_none() - - if setting and setting.value: - try: - cls._cache = json.loads(setting.value) - cls._cache_updated_at = setting.updated_at - except json.JSONDecodeError: - logger.warning("Invalid menu layout config JSON, using default") - cls._cache = cls.get_default_config() - cls._cache_updated_at = None - else: - cls._cache = cls.get_default_config() - cls._cache_updated_at = None - - return cls._cache - - @classmethod - async def get_config_updated_at(cls, db: AsyncSession) -> Optional[datetime]: - """Получить время последнего обновления конфигурации.""" - await cls.get_config(db) # Ensure cache is loaded - return cls._cache_updated_at - - @classmethod - async def save_config(cls, db: AsyncSession, config: Dict[str, Any]) -> None: - """Сохранить конфигурацию меню.""" - config_json = json.dumps(config, ensure_ascii=False, indent=2) - await upsert_system_setting( - db, - MENU_LAYOUT_CONFIG_KEY, - config_json, - description="Конфигурация конструктора меню", - ) - await db.commit() - cls.invalidate_cache() - - @classmethod - async def reset_to_default(cls, db: AsyncSession) -> Dict[str, Any]: - """Сбросить конфигурацию к дефолтной.""" - default_config = cls.get_default_config() - await cls.save_config(db, default_config) - return default_config - - @classmethod - async def update_button( - cls, - db: AsyncSession, - button_id: str, - updates: Dict[str, Any], - ) -> Dict[str, Any]: - """Обновить конфигурацию кнопки.""" - config = await cls.get_config(db) - config = config.copy() - buttons = config.get("buttons", {}) - - if button_id not in buttons: - raise KeyError(f"Button '{button_id}' not found") - - button = buttons[button_id].copy() - - # Применяем обновления - if "text" in updates and updates["text"] is not None: - button["text"] = updates["text"] - if "enabled" in updates and updates["enabled"] is not None: - button["enabled"] = updates["enabled"] - if "visibility" in updates and updates["visibility"] is not None: - button["visibility"] = updates["visibility"] - if "conditions" in updates: - button["conditions"] = updates["conditions"] - if "action" in updates and updates["action"] is not None: - # Только для URL/MiniApp кнопок - if button.get("type") in ("url", "mini_app"): - button["action"] = updates["action"] - - buttons[button_id] = button - config["buttons"] = buttons - - await cls.save_config(db, config) - return button - - @classmethod - async def reorder_rows( - cls, - db: AsyncSession, - ordered_ids: List[str], - ) -> List[Dict[str, Any]]: - """Изменить порядок строк.""" - config = await cls.get_config(db) - config = config.copy() - rows = config.get("rows", []) - - # Создаем словарь для быстрого поиска - rows_map = {row["id"]: row for row in rows} - - # Проверяем что все ID существуют - for row_id in ordered_ids: - if row_id not in rows_map: - raise KeyError(f"Row '{row_id}' not found") - - # Переупорядочиваем - new_rows = [rows_map[row_id] for row_id in ordered_ids] - - # Добавляем строки которые не были в списке (в конец) - for row in rows: - if row["id"] not in ordered_ids: - new_rows.append(row) - - config["rows"] = new_rows - await cls.save_config(db, config) - return new_rows - - @classmethod - async def add_row( - cls, - db: AsyncSession, - row_config: Dict[str, Any], - position: Optional[int] = None, - ) -> Dict[str, Any]: - """Добавить новую строку.""" - config = await cls.get_config(db) - config = config.copy() - rows = config.get("rows", []) - - # Проверяем уникальность ID - existing_ids = {row["id"] for row in rows} - if row_config["id"] in existing_ids: - raise ValueError(f"Row with id '{row_config['id']}' already exists") - - new_row = { - "id": row_config["id"], - "buttons": row_config.get("buttons", []), - "conditions": row_config.get("conditions"), - "max_per_row": row_config.get("max_per_row", 2), - } - - if position is not None and 0 <= position < len(rows): - rows.insert(position, new_row) - else: - rows.append(new_row) - - config["rows"] = rows - await cls.save_config(db, config) - return new_row - - @classmethod - async def delete_row(cls, db: AsyncSession, row_id: str) -> None: - """Удалить строку.""" - config = await cls.get_config(db) - config = config.copy() - rows = config.get("rows", []) - - new_rows = [row for row in rows if row["id"] != row_id] - if len(new_rows) == len(rows): - raise KeyError(f"Row '{row_id}' not found") - - config["rows"] = new_rows - await cls.save_config(db, config) - - @classmethod - async def add_custom_button( - cls, - db: AsyncSession, - button_id: str, - button_config: Dict[str, Any], - row_id: Optional[str] = None, - ) -> Dict[str, Any]: - """Добавить кастомную кнопку.""" - config = await cls.get_config(db) - config = config.copy() - buttons = config.get("buttons", {}) - - if button_id in buttons: - raise ValueError(f"Button with id '{button_id}' already exists") - - # Только URL и MiniApp кнопки могут быть добавлены - if button_config.get("type") not in ("url", "mini_app"): - raise ValueError("Only 'url' and 'mini_app' buttons can be added") - - buttons[button_id] = { - "type": button_config["type"], - "builtin_id": None, - "text": button_config["text"], - "action": button_config["action"], - "enabled": button_config.get("enabled", True), - "visibility": button_config.get("visibility", "all"), - "conditions": button_config.get("conditions"), - "dynamic_text": False, - } - - config["buttons"] = buttons - - # Добавляем в строку если указана - if row_id: - rows = config.get("rows", []) - for row in rows: - if row["id"] == row_id: - row["buttons"].append(button_id) - break - - await cls.save_config(db, config) - return buttons[button_id] - - @classmethod - async def delete_custom_button(cls, db: AsyncSession, button_id: str) -> None: - """Удалить кастомную кнопку.""" - config = await cls.get_config(db) - config = config.copy() - buttons = config.get("buttons", {}) - - if button_id not in buttons: - raise KeyError(f"Button '{button_id}' not found") - - # Нельзя удалять встроенные кнопки - if buttons[button_id].get("type") == "builtin": - raise ValueError("Cannot delete builtin buttons") - - del buttons[button_id] - config["buttons"] = buttons - - # Удаляем из всех строк - rows = config.get("rows", []) - for row in rows: - if button_id in row.get("buttons", []): - row["buttons"].remove(button_id) - - await cls.save_config(db, config) - - @classmethod - def _find_button_row(cls, config: Dict[str, Any], button_id: str) -> Optional[int]: - """Найти индекс строки содержащей кнопку.""" - rows = config.get("rows", []) - for i, row in enumerate(rows): - if button_id in row.get("buttons", []): - return i - return None - - @classmethod - async def move_button_up(cls, db: AsyncSession, button_id: str) -> Dict[str, Any]: - """Переместить кнопку на строку выше.""" - config = await cls.get_config(db) - config = config.copy() - rows = config.get("rows", []) - - current_row_idx = cls._find_button_row(config, button_id) - if current_row_idx is None: - raise KeyError(f"Button '{button_id}' not found in any row") - - if current_row_idx == 0: - raise ValueError("Button is already in the top row") - - # Удаляем из текущей строки - rows[current_row_idx]["buttons"].remove(button_id) - - # Добавляем в строку выше - rows[current_row_idx - 1]["buttons"].append(button_id) - - # Удаляем пустые строки - config["rows"] = [row for row in rows if row.get("buttons")] - - await cls.save_config(db, config) - return {"button_id": button_id, "new_row_index": current_row_idx - 1} - - @classmethod - async def move_button_down(cls, db: AsyncSession, button_id: str) -> Dict[str, Any]: - """Переместить кнопку на строку ниже.""" - config = await cls.get_config(db) - config = config.copy() - rows = config.get("rows", []) - - current_row_idx = cls._find_button_row(config, button_id) - if current_row_idx is None: - raise KeyError(f"Button '{button_id}' not found in any row") - - if current_row_idx >= len(rows) - 1: - raise ValueError("Button is already in the bottom row") - - # Удаляем из текущей строки - rows[current_row_idx]["buttons"].remove(button_id) - - # Добавляем в строку ниже - rows[current_row_idx + 1]["buttons"].append(button_id) - - # Удаляем пустые строки - config["rows"] = [row for row in rows if row.get("buttons")] - - await cls.save_config(db, config) - return {"button_id": button_id, "new_row_index": current_row_idx + 1} - - @classmethod - async def move_button_to_row( - cls, - db: AsyncSession, - button_id: str, - target_row_id: str, - position: Optional[int] = None, - ) -> Dict[str, Any]: - """Переместить кнопку в указанную строку.""" - config = await cls.get_config(db) - config = config.copy() - rows = config.get("rows", []) - - # Находим целевую строку - target_row_idx = None - for i, row in enumerate(rows): - if row["id"] == target_row_id: - target_row_idx = i - break - - if target_row_idx is None: - raise KeyError(f"Row '{target_row_id}' not found") - - # Удаляем кнопку из текущей строки - current_row_idx = cls._find_button_row(config, button_id) - if current_row_idx is not None: - rows[current_row_idx]["buttons"].remove(button_id) - - # Добавляем в целевую строку - target_buttons = rows[target_row_idx]["buttons"] - if position is not None and 0 <= position <= len(target_buttons): - target_buttons.insert(position, button_id) - else: - target_buttons.append(button_id) - - # Удаляем пустые строки - config["rows"] = [row for row in rows if row.get("buttons")] - - await cls.save_config(db, config) - return {"button_id": button_id, "target_row_id": target_row_id, "position": position} - - @classmethod - async def reorder_buttons_in_row( - cls, - db: AsyncSession, - row_id: str, - ordered_button_ids: List[str], - ) -> Dict[str, Any]: - """Изменить порядок кнопок внутри строки.""" - config = await cls.get_config(db) - config = config.copy() - rows = config.get("rows", []) - - # Находим строку - target_row = None - for row in rows: - if row["id"] == row_id: - target_row = row - break - - if target_row is None: - raise KeyError(f"Row '{row_id}' not found") - - # Проверяем что все кнопки принадлежат строке - current_buttons = set(target_row["buttons"]) - ordered_buttons = set(ordered_button_ids) - - if current_buttons != ordered_buttons: - missing = current_buttons - ordered_buttons - extra = ordered_buttons - current_buttons - errors = [] - if missing: - errors.append(f"missing: {missing}") - if extra: - errors.append(f"extra: {extra}") - raise ValueError(f"Button mismatch: {', '.join(errors)}") - - target_row["buttons"] = ordered_button_ids - - await cls.save_config(db, config) - return {"row_id": row_id, "buttons": ordered_button_ids} - - @classmethod - async def swap_buttons( - cls, - db: AsyncSession, - button_id_1: str, - button_id_2: str, - ) -> Dict[str, Any]: - """Поменять две кнопки местами.""" - config = await cls.get_config(db) - config = config.copy() - rows = config.get("rows", []) - - # Находим позиции обеих кнопок - pos1 = None - pos2 = None - - for row_idx, row in enumerate(rows): - buttons = row.get("buttons", []) - for btn_idx, btn_id in enumerate(buttons): - if btn_id == button_id_1: - pos1 = (row_idx, btn_idx) - elif btn_id == button_id_2: - pos2 = (row_idx, btn_idx) - - if pos1 is None: - raise KeyError(f"Button '{button_id_1}' not found") - if pos2 is None: - raise KeyError(f"Button '{button_id_2}' not found") - - # Меняем местами - rows[pos1[0]]["buttons"][pos1[1]] = button_id_2 - rows[pos2[0]]["buttons"][pos2[1]] = button_id_1 - - await cls.save_config(db, config) - return { - "button_1": {"id": button_id_1, "new_row": pos2[0], "new_position": pos2[1]}, - "button_2": {"id": button_id_2, "new_row": pos1[0], "new_position": pos1[1]}, - } - - @classmethod - def _evaluate_conditions( - cls, - conditions: Optional[Dict[str, Any]], - context: MenuContext, - ) -> bool: - """Проверить условия показа.""" - if not conditions: - return True - - # has_active_subscription - if conditions.get("has_active_subscription") is True: - if not context.has_active_subscription: - return False - - # subscription_is_active - if conditions.get("subscription_is_active") is True: - if not context.subscription_is_active: - return False - - # has_traffic_limit - подписка с лимитом трафика - if conditions.get("has_traffic_limit") is True: - if not context.subscription: - return False - traffic_limit = getattr(context.subscription, "traffic_limit_gb", 0) - is_trial = getattr(context.subscription, "is_trial", False) - if is_trial or traffic_limit <= 0: - return False - - # is_admin - if conditions.get("is_admin") is True: - if not context.is_admin: - return False - - # is_moderator - if conditions.get("is_moderator") is True: - if context.is_admin: # Админ не показывает кнопку модератора - return False - if not context.is_moderator: - return False - - # referral_enabled - if conditions.get("referral_enabled") is True: - if not settings.is_referral_program_enabled(): - return False - - # contests_visible - if conditions.get("contests_visible") is True: - if not settings.CONTESTS_BUTTON_VISIBLE: - return False - - # support_enabled - if conditions.get("support_enabled") is True: - try: - from app.services.support_settings_service import SupportSettingsService - if not SupportSettingsService.is_support_menu_enabled(): - return False - except Exception: - if not settings.SUPPORT_MENU_ENABLED: - return False - - # language_selection_enabled - if conditions.get("language_selection_enabled") is True: - if not settings.is_language_selection_enabled(): - return False - - # happ_enabled - if conditions.get("happ_enabled") is True: - if not settings.is_happ_download_button_enabled(): - return False - - # simple_subscription_enabled - if conditions.get("simple_subscription_enabled") is True: - if not settings.SIMPLE_SUBSCRIPTION_ENABLED: - return False - - # show_trial - if conditions.get("show_trial") is True: - if context.has_had_paid_subscription or context.has_active_subscription: - return False - - # show_buy - if conditions.get("show_buy") is True: - if context.has_active_subscription and context.subscription_is_active: - return False - - # has_saved_cart - if conditions.get("has_saved_cart") is True: - if not context.has_saved_cart and not context.show_resume_checkout: - return False - - return True - - @classmethod - def _check_visibility( - cls, - visibility: str, - context: MenuContext, - ) -> bool: - """Проверить видимость кнопки.""" - if visibility == "all": - return True - if visibility == "admins": - return context.is_admin - if visibility == "moderators": - return context.is_moderator and not context.is_admin - if visibility == "subscribers": - return context.has_active_subscription and context.subscription_is_active - return True - - @classmethod - def _get_localized_text( - cls, - text_config: Dict[str, str], - language: str, - fallback_language: str = "en", - ) -> str: - """Получить локализованный текст.""" - # Пробуем запрошенный язык - if language in text_config: - return text_config[language] - # Пробуем fallback - if fallback_language in text_config: - return text_config[fallback_language] - # Возвращаем первый доступный - if text_config: - return next(iter(text_config.values())) - return "" - - @classmethod - def _format_dynamic_text( - cls, - text: str, - context: MenuContext, - texts: Any, - ) -> str: - """Форматировать динамический текст.""" - if "{balance}" in text: - formatted_balance = texts.format_price(context.balance_kopeks) - text = text.replace("{balance}", formatted_balance) - return text - - @classmethod - def _build_button( - cls, - button_config: Dict[str, Any], - context: MenuContext, - texts: Any, - ) -> Optional[InlineKeyboardButton]: - """Построить кнопку из конфигурации.""" - button_type = button_config.get("type", "builtin") - text_config = button_config.get("text", {}) - action = button_config.get("action", "") - open_mode = button_config.get("open_mode", "callback") - webapp_url = button_config.get("webapp_url") - - # Получаем текст - text = cls._get_localized_text(text_config, context.language) - if not text: - return None - - # Форматируем динамический текст - if button_config.get("dynamic_text"): - text = cls._format_dynamic_text(text, context, texts) - - # Строим кнопку в зависимости от типа - if button_type == "url": - return InlineKeyboardButton(text=text, url=action) - elif button_type == "mini_app": - return InlineKeyboardButton( - text=text, web_app=types.WebAppInfo(url=action) - ) - else: - # builtin - проверяем open_mode - if open_mode == "direct" and webapp_url: - # Прямое открытие Mini App через WebAppInfo - return InlineKeyboardButton( - text=text, web_app=types.WebAppInfo(url=webapp_url) - ) - else: - # Стандартный callback_data - return InlineKeyboardButton(text=text, callback_data=action) - - @classmethod - async def build_keyboard( - cls, - db: AsyncSession, - context: MenuContext, - ) -> InlineKeyboardMarkup: - """Построить клавиатуру меню на основе конфигурации.""" - config = await cls.get_config(db) - texts = get_texts(context.language) - - keyboard_rows: List[List[InlineKeyboardButton]] = [] - rows_config = config.get("rows", []) - buttons_config = config.get("buttons", {}) - - for row_config in rows_config: - # Проверяем условия строки - row_conditions = row_config.get("conditions") - if not cls._evaluate_conditions(row_conditions, context): - continue - - row_buttons: List[InlineKeyboardButton] = [] - max_per_row = row_config.get("max_per_row", 2) - - for button_id in row_config.get("buttons", []): - if button_id not in buttons_config: - continue - - button_cfg = buttons_config[button_id] - - # Проверяем включена ли кнопка - if not button_cfg.get("enabled", True): - continue - - # Проверяем видимость - visibility = button_cfg.get("visibility", "all") - if not cls._check_visibility(visibility, context): - continue - - # Проверяем условия кнопки - button_conditions = button_cfg.get("conditions") - if not cls._evaluate_conditions(button_conditions, context): - continue - - # Строим кнопку - button = cls._build_button(button_cfg, context, texts) - if button: - row_buttons.append(button) - - # Добавляем кнопки с учетом max_per_row - if row_buttons: - for i in range(0, len(row_buttons), max_per_row): - keyboard_rows.append(row_buttons[i : i + max_per_row]) - - return InlineKeyboardMarkup(inline_keyboard=keyboard_rows) - - @classmethod - async def preview_keyboard( - cls, - db: AsyncSession, - context: MenuContext, - ) -> List[Dict[str, Any]]: - """Предпросмотр меню (возвращает структуру для API).""" - config = await cls.get_config(db) - texts = get_texts(context.language) - - preview_rows: List[Dict[str, Any]] = [] - rows_config = config.get("rows", []) - buttons_config = config.get("buttons", {}) - - for row_config in rows_config: - row_conditions = row_config.get("conditions") - if not cls._evaluate_conditions(row_conditions, context): - continue - - row_buttons: List[Dict[str, Any]] = [] - max_per_row = row_config.get("max_per_row", 2) - - for button_id in row_config.get("buttons", []): - if button_id not in buttons_config: - continue - - button_cfg = buttons_config[button_id] - - if not button_cfg.get("enabled", True): - continue - - visibility = button_cfg.get("visibility", "all") - if not cls._check_visibility(visibility, context): - continue - - button_conditions = button_cfg.get("conditions") - if not cls._evaluate_conditions(button_conditions, context): - continue - - text_config = button_cfg.get("text", {}) - text = cls._get_localized_text(text_config, context.language) - - if button_cfg.get("dynamic_text"): - text = cls._format_dynamic_text(text, context, texts) - - row_buttons.append({ - "text": text, - "action": button_cfg.get("action", ""), - "type": button_cfg.get("type", "builtin"), - }) - - if row_buttons: - for i in range(0, len(row_buttons), max_per_row): - preview_rows.append({"buttons": row_buttons[i : i + max_per_row]}) - - return preview_rows diff --git a/app/webapi/routes/menu_layout.py b/app/webapi/routes/menu_layout.py index 8eb7a6aa..fb8d6513 100644 --- a/app/webapi/routes/menu_layout.py +++ b/app/webapi/routes/menu_layout.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, List +from typing import Any, List, Optional from fastapi import APIRouter, Depends, HTTPException, Response, Security, status from sqlalchemy.ext.asyncio import AsyncSession @@ -17,14 +17,29 @@ from ..dependencies import get_db_session, require_api_token from ..schemas.menu_layout import ( AddCustomButtonRequest, AddRowRequest, + AvailableCallback, + AvailableCallbacksResponse, BuiltinButtonInfo, BuiltinButtonsListResponse, + ButtonClickStats, + ButtonClickStatsResponse, ButtonConditions, ButtonUpdateRequest, + DynamicPlaceholder, + DynamicPlaceholdersResponse, MenuButtonConfig, + MenuClickStatsResponse, MenuLayoutConfig, + MenuLayoutExportResponse, + MenuLayoutHistoryEntry, + MenuLayoutHistoryResponse, + MenuLayoutImportRequest, + MenuLayoutImportResponse, MenuLayoutResponse, + MenuLayoutRollbackRequest, MenuLayoutUpdateRequest, + MenuLayoutValidateRequest, + MenuLayoutValidateResponse, MenuPreviewButton, MenuPreviewRequest, MenuPreviewResponse, @@ -37,6 +52,7 @@ from ..schemas.menu_layout import ( RowsReorderRequest, SwapButtonsRequest, SwapButtonsResponse, + ValidationError, ) @@ -64,6 +80,7 @@ def _serialize_config(config: dict, is_enabled: bool, updated_at) -> MenuLayoutR type=btn_data["type"], builtin_id=btn_data.get("builtin_id"), text=btn_data.get("text", {}), + icon=btn_data.get("icon"), action=btn_data.get("action", ""), enabled=btn_data.get("enabled", True), visibility=btn_data.get("visibility", "all"), @@ -73,6 +90,8 @@ def _serialize_config(config: dict, is_enabled: bool, updated_at) -> MenuLayoutR dynamic_text=btn_data.get("dynamic_text", False), open_mode=btn_data.get("open_mode", "callback"), webapp_url=btn_data.get("webapp_url"), + description=btn_data.get("description"), + sort_order=btn_data.get("sort_order"), ) return MenuLayoutResponse( @@ -183,6 +202,7 @@ async def update_button( type=button["type"], builtin_id=button.get("builtin_id"), text=button.get("text", {}), + icon=button.get("icon"), action=button.get("action", ""), enabled=button.get("enabled", True), visibility=button.get("visibility", "all"), @@ -192,6 +212,7 @@ async def update_button( dynamic_text=button.get("dynamic_text", False), open_mode=button.get("open_mode", "callback"), webapp_url=button.get("webapp_url"), + description=button.get("description"), ) except KeyError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e @@ -273,16 +294,19 @@ async def add_custom_button( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> MenuButtonConfig: - """Добавить кастомную кнопку (URL или MiniApp).""" + """Добавить кастомную кнопку (URL, MiniApp или callback).""" try: button_config = { "type": payload.type.value, "text": payload.text, + "icon": payload.icon, "action": payload.action, "visibility": payload.visibility.value, "conditions": payload.conditions.model_dump(exclude_none=True) if payload.conditions else None, + "dynamic_text": payload.dynamic_text, + "description": payload.description, } button = await MenuLayoutService.add_custom_button( db, payload.id, button_config, payload.row_id @@ -292,6 +316,7 @@ async def add_custom_button( type=button["type"], builtin_id=button.get("builtin_id"), text=button.get("text", {}), + icon=button.get("icon"), action=button.get("action", ""), enabled=button.get("enabled", True), visibility=button.get("visibility", "all"), @@ -301,6 +326,7 @@ async def add_custom_button( dynamic_text=button.get("dynamic_text", False), open_mode=button.get("open_mode", "callback"), webapp_url=button.get("webapp_url"), + description=button.get("description"), ) except ValueError as e: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e @@ -464,3 +490,318 @@ async def swap_buttons( raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e except ValueError as e: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + + +# --- Новые эндпоинты --- + + +@router.get("/available-callbacks", response_model=AvailableCallbacksResponse) +async def list_available_callbacks( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> AvailableCallbacksResponse: + """Получить список всех доступных callback_data для создания кнопок.""" + callbacks = await MenuLayoutService.get_available_callbacks(db) + + items = [ + AvailableCallback( + callback_data=cb["callback_data"], + name=cb["name"], + description=cb.get("description"), + category=cb["category"], + default_text=cb.get("default_text"), + default_icon=cb.get("default_icon"), + requires_subscription=cb.get("requires_subscription", False), + is_in_menu=cb.get("is_in_menu", False), + ) + for cb in callbacks + ] + + categories = list(set(cb["category"] for cb in callbacks)) + + return AvailableCallbacksResponse( + items=items, + total=len(items), + categories=sorted(categories), + ) + + +@router.get("/placeholders", response_model=DynamicPlaceholdersResponse) +async def list_dynamic_placeholders( + _: Any = Security(require_api_token), +) -> DynamicPlaceholdersResponse: + """Получить список доступных динамических плейсхолдеров для текста кнопок.""" + placeholders = MenuLayoutService.get_dynamic_placeholders() + + items = [ + DynamicPlaceholder( + placeholder=p["placeholder"], + description=p["description"], + example=p["example"], + category=p["category"], + ) + for p in placeholders + ] + + return DynamicPlaceholdersResponse(items=items, total=len(items)) + + +@router.get("/export", response_model=MenuLayoutExportResponse) +async def export_menu_layout( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuLayoutExportResponse: + """Экспортировать конфигурацию меню.""" + from datetime import datetime + + export_data = await MenuLayoutService.export_config(db) + + rows = [] + for row_data in export_data.get("rows", []): + rows.append( + MenuRowConfig( + id=row_data["id"], + buttons=row_data.get("buttons", []), + conditions=ButtonConditions(**row_data["conditions"]) + if row_data.get("conditions") + else None, + max_per_row=row_data.get("max_per_row", 2), + ) + ) + + buttons = {} + for btn_id, btn_data in export_data.get("buttons", {}).items(): + buttons[btn_id] = MenuButtonConfig( + type=btn_data["type"], + builtin_id=btn_data.get("builtin_id"), + text=btn_data.get("text", {}), + icon=btn_data.get("icon"), + action=btn_data.get("action", ""), + enabled=btn_data.get("enabled", True), + visibility=btn_data.get("visibility", "all"), + conditions=ButtonConditions(**btn_data["conditions"]) + if btn_data.get("conditions") + else None, + dynamic_text=btn_data.get("dynamic_text", False), + open_mode=btn_data.get("open_mode", "callback"), + webapp_url=btn_data.get("webapp_url"), + description=btn_data.get("description"), + ) + + return MenuLayoutExportResponse( + version=export_data.get("version", 1), + rows=rows, + buttons=buttons, + exported_at=datetime.now(), + ) + + +@router.post("/import", response_model=MenuLayoutImportResponse) +async def import_menu_layout( + payload: MenuLayoutImportRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuLayoutImportResponse: + """Импортировать конфигурацию меню.""" + import_data = { + "version": payload.version, + "rows": [row.model_dump() for row in payload.rows], + "buttons": {btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items()}, + } + + result = await MenuLayoutService.import_config(db, import_data, payload.merge_mode) + + return MenuLayoutImportResponse( + success=result["success"], + imported_rows=result["imported_rows"], + imported_buttons=result["imported_buttons"], + warnings=result["warnings"], + ) + + +@router.post("/validate", response_model=MenuLayoutValidateResponse) +async def validate_menu_layout( + payload: MenuLayoutValidateRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuLayoutValidateResponse: + """Валидировать конфигурацию меню без сохранения.""" + # Если данные не переданы, валидируем текущую конфигурацию + if payload.rows is None and payload.buttons is None: + config = await MenuLayoutService.get_config(db) + else: + config = { + "rows": [row.model_dump() for row in payload.rows] if payload.rows else [], + "buttons": {btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items()} + if payload.buttons + else {}, + } + + result = MenuLayoutService.validate_config(config) + + return MenuLayoutValidateResponse( + is_valid=result["is_valid"], + errors=[ + ValidationError( + field=e["field"], + message=e["message"], + severity=e["severity"], + ) + for e in result["errors"] + ], + warnings=[ + ValidationError( + field=w["field"], + message=w["message"], + severity=w["severity"], + ) + for w in result["warnings"] + ], + ) + + +# --- Эндпоинты истории изменений --- + + +@router.get("/history", response_model=MenuLayoutHistoryResponse) +async def get_menu_layout_history( + limit: int = 50, + offset: int = 0, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuLayoutHistoryResponse: + """Получить историю изменений меню.""" + entries = await MenuLayoutService.get_history(db, limit, offset) + total = await MenuLayoutService.get_history_count(db) + + return MenuLayoutHistoryResponse( + items=[ + MenuLayoutHistoryEntry( + id=entry["id"], + created_at=entry["created_at"], + action=entry["action"], + changes_summary=entry["changes_summary"] or "", + user_info=entry["user_info"], + ) + for entry in entries + ], + total=total, + ) + + +@router.get("/history/{history_id}") +async def get_history_entry( + history_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> dict: + """Получить конкретную запись истории с полной конфигурацией.""" + entry = await MenuLayoutService.get_history_entry(db, history_id) + if not entry: + raise HTTPException(status.HTTP_404_NOT_FOUND, f"History entry {history_id} not found") + + return { + "id": entry["id"], + "action": entry["action"], + "changes_summary": entry["changes_summary"], + "user_info": entry["user_info"], + "created_at": entry["created_at"].isoformat() if entry["created_at"] else None, + "config": entry["config"], + } + + +@router.post("/history/{history_id}/rollback", response_model=MenuLayoutResponse) +async def rollback_to_history( + history_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuLayoutResponse: + """Откатить конфигурацию к записи из истории.""" + try: + config = await MenuLayoutService.rollback_to_history(db, history_id) + updated_at = await MenuLayoutService.get_config_updated_at(db) + return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at) + except KeyError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + + +# --- Эндпоинты статистики кликов --- + + +@router.get("/stats", response_model=MenuClickStatsResponse) +async def get_menu_click_stats( + days: int = 30, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuClickStatsResponse: + """Получить общую статистику кликов по всем кнопкам.""" + from datetime import datetime, timedelta + + stats = await MenuLayoutService.get_all_buttons_stats(db, days) + total_clicks = await MenuLayoutService.get_total_clicks(db, days) + + now = datetime.now() + period_start = now - timedelta(days=days) + + return MenuClickStatsResponse( + items=[ + ButtonClickStats( + button_id=s["button_id"], + clicks_total=s["clicks_total"], + unique_users=s["unique_users"], + last_click_at=s["last_click_at"], + ) + for s in stats + ], + total_clicks=total_clicks, + period_start=period_start, + period_end=now, + ) + + +@router.get("/stats/buttons/{button_id}", response_model=ButtonClickStatsResponse) +async def get_button_click_stats( + button_id: str, + days: int = 30, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> ButtonClickStatsResponse: + """Получить статистику кликов по конкретной кнопке.""" + stats = await MenuLayoutService.get_button_stats(db, button_id, days) + clicks_by_day = await MenuLayoutService.get_button_clicks_by_day(db, button_id, days) + + return ButtonClickStatsResponse( + button_id=button_id, + stats=ButtonClickStats( + button_id=stats["button_id"], + clicks_total=stats["clicks_total"], + clicks_today=stats["clicks_today"], + clicks_week=stats["clicks_week"], + clicks_month=stats["clicks_month"], + unique_users=stats["unique_users"], + last_click_at=stats["last_click_at"], + ), + clicks_by_day=clicks_by_day, + ) + + +@router.post("/stats/log-click") +async def log_button_click( + button_id: str, + user_id: Optional[int] = None, + callback_data: Optional[str] = None, + button_type: Optional[str] = None, + button_text: Optional[str] = None, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> dict: + """Записать клик по кнопке (для внешней интеграции).""" + await MenuLayoutService.log_button_click( + db, + button_id=button_id, + user_id=user_id, + callback_data=callback_data, + button_type=button_type, + button_text=button_text, + ) + return {"success": True} diff --git a/app/webapi/schemas/menu_layout.py b/app/webapi/schemas/menu_layout.py index bb413534..86e3823b 100644 --- a/app/webapi/schemas/menu_layout.py +++ b/app/webapi/schemas/menu_layout.py @@ -15,6 +15,7 @@ class ButtonType(str, Enum): BUILTIN = "builtin" # Встроенная кнопка с callback_data URL = "url" # Внешняя ссылка MINI_APP = "mini_app" # Telegram Mini App + CALLBACK = "callback" # Кастомная кнопка с любым callback_data class ButtonVisibility(str, Enum): @@ -36,6 +37,7 @@ class ButtonOpenMode(str, Enum): class ButtonConditions(BaseModel): """Условия показа кнопки.""" + # Существующие условия has_active_subscription: Optional[bool] = Field( default=None, description="Требуется активная подписка" ) @@ -77,6 +79,44 @@ class ButtonConditions(BaseModel): default=None, description="Есть сохраненная корзина" ) + # Расширенные условия + min_balance_kopeks: Optional[int] = Field( + default=None, ge=0, description="Минимальный баланс в копейках" + ) + max_balance_kopeks: Optional[int] = Field( + default=None, ge=0, description="Максимальный баланс в копейках" + ) + min_registration_days: Optional[int] = Field( + default=None, ge=0, description="Минимум дней с регистрации" + ) + max_registration_days: Optional[int] = Field( + default=None, ge=0, description="Максимум дней с регистрации" + ) + min_referrals: Optional[int] = Field( + default=None, ge=0, description="Минимальное количество рефералов" + ) + has_referrals: Optional[bool] = Field( + default=None, description="Есть рефералы" + ) + promo_group_ids: Optional[List[str]] = Field( + default=None, description="Список ID промо-групп (пользователь должен быть в одной из них)" + ) + exclude_promo_group_ids: Optional[List[str]] = Field( + default=None, description="Исключить пользователей из этих промо-групп" + ) + has_subscription_days_left: Optional[int] = Field( + default=None, ge=0, description="Минимум дней до окончания подписки" + ) + max_subscription_days_left: Optional[int] = Field( + default=None, ge=0, description="Максимум дней до окончания подписки" + ) + is_trial_user: Optional[bool] = Field( + default=None, description="Пользователь на пробном периоде" + ) + has_autopay: Optional[bool] = Field( + default=None, description="Автоплатёж включён" + ) + model_config = ConfigDict(extra="forbid") @@ -90,6 +130,9 @@ class MenuButtonConfig(BaseModel): text: Dict[str, str] = Field( ..., description="Локализованные тексты кнопки: {lang_code: text}" ) + icon: Optional[str] = Field( + default=None, max_length=10, description="Эмодзи/иконка кнопки (отдельно от текста)" + ) action: str = Field( ..., description="callback_data или URL в зависимости от типа" ) @@ -101,7 +144,7 @@ class MenuButtonConfig(BaseModel): default=None, description="Дополнительные условия показа" ) dynamic_text: bool = Field( - default=False, description="Текст содержит плейсхолдеры ({balance} и т.д.)" + default=False, description="Текст содержит плейсхолдеры ({balance}, {username} и т.д.)" ) open_mode: ButtonOpenMode = Field( default=ButtonOpenMode.CALLBACK, @@ -111,6 +154,12 @@ class MenuButtonConfig(BaseModel): default=None, description="URL для Mini App при open_mode=direct", ) + description: Optional[str] = Field( + default=None, max_length=200, description="Описание кнопки для админ-панели" + ) + sort_order: Optional[int] = Field( + default=None, description="Порядок сортировки (для отображения в админке)" + ) model_config = ConfigDict(extra="forbid") @@ -205,6 +254,9 @@ class ButtonUpdateRequest(BaseModel): text: Optional[Dict[str, str]] = Field( default=None, description="Новые локализованные тексты" ) + icon: Optional[str] = Field( + default=None, max_length=10, description="Эмодзи/иконка кнопки" + ) enabled: Optional[bool] = Field(default=None, description="Включить/выключить") visibility: Optional[ButtonVisibility] = Field( default=None, description="Новая видимость" @@ -213,7 +265,10 @@ class ButtonUpdateRequest(BaseModel): default=None, description="Новые условия показа" ) action: Optional[str] = Field( - default=None, description="Новый action (для URL/MiniApp кнопок)" + default=None, description="Новый action (callback_data или URL)" + ) + dynamic_text: Optional[bool] = Field( + default=None, description="Текст содержит плейсхолдеры" ) open_mode: Optional[ButtonOpenMode] = Field( default=None, description="Режим открытия: callback или direct" @@ -221,6 +276,12 @@ class ButtonUpdateRequest(BaseModel): webapp_url: Optional[str] = Field( default=None, description="URL для Mini App при open_mode=direct" ) + description: Optional[str] = Field( + default=None, max_length=200, description="Описание кнопки" + ) + sort_order: Optional[int] = Field( + default=None, description="Порядок сортировки" + ) model_config = ConfigDict(extra="forbid") @@ -257,8 +318,11 @@ class AddCustomButtonRequest(BaseModel): id: str = Field( ..., min_length=1, max_length=50, description="ID кнопки (уникальный)" ) - type: ButtonType = Field(..., description="Тип кнопки (url или mini_app)") + type: ButtonType = Field(..., description="Тип кнопки (url, mini_app или callback)") text: Dict[str, str] = Field(..., description="Локализованные тексты") + icon: Optional[str] = Field( + default=None, max_length=10, description="Эмодзи/иконка кнопки" + ) action: str = Field(..., min_length=1, description="URL или callback_data") visibility: ButtonVisibility = Field( default=ButtonVisibility.ALL, description="Видимость" @@ -266,9 +330,15 @@ class AddCustomButtonRequest(BaseModel): conditions: Optional[ButtonConditions] = Field( default=None, description="Условия показа" ) + dynamic_text: bool = Field( + default=False, description="Текст содержит плейсхолдеры" + ) row_id: Optional[str] = Field( default=None, description="ID строки для добавления кнопки" ) + description: Optional[str] = Field( + default=None, max_length=200, description="Описание кнопки для админ-панели" + ) model_config = ConfigDict(extra="forbid") @@ -365,3 +435,172 @@ class ReorderButtonsResponse(BaseModel): row_id: str buttons: List[str] + + +# --- Схемы для доступных callback_data --- + + +class AvailableCallback(BaseModel): + """Информация о доступном callback_data.""" + + callback_data: str = Field(description="callback_data для кнопки") + name: str = Field(description="Человекочитаемое название") + description: Optional[str] = Field(default=None, description="Описание действия") + category: str = Field(description="Категория: menu, subscription, balance, referral, support, etc.") + default_text: Optional[Dict[str, str]] = Field(default=None, description="Текст по умолчанию") + default_icon: Optional[str] = Field(default=None, description="Иконка по умолчанию") + requires_subscription: bool = Field(default=False, description="Требует активную подписку") + is_in_menu: bool = Field(default=False, description="Уже добавлена в меню") + + +class AvailableCallbacksResponse(BaseModel): + """Список всех доступных callback_data.""" + + items: List[AvailableCallback] + total: int + categories: List[str] = Field(description="Список всех категорий") + + +# --- Схемы для импорта/экспорта --- + + +class MenuLayoutExportResponse(BaseModel): + """Экспорт конфигурации меню.""" + + version: int + rows: List[MenuRowConfig] + buttons: Dict[str, MenuButtonConfig] + exported_at: datetime + bot_version: Optional[str] = None + + +class MenuLayoutImportRequest(BaseModel): + """Импорт конфигурации меню.""" + + version: int + rows: List[MenuRowConfig] + buttons: Dict[str, MenuButtonConfig] + merge_mode: str = Field( + default="replace", + description="Режим импорта: replace (заменить всё), merge (объединить)" + ) + + model_config = ConfigDict(extra="forbid") + + +class MenuLayoutImportResponse(BaseModel): + """Результат импорта.""" + + success: bool + imported_rows: int + imported_buttons: int + warnings: List[str] = Field(default_factory=list) + + +# --- Схемы для истории изменений --- + + +class MenuLayoutHistoryEntry(BaseModel): + """Запись в истории изменений.""" + + id: int + created_at: datetime + action: str = Field(description="Тип действия: update, reset, import") + changes_summary: str = Field(description="Краткое описание изменений") + user_info: Optional[str] = Field(default=None, description="Информация о пользователе") + + +class MenuLayoutHistoryResponse(BaseModel): + """История изменений.""" + + items: List[MenuLayoutHistoryEntry] + total: int + + +class MenuLayoutRollbackRequest(BaseModel): + """Запрос на откат к предыдущей версии.""" + + history_id: int = Field(description="ID записи в истории для отката") + + model_config = ConfigDict(extra="forbid") + + +# --- Схемы для валидации --- + + +class ValidationError(BaseModel): + """Ошибка валидации.""" + + field: str + message: str + severity: str = Field(description="error или warning") + + +class MenuLayoutValidateRequest(BaseModel): + """Запрос на валидацию конфигурации.""" + + rows: Optional[List[MenuRowConfig]] = None + buttons: Optional[Dict[str, MenuButtonConfig]] = None + + model_config = ConfigDict(extra="forbid") + + +class MenuLayoutValidateResponse(BaseModel): + """Результат валидации.""" + + is_valid: bool + errors: List[ValidationError] = Field(default_factory=list) + warnings: List[ValidationError] = Field(default_factory=list) + + +# --- Схемы для статистики кликов --- + + +class ButtonClickStats(BaseModel): + """Статистика кликов по кнопке.""" + + button_id: str + clicks_total: int = Field(default=0) + clicks_today: int = Field(default=0) + clicks_week: int = Field(default=0) + clicks_month: int = Field(default=0) + last_click_at: Optional[datetime] = None + unique_users: int = Field(default=0, description="Уникальные пользователи") + + +class ButtonClickStatsResponse(BaseModel): + """Статистика кликов для одной кнопки.""" + + button_id: str + stats: ButtonClickStats + clicks_by_day: List[Dict[str, Any]] = Field( + default_factory=list, description="Клики по дням [{date, count}]" + ) + + +class MenuClickStatsResponse(BaseModel): + """Общая статистика кликов по всем кнопкам.""" + + items: List[ButtonClickStats] + total_clicks: int + period_start: datetime + period_end: datetime + + +# --- Схемы для плейсхолдеров --- + + +class DynamicPlaceholder(BaseModel): + """Информация о динамическом плейсхолдере.""" + + placeholder: str = Field(description="Плейсхолдер, например {balance}") + description: str = Field(description="Описание") + example: str = Field(description="Пример значения") + category: str = Field(description="Категория: user, subscription, referral, etc.") + + +class DynamicPlaceholdersResponse(BaseModel): + """Список доступных плейсхолдеров.""" + + items: List[DynamicPlaceholder] + total: int diff --git a/app/webapi/schemas/promo_offers.py b/app/webapi/schemas/promo_offers.py index f5f4a9f8..4b237ad5 100644 --- a/app/webapi/schemas/promo_offers.py +++ b/app/webapi/schemas/promo_offers.py @@ -74,12 +74,22 @@ class PromoOfferBroadcastRequest(PromoOfferCreateRequest): "all", "active", "trial", + "trial_ending", + "trial_expired", "no", "expiring", + "expiring_subscribers", "expired", + "expired_subscribers", + "canceled_subscribers", "active_zero", "trial_zero", "zero", + "autopay_failed", + "low_balance", + "inactive_30d", + "inactive_60d", + "inactive_90d", } _CUSTOM_TARGETS: ClassVar[set[str]] = { "today", @@ -93,6 +103,9 @@ class PromoOfferBroadcastRequest(PromoOfferCreateRequest): } _TARGET_ALIASES: ClassVar[dict[str, str]] = { "no_sub": "no", + "all_users": "all", + "active_subscribers": "active", + "trial_users": "trial", } @validator("target") From e743689b340d43d42ccf92220d4e5af5b532b878 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sat, 20 Dec 2025 01:40:23 +0300 Subject: [PATCH 07/47] Enhance main menu keyboard functionality by adding user data support and subscription details retrieval --- app/handlers/menu.py | 2 ++ app/handlers/start.py | 6 ++++ app/keyboards/inline.py | 68 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/app/handlers/menu.py b/app/handlers/menu.py index f18f3d29..36d4ee82 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -201,6 +201,7 @@ async def show_main_menu( keyboard = await get_main_menu_keyboard_async( db=db, + user=db_user, language=db_user.language, is_admin=is_admin, is_moderator=is_moderator, @@ -1067,6 +1068,7 @@ async def handle_back_to_menu( keyboard = await get_main_menu_keyboard_async( db=db, + user=db_user, language=db_user.language, is_admin=is_admin, is_moderator=is_moderator, diff --git a/app/handlers/start.py b/app/handlers/start.py index 3a12a828..aa4df6d6 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -417,6 +417,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, keyboard = await get_main_menu_keyboard_async( db=db, + user=user, language=user.language, is_admin=is_admin, has_had_paid_subscription=user.has_had_paid_subscription, @@ -1072,6 +1073,7 @@ async def complete_registration_from_callback( try: keyboard = await get_main_menu_keyboard_async( db=db, + user=existing_user, language=existing_user.language, is_admin=is_admin, has_had_paid_subscription=existing_user.has_had_paid_subscription, @@ -1254,6 +1256,7 @@ async def complete_registration_from_callback( try: keyboard = await get_main_menu_keyboard_async( db=db, + user=user, language=user.language, is_admin=is_admin, has_had_paid_subscription=user.has_had_paid_subscription, @@ -1350,6 +1353,7 @@ async def complete_registration( try: keyboard = await get_main_menu_keyboard_async( db=db, + user=existing_user, language=existing_user.language, is_admin=is_admin, has_had_paid_subscription=existing_user.has_had_paid_subscription, @@ -1555,6 +1559,7 @@ async def complete_registration( try: keyboard = await get_main_menu_keyboard_async( db=db, + user=user, language=user.language, is_admin=is_admin, has_had_paid_subscription=user.has_had_paid_subscription, @@ -1888,6 +1893,7 @@ async def required_sub_channel_check( keyboard = await get_main_menu_keyboard_async( db=db, + user=user, language=user.language, is_admin=is_admin, has_had_paid_subscription=user.has_had_paid_subscription, diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index e28940a2..d5dbaf08 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -37,6 +37,7 @@ async def get_main_menu_keyboard_async( *, is_moderator: bool = False, custom_buttons: Optional[list[InlineKeyboardButton]] = None, + user=None, # Добавляем параметр пользователя для получения данных ) -> InlineKeyboardMarkup: """ Асинхронная версия get_main_menu_keyboard с поддержкой конструктора меню. @@ -46,6 +47,63 @@ async def get_main_menu_keyboard_async( """ if settings.MENU_LAYOUT_ENABLED: from app.services.menu_layout_service import MenuLayoutService, MenuContext + from datetime import datetime + + # Получаем данные для плейсхолдеров + subscription_days_left = 0 + traffic_used_gb = 0.0 + traffic_left_gb = 0.0 + referral_count = 0 + referral_earnings_kopeks = 0 + registration_days = 0 + promo_group_id = None + has_autopay = False + username = "" + + # Заполняем данными из подписки + if subscription: + # Дни до окончания подписки + if hasattr(subscription, 'ends_at') and subscription.ends_at: + days_left = (subscription.ends_at - datetime.now()).days + subscription_days_left = max(0, days_left) + + # Трафик + if hasattr(subscription, 'traffic_used_gb'): + traffic_used_gb = subscription.traffic_used_gb or 0.0 + + if hasattr(subscription, 'traffic_limit_gb') and subscription.traffic_limit_gb: + traffic_left_gb = max(0, subscription.traffic_limit_gb - (subscription.traffic_used_gb or 0)) + + # Автоплатеж + if hasattr(subscription, 'has_autopay'): + has_autopay = subscription.has_autopay + + # Получаем данные пользователя + if user: + # Имя пользователя + if hasattr(user, 'username') and user.username: + username = user.username + elif hasattr(user, 'first_name') and user.first_name: + username = user.first_name + + # Дни с регистрации + if hasattr(user, 'created_at') and user.created_at: + registration_days = (datetime.now() - user.created_at).days + + # ID промо-группы + if hasattr(user, 'promo_group_id'): + promo_group_id = user.promo_group_id + + # Получаем данные о рефералах из БД (если нужно) + try: + from app.database.crud.referral import get_user_referral_count + if user and hasattr(user, 'id'): + referral_data = await get_user_referral_count(db, user.id) + if referral_data: + referral_count = referral_data.get('count', 0) + referral_earnings_kopeks = referral_data.get('earnings_kopeks', 0) + except Exception as e: + logger.error(f"Error getting referral data: {e}") context = MenuContext( language=language, @@ -59,6 +117,16 @@ async def get_main_menu_keyboard_async( show_resume_checkout=show_resume_checkout, has_saved_cart=has_saved_cart, custom_buttons=custom_buttons or [], + # Добавляем данные для плейсхолдеров + username=username, + subscription_days_left=subscription_days_left, + traffic_used_gb=traffic_used_gb, + traffic_left_gb=traffic_left_gb, + referral_count=referral_count, + referral_earnings_kopeks=referral_earnings_kopeks, + registration_days=registration_days, + promo_group_id=promo_group_id, + has_autopay=has_autopay, ) return await MenuLayoutService.build_keyboard(db, context) From 5919cfff164b7b44c1f4953c07a366f08a368be9 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sat, 20 Dec 2025 01:55:26 +0300 Subject: [PATCH 08/47] Refactor referral data retrieval and update subscription placeholder naming in menu layout --- app/keyboards/inline.py | 8 ++++---- app/services/menu_layout/constants.py | 2 +- app/services/menu_layout/service.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index d5dbaf08..fa74bdcd 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -96,12 +96,12 @@ async def get_main_menu_keyboard_async( # Получаем данные о рефералах из БД (если нужно) try: - from app.database.crud.referral import get_user_referral_count + from app.database.crud.referral import get_user_referral_stats if user and hasattr(user, 'id'): - referral_data = await get_user_referral_count(db, user.id) + referral_data = await get_user_referral_stats(db, user.id) if referral_data: - referral_count = referral_data.get('count', 0) - referral_earnings_kopeks = referral_data.get('earnings_kopeks', 0) + referral_count = referral_data.get('invited_count', 0) + referral_earnings_kopeks = referral_data.get('total_earned_kopeks', 0) except Exception as e: logger.error(f"Error getting referral data: {e}") diff --git a/app/services/menu_layout/constants.py b/app/services/menu_layout/constants.py index 8bd96980..2ef26e83 100644 --- a/app/services/menu_layout/constants.py +++ b/app/services/menu_layout/constants.py @@ -493,7 +493,7 @@ AVAILABLE_CALLBACKS: List[Dict[str, Any]] = [ DYNAMIC_PLACEHOLDERS: List[Dict[str, str]] = [ {"placeholder": "{balance}", "description": "Баланс пользователя", "example": "1 500 ₽", "category": "user"}, {"placeholder": "{username}", "description": "Имя пользователя", "example": "John", "category": "user"}, - {"placeholder": "{subscription_days}", "description": "Дней до окончания подписки", "example": "14", "category": "subscription"}, + {"placeholder": "{subscription_days_left}", "description": "Дней до окончания подписки", "example": "14", "category": "subscription"}, {"placeholder": "{traffic_used}", "description": "Использованный трафик", "example": "5.2 GB", "category": "subscription"}, {"placeholder": "{traffic_left}", "description": "Оставшийся трафик", "example": "94.8 GB", "category": "subscription"}, {"placeholder": "{referral_count}", "description": "Количество рефералов", "example": "12", "category": "referral"}, diff --git a/app/services/menu_layout/service.py b/app/services/menu_layout/service.py index d7d73323..040e1c42 100644 --- a/app/services/menu_layout/service.py +++ b/app/services/menu_layout/service.py @@ -870,8 +870,8 @@ class MenuLayoutService: text = text.replace("{username}", context.username or "User") # Дней до окончания подписки - if "{subscription_days}" in text: - text = text.replace("{subscription_days}", str(context.subscription_days_left)) + if "{subscription_days_left}" in text: + text = text.replace("{subscription_days_left}", str(context.subscription_days_left)) # Использованный трафик if "{traffic_used}" in text: From a87d52f2bc9ceb6ff361ba90bf5dc8eed2bf8e42 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sat, 20 Dec 2025 02:15:51 +0300 Subject: [PATCH 09/47] Update subscription placeholder naming in menu layout and adjust context variable accordingly --- app/services/menu_layout/constants.py | 2 +- app/services/menu_layout/context.py | 2 +- app/services/menu_layout/service.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/services/menu_layout/constants.py b/app/services/menu_layout/constants.py index 2ef26e83..8bd96980 100644 --- a/app/services/menu_layout/constants.py +++ b/app/services/menu_layout/constants.py @@ -493,7 +493,7 @@ AVAILABLE_CALLBACKS: List[Dict[str, Any]] = [ DYNAMIC_PLACEHOLDERS: List[Dict[str, str]] = [ {"placeholder": "{balance}", "description": "Баланс пользователя", "example": "1 500 ₽", "category": "user"}, {"placeholder": "{username}", "description": "Имя пользователя", "example": "John", "category": "user"}, - {"placeholder": "{subscription_days_left}", "description": "Дней до окончания подписки", "example": "14", "category": "subscription"}, + {"placeholder": "{subscription_days}", "description": "Дней до окончания подписки", "example": "14", "category": "subscription"}, {"placeholder": "{traffic_used}", "description": "Использованный трафик", "example": "5.2 GB", "category": "subscription"}, {"placeholder": "{traffic_left}", "description": "Оставшийся трафик", "example": "94.8 GB", "category": "subscription"}, {"placeholder": "{referral_count}", "description": "Количество рефералов", "example": "12", "category": "referral"}, diff --git a/app/services/menu_layout/context.py b/app/services/menu_layout/context.py index fc6aeb13..5f0a24a3 100644 --- a/app/services/menu_layout/context.py +++ b/app/services/menu_layout/context.py @@ -25,7 +25,7 @@ class MenuContext: custom_buttons: List[InlineKeyboardButton] = field(default_factory=list) # Расширенные поля для плейсхолдеров и условий username: str = "" - subscription_days_left: int = 0 + subscription_days: int = 0 traffic_used_gb: float = 0.0 traffic_left_gb: float = 0.0 referral_count: int = 0 diff --git a/app/services/menu_layout/service.py b/app/services/menu_layout/service.py index 040e1c42..d7d73323 100644 --- a/app/services/menu_layout/service.py +++ b/app/services/menu_layout/service.py @@ -870,8 +870,8 @@ class MenuLayoutService: text = text.replace("{username}", context.username or "User") # Дней до окончания подписки - if "{subscription_days_left}" in text: - text = text.replace("{subscription_days_left}", str(context.subscription_days_left)) + if "{subscription_days}" in text: + text = text.replace("{subscription_days}", str(context.subscription_days_left)) # Использованный трафик if "{traffic_used}" in text: From 7a30aa23dcb7cb9c754e6bbea3abc2edb985f09f Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sat, 20 Dec 2025 02:23:55 +0300 Subject: [PATCH 10/47] Update subscription parameter naming in main menu keyboard function --- app/keyboards/inline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index fa74bdcd..af477dd1 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -119,7 +119,7 @@ async def get_main_menu_keyboard_async( custom_buttons=custom_buttons or [], # Добавляем данные для плейсхолдеров username=username, - subscription_days_left=subscription_days_left, + subscription_days=subscription_days_left, traffic_used_gb=traffic_used_gb, traffic_left_gb=traffic_left_gb, referral_count=referral_count, From b8671ef07d0ef60cfe540be963fb20c6c3d7714d Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sat, 20 Dec 2025 02:29:41 +0300 Subject: [PATCH 11/47] Refactor subscription days variable usage in MenuLayoutService to improve clarity and consistency --- app/services/menu_layout/service.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/services/menu_layout/service.py b/app/services/menu_layout/service.py index d7d73323..7600ab2a 100644 --- a/app/services/menu_layout/service.py +++ b/app/services/menu_layout/service.py @@ -790,13 +790,13 @@ class MenuLayoutService: # has_subscription_days_left - минимум дней до окончания min_sub_days = conditions.get("has_subscription_days_left") if min_sub_days is not None: - if context.subscription_days_left < min_sub_days: + if context.subscription_days < min_sub_days: return False # max_subscription_days_left max_sub_days = conditions.get("max_subscription_days_left") if max_sub_days is not None: - if context.subscription_days_left > max_sub_days: + if context.subscription_days > max_sub_days: return False # is_trial_user @@ -871,7 +871,7 @@ class MenuLayoutService: # Дней до окончания подписки if "{subscription_days}" in text: - text = text.replace("{subscription_days}", str(context.subscription_days_left)) + text = text.replace("{subscription_days}", str(context.subscription_days)) # Использованный трафик if "{traffic_used}" in text: From 5fa627bd7ffe2d74f756c97f7597d8d522c4892d Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sat, 20 Dec 2025 02:31:52 +0300 Subject: [PATCH 12/47] Refactor subscription days calculation and update autopay property naming in main menu keyboard --- app/keyboards/inline.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index af477dd1..03130768 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -63,8 +63,13 @@ async def get_main_menu_keyboard_async( # Заполняем данными из подписки if subscription: # Дни до окончания подписки - if hasattr(subscription, 'ends_at') and subscription.ends_at: - days_left = (subscription.ends_at - datetime.now()).days + if hasattr(subscription, 'days_left'): + # Используем свойство из модели, которое правильно вычисляет дни в UTC + subscription_days_left = subscription.days_left + elif hasattr(subscription, 'end_date') and subscription.end_date: + # Fallback: вычисляем вручную, используя UTC + from datetime import datetime + days_left = (subscription.end_date - datetime.utcnow()).days subscription_days_left = max(0, days_left) # Трафик @@ -75,8 +80,8 @@ async def get_main_menu_keyboard_async( traffic_left_gb = max(0, subscription.traffic_limit_gb - (subscription.traffic_used_gb or 0)) # Автоплатеж - if hasattr(subscription, 'has_autopay'): - has_autopay = subscription.has_autopay + if hasattr(subscription, 'autopay_enabled'): + has_autopay = subscription.autopay_enabled # Получаем данные пользователя if user: From dd24b7ffdeecbe24be505987089e0ed717855ee6 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sat, 20 Dec 2025 02:42:40 +0300 Subject: [PATCH 13/47] Add ButtonStatsMiddleware for automatic button click logging in bot setup --- app/bot.py | 7 ++ app/middlewares/button_stats.py | 120 ++++++++++++++++++++++++++++++ docs/menu_stats_api_usage.md | 128 ++++++++++++++++++++++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 app/middlewares/button_stats.py create mode 100644 docs/menu_stats_api_usage.md diff --git a/app/bot.py b/app/bot.py index daa19f1f..bdfac407 100644 --- a/app/bot.py +++ b/app/bot.py @@ -12,6 +12,7 @@ from app.middlewares.throttling import ThrottlingMiddleware from app.middlewares.subscription_checker import SubscriptionStatusMiddleware from app.middlewares.maintenance import MaintenanceMiddleware from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware +from app.middlewares.button_stats import ButtonStatsMiddleware from app.services.maintenance_service import maintenance_service from app.utils.cache import cache @@ -127,6 +128,12 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: dp.pre_checkout_query.middleware(display_name_middleware) dp.message.middleware(ThrottlingMiddleware()) dp.callback_query.middleware(ThrottlingMiddleware()) + + # Middleware для автоматического логирования кликов по кнопкам + if settings.MENU_LAYOUT_ENABLED: + button_stats_middleware = ButtonStatsMiddleware() + dp.callback_query.middleware(button_stats_middleware) + logger.info("📊 ButtonStatsMiddleware активирован - автоматическое логирование кликов включено") if settings.CHANNEL_IS_REQUIRED_SUB: from app.middlewares.channel_checker import ChannelCheckerMiddleware diff --git a/app/middlewares/button_stats.py b/app/middlewares/button_stats.py new file mode 100644 index 00000000..66cfa25e --- /dev/null +++ b/app/middlewares/button_stats.py @@ -0,0 +1,120 @@ +"""Middleware для автоматического логирования кликов по кнопкам.""" + +import logging +from typing import Callable, Dict, Any, Awaitable +from aiogram import BaseMiddleware +from aiogram.types import CallbackQuery, TelegramObject + +from app.config import settings +from app.database.database import AsyncSessionLocal + +logger = logging.getLogger(__name__) + + +class ButtonStatsMiddleware(BaseMiddleware): + """Middleware для автоматического логирования статистики кликов по кнопкам.""" + + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + """Перехватывает CallbackQuery и логирует клики по кнопкам.""" + + # Обрабатываем только CallbackQuery + if not isinstance(event, CallbackQuery): + return await handler(event, data) + + # Пропускаем, если статистика отключена + if not settings.MENU_LAYOUT_ENABLED: + return await handler(event, data) + + # Логируем клик асинхронно, не блокируя обработку + try: + # Получаем callback_data + callback_data = event.data + if not callback_data: + return await handler(event, data) + + # Получаем user_id + user_id = event.from_user.id if event.from_user else None + + # Определяем тип кнопки по callback_data + button_type = self._determine_button_type(callback_data) + + # Получаем текст кнопки, если возможно + button_text = None + if event.message and hasattr(event.message, 'reply_markup'): + button_text = self._extract_button_text(event.message.reply_markup, callback_data) + + # Логируем в фоне, не блокируя обработку + # Используем asyncio.create_task для фоновой задачи + import asyncio + asyncio.create_task( + self._log_button_click_async( + button_id=callback_data, + user_id=user_id, + callback_data=callback_data, + button_type=button_type, + button_text=button_text + ) + ) + except Exception as e: + # Не прерываем обработку при ошибке логирования + logger.debug(f"Ошибка логирования клика по кнопке: {e}") + + # Продолжаем обработку + return await handler(event, data) + + def _determine_button_type(self, callback_data: str) -> str: + """Определяет тип кнопки по callback_data.""" + if callback_data.startswith("http://") or callback_data.startswith("https://"): + return "url" + elif callback_data.startswith("menu_") or callback_data.startswith("admin_"): + return "builtin" + else: + return "callback" + + def _extract_button_text(self, reply_markup, callback_data: str) -> str: + """Извлекает текст кнопки из клавиатуры.""" + try: + if not reply_markup or not hasattr(reply_markup, 'inline_keyboard'): + return None + + for row in reply_markup.inline_keyboard: + for button in row: + if hasattr(button, 'callback_data') and button.callback_data == callback_data: + if hasattr(button, 'text'): + return button.text + except Exception: + pass + return None + + async def _log_button_click_async( + self, + button_id: str, + user_id: int = None, + callback_data: str = None, + button_type: str = None, + button_text: str = None + ): + """Асинхронно логирует клик по кнопке.""" + try: + async with AsyncSessionLocal() as db: + try: + from app.services.menu_layout_service import MenuLayoutService + + await MenuLayoutService.log_button_click( + db, + button_id=button_id, + user_id=user_id, + callback_data=callback_data, + button_type=button_type, + button_text=button_text + ) + except Exception as e: + logger.error(f"Ошибка логирования клика по кнопке {button_id}: {e}") + except Exception as e: + logger.error(f"Ошибка создания сессии БД для логирования клика: {e}") + diff --git a/docs/menu_stats_api_usage.md b/docs/menu_stats_api_usage.md new file mode 100644 index 00000000..022f5fec --- /dev/null +++ b/docs/menu_stats_api_usage.md @@ -0,0 +1,128 @@ +# Использование API статистики кнопок меню + +## Обзор + +Система статистики кликов по кнопкам меню позволяет отслеживать, какие кнопки чаще всего нажимают пользователи. + +## API Эндпоинты + +### 1. Логирование клика по кнопке + +**POST** `/menu-layout/stats/log-click` + +**Параметры:** +- `button_id` (str) - ID кнопки +- `user_id` (int, optional) - ID пользователя (telegram_id) +- `callback_data` (str, optional) - callback_data кнопки +- `button_type` (str, optional) - тип кнопки: `builtin`, `callback`, `url`, `mini_app` +- `button_text` (str, optional) - текст кнопки на момент клика + +**Пример:** +```python +await MenuLayoutService.log_button_click( + db, + button_id="menu_balance", + user_id=123456789, + callback_data="menu_balance", + button_type="builtin", + button_text="💰 Баланс" +) +``` + +### 2. Получение статистики по конкретной кнопке + +**GET** `/menu-layout/stats/buttons/{button_id}?days=30` + +**Возвращает:** +- `clicks_total` - общее количество кликов +- `clicks_today` - клики сегодня +- `clicks_week` - клики за неделю +- `clicks_month` - клики за месяц +- `unique_users` - уникальные пользователи +- `last_click_at` - последний клик +- `clicks_by_day` - клики по дням + +**Пример:** +```python +stats = await MenuLayoutService.get_button_stats(db, "menu_balance", days=30) +# Возвращает: +# { +# "button_id": "menu_balance", +# "clicks_total": 150, +# "clicks_today": 5, +# "clicks_week": 25, +# "clicks_month": 150, +# "unique_users": 45, +# "last_click_at": datetime(...) +# } +``` + +### 3. Получение общей статистики по всем кнопкам + +**GET** `/menu-layout/stats?days=30` + +**Возвращает:** +- `items` - список статистики по каждой кнопке +- `total_clicks` - общее количество кликов +- `period_start` - начало периода +- `period_end` - конец периода + +**Пример:** +```python +all_stats = await MenuLayoutService.get_all_buttons_stats(db, days=30) +total = await MenuLayoutService.get_total_clicks(db, days=30) +``` + +## Автоматическое логирование + +✅ **Логирование кликов происходит автоматически!** + +Все клики по кнопкам автоматически логируются через `ButtonStatsMiddleware`. Middleware перехватывает все `CallbackQuery` события и логирует их в базу данных. + +### Как это работает + +1. При каждом клике по кнопке middleware автоматически: + - Извлекает `callback_data` (используется как `button_id`) + - Получает `user_id` из события + - Определяет тип кнопки (`builtin`, `callback`, `url`) + - Извлекает текст кнопки из клавиатуры (если доступен) + - Логирует в базу данных асинхронно (не блокирует обработку) + +2. Middleware активируется автоматически, если `MENU_LAYOUT_ENABLED=True` + +3. Логирование происходит в фоновом режиме и не влияет на производительность + +### Ручное логирование (опционально) + +Если нужно логировать клики вручную (например, для внешних интеграций), можно использовать API: + +```python +# Через сервис +await MenuLayoutService.log_button_click( + db, + button_id="custom_button", + user_id=user_id, + callback_data="custom_callback", + button_type="callback", + button_text="Кастомная кнопка" +) + +# Или через API эндпоинт +POST /menu-layout/stats/log-click +{ + "button_id": "custom_button", + "user_id": 123456789, + "callback_data": "custom_callback", + "button_type": "callback", + "button_text": "Кастомная кнопка" +} +``` + +## Важные замечания + +1. **Автоматическое логирование**: Все клики по кнопкам логируются автоматически через `ButtonStatsMiddleware` +2. **Требуется авторизация**: API эндпоинты для получения статистики требуют токен авторизации (`require_api_token`) +3. **button_id**: Используется `callback_data` кнопки как идентификатор +4. **Производительность**: Логирование выполняется асинхронно в фоне и не блокирует обработку запросов +5. **Активация**: Middleware работает только если `MENU_LAYOUT_ENABLED=True` в настройках + From 931b282f5b03a05485149e4a8ce8ac7d2c5bea67 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sat, 20 Dec 2025 03:27:31 +0300 Subject: [PATCH 14/47] Enhance button handling in MenuLayoutService to improve connect button identification and URL management --- app/services/menu_layout/service.py | 107 ++++++++++++++-- tests/services/test_menu_layout_service.py | 139 +++++++++++++++++++++ 2 files changed, 239 insertions(+), 7 deletions(-) create mode 100644 tests/services/test_menu_layout_service.py diff --git a/app/services/menu_layout/service.py b/app/services/menu_layout/service.py index 7600ab2a..601443c8 100644 --- a/app/services/menu_layout/service.py +++ b/app/services/menu_layout/service.py @@ -270,10 +270,44 @@ class MenuLayoutService: config = config.copy() buttons = config.get("buttons", {}) + # Улучшенное определение кнопки connect для разных форматов ID + actual_button_id = button_id if button_id not in buttons: - raise KeyError(f"Button '{button_id}' not found") + # Пробуем найти кнопку connect по разным форматам + if "connect" in button_id.lower(): + # Проверяем разные варианты: connect, callback:connect и т.д. + for key in buttons.keys(): + if key == "connect" or buttons[key].get("builtin_id") == "connect": + actual_button_id = key + logger.info( + f"🔗 Найдена кнопка connect по ID '{button_id}' -> '{actual_button_id}'" + ) + break + else: + # Если не нашли, пробуем найти по builtin_id + for key, button in buttons.items(): + if button.get("builtin_id") == "connect" or "connect" in str(button.get("builtin_id", "")).lower(): + actual_button_id = key + logger.info( + f"🔗 Найдена кнопка connect по builtin_id '{button_id}' -> '{actual_button_id}'" + ) + break + else: + raise KeyError(f"Button '{button_id}' not found") - button = buttons[button_id].copy() + if actual_button_id not in buttons: + raise KeyError(f"Button '{actual_button_id}' not found") + + button = buttons[actual_button_id].copy() + + # Логирование для отладки + if "connect" in actual_button_id.lower() or button.get("builtin_id") == "connect": + logger.info( + f"🔗 Обновление кнопки connect (ID: {actual_button_id}): " + f"open_mode={updates.get('open_mode')}, " + f"action={updates.get('action')}, " + f"webapp_url={updates.get('webapp_url')}" + ) # Применяем обновления if "text" in updates and updates["text"] is not None: @@ -296,12 +330,16 @@ class MenuLayoutService: # Для URL/MiniApp/callback кнопок можно менять action if button.get("type") in ("url", "mini_app", "callback"): button["action"] = updates["action"] + # Для builtin кнопок можно менять action, если open_mode == "direct" + # Это позволяет указать URL Mini App в поле action для кнопки connect + elif button.get("type") == "builtin" and updates.get("open_mode") == "direct": + button["action"] = updates["action"] if "open_mode" in updates and updates["open_mode"] is not None: button["open_mode"] = updates["open_mode"] if "webapp_url" in updates: button["webapp_url"] = updates["webapp_url"] - buttons[button_id] = button + buttons[actual_button_id] = button config["buttons"] = buttons await cls.save_config(db, config) @@ -905,11 +943,28 @@ class MenuLayoutService: ) -> Optional[InlineKeyboardButton]: """Построить кнопку из конфигурации.""" button_type = button_config.get("type", "builtin") + button_id = button_config.get("builtin_id") or button_config.get("id", "") text_config = button_config.get("text", {}) action = button_config.get("action", "") open_mode = button_config.get("open_mode", "callback") webapp_url = button_config.get("webapp_url") icon = button_config.get("icon", "") + + # Логирование для отладки кнопки connect + is_connect_button = ( + button_id == "connect" or + "connect" in str(button_id).lower() or + action == "subscription_connect" or + "connect" in str(action).lower() + ) + + if is_connect_button: + logger.info( + f"🔗 Построение кнопки connect: " + f"button_id={button_id}, type={button_type}, " + f"open_mode={open_mode}, action={action}, " + f"webapp_url={webapp_url}" + ) # Получаем текст text = cls._get_localized_text(text_config, context.language) @@ -936,13 +991,51 @@ class MenuLayoutService: return InlineKeyboardButton(text=text, callback_data=action) else: # builtin - проверяем open_mode - if open_mode == "direct" and webapp_url: + if open_mode == "direct": # Прямое открытие Mini App через WebAppInfo - return InlineKeyboardButton( - text=text, web_app=types.WebAppInfo(url=webapp_url) - ) + # Используем webapp_url, если указан, иначе action (если это URL) + url = webapp_url or action + + # Для кнопки connect: если URL не указан или это callback_data, + # пытаемся получить URL из подписки пользователя + if is_connect_button and (not url or not (url.startswith("http://") or url.startswith("https://"))): + if context.subscription: + from app.utils.subscription_utils import get_display_subscription_link + subscription_url = get_display_subscription_link(context.subscription) + if subscription_url: + url = subscription_url + logger.info( + f"🔗 Кнопка connect: получен URL из подписки: {url[:50]}..." + ) + # Если все еще нет URL, пробуем использовать настройку MINIAPP_CUSTOM_URL + if not url or not (url.startswith("http://") or url.startswith("https://")): + if settings.MINIAPP_CUSTOM_URL: + url = settings.MINIAPP_CUSTOM_URL + logger.info( + f"🔗 Кнопка connect: использован MINIAPP_CUSTOM_URL: {url[:50]}..." + ) + + # Проверяем, что это действительно URL + if url and (url.startswith("http://") or url.startswith("https://")): + logger.info( + f"🔗 Кнопка connect: open_mode=direct, используем URL: {url[:50]}..." + ) + return InlineKeyboardButton( + text=text, web_app=types.WebAppInfo(url=url) + ) + else: + logger.warning( + f"🔗 Кнопка connect: open_mode=direct, но URL не найден. " + f"webapp_url={webapp_url}, action={action}, " + f"subscription_url={'есть' if context.subscription else 'нет'}" + ) + # Fallback на callback_data + return InlineKeyboardButton(text=text, callback_data=action) else: # Стандартный callback_data + logger.debug( + f"Кнопка connect: open_mode={open_mode}, используем callback_data: {action}" + ) return InlineKeyboardButton(text=text, callback_data=action) # --- Построение клавиатуры --- diff --git a/tests/services/test_menu_layout_service.py b/tests/services/test_menu_layout_service.py new file mode 100644 index 00000000..94d877f4 --- /dev/null +++ b/tests/services/test_menu_layout_service.py @@ -0,0 +1,139 @@ +"""Тесты для MenuLayoutService.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from aiogram.types import InlineKeyboardButton + +from app.services.menu_layout.service import MenuLayoutService +from app.services.menu_layout.context import MenuContext + + +@pytest.mark.anyio +async def test_build_button_connect_direct_mode_with_url(): + """Тест: кнопка connect с open_mode=direct и валидным URL должна создавать WebAppInfo.""" + button_config = { + "type": "builtin", + "builtin_id": "connect", + "text": {"ru": "🔗 Подключиться"}, + "action": "subscription_connect", + "open_mode": "direct", + "webapp_url": "https://example.com/miniapp", + } + + context = MenuContext( + language="ru", + has_active_subscription=True, + subscription_is_active=True, + ) + + texts = MagicMock() + texts.t = lambda key, default: default + + button = MenuLayoutService._build_button(button_config, context, texts) + + assert button is not None + assert isinstance(button, InlineKeyboardButton) + assert button.web_app is not None + assert button.web_app.url == "https://example.com/miniapp" + assert button.callback_data is None + + +@pytest.mark.anyio +async def test_build_button_connect_direct_mode_with_subscription_url(): + """Тест: кнопка connect с open_mode=direct должна получать URL из подписки.""" + button_config = { + "type": "builtin", + "builtin_id": "connect", + "text": {"ru": "🔗 Подключиться"}, + "action": "subscription_connect", + "open_mode": "direct", + "webapp_url": None, + } + + # Мокаем подписку с URL + mock_subscription = MagicMock() + mock_subscription.subscription_url = "https://subscription.example.com/link" + mock_subscription.subscription_crypto_link = None + + context = MenuContext( + language="ru", + has_active_subscription=True, + subscription_is_active=True, + subscription=mock_subscription, + ) + + texts = MagicMock() + texts.t = lambda key, default: default + + with patch('app.utils.subscription_utils.get_display_subscription_link') as mock_get_link: + mock_get_link.return_value = "https://subscription.example.com/link" + + button = MenuLayoutService._build_button(button_config, context, texts) + + assert button is not None + assert isinstance(button, InlineKeyboardButton) + assert button.web_app is not None + assert button.web_app.url == "https://subscription.example.com/link" + + +@pytest.mark.anyio +async def test_build_button_connect_callback_mode(): + """Тест: кнопка connect с open_mode=callback должна создавать callback кнопку.""" + button_config = { + "type": "builtin", + "builtin_id": "connect", + "text": {"ru": "🔗 Подключиться"}, + "action": "subscription_connect", + "open_mode": "callback", + "webapp_url": None, + } + + context = MenuContext( + language="ru", + has_active_subscription=True, + subscription_is_active=True, + ) + + texts = MagicMock() + texts.t = lambda key, default: default + + button = MenuLayoutService._build_button(button_config, context, texts) + + assert button is not None + assert isinstance(button, InlineKeyboardButton) + assert button.callback_data == "subscription_connect" + assert button.web_app is None + + +@pytest.mark.anyio +async def test_build_button_connect_direct_mode_fallback_to_callback(): + """Тест: кнопка connect с open_mode=direct без URL должна fallback на callback.""" + button_config = { + "type": "builtin", + "builtin_id": "connect", + "text": {"ru": "🔗 Подключиться"}, + "action": "subscription_connect", + "open_mode": "direct", + "webapp_url": None, + } + + context = MenuContext( + language="ru", + has_active_subscription=True, + subscription_is_active=True, + subscription=None, # Нет подписки + ) + + texts = MagicMock() + texts.t = lambda key, default: default + + with patch('app.services.menu_layout.service.settings') as mock_settings: + mock_settings.MINIAPP_CUSTOM_URL = None + + button = MenuLayoutService._build_button(button_config, context, texts) + + assert button is not None + assert isinstance(button, InlineKeyboardButton) + # Должен fallback на callback_data, так как URL не найден + assert button.callback_data == "subscription_connect" + From d75fc0c60f46e2e5d3481a9fe76119ebbb7fb512 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sat, 20 Dec 2025 03:32:34 +0300 Subject: [PATCH 15/47] Add statistics endpoints in MenuLayoutService for button clicks, including by type, hour, weekday, top users, period comparison, and user click sequences --- app/services/menu_layout/service.py | 63 ++++++ app/services/menu_layout/stats_service.py | 235 +++++++++++++++++++++- app/webapi/routes/menu_layout.py | 166 ++++++++++++++- app/webapi/schemas/menu_layout.py | 85 ++++++++ docs/menu_stats_api_usage.md | 131 ++++++++++++ 5 files changed, 675 insertions(+), 5 deletions(-) diff --git a/app/services/menu_layout/service.py b/app/services/menu_layout/service.py index 601443c8..053dd5df 100644 --- a/app/services/menu_layout/service.py +++ b/app/services/menu_layout/service.py @@ -1258,3 +1258,66 @@ class MenuLayoutService: ) -> int: """Получить общее количество кликов за период.""" return await MenuLayoutStatsService.get_total_clicks(db, days) + + @classmethod + async def get_stats_by_button_type( + cls, + db: AsyncSession, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Получить статистику кликов по типам кнопок.""" + return await MenuLayoutStatsService.get_stats_by_button_type(db, days) + + @classmethod + async def get_clicks_by_hour( + cls, + db: AsyncSession, + button_id: Optional[str] = None, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Получить статистику кликов по часам дня.""" + return await MenuLayoutStatsService.get_clicks_by_hour(db, button_id, days) + + @classmethod + async def get_clicks_by_weekday( + cls, + db: AsyncSession, + button_id: Optional[str] = None, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Получить статистику кликов по дням недели.""" + return await MenuLayoutStatsService.get_clicks_by_weekday(db, button_id, days) + + @classmethod + async def get_top_users( + cls, + db: AsyncSession, + button_id: Optional[str] = None, + limit: int = 10, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Получить топ пользователей по количеству кликов.""" + return await MenuLayoutStatsService.get_top_users(db, button_id, limit, days) + + @classmethod + async def get_period_comparison( + cls, + db: AsyncSession, + button_id: Optional[str] = None, + current_days: int = 7, + previous_days: int = 7, + ) -> Dict[str, Any]: + """Сравнить статистику текущего и предыдущего периода.""" + return await MenuLayoutStatsService.get_period_comparison( + db, button_id, current_days, previous_days + ) + + @classmethod + async def get_user_click_sequences( + cls, + db: AsyncSession, + user_id: int, + limit: int = 50, + ) -> List[Dict[str, Any]]: + """Получить последовательности кликов пользователя.""" + return await MenuLayoutStatsService.get_click_sequences(db, user_id, limit) diff --git a/app/services/menu_layout/stats_service.py b/app/services/menu_layout/stats_service.py index dd0f0159..af26f314 100644 --- a/app/services/menu_layout/stats_service.py +++ b/app/services/menu_layout/stats_service.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import datetime, timedelta from typing import Any, Dict, List, Optional -from sqlalchemy import select, func, and_, desc +from sqlalchemy import select, func, and_, desc, case from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import ButtonClickLog @@ -186,3 +186,236 @@ class MenuLayoutStatsService: .where(ButtonClickLog.clicked_at >= start_date) ) return result.scalar() or 0 + + @classmethod + async def get_stats_by_button_type( + cls, + db: AsyncSession, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Получить статистику кликов по типам кнопок.""" + start_date = datetime.now() - timedelta(days=days) + + result = await db.execute( + select( + ButtonClickLog.button_type, + func.count(ButtonClickLog.id).label("clicks_total"), + func.count(func.distinct(ButtonClickLog.user_id)).label("unique_users"), + ) + .where(and_( + ButtonClickLog.clicked_at >= start_date, + ButtonClickLog.button_type.isnot(None) + )) + .group_by(ButtonClickLog.button_type) + .order_by(desc(func.count(ButtonClickLog.id))) + ) + + return [ + { + "button_type": row.button_type or "unknown", + "clicks_total": row.clicks_total, + "unique_users": row.unique_users, + } + for row in result.all() + ] + + @classmethod + async def get_clicks_by_hour( + cls, + db: AsyncSession, + button_id: Optional[str] = None, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Получить статистику кликов по часам дня.""" + start_date = datetime.now() - timedelta(days=days) + + query = select( + func.extract('hour', ButtonClickLog.clicked_at).label("hour"), + func.count(ButtonClickLog.id).label("count") + ).where(ButtonClickLog.clicked_at >= start_date) + + if button_id: + query = query.where(ButtonClickLog.button_id == button_id) + + result = await db.execute( + query + .group_by(func.extract('hour', ButtonClickLog.clicked_at)) + .order_by(func.extract('hour', ButtonClickLog.clicked_at)) + ) + + return [ + {"hour": int(row.hour), "count": row.count} + for row in result.all() + ] + + @classmethod + async def get_clicks_by_weekday( + cls, + db: AsyncSession, + button_id: Optional[str] = None, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Получить статистику кликов по дням недели. + + PostgreSQL DOW возвращает 0=воскресенье, 1=понедельник, ..., 6=суббота + Преобразуем в 0=понедельник, 6=воскресенье для удобства. + """ + start_date = datetime.now() - timedelta(days=days) + + # Используем CASE для преобразования: 0 (воскресенье) -> 6, остальные -1 + weekday_expr = case( + (func.extract('dow', ButtonClickLog.clicked_at) == 0, 6), + else_=func.extract('dow', ButtonClickLog.clicked_at) - 1 + ).label("weekday") + + query = select( + weekday_expr, + func.count(ButtonClickLog.id).label("count") + ).where(ButtonClickLog.clicked_at >= start_date) + + if button_id: + query = query.where(ButtonClickLog.button_id == button_id) + + result = await db.execute( + query + .group_by(weekday_expr) + .order_by(weekday_expr) + ) + + weekday_names = ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье"] + return [ + { + "weekday": int(row.weekday), + "weekday_name": weekday_names[int(row.weekday)], + "count": row.count + } + for row in result.all() + ] + + @classmethod + async def get_top_users( + cls, + db: AsyncSession, + button_id: Optional[str] = None, + limit: int = 10, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Получить топ пользователей по количеству кликов.""" + start_date = datetime.now() - timedelta(days=days) + + query = select( + ButtonClickLog.user_id, + func.count(ButtonClickLog.id).label("clicks_count"), + func.max(ButtonClickLog.clicked_at).label("last_click_at") + ).where(and_( + ButtonClickLog.clicked_at >= start_date, + ButtonClickLog.user_id.isnot(None) + )) + + if button_id: + query = query.where(ButtonClickLog.button_id == button_id) + + result = await db.execute( + query + .group_by(ButtonClickLog.user_id) + .order_by(desc(func.count(ButtonClickLog.id))) + .limit(limit) + ) + + return [ + { + "user_id": row.user_id, + "clicks_count": row.clicks_count, + "last_click_at": row.last_click_at, + } + for row in result.all() + ] + + @classmethod + async def get_period_comparison( + cls, + db: AsyncSession, + button_id: Optional[str] = None, + current_days: int = 7, + previous_days: int = 7, + ) -> Dict[str, Any]: + """Сравнить статистику текущего и предыдущего периода.""" + now = datetime.now() + current_start = now - timedelta(days=current_days) + previous_start = current_start - timedelta(days=previous_days) + previous_end = current_start + + query_current = select(func.count(ButtonClickLog.id)) + query_previous = select(func.count(ButtonClickLog.id)) + + if button_id: + query_current = query_current.where(ButtonClickLog.button_id == button_id) + query_previous = query_previous.where(ButtonClickLog.button_id == button_id) + + query_current = query_current.where( + ButtonClickLog.clicked_at >= current_start + ) + query_previous = query_previous.where( + and_( + ButtonClickLog.clicked_at >= previous_start, + ButtonClickLog.clicked_at < previous_end + ) + ) + + current_result = await db.execute(query_current) + previous_result = await db.execute(query_previous) + + current_count = current_result.scalar() or 0 + previous_count = previous_result.scalar() or 0 + + change_percent = 0 + if previous_count > 0: + change_percent = ((current_count - previous_count) / previous_count) * 100 + + return { + "current_period": { + "clicks": current_count, + "days": current_days, + "start": current_start, + "end": now, + }, + "previous_period": { + "clicks": previous_count, + "days": previous_days, + "start": previous_start, + "end": previous_end, + }, + "change": { + "absolute": current_count - previous_count, + "percent": round(change_percent, 2), + "trend": "up" if change_percent > 0 else "down" if change_percent < 0 else "stable", + }, + } + + @classmethod + async def get_click_sequences( + cls, + db: AsyncSession, + user_id: int, + limit: int = 50, + ) -> List[Dict[str, Any]]: + """Получить последовательности кликов пользователя.""" + result = await db.execute( + select( + ButtonClickLog.button_id, + ButtonClickLog.button_text, + ButtonClickLog.clicked_at, + ) + .where(ButtonClickLog.user_id == user_id) + .order_by(ButtonClickLog.clicked_at) + .limit(limit) + ) + + return [ + { + "button_id": row.button_id, + "button_text": row.button_text, + "clicked_at": row.clicked_at, + } + for row in result.all() + ] \ No newline at end of file diff --git a/app/webapi/routes/menu_layout.py b/app/webapi/routes/menu_layout.py index fb8d6513..f025d825 100644 --- a/app/webapi/routes/menu_layout.py +++ b/app/webapi/routes/menu_layout.py @@ -24,9 +24,13 @@ from ..schemas.menu_layout import ( ButtonClickStats, ButtonClickStatsResponse, ButtonConditions, + ButtonTypeStats, + ButtonTypeStatsResponse, ButtonUpdateRequest, DynamicPlaceholder, DynamicPlaceholdersResponse, + HourlyStats, + HourlyStatsResponse, MenuButtonConfig, MenuClickStatsResponse, MenuLayoutConfig, @@ -47,7 +51,14 @@ from ..schemas.menu_layout import ( MenuRowConfig, MoveButtonResponse, MoveButtonToRowRequest, + PeriodComparisonResponse, ReorderButtonsInRowRequest, + TopUserStats, + TopUsersResponse, + UserClickSequence, + UserClickSequencesResponse, + WeekdayStats, + WeekdayStatsResponse, ReorderButtonsResponse, RowsReorderRequest, SwapButtonsRequest, @@ -128,9 +139,15 @@ async def update_menu_layout( config["rows"] = [row.model_dump() for row in payload.rows] if payload.buttons is not None: - config["buttons"] = { - btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items() - } + buttons_config = {} + for btn_id, btn in payload.buttons.items(): + btn_dict = btn.model_dump() + # Автоматически определяем наличие плейсхолдеров, если dynamic_text не установлен + if not btn_dict.get("dynamic_text", False): + from app.services.menu_layout.service import MenuLayoutService + btn_dict["dynamic_text"] = MenuLayoutService._text_has_placeholders(btn_dict.get("text", {})) + buttons_config[btn_id] = btn_dict + config["buttons"] = buttons_config await MenuLayoutService.save_config(db, config) updated_at = await MenuLayoutService.get_config_updated_at(db) @@ -296,6 +313,12 @@ async def add_custom_button( ) -> MenuButtonConfig: """Добавить кастомную кнопку (URL, MiniApp или callback).""" try: + # Автоматически определяем наличие плейсхолдеров, если dynamic_text не установлен + dynamic_text = payload.dynamic_text + if not dynamic_text: + from app.services.menu_layout.service import MenuLayoutService + dynamic_text = MenuLayoutService._text_has_placeholders(payload.text) + button_config = { "type": payload.type.value, "text": payload.text, @@ -305,7 +328,7 @@ async def add_custom_button( "conditions": payload.conditions.model_dump(exclude_none=True) if payload.conditions else None, - "dynamic_text": payload.dynamic_text, + "dynamic_text": dynamic_text, "description": payload.description, } button = await MenuLayoutService.add_custom_button( @@ -805,3 +828,138 @@ async def log_button_click( button_text=button_text, ) return {"success": True} + + +@router.get("/stats/by-type", response_model=ButtonTypeStatsResponse) +async def get_stats_by_button_type( + days: int = 30, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> ButtonTypeStatsResponse: + """Получить статистику кликов по типам кнопок (builtin, callback, url, mini_app).""" + stats = await MenuLayoutService.get_stats_by_button_type(db, days) + total_clicks = sum(s["clicks_total"] for s in stats) + + return ButtonTypeStatsResponse( + items=[ + ButtonTypeStats( + button_type=s["button_type"], + clicks_total=s["clicks_total"], + unique_users=s["unique_users"], + ) + for s in stats + ], + total_clicks=total_clicks, + ) + + +@router.get("/stats/by-hour", response_model=HourlyStatsResponse) +async def get_clicks_by_hour( + button_id: Optional[str] = None, + days: int = 30, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> HourlyStatsResponse: + """Получить статистику кликов по часам дня (0-23).""" + stats = await MenuLayoutService.get_clicks_by_hour(db, button_id, days) + + return HourlyStatsResponse( + items=[ + HourlyStats(hour=s["hour"], count=s["count"]) + for s in stats + ], + button_id=button_id, + ) + + +@router.get("/stats/by-weekday", response_model=WeekdayStatsResponse) +async def get_clicks_by_weekday( + button_id: Optional[str] = None, + days: int = 30, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> WeekdayStatsResponse: + """Получить статистику кликов по дням недели.""" + stats = await MenuLayoutService.get_clicks_by_weekday(db, button_id, days) + + return WeekdayStatsResponse( + items=[ + WeekdayStats( + weekday=s["weekday"], + weekday_name=s["weekday_name"], + count=s["count"] + ) + for s in stats + ], + button_id=button_id, + ) + + +@router.get("/stats/top-users", response_model=TopUsersResponse) +async def get_top_users( + button_id: Optional[str] = None, + limit: int = 10, + days: int = 30, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> TopUsersResponse: + """Получить топ пользователей по количеству кликов.""" + stats = await MenuLayoutService.get_top_users(db, button_id, limit, days) + + return TopUsersResponse( + items=[ + TopUserStats( + user_id=s["user_id"], + clicks_count=s["clicks_count"], + last_click_at=s["last_click_at"], + ) + for s in stats + ], + button_id=button_id, + limit=limit, + ) + + +@router.get("/stats/compare", response_model=PeriodComparisonResponse) +async def get_period_comparison( + button_id: Optional[str] = None, + current_days: int = 7, + previous_days: int = 7, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> PeriodComparisonResponse: + """Сравнить статистику текущего и предыдущего периода.""" + comparison = await MenuLayoutService.get_period_comparison( + db, button_id, current_days, previous_days + ) + + return PeriodComparisonResponse( + current_period=comparison["current_period"], + previous_period=comparison["previous_period"], + change=comparison["change"], + button_id=button_id, + ) + + +@router.get("/stats/users/{user_id}/sequences", response_model=UserClickSequencesResponse) +async def get_user_click_sequences( + user_id: int, + limit: int = 50, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> UserClickSequencesResponse: + """Получить последовательности кликов пользователя.""" + sequences = await MenuLayoutService.get_user_click_sequences(db, user_id, limit) + + return UserClickSequencesResponse( + user_id=user_id, + items=[ + UserClickSequence( + button_id=s["button_id"], + button_text=s["button_text"], + clicked_at=s["clicked_at"], + ) + for s in sequences + ], + total=len(sequences), + ) \ No newline at end of file diff --git a/app/webapi/schemas/menu_layout.py b/app/webapi/schemas/menu_layout.py index 86e3823b..e8c89c3c 100644 --- a/app/webapi/schemas/menu_layout.py +++ b/app/webapi/schemas/menu_layout.py @@ -587,6 +587,91 @@ class MenuClickStatsResponse(BaseModel): period_end: datetime +class ButtonTypeStats(BaseModel): + """Статистика по типу кнопки.""" + + button_type: str + clicks_total: int + unique_users: int + + +class ButtonTypeStatsResponse(BaseModel): + """Статистика кликов по типам кнопок.""" + + items: List[ButtonTypeStats] + total_clicks: int + + +class HourlyStats(BaseModel): + """Статистика по часам.""" + + hour: int + count: int + + +class HourlyStatsResponse(BaseModel): + """Статистика кликов по часам дня.""" + + items: List[HourlyStats] + button_id: Optional[str] = None + + +class WeekdayStats(BaseModel): + """Статистика по дням недели.""" + + weekday: int + weekday_name: str + count: int + + +class WeekdayStatsResponse(BaseModel): + """Статистика кликов по дням недели.""" + + items: List[WeekdayStats] + button_id: Optional[str] = None + + +class TopUserStats(BaseModel): + """Статистика пользователя.""" + + user_id: int + clicks_count: int + last_click_at: Optional[datetime] = None + + +class TopUsersResponse(BaseModel): + """Топ пользователей по кликам.""" + + items: List[TopUserStats] + button_id: Optional[str] = None + limit: int + + +class PeriodComparisonResponse(BaseModel): + """Сравнение периодов.""" + + current_period: Dict[str, Any] + previous_period: Dict[str, Any] + change: Dict[str, Any] + button_id: Optional[str] = None + + +class UserClickSequence(BaseModel): + """Последовательность кликов пользователя.""" + + button_id: str + button_text: Optional[str] = None + clicked_at: datetime + + +class UserClickSequencesResponse(BaseModel): + """Последовательности кликов пользователя.""" + + user_id: int + items: List[UserClickSequence] + total: int + + # --- Схемы для плейсхолдеров --- diff --git a/docs/menu_stats_api_usage.md b/docs/menu_stats_api_usage.md index 022f5fec..cc5125c6 100644 --- a/docs/menu_stats_api_usage.md +++ b/docs/menu_stats_api_usage.md @@ -118,6 +118,136 @@ POST /menu-layout/stats/log-click } ``` +### 4. Статистика по типам кнопок + +**GET** `/menu-layout/stats/by-type?days=30` + +**Возвращает:** +- Статистику кликов по каждому типу кнопок (builtin, callback, url, mini_app) +- Общее количество кликов по типам + +**Пример:** +```python +stats = await MenuLayoutService.get_stats_by_button_type(db, days=30) +# Возвращает: +# [ +# {"button_type": "builtin", "clicks_total": 500, "unique_users": 100}, +# {"button_type": "callback", "clicks_total": 200, "unique_users": 50}, +# ... +# ] +``` + +### 5. Статистика по часам дня + +**GET** `/menu-layout/stats/by-hour?button_id=menu_balance&days=30` + +**Параметры:** +- `button_id` (optional) - ID кнопки для фильтрации +- `days` (default: 30) - период в днях + +**Возвращает:** +- Распределение кликов по часам дня (0-23) + +**Пример:** +```python +stats = await MenuLayoutService.get_clicks_by_hour(db, button_id="menu_balance", days=30) +# Возвращает: +# [ +# {"hour": 9, "count": 50}, +# {"hour": 10, "count": 75}, +# ... +# ] +``` + +### 6. Статистика по дням недели + +**GET** `/menu-layout/stats/by-weekday?button_id=menu_balance&days=30` + +**Возвращает:** +- Распределение кликов по дням недели (0=понедельник, 6=воскресенье) + +**Пример:** +```python +stats = await MenuLayoutService.get_clicks_by_weekday(db, button_id="menu_balance", days=30) +# Возвращает: +# [ +# {"weekday": 0, "weekday_name": "Понедельник", "count": 100}, +# {"weekday": 1, "weekday_name": "Вторник", "count": 120}, +# ... +# ] +``` + +### 7. Топ пользователей по кликам + +**GET** `/menu-layout/stats/top-users?button_id=menu_balance&limit=10&days=30` + +**Параметры:** +- `button_id` (optional) - ID кнопки для фильтрации +- `limit` (default: 10) - количество пользователей +- `days` (default: 30) - период в днях + +**Возвращает:** +- Список пользователей с наибольшим количеством кликов + +**Пример:** +```python +top_users = await MenuLayoutService.get_top_users(db, button_id="menu_balance", limit=10, days=30) +# Возвращает: +# [ +# {"user_id": 123456789, "clicks_count": 50, "last_click_at": datetime(...)}, +# ... +# ] +``` + +### 8. Сравнение периодов + +**GET** `/menu-layout/stats/compare?button_id=menu_balance¤t_days=7&previous_days=7` + +**Параметры:** +- `button_id` (optional) - ID кнопки для фильтрации +- `current_days` (default: 7) - период текущего сравнения +- `previous_days` (default: 7) - период предыдущего сравнения + +**Возвращает:** +- Сравнение текущего и предыдущего периода +- Изменение в абсолютных числах и процентах +- Тренд (up/down/stable) + +**Пример:** +```python +comparison = await MenuLayoutService.get_period_comparison( + db, button_id="menu_balance", current_days=7, previous_days=7 +) +# Возвращает: +# { +# "current_period": {"clicks": 100, "days": 7, ...}, +# "previous_period": {"clicks": 80, "days": 7, ...}, +# "change": {"absolute": 20, "percent": 25.0, "trend": "up"} +# } +``` + +### 9. Последовательности кликов пользователя + +**GET** `/menu-layout/stats/users/{user_id}/sequences?limit=50` + +**Параметры:** +- `user_id` (path) - ID пользователя +- `limit` (default: 50) - максимальное количество записей + +**Возвращает:** +- Хронологическую последовательность кликов пользователя + +**Пример:** +```python +sequences = await MenuLayoutService.get_user_click_sequences(db, user_id=123456789, limit=50) +# Возвращает: +# [ +# {"button_id": "menu_balance", "button_text": "💰 Баланс", "clicked_at": datetime(...)}, +# {"button_id": "menu_subscription", "button_text": "📊 Подписка", "clicked_at": datetime(...)}, +# ... +# ] +``` + ## Важные замечания 1. **Автоматическое логирование**: Все клики по кнопкам логируются автоматически через `ButtonStatsMiddleware` @@ -125,4 +255,5 @@ POST /menu-layout/stats/log-click 3. **button_id**: Используется `callback_data` кнопки как идентификатор 4. **Производительность**: Логирование выполняется асинхронно в фоне и не блокирует обработку запросов 5. **Активация**: Middleware работает только если `MENU_LAYOUT_ENABLED=True` в настройках +6. **Временные зоны**: Все временные метрики используют локальное время сервера From c8c79ea8073e5dfadcccd1c4293c68c123f75219 Mon Sep 17 00:00:00 2001 From: gy9vin Date: Sat, 20 Dec 2025 14:32:26 +0300 Subject: [PATCH 16/47] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=80=D0=B5=D1=84=D0=B5=D1=80=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BA=D0=BE=D0=BD=D0=BA=D1=83?= =?UTF-8?q?=D1=80=D1=81=D0=B0!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/handlers/admin/contests.py | 51 +++++++++++++++++++++ app/keyboards/admin.py | 6 +++ app/services/referral_contest_service.py | 57 +++++++++++++++++++++++- 3 files changed, 113 insertions(+), 1 deletion(-) diff --git a/app/handlers/admin/contests.py b/app/handlers/admin/contests.py index ee5c04cd..e88ce95b 100644 --- a/app/handlers/admin/contests.py +++ b/app/handlers/admin/contests.py @@ -659,6 +659,56 @@ async def finalize_contest_creation(message: types.Message, state: FSMContext, d ) +@admin_required +@error_handler +async def show_detailed_stats( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + if not settings.is_contests_enabled(): + await callback.answer( + get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."), + show_alert=True, + ) + return + + contest_id = int(callback.data.split("_")[-1]) + contest = await get_referral_contest(db, contest_id) + + if not contest: + await callback.answer("Конкурс не найден.", show_alert=True) + return + + from app.services.referral_contest_service import referral_contest_service + stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id) + + lines = [ + "📈 Детальная статистика конкурса", + f"🏆 {contest.title}", + "", + f"👥 Участников: {stats['total_participants']}", + f"📨 Приглашено рефералов: {stats['total_invited']}", + f"💰 Оплатили подписок: {stats['total_paid_amount'] // 100} руб.", + f"❌ Не оплатили: {stats['total_unpaid']}", + "", + "📊 По участникам:", + ] + + for p in stats['participants']: + lines.append( + f"• {p['full_name']}: приглашено {p['total_referrals']}, оплатили {p['paid_referrals']}, не оплатили {p['unpaid_referrals']}, сумма {p['total_paid_amount'] // 100} руб." + ) + + await callback.message.edit_text( + "\n".join(lines), + reply_markup=get_referral_contest_manage_keyboard( + contest_id, is_active=contest.is_active, language=db_user.language + ), + ) + await callback.answer() + + def register_handlers(dp: Dispatcher): dp.callback_query.register(show_contests_menu, F.data == "admin_contests") dp.callback_query.register(show_referral_contests_menu, F.data == "admin_contests_referral") @@ -669,6 +719,7 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(prompt_edit_summary_times, F.data.startswith("admin_contest_edit_times_")) dp.callback_query.register(delete_contest, F.data.startswith("admin_contest_delete_")) dp.callback_query.register(show_leaderboard, F.data.startswith("admin_contest_leaderboard_")) + dp.callback_query.register(show_detailed_stats, F.data.startswith("admin_contest_detailed_stats_")) dp.callback_query.register(start_contest_creation, F.data == "admin_contests_create") dp.callback_query.register(select_contest_mode, F.data.in_(["admin_contest_mode_paid", "admin_contest_mode_registered"])) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index e3846883..0ab2cd7f 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -616,6 +616,12 @@ def get_referral_contest_manage_keyboard( callback_data=f"admin_contest_toggle_{contest_id}", ), ], + [ + InlineKeyboardButton( + text="📈 Детальная статистика", + callback_data=f"admin_contest_detailed_stats_{contest_id}", + ), + ], [ InlineKeyboardButton( text=_t(texts, "ADMIN_CONTEST_EDIT_SUMMARY_TIMES", "🕒 Итоги в день"), diff --git a/app/services/referral_contest_service.py b/app/services/referral_contest_service.py index bd19f21d..5ffb775c 100644 --- a/app/services/referral_contest_service.py +++ b/app/services/referral_contest_service.py @@ -277,7 +277,7 @@ class ReferralContestService: f"Название: {contest.title}", f"Статус: {'финал' if is_final else 'дневная сводка'}", f"Временная зона: {tz.key}", - f"Всего участников: {len(leaderboard)}", + f"Всего рефералов: {total_events}", "", "Топ участников:", ] @@ -390,6 +390,61 @@ class ReferralContestService: return "\n".join(lines) + async def get_detailed_contest_stats(self, db: AsyncSession, contest_id: int) -> dict: + from app.database.crud.referral_contest import get_contest_events, get_contest_leaderboard + from app.database.crud.user import get_user_by_id + + leaderboard = list(await get_contest_leaderboard(db, contest_id)) + total_participants = len(leaderboard) + + # Get all events for the contest + events = await get_contest_events(db, contest_id) + + # Total invited: unique referral_ids from events + invited_set = set(event.referral_id for event in events) + total_invited = len(invited_set) + + # Paid events: events with event_type == 'subscription_purchase' + paid_events = [e for e in events if e.event_type == 'subscription_purchase'] + total_paid_amount = sum(e.amount_kopeks for e in paid_events) + + # Paid referrals: unique referral_ids from paid events + paid_referrals_set = set(e.referral_id for e in paid_events) + total_paid_referrals = len(paid_referrals_set) + + total_unpaid = total_invited - total_paid_referrals + + # Per participant stats + participants_stats = [] + for user, score, _ in leaderboard: + referrer_id = user.id + # Total referrals for this referrer + referrer_events = [e for e in events if e.referrer_id == referrer_id] + total_referrals = len(set(e.referral_id for e in referrer_events)) + + # Paid referrals for this referrer + paid_referrer_events = [e for e in referrer_events if e.event_type == 'subscription_purchase'] + paid_referrals = len(set(e.referral_id for e in paid_referrer_events)) + unpaid_referrals = total_referrals - paid_referrals + total_paid_amount_for_referrer = sum(e.amount_kopeks for e in paid_referrer_events) + + participants_stats.append({ + 'referrer_id': referrer_id, + 'full_name': user.full_name, + 'total_referrals': total_referrals, + 'paid_referrals': paid_referrals, + 'unpaid_referrals': unpaid_referrals, + 'total_paid_amount': total_paid_amount_for_referrer, + }) + + return { + 'total_participants': total_participants, + 'total_invited': total_invited, + 'total_paid_amount': total_paid_amount, + 'total_unpaid': total_unpaid, + 'participants': participants_stats, + } + def _get_timezone(self, contest: ReferralContest) -> ZoneInfo: tz_name = contest.timezone or settings.TIMEZONE try: From bdc1df737bf8cc852887b4a9b10a5ab423a0bd93 Mon Sep 17 00:00:00 2001 From: gy9vin Date: Sat, 20 Dec 2025 14:57:38 +0300 Subject: [PATCH 17/47] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81=20=D0=B0=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D0=BF=D1=80=D0=BE=D0=B4=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B8=20=D0=A4=D0=B8=D0=BA=D1=81=D1=8B=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D0=BA=D1=83=D1=80=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/config.py | 2 + app/database/crud/referral_contest.py | 10 ++++ app/handlers/admin/contests.py | 69 +++++++++++++++++++++++---- app/services/monitoring_service.py | 17 ++++--- 4 files changed, 84 insertions(+), 14 deletions(-) diff --git a/app/config.py b/app/config.py index a87c8dd0..434c9e30 100644 --- a/app/config.py +++ b/app/config.py @@ -182,6 +182,8 @@ class Settings(BaseSettings): AUTOPAY_WARNING_DAYS: str = "3,1" + ENABLE_AUTOPAY: bool = False + DEFAULT_AUTOPAY_ENABLED: bool = False DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3 MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000 diff --git a/app/database/crud/referral_contest.py b/app/database/crud/referral_contest.py index 652e2ee3..89e7426b 100644 --- a/app/database/crud/referral_contest.py +++ b/app/database/crud/referral_contest.py @@ -248,6 +248,16 @@ async def get_contest_events_count( return int(result.scalar_one()) +async def get_contest_events( + db: AsyncSession, + contest_id: int, +) -> List[ReferralContestEvent]: + result = await db.execute( + select(ReferralContestEvent).where(ReferralContestEvent.contest_id == contest_id) + ) + return list(result.scalars().all()) + + async def mark_daily_summary_sent( db: AsyncSession, contest: ReferralContest, diff --git a/app/handlers/admin/contests.py b/app/handlers/admin/contests.py index e88ce95b..7402cdab 100644 --- a/app/handlers/admin/contests.py +++ b/app/handlers/admin/contests.py @@ -683,7 +683,8 @@ async def show_detailed_stats( from app.services.referral_contest_service import referral_contest_service stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id) - lines = [ + # Общее сообщение с основной статистикой + general_lines = [ "📈 Детальная статистика конкурса", f"🏆 {contest.title}", "", @@ -692,23 +693,74 @@ async def show_detailed_stats( f"💰 Оплатили подписок: {stats['total_paid_amount'] // 100} руб.", f"❌ Не оплатили: {stats['total_unpaid']}", "", - "📊 По участникам:", + "📊 Детали по участникам ниже:", ] - for p in stats['participants']: - lines.append( - f"• {p['full_name']}: приглашено {p['total_referrals']}, оплатили {p['paid_referrals']}, не оплатили {p['unpaid_referrals']}, сумма {p['total_paid_amount'] // 100} руб." - ) - await callback.message.edit_text( - "\n".join(lines), + "\n".join(general_lines), reply_markup=get_referral_contest_manage_keyboard( contest_id, is_active=contest.is_active, language=db_user.language ), ) + + # Пагинация участников + if stats['participants']: + await show_detailed_stats_page(callback, db_user, db, contest_id=contest_id, page=1, stats=stats) + else: + await callback.message.reply_text("📊 Нет участников с рефералами.") + await callback.answer() +@admin_required +@error_handler +async def show_detailed_stats_page( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, + contest_id: int = None, + page: int = 1, + stats: dict = None, +): + if contest_id is None or stats is None: + # Парсим из callback.data + parts = callback.data.split("_") + contest_id = int(parts[-2]) + page = int(parts[-1]) + + # Получаем stats если не переданы + from app.services.referral_contest_service import referral_contest_service + stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id) + + participants = stats['participants'] + total_participants = len(participants) + PAGE_SIZE = 10 + total_pages = math.ceil(total_participants / PAGE_SIZE) + + page = max(1, min(page, total_pages)) + offset = (page - 1) * PAGE_SIZE + page_participants = participants[offset:offset + PAGE_SIZE] + + lines = [f"📊 По участникам (страница {page}/{total_pages}):"] + for p in page_participants: + lines.append( + f"• {p['full_name']}: приглашено {p['total_referrals']}, оплатили {p['paid_referrals']}, не оплатили {p['unpaid_referrals']}, сумма {p['total_paid_amount'] // 100} руб." + ) + + pagination = get_admin_pagination_keyboard( + page, + total_pages, + f"admin_contest_detailed_stats_page_{contest_id}", + back_callback=f"admin_contest_view_{contest_id}", + language=db_user.language, + ) + + await callback.message.reply_text( + "\n".join(lines), + reply_markup=pagination, + ) + + def register_handlers(dp: Dispatcher): dp.callback_query.register(show_contests_menu, F.data == "admin_contests") dp.callback_query.register(show_referral_contests_menu, F.data == "admin_contests_referral") @@ -720,6 +772,7 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(delete_contest, F.data.startswith("admin_contest_delete_")) dp.callback_query.register(show_leaderboard, F.data.startswith("admin_contest_leaderboard_")) dp.callback_query.register(show_detailed_stats, F.data.startswith("admin_contest_detailed_stats_")) + dp.callback_query.register(show_detailed_stats_page, F.data.startswith("admin_contest_detailed_stats_page_")) dp.callback_query.register(start_contest_creation, F.data == "admin_contests_create") dp.callback_query.register(select_contest_mode, F.data.in_(["admin_contest_mode_paid", "admin_contest_mode_registered"])) diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 9a0f6f95..a681d2bc 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -214,7 +214,8 @@ class MonitoringService: await self._check_trial_inactivity_notifications(db) await self._check_trial_channel_subscriptions(db) await self._check_expired_subscription_followups(db) - await self._process_autopayments(db) + if settings.ENABLE_AUTOPAY: + await self._process_autopayments(db) await self._cleanup_inactive_users(db) await self._sync_with_remnawave(db) @@ -931,7 +932,7 @@ class MonitoringService: autopay_subscriptions = [] for sub in all_autopay_subscriptions: days_before_expiry = (sub.end_date - current_time).days - if days_before_expiry <= sub.autopay_days_before: + if days_before_expiry <= min(sub.autopay_days_before, 3): autopay_subscriptions.append(sub) processed_count = 0 @@ -1059,12 +1060,16 @@ class MonitoringService: texts = get_texts(user.language) days_text = format_days_declension(days, user.language) - if subscription.autopay_enabled: - autopay_status = "✅ Включен - подписка продлится автоматически" - action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}" + if settings.ENABLE_AUTOPAY: + if subscription.autopay_enabled: + autopay_status = "✅ Включен - подписка продлится автоматически" + action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}" + else: + autopay_status = "❌ Отключен - не забудьте продлить вручную!" + action_text = "💡 Включите автоплатеж или продлите подписку вручную" else: autopay_status = "❌ Отключен - не забудьте продлить вручную!" - action_text = "💡 Включите автоплатеж или продлите подписку вручную" + action_text = "💡 Продлите подписку вручную" message = f""" ⚠️ Подписка истекает через {days_text}! From 69927a4db23ba9d2825cd01edb803994aa1ee4b6 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sat, 20 Dec 2025 21:50:13 +0300 Subject: [PATCH 18/47] Improve error handling and logging in MenuLayout statistics endpoints; change button click log ordering to descending. --- app/services/menu_layout/stats_service.py | 2 +- app/webapi/routes/menu_layout.py | 131 +++++++++++++--------- 2 files changed, 80 insertions(+), 53 deletions(-) diff --git a/app/services/menu_layout/stats_service.py b/app/services/menu_layout/stats_service.py index af26f314..28da76a8 100644 --- a/app/services/menu_layout/stats_service.py +++ b/app/services/menu_layout/stats_service.py @@ -407,7 +407,7 @@ class MenuLayoutStatsService: ButtonClickLog.clicked_at, ) .where(ButtonClickLog.user_id == user_id) - .order_by(ButtonClickLog.clicked_at) + .order_by(desc(ButtonClickLog.clicked_at)) .limit(limit) ) diff --git a/app/webapi/routes/menu_layout.py b/app/webapi/routes/menu_layout.py index f025d825..4b7e72bc 100644 --- a/app/webapi/routes/menu_layout.py +++ b/app/webapi/routes/menu_layout.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any, List, Optional from fastapi import APIRouter, Depends, HTTPException, Response, Security, status @@ -13,6 +14,8 @@ from app.services.menu_layout_service import ( MenuLayoutService, ) +logger = logging.getLogger(__name__) + from ..dependencies import get_db_session, require_api_token from ..schemas.menu_layout import ( AddCustomButtonRequest, @@ -837,20 +840,26 @@ async def get_stats_by_button_type( db: AsyncSession = Depends(get_db_session), ) -> ButtonTypeStatsResponse: """Получить статистику кликов по типам кнопок (builtin, callback, url, mini_app).""" - stats = await MenuLayoutService.get_stats_by_button_type(db, days) - total_clicks = sum(s["clicks_total"] for s in stats) - - return ButtonTypeStatsResponse( - items=[ - ButtonTypeStats( - button_type=s["button_type"], - clicks_total=s["clicks_total"], - unique_users=s["unique_users"], - ) - for s in stats - ], - total_clicks=total_clicks, - ) + try: + stats = await MenuLayoutService.get_stats_by_button_type(db, days) + total_clicks = sum(s["clicks_total"] for s in stats) + + logger.debug(f"Stats by type: {len(stats)} types, total_clicks={total_clicks}") + + return ButtonTypeStatsResponse( + items=[ + ButtonTypeStats( + button_type=s["button_type"], + clicks_total=s["clicks_total"], + unique_users=s["unique_users"], + ) + for s in stats + ], + total_clicks=total_clicks, + ) + except Exception as e: + logger.error(f"Error getting stats by type: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") @router.get("/stats/by-hour", response_model=HourlyStatsResponse) @@ -904,20 +913,26 @@ async def get_top_users( db: AsyncSession = Depends(get_db_session), ) -> TopUsersResponse: """Получить топ пользователей по количеству кликов.""" - stats = await MenuLayoutService.get_top_users(db, button_id, limit, days) - - return TopUsersResponse( - items=[ - TopUserStats( - user_id=s["user_id"], - clicks_count=s["clicks_count"], - last_click_at=s["last_click_at"], - ) - for s in stats - ], - button_id=button_id, - limit=limit, - ) + try: + stats = await MenuLayoutService.get_top_users(db, button_id, limit, days) + + logger.debug(f"Top users: {len(stats)} users, button_id={button_id}, limit={limit}, days={days}") + + return TopUsersResponse( + items=[ + TopUserStats( + user_id=s["user_id"], + clicks_count=s["clicks_count"], + last_click_at=s["last_click_at"], + ) + for s in stats + ], + button_id=button_id, + limit=limit, + ) + except Exception as e: + logger.error(f"Error getting top users: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") @router.get("/stats/compare", response_model=PeriodComparisonResponse) @@ -929,16 +944,22 @@ async def get_period_comparison( db: AsyncSession = Depends(get_db_session), ) -> PeriodComparisonResponse: """Сравнить статистику текущего и предыдущего периода.""" - comparison = await MenuLayoutService.get_period_comparison( - db, button_id, current_days, previous_days - ) - - return PeriodComparisonResponse( - current_period=comparison["current_period"], - previous_period=comparison["previous_period"], - change=comparison["change"], - button_id=button_id, - ) + try: + comparison = await MenuLayoutService.get_period_comparison( + db, button_id, current_days, previous_days + ) + + logger.debug(f"Period comparison: button_id={button_id}, current_days={current_days}, previous_days={previous_days}, trend={comparison.get('change', {}).get('trend')}") + + return PeriodComparisonResponse( + current_period=comparison["current_period"], + previous_period=comparison["previous_period"], + change=comparison["change"], + button_id=button_id, + ) + except Exception as e: + logger.error(f"Error getting period comparison: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") @router.get("/stats/users/{user_id}/sequences", response_model=UserClickSequencesResponse) @@ -949,17 +970,23 @@ async def get_user_click_sequences( db: AsyncSession = Depends(get_db_session), ) -> UserClickSequencesResponse: """Получить последовательности кликов пользователя.""" - sequences = await MenuLayoutService.get_user_click_sequences(db, user_id, limit) - - return UserClickSequencesResponse( - user_id=user_id, - items=[ - UserClickSequence( - button_id=s["button_id"], - button_text=s["button_text"], - clicked_at=s["clicked_at"], - ) - for s in sequences - ], - total=len(sequences), - ) \ No newline at end of file + try: + sequences = await MenuLayoutService.get_user_click_sequences(db, user_id, limit) + + logger.debug(f"User sequences: user_id={user_id}, limit={limit}, found={len(sequences)} sequences") + + return UserClickSequencesResponse( + user_id=user_id, + items=[ + UserClickSequence( + button_id=s["button_id"], + button_text=s["button_text"], + clicked_at=s["clicked_at"], + ) + for s in sequences + ], + total=len(sequences), + ) + except Exception as e: + logger.error(f"Error getting user sequences: user_id={user_id}, error={e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") \ No newline at end of file From 32c28aedea8ffb8a6adc314e58c0192baaa7032f Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sat, 20 Dec 2025 21:56:33 +0300 Subject: [PATCH 19/47] Enhance MenuLayoutStatsService to include daily, weekly, and monthly click statistics for buttons, improving analytics capabilities in the menu layout. --- app/services/menu_layout/stats_service.py | 27 ++++++++++++++++++++--- app/webapi/routes/menu_layout.py | 3 +++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/app/services/menu_layout/stats_service.py b/app/services/menu_layout/stats_service.py index af26f314..695c19ca 100644 --- a/app/services/menu_layout/stats_service.py +++ b/app/services/menu_layout/stats_service.py @@ -148,16 +148,34 @@ class MenuLayoutStatsService: days: int = 30, ) -> List[Dict[str, Any]]: """Получить статистику по всем кнопкам.""" - start_date = datetime.now() - timedelta(days=days) + now = datetime.now() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=days) + # Для производительности используем один запрос с подзапросами через CASE result = await db.execute( select( ButtonClickLog.button_id, + # Общее количество кликов (все клики без фильтра по датам) func.count(ButtonClickLog.id).label("clicks_total"), + # Уникальные пользователи (все время) func.count(func.distinct(ButtonClickLog.user_id)).label("unique_users"), - func.max(ButtonClickLog.clicked_at).label("last_click_at") + # Последний клик (все время) + func.max(ButtonClickLog.clicked_at).label("last_click_at"), + # Подсчет кликов за сегодня + func.sum( + case((ButtonClickLog.clicked_at >= today_start, 1), else_=0) + ).label("clicks_today"), + # Подсчет кликов за неделю + func.sum( + case((ButtonClickLog.clicked_at >= week_ago, 1), else_=0) + ).label("clicks_week"), + # Подсчет кликов за месяц + func.sum( + case((ButtonClickLog.clicked_at >= month_ago, 1), else_=0) + ).label("clicks_month"), ) - .where(ButtonClickLog.clicked_at >= start_date) .group_by(ButtonClickLog.button_id) .order_by(desc(func.count(ButtonClickLog.id))) ) @@ -166,6 +184,9 @@ class MenuLayoutStatsService: { "button_id": row.button_id, "clicks_total": row.clicks_total, + "clicks_today": row.clicks_today or 0, + "clicks_week": row.clicks_week or 0, + "clicks_month": row.clicks_month or 0, "unique_users": row.unique_users, "last_click_at": row.last_click_at, } diff --git a/app/webapi/routes/menu_layout.py b/app/webapi/routes/menu_layout.py index f025d825..2488ff62 100644 --- a/app/webapi/routes/menu_layout.py +++ b/app/webapi/routes/menu_layout.py @@ -771,6 +771,9 @@ async def get_menu_click_stats( ButtonClickStats( button_id=s["button_id"], clicks_total=s["clicks_total"], + clicks_today=s.get("clicks_today", 0), + clicks_week=s.get("clicks_week", 0), + clicks_month=s.get("clicks_month", 0), unique_users=s["unique_users"], last_click_at=s["last_click_at"], ) From 2a3f1bac76a48d1ec01e38e98af7121457488ae7 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sat, 20 Dec 2025 23:33:35 +0300 Subject: [PATCH 20/47] Refactor MenuLayoutStatsService to create a dictionary for quick access to weekday statistics, ensuring all weekdays are returned with a count of 0 if no data exists. --- app/services/menu_layout/stats_service.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/app/services/menu_layout/stats_service.py b/app/services/menu_layout/stats_service.py index 13a9d5c2..7b977bdc 100644 --- a/app/services/menu_layout/stats_service.py +++ b/app/services/menu_layout/stats_service.py @@ -304,13 +304,21 @@ class MenuLayoutStatsService: ) weekday_names = ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье"] + + # Создаем словарь для быстрого доступа по weekday + stats_dict = { + int(row.weekday): row.count + for row in result.all() + } + + # Возвращаем все дни недели, даже если count = 0 return [ { - "weekday": int(row.weekday), - "weekday_name": weekday_names[int(row.weekday)], - "count": row.count + "weekday": weekday, + "weekday_name": weekday_names[weekday], + "count": stats_dict.get(weekday, 0) } - for row in result.all() + for weekday in range(7) ] @classmethod From aab32299acfe2799957c17021a8e4d47908be74a Mon Sep 17 00:00:00 2001 From: gy9vin Date: Sun, 21 Dec 2025 00:19:13 +0300 Subject: [PATCH 21/47] =?UTF-8?q?=D0=94=D0=B5=D1=82=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D0=BA=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D1=83=D1=80=D1=81=D1=83=20=D1=80=D0=B5=D1=84=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/database/crud/referral_contest.py | 12 +++- app/handlers/admin/contests.py | 33 +++++------ app/handlers/start.py | 12 ++-- app/services/referral_contest_service.py | 74 +++++++++++++----------- 4 files changed, 74 insertions(+), 57 deletions(-) diff --git a/app/database/crud/referral_contest.py b/app/database/crud/referral_contest.py index 89e7426b..7ff57379 100644 --- a/app/database/crud/referral_contest.py +++ b/app/database/crud/referral_contest.py @@ -4,12 +4,14 @@ from typing import List, Optional, Sequence, Tuple from sqlalchemy import and_, desc, func, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload +from sqlalchemy.orm import selectinload, aliased from app.database.models import ( ReferralContest, ReferralContestEvent, User, + Transaction, + TransactionType, ) logger = logging.getLogger(__name__) @@ -177,6 +179,10 @@ async def get_contest_leaderboard( *, limit: Optional[int] = None, ) -> Sequence[Tuple[User, int, int]]: + contest = await get_referral_contest(db, contest_id) + if not contest: + return [] + query = ( select( User, @@ -191,7 +197,9 @@ async def get_contest_leaderboard( if limit: query = query.limit(limit) result = await db.execute(query) - return result.all() + leaderboard = result.all() + + return leaderboard async def get_contest_participants( diff --git a/app/handlers/admin/contests.py b/app/handlers/admin/contests.py index 7402cdab..e024045e 100644 --- a/app/handlers/admin/contests.py +++ b/app/handlers/admin/contests.py @@ -436,7 +436,7 @@ async def show_leaderboard( texts.t("ADMIN_CONTEST_LEADERBOARD_TITLE", "📊 Топ участников:"), ] for idx, (user, score, _) in enumerate(leaderboard, start=1): - lines.append(f"{idx}. {user.full_name} — {score}") + lines.append(f"{idx}. {user.full_name} ({user.telegram_id}) — {score}") await callback.message.edit_text( "\n".join(lines), @@ -685,15 +685,13 @@ async def show_detailed_stats( # Общее сообщение с основной статистикой general_lines = [ - "📈 Детальная статистика конкурса", + "📈 Статистика конкурса", f"🏆 {contest.title}", "", f"👥 Участников: {stats['total_participants']}", f"📨 Приглашено рефералов: {stats['total_invited']}", f"💰 Оплатили подписок: {stats['total_paid_amount'] // 100} руб.", f"❌ Не оплатили: {stats['total_unpaid']}", - "", - "📊 Детали по участникам ниже:", ] await callback.message.edit_text( @@ -703,12 +701,6 @@ async def show_detailed_stats( ), ) - # Пагинация участников - if stats['participants']: - await show_detailed_stats_page(callback, db_user, db, contest_id=contest_id, page=1, stats=stats) - else: - await callback.message.reply_text("📊 Нет участников с рефералами.") - await callback.answer() @@ -723,10 +715,10 @@ async def show_detailed_stats_page( stats: dict = None, ): if contest_id is None or stats is None: - # Парсим из callback.data + # Парсим из callback.data: admin_contest_detailed_stats_page_{contest_id}_page_{page} parts = callback.data.split("_") - contest_id = int(parts[-2]) - page = int(parts[-1]) + contest_id = int(parts[5]) # contest_id после page + page = int(parts[7]) # page после второго page # Получаем stats если не переданы from app.services.referral_contest_service import referral_contest_service @@ -743,9 +735,14 @@ async def show_detailed_stats_page( lines = [f"📊 По участникам (страница {page}/{total_pages}):"] for p in page_participants: - lines.append( - f"• {p['full_name']}: приглашено {p['total_referrals']}, оплатили {p['paid_referrals']}, не оплатили {p['unpaid_referrals']}, сумма {p['total_paid_amount'] // 100} руб." - ) + lines.extend([ + f"• {p['full_name']}", + f" 📨 Приглашено: {p['total_referrals']}", + f" 💰 Оплатили: {p['paid_referrals']}", + f" ❌ Не оплатили: {p['unpaid_referrals']}", + f" 💵 Сумма: {p['total_paid_amount'] // 100} руб.", + "" # Пустая строка для разделения + ]) pagination = get_admin_pagination_keyboard( page, @@ -755,11 +752,13 @@ async def show_detailed_stats_page( language=db_user.language, ) - await callback.message.reply_text( + await callback.message.edit_text( "\n".join(lines), reply_markup=pagination, ) + await callback.answer() + def register_handlers(dp: Dispatcher): dp.callback_query.register(show_contests_menu, F.data == "admin_contests") diff --git a/app/handlers/start.py b/app/handlers/start.py index 9fce80cc..96025154 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -264,10 +264,14 @@ async def _continue_registration_after_language( return rules_text = await get_rules(language) - await target_message.answer( - rules_text, - reply_markup=get_rules_keyboard(language) - ) + try: + await target_message.answer( + rules_text, + reply_markup=get_rules_keyboard(language) + ) + except TelegramForbiddenError: + logger.warning(f"⚠️ Пользователь {callback.from_user.id if callback else message.from_user.id} заблокировал бота, пропускаем отправку правил") + return await state.set_state(RegistrationStates.waiting_for_rules_accept) logger.info("📋 LANGUAGE: Правила отправлены после выбора языка") diff --git a/app/services/referral_contest_service.py b/app/services/referral_contest_service.py index 5ffb775c..5b4842ae 100644 --- a/app/services/referral_contest_service.py +++ b/app/services/referral_contest_service.py @@ -285,7 +285,7 @@ class ReferralContestService: if leaderboard: for idx, (user, score, _) in enumerate(leaderboard[:5], start=1): name = user.full_name - lines.append(f"{idx}. {name} — {score}") + lines.append(f"{idx}. {name} ({user.telegram_id}) — {score}") else: lines.append("Пока нет участников.") @@ -391,52 +391,58 @@ class ReferralContestService: return "\n".join(lines) async def get_detailed_contest_stats(self, db: AsyncSession, contest_id: int) -> dict: - from app.database.crud.referral_contest import get_contest_events, get_contest_leaderboard + from app.database.crud.referral_contest import get_contest_events, get_contest_leaderboard, get_referral_contest from app.database.crud.user import get_user_by_id + from app.database.models import User, Subscription + from sqlalchemy import select - leaderboard = list(await get_contest_leaderboard(db, contest_id)) - total_participants = len(leaderboard) + contest = await get_referral_contest(db, contest_id) + if not contest: + return { + 'total_participants': 0, + 'total_invited': 0, + 'total_paid_amount': 0, + 'total_unpaid': 0, + 'participants': [], + } - # Get all events for the contest - events = await get_contest_events(db, contest_id) + # Get leaderboard to get scores + leaderboard = await get_contest_leaderboard(db, contest_id) + if not leaderboard: + return { + 'total_participants': 0, + 'total_invited': 0, + 'total_paid_amount': 0, + 'total_unpaid': 0, + 'participants': [], + } - # Total invited: unique referral_ids from events - invited_set = set(event.referral_id for event in events) - total_invited = len(invited_set) + # Create dict of referrer_id -> (score, amount) + scores = {user.id: (score, amount) for user, score, amount in leaderboard} - # Paid events: events with event_type == 'subscription_purchase' - paid_events = [e for e in events if e.event_type == 'subscription_purchase'] - total_paid_amount = sum(e.amount_kopeks for e in paid_events) + total_participants = len(scores) + total_invited = sum(score for score, _ in scores.values()) + total_paid_amount = sum(amount for _, amount in scores.values()) + total_unpaid = 0 - # Paid referrals: unique referral_ids from paid events - paid_referrals_set = set(e.referral_id for e in paid_events) - total_paid_referrals = len(paid_referrals_set) - - total_unpaid = total_invited - total_paid_referrals - - # Per participant stats participants_stats = [] - for user, score, _ in leaderboard: - referrer_id = user.id - # Total referrals for this referrer - referrer_events = [e for e in events if e.referrer_id == referrer_id] - total_referrals = len(set(e.referral_id for e in referrer_events)) - - # Paid referrals for this referrer - paid_referrer_events = [e for e in referrer_events if e.event_type == 'subscription_purchase'] - paid_referrals = len(set(e.referral_id for e in paid_referrer_events)) - unpaid_referrals = total_referrals - paid_referrals - total_paid_amount_for_referrer = sum(e.amount_kopeks for e in paid_referrer_events) + for referrer_id, (score, amount) in scores.items(): + user = await get_user_by_id(db, referrer_id) + if not user: + continue participants_stats.append({ 'referrer_id': referrer_id, 'full_name': user.full_name, - 'total_referrals': total_referrals, - 'paid_referrals': paid_referrals, - 'unpaid_referrals': unpaid_referrals, - 'total_paid_amount': total_paid_amount_for_referrer, + 'total_referrals': score, + 'paid_referrals': score, + 'unpaid_referrals': 0, + 'total_paid_amount': amount, }) + # Sort by total_referrals descending + participants_stats.sort(key=lambda p: p['total_referrals'], reverse=True) + return { 'total_participants': total_participants, 'total_invited': total_invited, From 837473b274fa790dbdc5152ad059b58edf189ac8 Mon Sep 17 00:00:00 2001 From: gy9vin Date: Sun, 21 Dec 2025 00:38:08 +0300 Subject: [PATCH 22/47] =?UTF-8?q?=D0=90=D0=BF=D0=B8=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=B4=D0=B5=D1=82=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=BA=D0=BE=D0=BD=D0=BA=D1=83=D1=80=D1=81=D1=83?= =?UTF-8?q?=20=D1=80=D0=B5=D1=84=D0=B5=D1=80=D0=B0=D0=BB=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/webapi/routes/contests.py | 21 +++++++++++++++++++++ app/webapi/schemas/contests.py | 17 +++++++++++++++++ docs/contests-api.md | 2 +- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/webapi/routes/contests.py b/app/webapi/routes/contests.py index efdd50eb..420aa7d9 100644 --- a/app/webapi/routes/contests.py +++ b/app/webapi/routes/contests.py @@ -51,11 +51,13 @@ from app.webapi.schemas.contests import ( ContestTemplateUpdateRequest, ReferralContestCreateRequest, ReferralContestDetailResponse, + ReferralContestDetailedStatsResponse, ReferralContestEventListResponse, ReferralContestEventResponse, ReferralContestEventUser, ReferralContestLeaderboardItem, ReferralContestListResponse, + ReferralContestParticipant, ReferralContestResponse, ReferralContestUpdateRequest, StartRoundRequest, @@ -676,3 +678,22 @@ async def list_referral_events( limit=limit, offset=offset, ) + + +@router.get( + "/referral/{contest_id}/detailed-stats", + response_model=ReferralContestDetailedStatsResponse, + tags=["contests"], +) +async def get_referral_detailed_stats( + contest_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> ReferralContestDetailedStatsResponse: + contest = await get_referral_contest(db, contest_id) + if not contest: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found") + + from app.services.referral_contest_service import referral_contest_service + stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id) + return ReferralContestDetailedStatsResponse(**stats) diff --git a/app/webapi/schemas/contests.py b/app/webapi/schemas/contests.py index 9713ed35..139cd125 100644 --- a/app/webapi/schemas/contests.py +++ b/app/webapi/schemas/contests.py @@ -192,3 +192,20 @@ class ReferralContestEventListResponse(BaseModel): total: int limit: int offset: int + + +class ReferralContestParticipant(BaseModel): + referrer_id: int + full_name: str + total_referrals: int + paid_referrals: int + unpaid_referrals: int + total_paid_amount: int + + +class ReferralContestDetailedStatsResponse(BaseModel): + total_participants: int + total_invited: int + total_paid_amount: int + total_unpaid: int + participants: List[ReferralContestParticipant] diff --git a/docs/contests-api.md b/docs/contests-api.md index 97ab3624..29eb071a 100644 --- a/docs/contests-api.md +++ b/docs/contests-api.md @@ -40,7 +40,7 @@ "created_by": 1 } ``` -- `GET /contests/referral/{id}` — детали + `total_events`, `leaderboard`. +- `GET /contests/referral/{id}/detailed-stats` — детальная статистика конкурса с разбивкой по участникам (total_participants, total_invited, total_paid_amount, total_unpaid, participants). - `PATCH /contests/referral/{id}` — частичное обновление (те же поля + `final_summary_sent`, `is_active`, `daily_summary_times` с несколькими временами через запятую). - `POST /contests/referral/{id}/toggle?is_active=true|false` — быстро включить/остановить. - `GET /contests/referral/{id}/events?limit&offset` — события (referrer/referral, тип, суммы). From 1bb0a5c00079df1b9046a4a0f71526614382e068 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sun, 21 Dec 2025 01:14:04 +0300 Subject: [PATCH 23/47] Refactor MenuLayoutStatsService to return hourly statistics as a complete list for all 24 hours, ensuring counts default to 0 when no data exists for a given hour. --- app/services/menu_layout/stats_service.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/services/menu_layout/stats_service.py b/app/services/menu_layout/stats_service.py index 7b977bdc..2b283a78 100644 --- a/app/services/menu_layout/stats_service.py +++ b/app/services/menu_layout/stats_service.py @@ -264,9 +264,16 @@ class MenuLayoutStatsService: .order_by(func.extract('hour', ButtonClickLog.clicked_at)) ) - return [ - {"hour": int(row.hour), "count": row.count} + # Создаем словарь для быстрого доступа по часу + stats_dict = { + int(row.hour): row.count for row in result.all() + } + + # Возвращаем все 24 часа, даже если count = 0 + return [ + {"hour": hour, "count": stats_dict.get(hour, 0)} + for hour in range(24) ] @classmethod From ce11ec7c0e586db99dc7eca8402f4e5e894f799e Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sun, 21 Dec 2025 02:17:40 +0300 Subject: [PATCH 24/47] Enhance ButtonStatsMiddleware with improved logging and error handling; log middleware activation status based on MENU_LAYOUT_ENABLED setting. --- app/bot.py | 3 +++ app/middlewares/button_stats.py | 25 ++++++++++++++----------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/app/bot.py b/app/bot.py index bdfac407..c3cd23bf 100644 --- a/app/bot.py +++ b/app/bot.py @@ -130,10 +130,13 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: dp.callback_query.middleware(ThrottlingMiddleware()) # Middleware для автоматического логирования кликов по кнопкам + logger.info(f"📊 MENU_LAYOUT_ENABLED = {settings.MENU_LAYOUT_ENABLED}") if settings.MENU_LAYOUT_ENABLED: button_stats_middleware = ButtonStatsMiddleware() dp.callback_query.middleware(button_stats_middleware) logger.info("📊 ButtonStatsMiddleware активирован - автоматическое логирование кликов включено") + else: + logger.warning("⚠️ ButtonStatsMiddleware ОТКЛЮЧЕН - MENU_LAYOUT_ENABLED=False") if settings.CHANNEL_IS_REQUIRED_SUB: from app.middlewares.channel_checker import ChannelCheckerMiddleware diff --git a/app/middlewares/button_stats.py b/app/middlewares/button_stats.py index 66cfa25e..4263462a 100644 --- a/app/middlewares/button_stats.py +++ b/app/middlewares/button_stats.py @@ -21,33 +21,35 @@ class ButtonStatsMiddleware(BaseMiddleware): data: Dict[str, Any] ) -> Any: """Перехватывает CallbackQuery и логирует клики по кнопкам.""" - + # Обрабатываем только CallbackQuery if not isinstance(event, CallbackQuery): return await handler(event, data) - + # Пропускаем, если статистика отключена if not settings.MENU_LAYOUT_ENABLED: return await handler(event, data) - + # Логируем клик асинхронно, не блокируя обработку try: # Получаем callback_data callback_data = event.data if not callback_data: return await handler(event, data) - + # Получаем user_id user_id = event.from_user.id if event.from_user else None - + # Определяем тип кнопки по callback_data button_type = self._determine_button_type(callback_data) - + # Получаем текст кнопки, если возможно button_text = None if event.message and hasattr(event.message, 'reply_markup'): button_text = self._extract_button_text(event.message.reply_markup, callback_data) - + + logger.info(f"📊 ButtonStats: клик user={user_id}, button={callback_data[:50]}, type={button_type}") + # Логируем в фоне, не блокируя обработку # Используем asyncio.create_task для фоновой задачи import asyncio @@ -62,7 +64,7 @@ class ButtonStatsMiddleware(BaseMiddleware): ) except Exception as e: # Не прерываем обработку при ошибке логирования - logger.debug(f"Ошибка логирования клика по кнопке: {e}") + logger.error(f"Ошибка логирования клика по кнопке: {e}", exc_info=True) # Продолжаем обработку return await handler(event, data) @@ -104,7 +106,7 @@ class ButtonStatsMiddleware(BaseMiddleware): async with AsyncSessionLocal() as db: try: from app.services.menu_layout_service import MenuLayoutService - + await MenuLayoutService.log_button_click( db, button_id=button_id, @@ -113,8 +115,9 @@ class ButtonStatsMiddleware(BaseMiddleware): button_type=button_type, button_text=button_text ) + logger.info(f"✅ ButtonStats: записано в БД button={button_id[:30]}, user={user_id}") except Exception as e: - logger.error(f"Ошибка логирования клика по кнопке {button_id}: {e}") + logger.error(f"❌ Ошибка записи клика в БД {button_id}: {e}", exc_info=True) except Exception as e: - logger.error(f"Ошибка создания сессии БД для логирования клика: {e}") + logger.error(f"❌ Ошибка создания сессии БД для логирования клика: {e}", exc_info=True) From 763d8f2aaaa700a64c767516b146dd6dfed3e569 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sun, 21 Dec 2025 02:54:40 +0300 Subject: [PATCH 25/47] Enhance logging in get_stats_by_button_type to include total record count in button_click_logs and update stats logging to info level for better visibility. --- app/webapi/routes/menu_layout.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/webapi/routes/menu_layout.py b/app/webapi/routes/menu_layout.py index bf0913ba..ef2c2fb5 100644 --- a/app/webapi/routes/menu_layout.py +++ b/app/webapi/routes/menu_layout.py @@ -844,10 +844,16 @@ async def get_stats_by_button_type( ) -> ButtonTypeStatsResponse: """Получить статистику кликов по типам кнопок (builtin, callback, url, mini_app).""" try: + # Отладка: проверяем сколько записей в таблице + from sqlalchemy import text + count_result = await db.execute(text("SELECT COUNT(*) FROM button_click_logs")) + total_in_table = count_result.scalar() + logger.info(f"📊 DEBUG: Всего записей в button_click_logs: {total_in_table}") + stats = await MenuLayoutService.get_stats_by_button_type(db, days) total_clicks = sum(s["clicks_total"] for s in stats) - - logger.debug(f"Stats by type: {len(stats)} types, total_clicks={total_clicks}") + + logger.info(f"📊 Stats by type: {len(stats)} types, total_clicks={total_clicks}") return ButtonTypeStatsResponse( items=[ From 7e1a2998af25c907bed43555ee7ba90bb788f230 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sun, 21 Dec 2025 03:10:41 +0300 Subject: [PATCH 26/47] Add debug_stats endpoint to expose raw button click log data and enhance logging for user_id checks in get_top_users --- app/webapi/routes/menu_layout.py | 47 ++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/app/webapi/routes/menu_layout.py b/app/webapi/routes/menu_layout.py index ef2c2fb5..7719d4f9 100644 --- a/app/webapi/routes/menu_layout.py +++ b/app/webapi/routes/menu_layout.py @@ -836,6 +836,43 @@ async def log_button_click( return {"success": True} +@router.get("/stats/debug") +async def debug_stats( + limit: int = 10, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> dict: + """DEBUG: Показать сырые данные из таблицы button_click_logs.""" + from sqlalchemy import text + + # Общее количество + total = await db.execute(text("SELECT COUNT(*) FROM button_click_logs")) + total_count = total.scalar() + + # Последние записи + result = await db.execute(text(f""" + SELECT id, button_id, user_id, button_type, clicked_at + FROM button_click_logs + ORDER BY clicked_at DESC + LIMIT {limit} + """)) + rows = result.fetchall() + + return { + "total_count": total_count, + "last_records": [ + { + "id": row[0], + "button_id": row[1], + "user_id": row[2], + "button_type": row[3], + "clicked_at": str(row[4]) if row[4] else None, + } + for row in rows + ] + } + + @router.get("/stats/by-type", response_model=ButtonTypeStatsResponse) async def get_stats_by_button_type( days: int = 30, @@ -923,9 +960,15 @@ async def get_top_users( ) -> TopUsersResponse: """Получить топ пользователей по количеству кликов.""" try: + # Отладка: проверяем user_id в таблице + from sqlalchemy import text + null_count = await db.execute(text("SELECT COUNT(*) FROM button_click_logs WHERE user_id IS NULL")) + not_null_count = await db.execute(text("SELECT COUNT(*) FROM button_click_logs WHERE user_id IS NOT NULL")) + logger.info(f"📊 DEBUG top-users: user_id IS NULL: {null_count.scalar()}, IS NOT NULL: {not_null_count.scalar()}") + stats = await MenuLayoutService.get_top_users(db, button_id, limit, days) - - logger.debug(f"Top users: {len(stats)} users, button_id={button_id}, limit={limit}, days={days}") + + logger.info(f"📊 Top users: {len(stats)} users, button_id={button_id}, limit={limit}, days={days}") return TopUsersResponse( items=[ From 02ebcd368e55ec3168f4b43ed8ffdbbe19c10ed6 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sun, 21 Dec 2025 03:15:25 +0300 Subject: [PATCH 27/47] Improve logging in get_top_users to include detailed user data and streamline response item creation --- app/webapi/routes/menu_layout.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/app/webapi/routes/menu_layout.py b/app/webapi/routes/menu_layout.py index 7719d4f9..7f79522b 100644 --- a/app/webapi/routes/menu_layout.py +++ b/app/webapi/routes/menu_layout.py @@ -968,17 +968,20 @@ async def get_top_users( stats = await MenuLayoutService.get_top_users(db, button_id, limit, days) - logger.info(f"📊 Top users: {len(stats)} users, button_id={button_id}, limit={limit}, days={days}") - + logger.info(f"📊 Top users: {len(stats)} users, data={stats}, button_id={button_id}, limit={limit}, days={days}") + + items = [ + TopUserStats( + user_id=s["user_id"], + clicks_count=s["clicks_count"], + last_click_at=s["last_click_at"], + ) + for s in stats + ] + logger.info(f"📊 Top users response items: {len(items)}") + return TopUsersResponse( - items=[ - TopUserStats( - user_id=s["user_id"], - clicks_count=s["clicks_count"], - last_click_at=s["last_click_at"], - ) - for s in stats - ], + items=items, button_id=button_id, limit=limit, ) From 9d038f7443c3fea4e682b777495a33e90e7a780b Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 21 Dec 2025 03:38:41 +0300 Subject: [PATCH 28/47] Update remnawave_api.py --- app/external/remnawave_api.py | 285 ++++++++++++++++++++++++++++++---- 1 file changed, 254 insertions(+), 31 deletions(-) diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index c9eb6e26..8ac9ecb5 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -189,6 +189,33 @@ class SubscriptionInfo: happ_crypto_link: Optional[str] = None +@dataclass +class SubscriptionPageConfig: + """Конфигурация страницы подписки""" + uuid: str + name: str + view_position: int + config: Optional[Dict[str, Any]] = None + + +@dataclass +class RemnaWaveExternalSquad: + """Структура External Squad""" + uuid: str + name: str + view_position: int + members_count: int + templates: List[Dict[str, str]] + subscription_settings: Optional[Dict[str, Any]] = None + host_overrides: Optional[Dict[str, Any]] = None + response_headers: Optional[Dict[str, str]] = None + hwid_settings: Optional[Dict[str, Any]] = None + custom_remarks: Optional[Dict[str, Any]] = None + subpage_config_uuid: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class RemnaWaveAPIError(Exception): def __init__(self, message: str, status_code: int = None, response_data: dict = None): self.message = message @@ -497,15 +524,6 @@ class RemnaWaveAPI: return await self.enrich_user_with_happ_link(user) async def get_all_users(self, start: int = 0, size: int = 100, enrich_happ_links: bool = False) -> Dict[str, Any]: - """ - Получает список всех пользователей. - - Args: - start: Смещение для пагинации - size: Размер страницы - enrich_happ_links: Если True, для каждого пользователя будет запрошена - зашифрованная happ ссылка (медленно для больших списков) - """ params = {'start': start, 'size': size} response = await self._make_request('GET', '/api/users', params=params) @@ -590,6 +608,100 @@ class RemnaWaveAPI: response = await self._make_request('POST', '/api/internal-squads/actions/reorder', data) return [self._parse_internal_squad(squad) for squad in response['response']['internalSquads']] + # ============== External Squads API ============== + + async def get_external_squads(self) -> List[RemnaWaveExternalSquad]: + """Получает список всех External Squads""" + response = await self._make_request('GET', '/api/external-squads') + return [self._parse_external_squad(squad) for squad in response['response']['externalSquads']] + + async def get_external_squad_by_uuid(self, uuid: str) -> Optional[RemnaWaveExternalSquad]: + """Получает External Squad по UUID""" + try: + response = await self._make_request('GET', f'/api/external-squads/{uuid}') + return self._parse_external_squad(response['response']) + except RemnaWaveAPIError as e: + if e.status_code == 404: + return None + raise + + async def create_external_squad(self, name: str) -> RemnaWaveExternalSquad: + data = {'name': name} + response = await self._make_request('POST', '/api/external-squads', data) + return self._parse_external_squad(response['response']) + + async def update_external_squad( + self, + uuid: str, + name: Optional[str] = None, + templates: Optional[List[Dict[str, str]]] = None, + subscription_settings: Optional[Dict[str, Any]] = None, + host_overrides: Optional[Dict[str, Any]] = None, + response_headers: Optional[Dict[str, str]] = None, + hwid_settings: Optional[Dict[str, Any]] = None, + custom_remarks: Optional[Dict[str, Any]] = None, + subpage_config_uuid: Optional[str] = None + ) -> RemnaWaveExternalSquad: + data = {'uuid': uuid} + if name is not None: + data['name'] = name + if templates is not None: + data['templates'] = templates + if subscription_settings is not None: + data['subscriptionSettings'] = subscription_settings + if host_overrides is not None: + data['hostOverrides'] = host_overrides + if response_headers is not None: + data['responseHeaders'] = response_headers + if hwid_settings is not None: + data['hwidSettings'] = hwid_settings + if custom_remarks is not None: + data['customRemarks'] = custom_remarks + if subpage_config_uuid is not None: + data['subpageConfigUuid'] = subpage_config_uuid + + response = await self._make_request('PATCH', '/api/external-squads', data) + return self._parse_external_squad(response['response']) + + async def delete_external_squad(self, uuid: str) -> bool: + """Удаляет External Squad""" + response = await self._make_request('DELETE', f'/api/external-squads/{uuid}') + return response['response']['isDeleted'] + + async def add_users_to_external_squad(self, uuid: str) -> bool: + """Добавляет всех пользователей в External Squad (bulk action)""" + response = await self._make_request('POST', f'/api/external-squads/{uuid}/bulk-actions/add-users') + return response['response']['eventSent'] + + async def remove_users_from_external_squad(self, uuid: str) -> bool: + """Удаляет всех пользователей из External Squad (bulk action)""" + response = await self._make_request('POST', f'/api/external-squads/{uuid}/bulk-actions/remove-users') + return response['response']['eventSent'] + + async def reorder_external_squads(self, items: List[Dict[str, Any]]) -> List[RemnaWaveExternalSquad]: + data = {'items': items} + response = await self._make_request('POST', '/api/external-squads/actions/reorder', data) + return [self._parse_external_squad(squad) for squad in response['response']['externalSquads']] + + def _parse_external_squad(self, squad_data: Dict) -> RemnaWaveExternalSquad: + """Парсит данные External Squad""" + info = squad_data.get('info', {}) + return RemnaWaveExternalSquad( + uuid=squad_data['uuid'], + name=squad_data['name'], + view_position=squad_data.get('viewPosition', 0), + members_count=info.get('membersCount', 0), + templates=squad_data.get('templates', []), + subscription_settings=squad_data.get('subscriptionSettings'), + host_overrides=squad_data.get('hostOverrides'), + response_headers=squad_data.get('responseHeaders'), + hwid_settings=squad_data.get('hwidSettings'), + custom_remarks=squad_data.get('customRemarks'), + subpage_config_uuid=squad_data.get('subpageConfigUuid'), + created_at=self._parse_optional_datetime(squad_data.get('createdAt')), + updated_at=self._parse_optional_datetime(squad_data.get('updatedAt')) + ) + async def get_all_nodes(self) -> List[RemnaWaveNode]: response = await self._make_request('GET', '/api/nodes') @@ -683,29 +795,148 @@ class RemnaWaveAPI: return response['response'] async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]: - response = await self._make_request('GET', '/api/nodes/usage/realtime') - return response['response'] + return await self.get_bandwidth_stats_nodes_realtime() async def get_user_stats_usage(self, user_uuid: str, start_date: str, end_date: str) -> Dict[str, Any]: - """ - Получает статистику использования трафика пользователем за указанный период + return await self.get_bandwidth_stats_user_legacy(user_uuid, start_date, end_date) - Args: - user_uuid: UUID пользователя - start_date: Начальная дата в формате ISO (например, "2025-12-09T00:00:00.000Z") - end_date: Конечная дата в формате ISO (например, "2025-12-09T23:59:59.999Z") + # ============== Bandwidth Stats API ============== - Returns: - Словарь с информацией о трафике пользователя за указанный период - """ + async def get_bandwidth_stats_nodes(self, start_date: str, end_date: str) -> Dict[str, Any]: params = { 'start': start_date, 'end': end_date } - response = await self._make_request('GET', f'/api/users/stats/usage/{user_uuid}/range', params=params) + response = await self._make_request('GET', '/api/bandwidth-stats/nodes', params=params) + return response['response'] + + async def get_bandwidth_stats_nodes_realtime(self) -> List[Dict[str, Any]]: + response = await self._make_request('GET', '/api/bandwidth-stats/nodes/realtime') + return response['response'] + + async def get_bandwidth_stats_node_users( + self, + node_uuid: str, + start_date: str, + end_date: str, + top_users_limit: int = 10 + ) -> Dict[str, Any]: + params = { + 'start': start_date, + 'end': end_date, + 'topUsersLimit': top_users_limit + } + response = await self._make_request('GET', f'/api/bandwidth-stats/nodes/{node_uuid}/users', params=params) + return response['response'] + + async def get_bandwidth_stats_node_users_legacy( + self, + node_uuid: str, + start_date: str, + end_date: str + ) -> Dict[str, Any]: + params = { + 'start': start_date, + 'end': end_date + } + response = await self._make_request('GET', f'/api/bandwidth-stats/nodes/{node_uuid}/users/legacy', params=params) + return response['response'] + + async def get_bandwidth_stats_user( + self, + user_uuid: str, + start_date: str, + end_date: str + ) -> Dict[str, Any]: + params = { + 'start': start_date, + 'end': end_date + } + response = await self._make_request('GET', f'/api/bandwidth-stats/users/{user_uuid}', params=params) + return response['response'] + + async def get_bandwidth_stats_user_legacy( + self, + user_uuid: str, + start_date: str, + end_date: str + ) -> Dict[str, Any]: + params = { + 'start': start_date, + 'end': end_date + } + response = await self._make_request('GET', f'/api/bandwidth-stats/users/{user_uuid}/legacy', params=params) return response - - + + # ============== Subscription Page Configs API ============== + + async def get_subscription_page_configs(self) -> List[SubscriptionPageConfig]: + response = await self._make_request('GET', '/api/subscription-page-configs') + configs_data = response['response'].get('configs', []) + return [self._parse_subscription_page_config(c) for c in configs_data] + + async def get_subscription_page_config(self, uuid: str) -> Optional[SubscriptionPageConfig]: + try: + response = await self._make_request('GET', f'/api/subscription-page-configs/{uuid}') + return self._parse_subscription_page_config(response['response']) + except RemnaWaveAPIError as e: + if e.status_code == 404: + return None + raise + + async def create_subscription_page_config(self, name: str) -> SubscriptionPageConfig: + data = {'name': name} + response = await self._make_request('POST', '/api/subscription-page-configs', data) + return self._parse_subscription_page_config(response['response']) + + async def update_subscription_page_config( + self, + uuid: str, + name: Optional[str] = None, + config: Optional[Dict[str, Any]] = None + ) -> SubscriptionPageConfig: + data = {'uuid': uuid} + if name is not None: + data['name'] = name + if config is not None: + data['config'] = config + response = await self._make_request('PATCH', '/api/subscription-page-configs', data) + return self._parse_subscription_page_config(response['response']) + + async def delete_subscription_page_config(self, uuid: str) -> bool: + response = await self._make_request('DELETE', f'/api/subscription-page-configs/{uuid}') + return response['response']['isDeleted'] + + async def reorder_subscription_page_configs(self, items: List[Dict[str, Any]]) -> List[SubscriptionPageConfig]: + data = {'items': items} + response = await self._make_request('POST', '/api/subscription-page-configs/actions/reorder', data) + configs_data = response['response'].get('configs', []) + return [self._parse_subscription_page_config(c) for c in configs_data] + + async def clone_subscription_page_config(self, clone_from_uuid: str) -> SubscriptionPageConfig: + data = {'cloneFromUuid': clone_from_uuid} + response = await self._make_request('POST', '/api/subscription-page-configs/actions/clone', data) + return self._parse_subscription_page_config(response['response']) + + async def get_subpage_config_by_short_uuid(self, short_uuid: str) -> Optional[Dict[str, Any]]: + try: + response = await self._make_request('GET', f'/api/subscriptions/subpage-config/{short_uuid}') + return response.get('response') + except RemnaWaveAPIError as e: + if e.status_code == 404: + return None + raise + + def _parse_subscription_page_config(self, data: Dict) -> SubscriptionPageConfig: + """Парсит данные конфигурации страницы подписки""" + return SubscriptionPageConfig( + uuid=data['uuid'], + name=data['name'], + view_position=data['viewPosition'], + config=data.get('config') + ) + + async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]: try: response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}') @@ -756,10 +987,6 @@ class RemnaWaveAPI: return False async def encrypt_happ_crypto_link(self, link_to_encrypt: str) -> Optional[str]: - """ - Шифрует ссылку подписки через API Remnawave. - Возвращает зашифрованную happ:// ссылку или None при ошибке. - """ try: data = {"linkToEncrypt": link_to_encrypt} response = await self._make_request('POST', '/api/system/tools/happ/encrypt', data) @@ -772,10 +999,6 @@ class RemnaWaveAPI: return None async def enrich_user_with_happ_link(self, user: RemnaWaveUser) -> RemnaWaveUser: - """ - Обогащает объект пользователя зашифрованной happ ссылкой, - если она отсутствует но есть subscription_url. - """ if not user.happ_crypto_link and user.subscription_url: encrypted = await self.encrypt_happ_crypto_link(user.subscription_url) if encrypted: From e4877765106b2427d4032ebfbf0881375c7dedf5 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 21 Dec 2025 03:40:00 +0300 Subject: [PATCH 29/47] Update remnawave_service.py --- app/services/remnawave_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index c23cbda1..ab644fb0 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -1945,8 +1945,8 @@ class RemnaWaveService: } usage_data = await api._make_request( - 'GET', - f'/api/nodes/usage/{node_uuid}/users/range', + 'GET', + f'/api/bandwidth-stats/nodes/{node_uuid}/users/legacy', params=params ) From aa669fa3cd78b2ef2f75840dafbc3ba53686c701 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sun, 21 Dec 2025 04:05:26 +0300 Subject: [PATCH 30/47] Refactor logging in ButtonStatsMiddleware and cleanup debug endpoints in menu_layout; streamline button click logging and enhance error handling. --- app/bot.py | 5 +-- app/middlewares/button_stats.py | 7 +--- app/webapi/routes/menu_layout.py | 72 ++++---------------------------- 3 files changed, 11 insertions(+), 73 deletions(-) diff --git a/app/bot.py b/app/bot.py index c3cd23bf..b663356a 100644 --- a/app/bot.py +++ b/app/bot.py @@ -130,13 +130,10 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: dp.callback_query.middleware(ThrottlingMiddleware()) # Middleware для автоматического логирования кликов по кнопкам - logger.info(f"📊 MENU_LAYOUT_ENABLED = {settings.MENU_LAYOUT_ENABLED}") if settings.MENU_LAYOUT_ENABLED: button_stats_middleware = ButtonStatsMiddleware() dp.callback_query.middleware(button_stats_middleware) - logger.info("📊 ButtonStatsMiddleware активирован - автоматическое логирование кликов включено") - else: - logger.warning("⚠️ ButtonStatsMiddleware ОТКЛЮЧЕН - MENU_LAYOUT_ENABLED=False") + logger.info("📊 ButtonStatsMiddleware активирован") if settings.CHANNEL_IS_REQUIRED_SUB: from app.middlewares.channel_checker import ChannelCheckerMiddleware diff --git a/app/middlewares/button_stats.py b/app/middlewares/button_stats.py index 4263462a..a676729f 100644 --- a/app/middlewares/button_stats.py +++ b/app/middlewares/button_stats.py @@ -48,8 +48,6 @@ class ButtonStatsMiddleware(BaseMiddleware): if event.message and hasattr(event.message, 'reply_markup'): button_text = self._extract_button_text(event.message.reply_markup, callback_data) - logger.info(f"📊 ButtonStats: клик user={user_id}, button={callback_data[:50]}, type={button_type}") - # Логируем в фоне, не блокируя обработку # Используем asyncio.create_task для фоновой задачи import asyncio @@ -115,9 +113,8 @@ class ButtonStatsMiddleware(BaseMiddleware): button_type=button_type, button_text=button_text ) - logger.info(f"✅ ButtonStats: записано в БД button={button_id[:30]}, user={user_id}") except Exception as e: - logger.error(f"❌ Ошибка записи клика в БД {button_id}: {e}", exc_info=True) + logger.debug(f"Ошибка записи клика в БД {button_id}: {e}") except Exception as e: - logger.error(f"❌ Ошибка создания сессии БД для логирования клика: {e}", exc_info=True) + logger.debug(f"Ошибка создания сессии БД для логирования клика: {e}") diff --git a/app/webapi/routes/menu_layout.py b/app/webapi/routes/menu_layout.py index 7f79522b..da8fde1d 100644 --- a/app/webapi/routes/menu_layout.py +++ b/app/webapi/routes/menu_layout.py @@ -836,43 +836,6 @@ async def log_button_click( return {"success": True} -@router.get("/stats/debug") -async def debug_stats( - limit: int = 10, - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> dict: - """DEBUG: Показать сырые данные из таблицы button_click_logs.""" - from sqlalchemy import text - - # Общее количество - total = await db.execute(text("SELECT COUNT(*) FROM button_click_logs")) - total_count = total.scalar() - - # Последние записи - result = await db.execute(text(f""" - SELECT id, button_id, user_id, button_type, clicked_at - FROM button_click_logs - ORDER BY clicked_at DESC - LIMIT {limit} - """)) - rows = result.fetchall() - - return { - "total_count": total_count, - "last_records": [ - { - "id": row[0], - "button_id": row[1], - "user_id": row[2], - "button_type": row[3], - "clicked_at": str(row[4]) if row[4] else None, - } - for row in rows - ] - } - - @router.get("/stats/by-type", response_model=ButtonTypeStatsResponse) async def get_stats_by_button_type( days: int = 30, @@ -881,16 +844,8 @@ async def get_stats_by_button_type( ) -> ButtonTypeStatsResponse: """Получить статистику кликов по типам кнопок (builtin, callback, url, mini_app).""" try: - # Отладка: проверяем сколько записей в таблице - from sqlalchemy import text - count_result = await db.execute(text("SELECT COUNT(*) FROM button_click_logs")) - total_in_table = count_result.scalar() - logger.info(f"📊 DEBUG: Всего записей в button_click_logs: {total_in_table}") - stats = await MenuLayoutService.get_stats_by_button_type(db, days) total_clicks = sum(s["clicks_total"] for s in stats) - - logger.info(f"📊 Stats by type: {len(stats)} types, total_clicks={total_clicks}") return ButtonTypeStatsResponse( items=[ @@ -960,28 +915,17 @@ async def get_top_users( ) -> TopUsersResponse: """Получить топ пользователей по количеству кликов.""" try: - # Отладка: проверяем user_id в таблице - from sqlalchemy import text - null_count = await db.execute(text("SELECT COUNT(*) FROM button_click_logs WHERE user_id IS NULL")) - not_null_count = await db.execute(text("SELECT COUNT(*) FROM button_click_logs WHERE user_id IS NOT NULL")) - logger.info(f"📊 DEBUG top-users: user_id IS NULL: {null_count.scalar()}, IS NOT NULL: {not_null_count.scalar()}") - stats = await MenuLayoutService.get_top_users(db, button_id, limit, days) - logger.info(f"📊 Top users: {len(stats)} users, data={stats}, button_id={button_id}, limit={limit}, days={days}") - - items = [ - TopUserStats( - user_id=s["user_id"], - clicks_count=s["clicks_count"], - last_click_at=s["last_click_at"], - ) - for s in stats - ] - logger.info(f"📊 Top users response items: {len(items)}") - return TopUsersResponse( - items=items, + items=[ + TopUserStats( + user_id=s["user_id"], + clicks_count=s["clicks_count"], + last_click_at=s["last_click_at"], + ) + for s in stats + ], button_id=button_id, limit=limit, ) From 1d01a77457faa6bd054c9f15257b7514d165c476 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sun, 21 Dec 2025 04:35:58 +0300 Subject: [PATCH 31/47] Add global partner statistics endpoints and enhance partner-related schemas; implement detailed stats retrieval for referrers and daily statistics. --- app/services/partner_stats_service.py | 595 ++++++++++++++++++++++++++ app/webapi/routes/partners.py | 179 +++++++- app/webapi/schemas/partners.py | 156 +++++++ 3 files changed, 929 insertions(+), 1 deletion(-) create mode 100644 app/services/partner_stats_service.py diff --git a/app/services/partner_stats_service.py b/app/services/partner_stats_service.py new file mode 100644 index 00000000..f5c2b254 --- /dev/null +++ b/app/services/partner_stats_service.py @@ -0,0 +1,595 @@ +"""Сервис расширенной статистики партнёров (рефереров).""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +from sqlalchemy import and_, case, desc, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import ( + Payment, + ReferralEarning, + Subscription, + SubscriptionStatus, + Transaction, + TransactionType, + User, +) + +logger = logging.getLogger(__name__) + + +class PartnerStatsService: + """Сервис для детальной статистики партнёров.""" + + @classmethod + async def get_referrer_detailed_stats( + cls, + db: AsyncSession, + user_id: int, + ) -> Dict[str, Any]: + """Получить детальную статистику реферера.""" + now = datetime.utcnow() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=30) + year_ago = now - timedelta(days=365) + + # Базовые данные о рефералах + referrals_query = select(User).where(User.referred_by_id == user_id) + referrals_result = await db.execute(referrals_query) + referrals = referrals_result.scalars().all() + referral_ids = [r.id for r in referrals] + + total_referrals = len(referrals) + + # Сколько сделали первое пополнение (has_made_first_topup) + paid_referrals = sum(1 for r in referrals if r.has_made_first_topup) + + # Активные рефералы (с активной подпиской) + if referral_ids: + active_result = await db.execute( + select(func.count(func.distinct(User.id))) + .join(Subscription, User.id == Subscription.user_id) + .where( + and_( + User.id.in_(referral_ids), + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.end_date > now, + ) + ) + ) + active_referrals = active_result.scalar() or 0 + else: + active_referrals = 0 + + # Заработки по периодам + earnings_all_time = await cls._get_earnings_for_period(db, user_id, None) + earnings_today = await cls._get_earnings_for_period(db, user_id, today_start) + earnings_week = await cls._get_earnings_for_period(db, user_id, week_ago) + earnings_month = await cls._get_earnings_for_period(db, user_id, month_ago) + earnings_year = await cls._get_earnings_for_period(db, user_id, year_ago) + + # Рефералы по периодам + referrals_today = sum(1 for r in referrals if r.created_at >= today_start) + referrals_week = sum(1 for r in referrals if r.created_at >= week_ago) + referrals_month = sum(1 for r in referrals if r.created_at >= month_ago) + referrals_year = sum(1 for r in referrals if r.created_at >= year_ago) + + # Конверсии + conversion_to_paid = round((paid_referrals / total_referrals * 100), 2) if total_referrals > 0 else 0 + conversion_to_active = round((active_referrals / total_referrals * 100), 2) if total_referrals > 0 else 0 + + # Средний доход с реферала + avg_earnings_per_referral = round(earnings_all_time / paid_referrals, 2) if paid_referrals > 0 else 0 + + return { + "user_id": user_id, + "summary": { + "total_referrals": total_referrals, + "paid_referrals": paid_referrals, + "active_referrals": active_referrals, + "conversion_to_paid_percent": conversion_to_paid, + "conversion_to_active_percent": conversion_to_active, + "avg_earnings_per_referral_kopeks": avg_earnings_per_referral, + }, + "earnings": { + "all_time_kopeks": earnings_all_time, + "year_kopeks": earnings_year, + "month_kopeks": earnings_month, + "week_kopeks": earnings_week, + "today_kopeks": earnings_today, + }, + "referrals_count": { + "all_time": total_referrals, + "year": referrals_year, + "month": referrals_month, + "week": referrals_week, + "today": referrals_today, + }, + } + + @classmethod + async def get_referrer_daily_stats( + cls, + db: AsyncSession, + user_id: int, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Получить статистику реферера по дням.""" + now = datetime.utcnow() + start_date = now - timedelta(days=days) + + # Рефералы по дням + referrals_by_day = await db.execute( + select( + func.date(User.created_at).label("date"), + func.count(User.id).label("referrals_count"), + ) + .where( + and_( + User.referred_by_id == user_id, + User.created_at >= start_date, + ) + ) + .group_by(func.date(User.created_at)) + .order_by(func.date(User.created_at)) + ) + referrals_dict = {str(row.date): row.referrals_count for row in referrals_by_day.all()} + + # Заработки по дням (из ReferralEarning) + earnings_by_day = await db.execute( + select( + func.date(ReferralEarning.created_at).label("date"), + func.sum(ReferralEarning.amount_kopeks).label("earnings"), + ) + .where( + and_( + ReferralEarning.user_id == user_id, + ReferralEarning.created_at >= start_date, + ) + ) + .group_by(func.date(ReferralEarning.created_at)) + ) + earnings_dict = {str(row.date): int(row.earnings or 0) for row in earnings_by_day.all()} + + # Формируем массив за все дни + result = [] + for i in range(days): + date = (start_date + timedelta(days=i)).date() + date_str = str(date) + result.append({ + "date": date_str, + "referrals_count": referrals_dict.get(date_str, 0), + "earnings_kopeks": earnings_dict.get(date_str, 0), + }) + + return result + + @classmethod + async def get_referrer_top_referrals( + cls, + db: AsyncSession, + user_id: int, + limit: int = 10, + ) -> List[Dict[str, Any]]: + """Получить топ рефералов по доходу для реферера.""" + # Получаем рефералов с их доходами + result = await db.execute( + select( + User.id, + User.telegram_id, + User.username, + User.first_name, + User.last_name, + User.created_at, + User.has_made_first_topup, + func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label("total_earnings"), + ) + .outerjoin(ReferralEarning, ReferralEarning.referral_id == User.id) + .where(User.referred_by_id == user_id) + .group_by(User.id) + .order_by(desc(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))) + .limit(limit) + ) + + referrals = [] + now = datetime.utcnow() + for row in result.all(): + # Проверяем активность подписки + sub_result = await db.execute( + select(Subscription) + .where( + and_( + Subscription.user_id == row.id, + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.end_date > now, + ) + ) + .limit(1) + ) + is_active = sub_result.scalar_one_or_none() is not None + + referrals.append({ + "id": row.id, + "telegram_id": row.telegram_id, + "username": row.username, + "first_name": row.first_name, + "last_name": row.last_name, + "full_name": f"{row.first_name or ''} {row.last_name or ''}".strip() or f"User {row.telegram_id}", + "created_at": row.created_at, + "has_made_first_topup": row.has_made_first_topup, + "is_active": is_active, + "total_earnings_kopeks": int(row.total_earnings), + }) + + return referrals + + @classmethod + async def get_referrer_period_comparison( + cls, + db: AsyncSession, + user_id: int, + current_days: int = 7, + previous_days: int = 7, + ) -> Dict[str, Any]: + """Сравнить текущий и предыдущий период.""" + now = datetime.utcnow() + current_start = now - timedelta(days=current_days) + previous_start = current_start - timedelta(days=previous_days) + previous_end = current_start + + # Рефералы за текущий период + current_referrals = await db.execute( + select(func.count(User.id)) + .where( + and_( + User.referred_by_id == user_id, + User.created_at >= current_start, + ) + ) + ) + current_referrals_count = current_referrals.scalar() or 0 + + # Рефералы за предыдущий период + previous_referrals = await db.execute( + select(func.count(User.id)) + .where( + and_( + User.referred_by_id == user_id, + User.created_at >= previous_start, + User.created_at < previous_end, + ) + ) + ) + previous_referrals_count = previous_referrals.scalar() or 0 + + # Заработки за текущий период + current_earnings = await cls._get_earnings_for_period(db, user_id, current_start) + + # Заработки за предыдущий период + previous_earnings = await cls._get_earnings_for_period( + db, user_id, previous_start, previous_end + ) + + # Расчёт изменений + referrals_change = current_referrals_count - previous_referrals_count + referrals_change_percent = ( + round((referrals_change / previous_referrals_count * 100), 2) + if previous_referrals_count > 0 + else 0 + ) + + earnings_change = current_earnings - previous_earnings + earnings_change_percent = ( + round((earnings_change / previous_earnings * 100), 2) + if previous_earnings > 0 + else 0 + ) + + return { + "current_period": { + "days": current_days, + "start": current_start.isoformat(), + "end": now.isoformat(), + "referrals_count": current_referrals_count, + "earnings_kopeks": current_earnings, + }, + "previous_period": { + "days": previous_days, + "start": previous_start.isoformat(), + "end": previous_end.isoformat(), + "referrals_count": previous_referrals_count, + "earnings_kopeks": previous_earnings, + }, + "change": { + "referrals": { + "absolute": referrals_change, + "percent": referrals_change_percent, + "trend": "up" if referrals_change > 0 else "down" if referrals_change < 0 else "stable", + }, + "earnings": { + "absolute": earnings_change, + "percent": earnings_change_percent, + "trend": "up" if earnings_change > 0 else "down" if earnings_change < 0 else "stable", + }, + }, + } + + @classmethod + async def get_global_partner_stats( + cls, + db: AsyncSession, + days: int = 30, + ) -> Dict[str, Any]: + """Глобальная статистика партнёрской программы.""" + now = datetime.utcnow() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=30) + year_ago = now - timedelta(days=365) + start_date = now - timedelta(days=days) + + # Всего рефереров (у кого есть рефералы) + total_referrers = await db.execute( + select(func.count(func.distinct(User.referred_by_id))) + .where(User.referred_by_id.isnot(None)) + ) + total_referrers_count = total_referrers.scalar() or 0 + + # Всего рефералов + total_referrals = await db.execute( + select(func.count(User.id)) + .where(User.referred_by_id.isnot(None)) + ) + total_referrals_count = total_referrals.scalar() or 0 + + # Рефералы которые заплатили + paid_referrals = await db.execute( + select(func.count(User.id)) + .where( + and_( + User.referred_by_id.isnot(None), + User.has_made_first_topup == True, + ) + ) + ) + paid_referrals_count = paid_referrals.scalar() or 0 + + # Всего выплачено + total_paid = await cls._get_total_earnings(db, None) + today_paid = await cls._get_total_earnings(db, today_start) + week_paid = await cls._get_total_earnings(db, week_ago) + month_paid = await cls._get_total_earnings(db, month_ago) + year_paid = await cls._get_total_earnings(db, year_ago) + + # Новые рефералы по периодам + new_referrals_today = await db.execute( + select(func.count(User.id)) + .where( + and_( + User.referred_by_id.isnot(None), + User.created_at >= today_start, + ) + ) + ) + new_referrals_week = await db.execute( + select(func.count(User.id)) + .where( + and_( + User.referred_by_id.isnot(None), + User.created_at >= week_ago, + ) + ) + ) + new_referrals_month = await db.execute( + select(func.count(User.id)) + .where( + and_( + User.referred_by_id.isnot(None), + User.created_at >= month_ago, + ) + ) + ) + + # Конверсия + conversion_rate = ( + round((paid_referrals_count / total_referrals_count * 100), 2) + if total_referrals_count > 0 + else 0 + ) + + # Средний доход с реферала + avg_per_referral = ( + round(total_paid / paid_referrals_count, 2) + if paid_referrals_count > 0 + else 0 + ) + + return { + "summary": { + "total_referrers": total_referrers_count, + "total_referrals": total_referrals_count, + "paid_referrals": paid_referrals_count, + "conversion_rate_percent": conversion_rate, + "avg_earnings_per_referral_kopeks": avg_per_referral, + }, + "payouts": { + "all_time_kopeks": total_paid, + "year_kopeks": year_paid, + "month_kopeks": month_paid, + "week_kopeks": week_paid, + "today_kopeks": today_paid, + }, + "new_referrals": { + "today": new_referrals_today.scalar() or 0, + "week": new_referrals_week.scalar() or 0, + "month": new_referrals_month.scalar() or 0, + }, + } + + @classmethod + async def get_global_daily_stats( + cls, + db: AsyncSession, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Глобальная статистика по дням.""" + now = datetime.utcnow() + start_date = now - timedelta(days=days) + + # Рефералы по дням + referrals_by_day = await db.execute( + select( + func.date(User.created_at).label("date"), + func.count(User.id).label("referrals_count"), + ) + .where( + and_( + User.referred_by_id.isnot(None), + User.created_at >= start_date, + ) + ) + .group_by(func.date(User.created_at)) + ) + referrals_dict = {str(row.date): row.referrals_count for row in referrals_by_day.all()} + + # Выплаты по дням + earnings_by_day = await db.execute( + select( + func.date(ReferralEarning.created_at).label("date"), + func.sum(ReferralEarning.amount_kopeks).label("earnings"), + ) + .where(ReferralEarning.created_at >= start_date) + .group_by(func.date(ReferralEarning.created_at)) + ) + earnings_dict = {str(row.date): int(row.earnings or 0) for row in earnings_by_day.all()} + + result = [] + for i in range(days): + date = (start_date + timedelta(days=i)).date() + date_str = str(date) + result.append({ + "date": date_str, + "referrals_count": referrals_dict.get(date_str, 0), + "earnings_kopeks": earnings_dict.get(date_str, 0), + }) + + return result + + @classmethod + async def get_top_referrers( + cls, + db: AsyncSession, + limit: int = 10, + days: Optional[int] = None, + ) -> List[Dict[str, Any]]: + """Получить топ рефереров.""" + now = datetime.utcnow() + start_date = now - timedelta(days=days) if days else None + + # Подсчёт рефералов и заработков + earnings_query = ( + select( + ReferralEarning.user_id, + func.sum(ReferralEarning.amount_kopeks).label("total_earnings"), + ) + .group_by(ReferralEarning.user_id) + ) + if start_date: + earnings_query = earnings_query.where(ReferralEarning.created_at >= start_date) + + earnings_result = await db.execute(earnings_query) + earnings_dict = {row.user_id: int(row.total_earnings or 0) for row in earnings_result.all()} + + # Подсчёт рефералов + referrals_query = ( + select( + User.referred_by_id, + func.count(User.id).label("referrals_count"), + ) + .where(User.referred_by_id.isnot(None)) + .group_by(User.referred_by_id) + ) + if start_date: + referrals_query = referrals_query.where(User.created_at >= start_date) + + referrals_result = await db.execute(referrals_query) + referrals_dict = {row.referred_by_id: row.referrals_count for row in referrals_result.all()} + + # Объединяем данные + all_referrer_ids = set(earnings_dict.keys()) | set(referrals_dict.keys()) + referrers_data = [] + + for referrer_id in all_referrer_ids: + referrers_data.append({ + "user_id": referrer_id, + "referrals_count": referrals_dict.get(referrer_id, 0), + "total_earnings": earnings_dict.get(referrer_id, 0), + }) + + # Сортируем по заработку + referrers_data.sort(key=lambda x: x["total_earnings"], reverse=True) + top_referrers = referrers_data[:limit] + + # Получаем данные пользователей + result = [] + for data in top_referrers: + user_result = await db.execute( + select(User).where(User.id == data["user_id"]) + ) + user = user_result.scalar_one_or_none() + + if user: + result.append({ + "id": user.id, + "telegram_id": user.telegram_id, + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "full_name": f"{user.first_name or ''} {user.last_name or ''}".strip() or f"User {user.telegram_id}", + "referral_code": user.referral_code, + "referrals_count": data["referrals_count"], + "total_earnings_kopeks": data["total_earnings"], + }) + + return result + + @classmethod + async def _get_earnings_for_period( + cls, + db: AsyncSession, + user_id: int, + start_date: Optional[datetime], + end_date: Optional[datetime] = None, + ) -> int: + """Получить заработки за период.""" + query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where( + ReferralEarning.user_id == user_id + ) + + if start_date: + query = query.where(ReferralEarning.created_at >= start_date) + if end_date: + query = query.where(ReferralEarning.created_at < end_date) + + result = await db.execute(query) + return int(result.scalar() or 0) + + @classmethod + async def _get_total_earnings( + cls, + db: AsyncSession, + start_date: Optional[datetime], + ) -> int: + """Получить общие выплаты за период.""" + query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) + + if start_date: + query = query.where(ReferralEarning.created_at >= start_date) + + result = await db.execute(query) + return int(result.scalar() or 0) diff --git a/app/webapi/routes/partners.py b/app/webapi/routes/partners.py index dd882689..05fcd856 100644 --- a/app/webapi/routes/partners.py +++ b/app/webapi/routes/partners.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Security, status @@ -14,6 +15,7 @@ from app.database.crud.user import ( update_user, ) from app.database.models import User +from app.services.partner_stats_service import PartnerStatsService from app.utils.user_utils import ( get_detailed_referral_list, get_effective_referral_commission_percent, @@ -21,14 +23,34 @@ from app.utils.user_utils import ( from ..dependencies import get_db_session, require_api_token from ..schemas.partners import ( + ChangeData, + DailyStats, + DailyStatsResponse, + EarningsByPeriod, + GlobalPartnerStats, + GlobalPartnerSummary, + NewReferralsByPeriod, + PartnerReferralCommissionUpdate, PartnerReferralItem, PartnerReferralList, - PartnerReferralCommissionUpdate, PartnerReferrerDetail, PartnerReferrerItem, PartnerReferrerListResponse, + PayoutsByPeriod, + PeriodChange, + PeriodComparisonResponse, + PeriodData, + ReferralsCountByPeriod, + ReferrerDetailedStats, + ReferrerSummary, + TopReferralItem, + TopReferralsResponse, + TopReferrerItem, + TopReferrersResponse, ) +logger = logging.getLogger(__name__) + router = APIRouter() @@ -197,3 +219,158 @@ async def update_referrer_commission( stats = await get_user_referral_stats(db, user.id) return _serialize_referrer(user, stats) + + +# ============================================================================ +# РАСШИРЕННАЯ СТАТИСТИКА ПАРТНЁРОВ +# ============================================================================ + + +@router.get("/stats", response_model=GlobalPartnerStats) +async def get_global_partner_stats( + days: int = Query(30, ge=1, le=365), + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> GlobalPartnerStats: + """Глобальная статистика партнёрской программы.""" + data = await PartnerStatsService.get_global_partner_stats(db, days) + + return GlobalPartnerStats( + summary=GlobalPartnerSummary(**data["summary"]), + payouts=PayoutsByPeriod(**data["payouts"]), + new_referrals=NewReferralsByPeriod(**data["new_referrals"]), + ) + + +@router.get("/stats/daily", response_model=DailyStatsResponse) +async def get_global_daily_stats( + days: int = Query(30, ge=1, le=365), + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> DailyStatsResponse: + """Глобальная статистика по дням.""" + data = await PartnerStatsService.get_global_daily_stats(db, days) + + return DailyStatsResponse( + items=[DailyStats(**item) for item in data], + days=days, + user_id=None, + ) + + +@router.get("/stats/top-referrers", response_model=TopReferrersResponse) +async def get_top_referrers( + limit: int = Query(10, ge=1, le=100), + days: Optional[int] = Query(None, ge=1, le=365), + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> TopReferrersResponse: + """Топ рефереров по заработку.""" + data = await PartnerStatsService.get_top_referrers(db, limit, days) + + return TopReferrersResponse( + items=[TopReferrerItem(**item) for item in data], + days=days, + ) + + +@router.get("/referrers/{user_id}/stats", response_model=ReferrerDetailedStats) +async def get_referrer_detailed_stats( + user_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> ReferrerDetailedStats: + """Детальная статистика реферера.""" + user = await get_user_by_telegram_id(db, user_id) + if not user: + user = await get_user_by_id(db, user_id) + + if not user: + raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + + data = await PartnerStatsService.get_referrer_detailed_stats(db, user.id) + + return ReferrerDetailedStats( + user_id=data["user_id"], + summary=ReferrerSummary(**data["summary"]), + earnings=EarningsByPeriod(**data["earnings"]), + referrals_count=ReferralsCountByPeriod(**data["referrals_count"]), + ) + + +@router.get("/referrers/{user_id}/stats/daily", response_model=DailyStatsResponse) +async def get_referrer_daily_stats( + user_id: int, + days: int = Query(30, ge=1, le=365), + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> DailyStatsResponse: + """Статистика реферера по дням.""" + user = await get_user_by_telegram_id(db, user_id) + if not user: + user = await get_user_by_id(db, user_id) + + if not user: + raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + + data = await PartnerStatsService.get_referrer_daily_stats(db, user.id, days) + + return DailyStatsResponse( + items=[DailyStats(**item) for item in data], + days=days, + user_id=user.id, + ) + + +@router.get("/referrers/{user_id}/stats/top-referrals", response_model=TopReferralsResponse) +async def get_referrer_top_referrals( + user_id: int, + limit: int = Query(10, ge=1, le=100), + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> TopReferralsResponse: + """Топ рефералов реферера по принесённому доходу.""" + user = await get_user_by_telegram_id(db, user_id) + if not user: + user = await get_user_by_id(db, user_id) + + if not user: + raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + + data = await PartnerStatsService.get_referrer_top_referrals(db, user.id, limit) + + return TopReferralsResponse( + items=[TopReferralItem(**item) for item in data], + user_id=user.id, + ) + + +@router.get("/referrers/{user_id}/stats/compare", response_model=PeriodComparisonResponse) +async def get_referrer_period_comparison( + user_id: int, + current_days: int = Query(7, ge=1, le=365), + previous_days: int = Query(7, ge=1, le=365), + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> PeriodComparisonResponse: + """Сравнение периодов для реферера.""" + user = await get_user_by_telegram_id(db, user_id) + if not user: + user = await get_user_by_id(db, user_id) + + if not user: + raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + + data = await PartnerStatsService.get_referrer_period_comparison( + db, user.id, current_days, previous_days + ) + + return PeriodComparisonResponse( + current_period=PeriodData(**data["current_period"]), + previous_period=PeriodData(**data["previous_period"]), + change=PeriodChange( + referrals=ChangeData(**data["change"]["referrals"]), + earnings=ChangeData(**data["change"]["earnings"]), + ), + user_id=user.id, + ) diff --git a/app/webapi/schemas/partners.py b/app/webapi/schemas/partners.py index c46f2c49..20442e54 100644 --- a/app/webapi/schemas/partners.py +++ b/app/webapi/schemas/partners.py @@ -73,3 +73,159 @@ class PartnerReferralCommissionUpdate(BaseModel): le=100, description="Индивидуальный процент реферальной комиссии для пользователя", ) + + +# ============================================================================ +# РАСШИРЕННАЯ СТАТИСТИКА ПАРТНЁРОВ +# ============================================================================ + + +class EarningsByPeriod(BaseModel): + """Заработки по периодам.""" + all_time_kopeks: int + year_kopeks: int + month_kopeks: int + week_kopeks: int + today_kopeks: int + + +class ReferralsCountByPeriod(BaseModel): + """Количество рефералов по периодам.""" + all_time: int + year: int + month: int + week: int + today: int + + +class ReferrerSummary(BaseModel): + """Сводка по рефереру.""" + total_referrals: int + paid_referrals: int + active_referrals: int + conversion_to_paid_percent: float + conversion_to_active_percent: float + avg_earnings_per_referral_kopeks: float + + +class ReferrerDetailedStats(BaseModel): + """Детальная статистика реферера.""" + user_id: int + summary: ReferrerSummary + earnings: EarningsByPeriod + referrals_count: ReferralsCountByPeriod + + +class DailyStats(BaseModel): + """Статистика за день.""" + date: str + referrals_count: int + earnings_kopeks: int + + +class DailyStatsResponse(BaseModel): + """Ответ со статистикой по дням.""" + items: List[DailyStats] + days: int + user_id: Optional[int] = None + + +class TopReferralItem(BaseModel): + """Топ реферал.""" + id: int + telegram_id: int + username: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + full_name: str + created_at: datetime + has_made_first_topup: bool + is_active: bool + total_earnings_kopeks: int + + +class TopReferralsResponse(BaseModel): + """Топ рефералов реферера.""" + items: List[TopReferralItem] + user_id: int + + +class PeriodData(BaseModel): + """Данные за период.""" + days: int + start: str + end: str + referrals_count: int + earnings_kopeks: int + + +class ChangeData(BaseModel): + """Данные об изменении.""" + absolute: int + percent: float + trend: str # up, down, stable + + +class PeriodChange(BaseModel): + """Изменения между периодами.""" + referrals: ChangeData + earnings: ChangeData + + +class PeriodComparisonResponse(BaseModel): + """Сравнение периодов.""" + current_period: PeriodData + previous_period: PeriodData + change: PeriodChange + user_id: Optional[int] = None + + +class GlobalPartnerSummary(BaseModel): + """Глобальная сводка партнёрской программы.""" + total_referrers: int + total_referrals: int + paid_referrals: int + conversion_rate_percent: float + avg_earnings_per_referral_kopeks: float + + +class PayoutsByPeriod(BaseModel): + """Выплаты по периодам.""" + all_time_kopeks: int + year_kopeks: int + month_kopeks: int + week_kopeks: int + today_kopeks: int + + +class NewReferralsByPeriod(BaseModel): + """Новые рефералы по периодам.""" + today: int + week: int + month: int + + +class GlobalPartnerStats(BaseModel): + """Глобальная статистика партнёрской программы.""" + summary: GlobalPartnerSummary + payouts: PayoutsByPeriod + new_referrals: NewReferralsByPeriod + + +class TopReferrerItem(BaseModel): + """Топ реферер.""" + id: int + telegram_id: int + username: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + full_name: str + referral_code: Optional[str] = None + referrals_count: int + total_earnings_kopeks: int + + +class TopReferrersResponse(BaseModel): + """Топ рефереров.""" + items: List[TopReferrerItem] + days: Optional[int] = None From 1b47f2aa99aa5f554ecaed774bb1f6b25762b75e Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sun, 21 Dec 2025 04:38:42 +0300 Subject: [PATCH 32/47] Remove unused imports from partner_stats_service.py to streamline the code and improve readability. --- app/services/partner_stats_service.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/services/partner_stats_service.py b/app/services/partner_stats_service.py index f5c2b254..9948568c 100644 --- a/app/services/partner_stats_service.py +++ b/app/services/partner_stats_service.py @@ -10,12 +10,9 @@ from sqlalchemy import and_, case, desc, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import ( - Payment, ReferralEarning, Subscription, SubscriptionStatus, - Transaction, - TransactionType, User, ) From cfd79f4b23874ed5c4c64d0a852dc0f8e1878dca Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 21 Dec 2025 07:15:39 +0300 Subject: [PATCH 33/47] Update partner_stats_service.py --- app/services/partner_stats_service.py | 165 +++++++++++++++----------- 1 file changed, 99 insertions(+), 66 deletions(-) diff --git a/app/services/partner_stats_service.py b/app/services/partner_stats_service.py index 9948568c..e2a27098 100644 --- a/app/services/partner_stats_service.py +++ b/app/services/partner_stats_service.py @@ -63,12 +63,30 @@ class PartnerStatsService: else: active_referrals = 0 - # Заработки по периодам - earnings_all_time = await cls._get_earnings_for_period(db, user_id, None) - earnings_today = await cls._get_earnings_for_period(db, user_id, today_start) - earnings_week = await cls._get_earnings_for_period(db, user_id, week_ago) - earnings_month = await cls._get_earnings_for_period(db, user_id, month_ago) - earnings_year = await cls._get_earnings_for_period(db, user_id, year_ago) + # Заработки по периодам - один запрос с CASE WHEN + earnings_result = await db.execute( + select( + func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label("all_time"), + func.coalesce(func.sum( + case((ReferralEarning.created_at >= today_start, ReferralEarning.amount_kopeks), else_=0) + ), 0).label("today"), + func.coalesce(func.sum( + case((ReferralEarning.created_at >= week_ago, ReferralEarning.amount_kopeks), else_=0) + ), 0).label("week"), + func.coalesce(func.sum( + case((ReferralEarning.created_at >= month_ago, ReferralEarning.amount_kopeks), else_=0) + ), 0).label("month"), + func.coalesce(func.sum( + case((ReferralEarning.created_at >= year_ago, ReferralEarning.amount_kopeks), else_=0) + ), 0).label("year"), + ).where(ReferralEarning.user_id == user_id) + ) + earnings_row = earnings_result.one() + earnings_all_time = int(earnings_row.all_time) + earnings_today = int(earnings_row.today) + earnings_week = int(earnings_row.week) + earnings_month = int(earnings_row.month) + earnings_year = int(earnings_row.year) # Рефералы по периодам referrals_today = sum(1 for r in referrals if r.created_at >= today_start) @@ -174,6 +192,8 @@ class PartnerStatsService: limit: int = 10, ) -> List[Dict[str, Any]]: """Получить топ рефералов по доходу для реферера.""" + now = datetime.utcnow() + # Получаем рефералов с их доходами result = await db.execute( select( @@ -192,24 +212,29 @@ class PartnerStatsService: .order_by(desc(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))) .limit(limit) ) + rows = result.all() + + if not rows: + return [] + + # Собираем user_id для проверки активных подписок одним запросом + user_ids = [row.id for row in rows] + + # Получаем все активные подписки для этих пользователей одним запросом + active_subs_result = await db.execute( + select(Subscription.user_id) + .where( + and_( + Subscription.user_id.in_(user_ids), + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.end_date > now, + ) + ) + ) + active_user_ids = {row.user_id for row in active_subs_result.all()} referrals = [] - now = datetime.utcnow() - for row in result.all(): - # Проверяем активность подписки - sub_result = await db.execute( - select(Subscription) - .where( - and_( - Subscription.user_id == row.id, - Subscription.status == SubscriptionStatus.ACTIVE.value, - Subscription.end_date > now, - ) - ) - .limit(1) - ) - is_active = sub_result.scalar_one_or_none() is not None - + for row in rows: referrals.append({ "id": row.id, "telegram_id": row.telegram_id, @@ -219,7 +244,7 @@ class PartnerStatsService: "full_name": f"{row.first_name or ''} {row.last_name or ''}".strip() or f"User {row.telegram_id}", "created_at": row.created_at, "has_made_first_topup": row.has_made_first_topup, - "is_active": is_active, + "is_active": row.id in active_user_ids, "total_earnings_kopeks": int(row.total_earnings), }) @@ -350,47 +375,49 @@ class PartnerStatsService: .where( and_( User.referred_by_id.isnot(None), - User.has_made_first_topup == True, + User.has_made_first_topup.is_(True), ) ) ) paid_referrals_count = paid_referrals.scalar() or 0 - # Всего выплачено - total_paid = await cls._get_total_earnings(db, None) - today_paid = await cls._get_total_earnings(db, today_start) - week_paid = await cls._get_total_earnings(db, week_ago) - month_paid = await cls._get_total_earnings(db, month_ago) - year_paid = await cls._get_total_earnings(db, year_ago) + # Всего выплачено - один запрос с CASE WHEN + payouts_result = await db.execute( + select( + func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label("all_time"), + func.coalesce(func.sum( + case((ReferralEarning.created_at >= today_start, ReferralEarning.amount_kopeks), else_=0) + ), 0).label("today"), + func.coalesce(func.sum( + case((ReferralEarning.created_at >= week_ago, ReferralEarning.amount_kopeks), else_=0) + ), 0).label("week"), + func.coalesce(func.sum( + case((ReferralEarning.created_at >= month_ago, ReferralEarning.amount_kopeks), else_=0) + ), 0).label("month"), + func.coalesce(func.sum( + case((ReferralEarning.created_at >= year_ago, ReferralEarning.amount_kopeks), else_=0) + ), 0).label("year"), + ) + ) + payouts_row = payouts_result.one() + total_paid = int(payouts_row.all_time) + today_paid = int(payouts_row.today) + week_paid = int(payouts_row.week) + month_paid = int(payouts_row.month) + year_paid = int(payouts_row.year) - # Новые рефералы по периодам - new_referrals_today = await db.execute( - select(func.count(User.id)) - .where( - and_( - User.referred_by_id.isnot(None), - User.created_at >= today_start, - ) - ) - ) - new_referrals_week = await db.execute( - select(func.count(User.id)) - .where( - and_( - User.referred_by_id.isnot(None), - User.created_at >= week_ago, - ) - ) - ) - new_referrals_month = await db.execute( - select(func.count(User.id)) - .where( - and_( - User.referred_by_id.isnot(None), - User.created_at >= month_ago, - ) - ) + # Новые рефералы по периодам - один запрос с CASE WHEN + new_referrals_result = await db.execute( + select( + func.sum(case((User.created_at >= today_start, 1), else_=0)).label("today"), + func.sum(case((User.created_at >= week_ago, 1), else_=0)).label("week"), + func.sum(case((User.created_at >= month_ago, 1), else_=0)).label("month"), + ).where(User.referred_by_id.isnot(None)) ) + new_referrals_row = new_referrals_result.one() + new_referrals_today_count = int(new_referrals_row.today or 0) + new_referrals_week_count = int(new_referrals_row.week or 0) + new_referrals_month_count = int(new_referrals_row.month or 0) # Конверсия conversion_rate = ( @@ -422,9 +449,9 @@ class PartnerStatsService: "today_kopeks": today_paid, }, "new_referrals": { - "today": new_referrals_today.scalar() or 0, - "week": new_referrals_week.scalar() or 0, - "month": new_referrals_month.scalar() or 0, + "today": new_referrals_today_count, + "week": new_referrals_week_count, + "month": new_referrals_month_count, }, } @@ -532,14 +559,20 @@ class PartnerStatsService: referrers_data.sort(key=lambda x: x["total_earnings"], reverse=True) top_referrers = referrers_data[:limit] - # Получаем данные пользователей + if not top_referrers: + return [] + + # Получаем данные всех пользователей одним запросом + top_user_ids = [data["user_id"] for data in top_referrers] + users_result = await db.execute( + select(User).where(User.id.in_(top_user_ids)) + ) + users_dict = {user.id: user for user in users_result.scalars().all()} + + # Формируем результат с сохранением порядка сортировки result = [] for data in top_referrers: - user_result = await db.execute( - select(User).where(User.id == data["user_id"]) - ) - user = user_result.scalar_one_or_none() - + user = users_dict.get(data["user_id"]) if user: result.append({ "id": user.id, From 7dd0f5f7b0b9955811abe29ed264a1d820d52c18 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 21 Dec 2025 07:16:06 +0300 Subject: [PATCH 34/47] Improve button configuration and logging Refactor button construction and configuration handling. --- app/services/menu_layout/service.py | 55 +++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/app/services/menu_layout/service.py b/app/services/menu_layout/service.py index 053dd5df..56b3e645 100644 --- a/app/services/menu_layout/service.py +++ b/app/services/menu_layout/service.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import copy import json import logging from datetime import datetime @@ -52,7 +53,7 @@ class MenuLayoutService: @classmethod def get_default_config(cls) -> Dict[str, Any]: """Получить дефолтную конфигурацию.""" - return DEFAULT_MENU_CONFIG.copy() + return copy.deepcopy(DEFAULT_MENU_CONFIG) @classmethod def get_builtin_buttons_info(cls) -> List[Dict[str, Any]]: @@ -95,7 +96,7 @@ class MenuLayoutService: "version": config.get("version", 1), "rows": config.get("rows", []), "buttons": config.get("buttons", {}), - "exported_at": datetime.now().isoformat(), + "exported_at": datetime.utcnow().isoformat(), } @classmethod @@ -871,6 +872,31 @@ class MenuLayoutService: # --- Форматирование текста --- + # Список всех поддерживаемых плейсхолдеров + _PLACEHOLDERS = ( + "{balance}", + "{username}", + "{subscription_days}", + "{traffic_used}", + "{traffic_left}", + "{referral_count}", + "{referral_earnings}", + ) + + @classmethod + def _text_has_placeholders(cls, text_config: Dict[str, str]) -> bool: + """Проверить, содержит ли текст динамические плейсхолдеры.""" + if not text_config or not isinstance(text_config, dict): + return False + + for lang_text in text_config.values(): + if not isinstance(lang_text, str): + continue + for placeholder in cls._PLACEHOLDERS: + if placeholder in lang_text: + return True + return False + @classmethod def _get_localized_text( cls, @@ -940,10 +966,19 @@ class MenuLayoutService: button_config: Dict[str, Any], context: MenuContext, texts: Any, + button_id: str = "", ) -> Optional[InlineKeyboardButton]: - """Построить кнопку из конфигурации.""" + """Построить кнопку из конфигурации. + + Args: + button_config: Конфигурация кнопки + context: Контекст пользователя + texts: Локализованные тексты + button_id: ID кнопки (ключ в словаре buttons) + """ button_type = button_config.get("type", "builtin") - button_id = button_config.get("builtin_id") or button_config.get("id", "") + # Используем переданный button_id или fallback на builtin_id + effective_button_id = button_id or button_config.get("builtin_id", "") text_config = button_config.get("text", {}) action = button_config.get("action", "") open_mode = button_config.get("open_mode", "callback") @@ -952,16 +987,16 @@ class MenuLayoutService: # Логирование для отладки кнопки connect is_connect_button = ( - button_id == "connect" or - "connect" in str(button_id).lower() or + effective_button_id == "connect" or + "connect" in str(effective_button_id).lower() or action == "subscription_connect" or "connect" in str(action).lower() ) - + if is_connect_button: logger.info( f"🔗 Построение кнопки connect: " - f"button_id={button_id}, type={button_type}, " + f"button_id={effective_button_id}, type={button_type}, " f"open_mode={open_mode}, action={action}, " f"webapp_url={webapp_url}" ) @@ -1083,8 +1118,8 @@ class MenuLayoutService: if not cls._evaluate_conditions(button_conditions, context): continue - # Строим кнопку - button = cls._build_button(button_cfg, context, texts) + # Строим кнопку (передаём button_id для кастомных кнопок) + button = cls._build_button(button_cfg, context, texts, button_id=button_id) if button: row_buttons.append(button) From 01d3659ad22054254335debc76011e7430185abf Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 21 Dec 2025 07:16:24 +0300 Subject: [PATCH 35/47] Update stats_service.py --- app/services/menu_layout/stats_service.py | 83 ++++++++++++++++------- 1 file changed, 59 insertions(+), 24 deletions(-) diff --git a/app/services/menu_layout/stats_service.py b/app/services/menu_layout/stats_service.py index 2b283a78..b5cda18b 100644 --- a/app/services/menu_layout/stats_service.py +++ b/app/services/menu_layout/stats_service.py @@ -5,15 +5,50 @@ from __future__ import annotations from datetime import datetime, timedelta from typing import Any, Dict, List, Optional -from sqlalchemy import select, func, and_, desc, case +from sqlalchemy import select, func, and_, desc, case, Integer from sqlalchemy.ext.asyncio import AsyncSession +from app.config import settings from app.database.models import ButtonClickLog class MenuLayoutStatsService: """Сервис для сбора и анализа статистики кликов по кнопкам.""" + @classmethod + def _is_sqlite(cls) -> bool: + """Проверить, используется ли SQLite.""" + return settings.is_sqlite() + + @classmethod + def _get_hour_expr(cls, column): + """Получить выражение для извлечения часа (совместимо с SQLite и PostgreSQL).""" + if cls._is_sqlite(): + # SQLite: strftime('%H', column) возвращает строку + return func.cast(func.strftime('%H', column), Integer) + else: + # PostgreSQL: EXTRACT(hour FROM column) + return func.extract('hour', column) + + @classmethod + def _get_weekday_expr(cls, column): + """Получить выражение для дня недели (0=Пн, 6=Вс) совместимо с SQLite и PostgreSQL.""" + if cls._is_sqlite(): + # SQLite: strftime('%w', column) возвращает 0=воскресенье, 1-6=пн-сб + # Преобразуем: 0->6, 1->0, 2->1, ..., 6->5 + dow = func.cast(func.strftime('%w', column), Integer) + return case( + (dow == 0, 6), + else_=dow - 1 + ) + else: + # PostgreSQL: EXTRACT(dow FROM column) возвращает 0=воскресенье, 1-6=пн-сб + dow = func.extract('dow', column) + return case( + (dow == 0, 6), + else_=dow - 1 + ) + @classmethod async def log_button_click( cls, @@ -44,7 +79,7 @@ class MenuLayoutStatsService: days: int = 30, ) -> Dict[str, Any]: """Получить статистику кликов по конкретной кнопке.""" - now = datetime.now() + now = datetime.utcnow() today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) week_ago = now - timedelta(days=7) month_ago = now - timedelta(days=days) @@ -120,7 +155,7 @@ class MenuLayoutStatsService: days: int = 30, ) -> List[Dict[str, Any]]: """Получить статистику кликов по дням.""" - start_date = datetime.now() - timedelta(days=days) + start_date = datetime.utcnow() - timedelta(days=days) # Группировка по дате result = await db.execute( @@ -148,7 +183,7 @@ class MenuLayoutStatsService: days: int = 30, ) -> List[Dict[str, Any]]: """Получить статистику по всем кнопкам.""" - now = datetime.now() + now = datetime.utcnow() today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) week_ago = now - timedelta(days=7) month_ago = now - timedelta(days=days) @@ -200,7 +235,7 @@ class MenuLayoutStatsService: days: int = 30, ) -> int: """Получить общее количество кликов за период.""" - start_date = datetime.now() - timedelta(days=days) + start_date = datetime.utcnow() - timedelta(days=days) result = await db.execute( select(func.count(ButtonClickLog.id)) @@ -215,7 +250,7 @@ class MenuLayoutStatsService: days: int = 30, ) -> List[Dict[str, Any]]: """Получить статистику кликов по типам кнопок.""" - start_date = datetime.now() - timedelta(days=days) + start_date = datetime.utcnow() - timedelta(days=days) result = await db.execute( select( @@ -248,10 +283,13 @@ class MenuLayoutStatsService: days: int = 30, ) -> List[Dict[str, Any]]: """Получить статистику кликов по часам дня.""" - start_date = datetime.now() - timedelta(days=days) + start_date = datetime.utcnow() - timedelta(days=days) + + # Используем helper-метод для совместимости с SQLite и PostgreSQL + hour_expr = cls._get_hour_expr(ButtonClickLog.clicked_at).label("hour") query = select( - func.extract('hour', ButtonClickLog.clicked_at).label("hour"), + hour_expr, func.count(ButtonClickLog.id).label("count") ).where(ButtonClickLog.clicked_at >= start_date) @@ -260,8 +298,8 @@ class MenuLayoutStatsService: result = await db.execute( query - .group_by(func.extract('hour', ButtonClickLog.clicked_at)) - .order_by(func.extract('hour', ButtonClickLog.clicked_at)) + .group_by(hour_expr) + .order_by(hour_expr) ) # Создаем словарь для быстрого доступа по часу @@ -269,7 +307,7 @@ class MenuLayoutStatsService: int(row.hour): row.count for row in result.all() } - + # Возвращаем все 24 часа, даже если count = 0 return [ {"hour": hour, "count": stats_dict.get(hour, 0)} @@ -284,17 +322,14 @@ class MenuLayoutStatsService: days: int = 30, ) -> List[Dict[str, Any]]: """Получить статистику кликов по дням недели. - - PostgreSQL DOW возвращает 0=воскресенье, 1=понедельник, ..., 6=суббота - Преобразуем в 0=понедельник, 6=воскресенье для удобства. - """ - start_date = datetime.now() - timedelta(days=days) - # Используем CASE для преобразования: 0 (воскресенье) -> 6, остальные -1 - weekday_expr = case( - (func.extract('dow', ButtonClickLog.clicked_at) == 0, 6), - else_=func.extract('dow', ButtonClickLog.clicked_at) - 1 - ).label("weekday") + Возвращает 0=понедельник, 6=воскресенье. + Поддерживает как PostgreSQL, так и SQLite. + """ + start_date = datetime.utcnow() - timedelta(days=days) + + # Используем helper-метод для совместимости с SQLite и PostgreSQL + weekday_expr = cls._get_weekday_expr(ButtonClickLog.clicked_at).label("weekday") query = select( weekday_expr, @@ -337,7 +372,7 @@ class MenuLayoutStatsService: days: int = 30, ) -> List[Dict[str, Any]]: """Получить топ пользователей по количеству кликов.""" - start_date = datetime.now() - timedelta(days=days) + start_date = datetime.utcnow() - timedelta(days=days) query = select( ButtonClickLog.user_id, @@ -376,7 +411,7 @@ class MenuLayoutStatsService: previous_days: int = 7, ) -> Dict[str, Any]: """Сравнить статистику текущего и предыдущего периода.""" - now = datetime.now() + now = datetime.utcnow() current_start = now - timedelta(days=current_days) previous_start = current_start - timedelta(days=previous_days) previous_end = current_start @@ -454,4 +489,4 @@ class MenuLayoutStatsService: "clicked_at": row.clicked_at, } for row in result.all() - ] \ No newline at end of file + ] From 151ce092b967a9cf6c64f09008015c408b527e7e Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 21 Dec 2025 07:16:55 +0300 Subject: [PATCH 36/47] Enhance button stats middleware with builtin callbacks Added a set of known builtin callback data for button statistics logging. --- app/middlewares/button_stats.py | 108 +++++++++++++++++++++++++++++--- 1 file changed, 98 insertions(+), 10 deletions(-) diff --git a/app/middlewares/button_stats.py b/app/middlewares/button_stats.py index a676729f..66881c29 100644 --- a/app/middlewares/button_stats.py +++ b/app/middlewares/button_stats.py @@ -1,7 +1,9 @@ """Middleware для автоматического логирования кликов по кнопкам.""" +import asyncio import logging -from typing import Callable, Dict, Any, Awaitable +from typing import Callable, Dict, Any, Awaitable, Set + from aiogram import BaseMiddleware from aiogram.types import CallbackQuery, TelegramObject @@ -10,10 +12,80 @@ from app.database.database import AsyncSessionLocal logger = logging.getLogger(__name__) +# Известные builtin callback_data из меню +BUILTIN_CALLBACKS: Set[str] = { + # Основные кнопки меню + "subscription_connect", + "subscription_happ_download", + "menu_subscription", + "buy_traffic", + "menu_balance", + "menu_trial", + "menu_buy", + "simple_subscription_purchase", + "return_to_saved_cart", + "menu_promocode", + "menu_referrals", + "contests_menu", + "menu_support", + "menu_info", + "menu_language", + "admin_panel", + "moderator_panel", + # Навигация + "back_to_menu", + "menu_faq", + "menu_info_promo_groups", + "menu_privacy_policy", + "menu_public_offer", + "menu_rules", + "menu_server_status", + # Баланс + "balance_history", + "balance_topup", + # Подписка + "subscription_extend", + "subscription_autopay", + "subscription_settings", + "open_subscription_link", + "subscription_add_countries", + "subscription_reset_traffic", + "subscription_switch_traffic", + "subscription_change_devices", + "subscription_manage_devices", + "subscription_upgrade", + # Устройства + "device_guide_ios", + "device_guide_android", + "device_guide_windows", + "device_guide_mac", + "device_guide_tv", + "device_guide_appletv", + # Happ + "happ_download_ios", + "happ_download_android", + "happ_download_macos", + "happ_download_windows", + # Рефералы + "referral_create_invite", + "referral_show_qr", + "referral_list", + "referral_analytics", + # Поддержка + "create_ticket", + "my_tickets", + # Триал + "trial_activate", + # Покупка + "clear_saved_cart", + "subscription_confirm", + "subscription_cancel", +} + class ButtonStatsMiddleware(BaseMiddleware): """Middleware для автоматического логирования статистики кликов по кнопкам.""" - + async def __call__( self, handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], @@ -49,8 +121,6 @@ class ButtonStatsMiddleware(BaseMiddleware): button_text = self._extract_button_text(event.message.reply_markup, callback_data) # Логируем в фоне, не блокируя обработку - # Используем asyncio.create_task для фоновой задачи - import asyncio asyncio.create_task( self._log_button_click_async( button_id=callback_data, @@ -68,13 +138,31 @@ class ButtonStatsMiddleware(BaseMiddleware): return await handler(event, data) def _determine_button_type(self, callback_data: str) -> str: - """Определяет тип кнопки по callback_data.""" - if callback_data.startswith("http://") or callback_data.startswith("https://"): - return "url" - elif callback_data.startswith("menu_") or callback_data.startswith("admin_"): + """Определяет тип кнопки по callback_data. + + Примечание: URL и MiniApp кнопки не имеют callback_data, + поэтому они не отслеживаются через этот middleware. + Для их отслеживания нужен отдельный механизм на стороне клиента. + """ + # Проверяем по известному списку builtin кнопок + if callback_data in BUILTIN_CALLBACKS: return "builtin" - else: - return "callback" + + # Дополнительная проверка по префиксам для динамических callback_data + builtin_prefixes = ( + "menu_", + "admin_", + "subscription_", + "balance_", + "referral_", + "device_guide_", + "happ_download_", + ) + if callback_data.startswith(builtin_prefixes): + return "builtin" + + # Всё остальное - кастомные callback кнопки + return "callback" def _extract_button_text(self, reply_markup, callback_data: str) -> str: """Извлекает текст кнопки из клавиатуры.""" From 16031023cbe330673ed8f384c8b922563911b670 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 21 Dec 2025 07:17:52 +0300 Subject: [PATCH 37/47] Change datetime from now() to utcnow() --- app/webapi/routes/menu_layout.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/webapi/routes/menu_layout.py b/app/webapi/routes/menu_layout.py index da8fde1d..6253d119 100644 --- a/app/webapi/routes/menu_layout.py +++ b/app/webapi/routes/menu_layout.py @@ -618,7 +618,7 @@ async def export_menu_layout( version=export_data.get("version", 1), rows=rows, buttons=buttons, - exported_at=datetime.now(), + exported_at=datetime.utcnow(), ) @@ -766,7 +766,7 @@ async def get_menu_click_stats( stats = await MenuLayoutService.get_all_buttons_stats(db, days) total_clicks = await MenuLayoutService.get_total_clicks(db, days) - now = datetime.now() + now = datetime.utcnow() period_start = now - timedelta(days=days) return MenuClickStatsResponse( @@ -988,4 +988,4 @@ async def get_user_click_sequences( ) except Exception as e: logger.error(f"Error getting user sequences: user_id={user_id}, error={e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") \ No newline at end of file + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") From bc6e57765ef9f6ce82af88ff17e8426579a22f15 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 21 Dec 2025 07:18:31 +0300 Subject: [PATCH 38/47] Use UTC for registration days calculation --- app/keyboards/inline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 38265f7b..4615c9a5 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -93,7 +93,7 @@ async def get_main_menu_keyboard_async( # Дни с регистрации if hasattr(user, 'created_at') and user.created_at: - registration_days = (datetime.now() - user.created_at).days + registration_days = (datetime.utcnow() - user.created_at).days # ID промо-группы if hasattr(user, 'promo_group_id'): From 1d1a9e0d78709c4ce64d0173707b92efc1186fbf Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 21 Dec 2025 07:18:52 +0300 Subject: [PATCH 39/47] Add tables for menu layout history and button clicks --- app/database/universal_migration.py | 141 ++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 9a7dcb07..e418502e 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -3715,6 +3715,133 @@ async def create_system_settings_table() -> bool: return False +async def create_menu_layout_history_table() -> bool: + """Создаёт таблицу для хранения истории изменений конфигурации меню.""" + table_exists = await check_table_exists("menu_layout_history") + if table_exists: + logger.info("ℹ️ Таблица menu_layout_history уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == "sqlite": + create_table_sql = """ + CREATE TABLE menu_layout_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + config_json TEXT NOT NULL, + action VARCHAR(50) NOT NULL, + changes_summary TEXT NULL, + user_info VARCHAR(255) NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """ + elif db_type == "postgresql": + create_table_sql = """ + CREATE TABLE menu_layout_history ( + id SERIAL PRIMARY KEY, + config_json TEXT NOT NULL, + action VARCHAR(50) NOT NULL, + changes_summary TEXT NULL, + user_info VARCHAR(255) NULL, + created_at TIMESTAMP DEFAULT NOW() + ) + """ + else: + create_table_sql = """ + CREATE TABLE menu_layout_history ( + id INT AUTO_INCREMENT PRIMARY KEY, + config_json TEXT NOT NULL, + action VARCHAR(50) NOT NULL, + changes_summary TEXT NULL, + user_info VARCHAR(255) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB + """ + + await conn.execute(text(create_table_sql)) + await conn.execute(text( + "CREATE INDEX ix_menu_layout_history_created ON menu_layout_history(created_at)" + )) + logger.info("✅ Таблица menu_layout_history создана") + return True + + except Exception as error: + logger.error(f"❌ Ошибка создания таблицы menu_layout_history: {error}") + return False + + +async def create_button_click_logs_table() -> bool: + """Создаёт таблицу для логирования кликов по кнопкам меню.""" + table_exists = await check_table_exists("button_click_logs") + if table_exists: + logger.info("ℹ️ Таблица button_click_logs уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == "sqlite": + create_table_sql = """ + CREATE TABLE button_click_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + button_id VARCHAR(100) NOT NULL, + user_id BIGINT NULL REFERENCES users(telegram_id) ON DELETE SET NULL, + callback_data VARCHAR(255) NULL, + clicked_at DATETIME DEFAULT CURRENT_TIMESTAMP, + button_type VARCHAR(20) NULL, + button_text VARCHAR(255) NULL + ) + """ + elif db_type == "postgresql": + create_table_sql = """ + CREATE TABLE button_click_logs ( + id SERIAL PRIMARY KEY, + button_id VARCHAR(100) NOT NULL, + user_id BIGINT NULL REFERENCES users(telegram_id) ON DELETE SET NULL, + callback_data VARCHAR(255) NULL, + clicked_at TIMESTAMP DEFAULT NOW(), + button_type VARCHAR(20) NULL, + button_text VARCHAR(255) NULL + ) + """ + else: + create_table_sql = """ + CREATE TABLE button_click_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + button_id VARCHAR(100) NOT NULL, + user_id BIGINT NULL, + callback_data VARCHAR(255) NULL, + clicked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + button_type VARCHAR(20) NULL, + button_text VARCHAR(255) NULL, + FOREIGN KEY (user_id) REFERENCES users(telegram_id) ON DELETE SET NULL + ) ENGINE=InnoDB + """ + + await conn.execute(text(create_table_sql)) + + # Создаём индексы отдельными запросами + index_statements = [ + "CREATE INDEX ix_button_click_logs_button_id ON button_click_logs(button_id)", + "CREATE INDEX ix_button_click_logs_user_id ON button_click_logs(user_id)", + "CREATE INDEX ix_button_click_logs_clicked_at ON button_click_logs(clicked_at)", + "CREATE INDEX ix_button_click_logs_button_date ON button_click_logs(button_id, clicked_at)", + "CREATE INDEX ix_button_click_logs_user_date ON button_click_logs(user_id, clicked_at)", + ] + for stmt in index_statements: + await conn.execute(text(stmt)) + + logger.info("✅ Таблица button_click_logs создана") + return True + + except Exception as error: + logger.error(f"❌ Ошибка создания таблицы button_click_logs: {error}") + return False + + async def create_web_api_tokens_table() -> bool: table_exists = await check_table_exists("web_api_tokens") if table_exists: @@ -4316,6 +4443,20 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей web_api_tokens") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MENU_LAYOUT_HISTORY ===") + menu_layout_history_ready = await create_menu_layout_history_table() + if menu_layout_history_ready: + logger.info("✅ Таблица menu_layout_history готова") + else: + logger.warning("⚠️ Проблемы с таблицей menu_layout_history") + + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ BUTTON_CLICK_LOGS ===") + button_click_logs_ready = await create_button_click_logs_table() + if button_click_logs_ready: + logger.info("✅ Таблица button_click_logs готова") + else: + logger.warning("⚠️ Проблемы с таблицей button_click_logs") + logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ ДЛЯ ТРИАЛЬНЫХ СКВАДОВ ===") trial_column_ready = await add_server_trial_flag_column() if trial_column_ready: From 3e6064756e0fa2d47c8475e905e86a72345f03ec Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 21 Dec 2025 07:51:21 +0300 Subject: [PATCH 40/47] Update contests.py --- app/handlers/contests.py | 68 +++++++++++++++------------------------- 1 file changed, 26 insertions(+), 42 deletions(-) diff --git a/app/handlers/contests.py b/app/handlers/contests.py index cfc3a72d..a32f3914 100644 --- a/app/handlers/contests.py +++ b/app/handlers/contests.py @@ -1,7 +1,6 @@ import logging import random -from datetime import datetime -from typing import Optional +from datetime import datetime, timedelta from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext @@ -11,8 +10,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.contest import ( get_active_rounds, - get_template_by_slug, - get_active_round_by_template, get_attempt, create_attempt, increment_winner_count, @@ -47,10 +44,6 @@ def _user_allowed(subscription) -> bool: } -async def _with_session() -> AsyncSession: - return AsyncSessionLocal() - - async def _award_prize(db: AsyncSession, user_id: int, prize_days: int, language: str) -> str: from app.database.crud.user import get_user_by_id user = await get_user_by_id(db, user_id) @@ -64,12 +57,6 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_days: int, language return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=prize_days) -async def _ensure_round_for_template(template: ContestTemplate) -> Optional[ContestRound]: - async with AsyncSessionLocal() as db: - round_obj = await get_active_round_by_template(db, template.id) - return round_obj - - async def _reply_not_eligible(callback: types.CallbackQuery, language: str): texts = get_texts(language) await callback.answer(texts.t("CONTEST_NOT_ELIGIBLE", "Игры доступны только с активной или триальной подпиской."), show_alert=True) @@ -181,7 +168,6 @@ async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: Contest texts = get_texts(db_user.language) rows = round_obj.payload.get("rows", 3) cols = round_obj.payload.get("cols", 3) - secret = random.randint(0, rows * cols - 1) keyboard = [] for r in range(rows): row_buttons = [] @@ -190,7 +176,7 @@ async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: Contest row_buttons.append( types.InlineKeyboardButton( text="🎛", - callback_data=f"contest_pick_{round_obj.id}_{idx}_{secret}" + callback_data=f"contest_pick_{round_obj.id}_quest_{idx}" ) ) keyboard.append(row_buttons) @@ -205,11 +191,10 @@ async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: Contest async def _render_locks(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate): texts = get_texts(db_user.language) total = round_obj.payload.get("total", 20) - secret = random.randint(0, total - 1) keyboard = [] row = [] for i in range(total): - row.append(types.InlineKeyboardButton(text="🔒", callback_data=f"contest_pick_{round_obj.id}_{i}_{secret}")) + row.append(types.InlineKeyboardButton(text="🔒", callback_data=f"contest_pick_{round_obj.id}_locks_{i}")) if len(row) == 5: keyboard.append(row) row = [] @@ -337,19 +322,31 @@ async def handle_pick(callback: types.CallbackQuery, db_user, db: AsyncSession): is_winner = False if tpl.slug == GAME_SERVER: is_winner = pick == correct_flag - elif tpl.slug in {GAME_QUEST, GAME_LOCKS}: + elif tpl.slug == GAME_QUEST: + # Format: quest_{idx} try: - idx_str, secret_str = pick.split("_", 1) - idx = int(idx_str) - secret = int(secret_str) - is_winner = idx == secret - except ValueError: + if pick.startswith("quest_"): + idx = int(pick.split("_")[1]) + is_winner = secret_idx is not None and idx == secret_idx + except (ValueError, IndexError): + is_winner = False + elif tpl.slug == GAME_LOCKS: + # Format: locks_{idx} + try: + if pick.startswith("locks_"): + idx = int(pick.split("_")[1]) + is_winner = secret_idx is not None and idx == secret_idx + except (ValueError, IndexError): is_winner = False elif tpl.slug == GAME_BLITZ: is_winner = pick == "blitz" else: is_winner = False + # Check if max winners already reached + if is_winner and round_obj.winners_count >= round_obj.max_winners: + is_winner = False # Too late, max winners already reached + await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=str(pick), is_winner=is_winner) if is_winner: @@ -393,6 +390,11 @@ async def handle_text_answer(message: types.Message, state: FSMContext, db_user, correct = (round_obj.payload.get("answer") or "").upper() is_winner = correct and answer == correct + + # Check if max winners already reached + if is_winner and round_obj.winners_count >= round_obj.max_winners: + is_winner = False # Too late, max winners already reached + await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=answer, is_winner=is_winner) if is_winner: @@ -404,24 +406,6 @@ async def handle_text_answer(message: types.Message, state: FSMContext, db_user, await state.clear() -async def _award_prize(db: AsyncSession, user_id: int, prize_days: int, language: str) -> str: - from app.database.crud.subscription import get_subscription_by_user_id - - logger = logging.getLogger(__name__) - - subscription = await get_subscription_by_user_id(db, user_id) - if not subscription: - return "ошибка: подписка не найдена" - - current_time = datetime.utcnow() - subscription.end_date = subscription.end_date + timedelta(days=prize_days) - subscription.updated_at = current_time - await db.commit() - await db.refresh(subscription) - logger.info(f"🎁 Продлена подписка пользователя {user_id} на {prize_days} дней за конкурс") - return f"подписка продлена на {prize_days} дней" - - def register_handlers(dp: Dispatcher): dp.callback_query.register(show_contests_menu, F.data == "contests_menu") dp.callback_query.register(play_contest, F.data.startswith("contest_play_")) From 7f8fbf069b3899416feba2918adc175e6ae29c2f Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 21 Dec 2025 07:52:00 +0300 Subject: [PATCH 41/47] Update referral_contest_service.py --- app/services/referral_contest_service.py | 28 +++++++----------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/app/services/referral_contest_service.py b/app/services/referral_contest_service.py index 5b4842ae..15b5f886 100644 --- a/app/services/referral_contest_service.py +++ b/app/services/referral_contest_service.py @@ -391,10 +391,7 @@ class ReferralContestService: return "\n".join(lines) async def get_detailed_contest_stats(self, db: AsyncSession, contest_id: int) -> dict: - from app.database.crud.referral_contest import get_contest_events, get_contest_leaderboard, get_referral_contest - from app.database.crud.user import get_user_by_id - from app.database.models import User, Subscription - from sqlalchemy import select + from app.database.crud.referral_contest import get_contest_leaderboard, get_referral_contest contest = await get_referral_contest(db, contest_id) if not contest: @@ -406,7 +403,7 @@ class ReferralContestService: 'participants': [], } - # Get leaderboard to get scores + # Get leaderboard - already includes User objects leaderboard = await get_contest_leaderboard(db, contest_id) if not leaderboard: return { @@ -417,22 +414,16 @@ class ReferralContestService: 'participants': [], } - # Create dict of referrer_id -> (score, amount) - scores = {user.id: (score, amount) for user, score, amount in leaderboard} - - total_participants = len(scores) - total_invited = sum(score for score, _ in scores.values()) - total_paid_amount = sum(amount for _, amount in scores.values()) + total_participants = len(leaderboard) + total_invited = sum(score for _, score, _ in leaderboard) + total_paid_amount = sum(amount for _, _, amount in leaderboard) total_unpaid = 0 + # Build participants stats directly from leaderboard (already has User objects) participants_stats = [] - for referrer_id, (score, amount) in scores.items(): - user = await get_user_by_id(db, referrer_id) - if not user: - continue - + for user, score, amount in leaderboard: participants_stats.append({ - 'referrer_id': referrer_id, + 'referrer_id': user.id, 'full_name': user.full_name, 'total_referrals': score, 'paid_referrals': score, @@ -440,9 +431,6 @@ class ReferralContestService: 'total_paid_amount': amount, }) - # Sort by total_referrals descending - participants_stats.sort(key=lambda p: p['total_referrals'], reverse=True) - return { 'total_participants': total_participants, 'total_invited': total_invited, From 6482284cd84a33cbbd5f304df13597305f770562 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 21 Dec 2025 07:52:35 +0300 Subject: [PATCH 42/47] Use configured timezone for contest scheduling Refactor time handling to use configured timezone for scheduling. --- app/services/contest_rotation_service.py | 28 +++++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/app/services/contest_rotation_service.py b/app/services/contest_rotation_service.py index 3984093e..534572a7 100644 --- a/app/services/contest_rotation_service.py +++ b/app/services/contest_rotation_service.py @@ -3,6 +3,7 @@ import logging import random from datetime import datetime, timedelta, time, timezone from typing import Dict, List, Optional +from zoneinfo import ZoneInfo from aiogram import Bot from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton @@ -191,10 +192,15 @@ class ContestRotationService: async def _tick(self) -> None: async with AsyncSessionLocal() as db: templates = await list_templates(db) - now_local = datetime.now().astimezone(timezone.utc) + # Get current time in configured timezone + tz = self._get_timezone() + now_utc = datetime.now(timezone.utc) + now_local = now_utc.astimezone(tz) + for tpl in templates: times = self._parse_times(tpl.schedule_times) or [] for slot in times[: tpl.times_per_day]: + # Apply schedule time to local date starts_at_local = now_local.replace( hour=slot.hour, minute=slot.minute, second=0, microsecond=0 ) @@ -207,18 +213,31 @@ class ContestRotationService: exists = await get_active_round_by_template(db, tpl.id) if exists: continue + + # Convert to UTC for storage + starts_at_utc = starts_at_local.astimezone(timezone.utc).replace(tzinfo=None) + ends_at_utc = ends_at_local.astimezone(timezone.utc).replace(tzinfo=None) + # Анонс перед созданием раунда await self._announce_round_start(tpl, starts_at_local, ends_at_local) payload = self._build_payload_for_template(tpl) round_obj = await create_round( db, template=tpl, - starts_at=starts_at_local.replace(tzinfo=None), - ends_at=ends_at_local.replace(tzinfo=None), + starts_at=starts_at_utc, + ends_at=ends_at_utc, payload=payload, ) logger.info("Создан раунд %s для шаблона %s", round_obj.id, tpl.slug) + def _get_timezone(self) -> ZoneInfo: + tz_name = settings.TIMEZONE or "UTC" + try: + return ZoneInfo(tz_name) + except Exception: + logger.warning("Не удалось загрузить TZ %s, используем UTC", tz_name) + return ZoneInfo("UTC") + def _build_payload_for_template(self, tpl: ContestTemplate) -> Dict: payload = tpl.payload or {} if tpl.slug == GAME_QUEST: @@ -262,9 +281,6 @@ class ContestRotationService: if not self.bot: return - tz = settings.TIMEZONE or "UTC" - starts_txt = starts_at_local.strftime("%d.%m %H:%M") - ends_txt = ends_at_local.strftime("%d.%m %H:%M") text = ( f"🎲 Стартует игра: {tpl.name}\n" f"Приз: {tpl.prize_days} дн. подписки • Победителей: {tpl.max_winners}\n" From 7dcd11b964a5a344c8bcdd069899666c11be96d9 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 21 Dec 2025 07:53:20 +0300 Subject: [PATCH 43/47] Update contests.py --- app/webapi/routes/contests.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/webapi/routes/contests.py b/app/webapi/routes/contests.py index 420aa7d9..1ce2109c 100644 --- a/app/webapi/routes/contests.py +++ b/app/webapi/routes/contests.py @@ -264,7 +264,7 @@ async def update_daily_template( if not tpl: raise HTTPException(status.HTTP_404_NOT_FOUND, "Template not found") - update_fields = payload.dict(exclude_none=True) + update_fields = payload.model_dump(exclude_none=True) if not update_fields: return _serialize_template(tpl) @@ -551,7 +551,7 @@ async def get_referral( leaderboard = [_serialize_leaderboard_item(row) for row in leaderboard_rows] return ReferralContestDetailResponse( - **_serialize_referral_contest(contest).dict(), + **_serialize_referral_contest(contest).model_dump(), total_events=int(total_events), leaderboard=leaderboard, ) @@ -572,7 +572,7 @@ async def update_referral( if not contest: raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found") - fields = payload.dict(exclude_none=True) + fields = payload.model_dump(exclude_none=True) if "start_at" in fields: fields["start_at"] = _to_utc_naive(fields["start_at"], fields.get("timezone") or contest.timezone) From ce865a9b2ad68dde2b9077cfaaae5e4c846671eb Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 21 Dec 2025 08:05:53 +0300 Subject: [PATCH 44/47] Update start.py --- app/handlers/start.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/handlers/start.py b/app/handlers/start.py index 3fc4025d..f2e125d1 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -2,6 +2,7 @@ import logging from datetime import datetime from aiogram import Dispatcher, types, F, Bot from aiogram.enums import ChatMemberStatus +from aiogram.exceptions import TelegramForbiddenError from aiogram.filters import Command, StateFilter from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession From b2027df7f3d225f9067494d01a9e1187a500458d Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 21 Dec 2025 08:06:52 +0300 Subject: [PATCH 45/47] Replace deprecated datetime.utcnow() with _utcnow() Replaced deprecated datetime.utcnow() with a custom _utcnow() function to return current UTC time as naive datetime. --- app/services/menu_layout/stats_service.py | 28 +++++++++++++++-------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/app/services/menu_layout/stats_service.py b/app/services/menu_layout/stats_service.py index b5cda18b..f85a5006 100644 --- a/app/services/menu_layout/stats_service.py +++ b/app/services/menu_layout/stats_service.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional from sqlalchemy import select, func, and_, desc, case, Integer @@ -12,6 +12,14 @@ from app.config import settings from app.database.models import ButtonClickLog +def _utcnow() -> datetime: + """Возвращает текущее UTC время как naive datetime (без timezone). + + Замена deprecated datetime.utcnow(). + """ + return datetime.now(timezone.utc).replace(tzinfo=None) + + class MenuLayoutStatsService: """Сервис для сбора и анализа статистики кликов по кнопкам.""" @@ -79,7 +87,7 @@ class MenuLayoutStatsService: days: int = 30, ) -> Dict[str, Any]: """Получить статистику кликов по конкретной кнопке.""" - now = datetime.utcnow() + now = _utcnow() today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) week_ago = now - timedelta(days=7) month_ago = now - timedelta(days=days) @@ -155,7 +163,7 @@ class MenuLayoutStatsService: days: int = 30, ) -> List[Dict[str, Any]]: """Получить статистику кликов по дням.""" - start_date = datetime.utcnow() - timedelta(days=days) + start_date = _utcnow() - timedelta(days=days) # Группировка по дате result = await db.execute( @@ -183,7 +191,7 @@ class MenuLayoutStatsService: days: int = 30, ) -> List[Dict[str, Any]]: """Получить статистику по всем кнопкам.""" - now = datetime.utcnow() + now = _utcnow() today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) week_ago = now - timedelta(days=7) month_ago = now - timedelta(days=days) @@ -235,7 +243,7 @@ class MenuLayoutStatsService: days: int = 30, ) -> int: """Получить общее количество кликов за период.""" - start_date = datetime.utcnow() - timedelta(days=days) + start_date = _utcnow() - timedelta(days=days) result = await db.execute( select(func.count(ButtonClickLog.id)) @@ -250,7 +258,7 @@ class MenuLayoutStatsService: days: int = 30, ) -> List[Dict[str, Any]]: """Получить статистику кликов по типам кнопок.""" - start_date = datetime.utcnow() - timedelta(days=days) + start_date = _utcnow() - timedelta(days=days) result = await db.execute( select( @@ -283,7 +291,7 @@ class MenuLayoutStatsService: days: int = 30, ) -> List[Dict[str, Any]]: """Получить статистику кликов по часам дня.""" - start_date = datetime.utcnow() - timedelta(days=days) + start_date = _utcnow() - timedelta(days=days) # Используем helper-метод для совместимости с SQLite и PostgreSQL hour_expr = cls._get_hour_expr(ButtonClickLog.clicked_at).label("hour") @@ -326,7 +334,7 @@ class MenuLayoutStatsService: Возвращает 0=понедельник, 6=воскресенье. Поддерживает как PostgreSQL, так и SQLite. """ - start_date = datetime.utcnow() - timedelta(days=days) + start_date = _utcnow() - timedelta(days=days) # Используем helper-метод для совместимости с SQLite и PostgreSQL weekday_expr = cls._get_weekday_expr(ButtonClickLog.clicked_at).label("weekday") @@ -372,7 +380,7 @@ class MenuLayoutStatsService: days: int = 30, ) -> List[Dict[str, Any]]: """Получить топ пользователей по количеству кликов.""" - start_date = datetime.utcnow() - timedelta(days=days) + start_date = _utcnow() - timedelta(days=days) query = select( ButtonClickLog.user_id, @@ -411,7 +419,7 @@ class MenuLayoutStatsService: previous_days: int = 7, ) -> Dict[str, Any]: """Сравнить статистику текущего и предыдущего периода.""" - now = datetime.utcnow() + now = _utcnow() current_start = now - timedelta(days=current_days) previous_start = current_start - timedelta(days=previous_days) previous_end = current_start From 42759854c2487d0abbb83ffb9324c81fca9f657e Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 21 Dec 2025 08:07:31 +0300 Subject: [PATCH 46/47] Use timezone-aware datetime for calculations --- app/keyboards/inline.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 4615c9a5..79cf296e 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1,7 +1,7 @@ from typing import List, Optional from aiogram import types from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton -from datetime import datetime +from datetime import datetime, timezone from app.database.models import User from sqlalchemy.ext.asyncio import AsyncSession @@ -68,8 +68,8 @@ async def get_main_menu_keyboard_async( subscription_days_left = subscription.days_left elif hasattr(subscription, 'end_date') and subscription.end_date: # Fallback: вычисляем вручную, используя UTC - from datetime import datetime - days_left = (subscription.end_date - datetime.utcnow()).days + now_utc = datetime.now(timezone.utc).replace(tzinfo=None) + days_left = (subscription.end_date - now_utc).days subscription_days_left = max(0, days_left) # Трафик @@ -93,7 +93,8 @@ async def get_main_menu_keyboard_async( # Дни с регистрации if hasattr(user, 'created_at') and user.created_at: - registration_days = (datetime.utcnow() - user.created_at).days + now_utc = datetime.now(timezone.utc).replace(tzinfo=None) + registration_days = (now_utc - user.created_at).days # ID промо-группы if hasattr(user, 'promo_group_id'): From a87d0671333da45e086c4c946d734ccecc2834fa Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 21 Dec 2025 08:08:07 +0300 Subject: [PATCH 47/47] Update exported_at to use timezone-aware datetime --- app/services/menu_layout/service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/services/menu_layout/service.py b/app/services/menu_layout/service.py index 56b3e645..84b39853 100644 --- a/app/services/menu_layout/service.py +++ b/app/services/menu_layout/service.py @@ -6,7 +6,7 @@ import asyncio import copy import json import logging -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict, List, Optional from aiogram import types @@ -96,7 +96,7 @@ class MenuLayoutService: "version": config.get("version", 1), "rows": config.get("rows", []), "buttons": config.get("buttons", {}), - "exported_at": datetime.utcnow().isoformat(), + "exported_at": datetime.now(timezone.utc).isoformat(), } @classmethod