"""HTTP API powering the admin section of the subscription Mini App. All routes require an authenticated webapp session (cookie or Bearer token) AND the resolved Telegram user id must appear in ``settings.ADMIN_IDS``. Authorization is enforced via the ``_require_admin_user_id`` helper, never trusted from the client. """ from __future__ import annotations import csv import io import json import logging from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from aiogram import Bot from aiohttp import web from pydantic import ValidationError from sqlalchemy import and_, case, cast, Float, func as sa_func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import sessionmaker from bot.app.web.admin_settings_manifest import ( SETTINGS_MANIFEST, coerce_value, get_field_by_key, manifest_payload, ) from bot.services.settings_override_service import ( current_value, update_overrides, ) from bot.services.referral_service import ReferralService from bot.utils import MessageContent, send_message_via_queue from bot.utils.message_queue import get_queue_manager from urllib.parse import parse_qsl, urlsplit, urlunsplit from config.settings import Settings from config.tariffs_config import TariffsConfig from db.dal import ( ad_dal, app_settings_dal, message_log_dal, panel_sync_dal, payment_dal, promo_code_dal, subscription_dal, user_dal, ) from db.models import ( AdCampaign, MessageLog, Payment, PromoCode, Subscription, User, UserTelegramAvatar, ) logger = logging.getLogger(__name__) # ─── Auth ────────────────────────────────────────────────────────── def _require_admin_user_id(request: web.Request) -> int: """Return the authenticated user id, or raise 401/403 for non-admins.""" from bot.app.web.subscription_webapp import _extract_authenticated_user_id settings: Settings = request.app["settings"] user_id = _extract_authenticated_user_id(request) if not user_id: raise web.HTTPUnauthorized( text=json.dumps({"ok": False, "error": "unauthorized"}), content_type="application/json", ) admin_ids = settings.ADMIN_IDS or [] db_user_telegram_id = request.get("admin_telegram_id") if db_user_telegram_id is None: raise web.HTTPForbidden( text=json.dumps({"ok": False, "error": "forbidden"}), content_type="application/json", ) if int(db_user_telegram_id) not in {int(x) for x in admin_ids}: raise web.HTTPForbidden( text=json.dumps({"ok": False, "error": "forbidden"}), content_type="application/json", ) return int(user_id) @web.middleware async def admin_auth_middleware(request: web.Request, handler): """Resolve the Telegram id of the current user and stash it on the request. Doing this once per request lets every admin route call ``_require_admin_user_id`` without re-querying the DB. """ if not request.path.startswith("/api/admin"): return await handler(request) from bot.app.web.subscription_webapp import _extract_authenticated_user_id user_id = _extract_authenticated_user_id(request) if user_id: async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: db_user = await user_dal.get_user_by_id(session, user_id) if db_user and db_user.telegram_id: request["admin_telegram_id"] = int(db_user.telegram_id) elif db_user: # No telegram_id yet (email-only user) — can't be an admin request["admin_telegram_id"] = None return await handler(request) # ─── Helpers ─────────────────────────────────────────────────────── def _ok(payload: Dict[str, Any], **extra) -> web.Response: body = {"ok": True, **payload, **extra} return web.json_response(body) def _error(status: int, code: str, message: str = "") -> web.Response: return web.json_response( {"ok": False, "error": code, "message": message or code}, status=status, ) async def _read_json(request: web.Request) -> Dict[str, Any]: try: data = await request.json() return data if isinstance(data, dict) else {} except Exception: return {} def _serialize_user(user: User) -> Dict[str, Any]: return { "user_id": int(user.user_id), "telegram_id": int(user.telegram_id) if user.telegram_id else None, "telegram_photo_url": user.telegram_photo_url, "username": user.username, "first_name": user.first_name, "last_name": user.last_name, "email": user.email, "language_code": user.language_code, "is_banned": bool(user.is_banned), "registration_date": user.registration_date.isoformat() if user.registration_date else None, "panel_user_uuid": user.panel_user_uuid, "referral_code": user.referral_code, "referred_by_id": int(user.referred_by_id) if user.referred_by_id else None, } def _premium_limit_bytes_from_subscription(sub: Subscription) -> int: premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0) return ( int(sub.premium_baseline_bytes or 0) + int(sub.premium_topup_balance_bytes or 0) + int(getattr(sub, "premium_topup_used_bytes", 0) or 0) + premium_bonus_bytes ) def _premium_traffic_list_payload(sub: Optional[Subscription]) -> Dict[str, Any]: """Premium traffic column when subscription has a finite premium quota (bytes > 0). Note: ``Subscription.premium_is_limited`` in the DB means *quota exhausted* for panel routing, not 'tariff includes premium traffic' — do not use it here. """ if sub is None: return {"state": "none"} if bool(getattr(sub, "premium_unlimited_override", False)): return { "state": "unlimited", "unlimited": True, "used_bytes": int(sub.premium_used_bytes or 0), "limit_bytes": None, "percent": None, } limit_bytes = _premium_limit_bytes_from_subscription(sub) if limit_bytes <= 0: return {"state": "none"} used_bytes = int(sub.premium_used_bytes or 0) ratio = float(used_bytes) / float(limit_bytes) if limit_bytes else 0.0 pct = int(max(0, min(100, round(ratio * 100)))) if ratio >= 1.0: state = "critical" elif ratio >= 0.85: state = "warn" else: state = "good" return { "state": state, "unlimited": False, "used_bytes": used_bytes, "limit_bytes": limit_bytes, "percent": pct, } def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0) regular_bonus_bytes = int(getattr(sub, "regular_bonus_bytes", 0) or 0) regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False)) premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False)) premium_limit_bytes = _premium_limit_bytes_from_subscription(sub) return { "subscription_id": int(sub.subscription_id), "panel_user_uuid": sub.panel_user_uuid, "panel_subscription_uuid": sub.panel_subscription_uuid, "start_date": sub.start_date.isoformat() if sub.start_date else None, "end_date": sub.end_date.isoformat() if sub.end_date else None, "duration_months": sub.duration_months, "is_active": bool(sub.is_active), "status_from_panel": sub.status_from_panel, "traffic_limit_bytes": sub.traffic_limit_bytes, "traffic_used_bytes": sub.traffic_used_bytes, "tier_baseline_bytes": sub.tier_baseline_bytes, "topup_balance_bytes": sub.topup_balance_bytes, "premium_used_bytes": sub.premium_used_bytes, "premium_limit_bytes": premium_limit_bytes, "premium_baseline_bytes": sub.premium_baseline_bytes, "premium_topup_balance_bytes": sub.premium_topup_balance_bytes, "premium_topup_used_bytes": getattr(sub, "premium_topup_used_bytes", 0), "premium_bonus_bytes": premium_bonus_bytes, "regular_bonus_bytes": regular_bonus_bytes, "regular_unlimited_override": regular_unlimited_override, "premium_unlimited_override": premium_unlimited_override, "premium_is_limited": bool(sub.premium_is_limited), "tariff_key": sub.tariff_key, "auto_renew_enabled": bool(sub.auto_renew_enabled), "provider": sub.provider, "is_throttled": bool(sub.is_throttled), } def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Optional[float]]: """For traffic purchases: ``(regular_gb, premium_gb)``. Other payments → (None, None).""" if payment.purchased_gb is None: return None, None try: gb = float(payment.purchased_gb) except (TypeError, ValueError): return None, None sm = (payment.sale_mode or "").strip() if not sm: return None, None base = sm.split("@", 1)[0].split("|", 1)[0].lower() if base == "premium_topup": return None, gb if base in {"traffic", "traffic_package", "topup"}: return gb, None return None, None def _serialize_payment(payment: Payment) -> Dict[str, Any]: # Avoid lazy-loading `payment.user` outside an active SQLAlchemy session. # Some admin routes serialize payments after the session scope is closed. user_label = str(payment.user_id) telegram_id = None loaded_user = payment.__dict__.get("user") if loaded_user is not None: user_label = loaded_user.username or loaded_user.first_name or str(payment.user_id) tid = getattr(loaded_user, "telegram_id", None) if tid is not None: try: telegram_id = int(tid) except (TypeError, ValueError): telegram_id = None reg_gb, prem_gb = _payment_traffic_gb_split(payment) return { "payment_id": int(payment.payment_id), "user_id": int(payment.user_id), "user_label": user_label, "telegram_id": telegram_id, "traffic_regular_gb": reg_gb, "traffic_premium_gb": prem_gb, "provider": payment.provider, "provider_payment_id": payment.provider_payment_id, "amount": float(payment.amount), "currency": payment.currency, "status": payment.status, "description": payment.description, "subscription_duration_months": payment.subscription_duration_months, "sale_mode": payment.sale_mode, "tariff_key": payment.tariff_key, "purchased_gb": payment.purchased_gb, "purchased_hwid_devices": payment.purchased_hwid_devices, "created_at": payment.created_at.isoformat() if payment.created_at else None, } def _serialize_promo(promo: PromoCode) -> Dict[str, Any]: return { "id": int(promo.promo_code_id), "code": promo.code, "bonus_days": int(promo.bonus_days), "max_activations": int(promo.max_activations), "current_activations": int(promo.current_activations or 0), "is_active": bool(promo.is_active), "valid_until": promo.valid_until.isoformat() if promo.valid_until else None, "created_at": promo.created_at.isoformat() if promo.created_at else None, "created_by_admin_id": int(promo.created_by_admin_id) if promo.created_by_admin_id else None, } def _serialize_ad(campaign: AdCampaign, totals: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: return { "id": int(campaign.ad_campaign_id), "source": campaign.source, "start_param": campaign.start_param, "cost": float(campaign.cost or 0), "is_active": bool(campaign.is_active), "created_at": campaign.created_at.isoformat() if campaign.created_at else None, "stats": totals or {}, } def _serialize_log(entry: MessageLog) -> Dict[str, Any]: return { "log_id": int(entry.log_id), "user_id": int(entry.user_id) if entry.user_id else None, "telegram_username": entry.telegram_username, "telegram_first_name": entry.telegram_first_name, "event_type": entry.event_type, "content": entry.content, "is_admin_event": bool(entry.is_admin_event), "target_user_id": int(entry.target_user_id) if entry.target_user_id else None, "timestamp": entry.timestamp.isoformat() if entry.timestamp else None, } def _tariffs_config_path(settings: Settings) -> Path: return Path(settings.TARIFFS_CONFIG_PATH).expanduser() def _tariffs_config_payload(config: TariffsConfig) -> Dict[str, Any]: return config.model_dump(mode="json", exclude_none=True) def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None: data = _tariffs_config_payload(config) path.parent.mkdir(parents=True, exist_ok=True) tmp_path = path.with_suffix(f"{path.suffix}.tmp") payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n" try: tmp_path.write_text(payload, encoding="utf-8") tmp_path.replace(path) except PermissionError: # A docker-compose single-file bind mount can make /app/config # unwritable while the mounted tariffs.json itself is writable. # Fall back to updating the existing file in-place. if tmp_path.exists(): try: tmp_path.unlink() except OSError: pass path.write_text(payload, encoding="utf-8") def _panel_node_uuid_key(node: Dict[str, Any]) -> str: uid = node.get("nodeUuid") or node.get("node_uuid") or node.get("uuid") or node.get("id") return str(uid).strip().lower() if uid else "" def _panel_node_users_online(node: Dict[str, Any]) -> Optional[int]: uo = node.get("usersOnline") if uo is None: uo = node.get("users_online") if uo is None: uo = node.get("onlineUsers") or node.get("online_users") if uo is None: mg = node.get("metricGroups") if isinstance(mg, dict): uo = mg.get("onlineUsers") or mg.get("online_users") if uo is None: return None try: return int(uo) except (TypeError, ValueError): return None def _panel_nodes_online_by_uuid(nodes_payload: Any) -> Dict[str, int]: """Build node_uuid(lower) -> usersOnline from GET /system/stats/nodes payload.""" out: Dict[str, int] = {} raw_list: Optional[List[Any]] = None if isinstance(nodes_payload, list): raw_list = nodes_payload elif isinstance(nodes_payload, dict): raw_list = nodes_payload.get("nodes") if raw_list is None: raw_list = nodes_payload.get("items") or nodes_payload.get("data") if not isinstance(raw_list, list): return out for n in raw_list: if not isinstance(n, dict): continue key = _panel_node_uuid_key(n) if not key: continue online = _panel_node_users_online(n) if online is not None: out[key] = online return out def _enrich_bandwidth_nodes_with_online( bw: Any, online_by_uuid: Dict[str, int], online_by_name: Optional[Dict[str, int]] = None, ) -> None: """Attach usersOnline to topNodes/series (UUID and optional node name).""" if not isinstance(bw, dict): return if not online_by_uuid and not online_by_name: return for key in ("topNodes", "series"): arr = bw.get(key) if not isinstance(arr, list): continue for item in arr: if not isinstance(item, dict): continue if item.get("usersOnline") is not None: continue uid = item.get("uuid") or item.get("nodeUuid") or item.get("node_uuid") if uid and online_by_uuid: hit = online_by_uuid.get(str(uid).strip().lower()) if hit is not None: item["usersOnline"] = hit continue if online_by_name: nm = item.get("name") if nm and isinstance(nm, str): hitn = online_by_name.get(nm.strip().lower()) if hitn is not None: item["usersOnline"] = hitn # ─── Routes ──────────────────────────────────────────────────────── async def admin_me_route(request: web.Request) -> web.Response: user_id = _require_admin_user_id(request) settings: Settings = request.app["settings"] return _ok({}, user_id=user_id, admin_ids=list(settings.ADMIN_IDS or [])) async def admin_stats_route(request: web.Request) -> web.Response: _require_admin_user_id(request) settings: Settings = request.app["settings"] async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: user_stats = await user_dal.get_enhanced_user_statistics(session) financial_stats = await payment_dal.get_financial_statistics(session) sync_status = await panel_sync_dal.get_panel_sync_status(session) recent_payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=10) payload = { "users": user_stats, "financial": financial_stats, "panel_sync": { "status": sync_status.status if sync_status else "never_run", "last_sync_time": sync_status.last_sync_time.isoformat() if sync_status and sync_status.last_sync_time else None, "details": sync_status.details if sync_status else None, "users_processed": sync_status.users_processed_from_panel if sync_status else 0, "subscriptions_synced": sync_status.subscriptions_synced if sync_status else 0, }, "recent_payments": [_serialize_payment(p) for p in recent_payments], } panel_service = request.app.get("panel_service") if panel_service is not None: try: system = await panel_service.get_system_stats() bandwidth = await panel_service.get_bandwidth_stats() panel_body: Dict[str, Any] = { "system": system or {}, "bandwidth": bandwidth or {}, } try: nodes = await panel_service.get_nodes_statistics() panel_body["nodes"] = nodes or {} except Exception as exc_nodes: # pragma: no cover - optional endpoint logger.debug("Panel nodes stats unavailable: %s", exc_nodes) panel_body["nodes"] = {} try: today = datetime.now(timezone.utc).date() start_d = today - timedelta(days=7) nodes_bw = await panel_service.get_nodes_bandwidth_usage( start=start_d.isoformat(), end=today.isoformat(), top_nodes_limit=64, ) panel_body["nodes_bandwidth"] = nodes_bw or {} except Exception as exc_nb: # pragma: no cover - optional endpoint logger.debug("Panel nodes bandwidth range unavailable: %s", exc_nb) panel_body["nodes_bandwidth"] = {} try: online_map = _panel_nodes_online_by_uuid(panel_body.get("nodes")) lookups = await panel_service.get_nodes_online_lookups() for k, v in lookups.get("byUuid", {}).items(): online_map[k] = v _enrich_bandwidth_nodes_with_online( panel_body.get("nodes_bandwidth"), online_map, lookups.get("byName") or {}, ) except Exception as exc_merge: # pragma: no cover logger.debug("Panel nodes online merge skipped: %s", exc_merge) payload["panel"] = panel_body except Exception as exc: logger.debug("Panel stats unavailable: %s", exc) payload["panel"] = {"error": "unavailable"} queue_manager = get_queue_manager() if queue_manager: try: payload["queue"] = queue_manager.get_queue_stats() except Exception: # pragma: no cover - defensive payload["queue"] = None payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" return _ok(payload) # ─── Users ───────────────────────────────────────────────────────── 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, }, } ) def _build_admin_webapp_referral_link( base_url: Optional[str], referral_code: Optional[str] ) -> Optional[str]: """Mirror of ``subscription_webapp._build_webapp_referral_link``. Kept local to avoid a cross-module import cycle (subscription_webapp imports admin_api). """ if not base_url or not referral_code: return None parts = urlsplit(base_url) query = dict(parse_qsl(parts.query, keep_blank_values=True)) query["ref"] = f"u{referral_code}" new_query = "&".join(f"{k}={v}" for k, v in query.items()) return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment)) 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) 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) 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, } ) # ─── Payments ────────────────────────────────────────────────────── async def admin_payments_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))) async with async_session_factory() as session: from sqlalchemy.orm import selectinload stmt = ( select(Payment) .options(selectinload(Payment.user)) .order_by(Payment.created_at.desc()) .offset(page * page_size) .limit(page_size) ) rows = (await session.execute(stmt)).scalars().all() total = await payment_dal.get_payments_count(session) return _ok( { "payments": [_serialize_payment(p) for p in rows], "page": page, "page_size": page_size, "total": int(total or 0), } ) async def admin_payments_export_route(request: web.Request) -> web.Response: _require_admin_user_id(request) async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: from sqlalchemy.orm import selectinload stmt = ( select(Payment) .options(selectinload(Payment.user)) .order_by(Payment.created_at.desc()) .limit(10000) ) rows = (await session.execute(stmt)).scalars().all() buffer = io.StringIO() writer = csv.writer(buffer) writer.writerow( [ "payment_id", "user_id", "user_label", "provider", "provider_payment_id", "amount", "currency", "status", "description", "duration_months", "sale_mode", "tariff_key", "created_at", ] ) for p in rows: label = "" if p.user: label = p.user.username or p.user.first_name or "" writer.writerow( [ p.payment_id, p.user_id, label, p.provider, p.provider_payment_id or "", p.amount, p.currency, p.status, p.description or "", p.subscription_duration_months or "", p.sale_mode or "", p.tariff_key or "", p.created_at.isoformat() if p.created_at else "", ] ) response = web.Response( body=buffer.getvalue().encode("utf-8-sig"), content_type="text/csv", charset="utf-8", ) response.headers["Content-Disposition"] = 'attachment; filename="payments.csv"' return response # ─── Promo codes ─────────────────────────────────────────────────── async def admin_promos_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))) async with async_session_factory() as session: promos = await promo_code_dal.get_all_promo_codes_with_details( session, limit=page_size, offset=page * page_size ) total = await promo_code_dal.get_promo_codes_count(session) return _ok( { "promos": [_serialize_promo(p) for p in promos], "page": page, "page_size": page_size, "total": int(total or 0), } ) async def admin_promo_create_route(request: web.Request) -> web.Response: actor_id = _require_admin_user_id(request) payload = await _read_json(request) code = str(payload.get("code") or "").strip().upper() bonus_days = int(payload.get("bonus_days") or 0) max_activations = int(payload.get("max_activations") or 0) valid_days = payload.get("valid_days") if not code or bonus_days <= 0 or max_activations <= 0: return _error(400, "invalid_payload") valid_until = None if valid_days: try: valid_until = datetime.now(timezone.utc) + timedelta(days=int(valid_days)) except (TypeError, ValueError): return _error(400, "invalid_valid_days") async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: existing = await promo_code_dal.get_promo_code_by_code(session, code) if existing: return _error(409, "duplicate_code") promo = await promo_code_dal.create_promo_code( session, { "code": code, "bonus_days": bonus_days, "max_activations": max_activations, "valid_until": valid_until, "created_by_admin_id": actor_id, "is_active": True, }, ) await session.commit() return _ok({"promo": _serialize_promo(promo)}) async def admin_promo_update_route(request: web.Request) -> web.Response: _require_admin_user_id(request) promo_id = int(request.match_info["promo_id"]) payload = await _read_json(request) update_data: Dict[str, Any] = {} if "is_active" in payload: update_data["is_active"] = bool(payload["is_active"]) if "bonus_days" in payload and payload["bonus_days"] is not None: update_data["bonus_days"] = int(payload["bonus_days"]) if "max_activations" in payload and payload["max_activations"] is not None: update_data["max_activations"] = int(payload["max_activations"]) if not update_data: return _error(400, "no_changes") async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: promo = await promo_code_dal.update_promo_code(session, promo_id, update_data) if not promo: return _error(404, "not_found") await session.commit() await session.refresh(promo) return _ok({"promo": _serialize_promo(promo)}) async def admin_promo_delete_route(request: web.Request) -> web.Response: _require_admin_user_id(request) promo_id = int(request.match_info["promo_id"]) async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: promo = await promo_code_dal.delete_promo_code(session, promo_id) if not promo: return _error(404, "not_found") await session.commit() return _ok({}) # ─── Logs ────────────────────────────────────────────────────────── async def admin_logs_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(200, max(1, int(request.query.get("page_size", 50) or 50))) user_filter = request.query.get("user_id") async with async_session_factory() as session: if user_filter: try: user_id = int(user_filter) except (TypeError, ValueError): return _error(400, "invalid_user_id") entries = await message_log_dal.get_user_message_logs( session, user_id, page_size, page * page_size ) total = await message_log_dal.count_user_message_logs(session, user_id) else: entries = await message_log_dal.get_all_message_logs( session, page_size, page * page_size ) total = await message_log_dal.count_all_message_logs(session) return _ok( { "logs": [_serialize_log(l) for l in entries], "page": page, "page_size": page_size, "total": int(total or 0), } ) # ─── Broadcast ───────────────────────────────────────────────────── async def admin_broadcast_route(request: web.Request) -> web.Response: actor_id = _require_admin_user_id(request) payload = await _read_json(request) text = str(payload.get("text") or "").strip() target = str(payload.get("target") or "all").strip().lower() if not text: return _error(400, "empty_text") if target not in {"all", "active", "inactive"}: target = "all" 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: if target == "active": user_ids = await user_dal.get_user_ids_with_active_subscription(session) elif target == "inactive": user_ids = await user_dal.get_user_ids_without_active_subscription(session) else: user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session) sent = 0 failed = 0 for uid in user_ids: try: await send_message_via_queue( queue_manager, int(uid), MessageContent(content_type="text", text=text), parse_mode="HTML", disable_web_page_preview=True, ) sent += 1 except Exception as exc: failed += 1 logger.debug("Broadcast queue failed for %s: %s", uid, exc) await message_log_dal.create_message_log( session, { "user_id": actor_id, "event_type": "admin_broadcast_webapp", "content": f"target={target} sent={sent} failed={failed} text={text[:120]}", "is_admin_event": True, }, ) return _ok({"queued": sent, "failed": failed, "target": target}) # ─── Sync ────────────────────────────────────────────────────────── async def admin_sync_route(request: web.Request) -> web.Response: _require_admin_user_id(request) panel_service = request.app.get("panel_service") if panel_service is None: return _error(503, "panel_unavailable") settings: Settings = request.app["settings"] async_session_factory: sessionmaker = request.app["async_session_factory"] i18n = request.app.get("i18n") from bot.handlers.admin.sync_admin import perform_sync async with async_session_factory() as session: result = await perform_sync( panel_service=panel_service, session=session, settings=settings, i18n_instance=i18n, ) return _ok({"result": result or {}}) # ─── Ad campaigns ────────────────────────────────────────────────── async def admin_ads_list_route(request: web.Request) -> web.Response: _require_admin_user_id(request) async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: campaigns = await ad_dal.list_campaigns(session) totals = await ad_dal.get_totals(session) results = [] for campaign in campaigns: try: stats = await ad_dal.get_campaign_stats(session, campaign.ad_campaign_id) except Exception: stats = {} results.append(_serialize_ad(campaign, stats)) return _ok({"campaigns": results, "totals": totals}) async def admin_ad_create_route(request: web.Request) -> web.Response: _require_admin_user_id(request) payload = await _read_json(request) source = str(payload.get("source") or "").strip() start_param = str(payload.get("start_param") or "").strip() cost = float(payload.get("cost") or 0.0) if not source or not start_param: return _error(400, "invalid_payload") async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: existing = await ad_dal.get_campaign_by_start_param(session, start_param) if existing: return _error(409, "duplicate_start_param") campaign = await ad_dal.create_campaign( session, source=source, start_param=start_param, cost=cost, ) await session.commit() await session.refresh(campaign) return _ok({"campaign": _serialize_ad(campaign)}) async def admin_ad_toggle_route(request: web.Request) -> web.Response: _require_admin_user_id(request) campaign_id = int(request.match_info["campaign_id"]) payload = await _read_json(request) is_active = bool(payload.get("is_active", True)) async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: ok = await ad_dal.toggle_campaign_active(session, campaign_id, is_active) if not ok: return _error(404, "not_found") await session.commit() return _ok({}) async def admin_ad_delete_route(request: web.Request) -> web.Response: _require_admin_user_id(request) campaign_id = int(request.match_info["campaign_id"]) async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: ok = await ad_dal.delete_campaign(session, campaign_id) if not ok: return _error(404, "not_found") await session.commit() return _ok({}) # ─── Settings (manifest + overrides) ─────────────────────────────── async def admin_settings_get_route(request: web.Request) -> web.Response: _require_admin_user_id(request) settings: Settings = request.app["settings"] async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: overrides = await app_settings_dal.get_overrides_with_meta(session) overrides_by_key = {entry["key"]: entry for entry in overrides} fields = manifest_payload() sections: Dict[str, Dict[str, Any]] = {} for field in fields: key = field["key"] section_id = field["section"] if section_id not in sections: sections[section_id] = { "id": section_id, "order": field["section_order"], "fields": [], } override = overrides_by_key.get(key) sections[section_id]["fields"].append( { **field, "value": current_value(settings, key), "overridden": bool(override), "updated_at": override.get("updated_at") if override else None, } ) ordered_sections = sorted(sections.values(), key=lambda s: s["order"]) return _ok({"sections": ordered_sections}) async def admin_settings_patch_route(request: web.Request) -> web.Response: actor_id = _require_admin_user_id(request) settings: Settings = request.app["settings"] async_session_factory: sessionmaker = request.app["async_session_factory"] payload = await _read_json(request) updates = payload.get("updates") or {} deletes = payload.get("deletes") or [] if not isinstance(updates, dict): return _error(400, "invalid_updates") if not isinstance(deletes, list): return _error(400, "invalid_deletes") result = await update_overrides( settings, async_session_factory, updates=updates, deletes=deletes, actor_id=actor_id, ) if not result.get("ok"): return web.json_response( {"ok": False, "error": "validation_failed", "errors": result.get("errors", {})}, status=400, ) # Bust the public webapp settings cache so users see new values immediately. cache = request.app.get("webapp_settings_cache") if isinstance(cache, dict): cache["ts"] = 0.0 cache["data"] = {} return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)}) # ─── Tariffs catalog ──────────────────────────────────────────────── async def admin_tariffs_get_route(request: web.Request) -> web.Response: _require_admin_user_id(request) settings: Settings = request.app["settings"] path = _tariffs_config_path(settings) try: config = settings.tariffs_config except Exception as exc: logger.warning("Invalid tariffs config requested from admin UI: %s", exc) return _error(400, "invalid_tariffs_config", str(exc)) if config is None: return _ok( { "exists": path.exists(), "path": str(path), "catalog": { "default_tariff": "", "topup_packages_default": {"rub": [], "stars": []}, "tariffs": [], }, } ) return _ok( { "exists": True, "path": str(path), "catalog": _tariffs_config_payload(config), } ) async def admin_tariffs_save_route(request: web.Request) -> web.Response: _require_admin_user_id(request) settings: Settings = request.app["settings"] payload = await _read_json(request) catalog = payload.get("catalog") if "catalog" in payload else payload if not isinstance(catalog, dict): return _error(400, "invalid_payload", "catalog must be an object") try: config = TariffsConfig.model_validate(catalog) except (ValidationError, ValueError) as exc: return _error(400, "invalid_tariffs_config", str(exc)) path = _tariffs_config_path(settings) try: _write_tariffs_config_file(path, config) except OSError as exc: logger.exception("Failed to write tariffs config to %s", path) return _error(500, "write_failed", str(exc)) cache = request.app.get("webapp_settings_cache") if isinstance(cache, dict): cache["ts"] = 0.0 cache["data"] = {} return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)}) async def admin_panel_internal_squads_route(request: web.Request) -> web.Response: _require_admin_user_id(request) panel_service = request.app.get("panel_service") if panel_service is None: return _error(503, "panel_unavailable", "Panel service unavailable") try: squads = await panel_service.get_internal_squads() except Exception as exc: logger.exception("Failed to load internal squads from panel") return _error(502, "panel_request_failed", str(exc)) if squads is None: return _error(502, "panel_request_failed", "Unable to load internal squads") items = [] for squad in squads: if not isinstance(squad, dict): continue uuid = squad.get("uuid") or squad.get("id") if not uuid: continue items.append( { "uuid": str(uuid), "name": squad.get("name") or squad.get("title") or str(uuid), "members_count": squad.get("membersCount") or squad.get("usersCount") or squad.get("members_count"), "active_inbounds_count": squad.get("activeInboundsCount") or squad.get("active_inbounds_count"), } ) return _ok({"squads": items}) # ─── Router setup ────────────────────────────────────────────────── def setup_admin_routes(app: web.Application) -> None: router = app.router router.add_get("/api/admin/me", admin_me_route) router.add_get("/api/admin/stats", admin_stats_route) 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+}/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) router.add_post("/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route) router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route) router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route) router.add_post( "/api/admin/users/{user_id:-?\\d+}/premium-override", admin_user_premium_override_route, ) router.add_post( "/api/admin/users/{user_id:-?\\d+}/regular-traffic-override", admin_user_regular_traffic_override_route, ) router.add_post( "/api/admin/users/{user_id:-?\\d+}/traffic-grant", admin_user_traffic_grant_route, ) router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route) router.add_get("/api/admin/payments", admin_payments_list_route) router.add_get("/api/admin/payments/export.csv", admin_payments_export_route) router.add_get("/api/admin/promos", admin_promos_list_route) router.add_post("/api/admin/promos", admin_promo_create_route) router.add_patch("/api/admin/promos/{promo_id:\\d+}", admin_promo_update_route) router.add_delete("/api/admin/promos/{promo_id:\\d+}", admin_promo_delete_route) router.add_get("/api/admin/logs", admin_logs_route) router.add_post("/api/admin/broadcast", admin_broadcast_route) router.add_post("/api/admin/sync", admin_sync_route) router.add_get("/api/admin/ads", admin_ads_list_route) router.add_post("/api/admin/ads", admin_ad_create_route) router.add_post("/api/admin/ads/{campaign_id:\\d+}/toggle", admin_ad_toggle_route) router.add_delete("/api/admin/ads/{campaign_id:\\d+}", admin_ad_delete_route) router.add_get("/api/admin/settings", admin_settings_get_route) router.add_patch("/api/admin/settings", admin_settings_patch_route) router.add_get("/api/admin/tariffs", admin_tariffs_get_route) router.add_put("/api/admin/tariffs", admin_tariffs_save_route) router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)