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
+201 -7
View File
@@ -19,7 +19,7 @@ from typing import Any, Dict, List, Optional, Tuple
from aiogram import Bot
from aiohttp import web
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.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)
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 = (
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,
@@ -507,6 +550,7 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
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:
@@ -515,6 +559,7 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
query=query,
filter_value=filter_value,
panel_status=panel_status,
premium_traffic=premium_traffic,
sort_value=sort_value,
page=page,
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])
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:
@@ -535,6 +581,7 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
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(
@@ -642,21 +689,115 @@ async def admin_user_avatar_route(request: web.Request) -> web.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(
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)
@@ -689,6 +830,53 @@ async def _filter_and_sort_users(
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(),
@@ -703,7 +891,13 @@ async def _filter_and_sort_users(
"id_asc": User.user_id.asc(),
"id_desc": User.user_id.desc(),
}
order = sort_map.get(sort_value, sort_map["registered_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:
@@ -1,7 +1,16 @@
<script>
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 { trafficOfLabel } from "../../lib/admin/format.js";
export let at = (key) => key;
export let fmtDateShort = (value) => value;
@@ -20,6 +29,7 @@
usersQuery,
usersFilter,
usersPanelStatus,
usersPremiumTraffic,
usersSort,
usersLoading,
} = $usersStore);
@@ -45,6 +55,8 @@
{ value: "name_desc", label: at("sort_name_desc", {}, "Имя ↓") },
{ value: "id_asc", label: at("sort_id_asc", {}, "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 = [
@@ -54,6 +66,37 @@
{ 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(() => {
usersStore.loadUsers();
});
@@ -95,6 +138,17 @@
/>
</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">
<span class="admin-toolbar-field-label">{at("sort", {}, "Сортировка")}</span>
<AdminSelect
@@ -113,34 +167,44 @@
</div>
</div>
<div class="admin-table-wrap">
<div class="admin-table-wrap admin-users-table-wrap">
{#if usersLoading}
<ul class="admin-user-list admin-user-list-skeleton" aria-hidden="true">
{#each Array(USERS_PAGE_SIZE) as _, i (i)}
<li>
<div class="admin-user-row admin-user-row-skeleton">
<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>
<AdminTableSkeleton
headers={userTableHeaders}
rows={USERS_PAGE_SIZE}
widths={["minmax(220px, 42%)", "minmax(140px, 28%)", "108px", "112px"]}
/>
{:else if !users.length}
<AdminEmptyState tone="card"><span class="admin-muted">{at("users_empty", {}, "Никого не найдено")}</span></AdminEmptyState>
{:else}
<ul class="admin-user-list">
<AdminTable class="admin-users-table">
<thead>
<tr>
<th>{at("user", {}, "Пользователь")}</th>
<th>{at("premium_traffic_filter_label", {}, "Премиум трафик")}</th>
<th>{at("status", {}, "Статус")}</th>
<th>{at("users_col_registration", {}, "Регистрация")}</th>
</tr>
</thead>
<tbody>
{#each users as user}
{@const avatar = resolvedAvatarUrl(user)}
{@const badge = panelStatusBadge(user)}
<li>
<button type="button" class="admin-user-row" on:click={() => usersStore.openUser(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" />
@@ -148,18 +212,32 @@
<span>{userInitials(user)}</span>
{/if}
</span>
<span class="admin-user-main">
<strong>{userDisplayName(user)}</strong>
<small>{userSecondaryName(user)}</small>
</span>
<span class="admin-user-side">
<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}
<span class="admin-user-premium-placeholder">{at("premium_traffic_na", {}, "—")}</span>
{/if}
</td>
<td data-label={at("status", {}, "Статус")}>
<AdminBadge variant={badge.variant}>{badge.label}</AdminBadge>
<span class="admin-user-tertiary">{fmtDateShort(user.registration_date)}</span>
</span>
</button>
</li>
</td>
<td class="admin-users-cell-date admin-cell-mono" data-label={at("users_col_registration", {}, "Регистрация")}>
{fmtDateShort(user.registration_date)}
</td>
</tr>
{/each}
</ul>
</tbody>
</AdminTable>
{/if}
</div>
@@ -172,3 +250,72 @@
onPrev={() => { usersStore.updateState({ usersPage: Math.max(0, 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: "",
usersFilter: "all",
usersPanelStatus: "all",
usersPremiumTraffic: "all",
usersSort: "registered_desc",
usersLoading: false,
@@ -59,6 +60,9 @@ export function createUsersStore({ api, onToast, at }) {
if (s.usersQuery.trim()) params.set("q", s.usersQuery.trim());
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.usersPremiumTraffic && s.usersPremiumTraffic !== "all") {
params.set("premium_traffic", s.usersPremiumTraffic);
}
if (s.usersSort && s.usersSort !== "registered_desc") params.set("sort", s.usersSort);
const data = await api(`/admin/users?${params.toString()}`);
if (data?.ok) {
@@ -23,6 +23,7 @@ export async function mockApi(path, options = {}, context = {}) {
telegram_photo_url: "",
registration_date: "2026-04-24T10:20:00Z",
is_banned: false,
premium_traffic: { state: "good", unlimited: false, used_bytes: 4 * 1073741824, limit_bytes: 25 * 1073741824, percent: 16 },
},
{
user_id: 100200301,
@@ -34,6 +35,7 @@ export async function mockApi(path, options = {}, context = {}) {
telegram_photo_url: "",
registration_date: "2026-04-26T08:15:00Z",
is_banned: false,
premium_traffic: { state: "warn", unlimited: false, used_bytes: 22 * 1073741824, limit_bytes: 25 * 1073741824, percent: 88 },
},
{
user_id: 100200302,
@@ -45,6 +47,7 @@ export async function mockApi(path, options = {}, context = {}) {
telegram_photo_url: "",
registration_date: "2026-04-29T16:45:00Z",
is_banned: true,
premium_traffic: { state: "none" },
},
];
if (path === "/admin/stats") {
+25 -3
View File
@@ -835,7 +835,7 @@
.admin-toolbar-controls {
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;
gap: 8px;
}
@@ -881,7 +881,7 @@
@media (max-width: 900px) {
.admin-toolbar-controls {
grid-template-columns: repeat(2, minmax(150px, 1fr));
grid-template-columns: repeat(2, minmax(130px, 1fr));
}
.admin-toolbar-summary {
@@ -1000,7 +1000,7 @@
.admin-user-row {
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;
gap: 12px;
width: 100%;
@@ -1023,6 +1023,28 @@
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 {
display: flex;
align-items: center;
+11
View File
@@ -885,6 +885,16 @@
"admin_sort_name_desc": "Name ↓",
"admin_sort_id_asc": "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_sidebar_navigation": "Admin navigation",
"admin_exit": "Exit",
@@ -896,6 +906,7 @@
"admin_sort": "Sort",
"admin_total": "Total",
"admin_users_empty": "No users found",
"admin_users_col_registration": "Registered",
"admin_page": "Page",
"admin_page_short": "Page",
"admin_back": "Back",
+11
View File
@@ -885,6 +885,16 @@
"admin_sort_name_desc": "Имя ↓",
"admin_sort_id_asc": "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_sidebar_navigation": "Навигация админки",
"admin_exit": "Выйти",
@@ -896,6 +906,7 @@
"admin_sort": "Сортировка",
"admin_total": "Всего",
"admin_users_empty": "Никого не найдено",
"admin_users_col_registration": "Регистрация",
"admin_page": "Страница",
"admin_page_short": "Стр.",
"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()