Merge pull request #140 from Fr1ngg/main

w
This commit is contained in:
Egor
2025-09-19 15:25:12 +03:00
committed by GitHub
8 changed files with 426 additions and 28 deletions

View File

@@ -216,7 +216,8 @@ async def get_users_list(
offset: int = 0,
limit: int = 50,
search: Optional[str] = None,
status: Optional[UserStatus] = None
status: Optional[UserStatus] = None,
order_by_balance: bool = False
) -> List[User]:
query = select(User).options(selectinload(User.subscription))
@@ -237,7 +238,13 @@ async def get_users_list(
query = query.where(or_(*conditions))
query = query.order_by(User.created_at.desc()).offset(offset).limit(limit)
# Сортировка по балансу в порядке убывания, если order_by_balance=True
if order_by_balance:
query = query.order_by(User.balance_kopeks.desc())
else:
query = query.order_by(User.created_at.desc())
query = query.offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()

View File

@@ -11,7 +11,8 @@ from app.database.models import User, UserStatus, Subscription, SubscriptionStat
from app.database.crud.user import get_user_by_id
from app.keyboards.admin import (
get_admin_users_keyboard, get_user_management_keyboard,
get_admin_pagination_keyboard, get_confirmation_keyboard
get_admin_pagination_keyboard, get_confirmation_keyboard,
get_admin_users_filters_keyboard
)
from app.localization.texts import get_texts
from app.services.user_service import UserService
@@ -58,15 +59,36 @@ async def show_users_menu(
await callback.answer()
@admin_required
@error_handler
async def show_users_filters(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
text = "⚙️ <b>Фильтры пользователей</b>\n\nВыберите фильтр для отображения пользователей:"
await callback.message.edit_text(
text,
reply_markup=get_admin_users_filters_keyboard(db_user.language)
)
await callback.answer()
@admin_required
@error_handler
async def show_users_list(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
page: int = 1
):
# Сбрасываем состояние, так как мы в обычном списке
await state.set_state(None)
user_service = UserService()
users_data = await user_service.get_users_page(db, page=page, limit=10)
@@ -152,20 +174,139 @@ async def show_users_list(
await callback.answer()
@admin_required
@error_handler
async def show_users_list_by_balance(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
page: int = 1
):
# Устанавливаем состояние, чтобы отслеживать, откуда пришел пользователь
await state.set_state(AdminStates.viewing_user_from_balance_list)
user_service = UserService()
users_data = await user_service.get_users_page(db, page=page, limit=10, order_by_balance=True)
if not users_data["users"]:
await callback.message.edit_text(
"👥 Пользователи не найдены",
reply_markup=get_admin_users_keyboard(db_user.language)
)
await callback.answer()
return
text = f"👥 <b>Список пользователей по балансу</b> (стр. {page}/{users_data['total_pages']})\n\n"
text += "Нажмите на пользователя для управления:"
keyboard = []
for user in users_data["users"]:
if user.status == UserStatus.ACTIVE.value:
status_emoji = ""
elif user.status == UserStatus.BLOCKED.value:
status_emoji = "🚫"
else:
status_emoji = "🗑️"
subscription_emoji = ""
if user.subscription:
if user.subscription.is_trial:
subscription_emoji = "🎁"
elif user.subscription.is_active:
subscription_emoji = "💎"
else:
subscription_emoji = ""
else:
subscription_emoji = ""
button_text = f"{status_emoji} {subscription_emoji} {user.full_name}"
if user.balance_kopeks > 0:
button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}"
# Добавляем дату окончания подписки, если есть подписка
if user.subscription and user.subscription.end_date:
days_left = (user.subscription.end_date - datetime.utcnow()).days
button_text += f" | 📅 {days_left}д"
if len(button_text) > 60:
short_name = user.full_name
if len(short_name) > 20:
short_name = short_name[:17] + "..."
button_text = f"{status_emoji} {subscription_emoji} {short_name}"
if user.balance_kopeks > 0:
button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}"
keyboard.append([
types.InlineKeyboardButton(
text=button_text,
callback_data=f"admin_user_manage_{user.id}"
)
])
if users_data["total_pages"] > 1:
pagination_row = get_admin_pagination_keyboard(
users_data["current_page"],
users_data["total_pages"],
"admin_users_balance_list",
"admin_users",
db_user.language
).inline_keyboard[0]
keyboard.append(pagination_row)
keyboard.extend([
[
types.InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search"),
types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats")
],
[
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")
]
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def handle_users_list_pagination_fixed(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
db: AsyncSession,
state: FSMContext
):
try:
callback_parts = callback.data.split('_')
page = int(callback_parts[-1])
await show_users_list(callback, db_user, db, page)
await show_users_list(callback, db_user, db, state, page)
except (ValueError, IndexError) as e:
logger.error(f"Ошибка парсинга номера страницы: {e}")
await show_users_list(callback, db_user, db, 1)
await show_users_list(callback, db_user, db, state, 1)
@admin_required
@error_handler
async def handle_users_balance_list_pagination(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
try:
callback_parts = callback.data.split('_')
page = int(callback_parts[-1])
await show_users_list_by_balance(callback, db_user, db, state, page)
except (ValueError, IndexError) as e:
logger.error(f"Ошибка парсинга номера страницы: {e}")
await show_users_list_by_balance(callback, db_user, db, state, 1)
@admin_required
@@ -564,11 +705,18 @@ async def process_user_search(
async def show_user_management(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
db: AsyncSession,
state: FSMContext
):
user_id = int(callback.data.split('_')[-1])
# Проверяем, откуда пришел пользователь
back_callback = "admin_users_list"
# Если callback_data содержит информацию о том, что мы пришли из списка по балансу
# В реальности это сложно определить, поэтому будем использовать состояние
user_service = UserService()
profile = await user_service.get_user_profile(db, user_id)
@@ -621,13 +769,19 @@ async def show_user_management(
else:
text += "\n<b>Подписка:</b> Отсутствует"
# Проверяем состояние, чтобы определить, откуда пришел пользователь
current_state = await state.get_state()
if current_state == AdminStates.viewing_user_from_balance_list:
back_callback = "admin_users_balance_filter"
await callback.message.edit_text(
text,
reply_markup=get_user_management_keyboard(user.id, user.status, db_user.language)
reply_markup=get_user_management_keyboard(user.id, user.status, db_user.language, back_callback)
)
await callback.answer()
@admin_required
@error_handler
async def start_balance_edit(
@@ -2756,6 +2910,11 @@ def register_handlers(dp: Dispatcher):
F.data.startswith("admin_users_list_page_")
)
dp.callback_query.register(
handle_users_balance_list_pagination,
F.data.startswith("admin_users_balance_list_page_")
)
dp.callback_query.register(
start_user_search,
F.data == "admin_users_search"
@@ -2938,6 +3097,22 @@ def register_handlers(dp: Dispatcher):
admin_buy_subscription_execute,
F.data.startswith("admin_buy_sub_execute_")
)
# Регистрация обработчиков для фильтрации пользователей
dp.callback_query.register(
show_users_filters,
F.data == "admin_users_filters"
)
dp.callback_query.register(
show_users_list_by_balance,
F.data == "admin_users_balance_filter"
)
dp.callback_query.register(
show_users_list_by_balance,
F.data.startswith("admin_users_balance_list_page_")
)

View File

@@ -26,30 +26,22 @@ TRANSACTIONS_PER_PAGE = 10
def get_quick_amount_buttons(language: str) -> list:
"""
Генерирует кнопки быстрого выбора суммы пополнения на основе
AVAILABLE_SUBSCRIPTION_PERIODS и PRICE_*_DAYS
"""
if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
return []
buttons = []
periods = settings.get_available_subscription_periods()
# Ограничиваем до 6 кнопок (3 ряда по 2 кнопки)
periods = periods[:6]
for period in periods:
# Получаем цену из настроек
price_attr = f"PRICE_{period}_DAYS"
if hasattr(settings, price_attr):
price_kopeks = getattr(settings, price_attr)
price_rubles = price_kopeks // 100
# Создаем callback_data для каждой кнопки
callback_data = f"quick_amount_{price_kopeks}"
# Добавляем кнопку
buttons.append(
types.InlineKeyboardButton(
text=f"{price_rubles} ₽ ({period} дней)",
@@ -57,7 +49,6 @@ def get_quick_amount_buttons(language: str) -> list:
)
)
# Разбиваем кнопки на ряды (по 2 в ряд)
keyboard_rows = []
for i in range(0, len(buttons), 2):
keyboard_rows.append(buttons[i:i + 2])
@@ -386,7 +377,67 @@ async def start_tribute_payment(
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
await callback.answer()
async def handle_successful_topup_with_cart(
user_id: int,
amount_kopeks: int,
bot,
db: AsyncSession
):
from app.database.crud.user import get_user_by_id
from aiogram.fsm.context import FSMContext
from aiogram.fsm.storage.base import StorageKey
from app.bot import dp
user = await get_user_by_id(db, user_id)
if not user:
return
storage = dp.storage
key = StorageKey(bot_id=bot.id, chat_id=user.telegram_id, user_id=user.telegram_id)
try:
state_data = await storage.get_data(key)
current_state = await storage.get_state(key)
if (current_state == "SubscriptionStates:cart_saved_for_topup" and
state_data.get('saved_cart')):
texts = get_texts(user.language)
total_price = state_data.get('total_price', 0)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text="🛒 Вернуться к оформлению подписки",
callback_data="return_to_saved_cart"
)],
[types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance"
)],
[types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu"
)]
])
success_text = (
f"✅ Баланс пополнен на {texts.format_price(amount_kopeks)}!\n\n"
f"💰 Текущий баланс: {texts.format_price(user.balance_kopeks)}\n\n"
f"🛒 У вас есть сохраненная корзина подписки\n"
f"Стоимость: {texts.format_price(total_price)}\n\n"
f"Хотите продолжить оформление?"
)
await bot.send_message(
chat_id=user.telegram_id,
text=success_text,
reply_markup=keyboard,
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка обработки успешного пополнения с корзиной: {e}")
@error_handler
async def request_support_topup(

View File

@@ -37,7 +37,10 @@ from app.keyboards.inline import (
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_reset_confirm_keyboard,
get_device_management_help_keyboard
get_device_management_help_keyboard,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.remnawave_service import RemnaWaveService
@@ -626,7 +629,97 @@ async def start_subscription_purchase(
await state.set_state(SubscriptionStates.selecting_period)
await callback.answer()
async def save_cart_and_redirect_to_topup(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
missing_amount: int
):
from app.handlers.balance import show_payment_methods
texts = get_texts(db_user.language)
data = await state.get_data()
await state.set_state(SubscriptionStates.cart_saved_for_topup)
await state.update_data({
**data,
'saved_cart': True,
'missing_amount': missing_amount,
'return_to_cart': True
})
await callback.message.edit_text(
f"💰 Недостаточно средств для оформления подписки\n\n"
f"Требуется: {texts.format_price(missing_amount)}\n"
f"У вас: {texts.format_price(db_user.balance_kopeks)}\n\n"
f"🛒 Ваша корзина сохранена!\n"
f"После пополнения баланса вы сможете вернуться к оформлению подписки.\n\n"
f"Выберите способ пополнения:",
reply_markup=get_payment_methods_keyboard_with_cart(db_user.language),
parse_mode="HTML"
)
async def return_to_saved_cart(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
data = await state.get_data()
texts = get_texts(db_user.language)
if not data.get('saved_cart'):
await callback.answer("❌ Сохраненная корзина не найдена", show_alert=True)
return
total_price = data.get('total_price', 0)
if db_user.balance_kopeks < total_price:
missing_amount = total_price - db_user.balance_kopeks
await callback.message.edit_text(
f"Все еще недостаточно средств\n\n"
f"Требуется: {texts.format_price(total_price)}\n"
f"У вас: {texts.format_price(db_user.balance_kopeks)}\n"
f"Не хватает: {texts.format_price(missing_amount)}",
reply_markup=get_insufficient_balance_keyboard_with_cart(db_user.language)
)
return
from app.utils.pricing_utils import calculate_months_from_days, format_period_description
countries = await _get_available_countries()
selected_countries_names = []
months_in_period = calculate_months_from_days(data['period_days'])
period_display = format_period_description(data['period_days'], db_user.language)
for country in countries:
if country['uuid'] in data['countries']:
selected_countries_names.append(country['name'])
if settings.is_traffic_fixed():
traffic_display = "Безлимитный" if data['traffic_gb'] == 0 else f"{data['traffic_gb']} ГБ"
else:
traffic_display = "Безлимитный" if data['traffic_gb'] == 0 else f"{data['traffic_gb']} ГБ"
summary_text = (
"🛒 Восстановленная корзина\n\n"
f"📅 Период: {period_display}\n"
f"📊 Трафик: {traffic_display}\n"
f"🌍 Страны: {', '.join(selected_countries_names)}\n"
f"📱 Устройства: {data['devices']}\n\n"
f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n"
"Подтверждаете покупку?"
)
await callback.message.edit_text(
summary_text,
reply_markup=get_subscription_confirm_keyboard_with_cart(db_user.language),
parse_mode="HTML"
)
await state.set_state(SubscriptionStates.confirming_purchase)
await callback.answer("✅ Корзина восстановлена!")
async def handle_add_countries(
callback: types.CallbackQuery,
@@ -3602,6 +3695,19 @@ async def confirm_switch_traffic(
await callback.answer()
async def clear_saved_cart(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
await state.clear()
from app.handlers.menu import show_main_menu
await show_main_menu(callback, db_user, db)
await callback.answer("🗑️ Корзина очищена")
async def execute_switch_traffic(
callback: types.CallbackQuery,
@@ -3899,11 +4005,6 @@ def register_handlers(dp: Dispatcher):
F.data == "subscription_confirm",
SubscriptionStates.confirming_purchase
)
dp.callback_query.register(
resume_subscription_checkout,
F.data == "subscription_resume_checkout",
)
dp.callback_query.register(
handle_autopay_menu,

View File

@@ -110,12 +110,26 @@ def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats"),
InlineKeyboardButton(text="🗑️ Неактивные", callback_data="admin_users_inactive")
],
[
InlineKeyboardButton(text="⚙️ Фильтры", callback_data="admin_users_filters")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_users")
]
])
def get_admin_users_filters_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="💰 По балансу", callback_data="admin_users_balance_filter")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")
]
])
def get_admin_subscriptions_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
@@ -375,7 +389,7 @@ def get_admin_statistics_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
])
def get_user_management_keyboard(user_id: int, user_status: str, language: str = "ru") -> InlineKeyboardMarkup:
def get_user_management_keyboard(user_id: int, user_status: str, language: str = "ru", back_callback: str = "admin_users_list") -> InlineKeyboardMarkup:
keyboard = [
[
InlineKeyboardButton(text="💰 Баланс", callback_data=f"admin_user_balance_{user_id}"),
@@ -406,7 +420,7 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
])
keyboard.append([
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users_list")
InlineKeyboardButton(text="⬅️ Назад", callback_data=back_callback)
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)

View File

@@ -269,6 +269,51 @@ def get_subscription_keyboard(
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_payment_methods_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup:
keyboard = get_payment_methods_keyboard(0, language)
# Добавляем кнопку "Очистить корзину"
keyboard.inline_keyboard.append([
InlineKeyboardButton(
text="🗑️ Очистить корзину и вернуться",
callback_data="clear_saved_cart"
)
])
return keyboard
def get_subscription_confirm_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="✅ Подтвердить покупку",
callback_data="subscription_confirm"
)],
[InlineKeyboardButton(
text="🗑️ Очистить корзину",
callback_data="clear_saved_cart"
)],
[InlineKeyboardButton(
text="🔙 Назад",
callback_data="back_to_menu"
)]
])
def get_insufficient_balance_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="💰 Пополнить баланс",
callback_data="balance_topup"
)],
[InlineKeyboardButton(
text="🗑️ Очистить корзину",
callback_data="clear_saved_cart"
)],
[InlineKeyboardButton(
text="🔙 Назад",
callback_data="back_to_menu"
)]
])
def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[

View File

@@ -145,13 +145,14 @@ class UserService:
db: AsyncSession,
page: int = 1,
limit: int = 20,
status: Optional[UserStatus] = None
status: Optional[UserStatus] = None,
order_by_balance: bool = False
) -> Dict[str, Any]:
try:
offset = (page - 1) * limit
users = await get_users_list(
db, offset=offset, limit=limit, status=status
db, offset=offset, limit=limit, status=status, order_by_balance=order_by_balance
)
total_count = await get_users_count(db, status=status)

View File

@@ -16,6 +16,7 @@ class SubscriptionStates(StatesGroup):
adding_devices = State()
extending_subscription = State()
confirming_traffic_reset = State()
cart_saved_for_topup = State()
class BalanceStates(StatesGroup):
waiting_for_amount = State()
@@ -86,6 +87,9 @@ class AdminStates(StatesGroup):
editing_welcome_text = State()
waiting_for_message_buttons = "waiting_for_message_buttons"
# Состояния для отслеживания источника перехода
viewing_user_from_balance_list = State()
class SupportStates(StatesGroup):
waiting_for_message = State()