feat: show and filter premium squad status in admin panel users list

This commit is contained in:
3252a8
2026-05-12 16:14:11 +03:00
parent 475a1953d1
commit 92277b2e27
8 changed files with 489 additions and 54 deletions
+204 -10
View File
@@ -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") {
+25 -3
View File
@@ -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;
+11
View File
@@ -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",
+11
View File
@@ -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": "Назад",
+43
View File
@@ -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()