diff --git a/.env.example b/.env.example index 14d9edc3..74982854 100644 --- a/.env.example +++ b/.env.example @@ -226,7 +226,7 @@ YOOKASSA_VAT_CODE=1 # 1 - НДС не облагается # 2 - НДС 0% # 3 - НДС 10% -# 4 - НДС 20% +# 4 - НДС 20%/22% # 5 - НДС 10/110 # 6 - НДС 20/120 diff --git a/app/bot.py b/app/bot.py index daa19f1f..b663356a 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/config.py b/app/config.py index a7b4fdb0..b3817c01 100644 --- a/app/config.py +++ b/app/config.py @@ -175,6 +175,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 # Порог трафика в ГБ за сутки @@ -183,6 +186,8 @@ class Settings(BaseSettings): AUTOPAY_WARNING_DAYS: str = "3,1" + ENABLE_AUTOPAY: bool = False + DEFAULT_AUTOPAY_ENABLED: bool = False DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3 MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000 diff --git a/app/database/crud/referral_contest.py b/app/database/crud/referral_contest.py index 652e2ee3..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( @@ -248,6 +256,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/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/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: 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: diff --git a/app/handlers/admin/contests.py b/app/handlers/admin/contests.py index ee5c04cd..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), @@ -659,6 +659,107 @@ 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) + + # Общее сообщение с основной статистикой + 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( + "\n".join(general_lines), + reply_markup=get_referral_contest_manage_keyboard( + contest_id, is_active=contest.is_active, language=db_user.language + ), + ) + + await callback.answer() + + +@admin_required +@error_handler +async def show_detailed_stats_page( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, + contest_id: int = None, + page: int = 1, + stats: dict = None, +): + if contest_id is None or stats is None: + # Парсим из callback.data: admin_contest_detailed_stats_page_{contest_id}_page_{page} + parts = callback.data.split("_") + contest_id = int(parts[5]) # contest_id после page + page = int(parts[7]) # page после второго page + + # Получаем stats если не переданы + from app.services.referral_contest_service import referral_contest_service + stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id) + + participants = stats['participants'] + total_participants = len(participants) + PAGE_SIZE = 10 + total_pages = math.ceil(total_participants / PAGE_SIZE) + + page = max(1, min(page, total_pages)) + offset = (page - 1) * PAGE_SIZE + page_participants = participants[offset:offset + PAGE_SIZE] + + lines = [f"📊 По участникам (страница {page}/{total_pages}):"] + for p in page_participants: + lines.extend([ + f"• {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, + total_pages, + f"admin_contest_detailed_stats_page_{contest_id}", + back_callback=f"admin_contest_view_{contest_id}", + language=db_user.language, + ) + + await callback.message.edit_text( + "\n".join(lines), + reply_markup=pagination, + ) + + await callback.answer() + + def register_handlers(dp: Dispatcher): dp.callback_query.register(show_contests_menu, F.data == "admin_contests") dp.callback_query.register(show_referral_contests_menu, F.data == "admin_contests_referral") @@ -669,6 +770,8 @@ 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(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/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/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_")) diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 2db00d56..8906abbf 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,26 @@ async def show_main_menu( subscription_is_active=subscription_is_active, ) + keyboard = await get_main_menu_keyboard_async( + db=db, + user=db_user, + language=db_user.language, + is_admin=is_admin, + is_moderator=is_moderator, + has_had_paid_subscription=db_user.has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active, + balance_kopeks=db_user.balance_kopeks, + subscription=db_user.subscription, + show_resume_checkout=show_resume_checkout, + has_saved_cart=has_saved_cart, + custom_buttons=custom_buttons, + ) + await edit_or_answer_photo( callback=callback, caption=menu_text, - keyboard=get_main_menu_keyboard( - language=db_user.language, - is_admin=is_admin, - is_moderator=is_moderator, - has_had_paid_subscription=db_user.has_had_paid_subscription, - has_active_subscription=has_active_subscription, - subscription_is_active=subscription_is_active, - balance_kopeks=db_user.balance_kopeks, - subscription=db_user.subscription, - show_resume_checkout=show_resume_checkout, - has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине - custom_buttons=custom_buttons, - ), + keyboard=keyboard, parse_mode="HTML", force_text=settings.is_text_main_menu_mode(), ) @@ -1061,22 +1066,26 @@ async def handle_back_to_menu( subscription_is_active=subscription_is_active, ) + keyboard = await get_main_menu_keyboard_async( + db=db, + user=db_user, + language=db_user.language, + is_admin=is_admin, + is_moderator=is_moderator, + has_had_paid_subscription=db_user.has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active, + balance_kopeks=db_user.balance_kopeks, + subscription=db_user.subscription, + show_resume_checkout=show_resume_checkout, + has_saved_cart=has_saved_cart, + custom_buttons=custom_buttons, + ) + await edit_or_answer_photo( callback=callback, caption=menu_text, - keyboard=get_main_menu_keyboard( - language=db_user.language, - is_admin=is_admin, - is_moderator=is_moderator, - has_had_paid_subscription=db_user.has_had_paid_subscription, - has_active_subscription=has_active_subscription, - subscription_is_active=subscription_is_active, - balance_kopeks=db_user.balance_kopeks, - subscription=db_user.subscription, - show_resume_checkout=show_resume_checkout, - has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине - custom_buttons=custom_buttons, - ), + keyboard=keyboard, parse_mode="HTML", force_text=settings.is_text_main_menu_mode(), ) @@ -1216,10 +1225,10 @@ 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 @@ -1303,7 +1312,7 @@ async def handle_activate_button( 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..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 @@ -22,6 +23,7 @@ from app.keyboards.inline import ( get_rules_keyboard, get_privacy_policy_keyboard, get_main_menu_keyboard, + get_main_menu_keyboard_async, get_post_registration_keyboard, get_language_selection_keyboard, ) @@ -264,10 +266,14 @@ async def _continue_registration_after_language( return rules_text = await get_rules(language) - await target_message.answer( - rules_text, - reply_markup=get_rules_keyboard(language) - ) + try: + await target_message.answer( + rules_text, + reply_markup=get_rules_keyboard(language) + ) + except TelegramForbiddenError: + logger.warning(f"⚠️ Пользователь {callback.from_user.id if callback else message.from_user.id} заблокировал бота, пропускаем отправку правил") + return await state.set_state(RegistrationStates.waiting_for_rules_accept) logger.info("📋 LANGUAGE: Правила отправлены после выбора языка") @@ -319,7 +325,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, if referral_code: await state.update_data(referral_code=referral_code) - + user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id) if campaign and not campaign_notification_sent: @@ -337,32 +343,32 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, campaign.id, notify_error, ) - + if user and user.status != UserStatus.DELETED.value: logger.info(f"✅ Активный пользователь найден: {user.telegram_id}") - + profile_updated = False - + if user.username != message.from_user.username: old_username = user.username user.username = message.from_user.username logger.info(f"📝 Username обновлен: '{old_username}' → '{user.username}'") profile_updated = True - + if user.first_name != message.from_user.first_name: old_first_name = user.first_name user.first_name = message.from_user.first_name logger.info(f"📝 Имя обновлено: '{old_first_name}' → '{user.first_name}'") profile_updated = True - + if user.last_name != message.from_user.last_name: old_last_name = user.last_name user.last_name = message.from_user.last_name logger.info(f"📝 Фамилия обновлена: '{old_last_name}' → '{user.last_name}'") profile_updated = True - + user.last_activity = datetime.utcnow() - + if profile_updated: user.updated_at = datetime.utcnow() await db.commit() @@ -370,7 +376,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, logger.info(f"💾 Профиль пользователя {user.telegram_id} обновлен") else: await db.commit() - + texts = get_texts(user.language) if referral_code and not user.referred_by_id: @@ -393,11 +399,11 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, logger.error( f"Ошибка отправки уведомления о рекламной кампании: {e}" ) - + has_active_subscription, subscription_is_active = _calculate_subscription_flags( user.subscription ) - + menu_text = await get_main_menu_text(user, texts, db) is_admin = settings.is_admin(user.telegram_id) @@ -414,35 +420,38 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, subscription_is_active=subscription_is_active, ) + keyboard = await get_main_menu_keyboard_async( + db=db, + user=user, + language=user.language, + is_admin=is_admin, + has_had_paid_subscription=user.has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active, + balance_kopeks=user.balance_kopeks, + subscription=user.subscription, + is_moderator=is_moderator, + custom_buttons=custom_buttons, + ) await message.answer( menu_text, - reply_markup=get_main_menu_keyboard( - language=user.language, - is_admin=is_admin, - has_had_paid_subscription=user.has_had_paid_subscription, - has_active_subscription=has_active_subscription, - subscription_is_active=subscription_is_active, - balance_kopeks=user.balance_kopeks, - subscription=user.subscription, - is_moderator=is_moderator, - custom_buttons=custom_buttons, - ), + reply_markup=keyboard, parse_mode="HTML" ) await state.clear() return - + if user and user.status == UserStatus.DELETED.value: logger.info(f"🔄 Удаленный пользователь {user.telegram_id} начинает повторную регистрацию") - + try: from app.services.user_service import UserService from app.database.models import ( - Subscription, Transaction, PromoCodeUse, + Subscription, Transaction, PromoCodeUse, ReferralEarning, SubscriptionServer ) from sqlalchemy import delete - + if user.subscription: await decrement_subscription_server_counts(db, user.subscription) await db.execute( @@ -451,51 +460,51 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, ) ) logger.info(f"🗑️ Удалены записи SubscriptionServer") - + if user.subscription: await db.delete(user.subscription) logger.info(f"🗑️ Удалена подписка пользователя") - + await db.execute( delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id) ) - + await db.execute( delete(ReferralEarning).where(ReferralEarning.user_id == user.id) ) await db.execute( delete(ReferralEarning).where(ReferralEarning.referral_id == user.id) ) - + await db.execute( delete(Transaction).where(Transaction.user_id == user.id) ) - + user.status = UserStatus.ACTIVE.value user.balance_kopeks = 0 user.remnawave_uuid = None user.has_had_paid_subscription = False user.referred_by_id = None - + user.username = message.from_user.username user.first_name = message.from_user.first_name user.last_name = message.from_user.last_name user.updated_at = datetime.utcnow() user.last_activity = datetime.utcnow() - + from app.utils.user_utils import generate_unique_referral_code user.referral_code = await generate_unique_referral_code(db, user.telegram_id) - + await db.commit() - + logger.info(f"✅ Пользователь {user.telegram_id} подготовлен к восстановлению") - + except Exception as e: logger.error(f"❌ Ошибка подготовки к восстановлению: {e}") await db.rollback() else: logger.info(f"🆕 Новый пользователь, начинаем регистрацию") - + data = await state.get_data() or {} if not data.get('language'): if settings.is_language_selection_enabled(): @@ -626,11 +635,11 @@ async def _show_privacy_policy_after_rules( Возвращает True, если политика была показана, False если её нет или произошла ошибка. """ policy = await PrivacyPolicyService.get_policy(db, language, fallback=True) - + if not policy or not policy.is_enabled: logger.info("⚠️ Политика конфиденциальности не включена, пропускаем её показ") return False - + if not policy.content or not policy.content.strip(): privacy_policy_text = get_privacy_policy(language) if not privacy_policy_text or not privacy_policy_text.strip(): @@ -640,7 +649,7 @@ async def _show_privacy_policy_after_rules( else: privacy_policy_text = policy.content logger.info(f"🔒 Используется политика конфиденциальности из БД для языка {language}") - + try: await callback.message.edit_text( privacy_policy_text, @@ -675,7 +684,7 @@ async def _continue_registration_after_rules( """ data = await state.get_data() or {} texts = get_texts(language) - + if data.get('referral_code'): logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}") @@ -717,10 +726,10 @@ async def process_rules_accept( logger.info(f"📋 RULES: Начало обработки правил") logger.info(f"📊 Callback data: {callback.data}") logger.info(f"👤 User: {callback.from_user.id}") - + current_state = await state.get_state() logger.info(f"📊 Текущее состояние: {current_state}") - + language = DEFAULT_LANGUAGE texts = get_texts(language) @@ -730,24 +739,24 @@ async def process_rules_accept( data = await state.get_data() or {} language = data.get('language', language) texts = get_texts(language) - + if callback.data == 'rules_accept': logger.info(f"✅ Правила приняты пользователем {callback.from_user.id}") - + # Пытаемся показать политику конфиденциальности policy_shown = await _show_privacy_policy_after_rules( callback, state, db, language ) - + # Если политика не была показана, продолжаем регистрацию if not policy_shown: await _continue_registration_after_rules( callback, state, db, language ) - + else: logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}") - + rules_required_text = texts.t( "RULES_REQUIRED", "Для использования бота необходимо принять правила сервиса.", @@ -767,9 +776,9 @@ async def process_rules_accept( ) except: pass - + logger.info(f"✅ Правила обработаны для пользователя {callback.from_user.id}") - + except Exception as e: logger.error(f"❌ Ошибка обработки правил: {e}", exc_info=True) await callback.answer( @@ -798,14 +807,14 @@ async def process_privacy_policy_accept( state: FSMContext, db: AsyncSession ): - + logger.info(f"🔒 PRIVACY POLICY: Начало обработки политики конфиденциальности") logger.info(f"📊 Callback data: {callback.data}") logger.info(f"👤 User: {callback.from_user.id}") - + current_state = await state.get_state() logger.info(f"📊 Текущее состояние: {current_state}") - + language = DEFAULT_LANGUAGE texts = get_texts(language) @@ -815,10 +824,10 @@ async def process_privacy_policy_accept( data = await state.get_data() or {} language = data.get('language', language) texts = get_texts(language) - + if callback.data == 'privacy_policy_accept': logger.info(f"✅ Политика конфиденциальности принята пользователем {callback.from_user.id}") - + try: await callback.message.delete() logger.info(f"🗑️ Сообщение с политикой конфиденциальности удалено") @@ -834,7 +843,7 @@ async def process_privacy_policy_accept( ) except Exception: pass - + if data.get('referral_code'): logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}") @@ -853,7 +862,7 @@ async def process_privacy_policy_accept( try: await state.set_data(data) await state.set_state(RegistrationStates.waiting_for_referral_code) - + await callback.bot.send_message( chat_id=callback.from_user.id, text=texts.t( @@ -866,10 +875,10 @@ async def process_privacy_policy_accept( except Exception as e: logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}") await complete_registration_from_callback(callback, state, db) - + else: logger.info(f"❌ Политика конфиденциальности отклонена пользователем {callback.from_user.id}") - + privacy_policy_required_text = texts.t( "PRIVACY_POLICY_REQUIRED", "Для использования бота необходимо принять политику конфиденциальности.", @@ -889,9 +898,9 @@ async def process_privacy_policy_accept( ) except: pass - + logger.info(f"✅ Политика конфиденциальности обработана для пользователя {callback.from_user.id}") - + except Exception as e: logger.error(f"❌ Ошибка обработки политики конфиденциальности: {e}", exc_info=True) await callback.answer( @@ -994,7 +1003,7 @@ async def process_referral_code_skip( ) except: pass - + await complete_registration_from_callback(callback, state, db) @@ -1029,11 +1038,11 @@ async def complete_registration_from_callback( from sqlalchemy.orm import selectinload existing_user = await get_user_by_telegram_id(db, callback.from_user.id) - + if existing_user and existing_user.status == UserStatus.ACTIVE.value: logger.warning(f"⚠️ Пользователь {callback.from_user.id} уже активен! Показываем главное меню.") texts = get_texts(existing_user.language) - + data = await state.get_data() or {} if data.get('referral_code') and not existing_user.referred_by_id: await callback.message.answer( @@ -1042,13 +1051,13 @@ async def complete_registration_from_callback( "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.", ) ) - + await db.refresh(existing_user, ['subscription']) - + has_active_subscription, subscription_is_active = _calculate_subscription_flags( existing_user.subscription ) - + menu_text = await get_main_menu_text(existing_user, texts, db) is_admin = settings.is_admin(existing_user.telegram_id) @@ -1067,19 +1076,22 @@ async def complete_registration_from_callback( ) try: + keyboard = await get_main_menu_keyboard_async( + db=db, + user=existing_user, + language=existing_user.language, + is_admin=is_admin, + has_had_paid_subscription=existing_user.has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active, + balance_kopeks=existing_user.balance_kopeks, + subscription=existing_user.subscription, + is_moderator=is_moderator, + custom_buttons=custom_buttons, + ) await callback.message.answer( menu_text, - reply_markup=get_main_menu_keyboard( - language=existing_user.language, - is_admin=is_admin, - has_had_paid_subscription=existing_user.has_had_paid_subscription, - has_active_subscription=has_active_subscription, - subscription_is_active=subscription_is_active, - balance_kopeks=existing_user.balance_kopeks, - subscription=existing_user.subscription, - is_moderator=is_moderator, - custom_buttons=custom_buttons, - ), + reply_markup=keyboard, parse_mode="HTML" ) except Exception as e: @@ -1090,10 +1102,10 @@ async def complete_registration_from_callback( "Добро пожаловать, {user_name}!", ).format(user_name=existing_user.full_name) ) - + await state.clear() return - + data = await state.get_data() or {} language = data.get('language', DEFAULT_LANGUAGE) texts = get_texts(language) @@ -1112,10 +1124,10 @@ async def complete_registration_from_callback( referrer = await get_user_by_referral_code(db, data['referral_code']) if referrer: referrer_id = referrer.id - + if existing_user and existing_user.status == UserStatus.DELETED.value: logger.info(f"🔄 Восстанавливаем удаленного пользователя {callback.from_user.id}") - + existing_user.username = callback.from_user.username existing_user.first_name = callback.from_user.first_name existing_user.last_name = callback.from_user.last_name @@ -1124,22 +1136,22 @@ async def complete_registration_from_callback( existing_user.status = UserStatus.ACTIVE.value existing_user.balance_kopeks = 0 existing_user.has_had_paid_subscription = False - + from datetime import datetime existing_user.updated_at = datetime.utcnow() existing_user.last_activity = datetime.utcnow() - + await db.commit() await db.refresh(existing_user, ['subscription']) - + user = existing_user logger.info(f"✅ Пользователь {callback.from_user.id} восстановлен") - + elif not existing_user: logger.info(f"🆕 Создаем нового пользователя {callback.from_user.id}") - + referral_code = await generate_unique_referral_code(db, callback.from_user.id) - + user = await create_user( db=db, telegram_id=callback.from_user.id, @@ -1148,7 +1160,7 @@ async def complete_registration_from_callback( last_name=callback.from_user.last_name, language=language, referred_by_id=referrer_id, - referral_code=referral_code + referral_code=referral_code ) await db.refresh(user, ['subscription']) else: @@ -1157,15 +1169,15 @@ async def complete_registration_from_callback( existing_user.language = language if referrer_id and not existing_user.referred_by_id: existing_user.referred_by_id = referrer_id - + from datetime import datetime existing_user.updated_at = datetime.utcnow() existing_user.last_activity = datetime.utcnow() - + await db.commit() await db.refresh(existing_user, ['subscription']) user = existing_user - + if referrer_id: try: await process_referral_registration(db, user.id, referrer_id, callback.bot) @@ -1224,11 +1236,11 @@ async def complete_registration_from_callback( logger.error(f"Ошибка при отправке приветственного сообщения: {e}") else: logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}") - + has_active_subscription, subscription_is_active = _calculate_subscription_flags( getattr(user, "subscription", None) ) - + menu_text = await get_main_menu_text(user, texts, db) is_admin = settings.is_admin(user.telegram_id) @@ -1247,19 +1259,22 @@ async def complete_registration_from_callback( ) try: + keyboard = await get_main_menu_keyboard_async( + db=db, + user=user, + language=user.language, + is_admin=is_admin, + has_had_paid_subscription=user.has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active, + balance_kopeks=user.balance_kopeks, + subscription=user.subscription, + is_moderator=is_moderator, + custom_buttons=custom_buttons, + ) await callback.message.answer( menu_text, - reply_markup=get_main_menu_keyboard( - language=user.language, - is_admin=is_admin, - has_had_paid_subscription=user.has_had_paid_subscription, - has_active_subscription=has_active_subscription, - subscription_is_active=subscription_is_active, - balance_kopeks=user.balance_kopeks, - subscription=user.subscription, - is_moderator=is_moderator, - custom_buttons=custom_buttons, - ), + reply_markup=keyboard, parse_mode="HTML" ) logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}") @@ -1303,11 +1318,11 @@ async def complete_registration( return existing_user = await get_user_by_telegram_id(db, message.from_user.id) - + if existing_user and existing_user.status == UserStatus.ACTIVE.value: logger.warning(f"⚠️ Пользователь {message.from_user.id} уже активен! Показываем главное меню.") texts = get_texts(existing_user.language) - + data = await state.get_data() or {} if data.get('referral_code') and not existing_user.referred_by_id: await message.answer( @@ -1316,13 +1331,13 @@ async def complete_registration( "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.", ) ) - + await db.refresh(existing_user, ['subscription']) - + has_active_subscription, subscription_is_active = _calculate_subscription_flags( existing_user.subscription ) - + menu_text = await get_main_menu_text(existing_user, texts, db) is_admin = settings.is_admin(existing_user.telegram_id) @@ -1341,19 +1356,22 @@ async def complete_registration( ) try: + keyboard = await get_main_menu_keyboard_async( + db=db, + user=existing_user, + language=existing_user.language, + is_admin=is_admin, + has_had_paid_subscription=existing_user.has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active, + balance_kopeks=existing_user.balance_kopeks, + subscription=existing_user.subscription, + is_moderator=is_moderator, + custom_buttons=custom_buttons, + ) await message.answer( menu_text, - reply_markup=get_main_menu_keyboard( - language=existing_user.language, - is_admin=is_admin, - has_had_paid_subscription=existing_user.has_had_paid_subscription, - has_active_subscription=has_active_subscription, - subscription_is_active=subscription_is_active, - balance_kopeks=existing_user.balance_kopeks, - subscription=existing_user.subscription, - is_moderator=is_moderator, - custom_buttons=custom_buttons, - ), + reply_markup=keyboard, parse_mode="HTML" ) except Exception as e: @@ -1364,10 +1382,10 @@ async def complete_registration( "Добро пожаловать, {user_name}!", ).format(user_name=existing_user.full_name) ) - + await state.clear() return - + data = await state.get_data() or {} language = data.get('language', DEFAULT_LANGUAGE) texts = get_texts(language) @@ -1386,10 +1404,10 @@ async def complete_registration( referrer = await get_user_by_referral_code(db, data['referral_code']) if referrer: referrer_id = referrer.id - + if existing_user and existing_user.status == UserStatus.DELETED.value: logger.info(f"🔄 Восстанавливаем удаленного пользователя {message.from_user.id}") - + existing_user.username = message.from_user.username existing_user.first_name = message.from_user.first_name existing_user.last_name = message.from_user.last_name @@ -1398,22 +1416,22 @@ async def complete_registration( existing_user.status = UserStatus.ACTIVE.value existing_user.balance_kopeks = 0 existing_user.has_had_paid_subscription = False - + from datetime import datetime existing_user.updated_at = datetime.utcnow() existing_user.last_activity = datetime.utcnow() - + await db.commit() await db.refresh(existing_user, ['subscription']) - + user = existing_user logger.info(f"✅ Пользователь {message.from_user.id} восстановлен") - + elif not existing_user: logger.info(f"🆕 Создаем нового пользователя {message.from_user.id}") - + referral_code = await generate_unique_referral_code(db, message.from_user.id) - + user = await create_user( db=db, telegram_id=message.from_user.id, @@ -1431,15 +1449,15 @@ async def complete_registration( existing_user.language = language if referrer_id and not existing_user.referred_by_id: existing_user.referred_by_id = referrer_id - + from datetime import datetime existing_user.updated_at = datetime.utcnow() existing_user.last_activity = datetime.utcnow() - + await db.commit() await db.refresh(existing_user, ['subscription']) user = existing_user - + if referrer_id: try: await process_referral_registration(db, user.id, referrer_id, message.bot) @@ -1521,11 +1539,11 @@ async def complete_registration( logger.error(f"Ошибка при отправке приветственного сообщения: {e}") else: logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}") - + has_active_subscription, subscription_is_active = _calculate_subscription_flags( getattr(user, "subscription", None) ) - + menu_text = await get_main_menu_text(user, texts, db) is_admin = settings.is_admin(user.telegram_id) @@ -1544,19 +1562,22 @@ async def complete_registration( ) try: + keyboard = await get_main_menu_keyboard_async( + db=db, + user=user, + language=user.language, + is_admin=is_admin, + has_had_paid_subscription=user.has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active, + balance_kopeks=user.balance_kopeks, + subscription=user.subscription, + is_moderator=is_moderator, + custom_buttons=custom_buttons, + ) await message.answer( menu_text, - reply_markup=get_main_menu_keyboard( - language=user.language, - is_admin=is_admin, - has_had_paid_subscription=user.has_had_paid_subscription, - has_active_subscription=has_active_subscription, - subscription_is_active=subscription_is_active, - balance_kopeks=user.balance_kopeks, - subscription=user.subscription, - is_moderator=is_moderator, - custom_buttons=custom_buttons, - ), + reply_markup=keyboard, parse_mode="HTML" ) logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}") @@ -1665,7 +1686,7 @@ def _insert_random_message(base_text: str, random_message: str, action_prompt: s def get_referral_code_keyboard(language: str): from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - + texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton( @@ -1875,7 +1896,9 @@ async def required_sub_channel_check( subscription_is_active=subscription_is_active, ) - keyboard = get_main_menu_keyboard( + keyboard = await get_main_menu_keyboard_async( + db=db, + user=user, language=user.language, is_admin=is_admin, has_had_paid_subscription=user.has_had_paid_subscription, @@ -1964,29 +1987,29 @@ async def required_sub_channel_check( await query.answer(f"{texts.ERROR}!", show_alert=True) def register_handlers(dp: Dispatcher): - + logger.info("🔧 === НАЧАЛО регистрации обработчиков start.py ===") - + dp.message.register( cmd_start, Command("start") ) logger.info("✅ Зарегистрирован cmd_start") - + dp.callback_query.register( process_rules_accept, F.data.in_(["rules_accept", "rules_decline"]), StateFilter(RegistrationStates.waiting_for_rules_accept) ) logger.info("✅ Зарегистрирован process_rules_accept") - + dp.callback_query.register( process_privacy_policy_accept, F.data.in_(["privacy_policy_accept", "privacy_policy_decline"]), StateFilter(RegistrationStates.waiting_for_privacy_policy_accept) ) logger.info("✅ Зарегистрирован process_privacy_policy_accept") - + dp.callback_query.register( process_language_selection, F.data.startswith("language_select:"), @@ -2000,13 +2023,13 @@ def register_handlers(dp: Dispatcher): StateFilter(RegistrationStates.waiting_for_referral_code) ) logger.info("✅ Зарегистрирован process_referral_code_skip") - + dp.message.register( process_referral_code_input, StateFilter(RegistrationStates.waiting_for_referral_code) ) logger.info("✅ Зарегистрирован process_referral_code_input") - + dp.message.register( handle_potential_referral_code, StateFilter( @@ -2021,6 +2044,6 @@ def register_handlers(dp: Dispatcher): F.data.in_(["sub_channel_check"]) ) logger.info("✅ Зарегистрирован required_sub_channel_check") - + logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===") - + 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/keyboards/inline.py b/app/keyboards/inline.py index 9cfaea45..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 @@ -23,6 +23,136 @@ import logging logger = logging.getLogger(__name__) +async def get_main_menu_keyboard_async( + db: AsyncSession, + language: str = DEFAULT_LANGUAGE, + is_admin: bool = False, + has_had_paid_subscription: bool = False, + has_active_subscription: bool = False, + subscription_is_active: bool = False, + balance_kopeks: int = 0, + subscription=None, + show_resume_checkout: bool = False, + has_saved_cart: bool = False, + *, + is_moderator: bool = False, + custom_buttons: Optional[list[InlineKeyboardButton]] = None, + user=None, # Добавляем параметр пользователя для получения данных +) -> InlineKeyboardMarkup: + """ + Асинхронная версия get_main_menu_keyboard с поддержкой конструктора меню. + + Если MENU_LAYOUT_ENABLED=True, использует конфигурацию из БД. + Иначе делегирует в синхронную версию. + """ + if settings.MENU_LAYOUT_ENABLED: + from app.services.menu_layout_service import MenuLayoutService, MenuContext + from datetime import datetime + + # Получаем данные для плейсхолдеров + subscription_days_left = 0 + traffic_used_gb = 0.0 + traffic_left_gb = 0.0 + referral_count = 0 + referral_earnings_kopeks = 0 + registration_days = 0 + promo_group_id = None + has_autopay = False + username = "" + + # Заполняем данными из подписки + if subscription: + # Дни до окончания подписки + if hasattr(subscription, 'days_left'): + # Используем свойство из модели, которое правильно вычисляет дни в UTC + subscription_days_left = subscription.days_left + elif hasattr(subscription, 'end_date') and subscription.end_date: + # Fallback: вычисляем вручную, используя UTC + now_utc = datetime.now(timezone.utc).replace(tzinfo=None) + days_left = (subscription.end_date - now_utc).days + subscription_days_left = max(0, days_left) + + # Трафик + if hasattr(subscription, 'traffic_used_gb'): + traffic_used_gb = subscription.traffic_used_gb or 0.0 + + if hasattr(subscription, 'traffic_limit_gb') and subscription.traffic_limit_gb: + traffic_left_gb = max(0, subscription.traffic_limit_gb - (subscription.traffic_used_gb or 0)) + + # Автоплатеж + if hasattr(subscription, 'autopay_enabled'): + has_autopay = subscription.autopay_enabled + + # Получаем данные пользователя + if user: + # Имя пользователя + if hasattr(user, 'username') and user.username: + username = user.username + elif hasattr(user, 'first_name') and user.first_name: + username = user.first_name + + # Дни с регистрации + if hasattr(user, 'created_at') and user.created_at: + now_utc = datetime.now(timezone.utc).replace(tzinfo=None) + registration_days = (now_utc - user.created_at).days + + # ID промо-группы + if hasattr(user, 'promo_group_id'): + promo_group_id = user.promo_group_id + + # Получаем данные о рефералах из БД (если нужно) + try: + from app.database.crud.referral import get_user_referral_stats + if user and hasattr(user, 'id'): + referral_data = await get_user_referral_stats(db, user.id) + if referral_data: + referral_count = referral_data.get('invited_count', 0) + referral_earnings_kopeks = referral_data.get('total_earned_kopeks', 0) + except Exception as e: + logger.error(f"Error getting referral data: {e}") + + context = MenuContext( + language=language, + is_admin=is_admin, + is_moderator=is_moderator, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active, + has_had_paid_subscription=has_had_paid_subscription, + balance_kopeks=balance_kopeks, + subscription=subscription, + show_resume_checkout=show_resume_checkout, + has_saved_cart=has_saved_cart, + custom_buttons=custom_buttons or [], + # Добавляем данные для плейсхолдеров + username=username, + subscription_days=subscription_days_left, + traffic_used_gb=traffic_used_gb, + traffic_left_gb=traffic_left_gb, + referral_count=referral_count, + referral_earnings_kopeks=referral_earnings_kopeks, + registration_days=registration_days, + promo_group_id=promo_group_id, + has_autopay=has_autopay, + ) + + return await MenuLayoutService.build_keyboard(db, context) + + # Fallback на синхронную версию + return get_main_menu_keyboard( + language=language, + is_admin=is_admin, + has_had_paid_subscription=has_had_paid_subscription, + has_active_subscription=has_active_subscription, + subscription_is_active=subscription_is_active, + balance_kopeks=balance_kopeks, + subscription=subscription, + show_resume_checkout=show_resume_checkout, + has_saved_cart=has_saved_cart, + is_moderator=is_moderator, + custom_buttons=custom_buttons, + ) + + def _get_localized_value(values, language: str, default_language: str = "en") -> str: if not isinstance(values, dict): return "" 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":"🏠返回主菜单", diff --git a/app/middlewares/button_stats.py b/app/middlewares/button_stats.py new file mode 100644 index 00000000..66881c29 --- /dev/null +++ b/app/middlewares/button_stats.py @@ -0,0 +1,208 @@ +"""Middleware для автоматического логирования кликов по кнопкам.""" + +import asyncio +import logging +from typing import Callable, Dict, Any, Awaitable, Set + +from aiogram import BaseMiddleware +from aiogram.types import CallbackQuery, TelegramObject + +from app.config import settings +from app.database.database import AsyncSessionLocal + +logger = logging.getLogger(__name__) + +# Известные builtin callback_data из меню +BUILTIN_CALLBACKS: Set[str] = { + # Основные кнопки меню + "subscription_connect", + "subscription_happ_download", + "menu_subscription", + "buy_traffic", + "menu_balance", + "menu_trial", + "menu_buy", + "simple_subscription_purchase", + "return_to_saved_cart", + "menu_promocode", + "menu_referrals", + "contests_menu", + "menu_support", + "menu_info", + "menu_language", + "admin_panel", + "moderator_panel", + # Навигация + "back_to_menu", + "menu_faq", + "menu_info_promo_groups", + "menu_privacy_policy", + "menu_public_offer", + "menu_rules", + "menu_server_status", + # Баланс + "balance_history", + "balance_topup", + # Подписка + "subscription_extend", + "subscription_autopay", + "subscription_settings", + "open_subscription_link", + "subscription_add_countries", + "subscription_reset_traffic", + "subscription_switch_traffic", + "subscription_change_devices", + "subscription_manage_devices", + "subscription_upgrade", + # Устройства + "device_guide_ios", + "device_guide_android", + "device_guide_windows", + "device_guide_mac", + "device_guide_tv", + "device_guide_appletv", + # Happ + "happ_download_ios", + "happ_download_android", + "happ_download_macos", + "happ_download_windows", + # Рефералы + "referral_create_invite", + "referral_show_qr", + "referral_list", + "referral_analytics", + # Поддержка + "create_ticket", + "my_tickets", + # Триал + "trial_activate", + # Покупка + "clear_saved_cart", + "subscription_confirm", + "subscription_cancel", +} + + +class ButtonStatsMiddleware(BaseMiddleware): + """Middleware для автоматического логирования статистики кликов по кнопкам.""" + + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + """Перехватывает CallbackQuery и логирует клики по кнопкам.""" + + # Обрабатываем только CallbackQuery + if not isinstance(event, CallbackQuery): + return await handler(event, data) + + # Пропускаем, если статистика отключена + if not settings.MENU_LAYOUT_ENABLED: + return await handler(event, data) + + # Логируем клик асинхронно, не блокируя обработку + try: + # Получаем callback_data + callback_data = event.data + if not callback_data: + return await handler(event, data) + + # Получаем user_id + user_id = event.from_user.id if event.from_user else None + + # Определяем тип кнопки по callback_data + button_type = self._determine_button_type(callback_data) + + # Получаем текст кнопки, если возможно + button_text = None + if event.message and hasattr(event.message, 'reply_markup'): + button_text = self._extract_button_text(event.message.reply_markup, callback_data) + + # Логируем в фоне, не блокируя обработку + asyncio.create_task( + self._log_button_click_async( + button_id=callback_data, + user_id=user_id, + callback_data=callback_data, + button_type=button_type, + button_text=button_text + ) + ) + except Exception as e: + # Не прерываем обработку при ошибке логирования + logger.error(f"Ошибка логирования клика по кнопке: {e}", exc_info=True) + + # Продолжаем обработку + return await handler(event, data) + + def _determine_button_type(self, callback_data: str) -> str: + """Определяет тип кнопки по callback_data. + + Примечание: URL и MiniApp кнопки не имеют callback_data, + поэтому они не отслеживаются через этот middleware. + Для их отслеживания нужен отдельный механизм на стороне клиента. + """ + # Проверяем по известному списку builtin кнопок + if callback_data in BUILTIN_CALLBACKS: + return "builtin" + + # Дополнительная проверка по префиксам для динамических callback_data + builtin_prefixes = ( + "menu_", + "admin_", + "subscription_", + "balance_", + "referral_", + "device_guide_", + "happ_download_", + ) + if callback_data.startswith(builtin_prefixes): + return "builtin" + + # Всё остальное - кастомные callback кнопки + return "callback" + + def _extract_button_text(self, reply_markup, callback_data: str) -> str: + """Извлекает текст кнопки из клавиатуры.""" + try: + if not reply_markup or not hasattr(reply_markup, 'inline_keyboard'): + return None + + for row in reply_markup.inline_keyboard: + for button in row: + if hasattr(button, 'callback_data') and button.callback_data == callback_data: + if hasattr(button, 'text'): + return button.text + except Exception: + pass + return None + + async def _log_button_click_async( + self, + button_id: str, + user_id: int = None, + callback_data: str = None, + button_type: str = None, + button_text: str = None + ): + """Асинхронно логирует клик по кнопке.""" + try: + async with AsyncSessionLocal() as db: + try: + from app.services.menu_layout_service import MenuLayoutService + + await MenuLayoutService.log_button_click( + db, + button_id=button_id, + user_id=user_id, + callback_data=callback_data, + button_type=button_type, + button_text=button_text + ) + except Exception as e: + logger.debug(f"Ошибка записи клика в БД {button_id}: {e}") + except Exception as e: + logger.debug(f"Ошибка создания сессии БД для логирования клика: {e}") + 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" 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..5f0a24a3 --- /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: 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..84b39853 --- /dev/null +++ b/app/services/menu_layout/service.py @@ -0,0 +1,1358 @@ +"""Основной сервис конструктора меню.""" + +from __future__ import annotations + +import asyncio +import copy +import json +import logging +from datetime import datetime, timezone +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 copy.deepcopy(DEFAULT_MENU_CONFIG) + + @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(timezone.utc).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", {}) + + # Улучшенное определение кнопки connect для разных форматов ID + actual_button_id = button_id + if button_id not in buttons: + # Пробуем найти кнопку 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") + + 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: + 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"] + # Для 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[actual_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 < 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 > 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 + + # --- Форматирование текста --- + + # Список всех поддерживаемых плейсхолдеров + _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, + 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)) + + # Использованный трафик + 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, + button_id: str = "", + ) -> Optional[InlineKeyboardButton]: + """Построить кнопку из конфигурации. + + Args: + button_config: Конфигурация кнопки + context: Контекст пользователя + texts: Локализованные тексты + button_id: ID кнопки (ключ в словаре buttons) + """ + button_type = button_config.get("type", "builtin") + # Используем переданный 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") + webapp_url = button_config.get("webapp_url") + icon = button_config.get("icon", "") + + # Логирование для отладки кнопки connect + is_connect_button = ( + 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={effective_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) + 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": + # Прямое открытие Mini App через WebAppInfo + # Используем 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) + + # --- Построение клавиатуры --- + + @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_id для кастомных кнопок) + button = cls._build_button(button_cfg, context, texts, button_id=button_id) + 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) + + @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 new file mode 100644 index 00000000..f85a5006 --- /dev/null +++ b/app/services/menu_layout/stats_service.py @@ -0,0 +1,500 @@ +"""Сервис статистики кликов по кнопкам меню.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +from sqlalchemy import select, func, and_, desc, case, Integer +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import ButtonClickLog + + +def _utcnow() -> datetime: + """Возвращает текущее UTC время как naive datetime (без timezone). + + Замена deprecated datetime.utcnow(). + """ + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class MenuLayoutStatsService: + """Сервис для сбора и анализа статистики кликов по кнопкам.""" + + @classmethod + def _is_sqlite(cls) -> bool: + """Проверить, используется ли SQLite.""" + return settings.is_sqlite() + + @classmethod + def _get_hour_expr(cls, column): + """Получить выражение для извлечения часа (совместимо с SQLite и PostgreSQL).""" + if cls._is_sqlite(): + # SQLite: strftime('%H', column) возвращает строку + return func.cast(func.strftime('%H', column), Integer) + else: + # PostgreSQL: EXTRACT(hour FROM column) + return func.extract('hour', column) + + @classmethod + def _get_weekday_expr(cls, column): + """Получить выражение для дня недели (0=Пн, 6=Вс) совместимо с SQLite и PostgreSQL.""" + if cls._is_sqlite(): + # SQLite: strftime('%w', column) возвращает 0=воскресенье, 1-6=пн-сб + # Преобразуем: 0->6, 1->0, 2->1, ..., 6->5 + dow = func.cast(func.strftime('%w', column), Integer) + return case( + (dow == 0, 6), + else_=dow - 1 + ) + else: + # PostgreSQL: EXTRACT(dow FROM column) возвращает 0=воскресенье, 1-6=пн-сб + dow = func.extract('dow', column) + return case( + (dow == 0, 6), + else_=dow - 1 + ) + + @classmethod + async def log_button_click( + cls, + db: AsyncSession, + button_id: str, + user_id: Optional[int] = None, + callback_data: Optional[str] = None, + button_type: Optional[str] = None, + button_text: Optional[str] = None, + ) -> ButtonClickLog: + """Записать клик по кнопке.""" + click_log = ButtonClickLog( + button_id=button_id, + user_id=user_id, + callback_data=callback_data, + button_type=button_type, + button_text=button_text, + ) + db.add(click_log) + await db.commit() + return click_log + + @classmethod + async def get_button_stats( + cls, + db: AsyncSession, + button_id: str, + days: int = 30, + ) -> Dict[str, Any]: + """Получить статистику кликов по конкретной кнопке.""" + now = _utcnow() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=days) + + # Общее количество кликов + total_result = await db.execute( + select(func.count(ButtonClickLog.id)) + .where(ButtonClickLog.button_id == button_id) + ) + clicks_total = total_result.scalar() or 0 + + # Клики сегодня + today_result = await db.execute( + select(func.count(ButtonClickLog.id)) + .where(and_( + ButtonClickLog.button_id == button_id, + ButtonClickLog.clicked_at >= today_start + )) + ) + clicks_today = today_result.scalar() or 0 + + # Клики за неделю + week_result = await db.execute( + select(func.count(ButtonClickLog.id)) + .where(and_( + ButtonClickLog.button_id == button_id, + ButtonClickLog.clicked_at >= week_ago + )) + ) + clicks_week = week_result.scalar() or 0 + + # Клики за месяц + month_result = await db.execute( + select(func.count(ButtonClickLog.id)) + .where(and_( + ButtonClickLog.button_id == button_id, + ButtonClickLog.clicked_at >= month_ago + )) + ) + clicks_month = month_result.scalar() or 0 + + # Уникальные пользователи + unique_result = await db.execute( + select(func.count(func.distinct(ButtonClickLog.user_id))) + .where(ButtonClickLog.button_id == button_id) + ) + unique_users = unique_result.scalar() or 0 + + # Последний клик + last_click_result = await db.execute( + select(ButtonClickLog.clicked_at) + .where(ButtonClickLog.button_id == button_id) + .order_by(desc(ButtonClickLog.clicked_at)) + .limit(1) + ) + last_click = last_click_result.scalar_one_or_none() + + return { + "button_id": button_id, + "clicks_total": clicks_total, + "clicks_today": clicks_today, + "clicks_week": clicks_week, + "clicks_month": clicks_month, + "unique_users": unique_users, + "last_click_at": last_click, + } + + @classmethod + async def get_button_clicks_by_day( + cls, + db: AsyncSession, + button_id: str, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Получить статистику кликов по дням.""" + start_date = _utcnow() - timedelta(days=days) + + # Группировка по дате + result = await db.execute( + select( + func.date(ButtonClickLog.clicked_at).label("date"), + func.count(ButtonClickLog.id).label("count") + ) + .where(and_( + ButtonClickLog.button_id == button_id, + ButtonClickLog.clicked_at >= start_date + )) + .group_by(func.date(ButtonClickLog.clicked_at)) + .order_by(func.date(ButtonClickLog.clicked_at)) + ) + + return [ + {"date": str(row.date), "count": row.count} + for row in result.all() + ] + + @classmethod + async def get_all_buttons_stats( + cls, + db: AsyncSession, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Получить статистику по всем кнопкам.""" + now = _utcnow() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=days) + + # Для производительности используем один запрос с подзапросами через CASE + result = await db.execute( + select( + ButtonClickLog.button_id, + # Общее количество кликов (все клики без фильтра по датам) + func.count(ButtonClickLog.id).label("clicks_total"), + # Уникальные пользователи (все время) + func.count(func.distinct(ButtonClickLog.user_id)).label("unique_users"), + # Последний клик (все время) + func.max(ButtonClickLog.clicked_at).label("last_click_at"), + # Подсчет кликов за сегодня + func.sum( + case((ButtonClickLog.clicked_at >= today_start, 1), else_=0) + ).label("clicks_today"), + # Подсчет кликов за неделю + func.sum( + case((ButtonClickLog.clicked_at >= week_ago, 1), else_=0) + ).label("clicks_week"), + # Подсчет кликов за месяц + func.sum( + case((ButtonClickLog.clicked_at >= month_ago, 1), else_=0) + ).label("clicks_month"), + ) + .group_by(ButtonClickLog.button_id) + .order_by(desc(func.count(ButtonClickLog.id))) + ) + + return [ + { + "button_id": row.button_id, + "clicks_total": row.clicks_total, + "clicks_today": row.clicks_today or 0, + "clicks_week": row.clicks_week or 0, + "clicks_month": row.clicks_month or 0, + "unique_users": row.unique_users, + "last_click_at": row.last_click_at, + } + for row in result.all() + ] + + @classmethod + async def get_total_clicks( + cls, + db: AsyncSession, + days: int = 30, + ) -> int: + """Получить общее количество кликов за период.""" + start_date = _utcnow() - timedelta(days=days) + + result = await db.execute( + select(func.count(ButtonClickLog.id)) + .where(ButtonClickLog.clicked_at >= start_date) + ) + return result.scalar() or 0 + + @classmethod + async def get_stats_by_button_type( + cls, + db: AsyncSession, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Получить статистику кликов по типам кнопок.""" + start_date = _utcnow() - timedelta(days=days) + + result = await db.execute( + select( + ButtonClickLog.button_type, + func.count(ButtonClickLog.id).label("clicks_total"), + func.count(func.distinct(ButtonClickLog.user_id)).label("unique_users"), + ) + .where(and_( + ButtonClickLog.clicked_at >= start_date, + ButtonClickLog.button_type.isnot(None) + )) + .group_by(ButtonClickLog.button_type) + .order_by(desc(func.count(ButtonClickLog.id))) + ) + + return [ + { + "button_type": row.button_type or "unknown", + "clicks_total": row.clicks_total, + "unique_users": row.unique_users, + } + for row in result.all() + ] + + @classmethod + async def get_clicks_by_hour( + cls, + db: AsyncSession, + button_id: Optional[str] = None, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Получить статистику кликов по часам дня.""" + start_date = _utcnow() - timedelta(days=days) + + # Используем helper-метод для совместимости с SQLite и PostgreSQL + hour_expr = cls._get_hour_expr(ButtonClickLog.clicked_at).label("hour") + + query = select( + hour_expr, + func.count(ButtonClickLog.id).label("count") + ).where(ButtonClickLog.clicked_at >= start_date) + + if button_id: + query = query.where(ButtonClickLog.button_id == button_id) + + result = await db.execute( + query + .group_by(hour_expr) + .order_by(hour_expr) + ) + + # Создаем словарь для быстрого доступа по часу + stats_dict = { + int(row.hour): row.count + for row in result.all() + } + + # Возвращаем все 24 часа, даже если count = 0 + return [ + {"hour": hour, "count": stats_dict.get(hour, 0)} + for hour in range(24) + ] + + @classmethod + async def get_clicks_by_weekday( + cls, + db: AsyncSession, + button_id: Optional[str] = None, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Получить статистику кликов по дням недели. + + Возвращает 0=понедельник, 6=воскресенье. + Поддерживает как PostgreSQL, так и SQLite. + """ + start_date = _utcnow() - timedelta(days=days) + + # Используем helper-метод для совместимости с SQLite и PostgreSQL + weekday_expr = cls._get_weekday_expr(ButtonClickLog.clicked_at).label("weekday") + + query = select( + weekday_expr, + func.count(ButtonClickLog.id).label("count") + ).where(ButtonClickLog.clicked_at >= start_date) + + if button_id: + query = query.where(ButtonClickLog.button_id == button_id) + + result = await db.execute( + query + .group_by(weekday_expr) + .order_by(weekday_expr) + ) + + weekday_names = ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье"] + + # Создаем словарь для быстрого доступа по weekday + stats_dict = { + int(row.weekday): row.count + for row in result.all() + } + + # Возвращаем все дни недели, даже если count = 0 + return [ + { + "weekday": weekday, + "weekday_name": weekday_names[weekday], + "count": stats_dict.get(weekday, 0) + } + for weekday in range(7) + ] + + @classmethod + async def get_top_users( + cls, + db: AsyncSession, + button_id: Optional[str] = None, + limit: int = 10, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Получить топ пользователей по количеству кликов.""" + start_date = _utcnow() - timedelta(days=days) + + query = select( + ButtonClickLog.user_id, + func.count(ButtonClickLog.id).label("clicks_count"), + func.max(ButtonClickLog.clicked_at).label("last_click_at") + ).where(and_( + ButtonClickLog.clicked_at >= start_date, + ButtonClickLog.user_id.isnot(None) + )) + + if button_id: + query = query.where(ButtonClickLog.button_id == button_id) + + result = await db.execute( + query + .group_by(ButtonClickLog.user_id) + .order_by(desc(func.count(ButtonClickLog.id))) + .limit(limit) + ) + + return [ + { + "user_id": row.user_id, + "clicks_count": row.clicks_count, + "last_click_at": row.last_click_at, + } + for row in result.all() + ] + + @classmethod + async def get_period_comparison( + cls, + db: AsyncSession, + button_id: Optional[str] = None, + current_days: int = 7, + previous_days: int = 7, + ) -> Dict[str, Any]: + """Сравнить статистику текущего и предыдущего периода.""" + now = _utcnow() + current_start = now - timedelta(days=current_days) + previous_start = current_start - timedelta(days=previous_days) + previous_end = current_start + + query_current = select(func.count(ButtonClickLog.id)) + query_previous = select(func.count(ButtonClickLog.id)) + + if button_id: + query_current = query_current.where(ButtonClickLog.button_id == button_id) + query_previous = query_previous.where(ButtonClickLog.button_id == button_id) + + query_current = query_current.where( + ButtonClickLog.clicked_at >= current_start + ) + query_previous = query_previous.where( + and_( + ButtonClickLog.clicked_at >= previous_start, + ButtonClickLog.clicked_at < previous_end + ) + ) + + current_result = await db.execute(query_current) + previous_result = await db.execute(query_previous) + + current_count = current_result.scalar() or 0 + previous_count = previous_result.scalar() or 0 + + change_percent = 0 + if previous_count > 0: + change_percent = ((current_count - previous_count) / previous_count) * 100 + + return { + "current_period": { + "clicks": current_count, + "days": current_days, + "start": current_start, + "end": now, + }, + "previous_period": { + "clicks": previous_count, + "days": previous_days, + "start": previous_start, + "end": previous_end, + }, + "change": { + "absolute": current_count - previous_count, + "percent": round(change_percent, 2), + "trend": "up" if change_percent > 0 else "down" if change_percent < 0 else "stable", + }, + } + + @classmethod + async def get_click_sequences( + cls, + db: AsyncSession, + user_id: int, + limit: int = 50, + ) -> List[Dict[str, Any]]: + """Получить последовательности кликов пользователя.""" + result = await db.execute( + select( + ButtonClickLog.button_id, + ButtonClickLog.button_text, + ButtonClickLog.clicked_at, + ) + .where(ButtonClickLog.user_id == user_id) + .order_by(desc(ButtonClickLog.clicked_at)) + .limit(limit) + ) + + return [ + { + "button_id": row.button_id, + "button_text": row.button_text, + "clicked_at": row.clicked_at, + } + for row in result.all() + ] diff --git a/app/services/menu_layout_service.py b/app/services/menu_layout_service.py new file mode 100644 index 00000000..90ed0584 --- /dev/null +++ b/app/services/menu_layout_service.py @@ -0,0 +1,40 @@ +""" +Сервис конструктора меню - управление конфигурацией через API. + +ВНИМАНИЕ: Этот файл оставлен для обратной совместимости. +Фактическая реализация находится в app/services/menu_layout/ + +Структура модуля: +- app/services/menu_layout/constants.py - константы +- app/services/menu_layout/context.py - MenuContext +- app/services/menu_layout/history_service.py - история изменений +- app/services/menu_layout/stats_service.py - статистика кликов +- app/services/menu_layout/service.py - основной сервис +""" + +# Реэкспорт для обратной совместимости +from app.services.menu_layout import ( + # Константы + MENU_LAYOUT_CONFIG_KEY, + DEFAULT_MENU_CONFIG, + BUILTIN_BUTTONS_INFO, + AVAILABLE_CALLBACKS, + DYNAMIC_PLACEHOLDERS, + # Классы + MenuContext, + MenuLayoutService, + MenuLayoutHistoryService, + MenuLayoutStatsService, +) + +__all__ = [ + "MENU_LAYOUT_CONFIG_KEY", + "DEFAULT_MENU_CONFIG", + "BUILTIN_BUTTONS_INFO", + "AVAILABLE_CALLBACKS", + "DYNAMIC_PLACEHOLDERS", + "MenuContext", + "MenuLayoutService", + "MenuLayoutHistoryService", + "MenuLayoutStatsService", +] diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 83717ff1..44b6b5b9 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) @@ -937,7 +938,7 @@ class MonitoringService: autopay_subscriptions = [] for sub in all_autopay_subscriptions: days_before_expiry = (sub.end_date - current_time).days - if days_before_expiry <= sub.autopay_days_before: + if days_before_expiry <= min(sub.autopay_days_before, 3): autopay_subscriptions.append(sub) processed_count = 0 @@ -1065,12 +1066,16 @@ class MonitoringService: texts = get_texts(user.language) days_text = format_days_declension(days, user.language) - if subscription.autopay_enabled: - autopay_status = "✅ Включен - подписка продлится автоматически" - action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}" + if settings.ENABLE_AUTOPAY: + if subscription.autopay_enabled: + autopay_status = "✅ Включен - подписка продлится автоматически" + action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}" + else: + autopay_status = "❌ Отключен - не забудьте продлить вручную!" + action_text = "💡 Включите автоплатеж или продлите подписку вручную" else: autopay_status = "❌ Отключен - не забудьте продлить вручную!" - action_text = "💡 Включите автоплатеж или продлите подписку вручную" + action_text = "💡 Продлите подписку вручную" message = f""" ⚠️ Подписка истекает через {days_text}! diff --git a/app/services/partner_stats_service.py b/app/services/partner_stats_service.py new file mode 100644 index 00000000..e2a27098 --- /dev/null +++ b/app/services/partner_stats_service.py @@ -0,0 +1,625 @@ +"""Сервис расширенной статистики партнёров (рефереров).""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +from sqlalchemy import and_, case, desc, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import ( + ReferralEarning, + Subscription, + SubscriptionStatus, + User, +) + +logger = logging.getLogger(__name__) + + +class PartnerStatsService: + """Сервис для детальной статистики партнёров.""" + + @classmethod + async def get_referrer_detailed_stats( + cls, + db: AsyncSession, + user_id: int, + ) -> Dict[str, Any]: + """Получить детальную статистику реферера.""" + now = datetime.utcnow() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=30) + year_ago = now - timedelta(days=365) + + # Базовые данные о рефералах + referrals_query = select(User).where(User.referred_by_id == user_id) + referrals_result = await db.execute(referrals_query) + referrals = referrals_result.scalars().all() + referral_ids = [r.id for r in referrals] + + total_referrals = len(referrals) + + # Сколько сделали первое пополнение (has_made_first_topup) + paid_referrals = sum(1 for r in referrals if r.has_made_first_topup) + + # Активные рефералы (с активной подпиской) + if referral_ids: + active_result = await db.execute( + select(func.count(func.distinct(User.id))) + .join(Subscription, User.id == Subscription.user_id) + .where( + and_( + User.id.in_(referral_ids), + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.end_date > now, + ) + ) + ) + active_referrals = active_result.scalar() or 0 + else: + active_referrals = 0 + + # Заработки по периодам - один запрос с CASE WHEN + earnings_result = await db.execute( + select( + func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label("all_time"), + func.coalesce(func.sum( + case((ReferralEarning.created_at >= today_start, ReferralEarning.amount_kopeks), else_=0) + ), 0).label("today"), + func.coalesce(func.sum( + case((ReferralEarning.created_at >= week_ago, ReferralEarning.amount_kopeks), else_=0) + ), 0).label("week"), + func.coalesce(func.sum( + case((ReferralEarning.created_at >= month_ago, ReferralEarning.amount_kopeks), else_=0) + ), 0).label("month"), + func.coalesce(func.sum( + case((ReferralEarning.created_at >= year_ago, ReferralEarning.amount_kopeks), else_=0) + ), 0).label("year"), + ).where(ReferralEarning.user_id == user_id) + ) + earnings_row = earnings_result.one() + earnings_all_time = int(earnings_row.all_time) + earnings_today = int(earnings_row.today) + earnings_week = int(earnings_row.week) + earnings_month = int(earnings_row.month) + earnings_year = int(earnings_row.year) + + # Рефералы по периодам + referrals_today = sum(1 for r in referrals if r.created_at >= today_start) + referrals_week = sum(1 for r in referrals if r.created_at >= week_ago) + referrals_month = sum(1 for r in referrals if r.created_at >= month_ago) + referrals_year = sum(1 for r in referrals if r.created_at >= year_ago) + + # Конверсии + conversion_to_paid = round((paid_referrals / total_referrals * 100), 2) if total_referrals > 0 else 0 + conversion_to_active = round((active_referrals / total_referrals * 100), 2) if total_referrals > 0 else 0 + + # Средний доход с реферала + avg_earnings_per_referral = round(earnings_all_time / paid_referrals, 2) if paid_referrals > 0 else 0 + + return { + "user_id": user_id, + "summary": { + "total_referrals": total_referrals, + "paid_referrals": paid_referrals, + "active_referrals": active_referrals, + "conversion_to_paid_percent": conversion_to_paid, + "conversion_to_active_percent": conversion_to_active, + "avg_earnings_per_referral_kopeks": avg_earnings_per_referral, + }, + "earnings": { + "all_time_kopeks": earnings_all_time, + "year_kopeks": earnings_year, + "month_kopeks": earnings_month, + "week_kopeks": earnings_week, + "today_kopeks": earnings_today, + }, + "referrals_count": { + "all_time": total_referrals, + "year": referrals_year, + "month": referrals_month, + "week": referrals_week, + "today": referrals_today, + }, + } + + @classmethod + async def get_referrer_daily_stats( + cls, + db: AsyncSession, + user_id: int, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Получить статистику реферера по дням.""" + now = datetime.utcnow() + start_date = now - timedelta(days=days) + + # Рефералы по дням + referrals_by_day = await db.execute( + select( + func.date(User.created_at).label("date"), + func.count(User.id).label("referrals_count"), + ) + .where( + and_( + User.referred_by_id == user_id, + User.created_at >= start_date, + ) + ) + .group_by(func.date(User.created_at)) + .order_by(func.date(User.created_at)) + ) + referrals_dict = {str(row.date): row.referrals_count for row in referrals_by_day.all()} + + # Заработки по дням (из ReferralEarning) + earnings_by_day = await db.execute( + select( + func.date(ReferralEarning.created_at).label("date"), + func.sum(ReferralEarning.amount_kopeks).label("earnings"), + ) + .where( + and_( + ReferralEarning.user_id == user_id, + ReferralEarning.created_at >= start_date, + ) + ) + .group_by(func.date(ReferralEarning.created_at)) + ) + earnings_dict = {str(row.date): int(row.earnings or 0) for row in earnings_by_day.all()} + + # Формируем массив за все дни + result = [] + for i in range(days): + date = (start_date + timedelta(days=i)).date() + date_str = str(date) + result.append({ + "date": date_str, + "referrals_count": referrals_dict.get(date_str, 0), + "earnings_kopeks": earnings_dict.get(date_str, 0), + }) + + return result + + @classmethod + async def get_referrer_top_referrals( + cls, + db: AsyncSession, + user_id: int, + limit: int = 10, + ) -> List[Dict[str, Any]]: + """Получить топ рефералов по доходу для реферера.""" + now = datetime.utcnow() + + # Получаем рефералов с их доходами + result = await db.execute( + select( + User.id, + User.telegram_id, + User.username, + User.first_name, + User.last_name, + User.created_at, + User.has_made_first_topup, + func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label("total_earnings"), + ) + .outerjoin(ReferralEarning, ReferralEarning.referral_id == User.id) + .where(User.referred_by_id == user_id) + .group_by(User.id) + .order_by(desc(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))) + .limit(limit) + ) + rows = result.all() + + if not rows: + return [] + + # Собираем user_id для проверки активных подписок одним запросом + user_ids = [row.id for row in rows] + + # Получаем все активные подписки для этих пользователей одним запросом + active_subs_result = await db.execute( + select(Subscription.user_id) + .where( + and_( + Subscription.user_id.in_(user_ids), + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.end_date > now, + ) + ) + ) + active_user_ids = {row.user_id for row in active_subs_result.all()} + + referrals = [] + for row in rows: + referrals.append({ + "id": row.id, + "telegram_id": row.telegram_id, + "username": row.username, + "first_name": row.first_name, + "last_name": row.last_name, + "full_name": f"{row.first_name or ''} {row.last_name or ''}".strip() or f"User {row.telegram_id}", + "created_at": row.created_at, + "has_made_first_topup": row.has_made_first_topup, + "is_active": row.id in active_user_ids, + "total_earnings_kopeks": int(row.total_earnings), + }) + + return referrals + + @classmethod + async def get_referrer_period_comparison( + cls, + db: AsyncSession, + user_id: int, + current_days: int = 7, + previous_days: int = 7, + ) -> Dict[str, Any]: + """Сравнить текущий и предыдущий период.""" + now = datetime.utcnow() + current_start = now - timedelta(days=current_days) + previous_start = current_start - timedelta(days=previous_days) + previous_end = current_start + + # Рефералы за текущий период + current_referrals = await db.execute( + select(func.count(User.id)) + .where( + and_( + User.referred_by_id == user_id, + User.created_at >= current_start, + ) + ) + ) + current_referrals_count = current_referrals.scalar() or 0 + + # Рефералы за предыдущий период + previous_referrals = await db.execute( + select(func.count(User.id)) + .where( + and_( + User.referred_by_id == user_id, + User.created_at >= previous_start, + User.created_at < previous_end, + ) + ) + ) + previous_referrals_count = previous_referrals.scalar() or 0 + + # Заработки за текущий период + current_earnings = await cls._get_earnings_for_period(db, user_id, current_start) + + # Заработки за предыдущий период + previous_earnings = await cls._get_earnings_for_period( + db, user_id, previous_start, previous_end + ) + + # Расчёт изменений + referrals_change = current_referrals_count - previous_referrals_count + referrals_change_percent = ( + round((referrals_change / previous_referrals_count * 100), 2) + if previous_referrals_count > 0 + else 0 + ) + + earnings_change = current_earnings - previous_earnings + earnings_change_percent = ( + round((earnings_change / previous_earnings * 100), 2) + if previous_earnings > 0 + else 0 + ) + + return { + "current_period": { + "days": current_days, + "start": current_start.isoformat(), + "end": now.isoformat(), + "referrals_count": current_referrals_count, + "earnings_kopeks": current_earnings, + }, + "previous_period": { + "days": previous_days, + "start": previous_start.isoformat(), + "end": previous_end.isoformat(), + "referrals_count": previous_referrals_count, + "earnings_kopeks": previous_earnings, + }, + "change": { + "referrals": { + "absolute": referrals_change, + "percent": referrals_change_percent, + "trend": "up" if referrals_change > 0 else "down" if referrals_change < 0 else "stable", + }, + "earnings": { + "absolute": earnings_change, + "percent": earnings_change_percent, + "trend": "up" if earnings_change > 0 else "down" if earnings_change < 0 else "stable", + }, + }, + } + + @classmethod + async def get_global_partner_stats( + cls, + db: AsyncSession, + days: int = 30, + ) -> Dict[str, Any]: + """Глобальная статистика партнёрской программы.""" + now = datetime.utcnow() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=30) + year_ago = now - timedelta(days=365) + start_date = now - timedelta(days=days) + + # Всего рефереров (у кого есть рефералы) + total_referrers = await db.execute( + select(func.count(func.distinct(User.referred_by_id))) + .where(User.referred_by_id.isnot(None)) + ) + total_referrers_count = total_referrers.scalar() or 0 + + # Всего рефералов + total_referrals = await db.execute( + select(func.count(User.id)) + .where(User.referred_by_id.isnot(None)) + ) + total_referrals_count = total_referrals.scalar() or 0 + + # Рефералы которые заплатили + paid_referrals = await db.execute( + select(func.count(User.id)) + .where( + and_( + User.referred_by_id.isnot(None), + User.has_made_first_topup.is_(True), + ) + ) + ) + paid_referrals_count = paid_referrals.scalar() or 0 + + # Всего выплачено - один запрос с CASE WHEN + payouts_result = await db.execute( + select( + func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label("all_time"), + func.coalesce(func.sum( + case((ReferralEarning.created_at >= today_start, ReferralEarning.amount_kopeks), else_=0) + ), 0).label("today"), + func.coalesce(func.sum( + case((ReferralEarning.created_at >= week_ago, ReferralEarning.amount_kopeks), else_=0) + ), 0).label("week"), + func.coalesce(func.sum( + case((ReferralEarning.created_at >= month_ago, ReferralEarning.amount_kopeks), else_=0) + ), 0).label("month"), + func.coalesce(func.sum( + case((ReferralEarning.created_at >= year_ago, ReferralEarning.amount_kopeks), else_=0) + ), 0).label("year"), + ) + ) + payouts_row = payouts_result.one() + total_paid = int(payouts_row.all_time) + today_paid = int(payouts_row.today) + week_paid = int(payouts_row.week) + month_paid = int(payouts_row.month) + year_paid = int(payouts_row.year) + + # Новые рефералы по периодам - один запрос с CASE WHEN + new_referrals_result = await db.execute( + select( + func.sum(case((User.created_at >= today_start, 1), else_=0)).label("today"), + func.sum(case((User.created_at >= week_ago, 1), else_=0)).label("week"), + func.sum(case((User.created_at >= month_ago, 1), else_=0)).label("month"), + ).where(User.referred_by_id.isnot(None)) + ) + new_referrals_row = new_referrals_result.one() + new_referrals_today_count = int(new_referrals_row.today or 0) + new_referrals_week_count = int(new_referrals_row.week or 0) + new_referrals_month_count = int(new_referrals_row.month or 0) + + # Конверсия + conversion_rate = ( + round((paid_referrals_count / total_referrals_count * 100), 2) + if total_referrals_count > 0 + else 0 + ) + + # Средний доход с реферала + avg_per_referral = ( + round(total_paid / paid_referrals_count, 2) + if paid_referrals_count > 0 + else 0 + ) + + return { + "summary": { + "total_referrers": total_referrers_count, + "total_referrals": total_referrals_count, + "paid_referrals": paid_referrals_count, + "conversion_rate_percent": conversion_rate, + "avg_earnings_per_referral_kopeks": avg_per_referral, + }, + "payouts": { + "all_time_kopeks": total_paid, + "year_kopeks": year_paid, + "month_kopeks": month_paid, + "week_kopeks": week_paid, + "today_kopeks": today_paid, + }, + "new_referrals": { + "today": new_referrals_today_count, + "week": new_referrals_week_count, + "month": new_referrals_month_count, + }, + } + + @classmethod + async def get_global_daily_stats( + cls, + db: AsyncSession, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Глобальная статистика по дням.""" + now = datetime.utcnow() + start_date = now - timedelta(days=days) + + # Рефералы по дням + referrals_by_day = await db.execute( + select( + func.date(User.created_at).label("date"), + func.count(User.id).label("referrals_count"), + ) + .where( + and_( + User.referred_by_id.isnot(None), + User.created_at >= start_date, + ) + ) + .group_by(func.date(User.created_at)) + ) + referrals_dict = {str(row.date): row.referrals_count for row in referrals_by_day.all()} + + # Выплаты по дням + earnings_by_day = await db.execute( + select( + func.date(ReferralEarning.created_at).label("date"), + func.sum(ReferralEarning.amount_kopeks).label("earnings"), + ) + .where(ReferralEarning.created_at >= start_date) + .group_by(func.date(ReferralEarning.created_at)) + ) + earnings_dict = {str(row.date): int(row.earnings or 0) for row in earnings_by_day.all()} + + result = [] + for i in range(days): + date = (start_date + timedelta(days=i)).date() + date_str = str(date) + result.append({ + "date": date_str, + "referrals_count": referrals_dict.get(date_str, 0), + "earnings_kopeks": earnings_dict.get(date_str, 0), + }) + + return result + + @classmethod + async def get_top_referrers( + cls, + db: AsyncSession, + limit: int = 10, + days: Optional[int] = None, + ) -> List[Dict[str, Any]]: + """Получить топ рефереров.""" + now = datetime.utcnow() + start_date = now - timedelta(days=days) if days else None + + # Подсчёт рефералов и заработков + earnings_query = ( + select( + ReferralEarning.user_id, + func.sum(ReferralEarning.amount_kopeks).label("total_earnings"), + ) + .group_by(ReferralEarning.user_id) + ) + if start_date: + earnings_query = earnings_query.where(ReferralEarning.created_at >= start_date) + + earnings_result = await db.execute(earnings_query) + earnings_dict = {row.user_id: int(row.total_earnings or 0) for row in earnings_result.all()} + + # Подсчёт рефералов + referrals_query = ( + select( + User.referred_by_id, + func.count(User.id).label("referrals_count"), + ) + .where(User.referred_by_id.isnot(None)) + .group_by(User.referred_by_id) + ) + if start_date: + referrals_query = referrals_query.where(User.created_at >= start_date) + + referrals_result = await db.execute(referrals_query) + referrals_dict = {row.referred_by_id: row.referrals_count for row in referrals_result.all()} + + # Объединяем данные + all_referrer_ids = set(earnings_dict.keys()) | set(referrals_dict.keys()) + referrers_data = [] + + for referrer_id in all_referrer_ids: + referrers_data.append({ + "user_id": referrer_id, + "referrals_count": referrals_dict.get(referrer_id, 0), + "total_earnings": earnings_dict.get(referrer_id, 0), + }) + + # Сортируем по заработку + referrers_data.sort(key=lambda x: x["total_earnings"], reverse=True) + top_referrers = referrers_data[:limit] + + if not top_referrers: + return [] + + # Получаем данные всех пользователей одним запросом + top_user_ids = [data["user_id"] for data in top_referrers] + users_result = await db.execute( + select(User).where(User.id.in_(top_user_ids)) + ) + users_dict = {user.id: user for user in users_result.scalars().all()} + + # Формируем результат с сохранением порядка сортировки + result = [] + for data in top_referrers: + user = users_dict.get(data["user_id"]) + if user: + result.append({ + "id": user.id, + "telegram_id": user.telegram_id, + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "full_name": f"{user.first_name or ''} {user.last_name or ''}".strip() or f"User {user.telegram_id}", + "referral_code": user.referral_code, + "referrals_count": data["referrals_count"], + "total_earnings_kopeks": data["total_earnings"], + }) + + return result + + @classmethod + async def _get_earnings_for_period( + cls, + db: AsyncSession, + user_id: int, + start_date: Optional[datetime], + end_date: Optional[datetime] = None, + ) -> int: + """Получить заработки за период.""" + query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where( + ReferralEarning.user_id == user_id + ) + + if start_date: + query = query.where(ReferralEarning.created_at >= start_date) + if end_date: + query = query.where(ReferralEarning.created_at < end_date) + + result = await db.execute(query) + return int(result.scalar() or 0) + + @classmethod + async def _get_total_earnings( + cls, + db: AsyncSession, + start_date: Optional[datetime], + ) -> int: + """Получить общие выплаты за период.""" + query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) + + if start_date: + query = query.where(ReferralEarning.created_at >= start_date) + + result = await db.execute(query) + return int(result.scalar() or 0) diff --git a/app/services/referral_contest_service.py b/app/services/referral_contest_service.py index bd19f21d..15b5f886 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}", "", "Топ участников:", ] @@ -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("Пока нет участников.") @@ -390,6 +390,55 @@ 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_leaderboard, get_referral_contest + + contest = await get_referral_contest(db, contest_id) + if not contest: + return { + 'total_participants': 0, + 'total_invited': 0, + 'total_paid_amount': 0, + 'total_unpaid': 0, + 'participants': [], + } + + # Get leaderboard - already includes User objects + leaderboard = await get_contest_leaderboard(db, contest_id) + if not leaderboard: + return { + 'total_participants': 0, + 'total_invited': 0, + 'total_paid_amount': 0, + 'total_unpaid': 0, + 'participants': [], + } + + total_participants = len(leaderboard) + total_invited = sum(score for _, score, _ in leaderboard) + total_paid_amount = sum(amount for _, _, amount in leaderboard) + total_unpaid = 0 + + # Build participants stats directly from leaderboard (already has User objects) + participants_stats = [] + for user, score, amount in leaderboard: + participants_stats.append({ + 'referrer_id': user.id, + 'full_name': user.full_name, + 'total_referrals': score, + 'paid_referrals': score, + 'unpaid_referrals': 0, + 'total_paid_amount': amount, + }) + + return { + 'total_participants': total_participants, + 'total_invited': total_invited, + 'total_paid_amount': total_paid_amount, + 'total_unpaid': total_unpaid, + 'participants': participants_stats, + } + def _get_timezone(self, contest: ReferralContest) -> ZoneInfo: tz_name = contest.timezone or settings.TIMEZONE try: 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 ) 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/contests.py b/app/webapi/routes/contests.py index efdd50eb..1ce2109c 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, @@ -262,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) @@ -549,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, ) @@ -570,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) @@ -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/routes/menu_layout.py b/app/webapi/routes/menu_layout.py new file mode 100644 index 00000000..6253d119 --- /dev/null +++ b/app/webapi/routes/menu_layout.py @@ -0,0 +1,991 @@ +"""API эндпоинты для конструктора меню.""" + +from __future__ import annotations + +import logging +from typing import Any, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Response, Security, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.services.menu_layout_service import ( + MenuContext, + MenuLayoutService, +) + +logger = logging.getLogger(__name__) + +from ..dependencies import get_db_session, require_api_token +from ..schemas.menu_layout import ( + AddCustomButtonRequest, + AddRowRequest, + AvailableCallback, + AvailableCallbacksResponse, + BuiltinButtonInfo, + BuiltinButtonsListResponse, + ButtonClickStats, + ButtonClickStatsResponse, + ButtonConditions, + ButtonTypeStats, + ButtonTypeStatsResponse, + ButtonUpdateRequest, + DynamicPlaceholder, + DynamicPlaceholdersResponse, + HourlyStats, + HourlyStatsResponse, + MenuButtonConfig, + MenuClickStatsResponse, + MenuLayoutConfig, + MenuLayoutExportResponse, + MenuLayoutHistoryEntry, + MenuLayoutHistoryResponse, + MenuLayoutImportRequest, + MenuLayoutImportResponse, + MenuLayoutResponse, + MenuLayoutRollbackRequest, + MenuLayoutUpdateRequest, + MenuLayoutValidateRequest, + MenuLayoutValidateResponse, + MenuPreviewButton, + MenuPreviewRequest, + MenuPreviewResponse, + MenuPreviewRow, + MenuRowConfig, + MoveButtonResponse, + MoveButtonToRowRequest, + PeriodComparisonResponse, + ReorderButtonsInRowRequest, + TopUserStats, + TopUsersResponse, + UserClickSequence, + UserClickSequencesResponse, + WeekdayStats, + WeekdayStatsResponse, + ReorderButtonsResponse, + RowsReorderRequest, + SwapButtonsRequest, + SwapButtonsResponse, + ValidationError, +) + + +router = APIRouter() + + +def _serialize_config(config: dict, is_enabled: bool, updated_at) -> MenuLayoutResponse: + """Сериализовать конфигурацию в response.""" + rows = [] + for row_data in config.get("rows", []): + rows.append( + MenuRowConfig( + id=row_data["id"], + buttons=row_data.get("buttons", []), + conditions=ButtonConditions(**row_data["conditions"]) + if row_data.get("conditions") + else None, + max_per_row=row_data.get("max_per_row", 2), + ) + ) + + buttons = {} + for btn_id, btn_data in config.get("buttons", {}).items(): + buttons[btn_id] = MenuButtonConfig( + type=btn_data["type"], + builtin_id=btn_data.get("builtin_id"), + text=btn_data.get("text", {}), + icon=btn_data.get("icon"), + action=btn_data.get("action", ""), + enabled=btn_data.get("enabled", True), + visibility=btn_data.get("visibility", "all"), + conditions=ButtonConditions(**btn_data["conditions"]) + if btn_data.get("conditions") + else None, + dynamic_text=btn_data.get("dynamic_text", False), + open_mode=btn_data.get("open_mode", "callback"), + webapp_url=btn_data.get("webapp_url"), + description=btn_data.get("description"), + sort_order=btn_data.get("sort_order"), + ) + + return MenuLayoutResponse( + version=config.get("version", 1), + rows=rows, + buttons=buttons, + is_enabled=is_enabled, + updated_at=updated_at, + ) + + +@router.get("", response_model=MenuLayoutResponse) +async def get_menu_layout( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuLayoutResponse: + """Получить текущую конфигурацию меню.""" + config = await MenuLayoutService.get_config(db) + updated_at = await MenuLayoutService.get_config_updated_at(db) + return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at) + + +@router.put("", response_model=MenuLayoutResponse) +async def update_menu_layout( + payload: MenuLayoutUpdateRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuLayoutResponse: + """Обновить конфигурацию меню полностью.""" + config = await MenuLayoutService.get_config(db) + config = config.copy() + + if payload.rows is not None: + config["rows"] = [row.model_dump() for row in payload.rows] + + if payload.buttons is not None: + buttons_config = {} + for btn_id, btn in payload.buttons.items(): + btn_dict = btn.model_dump() + # Автоматически определяем наличие плейсхолдеров, если dynamic_text не установлен + if not btn_dict.get("dynamic_text", False): + from app.services.menu_layout.service import MenuLayoutService + btn_dict["dynamic_text"] = MenuLayoutService._text_has_placeholders(btn_dict.get("text", {})) + buttons_config[btn_id] = btn_dict + config["buttons"] = buttons_config + + await MenuLayoutService.save_config(db, config) + updated_at = await MenuLayoutService.get_config_updated_at(db) + return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at) + + +@router.post("/reset", response_model=MenuLayoutResponse) +async def reset_menu_layout( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuLayoutResponse: + """Сбросить конфигурацию к дефолтной.""" + config = await MenuLayoutService.reset_to_default(db) + updated_at = await MenuLayoutService.get_config_updated_at(db) + return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at) + + +@router.get("/builtin-buttons", response_model=BuiltinButtonsListResponse) +async def list_builtin_buttons( + _: Any = Security(require_api_token), +) -> BuiltinButtonsListResponse: + """Получить список встроенных кнопок.""" + items = [] + for btn_info in MenuLayoutService.get_builtin_buttons_info(): + items.append( + BuiltinButtonInfo( + id=btn_info["id"], + default_text=btn_info["default_text"], + callback_data=btn_info["callback_data"], + default_conditions=ButtonConditions(**btn_info["default_conditions"]) + if btn_info.get("default_conditions") + else None, + supports_dynamic_text=btn_info.get("supports_dynamic_text", False), + supports_direct_open=btn_info.get("supports_direct_open", False), + ) + ) + + return BuiltinButtonsListResponse(items=items, total=len(items)) + + +@router.patch("/buttons/{button_id}") +async def update_button( + button_id: str, + payload: ButtonUpdateRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuButtonConfig: + """Обновить конфигурацию отдельной кнопки.""" + try: + updates = payload.model_dump(exclude_unset=True) + # Конвертируем visibility в строку если есть + if "visibility" in updates and updates["visibility"] is not None: + if hasattr(updates["visibility"], "value"): + updates["visibility"] = updates["visibility"].value + # Конвертируем open_mode в строку если есть + if "open_mode" in updates and updates["open_mode"] is not None: + if hasattr(updates["open_mode"], "value"): + updates["open_mode"] = updates["open_mode"].value + # Конвертируем conditions - убираем None значения если это dict + if "conditions" in updates and updates["conditions"] is not None: + if isinstance(updates["conditions"], dict): + updates["conditions"] = {k: v for k, v in updates["conditions"].items() if v is not None} + elif hasattr(updates["conditions"], "model_dump"): + updates["conditions"] = updates["conditions"].model_dump(exclude_none=True) + + button = await MenuLayoutService.update_button(db, button_id, updates) + + return MenuButtonConfig( + type=button["type"], + builtin_id=button.get("builtin_id"), + text=button.get("text", {}), + icon=button.get("icon"), + action=button.get("action", ""), + enabled=button.get("enabled", True), + visibility=button.get("visibility", "all"), + conditions=ButtonConditions(**button["conditions"]) + if button.get("conditions") + else None, + dynamic_text=button.get("dynamic_text", False), + open_mode=button.get("open_mode", "callback"), + webapp_url=button.get("webapp_url"), + description=button.get("description"), + ) + except KeyError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + + +@router.post("/rows/reorder", response_model=List[MenuRowConfig]) +async def reorder_rows( + payload: RowsReorderRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> List[MenuRowConfig]: + """Изменить порядок строк.""" + try: + rows = await MenuLayoutService.reorder_rows(db, payload.ordered_ids) + return [ + MenuRowConfig( + id=row["id"], + buttons=row.get("buttons", []), + conditions=ButtonConditions(**row["conditions"]) + if row.get("conditions") + else None, + max_per_row=row.get("max_per_row", 2), + ) + for row in rows + ] + except KeyError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + + +@router.post("/rows", response_model=MenuRowConfig, status_code=status.HTTP_201_CREATED) +async def add_row( + payload: AddRowRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuRowConfig: + """Добавить новую строку.""" + try: + row_config = { + "id": payload.id, + "buttons": payload.buttons, + "conditions": payload.conditions.model_dump(exclude_none=True) + if payload.conditions + else None, + "max_per_row": payload.max_per_row, + } + row = await MenuLayoutService.add_row(db, row_config, payload.position) + + return MenuRowConfig( + id=row["id"], + buttons=row.get("buttons", []), + conditions=ButtonConditions(**row["conditions"]) + if row.get("conditions") + else None, + max_per_row=row.get("max_per_row", 2), + ) + except ValueError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + + +@router.delete("/rows/{row_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) +async def delete_row( + row_id: str, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> Response: + """Удалить строку.""" + try: + await MenuLayoutService.delete_row(db, row_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + except KeyError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + + +@router.post( + "/buttons", response_model=MenuButtonConfig, status_code=status.HTTP_201_CREATED +) +async def add_custom_button( + payload: AddCustomButtonRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuButtonConfig: + """Добавить кастомную кнопку (URL, MiniApp или callback).""" + try: + # Автоматически определяем наличие плейсхолдеров, если dynamic_text не установлен + dynamic_text = payload.dynamic_text + if not dynamic_text: + from app.services.menu_layout.service import MenuLayoutService + dynamic_text = MenuLayoutService._text_has_placeholders(payload.text) + + button_config = { + "type": payload.type.value, + "text": payload.text, + "icon": payload.icon, + "action": payload.action, + "visibility": payload.visibility.value, + "conditions": payload.conditions.model_dump(exclude_none=True) + if payload.conditions + else None, + "dynamic_text": dynamic_text, + "description": payload.description, + } + button = await MenuLayoutService.add_custom_button( + db, payload.id, button_config, payload.row_id + ) + + return MenuButtonConfig( + type=button["type"], + builtin_id=button.get("builtin_id"), + text=button.get("text", {}), + icon=button.get("icon"), + action=button.get("action", ""), + enabled=button.get("enabled", True), + visibility=button.get("visibility", "all"), + conditions=ButtonConditions(**button["conditions"]) + if button.get("conditions") + else None, + dynamic_text=button.get("dynamic_text", False), + open_mode=button.get("open_mode", "callback"), + webapp_url=button.get("webapp_url"), + description=button.get("description"), + ) + except ValueError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + + +@router.delete("/buttons/{button_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) +async def delete_custom_button( + button_id: str, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> Response: + """Удалить кастомную кнопку.""" + try: + await MenuLayoutService.delete_custom_button(db, button_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + except KeyError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + except ValueError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + + +@router.post("/preview", response_model=MenuPreviewResponse) +async def preview_menu( + payload: MenuPreviewRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuPreviewResponse: + """Предпросмотр меню для указанного контекста пользователя.""" + context = MenuContext( + language=payload.language, + is_admin=payload.is_admin, + is_moderator=payload.is_moderator, + has_active_subscription=payload.has_active_subscription, + subscription_is_active=payload.subscription_is_active, + balance_kopeks=payload.balance_kopeks, + ) + + preview_rows = await MenuLayoutService.preview_keyboard(db, context) + + rows = [] + total_buttons = 0 + for row_data in preview_rows: + buttons = [ + MenuPreviewButton( + text=btn["text"], + action=btn["action"], + type=btn["type"], + ) + for btn in row_data["buttons"] + ] + total_buttons += len(buttons) + rows.append(MenuPreviewRow(buttons=buttons)) + + return MenuPreviewResponse(rows=rows, total_buttons=total_buttons) + + +# --- Эндпоинты для перемещения кнопок --- + + +@router.post("/buttons/{button_id}/move-up", response_model=MoveButtonResponse) +async def move_button_up( + button_id: str, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MoveButtonResponse: + """Переместить кнопку вверх (в предыдущую строку или на позицию выше в текущей строке).""" + try: + result = await MenuLayoutService.move_button_up(db, button_id) + return MoveButtonResponse( + button_id=button_id, + new_row_index=result.get("new_row_index"), + position=result.get("new_position"), + ) + except KeyError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + except ValueError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + + +@router.post("/buttons/{button_id}/move-down", response_model=MoveButtonResponse) +async def move_button_down( + button_id: str, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MoveButtonResponse: + """Переместить кнопку вниз (в следующую строку или на позицию ниже в текущей строке).""" + try: + result = await MenuLayoutService.move_button_down(db, button_id) + return MoveButtonResponse( + button_id=button_id, + new_row_index=result.get("new_row_index"), + position=result.get("new_position"), + ) + except KeyError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + except ValueError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + + +@router.post("/buttons/{button_id}/move-to-row", response_model=MoveButtonResponse) +async def move_button_to_row( + button_id: str, + payload: MoveButtonToRowRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MoveButtonResponse: + """Переместить кнопку в указанную строку.""" + try: + result = await MenuLayoutService.move_button_to_row( + db, button_id, payload.target_row_id, payload.position + ) + return MoveButtonResponse( + button_id=button_id, + target_row_id=payload.target_row_id, + position=result.get("new_position"), + ) + except KeyError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + except ValueError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + + +@router.post("/rows/{row_id}/reorder-buttons", response_model=ReorderButtonsResponse) +async def reorder_buttons_in_row( + row_id: str, + payload: ReorderButtonsInRowRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> ReorderButtonsResponse: + """Изменить порядок кнопок в строке.""" + try: + result = await MenuLayoutService.reorder_buttons_in_row( + db, row_id, payload.ordered_button_ids + ) + return ReorderButtonsResponse( + row_id=row_id, + buttons=result["buttons"], + ) + except KeyError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + except ValueError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + + +@router.post("/buttons/swap", response_model=SwapButtonsResponse) +async def swap_buttons( + payload: SwapButtonsRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> SwapButtonsResponse: + """Обменять местами две кнопки (даже из разных строк).""" + try: + result = await MenuLayoutService.swap_buttons( + db, payload.button_id_1, payload.button_id_2 + ) + return SwapButtonsResponse( + button_1=result["button_1"], + button_2=result["button_2"], + ) + except KeyError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + except ValueError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + + +# --- Новые эндпоинты --- + + +@router.get("/available-callbacks", response_model=AvailableCallbacksResponse) +async def list_available_callbacks( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> AvailableCallbacksResponse: + """Получить список всех доступных callback_data для создания кнопок.""" + callbacks = await MenuLayoutService.get_available_callbacks(db) + + items = [ + AvailableCallback( + callback_data=cb["callback_data"], + name=cb["name"], + description=cb.get("description"), + category=cb["category"], + default_text=cb.get("default_text"), + default_icon=cb.get("default_icon"), + requires_subscription=cb.get("requires_subscription", False), + is_in_menu=cb.get("is_in_menu", False), + ) + for cb in callbacks + ] + + categories = list(set(cb["category"] for cb in callbacks)) + + return AvailableCallbacksResponse( + items=items, + total=len(items), + categories=sorted(categories), + ) + + +@router.get("/placeholders", response_model=DynamicPlaceholdersResponse) +async def list_dynamic_placeholders( + _: Any = Security(require_api_token), +) -> DynamicPlaceholdersResponse: + """Получить список доступных динамических плейсхолдеров для текста кнопок.""" + placeholders = MenuLayoutService.get_dynamic_placeholders() + + items = [ + DynamicPlaceholder( + placeholder=p["placeholder"], + description=p["description"], + example=p["example"], + category=p["category"], + ) + for p in placeholders + ] + + return DynamicPlaceholdersResponse(items=items, total=len(items)) + + +@router.get("/export", response_model=MenuLayoutExportResponse) +async def export_menu_layout( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuLayoutExportResponse: + """Экспортировать конфигурацию меню.""" + from datetime import datetime + + export_data = await MenuLayoutService.export_config(db) + + rows = [] + for row_data in export_data.get("rows", []): + rows.append( + MenuRowConfig( + id=row_data["id"], + buttons=row_data.get("buttons", []), + conditions=ButtonConditions(**row_data["conditions"]) + if row_data.get("conditions") + else None, + max_per_row=row_data.get("max_per_row", 2), + ) + ) + + buttons = {} + for btn_id, btn_data in export_data.get("buttons", {}).items(): + buttons[btn_id] = MenuButtonConfig( + type=btn_data["type"], + builtin_id=btn_data.get("builtin_id"), + text=btn_data.get("text", {}), + icon=btn_data.get("icon"), + action=btn_data.get("action", ""), + enabled=btn_data.get("enabled", True), + visibility=btn_data.get("visibility", "all"), + conditions=ButtonConditions(**btn_data["conditions"]) + if btn_data.get("conditions") + else None, + dynamic_text=btn_data.get("dynamic_text", False), + open_mode=btn_data.get("open_mode", "callback"), + webapp_url=btn_data.get("webapp_url"), + description=btn_data.get("description"), + ) + + return MenuLayoutExportResponse( + version=export_data.get("version", 1), + rows=rows, + buttons=buttons, + exported_at=datetime.utcnow(), + ) + + +@router.post("/import", response_model=MenuLayoutImportResponse) +async def import_menu_layout( + payload: MenuLayoutImportRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuLayoutImportResponse: + """Импортировать конфигурацию меню.""" + import_data = { + "version": payload.version, + "rows": [row.model_dump() for row in payload.rows], + "buttons": {btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items()}, + } + + result = await MenuLayoutService.import_config(db, import_data, payload.merge_mode) + + return MenuLayoutImportResponse( + success=result["success"], + imported_rows=result["imported_rows"], + imported_buttons=result["imported_buttons"], + warnings=result["warnings"], + ) + + +@router.post("/validate", response_model=MenuLayoutValidateResponse) +async def validate_menu_layout( + payload: MenuLayoutValidateRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuLayoutValidateResponse: + """Валидировать конфигурацию меню без сохранения.""" + # Если данные не переданы, валидируем текущую конфигурацию + if payload.rows is None and payload.buttons is None: + config = await MenuLayoutService.get_config(db) + else: + config = { + "rows": [row.model_dump() for row in payload.rows] if payload.rows else [], + "buttons": {btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items()} + if payload.buttons + else {}, + } + + result = MenuLayoutService.validate_config(config) + + return MenuLayoutValidateResponse( + is_valid=result["is_valid"], + errors=[ + ValidationError( + field=e["field"], + message=e["message"], + severity=e["severity"], + ) + for e in result["errors"] + ], + warnings=[ + ValidationError( + field=w["field"], + message=w["message"], + severity=w["severity"], + ) + for w in result["warnings"] + ], + ) + + +# --- Эндпоинты истории изменений --- + + +@router.get("/history", response_model=MenuLayoutHistoryResponse) +async def get_menu_layout_history( + limit: int = 50, + offset: int = 0, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuLayoutHistoryResponse: + """Получить историю изменений меню.""" + entries = await MenuLayoutService.get_history(db, limit, offset) + total = await MenuLayoutService.get_history_count(db) + + return MenuLayoutHistoryResponse( + items=[ + MenuLayoutHistoryEntry( + id=entry["id"], + created_at=entry["created_at"], + action=entry["action"], + changes_summary=entry["changes_summary"] or "", + user_info=entry["user_info"], + ) + for entry in entries + ], + total=total, + ) + + +@router.get("/history/{history_id}") +async def get_history_entry( + history_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> dict: + """Получить конкретную запись истории с полной конфигурацией.""" + entry = await MenuLayoutService.get_history_entry(db, history_id) + if not entry: + raise HTTPException(status.HTTP_404_NOT_FOUND, f"History entry {history_id} not found") + + return { + "id": entry["id"], + "action": entry["action"], + "changes_summary": entry["changes_summary"], + "user_info": entry["user_info"], + "created_at": entry["created_at"].isoformat() if entry["created_at"] else None, + "config": entry["config"], + } + + +@router.post("/history/{history_id}/rollback", response_model=MenuLayoutResponse) +async def rollback_to_history( + history_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuLayoutResponse: + """Откатить конфигурацию к записи из истории.""" + try: + config = await MenuLayoutService.rollback_to_history(db, history_id) + updated_at = await MenuLayoutService.get_config_updated_at(db) + return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at) + except KeyError as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + + +# --- Эндпоинты статистики кликов --- + + +@router.get("/stats", response_model=MenuClickStatsResponse) +async def get_menu_click_stats( + days: int = 30, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MenuClickStatsResponse: + """Получить общую статистику кликов по всем кнопкам.""" + from datetime import datetime, timedelta + + stats = await MenuLayoutService.get_all_buttons_stats(db, days) + total_clicks = await MenuLayoutService.get_total_clicks(db, days) + + now = datetime.utcnow() + period_start = now - timedelta(days=days) + + return MenuClickStatsResponse( + items=[ + ButtonClickStats( + button_id=s["button_id"], + clicks_total=s["clicks_total"], + clicks_today=s.get("clicks_today", 0), + clicks_week=s.get("clicks_week", 0), + clicks_month=s.get("clicks_month", 0), + unique_users=s["unique_users"], + last_click_at=s["last_click_at"], + ) + for s in stats + ], + total_clicks=total_clicks, + period_start=period_start, + period_end=now, + ) + + +@router.get("/stats/buttons/{button_id}", response_model=ButtonClickStatsResponse) +async def get_button_click_stats( + button_id: str, + days: int = 30, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> ButtonClickStatsResponse: + """Получить статистику кликов по конкретной кнопке.""" + stats = await MenuLayoutService.get_button_stats(db, button_id, days) + clicks_by_day = await MenuLayoutService.get_button_clicks_by_day(db, button_id, days) + + return ButtonClickStatsResponse( + button_id=button_id, + stats=ButtonClickStats( + button_id=stats["button_id"], + clicks_total=stats["clicks_total"], + clicks_today=stats["clicks_today"], + clicks_week=stats["clicks_week"], + clicks_month=stats["clicks_month"], + unique_users=stats["unique_users"], + last_click_at=stats["last_click_at"], + ), + clicks_by_day=clicks_by_day, + ) + + +@router.post("/stats/log-click") +async def log_button_click( + button_id: str, + user_id: Optional[int] = None, + callback_data: Optional[str] = None, + button_type: Optional[str] = None, + button_text: Optional[str] = None, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> dict: + """Записать клик по кнопке (для внешней интеграции).""" + await MenuLayoutService.log_button_click( + db, + button_id=button_id, + user_id=user_id, + callback_data=callback_data, + button_type=button_type, + button_text=button_text, + ) + return {"success": True} + + +@router.get("/stats/by-type", response_model=ButtonTypeStatsResponse) +async def get_stats_by_button_type( + days: int = 30, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> ButtonTypeStatsResponse: + """Получить статистику кликов по типам кнопок (builtin, callback, url, mini_app).""" + try: + stats = await MenuLayoutService.get_stats_by_button_type(db, days) + total_clicks = sum(s["clicks_total"] for s in stats) + + return ButtonTypeStatsResponse( + items=[ + ButtonTypeStats( + button_type=s["button_type"], + clicks_total=s["clicks_total"], + unique_users=s["unique_users"], + ) + for s in stats + ], + total_clicks=total_clicks, + ) + except Exception as e: + logger.error(f"Error getting stats by type: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.get("/stats/by-hour", response_model=HourlyStatsResponse) +async def get_clicks_by_hour( + button_id: Optional[str] = None, + days: int = 30, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> HourlyStatsResponse: + """Получить статистику кликов по часам дня (0-23).""" + stats = await MenuLayoutService.get_clicks_by_hour(db, button_id, days) + + return HourlyStatsResponse( + items=[ + HourlyStats(hour=s["hour"], count=s["count"]) + for s in stats + ], + button_id=button_id, + ) + + +@router.get("/stats/by-weekday", response_model=WeekdayStatsResponse) +async def get_clicks_by_weekday( + button_id: Optional[str] = None, + days: int = 30, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> WeekdayStatsResponse: + """Получить статистику кликов по дням недели.""" + stats = await MenuLayoutService.get_clicks_by_weekday(db, button_id, days) + + return WeekdayStatsResponse( + items=[ + WeekdayStats( + weekday=s["weekday"], + weekday_name=s["weekday_name"], + count=s["count"] + ) + for s in stats + ], + button_id=button_id, + ) + + +@router.get("/stats/top-users", response_model=TopUsersResponse) +async def get_top_users( + button_id: Optional[str] = None, + limit: int = 10, + days: int = 30, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> TopUsersResponse: + """Получить топ пользователей по количеству кликов.""" + try: + stats = await MenuLayoutService.get_top_users(db, button_id, limit, days) + + return TopUsersResponse( + items=[ + TopUserStats( + user_id=s["user_id"], + clicks_count=s["clicks_count"], + last_click_at=s["last_click_at"], + ) + for s in stats + ], + button_id=button_id, + limit=limit, + ) + except Exception as e: + logger.error(f"Error getting top users: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.get("/stats/compare", response_model=PeriodComparisonResponse) +async def get_period_comparison( + button_id: Optional[str] = None, + current_days: int = 7, + previous_days: int = 7, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> PeriodComparisonResponse: + """Сравнить статистику текущего и предыдущего периода.""" + try: + comparison = await MenuLayoutService.get_period_comparison( + db, button_id, current_days, previous_days + ) + + logger.debug(f"Period comparison: button_id={button_id}, current_days={current_days}, previous_days={previous_days}, trend={comparison.get('change', {}).get('trend')}") + + return PeriodComparisonResponse( + current_period=comparison["current_period"], + previous_period=comparison["previous_period"], + change=comparison["change"], + button_id=button_id, + ) + except Exception as e: + logger.error(f"Error getting period comparison: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.get("/stats/users/{user_id}/sequences", response_model=UserClickSequencesResponse) +async def get_user_click_sequences( + user_id: int, + limit: int = 50, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> UserClickSequencesResponse: + """Получить последовательности кликов пользователя.""" + try: + sequences = await MenuLayoutService.get_user_click_sequences(db, user_id, limit) + + logger.debug(f"User sequences: user_id={user_id}, limit={limit}, found={len(sequences)} sequences") + + return UserClickSequencesResponse( + user_id=user_id, + items=[ + UserClickSequence( + button_id=s["button_id"], + button_text=s["button_text"], + clicked_at=s["clicked_at"], + ) + for s in sequences + ], + total=len(sequences), + ) + except Exception as e: + logger.error(f"Error getting user sequences: user_id={user_id}, error={e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") 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/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/app/webapi/schemas/menu_layout.py b/app/webapi/schemas/menu_layout.py new file mode 100644 index 00000000..e8c89c3c --- /dev/null +++ b/app/webapi/schemas/menu_layout.py @@ -0,0 +1,691 @@ +"""Pydantic схемы для API конструктора меню.""" + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class ButtonType(str, Enum): + """Тип кнопки меню.""" + + BUILTIN = "builtin" # Встроенная кнопка с callback_data + URL = "url" # Внешняя ссылка + MINI_APP = "mini_app" # Telegram Mini App + CALLBACK = "callback" # Кастомная кнопка с любым callback_data + + +class ButtonVisibility(str, Enum): + """Видимость кнопки.""" + + ALL = "all" # Видна всем + ADMINS = "admins" # Только админам + MODERATORS = "moderators" # Только модераторам + SUBSCRIBERS = "subscribers" # Только подписчикам + + +class ButtonOpenMode(str, Enum): + """Режим открытия кнопки.""" + + CALLBACK = "callback" # Отправляет callback_data боту (по умолчанию) + DIRECT = "direct" # Сразу открывает Mini App через WebAppInfo + + +class ButtonConditions(BaseModel): + """Условия показа кнопки.""" + + # Существующие условия + has_active_subscription: Optional[bool] = Field( + default=None, description="Требуется активная подписка" + ) + subscription_is_active: Optional[bool] = Field( + default=None, description="Подписка должна быть активна (не приостановлена)" + ) + has_traffic_limit: Optional[bool] = Field( + default=None, description="Подписка с лимитом трафика" + ) + is_admin: Optional[bool] = Field(default=None, description="Пользователь - админ") + is_moderator: Optional[bool] = Field( + default=None, description="Пользователь - модератор" + ) + referral_enabled: Optional[bool] = Field( + default=None, description="Реферальная программа включена" + ) + contests_visible: Optional[bool] = Field( + default=None, description="Конкурсы видимы" + ) + support_enabled: Optional[bool] = Field( + default=None, description="Поддержка включена" + ) + language_selection_enabled: Optional[bool] = Field( + default=None, description="Выбор языка включен" + ) + happ_enabled: Optional[bool] = Field( + default=None, description="Кнопка Happ включена" + ) + simple_subscription_enabled: Optional[bool] = Field( + default=None, description="Простая подписка включена" + ) + show_trial: Optional[bool] = Field( + default=None, description="Показать пробный период" + ) + show_buy: Optional[bool] = Field( + default=None, description="Показать кнопку покупки" + ) + has_saved_cart: Optional[bool] = Field( + default=None, description="Есть сохраненная корзина" + ) + + # Расширенные условия + min_balance_kopeks: Optional[int] = Field( + default=None, ge=0, description="Минимальный баланс в копейках" + ) + max_balance_kopeks: Optional[int] = Field( + default=None, ge=0, description="Максимальный баланс в копейках" + ) + min_registration_days: Optional[int] = Field( + default=None, ge=0, description="Минимум дней с регистрации" + ) + max_registration_days: Optional[int] = Field( + default=None, ge=0, description="Максимум дней с регистрации" + ) + min_referrals: Optional[int] = Field( + default=None, ge=0, description="Минимальное количество рефералов" + ) + has_referrals: Optional[bool] = Field( + default=None, description="Есть рефералы" + ) + promo_group_ids: Optional[List[str]] = Field( + default=None, description="Список ID промо-групп (пользователь должен быть в одной из них)" + ) + exclude_promo_group_ids: Optional[List[str]] = Field( + default=None, description="Исключить пользователей из этих промо-групп" + ) + has_subscription_days_left: Optional[int] = Field( + default=None, ge=0, description="Минимум дней до окончания подписки" + ) + max_subscription_days_left: Optional[int] = Field( + default=None, ge=0, description="Максимум дней до окончания подписки" + ) + is_trial_user: Optional[bool] = Field( + default=None, description="Пользователь на пробном периоде" + ) + has_autopay: Optional[bool] = Field( + default=None, description="Автоплатёж включён" + ) + + model_config = ConfigDict(extra="forbid") + + +class MenuButtonConfig(BaseModel): + """Конфигурация отдельной кнопки.""" + + type: ButtonType = Field(..., description="Тип кнопки") + builtin_id: Optional[str] = Field( + default=None, description="ID встроенной кнопки (для type=builtin)" + ) + text: Dict[str, str] = Field( + ..., description="Локализованные тексты кнопки: {lang_code: text}" + ) + icon: Optional[str] = Field( + default=None, max_length=10, description="Эмодзи/иконка кнопки (отдельно от текста)" + ) + action: str = Field( + ..., description="callback_data или URL в зависимости от типа" + ) + enabled: bool = Field(default=True, description="Кнопка активна") + visibility: ButtonVisibility = Field( + default=ButtonVisibility.ALL, description="Видимость кнопки" + ) + conditions: Optional[ButtonConditions] = Field( + default=None, description="Дополнительные условия показа" + ) + dynamic_text: bool = Field( + default=False, description="Текст содержит плейсхолдеры ({balance}, {username} и т.д.)" + ) + open_mode: ButtonOpenMode = Field( + default=ButtonOpenMode.CALLBACK, + description="Режим открытия: callback (через бота) или direct (сразу Mini App)", + ) + webapp_url: Optional[str] = Field( + default=None, + description="URL для Mini App при open_mode=direct", + ) + description: Optional[str] = Field( + default=None, max_length=200, description="Описание кнопки для админ-панели" + ) + sort_order: Optional[int] = Field( + default=None, description="Порядок сортировки (для отображения в админке)" + ) + + model_config = ConfigDict(extra="forbid") + + +class MenuRowConfig(BaseModel): + """Конфигурация строки меню.""" + + id: str = Field(..., min_length=1, max_length=50, description="Уникальный ID строки") + buttons: List[str] = Field( + ..., description="Список ID кнопок в строке" + ) + conditions: Optional[ButtonConditions] = Field( + default=None, description="Условия показа всей строки" + ) + max_per_row: int = Field( + default=2, ge=1, le=4, description="Максимум кнопок в строке" + ) + + model_config = ConfigDict(extra="forbid") + + +class MenuLayoutConfig(BaseModel): + """Полная конфигурация меню.""" + + version: int = Field(default=1, description="Версия формата конфигурации") + rows: List[MenuRowConfig] = Field( + default_factory=list, description="Строки меню" + ) + buttons: Dict[str, MenuButtonConfig] = Field( + default_factory=dict, description="Конфигурации кнопок" + ) + + model_config = ConfigDict(extra="forbid") + + +# --- Response schemas --- + + +class MenuLayoutResponse(BaseModel): + """Ответ с конфигурацией меню.""" + + version: int + rows: List[MenuRowConfig] + buttons: Dict[str, MenuButtonConfig] + is_enabled: bool = Field(description="Включен ли конструктор меню") + updated_at: Optional[datetime] = None + + +class BuiltinButtonInfo(BaseModel): + """Информация о встроенной кнопке.""" + + id: str = Field(description="Идентификатор кнопки") + default_text: Dict[str, str] = Field(description="Текст по умолчанию") + callback_data: str = Field(description="callback_data кнопки") + default_conditions: Optional[ButtonConditions] = Field( + default=None, description="Условия показа по умолчанию" + ) + supports_dynamic_text: bool = Field( + default=False, description="Поддерживает ли динамический текст" + ) + supports_direct_open: bool = Field( + default=False, description="Поддерживает ли прямое открытие Mini App" + ) + + +class BuiltinButtonsListResponse(BaseModel): + """Список встроенных кнопок.""" + + items: List[BuiltinButtonInfo] + total: int + + +# --- Request schemas --- + + +class MenuLayoutUpdateRequest(BaseModel): + """Запрос на обновление конфигурации меню.""" + + rows: Optional[List[MenuRowConfig]] = Field( + default=None, description="Новая конфигурация строк" + ) + buttons: Optional[Dict[str, MenuButtonConfig]] = Field( + default=None, description="Новая конфигурация кнопок" + ) + + model_config = ConfigDict(extra="forbid") + + +class ButtonUpdateRequest(BaseModel): + """Запрос на обновление отдельной кнопки.""" + + text: Optional[Dict[str, str]] = Field( + default=None, description="Новые локализованные тексты" + ) + icon: Optional[str] = Field( + default=None, max_length=10, description="Эмодзи/иконка кнопки" + ) + enabled: Optional[bool] = Field(default=None, description="Включить/выключить") + visibility: Optional[ButtonVisibility] = Field( + default=None, description="Новая видимость" + ) + conditions: Optional[ButtonConditions] = Field( + default=None, description="Новые условия показа" + ) + action: Optional[str] = Field( + default=None, description="Новый action (callback_data или URL)" + ) + dynamic_text: Optional[bool] = Field( + default=None, description="Текст содержит плейсхолдеры" + ) + open_mode: Optional[ButtonOpenMode] = Field( + default=None, description="Режим открытия: callback или direct" + ) + webapp_url: Optional[str] = Field( + default=None, description="URL для Mini App при open_mode=direct" + ) + description: Optional[str] = Field( + default=None, max_length=200, description="Описание кнопки" + ) + sort_order: Optional[int] = Field( + default=None, description="Порядок сортировки" + ) + + model_config = ConfigDict(extra="forbid") + + +class RowsReorderRequest(BaseModel): + """Запрос на изменение порядка строк.""" + + ordered_ids: List[str] = Field( + ..., min_length=1, description="Список ID строк в новом порядке" + ) + + model_config = ConfigDict(extra="forbid") + + +class AddRowRequest(BaseModel): + """Запрос на добавление новой строки.""" + + id: str = Field(..., min_length=1, max_length=50, description="ID новой строки") + buttons: List[str] = Field(..., description="Список ID кнопок") + conditions: Optional[ButtonConditions] = Field( + default=None, description="Условия показа" + ) + max_per_row: int = Field(default=2, ge=1, le=4, description="Макс. кнопок в строке") + position: Optional[int] = Field( + default=None, ge=0, description="Позиция вставки (по умолчанию - в конец)" + ) + + model_config = ConfigDict(extra="forbid") + + +class AddCustomButtonRequest(BaseModel): + """Запрос на добавление кастомной кнопки.""" + + id: str = Field( + ..., min_length=1, max_length=50, description="ID кнопки (уникальный)" + ) + type: ButtonType = Field(..., description="Тип кнопки (url, mini_app или callback)") + text: Dict[str, str] = Field(..., description="Локализованные тексты") + icon: Optional[str] = Field( + default=None, max_length=10, description="Эмодзи/иконка кнопки" + ) + action: str = Field(..., min_length=1, description="URL или callback_data") + visibility: ButtonVisibility = Field( + default=ButtonVisibility.ALL, description="Видимость" + ) + conditions: Optional[ButtonConditions] = Field( + default=None, description="Условия показа" + ) + dynamic_text: bool = Field( + default=False, description="Текст содержит плейсхолдеры" + ) + row_id: Optional[str] = Field( + default=None, description="ID строки для добавления кнопки" + ) + description: Optional[str] = Field( + default=None, max_length=200, description="Описание кнопки для админ-панели" + ) + + model_config = ConfigDict(extra="forbid") + + +class MenuPreviewRequest(BaseModel): + """Запрос на предпросмотр меню.""" + + language: str = Field(default="ru", description="Язык для предпросмотра") + is_admin: bool = Field(default=False, description="Режим админа") + is_moderator: bool = Field(default=False, description="Режим модератора") + has_active_subscription: bool = Field( + default=False, description="Есть активная подписка" + ) + subscription_is_active: bool = Field( + default=False, description="Подписка активна" + ) + balance_kopeks: int = Field(default=0, ge=0, description="Баланс в копейках") + + model_config = ConfigDict(extra="forbid") + + +class MenuPreviewButton(BaseModel): + """Кнопка в предпросмотре.""" + + text: str + action: str + type: ButtonType + + +class MenuPreviewRow(BaseModel): + """Строка в предпросмотре.""" + + buttons: List[MenuPreviewButton] + + +class MenuPreviewResponse(BaseModel): + """Ответ с предпросмотром меню.""" + + rows: List[MenuPreviewRow] + total_buttons: int + + +# --- Схемы для перемещения кнопок --- + + +class MoveButtonToRowRequest(BaseModel): + """Запрос на перемещение кнопки в другую строку.""" + + target_row_id: str = Field(..., description="ID целевой строки") + position: Optional[int] = Field( + default=None, ge=0, description="Позиция в строке (по умолчанию - в конец)" + ) + + model_config = ConfigDict(extra="forbid") + + +class ReorderButtonsInRowRequest(BaseModel): + """Запрос на изменение порядка кнопок в строке.""" + + ordered_button_ids: List[str] = Field( + ..., min_length=1, description="Список ID кнопок в новом порядке" + ) + + model_config = ConfigDict(extra="forbid") + + +class SwapButtonsRequest(BaseModel): + """Запрос на обмен местами двух кнопок.""" + + button_id_1: str = Field(..., description="ID первой кнопки") + button_id_2: str = Field(..., description="ID второй кнопки") + + model_config = ConfigDict(extra="forbid") + + +class MoveButtonResponse(BaseModel): + """Ответ на перемещение кнопки.""" + + button_id: str + new_row_index: Optional[int] = None + target_row_id: Optional[str] = None + position: Optional[int] = None + + +class SwapButtonsResponse(BaseModel): + """Ответ на обмен кнопок.""" + + button_1: Dict[str, Any] + button_2: Dict[str, Any] + + +class ReorderButtonsResponse(BaseModel): + """Ответ на изменение порядка кнопок.""" + + row_id: str + buttons: List[str] + + +# --- Схемы для доступных callback_data --- + + +class AvailableCallback(BaseModel): + """Информация о доступном callback_data.""" + + callback_data: str = Field(description="callback_data для кнопки") + name: str = Field(description="Человекочитаемое название") + description: Optional[str] = Field(default=None, description="Описание действия") + category: str = Field(description="Категория: menu, subscription, balance, referral, support, etc.") + default_text: Optional[Dict[str, str]] = Field(default=None, description="Текст по умолчанию") + default_icon: Optional[str] = Field(default=None, description="Иконка по умолчанию") + requires_subscription: bool = Field(default=False, description="Требует активную подписку") + is_in_menu: bool = Field(default=False, description="Уже добавлена в меню") + + +class AvailableCallbacksResponse(BaseModel): + """Список всех доступных callback_data.""" + + items: List[AvailableCallback] + total: int + categories: List[str] = Field(description="Список всех категорий") + + +# --- Схемы для импорта/экспорта --- + + +class MenuLayoutExportResponse(BaseModel): + """Экспорт конфигурации меню.""" + + version: int + rows: List[MenuRowConfig] + buttons: Dict[str, MenuButtonConfig] + exported_at: datetime + bot_version: Optional[str] = None + + +class MenuLayoutImportRequest(BaseModel): + """Импорт конфигурации меню.""" + + version: int + rows: List[MenuRowConfig] + buttons: Dict[str, MenuButtonConfig] + merge_mode: str = Field( + default="replace", + description="Режим импорта: replace (заменить всё), merge (объединить)" + ) + + model_config = ConfigDict(extra="forbid") + + +class MenuLayoutImportResponse(BaseModel): + """Результат импорта.""" + + success: bool + imported_rows: int + imported_buttons: int + warnings: List[str] = Field(default_factory=list) + + +# --- Схемы для истории изменений --- + + +class MenuLayoutHistoryEntry(BaseModel): + """Запись в истории изменений.""" + + id: int + created_at: datetime + action: str = Field(description="Тип действия: update, reset, import") + changes_summary: str = Field(description="Краткое описание изменений") + user_info: Optional[str] = Field(default=None, description="Информация о пользователе") + + +class MenuLayoutHistoryResponse(BaseModel): + """История изменений.""" + + items: List[MenuLayoutHistoryEntry] + total: int + + +class MenuLayoutRollbackRequest(BaseModel): + """Запрос на откат к предыдущей версии.""" + + history_id: int = Field(description="ID записи в истории для отката") + + model_config = ConfigDict(extra="forbid") + + +# --- Схемы для валидации --- + + +class ValidationError(BaseModel): + """Ошибка валидации.""" + + field: str + message: str + severity: str = Field(description="error или warning") + + +class MenuLayoutValidateRequest(BaseModel): + """Запрос на валидацию конфигурации.""" + + rows: Optional[List[MenuRowConfig]] = None + buttons: Optional[Dict[str, MenuButtonConfig]] = None + + model_config = ConfigDict(extra="forbid") + + +class MenuLayoutValidateResponse(BaseModel): + """Результат валидации.""" + + is_valid: bool + errors: List[ValidationError] = Field(default_factory=list) + warnings: List[ValidationError] = Field(default_factory=list) + + +# --- Схемы для статистики кликов --- + + +class ButtonClickStats(BaseModel): + """Статистика кликов по кнопке.""" + + button_id: str + clicks_total: int = Field(default=0) + clicks_today: int = Field(default=0) + clicks_week: int = Field(default=0) + clicks_month: int = Field(default=0) + last_click_at: Optional[datetime] = None + unique_users: int = Field(default=0, description="Уникальные пользователи") + + +class ButtonClickStatsResponse(BaseModel): + """Статистика кликов для одной кнопки.""" + + button_id: str + stats: ButtonClickStats + clicks_by_day: List[Dict[str, Any]] = Field( + default_factory=list, description="Клики по дням [{date, count}]" + ) + + +class MenuClickStatsResponse(BaseModel): + """Общая статистика кликов по всем кнопкам.""" + + items: List[ButtonClickStats] + total_clicks: int + period_start: datetime + period_end: datetime + + +class ButtonTypeStats(BaseModel): + """Статистика по типу кнопки.""" + + button_type: str + clicks_total: int + unique_users: int + + +class ButtonTypeStatsResponse(BaseModel): + """Статистика кликов по типам кнопок.""" + + items: List[ButtonTypeStats] + total_clicks: int + + +class HourlyStats(BaseModel): + """Статистика по часам.""" + + hour: int + count: int + + +class HourlyStatsResponse(BaseModel): + """Статистика кликов по часам дня.""" + + items: List[HourlyStats] + button_id: Optional[str] = None + + +class WeekdayStats(BaseModel): + """Статистика по дням недели.""" + + weekday: int + weekday_name: str + count: int + + +class WeekdayStatsResponse(BaseModel): + """Статистика кликов по дням недели.""" + + items: List[WeekdayStats] + button_id: Optional[str] = None + + +class TopUserStats(BaseModel): + """Статистика пользователя.""" + + user_id: int + clicks_count: int + last_click_at: Optional[datetime] = None + + +class TopUsersResponse(BaseModel): + """Топ пользователей по кликам.""" + + items: List[TopUserStats] + button_id: Optional[str] = None + limit: int + + +class PeriodComparisonResponse(BaseModel): + """Сравнение периодов.""" + + current_period: Dict[str, Any] + previous_period: Dict[str, Any] + change: Dict[str, Any] + button_id: Optional[str] = None + + +class UserClickSequence(BaseModel): + """Последовательность кликов пользователя.""" + + button_id: str + button_text: Optional[str] = None + clicked_at: datetime + + +class UserClickSequencesResponse(BaseModel): + """Последовательности кликов пользователя.""" + + user_id: int + items: List[UserClickSequence] + total: int + + +# --- Схемы для плейсхолдеров --- + + +class DynamicPlaceholder(BaseModel): + """Информация о динамическом плейсхолдере.""" + + placeholder: str = Field(description="Плейсхолдер, например {balance}") + description: str = Field(description="Описание") + example: str = Field(description="Пример значения") + category: str = Field(description="Категория: user, subscription, referral, etc.") + + +class DynamicPlaceholdersResponse(BaseModel): + """Список доступных плейсхолдеров.""" + + items: List[DynamicPlaceholder] + total: int 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 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") 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, тип, суммы). diff --git a/docs/menu_stats_api_usage.md b/docs/menu_stats_api_usage.md new file mode 100644 index 00000000..cc5125c6 --- /dev/null +++ b/docs/menu_stats_api_usage.md @@ -0,0 +1,259 @@ +# Использование API статистики кнопок меню + +## Обзор + +Система статистики кликов по кнопкам меню позволяет отслеживать, какие кнопки чаще всего нажимают пользователи. + +## API Эндпоинты + +### 1. Логирование клика по кнопке + +**POST** `/menu-layout/stats/log-click` + +**Параметры:** +- `button_id` (str) - ID кнопки +- `user_id` (int, optional) - ID пользователя (telegram_id) +- `callback_data` (str, optional) - callback_data кнопки +- `button_type` (str, optional) - тип кнопки: `builtin`, `callback`, `url`, `mini_app` +- `button_text` (str, optional) - текст кнопки на момент клика + +**Пример:** +```python +await MenuLayoutService.log_button_click( + db, + button_id="menu_balance", + user_id=123456789, + callback_data="menu_balance", + button_type="builtin", + button_text="💰 Баланс" +) +``` + +### 2. Получение статистики по конкретной кнопке + +**GET** `/menu-layout/stats/buttons/{button_id}?days=30` + +**Возвращает:** +- `clicks_total` - общее количество кликов +- `clicks_today` - клики сегодня +- `clicks_week` - клики за неделю +- `clicks_month` - клики за месяц +- `unique_users` - уникальные пользователи +- `last_click_at` - последний клик +- `clicks_by_day` - клики по дням + +**Пример:** +```python +stats = await MenuLayoutService.get_button_stats(db, "menu_balance", days=30) +# Возвращает: +# { +# "button_id": "menu_balance", +# "clicks_total": 150, +# "clicks_today": 5, +# "clicks_week": 25, +# "clicks_month": 150, +# "unique_users": 45, +# "last_click_at": datetime(...) +# } +``` + +### 3. Получение общей статистики по всем кнопкам + +**GET** `/menu-layout/stats?days=30` + +**Возвращает:** +- `items` - список статистики по каждой кнопке +- `total_clicks` - общее количество кликов +- `period_start` - начало периода +- `period_end` - конец периода + +**Пример:** +```python +all_stats = await MenuLayoutService.get_all_buttons_stats(db, days=30) +total = await MenuLayoutService.get_total_clicks(db, days=30) +``` + +## Автоматическое логирование + +✅ **Логирование кликов происходит автоматически!** + +Все клики по кнопкам автоматически логируются через `ButtonStatsMiddleware`. Middleware перехватывает все `CallbackQuery` события и логирует их в базу данных. + +### Как это работает + +1. При каждом клике по кнопке middleware автоматически: + - Извлекает `callback_data` (используется как `button_id`) + - Получает `user_id` из события + - Определяет тип кнопки (`builtin`, `callback`, `url`) + - Извлекает текст кнопки из клавиатуры (если доступен) + - Логирует в базу данных асинхронно (не блокирует обработку) + +2. Middleware активируется автоматически, если `MENU_LAYOUT_ENABLED=True` + +3. Логирование происходит в фоновом режиме и не влияет на производительность + +### Ручное логирование (опционально) + +Если нужно логировать клики вручную (например, для внешних интеграций), можно использовать API: + +```python +# Через сервис +await MenuLayoutService.log_button_click( + db, + button_id="custom_button", + user_id=user_id, + callback_data="custom_callback", + button_type="callback", + button_text="Кастомная кнопка" +) + +# Или через API эндпоинт +POST /menu-layout/stats/log-click +{ + "button_id": "custom_button", + "user_id": 123456789, + "callback_data": "custom_callback", + "button_type": "callback", + "button_text": "Кастомная кнопка" +} +``` + +### 4. Статистика по типам кнопок + +**GET** `/menu-layout/stats/by-type?days=30` + +**Возвращает:** +- Статистику кликов по каждому типу кнопок (builtin, callback, url, mini_app) +- Общее количество кликов по типам + +**Пример:** +```python +stats = await MenuLayoutService.get_stats_by_button_type(db, days=30) +# Возвращает: +# [ +# {"button_type": "builtin", "clicks_total": 500, "unique_users": 100}, +# {"button_type": "callback", "clicks_total": 200, "unique_users": 50}, +# ... +# ] +``` + +### 5. Статистика по часам дня + +**GET** `/menu-layout/stats/by-hour?button_id=menu_balance&days=30` + +**Параметры:** +- `button_id` (optional) - ID кнопки для фильтрации +- `days` (default: 30) - период в днях + +**Возвращает:** +- Распределение кликов по часам дня (0-23) + +**Пример:** +```python +stats = await MenuLayoutService.get_clicks_by_hour(db, button_id="menu_balance", days=30) +# Возвращает: +# [ +# {"hour": 9, "count": 50}, +# {"hour": 10, "count": 75}, +# ... +# ] +``` + +### 6. Статистика по дням недели + +**GET** `/menu-layout/stats/by-weekday?button_id=menu_balance&days=30` + +**Возвращает:** +- Распределение кликов по дням недели (0=понедельник, 6=воскресенье) + +**Пример:** +```python +stats = await MenuLayoutService.get_clicks_by_weekday(db, button_id="menu_balance", days=30) +# Возвращает: +# [ +# {"weekday": 0, "weekday_name": "Понедельник", "count": 100}, +# {"weekday": 1, "weekday_name": "Вторник", "count": 120}, +# ... +# ] +``` + +### 7. Топ пользователей по кликам + +**GET** `/menu-layout/stats/top-users?button_id=menu_balance&limit=10&days=30` + +**Параметры:** +- `button_id` (optional) - ID кнопки для фильтрации +- `limit` (default: 10) - количество пользователей +- `days` (default: 30) - период в днях + +**Возвращает:** +- Список пользователей с наибольшим количеством кликов + +**Пример:** +```python +top_users = await MenuLayoutService.get_top_users(db, button_id="menu_balance", limit=10, days=30) +# Возвращает: +# [ +# {"user_id": 123456789, "clicks_count": 50, "last_click_at": datetime(...)}, +# ... +# ] +``` + +### 8. Сравнение периодов + +**GET** `/menu-layout/stats/compare?button_id=menu_balance¤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` +2. **Требуется авторизация**: API эндпоинты для получения статистики требуют токен авторизации (`require_api_token`) +3. **button_id**: Используется `callback_data` кнопки как идентификатор +4. **Производительность**: Логирование выполняется асинхронно в фоне и не блокирует обработку запросов +5. **Активация**: Middleware работает только если `MENU_LAYOUT_ENABLED=True` в настройках +6. **Временные зоны**: Все временные метрики используют локальное время сервера + 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 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" +