diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index 9db7c7d..bcbd800 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -1,5 +1,16 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 +from .auth import _require_admin_user_id +from .common import ( + _build_admin_webapp_referral_link, + _error, + _ok, + _premium_traffic_list_payload, + _read_json, + _serialize_payment, + _serialize_subscription, + _serialize_user, +) import hashlib from html import escape as html_escape @@ -861,18 +872,62 @@ async def admin_user_delete_route(request: web.Request) -> web.Response: target_id = int(request.match_info["user_id"]) settings: Settings = request.app["settings"] + panel_service = request.app.get("panel_service") + if panel_service is None: + subscription_service = request.app.get("subscription_service") + panel_service = getattr(subscription_service, "panel_service", None) async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: + user = await user_dal.get_user_by_id(session, target_id) + if not user: + return _error(404, "not_found") + + panel_user_uuids = await user_dal.get_panel_user_uuids_for_user( + session, + target_id, + user=user, + ) + if panel_user_uuids and panel_service is None: + await session.rollback() + return _error(503, "panel_service_unavailable") + + for panel_uuid in panel_user_uuids: + try: + panel_deleted = await panel_service.delete_user_from_panel( + panel_uuid, + log_response=False, + ) + except Exception as exc: + logger.warning( + "Admin webapp failed to delete panel user %s for user %s: %s", + panel_uuid, + target_id, + exc, + ) + await session.rollback() + return _error(502, "panel_delete_failed", str(exc)) + + if not panel_deleted: + await session.rollback() + return _error( + 502, + "panel_delete_failed", + f"Failed to delete panel user {panel_uuid}", + ) + ok = await user_dal.delete_user_and_relations(session, target_id) if not ok: await session.rollback() return _error(404, "not_found") - await message_log_dal.create_message_log( + await message_log_dal.create_message_log_no_commit( session, { - "user_id": actor_id, + "user_id": actor_id if actor_id != target_id else None, "event_type": "admin_delete_user_webapp", - "content": f"Deleted user_id={target_id}", + "content": ( + f"Deleted user_id={target_id}; " + f"panel_uuids={','.join(panel_user_uuids) or 'none'}" + ), "is_admin_event": True, }, ) diff --git a/backend/bot/handlers/admin/user_management.py b/backend/bot/handlers/admin/user_management.py index 3a14cf8..786fbd2 100644 --- a/backend/bot/handlers/admin/user_management.py +++ b/backend/bot/handlers/admin/user_management.py @@ -1264,8 +1264,13 @@ async def process_delete_user_confirmation_handler( return try: - if user_model.panel_user_uuid: - panel_deleted = await panel_service.delete_user_from_panel(user_model.panel_user_uuid) + panel_user_uuids = await user_dal.get_panel_user_uuids_for_user( + session, + target_user_id, + user=user_model, + ) + for panel_uuid in panel_user_uuids: + panel_deleted = await panel_service.delete_user_from_panel(panel_uuid) if not panel_deleted: await message.answer( _( diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py index a65d6d7..3a1b54a 100644 --- a/backend/db/dal/user_dal.py +++ b/backend/db/dal/user_dal.py @@ -12,10 +12,17 @@ from sqlalchemy.orm import aliased from ..models import ( AdAttribution, + EmailVerificationCode, + HwidDevicePurchase, MessageLog, Payment, PromoCodeActivation, Subscription, + SupportTicket, + SupportTicketMessage, + TariffChange, + TrafficTopup, + TrafficWarning, User, UserBilling, UserPaymentMethod, @@ -583,6 +590,40 @@ async def get_all_users_with_panel_uuid(session: AsyncSession) -> List[User]: return result.scalars().all() +async def get_panel_user_uuids_for_user( + session: AsyncSession, + user_id: int, + *, + user: Optional[User] = None, +) -> List[str]: + """Return every Remnawave user UUID linked to a bot user. + + The canonical UUID normally lives on ``users.panel_user_uuid``, but older + or partially-synced records can still have UUIDs only on subscription rows. + """ + + if user is None: + user = await get_user_by_id(session, user_id) + + panel_uuids: List[str] = [] + seen: set[str] = set() + + def add_uuid(value: Any) -> None: + panel_uuid = str(value or "").strip() + if panel_uuid and panel_uuid not in seen: + seen.add(panel_uuid) + panel_uuids.append(panel_uuid) + + add_uuid(getattr(user, "panel_user_uuid", None)) + + stmt = select(Subscription.panel_user_uuid).where(Subscription.user_id == user_id) + result = await session.execute(stmt) + for panel_uuid in result.scalars().all(): + add_uuid(panel_uuid) + + return panel_uuids + + async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]: """Get comprehensive user statistics including active users, trial users, etc.""" from datetime import datetime, timezone @@ -706,19 +747,69 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool update(User).where(User.referred_by_id == user_id).values(referred_by_id=None) ) - # Clean up dependent tables that do not cascade automatically + subscription_ids = select(Subscription.subscription_id).where(Subscription.user_id == user_id) + payment_ids = select(Payment.payment_id).where(Payment.user_id == user_id) + support_ticket_ids = select(SupportTicket.ticket_id).where(SupportTicket.user_id == user_id) + + # Clean up dependent tables that do not cascade automatically. + await session.execute( + delete(TrafficTopup).where( + or_( + TrafficTopup.subscription_id.in_(subscription_ids), + TrafficTopup.payment_id.in_(payment_ids), + ) + ) + ) + await session.execute( + delete(HwidDevicePurchase).where( + or_( + HwidDevicePurchase.subscription_id.in_(subscription_ids), + HwidDevicePurchase.payment_id.in_(payment_ids), + ) + ) + ) + await session.execute( + delete(TariffChange).where( + or_( + TariffChange.subscription_id.in_(subscription_ids), + TariffChange.payment_id.in_(payment_ids), + ) + ) + ) + await session.execute( + delete(TrafficWarning).where(TrafficWarning.subscription_id.in_(subscription_ids)) + ) + await session.execute( + delete(SupportTicketMessage).where(SupportTicketMessage.ticket_id.in_(support_ticket_ids)) + ) + await session.execute( + update(SupportTicketMessage) + .where(SupportTicketMessage.author_user_id == user_id) + .values(author_user_id=None) + ) + await session.execute(delete(SupportTicket).where(SupportTicket.user_id == user_id)) + await session.execute( + delete(EmailVerificationCode).where(EmailVerificationCode.target_user_id == user_id) + ) 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(PromoCodeActivation).where( + or_( + PromoCodeActivation.user_id == user_id, + PromoCodeActivation.payment_id.in_(payment_ids), + ) + ) + ) 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)) await session.execute(delete(UserTelegramAvatar).where(UserTelegramAvatar.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.delete(user) await session.flush() diff --git a/frontend/src/admin/sections/UserDetailModal.svelte b/frontend/src/admin/sections/UserDetailModal.svelte index 5eb1d39..3a6fe23 100644 --- a/frontend/src/admin/sections/UserDetailModal.svelte +++ b/frontend/src/admin/sections/UserDetailModal.svelte @@ -1028,7 +1028,7 @@ description={at( "user_delete_confirm_subtitle", {}, - "Действие необратимо. Удалятся все платежи, подписки и логи." + "Действие необратимо. Удалятся записи в БД бота и пользователь в Remnawave Panel." )} closeLabel={at("close", {}, "Закрыть")} onclose={() => usersStore.updateState({ userDeleteOpen: false })} diff --git a/locales/en.json b/locales/en.json index 8e4c176..aea4c2e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -317,7 +317,7 @@ "admin_traffic_grant_regular_done": "✅ +{gb} GB of regular traffic granted to user {user_id}", "admin_traffic_grant_premium_done": "✅ +{gb} GB of premium traffic granted to user {user_id}", "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_confirmation_prompt": "⚠️ You are about to delete user {user_id} from the bot and the panel.\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.", @@ -1235,7 +1235,7 @@ "admin_user_ban_confirm_title": "Ban user?", "admin_user_ban_confirm_subtitle": "{name} will no longer be able to interact with the bot. This can be undone later.", "admin_user_delete_confirm_title": "Delete user?", - "admin_user_delete_confirm_subtitle": "This action is irreversible. All payments, subscriptions, and logs will be deleted.", + "admin_user_delete_confirm_subtitle": "This action is irreversible. Bot database records and the Remnawave Panel user will be deleted.", "admin_user_btn_confirm_delete": "Confirm Delete", "admin_btn_add_premium_squad": "Add premium squad", "admin_tariff_col_volume_gb": "Volume, GB", diff --git a/locales/ru.json b/locales/ru.json index 0f889cc..ab36faa 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -317,7 +317,7 @@ "admin_traffic_grant_regular_done": "✅ +{gb} ГБ обычного трафика выдано пользователю {user_id}", "admin_traffic_grant_premium_done": "✅ +{gb} ГБ премиум-трафика выдано пользователю {user_id}", "admin_user_delete_not_allowed": "❌ У вас нет прав для удаления пользователей.", - "admin_user_delete_confirmation_prompt": "⚠️ Вы хотите полностью удалить пользователя {user_id}.\n\nОтправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\nЛюбой другой ответ отменит операцию.", + "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 не совпадает. Удаление отменено.", @@ -1235,7 +1235,7 @@ "admin_user_ban_confirm_title": "Заблокировать пользователя?", "admin_user_ban_confirm_subtitle": "{name} больше не сможет взаимодействовать с ботом. Действие можно отменить позже.", "admin_user_delete_confirm_title": "Удалить пользователя?", - "admin_user_delete_confirm_subtitle": "Действие необратимо. Удалятся все платежи, подписки и логи.", + "admin_user_delete_confirm_subtitle": "Действие необратимо. Удалятся записи в БД бота и пользователь в Remnawave Panel.", "admin_user_btn_confirm_delete": "Подтвердить удаление", "admin_btn_add_premium_squad": "Добавить premium-сквад", "admin_tariff_col_volume_gb": "Объём, GB", diff --git a/tests/test_admin_user_delete.py b/tests/test_admin_user_delete.py new file mode 100644 index 0000000..302aa15 --- /dev/null +++ b/tests/test_admin_user_delete.py @@ -0,0 +1,155 @@ +import json +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from bot.app.web.admin_api_impl import users as admin_users + + +class FakeSession: + def __init__(self): + self.committed = False + self.rolled_back = False + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def commit(self): + self.committed = True + + async def rollback(self): + self.rolled_back = True + + +class AdminUserDeleteRouteTests(unittest.IsolatedAsyncioTestCase): + def _request(self, session: FakeSession, app_overrides=None): + app = { + "settings": SimpleNamespace(), + "async_session_factory": lambda: session, + } + app.update(app_overrides or {}) + return SimpleNamespace( + app=app, + match_info={"user_id": "42"}, + ) + + async def test_deletes_panel_users_before_bot_db_user(self): + session = FakeSession() + calls = [] + + async def delete_panel_user(panel_uuid, log_response=False): + calls.append(("panel", panel_uuid, log_response)) + return True + + async def delete_db_user(_session, user_id): + calls.append(("db", user_id)) + return True + + panel_service = SimpleNamespace( + delete_user_from_panel=AsyncMock(side_effect=delete_panel_user), + ) + request = self._request(session, {"panel_service": panel_service}) + user = SimpleNamespace(user_id=42, panel_user_uuid="panel-main") + + with ( + patch.object(admin_users, "_require_admin_user_id", return_value=100), + patch.object(admin_users.user_dal, "get_user_by_id", AsyncMock(return_value=user)), + patch.object( + admin_users.user_dal, + "get_panel_user_uuids_for_user", + AsyncMock(return_value=["panel-main", "panel-sub"]), + ) as collect_uuids, + patch.object( + admin_users.user_dal, + "delete_user_and_relations", + AsyncMock(side_effect=delete_db_user), + ), + patch.object( + admin_users.message_log_dal, + "create_message_log_no_commit", + AsyncMock(), + ) as log_mock, + patch.object(admin_users, "_invalidate_after_admin_user_mutation", AsyncMock()), + ): + response = await admin_users.admin_user_delete_route(request) + + self.assertEqual(response.status, 200) + self.assertEqual(json.loads(response.text)["ok"], True) + self.assertEqual( + calls, + [ + ("panel", "panel-main", False), + ("panel", "panel-sub", False), + ("db", 42), + ], + ) + collect_uuids.assert_awaited_once_with(session, 42, user=user) + log_payload = log_mock.await_args.args[1] + self.assertEqual(log_payload["event_type"], "admin_delete_user_webapp") + self.assertIn("panel-main,panel-sub", log_payload["content"]) + self.assertTrue(session.committed) + self.assertFalse(session.rolled_back) + + async def test_aborts_when_panel_service_is_unavailable_for_panel_user(self): + session = FakeSession() + request = self._request(session) + user = SimpleNamespace(user_id=42, panel_user_uuid="panel-main") + + with ( + patch.object(admin_users, "_require_admin_user_id", return_value=100), + patch.object(admin_users.user_dal, "get_user_by_id", AsyncMock(return_value=user)), + patch.object( + admin_users.user_dal, + "get_panel_user_uuids_for_user", + AsyncMock(return_value=["panel-main"]), + ), + patch.object( + admin_users.user_dal, + "delete_user_and_relations", + AsyncMock(), + ) as delete_db, + ): + response = await admin_users.admin_user_delete_route(request) + + self.assertEqual(response.status, 503) + self.assertEqual(json.loads(response.text)["error"], "panel_service_unavailable") + delete_db.assert_not_awaited() + self.assertTrue(session.rolled_back) + self.assertFalse(session.committed) + + async def test_aborts_when_panel_delete_fails(self): + session = FakeSession() + panel_service = SimpleNamespace( + delete_user_from_panel=AsyncMock(return_value=False), + ) + request = self._request(session, {"panel_service": panel_service}) + user = SimpleNamespace(user_id=42, panel_user_uuid="panel-main") + + with ( + patch.object(admin_users, "_require_admin_user_id", return_value=100), + patch.object(admin_users.user_dal, "get_user_by_id", AsyncMock(return_value=user)), + patch.object( + admin_users.user_dal, + "get_panel_user_uuids_for_user", + AsyncMock(return_value=["panel-main"]), + ), + patch.object( + admin_users.user_dal, + "delete_user_and_relations", + AsyncMock(), + ) as delete_db, + ): + response = await admin_users.admin_user_delete_route(request) + + self.assertEqual(response.status, 502) + self.assertEqual(json.loads(response.text)["error"], "panel_delete_failed") + panel_service.delete_user_from_panel.assert_awaited_once_with( + "panel-main", + log_response=False, + ) + delete_db.assert_not_awaited() + self.assertTrue(session.rolled_back) + self.assertFalse(session.committed) diff --git a/tests/test_user_dal.py b/tests/test_user_dal.py index 3ff2223..15fa647 100644 --- a/tests/test_user_dal.py +++ b/tests/test_user_dal.py @@ -29,6 +29,73 @@ class FakeResult: class UserDalMergeTests(unittest.IsolatedAsyncioTestCase): + async def test_get_panel_user_uuids_for_user_includes_subscription_fallbacks_once(self): + user = SimpleNamespace(user_id=42, panel_user_uuid="panel-main") + session = SimpleNamespace( + execute=AsyncMock( + return_value=FakeResult(["panel-main", "panel-sub", "panel-sub", ""]) + ), + ) + + result = await user_dal.get_panel_user_uuids_for_user(session, 42, user=user) + + self.assertEqual(result, ["panel-main", "panel-sub"]) + stmt = session.execute.await_args.args[0] + sql = str( + stmt.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) + self.assertIn("subscriptions", sql) + self.assertIn("42", sql) + + async def test_delete_user_and_relations_cleans_dependent_tables_before_parents(self): + user = SimpleNamespace(user_id=42) + session = SimpleNamespace( + execute=AsyncMock(side_effect=lambda stmt: FakeResult()), + delete=AsyncMock(), + flush=AsyncMock(), + ) + + with patch("db.dal.user_dal.get_user_by_id", AsyncMock(return_value=user)): + deleted = await user_dal.delete_user_and_relations(session, 42) + + self.assertTrue(deleted) + + delete_tables = [] + update_tables = [] + for call in session.execute.await_args_list: + stmt = call.args[0] + if isinstance(stmt, Delete): + delete_tables.append(stmt.table.name) + elif isinstance(stmt, Update): + update_tables.append(stmt.table.name) + + self.assertLess(delete_tables.index("traffic_topups"), delete_tables.index("payments")) + self.assertLess(delete_tables.index("traffic_topups"), delete_tables.index("subscriptions")) + self.assertLess( + delete_tables.index("hwid_device_purchases"), + delete_tables.index("payments"), + ) + self.assertLess(delete_tables.index("tariff_changes"), delete_tables.index("payments")) + self.assertLess( + delete_tables.index("traffic_warnings"), + delete_tables.index("subscriptions"), + ) + self.assertLess( + delete_tables.index("promo_code_activations"), + delete_tables.index("payments"), + ) + self.assertLess( + delete_tables.index("support_ticket_messages"), + delete_tables.index("support_tickets"), + ) + self.assertIn("support_ticket_messages", update_tables) + self.assertIn("email_verification_codes", delete_tables) + session.delete.assert_awaited_once_with(user) + session.flush.assert_awaited_once() + async def test_get_user_ids_without_active_subscription_uses_left_join_null_check(self): session = SimpleNamespace( execute=AsyncMock(return_value=FakeResult([2, 3])),