From 2713b3bae57e5be4976a197d7d003f9ef20ef6c3 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 11 Sep 2025 22:58:12 +0300 Subject: [PATCH 1/4] 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": "Кампания не найдена." } From 357d7caf26590b63ff35b6c00286e38d6e76f141 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 11 Sep 2025 23:03:41 +0300 Subject: [PATCH 2/4] Refactor i18n handling in ads deletion prompt to use direct gettext calls. This improves language support and error messaging consistency. --- bot/handlers/admin/ads.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/handlers/admin/ads.py b/bot/handlers/admin/ads.py index 2fb78ec..b9d38d5 100644 --- a/bot/handlers/admin/ads.py +++ b/bot/handlers/admin/ads.py @@ -131,7 +131,6 @@ async def show_ad_card(callback: types.CallbackQuery, settings: Settings, i18n_d 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 @@ -141,11 +140,11 @@ async def ads_delete_prompt(callback: types.CallbackQuery, settings: Settings, i camp_id = int(camp_id_str) back_page = int(back_page_str) except Exception: - await callback.answer(_("error_try_again"), show_alert=True) + await callback.answer(i18n.gettext(current_lang, "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) + confirm_text = i18n.gettext(current_lang, "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}", From a243ddcac88291f1abd3713f53795c33755284e7 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 11 Sep 2025 23:05:57 +0300 Subject: [PATCH 3/4] Refactor ads deletion callback data parsing to improve clarity and maintainability. Updated the way campaign ID and back page are extracted from the callback data. --- bot/handlers/admin/ads.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/handlers/admin/ads.py b/bot/handlers/admin/ads.py index b9d38d5..33bd539 100644 --- a/bot/handlers/admin/ads.py +++ b/bot/handlers/admin/ads.py @@ -169,9 +169,9 @@ async def ads_delete_cancel(callback: types.CallbackQuery, settings: Settings, i return try: - _, _, _, camp_id_str, back_page_str = callback.data.split(":", 4) - camp_id = int(camp_id_str) - back_page = int(back_page_str) + parts = callback.data.split(":", 3) + camp_id = int(parts[2]) + back_page = int(parts[3]) except Exception: await callback.answer(_("error_try_again"), show_alert=True) return @@ -215,9 +215,9 @@ async def ads_delete_confirm(callback: types.CallbackQuery, settings: Settings, return try: - _, _, _, camp_id_str, back_page_str = callback.data.split(":", 4) - camp_id = int(camp_id_str) - back_page = int(back_page_str) + parts = callback.data.split(":", 3) + camp_id = int(parts[2]) + back_page = int(parts[3]) except Exception: await callback.answer(_("error_try_again"), show_alert=True) return From 585cbe8fb68a0bc1974a8d653c7298792aaf36db Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Fri, 12 Sep 2025 12:57:35 +0300 Subject: [PATCH 4/4] Refactor subscription retrieval logic to limit results to the most recent active subscription. Updated query execution to return the first scalar result for improved performance. --- db/dal/subscription_dal.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/db/dal/subscription_dal.py b/db/dal/subscription_dal.py index 1785c82..2e258e0 100644 --- a/db/dal/subscription_dal.py +++ b/db/dal/subscription_dal.py @@ -14,13 +14,15 @@ async def get_active_subscription_by_user_id( user_id: int, panel_user_uuid: Optional[str] = None) -> Optional[Subscription]: stmt = select(Subscription).where( - Subscription.user_id == user_id, Subscription.is_active == True, - Subscription.end_date > datetime.now(timezone.utc)) + Subscription.user_id == user_id, + Subscription.is_active == True, + Subscription.end_date > datetime.now(timezone.utc), + ) if panel_user_uuid: stmt = stmt.where(Subscription.panel_user_uuid == panel_user_uuid) - stmt = stmt.order_by(Subscription.end_date.desc()) + stmt = stmt.order_by(Subscription.end_date.desc()).limit(1) result = await session.execute(stmt) - return result.scalar_one_or_none() + return result.scalars().first() async def get_subscription_by_panel_subscription_uuid(