From 1578a9da36e3dd204c8dc9a31de0f61c1ddf2107 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 31 May 2026 23:05:29 +0300 Subject: [PATCH 1/4] fix: align traffic top-up flows and unlimited overrides --- backend/bot/app/web/admin_api_impl/users.py | 2 +- .../subscription_service_impl/tariffs.py | 5 +- frontend/src/lib/components/ui/dialog.svelte | 2 +- .../src/lib/components/ui/scroll-area.svelte | 8 +++ frontend/src/lib/webapp/demoDataset.js | 41 +++++++------- frontend/src/styles/admin.css | 9 ---- locales/en.json | 10 ++-- locales/ru.json | 8 +-- tests/test_admin_traffic_grants.py | 53 +++++++++++++++++++ tests/test_mini_app_url.py | 17 ++++++ tests/test_subscription_service_behavior.py | 4 +- tests/test_support_notifications.py | 2 +- tests/test_tariff_worker.py | 25 +++++++++ 13 files changed, 139 insertions(+), 47 deletions(-) diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index bcbd800..b4ea1df 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -1031,7 +1031,7 @@ async def admin_user_premium_override_route(request: web.Request) -> web.Respons async def admin_user_regular_traffic_override_route(request: web.Request) -> web.Response: - """Main (regular) traffic: unlimited-style ceiling + admin bonus GB.""" + """Main (regular) traffic: native unlimited panel limit + admin bonus GB.""" actor_id = _require_admin_user_id(request) target_id = int(request.match_info["user_id"]) settings: Settings = request.app["settings"] diff --git a/backend/bot/services/subscription_service_impl/tariffs.py b/backend/bot/services/subscription_service_impl/tariffs.py index 58f4c06..f68e436 100644 --- a/backend/bot/services/subscription_service_impl/tariffs.py +++ b/backend/bot/services/subscription_service_impl/tariffs.py @@ -112,15 +112,14 @@ class TariffMixin: regular_unlimited_override: bool, traffic_used_bytes: int, ) -> int: - """Numeric cap sent to the panel; ``regular_unlimited_override`` uses a large practical ceiling.""" # noqa: E501 + """Numeric cap sent to the panel; Remnawave treats ``0`` as unlimited.""" floor = ( int(tier_baseline_bytes or 0) + max(0, int(topup_balance_bytes or 0)) + max(0, int(regular_bonus_bytes or 0)) ) if regular_unlimited_override: - used = max(0, int(traffic_used_bytes or 0)) - return max(floor, used + 512 * (1024**3), 1024**5) + return 0 return floor async def premium_access_for_tariff(self, tariff: Optional[Tariff]) -> Dict[str, Any]: diff --git a/frontend/src/lib/components/ui/dialog.svelte b/frontend/src/lib/components/ui/dialog.svelte index b716149..2f0ad8f 100644 --- a/frontend/src/lib/components/ui/dialog.svelte +++ b/frontend/src/lib/components/ui/dialog.svelte @@ -66,7 +66,7 @@ - + diff --git a/frontend/src/lib/components/ui/scroll-area.svelte b/frontend/src/lib/components/ui/scroll-area.svelte index 33f8364..d941f13 100644 --- a/frontend/src/lib/components/ui/scroll-area.svelte +++ b/frontend/src/lib/components/ui/scroll-area.svelte @@ -36,6 +36,10 @@ overflow: hidden; } + :global(.scroll-area--dialog) { + overflow: visible; + } + :global(.scroll-area__viewport) { width: 100%; height: 100%; @@ -56,6 +60,10 @@ width: 10px; } + :global(.scroll-area--dialog .scroll-area__scrollbar[data-orientation="vertical"]) { + transform: translateX(12px); + } + :global(.scroll-area__scrollbar[data-orientation="horizontal"]) { flex-direction: column; height: 10px; diff --git a/frontend/src/lib/webapp/demoDataset.js b/frontend/src/lib/webapp/demoDataset.js index d70b3d6..d24ddb4 100644 --- a/frontend/src/lib/webapp/demoDataset.js +++ b/frontend/src/lib/webapp/demoDataset.js @@ -104957,10 +104957,9 @@ export const DEMO_DATASET = { updated_by: null, }, en: { - base: "Unlimited-style ceiling and a persistent bonus on the main traffic limit.", + base: "Unlimited access and a persistent bonus on the main traffic limit.", fallback: "Режим безлимита и постоянный бонус к лимиту основного трафика.", - effective: - "Unlimited-style ceiling and a persistent bonus on the main traffic limit.", + effective: "Unlimited access and a persistent bonus on the main traffic limit.", override: "", overridden: false, updated_at: null, @@ -105241,22 +105240,22 @@ export const DEMO_DATASET = { audience: "internal", values: { ru: { - base: "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.", + base: "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит можно включить для основного трафика или премиум-сквадов.", fallback: - "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.", + "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит можно включить для основного трафика или премиум-сквадов.", effective: - "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.", + "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит можно включить для основного трафика или премиум-сквадов.", override: "", overridden: false, updated_at: null, updated_by: null, }, en: { - base: "Extra main or premium traffic limits on top of the tariff; unlimited applies to premium squads only.", + base: "Extra main or premium traffic limits on top of the tariff; unlimited can be applied to main traffic or premium squads.", fallback: - "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.", + "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит можно включить для основного трафика или премиум-сквадов.", effective: - "Extra main or premium traffic limits on top of the tariff; unlimited applies to premium squads only.", + "Extra main or premium traffic limits on top of the tariff; unlimited can be applied to main traffic or premium squads.", override: "", overridden: false, updated_at: null, @@ -134112,18 +134111,18 @@ export const DEMO_DATASET = { audience: "user", values: { ru: { - base: "Докупить премиум-трафик (мини-приложение)", - fallback: "Докупить премиум-трафик (мини-приложение)", - effective: "Докупить премиум-трафик (мини-приложение)", + base: "Докупить премиум-трафик", + fallback: "Докупить премиум-трафик", + effective: "Докупить премиум-трафик", override: "", overridden: false, updated_at: null, updated_by: null, }, en: { - base: "Top up premium traffic (mini app)", - fallback: "Докупить премиум-трафик (мини-приложение)", - effective: "Top up premium traffic (mini app)", + base: "Top up premium traffic", + fallback: "Докупить премиум-трафик", + effective: "Top up premium traffic", override: "", overridden: false, updated_at: null, @@ -134136,18 +134135,18 @@ export const DEMO_DATASET = { audience: "user", values: { ru: { - base: "Докупить трафик (мини-приложение)", - fallback: "Докупить трафик (мини-приложение)", - effective: "Докупить трафик (мини-приложение)", + base: "Докупить трафик", + fallback: "Докупить трафик", + effective: "Докупить трафик", override: "", overridden: false, updated_at: null, updated_by: null, }, en: { - base: "Top up traffic (mini app)", - fallback: "Докупить трафик (мини-приложение)", - effective: "Top up traffic (mini app)", + base: "Top up traffic", + fallback: "Докупить трафик", + effective: "Top up traffic", override: "", overridden: false, updated_at: null, diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index 3dd00c4..1a9ac83 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -2760,15 +2760,6 @@ max-height: min(100%, 760px); } -.admin-user-dialog .dialog-body-scroll { - margin-right: -10px; - padding-right: 10px; -} - -.admin-user-dialog .dialog-body-scroll > .scroll-area__viewport { - padding-right: 10px; -} - .admin-user-dialog-body { display: grid; grid-template-columns: minmax(0, 1fr); diff --git a/locales/en.json b/locales/en.json index 2524df2..64be717 100644 --- a/locales/en.json +++ b/locales/en.json @@ -374,8 +374,8 @@ "traffic_warning_premium_depleted": "⛔️ The premium server quota for plan {tariff_name} is used up.\n\nCurrent period:\nused — {used}\navailable — {remaining}\nlimit — {limit_total}\n\nThis limit applies to:\n{servers}\n\nAccess may be limited until the period resets or you buy more premium traffic.\n\nUse the button below to top up.", "traffic_warning_premium_generic_servers": "• servers with premium access under your plan", "traffic_warning_premium_servers_more": "… and {count} more servers", - "traffic_warn_btn_topup_webapp_regular": "Top up traffic (mini app)", - "traffic_warn_btn_topup_webapp_premium": "Top up premium traffic (mini app)", + "traffic_warn_btn_topup_webapp_regular": "Top up traffic", + "traffic_warn_btn_topup_webapp_premium": "Top up premium traffic", "traffic_warn_btn_topup_regular": "Top up traffic", "traffic_warn_btn_topup_premium": "Top up premium traffic", "log_promo_activation": "🎁 Promo Code Activated\n\n👤 User: {user_display}\n🏷 Code: {promo_code}\n🎯 Bonus: +{bonus_days}d\n🕐 Time: {timestamp}", @@ -685,7 +685,7 @@ "email_support_row_remaining": "Remaining", "email_support_row_panel_status": "Panel status", "email_support_cta_open_ticket": "Open ticket", - "email_support_cta_open_mini_app": "Open in Mini App", + "email_support_cta_open_mini_app": "Open ticket", "wa_loading": "Loading...", "wa_back": "Back", "wa_next": "Next", @@ -1268,11 +1268,11 @@ "user_premium_override_card_title": "Premium traffic", "user_premium_override_card_hint": "Unlimited access and extra volume for premium squads on top of the tariff.", "user_regular_override_card_title": "Main traffic", - "user_regular_override_card_hint": "Unlimited-style ceiling and a persistent bonus on the main traffic limit.", + "user_regular_override_card_hint": "Unlimited access and a persistent bonus on the main traffic limit.", "user_regular_override_status_unlimited": "Current: unlimited", "regular_override_saved": "Main traffic override saved", "user_traffic_override_title": "Traffic overrides", - "user_traffic_override_hint": "Extra main or premium traffic limits on top of the tariff; unlimited applies to premium squads only.", + "user_traffic_override_hint": "Extra main or premium traffic limits on top of the tariff; unlimited can be applied to main traffic or premium squads.", "user_regular_override_bonus": "Extra main traffic, GB", "user_regular_override_bonus_hint": "Persistent bonus bytes added to the main traffic limit (not a balance top-up).", "user_regular_override_status_bonus": "Main traffic now: +{gb} GB", diff --git a/locales/ru.json b/locales/ru.json index 674e4eb..650f6e9 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -374,8 +374,8 @@ "traffic_warning_premium_depleted": "⛔️ Лимит премиум-серверов тарифа {tariff_name} израсходован.\n\nТекущий период:\nизрасходовано — {used}\nдоступно — {remaining}\nлимит — {limit_total}\n\nЛимит действует на:\n{servers}\n\nДоступ может быть ограничен до сброса периода или докупки премиум-трафика.\n\nДокупить можно через кнопку ниже.", "traffic_warning_premium_generic_servers": "• серверы с премиум-доступом по вашему тарифу", "traffic_warning_premium_servers_more": "… и ещё серверов: {count}", - "traffic_warn_btn_topup_webapp_regular": "Докупить трафик (мини-приложение)", - "traffic_warn_btn_topup_webapp_premium": "Докупить премиум-трафик (мини-приложение)", + "traffic_warn_btn_topup_webapp_regular": "Докупить трафик", + "traffic_warn_btn_topup_webapp_premium": "Докупить премиум-трафик", "traffic_warn_btn_topup_regular": "Докупить трафик", "traffic_warn_btn_topup_premium": "Докупить премиум-трафик", "log_promo_activation": "🎁 Активирован промокод\n\n👤 Пользователь: {user_display}\n🏷 Код: {promo_code}\n🎯 Бонус: +{bonus_days} дн.\n🕐 Время: {timestamp}", @@ -685,7 +685,7 @@ "email_support_row_remaining": "Осталось", "email_support_row_panel_status": "Статус в панели", "email_support_cta_open_ticket": "Открыть тикет", - "email_support_cta_open_mini_app": "Открыть в Mini App", + "email_support_cta_open_mini_app": "Открыть обращение", "wa_loading": "Загрузка...", "wa_back": "Назад", "wa_next": "Далее", @@ -1272,7 +1272,7 @@ "user_regular_override_status_unlimited": "Сейчас: безлимит", "regular_override_saved": "Оверрайд основного трафика сохранён", "user_traffic_override_title": "Оверрайд трафика", - "user_traffic_override_hint": "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит только для премиум-сквадов.", + "user_traffic_override_hint": "Дополнительный лимит основного и премиум-трафика поверх тарифа; безлимит можно включить для основного трафика или премиум-сквадов.", "user_regular_override_bonus": "Доп. основной трафик, GB", "user_regular_override_bonus_hint": "Постоянный бонус к лимиту основного трафика (не то же самое, что докупка ГБ).", "user_regular_override_status_bonus": "Основной сейчас: +{gb} GB", diff --git a/tests/test_admin_traffic_grants.py b/tests/test_admin_traffic_grants.py index 1bf3934..7b01022 100644 --- a/tests/test_admin_traffic_grants.py +++ b/tests/test_admin_traffic_grants.py @@ -133,6 +133,59 @@ class AdminGrantTopupTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await service.admin_grant_topup(AsyncMock(), 1, 0)) self.assertIsNone(await service.admin_grant_topup(AsyncMock(), 1, -10)) + async def test_regular_unlimited_override_syncs_zero_panel_limit(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(_tariffs_config_payload(), tmpdir) + panel_service = AsyncMock(spec=PanelApiService) + panel_service.update_user_details_on_panel = AsyncMock(return_value={"response": {}}) + service = SubscriptionService(settings, panel_service) + + db_user = SimpleNamespace( + user_id=42, + first_name="Tester", + last_name=None, + username="tester", + language_code="ru", + panel_user_uuid="panel-uuid", + email=None, + telegram_id=42, + ) + sub = SimpleNamespace( + subscription_id=7, + user_id=42, + panel_user_uuid="panel-uuid", + end_date=datetime.now(timezone.utc) + timedelta(days=10), + tariff_key="standard", + tier_baseline_bytes=100 * (1024**3), + topup_balance_bytes=0, + traffic_limit_bytes=105 * (1024**3), + traffic_used_bytes=2 * (1024**5), + regular_bonus_bytes=0, + regular_unlimited_override=True, + is_throttled=True, + hwid_device_limit=3, + extra_hwid_devices=0, + premium_is_limited=False, + ) + + with ( + patch( + "bot.services.subscription_service.user_dal.get_user_by_id", + new=AsyncMock(return_value=db_user), + ), + patch( + "bot.services.subscription_service.subscription_dal.get_active_subscription_by_user_id", + new=AsyncMock(return_value=sub), + ), + ): + await service.sync_main_traffic_limit_to_panel(AsyncMock(), 42) + + self.assertEqual(sub.traffic_limit_bytes, 0) + self.assertFalse(sub.is_throttled) + panel_service.update_user_details_on_panel.assert_awaited_once() + panel_payload = panel_service.update_user_details_on_panel.await_args.args[1] + self.assertEqual(panel_payload["trafficLimitBytes"], 0) + async def test_premium_grant_clears_limited_state_when_balance_covers_overuse(self): with tempfile.TemporaryDirectory() as tmpdir: settings = _make_settings(_tariffs_config_payload(premium=True), tmpdir) diff --git a/tests/test_mini_app_url.py b/tests/test_mini_app_url.py index b137fdf..312b8d4 100644 --- a/tests/test_mini_app_url.py +++ b/tests/test_mini_app_url.py @@ -46,6 +46,23 @@ class MiniAppUrlTests(unittest.TestCase): subscription_mini_app_topup_url(s, "regular"), "https://app.example.com/webapp?topup=regular", ) + self.assertEqual( + subscription_mini_app_topup_url(s, "premium"), + "https://app.example.com/webapp?topup=premium", + ) + + def test_subscription_mini_app_topup_url_preserves_existing_query(self): + s = Settings( + _env_file=None, + BOT_TOKEN="x", + POSTGRES_USER="u", + POSTGRES_PASSWORD="p", + SUBSCRIPTION_MINI_APP_URL="https://app.example.com/webapp?lang=ru", + ) + self.assertEqual( + subscription_mini_app_topup_url(s, "premium"), + "https://app.example.com/webapp?lang=ru&topup=premium", + ) def test_subscription_mini_app_path_url(self): s = Settings( diff --git a/tests/test_subscription_service_behavior.py b/tests/test_subscription_service_behavior.py index 7ac0e16..9453289 100644 --- a/tests/test_subscription_service_behavior.py +++ b/tests/test_subscription_service_behavior.py @@ -96,7 +96,7 @@ class SubscriptionServiceCalculationTests(unittest.TestCase): ["fallback-a", "fallback-b"], ) - def test_main_traffic_limit_includes_topup_bonus_and_unlimited_floor(self): + def test_main_traffic_limit_includes_topup_bonus_and_unlimited_zero(self): with tempfile.TemporaryDirectory() as tmpdir: settings = _make_settings(_tariffs_config_payload(), tmpdir) service = _make_service(settings) @@ -117,7 +117,7 @@ class SubscriptionServiceCalculationTests(unittest.TestCase): regular_unlimited_override=True, traffic_used_bytes=2 * (1024**5), ) - self.assertEqual(unlimited_limit, 2 * (1024**5) + 512 * GIB) + self.assertEqual(unlimited_limit, 0) def test_premium_effective_limit_ignores_negative_balances(self): self.assertEqual( diff --git a/tests/test_support_notifications.py b/tests/test_support_notifications.py index 2068380..65b9e4d 100644 --- a/tests/test_support_notifications.py +++ b/tests/test_support_notifications.py @@ -41,7 +41,7 @@ def test_support_ticket_closed_email_uses_user_language(): assert ' dict: class TariffWorkerTests(unittest.IsolatedAsyncioTestCase): + def test_topup_webapp_button_labels_do_not_mention_mini_app(self): + class I18n: + def gettext(self, _lang, key, **_kwargs): + return { + "traffic_warn_btn_topup_webapp_regular": "Top up traffic", + "traffic_warn_btn_topup_webapp_premium": "Top up premium traffic", + }.get(key, key) + + worker = TariffTrafficWorker( + settings=SimpleNamespace(SUBSCRIPTION_MINI_APP_URL="https://app.example.com"), + session_factory=SimpleNamespace(), + panel_service=SimpleNamespace(), + subscription_service=SimpleNamespace(), + bot=SimpleNamespace(), + i18n=I18n(), + ) + + regular = worker._traffic_topup_markup("en", "regular").inline_keyboard[0][0] + premium = worker._traffic_topup_markup("en", "premium").inline_keyboard[0][0] + + self.assertEqual(regular.text, "Top up traffic") + self.assertEqual(regular.web_app.url, "https://app.example.com?topup=regular") + self.assertEqual(premium.text, "Top up premium traffic") + self.assertEqual(premium.web_app.url, "https://app.example.com?topup=premium") + async def test_db_tick_retries_deadlock_once(self): class FakeSession: def __init__(self): From ecf779763c580fa6debff46807c9d9a1a64eebd5 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 31 May 2026 23:05:40 +0300 Subject: [PATCH 2/4] feat: audit outbound user notifications --- backend/bot/services/email_auth_service.py | 25 +++++++- backend/bot/services/message_audit.py | 57 +++++++++++++++++++ .../subscription_lifecycle_notifications.py | 25 ++++++++ .../subscription_notification_worker.py | 18 ++++++ backend/bot/services/tariff_worker.py | 55 ++++++++++++++++++ .../bot/services/user_email_notifications.py | 15 +++++ tests/test_message_audit.py | 47 +++++++++++++++ 7 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 backend/bot/services/message_audit.py create mode 100644 tests/test_message_audit.py diff --git a/backend/bot/services/email_auth_service.py b/backend/bot/services/email_auth_service.py index 8d9b90b..9391932 100644 --- a/backend/bot/services/email_auth_service.py +++ b/backend/bot/services/email_auth_service.py @@ -17,8 +17,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from bot.middlewares.i18n import JsonI18n from bot.services.email_templates import EmailContent, render_login_code +from bot.services.message_audit import log_user_message_delivery from config.settings import Settings -from db.dal import security_dal +from db.dal import security_dal, user_dal from db.models import EmailVerificationCode logger = logging.getLogger(__name__) @@ -222,6 +223,28 @@ class EmailAuthService: magic_link=magic_link, purpose=purpose, ) + resolved_target_user_id = target_user_id + if resolved_target_user_id is None: + try: + existing_user = await user_dal.get_user_by_email(session, normalized_email) + resolved_target_user_id = ( + int(existing_user.user_id) if existing_user is not None else None + ) + except Exception: + logger.exception( + "Failed to resolve email auth target user for audit log: %s", + normalized_email, + ) + await log_user_message_delivery( + session, + target_user_id=resolved_target_user_id, + event_type="email_login_code_sent" + if purpose == "login" + else "email_verification_code_sent", + channel="email", + recipient=normalized_email, + content=f"purpose={purpose} magic_link={bool(magic_link)}", + ) return EmailCodeRequestResult(ok=True) async def verify_code( diff --git a/backend/bot/services/message_audit.py b/backend/bot/services/message_audit.py new file mode 100644 index 0000000..0c3594c --- /dev/null +++ b/backend/bot/services/message_audit.py @@ -0,0 +1,57 @@ +import logging +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from db.dal import message_log_dal + +logger = logging.getLogger(__name__) + + +def _clean_piece(value: Optional[object]) -> str: + return str(value or "").strip() + + +async def log_user_message_delivery( + session: AsyncSession, + *, + target_user_id: Optional[int], + event_type: str, + channel: str, + content: str, + recipient: Optional[str] = None, + timestamp: Optional[datetime] = None, +) -> None: + """Add a best-effort user log entry for important outbound messages.""" + clean_event = _clean_piece(event_type) + clean_channel = _clean_piece(channel) + if not clean_event or not clean_channel: + return + + parts = [f"channel={clean_channel}"] + clean_recipient = _clean_piece(recipient) + if clean_recipient: + parts.append(f"recipient={clean_recipient}") + clean_content = _clean_piece(content) + if clean_content: + parts.append(clean_content) + + try: + await message_log_dal.create_message_log_no_commit( + session, + { + "user_id": None, + "event_type": clean_event, + "content": " | ".join(parts)[:4000], + "is_admin_event": False, + "target_user_id": int(target_user_id) if target_user_id is not None else None, + "timestamp": timestamp or datetime.now(timezone.utc), + }, + ) + except Exception: + logger.exception( + "Failed to add outbound message audit log for user %s event %s", + target_user_id, + clean_event, + ) diff --git a/backend/bot/services/subscription_lifecycle_notifications.py b/backend/bot/services/subscription_lifecycle_notifications.py index 8fedd60..7cb2510 100644 --- a/backend/bot/services/subscription_lifecycle_notifications.py +++ b/backend/bot/services/subscription_lifecycle_notifications.py @@ -13,6 +13,7 @@ from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup from bot.middlewares.i18n import JsonI18n from bot.services.email_auth_service import EmailAuthService from bot.services.email_templates import render_subscription_lifecycle_notification +from bot.services.message_audit import log_user_message_delivery from bot.services.telegram_notifications import ( TELEGRAM_NOTIFICATIONS_BLOCKED, TELEGRAM_NOTIFICATIONS_ENABLED, @@ -188,6 +189,18 @@ class SubscriptionLifecycleNotificationService: self._channel_key(stage.key, "telegram"), sent_at=sent_at, ) + await log_user_message_delivery( + session, + target_user_id=getattr(sub, "user_id", None), + event_type="telegram_subscription_notification_sent", + channel="telegram", + recipient=str(chat_id), + content=( + f"stage={stage.key} message_key={stage.message_key} " + f"subscription_id={getattr(sub, 'subscription_id', '')}" + ), + timestamp=sent_at, + ) if user: status = normalize_telegram_notification_status( getattr(user, "telegram_notifications_status", None) @@ -253,6 +266,18 @@ class SubscriptionLifecycleNotificationService: self._channel_key(stage.key, "email"), sent_at=sent_at, ) + await log_user_message_delivery( + session, + target_user_id=getattr(sub, "user_id", None), + event_type="email_subscription_notification_sent", + channel="email", + recipient=recipient, + content=( + f"stage={stage.key} message_key={stage.message_key} " + f"subscription_id={getattr(sub, 'subscription_id', '')}" + ), + timestamp=sent_at, + ) return True async def _already_sent( diff --git a/backend/bot/services/subscription_notification_worker.py b/backend/bot/services/subscription_notification_worker.py index 5cde438..674a5d9 100644 --- a/backend/bot/services/subscription_notification_worker.py +++ b/backend/bot/services/subscription_notification_worker.py @@ -13,6 +13,7 @@ from sqlalchemy.orm import selectinload, sessionmaker from bot.infra.redis import redis_lock from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup from bot.middlewares.i18n import JsonI18n +from bot.services.message_audit import log_user_message_delivery from bot.services.panel_api_service import PanelApiService from bot.services.subscription_lifecycle_notifications import ( SubscriptionLifecycleNotificationService, @@ -331,6 +332,17 @@ class SubscriptionNotificationWorker: parse_mode="HTML", ) telegram_sent = True + await log_user_message_delivery( + session, + target_user_id=user_id, + event_type="telegram_traffic_warning_sent", + channel="telegram", + recipient=str(telegram_chat_id), + content=( + "kind=trial warning_key=trial_traffic_depleted " + f"used_bytes={used} limit_bytes={limit}" + ), + ) except Exception as exc: status = telegram_notification_status_from_error(exc) if status and user and user_id: @@ -355,6 +367,12 @@ class SubscriptionNotificationWorker: subject_key="email_trial_traffic_depleted_subject", message_text=message_text, dashboard_url=(getattr(self.settings, "SUBSCRIPTION_MINI_APP_URL", "") or None), + session=session, + audit_event_type="email_traffic_warning_sent", + audit_content=( + "kind=trial warning_key=trial_traffic_depleted " + f"used_bytes={used} limit_bytes={limit}" + ), ) return {"telegram": telegram_sent, "email": email_sent} diff --git a/backend/bot/services/tariff_worker.py b/backend/bot/services/tariff_worker.py index 49c2dad..3e19047 100644 --- a/backend/bot/services/tariff_worker.py +++ b/backend/bot/services/tariff_worker.py @@ -13,6 +13,7 @@ from sqlalchemy.orm import sessionmaker from bot.infra.redis import redis_lock from bot.middlewares.i18n import JsonI18n +from bot.services.message_audit import log_user_message_delivery from bot.services.panel_api_service import PanelApiService from bot.services.subscription_service import SubscriptionService from bot.services.user_email_notifications import send_user_notification_email @@ -111,6 +112,8 @@ class TariffTrafficWorker: subject_key: str, message_text: str, kind: str, + warning_key: str, + audit_content: str, ) -> None: try: user = await user_dal.get_user_by_id(session, user_id) @@ -131,6 +134,9 @@ class TariffTrafficWorker: if kind == "premium" else "email_traffic_warning_regular_cta" ), + session=session, + audit_event_type="email_traffic_warning_sent", + audit_content=f"{audit_content} subject_key={subject_key} warning_key={warning_key}", ) async def run(self) -> None: @@ -602,6 +608,15 @@ class TariffTrafficWorker: **usage, ) subject_key = "email_traffic_warning_regular_depleted_subject" + warning_key = ( + "traffic_warning_regular_almost" + if level < 100 + else "traffic_warning_regular_depleted" + ) + audit_content = ( + f"kind=regular warning_key={warning_key} level={level} " + f"used_bytes={used_val} limit_bytes={limit_val}" + ) if self.bot: try: markup = self._traffic_topup_markup(user_lang, "regular") @@ -611,6 +626,14 @@ class TariffTrafficWorker: reply_markup=markup, parse_mode="HTML", ) + await log_user_message_delivery( + session, + target_user_id=sub.user_id, + event_type="telegram_traffic_warning_sent", + channel="telegram", + recipient=str(sub.user_id), + content=audit_content, + ) except Exception: logging.exception("Failed to send traffic warning to user %s", sub.user_id) await self._send_traffic_warning_email( @@ -619,6 +642,8 @@ class TariffTrafficWorker: subject_key=subject_key, message_text=text, kind="regular", + warning_key=warning_key, + audit_content=audit_content, ) if ratio >= 1.0 and not sub.is_throttled: logging.info( @@ -1066,6 +1091,11 @@ class TariffTrafficWorker: servers=servers, **usage, ) + warning_key = "traffic_warning_premium_depleted" + audit_content = ( + f"kind=premium warning_key={warning_key} " + f"used_bytes={used_val} limit_bytes={limit_val}" + ) if self.bot: try: markup = self._traffic_topup_markup(user_lang, "premium") @@ -1075,6 +1105,14 @@ class TariffTrafficWorker: reply_markup=markup, parse_mode="HTML", ) + await log_user_message_delivery( + session, + target_user_id=sub.user_id, + event_type="telegram_traffic_warning_sent", + channel="telegram", + recipient=str(sub.user_id), + content=audit_content, + ) except Exception: logging.exception( "Failed to send premium traffic depleted warning to user %s", sub.user_id @@ -1085,6 +1123,8 @@ class TariffTrafficWorker: subject_key="email_traffic_warning_premium_depleted_subject", message_text=text, kind="premium", + warning_key=warning_key, + audit_content=audit_content, ) return @@ -1134,6 +1174,11 @@ class TariffTrafficWorker: servers=servers, **usage, ) + warning_key = "traffic_warning_premium_almost" + audit_content = ( + f"kind=premium warning_key={warning_key} level={int(level)} " + f"used_bytes={used_val} limit_bytes={limit_val}" + ) if self.bot: try: markup = self._traffic_topup_markup(user_lang, "premium") @@ -1143,6 +1188,14 @@ class TariffTrafficWorker: reply_markup=markup, parse_mode="HTML", ) + await log_user_message_delivery( + session, + target_user_id=sub.user_id, + event_type="telegram_traffic_warning_sent", + channel="telegram", + recipient=str(sub.user_id), + content=audit_content, + ) except Exception: logging.exception( "Failed to send premium traffic warning to user %s", sub.user_id @@ -1153,6 +1206,8 @@ class TariffTrafficWorker: subject_key="email_traffic_warning_premium_almost_subject", message_text=text, kind="premium", + warning_key=warning_key, + audit_content=audit_content, ) async def _premium_node_uuids_for_tariff(self, tariff) -> list[str]: diff --git a/backend/bot/services/user_email_notifications.py b/backend/bot/services/user_email_notifications.py index 6dc86be..e889a4e 100644 --- a/backend/bot/services/user_email_notifications.py +++ b/backend/bot/services/user_email_notifications.py @@ -1,9 +1,12 @@ import logging from typing import Any, Optional +from sqlalchemy.ext.asyncio import AsyncSession + from bot.middlewares.i18n import JsonI18n from bot.services.email_auth_service import EmailAuthService from bot.services.email_templates import render_user_notification +from bot.services.message_audit import log_user_message_delivery from config.settings import Settings @@ -34,6 +37,9 @@ async def send_user_notification_email( subject_kwargs: Optional[dict[str, Any]] = None, heading_key: Optional[str] = None, intro_key: Optional[str] = None, + session: Optional[AsyncSession] = None, + audit_event_type: Optional[str] = None, + audit_content: Optional[str] = None, ) -> bool: if not getattr(settings, "email_auth_configured", False): return False @@ -78,6 +84,15 @@ async def send_user_notification_email( email=recipient, content=content, ) + if session is not None and audit_event_type: + await log_user_message_delivery( + session, + target_user_id=getattr(user, "user_id", None), + event_type=audit_event_type, + channel="email", + recipient=recipient, + content=audit_content or f"subject_key={subject_key}", + ) return True except Exception: logging.exception("Failed to send user notification email to %s.", recipient) diff --git a/tests/test_message_audit.py b/tests/test_message_audit.py new file mode 100644 index 0000000..95dd9fb --- /dev/null +++ b/tests/test_message_audit.py @@ -0,0 +1,47 @@ +import unittest +from datetime import datetime, timezone + +from bot.services import message_audit + + +class MessageAuditTests(unittest.IsolatedAsyncioTestCase): + async def test_log_user_message_delivery_adds_targeted_log(self): + calls = [] + sent_at = datetime(2026, 5, 31, tzinfo=timezone.utc) + + async def fake_create(_session, payload): + calls.append(payload) + + original = message_audit.message_log_dal.create_message_log_no_commit + message_audit.message_log_dal.create_message_log_no_commit = fake_create + self.addCleanup( + self._restore_create_message_log, + original, + ) + + await message_audit.log_user_message_delivery( + object(), + target_user_id=42, + event_type="telegram_traffic_warning_sent", + channel="telegram", + recipient="100500", + content="kind=regular level=90", + timestamp=sent_at, + ) + + self.assertEqual( + calls, + [ + { + "user_id": None, + "event_type": "telegram_traffic_warning_sent", + "content": "channel=telegram | recipient=100500 | kind=regular level=90", + "is_admin_event": False, + "target_user_id": 42, + "timestamp": sent_at, + } + ], + ) + + def _restore_create_message_log(self, original): + message_audit.message_log_dal.create_message_log_no_commit = original From 7cf1d577f33127de69ee6f5415d54b2c22720006 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 31 May 2026 23:27:38 +0300 Subject: [PATCH 3/4] fix: restore pending email code screens --- frontend/src/App.svelte | 4 + .../src/lib/webapp/stores/accountStore.js | 14 ++- frontend/src/lib/webapp/stores/authStore.js | 94 ++++++++++++++++++- 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index cfc7d79..c71616c 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1219,6 +1219,7 @@ const emailHint = readEmailCodeLoginDeeplink(); if (!emailHint) return; emailLoginDeeplinkConsumed = true; + authStore.clearPendingEmailCode(); authStore.update((s) => ({ ...s, email: emailHint, @@ -1670,6 +1671,9 @@ screen = "login"; activeTab = "home"; setPasswordLoginMode(isPasswordLoginPath(), true); + authStore.restorePendingEmailCode((nextScreen) => { + screen = nextScreen; + }); void startEmailCodeLoginFromDeeplink(); } diff --git a/frontend/src/lib/webapp/stores/accountStore.js b/frontend/src/lib/webapp/stores/accountStore.js index 2771f45..f911dbd 100644 --- a/frontend/src/lib/webapp/stores/accountStore.js +++ b/frontend/src/lib/webapp/stores/accountStore.js @@ -188,10 +188,17 @@ export function createAccountStore({ async function requestLinkEmailCode() { const s = get(state); - if (s.linkEmailPending && s.linkEmailResendCooldown > 0) return; const normalized = String(s.linkEmailValue || "") .trim() .toLowerCase(); + if ( + s.linkEmailPending && + s.linkEmailResendCooldown > 0 && + (!normalized || normalized === s.linkEmailPending) + ) { + state.update((s) => ({ ...s, linkEmailOpen: true })); + return; + } if (!normalized || !normalized.includes("@")) { state.update((s) => ({ ...s, linkEmailFieldError: t("wa_auth_invalid_email") })); return; @@ -251,7 +258,10 @@ export function createAccountStore({ async function requestSetPasswordCode() { const s = get(state); - if (s.setPasswordPending && s.setPasswordResendCooldown > 0) return; + if (s.setPasswordPending && s.setPasswordResendCooldown > 0) { + state.update((s) => ({ ...s, setPasswordOpen: true })); + return; + } if (!validatePasswordDraft()) return; state.update((s) => ({ ...s, setPasswordBusy: true })); setPasswordStatus(t("wa_auth_sending_code")); diff --git a/frontend/src/lib/webapp/stores/authStore.js b/frontend/src/lib/webapp/stores/authStore.js index 9d34fdb..e7d5613 100644 --- a/frontend/src/lib/webapp/stores/authStore.js +++ b/frontend/src/lib/webapp/stores/authStore.js @@ -6,6 +6,10 @@ import { emailError, } from "../authHelpers.js"; +const EMAIL_CODE_PENDING_STORAGE_KEY = "rw_email_code_login_pending_v1"; +const EMAIL_CODE_PENDING_TTL_MS = 10 * 60 * 1000; +const EMAIL_CODE_RESEND_MS = 60 * 1000; + export function createAuthStore({ publicApi, setToken, @@ -35,6 +39,80 @@ export function createAuthStore({ let authResendTimer = null; let telegramLoginWatchdogTimer = null; + function readPendingEmailCodeSession() { + if (typeof window === "undefined" || !window.sessionStorage) return null; + try { + const raw = window.sessionStorage.getItem(EMAIL_CODE_PENDING_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + const email = String(parsed?.email || "") + .trim() + .toLowerCase(); + const expiresAt = Number(parsed?.expiresAt || 0); + const cooldownUntil = Number(parsed?.cooldownUntil || 0); + if (!email || !email.includes("@") || !expiresAt || expiresAt <= Date.now()) { + window.sessionStorage.removeItem(EMAIL_CODE_PENDING_STORAGE_KEY); + return null; + } + return { email, expiresAt, cooldownUntil }; + } catch (_error) { + window.sessionStorage.removeItem(EMAIL_CODE_PENDING_STORAGE_KEY); + return null; + } + } + + function writePendingEmailCodeSession(email) { + if (typeof window === "undefined" || !window.sessionStorage) return; + try { + window.sessionStorage.setItem( + EMAIL_CODE_PENDING_STORAGE_KEY, + JSON.stringify({ + email, + expiresAt: Date.now() + EMAIL_CODE_PENDING_TTL_MS, + cooldownUntil: Date.now() + EMAIL_CODE_RESEND_MS, + }) + ); + } catch (_error) { + void _error; + } + } + + function clearPendingEmailCode() { + if (typeof window === "undefined" || !window.sessionStorage) return; + try { + window.sessionStorage.removeItem(EMAIL_CODE_PENDING_STORAGE_KEY); + } catch (_error) { + void _error; + } + } + + function restorePendingEmailCode(changeScreen) { + const pending = readPendingEmailCodeSession(); + if (!pending) return false; + state.update((s) => ({ + ...s, + email: pending.email, + pendingEmail: pending.email, + emailCode: "", + authStatus: "", + authIsError: false, + authBusy: false, + passwordLoginMode: false, + passwordLoginFallback: false, + loginEmailFieldError: "", + loginEmailTooltipOpen: false, + })); + const cooldownSeconds = Math.ceil((pending.cooldownUntil - Date.now()) / 1000); + if (cooldownSeconds > 0) { + startCooldownTimer(cooldownSeconds); + } else { + clearCooldownTimer(); + state.update((s) => ({ ...s, authResendCooldown: 0 })); + } + if (typeof changeScreen === "function") changeScreen("code"); + return true; + } + function setAuthStatus(message, isError = false) { state.update((s) => ({ ...s, authStatus: message, authIsError: isError })); } @@ -101,6 +179,7 @@ export function createAuthStore({ const response = await publicApi("/auth/email/magic", payload); if (response.ok && response.csrf_token) { setToken("", response.csrf_token); + clearPendingEmailCode(); clearAuthQuery(); await loadData(); return true; @@ -131,6 +210,7 @@ export function createAuthStore({ const response = await publicApi("/auth/token", payload, { signal: options.signal }); if (response.ok && response.csrf_token) { setToken("", response.csrf_token); + clearPendingEmailCode(); clearAuthQuery(); setAuthStatus(""); await loadData(); @@ -157,8 +237,15 @@ export function createAuthStore({ async function requestEmailCode(changeScreen) { const s = get(state); - if (s.authResendCooldown > 0 && s.pendingEmail) return; const normalized = s.email.trim().toLowerCase(); + if ( + s.authResendCooldown > 0 && + s.pendingEmail && + (!normalized || normalized === s.pendingEmail) + ) { + if (typeof changeScreen === "function") changeScreen("code"); + return; + } if (!normalized || !normalized.includes("@")) { state.update((s) => ({ ...s, @@ -185,6 +272,7 @@ export function createAuthStore({ .replace(/\D/g, "") .slice(0, 6); state.update((s) => ({ ...s, pendingEmail: normalized, emailCode: presetCode })); + writePendingEmailCodeSession(normalized); changeScreen("code"); setAuthStatus(""); startCooldownTimer(60); @@ -226,6 +314,7 @@ export function createAuthStore({ }); if (!response.ok || !response.csrf_token) throw response; setToken("", response.csrf_token); + clearPendingEmailCode(); await loadData(); setAuthStatus(""); } catch (error) { @@ -258,6 +347,7 @@ export function createAuthStore({ const response = await publicApi("/auth/email/verify", payload); if (!response.ok || !response.csrf_token) throw response; setToken("", response.csrf_token); + clearPendingEmailCode(); await loadData(); setAuthStatus(""); } catch (error) { @@ -335,6 +425,8 @@ export function createAuthStore({ loginWithEmailPassword, verifyEmailCode, openTelegramLogin, + restorePendingEmailCode, + clearPendingEmailCode, clearCooldownTimer, stopTelegramLoginWatchdog, setAuthStatus, From c2344824dc961d2194ad809e345821c80a711a96 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 1 Jun 2026 00:36:05 +0300 Subject: [PATCH 4/4] feat: show referral relationships in admin user cards --- backend/bot/app/web/admin_api_impl/routes.py | 1 + backend/bot/app/web/admin_api_impl/users.py | 69 +++++- backend/bot/handlers/admin/user_management.py | 165 +++++++++++++- backend/db/dal/user_dal.py | 33 +++ .../src/admin/sections/UserDetailModal.svelte | 204 ++++++++++++++++++ frontend/src/lib/admin/stores/usersStore.js | 74 +++++++ frontend/src/lib/webapp/mockApi.js | 41 +++- locales/en.json | 18 ++ locales/ru.json | 18 ++ tests/test_user_dal.py | 50 +++++ tests/test_webapp_route_contract.py | 1 + 11 files changed, 662 insertions(+), 12 deletions(-) diff --git a/backend/bot/app/web/admin_api_impl/routes.py b/backend/bot/app/web/admin_api_impl/routes.py index 5b20c35..6b0a10c 100644 --- a/backend/bot/app/web/admin_api_impl/routes.py +++ b/backend/bot/app/web/admin_api_impl/routes.py @@ -9,6 +9,7 @@ def setup_admin_routes(app: web.Application) -> None: router.add_get("/api/admin/users", admin_users_list_route) router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route) + router.add_get("/api/admin/users/{user_id:-?\\d+}/referrals", admin_user_referrals_route) router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route) router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route) router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route) diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index b4ea1df..29ee2e2 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -255,6 +255,17 @@ async def _bulk_user_avatar_keys(session: AsyncSession, user_ids: List[int]) -> return {int(uid): (updated_at.isoformat() if updated_at else "") for uid, updated_at in rows} +def _serialize_admin_user_with_avatar(user: User, avatar_keys: Dict[int, str]) -> Dict[str, Any]: + payload = _serialize_user(user) + user_id = int(user.user_id) + payload["avatar_url"] = ( + f"/api/admin/users/{user_id}/avatar?v={avatar_keys[user_id]}" + if user_id in avatar_keys + else None + ) + return payload + + async def admin_user_avatar_route(request: web.Request) -> web.Response: """Serve the cached Telegram avatar for any user (admin-only). @@ -587,7 +598,12 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: ) recent_payments = (await session.execute(recent_payments_stmt)).scalars().all() log_count = await message_log_dal.count_user_message_logs(session, target_id) - avatar_keys = await _bulk_user_avatar_keys(session, [target_id]) + inviter = await user_dal.get_referrer_for_user(session, user) + invitees_total = await user_dal.count_users_referred_by(session, target_id) + avatar_user_ids = [target_id] + if inviter is not None: + avatar_user_ids.append(int(inviter.user_id)) + avatar_keys = await _bulk_user_avatar_keys(session, avatar_user_ids) # Referral links — both the bot deep-link and the webapp deep-link. referral_code: Optional[str] = None @@ -635,11 +651,9 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: exc_panel, ) - serialized_user = _serialize_user(user) - serialized_user["avatar_url"] = ( - f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}" - if target_id in avatar_keys - else None + serialized_user = _serialize_admin_user_with_avatar(user, avatar_keys) + serialized_inviter = ( + _serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None ) return _ok( @@ -655,11 +669,54 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: "code": referral_code, "bot_link": referral_bot_link, "webapp_link": referral_webapp_link, + "inviter": serialized_inviter, + "invitees_total": int(invitees_total or 0), }, } ) +async def admin_user_referrals_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + page = max(0, int(request.query.get("page", 0) or 0)) + page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25))) + 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", "User not found") + + inviter = await user_dal.get_referrer_for_user(session, user) + invitees_total = await user_dal.count_users_referred_by(session, target_id) + invitees = await user_dal.get_users_referred_by( + session, + target_id, + limit=page_size, + offset=page * page_size, + ) + avatar_user_ids = [target_id, *(int(u.user_id) for u in invitees)] + if inviter is not None: + avatar_user_ids.append(int(inviter.user_id)) + avatar_keys = await _bulk_user_avatar_keys(session, avatar_user_ids) + + return _ok( + { + "user": _serialize_admin_user_with_avatar(user, avatar_keys), + "inviter": _serialize_admin_user_with_avatar(inviter, avatar_keys) + if inviter is not None + else None, + "invitees": [ + _serialize_admin_user_with_avatar(invitee, avatar_keys) for invitee in invitees + ], + "total": int(invitees_total or 0), + "page": page, + "page_size": page_size, + } + ) + + async def admin_user_ban_route(request: web.Request) -> web.Response: _require_admin_user_id(request) target_id = int(request.match_info["user_id"]) diff --git a/backend/bot/handlers/admin/user_management.py b/backend/bot/handlers/admin/user_management.py index 786fbd2..2476e0e 100644 --- a/backend/bot/handlers/admin/user_management.py +++ b/backend/bot/handlers/admin/user_management.py @@ -88,6 +88,31 @@ async def _find_user_by_admin_input( return None +def _admin_user_reference_label( + user: Optional[User], fallback_user_id: Optional[int] = None +) -> str: + if user is None: + return f"ID {fallback_user_id}" if fallback_user_id is not None else "N/A" + + first_name = sanitize_display_name(user.first_name) if user.first_name else "" + last_name = sanitize_display_name(user.last_name) if user.last_name else "" + full_name = f"{first_name} {last_name}".strip() + if full_name: + label = full_name + elif user.username: + label = username_for_display(user.username, with_at=True) + elif user.email: + label = user.email + else: + label = f"ID {user.user_id}" + return f"{label} · ID {user.user_id}" + + +def _admin_user_button_label(user: User) -> str: + label = _admin_user_reference_label(user) + return label[:64] + + async def users_list_handler( callback: types.CallbackQuery, i18n_data: dict, @@ -195,7 +220,13 @@ def get_user_card_keyboard( text=_(key="admin_user_refresh_button"), callback_data=f"user_action:refresh:{user_id}" ) - # Row 3b: Premium override + traffic grant + # Row 3b: Referral details + builder.button( + text=_(key="admin_user_invitees_button"), + callback_data=f"user_action:invitees:{user_id}:0", + ) + + # Row 4: Premium override + traffic grant builder.button( text=_(key="admin_user_premium_override_button"), callback_data=f"user_action:premium_override:{user_id}", @@ -229,9 +260,9 @@ def get_user_card_keyboard( quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0) if quick_links_count == 0: - builder.adjust(2, 2, 2, 2, 1, 2) + builder.adjust(2, 2, 2, 1, 2, 1, 2) else: - builder.adjust(2, 2, 2, 2, quick_links_count, 1, 2) + builder.adjust(2, 2, 2, 1, 2, quick_links_count, 1, 2) return builder @@ -314,7 +345,11 @@ async def format_user_card( # Referral info if user.referred_by_id: - card_parts.append(f"{_('admin_user_referral_label')} {hcode(str(user.referred_by_id))}") + referrer = await user_dal.get_referrer_for_user(session, user) + card_parts.append( + f"{_('admin_user_invited_by_label')} " + f"{hcode(_admin_user_reference_label(referrer, user.referred_by_id))}" + ) # Panel info if user.panel_user_uuid: @@ -619,6 +654,12 @@ async def user_action_handler( await handle_send_message_prompt(callback, state, user, i18n, current_lang) elif action == "view_logs": await handle_view_user_logs(callback, user, session, settings, i18n, current_lang) + elif action == "invitees": + try: + page = max(0, int(parts[3])) if len(parts) > 3 else 0 + except (TypeError, ValueError): + page = 0 + await handle_view_user_invitees(callback, user, session, i18n, current_lang, page=page) elif action == "refresh": await handle_refresh_user_card( callback, user, subscription_service, session, settings, i18n, current_lang @@ -1056,6 +1097,120 @@ async def handle_view_user_logs( await callback.answer(_("admin_user_logs_error"), show_alert=True) +async def handle_view_user_invitees( + callback: types.CallbackQuery, + user: User, + session: AsyncSession, + i18n_instance, + lang: str, + *, + page: int = 0, +): + """Show users invited by the selected account.""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + page_size = 10 + safe_page = max(0, int(page or 0)) + + try: + total = await user_dal.count_users_referred_by(session, user.user_id) + total_pages = max(1, (total + page_size - 1) // page_size) + if safe_page >= total_pages: + safe_page = total_pages - 1 + invitees = await user_dal.get_users_referred_by( + session, + user.user_id, + limit=page_size, + offset=safe_page * page_size, + ) + + header = _( + "admin_user_invitees_message_title", + user=hcode(_admin_user_reference_label(user)), + total=total, + current=safe_page + 1, + total_pages=total_pages, + ) + if total <= 0: + invitees_text = f"{header}\n\n{_('admin_user_invitees_empty')}" + else: + lines = [] + for index, invitee in enumerate(invitees, start=safe_page * page_size + 1): + registered = ( + invitee.registration_date.strftime("%Y-%m-%d") + if invitee.registration_date + else "" + ) + suffix = ( + _("admin_user_invitee_registered_suffix", date=registered) if registered else "" + ) + lines.append( + _( + "admin_user_invitee_item", + index=index, + user=hcode(_admin_user_reference_label(invitee)), + suffix=suffix, + ) + ) + invitees_text = "\n".join([header, "", *lines]) + + builder = InlineKeyboardBuilder() + for invitee in invitees: + builder.row( + types.InlineKeyboardButton( + text=_admin_user_button_label(invitee), + callback_data=f"user_action:refresh:{invitee.user_id}", + ) + ) + + pagination_buttons = [] + if safe_page > 0: + pagination_buttons.append( + types.InlineKeyboardButton( + text=_("prev_page_button"), + callback_data=f"user_action:invitees:{user.user_id}:{safe_page - 1}", + ) + ) + if safe_page < total_pages - 1: + pagination_buttons.append( + types.InlineKeyboardButton( + text=_("next_page_button"), + callback_data=f"user_action:invitees:{user.user_id}:{safe_page + 1}", + ) + ) + if pagination_buttons: + builder.row(*pagination_buttons) + builder.row( + types.InlineKeyboardButton( + text=_("admin_user_back_to_card_button"), + callback_data=f"user_action:refresh:{user.user_id}", + ) + ) + builder.row( + types.InlineKeyboardButton( + text=_("back_to_admin_panel_button"), callback_data="admin_action:main" + ) + ) + + try: + await callback.message.edit_text( + invitees_text, reply_markup=builder.as_markup(), parse_mode="HTML" + ) + except Exception: + await callback.message.answer( + invitees_text, reply_markup=builder.as_markup(), parse_mode="HTML" + ) + + await callback.answer() + except Exception as exc: + logging.error( + "Error viewing invitees for user %s: %s", + user.user_id, + exc, + exc_info=True, + ) + await callback.answer(_("admin_user_invitees_error"), show_alert=True) + + async def handle_refresh_user_card( callback: types.CallbackQuery, user: User, @@ -1964,7 +2119,7 @@ async def user_card_from_list_handler( text=_("admin_user_back_to_list_button"), callback_data=f"admin_action:users_list:{page}" ) quick_links_width = 2 if user.referred_by_id else 1 - keyboard.adjust(2, 2, 2, 2, quick_links_width, 1, 2, 1) + keyboard.adjust(2, 2, 2, 1, 2, quick_links_width, 1, 2, 1) # Format user card try: diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py index 8b506e4..cc2102e 100644 --- a/backend/db/dal/user_dal.py +++ b/backend/db/dal/user_dal.py @@ -95,6 +95,39 @@ async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]: return result.scalar_one_or_none() +async def get_referrer_for_user(session: AsyncSession, user: User) -> Optional[User]: + referred_by_id = getattr(user, "referred_by_id", None) + if referred_by_id is None: + return None + return await get_user_by_id(session, int(referred_by_id)) + + +async def get_users_referred_by( + session: AsyncSession, + user_id: int, + *, + limit: int = 50, + offset: int = 0, +) -> List[User]: + safe_limit = max(1, min(500, int(limit or 50))) + safe_offset = max(0, int(offset or 0)) + stmt = ( + select(User) + .where(User.referred_by_id == user_id) + .order_by(User.registration_date.desc().nullslast(), User.user_id.desc()) + .offset(safe_offset) + .limit(safe_limit) + ) + result = await session.execute(stmt) + return result.scalars().all() + + +async def count_users_referred_by(session: AsyncSession, user_id: int) -> int: + stmt = select(func.count(User.user_id)).where(User.referred_by_id == user_id) + result = await session.execute(stmt) + return int(result.scalar_one() or 0) + + async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]: clean_username = username.lstrip("@").lower() stmt = select(User).where(func.lower(User.username) == clean_username) diff --git a/frontend/src/admin/sections/UserDetailModal.svelte b/frontend/src/admin/sections/UserDetailModal.svelte index a424b25..f0ecc1d 100644 --- a/frontend/src/admin/sections/UserDetailModal.svelte +++ b/frontend/src/admin/sections/UserDetailModal.svelte @@ -23,6 +23,7 @@ Trash2, UserMinus, UserPlus, + UsersRound, } from "$components/ui/icons.js"; import { getContext } from "svelte"; @@ -63,6 +64,12 @@ userDeleteOpen, userBanConfirmOpen, userMessageConfirmOpen, + userReferralsOpen, + userReferralsLoading, + userReferrals, + userReferralsTotal, + userReferralsPage, + userReferralsPageSize, premiumUnlimitedDraft, userDetailTab, userLogs, @@ -75,8 +82,13 @@ $: userLogsHasMore = Number(userLogsTotal || 0) > (Number(userLogsPage || 0) + 1) * Number(userLogsPageSize || 20); + $: userReferralsHasMore = + Number(userReferralsTotal || 0) > + (Number(userReferralsPage || 0) + 1) * Number(userReferralsPageSize || 25); $: openedUserAvatarUrl = openedUser ? resolvedAvatarUrl(openedUser) : ""; + $: referralInviter = openedUserDetail?.referral?.inviter || null; + $: referralInviteesTotal = Number(openedUserDetail?.referral?.invitees_total || 0); $: openedUserTelegramProfileLink = openedUser ? userTelegramProfileLink(openedUser) : ""; $: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : ""; $: openedUserTelegramProfileHint = @@ -119,6 +131,12 @@ } openTelegramProfileLink(openedUserTelegramProfileLink); } + + function openRelatedUser(user) { + if (!user?.user_id) return; + usersStore.closeUserReferrals(); + usersStore.openUser(user); + } +
  • + {at("user_label_invited_by", {}, "Пригласил")} + + {#if referralInviter} + {userDisplayName(referralInviter)} + ID {referralInviter.user_id} + {:else} + {at("user_invited_by_none", {}, "—")} + {/if} + + {#if referralInviter} + openRelatedUser(referralInviter)} + > + + + {/if} +
  • +
  • + {at("user_label_invited_users", {}, "Приглашённые")} + {referralInviteesTotal} + usersStore.openUserReferrals(0)} + > + + {at("user_invitees_open", {}, "Показать")} + +
  • {#if openedUserDetail.subscription_url || openedUserDetail.referral?.bot_link || openedUserDetail.referral?.webapp_link} @@ -934,6 +987,101 @@ {/if}
    + +
    + {#if userReferralsLoading} + + {:else if !userReferrals.length} + + {at("user_invitees_empty", {}, "Пользователь пока никого не пригласил")} + + {:else} + + + + + {at("user_col_user", {}, "Пользователь")} + ID + {at("user_label_registration", {}, "Регистрация")} + + + + + {#each userReferrals as invitee (invitee.user_id)} + + + + {userDisplayName(invitee)} + {userSecondaryName(invitee)} + + + {invitee.user_id} + + {fmtDateShort(invitee.registration_date)} + + + openRelatedUser(invitee)} + > + + + + + {/each} + + + + {/if} + + {#if userReferralsTotal > userReferralsPageSize} + usersStore.setUserReferralsPage(userReferralsPage - 1)} + onNext={() => usersStore.setUserReferralsPage(userReferralsPage + 1)} + /> + {/if} +
    +
    + span { + grid-column: 1 / -1; + } + } :global(.admin-avatar-dialog) { display: grid; grid-template-rows: auto minmax(0, 1fr); diff --git a/frontend/src/lib/admin/stores/usersStore.js b/frontend/src/lib/admin/stores/usersStore.js index 4e58ad7..082081b 100644 --- a/frontend/src/lib/admin/stores/usersStore.js +++ b/frontend/src/lib/admin/stores/usersStore.js @@ -25,6 +25,13 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { userDeleteOpen: false, userBanConfirmOpen: false, userMessageConfirmOpen: false, + userReferralsOpen: false, + userReferralsLoading: false, + userReferrals: [], + userReferralsTotal: 0, + userReferralsPage: 0, + userReferralsPageSize: USERS_PAGE_SIZE, + userReferralsInviter: null, userDetailTab: "profile", premiumUnlimitedDraft: false, premiumBonusGbDraft: "", @@ -126,6 +133,12 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { userExtendDays: 30, userDetailLoading: true, userDetailTab: "subscription", + userReferralsOpen: false, + userReferralsLoading: false, + userReferrals: [], + userReferralsTotal: 0, + userReferralsPage: 0, + userReferralsInviter: null, userLogs: [], userLogsTotal: 0, userLogsPage: 0, @@ -175,6 +188,12 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { userDeleteOpen: false, userBanConfirmOpen: false, userMessageConfirmOpen: false, + userReferralsOpen: false, + userReferralsLoading: false, + userReferrals: [], + userReferralsTotal: 0, + userReferralsPage: 0, + userReferralsInviter: null, userLogs: [], userLogsTotal: 0, userLogsPage: 0, @@ -231,6 +250,58 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { loadUserLogs(page); } + async function openUserReferrals(page = 0) { + let s; + state.update((st) => { + s = st; + return st; + }); + if (!s.openedUser) return; + const userId = s.openedUser.user_id; + const targetPage = Number.isFinite(page) ? Math.max(0, Math.floor(page)) : 0; + state.update((st) => ({ + ...st, + userReferralsOpen: true, + userReferralsLoading: true, + userReferralsPage: targetPage, + })); + try { + const params = new URLSearchParams({ + page: String(targetPage), + page_size: String(s.userReferralsPageSize || USERS_PAGE_SIZE), + }); + const data = await api(`/admin/users/${userId}/referrals?${params.toString()}`); + if (data?.ok) { + state.update((st) => { + if (!st.openedUser || st.openedUser.user_id !== userId) return st; + return { + ...st, + userReferrals: data.invitees || [], + userReferralsTotal: Number(data.total || 0), + userReferralsPage: Number(data.page || 0), + userReferralsPageSize: Number(data.page_size || st.userReferralsPageSize), + userReferralsInviter: data.inviter || null, + }; + }); + } else if (data?.error) { + onToast(data.error); + } + } finally { + state.update((st) => ({ ...st, userReferralsLoading: false })); + } + } + + function closeUserReferrals() { + state.update((s) => ({ + ...s, + userReferralsOpen: false, + })); + } + + function setUserReferralsPage(page) { + openUserReferrals(page); + } + function copyToClipboard(text, successMessage = at("link_copied", {}, "Скопировано")) { if (!text) return; if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) { @@ -564,5 +635,8 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { grantTraffic, loadUserLogs, setUserLogsPage, + openUserReferrals, + closeUserReferrals, + setUserReferralsPage, }; } diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index 670e33f..058d813 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -405,6 +405,32 @@ function withDemoAvatars(users, size = 96) { return (users || []).map((user) => withDemoAvatar(user, size)); } +function demoAdminUserById(userId) { + return (DEMO_DATASET.adminUsers || []).find((user) => Number(user.user_id) === Number(userId)); +} + +function demoInviteesForUser(userId) { + return (DEMO_DATASET.adminUsers || []) + .filter((user) => Number(user.referred_by_id) === Number(userId)) + .sort((a, b) => stringDate(b.registration_date) - stringDate(a.registration_date)); +} + +function withDemoReferralSummary(detail) { + if (!detail || typeof detail !== "object") return detail; + const decorated = withDemoAvatarDetail(detail); + const user = decorated.user || {}; + const inviter = user.referred_by_id ? demoAdminUserById(user.referred_by_id) : null; + const invitees = demoInviteesForUser(user.user_id); + return { + ...decorated, + referral: { + ...(decorated.referral || {}), + inviter: inviter ? withDemoAvatar(inviter) : null, + invitees_total: invitees.length, + }, + }; +} + function withDemoAvatarTickets(tickets, size = 96) { return (tickets || []).map((ticket) => withDemoAvatarTicket(ticket, size)); } @@ -610,8 +636,21 @@ function demoApiResponse(path, cleanPath, options, context) { const id = Number(parts[3]); const detail = DEMO_DATASET.adminUserDetails?.[String(id)]; if (!detail) return { ok: false, error: "not_found" }; - const decoratedDetail = withDemoAvatarDetail(detail); + const decoratedDetail = withDemoReferralSummary(detail); if (parts[4]) { + if (parts[4] === "referrals") { + const invitees = demoInviteesForUser(id); + const page = paged(invitees, params, 25); + return { + ok: true, + user: clone(decoratedDetail.user), + inviter: clone(decoratedDetail.referral?.inviter || null), + invitees: clone(withDemoAvatars(page.items)), + total: page.total, + page: page.page, + page_size: page.pageSize, + }; + } if (parts[4] === "telegram-profile-link") { return { ok: true, url: `https://t.me/${detail.user?.username || "demo_user"}` }; } diff --git a/locales/en.json b/locales/en.json index 64be717..2346c55 100644 --- a/locales/en.json +++ b/locales/en.json @@ -440,6 +440,13 @@ "admin_user_referral_revenue_label": "💸 Referral Revenue:", "admin_user_invited_friends_label": "👥 Friends invited:", "admin_user_ref_purchased_label": "💳 Purchased subscription:", + "admin_user_invited_by_label": "🎁 Invited by:", + "admin_user_invitees_button": "👥 Invited users", + "admin_user_invitees_message_title": "👥 Users invited by {user}\n\nTotal: {total}. Page {current}/{total_pages}.", + "admin_user_invitees_empty": "This user has not invited anyone yet.", + "admin_user_invitee_item": "{index}. {user}{suffix}", + "admin_user_invitee_registered_suffix": " · registered {date}", + "admin_user_invitees_error": "❌ Failed to load invited users", "admin_user_links_section_title": "🔗 Links", "admin_user_subscription_url_label": "📡 Subscription:", "admin_user_ref_bot_link_label": "🤖 Referral link (bot):", @@ -1247,6 +1254,14 @@ "admin_user_section_profile": "Profile", "admin_user_label_registration": "Registration", "admin_user_label_ref_code": "Referral Code", + "admin_user_label_invited_by": "Invited by", + "admin_user_label_invited_users": "Invited users", + "admin_user_invited_by_none": "—", + "admin_user_invitees_open": "Show", + "admin_user_open_related": "Open user card", + "admin_user_invitees_title": "Invited users", + "admin_user_invitees_description": "{name} · {count}", + "admin_user_col_user": "User", "admin_user_section_links": "Links", "admin_user_copy_tooltip": "Copy", "admin_user_sub_link_copied": "Subscription link copied", @@ -1373,6 +1388,9 @@ "admin_ads_col_registrations": "Registrations", "admin_ads_col_conversions": "Conversions", "admin_no_data": "No data", + "admin_pagination_meta": "{current}/{total}", + "admin_prev_page": "Back", + "admin_next_page": "Next", "admin_settings_hint": "Changes in the admin panel take precedence over .env. The 'Reset' button returns the value from environment variables.", "admin_settings_legacy_tariffs_warning_title": "remnawave-tg-shop legacy compatibility", "admin_settings_legacy_tariffs_warning_body": "These fields are only for the old mode without a JSON catalog. They do not apply when tariffs are configured in the dedicated Tariffs section.", diff --git a/locales/ru.json b/locales/ru.json index 650f6e9..a8fc836 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -440,6 +440,13 @@ "admin_user_referral_revenue_label": "💸 Доход по рефералам:", "admin_user_invited_friends_label": "👥 Приглашено друзей:", "admin_user_ref_purchased_label": "💳 Купили подписку:", + "admin_user_invited_by_label": "🎁 Пригласил:", + "admin_user_invitees_button": "👥 Приглашённые", + "admin_user_invitees_message_title": "👥 Пользователи, приглашённые {user}\n\nВсего: {total}. Страница {current}/{total_pages}.", + "admin_user_invitees_empty": "Пользователь пока никого не пригласил.", + "admin_user_invitee_item": "{index}. {user}{suffix}", + "admin_user_invitee_registered_suffix": " · регистрация {date}", + "admin_user_invitees_error": "❌ Ошибка загрузки приглашённых пользователей", "admin_user_links_section_title": "🔗 Ссылки", "admin_user_subscription_url_label": "📡 Подписка:", "admin_user_ref_bot_link_label": "🤖 Реф. ссылка (бот):", @@ -1247,6 +1254,14 @@ "admin_user_section_profile": "Профиль", "admin_user_label_registration": "Регистрация", "admin_user_label_ref_code": "Реф. код", + "admin_user_label_invited_by": "Пригласил", + "admin_user_label_invited_users": "Приглашённые", + "admin_user_invited_by_none": "—", + "admin_user_invitees_open": "Показать", + "admin_user_open_related": "Открыть карточку", + "admin_user_invitees_title": "Приглашённые пользователи", + "admin_user_invitees_description": "{name} · {count}", + "admin_user_col_user": "Пользователь", "admin_user_section_links": "Ссылки", "admin_user_copy_tooltip": "Скопировать", "admin_user_sub_link_copied": "Ссылка на подписку скопирована", @@ -1373,6 +1388,9 @@ "admin_ads_col_registrations": "Регистрации", "admin_ads_col_conversions": "Конверсии", "admin_no_data": "Нет данных", + "admin_pagination_meta": "{current}/{total}", + "admin_prev_page": "Назад", + "admin_next_page": "Вперёд", "admin_settings_hint": "Изменения в админке имеют приоритет над .env. Кнопка «Сбросить» возвращает значение из переменных окружения.", "admin_settings_legacy_tariffs_warning_title": "Совместимость с remnawave-tg-shop legacy", "admin_settings_legacy_tariffs_warning_body": "Эти поля нужны только для старого режима без JSON-каталога. Если тарифы настроены через отдельный раздел «Тарифы», они не применяются.", diff --git a/tests/test_user_dal.py b/tests/test_user_dal.py index a0358ec..873e587 100644 --- a/tests/test_user_dal.py +++ b/tests/test_user_dal.py @@ -17,6 +17,9 @@ class FakeResult: def scalar_one_or_none(self): return self._scalar_value + def scalar_one(self): + return self._scalar_value + def scalars(self): return self @@ -71,6 +74,53 @@ class UserDalStatisticsTests(unittest.IsolatedAsyncioTestCase): self.assertIn("TRIAL", sql) +class UserDalReferralTests(unittest.IsolatedAsyncioTestCase): + async def test_get_users_referred_by_filters_with_pagination(self): + invited = [SimpleNamespace(user_id=1001), SimpleNamespace(user_id=1002)] + session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult(invited))) + + result = await user_dal.get_users_referred_by(session, 42, limit=2, offset=10) + + self.assertEqual(result, invited) + stmt = session.execute.await_args.args[0] + sql = str( + stmt.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ).upper() + self.assertIn("REFERRED_BY_ID = 42", sql) + self.assertIn("ORDER BY", sql) + self.assertIn("LIMIT 2", sql) + self.assertIn("OFFSET 10", sql) + + async def test_count_users_referred_by_counts_matching_rows(self): + session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult(3))) + + result = await user_dal.count_users_referred_by(session, 42) + + self.assertEqual(result, 3) + stmt = session.execute.await_args.args[0] + sql = str( + stmt.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ).upper() + self.assertIn("COUNT", sql) + self.assertIn("REFERRED_BY_ID = 42", sql) + + async def test_get_referrer_for_user_uses_referred_by_id(self): + referrer = SimpleNamespace(user_id=7) + session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult(referrer))) + + result = await user_dal.get_referrer_for_user( + session, SimpleNamespace(user_id=42, referred_by_id=7) + ) + + self.assertIs(result, referrer) + + 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") diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index 3384482..e201b8a 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -156,6 +156,7 @@ class WebAppRouteContractTests(unittest.TestCase): ("GET", "/api/admin/stats"): "admin_stats_route", ("GET", "/api/admin/users"): "admin_users_list_route", ("GET", "/api/admin/users/{user_id}"): "admin_user_detail_route", + ("GET", "/api/admin/users/{user_id}/referrals"): "admin_user_referrals_route", ("GET", "/api/admin/users/{user_id}/avatar"): "admin_user_avatar_route", ("POST", "/api/admin/users/{user_id}/ban"): "admin_user_ban_route", ("POST", "/api/admin/users/{user_id}/message"): "admin_user_message_route",