chore: run lint and prettifier

This commit is contained in:
3252a8
2026-05-12 21:54:12 +03:00
parent f31540afdb
commit 11187487b4
174 changed files with 12383 additions and 6688 deletions
+2 -9
View File
@@ -1,14 +1,7 @@
from aiogram import Router
from . import common
from . import broadcast
from . import ads, broadcast, common, logs_admin, payments, statistics, sync_admin, user_management
from .promo import promo_router_aggregate
from . import user_management
from . import statistics
from . import sync_admin
from . import logs_admin
from . import payments
from . import ads
admin_router_aggregate = Router(name="admin_features_router")
@@ -22,4 +15,4 @@ admin_router_aggregate.include_router(logs_admin.router)
admin_router_aggregate.include_router(payments.router)
admin_router_aggregate.include_router(ads.router)
__all__ = ("admin_router_aggregate", )
__all__ = ("admin_router_aggregate",)
+55 -16
View File
@@ -1,14 +1,15 @@
import logging
from aiogram import Router, F, types
from typing import Optional
from aiogram import F, Router, types
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from bot.middlewares.i18n import JsonI18n
from db.dal import ad_dal
from bot.states.admin_states import AdminStates
from config.settings import Settings
from db.dal import ad_dal
router = Router(name="admin_ads_router")
@@ -17,7 +18,9 @@ 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):
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)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -35,14 +38,20 @@ async def show_ads_menu(callback: types.CallbackQuery, settings: Settings, i18n_
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)
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)
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()
@@ -51,7 +60,9 @@ async def show_ads_menu(callback: types.CallbackQuery, settings: Settings, i18n_
@router.callback_query(F.data.startswith("admin_ads:page:"))
async def ads_list_pagination(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
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
@@ -65,7 +76,11 @@ async def ads_list_pagination(callback: types.CallbackQuery, settings: Settings,
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}")
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))
@@ -73,6 +88,7 @@ async def ads_list_pagination(callback: types.CallbackQuery, settings: Settings,
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)
@@ -83,7 +99,9 @@ async def ads_list_pagination(callback: types.CallbackQuery, settings: Settings,
@router.callback_query(F.data.startswith("admin_ads:card:"))
async def show_ad_card(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
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
@@ -118,6 +136,7 @@ async def show_ad_card(callback: types.CallbackQuery, settings: Settings, i18n_d
)
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")
@@ -144,6 +163,7 @@ async def ads_delete_prompt(callback: types.CallbackQuery, settings: Settings, i
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}",
@@ -159,7 +179,9 @@ async def ads_delete_prompt(callback: types.CallbackQuery, settings: Settings, i
@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):
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")
@@ -197,6 +219,7 @@ async def ads_delete_cancel(callback: types.CallbackQuery, settings: Settings, i
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")
@@ -206,7 +229,9 @@ async def ads_delete_cancel(callback: types.CallbackQuery, settings: Settings, i
@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):
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
@@ -241,15 +266,21 @@ async def ads_delete_confirm(callback: types.CallbackQuery, settings: Settings,
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):
async def ads_create_start(
callback: types.CallbackQuery, state: FSMContext, settings: Settings, i18n_data: dict
):
from bot.states.admin_states import AdminStates
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
@@ -274,7 +305,13 @@ async def ads_create_start(callback: types.CallbackQuery, state: FSMContext, set
),
F.text,
)
async def ads_create_flow(message: types.Message, state: FSMContext, settings: Settings, i18n_data: dict, session: AsyncSession):
async def ads_create_flow(
message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
):
current_state = await state.get_state()
if current_state not in (
AdminStates.waiting_for_ad_source.state,
@@ -301,6 +338,7 @@ async def ads_create_flow(message: types.Message, state: FSMContext, settings: S
start_param = message.text.strip()
# Allow alnum underscore dash only
import re as _re
if not _re.match(r"^[A-Za-z0-9_\-]{2,64}$", start_param):
await message.answer(_("admin_ads_invalid_start_param"))
return
@@ -354,6 +392,7 @@ async def ads_create_flow(message: types.Message, state: FSMContext, settings: S
)
# Offer back to ads menu
from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard
await message.answer(_("admin_ads_back_to_menu_hint"), reply_markup=get_ads_menu_keyboard(i18n, current_lang))
await message.answer(
_("admin_ads_back_to_menu_hint"), reply_markup=get_ads_menu_keyboard(i18n, current_lang)
)
+39 -52
View File
@@ -1,25 +1,28 @@
import logging
import asyncio
from aiogram import Router, F, types, Bot
from aiogram.exceptions import TelegramRetryAfter, TelegramBadRequest
from aiogram.fsm.context import FSMContext
import logging
from typing import Optional
from aiogram import Bot, F, Router, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import user_dal, message_log_dal
from bot.states.admin_states import AdminStates
from bot.keyboards.inline.admin_keyboards import (
get_broadcast_confirmation_keyboard,
get_back_to_admin_panel_keyboard,
get_admin_panel_keyboard,
get_back_to_admin_panel_keyboard,
get_broadcast_confirmation_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.states.admin_states import AdminStates
from bot.utils import (
MessageContent,
get_message_content,
send_message_by_type,
send_message_via_queue,
)
from bot.utils.message_queue import get_queue_manager
from bot.utils import get_message_content, send_message_by_type, send_message_via_queue, MessageContent
from config.settings import Settings
from db.dal import message_log_dal, user_dal
router = Router(name="admin_broadcast_router")
@@ -48,9 +51,7 @@ async def broadcast_message_prompt_handler(
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
)
except Exception as e:
logging.warning(
f"Could not edit message for broadcast prompt: {e}. Sending new."
)
logging.warning(f"Could not edit message for broadcast prompt: {e}. Sending new.")
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
@@ -100,8 +101,8 @@ async def process_broadcast_message_handler(
# Для медиа-сообщений используем caption_entities, для текста - entities
if content.content_type == "text":
await send_message_by_type(
bot,
chat_id=message.chat.id,
bot,
chat_id=message.chat.id,
content=content,
parse_mode="HTML",
entities=entities,
@@ -110,8 +111,8 @@ async def process_broadcast_message_handler(
)
else:
await send_message_by_type(
bot,
chat_id=message.chat.id,
bot,
chat_id=message.chat.id,
content=content,
parse_mode="HTML",
caption_entities=entities,
@@ -161,24 +162,18 @@ async def change_broadcast_target_handler(
await state.update_data(broadcast_target=new_target)
user_fsm_data = await state.get_data()
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
confirmation_prompt = _(
"admin_broadcast_confirm_prompt_short"
)
confirmation_prompt = _("admin_broadcast_confirm_prompt_short")
try:
await callback.message.edit_text(
confirmation_prompt,
reply_markup=get_broadcast_confirmation_keyboard(
current_lang, i18n, target=new_target
),
reply_markup=get_broadcast_confirmation_keyboard(current_lang, i18n, target=new_target),
)
except Exception:
pass
await callback.answer()
@router.callback_query(
F.data == "admin_action:main", AdminStates.waiting_for_broadcast_message
)
@router.callback_query(F.data == "admin_action:main", AdminStates.waiting_for_broadcast_message)
async def cancel_broadcast_at_prompt_stage(
callback: types.CallbackQuery,
state: FSMContext,
@@ -194,9 +189,7 @@ async def cancel_broadcast_at_prompt_stage(
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
await callback.message.edit_text(
_("admin_broadcast_cancelled_nav_back"), reply_markup=None
)
await callback.message.edit_text(_("admin_broadcast_cancelled_nav_back"), reply_markup=None)
except Exception:
await callback.message.answer(_("admin_broadcast_cancelled_nav_back"))
@@ -236,16 +229,14 @@ async def confirm_broadcast_callback_handler(
content = MessageContent(
content_type=user_fsm_data.get("broadcast_content_type", "text"),
file_id=user_fsm_data.get("broadcast_file_id"),
text=user_fsm_data.get("broadcast_text")
text=user_fsm_data.get("broadcast_text"),
)
entities = user_fsm_data.get("broadcast_entities", [])
if not content.text and content.content_type == "text":
await callback.message.edit_text(_("admin_broadcast_error_no_message"))
await state.clear()
await callback.answer(
_("admin_broadcast_error_no_message_alert"), show_alert=True
)
await callback.answer(_("admin_broadcast_error_no_message_alert"), show_alert=True)
return
await callback.message.edit_text(_("admin_broadcast_sending_started"), reply_markup=None)
@@ -269,7 +260,9 @@ async def confirm_broadcast_callback_handler(
# Get message queue manager
queue_manager = get_queue_manager()
if not queue_manager:
await callback.message.edit_text("❌ Ошибка: система очередей не инициализирована", reply_markup=None)
await callback.message.edit_text(
"❌ Ошибка: система очередей не инициализирована", reply_markup=None
)
return
# Queue all messages for sending
@@ -278,8 +271,8 @@ async def confirm_broadcast_callback_handler(
# Для медиа-сообщений используем caption_entities, для текста - entities
if content.content_type == "text":
await send_message_via_queue(
queue_manager,
uid,
queue_manager,
uid,
content,
parse_mode="HTML",
entities=entities,
@@ -287,15 +280,15 @@ async def confirm_broadcast_callback_handler(
)
else:
await send_message_via_queue(
queue_manager,
uid,
queue_manager,
uid,
content,
parse_mode="HTML",
caption_entities=entities,
disable_web_page_preview=True,
)
sent_count += 1
# Log successful queuing
await message_log_dal.create_message_log(
session,
@@ -311,9 +304,7 @@ async def confirm_broadcast_callback_handler(
)
except Exception as e:
failed_count += 1
logging.warning(
f"Failed to queue broadcast to {uid}: {type(e).__name__} {e}"
)
logging.warning(f"Failed to queue broadcast to {uid}: {type(e).__name__} {e}")
await message_log_dal.create_message_log(
session,
{
@@ -387,14 +378,10 @@ async def confirm_broadcast_callback_handler(
if "message is not modified" in str(e):
last_text = new_text
else:
logging.debug(
"Broadcast queue auto-update stopped: %s", e
)
logging.debug("Broadcast queue auto-update stopped: %s", e)
break
except Exception as e:
logging.debug(
"Broadcast queue auto-update unexpected error: %s", e
)
logging.debug("Broadcast queue auto-update unexpected error: %s", e)
break
if queues_drained:
+100 -74
View File
@@ -1,29 +1,33 @@
import logging
from aiogram import Router, F, types, Bot
from typing import Optional
from aiogram import Bot, F, Router, types
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from bot.keyboards.inline.admin_keyboards import (
get_admin_panel_keyboard, get_stats_monitoring_keyboard,
get_user_management_keyboard, get_ban_management_keyboard,
get_promo_marketing_keyboard, get_system_functions_keyboard
get_admin_panel_keyboard,
get_ban_management_keyboard,
get_promo_marketing_keyboard,
get_stats_monitoring_keyboard,
get_system_functions_keyboard,
get_user_management_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.utils.message_queue import get_queue_manager
from config.settings import Settings
from . import broadcast as admin_broadcast_handlers
from .promo import create as admin_promo_create_handlers
from .promo import manage as admin_promo_manage_handlers
from .promo import bulk as admin_promo_bulk_handlers
from . import user_management as admin_user_mgmnt_handlers
from . import logs_admin as admin_logs_handlers
from . import statistics as admin_stats_handlers
from . import sync_admin as admin_sync_handlers
from . import logs_admin as admin_logs_handlers
from . import user_management as admin_user_mgmnt_handlers
from .promo import bulk as admin_promo_bulk_handlers
from .promo import create as admin_promo_create_handlers
from .promo import manage as admin_promo_manage_handlers
router = Router(name="admin_common_router")
@@ -44,16 +48,23 @@ async def admin_panel_command_handler(
await state.clear()
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await message.answer(_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(
i18n, current_lang, settings))
await message.answer(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
@router.callback_query(F.data.startswith("admin_action:"))
async def admin_panel_actions_callback_handler(
callback: types.CallbackQuery, state: FSMContext, settings: Settings,
i18n_data: dict, bot: Bot, panel_service: PanelApiService,
subscription_service: SubscriptionService, session: AsyncSession):
callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
i18n_data: dict,
bot: Bot,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
session: AsyncSession,
):
action_parts = callback.data.split(":")
action = action_parts[1]
@@ -69,109 +80,124 @@ async def admin_panel_actions_callback_handler(
logging.error(
f"CallbackQuery {callback.id} from {callback.from_user.id} has no message for admin_action {action}"
)
await callback.answer("Error processing action: message context lost.",
show_alert=True)
await callback.answer("Error processing action: message context lost.", show_alert=True)
return
if action == "stats":
await admin_stats_handlers.show_statistics_handler(
callback, i18n_data, settings, session)
await admin_stats_handlers.show_statistics_handler(callback, i18n_data, settings, session)
elif action == "broadcast":
await admin_broadcast_handlers.broadcast_message_prompt_handler(
callback, state, i18n_data, settings, session)
callback, state, i18n_data, settings, session
)
elif action == "create_promo":
await admin_promo_create_handlers.create_promo_prompt_handler(
callback, state, i18n_data, settings, session)
callback, state, i18n_data, settings, session
)
elif action == "create_bulk_promo":
await admin_promo_bulk_handlers.create_bulk_promo_prompt_handler(
callback, state, i18n_data, settings, session)
callback, state, i18n_data, settings, session
)
elif action == "manage_promos":
await admin_promo_manage_handlers.manage_promo_codes_handler(
callback, i18n_data, settings, session)
callback, i18n_data, settings, session
)
elif action == "view_promos":
await admin_promo_manage_handlers.view_promo_codes_handler(
callback, i18n_data, settings, session)
callback, i18n_data, settings, session
)
elif action == "ban_user_prompt":
await admin_user_mgmnt_handlers.ban_user_prompt_handler(
callback, state, i18n_data, settings, session)
callback, state, i18n_data, settings, session
)
elif action == "unban_user_prompt":
await admin_user_mgmnt_handlers.unban_user_prompt_handler(
callback, state, i18n_data, settings, session)
callback, state, i18n_data, settings, session
)
elif action == "users_management":
# This is deprecated, kept for compatibility
from . import user_management as admin_user_management_handlers
await admin_user_management_handlers.user_search_prompt_handler(
callback, state, i18n_data, settings, session)
callback, state, i18n_data, settings, session
)
elif action == "users_list" and len(action_parts) > 2:
# Route to users list handler with page number
from . import user_management as admin_user_management_handlers
try:
page = int(action_parts[2])
await admin_user_management_handlers.users_list_handler(
callback, i18n_data, settings, session, page)
callback, i18n_data, settings, session, page
)
except (IndexError, ValueError):
await callback.answer("Invalid page number", show_alert=True)
elif action == "users_search_prompt":
from . import user_management as admin_user_management_handlers
await admin_user_management_handlers.user_search_prompt_handler(
callback, state, i18n_data, settings, session)
elif action == "view_banned":
await admin_user_management_handlers.user_search_prompt_handler(
callback, state, i18n_data, settings, session
)
elif action == "view_banned":
await admin_user_mgmnt_handlers.view_banned_users_handler(
callback, state, i18n_data, settings, session)
callback, state, i18n_data, settings, session
)
elif action == "view_logs_menu":
await admin_logs_handlers.display_logs_menu(callback, i18n_data,
settings, session)
await admin_logs_handlers.display_logs_menu(callback, i18n_data, settings, session)
elif action == "promo_management":
await admin_promo_manage_handlers.promo_management_handler(
callback, i18n_data, settings, session)
callback, i18n_data, settings, session
)
elif action == "sync_panel":
await admin_sync_handlers.sync_command_handler(
message_event=callback,
bot=bot,
settings=settings,
i18n_data=i18n_data,
panel_service=panel_service,
session=session)
session=session,
)
await callback.answer(_("admin_sync_initiated_from_panel"))
elif action == "queue_status":
await show_queue_status_handler(callback, i18n_data)
elif action == "view_payments":
from . import payments as admin_payments_handlers
await admin_payments_handlers.view_payments_handler(
callback, i18n_data, settings, session)
await admin_payments_handlers.view_payments_handler(callback, i18n_data, settings, session)
elif action == "user_ratings":
await admin_stats_handlers.show_user_ratings_handler(
callback, i18n_data, settings, session)
await admin_stats_handlers.show_user_ratings_handler(callback, i18n_data, settings, session)
elif action == "ads":
from . import ads as admin_ads_handlers
await admin_ads_handlers.show_ads_menu(callback, settings, i18n_data, session)
elif action == "ads_create":
from . import ads as admin_ads_handlers
await admin_ads_handlers.ads_create_start(callback, state, settings, i18n_data)
elif action == "main":
try:
await callback.message.edit_text(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang,
settings))
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
except Exception:
await callback.message.answer(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang,
settings))
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
await callback.answer()
else:
logging.warning(
f"Unknown admin_action received: {action} from callback {callback.data}"
)
logging.warning(f"Unknown admin_action received: {action} from callback {callback.data}")
await callback.answer(_("admin_unknown_action"), show_alert=True)
@router.callback_query(F.data.startswith("admin_section:"))
async def admin_section_handler(callback: types.CallbackQuery, state: FSMContext,
settings: Settings, i18n_data: dict, session: AsyncSession):
async def admin_section_handler(
callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
):
section = callback.data.split(":")[1]
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -188,38 +214,38 @@ async def admin_section_handler(callback: types.CallbackQuery, state: FSMContext
if section == "stats_monitoring":
await callback.message.edit_text(
_("admin_stats_and_monitoring_section"),
reply_markup=get_stats_monitoring_keyboard(i18n, current_lang)
reply_markup=get_stats_monitoring_keyboard(i18n, current_lang),
)
elif section == "user_management":
await callback.message.edit_text(
_("admin_user_management_section"),
reply_markup=get_user_management_keyboard(i18n, current_lang)
reply_markup=get_user_management_keyboard(i18n, current_lang),
)
elif section == "ban_management":
await callback.message.edit_text(
_("admin_ban_management_section"),
reply_markup=get_ban_management_keyboard(i18n, current_lang)
reply_markup=get_ban_management_keyboard(i18n, current_lang),
)
elif section == "promo_marketing":
await callback.message.edit_text(
_("admin_promo_marketing_section"),
reply_markup=get_promo_marketing_keyboard(i18n, current_lang)
reply_markup=get_promo_marketing_keyboard(i18n, current_lang),
)
elif section == "system_functions":
await callback.message.edit_text(
_("admin_system_functions_section"),
reply_markup=get_system_functions_keyboard(i18n, current_lang)
reply_markup=get_system_functions_keyboard(i18n, current_lang),
)
else:
await callback.answer(_("admin_unknown_action"), show_alert=True)
return
await callback.answer()
except Exception as e:
logging.error(f"Error handling admin section {section}: {e}")
await callback.message.answer(
_("error_occurred_try_again"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
await callback.answer()
@@ -236,38 +262,38 @@ async def show_queue_status_handler(callback: types.CallbackQuery, i18n_data: di
queue_manager = get_queue_manager()
if not queue_manager:
from aiogram.utils.keyboard import InlineKeyboardBuilder
await callback.message.edit_text(
"❌ Система очередей не инициализирована",
reply_markup=InlineKeyboardBuilder().button(
text=_("back_to_admin_panel_button"),
callback_data="admin_action:main"
).as_markup()
reply_markup=InlineKeyboardBuilder()
.button(text=_("back_to_admin_panel_button"), callback_data="admin_action:main")
.as_markup(),
)
await callback.answer()
return
try:
stats = queue_manager.get_queue_stats()
message_text = _(
"admin_queue_status_info",
user_queue_size=stats['user_queue_size'],
user_processing="✅ Да" if stats['user_queue_processing'] else "❌ Нет",
user_recent=stats['user_recent_sends'],
group_queue_size=stats['group_queue_size'],
group_processing="✅ Да" if stats['group_queue_processing'] else "❌ Нет",
group_recent=stats['group_recent_sends']
user_queue_size=stats["user_queue_size"],
user_processing="✅ Да" if stats["user_queue_processing"] else "❌ Нет",
user_recent=stats["user_recent_sends"],
group_queue_size=stats["group_queue_size"],
group_processing="✅ Да" if stats["group_queue_processing"] else "❌ Нет",
group_recent=stats["group_recent_sends"],
)
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
await callback.message.edit_text(
message_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
parse_mode="HTML",
)
await callback.answer()
except Exception as e:
logging.error(f"Error getting queue status: {e}")
await callback.answer("❌ Ошибка получения статуса очередей", show_alert=True)
+180 -155
View File
@@ -1,33 +1,33 @@
import csv
import io
import logging
import math
import re
import csv
import io
from datetime import datetime
from aiogram import Router, F, types, Bot
from aiogram.fsm.context import FSMContext
from typing import Optional, List, Dict, Any
from typing import Any, Dict, List, Optional
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import (
get_logs_menu_keyboard,
get_logs_pagination_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.states.admin_states import AdminStates
from config.settings import Settings
from db.dal import message_log_dal, user_dal
from db.models import MessageLog, User
from bot.states.admin_states import AdminStates
from bot.keyboards.inline.admin_keyboards import (
get_logs_menu_keyboard, get_logs_pagination_keyboard,
get_back_to_admin_panel_keyboard)
from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_logs_router")
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
async def display_logs_menu(callback: types.CallbackQuery, i18n_data: dict,
settings: Settings, session: AsyncSession):
async def display_logs_menu(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -37,50 +37,60 @@ async def display_logs_menu(callback: types.CallbackQuery, i18n_data: dict,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
await callback.message.edit_text(text=_(key="admin_logs_menu_title"),
reply_markup=get_logs_menu_keyboard(
i18n, current_lang))
await callback.message.edit_text(
text=_(key="admin_logs_menu_title"),
reply_markup=get_logs_menu_keyboard(i18n, current_lang),
)
except Exception as e:
logging.warning(
f"Failed to edit message for logs menu: {e}. Sending new.")
await callback.message.answer(text=_(key="admin_logs_menu_title"),
reply_markup=get_logs_menu_keyboard(
i18n, current_lang))
logging.warning(f"Failed to edit message for logs menu: {e}. Sending new.")
await callback.message.answer(
text=_(key="admin_logs_menu_title"),
reply_markup=get_logs_menu_keyboard(i18n, current_lang),
)
await callback.answer()
async def _display_formatted_logs(target_message: types.Message,
logs: List[MessageLog],
total_logs: int,
current_page_idx: int,
settings: Settings,
title_key: str,
base_pagination_callback_data: str,
i18n: JsonI18n,
current_lang: str,
title_kwargs: Optional[Dict[str,
Any]] = None):
async def _display_formatted_logs(
target_message: types.Message,
logs: List[MessageLog],
total_logs: int,
current_page_idx: int,
settings: Settings,
title_key: str,
base_pagination_callback_data: str,
i18n: JsonI18n,
current_lang: str,
title_kwargs: Optional[Dict[str, Any]] = None,
):
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
page_size = settings.LOGS_PAGE_SIZE
actual_title_kwargs = title_kwargs or {}
if not logs and total_logs == 0:
text = _(
title_key, current_page=1, total_pages=1, **
actual_title_kwargs) + "\n\n" + _("admin_no_logs_found")
text = (
_(title_key, current_page=1, total_pages=1, **actual_title_kwargs)
+ "\n\n"
+ _("admin_no_logs_found")
)
reply_markup = get_logs_pagination_keyboard(
current_page_idx,
1,
base_pagination_callback_data,
i18n,
current_lang,
back_to_logs_menu=True)
back_to_logs_menu=True,
)
else:
total_pages = math.ceil(total_logs / page_size) if page_size > 0 else 1
text = _(title_key,
current_page=current_page_idx + 1,
total_pages=max(1, total_pages),
**actual_title_kwargs) + "\n"
text = (
_(
title_key,
current_page=current_page_idx + 1,
total_pages=max(1, total_pages),
**actual_title_kwargs,
)
+ "\n"
)
log_entries_text = []
for log_entry_model in logs:
@@ -88,33 +98,40 @@ async def _display_formatted_logs(target_message: types.Message,
if log_entry_model.telegram_first_name:
user_display_parts.append(log_entry_model.telegram_first_name)
if log_entry_model.telegram_username:
user_display_parts.append(
f"(@{log_entry_model.telegram_username})")
user_display_parts.append(f"(@{log_entry_model.telegram_username})")
user_display = " ".join(user_display_parts).strip()
if not user_display:
user_display = _(
"system_or_unknown_user"
) if not log_entry_model.user_id else f"ID: {log_entry_model.user_id}"
user_display = (
_("system_or_unknown_user")
if not log_entry_model.user_id
else f"ID: {log_entry_model.user_id}"
)
user_id_display = str(
log_entry_model.user_id
) if log_entry_model.user_id is not None else "N/A"
user_id_display = (
str(log_entry_model.user_id) if log_entry_model.user_id is not None else "N/A"
)
content_raw = log_entry_model.content or ""
content_preview = (content_raw[:100] +
"...") if len(content_raw) > 100 else (
content_raw or "N/A")
content_preview = (
(content_raw[:100] + "...") if len(content_raw) > 100 else (content_raw or "N/A")
)
timestamp_str_display = log_entry_model.timestamp.strftime(
'%Y-%m-%d %H:%M:%S') if log_entry_model.timestamp else 'N/A'
timestamp_str_display = (
log_entry_model.timestamp.strftime("%Y-%m-%d %H:%M:%S")
if log_entry_model.timestamp
else "N/A"
)
log_entries_text.append(
_("admin_log_entry_format",
timestamp_str=timestamp_str_display,
user_display=user_display,
user_id=user_id_display,
event_type=log_entry_model.event_type or 'N/A',
content_preview=content_preview).replace("\n", "\n "))
_(
"admin_log_entry_format",
timestamp_str=timestamp_str_display,
user_display=user_display,
user_id=user_id_display,
event_type=log_entry_model.event_type or "N/A",
content_preview=content_preview,
).replace("\n", "\n ")
)
text += "\n\n".join(log_entries_text)
reply_markup = get_logs_pagination_keyboard(
current_page_idx,
@@ -122,13 +139,13 @@ async def _display_formatted_logs(target_message: types.Message,
base_pagination_callback_data,
i18n,
current_lang,
back_to_logs_menu=True)
back_to_logs_menu=True,
)
try:
await target_message.edit_text(text,
reply_markup=reply_markup,
parse_mode="HTML",
disable_web_page_preview=True)
await target_message.edit_text(
text, reply_markup=reply_markup, parse_mode="HTML", disable_web_page_preview=True
)
except Exception as e:
logging.warning(
f"Failed to edit message for logs display (len: {len(text)}): {e}. Sending new message(s)."
@@ -136,28 +153,30 @@ async def _display_formatted_logs(target_message: types.Message,
max_chunk_size = 4000
for i in range(0, len(text), max_chunk_size):
chunk = text[i:i + max_chunk_size]
chunk = text[i : i + max_chunk_size]
is_last_chunk = (i + max_chunk_size) >= len(text)
try:
await target_message.answer(
chunk,
reply_markup=reply_markup if is_last_chunk else None,
parse_mode="HTML",
disable_web_page_preview=True)
disable_web_page_preview=True,
)
except Exception as e_chunk:
logging.error(f"Failed to send log chunk: {e_chunk}")
if i == 0:
await target_message.answer(
_("error_displaying_logs_too_long"),
reply_markup=reply_markup if is_last_chunk else None)
reply_markup=reply_markup if is_last_chunk else None,
)
break
@router.callback_query(F.data.startswith("admin_logs:view_all"))
async def view_all_logs_handler(callback: types.CallbackQuery,
settings: Settings, i18n_data: dict,
session: AsyncSession):
async def view_all_logs_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
):
page_idx = 0
parts = callback.data.split(":")
if len(parts) == 3:
@@ -173,7 +192,8 @@ async def view_all_logs_handler(callback: types.CallbackQuery,
return
logs_models = await message_log_dal.get_all_message_logs(
session, settings.LOGS_PAGE_SIZE, page_idx * settings.LOGS_PAGE_SIZE)
session, settings.LOGS_PAGE_SIZE, page_idx * settings.LOGS_PAGE_SIZE
)
total_logs_count = await message_log_dal.count_all_message_logs(session)
await _display_formatted_logs(
@@ -185,35 +205,42 @@ async def view_all_logs_handler(callback: types.CallbackQuery,
title_key="admin_all_logs_title",
base_pagination_callback_data="admin_logs:view_all",
i18n=i18n,
current_lang=current_lang)
current_lang=current_lang,
)
await callback.answer()
@router.callback_query(F.data == "admin_logs:prompt_user")
async def prompt_user_for_logs_handler(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
settings: Settings,
session: AsyncSession):
async def prompt_user_for_logs_handler(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
if not i18n or not callback.message:
await callback.answer("Error preparing user log prompt.",
show_alert=True)
await callback.answer("Error preparing user log prompt.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await callback.message.edit_text(
text=_("admin_prompt_for_user_id_or_username_logs"),
reply_markup=get_logs_menu_keyboard(i18n, current_lang))
reply_markup=get_logs_menu_keyboard(i18n, current_lang),
)
await state.set_state(AdminStates.waiting_for_user_id_for_logs)
await callback.answer()
@router.message(AdminStates.waiting_for_user_id_for_logs, F.text)
async def process_user_id_for_logs_handler(message: types.Message,
state: FSMContext,
settings: Settings, i18n_data: dict,
session: AsyncSession):
async def process_user_id_for_logs_handler(
message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
):
await state.clear()
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -228,18 +255,15 @@ async def process_user_id_for_logs_handler(message: types.Message,
if input_text.isdigit() or (input_text.startswith("-") and input_text[1:].isdigit()):
try:
user_model_for_logs = await user_dal.get_user_by_id(
session, int(input_text))
user_model_for_logs = await user_dal.get_user_by_id(session, int(input_text))
except ValueError:
pass
elif EMAIL_REGEX.match(input_text):
user_model_for_logs = await user_dal.get_user_by_email(session, input_text)
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
user_model_for_logs = await user_dal.get_user_by_username(
session, input_text[1:])
user_model_for_logs = await user_dal.get_user_by_username(session, input_text[1:])
elif USERNAME_REGEX.match(input_text):
user_model_for_logs = await user_dal.get_user_by_username(
session, input_text)
user_model_for_logs = await user_dal.get_user_by_username(session, input_text)
if not user_model_for_logs:
await message.answer(_("admin_log_user_not_found", input=input_text))
@@ -248,12 +272,14 @@ async def process_user_id_for_logs_handler(message: types.Message,
target_user_id = user_model_for_logs.user_id
user_display_name = user_model_for_logs.first_name or (
f"@{user_model_for_logs.username}"
if user_model_for_logs.username else (user_model_for_logs.email or f"ID {target_user_id}"))
if user_model_for_logs.username
else (user_model_for_logs.email or f"ID {target_user_id}")
)
logs_models = await message_log_dal.get_user_message_logs(
session, target_user_id, settings.LOGS_PAGE_SIZE, 0)
total_user_logs_count = await message_log_dal.count_user_message_logs(
session, target_user_id)
session, target_user_id, settings.LOGS_PAGE_SIZE, 0
)
total_user_logs_count = await message_log_dal.count_user_message_logs(session, target_user_id)
await _display_formatted_logs(
target_message=message,
@@ -265,13 +291,14 @@ async def process_user_id_for_logs_handler(message: types.Message,
base_pagination_callback_data=f"admin_logs:view_user:{target_user_id}",
i18n=i18n,
current_lang=current_lang,
title_kwargs={"user_display": user_display_name})
title_kwargs={"user_display": user_display_name},
)
@router.callback_query(F.data.startswith("admin_logs:view_user:"))
async def view_user_logs_paginated_handler(callback: types.CallbackQuery,
settings: Settings, i18n_data: dict,
session: AsyncSession):
async def view_user_logs_paginated_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
):
try:
parts = callback.data.split(":")
target_user_id = int(parts[2])
@@ -286,8 +313,7 @@ async def view_user_logs_paginated_handler(callback: types.CallbackQuery,
await callback.answer("Error processing request.", show_alert=True)
return
user_model_for_logs = await user_dal.get_user_by_id(
session, target_user_id)
user_model_for_logs = await user_dal.get_user_by_id(session, target_user_id)
if not user_model_for_logs:
await callback.message.edit_text("User not found for logs.")
await callback.answer()
@@ -295,13 +321,14 @@ async def view_user_logs_paginated_handler(callback: types.CallbackQuery,
user_display_name = user_model_for_logs.first_name or (
f"@{user_model_for_logs.username}"
if user_model_for_logs.username else (user_model_for_logs.email or f"ID {target_user_id}"))
if user_model_for_logs.username
else (user_model_for_logs.email or f"ID {target_user_id}")
)
logs_models = await message_log_dal.get_user_message_logs(
session, target_user_id, settings.LOGS_PAGE_SIZE,
page_idx * settings.LOGS_PAGE_SIZE)
total_user_logs_count = await message_log_dal.count_user_message_logs(
session, target_user_id)
session, target_user_id, settings.LOGS_PAGE_SIZE, page_idx * settings.LOGS_PAGE_SIZE
)
total_user_logs_count = await message_log_dal.count_user_message_logs(session, target_user_id)
await _display_formatted_logs(
target_message=callback.message,
@@ -313,26 +340,30 @@ async def view_user_logs_paginated_handler(callback: types.CallbackQuery,
base_pagination_callback_data=f"admin_logs:view_user:{target_user_id}",
i18n=i18n,
current_lang=current_lang,
title_kwargs={"user_display": user_display_name})
title_kwargs={"user_display": user_display_name},
)
await callback.answer()
@router.callback_query(F.data == "admin_action:view_logs_menu",
AdminStates.waiting_for_user_id_for_logs)
async def cancel_log_user_input_state_to_menu(callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
i18n_data: dict,
session: AsyncSession):
@router.callback_query(
F.data == "admin_action:view_logs_menu", AdminStates.waiting_for_user_id_for_logs
)
async def cancel_log_user_input_state_to_menu(
callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
):
await state.clear()
await display_logs_menu(callback, i18n_data, settings, session)
@router.callback_query(F.data == "admin_logs:export_csv")
async def export_logs_csv_handler(callback: types.CallbackQuery,
settings: Settings, i18n_data: dict,
session: AsyncSession):
async def export_logs_csv_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
if not i18n or not callback.message:
@@ -340,25 +371,20 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await callback.answer(_(
"admin_logs_csv_export_started"
))
await callback.answer(_("admin_logs_csv_export_started"))
try:
# Get all logs (limit to 10000 for performance)
logs_models = await message_log_dal.get_all_message_logs(
session, limit=10000, offset=0)
logs_models = await message_log_dal.get_all_message_logs(session, limit=10000, offset=0)
if not logs_models:
await callback.message.answer(_(
"admin_logs_csv_no_data"
))
await callback.message.answer(_("admin_logs_csv_no_data"))
return
# Create CSV content
csv_buffer = io.StringIO()
csv_writer = csv.writer(csv_buffer, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL)
csv_writer = csv.writer(csv_buffer, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL)
# Write header
headers = [
_("admin_csv_header_log_id"),
@@ -370,59 +396,58 @@ async def export_logs_csv_handler(callback: types.CallbackQuery,
_("admin_csv_header_content"),
_("admin_csv_header_is_admin_event"),
_("admin_csv_header_target_user_id"),
_("admin_csv_header_raw_update_preview")
_("admin_csv_header_raw_update_preview"),
]
csv_writer.writerow(headers)
# Write data rows
for log in logs_models:
# Format timestamp
timestamp_str = log.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') if log.timestamp else ''
timestamp_str = log.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC") if log.timestamp else ""
# Clean content and raw_update_preview (remove newlines and quotes for CSV)
content_clean = (log.content or '').replace('\n', ' ').replace('\r', ' ').strip()
raw_update_clean = (log.raw_update_preview or '').replace('\n', ' ').replace('\r', ' ').strip()
content_clean = (log.content or "").replace("\n", " ").replace("\r", " ").strip()
raw_update_clean = (
(log.raw_update_preview or "").replace("\n", " ").replace("\r", " ").strip()
)
row = [
log.log_id or '',
log.log_id or "",
timestamp_str,
log.user_id or '',
log.telegram_username or '',
log.telegram_first_name or '',
log.event_type or '',
log.user_id or "",
log.telegram_username or "",
log.telegram_first_name or "",
log.event_type or "",
content_clean,
'Yes' if log.is_admin_event else 'No',
log.target_user_id or '',
raw_update_clean
"Yes" if log.is_admin_event else "No",
log.target_user_id or "",
raw_update_clean,
]
csv_writer.writerow(row)
# Create file
csv_content = csv_buffer.getvalue()
csv_buffer.close()
# Generate filename with current timestamp
now = datetime.now()
filename = f"message_logs_{now.strftime('%Y%m%d_%H%M%S')}.csv"
# Send as document
csv_file = types.BufferedInputFile(
csv_content.encode('utf-8-sig'), # BOM for Excel compatibility
filename=filename
csv_content.encode("utf-8-sig"), # BOM for Excel compatibility
filename=filename,
)
await callback.message.answer_document(
csv_file,
caption=_(
"admin_logs_csv_export_success",
count=len(logs_models),
date=now.strftime('%Y-%m-%d %H:%M:%S')
)
date=now.strftime("%Y-%m-%d %H:%M:%S"),
),
)
except Exception as e:
logging.error(f"Error exporting logs to CSV: {e}", exc_info=True)
await callback.message.answer(_(
"admin_logs_csv_export_failed",
error=str(e)
))
await callback.message.answer(_("admin_logs_csv_export_failed", error=str(e)))
+147 -127
View File
@@ -1,83 +1,89 @@
import logging
import csv
import io
from aiogram import Router, F, types
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from datetime import datetime, timedelta, timezone
from typing import Optional, List
import logging
from datetime import datetime
from typing import List, Optional
from aiogram import F, Router, types
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
from db.dal import payment_dal
from db.models import Payment
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_payments_router")
async def get_payments_with_pagination(session: AsyncSession, page: int = 0,
page_size: int = 10) -> tuple[List[Payment], int]:
async def get_payments_with_pagination(
session: AsyncSession, page: int = 0, page_size: int = 10
) -> tuple[List[Payment], int]:
"""Get payments with pagination and total count."""
offset = page * page_size
# Get total count
total_count = await payment_dal.get_payments_count(session)
# Get payments for current page
payments = await payment_dal.get_recent_payment_logs_with_user(
session, limit=page_size, offset=offset
)
return payments, total_count
def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: Settings) -> str:
"""Format single payment info as text."""
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
pending_statuses = [
'pending',
'pending_yookassa',
'pending_freekassa',
'pending_platega',
'pending_severpay',
'pending_cryptopay',
"pending",
"pending_yookassa",
"pending_freekassa",
"pending_platega",
"pending_severpay",
"pending_cryptopay",
]
status_emoji = "" if payment.status == 'succeeded' else (
"" if payment.status in pending_statuses else ""
status_emoji = (
""
if payment.status == "succeeded"
else ("" if payment.status in pending_statuses else "")
)
user_info = f"User {payment.user_id}"
if payment.user and payment.user.username:
user_info += f" (@{payment.user.username})"
elif payment.user and payment.user.first_name:
user_info += f" ({payment.user.first_name})"
payment_date = payment.created_at.strftime('%Y-%m-%d %H:%M') if payment.created_at else "N/A"
payment_date = payment.created_at.strftime("%Y-%m-%d %H:%M") if payment.created_at else "N/A"
provider_text = {
'yookassa': 'YooKassa',
'telegram_stars': 'Telegram Stars',
'cryptopay': 'CryptoPay',
'freekassa': 'FreeKassa',
'severpay': 'SeverPay',
'platega': 'Platega',
}.get(payment.provider, payment.provider or 'Unknown')
"yookassa": "YooKassa",
"telegram_stars": "Telegram Stars",
"cryptopay": "CryptoPay",
"freekassa": "FreeKassa",
"severpay": "SeverPay",
"platega": "Platega",
}.get(payment.provider, payment.provider or "Unknown")
sale_base = (payment.sale_mode or "").split("@", 1)[0].split("|", 1)[0]
traffic_like = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
if traffic_like:
traffic_val = payment.purchased_gb or payment.subscription_duration_months or 0
traffic_display = str(int(traffic_val)) if float(traffic_val).is_integer() else f"{traffic_val:g}"
traffic_display = (
str(int(traffic_val)) if float(traffic_val).is_integer() else f"{traffic_val:g}"
)
period_line = _("admin_payment_traffic_label", traffic_gb=traffic_display)
else:
period_line = _("admin_payment_months_label", months=payment.subscription_duration_months or 0)
period_line = _(
"admin_payment_months_label", months=payment.subscription_duration_months or 0
)
tariff_line = f"\nTariff: {payment.tariff_key}" if payment.tariff_key else ""
sale_line = f"\nSale mode: {payment.sale_mode}" if payment.sale_mode else ""
return (
f"{status_emoji} <b>{payment.amount} {payment.currency}</b>\n"
f"👤 {user_info}\n"
@@ -89,8 +95,13 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: S
)
async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
settings: Settings, session: AsyncSession, page: int = 0):
async def view_payments_handler(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
page: int = 0,
):
"""Display paginated list of all payments."""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -107,68 +118,77 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
await callback.message.edit_text(
_("admin_no_payments_found"),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
parse_mode="HTML",
)
await callback.answer()
return
# Format payments text
text_parts = [_("admin_payments_header")]
text_parts.append(_("admin_payments_pagination_info",
shown=len(payments),
total=total_count,
current_page=page + 1,
total_pages=total_pages) + "\n")
text_parts.append(
_(
"admin_payments_pagination_info",
shown=len(payments),
total=total_count,
current_page=page + 1,
total_pages=total_pages,
)
+ "\n"
)
for i, payment in enumerate(payments, 1):
text_parts.append(f"<b>{page * page_size + i}.</b> {format_payment_text(payment, i18n, current_lang, settings)}")
text_parts.append(
f"<b>{page * page_size + i}.</b> {format_payment_text(payment, i18n, current_lang, settings)}"
)
text_parts.append("") # Empty line between payments
# Build keyboard with pagination and export
builder = InlineKeyboardBuilder()
# Pagination buttons
nav_buttons = []
if page > 0:
nav_buttons.append(InlineKeyboardButton(text="⬅️", callback_data=f"payments_page:{page-1}"))
nav_buttons.append(
InlineKeyboardButton(text="⬅️", callback_data=f"payments_page:{page - 1}")
)
nav_buttons.append(InlineKeyboardButton(text=f"{page + 1}/{total_pages}", callback_data="noop"))
if page < total_pages - 1:
nav_buttons.append(InlineKeyboardButton(text="➡️", callback_data=f"payments_page:{page+1}"))
nav_buttons.append(
InlineKeyboardButton(text="➡️", callback_data=f"payments_page:{page + 1}")
)
if nav_buttons:
builder.row(*nav_buttons)
# Export and refresh buttons
builder.row(
InlineKeyboardButton(
text=_("admin_export_payments_csv"),
callback_data="payments_export_csv"
text=_("admin_export_payments_csv"), callback_data="payments_export_csv"
),
InlineKeyboardButton(
text=_("admin_refresh_payments"),
callback_data=f"payments_page:{page}"
text=_("admin_refresh_payments"), callback_data=f"payments_page:{page}"
),
)
# Back button
builder.row(
InlineKeyboardButton(
text=_("back_to_admin_panel_button"), callback_data="admin_section:stats_monitoring"
)
)
# Back button
builder.row(InlineKeyboardButton(
text=_("back_to_admin_panel_button"),
callback_data="admin_section:stats_monitoring"
))
await callback.message.edit_text(
"\n".join(text_parts),
reply_markup=builder.as_markup(),
parse_mode="HTML"
"\n".join(text_parts), reply_markup=builder.as_markup(), parse_mode="HTML"
)
await callback.answer()
@router.callback_query(F.data.startswith("payments_page:"))
async def payments_pagination_handler(callback: types.CallbackQuery, i18n_data: dict,
settings: Settings, session: AsyncSession):
async def payments_pagination_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
"""Handle pagination for payments list."""
try:
page = int(callback.data.split(":")[1])
@@ -178,8 +198,9 @@ async def payments_pagination_handler(callback: types.CallbackQuery, i18n_data:
@router.callback_query(F.data == "payments_export_csv")
async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data: dict,
settings: Settings, session: AsyncSession):
async def export_payments_csv_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
"""Export all successful payments to CSV file."""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -191,86 +212,85 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
try:
# Get all successful payments
all_payments = await payment_dal.get_all_succeeded_payments_with_user(session)
if not all_payments:
await callback.answer(
_("admin_no_payments_to_export"),
show_alert=True
)
await callback.answer(_("admin_no_payments_to_export"), show_alert=True)
return
# Create CSV in memory
output = io.StringIO()
writer = csv.writer(output)
# Write header
writer.writerow([
_("admin_csv_payment_id"),
_("admin_csv_user_id"),
_("admin_csv_username"),
_("admin_csv_first_name"),
_("admin_csv_amount"),
_("admin_csv_currency"),
_("admin_csv_provider"),
_("admin_csv_status"),
_("admin_csv_description"),
_("admin_csv_units"),
"sale_mode",
"tariff_key",
"purchased_gb",
_("admin_csv_created_at"),
_("admin_csv_provider_payment_id")
])
writer.writerow(
[
_("admin_csv_payment_id"),
_("admin_csv_user_id"),
_("admin_csv_username"),
_("admin_csv_first_name"),
_("admin_csv_amount"),
_("admin_csv_currency"),
_("admin_csv_provider"),
_("admin_csv_status"),
_("admin_csv_description"),
_("admin_csv_units"),
"sale_mode",
"tariff_key",
"purchased_gb",
_("admin_csv_created_at"),
_("admin_csv_provider_payment_id"),
]
)
# Write payment data
for payment in all_payments:
units_val = payment.purchased_gb or payment.subscription_duration_months or ""
if (payment.purchased_gb is not None) and units_val not in ("", None):
try:
units_val = str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
units_val = (
str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
)
except Exception:
units_val = payment.purchased_gb or payment.subscription_duration_months or ""
writer.writerow([
payment.payment_id,
payment.user_id,
payment.user.username if payment.user and payment.user.username else "",
payment.user.first_name if payment.user and payment.user.first_name else "",
payment.amount,
payment.currency,
payment.provider or "",
payment.status,
payment.description or "",
units_val,
payment.sale_mode or "",
payment.tariff_key or "",
payment.purchased_gb or "",
payment.created_at.strftime('%Y-%m-%d %H:%M:%S') if payment.created_at else "",
payment.provider_payment_id or ""
])
writer.writerow(
[
payment.payment_id,
payment.user_id,
payment.user.username if payment.user and payment.user.username else "",
payment.user.first_name if payment.user and payment.user.first_name else "",
payment.amount,
payment.currency,
payment.provider or "",
payment.status,
payment.description or "",
units_val,
payment.sale_mode or "",
payment.tariff_key or "",
payment.purchased_gb or "",
payment.created_at.strftime("%Y-%m-%d %H:%M:%S") if payment.created_at else "",
payment.provider_payment_id or "",
]
)
# Prepare file
csv_content = output.getvalue().encode('utf-8-sig') # UTF-8 with BOM for Excel
csv_content = output.getvalue().encode("utf-8-sig") # UTF-8 with BOM for Excel
output.close()
# Generate filename with current date
current_time = datetime.now().strftime('%Y-%m-%d_%H-%M')
current_time = datetime.now().strftime("%Y-%m-%d_%H-%M")
filename = f"payments_export_{current_time}.csv"
# Send file
from aiogram.types import BufferedInputFile
file = BufferedInputFile(csv_content, filename=filename)
await callback.message.reply_document(
document=file,
caption=_("admin_payments_export_success",
count=len(all_payments))
document=file, caption=_("admin_payments_export_success", count=len(all_payments))
)
await callback.answer(
_("admin_export_sent"),
show_alert=False
)
await callback.answer(_("admin_export_sent"), show_alert=False)
except Exception as e:
logging.error(f"Failed to export payments CSV: {e}", exc_info=True)
await callback.answer(f"❌ Ошибка экспорта: {str(e)}", show_alert=True)
+2 -4
View File
@@ -1,8 +1,6 @@
from aiogram import Router
from . import create
from . import manage
from . import bulk
from . import bulk, create, manage
promo_router_aggregate = Router(name="promo_features_router")
@@ -10,4 +8,4 @@ promo_router_aggregate.include_router(create.router)
promo_router_aggregate.include_router(manage.router)
promo_router_aggregate.include_router(bulk.router)
__all__ = ("promo_router_aggregate",)
__all__ = ("promo_router_aggregate",)
+195 -202
View File
@@ -1,54 +1,59 @@
import csv
import io
import logging
import random
import string
import csv
import io
from aiogram import Router, F, types
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from datetime import datetime, timedelta, timezone
from typing import Optional
from aiogram import F, Router, types
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import (
get_admin_panel_keyboard,
get_back_to_admin_panel_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.states.admin_states import AdminStates
from config.settings import Settings
from db.dal import promo_code_dal
from bot.states.admin_states import AdminStates
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from bot.middlewares.i18n import JsonI18n
router = Router(name="promo_bulk_router")
async def create_bulk_promo_prompt_handler(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
settings: Settings,
session: AsyncSession):
async def create_bulk_promo_prompt_handler(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
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("Error preparing bulk promo creation.",
show_alert=True)
await callback.answer("Error preparing bulk promo creation.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
# Step 1: Ask for quantity
prompt_text = _(
"admin_bulk_promo_step1_quantity"
)
prompt_text = _("admin_bulk_promo_step1_quantity")
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML")
parse_mode="HTML",
)
except Exception as e:
logging.warning(
f"Could not edit message for bulk promo prompt: {e}. Sending new.")
logging.warning(f"Could not edit message for bulk promo prompt: {e}. Sending new.")
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML")
parse_mode="HTML",
)
await callback.answer()
await state.set_state(AdminStates.waiting_for_bulk_promo_quantity)
@@ -56,15 +61,14 @@ async def create_bulk_promo_prompt_handler(callback: types.CallbackQuery,
def generate_unique_promo_code(length: int = 8) -> str:
"""Generate a unique random promo code"""
characters = string.ascii_uppercase + string.digits
return ''.join(random.choice(characters) for _ in range(length))
return "".join(random.choice(characters) for _ in range(length))
# Step 1: Process quantity
@router.message(AdminStates.waiting_for_bulk_promo_quantity, F.text)
async def process_bulk_promo_quantity_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings):
async def process_bulk_promo_quantity_handler(
message: types.Message, state: FSMContext, i18n_data: dict, settings: Settings
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
@@ -75,30 +79,23 @@ async def process_bulk_promo_quantity_handler(message: types.Message,
try:
quantity = int(message.text.strip())
if not (1 <= quantity <= 100):
await message.answer(_(
"admin_bulk_promo_invalid_quantity"
))
await message.answer(_("admin_bulk_promo_invalid_quantity"))
return
await state.update_data(quantity=quantity)
# Step 2: Ask for bonus days
prompt_text = _(
"admin_bulk_promo_step2_bonus_days",
quantity=quantity
)
prompt_text = _("admin_bulk_promo_step2_bonus_days", quantity=quantity)
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
parse_mode="HTML",
)
await state.set_state(AdminStates.waiting_for_bulk_promo_bonus_days)
except ValueError:
await message.answer(_(
"admin_promo_invalid_number"
))
await message.answer(_("admin_promo_invalid_number"))
except Exception as e:
logging.error(f"Error processing bulk promo quantity: {e}")
await message.answer(_("error_occurred_try_again"))
@@ -106,10 +103,9 @@ async def process_bulk_promo_quantity_handler(message: types.Message,
# Step 2: Process bonus days
@router.message(AdminStates.waiting_for_bulk_promo_bonus_days, F.text)
async def process_bulk_promo_bonus_days_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings):
async def process_bulk_promo_bonus_days_handler(
message: types.Message, state: FSMContext, i18n_data: dict, settings: Settings
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
@@ -120,32 +116,28 @@ async def process_bulk_promo_bonus_days_handler(message: types.Message,
try:
bonus_days = int(message.text.strip())
if not (1 <= bonus_days <= 365):
await message.answer(_(
"admin_promo_invalid_bonus_days"
))
await message.answer(_("admin_promo_invalid_bonus_days"))
return
await state.update_data(bonus_days=bonus_days)
# Step 3: Ask for max activations
data = await state.get_data()
prompt_text = _(
"admin_bulk_promo_step3_max_activations",
quantity=data.get("quantity"),
bonus_days=bonus_days
bonus_days=bonus_days,
)
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
parse_mode="HTML",
)
await state.set_state(AdminStates.waiting_for_bulk_promo_max_activations)
except ValueError:
await message.answer(_(
"admin_promo_invalid_number"
))
await message.answer(_("admin_promo_invalid_number"))
except Exception as e:
logging.error(f"Error processing bulk promo bonus days: {e}")
await message.answer(_("error_occurred_try_again"))
@@ -153,10 +145,9 @@ async def process_bulk_promo_bonus_days_handler(message: types.Message,
# Step 3: Process max activations
@router.message(AdminStates.waiting_for_bulk_promo_max_activations, F.text)
async def process_bulk_promo_max_activations_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings):
async def process_bulk_promo_max_activations_handler(
message: types.Message, state: FSMContext, i18n_data: dict, settings: Settings
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
@@ -167,76 +158,71 @@ async def process_bulk_promo_max_activations_handler(message: types.Message,
try:
max_activations = int(message.text.strip())
if not (1 <= max_activations <= 10000):
await message.answer(_(
"admin_promo_invalid_max_activations"
))
await message.answer(_("admin_promo_invalid_max_activations"))
return
await state.update_data(max_activations=max_activations)
# Step 4: Ask for validity
data = await state.get_data()
prompt_text = _(
"admin_bulk_promo_step4_validity",
quantity=data.get("quantity"),
bonus_days=data.get("bonus_days"),
max_activations=max_activations
max_activations=max_activations,
)
# Create keyboard for validity options
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=_("admin_promo_unlimited_validity"),
callback_data="bulk_promo_unlimited_validity"
callback_data="bulk_promo_unlimited_validity",
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_set_validity_days"),
callback_data="bulk_promo_set_validity"
text=_("admin_promo_set_validity_days"), callback_data="bulk_promo_set_validity"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_back_to_panel"),
callback_data="admin_action:main"
)
)
await message.answer(
prompt_text,
reply_markup=builder.as_markup(),
parse_mode="HTML"
InlineKeyboardButton(text=_("admin_back_to_panel"), callback_data="admin_action:main")
)
await message.answer(prompt_text, reply_markup=builder.as_markup(), parse_mode="HTML")
await state.set_state(AdminStates.waiting_for_bulk_promo_validity_days)
except ValueError:
await message.answer(_(
"admin_promo_invalid_number"
))
await message.answer(_("admin_promo_invalid_number"))
except Exception as e:
logging.error(f"Error processing bulk promo max activations: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 4: Handle unlimited validity
@router.callback_query(F.data == "bulk_promo_unlimited_validity", StateFilter(AdminStates.waiting_for_bulk_promo_validity_days))
async def process_bulk_promo_unlimited_validity(callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession):
@router.callback_query(
F.data == "bulk_promo_unlimited_validity",
StateFilter(AdminStates.waiting_for_bulk_promo_validity_days),
)
async def process_bulk_promo_unlimited_validity(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
await state.update_data(validity_days=None)
await create_bulk_promo_codes_final(callback, state, i18n_data, settings, session)
# Step 4: Handle set validity
@router.callback_query(F.data == "bulk_promo_set_validity", StateFilter(AdminStates.waiting_for_bulk_promo_validity_days))
async def process_bulk_promo_set_validity(callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings):
@router.callback_query(
F.data == "bulk_promo_set_validity",
StateFilter(AdminStates.waiting_for_bulk_promo_validity_days),
)
async def process_bulk_promo_set_validity(
callback: types.CallbackQuery, state: FSMContext, i18n_data: dict, settings: Settings
):
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:
@@ -249,31 +235,33 @@ async def process_bulk_promo_set_validity(callback: types.CallbackQuery,
"admin_bulk_promo_enter_validity_days",
quantity=data.get("quantity"),
bonus_days=data.get("bonus_days"),
max_activations=data.get("max_activations")
max_activations=data.get("max_activations"),
)
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
parse_mode="HTML",
)
except Exception:
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
parse_mode="HTML",
)
await callback.answer()
# Step 4: Process validity days
@router.message(AdminStates.waiting_for_bulk_promo_validity_days, F.text)
async def process_bulk_promo_validity_days_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession):
async def process_bulk_promo_validity_days_handler(
message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
@@ -284,28 +272,26 @@ async def process_bulk_promo_validity_days_handler(message: types.Message,
try:
validity_days = int(message.text.strip())
if not (1 <= validity_days <= 365):
await message.answer(_(
"admin_promo_invalid_validity_days"
))
await message.answer(_("admin_promo_invalid_validity_days"))
return
await state.update_data(validity_days=validity_days)
await create_bulk_promo_codes_final(message, state, i18n_data, settings, session)
except ValueError:
await message.answer(_(
"admin_promo_invalid_number"
))
await message.answer(_("admin_promo_invalid_number"))
except Exception as e:
logging.error(f"Error processing bulk promo validity days: {e}")
await message.answer(_("error_occurred_try_again"))
async def create_bulk_promo_codes_final(callback_or_message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession):
async def create_bulk_promo_codes_final(
callback_or_message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
"""Final step - create multiple promo codes in database"""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -316,14 +302,11 @@ async def create_bulk_promo_codes_final(callback_or_message,
try:
data = await state.get_data()
quantity = data["quantity"]
# Show progress message
progress_text = _(
"admin_bulk_promo_creating",
quantity=quantity
)
if hasattr(callback_or_message, 'message'): # CallbackQuery
progress_text = _("admin_bulk_promo_creating", quantity=quantity)
if hasattr(callback_or_message, "message"): # CallbackQuery
try:
await callback_or_message.message.edit_text(progress_text, parse_mode="HTML")
except Exception:
@@ -331,26 +314,28 @@ async def create_bulk_promo_codes_final(callback_or_message,
await callback_or_message.answer()
else: # Message
await callback_or_message.answer(progress_text, parse_mode="HTML")
# Generate and create promo codes
created_codes = []
failed_codes = []
for i in range(quantity):
try:
# Generate unique code
attempts = 0
while attempts < 10: # Max 10 attempts to generate unique code
promo_code = generate_unique_promo_code()
existing_promo = await promo_code_dal.get_promo_code_by_code(session, promo_code)
existing_promo = await promo_code_dal.get_promo_code_by_code(
session, promo_code
)
if not existing_promo:
break
attempts += 1
if attempts >= 10:
failed_codes.append(f"Код #{i+1} (не удалось сгенерировать уникальный)")
failed_codes.append(f"Код #{i + 1} (не удалось сгенерировать уникальный)")
continue
# Prepare promo code data
promo_data = {
"code": promo_code,
@@ -359,156 +344,162 @@ async def create_bulk_promo_codes_final(callback_or_message,
"current_activations": 0,
"is_active": True,
"created_by_admin_id": callback_or_message.from_user.id,
"created_at": datetime.now(timezone.utc)
"created_at": datetime.now(timezone.utc),
}
# Set validity
if data.get("validity_days"):
promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=data["validity_days"])
promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(
days=data["validity_days"]
)
else:
promo_data["valid_until"] = None
# Create promo code
created_promo = await promo_code_dal.create_promo_code(session, promo_data)
created_codes.append(created_promo.code)
except Exception as e:
logging.error(f"Error creating bulk promo code #{i+1}: {e}")
failed_codes.append(f"Код #{i+1} ({str(e)[:50]})")
logging.error(f"Error creating bulk promo code #{i + 1}: {e}")
failed_codes.append(f"Код #{i + 1} ({str(e)[:50]})")
await session.commit()
# Success message
success_lines = [
_(
"admin_bulk_promo_created_title"
),
_(
"admin_bulk_promo_created_stats",
created=len(created_codes),
total=quantity
)
_("admin_bulk_promo_created_title"),
_("admin_bulk_promo_created_stats", created=len(created_codes), total=quantity),
]
if data.get("validity_days"):
validity_text = f"{data['validity_days']} дней"
else:
validity_text = _("admin_promo_unlimited")
success_lines.append(
_(
"admin_bulk_promo_settings",
bonus_days=data["bonus_days"],
max_activations=data["max_activations"],
validity=validity_text
validity=validity_text,
)
)
# Create CSV file with promo codes if any were created
csv_file = None
if created_codes:
success_lines.append(f"\n🎟 <b>Создано {len(created_codes)} промокодов</b>")
success_lines.append("📄 CSV файл с промокодами отправлен отдельным сообщением")
# Create CSV file
output = io.StringIO()
writer = csv.writer(output)
# CSV headers
writer.writerow([
"Промокод", "Бонусные дни", "Макс. активации", "Действителен до",
"Команда для старта", "Ссылка для активации"
])
writer.writerow(
[
"Промокод",
"Бонусные дни",
"Макс. активации",
"Действителен до",
"Команда для старта",
"Ссылка для активации",
]
)
# Get real bot username
bot_username = 'your_bot' # fallback
bot_username = "your_bot" # fallback
try:
if hasattr(callback_or_message, 'message'):
if hasattr(callback_or_message, "message"):
bot = callback_or_message.message.bot
else:
bot = callback_or_message.bot
bot_info = await bot.get_me()
bot_username = bot_info.username or 'your_bot'
bot_username = bot_info.username or "your_bot"
except Exception as e:
logging.error(f"Failed to get bot username for CSV links: {e}")
bot_username = 'your_bot'
bot_username = "your_bot"
for code in created_codes:
# Determine validity info
if data.get("validity_days"):
valid_until = (datetime.now(timezone.utc) + timedelta(days=data["validity_days"])).strftime("%Y-%m-%d %H:%M:%S")
valid_until = (
datetime.now(timezone.utc) + timedelta(days=data["validity_days"])
).strftime("%Y-%m-%d %H:%M:%S")
else:
valid_until = "Без ограничений"
start_command = f"/start promo_{code}"
telegram_link = f"https://t.me/{bot_username}?start=promo_{code}"
writer.writerow([
code,
data["bonus_days"],
data["max_activations"],
valid_until,
start_command,
telegram_link
])
writer.writerow(
[
code,
data["bonus_days"],
data["max_activations"],
valid_until,
start_command,
telegram_link,
]
)
output.seek(0)
# Create file for sending
filename = f"bulk_promo_codes_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
csv_file = types.BufferedInputFile(
output.getvalue().encode('utf-8-sig'), # BOM for correct Excel display
filename=filename
output.getvalue().encode("utf-8-sig"), # BOM for correct Excel display
filename=filename,
)
if failed_codes:
success_lines.append(f"\n❌ <b>Ошибки ({len(failed_codes)}):</b>")
for error in failed_codes[:5]: # Show first 5 errors
success_lines.append(error)
if len(failed_codes) > 5:
success_lines.append(f"... и еще {len(failed_codes) - 5} ошибок")
success_text = "\n".join(success_lines)
if hasattr(callback_or_message, 'message'): # CallbackQuery
if hasattr(callback_or_message, "message"): # CallbackQuery
try:
await callback_or_message.message.edit_text(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
parse_mode="HTML",
)
message_obj = callback_or_message.message
except Exception:
message_obj = await callback_or_message.message.answer(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
parse_mode="HTML",
)
await callback_or_message.answer()
else: # Message
message_obj = await callback_or_message.answer(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
parse_mode="HTML",
)
# Send CSV file if created
if csv_file:
csv_caption = f"📄 Промокоды для массового создания\n💫 Всего: {len(created_codes)} промокодов\n🎁 Бонус: {data['bonus_days']} дней каждый"
await message_obj.answer_document(csv_file, caption=csv_caption)
await state.clear()
except Exception as e:
logging.error(f"Error creating bulk promo codes: {e}")
error_text = _("error_occurred_try_again")
if hasattr(callback_or_message, 'message'): # CallbackQuery
if hasattr(callback_or_message, "message"): # CallbackQuery
await callback_or_message.message.answer(error_text)
else: # Message
await callback_or_message.answer(error_text)
await state.clear()
@@ -522,11 +513,13 @@ async def create_bulk_promo_codes_final(callback_or_message,
AdminStates.waiting_for_bulk_promo_validity_days,
),
)
async def cancel_bulk_promo_creation_state_to_menu(callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
i18n_data: dict,
session: AsyncSession):
async def cancel_bulk_promo_creation_state_to_menu(
callback: types.CallbackQuery,
state: FSMContext,
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")
if not i18n or not callback.message:
@@ -537,13 +530,13 @@ async def cancel_bulk_promo_creation_state_to_menu(callback: types.CallbackQuery
try:
await callback.message.edit_text(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
except Exception:
await callback.message.answer(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
await callback.answer(_("admin_bulk_promo_creation_cancelled"))
await state.clear()
+143 -149
View File
@@ -1,61 +1,68 @@
import logging
from aiogram import Router, F, types
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from datetime import datetime, timedelta, timezone
from typing import Optional
from aiogram import F, Router, types
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import (
get_admin_panel_keyboard,
get_back_to_admin_panel_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.states.admin_states import AdminStates
from config.settings import Settings
from db.dal import promo_code_dal
from bot.states.admin_states import AdminStates
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from bot.middlewares.i18n import JsonI18n
router = Router(name="promo_create_router")
async def create_promo_prompt_handler(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
settings: Settings,
session: AsyncSession):
async def create_promo_prompt_handler(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
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("Error preparing promo creation.",
show_alert=True)
await callback.answer("Error preparing promo creation.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
# Step 1: Ask for promo code
prompt_text = _(
"admin_promo_step1_code"
)
prompt_text = _("admin_promo_step1_code")
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML")
parse_mode="HTML",
)
except Exception as e:
logging.warning(
f"Could not edit message for promo prompt: {e}. Sending new.")
logging.warning(f"Could not edit message for promo prompt: {e}. Sending new.")
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML")
parse_mode="HTML",
)
await callback.answer()
await state.set_state(AdminStates.waiting_for_promo_code)
# Step 1: Process promo code
@router.message(AdminStates.waiting_for_promo_code, F.text)
async def process_promo_code_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession):
async def process_promo_code_handler(
message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
@@ -66,34 +73,27 @@ async def process_promo_code_handler(message: types.Message,
try:
code_str = message.text.strip().upper()
if not (3 <= len(code_str) <= 30 and code_str.isalnum()):
await message.answer(_(
"admin_promo_invalid_code_format"
))
await message.answer(_("admin_promo_invalid_code_format"))
return
# Check if code already exists
existing_promo = await promo_code_dal.get_promo_code_by_code(session, code_str)
if existing_promo:
await message.answer(_(
"admin_promo_code_already_exists"
))
await message.answer(_("admin_promo_code_already_exists"))
return
await state.update_data(promo_code=code_str)
# Step 2: Ask for bonus days
prompt_text = _(
"admin_promo_step2_bonus_days",
code=code_str
)
prompt_text = _("admin_promo_step2_bonus_days", code=code_str)
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
parse_mode="HTML",
)
await state.set_state(AdminStates.waiting_for_promo_bonus_days)
except Exception as e:
logging.error(f"Error processing promo code: {e}")
await message.answer(_("error_occurred_try_again"))
@@ -101,10 +101,9 @@ async def process_promo_code_handler(message: types.Message,
# Step 2: Process bonus days
@router.message(AdminStates.waiting_for_promo_bonus_days, F.text)
async def process_promo_bonus_days_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings):
async def process_promo_bonus_days_handler(
message: types.Message, state: FSMContext, i18n_data: dict, settings: Settings
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
@@ -115,32 +114,26 @@ async def process_promo_bonus_days_handler(message: types.Message,
try:
bonus_days = int(message.text.strip())
if not (1 <= bonus_days <= 365):
await message.answer(_(
"admin_promo_invalid_bonus_days"
))
await message.answer(_("admin_promo_invalid_bonus_days"))
return
await state.update_data(bonus_days=bonus_days)
# Step 3: Ask for max activations
data = await state.get_data()
prompt_text = _(
"admin_promo_step3_max_activations",
code=data.get("promo_code"),
bonus_days=bonus_days
"admin_promo_step3_max_activations", code=data.get("promo_code"), bonus_days=bonus_days
)
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
parse_mode="HTML",
)
await state.set_state(AdminStates.waiting_for_promo_max_activations)
except ValueError:
await message.answer(_(
"admin_promo_invalid_number"
))
await message.answer(_("admin_promo_invalid_number"))
except Exception as e:
logging.error(f"Error processing promo bonus days: {e}")
await message.answer(_("error_occurred_try_again"))
@@ -148,10 +141,9 @@ async def process_promo_bonus_days_handler(message: types.Message,
# Step 3: Process max activations
@router.message(AdminStates.waiting_for_promo_max_activations, F.text)
async def process_promo_max_activations_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings):
async def process_promo_max_activations_handler(
message: types.Message, state: FSMContext, i18n_data: dict, settings: Settings
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
@@ -162,76 +154,68 @@ async def process_promo_max_activations_handler(message: types.Message,
try:
max_activations = int(message.text.strip())
if not (1 <= max_activations <= 10000):
await message.answer(_(
"admin_promo_invalid_max_activations"
))
await message.answer(_("admin_promo_invalid_max_activations"))
return
await state.update_data(max_activations=max_activations)
# Step 4: Ask for validity
data = await state.get_data()
prompt_text = _(
"admin_promo_step4_validity",
code=data.get("promo_code"),
bonus_days=data.get("bonus_days"),
max_activations=max_activations
max_activations=max_activations,
)
# Create keyboard for validity options
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=_("admin_promo_unlimited_validity"),
callback_data="promo_unlimited_validity"
text=_("admin_promo_unlimited_validity"), callback_data="promo_unlimited_validity"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_set_validity_days"),
callback_data="promo_set_validity"
text=_("admin_promo_set_validity_days"), callback_data="promo_set_validity"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_back_to_panel"),
callback_data="admin_action:main"
)
)
await message.answer(
prompt_text,
reply_markup=builder.as_markup(),
parse_mode="HTML"
InlineKeyboardButton(text=_("admin_back_to_panel"), callback_data="admin_action:main")
)
await message.answer(prompt_text, reply_markup=builder.as_markup(), parse_mode="HTML")
await state.set_state(AdminStates.waiting_for_promo_validity_days)
except ValueError:
await message.answer(_(
"admin_promo_invalid_number"
))
await message.answer(_("admin_promo_invalid_number"))
except Exception as e:
logging.error(f"Error processing promo max activations: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 4: Handle unlimited validity
@router.callback_query(F.data == "promo_unlimited_validity", StateFilter(AdminStates.waiting_for_promo_validity_days))
async def process_promo_unlimited_validity(callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession):
@router.callback_query(
F.data == "promo_unlimited_validity", StateFilter(AdminStates.waiting_for_promo_validity_days)
)
async def process_promo_unlimited_validity(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
await state.update_data(validity_days=None)
await create_promo_code_final(callback, state, i18n_data, settings, session)
# Step 4: Handle set validity
@router.callback_query(F.data == "promo_set_validity", StateFilter(AdminStates.waiting_for_promo_validity_days))
async def process_promo_set_validity(callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings):
@router.callback_query(
F.data == "promo_set_validity", StateFilter(AdminStates.waiting_for_promo_validity_days)
)
async def process_promo_set_validity(
callback: types.CallbackQuery, state: FSMContext, i18n_data: dict, settings: Settings
):
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:
@@ -244,31 +228,33 @@ async def process_promo_set_validity(callback: types.CallbackQuery,
"admin_promo_enter_validity_days",
code=data.get("promo_code"),
bonus_days=data.get("bonus_days"),
max_activations=data.get("max_activations")
max_activations=data.get("max_activations"),
)
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
parse_mode="HTML",
)
except Exception:
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
parse_mode="HTML",
)
await callback.answer()
# Step 4: Process validity days
@router.message(AdminStates.waiting_for_promo_validity_days, F.text)
async def process_promo_validity_days_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession):
async def process_promo_validity_days_handler(
message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
@@ -279,28 +265,26 @@ async def process_promo_validity_days_handler(message: types.Message,
try:
validity_days = int(message.text.strip())
if not (1 <= validity_days <= 365):
await message.answer(_(
"admin_promo_invalid_validity_days"
))
await message.answer(_("admin_promo_invalid_validity_days"))
return
await state.update_data(validity_days=validity_days)
await create_promo_code_final(message, state, i18n_data, settings, session)
except ValueError:
await message.answer(_(
"admin_promo_invalid_number"
))
await message.answer(_("admin_promo_invalid_number"))
except Exception as e:
logging.error(f"Error processing promo validity days: {e}")
await message.answer(_("error_occurred_try_again"))
async def create_promo_code_final(callback_or_message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession):
async def create_promo_code_final(
callback_or_message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
"""Final step - create the promo code in database"""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -310,7 +294,7 @@ async def create_promo_code_final(callback_or_message,
try:
data = await state.get_data()
# Prepare promo code data
promo_data = {
"code": data["promo_code"],
@@ -319,65 +303,73 @@ async def create_promo_code_final(callback_or_message,
"current_activations": 0,
"is_active": True,
"created_by_admin_id": callback_or_message.from_user.id,
"created_at": datetime.now(timezone.utc)
"created_at": datetime.now(timezone.utc),
}
# Set validity
if data.get("validity_days"):
promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=data["validity_days"])
promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(
days=data["validity_days"]
)
else:
promo_data["valid_until"] = None
# Create promo code
created_promo = await promo_code_dal.create_promo_code(session, promo_data)
await session.commit()
# Log successful creation
logging.info(f"Promo code '{data['promo_code']}' created with ID {created_promo.promo_code_id}")
logging.info(
f"Promo code '{data['promo_code']}' created with ID {created_promo.promo_code_id}"
)
# Success message
valid_until_str = _("admin_promo_unlimited") if not data.get("validity_days") else f"{data['validity_days']} дней"
valid_until_str = (
_("admin_promo_unlimited")
if not data.get("validity_days")
else f"{data['validity_days']} дней"
)
success_text = _(
"admin_promo_created_success",
code=data["promo_code"],
bonus_days=data["bonus_days"],
max_activations=data["max_activations"],
valid_until_str=valid_until_str
valid_until_str=valid_until_str,
)
if hasattr(callback_or_message, 'message'): # CallbackQuery
if hasattr(callback_or_message, "message"): # CallbackQuery
try:
await callback_or_message.message.edit_text(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
parse_mode="HTML",
)
except Exception:
await callback_or_message.message.answer(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
parse_mode="HTML",
)
await callback_or_message.answer()
else: # Message
await callback_or_message.answer(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
parse_mode="HTML",
)
await state.clear()
except Exception as e:
logging.error(f"Error creating promo code: {e}")
error_text = _("error_occurred_try_again")
if hasattr(callback_or_message, 'message'): # CallbackQuery
if hasattr(callback_or_message, "message"): # CallbackQuery
await callback_or_message.message.answer(error_text)
await callback_or_message.answer()
else: # Message
await callback_or_message.answer(error_text)
await state.clear()
@@ -391,11 +383,13 @@ async def create_promo_code_final(callback_or_message,
AdminStates.waiting_for_promo_validity_days,
),
)
async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
i18n_data: dict,
session: AsyncSession):
async def cancel_promo_creation_state_to_menu(
callback: types.CallbackQuery,
state: FSMContext,
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")
if not i18n or not callback.message:
@@ -406,13 +400,13 @@ async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
try:
await callback.message.edit_text(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
except Exception:
await callback.message.answer(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
await callback.answer(_("admin_promo_creation_cancelled"))
await state.clear()
+307 -130
View File
@@ -1,20 +1,20 @@
import logging
import csv
import io
from aiogram import Router, F, types
from datetime import datetime, timedelta, timezone
from typing import Optional
from aiogram import F, Router, types
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from datetime import datetime, timedelta, timezone
from typing import Optional, List
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings, get_settings
from db.dal import promo_code_dal
from db.models import PromoCode, PromoCodeActivation
from bot.states.admin_states import AdminStates
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.states.admin_states import AdminStates
from config.settings import Settings
from db.dal import promo_code_dal
from db.models import PromoCode
router = Router(name="promo_manage_router")
@@ -22,7 +22,7 @@ router = Router(name="promo_manage_router")
def get_promo_status_emoji_and_text(promo: PromoCode, i18n: JsonI18n, current_lang: str):
"""Determine promo code status and return emoji + text"""
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if promo.valid_until and promo.valid_until < datetime.now(timezone.utc):
return "", _("admin_promo_status_expired")
elif promo.current_activations >= promo.max_activations:
@@ -33,7 +33,9 @@ def get_promo_status_emoji_and_text(promo: PromoCode, i18n: JsonI18n, current_la
return "🚫", _("admin_promo_status_inactive")
async def get_promo_detail_text_and_keyboard(promo_id: int, session: AsyncSession, i18n: JsonI18n, current_lang: str):
async def get_promo_detail_text_and_keyboard(
promo_id: int, session: AsyncSession, i18n: JsonI18n, current_lang: str
):
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
if not promo:
@@ -47,27 +49,56 @@ async def get_promo_detail_text_and_keyboard(promo_id: int, session: AsyncSessio
created = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A"
text = "\n".join([
_("admin_promo_card_title", code=promo.code),
_("admin_promo_card_bonus_days", days=promo.bonus_days),
_("admin_promo_card_activations", current=promo.current_activations, max=promo.max_activations),
_("admin_promo_card_validity", validity=validity),
_("admin_promo_card_status", status=status),
_("admin_promo_card_created", created=created),
_("admin_promo_card_created_by", creator=promo.created_by_admin_id)
])
text = "\n".join(
[
_("admin_promo_card_title", code=promo.code),
_("admin_promo_card_bonus_days", days=promo.bonus_days),
_(
"admin_promo_card_activations",
current=promo.current_activations,
max=promo.max_activations,
),
_("admin_promo_card_validity", validity=validity),
_("admin_promo_card_status", status=status),
_("admin_promo_card_created", created=created),
_("admin_promo_card_created_by", creator=promo.created_by_admin_id),
]
)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_button"), callback_data=f"promo_edit_select:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_toggle_status_button"), callback_data=f"promo_toggle:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_view_activations_button"), callback_data=f"promo_activations:{promo_id}:0"))
builder.row(InlineKeyboardButton(text=_("admin_promo_delete_button"), callback_data=f"promo_delete:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_list_button"), callback_data="admin_action:promo_management"))
builder.row(
InlineKeyboardButton(
text=_("admin_promo_edit_button"), callback_data=f"promo_edit_select:{promo_id}"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_toggle_status_button"), callback_data=f"promo_toggle:{promo_id}"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_view_activations_button"),
callback_data=f"promo_activations:{promo_id}:0",
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_delete_button"), callback_data=f"promo_delete:{promo_id}"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_back_to_list_button"), callback_data="admin_action:promo_management"
)
)
return text, builder.as_markup()
async def view_promo_codes_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
async def view_promo_codes_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
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:
@@ -76,18 +107,31 @@ async def view_promo_codes_handler(callback: types.CallbackQuery, i18n_data: dic
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo_models = await promo_code_dal.get_all_active_promo_codes(session, limit=20, offset=0)
text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}" if not promo_models else "\n".join(
[_("admin_active_promos_list_header"), ""] + [
f"{get_promo_status_emoji_and_text(p, i18n, current_lang)[0]} <code>{p.code}</code> | 🎁 {p.bonus_days}д | 📊 {p.current_activations}/{p.max_activations} | ⏰ {p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')}"
for p in promo_models
]
text = (
f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}"
if not promo_models
else "\n".join(
[_("admin_active_promos_list_header"), ""]
+ [
f"{get_promo_status_emoji_and_text(p, i18n, current_lang)[0]} <code>{p.code}</code> | 🎁 {p.bonus_days}д | 📊 {p.current_activations}/{p.max_activations} | ⏰ {p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')}"
for p in promo_models
]
)
)
await callback.message.edit_text(
text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML"
)
await callback.message.edit_text(text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML")
await callback.answer()
async def promo_management_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession, page: int = 0):
async def promo_management_handler(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
page: int = 0,
):
current_lang = i18n_data.get("current_language", "ru")
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
@@ -97,49 +141,79 @@ async def promo_management_handler(callback: types.CallbackQuery, i18n_data: dic
page_size = 10 # Количество промокодов на странице
offset = page * page_size
# Получаем общее количество промокодов
total_count = await promo_code_dal.get_promo_codes_count(session)
total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 1
promo_models = await promo_code_dal.get_all_promo_codes_with_details(session, limit=page_size, offset=offset)
promo_models = await promo_code_dal.get_all_promo_codes_with_details(
session, limit=page_size, offset=offset
)
if not promo_models and page == 0:
await callback.message.edit_text(_("admin_promo_management_empty"), reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML")
await callback.message.edit_text(
_("admin_promo_management_empty"),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await callback.answer()
return
builder = InlineKeyboardBuilder()
for promo in promo_models:
status_emoji, status_text = get_promo_status_emoji_and_text(promo, i18n, current_lang)
button_text = f"{status_emoji} {promo.code} ({promo.current_activations}/{promo.max_activations})"
builder.row(InlineKeyboardButton(text=button_text, callback_data=f"promo_detail:{promo.promo_code_id}"))
button_text = (
f"{status_emoji} {promo.code} ({promo.current_activations}/{promo.max_activations})"
)
builder.row(
InlineKeyboardButton(
text=button_text, callback_data=f"promo_detail:{promo.promo_code_id}"
)
)
# Добавляем кнопки пагинации если есть больше одной страницы
if total_pages > 1:
pagination_buttons = []
if page > 0:
pagination_buttons.append(InlineKeyboardButton(text=_("prev_page_button"), callback_data=f"promo_management:{page-1}"))
pagination_buttons.append(
InlineKeyboardButton(
text=_("prev_page_button"), callback_data=f"promo_management:{page - 1}"
)
)
if page < total_pages - 1:
pagination_buttons.append(InlineKeyboardButton(text=_("next_page_button"), callback_data=f"promo_management:{page+1}"))
pagination_buttons.append(
InlineKeyboardButton(
text=_("next_page_button"), callback_data=f"promo_management:{page + 1}"
)
)
if pagination_buttons:
builder.row(*pagination_buttons)
# Добавляем кнопки экспорта и возврата
builder.row(InlineKeyboardButton(text=_("admin_promo_export_csv_button"), callback_data="promo_export_all"))
builder.row(InlineKeyboardButton(text=_("back_to_admin_panel_button"), callback_data="admin_action:main"))
builder.row(
InlineKeyboardButton(
text=_("admin_promo_export_csv_button"), callback_data="promo_export_all"
)
)
builder.row(
InlineKeyboardButton(
text=_("back_to_admin_panel_button"), callback_data="admin_action:main"
)
)
# Формируем заголовок с информацией о страницах
title = _("admin_promo_management_title")
if total_pages > 1:
title += f"\n{_('admin_promo_list_page_info', current=page+1, total=total_pages, count=total_count)}"
title += f"\n{_('admin_promo_list_page_info', current=page + 1, total=total_pages, count=total_count)}"
await callback.message.edit_text(title, reply_markup=builder.as_markup(), parse_mode="HTML")
await callback.answer()
@router.callback_query(F.data.startswith("promo_management:"))
async def promo_management_pagination_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
async def promo_management_pagination_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
try:
page = int(callback.data.split(":")[1])
await promo_management_handler(callback, i18n_data, settings, session, page)
@@ -148,33 +222,41 @@ async def promo_management_pagination_handler(callback: types.CallbackQuery, i18
@router.callback_query(F.data.startswith("promo_detail:"))
async def promo_detail_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
async def promo_detail_handler(
callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
await callback.answer("Error processing request.", show_alert=True)
return
try:
promo_id = int(callback.data.split(":")[1])
text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
text, keyboard = await get_promo_detail_text_and_keyboard(
promo_id, session, i18n, current_lang
)
if text:
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
else:
await callback.answer(i18n.gettext(current_lang, "admin_promo_not_found"), show_alert=True)
await callback.answer(
i18n.gettext(current_lang, "admin_promo_not_found"), show_alert=True
)
except (ValueError, IndexError):
await callback.answer(i18n.gettext(current_lang, "admin_promo_not_found"), show_alert=True)
await callback.answer()
@router.callback_query(F.data.startswith("promo_toggle:"))
async def promo_toggle_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
async def promo_toggle_handler(
callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
return await callback.answer("Language service error.", show_alert=True)
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
promo_id = int(callback.data.split(":")[1])
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
@@ -184,10 +266,18 @@ async def promo_toggle_handler(callback: types.CallbackQuery, i18n_data: dict, s
new_status = not promo.is_active
if await promo_code_dal.update_promo_code(session, promo_id, {"is_active": new_status}):
await session.commit()
status_text = _("admin_promo_status_activated") if new_status else _("admin_promo_status_deactivated")
await callback.answer(_("admin_promo_toggle_success", code=promo.code, status=status_text))
text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
status_text = (
_("admin_promo_status_activated")
if new_status
else _("admin_promo_status_deactivated")
)
await callback.answer(
_("admin_promo_toggle_success", code=promo.code, status=status_text)
)
text, keyboard = await get_promo_detail_text_and_keyboard(
promo_id, session, i18n, current_lang
)
if text:
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
else:
@@ -197,7 +287,9 @@ async def promo_toggle_handler(callback: types.CallbackQuery, i18n_data: dict, s
@router.callback_query(F.data.startswith("promo_activations:"))
async def promo_activations_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
async def promo_activations_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
@@ -214,26 +306,56 @@ async def promo_activations_handler(callback: types.CallbackQuery, i18n_data: di
if not promo:
return await callback.answer(_("admin_promo_not_found"), show_alert=True)
total_activations = await promo_code_dal.count_promo_activations_by_code_id(session, promo_id)
activations = await promo_code_dal.get_promo_activations_by_code_id(session, promo_id, limit=page_size, offset=page * page_size)
total_activations = await promo_code_dal.count_promo_activations_by_code_id(
session, promo_id
)
activations = await promo_code_dal.get_promo_activations_by_code_id(
session, promo_id, limit=page_size, offset=page * page_size
)
builder = InlineKeyboardBuilder()
if not activations:
text = _("admin_promo_no_activations", code=promo.code)
else:
text = _("admin_promo_activations_header", code=promo.code) + "\n\n"
text += "\n".join([_("admin_promo_activation_item", user_id=a.user_id, date=a.activated_at.strftime("%d.%m.%Y %H:%M")) for a in activations])
text += "\n".join(
[
_(
"admin_promo_activation_item",
user_id=a.user_id,
date=a.activated_at.strftime("%d.%m.%Y %H:%M"),
)
for a in activations
]
)
nav_buttons = []
if page > 0:
nav_buttons.append(InlineKeyboardButton(text="⬅️", callback_data=f"promo_activations:{promo_id}:{page-1}"))
nav_buttons.append(
InlineKeyboardButton(
text="⬅️", callback_data=f"promo_activations:{promo_id}:{page - 1}"
)
)
if (page + 1) * page_size < total_activations:
nav_buttons.append(InlineKeyboardButton(text="➡️", callback_data=f"promo_activations:{promo_id}:{page+1}"))
nav_buttons.append(
InlineKeyboardButton(
text="➡️", callback_data=f"promo_activations:{promo_id}:{page + 1}"
)
)
if nav_buttons:
builder.row(*nav_buttons)
builder.row(InlineKeyboardButton(text=_("admin_promo_export_csv_button"), callback_data=f"promo_export:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_detail_button"), callback_data=f"promo_detail:{promo_id}"))
builder.row(
InlineKeyboardButton(
text=_("admin_promo_export_csv_button"), callback_data=f"promo_export:{promo_id}"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_back_to_detail_button"),
callback_data=f"promo_detail:{promo_id}",
)
)
await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="HTML")
except (ValueError, IndexError):
@@ -242,7 +364,9 @@ async def promo_activations_handler(callback: types.CallbackQuery, i18n_data: di
@router.callback_query(F.data.startswith("promo_export:"))
async def promo_export_activations_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
async def promo_export_activations_handler(
callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
@@ -258,20 +382,23 @@ async def promo_export_activations_handler(callback: types.CallbackQuery, i18n_d
activations = await promo_code_dal.get_promo_activations_by_code_id(session, promo_id)
if not activations:
return await callback.answer(_("admin_promo_no_activations", code=promo.code), show_alert=True)
return await callback.answer(
_("admin_promo_no_activations", code=promo.code), show_alert=True
)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["User ID", "Activation Date"])
for act in activations:
writer.writerow([act.user_id, act.activated_at.strftime("%Y-%m-%d %H:%M:%S")])
output.seek(0)
file = types.BufferedInputFile(output.getvalue().encode('utf-8'), filename=f"promo_{promo.code}_activations.csv")
file = types.BufferedInputFile(
output.getvalue().encode("utf-8"), filename=f"promo_{promo.code}_activations.csv"
)
# Force English caption for exports
await callback.message.answer_document(
file,
caption=i18n.gettext(export_lang, "admin_promo_export_caption", code=promo.code)
file, caption=i18n.gettext(export_lang, "admin_promo_export_caption", code=promo.code)
)
except (ValueError, IndexError):
@@ -280,7 +407,9 @@ async def promo_export_activations_handler(callback: types.CallbackQuery, i18n_d
@router.callback_query(F.data == "promo_export_all")
async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
async def promo_export_all_handler(
callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
@@ -289,31 +418,37 @@ async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dic
export_lang = "en"
try:
await callback.answer(i18n.gettext(export_lang, "admin_promo_export_all_generating"), show_alert=True)
await callback.answer(
i18n.gettext(export_lang, "admin_promo_export_all_generating"), show_alert=True
)
# Получаем все промокоды
all_promos = await promo_code_dal.get_all_promo_codes_with_details(session, limit=10000, offset=0)
all_promos = await promo_code_dal.get_all_promo_codes_with_details(
session, limit=10000, offset=0
)
output = io.StringIO()
writer = csv.writer(output)
# CSV headers (forced to English)
writer.writerow([
i18n.gettext(export_lang, "admin_promo_csv_code"),
i18n.gettext(export_lang, "admin_promo_csv_bonus_days"),
i18n.gettext(export_lang, "admin_promo_csv_max_activations"),
i18n.gettext(export_lang, "admin_promo_csv_current_activations"),
i18n.gettext(export_lang, "admin_promo_csv_status"),
i18n.gettext(export_lang, "admin_promo_csv_is_active"),
i18n.gettext(export_lang, "admin_promo_csv_valid_until"),
i18n.gettext(export_lang, "admin_promo_csv_created_at"),
i18n.gettext(export_lang, "admin_promo_csv_created_by_admin_id"),
])
writer.writerow(
[
i18n.gettext(export_lang, "admin_promo_csv_code"),
i18n.gettext(export_lang, "admin_promo_csv_bonus_days"),
i18n.gettext(export_lang, "admin_promo_csv_max_activations"),
i18n.gettext(export_lang, "admin_promo_csv_current_activations"),
i18n.gettext(export_lang, "admin_promo_csv_status"),
i18n.gettext(export_lang, "admin_promo_csv_is_active"),
i18n.gettext(export_lang, "admin_promo_csv_valid_until"),
i18n.gettext(export_lang, "admin_promo_csv_created_at"),
i18n.gettext(export_lang, "admin_promo_csv_created_by_admin_id"),
]
)
for promo in all_promos:
# Определяем статус
status_emoji, status_text = get_promo_status_emoji_and_text(promo, i18n, export_lang)
# Формируем данные для CSV
row = [
promo.code,
@@ -321,31 +456,37 @@ async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dic
promo.max_activations,
promo.current_activations,
status_text,
i18n.gettext(export_lang, "csv_yes") if promo.is_active else i18n.gettext(export_lang, "csv_no"),
promo.valid_until.strftime("%Y-%m-%d %H:%M:%S") if promo.valid_until else i18n.gettext(export_lang, "admin_promo_valid_indefinitely"),
i18n.gettext(export_lang, "csv_yes")
if promo.is_active
else i18n.gettext(export_lang, "csv_no"),
promo.valid_until.strftime("%Y-%m-%d %H:%M:%S")
if promo.valid_until
else i18n.gettext(export_lang, "admin_promo_valid_indefinitely"),
promo.created_at.strftime("%Y-%m-%d %H:%M:%S") if promo.created_at else "N/A",
promo.created_by_admin_id or "N/A"
promo.created_by_admin_id or "N/A",
]
writer.writerow(row)
output.seek(0)
# Создаем файл для отправки
filename = f"promo_codes_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
file = types.BufferedInputFile(
output.getvalue().encode('utf-8-sig'), # BOM для корректного отображения в Excel
filename=filename
output.getvalue().encode("utf-8-sig"), # BOM для корректного отображения в Excel
filename=filename,
)
caption = i18n.gettext(export_lang, "admin_promo_export_all_caption", count=len(all_promos))
await callback.message.answer_document(file, caption=caption)
except Exception as e:
await callback.answer(f"❌ Export error: {str(e)}", show_alert=True)
@router.callback_query(F.data.startswith("promo_delete:"))
async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
async def promo_delete_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
@@ -357,7 +498,9 @@ async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, s
promo = await promo_code_dal.delete_promo_code(session, promo_id)
if promo:
await session.commit()
await callback.answer(_("admin_promo_deleted_success", code=promo.code), show_alert=True)
await callback.answer(
_("admin_promo_deleted_success", code=promo.code), show_alert=True
)
await promo_management_handler(callback, i18n_data, settings, session, 0)
else:
await callback.answer(_("admin_promo_not_found"), show_alert=True)
@@ -367,65 +510,95 @@ async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, s
# --- Promo Edit Handlers ---
@router.callback_query(F.data.startswith("promo_edit_select:"))
async def promo_edit_select_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
async def promo_edit_select_handler(
callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo_id = int(callback.data.split(":")[1])
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_bonus_days"), callback_data=f"promo_edit_field:bonus_days:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_max_activations"), callback_data=f"promo_edit_field:max_activations:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_validity"), callback_data=f"promo_edit_field:valid_until:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_detail_button"), callback_data=f"promo_detail:{promo_id}"))
await callback.message.edit_text(_("admin_promo_edit_select_field"), reply_markup=builder.as_markup())
builder.row(
InlineKeyboardButton(
text=_("admin_promo_edit_bonus_days"),
callback_data=f"promo_edit_field:bonus_days:{promo_id}",
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_edit_max_activations"),
callback_data=f"promo_edit_field:max_activations:{promo_id}",
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_edit_validity"),
callback_data=f"promo_edit_field:valid_until:{promo_id}",
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_back_to_detail_button"), callback_data=f"promo_detail:{promo_id}"
)
)
await callback.message.edit_text(
_("admin_promo_edit_select_field"), reply_markup=builder.as_markup()
)
await callback.answer()
@router.callback_query(F.data.startswith("promo_edit_field:"))
async def promo_edit_field_handler(callback: types.CallbackQuery, state: FSMContext, i18n_data: dict, session: AsyncSession):
async def promo_edit_field_handler(
callback: types.CallbackQuery, state: FSMContext, i18n_data: dict, session: AsyncSession
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang: return
if not i18n or not callback.message or not current_lang:
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
action, field, promo_id_str = callback.data.split(":")
await state.update_data(promo_id=int(promo_id_str), field_to_edit=field)
prompts = {
"bonus_days": "admin_promo_prompt_bonus_days",
"max_activations": "admin_promo_prompt_max_activations",
"valid_until": "admin_promo_prompt_validity_days"
"valid_until": "admin_promo_prompt_validity_days",
}
await state.set_state(AdminStates.waiting_for_promo_edit_details)
await callback.message.edit_text(_(prompts.get(field, "error_occurred_try_again")))
await callback.answer()
@router.message(StateFilter(AdminStates.waiting_for_promo_edit_details))
async def process_promo_edit_details(message: types.Message, state: FSMContext, session: AsyncSession, i18n_data: dict):
async def process_promo_edit_details(
message: types.Message, state: FSMContext, session: AsyncSession, i18n_data: dict
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not message or not current_lang: return
if not i18n or not message or not current_lang:
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
data = await state.get_data()
promo_id = data.get("promo_id")
field = data.get("field_to_edit")
try:
value = message.text
update_data = {}
if field == "bonus_days":
update_data["bonus_days"] = int(value)
elif field == "max_activations":
update_data["max_activations"] = int(value)
elif field == "valid_until":
if value.lower() in ['0', 'вечно', 'бессрочно', 'indefinite']:
update_data["valid_until"] = None
if value.lower() in ["0", "вечно", "бессрочно", "indefinite"]:
update_data["valid_until"] = None
else:
days = int(value)
update_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=days)
@@ -433,10 +606,12 @@ async def process_promo_edit_details(message: types.Message, state: FSMContext,
if await promo_code_dal.update_promo_code(session, promo_id, update_data):
await session.commit()
await message.answer(_("admin_promo_edit_success"))
# Reset state and show updated details
await state.clear()
text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
text, keyboard = await get_promo_detail_text_and_keyboard(
promo_id, session, i18n, current_lang
)
if text:
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
else:
@@ -448,5 +623,7 @@ async def process_promo_edit_details(message: types.Message, state: FSMContext,
# Don't clear state, let them try again
async def manage_promo_codes_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
async def manage_promo_codes_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
await promo_management_handler(callback, i18n_data, settings, session)
+120 -105
View File
@@ -1,27 +1,26 @@
import logging
from aiogram import Router, F, types
from typing import Optional, Dict, List
from datetime import datetime
import html
import logging
from typing import Dict, List, Optional
from aiogram import Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import user_dal, payment_dal, panel_sync_dal
from db.models import Payment, PanelSyncStatus
from bot.services.panel_api_service import PanelApiService
from bot.keyboards.inline.admin_keyboards import (
get_back_to_admin_panel_keyboard,
get_back_to_user_management_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from config.settings import Settings
from db.dal import panel_sync_dal, payment_dal, user_dal
from db.models import PanelSyncStatus, Payment
router = Router(name="admin_statistics_router")
def _format_rating_user_label(user_row: Dict[str, object],
bot_username: Optional[str] = None) -> str:
def _format_rating_user_label(
user_row: Dict[str, object], bot_username: Optional[str] = None
) -> str:
user_id = int(user_row.get("user_id", 0) or 0)
username = user_row.get("username")
first_name = user_row.get("first_name")
@@ -49,9 +48,9 @@ def _format_rating_user_label(user_row: Dict[str, object],
return " ".join(parts)
async def show_statistics_handler(callback: types.CallbackQuery,
i18n_data: dict, settings: Settings,
session: AsyncSession):
async def show_statistics_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
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:
@@ -65,10 +64,8 @@ async def show_statistics_handler(callback: types.CallbackQuery,
# Enhanced user statistics
user_stats = await user_dal.get_enhanced_user_statistics(session)
stats_text_parts.append(
f"\n<b>👥 {_('admin_enhanced_users_stats_header')}</b>"
)
stats_text_parts.append(f"\n<b>👥 {_('admin_enhanced_users_stats_header')}</b>")
stats_text_parts.append(
f"📊 {_('admin_user_stats_total_label')}: <b>{user_stats['total_users']}</b>"
)
@@ -88,82 +85,102 @@ async def show_statistics_handler(callback: types.CallbackQuery,
stats_text_parts.append(
f"🎁 {_('admin_user_stats_referral_label')}: <b>{user_stats['referral_users']}</b>"
)
# Panel Statistics - moved above financial
stats_text_parts.append(f"\n<b>🖥 {_('admin_panel_stats_header')}</b>")
try:
async with PanelApiService(settings) as panel_service:
# Get system stats
system_stats = await panel_service.get_system_stats()
bandwidth_stats = await panel_service.get_bandwidth_stats()
nodes_stats = await panel_service.get_nodes_statistics()
logging.info(f"Panel stats response: system={system_stats}, bandwidth={bandwidth_stats}, nodes={nodes_stats}")
logging.info(
f"Panel stats response: system={system_stats}, bandwidth={bandwidth_stats}, nodes={nodes_stats}"
)
if system_stats:
users = system_stats.get('users', {})
status_counts = users.get('statusCounts', {})
online_stats = system_stats.get('onlineStats', {})
active_users = status_counts.get('ACTIVE', 0)
disabled_users = status_counts.get('DISABLED', 0)
expired_users = status_counts.get('EXPIRED', 0)
limited_users = status_counts.get('LIMITED', 0)
total_users = users.get('totalUsers', 0)
online_now = online_stats.get('onlineNow', 0)
users = system_stats.get("users", {})
status_counts = users.get("statusCounts", {})
online_stats = system_stats.get("onlineStats", {})
active_users = status_counts.get("ACTIVE", 0)
disabled_users = status_counts.get("DISABLED", 0)
expired_users = status_counts.get("EXPIRED", 0)
limited_users = status_counts.get("LIMITED", 0)
total_users = users.get("totalUsers", 0)
online_now = online_stats.get("onlineNow", 0)
stats_text_parts.append(f"🟢 {_('admin_panel_online_label')}: <b>{online_now}</b>")
stats_text_parts.append(f"📊 {_('admin_panel_active_label')}: <b>{active_users}</b>")
stats_text_parts.append(f"🔴 {_('admin_panel_disabled_label')}: <b>{disabled_users}</b>")
stats_text_parts.append(f"{_('admin_panel_expired_label')}: <b>{expired_users}</b>")
stats_text_parts.append(f"⚠️ {_('admin_panel_limited_label')}: <b>{limited_users}</b>")
stats_text_parts.append(f"👥 {_('admin_panel_total_users_label')}: <b>{total_users}</b>")
stats_text_parts.append(
f"📊 {_('admin_panel_active_label')}: <b>{active_users}</b>"
)
stats_text_parts.append(
f"🔴 {_('admin_panel_disabled_label')}: <b>{disabled_users}</b>"
)
stats_text_parts.append(
f"{_('admin_panel_expired_label')}: <b>{expired_users}</b>"
)
stats_text_parts.append(
f"⚠️ {_('admin_panel_limited_label')}: <b>{limited_users}</b>"
)
stats_text_parts.append(
f"👥 {_('admin_panel_total_users_label')}: <b>{total_users}</b>"
)
# System resources
memory = system_stats.get('memory', {})
memory = system_stats.get("memory", {})
if memory:
memory_total = memory.get('total', 1)
memory_used = memory.get('used', 0)
memory_total = memory.get("total", 1)
memory_used = memory.get("used", 0)
memory_usage = (memory_used / memory_total) * 100 if memory_total > 0 else 0
stats_text_parts.append(f"💾 {_('admin_panel_memory_usage_label')}: <b>{memory_usage:.1f}%</b>")
stats_text_parts.append(
f"💾 {_('admin_panel_memory_usage_label')}: <b>{memory_usage:.1f}%</b>"
)
else:
stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error')}")
# Bandwidth stats
if bandwidth_stats:
week_traffic = bandwidth_stats.get('bandwidthLastSevenDays', {})
month_traffic = bandwidth_stats.get('bandwidthLast30Days', {})
week_traffic = bandwidth_stats.get("bandwidthLastSevenDays", {})
month_traffic = bandwidth_stats.get("bandwidthLast30Days", {})
# Fallback to the actual key name from API if the above doesn't exist
if not month_traffic:
month_traffic = bandwidth_stats.get('bandwidthLastThirtyDays', {})
month_traffic = bandwidth_stats.get("bandwidthLastThirtyDays", {})
if week_traffic:
week_total = week_traffic.get('current', '0 B')
stats_text_parts.append(f"📊 {_('admin_panel_traffic_week_label')}: <b>{week_total}</b>")
week_total = week_traffic.get("current", "0 B")
stats_text_parts.append(
f"📊 {_('admin_panel_traffic_week_label')}: <b>{week_total}</b>"
)
if month_traffic:
month_total = month_traffic.get('current', '0 B')
stats_text_parts.append(f"📊 {_('admin_panel_traffic_month_label')}: <b>{month_total}</b>")
month_total = month_traffic.get("current", "0 B")
stats_text_parts.append(
f"📊 {_('admin_panel_traffic_month_label')}: <b>{month_total}</b>"
)
else:
stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error')}")
# Nodes stats
if nodes_stats and 'lastSevenDays' in nodes_stats:
last_seven_days = nodes_stats.get('lastSevenDays', [])
# Nodes stats
if nodes_stats and "lastSevenDays" in nodes_stats:
last_seven_days = nodes_stats.get("lastSevenDays", [])
# Get unique node names from the data
unique_nodes = set()
for node_data in last_seven_days:
unique_nodes.add(node_data.get('nodeName', ''))
unique_nodes.add(node_data.get("nodeName", ""))
total_nodes_count = len(unique_nodes)
# Assume all nodes are active since we don't have status info
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label')}: <b>{total_nodes_count}/{total_nodes_count}</b>")
stats_text_parts.append(
f"🔗 {_('admin_panel_nodes_label')}: <b>{total_nodes_count}/{total_nodes_count}</b>"
)
else:
# Use nodes total from system stats as fallback
nodes_info = system_stats.get('nodes', {}) if system_stats else {}
total_online = nodes_info.get('totalOnline', 0)
nodes_info = system_stats.get("nodes", {}) if system_stats else {}
total_online = nodes_info.get("totalOnline", 0)
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label')}: <b>{total_online}</b>")
except Exception as e:
logging.error(f"Failed to fetch panel statistics: {e}", exc_info=True)
stats_text_parts.append(f"{_('admin_panel_stats_fetch_error')}")
@@ -171,10 +188,8 @@ async def show_statistics_handler(callback: types.CallbackQuery,
# Financial statistics
financial_stats = await payment_dal.get_financial_statistics(session)
stats_text_parts.append(
f"\n<b>💰 {_('admin_financial_stats_header')}</b>"
)
stats_text_parts.append(f"\n<b>💰 {_('admin_financial_stats_header')}</b>")
stats_text_parts.append(
f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})"
)
@@ -188,12 +203,11 @@ async def show_statistics_handler(callback: types.CallbackQuery,
f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>"
)
last_payments_models: List[
Payment] = await payment_dal.get_recent_payment_logs_with_user(session,
limit=5)
last_payments_models: List[Payment] = await payment_dal.get_recent_payment_logs_with_user(
session, limit=5
)
if last_payments_models:
stats_text_parts.append(
f"\n<b>{_('admin_stats_recent_payments_header')}</b>")
stats_text_parts.append(f"\n<b>{_('admin_stats_recent_payments_header')}</b>")
for payment in last_payments_models:
pending_statuses = [
"pending",
@@ -217,45 +231,45 @@ async def show_statistics_handler(callback: types.CallbackQuery,
elif payment.user and payment.user.first_name:
user_info += f" ({payment.user.first_name})"
payment_date_str = payment.created_at.strftime(
'%Y-%m-%d') if payment.created_at else "N/A"
payment_date_str = (
payment.created_at.strftime("%Y-%m-%d") if payment.created_at else "N/A"
)
stats_text_parts.append(
_("admin_stats_payment_item",
status_emoji=status_emoji,
amount=payment.amount,
currency=payment.currency,
user_info=user_info,
p_status=payment.status,
p_date=payment_date_str))
_(
"admin_stats_payment_item",
status_emoji=status_emoji,
amount=payment.amount,
currency=payment.currency,
user_info=user_info,
p_status=payment.status,
p_date=payment_date_str,
)
)
else:
stats_text_parts.append(f"\n{_('admin_stats_no_payments_found')}")
sync_status_model: Optional[
PanelSyncStatus] = await panel_sync_dal.get_panel_sync_status(session)
sync_status_model: Optional[PanelSyncStatus] = await panel_sync_dal.get_panel_sync_status(
session
)
if sync_status_model and sync_status_model.status != "never_run":
stats_text_parts.append(
f"\n<b>{_('admin_stats_last_sync_header')}</b>")
stats_text_parts.append(f"\n<b>{_('admin_stats_last_sync_header')}</b>")
sync_time_val = sync_status_model.last_sync_time
sync_time_str = sync_time_val.strftime(
'%Y-%m-%d %H:%M:%S UTC') if sync_time_val else "N/A"
sync_time_str = sync_time_val.strftime("%Y-%m-%d %H:%M:%S UTC") if sync_time_val else "N/A"
details_val = sync_status_model.details
details_str = details_val or "N/A"
stats_text_parts.append(
f" {_('admin_stats_sync_time')}: {sync_time_str}")
stats_text_parts.append(
f" {_('admin_stats_sync_status')}: {sync_status_model.status}")
stats_text_parts.append(f" {_('admin_stats_sync_time')}: {sync_time_str}")
stats_text_parts.append(f" {_('admin_stats_sync_status')}: {sync_status_model.status}")
stats_text_parts.append(
f" {_('admin_stats_sync_users_processed')}: {sync_status_model.users_processed_from_panel}"
)
stats_text_parts.append(
f" {_('admin_stats_sync_subs_synced')}: {sync_status_model.subscriptions_synced}"
)
stats_text_parts.append(
f" {_('admin_stats_sync_details_label')}: {details_str}")
stats_text_parts.append(f" {_('admin_stats_sync_details_label')}: {details_str}")
else:
stats_text_parts.append(f"\n{_('admin_sync_status_never_run')}")
@@ -265,28 +279,30 @@ async def show_statistics_handler(callback: types.CallbackQuery,
await callback.message.edit_text(
final_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML")
parse_mode="HTML",
)
except Exception as e_edit:
logging.error(f"Error editing message for statistics: {e_edit}",
exc_info=True)
logging.error(f"Error editing message for statistics: {e_edit}", exc_info=True)
max_chunk_size = 4000
for i in range(0, len(final_text), max_chunk_size):
chunk = final_text[i:i + max_chunk_size]
chunk = final_text[i : i + max_chunk_size]
is_last_chunk = (i + max_chunk_size) >= len(final_text)
try:
await callback.message.answer(
chunk,
reply_markup=get_back_to_admin_panel_keyboard(
current_lang, i18n) if is_last_chunk else None,
parse_mode="HTML")
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)
if is_last_chunk
else None,
parse_mode="HTML",
)
except Exception as e_chunk:
logging.error(f"Failed to send statistics chunk: {e_chunk}")
if i == 0:
await callback.message.answer(
_("error_displaying_statistics"),
reply_markup=get_back_to_admin_panel_keyboard(
current_lang, i18n))
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
)
break
@@ -311,8 +327,7 @@ async def show_user_ratings_handler(
me = await callback.bot.get_me()
bot_username = me.username
except Exception as e_get_me:
logging.warning("Failed to resolve bot username for ratings links: %s",
e_get_me)
logging.warning("Failed to resolve bot username for ratings links: %s", e_get_me)
traffic_top = await user_dal.get_top_users_by_traffic_used(session, limit=top_limit)
lifetime_traffic_top = await user_dal.get_top_users_by_lifetime_traffic_used(
+53 -84
View File
@@ -1,19 +1,18 @@
import logging
from aiogram import Router, types, Bot
from aiogram.filters import Command
from typing import Optional, Union
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import update, or_
from datetime import datetime, timezone
from typing import Optional, Union
from config.settings import Settings
from bot.services.panel_api_service import PanelApiService
from bot.services.notification_service import NotificationService
from db.dal import user_dal, subscription_dal, panel_sync_dal
from db.models import Subscription
from aiogram import Bot, Router, types
from aiogram.filters import Command
from sqlalchemy import or_, update
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from config.settings import Settings
from db.dal import panel_sync_dal, subscription_dal, user_dal
from db.models import Subscription
router = Router(name="admin_sync_router")
@@ -26,9 +25,7 @@ def _normalize_panel_email(value: Optional[str]) -> Optional[str]:
def _extract_lifetime_used_traffic_bytes(panel_user_data: dict) -> Optional[int]:
user_traffic = panel_user_data.get("userTraffic") or {}
raw_value = (
user_traffic.get("lifetimeUsedTrafficBytes")
if isinstance(user_traffic, dict)
else None
user_traffic.get("lifetimeUsedTrafficBytes") if isinstance(user_traffic, dict) else None
)
if raw_value is None:
raw_value = panel_user_data.get("lifetimeUsedTrafficBytes")
@@ -156,9 +153,7 @@ async def perform_sync(
if not panel_users_data:
status_msg = "No users found in the panel to sync."
await panel_sync_dal.update_panel_sync_status(
session, "success", status_msg, 0, 0
)
await panel_sync_dal.update_panel_sync_status(session, "success", status_msg, 0, 0)
await session.commit()
return {
"status": "success",
@@ -174,17 +169,15 @@ async def perform_sync(
try:
panel_records_checked += 1
panel_uuid = panel_user_dict.get("uuid")
panel_subscription_uuid = panel_user_dict.get("subscriptionUuid") or panel_user_dict.get(
"shortUuid"
)
panel_subscription_uuid = panel_user_dict.get(
"subscriptionUuid"
) or panel_user_dict.get("shortUuid")
telegram_id_from_panel = panel_user_dict.get("telegramId")
email_from_panel = _normalize_panel_email(panel_user_dict.get("email"))
if not panel_uuid:
sync_errors.append(f"Panel user missing UUID: {panel_user_dict}")
logging.warning(
f"Skipping panel user without UUID: {panel_user_dict}"
)
logging.warning(f"Skipping panel user without UUID: {panel_user_dict}")
continue
# Track users without telegram ID
@@ -204,16 +197,12 @@ async def perform_sync(
session, telegram_id_from_panel
)
if existing_user:
logging.debug(
f"Found user by telegramId {telegram_id_from_panel}"
)
logging.debug(f"Found user by telegramId {telegram_id_from_panel}")
# If not found by telegram ID, try to find by panel UUID.
# The panel UUID is the strongest local link for subscription sync.
if not existing_user:
existing_user = await user_dal.get_user_by_panel_uuid(
session, panel_uuid
)
existing_user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
if existing_user:
logging.info(
f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}"
@@ -230,9 +219,7 @@ async def perform_sync(
# Finally, fall back to email. This mainly catches panel users that
# were first imported as email-only identities.
if not existing_user and email_from_panel:
existing_user = await user_dal.get_user_by_email(
session, email_from_panel
)
existing_user = await user_dal.get_user_by_email(session, email_from_panel)
if existing_user:
logging.debug(f"Found user by email {email_from_panel}")
@@ -246,9 +233,7 @@ async def perform_sync(
"telegram_id": telegram_id_from_panel,
"email": email_from_panel,
"email_verified_at": (
datetime.now(timezone.utc)
if email_from_panel
else None
datetime.now(timezone.utc) if email_from_panel else None
),
"username": None, # Username will be updated when user interacts with bot
"first_name": None, # Panel doesn't provide this info
@@ -259,9 +244,7 @@ async def perform_sync(
"referred_by_id": None,
}
new_user, was_created = await user_dal.create_user(
session, user_data
)
new_user, was_created = await user_dal.create_user(session, user_data)
if was_created:
users_created += 1
logging.info(
@@ -318,9 +301,7 @@ async def perform_sync(
existing_user.panel_user_uuid = panel_uuid
user_was_updated = True
users_uuid_updated += 1
logging.info(
f"Updated panel UUID for user {actual_user_id}: {panel_uuid}"
)
logging.info(f"Updated panel UUID for user {actual_user_id}: {panel_uuid}")
existing_user, email_was_bound = await _bind_panel_email_to_user(
session,
existing_user=existing_user,
@@ -329,10 +310,7 @@ async def perform_sync(
)
if email_was_bound:
user_was_updated = True
if (
telegram_id_from_panel
and existing_user.telegram_id != telegram_id_from_panel
):
if telegram_id_from_panel and existing_user.telegram_id != telegram_id_from_panel:
existing_user.telegram_id = telegram_id_from_panel
user_was_updated = True
@@ -348,28 +326,36 @@ async def perform_sync(
try:
if panel_uuid and existing_user:
description_text = "\n".join(
line for line in [
line
for line in [
existing_user.email or "",
existing_user.username or "",
existing_user.first_name or "",
existing_user.last_name or "",
] if line
]
if line
)
# Update description only when it differs from the current one on panel
current_panel_description = (
panel_user_dict.get("description") or ""
).strip()
desired_description = description_text.strip()
if (
desired_description
and desired_description != current_panel_description
):
if desired_description and desired_description != current_panel_description:
await panel_service.update_user_details_on_panel(
panel_uuid, {
panel_uuid,
{
"description": description_text,
**({"email": existing_user.email} if existing_user.email else {}),
**({"telegramId": existing_user.telegram_id} if existing_user.telegram_id else {}),
}
**(
{"email": existing_user.email}
if existing_user.email
else {}
),
**(
{"telegramId": existing_user.telegram_id}
if existing_user.telegram_id
else {}
),
},
)
except Exception as e_desc:
logging.warning(
@@ -387,10 +373,9 @@ async def perform_sync(
)
# Prefer syncing by concrete subscription UUID (shortUuid/subscriptionUuid)
subscription_uuid_from_panel = (
panel_user_dict.get("subscriptionUuid")
or panel_user_dict.get("shortUuid")
)
subscription_uuid_from_panel = panel_user_dict.get(
"subscriptionUuid"
) or panel_user_dict.get("shortUuid")
if subscription_uuid_from_panel:
# Если панель говорит, что подписка ACTIVE — сначала деактивируем все другие активные
@@ -403,9 +388,7 @@ async def perform_sync(
or_(
Subscription.panel_subscription_uuid
!= subscription_uuid_from_panel,
Subscription.panel_subscription_uuid.is_(
None
),
Subscription.panel_subscription_uuid.is_(None),
),
)
.values(
@@ -468,10 +451,8 @@ async def perform_sync(
)
else:
# No subscription UUID from panel: only update an already active subscription for this user/panel UUID
active_sub = (
await subscription_dal.get_active_subscription_by_user_id(
session, actual_user_id, panel_uuid
)
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, actual_user_id, panel_uuid
)
if active_sub:
await subscription_dal.update_subscription(
@@ -500,9 +481,7 @@ async def perform_sync(
sync_errors.append(
f"Error syncing subscription for user {actual_user_id}: {str(e)}"
)
logging.error(
f"Error syncing subscription for user {actual_user_id}: {e}"
)
logging.error(f"Error syncing subscription for user {actual_user_id}: {e}")
if user_was_updated:
users_updated += 1
@@ -559,7 +538,7 @@ async def perform_sync(
await session.commit()
# Detailed logging summary
logging.info(f"Sync completed - Summary:")
logging.info("Sync completed - Summary:")
logging.info(f" Panel records checked: {panel_records_checked}")
logging.info(f" Users without telegramId: {users_without_telegram_id}")
logging.info(f" Users not found in local DB: {users_not_found_in_db}")
@@ -671,21 +650,15 @@ async def sync_command_handler(
logging.error(f"Failed to send sync notification: {e_notification}")
except Exception as e_sync_global:
logging.error(
f"Global error during /sync command: {e_sync_global}", exc_info=True
)
logging.error(f"Global error during /sync command: {e_sync_global}", exc_info=True)
await bot.send_message(target_chat_id, _("sync_critical_error"))
# Send notification to log channel about failure
try:
notification_service = NotificationService(bot, settings, i18n)
await notification_service.notify_panel_sync(
"failed", str(e_sync_global), 0, 0
)
await notification_service.notify_panel_sync("failed", str(e_sync_global), 0, 0)
except Exception as e_notification:
logging.error(
f"Failed to send sync failure notification: {e_notification}"
)
logging.error(f"Failed to send sync failure notification: {e_notification}")
@router.message(Command("syncstatus"))
@@ -703,11 +676,7 @@ async def sync_status_command_handler(
response_text = ""
if status_record_model:
last_time_val = status_record_model.last_sync_time
last_time_str = (
last_time_val.strftime("%Y-%m-%d %H:%M:%S UTC")
if last_time_val
else "N/A"
)
last_time_str = last_time_val.strftime("%Y-%m-%d %H:%M:%S UTC") if last_time_val else "N/A"
details_val = status_record_model.details
details_str = details_val or "N/A"
File diff suppressed because it is too large Load Diff
+133 -142
View File
@@ -1,24 +1,26 @@
import logging
from aiogram import Router, types, Bot
from aiogram.types import InlineQuery, InlineQueryResultArticle, InputTextMessageContent
from typing import List, Optional
from aiogram import Bot, Router
from aiogram.types import InlineQuery, InlineQueryResultArticle, InputTextMessageContent
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import user_dal, payment_dal
from bot.services.referral_service import ReferralService
from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from config.settings import Settings
router = Router(name="inline_mode_router")
@router.inline_query()
async def inline_query_handler(inline_query: InlineQuery,
settings: Settings,
i18n_data: dict,
referral_service: ReferralService,
bot: Bot,
session: AsyncSession):
async def inline_query_handler(
inline_query: InlineQuery,
settings: Settings,
i18n_data: dict,
referral_service: ReferralService,
bot: Bot,
session: AsyncSession,
):
"""Handle inline queries for referral links and admin statistics"""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -28,12 +30,12 @@ async def inline_query_handler(inline_query: InlineQuery,
user_id = inline_query.from_user.id
query = inline_query.query.lower().strip()
results: List[InlineQueryResultArticle] = []
# Check if user is admin
is_admin = user_id in settings.ADMIN_IDS if settings.ADMIN_IDS else False
try:
# For all users: referral functionality
if not query or "реф" in query or "ref" in query or "друг" in query or "friend" in query:
@@ -48,25 +50,23 @@ async def inline_query_handler(inline_query: InlineQuery,
)
if referral_result:
results.append(referral_result)
# For admins: statistics
if is_admin and (not query or "стат" in query or "stat" in query or "админ" in query or "admin" in query):
stats_results = await create_admin_stats_results(
session, i18n, current_lang, settings
)
results.extend(stats_results)
# For admins: statistics
if is_admin and (
not query or "стат" in query or "stat" in query or "админ" in query or "admin" in query
):
stats_results = await create_admin_stats_results(session, i18n, current_lang, settings)
results.extend(stats_results)
# Limit results to 50 (Telegram limit)
results = results[:50]
await inline_query.answer(
results=results,
cache_time=30, # Cache for 30 seconds
is_personal=True # Results are personalized
is_personal=True, # Results are personalized
)
except Exception as e:
logging.error(f"Error handling inline query from user {user_id}: {e}")
# Send empty results in case of error
@@ -84,13 +84,13 @@ async def create_referral_result(
) -> Optional[InlineQueryResultArticle]:
"""Create referral link result for inline query"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
try:
bot_info = await bot.get_me()
bot_username = bot_info.username
if not bot_username:
return None
user_id = inline_query.from_user.id
referral_link = await referral_service.generate_referral_link(
session, bot_username, user_id
@@ -99,195 +99,196 @@ async def create_referral_result(
if not referral_link:
logging.warning("Could not produce referral link for inline user %s", user_id)
return None
# Create message content (use same text as friend message)
message_text = _(
"referral_friend_message",
referral_link=referral_link
)
message_text = _("referral_friend_message", referral_link=referral_link)
return InlineQueryResultArticle(
id="referral_link",
title=_(
"inline_referral_title"
),
description=_(
"inline_referral_description"
),
title=_("inline_referral_title"),
description=_("inline_referral_description"),
input_message_content=InputTextMessageContent(
message_text=message_text,
disable_web_page_preview=True
message_text=message_text, disable_web_page_preview=True
),
thumbnail_url=settings.INLINE_REFERRAL_THUMBNAIL_URL
thumbnail_url=settings.INLINE_REFERRAL_THUMBNAIL_URL,
)
except Exception as e:
logging.error(f"Error creating referral result: {e}")
return None
async def create_admin_stats_results(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> List[InlineQueryResultArticle]:
async def create_admin_stats_results(
session: AsyncSession, i18n_instance, lang: str, settings: Settings
) -> List[InlineQueryResultArticle]:
"""Create admin statistics results for inline query"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
results = []
try:
# Quick user stats
user_stats_result = await create_user_stats_result(session, i18n_instance, lang, settings)
if user_stats_result:
results.append(user_stats_result)
# Quick financial stats
financial_stats_result = await create_financial_stats_result(session, i18n_instance, lang, settings)
financial_stats_result = await create_financial_stats_result(
session, i18n_instance, lang, settings
)
if financial_stats_result:
results.append(financial_stats_result)
# Quick system stats
system_stats_result = await create_system_stats_result(session, i18n_instance, lang, settings)
system_stats_result = await create_system_stats_result(
session, i18n_instance, lang, settings
)
if system_stats_result:
results.append(system_stats_result)
except Exception as e:
logging.error(f"Error creating admin stats results: {e}")
return results
async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]:
async def create_user_stats_result(
session: AsyncSession, i18n_instance, lang: str, settings: Settings
) -> Optional[InlineQueryResultArticle]:
"""Create user statistics result"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
try:
from db.dal.user_dal import get_enhanced_user_statistics
user_stats = await get_enhanced_user_statistics(session)
stats_text = _(
"inline_user_stats_message",
total=user_stats['total_users'],
active_today=user_stats['active_today'],
paid=user_stats['paid_subscriptions'],
trial=user_stats['trial_users'],
inactive=user_stats['inactive_users'],
banned=user_stats['banned_users'],
referral=user_stats['referral_users']
total=user_stats["total_users"],
active_today=user_stats["active_today"],
paid=user_stats["paid_subscriptions"],
trial=user_stats["trial_users"],
inactive=user_stats["inactive_users"],
banned=user_stats["banned_users"],
referral=user_stats["referral_users"],
)
return InlineQueryResultArticle(
id="admin_user_stats",
title=_(
"inline_admin_user_stats_title"
),
title=_("inline_admin_user_stats_title"),
description=_(
"inline_user_stats_description",
total=user_stats['total_users'],
active=user_stats['paid_subscriptions']
total=user_stats["total_users"],
active=user_stats["paid_subscriptions"],
),
input_message_content=InputTextMessageContent(
message_text=stats_text,
parse_mode="HTML"
message_text=stats_text, parse_mode="HTML"
),
thumbnail_url=settings.INLINE_USER_STATS_THUMBNAIL_URL
thumbnail_url=settings.INLINE_USER_STATS_THUMBNAIL_URL,
)
except Exception as e:
logging.error(f"Error creating user stats result: {e}")
return None
async def create_financial_stats_result(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]:
async def create_financial_stats_result(
session: AsyncSession, i18n_instance, lang: str, settings: Settings
) -> Optional[InlineQueryResultArticle]:
"""Create financial statistics result"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
try:
from db.dal.payment_dal import get_financial_statistics
financial_stats = await get_financial_statistics(session)
stats_text = _(
"inline_financial_stats_message",
today=financial_stats['today_revenue'],
today_count=financial_stats['today_payments_count'],
week=financial_stats['week_revenue'],
month=financial_stats['month_revenue'],
all_time=financial_stats['all_time_revenue']
today=financial_stats["today_revenue"],
today_count=financial_stats["today_payments_count"],
week=financial_stats["week_revenue"],
month=financial_stats["month_revenue"],
all_time=financial_stats["all_time_revenue"],
)
return InlineQueryResultArticle(
id="admin_financial_stats",
title=_(
"inline_admin_financial_stats_title"
),
title=_("inline_admin_financial_stats_title"),
description=_(
"inline_financial_description",
today=f"{financial_stats['today_revenue']:.2f}"
"inline_financial_description", today=f"{financial_stats['today_revenue']:.2f}"
),
input_message_content=InputTextMessageContent(
message_text=stats_text,
parse_mode="HTML"
message_text=stats_text, parse_mode="HTML"
),
thumbnail_url=settings.INLINE_FINANCIAL_STATS_THUMBNAIL_URL
thumbnail_url=settings.INLINE_FINANCIAL_STATS_THUMBNAIL_URL,
)
except Exception as e:
logging.error(f"Error creating financial stats result: {e}")
return None
async def create_system_stats_result(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]:
async def create_system_stats_result(
session: AsyncSession, i18n_instance, lang: str, settings: Settings
) -> Optional[InlineQueryResultArticle]:
"""Create panel statistics result with system/nodes/bandwidth info"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
try:
from bot.services.panel_api_service import PanelApiService
# Get panel stats similar to main statistics
async with PanelApiService(settings) as panel_service:
system_stats = await panel_service.get_system_stats()
bandwidth_stats = await panel_service.get_bandwidth_stats()
nodes_stats = await panel_service.get_nodes_statistics()
if system_stats:
users = system_stats.get('users', {})
status_counts = users.get('statusCounts', {})
online_stats = system_stats.get('onlineStats', {})
active_users = status_counts.get('ACTIVE', 0)
disabled_users = status_counts.get('DISABLED', 0)
expired_users = status_counts.get('EXPIRED', 0)
limited_users = status_counts.get('LIMITED', 0)
total_users = users.get('totalUsers', 0)
online_now = online_stats.get('onlineNow', 0)
users = system_stats.get("users", {})
status_counts = users.get("statusCounts", {})
online_stats = system_stats.get("onlineStats", {})
active_users = status_counts.get("ACTIVE", 0)
disabled_users = status_counts.get("DISABLED", 0)
expired_users = status_counts.get("EXPIRED", 0)
limited_users = status_counts.get("LIMITED", 0)
total_users = users.get("totalUsers", 0)
online_now = online_stats.get("onlineNow", 0)
# Memory usage
memory = system_stats.get('memory', {})
memory = system_stats.get("memory", {})
memory_usage = 0
if memory:
memory_total = memory.get('total', 1)
memory_used = memory.get('used', 0)
memory_total = memory.get("total", 1)
memory_used = memory.get("used", 0)
memory_usage = (memory_used / memory_total) * 100 if memory_total > 0 else 0
# Bandwidth
week_traffic = "N/A"
month_traffic = "N/A"
if bandwidth_stats:
week_data = bandwidth_stats.get('bandwidthLastSevenDays', {})
month_data = bandwidth_stats.get('bandwidthLast30Days', {}) or bandwidth_stats.get('bandwidthLastThirtyDays', {})
week_traffic = week_data.get('current', 'N/A') if week_data else 'N/A'
month_traffic = month_data.get('current', 'N/A') if month_data else 'N/A'
week_data = bandwidth_stats.get("bandwidthLastSevenDays", {})
month_data = bandwidth_stats.get(
"bandwidthLast30Days", {}
) or bandwidth_stats.get("bandwidthLastThirtyDays", {})
week_traffic = week_data.get("current", "N/A") if week_data else "N/A"
month_traffic = month_data.get("current", "N/A") if month_data else "N/A"
# Nodes
active_nodes = 0
total_nodes = 0
if nodes_stats and 'lastSevenDays' in nodes_stats:
if nodes_stats and "lastSevenDays" in nodes_stats:
unique_nodes = set()
for node_data in nodes_stats.get('lastSevenDays', []):
unique_nodes.add(node_data.get('nodeName', ''))
for node_data in nodes_stats.get("lastSevenDays", []):
unique_nodes.add(node_data.get("nodeName", ""))
total_nodes = len(unique_nodes)
active_nodes = total_nodes # Assume all are active
elif system_stats and 'nodes' in system_stats:
active_nodes = system_stats.get('nodes', {}).get('totalOnline', 0)
elif system_stats and "nodes" in system_stats:
active_nodes = system_stats.get("nodes", {}).get("totalOnline", 0)
total_nodes = active_nodes
stats_text = _(
"inline_system_stats_message",
online=online_now,
@@ -300,43 +301,33 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
week_traffic=week_traffic,
month_traffic=month_traffic,
active_nodes=active_nodes,
total_nodes=total_nodes
total_nodes=total_nodes,
)
else:
stats_text = _("inline_panel_stats_error")
return InlineQueryResultArticle(
id="admin_system_stats",
title=_(
"inline_admin_system_stats_title"
),
description=_(
"inline_system_description",
online=online_now,
active=active_users
),
title=_("inline_admin_system_stats_title"),
description=_("inline_system_description", online=online_now, active=active_users),
input_message_content=InputTextMessageContent(
message_text=stats_text,
parse_mode="HTML"
message_text=stats_text, parse_mode="HTML"
),
thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL
thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL,
)
except Exception as e:
logging.error(f"Error creating system stats result: {e}")
# Fallback error message
error_text = _("inline_panel_stats_error")
return InlineQueryResultArticle(
id="admin_system_stats",
title=_(
"inline_admin_system_stats_title"
),
title=_("inline_admin_system_stats_title"),
description=_("inline_system_error"),
input_message_content=InputTextMessageContent(
message_text=error_text,
parse_mode="HTML"
message_text=error_text, parse_mode="HTML"
),
thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL
thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL,
)
return None
+2 -4
View File
@@ -1,11 +1,9 @@
from aiogram import Router
from . import start
from . import promo_user, referral, start, trial_handler
# TODO: after splitting subscription into a package, replace this import
from .subscription import router as subscription_router
from . import referral
from . import promo_user
from . import trial_handler
user_router_aggregate = Router(name="user_router_aggregate")
+250 -168
View File
@@ -1,37 +1,34 @@
import logging
import json
import asyncio
from datetime import datetime, timezone, timedelta
from typing import Optional, Dict, Any
import json
import logging
from datetime import datetime, timezone
from typing import Optional
from aiohttp import web
from aiogram import Bot
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from yookassa.domain.notification import WebhookNotification
from yookassa.domain.models.amount import Amount as YooKassaAmount
from db.dal import payment_dal, user_dal, user_billing_dal
from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService
from bot.services.panel_api_service import PanelApiService
from bot.services.yookassa_service import YooKassaService
from bot.services.lknpd_service import LknpdService
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
from bot.services.notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from bot.middlewares.i18n import JsonI18n
from bot.services.lknpd_service import LknpdService
from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.services.yookassa_service import YooKassaService
from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from config.settings import Settings
from db.dal import payment_dal, user_billing_dal, user_dal
payment_processing_lock = asyncio.Lock()
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded'
YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled'
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = 'payment.waiting_for_capture'
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = "payment.succeeded"
YOOKASSA_EVENT_PAYMENT_CANCELED = "payment.canceled"
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = "payment.waiting_for_capture"
YOOKASSA_WEBHOOK_ALLOWED_IPS = [
"185.71.76.0/27",
"185.71.77.0/27",
@@ -43,23 +40,28 @@ YOOKASSA_WEBHOOK_ALLOWED_IPS = [
]
async def process_successful_payment(session: AsyncSession, bot: Bot,
payment_info_from_webhook: dict,
i18n: JsonI18n, settings: Settings,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
referral_service: ReferralService,
lknpd_service: Optional[LknpdService] = None):
async def process_successful_payment(
session: AsyncSession,
bot: Bot,
payment_info_from_webhook: dict,
i18n: JsonI18n,
settings: Settings,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
referral_service: ReferralService,
lknpd_service: Optional[LknpdService] = None,
):
metadata = payment_info_from_webhook.get("metadata", {})
user_id_str = metadata.get("user_id")
subscription_months_str = metadata.get("subscription_months")
traffic_gb_str = metadata.get("traffic_gb")
sale_mode = metadata.get("sale_mode") or ("traffic" if settings.traffic_sale_mode else "subscription")
sale_mode = metadata.get("sale_mode") or (
"traffic" if settings.traffic_sale_mode else "subscription"
)
sale_mode_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
promo_code_id_str = metadata.get("promo_code_id")
payment_db_id_str = metadata.get("payment_db_id")
auto_renew_subscription_id_str = metadata.get(
"auto_renew_for_subscription_id")
auto_renew_subscription_id_str = metadata.get("auto_renew_for_subscription_id")
# For auto-renew payments, payment_db_id may be absent. In that case,
# we will create/ensure a payment record idempotently using provider payment id.
@@ -78,12 +80,17 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
user_id = int(user_id_str)
subscription_months = float(subscription_months_str or 0)
traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months
payment_db_id = int(
payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id and sale_mode_base == "subscription")
promo_code_id = int(
promo_code_id_str
) if promo_code_id_str and promo_code_id_str.isdigit() else None
payment_db_id = (
int(payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
)
is_auto_renew = bool(
auto_renew_subscription_id_str
and not payment_db_id
and sale_mode_base == "subscription"
)
promo_code_id = (
int(promo_code_id_str) if promo_code_id_str and promo_code_id_str.isdigit() else None
)
amount_data = payment_info_from_webhook.get("amount", {})
months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0
@@ -100,6 +107,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
)
return
from db.dal import payment_dal as _payment_dal
payment_record = await _payment_dal.get_payment_by_provider_payment_id(
session, yk_payment_id_from_hook
)
@@ -110,8 +118,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
amount=payment_value,
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
months=months_for_record or 1,
description=payment_info_from_webhook.get(
"description") or f"Auto-renewal for {months_for_record or subscription_months} months",
description=payment_info_from_webhook.get("description")
or f"Auto-renewal for {months_for_record or subscription_months} months",
provider="yookassa",
provider_payment_id=yk_payment_id_from_hook,
)
@@ -143,25 +151,24 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
)
await payment_dal.update_payment_status_by_db_id(
session, payment_db_id, "failed_user_not_found",
payment_info_from_webhook.get("id"))
session, payment_db_id, "failed_user_not_found", payment_info_from_webhook.get("id")
)
return
except (TypeError, ValueError) as e:
logging.error(
f"Invalid metadata format for payment processing: {metadata} - {e}"
)
logging.error(f"Invalid metadata format for payment processing: {metadata} - {e}")
if payment_db_id_str and payment_db_id_str.isdigit():
try:
await payment_dal.update_payment_status_by_db_id(
session, int(payment_db_id_str), "failed_metadata_error",
payment_info_from_webhook.get("id"))
except Exception as e_upd:
logging.error(
f"Failed to update payment status after metadata error: {e_upd}"
session,
int(payment_db_id_str),
"failed_metadata_error",
payment_info_from_webhook.get("id"),
)
except Exception as e_upd:
logging.error(f"Failed to update payment status after metadata error: {e_upd}")
return
try:
@@ -183,12 +190,18 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
# Try to capture and save payment method for future charges if available
try:
payment_method = payment_info_from_webhook.get("payment_method")
if settings.yookassa_autopayments_active and isinstance(payment_method, dict) and payment_method.get("saved", False):
if (
settings.yookassa_autopayments_active
and isinstance(payment_method, dict)
and payment_method.get("saved", False)
):
pm_id = payment_method.get("id")
pm_type = payment_method.get("type")
title = payment_method.get("title")
card = payment_method.get("card") or {}
account_number = payment_method.get("account_number") or payment_method.get("account")
account_number = payment_method.get("account_number") or payment_method.get(
"account"
)
display_network = None
display_last4 = None
# Build generic display for various instrument types
@@ -228,7 +241,9 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
logging.exception("Failed to persist multi-card YooKassa method from webhook")
except Exception:
logging.exception("Failed to persist YooKassa payment method from webhook")
months_for_activation = int(subscription_months) if sale_mode_base == "subscription" else int(traffic_amount_gb)
months_for_activation = (
int(subscription_months) if sale_mode_base == "subscription" else int(traffic_amount_gb)
)
activation_details = await subscription_service.activate_subscription(
session,
user_id,
@@ -238,32 +253,32 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
promo_code_id_from_payment=promo_code_id,
provider="yookassa",
sale_mode=sale_mode,
traffic_gb=traffic_amount_gb if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
traffic_gb=traffic_amount_gb
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
)
if not activation_details or not activation_details.get('end_date'):
if not activation_details or not activation_details.get("end_date"):
logging.error(
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}"
)
raise Exception(
f"Subscription Error: Failed to activate for user {user_id}")
raise Exception(f"Subscription Error: Failed to activate for user {user_id}")
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=payment_db_id,
new_status=payment_info_from_webhook.get("status", "succeeded"),
yk_payment_id=yk_payment_id_from_hook)
yk_payment_id=yk_payment_id_from_hook,
)
if not updated_payment_record:
logging.error(
f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}"
)
raise Exception(
f"DB Error: Could not update payment record {payment_db_id}")
raise Exception(f"DB Error: Could not update payment record {payment_db_id}")
base_subscription_end_date = activation_details['end_date']
base_subscription_end_date = activation_details["end_date"]
final_end_date_for_user = base_subscription_end_date
applied_promo_bonus_days = activation_details.get(
"applied_promo_bonus_days", 0)
applied_promo_bonus_days = activation_details.get("applied_promo_bonus_days", 0)
referral_bonus_info = None
if sale_mode_base == "subscription":
@@ -275,19 +290,24 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
skip_if_active_before_payment=False,
)
applied_referee_bonus_days_from_referral: Optional[int] = None
if referral_bonus_info and referral_bonus_info.get(
"referee_new_end_date"):
final_end_date_for_user = referral_bonus_info[
"referee_new_end_date"]
if referral_bonus_info and referral_bonus_info.get("referee_new_end_date"):
final_end_date_for_user = referral_bonus_info["referee_new_end_date"]
applied_referee_bonus_days_from_referral = referral_bonus_info.get(
"referee_bonus_applied_days")
"referee_bonus_applied_days"
)
# Use user's DB language for all user-facing messages
user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
user_lang = (
db_user.language_code
if db_user and db_user.language_code
else settings.DEFAULT_LANGUAGE
)
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
traffic_label = (
str(int(traffic_amount_gb)) if float(traffic_amount_gb).is_integer() else f"{traffic_amount_gb:g}"
str(int(traffic_amount_gb))
if float(traffic_amount_gb).is_integer()
else f"{traffic_amount_gb:g}"
)
if should_send_lknpd_receipt:
receipt_item_name = payment_info_from_webhook.get("description")
@@ -295,7 +315,9 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label)
else:
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(months=int(subscription_months))
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(
months=int(subscription_months)
)
try:
await lknpd_service.create_income_receipt(
item_name=receipt_item_name,
@@ -317,14 +339,16 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
details_message = _(
"yookassa_auto_renewal",
months=int(subscription_months),
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
)
details_markup = None
elif sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
details_message = _(
"payment_successful_traffic_full",
traffic_gb=traffic_label,
end_date=final_end_date_for_user.strftime('%Y-%m-%d') if final_end_date_for_user else "",
end_date=final_end_date_for_user.strftime("%Y-%m-%d")
if final_end_date_for_user
else "",
config_link=config_link_text,
)
details_markup = get_connect_and_main_keyboard(
@@ -339,21 +363,26 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(
session, db_user.referred_by_id)
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter:
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
safe_name = (
sanitize_display_name(inviter.first_name)
if inviter.first_name
else None
)
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(inviter.username, with_at=False)
inviter_name_display = username_for_display(
inviter.username, with_at=False
)
details_message = _(
"payment_successful_with_referral_bonus_full",
months=int(subscription_months),
base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'),
base_end_date=base_subscription_end_date.strftime("%Y-%m-%d"),
bonus_days=applied_referee_bonus_days_from_referral,
final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
final_end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
inviter_name=inviter_name_display,
config_link=config_link_text,
)
@@ -362,14 +391,14 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
"payment_successful_with_promo_full",
months=int(subscription_months),
bonus_days=applied_promo_bonus_days,
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
config_link=config_link_text,
)
elif final_end_date_for_user:
details_message = _(
"payment_successful_full",
months=int(subscription_months),
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
config_link=config_link_text,
)
else:
@@ -395,9 +424,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
disable_web_page_preview=True,
)
except Exception as e_notify:
logging.error(
f"Failed to send payment details message to user {user_id}: {e_notify}"
)
logging.error(f"Failed to send payment details message to user {user_id}: {e_notify}")
# Send notification about payment
try:
@@ -417,7 +444,9 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
months=int(subscription_months) if sale_mode_base == "subscription" else 0,
payment_provider="yookassa", # This is specifically for YooKassa webhook
username=user.username if user else None,
traffic_gb=traffic_amount_gb if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
traffic_gb=traffic_amount_gb
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
traffic_is_premium=sale_mode_base == "premium_topup",
tariff_key=tariff_for_log,
)
@@ -427,14 +456,19 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
except Exception as e_process:
logging.error(
f"Error during process_successful_payment main try block for user {user_id}: {e_process}",
exc_info=True)
exc_info=True,
)
raise
async def process_cancelled_payment(session: AsyncSession, bot: Bot,
payment_info_from_webhook: dict,
i18n: JsonI18n, settings: Settings):
async def process_cancelled_payment(
session: AsyncSession,
bot: Bot,
payment_info_from_webhook: dict,
i18n: JsonI18n,
settings: Settings,
):
metadata = payment_info_from_webhook.get("metadata", {})
user_id_str = metadata.get("user_id")
@@ -449,8 +483,7 @@ async def process_cancelled_payment(session: AsyncSession, bot: Bot,
user_id = int(user_id_str)
payment_db_id = int(payment_db_id_str)
except ValueError:
logging.error(
f"Invalid metadata in cancelled payment webhook: {metadata}")
logging.error(f"Invalid metadata in cancelled payment webhook: {metadata}")
return
try:
@@ -458,7 +491,8 @@ async def process_cancelled_payment(session: AsyncSession, bot: Bot,
session,
payment_db_id=payment_db_id,
new_status=payment_info_from_webhook.get("status", "canceled"),
yk_payment_id=payment_info_from_webhook.get("id"))
yk_payment_id=payment_info_from_webhook.get("id"),
)
if updated_payment:
logging.info(
@@ -471,7 +505,8 @@ async def process_cancelled_payment(session: AsyncSession, bot: Bot,
db_user = await user_dal.get_user_by_id(session, user_id)
user_lang = settings.DEFAULT_LANGUAGE
if db_user and db_user.language_code: user_lang = db_user.language_code
if db_user and db_user.language_code:
user_lang = db_user.language_code
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
await bot.send_message(user_id, _("payment_failed"))
@@ -479,29 +514,25 @@ async def process_cancelled_payment(session: AsyncSession, bot: Bot,
except Exception as e_process_cancel:
logging.error(
f"Error processing cancelled payment for user {user_id}, payment_db_id {payment_db_id}: {e_process_cancel}",
exc_info=True)
exc_info=True,
)
raise
async def yookassa_webhook_route(request: web.Request):
try:
bot: Bot = request.app['bot']
i18n_instance: JsonI18n = request.app['i18n']
settings: Settings = request.app['settings']
panel_service: PanelApiService = request.app['panel_service']
subscription_service: SubscriptionService = request.app[
'subscription_service']
referral_service: ReferralService = request.app['referral_service']
lknpd_service: Optional[LknpdService] = request.app.get('lknpd_service')
async_session_factory: sessionmaker = request.app[
'async_session_factory']
bot: Bot = request.app["bot"]
i18n_instance: JsonI18n = request.app["i18n"]
settings: Settings = request.app["settings"]
panel_service: PanelApiService = request.app["panel_service"]
subscription_service: SubscriptionService = request.app["subscription_service"]
referral_service: ReferralService = request.app["referral_service"]
lknpd_service: Optional[LknpdService] = request.app.get("lknpd_service")
async_session_factory: sessionmaker = request.app["async_session_factory"]
except KeyError:
logging.exception(
"KeyError accessing app context in yookassa_webhook_route.")
return web.Response(
status=500,
text="Internal Server Error: Missing app context component")
logging.exception("KeyError accessing app context in yookassa_webhook_route.")
return web.Response(status=500, text="Internal Server Error: Missing app context component")
client_ip = request_client_ip(request, trusted_proxies=settings.trusted_proxies)
if not ip_in_allowlist(client_ip, YOOKASSA_WEBHOOK_ALLOWED_IPS):
@@ -519,39 +550,41 @@ async def yookassa_webhook_route(request: web.Request):
f"PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'"
)
if not payment_data_from_notification or not hasattr(
payment_data_from_notification,
'metadata') or payment_data_from_notification.metadata is None:
if (
not payment_data_from_notification
or not hasattr(payment_data_from_notification, "metadata")
or payment_data_from_notification.metadata is None
):
logging.error(
f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata. Cannot process."
)
return web.Response(status=200, text="ok_error_no_metadata")
# Safely extract payment_method details (SDK objects may not have to_dict)
pm_obj = getattr(payment_data_from_notification, 'payment_method', None)
pm_obj = getattr(payment_data_from_notification, "payment_method", None)
pm_dict = None
if pm_obj is not None:
try:
card_obj = getattr(pm_obj, 'card', None)
card_obj = getattr(pm_obj, "card", None)
pm_dict = {
"id": getattr(pm_obj, 'id', None),
"type": getattr(pm_obj, 'type', None),
"saved": bool(getattr(pm_obj, 'saved', False)),
"title": getattr(pm_obj, 'title', None),
"id": getattr(pm_obj, "id", None),
"type": getattr(pm_obj, "type", None),
"saved": bool(getattr(pm_obj, "saved", False)),
"title": getattr(pm_obj, "title", None),
"account_number": (
getattr(pm_obj, 'account_number', None)
if hasattr(pm_obj, 'account_number') else (
getattr(pm_obj, 'account', None)
if hasattr(pm_obj, 'account') else None
getattr(pm_obj, "account_number", None)
if hasattr(pm_obj, "account_number")
else (
getattr(pm_obj, "account", None) if hasattr(pm_obj, "account") else None
)
),
"card": (
{
"first6": getattr(card_obj, 'first6', None),
"last4": getattr(card_obj, 'last4', None),
"expiry_month": getattr(card_obj, 'expiry_month', None),
"expiry_year": getattr(card_obj, 'expiry_year', None),
"card_type": getattr(card_obj, 'card_type', None),
"first6": getattr(card_obj, "first6", None),
"last4": getattr(card_obj, "last4", None),
"expiry_month": getattr(card_obj, "expiry_month", None),
"expiry_year": getattr(card_obj, "expiry_year", None),
"card_type": getattr(card_obj, "card_type", None),
}
if card_obj is not None
else None
@@ -562,21 +595,19 @@ async def yookassa_webhook_route(request: web.Request):
pm_dict = None
payment_dict_for_processing = {
"id":
str(payment_data_from_notification.id),
"status":
str(payment_data_from_notification.status),
"paid":
bool(payment_data_from_notification.paid),
"id": str(payment_data_from_notification.id),
"status": str(payment_data_from_notification.status),
"paid": bool(payment_data_from_notification.paid),
"amount": {
"value": str(payment_data_from_notification.amount.value),
"currency": str(payment_data_from_notification.amount.currency)
} if payment_data_from_notification.amount else {},
"metadata":
dict(payment_data_from_notification.metadata),
"description":
str(payment_data_from_notification.description)
if payment_data_from_notification.description else None,
"currency": str(payment_data_from_notification.amount.currency),
}
if payment_data_from_notification.amount
else {},
"metadata": dict(payment_data_from_notification.metadata),
"description": str(payment_data_from_notification.description)
if payment_data_from_notification.description
else None,
"payment_method": pm_dict,
}
@@ -584,14 +615,21 @@ async def yookassa_webhook_route(request: web.Request):
async with async_session_factory() as session:
try:
if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
if payment_dict_for_processing.get(
"paid") and payment_dict_for_processing.get(
"status") == "succeeded":
if (
payment_dict_for_processing.get("paid")
and payment_dict_for_processing.get("status") == "succeeded"
):
await process_successful_payment(
session, bot, payment_dict_for_processing,
i18n_instance, settings, panel_service,
subscription_service, referral_service,
lknpd_service)
session,
bot,
payment_dict_for_processing,
i18n_instance,
settings,
panel_service,
subscription_service,
referral_service,
lknpd_service,
)
await session.commit()
else:
logging.warning(
@@ -601,37 +639,62 @@ async def yookassa_webhook_route(request: web.Request):
)
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
await process_cancelled_payment(
session, bot, payment_dict_for_processing,
i18n_instance, settings)
session, bot, payment_dict_for_processing, i18n_instance, settings
)
await session.commit()
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
# Bind-only flow: save method and cancel auth if metadata has bind_only
metadata = payment_dict_for_processing.get("metadata", {}) or {}
if settings.yookassa_autopayments_active and metadata.get("bind_only") == "1":
if (
settings.yookassa_autopayments_active
and metadata.get("bind_only") == "1"
):
try:
user_id_str = metadata.get("user_id")
if user_id_str and user_id_str.isdigit():
user_id = int(user_id_str)
payment_method = payment_dict_for_processing.get("payment_method")
if isinstance(payment_method, dict) and payment_method.get("id"):
payment_method = payment_dict_for_processing.get(
"payment_method"
)
if isinstance(payment_method, dict) and payment_method.get(
"id"
):
pm_type = payment_method.get("type")
title = payment_method.get("title")
card = payment_method.get("card") or {}
account_number = payment_method.get("account_number") or payment_method.get("account")
account_number = payment_method.get(
"account_number"
) or payment_method.get("account")
display_network = None
display_last4 = None
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
display_network = card.get("card_type") or title or "Card"
if (pm_type or "").lower() in {
"bank_card",
"bank-card",
"card",
}:
display_network = (
card.get("card_type") or title or "Card"
)
display_last4 = card.get("last4")
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
elif (pm_type or "").lower() in {
"yoo_money",
"yoomoney",
"yoo-money",
"wallet",
}:
# Normalize wallet display name to avoid leaking full account from title
display_network = "YooMoney"
if isinstance(account_number, str) and len(account_number) >= 4:
if (
isinstance(account_number, str)
and len(account_number) >= 4
):
display_last4 = account_number[-4:]
else:
display_last4 = None
else:
display_network = title or (pm_type.upper() if pm_type else "Payment method")
display_network = title or (
pm_type.upper() if pm_type else "Payment method"
)
display_last4 = None
await user_billing_dal.upsert_yk_payment_method(
session,
@@ -644,6 +707,7 @@ async def yookassa_webhook_route(request: web.Request):
# Save multi-card entry and mark default if first
try:
from db.dal import user_billing_dal as ub
await ub.upsert_user_payment_method(
session,
user_id=user_id,
@@ -661,35 +725,53 @@ async def yookassa_webhook_route(request: web.Request):
# Use user's DB language for bind success notification
i18n_lang = settings.DEFAULT_LANGUAGE
from db.dal import user_dal
db_user = await user_dal.get_user_by_id(session, user_id)
db_user = await user_dal.get_user_by_id(
session, user_id
)
if db_user and db_user.language_code:
i18n_lang = db_user.language_code
_ = lambda key, **kwargs: i18n_instance.gettext(i18n_lang, key, **kwargs)
from bot.keyboards.inline.user_keyboards import get_back_to_payment_methods_keyboard
_ = lambda key, **kwargs: i18n_instance.gettext(
i18n_lang, key, **kwargs
)
from bot.keyboards.inline.user_keyboards import (
get_back_to_payment_methods_keyboard,
)
await bot.send_message(
chat_id=user_id,
text=_("payment_method_bound_success"),
reply_markup=get_back_to_payment_methods_keyboard(i18n_lang, i18n_instance)
reply_markup=get_back_to_payment_methods_keyboard(
i18n_lang, i18n_instance
),
)
except Exception:
pass
# Attempt to cancel the authorization to avoid charge hold
try:
yk: YooKassaService = request.app.get('yookassa_service')
yk: YooKassaService = request.app.get(
"yookassa_service"
)
if yk:
await yk.cancel_payment(payment_dict_for_processing.get("id"))
await yk.cancel_payment(
payment_dict_for_processing.get("id")
)
except Exception:
logging.exception("Failed to cancel bind-only payment auth")
logging.exception(
"Failed to cancel bind-only payment auth"
)
except Exception:
logging.exception("Failed to handle bind-only waiting_for_capture webhook")
logging.exception(
"Failed to handle bind-only waiting_for_capture webhook"
)
except Exception:
await session.rollback()
logging.exception(
"Error processing YooKassa webhook event '%s' for YK Payment ID %s in DB transaction.",
notification_object.event,
payment_dict_for_processing.get('id'))
return web.Response(
status=500, text="internal_processing_error")
payment_dict_for_processing.get("id"),
)
return web.Response(status=500, text="internal_processing_error")
return web.Response(status=200, text="ok")
+59 -53
View File
@@ -1,21 +1,22 @@
import logging
import re
from aiogram import Router, F, types, Bot
from aiogram.fsm.context import FSMContext
from datetime import datetime
from typing import Optional
from aiogram import Bot, F, Router, types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from bot.states.user_states import UserPromoStates
from bot.services.promo_code_service import PromoCodeService
from bot.services.subscription_service import SubscriptionService
from bot.keyboards.inline.user_keyboards import (
get_back_to_main_menu_markup,
get_connect_and_main_keyboard,
)
from datetime import datetime
from bot.middlewares.i18n import JsonI18n
from bot.services.promo_code_service import PromoCodeService
from bot.services.subscription_service import SubscriptionService
from bot.states.user_states import UserPromoStates
from bot.utils.callback_answer import safe_answer_callback
from config.settings import Settings
from .start import send_main_menu
@@ -24,15 +25,20 @@ router = Router(name="user_promo_router")
SUSPICIOUS_SQL_KEYWORDS_REGEX = re.compile(
r"\b(DROP\s*TABLE|DELETE\s*FROM|ALTER\s*TABLE|TRUNCATE\s*TABLE|UNION\s*SELECT|"
r";\s*SELECT|;\s*INSERT|;\s*UPDATE|;\s*DELETE|xp_cmdshell|sysdatabases|sysobjects|INFORMATION_SCHEMA)\b",
re.IGNORECASE)
re.IGNORECASE,
)
SUSPICIOUS_CHARS_REGEX = re.compile(r"(--|#\s|;|\*\/|\/\*)")
MAX_PROMO_CODE_INPUT_LENGTH = 100
async def prompt_promo_code_input(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
settings: Settings, session: AsyncSession,
back_callback: str = "main_action:back_to_main"):
async def prompt_promo_code_input(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
back_callback: str = "main_action:back_to_main",
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
@@ -41,8 +47,7 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if not callback.message:
logging.error(
"CallbackQuery has no message in prompt_promo_code_input")
logging.error("CallbackQuery has no message in prompt_promo_code_input")
await safe_answer_callback(
callback,
_("error_occurred_processing_request"),
@@ -57,32 +62,38 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
current_lang,
i18n,
callback_data=back_callback,
))
except Exception as e_edit:
logging.warning(
f"Failed to edit message for promo prompt: {e_edit}. Sending new one."
),
)
except Exception as e_edit:
logging.warning(f"Failed to edit message for promo prompt: {e_edit}. Sending new one.")
await callback.message.answer(
text=_(key="promo_code_prompt"),
reply_markup=get_back_to_main_menu_markup(
current_lang,
i18n,
callback_data=back_callback,
))
),
)
await safe_answer_callback(callback)
await state.set_state(UserPromoStates.waiting_for_promo_code)
logging.info(
f"User {callback.from_user.id} entered state UserPromoStates.waiting_for_promo_code. "
f"FSM state: {await state.get_state()}")
f"FSM state: {await state.get_state()}"
)
@router.message(UserPromoStates.waiting_for_promo_code, F.text)
async def process_promo_code_input(message: types.Message, state: FSMContext,
settings: Settings, i18n_data: dict,
promo_code_service: PromoCodeService,
subscription_service: SubscriptionService,
bot: Bot, session: AsyncSession):
async def process_promo_code_input(
message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
promo_code_service: PromoCodeService,
subscription_service: SubscriptionService,
bot: Bot,
session: AsyncSession,
):
logging.info(
f"Processing promo code input from user {message.from_user.id} in state {await state.get_state()}: '{message.text}'"
)
@@ -91,9 +102,7 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not promo_code_service:
logging.error(
"Dependencies (i18n or PromoCodeService) missing in process_promo_code_input"
)
logging.error("Dependencies (i18n or PromoCodeService) missing in process_promo_code_input")
await message.reply("Service error. Please try again later.")
await state.clear()
return
@@ -106,10 +115,11 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
if not code_input:
is_suspicious = True
logging.warning(f"Empty promo code input by user {user.id}.")
elif len(
code_input
) > MAX_PROMO_CODE_INPUT_LENGTH or SUSPICIOUS_SQL_KEYWORDS_REGEX.search(
code_input) or SUSPICIOUS_CHARS_REGEX.search(code_input):
elif (
len(code_input) > MAX_PROMO_CODE_INPUT_LENGTH
or SUSPICIOUS_SQL_KEYWORDS_REGEX.search(code_input)
or SUSPICIOUS_CHARS_REGEX.search(code_input)
):
is_suspicious = True
logging.warning(
f"Suspicious input for promo code by user {user.id} (len: {len(code_input)}): '{code_input}'"
@@ -121,23 +131,23 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
if settings.LOG_SUSPICIOUS_ACTIVITY:
try:
from bot.services.notification_service import NotificationService
notification_service = NotificationService(bot, settings, i18n)
await notification_service.notify_suspicious_promo_attempt(
user_id=user.id,
username=user.username,
first_name=user.first_name,
suspicious_input=code_input
suspicious_input=code_input,
)
except Exception as e:
logging.error(f"Failed to send suspicious promo notification: {e}")
success, result = await promo_code_service.apply_promo_code(
session, user.id, code_input, current_lang)
session, user.id, code_input, current_lang
)
if success:
await session.commit()
logging.info(
f"Promo code '{code_input}' successfully applied for user {user.id}."
)
logging.info(f"Promo code '{code_input}' successfully applied for user {user.id}.")
new_end_date = result if isinstance(result, datetime) else None
active = await subscription_service.get_active_subscription_details(session, user.id)
@@ -163,9 +173,7 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
)
response_to_user_text = result
reply_markup = get_back_to_main_menu_markup(
current_lang, i18n
)
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
await message.answer(
response_to_user_text,
@@ -178,12 +186,15 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
)
@router.callback_query(F.data == "main_action:back_to_main",
UserPromoStates.waiting_for_promo_code)
@router.callback_query(F.data == "main_action:back_to_main", UserPromoStates.waiting_for_promo_code)
async def cancel_promo_input_via_button(
callback: types.CallbackQuery, state: FSMContext, settings: Settings,
i18n_data: dict, subscription_service: SubscriptionService,
session: AsyncSession):
callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
@@ -197,15 +208,10 @@ async def cancel_promo_input_via_button(
await state.clear()
if callback.message:
await send_main_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=True)
await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
else:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await safe_answer_callback(
callback,
+80 -65
View File
@@ -1,45 +1,45 @@
import logging
from aiogram import Router, F, types, Bot
from aiogram.filters import Command
from typing import Optional, Union
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from aiogram import Bot, F, Router, types
from aiogram.filters import Command
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from config.settings import Settings
from db.dal import user_dal
from bot.services.referral_service import ReferralService
from bot.middlewares.i18n import JsonI18n
router = Router(name="user_referral_router")
async def referral_command_handler(event: Union[types.Message, types.CallbackQuery],
settings: Settings, i18n_data: dict,
referral_service: ReferralService, bot: Bot,
session: AsyncSession,
back_callback: str = "main_action:back_to_main"):
async def referral_command_handler(
event: Union[types.Message, types.CallbackQuery],
settings: Settings,
i18n_data: dict,
referral_service: ReferralService,
bot: Bot,
session: AsyncSession,
back_callback: str = "main_action:back_to_main",
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
target_message_obj = event.message if isinstance(
event, types.CallbackQuery) else event
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
if not target_message_obj:
logging.error(
"Target message is None in referral_command_handler (possibly from callback without message)."
)
if isinstance(event, types.CallbackQuery):
await event.answer("Error displaying referral info.",
show_alert=True)
await event.answer("Error displaying referral info.", show_alert=True)
return
if not i18n or not referral_service:
logging.error(
"Dependencies (i18n or ReferralService) missing in referral_command_handler"
)
await target_message_obj.answer(
"Service error. Please try again later.")
if isinstance(event, types.CallbackQuery): await event.answer()
logging.error("Dependencies (i18n or ReferralService) missing in referral_command_handler")
await target_message_obj.answer("Service error. Please try again later.")
if isinstance(event, types.CallbackQuery):
await event.answer()
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
@@ -48,21 +48,23 @@ async def referral_command_handler(event: Union[types.Message, types.CallbackQue
bot_info = await bot.get_me()
bot_username = bot_info.username
except Exception as e_bot_info:
logging.error(
f"Failed to get bot info for referral link: {e_bot_info}")
logging.error(f"Failed to get bot info for referral link: {e_bot_info}")
await target_message_obj.answer(_("error_generating_referral_link"))
if isinstance(event, types.CallbackQuery): await event.answer()
if isinstance(event, types.CallbackQuery):
await event.answer()
return
if not bot_username:
logging.error("Bot username is None, cannot generate referral link.")
await target_message_obj.answer(_("error_generating_referral_link"))
if isinstance(event, types.CallbackQuery): await event.answer()
if isinstance(event, types.CallbackQuery):
await event.answer()
return
inviter_user_id = event.from_user.id
referral_link = await referral_service.generate_referral_link(
session, bot_username, inviter_user_id)
session, bot_username, inviter_user_id
)
if not referral_link:
logging.error(
@@ -79,22 +81,26 @@ async def referral_command_handler(event: Union[types.Message, types.CallbackQue
bonus_details_str = _("referral_not_available_for_traffic")
else:
if settings.subscription_options:
for months_period_key, _price in sorted(
settings.subscription_options.items()):
for months_period_key, _price in sorted(settings.subscription_options.items()):
inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
ref_bonus = settings.referral_bonus_referee.get(months_period_key)
if inv_bonus is not None or ref_bonus is not None:
bonus_info_parts.append(
_("referral_bonus_per_period",
months=months_period_key,
inviter_bonus_days=inv_bonus
if inv_bonus is not None else _("no_bonus_placeholder"),
referee_bonus_days=ref_bonus
if ref_bonus is not None else _("no_bonus_placeholder")))
_(
"referral_bonus_per_period",
months=months_period_key,
inviter_bonus_days=inv_bonus
if inv_bonus is not None
else _("no_bonus_placeholder"),
referee_bonus_days=ref_bonus
if ref_bonus is not None
else _("no_bonus_placeholder"),
)
)
bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _(
"referral_no_bonuses_configured")
bonus_details_str = (
"\n".join(bonus_info_parts) if bonus_info_parts else _("referral_no_bonuses_configured")
)
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
@@ -112,14 +118,17 @@ async def referral_command_handler(event: Union[types.Message, types.CallbackQue
else ""
)
text = _("referral_program_info_new",
referral_link=referral_link,
webapp_link_section=webapp_link_section,
bonus_details=bonus_details_str,
invited_count=referral_stats["invited_count"],
purchased_count=referral_stats["purchased_count"])
text = _(
"referral_program_info_new",
referral_link=referral_link,
webapp_link_section=webapp_link_section,
bonus_details=bonus_details_str,
invited_count=referral_stats["invited_count"],
purchased_count=referral_stats["purchased_count"],
)
from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard
reply_markup_val = get_referral_link_keyboard(
current_lang,
i18n,
@@ -127,28 +136,29 @@ async def referral_command_handler(event: Union[types.Message, types.CallbackQue
)
if isinstance(event, types.Message):
await event.answer(text,
reply_markup=reply_markup_val,
disable_web_page_preview=True)
await event.answer(text, reply_markup=reply_markup_val, disable_web_page_preview=True)
elif isinstance(event, types.CallbackQuery) and event.message:
try:
await event.message.edit_text(text,
reply_markup=reply_markup_val,
disable_web_page_preview=True)
await event.message.edit_text(
text, reply_markup=reply_markup_val, disable_web_page_preview=True
)
except Exception as e_edit:
logging.warning(
f"Failed to edit message for referral info: {e_edit}. Sending new one."
logging.warning(f"Failed to edit message for referral info: {e_edit}. Sending new one.")
await event.message.answer(
text, reply_markup=reply_markup_val, disable_web_page_preview=True
)
await event.message.answer(text,
reply_markup=reply_markup_val,
disable_web_page_preview=True)
await event.answer()
@router.callback_query(F.data.startswith("referral_action:"))
async def referral_action_handler(callback: types.CallbackQuery, settings: Settings,
i18n_data: dict, referral_service: ReferralService,
bot: Bot, session: AsyncSession):
async def referral_action_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
referral_service: ReferralService,
bot: Bot,
session: AsyncSession,
):
action = callback.data.split(":")[1]
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n = i18n_data.get("i18n_instance")
@@ -164,7 +174,8 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
inviter_user_id = callback.from_user.id
referral_link = await referral_service.generate_referral_link(
session, bot_username, inviter_user_id)
session, bot_username, inviter_user_id
)
if not referral_link:
logging.error(
@@ -188,10 +199,7 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
else:
friend_message = _("referral_friend_message", referral_link=referral_link)
await callback.message.answer(
friend_message,
disable_web_page_preview=True
)
await callback.message.answer(friend_message, disable_web_page_preview=True)
except Exception as e:
logging.error(f"Error in referral share message: {e}")
@@ -200,7 +208,9 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
await callback.answer()
def _build_webapp_referral_link(base_url: Optional[str], referral_code: Optional[str]) -> Optional[str]:
def _build_webapp_referral_link(
base_url: Optional[str], referral_code: Optional[str]
) -> Optional[str]:
if not base_url or not referral_code:
return None
parts = urlsplit(base_url)
@@ -233,7 +243,12 @@ async def _generate_webapp_referral_link(
@router.message(Command("referral"))
async def referral_command_message_handler(message: types.Message, settings: Settings,
i18n_data: dict, referral_service: ReferralService,
bot: Bot, session: AsyncSession):
async def referral_command_message_handler(
message: types.Message,
settings: Settings,
i18n_data: dict,
referral_service: ReferralService,
bot: Bot,
session: AsyncSession,
):
await referral_command_handler(message, settings, i18n_data, referral_service, bot, session)
+239 -245
View File
@@ -1,61 +1,62 @@
import logging
import re
from aiogram import Router, F, types, Bot
from aiogram.utils.text_decorations import html_decoration as hd
from aiogram.filters import CommandStart, Command
from aiogram.fsm.context import FSMContext
from typing import Optional, Union
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timezone
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError
from typing import Optional, Union
from aiogram import Bot, F, Router, types
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import Command, CommandStart
from aiogram.fsm.context import FSMContext
from aiogram.utils.text_decorations import html_decoration as hd
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import (
get_bot_interface_inline_keyboard,
get_channel_subscription_keyboard,
get_information_links_keyboard,
get_language_selection_keyboard,
get_main_menu_inline_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.callback_answer import safe_answer_callback
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings
from db.dal import user_dal
from db.models import User
from bot.keyboards.inline.user_keyboards import (
get_main_menu_inline_keyboard,
get_bot_interface_inline_keyboard,
get_language_selection_keyboard,
get_channel_subscription_keyboard,
get_information_links_keyboard,
)
from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from bot.services.promo_code_service import PromoCodeService
from config.settings import Settings
from bot.middlewares.i18n import JsonI18n
from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name
from bot.utils.callback_answer import safe_answer_callback
router = Router(name="user_start_router")
async def should_show_trial_button(
settings: Settings,
subscription_service: SubscriptionService,
session: AsyncSession,
user_id: int) -> bool:
settings: Settings,
subscription_service: SubscriptionService,
session: AsyncSession,
user_id: int,
) -> bool:
if not settings.TRIAL_ENABLED:
return False
if hasattr(subscription_service, 'has_had_any_subscription') and callable(
getattr(subscription_service, 'has_had_any_subscription')):
return not await subscription_service.has_had_any_subscription(
session, user_id)
if hasattr(subscription_service, "has_had_any_subscription") and callable(
getattr(subscription_service, "has_had_any_subscription")
):
return not await subscription_service.has_had_any_subscription(session, user_id)
logging.error(
"Method has_had_any_subscription is missing in SubscriptionService!"
)
logging.error("Method has_had_any_subscription is missing in SubscriptionService!")
return False
async def send_main_menu(target_event: Union[types.Message,
types.CallbackQuery],
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
is_edit: bool = False):
async def send_main_menu(
target_event: Union[types.Message, types.CallbackQuery],
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
is_edit: bool = False,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -63,8 +64,7 @@ async def send_main_menu(target_event: Union[types.Message,
user_full_name = hd.quote(target_event.from_user.full_name)
if not i18n:
logging.error(
f"i18n_instance missing in send_main_menu for user {user_id}")
logging.error(f"i18n_instance missing in send_main_menu for user {user_id}")
err_msg_fallback = "Error: Language service unavailable. Please try again later."
if isinstance(target_event, types.CallbackQuery):
try:
@@ -78,27 +78,25 @@ async def send_main_menu(target_event: Union[types.Message,
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
show_trial_button_in_menu = await should_show_trial_button(
settings, subscription_service, session, user_id)
settings, subscription_service, session, user_id
)
text = _(key="main_menu_greeting", user_name=user_full_name)
reply_markup = get_main_menu_inline_keyboard(current_lang, i18n, settings,
show_trial_button_in_menu)
reply_markup = get_main_menu_inline_keyboard(
current_lang, i18n, settings, show_trial_button_in_menu
)
target_message_obj: Optional[types.Message] = None
if isinstance(target_event, types.Message):
target_message_obj = target_event
elif isinstance(target_event,
types.CallbackQuery) and target_event.message:
elif isinstance(target_event, types.CallbackQuery) and target_event.message:
target_message_obj = target_event.message
if not target_message_obj:
logging.error(
f"send_main_menu: target_message_obj is None for event from user {user_id}."
)
logging.error(f"send_main_menu: target_message_obj is None for event from user {user_id}.")
if isinstance(target_event, types.CallbackQuery):
await safe_answer_callback(
target_event,
@@ -134,12 +132,13 @@ async def send_main_menu(target_event: Union[types.Message,
async def send_bot_interface_menu(
target_event: Union[types.Message, types.CallbackQuery],
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
is_edit: bool = False):
target_event: Union[types.Message, types.CallbackQuery],
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
is_edit: bool = False,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -149,16 +148,15 @@ async def send_bot_interface_menu(
user_id = target_event.from_user.id
show_trial_button_in_menu = await should_show_trial_button(
settings, subscription_service, session, user_id)
settings, subscription_service, session, user_id
)
text = i18n.gettext(current_lang, "bot_interface_menu_title")
if settings.SUBSCRIPTION_MINI_APP_URL:
text = (
f"{text}\n\n"
f"{i18n.gettext(current_lang, 'bot_interface_menu_webapp_hint')}"
)
text = f"{text}\n\n{i18n.gettext(current_lang, 'bot_interface_menu_webapp_hint')}"
reply_markup = get_bot_interface_inline_keyboard(
current_lang, i18n, settings, show_trial_button_in_menu)
current_lang, i18n, settings, show_trial_button_in_menu
)
target_message_obj: Optional[types.Message] = None
if isinstance(target_event, types.Message):
@@ -201,12 +199,13 @@ async def send_bot_interface_menu(
async def ensure_required_channel_subscription(
event: Union[types.Message, types.CallbackQuery],
settings: Settings,
i18n: Optional[JsonI18n],
current_lang: str,
session: AsyncSession,
db_user: Optional[User] = None) -> bool:
event: Union[types.Message, types.CallbackQuery],
settings: Settings,
i18n: Optional[JsonI18n],
current_lang: str,
session: AsyncSession,
db_user: Optional[User] = None,
) -> bool:
"""
Verify that the user is a member of the required channel (if configured).
Returns True when access can proceed, False when user must subscribe first.
@@ -227,9 +226,7 @@ async def ensure_required_channel_subscription(
message_obj = event
if bot_instance is None:
logging.error(
"Channel subscription check: bot instance missing for user %s.", user_id
)
logging.error("Channel subscription check: bot instance missing for user %s.", user_id)
return False
if user_id in settings.ADMIN_IDS:
@@ -254,9 +251,10 @@ async def ensure_required_channel_subscription(
)
return True
if (db_user.channel_subscription_verified
and db_user.channel_subscription_verified_for
== required_channel_id):
if (
db_user.channel_subscription_verified
and db_user.channel_subscription_verified_for == required_channel_id
):
return True
def translate(key: str, **kwargs) -> str:
@@ -346,10 +344,11 @@ async def ensure_required_channel_subscription(
)
return True
keyboard = (get_channel_subscription_keyboard(
current_lang, i18n, settings.REQUIRED_CHANNEL_LINK
keyboard = (
get_channel_subscription_keyboard(current_lang, i18n, settings.REQUIRED_CHANNEL_LINK)
if i18n
else None
)
if i18n else None)
prompt_text = translate("channel_subscription_required")
@@ -379,28 +378,41 @@ async def ensure_required_channel_subscription(
@router.message(CommandStart())
@router.message(CommandStart(magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_("ref_match")))
@router.message(
CommandStart(
magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_(
"ref_match"
)
)
)
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_|admin_user_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
async def start_command_handler(message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
referral_service: ReferralService,
session: AsyncSession,
ref_match: Optional[re.Match] = None,
promo_match: Optional[re.Match] = None,
page_ref_match: Optional[re.Match] = None,
ad_param_match: Optional[re.Match] = None,
admin_user_match: Optional[re.Match] = None):
@router.message(
CommandStart(
magic=F.args.regexp(
r"^(?!ref_|promo_|admin_user_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
).as_("ad_param_match")
)
)
async def start_command_handler(
message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
referral_service: ReferralService,
session: AsyncSession,
ref_match: Optional[re.Match] = None,
promo_match: Optional[re.Match] = None,
page_ref_match: Optional[re.Match] = None,
ad_param_match: Optional[re.Match] = None,
admin_user_match: Optional[re.Match] = None,
):
await state.clear()
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
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
user = message.from_user
user_id = user.id
@@ -409,21 +421,17 @@ async def start_command_handler(message: types.Message,
target_user_id = int(admin_user_match.group(1))
target_user = await user_dal.get_user_by_id(session, target_user_id)
if not target_user:
await message.answer(
_("admin_user_not_found", input=hd.quote(str(target_user_id)))
)
await message.answer(_("admin_user_not_found", input=hd.quote(str(target_user_id))))
return
try:
from bot.handlers.admin.user_management import (
_send_with_profile_link_fallback,
format_user_card,
get_user_card_keyboard,
_send_with_profile_link_fallback,
)
referral_service = ReferralService(
settings, subscription_service, message.bot, i18n
)
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
user_card_text = await format_user_card(
target_user,
session,
@@ -468,7 +476,8 @@ async def start_command_handler(message: types.Message,
if settings.LEGACY_REFS:
potential_referrer_id = int(raw_ref_value)
if potential_referrer_id != user_id and await user_dal.get_user_by_id(
session, potential_referrer_id):
session, potential_referrer_id
):
referred_by_user_id = potential_referrer_id
else:
normalized_code = raw_ref_value.strip()
@@ -476,8 +485,7 @@ async def start_command_handler(message: types.Message,
normalized_code = normalized_code[1:]
ref_user = None
if normalized_code:
ref_user = await user_dal.get_user_by_referral_code(
session, normalized_code)
ref_user = await user_dal.get_user_by_referral_code(session, normalized_code)
if ref_user and ref_user.user_id != user_id:
referred_by_user_id = ref_user.user_id
elif promo_match:
@@ -504,7 +512,7 @@ async def start_command_handler(message: types.Message,
"last_name": sanitized_last_name,
"language_code": current_lang,
"referred_by_id": referred_by_user_id,
"registration_date": datetime.now(timezone.utc)
"registration_date": datetime.now(timezone.utc),
}
try:
db_user, created = await user_dal.create_user(session, user_data_to_create)
@@ -531,11 +539,13 @@ async def start_command_handler(message: types.Message,
)
if referred_by_user_id and referral_welcome_days > 0:
try:
referral_bonus_end_date = await subscription_service.extend_active_subscription_days(
session,
user_id,
referral_welcome_days,
reason="referral_welcome_bonus",
referral_bonus_end_date = (
await subscription_service.extend_active_subscription_days(
session,
user_id,
referral_welcome_days,
reason="referral_welcome_bonus",
)
)
if referral_bonus_end_date:
await session.commit()
@@ -572,20 +582,18 @@ async def start_command_handler(message: types.Message,
# Send notification about new user registration
try:
from bot.services.notification_service import NotificationService
notification_service = NotificationService(message.bot, settings, i18n)
await notification_service.notify_new_user_registration(
user_id=user_id,
username=sanitized_username,
first_name=sanitized_first_name,
referred_by_id=referred_by_user_id
referred_by_id=referred_by_user_id,
)
except Exception as e:
logging.error(f"Failed to send new user notification: {e}")
except Exception as e_create:
logging.error(
f"Failed to add new user {user_id} to session: {e_create}",
exc_info=True)
logging.error(f"Failed to add new user {user_id} to session: {e_create}", exc_info=True)
await message.answer(_("error_occurred_processing_request"))
return
else:
@@ -612,22 +620,23 @@ async def start_command_handler(message: types.Message,
try:
await user_dal.update_user(session, user_id, update_payload)
logging.info(
f"Updated existing user {user_id} in session: {update_payload}"
)
logging.info(f"Updated existing user {user_id} in session: {update_payload}")
except Exception as e_update:
logging.error(
f"Failed to update existing user {user_id} in session: {e_update}",
exc_info=True)
exc_info=True,
)
# Attribute user to ad campaign if start param provided
if ad_start_param:
try:
from db.dal import ad_dal as _ad_dal
campaign = await _ad_dal.get_campaign_by_start_param(session, ad_start_param)
if campaign and campaign.is_active:
await _ad_dal.ensure_attribution(session, user_id=user_id, campaign_id=campaign.ad_campaign_id)
await _ad_dal.ensure_attribution(
session, user_id=user_id, campaign_id=campaign.ad_campaign_id
)
await session.commit()
except Exception as e_attr:
logging.error(f"Failed to attribute user {user_id} to ad '{ad_start_param}': {e_attr}")
@@ -636,14 +645,12 @@ async def start_command_handler(message: types.Message,
except Exception:
pass
if not await ensure_required_channel_subscription(message, settings, i18n,
current_lang, session,
db_user):
if not await ensure_required_channel_subscription(
message, settings, i18n, current_lang, session, db_user
):
return
open_referral_page_for_existing_user = (
should_open_referral_from_start and is_existing_user
)
open_referral_page_for_existing_user = should_open_referral_from_start and is_existing_user
# Send welcome message if not disabled
if not settings.DISABLE_WELCOME_MESSAGE and not open_referral_page_for_existing_user:
@@ -653,6 +660,7 @@ async def start_command_handler(message: types.Message,
if promo_code_to_apply:
try:
from bot.services.promo_code_service import PromoCodeService
promo_code_service = PromoCodeService(settings, subscription_service, message.bot, i18n)
success, result = await promo_code_service.apply_promo_code(
@@ -664,7 +672,9 @@ async def start_command_handler(message: types.Message,
logging.info(f"Auto-applied promo code '{promo_code_to_apply}' for user {user_id}")
# Get updated subscription details
active = await subscription_service.get_active_subscription_details(session, user_id)
active = await subscription_service.get_active_subscription_details(
session, user_id
)
config_link_display = active.get("config_link") if active else None
connect_button_url = active.get("connect_button_url") if active else None
config_link_text = config_link_display or _("config_link_not_available")
@@ -673,11 +683,14 @@ async def start_command_handler(message: types.Message,
promo_success_text = _(
"promo_code_applied_success_full",
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
end_date=(
new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"
),
config_link=config_link_text,
)
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
await message.answer(
promo_success_text,
reply_markup=get_connect_and_main_keyboard(
@@ -687,75 +700,76 @@ async def start_command_handler(message: types.Message,
config_link_display,
connect_button_url=connect_button_url,
),
parse_mode="HTML"
parse_mode="HTML",
)
# Don't show main menu if promo was successfully applied
return
else:
await session.commit()
logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}")
logging.warning(
f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}"
)
await message.answer(str(result), parse_mode="HTML")
# Continue to show main menu if promo failed
except Exception as e:
logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}")
logging.error(
f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}"
)
await session.rollback()
if open_referral_page_for_existing_user:
from . import referral as user_referral_handlers
await user_referral_handlers.referral_command_handler(
message, settings, i18n_data, referral_service, message.bot, session
)
return
await send_main_menu(message,
settings,
i18n_data,
subscription_service,
session,
is_edit=False)
await send_main_menu(message, settings, i18n_data, subscription_service, session, is_edit=False)
@router.message(Command("tg"))
async def tg_interface_command_handler(message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession):
async def tg_interface_command_handler(
message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
):
await state.clear()
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
if not await ensure_required_channel_subscription(message, settings, i18n,
current_lang, session,
db_user):
if not await ensure_required_channel_subscription(
message, settings, i18n, current_lang, session, db_user
):
return
await send_bot_interface_menu(message,
settings,
i18n_data,
subscription_service,
session,
is_edit=False)
await send_bot_interface_menu(
message, settings, i18n_data, subscription_service, session, is_edit=False
)
@router.callback_query(F.data == "channel_subscription:verify")
async def verify_channel_subscription_callback(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession):
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
db_user = await user_dal.get_user_by_id(session, callback.from_user.id)
verified = await ensure_required_channel_subscription(
callback, settings, i18n, current_lang, session, db_user)
callback, settings, i18n, current_lang, session, db_user
)
if not verified:
return
@@ -769,15 +783,13 @@ async def verify_channel_subscription_callback(
_ = lambda key, **kwargs: key
if not settings.DISABLE_WELCOME_MESSAGE:
welcome_text = _(key="welcome",
user_name=hd.quote(callback.from_user.full_name))
welcome_text = _(key="welcome", user_name=hd.quote(callback.from_user.full_name))
if callback.message:
await callback.message.answer(welcome_text)
else:
fallback_bot: Optional[Bot] = getattr(callback, "bot", None)
if fallback_bot:
await fallback_bot.send_message(callback.from_user.id,
welcome_text)
await fallback_bot.send_message(callback.from_user.id, welcome_text)
try:
await safe_answer_callback(
@@ -788,12 +800,9 @@ async def verify_channel_subscription_callback(
except Exception:
pass
await send_main_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=bool(callback.message))
await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=bool(callback.message)
)
@router.message(Command("language"))
@@ -806,8 +815,7 @@ async def language_command_handler(
):
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
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
text_to_send = _(key="choose_language")
reply_markup = get_language_selection_keyboard(
@@ -816,8 +824,7 @@ async def language_command_handler(
back_callback=back_callback,
)
target_message_obj = event.message if isinstance(
event, types.CallbackQuery) else event
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
if not target_message_obj:
if isinstance(event, types.CallbackQuery):
await safe_answer_callback(
@@ -830,21 +837,22 @@ async def language_command_handler(
if isinstance(event, types.CallbackQuery):
if event.message:
try:
await event.message.edit_text(text_to_send,
reply_markup=reply_markup)
await event.message.edit_text(text_to_send, reply_markup=reply_markup)
except Exception:
await target_message_obj.answer(text_to_send,
reply_markup=reply_markup)
await target_message_obj.answer(text_to_send, reply_markup=reply_markup)
await safe_answer_callback(event)
else:
await target_message_obj.answer(text_to_send,
reply_markup=reply_markup)
await target_message_obj.answer(text_to_send, reply_markup=reply_markup)
@router.callback_query(F.data.startswith("set_lang_"))
async def select_language_callback_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings,
subscription_service: SubscriptionService, session: AsyncSession):
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
subscription_service: SubscriptionService,
session: AsyncSession,
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await safe_answer_callback(
@@ -867,15 +875,12 @@ async def select_language_callback_handler(
user_id = callback.from_user.id
try:
updated = await user_dal.update_user_language(session, user_id,
lang_code)
updated = await user_dal.update_user_language(session, user_id, lang_code)
if updated:
i18n_data["current_language"] = lang_code
_ = lambda key, **kwargs: i18n.gettext(lang_code, key, **kwargs)
await safe_answer_callback(callback, _(key="language_set_alert"))
logging.info(
f"User {user_id} language updated to {lang_code} in session.")
logging.info(f"User {user_id} language updated to {lang_code} in session.")
else:
await safe_answer_callback(
callback,
@@ -884,43 +889,41 @@ async def select_language_callback_handler(
)
return
except Exception as e_lang_update:
logging.error(
f"Error updating lang for user {user_id}: {e_lang_update}",
exc_info=True)
logging.error(f"Error updating lang for user {user_id}: {e_lang_update}", exc_info=True)
await safe_answer_callback(callback, "Error setting language.", show_alert=True)
return
if return_target == "bot":
await send_bot_interface_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=True)
await send_bot_interface_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
else:
await send_main_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=True)
await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
@router.callback_query(F.data.startswith("main_action:"))
async def main_action_callback_handler(
callback: types.CallbackQuery, state: FSMContext, settings: Settings,
i18n_data: dict, bot: Bot, subscription_service: SubscriptionService,
referral_service: ReferralService, panel_service: PanelApiService,
promo_code_service: PromoCodeService, session: AsyncSession):
callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
i18n_data: dict,
bot: Bot,
subscription_service: SubscriptionService,
referral_service: ReferralService,
panel_service: PanelApiService,
promo_code_service: PromoCodeService,
session: AsyncSession,
):
action = callback.data.split(":")[1]
user_id = callback.from_user.id
if action in {"back_to_main", "back_to_main_keep", "bot_interface"}:
await state.clear()
from . import subscription as user_subscription_handlers
from . import referral as user_referral_handlers
from . import promo_user as user_promo_handlers
from . import referral as user_referral_handlers
from . import subscription as user_subscription_handlers
from . import trial_handler as user_trial_handlers
if not callback.message:
@@ -933,7 +936,8 @@ async def main_action_callback_handler(
if action == "subscribe":
await user_subscription_handlers.display_subscription_options(
callback, i18n_data, settings, session)
callback, i18n_data, settings, session
)
elif action == "bot_subscribe":
await user_subscription_handlers.display_subscription_options(
callback,
@@ -944,8 +948,8 @@ async def main_action_callback_handler(
)
elif action == "my_subscription":
await user_subscription_handlers.my_subscription_command_handler(
callback, i18n_data, settings, panel_service, subscription_service,
session, bot)
callback, i18n_data, settings, panel_service, subscription_service, session, bot
)
elif action == "bot_my_subscription":
await user_subscription_handlers.my_subscription_command_handler(
callback,
@@ -959,11 +963,12 @@ async def main_action_callback_handler(
)
elif action == "my_devices":
await user_subscription_handlers.my_devices_command_handler(
callback, i18n_data, settings, panel_service, subscription_service,
session, bot)
callback, i18n_data, settings, panel_service, subscription_service, session, bot
)
elif action == "referral":
await user_referral_handlers.referral_command_handler(
callback, settings, i18n_data, referral_service, bot, session)
callback, settings, i18n_data, referral_service, bot, session
)
elif action == "bot_referral":
await user_referral_handlers.referral_command_handler(
callback,
@@ -976,7 +981,8 @@ async def main_action_callback_handler(
)
elif action == "apply_promo":
await user_promo_handlers.prompt_promo_code_input(
callback, state, i18n_data, settings, session)
callback, state, i18n_data, settings, session
)
elif action == "bot_apply_promo":
await user_promo_handlers.prompt_promo_code_input(
callback,
@@ -988,9 +994,9 @@ async def main_action_callback_handler(
)
elif action == "request_trial":
await user_trial_handlers.request_trial_confirmation_handler(
callback, settings, i18n_data, subscription_service, session)
callback, settings, i18n_data, subscription_service, session
)
elif action == "language":
await language_command_handler(callback, i18n_data, settings)
elif action == "bot_language":
await language_command_handler(
@@ -1000,16 +1006,12 @@ async def main_action_callback_handler(
back_callback="main_action:bot_interface",
)
elif action == "bot_interface":
await send_bot_interface_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=True)
await send_bot_interface_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
elif action in {"info", "bot_info"}:
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language",
settings.DEFAULT_LANGUAGE)
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
if not i18n:
await safe_answer_callback(
callback,
@@ -1017,8 +1019,7 @@ async def main_action_callback_handler(
show_alert=True,
)
return
_ = lambda key, **kwargs: i18n.gettext(
current_lang, key, **kwargs) if i18n else key
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
privacy_url = settings.PRIVACY_POLICY_URL
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
@@ -1043,30 +1044,23 @@ async def main_action_callback_handler(
),
)
try:
await callback.message.edit_text(_(key="info_links_message"),
reply_markup=reply_markup)
await callback.message.edit_text(_(key="info_links_message"), reply_markup=reply_markup)
except Exception:
await callback.message.answer(_(key="info_links_message"),
reply_markup=reply_markup)
await callback.message.answer(_(key="info_links_message"), reply_markup=reply_markup)
await safe_answer_callback(callback)
elif action == "back_to_main":
await send_main_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=True)
await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
elif action == "back_to_main_keep":
await send_main_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=False)
await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=False
)
else:
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(
i18n_data.get("current_language"), key, **kwargs) if i18n else key
_ = lambda key, **kwargs: (
i18n.gettext(i18n_data.get("current_language"), key, **kwargs) if i18n else key
)
await safe_answer_callback(
callback,
_("main_menu_unknown_action"),
+6 -6
View File
@@ -1,8 +1,6 @@
from aiogram import Router
from . import core
from . import payments
from . import payment_methods
from . import core, payment_methods, payments
router = Router(name="user_subscription_router")
@@ -12,6 +10,8 @@ router.include_router(payments.router)
router.include_router(payment_methods.router)
# Re-export commonly used entrypoints for backward compatibility
from .core import display_subscription_options, my_subscription_command_handler, my_devices_command_handler # noqa: E402,F401
from .core import ( # noqa: E402,F401
display_subscription_options,
my_devices_command_handler,
my_subscription_command_handler,
)
+418 -134
View File
@@ -1,29 +1,29 @@
import hashlib
import html
import logging
from aiogram import Router, F, types, Bot
from datetime import datetime
from typing import Optional, Union
from aiogram import Bot, F, Router, types
from aiogram.filters import Command
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder
from typing import Optional, Union
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from config.settings import Settings
from bot.keyboards.inline.user_keyboards import (
get_subscription_options_keyboard,
get_back_to_main_menu_markup,
get_autorenew_confirm_keyboard,
get_tariff_catalog_keyboard,
get_tariff_periods_keyboard,
get_tariff_packages_keyboard,
get_payment_method_keyboard,
get_back_to_main_menu_markup,
get_hwid_device_packages_keyboard,
get_payment_method_keyboard,
get_subscription_options_keyboard,
get_tariff_catalog_keyboard,
get_tariff_packages_keyboard,
get_tariff_periods_keyboard,
)
from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from db.dal import subscription_dal, user_billing_dal
from db.models import Subscription
@@ -55,7 +55,9 @@ def _has_multiple_enabled_tariffs(settings: Settings) -> bool:
return len(_enabled_tariffs(settings)) > 1
def _tariff_purchase_markup(tariff, current_lang: str, i18n: JsonI18n, settings: Settings) -> InlineKeyboardMarkup:
def _tariff_purchase_markup(
tariff, current_lang: str, i18n: JsonI18n, settings: Settings
) -> InlineKeyboardMarkup:
if tariff.billing_model == "period":
return get_tariff_periods_keyboard(tariff, current_lang, i18n, settings)
return get_tariff_packages_keyboard(tariff, tariff.traffic_packages.rub, current_lang, i18n)
@@ -130,7 +132,11 @@ async def display_subscription_options(
options = settings.subscription_options
if options:
text_content = get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period")
text_content = (
get_text("select_traffic_package")
if traffic_mode
else get_text("select_subscription_period")
)
reply_markup = get_subscription_options_keyboard(
options,
currency_symbol_val,
@@ -170,12 +176,16 @@ async def display_subscription_options(
@router.callback_query(F.data == "main_action:subscribe")
async def reshow_subscription_options_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
async def reshow_subscription_options_callback(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
await display_subscription_options(callback, i18n_data, settings, session)
@router.callback_query(F.data.startswith("tariff:select:"))
async def select_tariff_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
async def select_tariff_callback(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
@@ -196,7 +206,9 @@ async def select_tariff_callback(callback: types.CallbackQuery, i18n_data: dict,
@router.callback_query(F.data.startswith("tariff:period:"))
async def select_tariff_period_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
async def select_tariff_period_callback(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
@@ -227,7 +239,9 @@ async def select_tariff_period_callback(callback: types.CallbackQuery, i18n_data
@router.callback_query(F.data.startswith("tariff:package:"))
async def select_tariff_package_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
async def select_tariff_package_callback(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
@@ -238,12 +252,18 @@ async def select_tariff_package_callback(callback: types.CallbackQuery, i18n_dat
_, _, tariff_key, gb_raw = callback.data.split(":", 3)
tariff = config.require(tariff_key)
gb = float(gb_raw)
packages = tariff.traffic_packages.rub if tariff.billing_model == "traffic" else (config.topup_packages_for(tariff).rub if config.topup_packages_for(tariff) else [])
packages = (
tariff.traffic_packages.rub
if tariff.billing_model == "traffic"
else (config.topup_packages_for(tariff).rub if config.topup_packages_for(tariff) else [])
)
package = next((pkg for pkg in packages if float(pkg.gb) == gb), None)
if not package:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
sale_mode = f"{'traffic_package' if tariff.billing_model == 'traffic' else 'topup'}@{tariff.key}"
sale_mode = (
f"{'traffic_package' if tariff.billing_model == 'traffic' else 'topup'}@{tariff.key}"
)
markup = get_payment_method_keyboard(
gb,
package.price,
@@ -259,12 +279,20 @@ async def select_tariff_package_callback(callback: types.CallbackQuery, i18n_dat
@router.callback_query(F.data == "tariff_topup:list")
async def tariff_topup_list_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, subscription_service: SubscriptionService, session: AsyncSession):
async def tariff_topup_list_callback(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
subscription_service: SubscriptionService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
config = settings.tariffs_config
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
active = await subscription_service.get_active_subscription_details(
session, callback.from_user.id
)
if not config or not active or not active.get("tariff_key") or not callback.message:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
@@ -291,14 +319,24 @@ async def tariff_topup_list_callback(callback: types.CallbackQuery, i18n_data: d
callback_data=f"tariff:premium_package:{tariff.key}:{package.gb:g}",
)
)
builder.row(InlineKeyboardButton(text=get_text("back_to_main_menu_button"), callback_data="main_action:my_subscription"))
builder.row(
InlineKeyboardButton(
text=get_text("back_to_main_menu_button"), callback_data="main_action:my_subscription"
)
)
premium_lines = []
carryover_lines = []
if rub_packages or premium_packages:
carryover_lines.append("Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток.")
carryover_lines.append(
"Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток."
)
if int(active.get("premium_limit_bytes") or 0) > 0:
premium_left = max(0, int(active.get("premium_limit_bytes") or 0) - int(active.get("premium_used_bytes") or 0))
premium_left = max(
0,
int(active.get("premium_limit_bytes") or 0)
- int(active.get("premium_used_bytes") or 0),
)
labels = active.get("premium_node_labels") or active.get("premium_squad_labels") or []
if labels:
visible = [str(label) for label in labels[:8]]
@@ -319,7 +357,9 @@ async def tariff_topup_list_callback(callback: types.CallbackQuery, i18n_data: d
@router.callback_query(F.data.startswith("tariff:premium_package:"))
async def select_tariff_premium_package_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
async def select_tariff_premium_package_callback(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
@@ -350,12 +390,20 @@ async def select_tariff_premium_package_callback(callback: types.CallbackQuery,
@router.callback_query(F.data == "hwid_devices:list")
async def hwid_devices_list_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, subscription_service: SubscriptionService, session: AsyncSession):
async def hwid_devices_list_callback(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
subscription_service: SubscriptionService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
config = settings.tariffs_config
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
active = await subscription_service.get_active_subscription_details(
session, callback.from_user.id
)
if not config or not active or not active.get("tariff_key") or not callback.message:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
@@ -381,7 +429,9 @@ async def hwid_devices_list_callback(callback: types.CallbackQuery, i18n_data: d
@router.callback_query(F.data.startswith("hwid_devices:package:"))
async def hwid_devices_package_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
async def hwid_devices_package_callback(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
@@ -393,7 +443,11 @@ async def hwid_devices_package_callback(callback: types.CallbackQuery, i18n_data
tariff = config.require(tariff_key)
count = int(count_raw)
package = next(
(pkg for pkg in (tariff.hwid_device_packages.rub if tariff.hwid_device_packages else []) if int(pkg.count) == count),
(
pkg
for pkg in (tariff.hwid_device_packages.rub if tariff.hwid_device_packages else [])
if int(pkg.count) == count
),
None,
)
if not package:
@@ -409,34 +463,68 @@ async def hwid_devices_package_callback(callback: types.CallbackQuery, i18n_data
settings,
sale_mode=f"hwid_devices@{tariff.key}",
)
await callback.message.edit_text(get_text("choose_payment_method_hwid_devices"), reply_markup=markup)
await callback.message.edit_text(
get_text("choose_payment_method_hwid_devices"), reply_markup=markup
)
await callback.answer()
@router.callback_query(F.data == "tariff_change:list")
async def tariff_change_list_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, subscription_service: SubscriptionService, session: AsyncSession):
async def tariff_change_list_callback(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
subscription_service: SubscriptionService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
config = settings.tariffs_config
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
active = await subscription_service.get_active_subscription_details(
session, callback.from_user.id
)
if not config or not active or not callback.message:
await callback.answer("Error", show_alert=True)
return
if len(config.enabled_tariffs) <= 1:
await callback.answer("Смена тарифа недоступна: сейчас включен только один тариф.", show_alert=True)
await callback.answer(
"Смена тарифа недоступна: сейчас включен только один тариф.", show_alert=True
)
return
rows = []
for tariff in config.enabled_tariffs:
if tariff.key == active.get("tariff_key"):
continue
rows.append([InlineKeyboardButton(text=tariff.name(current_lang), callback_data=f"tariff_change:select:{tariff.key}")])
rows.append([InlineKeyboardButton(text=i18n.gettext(current_lang, "back_to_main_menu_button"), callback_data="main_action:my_subscription")])
await callback.message.edit_text("Выберите тариф", reply_markup=InlineKeyboardMarkup(inline_keyboard=rows))
rows.append(
[
InlineKeyboardButton(
text=tariff.name(current_lang),
callback_data=f"tariff_change:select:{tariff.key}",
)
]
)
rows.append(
[
InlineKeyboardButton(
text=i18n.gettext(current_lang, "back_to_main_menu_button"),
callback_data="main_action:my_subscription",
)
]
)
await callback.message.edit_text(
"Выберите тариф", reply_markup=InlineKeyboardMarkup(inline_keyboard=rows)
)
await callback.answer()
@router.callback_query(F.data.startswith("tariff_change:select:"))
async def tariff_change_select_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, subscription_service: SubscriptionService, session: AsyncSession):
async def tariff_change_select_callback(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
subscription_service: SubscriptionService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
config = settings.tariffs_config
@@ -445,32 +533,85 @@ async def tariff_change_select_callback(callback: types.CallbackQuery, i18n_data
return
tariff_key = callback.data.split(":", 2)[2]
target = config.require(tariff_key)
db_sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id)
db_sub = await subscription_dal.get_active_subscription_by_user_id(
session, callback.from_user.id
)
if not db_sub:
await callback.answer("Error", show_alert=True)
return
options = subscription_service.calculate_tariff_switch_options(db_sub, target)
rows = []
if options["mode"] == "period_to_period":
rows.append([InlineKeyboardButton(text=f"Без доплаты, дней станет {options['recalc_days']}", callback_data=f"tariff_change:confirm_apply:{target.key}:recalc_days")])
rows.append(
[
InlineKeyboardButton(
text=f"Без доплаты, дней станет {options['recalc_days']}",
callback_data=f"tariff_change:confirm_apply:{target.key}:recalc_days",
)
]
)
if options.get("paid_diff_rub", 0) > 0:
rows.append([InlineKeyboardButton(text=f"Доплатить {options['paid_diff_rub']} RUB", callback_data=f"tariff_change:confirm_pay:{target.key}:{options['paid_diff_rub']}")])
rows.append(
[
InlineKeyboardButton(
text=f"Доплатить {options['paid_diff_rub']} RUB",
callback_data=f"tariff_change:confirm_pay:{target.key}:{options['paid_diff_rub']}",
)
]
)
elif options["mode"] == "period_to_traffic":
rows.append([InlineKeyboardButton(text=f"Перейти без доплаты, получить {options['converted_gb']} GB", callback_data=f"tariff_change:confirm_apply:{target.key}:convert_days_to_gb")])
rows.append(
[
InlineKeyboardButton(
text=f"Перейти без доплаты, получить {options['converted_gb']} GB",
callback_data=f"tariff_change:confirm_apply:{target.key}:convert_days_to_gb",
)
]
)
for package in target.traffic_packages.rub:
rows.append([InlineKeyboardButton(text=f"+ {package.gb:g} GB за {package.price:g} RUB", callback_data=f"tariff:package:{target.key}:{package.gb:g}")])
rows.append(
[
InlineKeyboardButton(
text=f"+ {package.gb:g} GB за {package.price:g} RUB",
callback_data=f"tariff:package:{target.key}:{package.gb:g}",
)
]
)
else:
for months in target.enabled_periods:
price = target.period_price(months, "rub")
if price:
rows.append([InlineKeyboardButton(text=f"{months} мес. за {price:g} RUB", callback_data=f"tariff:period:{target.key}:{months}")])
rows.append([InlineKeyboardButton(text=i18n.gettext(current_lang, "back_to_main_menu_button"), callback_data="tariff_change:list")])
await callback.message.edit_text(f"{target.name(current_lang)}\n{target.description(current_lang)}".strip(), reply_markup=InlineKeyboardMarkup(inline_keyboard=rows))
rows.append(
[
InlineKeyboardButton(
text=f"{months} мес. за {price:g} RUB",
callback_data=f"tariff:period:{target.key}:{months}",
)
]
)
rows.append(
[
InlineKeyboardButton(
text=i18n.gettext(current_lang, "back_to_main_menu_button"),
callback_data="tariff_change:list",
)
]
)
await callback.message.edit_text(
f"{target.name(current_lang)}\n{target.description(current_lang)}".strip(),
reply_markup=InlineKeyboardMarkup(inline_keyboard=rows),
)
await callback.answer()
@router.callback_query(F.data.startswith("tariff_change:confirm_apply:"))
async def tariff_change_confirm_apply_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, subscription_service: SubscriptionService, session: AsyncSession):
async def tariff_change_confirm_apply_callback(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
subscription_service: SubscriptionService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
config = settings.tariffs_config
@@ -479,7 +620,9 @@ async def tariff_change_confirm_apply_callback(callback: types.CallbackQuery, i1
return
_, _, tariff_key, mode = callback.data.split(":", 3)
target = config.require(tariff_key)
db_sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id)
db_sub = await subscription_dal.get_active_subscription_by_user_id(
session, callback.from_user.id
)
if not db_sub:
await callback.answer("Error", show_alert=True)
return
@@ -491,8 +634,17 @@ async def tariff_change_confirm_apply_callback(callback: types.CallbackQuery, i1
else:
action_text = "тариф будет изменен без доплаты"
rows = [
[InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"tariff_change:apply:{target.key}:{mode}")],
[InlineKeyboardButton(text=i18n.gettext(current_lang, "back_to_main_menu_button"), callback_data=f"tariff_change:select:{target.key}")],
[
InlineKeyboardButton(
text="✅ Подтвердить", callback_data=f"tariff_change:apply:{target.key}:{mode}"
)
],
[
InlineKeyboardButton(
text=i18n.gettext(current_lang, "back_to_main_menu_button"),
callback_data=f"tariff_change:select:{target.key}",
)
],
]
await callback.message.edit_text(
f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nИзменение: {action_text}",
@@ -502,7 +654,9 @@ async def tariff_change_confirm_apply_callback(callback: types.CallbackQuery, i1
@router.callback_query(F.data.startswith("tariff_change:confirm_pay:"))
async def tariff_change_confirm_pay_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings):
async def tariff_change_confirm_pay_callback(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
config = settings.tariffs_config
@@ -512,8 +666,18 @@ async def tariff_change_confirm_pay_callback(callback: types.CallbackQuery, i18n
_, _, tariff_key, amount_raw = callback.data.split(":", 3)
target = config.require(tariff_key)
rows = [
[InlineKeyboardButton(text="✅ Подтвердить и оплатить", callback_data=f"tariff_change:pay:{target.key}:{amount_raw}")],
[InlineKeyboardButton(text=i18n.gettext(current_lang, "back_to_main_menu_button"), callback_data=f"tariff_change:select:{target.key}")],
[
InlineKeyboardButton(
text="✅ Подтвердить и оплатить",
callback_data=f"tariff_change:pay:{target.key}:{amount_raw}",
)
],
[
InlineKeyboardButton(
text=i18n.gettext(current_lang, "back_to_main_menu_button"),
callback_data=f"tariff_change:select:{target.key}",
)
],
]
await callback.message.edit_text(
f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} RUB.",
@@ -523,19 +687,37 @@ async def tariff_change_confirm_pay_callback(callback: types.CallbackQuery, i18n
@router.callback_query(F.data.startswith("tariff_change:apply:"))
async def tariff_change_apply_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, subscription_service: SubscriptionService, session: AsyncSession):
async def tariff_change_apply_callback(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
subscription_service: SubscriptionService,
session: AsyncSession,
):
_, _, tariff_key, mode = callback.data.split(":", 3)
result = await subscription_service.switch_tariff_without_payment(session, callback.from_user.id, tariff_key, mode)
result = await subscription_service.switch_tariff_without_payment(
session, callback.from_user.id, tariff_key, mode
)
if result:
await session.commit()
await callback.answer("Готово", show_alert=True)
await my_subscription_command_handler(callback, i18n_data, settings, subscription_service.panel_service, subscription_service, session, callback.bot)
await my_subscription_command_handler(
callback,
i18n_data,
settings,
subscription_service.panel_service,
subscription_service,
session,
callback.bot,
)
else:
await callback.answer("Error", show_alert=True)
@router.callback_query(F.data.startswith("tariff_change:pay:"))
async def tariff_change_pay_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
async def tariff_change_pay_callback(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
_, _, tariff_key, amount_raw = callback.data.split(":", 3)
@@ -613,6 +795,7 @@ async def my_subscription_command_handler(
config_link_display = active.get("config_link")
connect_button_url = active.get("connect_button_url")
config_link_value = config_link_display or get_text("config_link_not_available")
def _fmt_gb(val: Optional[float]) -> str:
if val is None:
return get_text("traffic_na")
@@ -623,6 +806,7 @@ async def my_subscription_command_handler(
except Exception:
pass
return str(val)
def _format_traffic_period(strategy: Optional[str]) -> Optional[str]:
if not strategy:
return None
@@ -639,14 +823,18 @@ async def my_subscription_command_handler(
def _format_used_with_period(used_display: str, period_label: Optional[str]) -> str:
if not period_label:
return used_display
return get_text("traffic_used_with_period", traffic_used=used_display, traffic_period=period_label)
return get_text(
"traffic_used_with_period", traffic_used=used_display, traffic_period=period_label
)
period_label = _format_traffic_period(active.get("traffic_limit_strategy"))
period_label = period_label or get_text("traffic_period_unknown")
if traffic_mode:
limit_display = _fmt_gb(active.get("traffic_limit_bytes"))
used_display = _format_used_with_period(_fmt_gb(active.get("traffic_used_bytes")), period_label)
used_display = _format_used_with_period(
_fmt_gb(active.get("traffic_used_bytes")), period_label
)
remaining_display = get_text("traffic_na")
try:
limit_val = active.get("traffic_limit_bytes") or 0
@@ -677,10 +865,16 @@ async def my_subscription_command_handler(
days_left=max(0, days_left),
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
config_link=config_link_value,
traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
traffic_limit=(
f"{active['traffic_limit_bytes'] / 2**30:.2f} GB"
if active.get("traffic_limit_bytes")
else get_text("traffic_unlimited")
),
traffic_used=(
_format_used_with_period(
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na"),
f"{active['traffic_used_bytes'] / 2**30:.2f} GB"
if active.get("traffic_used_bytes") is not None
else get_text("traffic_na"),
period_label,
)
),
@@ -721,26 +915,32 @@ async def my_subscription_command_handler(
)
kb = base_markup.inline_keyboard
try:
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
local_sub = await subscription_dal.get_active_subscription_by_user_id(
session, event.from_user.id
)
# Build rows to prepend above the base "back" markup
prepend_rows = []
# 1) Connect button: prefer the actual subscription URL; fall back to mini-app
cfg_link_val = connect_button_url or config_link_display
if cfg_link_val:
prepend_rows.append([
InlineKeyboardButton(
text=get_text("connect_button"),
url=cfg_link_val,
)
])
prepend_rows.append(
[
InlineKeyboardButton(
text=get_text("connect_button"),
url=cfg_link_val,
)
]
)
elif settings.SUBSCRIPTION_MINI_APP_URL:
prepend_rows.append([
InlineKeyboardButton(
text=get_text("connect_button"),
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
)
])
prepend_rows.append(
[
InlineKeyboardButton(
text=get_text("connect_button"),
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
)
]
)
if settings.MY_DEVICES_SECTION_ENABLED:
max_devices_value = active.get("max_devices")
@@ -786,47 +986,69 @@ async def my_subscription_command_handler(
current_devices=current_devices_display,
max_devices=max_devices_display,
)
prepend_rows.append([
InlineKeyboardButton(
text=devices_button_text,
callback_data="main_action:my_devices",
)
])
prepend_rows.append(
[
InlineKeyboardButton(
text=devices_button_text,
callback_data="main_action:my_devices",
)
]
)
if settings.tariffs_config and local_sub and local_sub.tariff_key:
try:
tariff_for_devices = settings.tariffs_config.require(local_sub.tariff_key)
if tariff_for_devices.hwid_device_packages and tariff_for_devices.hwid_device_packages.rub:
prepend_rows.append([
InlineKeyboardButton(
text=get_text("buy_hwid_devices_menu_button"),
callback_data="hwid_devices:list",
)
])
if (
tariff_for_devices.hwid_device_packages
and tariff_for_devices.hwid_device_packages.rub
):
prepend_rows.append(
[
InlineKeyboardButton(
text=get_text("buy_hwid_devices_menu_button"),
callback_data="hwid_devices:list",
)
]
)
except Exception:
pass
# 2) Auto-renew toggle (YooKassa only)
if not traffic_mode and local_sub and local_sub.provider == "yookassa" and settings.yookassa_autopayments_active:
if (
not traffic_mode
and local_sub
and local_sub.provider == "yookassa"
and settings.yookassa_autopayments_active
):
toggle_text = (
get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button")
get_text("autorenew_disable_button")
if local_sub.auto_renew_enabled
else get_text("autorenew_enable_button")
)
prepend_rows.append(
[
InlineKeyboardButton(
text=toggle_text,
callback_data=f"toggle_autorenew:{local_sub.subscription_id}:{1 if not local_sub.auto_renew_enabled else 0}",
)
]
)
prepend_rows.append([
InlineKeyboardButton(
text=toggle_text,
callback_data=f"toggle_autorenew:{local_sub.subscription_id}:{1 if not local_sub.auto_renew_enabled else 0}",
)
])
# 3) Payment methods management (when autopayments enabled)
if not traffic_mode and settings.yookassa_autopayments_active:
prepend_rows.append([
InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")
])
prepend_rows.append(
[
InlineKeyboardButton(
text=get_text("payment_methods_manage_button"), callback_data="pm:manage"
)
]
)
if settings.tariffs_config and local_sub and local_sub.tariff_key:
tariff_actions = []
if _has_multiple_enabled_tariffs(settings):
tariff_actions.append(InlineKeyboardButton(text="Сменить тариф", callback_data="tariff_change:list"))
tariff_actions.append(
InlineKeyboardButton(text="Сменить тариф", callback_data="tariff_change:list")
)
try:
tariff = settings.tariffs_config.require(local_sub.tariff_key)
topup_packages = settings.tariffs_config.topup_packages_for(tariff)
@@ -837,7 +1059,9 @@ async def my_subscription_command_handler(
except Exception:
has_topup_packages = False
if has_topup_packages:
tariff_actions.append(InlineKeyboardButton(text="Докупить трафик", callback_data="tariff_topup:list"))
tariff_actions.append(
InlineKeyboardButton(text="Докупить трафик", callback_data="tariff_topup:list")
)
if tariff_actions:
prepend_rows.append(tariff_actions)
@@ -853,7 +1077,9 @@ async def my_subscription_command_handler(
except Exception:
pass
try:
await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
await event.message.edit_text(
text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True
)
except Exception:
await bot.send_message(
chat_id=target.chat.id,
@@ -863,7 +1089,9 @@ async def my_subscription_command_handler(
disable_web_page_preview=True,
)
else:
await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
await target.answer(
text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True
)
@router.callback_query(F.data == "main_action:my_devices")
@@ -942,46 +1170,79 @@ async def my_devices_command_handler(
devices_list = []
current_devices = len(devices_list_raw)
for index, device in enumerate(devices_list_raw, start=1):
device_model = device.get('deviceModel') or None
platform = device.get('platform') or None
user_agent = device.get('userAgent') or None
os_version = device.get('osVersion') or None
created_at = device.get('createdAt')
hwid = device.get('hwid')
device_model = device.get("deviceModel") or None
platform = device.get("platform") or None
user_agent = device.get("userAgent") or None
os_version = device.get("osVersion") or None
created_at = device.get("createdAt")
hwid = device.get("hwid")
try:
created_at_str = datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M") if created_at else "-"
created_at_str = (
datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M")
if created_at
else "-"
)
except Exception:
created_at_str = str(created_at)
device_details = get_text("device_details", index=index, device_model=device_model, platform=platform, os_version=os_version, created_at_str=created_at_str, user_agent=user_agent, hwid=hwid)
device_details = get_text(
"device_details",
index=index,
device_model=device_model,
platform=platform,
os_version=os_version,
created_at_str=created_at_str,
user_agent=user_agent,
hwid=hwid,
)
devices_list.append(device_details)
text = get_text("my_devices_details", devices="\n\n".join(devices_list), current_devices=current_devices, max_devices=max_devices_display)
text = get_text(
"my_devices_details",
devices="\n\n".join(devices_list),
current_devices=current_devices,
max_devices=max_devices_display,
)
base_markup = get_back_to_main_menu_markup(current_lang, i18n, callback_data="main_action:my_subscription")
base_markup = get_back_to_main_menu_markup(
current_lang, i18n, callback_data="main_action:my_subscription"
)
kb = base_markup.inline_keyboard
devices_kb = []
if settings.tariffs_config and active.get("tariff_key") and max_devices_value != 0:
try:
tariff_for_devices = settings.tariffs_config.require(active["tariff_key"])
if tariff_for_devices.hwid_device_packages and tariff_for_devices.hwid_device_packages.rub:
devices_kb.append([
InlineKeyboardButton(
text=get_text("buy_hwid_devices_menu_button"),
callback_data="hwid_devices:list",
)
])
if (
tariff_for_devices.hwid_device_packages
and tariff_for_devices.hwid_device_packages.rub
):
devices_kb.append(
[
InlineKeyboardButton(
text=get_text("buy_hwid_devices_menu_button"),
callback_data="hwid_devices:list",
)
]
)
except Exception:
pass
for index, device in enumerate(devices_list_raw, start=1):
hwid = device.get('hwid')
hwid = device.get("hwid")
if not hwid:
continue
device_button_text = get_text("disconnect_device_button", hwid=_shorten_hwid_for_display(hwid), index=index)
device_button_text = get_text(
"disconnect_device_button", hwid=_shorten_hwid_for_display(hwid), index=index
)
hwid_token = _hwid_callback_token(hwid)
devices_kb.append([InlineKeyboardButton(text=device_button_text, callback_data=f"disconnect_device:{hwid_token}")])
devices_kb.append(
[
InlineKeyboardButton(
text=device_button_text, callback_data=f"disconnect_device:{hwid_token}"
)
]
)
kb = devices_kb + kb
markup = InlineKeyboardMarkup(inline_keyboard=kb)
@@ -1028,7 +1289,9 @@ async def disconnect_device_handler(
pass
return
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
active = await subscription_service.get_active_subscription_details(
session, callback.from_user.id
)
if not active or not active.get("user_id"):
await callback.answer(get_text("subscription_not_active"), show_alert=True)
return
@@ -1064,7 +1327,9 @@ async def disconnect_device_handler(
await callback.answer(get_text("device_disconnected"))
except Exception:
pass
await my_devices_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
await my_devices_command_handler(
callback, i18n_data, settings, panel_service, subscription_service, session, bot
)
@router.callback_query(F.data.startswith("toggle_autorenew:"))
@@ -1101,7 +1366,9 @@ async def toggle_autorenew_handler(
await callback.answer(get_text("error_try_again"), show_alert=True)
return
if enable:
has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id)
has_saved_card = await user_billing_dal.user_has_saved_payment_method(
session, callback.from_user.id
)
if not has_saved_card:
try:
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
@@ -1110,7 +1377,9 @@ async def toggle_autorenew_handler(
return
# Show confirmation popup and inline buttons
confirm_text = get_text("autorenew_confirm_enable") if enable else get_text("autorenew_confirm_disable")
confirm_text = (
get_text("autorenew_confirm_enable") if enable else get_text("autorenew_confirm_disable")
)
kb = get_autorenew_confirm_keyboard(enable, sub.subscription_id, current_lang, i18n)
try:
await callback.message.edit_text(confirm_text, reply_markup=kb)
@@ -1159,25 +1428,33 @@ async def confirm_autorenew_handler(
await callback.answer(get_text("error_try_again"), show_alert=True)
return
if enable:
has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id)
has_saved_card = await user_billing_dal.user_has_saved_payment_method(
session, callback.from_user.id
)
if not has_saved_card:
try:
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
except Exception:
pass
try:
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
await my_subscription_command_handler(
callback, i18n_data, settings, panel_service, subscription_service, session, bot
)
except Exception:
pass
return
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable})
await subscription_dal.update_subscription(
session, sub.subscription_id, {"auto_renew_enabled": enable}
)
await session.commit()
try:
await callback.answer(get_text("subscription_autorenew_updated"))
except Exception:
pass
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
await my_subscription_command_handler(
callback, i18n_data, settings, panel_service, subscription_service, session, bot
)
@router.callback_query(F.data == "autorenew:cancel")
@@ -1196,6 +1473,7 @@ async def autorenew_cancel_from_webhook_button(
# Disable auto-renew on the active subscription
from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id)
if not sub:
try:
@@ -1209,13 +1487,17 @@ async def autorenew_cancel_from_webhook_button(
except Exception:
pass
return
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": False})
await subscription_dal.update_subscription(
session, sub.subscription_id, {"auto_renew_enabled": False}
)
await session.commit()
try:
await callback.answer(get_text("subscription_autorenew_updated"))
except Exception:
pass
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
await my_subscription_command_handler(
callback, i18n_data, settings, panel_service, subscription_service, session, bot
)
@router.message(Command("connect"))
@@ -1229,4 +1511,6 @@ async def connect_command_handler(
bot: Bot,
):
logging.info(f"User {message.from_user.id} used /connect command.")
await my_subscription_command_handler(message, i18n_data, settings, panel_service, subscription_service, session, bot)
await my_subscription_command_handler(
message, i18n_data, settings, panel_service, subscription_service, session, bot
)
+126 -40
View File
@@ -1,25 +1,28 @@
from aiogram import Router, F, types
from typing import Optional, List
from typing import List, Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from config.settings import Settings
from bot.keyboards.inline.user_keyboards import (
get_payment_methods_list_keyboard,
get_bind_url_keyboard,
get_payment_method_delete_confirm_keyboard,
get_payment_method_details_keyboard,
get_bind_url_keyboard,
get_payment_methods_list_keyboard,
)
from bot.services.yookassa_service import YooKassaService
from bot.middlewares.i18n import JsonI18n
from bot.services.yookassa_service import YooKassaService
from config.settings import Settings
from db.dal import user_billing_dal
from db.models import Payment
from sqlalchemy.future import select
router = Router(name="user_subscription_payment_methods_router")
@router.callback_query(F.data == "pm:manage")
async def payment_methods_manage(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
async def payment_methods_manage(
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")
if not settings.yookassa_autopayments_active:
@@ -32,6 +35,7 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
from db.dal.user_billing_dal import list_user_payment_methods
get_text = _
methods = await list_user_payment_methods(session, callback.from_user.id)
cards: List[tuple] = []
@@ -64,7 +68,9 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
if not cards:
text += "\n\n" + get_text("payment_method_none")
await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n))
await callback.message.edit_text(
text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n)
)
try:
await callback.answer()
except Exception:
@@ -72,7 +78,13 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
@router.callback_query(F.data == "pm:bind")
async def payment_method_bind(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
async def payment_method_bind(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
yookassa_service: YooKassaService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not settings.yookassa_autopayments_active:
@@ -98,7 +110,10 @@ async def payment_method_bind(callback: types.CallbackQuery, settings: Settings,
if not resp or not resp.get("confirmation_url"):
await callback.answer(_("error_payment_gateway"), show_alert=True)
return
await callback.message.edit_text(_("payment_methods_title"), reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n))
await callback.message.edit_text(
_("payment_methods_title"),
reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n),
)
try:
await callback.answer()
except Exception:
@@ -106,7 +121,9 @@ async def payment_method_bind(callback: types.CallbackQuery, settings: Settings,
@router.callback_query(F.data.startswith("pm:delete_confirm"))
async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict):
async def payment_method_delete_confirm(
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 settings.yookassa_autopayments_active:
@@ -119,7 +136,10 @@ async def payment_method_delete_confirm(callback: types.CallbackQuery, settings:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
parts = callback.data.split(":", 2)
pm_id = parts[2] if len(parts) >= 3 else ""
await callback.message.edit_text(_("payment_method_delete_confirm"), reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n))
await callback.message.edit_text(
_("payment_method_delete_confirm"),
reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n),
)
try:
await callback.answer()
except Exception:
@@ -127,7 +147,9 @@ async def payment_method_delete_confirm(callback: types.CallbackQuery, settings:
@router.callback_query(F.data.startswith("pm:delete"))
async def payment_method_delete(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
async def payment_method_delete(
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")
if not settings.yookassa_autopayments_active:
@@ -148,13 +170,20 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
delete_user_payment_method_by_provider_id,
list_user_payment_methods,
)
if pm_id_raw:
if pm_id_raw.isdigit():
deleted = await delete_user_payment_method(session, callback.from_user.id, int(pm_id_raw))
deleted = await delete_user_payment_method(
session, callback.from_user.id, int(pm_id_raw)
)
else:
deleted = await delete_user_payment_method_by_provider_id(session, callback.from_user.id, pm_id_raw)
deleted = await delete_user_payment_method_by_provider_id(
session, callback.from_user.id, pm_id_raw
)
try:
legacy_deleted = await user_billing_dal.delete_yk_payment_method(session, callback.from_user.id)
legacy_deleted = await user_billing_dal.delete_yk_payment_method(
session, callback.from_user.id
)
deleted = deleted or legacy_deleted
except Exception:
pass
@@ -164,12 +193,15 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
text = _("payment_methods_title")
cards = []
for m in methods:
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
@@ -181,12 +213,16 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
return _("payment_method_card_title", network=network_name, last4=last4)
network_name = network or _("payment_network_generic")
return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(m.card_network, m.card_last4)
cards.append((str(m.method_id), title if not m.is_default else f"{title}"))
if not cards:
text += "\n\n" + _("payment_method_none")
msg = _("payment_method_deleted_success") if deleted else _("error_try_again")
await callback.message.edit_text(f"{msg}\n\n{text}", reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n))
await callback.message.edit_text(
f"{msg}\n\n{text}",
reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n),
)
try:
await callback.answer()
except Exception:
@@ -201,7 +237,9 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
@router.callback_query(F.data.startswith("pm:view"))
async def payment_method_view(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
async def payment_method_view(
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")
if not settings.yookassa_autopayments_active:
@@ -216,13 +254,21 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
billing = await user_billing_dal.get_user_billing(session, callback.from_user.id)
if not billing or not billing.yookassa_payment_method_id:
from db.dal.user_billing_dal import list_user_payment_methods
methods = await list_user_payment_methods(session, callback.from_user.id)
if not methods:
await callback.answer(_("payment_method_none"), show_alert=True)
return
parts = callback.data.split(":", 2)
pm_id = parts[2] if len(parts) >= 3 else str(methods[0].method_id)
sel = next((m for m in methods if str(m.method_id) == pm_id or m.provider_payment_method_id == pm_id), methods[0])
sel = next(
(
m
for m in methods
if str(m.method_id) == pm_id or m.provider_payment_method_id == pm_id
),
methods[0],
)
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
@@ -245,15 +291,15 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(sel.card_network, sel.card_last4)
added_at = sel.created_at.strftime('%Y-%m-%d') if getattr(sel, 'created_at', None) else ""
added_at = sel.created_at.strftime("%Y-%m-%d") if getattr(sel, "created_at", None) else ""
last_tx = ""
try:
stmt = (
select(Payment)
.where(
Payment.user_id == callback.from_user.id,
Payment.status == 'succeeded',
Payment.provider == 'yookassa',
Payment.status == "succeeded",
Payment.provider == "yookassa",
)
.order_by(Payment.created_at.desc())
.limit(1)
@@ -261,26 +307,33 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
result = await session.execute(stmt)
lp = result.scalar_one_or_none()
if lp and lp.created_at:
last_tx = lp.created_at.strftime('%Y-%m-%d')
last_tx = lp.created_at.strftime("%Y-%m-%d")
except Exception:
pass
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}"
await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(str(sel.method_id), current_lang, i18n))
await callback.message.edit_text(
details,
reply_markup=get_payment_method_details_keyboard(
str(sel.method_id), current_lang, i18n
),
)
try:
await callback.answer()
except Exception:
pass
return
added_at = billing.created_at.strftime('%Y-%m-%d') if getattr(billing, 'created_at', None) else ""
added_at = (
billing.created_at.strftime("%Y-%m-%d") if getattr(billing, "created_at", None) else ""
)
last_tx = ""
try:
stmt = (
select(Payment)
.where(
Payment.user_id == callback.from_user.id,
Payment.status == 'succeeded',
Payment.provider == 'yookassa',
Payment.status == "succeeded",
Payment.provider == "yookassa",
)
.order_by(Payment.created_at.desc())
.limit(1)
@@ -288,7 +341,7 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
result = await session.execute(stmt)
last_payment = result.scalar_one_or_none()
if last_payment and last_payment.created_at:
last_tx = last_payment.created_at.strftime('%Y-%m-%d')
last_tx = last_payment.created_at.strftime("%Y-%m-%d")
except Exception:
pass
@@ -314,7 +367,12 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
title = _format_pm_title(billing.card_network, billing.card_last4)
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}"
await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(billing.yookassa_payment_method_id, current_lang, i18n))
await callback.message.edit_text(
details,
reply_markup=get_payment_method_details_keyboard(
billing.yookassa_payment_method_id, current_lang, i18n
),
)
try:
await callback.answer()
except Exception:
@@ -322,7 +380,13 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
@router.callback_query(F.data.startswith("pm:history"))
async def payment_method_history(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
async def payment_method_history(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
yookassa_service: YooKassaService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not settings.yookassa_autopayments_active:
@@ -335,6 +399,7 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
from db.dal import payment_dal
payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=30, offset=0)
user_payments = [p for p in payments if p.user_id == callback.from_user.id]
@@ -346,6 +411,7 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
pm_filter_requested = True
if split_pm_id.isdigit():
from db.dal.user_billing_dal import list_user_payment_methods
methods = await list_user_payment_methods(session, callback.from_user.id)
sel = next((m for m in methods if str(m.method_id) == split_pm_id), None)
if sel and sel.provider_payment_method_id:
@@ -362,7 +428,7 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
if selected_pm_provider_id:
filtered: List[Payment] = []
for p in user_payments:
if p.provider != 'yookassa':
if p.provider != "yookassa":
continue
if p.yookassa_payment_id and yookassa_service:
try:
@@ -376,7 +442,11 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
user_payments = filtered
if not user_payments:
from bot.keyboards.inline.user_keyboards import get_back_to_payment_method_details_keyboard, get_payment_methods_manage_keyboard
from bot.keyboards.inline.user_keyboards import (
get_back_to_payment_method_details_keyboard,
get_payment_methods_manage_keyboard,
)
back_pm_id = ""
try:
split_a, split_b, back_pm_id = callback.data.split(":", 2)
@@ -395,11 +465,15 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
def _format_item(p: Payment) -> str:
if traffic_mode:
units_val = p.subscription_duration_months or 0
units_display = str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
units_display = (
str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
)
title = p.description or _("traffic_purchase_title", traffic_gb=units_display)
else:
title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1)
date_str = p.created_at.strftime('%Y-%m-%d') if p.created_at else "N/A"
title = p.description or _(
"subscription_purchase_title", months=p.subscription_duration_months or 1
)
date_str = p.created_at.strftime("%Y-%m-%d") if p.created_at else "N/A"
return f"{date_str}{title}{p.amount:.2f} {p.currency}"
lines = [_format_item(p) for p in user_payments]
@@ -408,7 +482,11 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
split_a, split_b, split_pm_id_for_back = callback.data.split(":", 2)
except Exception:
split_pm_id_for_back = ""
from bot.keyboards.inline.user_keyboards import get_back_to_payment_method_details_keyboard, get_payment_methods_manage_keyboard
from bot.keyboards.inline.user_keyboards import (
get_back_to_payment_method_details_keyboard,
get_payment_methods_manage_keyboard,
)
back_markup = (
get_back_to_payment_method_details_keyboard(split_pm_id_for_back, current_lang, i18n)
if split_pm_id_for_back
@@ -418,21 +496,27 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
@router.callback_query(F.data.startswith("pm:list:"))
async def payment_methods_list(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
async def payment_methods_list(
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")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
from db.dal.user_billing_dal import list_user_payment_methods
cards: List[tuple] = []
methods = await list_user_payment_methods(session, callback.from_user.id)
for m in methods:
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
@@ -444,6 +528,7 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings
return get_text("payment_method_card_title", network=network_name, last4=last4)
network_name = network or get_text("payment_network_generic")
return get_text("payment_method_generic_title", network=network_name)
title = _format_pm_title(m.card_network, m.card_last4)
cards.append((str(m.method_id), title if not m.is_default else f"{title}"))
@@ -456,9 +541,10 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings
text = get_text("payment_methods_title")
if not cards:
text += "\n\n" + get_text("payment_method_none")
await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n))
await callback.message.edit_text(
text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n)
)
try:
await callback.answer()
except Exception:
pass
@@ -1,4 +1,4 @@
from typing import Optional
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
@@ -21,7 +21,7 @@ async def pay_crypto_callback_handler(
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = (lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key)
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
@@ -30,7 +30,11 @@ async def pay_crypto_callback_handler(
pass
return
if not settings.CRYPTOPAY_ENABLED or not cryptopay_service or not getattr(cryptopay_service, "configured", False):
if (
not settings.CRYPTOPAY_ENABLED
or not cryptopay_service
or not getattr(cryptopay_service, "configured", False)
):
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
@@ -56,7 +60,11 @@ async def pay_crypto_callback_handler(
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months)))
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
invoice_url = await cryptopay_service.create_invoice(
@@ -72,7 +80,9 @@ async def pay_crypto_callback_handler(
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -89,7 +99,9 @@ async def pay_crypto_callback_handler(
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -1,4 +1,4 @@
import logging
import logging
from datetime import datetime
from typing import Optional
@@ -65,9 +65,17 @@ async def pay_fk_callback_handler(
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months)))
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
currency_code = (
getattr(freekassa_service, "default_currency", None)
or settings.DEFAULT_CURRENCY_SYMBOL
or "RUB"
)
currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_record_payload = {
"user_id": user_id,
@@ -79,8 +87,12 @@ async def pay_fk_callback_handler(
"provider": "freekassa",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
"purchased_hwid_devices": int(months) if sale_base in {"hwid_device", "hwid_devices"} else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"}
else None,
}
try:
@@ -138,7 +150,9 @@ async def pay_fk_callback_handler(
)
if location:
order_identifier_display = str(order_id_api or provider_identifier or payment_record.payment_id)
order_identifier_display = str(
order_id_api or provider_identifier or payment_record.payment_id
)
order_info_text = get_text(
"free_kassa_order_info",
order_id=order_identifier_display,
@@ -146,8 +160,11 @@ async def pay_fk_callback_handler(
)
try:
await callback.message.edit_text(
f"{order_info_text}\n\n" + get_text(
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
f"{order_info_text}\n\n"
+ get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -161,11 +178,16 @@ async def pay_fk_callback_handler(
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(f"FreeKassa: failed to display payment link ({e_edit}), sending new message.")
logging.warning(
f"FreeKassa: failed to display payment link ({e_edit}), sending new message."
)
try:
await callback.message.answer(
f"{order_info_text}\n\n" + get_text(
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
f"{order_info_text}\n\n"
+ get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -207,7 +229,10 @@ async def pay_fk_callback_handler(
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
logging.error(
f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
@@ -1,4 +1,4 @@
import json
import json
import logging
from typing import Optional
@@ -92,7 +92,11 @@ async def pay_platega_callback_handler(
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months)))
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
@@ -106,8 +110,12 @@ async def pay_platega_callback_handler(
"provider": "platega",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
"purchased_hwid_devices": int(months) if sale_base in {"hwid_device", "hwid_devices"} else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"}
else None,
}
try:
@@ -178,7 +186,9 @@ async def pay_platega_callback_handler(
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -192,11 +202,15 @@ async def pay_platega_callback_handler(
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(f"Platega: failed to display payment link ({e_edit}), sending new message.")
logging.warning(
f"Platega: failed to display payment link ({e_edit}), sending new message."
)
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -232,7 +246,10 @@ async def pay_platega_callback_handler(
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(f"Platega: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
logging.error(
f"Platega: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
@@ -1,4 +1,4 @@
import logging
import logging
from typing import Optional
from aiogram import F, Router, types
@@ -64,7 +64,11 @@ async def pay_severpay_callback_handler(
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months)))
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
@@ -78,8 +82,12 @@ async def pay_severpay_callback_handler(
"provider": "severpay",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
"purchased_hwid_devices": int(months) if sale_base in {"hwid_device", "hwid_devices"} else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"}
else None,
}
try:
@@ -138,7 +146,9 @@ async def pay_severpay_callback_handler(
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -152,11 +162,15 @@ async def pay_severpay_callback_handler(
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(f"SeverPay: failed to display payment link ({e_edit}), sending new message.")
logging.warning(
f"SeverPay: failed to display payment link ({e_edit}), sending new message."
)
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -192,7 +206,10 @@ async def pay_severpay_callback_handler(
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
logging.error(
f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
@@ -1,4 +1,4 @@
import logging
import logging
from typing import Optional
from aiogram import F, Router, types
@@ -22,7 +22,7 @@ async def pay_stars_callback_handler(
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = (lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key)
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
@@ -57,7 +57,11 @@ async def pay_stars_callback_handler(
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months)))
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
payment_db_id = await stars_service.create_invoice(
@@ -73,16 +77,22 @@ async def pay_stars_callback_handler(
try:
await callback.message.edit_text(
get_text(
"payment_invoice_sent_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_invoice_sent_message",
"payment_invoice_sent_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_invoice_sent_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=get_text("back_to_payment_methods_button"),
callback_data=f"subscribe_period:{human_value}",
)]
]),
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=get_text("back_to_payment_methods_button"),
callback_data=f"subscribe_period:{human_value}",
)
]
]
),
)
except Exception as e_edit:
logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})")
@@ -115,8 +125,9 @@ async def handle_successful_stars_payment(
session: AsyncSession,
stars_service: StarsService,
):
payload = (message.successful_payment.invoice_payload
if message and message.successful_payment else "")
payload = (
message.successful_payment.invoice_payload if message and message.successful_payment else ""
)
try:
parts = (payload or "").split(":")
payment_db_id = int(parts[0])
@@ -43,7 +43,9 @@ async def select_subscription_period_callback_handler(
return
price_source = traffic_packages if traffic_mode else settings.subscription_options
stars_price_source = stars_traffic_packages if traffic_mode else settings.stars_subscription_options
stars_price_source = (
stars_traffic_packages if traffic_mode else settings.stars_subscription_options
)
price_rub = price_source.get(months)
stars_price = stars_price_source.get(months)
@@ -82,7 +84,11 @@ async def select_subscription_period_callback_handler(
pass
return
text_content = get_text("choose_payment_method_traffic") if traffic_mode else get_text("choose_payment_method")
text_content = (
get_text("choose_payment_method_traffic")
if traffic_mode
else get_text("choose_payment_method")
)
reply_markup = get_payment_method_keyboard(
months,
price_rub,
@@ -1,4 +1,4 @@
import logging
import logging
from typing import List, Optional, Tuple
from aiogram import F, Router, types
@@ -37,7 +37,9 @@ def _sale_mode_base(sale_mode: str) -> str:
return (sale_mode or "subscription").split("@", 1)[0].split("|", 1)[0]
def _format_saved_payment_method_title(get_text, network: Optional[str], last4: Optional[str], is_default: bool) -> str:
def _format_saved_payment_method_title(
get_text, network: Optional[str], last4: Optional[str], is_default: bool
) -> str:
def _is_yoomoney_network(name: Optional[str]) -> bool:
s = (name or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
@@ -85,7 +87,11 @@ async def _initiate_yk_payment(
payment_description = (
get_text("payment_description_traffic", traffic_gb=_format_value(months))
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months)))
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
payment_record_data = {
"user_id": user_id,
@@ -96,8 +102,12 @@ async def _initiate_yk_payment(
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"sale_mode": sale_base,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
"purchased_hwid_devices": int(months) if sale_base in {"hwid_device", "hwid_devices"} else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"}
else None,
}
db_payment_record = None
@@ -157,7 +167,11 @@ async def _initiate_yk_payment(
title = pm.get("title")
card = pm.get("card") or {}
account_number = pm.get("account_number") or pm.get("account")
if isinstance(card, dict) and (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
if isinstance(card, dict) and (pm_type or "").lower() in {
"bank_card",
"bank-card",
"card",
}:
display_network = card.get("card_type") or title or "Card"
display_last4 = card.get("last4")
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
@@ -206,7 +220,9 @@ async def _initiate_yk_payment(
session, user_id, selected_method_internal_id
)
except Exception:
logging.exception("Failed to set default payment method after initiating payment")
logging.exception(
"Failed to set default payment method after initiating payment"
)
await session.commit()
except Exception as e_db_update_ykid:
await session.rollback()
@@ -223,7 +239,9 @@ async def _initiate_yk_payment(
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=_format_value(months),
),
@@ -237,13 +255,13 @@ async def _initiate_yk_payment(
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(
f"Edit message for payment link failed: {e_edit}. Sending new one."
)
logging.warning(f"Edit message for payment link failed: {e_edit}. Sending new one.")
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=_format_value(months),
),
@@ -275,7 +293,9 @@ async def _initiate_yk_payment(
session, user_id, selected_method_internal_id
)
except Exception:
logging.exception("Failed to set default payment method after saved-card payment start")
logging.exception(
"Failed to set default payment method after saved-card payment start"
)
await session.commit()
except Exception as e_db_update_saved:
await session.rollback()
@@ -328,7 +348,13 @@ async def _initiate_yk_payment(
@router.callback_query(F.data.startswith("pay_yk:"))
async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
async def pay_yk_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
yookassa_service: YooKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -372,9 +398,13 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
months, price_rub, sale_mode = parsed
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
autopay_enabled = bool(settings.yookassa_autopayments_active and _sale_mode_base(sale_mode) == "subscription" and not settings.traffic_sale_mode)
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
and not settings.traffic_sale_mode
)
autopay_require_binding = bool(
getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True)
getattr(settings, "YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", True)
)
saved_methods: List = []
if autopay_enabled:
@@ -444,7 +474,13 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
@router.callback_query(F.data.startswith("pay_yk_new:"))
async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
async def pay_yk_new_card_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
yookassa_service: YooKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -490,9 +526,13 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
months, price_rub, sale_mode = parsed
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
autopay_enabled = bool(settings.yookassa_autopayments_active and _sale_mode_base(sale_mode) == "subscription" and not settings.traffic_sale_mode)
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
and not settings.traffic_sale_mode
)
autopay_require_binding = bool(
getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True)
getattr(settings, "YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", True)
)
await _initiate_yk_payment(
@@ -518,7 +558,13 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
@router.callback_query(F.data.startswith("pay_yk_saved_list:"))
async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
async def pay_yk_saved_list_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
yookassa_service: YooKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -562,7 +608,11 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set
pass
return
autopay_enabled = bool(settings.yookassa_autopayments_active and _sale_mode_base(sale_mode) == "subscription" and not settings.traffic_sale_mode)
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
and not settings.traffic_sale_mode
)
if not autopay_enabled:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
@@ -662,7 +712,13 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set
@router.callback_query(F.data.startswith("pay_yk_use_saved:"))
async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
async def pay_yk_use_saved_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
yookassa_service: YooKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -717,7 +773,11 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
pass
return
autopay_enabled = bool(settings.yookassa_autopayments_active and _sale_mode_base(sale_mode) == "subscription" and not settings.traffic_sale_mode)
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
and not settings.traffic_sale_mode
)
if not autopay_enabled:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
@@ -747,7 +807,9 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
break
if not selected_method:
logging.warning(f"Selected payment method not found for user {user_id}: {method_identifier}")
logging.warning(
f"Selected payment method not found for user {user_id}: {method_identifier}"
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
+30 -57
View File
@@ -1,20 +1,21 @@
import logging
from aiogram import Router, F, types, Bot
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService
from bot.services.notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import (
get_trial_confirmation_keyboard,
get_main_menu_inline_keyboard,
get_connect_and_main_keyboard,
get_main_menu_inline_keyboard,
)
from bot.utils.config_link import prepare_config_links
from bot.middlewares.i18n import JsonI18n
from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
from config.settings import Settings
from .start import send_main_menu
router = Router(name="user_trial_router")
@@ -47,9 +48,7 @@ async def request_trial_confirmation_handler(
if not settings.TRIAL_ENABLED:
await callback.message.edit_text(
_("trial_feature_disabled"),
reply_markup=get_main_menu_inline_keyboard(
current_lang, i18n, settings, False
),
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n, settings, False),
)
try:
await callback.answer()
@@ -60,9 +59,7 @@ async def request_trial_confirmation_handler(
if await subscription_service.has_had_any_subscription(session, user_id):
await callback.message.edit_text(
_("trial_already_had_subscription_or_trial"),
reply_markup=get_main_menu_inline_keyboard(
current_lang, i18n, settings, False
),
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n, settings, False),
)
try:
await callback.answer()
@@ -71,9 +68,7 @@ async def request_trial_confirmation_handler(
return
# Directly activate trial without confirmation
activation_result = await subscription_service.activate_trial_subscription(
session, user_id
)
activation_result = await subscription_service.activate_trial_subscription(session, user_id)
final_message_text_in_chat = ""
show_trial_button_after_action = False
@@ -93,9 +88,7 @@ async def request_trial_confirmation_handler(
)
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
traffic_gb_val = activation_result.get(
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
)
traffic_gb_val = activation_result.get("traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB)
traffic_display = (
f"{traffic_gb_val} GB"
if traffic_gb_val and traffic_gb_val > 0
@@ -106,20 +99,19 @@ async def request_trial_confirmation_handler(
"trial_activated_details_message",
days=activation_result.get("days", settings.TRIAL_DURATION_DAYS),
end_date=(
end_date_obj.strftime("%Y-%m-%d")
if isinstance(end_date_obj, datetime)
else "N/A"
end_date_obj.strftime("%Y-%m-%d") if isinstance(end_date_obj, datetime) else "N/A"
),
config_link=config_link_for_trial,
traffic_gb=traffic_display,
)
# Send notification to admin about new trial
notification_service = NotificationService(callback.bot, settings, i18n)
await notification_service.notify_trial_activation(user_id, end_date_obj)
# Mark ad attribution trial if exists
try:
from db.dal import ad_dal as _ad_dal
await _ad_dal.mark_trial_activated(session, user_id)
await session.commit()
except Exception as e_mark:
@@ -136,11 +128,8 @@ async def request_trial_confirmation_handler(
await callback.answer(final_message_text_in_chat, show_alert=True)
except Exception:
pass
if (
settings.TRIAL_ENABLED
and not await subscription_service.has_had_any_subscription(
session, user_id
)
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
session, user_id
):
show_trial_button_after_action = True
@@ -166,9 +155,7 @@ async def request_trial_confirmation_handler(
disable_web_page_preview=True,
)
except Exception as e_edit:
logging.warning(
f"Could not edit trial result message: {e_edit}. Sending new one."
)
logging.warning(f"Could not edit trial result message: {e_edit}. Sending new one.")
if callback.message:
await callback.message.answer(
@@ -213,9 +200,7 @@ async def confirm_activate_trial_handler(
return
if await subscription_service.has_had_any_subscription(session, user_id):
try:
await callback.answer(
_("trial_already_had_subscription_or_trial"), show_alert=True
)
await callback.answer(_("trial_already_had_subscription_or_trial"), show_alert=True)
except Exception:
pass
await send_main_menu(
@@ -223,9 +208,7 @@ async def confirm_activate_trial_handler(
)
return
activation_result = await subscription_service.activate_trial_subscription(
session, user_id
)
activation_result = await subscription_service.activate_trial_subscription(session, user_id)
final_message_text_in_chat = ""
show_trial_button_after_action = False
@@ -245,9 +228,7 @@ async def confirm_activate_trial_handler(
)
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
traffic_gb_val = activation_result.get(
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
)
traffic_gb_val = activation_result.get("traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB)
traffic_display = (
f"{traffic_gb_val} GB"
if traffic_gb_val and traffic_gb_val > 0
@@ -258,9 +239,7 @@ async def confirm_activate_trial_handler(
"trial_activated_details_message",
days=activation_result.get("days", settings.TRIAL_DURATION_DAYS),
end_date=(
end_date_obj.strftime("%Y-%m-%d")
if isinstance(end_date_obj, datetime)
else "N/A"
end_date_obj.strftime("%Y-%m-%d") if isinstance(end_date_obj, datetime) else "N/A"
),
config_link=config_link_for_trial,
traffic_gb=traffic_display,
@@ -276,11 +255,8 @@ async def confirm_activate_trial_handler(
await callback.answer(final_message_text_in_chat, show_alert=True)
except Exception:
pass
if (
settings.TRIAL_ENABLED
and not await subscription_service.has_had_any_subscription(
session, user_id
)
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
session, user_id
):
show_trial_button_after_action = True
@@ -306,9 +282,7 @@ async def confirm_activate_trial_handler(
disable_web_page_preview=True,
)
except Exception as e_edit:
logging.warning(
f"Could not edit trial result message: {e_edit}. Sending new one."
)
logging.warning(f"Could not edit trial result message: {e_edit}. Sending new one.")
if callback.message:
await callback.message.answer(
@@ -323,6 +297,7 @@ async def confirm_activate_trial_handler(
await notification_service.notify_trial_activation(user_id, end_date_obj)
try:
from db.dal import ad_dal as _ad_dal
await _ad_dal.mark_trial_activated(session, user_id)
await session.commit()
except Exception as e_mark:
@@ -338,6 +313,4 @@ async def cancel_trial_activation(
subscription_service: SubscriptionService,
session: AsyncSession,
):
await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
await send_main_menu(callback, settings, i18n_data, subscription_service, session, is_edit=True)