feat: admin dashboard

This commit is contained in:
3252a8
2026-05-09 22:32:44 +03:00
parent 0a9d929181
commit 6f9a87cf98
13 changed files with 6893 additions and 11 deletions
File diff suppressed because it is too large Load Diff
+261
View File
@@ -0,0 +1,261 @@
"""Manifest of settings editable from the admin web app.
Each entry describes a single overridable attribute on the global
``Settings`` instance. The manifest is the only contract between the
admin UI and the backend: keys not listed here cannot be changed via
the API, even by an admin.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable, List, Optional, Tuple
@dataclass(frozen=True)
class SettingField:
key: str
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "secret"
section: str
label: str
description: str = ""
placeholder: str = ""
optional: bool = True
secret: bool = False
min: Optional[float] = None
max: Optional[float] = None
choices: Optional[Tuple[Tuple[str, str], ...]] = None
subsection: Optional[str] = None # group label inside a section
SETTINGS_MANIFEST: List[SettingField] = [
# ─── General ────────────────────────────────────────────────────
SettingField("DEFAULT_LANGUAGE", "string", "general", "Язык по умолчанию", "Используется для приветственных сообщений и публичных страниц."),
SettingField("DEFAULT_CURRENCY_SYMBOL", "string", "general", "Валюта", "Например, RUB, USD, EUR.", placeholder="RUB"),
SettingField("SUPPORT_LINK", "url", "general", "Ссылка поддержки", "Куда вести пользователей за помощью."),
SettingField("SERVER_STATUS_URL", "url", "general", "Ссылка на статус серверов"),
SettingField("TERMS_OF_SERVICE_URL", "url", "general", "Условия использования"),
SettingField("PRIVACY_POLICY_URL", "url", "general", "Политика конфиденциальности"),
SettingField("USER_AGREEMENT_URL", "url", "general", "Пользовательское соглашение"),
SettingField("DISABLE_WELCOME_MESSAGE", "bool", "general", "Скрыть приветствие /start"),
SettingField("START_COMMAND_DESCRIPTION", "string", "general", "Описание /start", placeholder=""),
SettingField("REQUIRED_CHANNEL_ID", "int", "general", "ID обязательного канала", "Telegram ID канала, в котором нужно состоять."),
SettingField("REQUIRED_CHANNEL_LINK", "string", "general", "Ссылка на канал", "Имя пользователя или invite-link."),
# ─── Web app appearance ────────────────────────────────────────
SettingField("WEBAPP_TITLE", "string", "appearance", "Название Web App", placeholder="Моя подписка"),
SettingField("WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"),
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"),
SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"),
# ─── Subscription periods & pricing ────────────────────────────
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
SettingField("MONTH_3_ENABLED", "bool", "pricing", "Тариф 3 месяца"),
SettingField("MONTH_6_ENABLED", "bool", "pricing", "Тариф 6 месяцев"),
SettingField("MONTH_12_ENABLED", "bool", "pricing", "Тариф 12 месяцев"),
SettingField("RUB_PRICE_1_MONTH", "int", "pricing", "Цена 1 мес. (RUB)"),
SettingField("RUB_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (RUB)"),
SettingField("RUB_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (RUB)"),
SettingField("RUB_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (RUB)"),
SettingField("STARS_PRICE_1_MONTH", "int", "pricing", "Цена 1 мес. (Stars)"),
SettingField("STARS_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (Stars)"),
SettingField("STARS_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (Stars)"),
SettingField("STARS_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (Stars)"),
SettingField("TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"),
SettingField("STARS_TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика (Stars)"),
SettingField("PAYMENT_METHODS_ORDER", "string", "pricing", "Порядок методов оплаты", "Через запятую, например: severpay,freekassa,yookassa"),
# ─── Payment providers (toggles) ───────────────────────────────
# Common
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="Общие"),
SettingField("PAYMENT_METHODS_ORDER", "string", "payments", "Порядок методов оплаты",
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay",
subsection="Общие"),
# YooKassa
SettingField("YOOKASSA_ENABLED", "bool", "payments", "Включена", subsection="YooKassa"),
SettingField("YOOKASSA_SHOP_ID", "string", "payments", "Shop ID", subsection="YooKassa"),
SettingField("YOOKASSA_SECRET_KEY", "string", "payments", "Secret key", subsection="YooKassa", secret=True),
SettingField("YOOKASSA_RETURN_URL", "url", "payments", "Return URL", subsection="YooKassa"),
SettingField("YOOKASSA_DEFAULT_RECEIPT_EMAIL", "string", "payments", "Email для чека по умолчанию",
subsection="YooKassa"),
SettingField("YOOKASSA_VAT_CODE", "int", "payments", "VAT code", "1..6 в зависимости от системы налогообложения",
subsection="YooKassa", min=1, max=6),
SettingField("YOOKASSA_AUTOPAYMENTS_ENABLED", "bool", "payments", "Автоплатежи (recurring)",
subsection="YooKassa"),
SettingField("YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", "bool", "payments",
"Принудительная привязка карты", subsection="YooKassa"),
# FreeKassa
SettingField("FREEKASSA_ENABLED", "bool", "payments", "Включена", subsection="FreeKassa"),
SettingField("FREEKASSA_MERCHANT_ID", "string", "payments", "Merchant ID", subsection="FreeKassa"),
SettingField("FREEKASSA_FIRST_SECRET", "string", "payments", "First secret",
subsection="FreeKassa", secret=True),
SettingField("FREEKASSA_SECOND_SECRET", "string", "payments", "Second secret",
"Используется для проверки подписи входящих уведомлений",
subsection="FreeKassa", secret=True),
SettingField("FREEKASSA_API_KEY", "string", "payments", "API key",
subsection="FreeKassa", secret=True),
SettingField("FREEKASSA_PAYMENT_URL", "url", "payments", "Payment URL",
placeholder="https://pay.freekassa.ru/", subsection="FreeKassa"),
SettingField("FREEKASSA_PAYMENT_METHOD_ID", "int", "payments", "Метод оплаты по умолчанию",
subsection="FreeKassa"),
SettingField("FREEKASSA_PAYMENT_IP", "string", "payments", "IP сервера",
"Передаётся в подпись запроса при создании платежа",
subsection="FreeKassa"),
SettingField("FREEKASSA_TRUSTED_IPS", "string", "payments", "Доверенные IP",
"Через запятую — IP-адреса, с которых принимаются нотификации",
subsection="FreeKassa"),
# Platega
SettingField("PLATEGA_ENABLED", "bool", "payments", "Включена", subsection="Platega"),
SettingField("PLATEGA_BASE_URL", "url", "payments", "Base URL",
placeholder="https://app.platega.io", subsection="Platega"),
SettingField("PLATEGA_MERCHANT_ID", "string", "payments", "Merchant ID", subsection="Platega"),
SettingField("PLATEGA_SECRET", "string", "payments", "Secret", subsection="Platega", secret=True),
SettingField("PLATEGA_PAYMENT_METHOD", "int", "payments", "Метод оплаты (legacy)", subsection="Platega"),
SettingField("PLATEGA_SBP_ENABLED", "bool", "payments", "SBP-кнопка", subsection="Platega"),
SettingField("PLATEGA_SBP_METHOD", "int", "payments", "SBP method ID", subsection="Platega"),
SettingField("PLATEGA_CRYPTO_ENABLED", "bool", "payments", "Crypto-кнопка", subsection="Platega"),
SettingField("PLATEGA_CRYPTO_METHOD", "int", "payments", "Crypto method ID", subsection="Platega"),
SettingField("PLATEGA_RETURN_URL", "url", "payments", "Return URL", subsection="Platega"),
SettingField("PLATEGA_FAILED_URL", "url", "payments", "Failed URL", subsection="Platega"),
# SeverPay
SettingField("SEVERPAY_ENABLED", "bool", "payments", "Включена", subsection="SeverPay"),
SettingField("SEVERPAY_MID", "int", "payments", "MID", subsection="SeverPay"),
SettingField("SEVERPAY_TOKEN", "string", "payments", "Token", subsection="SeverPay", secret=True),
SettingField("SEVERPAY_BASE_URL", "url", "payments", "Base URL",
placeholder="https://severpay.io/api/merchant", subsection="SeverPay"),
SettingField("SEVERPAY_RETURN_URL", "url", "payments", "Return URL", subsection="SeverPay"),
SettingField("SEVERPAY_LIFETIME_MINUTES", "int", "payments", "Срок жизни ссылки (мин)",
"30..4320; пусто — значение провайдера", subsection="SeverPay", min=30, max=4320),
# CryptoPay
SettingField("CRYPTOPAY_ENABLED", "bool", "payments", "Включена", subsection="CryptoPay"),
SettingField("CRYPTOPAY_TOKEN", "string", "payments", "Token", subsection="CryptoPay", secret=True),
SettingField("CRYPTOPAY_NETWORK", "string", "payments", "Network",
"mainnet или testnet", subsection="CryptoPay"),
SettingField("CRYPTOPAY_CURRENCY_TYPE", "string", "payments", "Currency type",
"fiat или crypto", subsection="CryptoPay"),
SettingField("CRYPTOPAY_ASSET", "string", "payments", "Asset",
placeholder="RUB", subsection="CryptoPay"),
# ─── Trial ─────────────────────────────────────────────────────
SettingField("TRIAL_ENABLED", "bool", "trial", "Триал включён"),
SettingField("TRIAL_DURATION_DAYS", "int", "trial", "Длительность триала (дней)", min=0),
SettingField("TRIAL_TRAFFIC_LIMIT_GB", "float", "trial", "Лимит трафика триала (ГБ)", min=0),
SettingField("TRIAL_TRAFFIC_STRATEGY", "string", "trial", "Стратегия сброса трафика триала"),
# ─── Referral program ──────────────────────────────────────────
SettingField("REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого"),
SettingField("REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0),
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
SettingField("REFERRAL_BONUS_DAYS_INVITER_1_MONTH", "int", "referral", "Бонус приглашающему: 1 мес.", min=0),
SettingField("REFERRAL_BONUS_DAYS_INVITER_3_MONTHS", "int", "referral", "Бонус приглашающему: 3 мес.", min=0),
SettingField("REFERRAL_BONUS_DAYS_INVITER_6_MONTHS", "int", "referral", "Бонус приглашающему: 6 мес.", min=0),
SettingField("REFERRAL_BONUS_DAYS_INVITER_12_MONTHS", "int", "referral", "Бонус приглашающему: 12 мес.", min=0),
SettingField("REFERRAL_BONUS_DAYS_REFEREE_1_MONTH", "int", "referral", "Бонус приглашённому: 1 мес.", min=0),
SettingField("REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS", "int", "referral", "Бонус приглашённому: 3 мес.", min=0),
SettingField("REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS", "int", "referral", "Бонус приглашённому: 6 мес.", min=0),
SettingField("REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS", "int", "referral", "Бонус приглашённому: 12 мес.", min=0),
# ─── Notifications ─────────────────────────────────────────────
SettingField("SUBSCRIPTION_NOTIFICATIONS_ENABLED", "bool", "notifications", "Включены уведомления о подписке"),
SettingField("SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"),
SettingField("SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", "bool", "notifications", "Уведомлять после истечения"),
SettingField("SUBSCRIPTION_NOTIFY_DAYS_BEFORE", "int", "notifications", "За сколько дней предупреждать", min=0),
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
SettingField("LOG_PROMO_ACTIVATIONS", "bool", "notifications", "Логировать активации промокодов"),
SettingField("LOG_TRIAL_ACTIVATIONS", "bool", "notifications", "Логировать активации триала"),
SettingField("LOG_SUSPICIOUS_ACTIVITY", "bool", "notifications", "Логировать подозрительные действия"),
SettingField("LOG_LEVEL", "string", "notifications", "Глобальный уровень логов", "DEBUG / INFO / WARNING / ERROR"),
SettingField("LOG_CHAT_ID", "int", "notifications", "ID чата для логов"),
SettingField("LOG_THREAD_ID", "int", "notifications", "ID треда (для супергрупп)"),
# ─── Devices ───────────────────────────────────────────────────
SettingField("MY_DEVICES_SECTION_ENABLED", "bool", "devices", "Раздел «Мои устройства»"),
SettingField("USER_HWID_DEVICE_LIMIT", "int", "devices", "Лимит устройств по умолчанию (0 = ∞)", min=0),
SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"),
SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"),
]
def get_field_by_key(key: str) -> Optional[SettingField]:
for field in SETTINGS_MANIFEST:
if field.key == key:
return field
return None
def manifest_keys() -> List[str]:
return [f.key for f in SETTINGS_MANIFEST]
def coerce_value(field: SettingField, raw: Any) -> Any:
"""Coerce a value coming from JSON to the type declared by the field."""
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
return None
if field.type == "bool":
if isinstance(raw, bool):
return raw
if isinstance(raw, (int, float)):
return bool(raw)
if isinstance(raw, str):
return raw.strip().lower() in {"1", "true", "yes", "on"}
return bool(raw)
if field.type == "int":
try:
value = int(str(raw).strip())
except (TypeError, ValueError) as exc:
raise ValueError(f"{field.key}: integer expected") from exc
if field.min is not None and value < field.min:
raise ValueError(f"{field.key}: must be >= {field.min:g}")
if field.max is not None and value > field.max:
raise ValueError(f"{field.key}: must be <= {field.max:g}")
return value
if field.type == "float":
try:
value = float(str(raw).strip())
except (TypeError, ValueError) as exc:
raise ValueError(f"{field.key}: number expected") from exc
if field.min is not None and value < field.min:
raise ValueError(f"{field.key}: must be >= {field.min:g}")
if field.max is not None and value > field.max:
raise ValueError(f"{field.key}: must be <= {field.max:g}")
return value
if isinstance(raw, str):
return raw.strip()
return str(raw)
def manifest_payload() -> List[dict]:
"""Serialize the manifest for the admin UI."""
sections_order = {
"general": 1,
"appearance": 2,
"pricing": 3,
"payments": 4,
"trial": 5,
"referral": 6,
"notifications": 7,
"devices": 8,
}
items: List[dict] = []
for field in SETTINGS_MANIFEST:
items.append(
{
"key": field.key,
"type": field.type,
"section": field.section,
"section_order": sections_order.get(field.section, 99),
"subsection": field.subsection,
"label": field.label,
"description": field.description,
"placeholder": field.placeholder,
"optional": field.optional,
"secret": field.secret,
}
)
return items
+205 -10
View File
@@ -36,6 +36,7 @@
import Dialog from "./lib/components/ui/dialog.svelte"; import Dialog from "./lib/components/ui/dialog.svelte";
import Input from "./lib/components/ui/input.svelte"; import Input from "./lib/components/ui/input.svelte";
import PreviewBoard from "./PreviewBoard.svelte"; import PreviewBoard from "./PreviewBoard.svelte";
import AdminPanel from "./admin/AdminPanel.svelte";
const MANUAL_LOGOUT_FLAG_KEY = "rw_webapp_manual_logout"; const MANUAL_LOGOUT_FLAG_KEY = "rw_webapp_manual_logout";
const LANGUAGE_LABELS = { const LANGUAGE_LABELS = {
@@ -62,7 +63,19 @@
invite: "/invite", invite: "/invite",
devices: "/devices", devices: "/devices",
settings: "/settings", settings: "/settings",
admin: "/admin",
}; };
const ADMIN_SECTIONS = new Set([
"stats",
"users",
"payments",
"promos",
"ads",
"broadcast",
"logs",
"tariffs",
"settings",
]);
const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js"; const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js";
const TELEGRAM_OAUTH_AVAILABILITY_URL = "https://oauth.telegram.org/"; const TELEGRAM_OAUTH_AVAILABILITY_URL = "https://oauth.telegram.org/";
const TELEGRAM_SDK_BOOT_TIMEOUT_MS = 900; const TELEGRAM_SDK_BOOT_TIMEOUT_MS = 900;
@@ -100,6 +113,7 @@
telegram_photo_url: "", telegram_photo_url: "",
first_name: "Preview", first_name: "Preview",
language_code: "ru", language_code: "ru",
is_admin: true,
}, },
subscription: { subscription: {
active: true, active: true,
@@ -487,6 +501,11 @@
hasActiveTariffSubscription && Number(subscription?.traffic_limit_bytes || 0) > 0 && trafficPercent(subscription) >= 85, hasActiveTariffSubscription && Number(subscription?.traffic_limit_bytes || 0) > 0 && trafficPercent(subscription) >= 85,
); );
$: user = data?.user || {}; $: user = data?.user || {};
$: isAdmin = Boolean(user?.is_admin);
$: if (screen === "admin" && !isAdmin) {
screen = "settings";
activeTab = "settings";
}
$: referral = data?.referral || DEV_MOCK.data.referral; $: referral = data?.referral || DEV_MOCK.data.referral;
$: currentLang = normalizeLangCode(user?.language_code || CFG.language || "ru"); $: currentLang = normalizeLangCode(user?.language_code || CFG.language || "ru");
$: languageOptions = WEBAPP_LANGUAGE_ORDER.map((code) => ({ $: languageOptions = WEBAPP_LANGUAGE_ORDER.map((code) => ({
@@ -570,6 +589,10 @@
const onPopState = () => { const onPopState = () => {
const section = sectionFromPath(window.location.pathname); const section = sectionFromPath(window.location.pathname);
if (mode === "app") { if (mode === "app") {
if (section === "admin" && isAdmin) {
screen = "admin";
return;
}
const nextSection = section === "devices" && !devicesEnabled ? "home" : section; const nextSection = section === "devices" && !devicesEnabled ? "home" : section;
activeTab = nextSection; activeTab = nextSection;
screen = nextSection; screen = nextSection;
@@ -709,7 +732,10 @@
function normalizeSection(value) { function normalizeSection(value) {
const section = String(value || "").trim().toLowerCase(); const section = String(value || "").trim().toLowerCase();
return section === "invite" || section === "devices" || section === "settings" ? section : "home"; if (section === "invite" || section === "devices" || section === "settings" || section === "admin") {
return section;
}
return "home";
} }
function sectionFromPath(pathname) { function sectionFromPath(pathname) {
@@ -718,14 +744,26 @@
.toLowerCase() .toLowerCase()
.replace(/\/+$/, ""); .replace(/\/+$/, "");
if (!normalizedPath || normalizedPath === "/") return "home"; if (!normalizedPath || normalizedPath === "/") return "home";
if (normalizedPath === "/admin" || normalizedPath.startsWith("/admin/")) return "admin";
const section = normalizedPath.startsWith("/") ? normalizedPath.slice(1) : normalizedPath; const section = normalizedPath.startsWith("/") ? normalizedPath.slice(1) : normalizedPath;
return normalizeSection(section); return normalizeSection(section);
} }
function syncSectionPath(section, replace = false) { function adminSectionFromPath(pathname) {
const normalized = String(pathname || "").toLowerCase().replace(/\/+$/, "");
const m = normalized.match(/^\/admin\/([a-z0-9_-]+)$/);
if (m && ADMIN_SECTIONS.has(m[1])) return m[1];
return "stats";
}
function syncSectionPath(section, replace = false, adminSection = null) {
if (window.location.protocol === "file:") return; if (window.location.protocol === "file:") return;
const normalized = normalizeSection(section); const normalized = normalizeSection(section);
const targetPath = APP_SECTION_PATHS[normalized] || APP_SECTION_PATHS.home; let targetPath = APP_SECTION_PATHS[normalized] || APP_SECTION_PATHS.home;
if (normalized === "admin") {
const adm = adminSection || adminSectionFromPath(window.location.pathname) || "stats";
targetPath = `/admin/${adm}`;
}
if (window.location.pathname === targetPath) return; if (window.location.pathname === targetPath) return;
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`; const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
window.history[replace ? "replaceState" : "pushState"](null, "", nextUrl); window.history[replace ? "replaceState" : "pushState"](null, "", nextUrl);
@@ -949,11 +987,16 @@
paymentStep = "tariff"; paymentStep = "tariff";
selectedMethod = payload.payment_methods?.[0]?.id || ""; selectedMethod = payload.payment_methods?.[0]?.id || "";
let section = MOCK && query.get("screen") ? normalizeSection(query.get("screen")) : sectionFromPath(window.location.pathname); let section = MOCK && query.get("screen") ? normalizeSection(query.get("screen")) : sectionFromPath(window.location.pathname);
if (section === "admin" && !payload.user?.is_admin) section = "settings";
if (section === "devices" && !payload.settings?.my_devices_enabled) section = "home"; if (section === "devices" && !payload.settings?.my_devices_enabled) section = "home";
activeTab = section; activeTab = section === "admin" ? "settings" : section;
screen = section; screen = section;
mode = "app"; mode = "app";
syncSectionPath(section, true); syncSectionPath(
section,
true,
section === "admin" ? adminSectionFromPath(window.location.pathname) : null,
);
if (section === "devices" && payload.settings?.my_devices_enabled) { if (section === "devices" && payload.settings?.my_devices_enabled) {
await loadDevices(); await loadDevices();
} }
@@ -1002,6 +1045,105 @@
async function mockApi(path, options = {}) { async function mockApi(path, options = {}) {
await new Promise((resolve) => window.setTimeout(resolve, 120)); await new Promise((resolve) => window.setTimeout(resolve, 120));
const cleanPath = String(path || "").split("?")[0];
const adminUsers = [
{
user_id: 100200300,
telegram_id: 100200300,
username: "anna_ops",
first_name: "Анна",
last_name: "Смирнова",
email: "anna@example.com",
telegram_photo_url: "",
registration_date: "2026-04-24T10:20:00Z",
is_banned: false,
},
{
user_id: 100200301,
telegram_id: 87543123,
username: "client_pro",
first_name: "Максим",
last_name: "Котов",
email: "",
telegram_photo_url: "",
registration_date: "2026-04-26T08:15:00Z",
is_banned: false,
},
{
user_id: 100200302,
telegram_id: 88440011,
username: "",
first_name: "Daria",
last_name: "",
email: "daria@example.com",
telegram_photo_url: "",
registration_date: "2026-04-29T16:45:00Z",
is_banned: true,
},
];
if (path === "/admin/stats") {
return {
ok: true,
users: { total_users: 248, active_subscriptions: 172, banned_users: 3 },
financial: { total_revenue: 186240, successful_payments_count: 934 },
panel_sync: { status: "success", last_sync_time: new Date().toISOString(), users_processed: 172, subscriptions_synced: 168 },
recent_payments: [
{ payment_id: 1, user_id: 100200300, user_label: "anna_ops", amount: 790, currency: "RUB", provider: "yookassa", status: "succeeded", created_at: new Date().toISOString() },
],
};
}
if (cleanPath === "/admin/users") return { ok: true, users: adminUsers, total: adminUsers.length, page: 0, page_size: 25 };
if (cleanPath.startsWith("/admin/users/")) {
const id = Number(cleanPath.split("/")[3]);
const user = adminUsers.find((item) => item.user_id === id) || adminUsers[0];
return {
ok: true,
user,
active_subscription: {
subscription_id: 10,
end_date: "2026-06-08T12:00:00Z",
tariff_key: "standard",
auto_renew_enabled: true,
provider: "yookassa",
},
subscriptions: [
{ subscription_id: 10, end_date: "2026-06-08T12:00:00Z", tariff_key: "standard", is_active: true, status_from_panel: "ACTIVE" },
{ subscription_id: 9, end_date: "2026-05-08T12:00:00Z", tariff_key: "standard", is_active: false, status_from_panel: "EXPIRED" },
],
total_paid: 2380,
recent_payments: [
{ payment_id: 12, amount: 790, currency: "RUB", provider: "yookassa", status: "succeeded", created_at: "2026-05-01T14:15:00Z" },
{ payment_id: 11, amount: 790, currency: "RUB", provider: "stars", status: "succeeded", created_at: "2026-04-01T14:15:00Z" },
],
log_count: 18,
};
}
if (path === "/admin/tariffs") {
return {
ok: true,
path: "config/tariffs.json",
catalog: {
default_tariff: "standard",
topup_packages_default: { rub: [{ gb: 10, price: 99 }], stars: [] },
tariffs: [
{
key: "standard",
names: { ru: "Стандарт", en: "Standard" },
descriptions: { ru: "Базовый набор серверов" },
squad_uuids: ["db786ee8-816b-4760-80aa-1fc7a3669ff2"],
billing_model: "period",
monthly_gb: 500,
prices_rub: { "1": 150, "3": 400 },
prices_stars: { "1": 0, "3": 0 },
enabled_periods: [1, 3],
enabled: true,
},
],
},
};
}
if (path === "/admin/settings") return { ok: true, sections: [] };
if (cleanPath.startsWith("/admin/")) return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 };
if (path === "/me") return structuredCloneSafe(DEV_MOCK.data); if (path === "/me") return structuredCloneSafe(DEV_MOCK.data);
if (path === "/auth/email/request") return { ok: true }; if (path === "/auth/email/request") return { ok: true };
if (path === "/auth/email/verify" || path === "/auth/email/magic") { if (path === "/auth/email/verify" || path === "/auth/email/magic") {
@@ -2042,6 +2184,27 @@
syncSectionPath("settings"); syncSectionPath("settings");
} }
function openAdminPanel() {
if (!isAdmin) return;
paymentModalOpen = false;
screen = "admin";
syncSectionPath("admin", false, adminSectionFromPath(window.location.pathname));
}
function closeAdminPanel() {
screen = "settings";
activeTab = "settings";
syncSectionPath("settings");
}
function handleAdminSectionChange(adminSection) {
if (screen !== "admin") return;
if (window.location.protocol === "file:") return;
const targetPath = `/admin/${adminSection}`;
if (window.location.pathname === targetPath) return;
window.history.pushState(null, "", `${targetPath}${window.location.search}${window.location.hash}`);
}
function openPaymentModal() { function openPaymentModal() {
if (tariffMode) { if (tariffMode) {
if (singleTariffMode && tariffCatalog[0]?.key) { if (singleTariffMode && tariffCatalog[0]?.key) {
@@ -2702,8 +2865,16 @@
</div> </div>
{/if} {/if}
</div> </div>
{:else if screen === "admin" && isAdmin}
<AdminPanel
api={api}
onClose={closeAdminPanel}
onToast={(text) => showToast(text)}
initialSection={adminSectionFromPath(window.location.pathname)}
onSectionChange={handleAdminSectionChange}
/>
{:else} {:else}
<div class="phone-screen"> <div class="phone-screen" class:home-screen={screen === "home"}>
{#if screen === "invite" || screen === "devices" || screen === "settings"} {#if screen === "invite" || screen === "devices" || screen === "settings"}
<header class="app-header accent-title"> <header class="app-header accent-title">
<div class="brand-row"> <div class="brand-row">
@@ -2977,6 +3148,20 @@
<small>{profileTelegramId}</small> <small>{profileTelegramId}</small>
</div> </div>
</Card> </Card>
{#if isAdmin}
<div class="settings-admin-block">
<div class="settings-divider" aria-hidden="true"></div>
<button class="settings-row settings-row-admin" type="button" on:click={openAdminPanel}>
<Shield size={21} />
<span>
<strong>Админ-панель</strong>
<small>Управление приложением</small>
</span>
<ArrowRight size={17} />
</button>
<div class="settings-divider" aria-hidden="true"></div>
</div>
{/if}
<div class="settings-links-block"> <div class="settings-links-block">
<div class="settings-divider" aria-hidden="true"></div> <div class="settings-divider" aria-hidden="true"></div>
{#if user?.telegram_linked} {#if user?.telegram_linked}
@@ -3068,27 +3253,27 @@
</Select.Root> </Select.Root>
</div> </div>
{#if supportUrl} {#if supportUrl}
<button class="settings-row" type="button" on:click={() => openExternalLink(supportUrl)}> <button class="settings-row settings-row-support" type="button" on:click={() => openExternalLink(supportUrl)}>
<Send size={21} /> <Send size={21} />
<span><strong>{t("menu_support_button")}</strong></span> <span><strong>{t("menu_support_button")}</strong></span>
<ArrowRight size={17} /> <ArrowRight size={17} />
</button> </button>
{/if} {/if}
{#if userAgreementUrl} {#if userAgreementUrl}
<button class="settings-row" type="button" on:click={() => openExternalLink(userAgreementUrl)}> <button class="settings-row settings-row-policy" type="button" on:click={() => openExternalLink(userAgreementUrl)}>
<FileText size={21} /> <FileText size={21} />
<span><strong>{t("wa_settings_user_agreement")}</strong></span> <span><strong>{t("wa_settings_user_agreement")}</strong></span>
<ArrowRight size={17} /> <ArrowRight size={17} />
</button> </button>
{/if} {/if}
{#if privacyPolicyUrl} {#if privacyPolicyUrl}
<button class="settings-row" type="button" on:click={() => openExternalLink(privacyPolicyUrl)}> <button class="settings-row settings-row-policy" type="button" on:click={() => openExternalLink(privacyPolicyUrl)}>
<Shield size={21} /> <Shield size={21} />
<span><strong>{t("wa_settings_privacy_policy")}</strong></span> <span><strong>{t("wa_settings_privacy_policy")}</strong></span>
<ArrowRight size={17} /> <ArrowRight size={17} />
</button> </button>
{/if} {/if}
<button class="settings-row" type="button" on:click={logout}> <button class="settings-row settings-row-logout" type="button" on:click={logout}>
<UserRound size={21} /> <UserRound size={21} />
<span><strong>{t("wa_logout")}</strong><small>{t("wa_end_session")}</small></span> <span><strong>{t("wa_logout")}</strong><small>{t("wa_end_session")}</small></span>
<ArrowRight size={17} /> <ArrowRight size={17} />
@@ -3099,6 +3284,10 @@
{#if screen === "home" || screen === "invite" || screen === "devices" || screen === "settings"} {#if screen === "home" || screen === "invite" || screen === "devices" || screen === "settings"}
<nav class:bottom-nav-devices={devicesEnabled} class="bottom-nav" aria-label={t("wa_navigation")}> <nav class:bottom-nav-devices={devicesEnabled} class="bottom-nav" aria-label={t("wa_navigation")}>
<div class="rail-brand" aria-hidden="true">
<BrandMark logoUrl={CFG.logoUrl} emoji={brandEmoji} />
<strong>{brandTitle}</strong>
</div>
<button class:active={activeTab === "home"} type="button" on:click={goHome}> <button class:active={activeTab === "home"} type="button" on:click={goHome}>
<Home size={21} /> <Home size={21} />
<span>{t("wa_nav_home")}</span> <span>{t("wa_nav_home")}</span>
@@ -3120,6 +3309,12 @@
<SettingsIcon size={21} /> <SettingsIcon size={21} />
<span>{t("wa_nav_settings")}</span> <span>{t("wa_nav_settings")}</span>
</button> </button>
{#if isAdmin}
<button class="rail-admin-entry" type="button" on:click={openAdminPanel}>
<Shield size={21} />
<span>Админ-панель</span>
</button>
{/if}
</nav> </nav>
{/if} {/if}
</div> </div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+18 -1
View File
@@ -23,6 +23,10 @@ from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, f
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from bot.app.web.admin_api import (
admin_auth_middleware,
setup_admin_routes,
)
from bot.app.web.webapp_auth import ( from bot.app.web.webapp_auth import (
create_signed_telegram_oauth_state, create_signed_telegram_oauth_state,
create_telegram_oauth_nonce, create_telegram_oauth_nonce,
@@ -148,7 +152,13 @@ def create_subscription_webapp_application(
settings: Settings, settings: Settings,
async_session_factory: sessionmaker, async_session_factory: sessionmaker,
) -> web.Application: ) -> web.Application:
app = web.Application(middlewares=[_security_headers_middleware, _csrf_protection_middleware]) app = web.Application(
middlewares=[
_security_headers_middleware,
_csrf_protection_middleware,
admin_auth_middleware,
]
)
app["bot"] = bot app["bot"] = bot
app["dp"] = dp app["dp"] = dp
app["settings"] = settings app["settings"] = settings
@@ -179,6 +189,7 @@ def create_subscription_webapp_application(
"severpay_service", "severpay_service",
"promo_code_service", "promo_code_service",
"referral_service", "referral_service",
"panel_service",
): ):
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined] if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined]
app[key] = dp.workflow_data[key] # type: ignore[index] app[key] = dp.workflow_data[key] # type: ignore[index]
@@ -196,6 +207,8 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/invite", index_route) app.router.add_get("/invite", index_route)
app.router.add_get("/devices", index_route) app.router.add_get("/devices", index_route)
app.router.add_get("/settings", index_route) app.router.add_get("/settings", index_route)
app.router.add_get("/admin", index_route)
app.router.add_get("/admin/{section:[a-z][a-z0-9_-]*}", index_route)
app.router.add_get("/auth/telegram/start", telegram_oauth_start_route) app.router.add_get("/auth/telegram/start", telegram_oauth_start_route)
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route) app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
app.router.add_get("/health", health_route) app.router.add_get("/health", health_route)
@@ -226,6 +239,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_post("/api/tariffs/change-payment", tariff_change_payment_route) app.router.add_post("/api/tariffs/change-payment", tariff_change_payment_route)
app.router.add_post("/api/payments", create_payment_route) app.router.add_post("/api/payments", create_payment_route)
app.router.add_get("/api/payments/{payment_id}", payment_status_route) app.router.add_get("/api/payments/{payment_id}", payment_status_route)
setup_admin_routes(app)
async def health_route(request: web.Request) -> web.Response: async def health_route(request: web.Request) -> web.Response:
@@ -2942,6 +2956,8 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
await session.rollback() await session.rollback()
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE) lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
return { return {
"user": { "user": {
"id": user_id, "id": user_id,
@@ -2953,6 +2969,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"telegram_photo_url": _telegram_avatar_url(avatar), "telegram_photo_url": _telegram_avatar_url(avatar),
"first_name": db_user.first_name, "first_name": db_user.first_name,
"language_code": lang, "language_code": lang,
"is_admin": is_admin,
}, },
"subscription": _serialize_subscription(active, local_sub, lang), "subscription": _serialize_subscription(active, local_sub, lang),
"referral": { "referral": {
+164
View File
@@ -0,0 +1,164 @@
"""Apply persisted setting overrides on top of the env-based Settings.
The runtime treats DB overrides as the source of truth: env values are
loaded once via pydantic, then any matching keys from the
``app_setting_overrides`` table replace those attributes in-process.
This way the admin can flip flags, adjust prices or rename labels
without restarting the container.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.app.web.admin_settings_manifest import (
SettingField,
coerce_value,
get_field_by_key,
manifest_keys,
)
from config.settings import Settings
from db.dal import app_settings_dal
logger = logging.getLogger(__name__)
def _resolve_attribute_name(settings: Settings, key: str) -> Optional[str]:
"""Resolve the actual attribute name on the Settings model.
Some settings expose their env name via ``alias`` (e.g. MONTH_1_ENABLED is
aliased to "1_MONTH_ENABLED"). Lookups by either alias or attribute name
should both succeed, with the attribute name returned in either case.
"""
if hasattr(settings, key):
return key
fields = type(settings).model_fields
for attr_name, field_info in fields.items():
alias = getattr(field_info, "alias", None)
if alias and alias == key:
return attr_name
return None
def _apply_value(settings: Settings, key: str, value: Any) -> bool:
attr_name = _resolve_attribute_name(settings, key)
if not attr_name:
return False
try:
setattr(settings, attr_name, value)
return True
except Exception as exc: # pragma: no cover - defensive
logger.warning("Failed to apply override %s=%r: %s", key, value, exc)
return False
def apply_overrides(settings: Settings, overrides: Dict[str, Any]) -> int:
applied = 0
for key, raw_value in overrides.items():
field = get_field_by_key(key)
if not field:
continue
try:
coerced = coerce_value(field, raw_value)
except ValueError as exc:
logger.warning("Skipping override %s: %s", key, exc)
continue
if _apply_value(settings, key, coerced):
applied += 1
return applied
async def load_overrides_from_db(
settings: Settings, async_session_factory: sessionmaker
) -> int:
"""Fetch overrides from the DB and apply them to the in-memory settings."""
try:
async with async_session_factory() as session:
overrides = await app_settings_dal.get_all_overrides(session)
except Exception as exc:
logger.warning("Could not load setting overrides from DB: %s", exc)
return 0
applied = apply_overrides(settings, overrides)
if applied:
logger.info("Applied %s setting overrides from DB", applied)
return applied
async def update_overrides(
settings: Settings,
async_session_factory: sessionmaker,
*,
updates: Dict[str, Any],
deletes: Optional[list] = None,
actor_id: Optional[int] = None,
) -> Dict[str, Any]:
"""Persist + apply a batch of changes coming from the admin UI."""
deletes = list(deletes or [])
coerced_updates: Dict[str, Any] = {}
errors: Dict[str, str] = {}
for key, raw in updates.items():
field: Optional[SettingField] = get_field_by_key(key)
if not field:
errors[key] = "unknown_setting"
continue
try:
coerced_updates[key] = coerce_value(field, raw)
except ValueError as exc:
errors[key] = str(exc)
valid_deletes = []
for key in deletes:
if get_field_by_key(key) is None:
errors.setdefault(key, "unknown_setting")
continue
valid_deletes.append(key)
if errors:
return {"ok": False, "errors": errors}
async with async_session_factory() as session: # type: AsyncSession
async with session.begin():
for key, value in coerced_updates.items():
await app_settings_dal.upsert_override(
session, key=key, value=value, updated_by=actor_id
)
for key in valid_deletes:
await app_settings_dal.delete_override(session, key)
# Apply locally; deletes need an env-default fallback. We re-read the env
# default by instantiating a fresh Settings() (cheap; just a few ms) and
# copying the matching attributes back over.
if valid_deletes:
try:
env_only = Settings()
for key in valid_deletes:
attr_name = _resolve_attribute_name(env_only, key) or key
if hasattr(env_only, attr_name):
setattr(settings, attr_name, getattr(env_only, attr_name))
except Exception as exc: # pragma: no cover - defensive
logger.warning("Failed to restore env defaults: %s", exc)
apply_overrides(settings, coerced_updates)
return {"ok": True, "applied": len(coerced_updates), "reverted": len(valid_deletes)}
def overridable_keys() -> list:
return list(manifest_keys())
def current_value(settings: Settings, key: str) -> Any:
attr_name = _resolve_attribute_name(settings, key)
if not attr_name:
return None
return getattr(settings, attr_name, None)
+2
View File
@@ -7,6 +7,7 @@ from . import message_log_dal
from . import user_billing_dal from . import user_billing_dal
from . import ad_dal from . import ad_dal
from . import security_dal from . import security_dal
from . import app_settings_dal
__all__ = ( __all__ = (
"user_dal", "user_dal",
@@ -18,6 +19,7 @@ __all__ = (
"user_billing_dal", "user_billing_dal",
"ad_dal", "ad_dal",
"security_dal", "security_dal",
"app_settings_dal",
) )
+100
View File
@@ -0,0 +1,100 @@
"""Persistent overrides for application settings.
Overrides take priority over `.env` values for keys exposed via the admin
manifest. Values are stored as JSON-encoded text to preserve typing across
strings, booleans, integers and floats.
"""
import json
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import AppSettingOverride
logger = logging.getLogger(__name__)
def _encode(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def _decode(raw: Optional[str]) -> Any:
if raw is None:
return None
try:
return json.loads(raw)
except (TypeError, ValueError):
return raw
async def get_all_overrides(session: AsyncSession) -> Dict[str, Any]:
rows = (await session.execute(select(AppSettingOverride))).scalars().all()
return {row.key: _decode(row.value) for row in rows}
async def get_overrides_with_meta(session: AsyncSession) -> List[Dict[str, Any]]:
rows = (await session.execute(select(AppSettingOverride))).scalars().all()
items: List[Dict[str, Any]] = []
for row in rows:
items.append(
{
"key": row.key,
"value": _decode(row.value),
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"updated_by": row.updated_by,
}
)
return items
async def upsert_override(
session: AsyncSession,
*,
key: str,
value: Any,
updated_by: Optional[int],
) -> None:
encoded = _encode(value)
now = datetime.now(timezone.utc)
stmt = (
pg_insert(AppSettingOverride)
.values(key=key, value=encoded, updated_at=now, updated_by=updated_by)
.on_conflict_do_update(
index_elements=[AppSettingOverride.key],
set_={
"value": encoded,
"updated_at": now,
"updated_by": updated_by,
},
)
)
await session.execute(stmt)
async def delete_override(session: AsyncSession, key: str) -> bool:
stmt = delete(AppSettingOverride).where(AppSettingOverride.key == key)
result = await session.execute(stmt)
return bool(result.rowcount or 0)
async def bulk_apply(
session: AsyncSession,
*,
updates: Dict[str, Tuple[bool, Any]],
updated_by: Optional[int],
) -> None:
"""Apply a batch of changes. Each entry maps key -> (set_flag, value).
When set_flag is False the override is deleted (revert to env). Otherwise
the value is upserted.
"""
for key, (set_flag, value) in updates.items():
if set_flag:
await upsert_override(session, key=key, value=value, updated_by=updated_by)
else:
await delete_override(session, key)
+8
View File
@@ -70,6 +70,14 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
"PostgreSQL database initialized/checked successfully using SQLAlchemy." "PostgreSQL database initialized/checked successfully using SQLAlchemy."
) )
try:
from bot.services.settings_override_service import load_overrides_from_db
await load_overrides_from_db(settings, session_factory)
except Exception as e_overrides:
logging.warning(
f"Failed to load setting overrides on startup: {e_overrides}"
)
async with session_factory() as session: async with session_factory() as session:
from .dal.panel_sync_dal import get_panel_sync_status, update_panel_sync_status from .dal.panel_sync_dal import get_panel_sync_status, update_panel_sync_status
from sqlalchemy import text from sqlalchemy import text
+16
View File
@@ -531,6 +531,22 @@ MIGRATIONS: List[Migration] = [
description="Add tariff catalog columns and traffic accounting tables", description="Add tariff catalog columns and traffic accounting tables",
upgrade=_migration_0012_add_tariffs_schema, upgrade=_migration_0012_add_tariffs_schema,
), ),
Migration(
id="0013_add_app_setting_overrides",
description="Persisted runtime overrides for application settings managed via admin webapp",
upgrade=lambda connection: connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS app_setting_overrides (
key VARCHAR(128) PRIMARY KEY,
value TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by BIGINT
)
"""
)
),
),
] ]
+14
View File
@@ -415,3 +415,17 @@ class AdAttribution(Base):
user = relationship("User") user = relationship("User")
campaign = relationship("AdCampaign", back_populates="attributions") campaign = relationship("AdCampaign", back_populates="attributions")
class AppSettingOverride(Base):
__tablename__ = "app_setting_overrides"
key = Column(String(128), primary_key=True)
value = Column(Text, nullable=True)
updated_at = Column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
updated_by = Column(BigInteger, nullable=True)
+15
View File
@@ -7,6 +7,21 @@
JSON-каталог может содержать несколько тарифов разных моделей: подписки на срок, пакеты трафика без срока действия, разные наборы Internal Squads, лимиты устройств и пакеты докупки. Пример формата: [config/tariffs.example.json](../config/tariffs.example.json). JSON-каталог может содержать несколько тарифов разных моделей: подписки на срок, пакеты трафика без срока действия, разные наборы Internal Squads, лимиты устройств и пакеты докупки. Пример формата: [config/tariffs.example.json](../config/tariffs.example.json).
## Управление через админку
Каталог тарифов можно настраивать из Web App админки: раздел **Система → Тарифы**. Админка читает и сохраняет файл из `TARIFFS_CONFIG_PATH`, валидирует данные той же моделью `TariffsConfig`, что и бот, и атомарно перезаписывает JSON только после успешной проверки.
В интерфейсе доступны:
- добавление, редактирование и удаление тарифов;
- включение и выключение тарифа на витрине;
- выбор тарифа по умолчанию;
- настройка `period`-тарифов: месячный лимит, периоды, RUB/Stars цены, пакеты докупки трафика;
- настройка `traffic`-тарифов: пакеты GB, RUB/Stars цены, курс конвертации;
- настройка Internal Squads, базового HWID-лимита и пакетов докупки устройств.
После сохранения изменения применяются к новым запросам Web App сразу, потому что конфиг тарифов загружается из JSON при обращении. Уже созданные подписки сохраняют свой `tariff_key`; при удалении или отключении тарифа проверьте, что активные подписки с этим ключом не требуют дальнейшего продления или смены.
## Как выбирается режим ## Как выбирается режим
Если файл из `TARIFFS_CONFIG_PATH` существует и проходит валидацию, используется каталог тарифов. В этом режиме `TRAFFIC_PACKAGES` и цены подписок из `.env` не формируют витрину продаж, потому что цены и пакеты берутся из JSON. Если файл из `TARIFFS_CONFIG_PATH` существует и проходит валидацию, используется каталог тарифов. В этом режиме `TRAFFIC_PACKAGES` и цены подписок из `.env` не формируют витрину продаж, потому что цены и пакеты берутся из JSON.