feat: show and filter premium squad status in admin panel users list
This commit is contained in:
+204
-10
@@ -19,7 +19,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from sqlalchemy import func as sa_func, or_, select
|
from sqlalchemy import and_, case, cast, Float, func as sa_func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
@@ -162,17 +162,60 @@ def _serialize_user(user: User) -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
|
def _premium_limit_bytes_from_subscription(sub: Subscription) -> int:
|
||||||
premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0)
|
premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0)
|
||||||
regular_bonus_bytes = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
|
return (
|
||||||
regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False))
|
|
||||||
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
|
|
||||||
premium_limit_bytes = (
|
|
||||||
int(sub.premium_baseline_bytes or 0)
|
int(sub.premium_baseline_bytes or 0)
|
||||||
+ int(sub.premium_topup_balance_bytes or 0)
|
+ int(sub.premium_topup_balance_bytes or 0)
|
||||||
+ int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
|
+ int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
|
||||||
+ premium_bonus_bytes
|
+ 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 {
|
return {
|
||||||
"subscription_id": int(sub.subscription_id),
|
"subscription_id": int(sub.subscription_id),
|
||||||
"panel_user_uuid": sub.panel_user_uuid,
|
"panel_user_uuid": sub.panel_user_uuid,
|
||||||
@@ -507,6 +550,7 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
|
|||||||
query = (request.query.get("q") or "").strip()
|
query = (request.query.get("q") or "").strip()
|
||||||
filter_value = (request.query.get("filter") or "all").lower()
|
filter_value = (request.query.get("filter") or "all").lower()
|
||||||
panel_status = (request.query.get("panel_status") 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()
|
sort_value = (request.query.get("sort") or "registered_desc").lower()
|
||||||
|
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
@@ -515,6 +559,7 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
|
|||||||
query=query,
|
query=query,
|
||||||
filter_value=filter_value,
|
filter_value=filter_value,
|
||||||
panel_status=panel_status,
|
panel_status=panel_status,
|
||||||
|
premium_traffic=premium_traffic,
|
||||||
sort_value=sort_value,
|
sort_value=sort_value,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
@@ -522,6 +567,7 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
|
|||||||
|
|
||||||
statuses = await _bulk_user_statuses(session, [u.user_id for u in users])
|
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])
|
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 = []
|
serialized = []
|
||||||
for user in users:
|
for user in users:
|
||||||
@@ -535,6 +581,7 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
|
|||||||
if user.user_id in cached_avatar_ids
|
if user.user_id in cached_avatar_ids
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id))
|
||||||
serialized.append(payload)
|
serialized.append(payload)
|
||||||
|
|
||||||
return _ok(
|
return _ok(
|
||||||
@@ -642,21 +689,115 @@ async def admin_user_avatar_route(request: web.Request) -> web.Response:
|
|||||||
return response
|
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(
|
async def _filter_and_sort_users(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
query: str = "",
|
query: str = "",
|
||||||
filter_value: str,
|
filter_value: str,
|
||||||
panel_status: str = "all",
|
panel_status: str = "all",
|
||||||
|
premium_traffic: str = "all",
|
||||||
sort_value: str,
|
sort_value: str,
|
||||||
page: int,
|
page: int,
|
||||||
page_size: int,
|
page_size: int,
|
||||||
) -> tuple[List[User], int]:
|
) -> tuple[List[User], int]:
|
||||||
"""Return paginated users with optional search, filter and sort applied."""
|
"""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)
|
stmt = select(User)
|
||||||
count_stmt = select(sa_func.count(User.user_id))
|
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)
|
search_cond = _user_search_condition(query)
|
||||||
if search_cond is not None:
|
if search_cond is not None:
|
||||||
stmt = stmt.where(search_cond)
|
stmt = stmt.where(search_cond)
|
||||||
@@ -689,6 +830,53 @@ async def _filter_and_sort_users(
|
|||||||
stmt = stmt.where(panel_cond)
|
stmt = stmt.where(panel_cond)
|
||||||
count_stmt = count_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 = {
|
sort_map = {
|
||||||
"registered_desc": User.registration_date.desc().nullslast(),
|
"registered_desc": User.registration_date.desc().nullslast(),
|
||||||
"registered_asc": User.registration_date.asc().nullslast(),
|
"registered_asc": User.registration_date.asc().nullslast(),
|
||||||
@@ -703,11 +891,17 @@ async def _filter_and_sort_users(
|
|||||||
"id_asc": User.user_id.asc(),
|
"id_asc": User.user_id.asc(),
|
||||||
"id_desc": User.user_id.desc(),
|
"id_desc": User.user_id.desc(),
|
||||||
}
|
}
|
||||||
order = sort_map.get(sort_value, sort_map["registered_desc"])
|
|
||||||
if isinstance(order, tuple):
|
if needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_asc":
|
||||||
stmt = stmt.order_by(*order)
|
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:
|
else:
|
||||||
stmt = stmt.order_by(order)
|
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))
|
stmt = stmt.offset(max(page, 0) * max(page_size, 1)).limit(max(page_size, 1))
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
<script>
|
<script>
|
||||||
import { Label } from "$components/ui/primitives.js";
|
import { Label } from "$components/ui/primitives.js";
|
||||||
import { AdminBadge, AdminButton, AdminEmptyState, AdminPagination, AdminSelect } from "$components/patterns/admin/index.js";
|
import {
|
||||||
|
AdminBadge,
|
||||||
|
AdminButton,
|
||||||
|
AdminEmptyState,
|
||||||
|
AdminPagination,
|
||||||
|
AdminSelect,
|
||||||
|
AdminTable,
|
||||||
|
AdminTableSkeleton,
|
||||||
|
} from "$components/patterns/admin/index.js";
|
||||||
import { getContext, onMount } from "svelte";
|
import { getContext, onMount } from "svelte";
|
||||||
|
import { trafficOfLabel } from "../../lib/admin/format.js";
|
||||||
|
|
||||||
export let at = (key) => key;
|
export let at = (key) => key;
|
||||||
export let fmtDateShort = (value) => value;
|
export let fmtDateShort = (value) => value;
|
||||||
@@ -20,6 +29,7 @@
|
|||||||
usersQuery,
|
usersQuery,
|
||||||
usersFilter,
|
usersFilter,
|
||||||
usersPanelStatus,
|
usersPanelStatus,
|
||||||
|
usersPremiumTraffic,
|
||||||
usersSort,
|
usersSort,
|
||||||
usersLoading,
|
usersLoading,
|
||||||
} = $usersStore);
|
} = $usersStore);
|
||||||
@@ -45,6 +55,8 @@
|
|||||||
{ value: "name_desc", label: at("sort_name_desc", {}, "Имя ↓") },
|
{ value: "name_desc", label: at("sort_name_desc", {}, "Имя ↓") },
|
||||||
{ value: "id_asc", label: at("sort_id_asc", {}, "ID ↑") },
|
{ value: "id_asc", label: at("sort_id_asc", {}, "ID ↑") },
|
||||||
{ value: "id_desc", label: at("sort_id_desc", {}, "ID ↓") },
|
{ value: "id_desc", label: at("sort_id_desc", {}, "ID ↓") },
|
||||||
|
{ value: "premium_ratio_asc", label: at("sort_premium_ratio_asc", {}, "Премиум % ↑") },
|
||||||
|
{ value: "premium_ratio_desc", label: at("sort_premium_ratio_desc", {}, "Премиум % ↓") },
|
||||||
];
|
];
|
||||||
|
|
||||||
const USERS_PANEL_STATUS_OPTIONS = [
|
const USERS_PANEL_STATUS_OPTIONS = [
|
||||||
@@ -54,6 +66,37 @@
|
|||||||
{ value: "limited", label: at("status_limited", {}, "limited") },
|
{ value: "limited", label: at("status_limited", {}, "limited") },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const USERS_PREMIUM_TRAFFIC_OPTIONS = [
|
||||||
|
{ value: "all", label: at("premium_traffic_filter_all", {}, "Все (премиум)") },
|
||||||
|
{ value: "none", label: at("premium_traffic_filter_none", {}, "Без лимита в тарифе") },
|
||||||
|
{ value: "unlimited", label: at("premium_traffic_filter_unlimited", {}, "Безлимит (оверрайд)") },
|
||||||
|
{ value: "good", label: at("premium_traffic_filter_good", {}, "Премиум: норма") },
|
||||||
|
{ value: "warn", label: at("premium_traffic_filter_warn", {}, "Премиум: мало") },
|
||||||
|
{ value: "critical", label: at("premium_traffic_filter_critical", {}, "Премиум: исчерпан") },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @param {Record<string, unknown> | null | undefined} pt */
|
||||||
|
function premiumTrafficBadgeVariant(pt) {
|
||||||
|
if (!pt || pt.state === "none") return "muted";
|
||||||
|
if (pt.state === "unlimited" || pt.state === "good") return "success";
|
||||||
|
if (pt.state === "warn") return "warning";
|
||||||
|
return "danger";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Record<string, unknown> | null | undefined} pt */
|
||||||
|
function premiumTrafficBadgeText(pt) {
|
||||||
|
if (!pt || pt.state === "none") return "";
|
||||||
|
if (pt.state === "unlimited") return trafficOfLabel(pt.used_bytes, 0);
|
||||||
|
return trafficOfLabel(pt.used_bytes, pt.limit_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: userTableHeaders = [
|
||||||
|
at("user", {}, "Пользователь"),
|
||||||
|
at("premium_traffic_filter_label", {}, "Премиум трафик"),
|
||||||
|
at("status", {}, "Статус"),
|
||||||
|
at("users_col_registration", {}, "Регистрация"),
|
||||||
|
];
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
usersStore.loadUsers();
|
usersStore.loadUsers();
|
||||||
});
|
});
|
||||||
@@ -95,6 +138,17 @@
|
|||||||
/>
|
/>
|
||||||
</Label.Root>
|
</Label.Root>
|
||||||
|
|
||||||
|
<Label.Root class="admin-toolbar-field">
|
||||||
|
<span class="admin-toolbar-field-label">{at("premium_traffic_filter_label", {}, "Премиум трафик")}</span>
|
||||||
|
<AdminSelect
|
||||||
|
value={usersPremiumTraffic}
|
||||||
|
items={USERS_PREMIUM_TRAFFIC_OPTIONS}
|
||||||
|
class="admin-toolbar-select"
|
||||||
|
ariaLabel={at("premium_traffic_filter_label", {}, "Премиум трафик")}
|
||||||
|
onValueChange={(value) => { usersStore.updateState({ usersPremiumTraffic: value, usersPage: 0 }); usersStore.loadUsers(); }}
|
||||||
|
/>
|
||||||
|
</Label.Root>
|
||||||
|
|
||||||
<Label.Root class="admin-toolbar-field">
|
<Label.Root class="admin-toolbar-field">
|
||||||
<span class="admin-toolbar-field-label">{at("sort", {}, "Сортировка")}</span>
|
<span class="admin-toolbar-field-label">{at("sort", {}, "Сортировка")}</span>
|
||||||
<AdminSelect
|
<AdminSelect
|
||||||
@@ -113,53 +167,77 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-table-wrap">
|
<div class="admin-table-wrap admin-users-table-wrap">
|
||||||
{#if usersLoading}
|
{#if usersLoading}
|
||||||
<ul class="admin-user-list admin-user-list-skeleton" aria-hidden="true">
|
<AdminTableSkeleton
|
||||||
{#each Array(USERS_PAGE_SIZE) as _, i (i)}
|
headers={userTableHeaders}
|
||||||
<li>
|
rows={USERS_PAGE_SIZE}
|
||||||
<div class="admin-user-row admin-user-row-skeleton">
|
widths={["minmax(220px, 42%)", "minmax(140px, 28%)", "108px", "112px"]}
|
||||||
<span class="admin-skeleton admin-skeleton-avatar"></span>
|
/>
|
||||||
<span class="admin-user-main">
|
|
||||||
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"></span>
|
|
||||||
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-soft"></span>
|
|
||||||
</span>
|
|
||||||
<span class="admin-user-side">
|
|
||||||
<span class="admin-skeleton admin-skeleton-badge"></span>
|
|
||||||
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{:else if !users.length}
|
{:else if !users.length}
|
||||||
<AdminEmptyState tone="card"><span class="admin-muted">{at("users_empty", {}, "Никого не найдено")}</span></AdminEmptyState>
|
<AdminEmptyState tone="card"><span class="admin-muted">{at("users_empty", {}, "Никого не найдено")}</span></AdminEmptyState>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="admin-user-list">
|
<AdminTable class="admin-users-table">
|
||||||
{#each users as user}
|
<thead>
|
||||||
{@const avatar = resolvedAvatarUrl(user)}
|
<tr>
|
||||||
{@const badge = panelStatusBadge(user)}
|
<th>{at("user", {}, "Пользователь")}</th>
|
||||||
<li>
|
<th>{at("premium_traffic_filter_label", {}, "Премиум трафик")}</th>
|
||||||
<button type="button" class="admin-user-row" on:click={() => usersStore.openUser(user)}>
|
<th>{at("status", {}, "Статус")}</th>
|
||||||
<span class="admin-avatar admin-avatar-sm">
|
<th>{at("users_col_registration", {}, "Регистрация")}</th>
|
||||||
{#if avatar}
|
</tr>
|
||||||
<img src={avatar} alt="" loading="lazy" referrerpolicy="no-referrer" />
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each users as user}
|
||||||
|
{@const avatar = resolvedAvatarUrl(user)}
|
||||||
|
{@const badge = panelStatusBadge(user)}
|
||||||
|
<tr
|
||||||
|
class="is-clickable"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
data-user-id={user.user_id}
|
||||||
|
on:click={() => usersStore.openUser(user)}
|
||||||
|
on:keydown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
usersStore.openUser(user);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td class="admin-users-cell-user" data-label={at("user", {}, "Пользователь")}>
|
||||||
|
<div class="admin-users-cell-user-inner">
|
||||||
|
<span class="admin-avatar admin-avatar-sm">
|
||||||
|
{#if avatar}
|
||||||
|
<img src={avatar} alt="" loading="lazy" referrerpolicy="no-referrer" />
|
||||||
|
{:else}
|
||||||
|
<span>{userInitials(user)}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<div class="admin-users-cell-user-text">
|
||||||
|
<span class="admin-users-cell-name">{userDisplayName(user)}</span>
|
||||||
|
<span class="admin-users-cell-secondary">{userSecondaryName(user)}</span>
|
||||||
|
<span class="admin-users-cell-id">#{user.user_id}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="admin-users-cell-premium" data-label={at("premium_traffic_filter_label", {}, "Премиум трафик")}>
|
||||||
|
{#if user.premium_traffic && user.premium_traffic.state !== "none"}
|
||||||
|
<AdminBadge variant={premiumTrafficBadgeVariant(user.premium_traffic)} class="admin-user-premium-badge">
|
||||||
|
{premiumTrafficBadgeText(user.premium_traffic)}
|
||||||
|
</AdminBadge>
|
||||||
{:else}
|
{:else}
|
||||||
<span>{userInitials(user)}</span>
|
<span class="admin-user-premium-placeholder">{at("premium_traffic_na", {}, "—")}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</td>
|
||||||
<span class="admin-user-main">
|
<td data-label={at("status", {}, "Статус")}>
|
||||||
<strong>{userDisplayName(user)}</strong>
|
|
||||||
<small>{userSecondaryName(user)}</small>
|
|
||||||
</span>
|
|
||||||
<span class="admin-user-side">
|
|
||||||
<AdminBadge variant={badge.variant}>{badge.label}</AdminBadge>
|
<AdminBadge variant={badge.variant}>{badge.label}</AdminBadge>
|
||||||
<span class="admin-user-tertiary">{fmtDateShort(user.registration_date)}</span>
|
</td>
|
||||||
</span>
|
<td class="admin-users-cell-date admin-cell-mono" data-label={at("users_col_registration", {}, "Регистрация")}>
|
||||||
</button>
|
{fmtDateShort(user.registration_date)}
|
||||||
</li>
|
</td>
|
||||||
{/each}
|
</tr>
|
||||||
</ul>
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</AdminTable>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -172,3 +250,72 @@
|
|||||||
onPrev={() => { usersStore.updateState({ usersPage: Math.max(0, usersPage - 1) }); usersStore.loadUsers(); }}
|
onPrev={() => { usersStore.updateState({ usersPage: Math.max(0, usersPage - 1) }); usersStore.loadUsers(); }}
|
||||||
onNext={() => { usersStore.updateState({ usersPage: usersPage + 1 }); usersStore.loadUsers(); }}
|
onNext={() => { usersStore.updateState({ usersPage: usersPage + 1 }); usersStore.loadUsers(); }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.admin-users-cell-user-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-cell-user-text {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-cell-name {
|
||||||
|
font-weight: 650;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.25;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-cell-secondary {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--admin-dim);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-cell-id {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--admin-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-cell-premium {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-cell-premium :global(.admin-user-premium-badge) {
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-user-premium-placeholder {
|
||||||
|
color: var(--admin-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-cell-date {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--admin-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-table-wrap :global(.admin-users-table tbody tr.is-clickable:focus-visible) {
|
||||||
|
outline: 2px solid var(--admin-ring);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function createUsersStore({ api, onToast, at }) {
|
|||||||
usersQuery: "",
|
usersQuery: "",
|
||||||
usersFilter: "all",
|
usersFilter: "all",
|
||||||
usersPanelStatus: "all",
|
usersPanelStatus: "all",
|
||||||
|
usersPremiumTraffic: "all",
|
||||||
usersSort: "registered_desc",
|
usersSort: "registered_desc",
|
||||||
usersLoading: false,
|
usersLoading: false,
|
||||||
|
|
||||||
@@ -59,6 +60,9 @@ export function createUsersStore({ api, onToast, at }) {
|
|||||||
if (s.usersQuery.trim()) params.set("q", s.usersQuery.trim());
|
if (s.usersQuery.trim()) params.set("q", s.usersQuery.trim());
|
||||||
if (s.usersFilter && s.usersFilter !== "all") params.set("filter", s.usersFilter);
|
if (s.usersFilter && s.usersFilter !== "all") params.set("filter", s.usersFilter);
|
||||||
if (s.usersPanelStatus && s.usersPanelStatus !== "all") params.set("panel_status", s.usersPanelStatus);
|
if (s.usersPanelStatus && s.usersPanelStatus !== "all") params.set("panel_status", s.usersPanelStatus);
|
||||||
|
if (s.usersPremiumTraffic && s.usersPremiumTraffic !== "all") {
|
||||||
|
params.set("premium_traffic", s.usersPremiumTraffic);
|
||||||
|
}
|
||||||
if (s.usersSort && s.usersSort !== "registered_desc") params.set("sort", s.usersSort);
|
if (s.usersSort && s.usersSort !== "registered_desc") params.set("sort", s.usersSort);
|
||||||
const data = await api(`/admin/users?${params.toString()}`);
|
const data = await api(`/admin/users?${params.toString()}`);
|
||||||
if (data?.ok) {
|
if (data?.ok) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export async function mockApi(path, options = {}, context = {}) {
|
|||||||
telegram_photo_url: "",
|
telegram_photo_url: "",
|
||||||
registration_date: "2026-04-24T10:20:00Z",
|
registration_date: "2026-04-24T10:20:00Z",
|
||||||
is_banned: false,
|
is_banned: false,
|
||||||
|
premium_traffic: { state: "good", unlimited: false, used_bytes: 4 * 1073741824, limit_bytes: 25 * 1073741824, percent: 16 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
user_id: 100200301,
|
user_id: 100200301,
|
||||||
@@ -34,6 +35,7 @@ export async function mockApi(path, options = {}, context = {}) {
|
|||||||
telegram_photo_url: "",
|
telegram_photo_url: "",
|
||||||
registration_date: "2026-04-26T08:15:00Z",
|
registration_date: "2026-04-26T08:15:00Z",
|
||||||
is_banned: false,
|
is_banned: false,
|
||||||
|
premium_traffic: { state: "warn", unlimited: false, used_bytes: 22 * 1073741824, limit_bytes: 25 * 1073741824, percent: 88 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
user_id: 100200302,
|
user_id: 100200302,
|
||||||
@@ -45,6 +47,7 @@ export async function mockApi(path, options = {}, context = {}) {
|
|||||||
telegram_photo_url: "",
|
telegram_photo_url: "",
|
||||||
registration_date: "2026-04-29T16:45:00Z",
|
registration_date: "2026-04-29T16:45:00Z",
|
||||||
is_banned: true,
|
is_banned: true,
|
||||||
|
premium_traffic: { state: "none" },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
if (path === "/admin/stats") {
|
if (path === "/admin/stats") {
|
||||||
|
|||||||
@@ -835,7 +835,7 @@
|
|||||||
|
|
||||||
.admin-toolbar-controls {
|
.admin-toolbar-controls {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(150px, 1fr)) minmax(96px, auto);
|
grid-template-columns: repeat(4, minmax(130px, 1fr)) minmax(96px, auto);
|
||||||
align-items: end;
|
align-items: end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
@@ -881,7 +881,7 @@
|
|||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.admin-toolbar-controls {
|
.admin-toolbar-controls {
|
||||||
grid-template-columns: repeat(2, minmax(150px, 1fr));
|
grid-template-columns: repeat(2, minmax(130px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-toolbar-summary {
|
.admin-toolbar-summary {
|
||||||
@@ -1000,7 +1000,7 @@
|
|||||||
|
|
||||||
.admin-user-row {
|
.admin-user-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
grid-template-columns: 36px minmax(0, 1fr) minmax(72px, max-content) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1023,6 +1023,28 @@
|
|||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-user-premium-col {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-user-premium-badge {
|
||||||
|
max-width: 148px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-user-premium-placeholder {
|
||||||
|
color: var(--admin-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-user-side {
|
.admin-user-side {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -885,6 +885,16 @@
|
|||||||
"admin_sort_name_desc": "Name ↓",
|
"admin_sort_name_desc": "Name ↓",
|
||||||
"admin_sort_id_asc": "ID ↑",
|
"admin_sort_id_asc": "ID ↑",
|
||||||
"admin_sort_id_desc": "ID ↓",
|
"admin_sort_id_desc": "ID ↓",
|
||||||
|
"admin_sort_premium_ratio_asc": "Premium usage % ↑",
|
||||||
|
"admin_sort_premium_ratio_desc": "Premium usage % ↓",
|
||||||
|
"admin_premium_traffic_filter_label": "Premium traffic",
|
||||||
|
"admin_premium_traffic_filter_all": "All",
|
||||||
|
"admin_premium_traffic_filter_none": "No tariff limit",
|
||||||
|
"admin_premium_traffic_filter_unlimited": "Unlimited (override)",
|
||||||
|
"admin_premium_traffic_filter_good": "Premium: OK",
|
||||||
|
"admin_premium_traffic_filter_warn": "Premium: low",
|
||||||
|
"admin_premium_traffic_filter_critical": "Premium: depleted",
|
||||||
|
"admin_premium_traffic_na": "—",
|
||||||
"admin_close_menu": "Close menu",
|
"admin_close_menu": "Close menu",
|
||||||
"admin_sidebar_navigation": "Admin navigation",
|
"admin_sidebar_navigation": "Admin navigation",
|
||||||
"admin_exit": "Exit",
|
"admin_exit": "Exit",
|
||||||
@@ -896,6 +906,7 @@
|
|||||||
"admin_sort": "Sort",
|
"admin_sort": "Sort",
|
||||||
"admin_total": "Total",
|
"admin_total": "Total",
|
||||||
"admin_users_empty": "No users found",
|
"admin_users_empty": "No users found",
|
||||||
|
"admin_users_col_registration": "Registered",
|
||||||
"admin_page": "Page",
|
"admin_page": "Page",
|
||||||
"admin_page_short": "Page",
|
"admin_page_short": "Page",
|
||||||
"admin_back": "Back",
|
"admin_back": "Back",
|
||||||
|
|||||||
@@ -885,6 +885,16 @@
|
|||||||
"admin_sort_name_desc": "Имя ↓",
|
"admin_sort_name_desc": "Имя ↓",
|
||||||
"admin_sort_id_asc": "ID ↑",
|
"admin_sort_id_asc": "ID ↑",
|
||||||
"admin_sort_id_desc": "ID ↓",
|
"admin_sort_id_desc": "ID ↓",
|
||||||
|
"admin_sort_premium_ratio_asc": "Премиум % ↑",
|
||||||
|
"admin_sort_premium_ratio_desc": "Премиум % ↓",
|
||||||
|
"admin_premium_traffic_filter_label": "Премиум трафик",
|
||||||
|
"admin_premium_traffic_filter_all": "Все",
|
||||||
|
"admin_premium_traffic_filter_none": "Без лимита в тарифе",
|
||||||
|
"admin_premium_traffic_filter_unlimited": "Безлимит (оверрайд)",
|
||||||
|
"admin_premium_traffic_filter_good": "Премиум: норма",
|
||||||
|
"admin_premium_traffic_filter_warn": "Премиум: мало",
|
||||||
|
"admin_premium_traffic_filter_critical": "Премиум: исчерпан",
|
||||||
|
"admin_premium_traffic_na": "—",
|
||||||
"admin_close_menu": "Закрыть меню",
|
"admin_close_menu": "Закрыть меню",
|
||||||
"admin_sidebar_navigation": "Навигация админки",
|
"admin_sidebar_navigation": "Навигация админки",
|
||||||
"admin_exit": "Выйти",
|
"admin_exit": "Выйти",
|
||||||
@@ -896,6 +906,7 @@
|
|||||||
"admin_sort": "Сортировка",
|
"admin_sort": "Сортировка",
|
||||||
"admin_total": "Всего",
|
"admin_total": "Всего",
|
||||||
"admin_users_empty": "Никого не найдено",
|
"admin_users_empty": "Никого не найдено",
|
||||||
|
"admin_users_col_registration": "Регистрация",
|
||||||
"admin_page": "Страница",
|
"admin_page": "Страница",
|
||||||
"admin_page_short": "Стр.",
|
"admin_page_short": "Стр.",
|
||||||
"admin_back": "Назад",
|
"admin_back": "Назад",
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Admin user list: premium traffic payload must not misuse ``premium_is_limited``."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from bot.app.web.admin_api import _premium_traffic_list_payload
|
||||||
|
|
||||||
|
|
||||||
|
class AdminPremiumTrafficPayloadTests(unittest.TestCase):
|
||||||
|
def test_under_quota_shows_good_when_premium_is_limited_flag_false(self):
|
||||||
|
"""DB ``premium_is_limited`` is 'depleted' flag; under quota it is False."""
|
||||||
|
|
||||||
|
gb = 1024**3
|
||||||
|
sub = SimpleNamespace(
|
||||||
|
premium_unlimited_override=False,
|
||||||
|
premium_is_limited=False,
|
||||||
|
premium_used_bytes=5 * gb,
|
||||||
|
premium_baseline_bytes=25 * gb,
|
||||||
|
premium_topup_balance_bytes=0,
|
||||||
|
premium_topup_used_bytes=0,
|
||||||
|
premium_bonus_bytes=0,
|
||||||
|
)
|
||||||
|
payload = _premium_traffic_list_payload(sub)
|
||||||
|
self.assertEqual(payload["state"], "good")
|
||||||
|
self.assertEqual(payload["percent"], 20)
|
||||||
|
|
||||||
|
def test_over_quota_critical_even_when_column_semantics_differ(self):
|
||||||
|
gb = 1024**3
|
||||||
|
sub = SimpleNamespace(
|
||||||
|
premium_unlimited_override=False,
|
||||||
|
premium_is_limited=True,
|
||||||
|
premium_used_bytes=30 * gb,
|
||||||
|
premium_baseline_bytes=25 * gb,
|
||||||
|
premium_topup_balance_bytes=0,
|
||||||
|
premium_topup_used_bytes=0,
|
||||||
|
premium_bonus_bytes=0,
|
||||||
|
)
|
||||||
|
payload = _premium_traffic_list_payload(sub)
|
||||||
|
self.assertEqual(payload["state"], "critical")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user