From 2713b3bae57e5be4976a197d7d003f9ef20ef6c3 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 11 Sep 2025 22:58:12 +0300 Subject: [PATCH] Add ad campaign deletion functionality with confirmation prompts and error handling. Updated inline keyboards and translations for delete actions. Enhanced subscription command handler to include fallback for config link URL. --- bot/handlers/admin/ads.py | 121 ++++++++++++++++++++++++ bot/handlers/user/subscription/core.py | 11 ++- bot/keyboards/inline/admin_keyboards.py | 3 + db/dal/ad_dal.py | 18 ++++ locales/en.json | 6 +- locales/ru.json | 6 +- 6 files changed, 162 insertions(+), 3 deletions(-) diff --git a/bot/handlers/admin/ads.py b/bot/handlers/admin/ads.py index a16e4ac..2fb78ec 100644 --- a/bot/handlers/admin/ads.py +++ b/bot/handlers/admin/ads.py @@ -127,6 +127,127 @@ async def show_ad_card(callback: types.CallbackQuery, settings: Settings, i18n_d await callback.answer() +@router.callback_query(F.data.startswith("admin_ads:delete:")) +async def ads_delete_prompt(callback: types.CallbackQuery, settings: Settings, i18n_data: dict): + 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: + _, _, camp_id_str, back_page_str = callback.data.split(":", 3) + camp_id = int(camp_id_str) + back_page = int(back_page_str) + except Exception: + await callback.answer(_("error_try_again"), show_alert=True) + return + + from bot.keyboards.inline.admin_keyboards import get_confirmation_keyboard + confirm_text = _("admin_ads_delete_confirm", id=camp_id) + kb = get_confirmation_keyboard( + yes_callback_data=f"admin_ads:delete_confirm:{camp_id}:{back_page}", + no_callback_data=f"admin_ads:delete_cancel:{camp_id}:{back_page}", + i18n_instance=i18n, + lang=current_lang, + ) + try: + await callback.message.edit_text(confirm_text, reply_markup=kb) + await callback.answer() + except Exception: + await callback.answer() + + +@router.callback_query(F.data.startswith("admin_ads:delete_cancel:")) +async def ads_delete_cancel(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): + # Return to the ad card view + 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: + _, _, _, camp_id_str, back_page_str = callback.data.split(":", 4) + camp_id = int(camp_id_str) + back_page = int(back_page_str) + except Exception: + await callback.answer(_("error_try_again"), show_alert=True) + return + + camp = await ad_dal.get_campaign_by_id(session, camp_id) + if not camp: + await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), 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: + await callback.answer() + + +@router.callback_query(F.data.startswith("admin_ads:delete_confirm:")) +async def ads_delete_confirm(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: + _, _, _, camp_id_str, back_page_str = callback.data.split(":", 4) + camp_id = int(camp_id_str) + back_page = int(back_page_str) + except Exception: + await callback.answer(_("error_try_again"), show_alert=True) + return + + existed = await ad_dal.delete_campaign(session, camp_id) + if not existed: + await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True) + return + await session.commit() + + # After delete, show list page (may shift due to fewer items) + 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(back_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(_("admin_ads_deleted_success"), show_alert=True) + except Exception: + await callback.answer(_("admin_ads_deleted_success"), show_alert=True) @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/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index 3b883e7..5fbd66e 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -155,7 +155,7 @@ async def my_subscription_command_handler( # Build rows to prepend above the base "back" markup prepend_rows = [] - # 1) Mini-app connect button on top if enabled + # 1) Mini-app connect button on top if enabled, otherwise fall back to config link URL if settings.SUBSCRIPTION_MINI_APP_URL: prepend_rows.append([ InlineKeyboardButton( @@ -163,6 +163,15 @@ async def my_subscription_command_handler( web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL), ) ]) + else: + cfg_link_val = (active or {}).get("config_link") + if cfg_link_val: + prepend_rows.append([ + InlineKeyboardButton( + text=get_text("connect_button"), + url=cfg_link_val, + ) + ]) # 2) Auto-renew toggle (if supported and not tribute) if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): diff --git a/bot/keyboards/inline/admin_keyboards.py b/bot/keyboards/inline/admin_keyboards.py index 644cfa6..37dfbf8 100644 --- a/bot/keyboards/inline/admin_keyboards.py +++ b/bot/keyboards/inline/admin_keyboards.py @@ -185,6 +185,9 @@ def get_ads_list_keyboard( 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() + # Dangerous action: Delete campaign + builder.button(text=_(key="admin_ads_delete_button", default="🗑 Удалить кампанию"), + callback_data=f"admin_ads:delete:{campaign_id}:{back_page}") 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"), diff --git a/db/dal/ad_dal.py b/db/dal/ad_dal.py index 6a9bd91..c81f763 100644 --- a/db/dal/ad_dal.py +++ b/db/dal/ad_dal.py @@ -162,3 +162,21 @@ async def get_totals(session: AsyncSession) -> Dict[str, float]: return {"cost": total_cost, "revenue": total_revenue} +async def delete_campaign(session: AsyncSession, campaign_id: int) -> bool: + """Delete ad campaign by id along with related attributions. + + Returns True if campaign existed and was deleted, False otherwise. + """ + try: + campaign = await session.get(AdCampaign, campaign_id) + if not campaign: + return False + await session.delete(campaign) + await session.flush() + logging.info(f"AdCampaign deleted id={campaign_id}") + return True + except Exception as e: + logging.error(f"Failed to delete AdCampaign id={campaign_id}: {e}", exc_info=True) + raise + + diff --git a/locales/en.json b/locales/en.json index 22666b2..f772b3b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -428,5 +428,9 @@ "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" + "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", + "admin_ads_delete_button": "🗑 Delete campaign", + "admin_ads_delete_confirm": "Are you sure you want to delete campaign #{id}? This action is irreversible.", + "admin_ads_deleted_success": "Campaign deleted.", + "admin_ads_not_found": "Campaign not found." } diff --git a/locales/ru.json b/locales/ru.json index 9ab8fa4..7227c27 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -427,5 +427,9 @@ "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" + "admin_ads_card": "📈 Кампания #{id}\nИсточник: {source}\nstart={start_param}\nСтоимость: {cost} RUB\nАктивна: {active}\n\n👥 Запустили: {starts}\n🆓 Взяли триал: {trials}\n💳 Оплатили: {payers}\n💵 Доход: {revenue} RUB", + "admin_ads_delete_button": "🗑 Удалить кампанию", + "admin_ads_delete_confirm": "Вы уверены, что хотите удалить кампанию #{id}? Это действие необратимо.", + "admin_ads_deleted_success": "Кампания удалена.", + "admin_ads_not_found": "Кампания не найдена." }