From 8113908a9844cd1091b30ac9de6970167e042729 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Fri, 5 Sep 2025 15:08:52 +0300 Subject: [PATCH] Implement pagination and detailed views for ad campaigns in admin panel - Added pagination support for the ads list, allowing admins to navigate through multiple pages of campaigns. - Introduced detailed views for individual ad campaigns, displaying comprehensive statistics and information. - Enhanced the database access layer with new methods for counting and listing campaigns with pagination. - Updated localization files to include new strings for the ads overview and campaign details. - Improved the inline keyboard structure for better navigation within the ads management interface. --- bot/handlers/admin/ads.py | 124 ++++++++++++++++++------ bot/keyboards/inline/admin_keyboards.py | 61 ++++++++++++ db/dal/ad_dal.py | 35 +++++++ locales/en.json | 5 +- locales/ru.json | 5 +- 5 files changed, 200 insertions(+), 30 deletions(-) diff --git a/bot/handlers/admin/ads.py b/bot/handlers/admin/ads.py index 7ba5e6b..a16e4ac 100644 --- a/bot/handlers/admin/ads.py +++ b/bot/handlers/admin/ads.py @@ -13,6 +13,9 @@ from bot.states.admin_states import AdminStates router = Router(name="admin_ads_router") +PAGE_SIZE = 5 + + @router.callback_query(F.data == "admin_action:ads") async def show_ads_menu(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) @@ -23,35 +26,23 @@ async def show_ads_menu(callback: types.CallbackQuery, settings: Settings, i18n_ await callback.answer("Language error.", show_alert=True) return - campaigns = await ad_dal.list_campaigns(session) - if not campaigns: - text = _("admin_ads_empty") - else: - text_lines = [_("admin_ads_header")] - for camp in campaigns: - try: - stats = await ad_dal.get_campaign_stats(session, camp.ad_campaign_id) - except Exception as e_stats: - logging.error(f"Failed to calc stats for campaign {camp.ad_campaign_id}: {e_stats}") - stats = {"starts": 0, "trials": 0, "payers": 0, "revenue": 0.0} - text_lines.append( - _( - "admin_ads_item", - id=camp.ad_campaign_id, - source=camp.source, - start_param=camp.start_param, - cost=f"{camp.cost:.2f}", - active=_("csv_yes") if camp.is_active else _("csv_no"), - starts=stats["starts"], - trials=stats["trials"], - payers=stats["payers"], - revenue=f"{stats['revenue']:.2f}", - ) - ) - text = "\n\n".join(text_lines) + totals = await ad_dal.get_totals(session) + total_cost = totals.get("cost", 0.0) + total_revenue = totals.get("revenue", 0.0) + overview = _("admin_ads_overview", revenue=f"{total_revenue:.2f}", cost=f"{total_cost:.2f}") - from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard - reply_markup = get_ads_menu_keyboard(i18n, current_lang) + total_count = await ad_dal.count_campaigns(session) + if total_count == 0: + text = overview + "\n\n" + _("admin_ads_empty") + from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard + reply_markup = get_ads_menu_keyboard(i18n, current_lang) + else: + current_page = 0 + total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE) + campaigns = await ad_dal.list_campaigns_paged(session, page=current_page, page_size=PAGE_SIZE) + text = overview + "\n\n" + _("admin_ads_header") + from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard + reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, current_page, total_pages) await callback.message.edit_text(text, reply_markup=reply_markup) try: await callback.answer() @@ -59,6 +50,83 @@ async def show_ads_menu(callback: types.CallbackQuery, settings: Settings, i18n_ pass +@router.callback_query(F.data.startswith("admin_ads:page:")) +async def ads_list_pagination(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + if not i18n or not callback.message: + await callback.answer("Language error.", show_alert=True) + return + + try: + page = int(callback.data.split(":")[2]) + except Exception: + page = 0 + + totals = await ad_dal.get_totals(session) + overview = _("admin_ads_overview", revenue=f"{totals.get('revenue', 0.0):.2f}", cost=f"{totals.get('cost', 0.0):.2f}") + total_count = await ad_dal.count_campaigns(session) + total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE) + page = max(0, min(page, total_pages - 1)) + + campaigns = await ad_dal.list_campaigns_paged(session, page=page, page_size=PAGE_SIZE) + text = overview + "\n\n" + _("admin_ads_header") + from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard + reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, page, total_pages) + try: + await callback.message.edit_text(text, reply_markup=reply_markup) + await callback.answer() + except Exception as e: + logging.error(f"Failed to paginate ads list: {e}") + await callback.answer() + + +@router.callback_query(F.data.startswith("admin_ads:card:")) +async def show_ad_card(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + if not i18n or not callback.message: + await callback.answer("Language error.", show_alert=True) + return + + parts = callback.data.split(":") + camp_id = int(parts[2]) + back_page = int(parts[3]) if len(parts) > 3 else 0 + + camp = await ad_dal.get_campaign_by_id(session, camp_id) + if not camp: + await callback.answer(_("admin_promo_not_found"), show_alert=True) + return + try: + stats = await ad_dal.get_campaign_stats(session, camp_id) + except Exception: + stats = {"starts": 0, "trials": 0, "payers": 0, "revenue": 0.0} + + text = _( + "admin_ads_card", + id=camp.ad_campaign_id, + source=camp.source, + start_param=camp.start_param, + cost=f"{camp.cost:.2f}", + active=_("csv_yes") if camp.is_active else _("csv_no"), + starts=stats["starts"], + trials=stats["trials"], + payers=stats["payers"], + revenue=f"{stats['revenue']:.2f}", + ) + + from bot.keyboards.inline.admin_keyboards import get_ad_card_keyboard + reply_markup = get_ad_card_keyboard(i18n, current_lang, camp.ad_campaign_id, back_page) + try: + await callback.message.edit_text(text, reply_markup=reply_markup, parse_mode="HTML") + await callback.answer() + except Exception as e: + logging.error(f"Failed to show ad card: {e}") + await callback.answer() + + @router.callback_query(F.data == "admin_action:ads_create") async def ads_create_start(callback: types.CallbackQuery, state: FSMContext, settings: Settings, i18n_data: dict): from bot.states.admin_states import AdminStates diff --git a/bot/keyboards/inline/admin_keyboards.py b/bot/keyboards/inline/admin_keyboards.py index 85677dd..f9c4f21 100644 --- a/bot/keyboards/inline/admin_keyboards.py +++ b/bot/keyboards/inline/admin_keyboards.py @@ -131,6 +131,67 @@ def get_ads_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup: return builder.as_markup() +def get_ads_list_keyboard( + i18n_instance, + lang: str, + campaigns: list, + current_page: int, + total_pages: int, +) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + + for c in campaigns: + title = f"{c.source}" + builder.button( + text=title, + callback_data=f"admin_ads:card:{c.ad_campaign_id}:{current_page}", + ) + + # Pagination row + row = [] + if current_page > 0: + row.append( + InlineKeyboardButton( + text="⬅️ " + _("prev_page_button", default="Prev"), + callback_data=f"admin_ads:page:{current_page - 1}", + ) + ) + row.append( + InlineKeyboardButton( + text=f"{current_page + 1}/{total_pages}", + callback_data="ads_page_display", + ) + ) + if current_page < total_pages - 1: + row.append( + InlineKeyboardButton( + text=_("next_page_button", default="Next") + " ➡️", + callback_data=f"admin_ads:page:{current_page + 1}", + ) + ) + if row: + builder.row(*row) + + builder.button(text=_(key="admin_ads_create_button", default="➕ Создать кампанию"), + callback_data="admin_action:ads_create") + builder.button(text=_(key="back_to_admin_panel_button"), + callback_data="admin_action:main") + builder.adjust(1) + return builder.as_markup() + + +def get_ad_card_keyboard(i18n_instance, lang: str, campaign_id: int, back_page: int) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.button(text=_(key="back_to_ads_list_button", default="⬅️ К списку"), + callback_data=f"admin_ads:page:{back_page}") + builder.button(text=_(key="back_to_admin_panel_button"), + callback_data="admin_action:main") + builder.adjust(1) + return builder.as_markup() + + def get_logs_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() diff --git a/db/dal/ad_dal.py b/db/dal/ad_dal.py index 85f820c..6a9bd91 100644 --- a/db/dal/ad_dal.py +++ b/db/dal/ad_dal.py @@ -127,3 +127,38 @@ async def get_campaign_stats(session: AsyncSession, campaign_id: int) -> Dict[st } +async def count_campaigns(session: AsyncSession, *, only_active: bool = False) -> int: + stmt = select(func.count(AdCampaign.ad_campaign_id)) + if only_active: + stmt = stmt.where(AdCampaign.is_active == True) + return int((await session.execute(stmt)).scalar() or 0) + + +async def list_campaigns_paged( + session: AsyncSession, *, page: int, page_size: int, only_active: bool = False +) -> List[AdCampaign]: + offset = max(0, page) * max(1, page_size) + stmt = select(AdCampaign).order_by(AdCampaign.created_at.desc()).offset(offset).limit(page_size) + if only_active: + stmt = stmt.where(AdCampaign.is_active == True) + result = await session.execute(stmt) + return result.scalars().all() + + +async def get_totals(session: AsyncSession) -> Dict[str, float]: + # Total cost across all campaigns + total_cost_stmt = select(func.coalesce(func.sum(AdCampaign.cost), 0.0)) + total_cost = float((await session.execute(total_cost_stmt)).scalar() or 0.0) + + # Total revenue from all attributed users (unique users counted across all campaigns) + revenue_stmt = select(func.coalesce(func.sum(Payment.amount), 0.0)).select_from(Payment).where( + and_( + Payment.status == "succeeded", + Payment.user_id.in_(select(AdAttribution.user_id)), + ) + ) + total_revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0) + + return {"cost": total_cost, "revenue": total_revenue} + + diff --git a/locales/en.json b/locales/en.json index 9275745..63234a7 100644 --- a/locales/en.json +++ b/locales/en.json @@ -420,5 +420,8 @@ "admin_ads_invalid_cost": "❌ Invalid amount. Enter a non-negative number.", "admin_ads_start_param_exists": "❌ A campaign with this start parameter already exists.", "admin_ads_created_success": "✅ Campaign created!\nID: {id}\nSource: {source}\nParam: {start_param}\nCost: {cost} RUB", - "admin_ads_back_to_menu_hint": "Done. Back to Ads section:" + "admin_ads_back_to_menu_hint": "Done. Back to Ads section:", + "admin_ads_overview": "📈 Ads\n💰 Revenue: {revenue} RUB\n💸 Spent: {cost} RUB", + "back_to_ads_list_button": "⬅️ Back to list", + "admin_ads_card": "📈 Campaign #{id}\nSource: {source}\nstart={start_param}\nCost: {cost} RUB\nActive: {active}\n\n👥 Starts: {starts}\n🆓 Trials: {trials}\n💳 Payers: {payers}\n💵 Revenue: {revenue} RUB" } diff --git a/locales/ru.json b/locales/ru.json index 990fdea..ea8ffaa 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -419,5 +419,8 @@ "admin_ads_invalid_cost": "❌ Неверная сумма. Введите неотрицательное число.", "admin_ads_start_param_exists": "❌ Кампания с таким start-параметром уже существует.", "admin_ads_created_success": "✅ Кампания создана!\nID: {id}\nИсточник: {source}\nПараметр: {start_param}\nЗатраты: {cost} RUB", - "admin_ads_back_to_menu_hint": "Готово. Вернуться к разделу рекламы:" + "admin_ads_back_to_menu_hint": "Готово. Вернуться к разделу рекламы:", + "admin_ads_overview": "📈 Реклама\n💰 Пришло: {revenue} RUB\n💸 Потрачено: {cost} RUB", + "back_to_ads_list_button": "⬅️ К списку", + "admin_ads_card": "📈 Кампания #{id}\nИсточник: {source}\nstart={start_param}\nСтоимость: {cost} RUB\nАктивна: {active}\n\n👥 Запустили: {starts}\n🆓 Взяли триал: {trials}\n💳 Оплатили: {payers}\n💵 Доход: {revenue} RUB" }