fix(admin): delete Remnawave user with bot account

This commit is contained in:
3252a8
2026-05-24 23:35:19 +03:00
parent 85ecd644b8
commit 3410eeaec1
8 changed files with 387 additions and 14 deletions
+58 -3
View File
@@ -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,
},
)
@@ -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(
_(
+95 -4
View File
@@ -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()
@@ -1028,7 +1028,7 @@
description={at(
"user_delete_confirm_subtitle",
{},
"Действие необратимо. Удалятся все платежи, подписки и логи."
"Действие необратимо. Удалятся записи в БД бота и пользователь в Remnawave Panel."
)}
closeLabel={at("close", {}, "Закрыть")}
onclose={() => usersStore.updateState({ userDeleteOpen: false })}
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+155
View File
@@ -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)
+67
View File
@@ -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])),