926 lines
34 KiB
Python
926 lines
34 KiB
Python
# ruff: noqa: F401,F403,F405,I001
|
|
from ._runtime import * # noqa: F403,F405
|
|
|
|
|
|
async def admin_users_list_route(request: web.Request) -> web.Response:
|
|
_require_admin_user_id(request)
|
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
|
|
|
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)))
|
|
query = (request.query.get("q") or "").strip()
|
|
filter_value = (request.query.get("filter") or "all").lower()
|
|
panel_status = (request.query.get("panel_status") or "all").lower()
|
|
premium_traffic = (request.query.get("premium_traffic") or "all").lower()
|
|
sort_value = (request.query.get("sort") or "registered_desc").lower()
|
|
|
|
async with async_session_factory() as session:
|
|
users, total = await _filter_and_sort_users(
|
|
session,
|
|
query=query,
|
|
filter_value=filter_value,
|
|
panel_status=panel_status,
|
|
premium_traffic=premium_traffic,
|
|
sort_value=sort_value,
|
|
page=page,
|
|
page_size=page_size,
|
|
)
|
|
|
|
statuses = await _bulk_user_statuses(session, [u.user_id for u in users])
|
|
cached_avatar_ids = await _bulk_user_avatar_keys(session, [u.user_id for u in users])
|
|
active_subs = await _bulk_active_subscriptions_for_users(
|
|
session, [u.user_id for u in users]
|
|
)
|
|
|
|
serialized = []
|
|
for user in users:
|
|
payload = _serialize_user(user)
|
|
status_payload = statuses.get(user.user_id) or {"status": "bot_only", "end_date": None}
|
|
payload["panel_status"] = status_payload.get("status")
|
|
if status_payload.get("status") == "expired" and status_payload.get("end_date"):
|
|
payload["panel_status_expired_at"] = status_payload["end_date"]
|
|
payload["avatar_url"] = (
|
|
f"/api/admin/users/{user.user_id}/avatar?v={cached_avatar_ids[user.user_id]}"
|
|
if user.user_id in cached_avatar_ids
|
|
else None
|
|
)
|
|
payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id))
|
|
serialized.append(payload)
|
|
|
|
return _ok(
|
|
{
|
|
"users": serialized,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"total": total,
|
|
}
|
|
)
|
|
|
|
|
|
async def _bulk_user_statuses(
|
|
session: AsyncSession, user_ids: List[int]
|
|
) -> Dict[int, Dict[str, Optional[str]]]:
|
|
"""Return active subscription status for a batch of users.
|
|
|
|
Returns the panel status (active/expired/limited/disabled) when an active
|
|
subscription exists, otherwise ``"bot_only"`` when the user is in the bot
|
|
but has no panel subscription.
|
|
"""
|
|
|
|
if not user_ids:
|
|
return {}
|
|
stmt = (
|
|
select(
|
|
Subscription.user_id,
|
|
Subscription.status_from_panel,
|
|
Subscription.is_active,
|
|
Subscription.end_date,
|
|
)
|
|
.where(Subscription.user_id.in_(user_ids))
|
|
.order_by(Subscription.is_active.desc(), Subscription.end_date.desc().nullslast())
|
|
)
|
|
rows = (await session.execute(stmt)).all()
|
|
out: Dict[int, Dict[str, Optional[str]]] = {}
|
|
for uid, panel_status, is_active, end_date in rows:
|
|
if uid in out:
|
|
continue
|
|
if is_active:
|
|
status = (panel_status or "active").lower()
|
|
else:
|
|
status = (panel_status or "expired").lower()
|
|
out[uid] = {
|
|
"status": status,
|
|
"end_date": end_date.isoformat() if end_date else None,
|
|
}
|
|
for uid in user_ids:
|
|
if uid not in out:
|
|
out[uid] = {"status": "bot_only", "end_date": None}
|
|
return out
|
|
|
|
|
|
async def _bulk_user_avatar_keys(session: AsyncSession, user_ids: List[int]) -> Dict[int, str]:
|
|
"""Return ``{user_id: cache_key}`` for users with a cached Telegram avatar.
|
|
|
|
The cache key is the row's ``updated_at`` timestamp — used as a
|
|
cache-buster query param so the browser refetches when the avatar
|
|
changes.
|
|
"""
|
|
|
|
if not user_ids:
|
|
return {}
|
|
stmt = select(UserTelegramAvatar.user_id, UserTelegramAvatar.updated_at).where(
|
|
UserTelegramAvatar.user_id.in_(user_ids)
|
|
)
|
|
rows = (await session.execute(stmt)).all()
|
|
return {int(uid): (updated_at.isoformat() if updated_at else "") for uid, updated_at in rows}
|
|
|
|
|
|
async def admin_user_avatar_route(request: web.Request) -> web.Response:
|
|
"""Serve the cached Telegram avatar for any user (admin-only).
|
|
|
|
Mirrors ``/api/account/avatar`` but takes a ``user_id`` from the URL
|
|
and uses admin auth. Only the cached blob from
|
|
``user_telegram_avatars`` is served — refreshing from Telegram is the
|
|
job of the user-facing endpoint, so the admin list never blocks on a
|
|
Telegram round-trip.
|
|
"""
|
|
|
|
_require_admin_user_id(request)
|
|
target_id = int(request.match_info["user_id"])
|
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
|
async with async_session_factory() as session:
|
|
avatar = await session.get(UserTelegramAvatar, target_id)
|
|
|
|
if not avatar:
|
|
raise web.HTTPNotFound(text="avatar_not_cached")
|
|
|
|
etag = (
|
|
f'W/"avatar-{target_id}-{int(avatar.updated_at.timestamp())}"'
|
|
if avatar.updated_at
|
|
else None
|
|
)
|
|
if etag and request.headers.get("If-None-Match") == etag:
|
|
return web.Response(status=304, headers={"ETag": etag})
|
|
|
|
response = web.Response(
|
|
body=bytes(avatar.image_bytes),
|
|
content_type=avatar.content_type or "image/jpeg",
|
|
)
|
|
response.headers["Cache-Control"] = "private, max-age=3600"
|
|
if etag:
|
|
response.headers["ETag"] = etag
|
|
return response
|
|
|
|
|
|
def _ranked_active_subscriptions_sq(now: datetime):
|
|
"""Latest active subscription per user (same ordering as subscription_dal)."""
|
|
|
|
rn = sa_func.row_number().over(
|
|
partition_by=Subscription.user_id,
|
|
order_by=(
|
|
Subscription.end_date.desc(),
|
|
Subscription.subscription_id.desc(),
|
|
),
|
|
)
|
|
inner = (
|
|
select(
|
|
Subscription.user_id,
|
|
Subscription.subscription_id,
|
|
Subscription.premium_used_bytes,
|
|
Subscription.premium_baseline_bytes,
|
|
Subscription.premium_topup_balance_bytes,
|
|
Subscription.premium_topup_used_bytes,
|
|
Subscription.premium_bonus_bytes,
|
|
Subscription.premium_unlimited_override,
|
|
rn.label("rn"),
|
|
)
|
|
.where(
|
|
Subscription.is_active.is_(True),
|
|
Subscription.end_date > now,
|
|
)
|
|
.subquery()
|
|
)
|
|
return select(inner).where(inner.c.rn == 1).subquery(name="ranked_active_sub")
|
|
|
|
|
|
async def _bulk_active_subscriptions_for_users(
|
|
session: AsyncSession, user_ids: List[int]
|
|
) -> Dict[int, Subscription]:
|
|
"""Active subscription row per user (for admin list premium traffic column)."""
|
|
|
|
if not user_ids:
|
|
return {}
|
|
now = datetime.now(timezone.utc)
|
|
stmt = (
|
|
select(Subscription)
|
|
.where(
|
|
Subscription.user_id.in_(user_ids),
|
|
Subscription.is_active.is_(True),
|
|
Subscription.end_date > now,
|
|
)
|
|
.order_by(
|
|
Subscription.user_id.asc(),
|
|
Subscription.end_date.desc(),
|
|
Subscription.subscription_id.desc(),
|
|
)
|
|
)
|
|
rows = (await session.execute(stmt)).scalars().all()
|
|
out: Dict[int, Subscription] = {}
|
|
for sub in rows:
|
|
uid = int(sub.user_id)
|
|
if uid not in out:
|
|
out[uid] = sub
|
|
return out
|
|
|
|
|
|
async def _filter_and_sort_users(
|
|
session: AsyncSession,
|
|
*,
|
|
query: str = "",
|
|
filter_value: str,
|
|
panel_status: str = "all",
|
|
premium_traffic: str = "all",
|
|
sort_value: str,
|
|
page: int,
|
|
page_size: int,
|
|
) -> tuple[List[User], int]:
|
|
"""Return paginated users with optional search, filter and sort applied."""
|
|
|
|
now = datetime.now(timezone.utc)
|
|
sort_key = (sort_value or "registered_desc").lower()
|
|
pt_filter = (premium_traffic or "all").lower()
|
|
needs_premium_sq = pt_filter != "all" or sort_key in {
|
|
"premium_ratio_asc",
|
|
"premium_ratio_desc",
|
|
}
|
|
|
|
stmt = select(User)
|
|
count_stmt = select(sa_func.count(User.user_id))
|
|
|
|
sq = None
|
|
ratio_expr = None
|
|
plim_expr = None
|
|
pu_expr = None
|
|
|
|
if needs_premium_sq:
|
|
sq = _ranked_active_subscriptions_sq(now)
|
|
stmt = stmt.outerjoin(sq, User.user_id == sq.c.user_id)
|
|
count_stmt = count_stmt.outerjoin(sq, User.user_id == sq.c.user_id)
|
|
pb = sa_func.coalesce(sq.c.premium_bonus_bytes, 0)
|
|
plim_expr = (
|
|
sa_func.coalesce(sq.c.premium_baseline_bytes, 0)
|
|
+ sa_func.coalesce(sq.c.premium_topup_balance_bytes, 0)
|
|
+ sa_func.coalesce(sq.c.premium_topup_used_bytes, 0)
|
|
+ pb
|
|
)
|
|
pu_expr = sa_func.coalesce(sq.c.premium_used_bytes, 0)
|
|
ratio_expr = case(
|
|
(sq.c.user_id.is_(None), None),
|
|
(sq.c.premium_unlimited_override.is_(True), None),
|
|
(plim_expr <= 0, None),
|
|
else_=cast(pu_expr, Float) / cast(plim_expr, Float),
|
|
)
|
|
|
|
search_cond = _user_search_condition(query)
|
|
if search_cond is not None:
|
|
stmt = stmt.where(search_cond)
|
|
count_stmt = count_stmt.where(search_cond)
|
|
|
|
f = (filter_value or "all").lower()
|
|
if f == "banned":
|
|
cond = User.is_banned.is_(True)
|
|
elif f == "active":
|
|
cond = User.is_banned.is_(False)
|
|
elif f == "tg_linked":
|
|
cond = User.telegram_id.is_not(None)
|
|
elif f == "no_tg":
|
|
cond = User.telegram_id.is_(None)
|
|
elif f == "email_linked":
|
|
cond = User.email.is_not(None)
|
|
elif f == "no_email":
|
|
cond = User.email.is_(None)
|
|
elif f == "panel_linked":
|
|
cond = User.panel_user_uuid.is_not(None)
|
|
else:
|
|
cond = None
|
|
|
|
if cond is not None:
|
|
stmt = stmt.where(cond)
|
|
count_stmt = count_stmt.where(cond)
|
|
|
|
panel_cond = _user_panel_status_condition(panel_status)
|
|
if panel_cond is not None:
|
|
stmt = stmt.where(panel_cond)
|
|
count_stmt = count_stmt.where(panel_cond)
|
|
|
|
if needs_premium_sq and sq is not None and plim_expr is not None and pu_expr is not None:
|
|
if pt_filter == "none":
|
|
premium_cond = or_(
|
|
sq.c.user_id.is_(None),
|
|
and_(
|
|
sq.c.premium_unlimited_override.is_(False),
|
|
plim_expr <= 0,
|
|
),
|
|
)
|
|
stmt = stmt.where(premium_cond)
|
|
count_stmt = count_stmt.where(premium_cond)
|
|
elif pt_filter == "unlimited":
|
|
premium_cond = and_(
|
|
sq.c.user_id.isnot(None),
|
|
sq.c.premium_unlimited_override.is_(True),
|
|
)
|
|
stmt = stmt.where(premium_cond)
|
|
count_stmt = count_stmt.where(premium_cond)
|
|
elif pt_filter == "good":
|
|
premium_cond = and_(
|
|
sq.c.user_id.isnot(None),
|
|
sq.c.premium_unlimited_override.is_(False),
|
|
plim_expr > 0,
|
|
(100 * pu_expr) < (85 * plim_expr),
|
|
)
|
|
stmt = stmt.where(premium_cond)
|
|
count_stmt = count_stmt.where(premium_cond)
|
|
elif pt_filter == "warn":
|
|
premium_cond = and_(
|
|
sq.c.user_id.isnot(None),
|
|
sq.c.premium_unlimited_override.is_(False),
|
|
plim_expr > 0,
|
|
(100 * pu_expr) >= (85 * plim_expr),
|
|
pu_expr < plim_expr,
|
|
)
|
|
stmt = stmt.where(premium_cond)
|
|
count_stmt = count_stmt.where(premium_cond)
|
|
elif pt_filter == "critical":
|
|
premium_cond = and_(
|
|
sq.c.user_id.isnot(None),
|
|
sq.c.premium_unlimited_override.is_(False),
|
|
plim_expr > 0,
|
|
pu_expr >= plim_expr,
|
|
)
|
|
stmt = stmt.where(premium_cond)
|
|
count_stmt = count_stmt.where(premium_cond)
|
|
|
|
sort_map = {
|
|
"registered_desc": User.registration_date.desc().nullslast(),
|
|
"registered_asc": User.registration_date.asc().nullslast(),
|
|
"name_asc": (
|
|
sa_func.coalesce(User.first_name, User.username, User.email).asc(),
|
|
User.user_id.asc(),
|
|
),
|
|
"name_desc": (
|
|
sa_func.coalesce(User.first_name, User.username, User.email).desc(),
|
|
User.user_id.desc(),
|
|
),
|
|
"id_asc": User.user_id.asc(),
|
|
"id_desc": User.user_id.desc(),
|
|
}
|
|
|
|
if needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_asc":
|
|
stmt = stmt.order_by(ratio_expr.asc().nullslast(), User.user_id.asc())
|
|
elif needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_desc":
|
|
stmt = stmt.order_by(ratio_expr.desc().nullslast(), User.user_id.desc())
|
|
else:
|
|
order = sort_map.get(sort_key, sort_map["registered_desc"])
|
|
if isinstance(order, tuple):
|
|
stmt = stmt.order_by(*order)
|
|
else:
|
|
stmt = stmt.order_by(order)
|
|
|
|
stmt = stmt.offset(max(page, 0) * max(page_size, 1)).limit(max(page_size, 1))
|
|
|
|
users = (await session.execute(stmt)).scalars().all()
|
|
total = (await session.execute(count_stmt)).scalar_one()
|
|
return users, int(total)
|
|
|
|
|
|
def _user_panel_status_condition(panel_status: str):
|
|
status = (panel_status or "all").lower()
|
|
if status not in {"active", "expired", "limited"}:
|
|
return None
|
|
|
|
normalized_status = sa_func.lower(sa_func.coalesce(Subscription.status_from_panel, ""))
|
|
blank_status = or_(
|
|
Subscription.status_from_panel.is_(None), Subscription.status_from_panel == ""
|
|
)
|
|
if status == "active":
|
|
status_cond = or_(
|
|
normalized_status == "active", blank_status & Subscription.is_active.is_(True)
|
|
)
|
|
elif status == "expired":
|
|
status_cond = or_(
|
|
normalized_status == "expired", blank_status & Subscription.is_active.is_(False)
|
|
)
|
|
else:
|
|
status_cond = normalized_status == "limited"
|
|
|
|
return (
|
|
select(Subscription.subscription_id)
|
|
.where(Subscription.user_id == User.user_id, status_cond)
|
|
.exists()
|
|
)
|
|
|
|
|
|
def _user_search_condition(query: str):
|
|
raw = (query or "").strip().lstrip("@")
|
|
if not raw:
|
|
return None
|
|
|
|
like = f"%{raw}%"
|
|
conditions = [
|
|
User.username.ilike(like),
|
|
User.first_name.ilike(like),
|
|
User.last_name.ilike(like),
|
|
User.email.ilike(like),
|
|
]
|
|
if raw.isdigit():
|
|
numeric = int(raw)
|
|
conditions.extend([User.user_id == numeric, User.telegram_id == numeric])
|
|
|
|
return or_(*conditions)
|
|
|
|
|
|
async def admin_user_detail_route(request: web.Request) -> web.Response:
|
|
_require_admin_user_id(request)
|
|
target_id = int(request.match_info["user_id"])
|
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
|
settings: Settings = request.app["settings"]
|
|
|
|
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")
|
|
|
|
active_sub = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
|
latest_subs_stmt = (
|
|
select(Subscription)
|
|
.where(Subscription.user_id == target_id)
|
|
.order_by(Subscription.start_date.desc().nullslast())
|
|
.limit(20)
|
|
)
|
|
latest_subs = (await session.execute(latest_subs_stmt)).scalars().all()
|
|
total_paid = await payment_dal.get_user_total_paid(session, target_id)
|
|
recent_payments_stmt = (
|
|
select(Payment)
|
|
.where(Payment.user_id == target_id)
|
|
.order_by(Payment.created_at.desc())
|
|
.limit(20)
|
|
)
|
|
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])
|
|
|
|
# Referral links — both the bot deep-link and the webapp deep-link.
|
|
referral_code: Optional[str] = None
|
|
try:
|
|
referral_code = await user_dal.ensure_referral_code(session, user)
|
|
await session.commit()
|
|
except Exception as exc_ref: # pragma: no cover — defensive
|
|
logger.warning("Failed to ensure referral code for user %s: %s", target_id, exc_ref)
|
|
await session.rollback()
|
|
|
|
referral_service: Optional[ReferralService] = request.app.get("referral_service")
|
|
bot_username = request.app.get("bot_username") or ""
|
|
referral_bot_link: Optional[str] = None
|
|
if referral_service and bot_username and referral_code:
|
|
try:
|
|
async with async_session_factory() as session:
|
|
referral_bot_link = await referral_service.generate_referral_link(
|
|
session, bot_username, target_id
|
|
)
|
|
except Exception as exc_link: # pragma: no cover
|
|
logger.warning("Failed to build bot referral link for %s: %s", target_id, exc_link)
|
|
referral_webapp_link = _build_admin_webapp_referral_link(
|
|
getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None),
|
|
referral_code,
|
|
)
|
|
|
|
# Subscription page URL — the raw panel `subscriptionUrl` that the user
|
|
# imports into their VPN client. May be missing if the user has never
|
|
# been provisioned on the panel.
|
|
subscription_url: Optional[str] = None
|
|
panel_uuid = getattr(user, "panel_user_uuid", None)
|
|
if panel_uuid:
|
|
subscription_service = request.app.get("subscription_service")
|
|
panel_service = getattr(subscription_service, "panel_service", None)
|
|
if panel_service is not None:
|
|
try:
|
|
panel_data = await panel_service.get_user_by_uuid(panel_uuid)
|
|
if panel_data:
|
|
subscription_url = panel_data.get("subscriptionUrl") or None
|
|
except Exception as exc_panel: # pragma: no cover
|
|
logger.warning(
|
|
"Failed to fetch subscriptionUrl for user %s (uuid=%s): %s",
|
|
target_id,
|
|
panel_uuid,
|
|
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
|
|
)
|
|
|
|
return _ok(
|
|
{
|
|
"user": serialized_user,
|
|
"active_subscription": _serialize_subscription(active_sub) if active_sub else None,
|
|
"subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])],
|
|
"total_paid": float(total_paid),
|
|
"recent_payments": [_serialize_payment(p) for p in recent_payments],
|
|
"log_count": int(log_count or 0),
|
|
"subscription_url": subscription_url,
|
|
"referral": {
|
|
"code": referral_code,
|
|
"bot_link": referral_bot_link,
|
|
"webapp_link": referral_webapp_link,
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
async def admin_user_ban_route(request: web.Request) -> web.Response:
|
|
_require_admin_user_id(request)
|
|
target_id = int(request.match_info["user_id"])
|
|
payload = await _read_json(request)
|
|
desired = bool(payload.get("banned"))
|
|
|
|
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.is_banned = bool(desired)
|
|
await session.commit()
|
|
await session.refresh(user)
|
|
return _ok({"user": _serialize_user(user)})
|
|
|
|
|
|
async def admin_user_message_route(request: web.Request) -> web.Response:
|
|
actor_id = _require_admin_user_id(request)
|
|
target_id = int(request.match_info["user_id"])
|
|
payload = await _read_json(request)
|
|
text = str(payload.get("text") or "").strip()
|
|
if not text:
|
|
return _error(400, "empty_text")
|
|
|
|
queue_manager = get_queue_manager()
|
|
if not queue_manager:
|
|
return _error(503, "queue_unavailable")
|
|
|
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
|
async with async_session_factory() as session:
|
|
target_user = await user_dal.get_user_by_id(session, target_id)
|
|
if not target_user or not target_user.telegram_id:
|
|
return _error(404, "no_telegram_account")
|
|
|
|
try:
|
|
await send_message_via_queue(
|
|
queue_manager,
|
|
int(target_user.telegram_id),
|
|
MessageContent(content_type="text", text=text),
|
|
parse_mode="HTML",
|
|
disable_web_page_preview=True,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("Admin direct message failed: %s", exc)
|
|
return _error(502, "send_failed", str(exc))
|
|
|
|
await message_log_dal.create_message_log(
|
|
session,
|
|
{
|
|
"user_id": actor_id,
|
|
"event_type": "admin_direct_message_webapp",
|
|
"content": text[:4000],
|
|
"is_admin_event": True,
|
|
"target_user_id": target_id,
|
|
},
|
|
)
|
|
|
|
return _ok({})
|
|
|
|
|
|
async def admin_user_message_preview_route(request: web.Request) -> web.Response:
|
|
actor_id = _require_admin_user_id(request)
|
|
admin_telegram_id = request.get("admin_telegram_id")
|
|
target_id = int(request.match_info["user_id"])
|
|
payload = await _read_json(request)
|
|
text = str(payload.get("text") or "").strip()
|
|
if not text:
|
|
return _error(400, "empty_text")
|
|
if not admin_telegram_id:
|
|
return _error(403, "admin_telegram_unavailable")
|
|
|
|
queue_manager = get_queue_manager()
|
|
if not queue_manager:
|
|
return _error(503, "queue_unavailable")
|
|
|
|
try:
|
|
await send_message_via_queue(
|
|
queue_manager,
|
|
int(admin_telegram_id),
|
|
MessageContent(content_type="text", text=text),
|
|
parse_mode="HTML",
|
|
disable_web_page_preview=True,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("Admin direct message preview failed: %s", exc)
|
|
return _error(502, "preview_failed", str(exc))
|
|
|
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
|
async with async_session_factory() as session:
|
|
await message_log_dal.create_message_log(
|
|
session,
|
|
{
|
|
"user_id": actor_id,
|
|
"event_type": "admin_direct_message_preview_webapp",
|
|
"content": text[:4000],
|
|
"is_admin_event": True,
|
|
"target_user_id": target_id,
|
|
},
|
|
)
|
|
|
|
return _ok({})
|
|
|
|
|
|
async def admin_user_delete_route(request: web.Request) -> web.Response:
|
|
actor_id = _require_admin_user_id(request)
|
|
target_id = int(request.match_info["user_id"])
|
|
|
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
|
async with async_session_factory() as session:
|
|
ok = await user_dal.delete_user_and_relations(session, target_id)
|
|
if not ok:
|
|
await session.rollback()
|
|
return _error(404, "not_found")
|
|
await message_log_dal.create_message_log(
|
|
session,
|
|
{
|
|
"user_id": actor_id,
|
|
"event_type": "admin_delete_user_webapp",
|
|
"content": f"Deleted user_id={target_id}",
|
|
"is_admin_event": True,
|
|
},
|
|
)
|
|
await session.commit()
|
|
return _ok({})
|
|
|
|
|
|
async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
|
|
actor_id = _require_admin_user_id(request)
|
|
target_id = int(request.match_info["user_id"])
|
|
panel_service = request.app.get("panel_service")
|
|
subscription_service = request.app.get("subscription_service")
|
|
if panel_service is None or subscription_service is None:
|
|
return _error(503, "service_unavailable")
|
|
|
|
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")
|
|
|
|
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
|
if active:
|
|
await session.delete(active)
|
|
|
|
await message_log_dal.create_message_log(
|
|
session,
|
|
{
|
|
"user_id": actor_id,
|
|
"event_type": "admin_reset_trial_webapp",
|
|
"content": f"Reset trial for user_id={target_id}",
|
|
"is_admin_event": True,
|
|
"target_user_id": target_id,
|
|
},
|
|
)
|
|
await session.commit()
|
|
return _ok({})
|
|
|
|
|
|
async def admin_user_premium_override_route(request: web.Request) -> web.Response:
|
|
"""Premium-squad traffic overrides only (unlimited toggle + bonus GB)."""
|
|
actor_id = _require_admin_user_id(request)
|
|
target_id = int(request.match_info["user_id"])
|
|
payload = await _read_json(request)
|
|
subscription_service = request.app.get("subscription_service")
|
|
|
|
unlimited = bool(payload.get("unlimited"))
|
|
bonus_bytes_raw = payload.get("bonus_bytes")
|
|
bonus_gb_raw = payload.get("bonus_gb")
|
|
if bonus_bytes_raw is None and bonus_gb_raw is None:
|
|
bonus_bytes = 0
|
|
elif bonus_bytes_raw is not None:
|
|
try:
|
|
bonus_bytes = int(bonus_bytes_raw)
|
|
except (TypeError, ValueError):
|
|
return _error(400, "invalid_bonus", "bonus_bytes must be an integer")
|
|
else:
|
|
try:
|
|
bonus_bytes = int(round(float(bonus_gb_raw) * (1024**3)))
|
|
except (TypeError, ValueError):
|
|
return _error(400, "invalid_bonus", "bonus_gb must be a number")
|
|
|
|
if bonus_bytes < 0:
|
|
return _error(400, "invalid_bonus", "bonus must be non-negative")
|
|
|
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
|
async with async_session_factory() as session:
|
|
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
|
if not active:
|
|
return _error(404, "no_active_subscription")
|
|
|
|
active.premium_unlimited_override = bool(unlimited)
|
|
active.premium_bonus_bytes = int(bonus_bytes)
|
|
if active.premium_unlimited_override:
|
|
active.premium_is_limited = False
|
|
|
|
await message_log_dal.create_message_log(
|
|
session,
|
|
{
|
|
"user_id": actor_id,
|
|
"event_type": "admin_premium_override_webapp",
|
|
"content": (f"unlimited={bool(unlimited)} bonus_bytes={int(bonus_bytes)}"),
|
|
"is_admin_event": True,
|
|
"target_user_id": target_id,
|
|
},
|
|
)
|
|
await session.commit()
|
|
await session.refresh(active)
|
|
|
|
if subscription_service is not None:
|
|
await subscription_service.sync_premium_squad_access_to_panel(session, target_id)
|
|
await session.commit()
|
|
await session.refresh(active)
|
|
|
|
return _ok({"subscription": _serialize_subscription(active)})
|
|
|
|
|
|
async def admin_user_regular_traffic_override_route(request: web.Request) -> web.Response:
|
|
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
|
|
actor_id = _require_admin_user_id(request)
|
|
target_id = int(request.match_info["user_id"])
|
|
payload = await _read_json(request)
|
|
|
|
unlimited = bool(payload.get("unlimited"))
|
|
regular_bonus_bytes_raw = payload.get("regular_bonus_bytes")
|
|
regular_bonus_gb_raw = payload.get("regular_bonus_gb")
|
|
if regular_bonus_bytes_raw is None and regular_bonus_gb_raw is None:
|
|
regular_bonus_bytes = 0
|
|
elif regular_bonus_bytes_raw is not None:
|
|
try:
|
|
regular_bonus_bytes = int(regular_bonus_bytes_raw)
|
|
except (TypeError, ValueError):
|
|
return _error(400, "invalid_regular_bonus", "regular_bonus_bytes must be an integer")
|
|
else:
|
|
try:
|
|
regular_bonus_bytes = int(round(float(regular_bonus_gb_raw) * (1024**3)))
|
|
except (TypeError, ValueError):
|
|
return _error(400, "invalid_regular_bonus", "regular_bonus_gb must be a number")
|
|
|
|
if regular_bonus_bytes < 0:
|
|
return _error(400, "invalid_regular_bonus", "regular bonus must be non-negative")
|
|
|
|
subscription_service = request.app.get("subscription_service")
|
|
|
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
|
async with async_session_factory() as session:
|
|
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
|
if not active:
|
|
return _error(404, "no_active_subscription")
|
|
|
|
active.regular_unlimited_override = bool(unlimited)
|
|
active.regular_bonus_bytes = int(regular_bonus_bytes)
|
|
|
|
if subscription_service is not None:
|
|
await subscription_service.sync_main_traffic_limit_to_panel(session, target_id)
|
|
|
|
await message_log_dal.create_message_log(
|
|
session,
|
|
{
|
|
"user_id": actor_id,
|
|
"event_type": "admin_regular_traffic_override_webapp",
|
|
"content": (
|
|
f"unlimited={bool(unlimited)} regular_bonus_bytes={int(regular_bonus_bytes)}"
|
|
),
|
|
"is_admin_event": True,
|
|
"target_user_id": target_id,
|
|
},
|
|
)
|
|
await session.commit()
|
|
await session.refresh(active)
|
|
|
|
return _ok({"subscription": _serialize_subscription(active)})
|
|
|
|
|
|
async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
|
|
"""Credit regular or premium traffic to a user without a payment.
|
|
|
|
Body: ``{"kind": "regular" | "premium", "gb": float}`` (alternatively
|
|
``"bytes": int``). Mirrors the same effect as a user-purchased top-up:
|
|
the chosen balance grows, panel limit/squads are refreshed, and an entry
|
|
is added to ``traffic_topups`` with ``kind="admin_topup"`` or
|
|
``kind="admin_premium_topup"`` and ``payment_id=NULL``.
|
|
"""
|
|
actor_id = _require_admin_user_id(request)
|
|
target_id = int(request.match_info["user_id"])
|
|
payload = await _read_json(request)
|
|
|
|
kind = str(payload.get("kind") or "regular").strip().lower()
|
|
if kind not in {"regular", "premium"}:
|
|
return _error(400, "invalid_kind", "kind must be 'regular' or 'premium'")
|
|
|
|
bytes_raw = payload.get("bytes")
|
|
gb_raw = payload.get("gb")
|
|
if bytes_raw is None and gb_raw is None:
|
|
return _error(400, "missing_amount", "either 'gb' or 'bytes' is required")
|
|
try:
|
|
if bytes_raw is not None:
|
|
grant_bytes = int(bytes_raw)
|
|
gb_value = grant_bytes / (1024**3)
|
|
else:
|
|
gb_value = float(gb_raw)
|
|
grant_bytes = int(round(gb_value * (1024**3)))
|
|
except (TypeError, ValueError):
|
|
return _error(400, "invalid_amount", "amount must be a positive number")
|
|
if gb_value <= 0 or grant_bytes <= 0:
|
|
return _error(400, "invalid_amount", "amount must be positive")
|
|
|
|
subscription_service = request.app.get("subscription_service")
|
|
if subscription_service is None:
|
|
return _error(503, "subscription_service_unavailable")
|
|
|
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
|
async with async_session_factory() as session:
|
|
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
|
if not active:
|
|
return _error(404, "no_active_subscription")
|
|
|
|
if kind == "regular":
|
|
result = await subscription_service.admin_grant_topup(session, target_id, gb_value)
|
|
else:
|
|
result = await subscription_service.admin_grant_premium_topup(
|
|
session, target_id, gb_value
|
|
)
|
|
if not result:
|
|
await session.rollback()
|
|
return _error(
|
|
422,
|
|
"grant_failed",
|
|
"Unable to credit traffic (missing tariff/squads or panel error)",
|
|
)
|
|
|
|
await message_log_dal.create_message_log(
|
|
session,
|
|
{
|
|
"user_id": actor_id,
|
|
"event_type": "admin_traffic_grant_webapp",
|
|
"content": f"kind={kind} bytes={grant_bytes}",
|
|
"is_admin_event": True,
|
|
"target_user_id": target_id,
|
|
},
|
|
)
|
|
await session.commit()
|
|
|
|
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
|
|
|
return _ok(
|
|
{
|
|
"subscription": _serialize_subscription(refreshed) if refreshed else None,
|
|
"grant": {
|
|
"kind": kind,
|
|
"granted_bytes": grant_bytes,
|
|
"granted_gb": gb_value,
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
async def admin_user_extend_route(request: web.Request) -> web.Response:
|
|
actor_id = _require_admin_user_id(request)
|
|
target_id = int(request.match_info["user_id"])
|
|
payload = await _read_json(request)
|
|
try:
|
|
days = int(payload.get("days") or 0)
|
|
except (TypeError, ValueError):
|
|
return _error(400, "invalid_days")
|
|
if days <= 0:
|
|
return _error(400, "invalid_days")
|
|
|
|
subscription_service = request.app.get("subscription_service")
|
|
if subscription_service is None:
|
|
return _error(503, "subscription_service_unavailable")
|
|
|
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
|
async with async_session_factory() as session:
|
|
new_end = await subscription_service.extend_active_subscription_days(
|
|
session,
|
|
target_id,
|
|
days,
|
|
"admin_extend_subscription_webapp",
|
|
)
|
|
if not new_end:
|
|
await session.rollback()
|
|
return _error(500, "extend_failed")
|
|
|
|
await message_log_dal.create_message_log(
|
|
session,
|
|
{
|
|
"user_id": actor_id,
|
|
"event_type": "admin_extend_subscription_webapp",
|
|
"content": f"+{days}d -> {new_end.isoformat()}",
|
|
"is_admin_event": True,
|
|
"target_user_id": target_id,
|
|
},
|
|
)
|
|
await session.commit()
|
|
|
|
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
|
|
|
return _ok(
|
|
{
|
|
"subscription": _serialize_subscription(refreshed) if refreshed else None,
|
|
}
|
|
)
|