Merge branch 'dev' into feature/multicurrency

This commit is contained in:
3252a8
2026-06-01 08:18:25 +03:00
31 changed files with 1151 additions and 63 deletions
@@ -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)
+64 -7
View File
@@ -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"])
@@ -1031,7 +1088,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"]
+160 -5
View File
@@ -89,6 +89,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,
@@ -196,7 +221,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}",
@@ -230,9 +261,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
@@ -315,7 +346,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:
@@ -622,6 +657,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
@@ -1059,6 +1100,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,
@@ -1967,7 +2122,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:
+24 -1
View File
@@ -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(
+57
View File
@@ -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,
)
@@ -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(
@@ -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}
@@ -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]:
+55
View File
@@ -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]:
@@ -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)
+33
View File
@@ -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)
+4
View File
@@ -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();
}
@@ -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);
}
</script>
<Dialog
@@ -225,6 +243,41 @@
"—"}</strong
>
</li>
<li class="admin-user-ref-row">
<span>{at("user_label_invited_by", {}, "Пригласил")}</span>
<strong class="admin-user-ref-value">
{#if referralInviter}
<span>{userDisplayName(referralInviter)}</span>
<small>ID {referralInviter.user_id}</small>
{:else}
<span>{at("user_invited_by_none", {}, "—")}</span>
{/if}
</strong>
{#if referralInviter}
<AdminButton
size="icon"
variant="icon"
title={at("user_open_related", {}, "Открыть карточку")}
aria-label={at("user_open_related", {}, "Открыть карточку")}
onclick={() => openRelatedUser(referralInviter)}
>
<ExternalLink size={14} />
</AdminButton>
{/if}
</li>
<li class="admin-user-ref-row">
<span>{at("user_label_invited_users", {}, "Приглашённые")}</span>
<strong>{referralInviteesTotal}</strong>
<AdminButton
size="sm"
variant="ghost"
disabled={referralInviteesTotal <= 0}
onclick={() => usersStore.openUserReferrals(0)}
>
<UsersRound size={14} />
{at("user_invitees_open", {}, "Показать")}
</AdminButton>
</li>
</ul>
{#if openedUserDetail.subscription_url || openedUserDetail.referral?.bot_link || openedUserDetail.referral?.webapp_link}
@@ -934,6 +987,101 @@
{/if}
</Dialog>
<Dialog
open={userReferralsOpen}
title={at("user_invitees_title", {}, "Приглашённые пользователи")}
description={openedUser
? at(
"user_invitees_description",
{ name: userDisplayName(openedUser), count: userReferralsTotal },
`${userDisplayName(openedUser)} · ${userReferralsTotal}`
)
: ""}
closeLabel={at("close", {}, "Закрыть")}
onclose={usersStore.closeUserReferrals}
class="admin-dialog admin-user-referrals-dialog"
>
<div class="admin-user-referrals-body">
{#if userReferralsLoading}
<AdminTableSkeleton
headers={[
at("user_col_user", {}, "Пользователь"),
"ID",
at("user_label_registration", {}, "Регистрация"),
"",
]}
rows={5}
widths={["42%", "18%", "26%", "14%"]}
/>
{:else if !userReferrals.length}
<AdminEmptyState tone="card">
<span class="admin-muted"
>{at("user_invitees_empty", {}, "Пользователь пока никого не пригласил")}</span
>
</AdminEmptyState>
{:else}
<ScrollArea class="admin-user-referrals-table-wrap" maxHeight="min(55vh, 460px)">
<AdminTable class="admin-user-referrals-table">
<thead>
<tr>
<th>{at("user_col_user", {}, "Пользователь")}</th>
<th>ID</th>
<th>{at("user_label_registration", {}, "Регистрация")}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each userReferrals as invitee (invitee.user_id)}
<tr>
<td data-label={at("user_col_user", {}, "Пользователь")}>
<span class="admin-referral-user-cell">
<strong>{userDisplayName(invitee)}</strong>
<small>{userSecondaryName(invitee)}</small>
</span>
</td>
<td class="admin-cell-mono" data-label="ID">{invitee.user_id}</td>
<td data-label={at("user_label_registration", {}, "Регистрация")}>
{fmtDateShort(invitee.registration_date)}
</td>
<td class="admin-referral-user-actions">
<AdminButton
size="icon"
variant="icon"
title={at("user_open_related", {}, "Открыть карточку")}
aria-label={at("user_open_related", {}, "Открыть карточку")}
onclick={() => openRelatedUser(invitee)}
>
<ExternalLink size={14} />
</AdminButton>
</td>
</tr>
{/each}
</tbody>
</AdminTable>
</ScrollArea>
{/if}
{#if userReferralsTotal > userReferralsPageSize}
<AdminPagination
meta={at(
"pagination_meta",
{
current: userReferralsPage + 1,
total: Math.max(1, Math.ceil(userReferralsTotal / userReferralsPageSize)),
},
`${userReferralsPage + 1}/${Math.max(1, Math.ceil(userReferralsTotal / userReferralsPageSize))}`
)}
prevLabel={at("prev_page", {}, "Назад")}
nextLabel={at("next_page", {}, "Вперёд")}
prevDisabled={userReferralsLoading || userReferralsPage <= 0}
nextDisabled={userReferralsLoading || !userReferralsHasMore}
onPrev={() => usersStore.setUserReferralsPage(userReferralsPage - 1)}
onNext={() => usersStore.setUserReferralsPage(userReferralsPage + 1)}
/>
{/if}
</div>
</Dialog>
<Dialog
open={avatarPreviewOpen}
title={avatarPreviewName || at("user_avatar_title", {}, "Аватар")}
@@ -1159,6 +1307,62 @@
gap: 8px;
margin-top: 6px;
}
.admin-user-ref-row {
grid-template-columns: 130px minmax(0, 1fr) auto;
align-items: center;
}
.admin-user-ref-row :global(.admin-btn) {
flex: 0 0 auto;
}
.admin-user-ref-value {
display: grid;
gap: 2px;
min-width: 0;
}
.admin-user-ref-value small {
color: var(--admin-muted);
font-size: 11px;
font-weight: 500;
}
:global(.admin-user-referrals-dialog) {
width: min(760px, calc(100vw - 28px));
max-height: min(760px, calc(100dvh - 28px));
}
.admin-user-referrals-body {
display: grid;
gap: 12px;
min-width: 0;
}
:global(.admin-user-referrals-table-wrap) {
min-height: 120px;
}
.admin-referral-user-cell {
display: grid;
gap: 2px;
min-width: 0;
}
.admin-referral-user-cell strong {
color: var(--admin-text);
font-weight: 650;
word-break: break-word;
}
.admin-referral-user-cell small {
color: var(--admin-muted);
font-size: 12px;
word-break: break-word;
}
.admin-referral-user-actions {
text-align: right;
}
@media (max-width: 560px) {
.admin-user-ref-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: 4px 8px;
}
.admin-user-ref-row > span {
grid-column: 1 / -1;
}
}
:global(.admin-avatar-dialog) {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
@@ -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,
};
}
+1 -1
View File
@@ -66,7 +66,7 @@
<X size={18} />
</Button>
</div>
<ScrollArea class="dialog-body-scroll" maxHeight="none">
<ScrollArea class="dialog-body-scroll scroll-area--dialog" maxHeight="none">
<slot />
</ScrollArea>
</section>
@@ -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;
+20 -21
View File
@@ -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,
+40 -1
View File
@@ -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"}` };
}
+12 -2
View File
@@ -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"));
+93 -1
View File
@@ -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,
-9
View File
@@ -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);
+23 -5
View File
@@ -374,8 +374,8 @@
"traffic_warning_premium_depleted": "⛔️ The <b>premium</b> server quota for plan <b>{tariff_name}</b> is used up.\n\n<b>Current period:</b>\nused — <b>{used}</b>\navailable — <b>{remaining}</b>\nlimit — <b>{limit_total}</b>\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": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
@@ -440,6 +440,13 @@
"admin_user_referral_revenue_label": "💸 <b>Referral Revenue:</b>",
"admin_user_invited_friends_label": "👥 <b>Friends invited:</b>",
"admin_user_ref_purchased_label": "💳 <b>Purchased subscription:</b>",
"admin_user_invited_by_label": "🎁 <b>Invited by:</b>",
"admin_user_invitees_button": "👥 Invited users",
"admin_user_invitees_message_title": "👥 <b>Users invited by {user}</b>\n\nTotal: <b>{total}</b>. 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": "🔗 <b>Links</b>",
"admin_user_subscription_url_label": "📡 <b>Subscription:</b>",
"admin_user_ref_bot_link_label": "🤖 <b>Referral link (bot):</b>",
@@ -685,7 +692,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",
@@ -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",
@@ -1268,11 +1283,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",
@@ -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.",
+22 -4
View File
@@ -374,8 +374,8 @@
"traffic_warning_premium_depleted": "⛔️ Лимит премиум-серверов тарифа <b>{tariff_name}</b> израсходован.\n\n<b>Текущий период:</b>\nизрасходовано — <b>{used}</b>\nдоступно — <b>{remaining}</b>\nлимит — <b>{limit_total}</b>\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": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
@@ -440,6 +440,13 @@
"admin_user_referral_revenue_label": "💸 <b>Доход по рефералам:</b>",
"admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>",
"admin_user_ref_purchased_label": "💳 <b>Купили подписку:</b>",
"admin_user_invited_by_label": "🎁 <b>Пригласил:</b>",
"admin_user_invitees_button": "👥 Приглашённые",
"admin_user_invitees_message_title": "👥 <b>Пользователи, приглашённые {user}</b>\n\nВсего: <b>{total}</b>. Страница {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": "🔗 <b>Ссылки</b>",
"admin_user_subscription_url_label": "📡 <b>Подписка:</b>",
"admin_user_ref_bot_link_label": "🤖 <b>Реф. ссылка (бот):</b>",
@@ -685,7 +692,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": "Далее",
@@ -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": "Ссылка на подписку скопирована",
@@ -1272,7 +1287,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",
@@ -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-каталога. Если тарифы настроены через отдельный раздел «Тарифы», они не применяются.",
+53
View File
@@ -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)
+47
View File
@@ -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
+17
View File
@@ -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(
+2 -2
View File
@@ -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(
+1 -1
View File
@@ -41,7 +41,7 @@ def test_support_ticket_closed_email_uses_user_language():
assert '<html lang="ru"' in content.html
assert "Ticket #7 was closed" not in content.text
assert "Тема: Проблема с подключением" in content.text
assert "Открыть в Mini App" in content.html
assert "Открыть обращение" in content.html
def test_support_admin_email_localizes_snapshot_rows_for_recipient():
+25
View File
@@ -34,6 +34,31 @@ def _tariffs_config_payload() -> 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):
+50
View File
@@ -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")
+1
View File
@@ -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",