diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py index b970156..e0ea2d1 100644 --- a/bot/handlers/admin/user_management.py +++ b/bot/handlers/admin/user_management.py @@ -138,8 +138,14 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyb text=_(key="admin_user_refresh_button", default="🔄 Обновить"), callback_data=f"user_action:refresh:{user_id}" ) + + # Row 4: Destructive action + builder.button( + text=_(key="admin_user_delete_button", default="❌ Удалить пользователя"), + callback_data=f"user_action:delete_user:{user_id}" + ) - # Row 4: Back button + # Row 5: Navigation builder.button( text=_(key="admin_user_search_new_button", default="🔍 Найти другого"), callback_data="admin_action:users_management" @@ -149,7 +155,7 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyb callback_data="admin_action:main" ) - builder.adjust(2, 2, 2, 2) + builder.adjust(2, 2, 2, 1, 2) return builder @@ -367,6 +373,10 @@ async def user_action_handler(callback: types.CallbackQuery, state: FSMContext, await handle_view_user_logs(callback, user, session, settings, i18n, current_lang) elif action == "refresh": await handle_refresh_user_card(callback, user, subscription_service, session, i18n, current_lang) + elif action == "delete_user": + await handle_delete_user_prompt( + callback, state, user, settings, i18n, current_lang, session + ) else: await callback.answer(_("admin_unknown_action"), show_alert=True) @@ -592,8 +602,217 @@ async def handle_refresh_user_card(callback: types.CallbackQuery, user: User, await callback.answer("Error refreshing user card", show_alert=True) +# Destructive deletion flow +async def handle_delete_user_prompt(callback: types.CallbackQuery, state: FSMContext, + user: User, settings: Settings, i18n_instance, + lang: str, session: AsyncSession): + """Trigger confirmation workflow for destructive deletion.""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + admin = callback.from_user + admin_id = admin.id if admin else None + if not admin_id or admin_id not in settings.ADMIN_IDS: + logging.warning( + f"Unauthorized delete attempt by user {admin_id} targeting {user.user_id}." + ) + await callback.answer( + _( + "admin_user_delete_not_allowed", + default="❌ У вас нет прав для удаления пользователей.", + ), + show_alert=True, + ) + return + + await state.update_data( + target_user_id=user.user_id, + delete_initiator_id=admin_id, + ) + await state.set_state(AdminStates.waiting_for_user_delete_confirmation) + + prompt_text = _( + "admin_user_delete_confirmation_prompt", + default=( + "⚠️ Вы хотите полностью удалить пользователя {user_id}.\n\n" + "Отправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\n" + "Любой другой ответ отменит операцию." + ), + user_id=hcode(str(user.user_id)), + ) + + try: + await callback.message.answer(prompt_text, parse_mode="HTML") + except Exception as e: + logging.error( + f"Failed to send delete confirmation prompt for user {user.user_id}: {e}" + ) + await callback.message.reply(prompt_text, parse_mode="HTML") + + await callback.answer() + + +async def _log_admin_user_deletion( + session: AsyncSession, + admin_id: int, + admin_user: Optional[types.User], + target_user_id: int, +) -> None: + """Store audit log for successful deletion.""" + try: + await message_log_dal.create_message_log_no_commit( + session, + { + "user_id": admin_id, + "telegram_username": admin_user.username if admin_user else None, + "telegram_first_name": admin_user.first_name if admin_user else None, + "event_type": "admin:user_deleted", + "content": f"Admin {admin_id} deleted user {target_user_id}", + "raw_update_preview": None, + "is_admin_event": True, + "target_user_id": target_user_id, + "timestamp": datetime.now(timezone.utc), + }, + ) + except Exception as e: + logging.error( + f"Failed to log deletion audit for admin {admin_id} -> user {target_user_id}: {e}", + exc_info=True, + ) + + # Message handlers for state-based inputs +@router.message(AdminStates.waiting_for_user_delete_confirmation, F.text) +async def process_delete_user_confirmation_handler(message: types.Message, + state: FSMContext, + settings: Settings, + i18n_data: dict, + panel_service: PanelApiService, + session: AsyncSession): + """Confirm and execute destructive user deletion.""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + await state.clear() + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + admin = message.from_user + admin_id = admin.id if admin else None + if not admin_id or admin_id not in settings.ADMIN_IDS: + logging.warning( + f"Unauthorized delete confirmation attempt by user {admin_id}." + ) + await message.answer( + _( + "admin_user_delete_not_allowed", + default="❌ У вас нет прав для удаления пользователей.", + ) + ) + await state.clear() + return + + data = await state.get_data() + target_user_id = data.get("target_user_id") + if not target_user_id: + await message.answer( + _( + "admin_user_delete_state_missing", + default="⚠️ Нет активной операции удаления. Начните заново.", + ) + ) + await state.clear() + return + + confirmation_input = message.text.strip() if message.text else "" + if confirmation_input.lower() in {"/cancel", "cancel", "отмена"}: + await message.answer( + _( + "admin_user_delete_cancelled", + default="Операция удаления отменена по запросу.", + ) + ) + await state.clear() + return + + if confirmation_input != str(target_user_id): + await message.answer( + _( + "admin_user_delete_mismatch", + default="⚠️ ID не совпадает. Удаление отменено.", + ) + ) + await state.clear() + return + + user_model = await user_dal.get_user_by_id(session, target_user_id) + if not user_model: + await message.answer( + _( + "admin_user_delete_already_removed", + default="ℹ️ Пользователь уже удален.", + ) + ) + await state.clear() + return + + try: + if user_model.panel_user_uuid: + panel_deleted = await panel_service.delete_user_from_panel( + user_model.panel_user_uuid + ) + if not panel_deleted: + await message.answer( + _( + "admin_user_delete_panel_error", + default=( + "❌ Не удалось удалить пользователя на панели. " + "Операция прервана." + ), + ) + ) + await session.rollback() + await state.clear() + return + + deleted = await user_dal.delete_user_and_relations( + session, target_user_id + ) + if not deleted: + await message.answer( + _( + "admin_user_delete_already_removed", + default="ℹ️ Пользователь уже удален.", + ) + ) + await state.clear() + return + + await _log_admin_user_deletion(session, admin_id, admin, target_user_id) + await session.commit() + + await message.answer( + _( + "admin_user_delete_success", + default="✅ Пользователь {user_id} удален из бота и панели.", + user_id=hcode(str(target_user_id)), + ), + parse_mode="HTML", + ) + except Exception as e: + logging.error(f"Error deleting user {target_user_id}: {e}", exc_info=True) + await session.rollback() + await message.answer( + _( + "admin_user_delete_error", + default="❌ Не удалось завершить удаление пользователя. Попробуйте позже.", + ) + ) + finally: + await state.clear() + + @router.message(AdminStates.waiting_for_subscription_days_to_add, F.text) async def process_subscription_days_handler(message: types.Message, state: FSMContext, settings: Settings, i18n_data: dict, diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py index 60586d0..6acb08d 100644 --- a/bot/services/panel_api_service.py +++ b/bot/services/panel_api_service.py @@ -455,6 +455,37 @@ class PanelApiService: ) return False + async def delete_user_from_panel(self, + user_uuid: str, + log_response: bool = True) -> bool: + """Delete a user from the panel. Treat not-found as already deleted.""" + endpoint = f"/users/{user_uuid}" + response_data = await self._request( + "DELETE", endpoint, log_full_response=log_response + ) + + if not response_data: + logging.error( + f"Panel API delete_user_from_panel returned no data for user {user_uuid}." + ) + return False + + if response_data.get("error"): + details = response_data.get("details") or {} + error_code = details.get("errorCode") or response_data.get("errorCode") + if error_code in {"A062", "A040"}: + logging.info( + f"Panel user {user_uuid} already absent (errorCode {error_code}). Treating as deleted." + ) + return True + logging.error( + f"Failed to delete user {user_uuid} on panel. Response: {response_data}" + ) + return False + + logging.info(f"Panel user {user_uuid} deleted successfully.") + return True + async def get_subscription_link( self, short_uuid_or_sub_uuid: str, diff --git a/bot/states/admin_states.py b/bot/states/admin_states.py index 6db5421..68b0c4a 100644 --- a/bot/states/admin_states.py +++ b/bot/states/admin_states.py @@ -28,6 +28,7 @@ class AdminStates(StatesGroup): waiting_for_user_search = State() waiting_for_subscription_days_to_add = State() waiting_for_direct_message_to_user = State() + waiting_for_user_delete_confirmation = State() # Ads campaigns waiting_for_ad_source = State() diff --git a/db/dal/user_dal.py b/db/dal/user_dal.py index b0b7b42..4676a81 100644 --- a/db/dal/user_dal.py +++ b/db/dal/user_dal.py @@ -3,11 +3,20 @@ from typing import Optional, List, Dict, Any, Tuple from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import selectinload -from sqlalchemy import update, delete, func, and_ +from sqlalchemy import update, delete, func, and_, or_ from datetime import datetime, timezone from sqlalchemy.dialects.postgresql import insert as pg_insert -from ..models import User, Subscription +from ..models import ( + User, + Subscription, + Payment, + PromoCodeActivation, + MessageLog, + UserBilling, + UserPaymentMethod, + AdAttribution, +) async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]: @@ -102,6 +111,29 @@ async def get_banned_users(session: AsyncSession) -> List[User]: return result.scalars().all() +async def get_all_users_paginated( + session: AsyncSession, *, page: int = 0, page_size: int = 15 +) -> List[User]: + """Return a slice of users ordered by newest registration first.""" + safe_page = max(page, 0) + safe_page_size = max(page_size, 1) + + stmt = ( + select(User) + .order_by(User.registration_date.desc()) + .offset(safe_page * safe_page_size) + .limit(safe_page_size) + ) + result = await session.execute(stmt) + return result.scalars().all() + + +async def count_all_users(session: AsyncSession) -> int: + """Count total number of users.""" + result = await session.execute(select(func.count(User.user_id))) + return result.scalar_one() + + async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[int]: stmt = select(User.user_id).where(User.is_banned == False) result = await session.execute(stmt) @@ -229,21 +261,39 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis return result.scalars().all() -async def get_all_users_paginated(session: AsyncSession, page: int = 0, page_size: int = 15) -> List[User]: - """Get all users with pagination, ordered by registration date descending.""" - offset = page * page_size - stmt = ( - select(User) - .order_by(User.registration_date.desc()) - .offset(offset) - .limit(page_size) +async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool: + """Completely remove a user and all dependent records from the database. + + This helper ensures we do not leave dangling foreign keys or orphaned data. + """ + user = await get_user_by_id(session, user_id) + if not user: + return False + + # Ensure referral pointers do not block deletion + await session.execute( + update(User).where(User.referred_by_id == user_id).values(referred_by_id=None) ) - result = await session.execute(stmt) - return result.scalars().all() + # Clean up dependent tables that do not cascade automatically + await session.execute( + delete(MessageLog).where( + or_(MessageLog.user_id == user_id, MessageLog.target_user_id == user_id) + ) + ) + await session.execute(delete(Payment).where(Payment.user_id == user_id)) + await session.execute( + delete(Subscription).where(Subscription.user_id == user_id) + ) + await session.execute( + delete(PromoCodeActivation).where(PromoCodeActivation.user_id == user_id) + ) + await session.execute( + delete(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id) + ) + await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id)) + await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id)) -async def count_all_users(session: AsyncSession) -> int: - """Count total number of users in the database.""" - stmt = select(func.count(User.user_id)) - result = await session.execute(stmt) - return result.scalar() or 0 \ No newline at end of file + await session.delete(user) + await session.flush() + return True diff --git a/locales/en.json b/locales/en.json index 9fc48f6..b3ffec6 100644 --- a/locales/en.json +++ b/locales/en.json @@ -247,6 +247,16 @@ "admin_user_send_message_button": "✉️ Send Message", "admin_user_view_logs_button": "📜 User Actions", "admin_user_refresh_button": "🔄 Refresh", + "admin_user_delete_button": "❌ Delete User", + "admin_user_delete_not_allowed": "❌ You are not permitted to delete users.", + "admin_user_delete_confirmation_prompt": "⚠️ You are about to delete user {user_id} completely.\n\nSend the exact Telegram ID of this user to confirm.\nAny other reply will cancel the operation.", + "admin_user_delete_state_missing": "⚠️ No active delete operation. Start again.", + "admin_user_delete_cancelled": "Deletion cancelled.", + "admin_user_delete_mismatch": "⚠️ ID mismatch. Deletion aborted.", + "admin_user_delete_already_removed": "ℹ️ The user is already removed.", + "admin_user_delete_panel_error": "❌ Failed to remove the user from the panel. Operation aborted.", + "admin_user_delete_success": "✅ User {user_id} was removed from the bot and the panel.", + "admin_user_delete_error": "❌ Unable to delete the user. Please try again later.", "admin_user_search_new_button": "🔍 Find Another", "admin_user_view_all_logs_button": "📋 All Actions", "admin_user_back_to_card_button": "🔙 Back to Card", diff --git a/locales/ru.json b/locales/ru.json index b2c3b2d..a9e97d9 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -247,6 +247,16 @@ "admin_user_send_message_button": "✉️ Сообщение", "admin_user_view_logs_button": "📜 Логи", "admin_user_refresh_button": "🔄 Обновить", + "admin_user_delete_button": "❌ Удалить пользователя", + "admin_user_delete_not_allowed": "❌ У вас нет прав для удаления пользователей.", + "admin_user_delete_confirmation_prompt": "⚠️ Вы хотите полностью удалить пользователя {user_id}.\n\nОтправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\nЛюбой другой ответ отменит операцию.", + "admin_user_delete_state_missing": "⚠️ Нет активной операции удаления. Начните заново.", + "admin_user_delete_cancelled": "Удаление отменено.", + "admin_user_delete_mismatch": "⚠️ ID не совпадает. Удаление отменено.", + "admin_user_delete_already_removed": "ℹ️ Пользователь уже удалён.", + "admin_user_delete_panel_error": "❌ Не удалось удалить пользователя на панели. Операция прервана.", + "admin_user_delete_success": "✅ Пользователь {user_id} удалён из бота и панели.", + "admin_user_delete_error": "❌ Не удалось удалить пользователя. Попробуйте позже.", "admin_user_search_new_button": "🔍 Новый поиск", "admin_user_view_all_logs_button": "📋 Все логи", "admin_user_back_to_card_button": "🔙 К карточке",