diff --git a/bot/handlers/admin/ads.py b/bot/handlers/admin/ads.py
index a16e4ac..33bd539 100644
--- a/bot/handlers/admin/ads.py
+++ b/bot/handlers/admin/ads.py
@@ -127,6 +127,126 @@ 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")
+ 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(i18n.gettext(current_lang, "error_try_again"), show_alert=True)
+ return
+
+ from bot.keyboards.inline.admin_keyboards import get_confirmation_keyboard
+ 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}",
+ 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:
+ 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
+
+ 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:
+ 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
+
+ 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/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(
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": "Кампания не найдена."
}