diff --git a/app/bot.py b/app/bot.py index bdfac407..b663356a 100644 --- a/app/bot.py +++ b/app/bot.py @@ -133,7 +133,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: if settings.MENU_LAYOUT_ENABLED: button_stats_middleware = ButtonStatsMiddleware() dp.callback_query.middleware(button_stats_middleware) - logger.info("πŸ“Š ButtonStatsMiddleware Π°ΠΊΡ‚ΠΈΠ²ΠΈΡ€ΠΎΠ²Π°Π½ - автоматичСскоС Π»ΠΎΠ³ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ ΠΊΠ»ΠΈΠΊΠΎΠ² Π²ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΎ") + logger.info("πŸ“Š ButtonStatsMiddleware Π°ΠΊΡ‚ΠΈΠ²ΠΈΡ€ΠΎΠ²Π°Π½") if settings.CHANNEL_IS_REQUIRED_SUB: from app.middlewares.channel_checker import ChannelCheckerMiddleware diff --git a/app/middlewares/button_stats.py b/app/middlewares/button_stats.py index 66cfa25e..a676729f 100644 --- a/app/middlewares/button_stats.py +++ b/app/middlewares/button_stats.py @@ -21,33 +21,33 @@ class ButtonStatsMiddleware(BaseMiddleware): data: Dict[str, Any] ) -> Any: """ΠŸΠ΅Ρ€Π΅Ρ…Π²Π°Ρ‚Ρ‹Π²Π°Π΅Ρ‚ CallbackQuery ΠΈ Π»ΠΎΠ³ΠΈΡ€ΡƒΠ΅Ρ‚ ΠΊΠ»ΠΈΠΊΠΈ ΠΏΠΎ ΠΊΠ½ΠΎΠΏΠΊΠ°ΠΌ.""" - + # ΠžΠ±Ρ€Π°Π±Π°Ρ‚Ρ‹Π²Π°Π΅ΠΌ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ CallbackQuery if not isinstance(event, CallbackQuery): return await handler(event, data) - + # ΠŸΡ€ΠΎΠΏΡƒΡΠΊΠ°Π΅ΠΌ, Ссли статистика ΠΎΡ‚ΠΊΠ»ΡŽΡ‡Π΅Π½Π° if not settings.MENU_LAYOUT_ENABLED: return await handler(event, data) - + # Π›ΠΎΠ³ΠΈΡ€ΡƒΠ΅ΠΌ ΠΊΠ»ΠΈΠΊ асинхронно, Π½Π΅ блокируя ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΡƒ try: # ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅ΠΌ callback_data callback_data = event.data if not callback_data: return await handler(event, data) - + # ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅ΠΌ user_id user_id = event.from_user.id if event.from_user else None - + # ΠžΠΏΡ€Π΅Π΄Π΅Π»ΡΠ΅ΠΌ Ρ‚ΠΈΠΏ ΠΊΠ½ΠΎΠΏΠΊΠΈ ΠΏΠΎ callback_data button_type = self._determine_button_type(callback_data) - + # ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅ΠΌ тСкст ΠΊΠ½ΠΎΠΏΠΊΠΈ, Ссли Π²ΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎ button_text = None if event.message and hasattr(event.message, 'reply_markup'): button_text = self._extract_button_text(event.message.reply_markup, callback_data) - + # Π›ΠΎΠ³ΠΈΡ€ΡƒΠ΅ΠΌ Π² Ρ„ΠΎΠ½Π΅, Π½Π΅ блокируя ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΡƒ # Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ asyncio.create_task для Ρ„ΠΎΠ½ΠΎΠ²ΠΎΠΉ Π·Π°Π΄Π°Ρ‡ΠΈ import asyncio @@ -62,7 +62,7 @@ class ButtonStatsMiddleware(BaseMiddleware): ) except Exception as e: # НС ΠΏΡ€Π΅Ρ€Ρ‹Π²Π°Π΅ΠΌ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΡƒ ΠΏΡ€ΠΈ ошибкС логирования - logger.debug(f"Ошибка логирования ΠΊΠ»ΠΈΠΊΠ° ΠΏΠΎ ΠΊΠ½ΠΎΠΏΠΊΠ΅: {e}") + logger.error(f"Ошибка логирования ΠΊΠ»ΠΈΠΊΠ° ΠΏΠΎ ΠΊΠ½ΠΎΠΏΠΊΠ΅: {e}", exc_info=True) # ΠŸΡ€ΠΎΠ΄ΠΎΠ»ΠΆΠ°Π΅ΠΌ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΡƒ return await handler(event, data) @@ -104,7 +104,7 @@ class ButtonStatsMiddleware(BaseMiddleware): async with AsyncSessionLocal() as db: try: from app.services.menu_layout_service import MenuLayoutService - + await MenuLayoutService.log_button_click( db, button_id=button_id, @@ -114,7 +114,7 @@ class ButtonStatsMiddleware(BaseMiddleware): button_text=button_text ) except Exception as e: - logger.error(f"Ошибка логирования ΠΊΠ»ΠΈΠΊΠ° ΠΏΠΎ ΠΊΠ½ΠΎΠΏΠΊΠ΅ {button_id}: {e}") + logger.debug(f"Ошибка записи ΠΊΠ»ΠΈΠΊΠ° Π² Π‘Π” {button_id}: {e}") except Exception as e: - logger.error(f"Ошибка создания сСссии Π‘Π” для логирования ΠΊΠ»ΠΈΠΊΠ°: {e}") + logger.debug(f"Ошибка создания сСссии Π‘Π” для логирования ΠΊΠ»ΠΈΠΊΠ°: {e}") diff --git a/app/services/menu_layout/service.py b/app/services/menu_layout/service.py index 7600ab2a..053dd5df 100644 --- a/app/services/menu_layout/service.py +++ b/app/services/menu_layout/service.py @@ -270,10 +270,44 @@ class MenuLayoutService: config = config.copy() buttons = config.get("buttons", {}) + # Π£Π»ΡƒΡ‡ΡˆΠ΅Π½Π½ΠΎΠ΅ ΠΎΠΏΡ€Π΅Π΄Π΅Π»Π΅Π½ΠΈΠ΅ ΠΊΠ½ΠΎΠΏΠΊΠΈ connect для Ρ€Π°Π·Π½Ρ‹Ρ… Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ΠΎΠ² ID + actual_button_id = button_id if button_id not in buttons: - raise KeyError(f"Button '{button_id}' not found") + # ΠŸΡ€ΠΎΠ±ΡƒΠ΅ΠΌ Π½Π°ΠΉΡ‚ΠΈ ΠΊΠ½ΠΎΠΏΠΊΡƒ connect ΠΏΠΎ Ρ€Π°Π·Π½Ρ‹ΠΌ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚Π°ΠΌ + if "connect" in button_id.lower(): + # ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅ΠΌ Ρ€Π°Π·Π½Ρ‹Π΅ Π²Π°Ρ€ΠΈΠ°Π½Ρ‚Ρ‹: connect, callback:connect ΠΈ Ρ‚.Π΄. + for key in buttons.keys(): + if key == "connect" or buttons[key].get("builtin_id") == "connect": + actual_button_id = key + logger.info( + f"πŸ”— НайдСна ΠΊΠ½ΠΎΠΏΠΊΠ° connect ΠΏΠΎ ID '{button_id}' -> '{actual_button_id}'" + ) + break + else: + # Если Π½Π΅ нашли, ΠΏΡ€ΠΎΠ±ΡƒΠ΅ΠΌ Π½Π°ΠΉΡ‚ΠΈ ΠΏΠΎ builtin_id + for key, button in buttons.items(): + if button.get("builtin_id") == "connect" or "connect" in str(button.get("builtin_id", "")).lower(): + actual_button_id = key + logger.info( + f"πŸ”— НайдСна ΠΊΠ½ΠΎΠΏΠΊΠ° connect ΠΏΠΎ builtin_id '{button_id}' -> '{actual_button_id}'" + ) + break + else: + raise KeyError(f"Button '{button_id}' not found") - button = buttons[button_id].copy() + if actual_button_id not in buttons: + raise KeyError(f"Button '{actual_button_id}' not found") + + button = buttons[actual_button_id].copy() + + # Π›ΠΎΠ³ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ для ΠΎΡ‚Π»Π°Π΄ΠΊΠΈ + if "connect" in actual_button_id.lower() or button.get("builtin_id") == "connect": + logger.info( + f"πŸ”— ОбновлСниС ΠΊΠ½ΠΎΠΏΠΊΠΈ connect (ID: {actual_button_id}): " + f"open_mode={updates.get('open_mode')}, " + f"action={updates.get('action')}, " + f"webapp_url={updates.get('webapp_url')}" + ) # ΠŸΡ€ΠΈΠΌΠ΅Π½ΡΠ΅ΠΌ обновлСния if "text" in updates and updates["text"] is not None: @@ -296,12 +330,16 @@ class MenuLayoutService: # Для URL/MiniApp/callback ΠΊΠ½ΠΎΠΏΠΎΠΊ ΠΌΠΎΠΆΠ½ΠΎ ΠΌΠ΅Π½ΡΡ‚ΡŒ action if button.get("type") in ("url", "mini_app", "callback"): button["action"] = updates["action"] + # Для builtin ΠΊΠ½ΠΎΠΏΠΎΠΊ ΠΌΠΎΠΆΠ½ΠΎ ΠΌΠ΅Π½ΡΡ‚ΡŒ action, Ссли open_mode == "direct" + # Π­Ρ‚ΠΎ позволяСт ΡƒΠΊΠ°Π·Π°Ρ‚ΡŒ URL Mini App Π² ΠΏΠΎΠ»Π΅ action для ΠΊΠ½ΠΎΠΏΠΊΠΈ connect + elif button.get("type") == "builtin" and updates.get("open_mode") == "direct": + button["action"] = updates["action"] if "open_mode" in updates and updates["open_mode"] is not None: button["open_mode"] = updates["open_mode"] if "webapp_url" in updates: button["webapp_url"] = updates["webapp_url"] - buttons[button_id] = button + buttons[actual_button_id] = button config["buttons"] = buttons await cls.save_config(db, config) @@ -905,11 +943,28 @@ class MenuLayoutService: ) -> Optional[InlineKeyboardButton]: """ΠŸΠΎΡΡ‚Ρ€ΠΎΠΈΡ‚ΡŒ ΠΊΠ½ΠΎΠΏΠΊΡƒ ΠΈΠ· ΠΊΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΠΈ.""" button_type = button_config.get("type", "builtin") + button_id = button_config.get("builtin_id") or button_config.get("id", "") text_config = button_config.get("text", {}) action = button_config.get("action", "") open_mode = button_config.get("open_mode", "callback") webapp_url = button_config.get("webapp_url") icon = button_config.get("icon", "") + + # Π›ΠΎΠ³ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ для ΠΎΡ‚Π»Π°Π΄ΠΊΠΈ ΠΊΠ½ΠΎΠΏΠΊΠΈ connect + is_connect_button = ( + button_id == "connect" or + "connect" in str(button_id).lower() or + action == "subscription_connect" or + "connect" in str(action).lower() + ) + + if is_connect_button: + logger.info( + f"πŸ”— ΠŸΠΎΡΡ‚Ρ€ΠΎΠ΅Π½ΠΈΠ΅ ΠΊΠ½ΠΎΠΏΠΊΠΈ connect: " + f"button_id={button_id}, type={button_type}, " + f"open_mode={open_mode}, action={action}, " + f"webapp_url={webapp_url}" + ) # ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅ΠΌ тСкст text = cls._get_localized_text(text_config, context.language) @@ -936,13 +991,51 @@ class MenuLayoutService: return InlineKeyboardButton(text=text, callback_data=action) else: # builtin - провСряСм open_mode - if open_mode == "direct" and webapp_url: + if open_mode == "direct": # ΠŸΡ€ΡΠΌΠΎΠ΅ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΠΈΠ΅ Mini App Ρ‡Π΅Ρ€Π΅Π· WebAppInfo - return InlineKeyboardButton( - text=text, web_app=types.WebAppInfo(url=webapp_url) - ) + # Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ webapp_url, Ссли ΡƒΠΊΠ°Π·Π°Π½, ΠΈΠ½Π°Ρ‡Π΅ action (Ссли это URL) + url = webapp_url or action + + # Для ΠΊΠ½ΠΎΠΏΠΊΠΈ connect: Ссли URL Π½Π΅ ΡƒΠΊΠ°Π·Π°Π½ ΠΈΠ»ΠΈ это callback_data, + # пытаСмся ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ URL ΠΈΠ· подписки ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ + if is_connect_button and (not url or not (url.startswith("http://") or url.startswith("https://"))): + if context.subscription: + from app.utils.subscription_utils import get_display_subscription_link + subscription_url = get_display_subscription_link(context.subscription) + if subscription_url: + url = subscription_url + logger.info( + f"πŸ”— Кнопка connect: ΠΏΠΎΠ»ΡƒΡ‡Π΅Π½ URL ΠΈΠ· подписки: {url[:50]}..." + ) + # Если всС Π΅Ρ‰Π΅ Π½Π΅Ρ‚ URL, ΠΏΡ€ΠΎΠ±ΡƒΠ΅ΠΌ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ настройку MINIAPP_CUSTOM_URL + if not url or not (url.startswith("http://") or url.startswith("https://")): + if settings.MINIAPP_CUSTOM_URL: + url = settings.MINIAPP_CUSTOM_URL + logger.info( + f"πŸ”— Кнопка connect: использован MINIAPP_CUSTOM_URL: {url[:50]}..." + ) + + # ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅ΠΌ, Ρ‡Ρ‚ΠΎ это Π΄Π΅ΠΉΡΡ‚Π²ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎ URL + if url and (url.startswith("http://") or url.startswith("https://")): + logger.info( + f"πŸ”— Кнопка connect: open_mode=direct, ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ URL: {url[:50]}..." + ) + return InlineKeyboardButton( + text=text, web_app=types.WebAppInfo(url=url) + ) + else: + logger.warning( + f"πŸ”— Кнопка connect: open_mode=direct, Π½ΠΎ URL Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½. " + f"webapp_url={webapp_url}, action={action}, " + f"subscription_url={'Π΅ΡΡ‚ΡŒ' if context.subscription else 'Π½Π΅Ρ‚'}" + ) + # Fallback Π½Π° callback_data + return InlineKeyboardButton(text=text, callback_data=action) else: # Π‘Ρ‚Π°Π½Π΄Π°Ρ€Ρ‚Π½Ρ‹ΠΉ callback_data + logger.debug( + f"Кнопка connect: open_mode={open_mode}, ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ callback_data: {action}" + ) return InlineKeyboardButton(text=text, callback_data=action) # --- ΠŸΠΎΡΡ‚Ρ€ΠΎΠ΅Π½ΠΈΠ΅ ΠΊΠ»Π°Π²ΠΈΠ°Ρ‚ΡƒΡ€Ρ‹ --- @@ -1165,3 +1258,66 @@ class MenuLayoutService: ) -> int: """ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ ΠΎΠ±Ρ‰Π΅Π΅ количСство ΠΊΠ»ΠΈΠΊΠΎΠ² Π·Π° ΠΏΠ΅Ρ€ΠΈΠΎΠ΄.""" return await MenuLayoutStatsService.get_total_clicks(db, days) + + @classmethod + async def get_stats_by_button_type( + cls, + db: AsyncSession, + days: int = 30, + ) -> List[Dict[str, Any]]: + """ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ статистику ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎ Ρ‚ΠΈΠΏΠ°ΠΌ ΠΊΠ½ΠΎΠΏΠΎΠΊ.""" + return await MenuLayoutStatsService.get_stats_by_button_type(db, days) + + @classmethod + async def get_clicks_by_hour( + cls, + db: AsyncSession, + button_id: Optional[str] = None, + days: int = 30, + ) -> List[Dict[str, Any]]: + """ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ статистику ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎ часам дня.""" + return await MenuLayoutStatsService.get_clicks_by_hour(db, button_id, days) + + @classmethod + async def get_clicks_by_weekday( + cls, + db: AsyncSession, + button_id: Optional[str] = None, + days: int = 30, + ) -> List[Dict[str, Any]]: + """ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ статистику ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎ дням Π½Π΅Π΄Π΅Π»ΠΈ.""" + return await MenuLayoutStatsService.get_clicks_by_weekday(db, button_id, days) + + @classmethod + async def get_top_users( + cls, + db: AsyncSession, + button_id: Optional[str] = None, + limit: int = 10, + days: int = 30, + ) -> List[Dict[str, Any]]: + """ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ Ρ‚ΠΎΠΏ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ ΠΏΠΎ количСству ΠΊΠ»ΠΈΠΊΠΎΠ².""" + return await MenuLayoutStatsService.get_top_users(db, button_id, limit, days) + + @classmethod + async def get_period_comparison( + cls, + db: AsyncSession, + button_id: Optional[str] = None, + current_days: int = 7, + previous_days: int = 7, + ) -> Dict[str, Any]: + """Π‘Ρ€Π°Π²Π½ΠΈΡ‚ΡŒ статистику Ρ‚Π΅ΠΊΡƒΡ‰Π΅Π³ΠΎ ΠΈ ΠΏΡ€Π΅Π΄Ρ‹Π΄ΡƒΡ‰Π΅Π³ΠΎ ΠΏΠ΅Ρ€ΠΈΠΎΠ΄Π°.""" + return await MenuLayoutStatsService.get_period_comparison( + db, button_id, current_days, previous_days + ) + + @classmethod + async def get_user_click_sequences( + cls, + db: AsyncSession, + user_id: int, + limit: int = 50, + ) -> List[Dict[str, Any]]: + """ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ ΠΏΠΎΡΠ»Π΅Π΄ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΠ½ΠΎΡΡ‚ΠΈ ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ.""" + return await MenuLayoutStatsService.get_click_sequences(db, user_id, limit) diff --git a/app/services/menu_layout/stats_service.py b/app/services/menu_layout/stats_service.py index dd0f0159..2b283a78 100644 --- a/app/services/menu_layout/stats_service.py +++ b/app/services/menu_layout/stats_service.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import datetime, timedelta from typing import Any, Dict, List, Optional -from sqlalchemy import select, func, and_, desc +from sqlalchemy import select, func, and_, desc, case from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import ButtonClickLog @@ -148,16 +148,34 @@ class MenuLayoutStatsService: days: int = 30, ) -> List[Dict[str, Any]]: """ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ статистику ΠΏΠΎ всСм ΠΊΠ½ΠΎΠΏΠΊΠ°ΠΌ.""" - start_date = datetime.now() - timedelta(days=days) + now = datetime.now() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=days) + # Для ΠΏΡ€ΠΎΠΈΠ·Π²ΠΎΠ΄ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎΡΡ‚ΠΈ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ ΠΎΠ΄ΠΈΠ½ запрос с подзапросами Ρ‡Π΅Ρ€Π΅Π· CASE result = await db.execute( select( ButtonClickLog.button_id, + # ΠžΠ±Ρ‰Π΅Π΅ количСство ΠΊΠ»ΠΈΠΊΠΎΠ² (всС ΠΊΠ»ΠΈΠΊΠΈ Π±Π΅Π· Ρ„ΠΈΠ»ΡŒΡ‚Ρ€Π° ΠΏΠΎ Π΄Π°Ρ‚Π°ΠΌ) func.count(ButtonClickLog.id).label("clicks_total"), + # Π£Π½ΠΈΠΊΠ°Π»ΡŒΠ½Ρ‹Π΅ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΠΈ (всС врСмя) func.count(func.distinct(ButtonClickLog.user_id)).label("unique_users"), - func.max(ButtonClickLog.clicked_at).label("last_click_at") + # ПослСдний ΠΊΠ»ΠΈΠΊ (всС врСмя) + func.max(ButtonClickLog.clicked_at).label("last_click_at"), + # ΠŸΠΎΠ΄ΡΡ‡Π΅Ρ‚ ΠΊΠ»ΠΈΠΊΠΎΠ² Π·Π° сСгодня + func.sum( + case((ButtonClickLog.clicked_at >= today_start, 1), else_=0) + ).label("clicks_today"), + # ΠŸΠΎΠ΄ΡΡ‡Π΅Ρ‚ ΠΊΠ»ΠΈΠΊΠΎΠ² Π·Π° нСдСлю + func.sum( + case((ButtonClickLog.clicked_at >= week_ago, 1), else_=0) + ).label("clicks_week"), + # ΠŸΠΎΠ΄ΡΡ‡Π΅Ρ‚ ΠΊΠ»ΠΈΠΊΠΎΠ² Π·Π° мСсяц + func.sum( + case((ButtonClickLog.clicked_at >= month_ago, 1), else_=0) + ).label("clicks_month"), ) - .where(ButtonClickLog.clicked_at >= start_date) .group_by(ButtonClickLog.button_id) .order_by(desc(func.count(ButtonClickLog.id))) ) @@ -166,6 +184,9 @@ class MenuLayoutStatsService: { "button_id": row.button_id, "clicks_total": row.clicks_total, + "clicks_today": row.clicks_today or 0, + "clicks_week": row.clicks_week or 0, + "clicks_month": row.clicks_month or 0, "unique_users": row.unique_users, "last_click_at": row.last_click_at, } @@ -186,3 +207,251 @@ class MenuLayoutStatsService: .where(ButtonClickLog.clicked_at >= start_date) ) return result.scalar() or 0 + + @classmethod + async def get_stats_by_button_type( + cls, + db: AsyncSession, + days: int = 30, + ) -> List[Dict[str, Any]]: + """ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ статистику ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎ Ρ‚ΠΈΠΏΠ°ΠΌ ΠΊΠ½ΠΎΠΏΠΎΠΊ.""" + start_date = datetime.now() - timedelta(days=days) + + result = await db.execute( + select( + ButtonClickLog.button_type, + func.count(ButtonClickLog.id).label("clicks_total"), + func.count(func.distinct(ButtonClickLog.user_id)).label("unique_users"), + ) + .where(and_( + ButtonClickLog.clicked_at >= start_date, + ButtonClickLog.button_type.isnot(None) + )) + .group_by(ButtonClickLog.button_type) + .order_by(desc(func.count(ButtonClickLog.id))) + ) + + return [ + { + "button_type": row.button_type or "unknown", + "clicks_total": row.clicks_total, + "unique_users": row.unique_users, + } + for row in result.all() + ] + + @classmethod + async def get_clicks_by_hour( + cls, + db: AsyncSession, + button_id: Optional[str] = None, + days: int = 30, + ) -> List[Dict[str, Any]]: + """ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ статистику ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎ часам дня.""" + start_date = datetime.now() - timedelta(days=days) + + query = select( + func.extract('hour', ButtonClickLog.clicked_at).label("hour"), + func.count(ButtonClickLog.id).label("count") + ).where(ButtonClickLog.clicked_at >= start_date) + + if button_id: + query = query.where(ButtonClickLog.button_id == button_id) + + result = await db.execute( + query + .group_by(func.extract('hour', ButtonClickLog.clicked_at)) + .order_by(func.extract('hour', ButtonClickLog.clicked_at)) + ) + + # Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ ΡΠ»ΠΎΠ²Π°Ρ€ΡŒ для быстрого доступа ΠΏΠΎ часу + 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]]: + """ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ статистику ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎ дням Π½Π΅Π΄Π΅Π»ΠΈ. + + PostgreSQL DOW Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ 0=Π²ΠΎΡΠΊΡ€Π΅ΡΠ΅Π½ΡŒΠ΅, 1=понСдСльник, ..., 6=суббота + ΠŸΡ€Π΅ΠΎΠ±Ρ€Π°Π·ΡƒΠ΅ΠΌ Π² 0=понСдСльник, 6=Π²ΠΎΡΠΊΡ€Π΅ΡΠ΅Π½ΡŒΠ΅ для удобства. + """ + start_date = datetime.now() - timedelta(days=days) + + # Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ CASE для прСобразования: 0 (Π²ΠΎΡΠΊΡ€Π΅ΡΠ΅Π½ΡŒΠ΅) -> 6, ΠΎΡΡ‚Π°Π»ΡŒΠ½Ρ‹Π΅ -1 + weekday_expr = case( + (func.extract('dow', ButtonClickLog.clicked_at) == 0, 6), + else_=func.extract('dow', ButtonClickLog.clicked_at) - 1 + ).label("weekday") + + query = select( + weekday_expr, + func.count(ButtonClickLog.id).label("count") + ).where(ButtonClickLog.clicked_at >= start_date) + + if button_id: + query = query.where(ButtonClickLog.button_id == button_id) + + result = await db.execute( + query + .group_by(weekday_expr) + .order_by(weekday_expr) + ) + + weekday_names = ["ПонСдСльник", "Π’Ρ‚ΠΎΡ€Π½ΠΈΠΊ", "Π‘Ρ€Π΅Π΄Π°", "Π§Π΅Ρ‚Π²Π΅Ρ€Π³", "ΠŸΡΡ‚Π½ΠΈΡ†Π°", "Π‘ΡƒΠ±Π±ΠΎΡ‚Π°", "Π’ΠΎΡΠΊΡ€Π΅ΡΠ΅Π½ΡŒΠ΅"] + + # Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ ΡΠ»ΠΎΠ²Π°Ρ€ΡŒ для быстрого доступа ΠΏΠΎ 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 = datetime.now() - timedelta(days=days) + + query = select( + ButtonClickLog.user_id, + func.count(ButtonClickLog.id).label("clicks_count"), + func.max(ButtonClickLog.clicked_at).label("last_click_at") + ).where(and_( + ButtonClickLog.clicked_at >= start_date, + ButtonClickLog.user_id.isnot(None) + )) + + if button_id: + query = query.where(ButtonClickLog.button_id == button_id) + + result = await db.execute( + query + .group_by(ButtonClickLog.user_id) + .order_by(desc(func.count(ButtonClickLog.id))) + .limit(limit) + ) + + return [ + { + "user_id": row.user_id, + "clicks_count": row.clicks_count, + "last_click_at": row.last_click_at, + } + for row in result.all() + ] + + @classmethod + async def get_period_comparison( + cls, + db: AsyncSession, + button_id: Optional[str] = None, + current_days: int = 7, + previous_days: int = 7, + ) -> Dict[str, Any]: + """Π‘Ρ€Π°Π²Π½ΠΈΡ‚ΡŒ статистику Ρ‚Π΅ΠΊΡƒΡ‰Π΅Π³ΠΎ ΠΈ ΠΏΡ€Π΅Π΄Ρ‹Π΄ΡƒΡ‰Π΅Π³ΠΎ ΠΏΠ΅Ρ€ΠΈΠΎΠ΄Π°.""" + now = datetime.now() + current_start = now - timedelta(days=current_days) + previous_start = current_start - timedelta(days=previous_days) + previous_end = current_start + + query_current = select(func.count(ButtonClickLog.id)) + query_previous = select(func.count(ButtonClickLog.id)) + + if button_id: + query_current = query_current.where(ButtonClickLog.button_id == button_id) + query_previous = query_previous.where(ButtonClickLog.button_id == button_id) + + query_current = query_current.where( + ButtonClickLog.clicked_at >= current_start + ) + query_previous = query_previous.where( + and_( + ButtonClickLog.clicked_at >= previous_start, + ButtonClickLog.clicked_at < previous_end + ) + ) + + current_result = await db.execute(query_current) + previous_result = await db.execute(query_previous) + + current_count = current_result.scalar() or 0 + previous_count = previous_result.scalar() or 0 + + change_percent = 0 + if previous_count > 0: + change_percent = ((current_count - previous_count) / previous_count) * 100 + + return { + "current_period": { + "clicks": current_count, + "days": current_days, + "start": current_start, + "end": now, + }, + "previous_period": { + "clicks": previous_count, + "days": previous_days, + "start": previous_start, + "end": previous_end, + }, + "change": { + "absolute": current_count - previous_count, + "percent": round(change_percent, 2), + "trend": "up" if change_percent > 0 else "down" if change_percent < 0 else "stable", + }, + } + + @classmethod + async def get_click_sequences( + cls, + db: AsyncSession, + user_id: int, + limit: int = 50, + ) -> List[Dict[str, Any]]: + """ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ ΠΏΠΎΡΠ»Π΅Π΄ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΠ½ΠΎΡΡ‚ΠΈ ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ.""" + result = await db.execute( + select( + ButtonClickLog.button_id, + ButtonClickLog.button_text, + ButtonClickLog.clicked_at, + ) + .where(ButtonClickLog.user_id == user_id) + .order_by(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() + ] \ No newline at end of file diff --git a/app/services/partner_stats_service.py b/app/services/partner_stats_service.py new file mode 100644 index 00000000..9948568c --- /dev/null +++ b/app/services/partner_stats_service.py @@ -0,0 +1,592 @@ +"""БСрвис Ρ€Π°ΡΡˆΠΈΡ€Π΅Π½Π½ΠΎΠΉ статистики ΠΏΠ°Ρ€Ρ‚Π½Ρ‘Ρ€ΠΎΠ² (Ρ€Π΅Ρ„Π΅Ρ€Π΅Ρ€ΠΎΠ²).""" + +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 + + # Π—Π°Ρ€Π°Π±ΠΎΡ‚ΠΊΠΈ ΠΏΠΎ ΠΏΠ΅Ρ€ΠΈΠΎΠ΄Π°ΠΌ + earnings_all_time = await cls._get_earnings_for_period(db, user_id, None) + earnings_today = await cls._get_earnings_for_period(db, user_id, today_start) + earnings_week = await cls._get_earnings_for_period(db, user_id, week_ago) + earnings_month = await cls._get_earnings_for_period(db, user_id, month_ago) + earnings_year = await cls._get_earnings_for_period(db, user_id, year_ago) + + # Π Π΅Ρ„Π΅Ρ€Π°Π»Ρ‹ ΠΏΠΎ ΠΏΠ΅Ρ€ΠΈΠΎΠ΄Π°ΠΌ + referrals_today = sum(1 for r in referrals if r.created_at >= today_start) + referrals_week = sum(1 for r in referrals if r.created_at >= week_ago) + referrals_month = sum(1 for r in referrals if r.created_at >= month_ago) + referrals_year = sum(1 for r in referrals if r.created_at >= year_ago) + + # ΠšΠΎΠ½Π²Π΅Ρ€ΡΠΈΠΈ + conversion_to_paid = round((paid_referrals / total_referrals * 100), 2) if total_referrals > 0 else 0 + conversion_to_active = round((active_referrals / total_referrals * 100), 2) if total_referrals > 0 else 0 + + # Π‘Ρ€Π΅Π΄Π½ΠΈΠΉ Π΄ΠΎΡ…ΠΎΠ΄ с Ρ€Π΅Ρ„Π΅Ρ€Π°Π»Π° + avg_earnings_per_referral = round(earnings_all_time / paid_referrals, 2) if paid_referrals > 0 else 0 + + return { + "user_id": user_id, + "summary": { + "total_referrals": total_referrals, + "paid_referrals": paid_referrals, + "active_referrals": active_referrals, + "conversion_to_paid_percent": conversion_to_paid, + "conversion_to_active_percent": conversion_to_active, + "avg_earnings_per_referral_kopeks": avg_earnings_per_referral, + }, + "earnings": { + "all_time_kopeks": earnings_all_time, + "year_kopeks": earnings_year, + "month_kopeks": earnings_month, + "week_kopeks": earnings_week, + "today_kopeks": earnings_today, + }, + "referrals_count": { + "all_time": total_referrals, + "year": referrals_year, + "month": referrals_month, + "week": referrals_week, + "today": referrals_today, + }, + } + + @classmethod + async def get_referrer_daily_stats( + cls, + db: AsyncSession, + user_id: int, + days: int = 30, + ) -> List[Dict[str, Any]]: + """ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ статистику Ρ€Π΅Ρ„Π΅Ρ€Π΅Ρ€Π° ΠΏΠΎ дням.""" + now = datetime.utcnow() + start_date = now - timedelta(days=days) + + # Π Π΅Ρ„Π΅Ρ€Π°Π»Ρ‹ ΠΏΠΎ дням + referrals_by_day = await db.execute( + select( + func.date(User.created_at).label("date"), + func.count(User.id).label("referrals_count"), + ) + .where( + and_( + User.referred_by_id == user_id, + User.created_at >= start_date, + ) + ) + .group_by(func.date(User.created_at)) + .order_by(func.date(User.created_at)) + ) + referrals_dict = {str(row.date): row.referrals_count for row in referrals_by_day.all()} + + # Π—Π°Ρ€Π°Π±ΠΎΡ‚ΠΊΠΈ ΠΏΠΎ дням (ΠΈΠ· ReferralEarning) + earnings_by_day = await db.execute( + select( + func.date(ReferralEarning.created_at).label("date"), + func.sum(ReferralEarning.amount_kopeks).label("earnings"), + ) + .where( + and_( + ReferralEarning.user_id == user_id, + ReferralEarning.created_at >= start_date, + ) + ) + .group_by(func.date(ReferralEarning.created_at)) + ) + earnings_dict = {str(row.date): int(row.earnings or 0) for row in earnings_by_day.all()} + + # Π€ΠΎΡ€ΠΌΠΈΡ€ΡƒΠ΅ΠΌ массив Π·Π° всС Π΄Π½ΠΈ + result = [] + for i in range(days): + date = (start_date + timedelta(days=i)).date() + date_str = str(date) + result.append({ + "date": date_str, + "referrals_count": referrals_dict.get(date_str, 0), + "earnings_kopeks": earnings_dict.get(date_str, 0), + }) + + return result + + @classmethod + async def get_referrer_top_referrals( + cls, + db: AsyncSession, + user_id: int, + limit: int = 10, + ) -> List[Dict[str, Any]]: + """ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ Ρ‚ΠΎΠΏ Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΠΎΠ² ΠΏΠΎ Π΄ΠΎΡ…ΠΎΠ΄Ρƒ для Ρ€Π΅Ρ„Π΅Ρ€Π΅Ρ€Π°.""" + # ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅ΠΌ Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΠΎΠ² с ΠΈΡ… Π΄ΠΎΡ…ΠΎΠ΄Π°ΠΌΠΈ + result = await db.execute( + select( + User.id, + User.telegram_id, + User.username, + User.first_name, + User.last_name, + User.created_at, + User.has_made_first_topup, + func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label("total_earnings"), + ) + .outerjoin(ReferralEarning, ReferralEarning.referral_id == User.id) + .where(User.referred_by_id == user_id) + .group_by(User.id) + .order_by(desc(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))) + .limit(limit) + ) + + referrals = [] + now = datetime.utcnow() + for row in result.all(): + # ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅ΠΌ Π°ΠΊΡ‚ΠΈΠ²Π½ΠΎΡΡ‚ΡŒ подписки + sub_result = await db.execute( + select(Subscription) + .where( + and_( + Subscription.user_id == row.id, + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.end_date > now, + ) + ) + .limit(1) + ) + is_active = sub_result.scalar_one_or_none() is not None + + referrals.append({ + "id": row.id, + "telegram_id": row.telegram_id, + "username": row.username, + "first_name": row.first_name, + "last_name": row.last_name, + "full_name": f"{row.first_name or ''} {row.last_name or ''}".strip() or f"User {row.telegram_id}", + "created_at": row.created_at, + "has_made_first_topup": row.has_made_first_topup, + "is_active": is_active, + "total_earnings_kopeks": int(row.total_earnings), + }) + + return referrals + + @classmethod + async def get_referrer_period_comparison( + cls, + db: AsyncSession, + user_id: int, + current_days: int = 7, + previous_days: int = 7, + ) -> Dict[str, Any]: + """Π‘Ρ€Π°Π²Π½ΠΈΡ‚ΡŒ Ρ‚Π΅ΠΊΡƒΡ‰ΠΈΠΉ ΠΈ ΠΏΡ€Π΅Π΄Ρ‹Π΄ΡƒΡ‰ΠΈΠΉ ΠΏΠ΅Ρ€ΠΈΠΎΠ΄.""" + now = datetime.utcnow() + current_start = now - timedelta(days=current_days) + previous_start = current_start - timedelta(days=previous_days) + previous_end = current_start + + # Π Π΅Ρ„Π΅Ρ€Π°Π»Ρ‹ Π·Π° Ρ‚Π΅ΠΊΡƒΡ‰ΠΈΠΉ ΠΏΠ΅Ρ€ΠΈΠΎΠ΄ + current_referrals = await db.execute( + select(func.count(User.id)) + .where( + and_( + User.referred_by_id == user_id, + User.created_at >= current_start, + ) + ) + ) + current_referrals_count = current_referrals.scalar() or 0 + + # Π Π΅Ρ„Π΅Ρ€Π°Π»Ρ‹ Π·Π° ΠΏΡ€Π΅Π΄Ρ‹Π΄ΡƒΡ‰ΠΈΠΉ ΠΏΠ΅Ρ€ΠΈΠΎΠ΄ + previous_referrals = await db.execute( + select(func.count(User.id)) + .where( + and_( + User.referred_by_id == user_id, + User.created_at >= previous_start, + User.created_at < previous_end, + ) + ) + ) + previous_referrals_count = previous_referrals.scalar() or 0 + + # Π—Π°Ρ€Π°Π±ΠΎΡ‚ΠΊΠΈ Π·Π° Ρ‚Π΅ΠΊΡƒΡ‰ΠΈΠΉ ΠΏΠ΅Ρ€ΠΈΠΎΠ΄ + current_earnings = await cls._get_earnings_for_period(db, user_id, current_start) + + # Π—Π°Ρ€Π°Π±ΠΎΡ‚ΠΊΠΈ Π·Π° ΠΏΡ€Π΅Π΄Ρ‹Π΄ΡƒΡ‰ΠΈΠΉ ΠΏΠ΅Ρ€ΠΈΠΎΠ΄ + previous_earnings = await cls._get_earnings_for_period( + db, user_id, previous_start, previous_end + ) + + # Расчёт ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ + referrals_change = current_referrals_count - previous_referrals_count + referrals_change_percent = ( + round((referrals_change / previous_referrals_count * 100), 2) + if previous_referrals_count > 0 + else 0 + ) + + earnings_change = current_earnings - previous_earnings + earnings_change_percent = ( + round((earnings_change / previous_earnings * 100), 2) + if previous_earnings > 0 + else 0 + ) + + return { + "current_period": { + "days": current_days, + "start": current_start.isoformat(), + "end": now.isoformat(), + "referrals_count": current_referrals_count, + "earnings_kopeks": current_earnings, + }, + "previous_period": { + "days": previous_days, + "start": previous_start.isoformat(), + "end": previous_end.isoformat(), + "referrals_count": previous_referrals_count, + "earnings_kopeks": previous_earnings, + }, + "change": { + "referrals": { + "absolute": referrals_change, + "percent": referrals_change_percent, + "trend": "up" if referrals_change > 0 else "down" if referrals_change < 0 else "stable", + }, + "earnings": { + "absolute": earnings_change, + "percent": earnings_change_percent, + "trend": "up" if earnings_change > 0 else "down" if earnings_change < 0 else "stable", + }, + }, + } + + @classmethod + async def get_global_partner_stats( + cls, + db: AsyncSession, + days: int = 30, + ) -> Dict[str, Any]: + """Π“Π»ΠΎΠ±Π°Π»ΡŒΠ½Π°Ρ статистика партнёрской ΠΏΡ€ΠΎΠ³Ρ€Π°ΠΌΠΌΡ‹.""" + now = datetime.utcnow() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=30) + year_ago = now - timedelta(days=365) + start_date = now - timedelta(days=days) + + # ВсСго Ρ€Π΅Ρ„Π΅Ρ€Π΅Ρ€ΠΎΠ² (Ρƒ ΠΊΠΎΠ³ΠΎ Π΅ΡΡ‚ΡŒ Ρ€Π΅Ρ„Π΅Ρ€Π°Π»Ρ‹) + total_referrers = await db.execute( + select(func.count(func.distinct(User.referred_by_id))) + .where(User.referred_by_id.isnot(None)) + ) + total_referrers_count = total_referrers.scalar() or 0 + + # ВсСго Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΠΎΠ² + total_referrals = await db.execute( + select(func.count(User.id)) + .where(User.referred_by_id.isnot(None)) + ) + total_referrals_count = total_referrals.scalar() or 0 + + # Π Π΅Ρ„Π΅Ρ€Π°Π»Ρ‹ ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Π΅ Π·Π°ΠΏΠ»Π°Ρ‚ΠΈΠ»ΠΈ + paid_referrals = await db.execute( + select(func.count(User.id)) + .where( + and_( + User.referred_by_id.isnot(None), + User.has_made_first_topup == True, + ) + ) + ) + paid_referrals_count = paid_referrals.scalar() or 0 + + # ВсСго Π²Ρ‹ΠΏΠ»Π°Ρ‡Π΅Π½ΠΎ + total_paid = await cls._get_total_earnings(db, None) + today_paid = await cls._get_total_earnings(db, today_start) + week_paid = await cls._get_total_earnings(db, week_ago) + month_paid = await cls._get_total_earnings(db, month_ago) + year_paid = await cls._get_total_earnings(db, year_ago) + + # НовыС Ρ€Π΅Ρ„Π΅Ρ€Π°Π»Ρ‹ ΠΏΠΎ ΠΏΠ΅Ρ€ΠΈΠΎΠ΄Π°ΠΌ + new_referrals_today = await db.execute( + select(func.count(User.id)) + .where( + and_( + User.referred_by_id.isnot(None), + User.created_at >= today_start, + ) + ) + ) + new_referrals_week = await db.execute( + select(func.count(User.id)) + .where( + and_( + User.referred_by_id.isnot(None), + User.created_at >= week_ago, + ) + ) + ) + new_referrals_month = await db.execute( + select(func.count(User.id)) + .where( + and_( + User.referred_by_id.isnot(None), + User.created_at >= month_ago, + ) + ) + ) + + # ΠšΠΎΠ½Π²Π΅Ρ€ΡΠΈΡ + conversion_rate = ( + round((paid_referrals_count / total_referrals_count * 100), 2) + if total_referrals_count > 0 + else 0 + ) + + # Π‘Ρ€Π΅Π΄Π½ΠΈΠΉ Π΄ΠΎΡ…ΠΎΠ΄ с Ρ€Π΅Ρ„Π΅Ρ€Π°Π»Π° + avg_per_referral = ( + round(total_paid / paid_referrals_count, 2) + if paid_referrals_count > 0 + else 0 + ) + + return { + "summary": { + "total_referrers": total_referrers_count, + "total_referrals": total_referrals_count, + "paid_referrals": paid_referrals_count, + "conversion_rate_percent": conversion_rate, + "avg_earnings_per_referral_kopeks": avg_per_referral, + }, + "payouts": { + "all_time_kopeks": total_paid, + "year_kopeks": year_paid, + "month_kopeks": month_paid, + "week_kopeks": week_paid, + "today_kopeks": today_paid, + }, + "new_referrals": { + "today": new_referrals_today.scalar() or 0, + "week": new_referrals_week.scalar() or 0, + "month": new_referrals_month.scalar() or 0, + }, + } + + @classmethod + async def get_global_daily_stats( + cls, + db: AsyncSession, + days: int = 30, + ) -> List[Dict[str, Any]]: + """Π“Π»ΠΎΠ±Π°Π»ΡŒΠ½Π°Ρ статистика ΠΏΠΎ дням.""" + now = datetime.utcnow() + start_date = now - timedelta(days=days) + + # Π Π΅Ρ„Π΅Ρ€Π°Π»Ρ‹ ΠΏΠΎ дням + referrals_by_day = await db.execute( + select( + func.date(User.created_at).label("date"), + func.count(User.id).label("referrals_count"), + ) + .where( + and_( + User.referred_by_id.isnot(None), + User.created_at >= start_date, + ) + ) + .group_by(func.date(User.created_at)) + ) + referrals_dict = {str(row.date): row.referrals_count for row in referrals_by_day.all()} + + # Π’Ρ‹ΠΏΠ»Π°Ρ‚Ρ‹ ΠΏΠΎ дням + earnings_by_day = await db.execute( + select( + func.date(ReferralEarning.created_at).label("date"), + func.sum(ReferralEarning.amount_kopeks).label("earnings"), + ) + .where(ReferralEarning.created_at >= start_date) + .group_by(func.date(ReferralEarning.created_at)) + ) + earnings_dict = {str(row.date): int(row.earnings or 0) for row in earnings_by_day.all()} + + result = [] + for i in range(days): + date = (start_date + timedelta(days=i)).date() + date_str = str(date) + result.append({ + "date": date_str, + "referrals_count": referrals_dict.get(date_str, 0), + "earnings_kopeks": earnings_dict.get(date_str, 0), + }) + + return result + + @classmethod + async def get_top_referrers( + cls, + db: AsyncSession, + limit: int = 10, + days: Optional[int] = None, + ) -> List[Dict[str, Any]]: + """ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ Ρ‚ΠΎΠΏ Ρ€Π΅Ρ„Π΅Ρ€Π΅Ρ€ΠΎΠ².""" + now = datetime.utcnow() + start_date = now - timedelta(days=days) if days else None + + # ΠŸΠΎΠ΄ΡΡ‡Ρ‘Ρ‚ Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΠΎΠ² ΠΈ Π·Π°Ρ€Π°Π±ΠΎΡ‚ΠΊΠΎΠ² + earnings_query = ( + select( + ReferralEarning.user_id, + func.sum(ReferralEarning.amount_kopeks).label("total_earnings"), + ) + .group_by(ReferralEarning.user_id) + ) + if start_date: + earnings_query = earnings_query.where(ReferralEarning.created_at >= start_date) + + earnings_result = await db.execute(earnings_query) + earnings_dict = {row.user_id: int(row.total_earnings or 0) for row in earnings_result.all()} + + # ΠŸΠΎΠ΄ΡΡ‡Ρ‘Ρ‚ Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΠΎΠ² + referrals_query = ( + select( + User.referred_by_id, + func.count(User.id).label("referrals_count"), + ) + .where(User.referred_by_id.isnot(None)) + .group_by(User.referred_by_id) + ) + if start_date: + referrals_query = referrals_query.where(User.created_at >= start_date) + + referrals_result = await db.execute(referrals_query) + referrals_dict = {row.referred_by_id: row.referrals_count for row in referrals_result.all()} + + # ОбъСдиняСм Π΄Π°Π½Π½Ρ‹Π΅ + all_referrer_ids = set(earnings_dict.keys()) | set(referrals_dict.keys()) + referrers_data = [] + + for referrer_id in all_referrer_ids: + referrers_data.append({ + "user_id": referrer_id, + "referrals_count": referrals_dict.get(referrer_id, 0), + "total_earnings": earnings_dict.get(referrer_id, 0), + }) + + # Π‘ΠΎΡ€Ρ‚ΠΈΡ€ΡƒΠ΅ΠΌ ΠΏΠΎ Π·Π°Ρ€Π°Π±ΠΎΡ‚ΠΊΡƒ + referrers_data.sort(key=lambda x: x["total_earnings"], reverse=True) + top_referrers = referrers_data[:limit] + + # ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅ΠΌ Π΄Π°Π½Π½Ρ‹Π΅ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ + result = [] + for data in top_referrers: + user_result = await db.execute( + select(User).where(User.id == data["user_id"]) + ) + user = user_result.scalar_one_or_none() + + if user: + result.append({ + "id": user.id, + "telegram_id": user.telegram_id, + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "full_name": f"{user.first_name or ''} {user.last_name or ''}".strip() or f"User {user.telegram_id}", + "referral_code": user.referral_code, + "referrals_count": data["referrals_count"], + "total_earnings_kopeks": data["total_earnings"], + }) + + return result + + @classmethod + async def _get_earnings_for_period( + cls, + db: AsyncSession, + user_id: int, + start_date: Optional[datetime], + end_date: Optional[datetime] = None, + ) -> int: + """ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ Π·Π°Ρ€Π°Π±ΠΎΡ‚ΠΊΠΈ Π·Π° ΠΏΠ΅Ρ€ΠΈΠΎΠ΄.""" + query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where( + ReferralEarning.user_id == user_id + ) + + if start_date: + query = query.where(ReferralEarning.created_at >= start_date) + if end_date: + query = query.where(ReferralEarning.created_at < end_date) + + result = await db.execute(query) + return int(result.scalar() or 0) + + @classmethod + async def _get_total_earnings( + cls, + db: AsyncSession, + start_date: Optional[datetime], + ) -> int: + """ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ ΠΎΠ±Ρ‰ΠΈΠ΅ Π²Ρ‹ΠΏΠ»Π°Ρ‚Ρ‹ Π·Π° ΠΏΠ΅Ρ€ΠΈΠΎΠ΄.""" + query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) + + if start_date: + query = query.where(ReferralEarning.created_at >= start_date) + + result = await db.execute(query) + return int(result.scalar() or 0) diff --git a/app/webapi/routes/menu_layout.py b/app/webapi/routes/menu_layout.py index fb8d6513..da8fde1d 100644 --- a/app/webapi/routes/menu_layout.py +++ b/app/webapi/routes/menu_layout.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any, List, Optional from fastapi import APIRouter, Depends, HTTPException, Response, Security, status @@ -13,6 +14,8 @@ from app.services.menu_layout_service import ( MenuLayoutService, ) +logger = logging.getLogger(__name__) + from ..dependencies import get_db_session, require_api_token from ..schemas.menu_layout import ( AddCustomButtonRequest, @@ -24,9 +27,13 @@ from ..schemas.menu_layout import ( ButtonClickStats, ButtonClickStatsResponse, ButtonConditions, + ButtonTypeStats, + ButtonTypeStatsResponse, ButtonUpdateRequest, DynamicPlaceholder, DynamicPlaceholdersResponse, + HourlyStats, + HourlyStatsResponse, MenuButtonConfig, MenuClickStatsResponse, MenuLayoutConfig, @@ -47,7 +54,14 @@ from ..schemas.menu_layout import ( MenuRowConfig, MoveButtonResponse, MoveButtonToRowRequest, + PeriodComparisonResponse, ReorderButtonsInRowRequest, + TopUserStats, + TopUsersResponse, + UserClickSequence, + UserClickSequencesResponse, + WeekdayStats, + WeekdayStatsResponse, ReorderButtonsResponse, RowsReorderRequest, SwapButtonsRequest, @@ -128,9 +142,15 @@ async def update_menu_layout( config["rows"] = [row.model_dump() for row in payload.rows] if payload.buttons is not None: - config["buttons"] = { - btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items() - } + buttons_config = {} + for btn_id, btn in payload.buttons.items(): + btn_dict = btn.model_dump() + # АвтоматичСски опрСдСляСм Π½Π°Π»ΠΈΡ‡ΠΈΠ΅ плСйсхолдСров, Ссли dynamic_text Π½Π΅ установлСн + if not btn_dict.get("dynamic_text", False): + from app.services.menu_layout.service import MenuLayoutService + btn_dict["dynamic_text"] = MenuLayoutService._text_has_placeholders(btn_dict.get("text", {})) + buttons_config[btn_id] = btn_dict + config["buttons"] = buttons_config await MenuLayoutService.save_config(db, config) updated_at = await MenuLayoutService.get_config_updated_at(db) @@ -296,6 +316,12 @@ async def add_custom_button( ) -> MenuButtonConfig: """Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΊΠ°ΡΡ‚ΠΎΠΌΠ½ΡƒΡŽ ΠΊΠ½ΠΎΠΏΠΊΡƒ (URL, MiniApp ΠΈΠ»ΠΈ callback).""" try: + # АвтоматичСски опрСдСляСм Π½Π°Π»ΠΈΡ‡ΠΈΠ΅ плСйсхолдСров, Ссли dynamic_text Π½Π΅ установлСн + dynamic_text = payload.dynamic_text + if not dynamic_text: + from app.services.menu_layout.service import MenuLayoutService + dynamic_text = MenuLayoutService._text_has_placeholders(payload.text) + button_config = { "type": payload.type.value, "text": payload.text, @@ -305,7 +331,7 @@ async def add_custom_button( "conditions": payload.conditions.model_dump(exclude_none=True) if payload.conditions else None, - "dynamic_text": payload.dynamic_text, + "dynamic_text": dynamic_text, "description": payload.description, } button = await MenuLayoutService.add_custom_button( @@ -748,6 +774,9 @@ async def get_menu_click_stats( ButtonClickStats( button_id=s["button_id"], clicks_total=s["clicks_total"], + clicks_today=s.get("clicks_today", 0), + clicks_week=s.get("clicks_week", 0), + clicks_month=s.get("clicks_month", 0), unique_users=s["unique_users"], last_click_at=s["last_click_at"], ) @@ -805,3 +834,158 @@ async def log_button_click( button_text=button_text, ) return {"success": True} + + +@router.get("/stats/by-type", response_model=ButtonTypeStatsResponse) +async def get_stats_by_button_type( + days: int = 30, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> ButtonTypeStatsResponse: + """ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ статистику ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎ Ρ‚ΠΈΠΏΠ°ΠΌ ΠΊΠ½ΠΎΠΏΠΎΠΊ (builtin, callback, url, mini_app).""" + 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)}") \ No newline at end of file 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/menu_layout.py b/app/webapi/schemas/menu_layout.py index 86e3823b..e8c89c3c 100644 --- a/app/webapi/schemas/menu_layout.py +++ b/app/webapi/schemas/menu_layout.py @@ -587,6 +587,91 @@ class MenuClickStatsResponse(BaseModel): period_end: datetime +class ButtonTypeStats(BaseModel): + """Бтатистика ΠΏΠΎ Ρ‚ΠΈΠΏΡƒ ΠΊΠ½ΠΎΠΏΠΊΠΈ.""" + + button_type: str + clicks_total: int + unique_users: int + + +class ButtonTypeStatsResponse(BaseModel): + """Бтатистика ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎ Ρ‚ΠΈΠΏΠ°ΠΌ ΠΊΠ½ΠΎΠΏΠΎΠΊ.""" + + items: List[ButtonTypeStats] + total_clicks: int + + +class HourlyStats(BaseModel): + """Бтатистика ΠΏΠΎ часам.""" + + hour: int + count: int + + +class HourlyStatsResponse(BaseModel): + """Бтатистика ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎ часам дня.""" + + items: List[HourlyStats] + button_id: Optional[str] = None + + +class WeekdayStats(BaseModel): + """Бтатистика ΠΏΠΎ дням Π½Π΅Π΄Π΅Π»ΠΈ.""" + + weekday: int + weekday_name: str + count: int + + +class WeekdayStatsResponse(BaseModel): + """Бтатистика ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎ дням Π½Π΅Π΄Π΅Π»ΠΈ.""" + + items: List[WeekdayStats] + button_id: Optional[str] = None + + +class TopUserStats(BaseModel): + """Бтатистика ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ.""" + + user_id: int + clicks_count: int + last_click_at: Optional[datetime] = None + + +class TopUsersResponse(BaseModel): + """Π’ΠΎΠΏ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ ΠΏΠΎ ΠΊΠ»ΠΈΠΊΠ°ΠΌ.""" + + items: List[TopUserStats] + button_id: Optional[str] = None + limit: int + + +class PeriodComparisonResponse(BaseModel): + """Π‘Ρ€Π°Π²Π½Π΅Π½ΠΈΠ΅ ΠΏΠ΅Ρ€ΠΈΠΎΠ΄ΠΎΠ².""" + + current_period: Dict[str, Any] + previous_period: Dict[str, Any] + change: Dict[str, Any] + button_id: Optional[str] = None + + +class UserClickSequence(BaseModel): + """ΠŸΠΎΡΠ»Π΅Π΄ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΠ½ΠΎΡΡ‚ΡŒ ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ.""" + + button_id: str + button_text: Optional[str] = None + clicked_at: datetime + + +class UserClickSequencesResponse(BaseModel): + """ΠŸΠΎΡΠ»Π΅Π΄ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΠ½ΠΎΡΡ‚ΠΈ ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ.""" + + user_id: int + items: List[UserClickSequence] + total: int + + # --- Π‘Ρ…Π΅ΠΌΡ‹ для плСйсхолдСров --- diff --git a/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/docs/menu_stats_api_usage.md b/docs/menu_stats_api_usage.md index 022f5fec..cc5125c6 100644 --- a/docs/menu_stats_api_usage.md +++ b/docs/menu_stats_api_usage.md @@ -118,6 +118,136 @@ POST /menu-layout/stats/log-click } ``` +### 4. Бтатистика ΠΏΠΎ Ρ‚ΠΈΠΏΠ°ΠΌ ΠΊΠ½ΠΎΠΏΠΎΠΊ + +**GET** `/menu-layout/stats/by-type?days=30` + +**Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚:** +- Бтатистику ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎ ΠΊΠ°ΠΆΠ΄ΠΎΠΌΡƒ Ρ‚ΠΈΠΏΡƒ ΠΊΠ½ΠΎΠΏΠΎΠΊ (builtin, callback, url, mini_app) +- ΠžΠ±Ρ‰Π΅Π΅ количСство ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎ Ρ‚ΠΈΠΏΠ°ΠΌ + +**ΠŸΡ€ΠΈΠΌΠ΅Ρ€:** +```python +stats = await MenuLayoutService.get_stats_by_button_type(db, days=30) +# Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚: +# [ +# {"button_type": "builtin", "clicks_total": 500, "unique_users": 100}, +# {"button_type": "callback", "clicks_total": 200, "unique_users": 50}, +# ... +# ] +``` + +### 5. Бтатистика ΠΏΠΎ часам дня + +**GET** `/menu-layout/stats/by-hour?button_id=menu_balance&days=30` + +**ΠŸΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€Ρ‹:** +- `button_id` (optional) - ID ΠΊΠ½ΠΎΠΏΠΊΠΈ для Ρ„ΠΈΠ»ΡŒΡ‚Ρ€Π°Ρ†ΠΈΠΈ +- `days` (default: 30) - ΠΏΠ΅Ρ€ΠΈΠΎΠ΄ Π² днях + +**Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚:** +- РаспрСдСлСниС ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎ часам дня (0-23) + +**ΠŸΡ€ΠΈΠΌΠ΅Ρ€:** +```python +stats = await MenuLayoutService.get_clicks_by_hour(db, button_id="menu_balance", days=30) +# Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚: +# [ +# {"hour": 9, "count": 50}, +# {"hour": 10, "count": 75}, +# ... +# ] +``` + +### 6. Бтатистика ΠΏΠΎ дням Π½Π΅Π΄Π΅Π»ΠΈ + +**GET** `/menu-layout/stats/by-weekday?button_id=menu_balance&days=30` + +**Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚:** +- РаспрСдСлСниС ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎ дням Π½Π΅Π΄Π΅Π»ΠΈ (0=понСдСльник, 6=Π²ΠΎΡΠΊΡ€Π΅ΡΠ΅Π½ΡŒΠ΅) + +**ΠŸΡ€ΠΈΠΌΠ΅Ρ€:** +```python +stats = await MenuLayoutService.get_clicks_by_weekday(db, button_id="menu_balance", days=30) +# Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚: +# [ +# {"weekday": 0, "weekday_name": "ПонСдСльник", "count": 100}, +# {"weekday": 1, "weekday_name": "Π’Ρ‚ΠΎΡ€Π½ΠΈΠΊ", "count": 120}, +# ... +# ] +``` + +### 7. Π’ΠΎΠΏ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ ΠΏΠΎ ΠΊΠ»ΠΈΠΊΠ°ΠΌ + +**GET** `/menu-layout/stats/top-users?button_id=menu_balance&limit=10&days=30` + +**ΠŸΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€Ρ‹:** +- `button_id` (optional) - ID ΠΊΠ½ΠΎΠΏΠΊΠΈ для Ρ„ΠΈΠ»ΡŒΡ‚Ρ€Π°Ρ†ΠΈΠΈ +- `limit` (default: 10) - количСство ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ +- `days` (default: 30) - ΠΏΠ΅Ρ€ΠΈΠΎΠ΄ Π² днях + +**Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚:** +- Бписок ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ с наибольшим количСством ΠΊΠ»ΠΈΠΊΠΎΠ² + +**ΠŸΡ€ΠΈΠΌΠ΅Ρ€:** +```python +top_users = await MenuLayoutService.get_top_users(db, button_id="menu_balance", limit=10, days=30) +# Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚: +# [ +# {"user_id": 123456789, "clicks_count": 50, "last_click_at": datetime(...)}, +# ... +# ] +``` + +### 8. Π‘Ρ€Π°Π²Π½Π΅Π½ΠΈΠ΅ ΠΏΠ΅Ρ€ΠΈΠΎΠ΄ΠΎΠ² + +**GET** `/menu-layout/stats/compare?button_id=menu_balance¤t_days=7&previous_days=7` + +**ΠŸΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€Ρ‹:** +- `button_id` (optional) - ID ΠΊΠ½ΠΎΠΏΠΊΠΈ для Ρ„ΠΈΠ»ΡŒΡ‚Ρ€Π°Ρ†ΠΈΠΈ +- `current_days` (default: 7) - ΠΏΠ΅Ρ€ΠΈΠΎΠ΄ Ρ‚Π΅ΠΊΡƒΡ‰Π΅Π³ΠΎ сравнСния +- `previous_days` (default: 7) - ΠΏΠ΅Ρ€ΠΈΠΎΠ΄ ΠΏΡ€Π΅Π΄Ρ‹Π΄ΡƒΡ‰Π΅Π³ΠΎ сравнСния + +**Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚:** +- Π‘Ρ€Π°Π²Π½Π΅Π½ΠΈΠ΅ Ρ‚Π΅ΠΊΡƒΡ‰Π΅Π³ΠΎ ΠΈ ΠΏΡ€Π΅Π΄Ρ‹Π΄ΡƒΡ‰Π΅Π³ΠΎ ΠΏΠ΅Ρ€ΠΈΠΎΠ΄Π° +- ИзмСнСниС Π² Π°Π±ΡΠΎΠ»ΡŽΡ‚Π½Ρ‹Ρ… числах ΠΈ ΠΏΡ€ΠΎΡ†Π΅Π½Ρ‚Π°Ρ… +- Π’Ρ€Π΅Π½Π΄ (up/down/stable) + +**ΠŸΡ€ΠΈΠΌΠ΅Ρ€:** +```python +comparison = await MenuLayoutService.get_period_comparison( + db, button_id="menu_balance", current_days=7, previous_days=7 +) +# Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚: +# { +# "current_period": {"clicks": 100, "days": 7, ...}, +# "previous_period": {"clicks": 80, "days": 7, ...}, +# "change": {"absolute": 20, "percent": 25.0, "trend": "up"} +# } +``` + +### 9. ΠŸΠΎΡΠ»Π΅Π΄ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΠ½ΠΎΡΡ‚ΠΈ ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ + +**GET** `/menu-layout/stats/users/{user_id}/sequences?limit=50` + +**ΠŸΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€Ρ‹:** +- `user_id` (path) - ID ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ +- `limit` (default: 50) - максимальноС количСство записСй + +**Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚:** +- Π₯Ρ€ΠΎΠ½ΠΎΠ»ΠΎΠ³ΠΈΡ‡Π΅ΡΠΊΡƒΡŽ ΠΏΠΎΡΠ»Π΅Π΄ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΠ½ΠΎΡΡ‚ΡŒ ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ + +**ΠŸΡ€ΠΈΠΌΠ΅Ρ€:** +```python +sequences = await MenuLayoutService.get_user_click_sequences(db, user_id=123456789, limit=50) +# Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚: +# [ +# {"button_id": "menu_balance", "button_text": "πŸ’° Баланс", "clicked_at": datetime(...)}, +# {"button_id": "menu_subscription", "button_text": "πŸ“Š Подписка", "clicked_at": datetime(...)}, +# ... +# ] +``` + ## Π’Π°ΠΆΠ½Ρ‹Π΅ замСчания 1. **АвтоматичСскоС Π»ΠΎΠ³ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅**: ВсС ΠΊΠ»ΠΈΠΊΠΈ ΠΏΠΎ ΠΊΠ½ΠΎΠΏΠΊΠ°ΠΌ Π»ΠΎΠ³ΠΈΡ€ΡƒΡŽΡ‚ΡΡ автоматичСски Ρ‡Π΅Ρ€Π΅Π· `ButtonStatsMiddleware` @@ -125,4 +255,5 @@ POST /menu-layout/stats/log-click 3. **button_id**: Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ΡΡ `callback_data` ΠΊΠ½ΠΎΠΏΠΊΠΈ ΠΊΠ°ΠΊ ΠΈΠ΄Π΅Π½Ρ‚ΠΈΡ„ΠΈΠΊΠ°Ρ‚ΠΎΡ€ 4. **ΠŸΡ€ΠΎΠΈΠ·Π²ΠΎΠ΄ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎΡΡ‚ΡŒ**: Π›ΠΎΠ³ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ выполняСтся асинхронно Π² Ρ„ΠΎΠ½Π΅ ΠΈ Π½Π΅ Π±Π»ΠΎΠΊΠΈΡ€ΡƒΠ΅Ρ‚ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΡƒ запросов 5. **Активация**: Middleware Ρ€Π°Π±ΠΎΡ‚Π°Π΅Ρ‚ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Ссли `MENU_LAYOUT_ENABLED=True` Π² настройках +6. **Π’Ρ€Π΅ΠΌΠ΅Π½Π½Ρ‹Π΅ Π·ΠΎΠ½Ρ‹**: ВсС Π²Ρ€Π΅ΠΌΠ΅Π½Π½Ρ‹Π΅ ΠΌΠ΅Ρ‚Ρ€ΠΈΠΊΠΈ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΡŽΡ‚ локальноС врСмя сСрвСра 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" +