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"
}