feat: admin dashboard
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -36,6 +36,7 @@
|
||||
import Dialog from "./lib/components/ui/dialog.svelte";
|
||||
import Input from "./lib/components/ui/input.svelte";
|
||||
import PreviewBoard from "./PreviewBoard.svelte";
|
||||
import AdminPanel from "./admin/AdminPanel.svelte";
|
||||
|
||||
const MANUAL_LOGOUT_FLAG_KEY = "rw_webapp_manual_logout";
|
||||
const LANGUAGE_LABELS = {
|
||||
@@ -62,7 +63,19 @@
|
||||
invite: "/invite",
|
||||
devices: "/devices",
|
||||
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_OAUTH_AVAILABILITY_URL = "https://oauth.telegram.org/";
|
||||
const TELEGRAM_SDK_BOOT_TIMEOUT_MS = 900;
|
||||
@@ -100,6 +113,7 @@
|
||||
telegram_photo_url: "",
|
||||
first_name: "Preview",
|
||||
language_code: "ru",
|
||||
is_admin: true,
|
||||
},
|
||||
subscription: {
|
||||
active: true,
|
||||
@@ -487,6 +501,11 @@
|
||||
hasActiveTariffSubscription && Number(subscription?.traffic_limit_bytes || 0) > 0 && trafficPercent(subscription) >= 85,
|
||||
);
|
||||
$: user = data?.user || {};
|
||||
$: isAdmin = Boolean(user?.is_admin);
|
||||
$: if (screen === "admin" && !isAdmin) {
|
||||
screen = "settings";
|
||||
activeTab = "settings";
|
||||
}
|
||||
$: referral = data?.referral || DEV_MOCK.data.referral;
|
||||
$: currentLang = normalizeLangCode(user?.language_code || CFG.language || "ru");
|
||||
$: languageOptions = WEBAPP_LANGUAGE_ORDER.map((code) => ({
|
||||
@@ -570,6 +589,10 @@
|
||||
const onPopState = () => {
|
||||
const section = sectionFromPath(window.location.pathname);
|
||||
if (mode === "app") {
|
||||
if (section === "admin" && isAdmin) {
|
||||
screen = "admin";
|
||||
return;
|
||||
}
|
||||
const nextSection = section === "devices" && !devicesEnabled ? "home" : section;
|
||||
activeTab = nextSection;
|
||||
screen = nextSection;
|
||||
@@ -709,7 +732,10 @@
|
||||
|
||||
function normalizeSection(value) {
|
||||
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) {
|
||||
@@ -718,14 +744,26 @@
|
||||
.toLowerCase()
|
||||
.replace(/\/+$/, "");
|
||||
if (!normalizedPath || normalizedPath === "/") return "home";
|
||||
if (normalizedPath === "/admin" || normalizedPath.startsWith("/admin/")) return "admin";
|
||||
const section = normalizedPath.startsWith("/") ? normalizedPath.slice(1) : normalizedPath;
|
||||
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;
|
||||
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;
|
||||
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
|
||||
window.history[replace ? "replaceState" : "pushState"](null, "", nextUrl);
|
||||
@@ -949,11 +987,16 @@
|
||||
paymentStep = "tariff";
|
||||
selectedMethod = payload.payment_methods?.[0]?.id || "";
|
||||
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";
|
||||
activeTab = section;
|
||||
activeTab = section === "admin" ? "settings" : section;
|
||||
screen = section;
|
||||
mode = "app";
|
||||
syncSectionPath(section, true);
|
||||
syncSectionPath(
|
||||
section,
|
||||
true,
|
||||
section === "admin" ? adminSectionFromPath(window.location.pathname) : null,
|
||||
);
|
||||
if (section === "devices" && payload.settings?.my_devices_enabled) {
|
||||
await loadDevices();
|
||||
}
|
||||
@@ -1002,6 +1045,105 @@
|
||||
|
||||
async function mockApi(path, options = {}) {
|
||||
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 === "/auth/email/request") return { ok: true };
|
||||
if (path === "/auth/email/verify" || path === "/auth/email/magic") {
|
||||
@@ -2042,6 +2184,27 @@
|
||||
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() {
|
||||
if (tariffMode) {
|
||||
if (singleTariffMode && tariffCatalog[0]?.key) {
|
||||
@@ -2702,8 +2865,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if screen === "admin" && isAdmin}
|
||||
<AdminPanel
|
||||
api={api}
|
||||
onClose={closeAdminPanel}
|
||||
onToast={(text) => showToast(text)}
|
||||
initialSection={adminSectionFromPath(window.location.pathname)}
|
||||
onSectionChange={handleAdminSectionChange}
|
||||
/>
|
||||
{:else}
|
||||
<div class="phone-screen">
|
||||
<div class="phone-screen" class:home-screen={screen === "home"}>
|
||||
{#if screen === "invite" || screen === "devices" || screen === "settings"}
|
||||
<header class="app-header accent-title">
|
||||
<div class="brand-row">
|
||||
@@ -2977,6 +3148,20 @@
|
||||
<small>{profileTelegramId}</small>
|
||||
</div>
|
||||
</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-divider" aria-hidden="true"></div>
|
||||
{#if user?.telegram_linked}
|
||||
@@ -3068,27 +3253,27 @@
|
||||
</Select.Root>
|
||||
</div>
|
||||
{#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} />
|
||||
<span><strong>{t("menu_support_button")}</strong></span>
|
||||
<ArrowRight size={17} />
|
||||
</button>
|
||||
{/if}
|
||||
{#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} />
|
||||
<span><strong>{t("wa_settings_user_agreement")}</strong></span>
|
||||
<ArrowRight size={17} />
|
||||
</button>
|
||||
{/if}
|
||||
{#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} />
|
||||
<span><strong>{t("wa_settings_privacy_policy")}</strong></span>
|
||||
<ArrowRight size={17} />
|
||||
</button>
|
||||
{/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} />
|
||||
<span><strong>{t("wa_logout")}</strong><small>{t("wa_end_session")}</small></span>
|
||||
<ArrowRight size={17} />
|
||||
@@ -3099,6 +3284,10 @@
|
||||
|
||||
{#if screen === "home" || screen === "invite" || screen === "devices" || screen === "settings"}
|
||||
<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}>
|
||||
<Home size={21} />
|
||||
<span>{t("wa_nav_home")}</span>
|
||||
@@ -3120,6 +3309,12 @@
|
||||
<SettingsIcon size={21} />
|
||||
<span>{t("wa_nav_settings")}</span>
|
||||
</button>
|
||||
{#if isAdmin}
|
||||
<button class="rail-admin-entry" type="button" on:click={openAdminPanel}>
|
||||
<Shield size={21} />
|
||||
<span>Админ-панель</span>
|
||||
</button>
|
||||
{/if}
|
||||
</nav>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,10 @@ from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, f
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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 (
|
||||
create_signed_telegram_oauth_state,
|
||||
create_telegram_oauth_nonce,
|
||||
@@ -148,7 +152,13 @@ def create_subscription_webapp_application(
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
) -> 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["dp"] = dp
|
||||
app["settings"] = settings
|
||||
@@ -179,6 +189,7 @@ def create_subscription_webapp_application(
|
||||
"severpay_service",
|
||||
"promo_code_service",
|
||||
"referral_service",
|
||||
"panel_service",
|
||||
):
|
||||
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined]
|
||||
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("/devices", 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/callback", telegram_oauth_callback_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/payments", create_payment_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:
|
||||
@@ -2942,6 +2956,8 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
await session.rollback()
|
||||
|
||||
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 {
|
||||
"user": {
|
||||
"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),
|
||||
"first_name": db_user.first_name,
|
||||
"language_code": lang,
|
||||
"is_admin": is_admin,
|
||||
},
|
||||
"subscription": _serialize_subscription(active, local_sub, lang),
|
||||
"referral": {
|
||||
|
||||
@@ -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)
|
||||
@@ -7,6 +7,7 @@ from . import message_log_dal
|
||||
from . import user_billing_dal
|
||||
from . import ad_dal
|
||||
from . import security_dal
|
||||
from . import app_settings_dal
|
||||
|
||||
__all__ = (
|
||||
"user_dal",
|
||||
@@ -18,6 +19,7 @@ __all__ = (
|
||||
"user_billing_dal",
|
||||
"ad_dal",
|
||||
"security_dal",
|
||||
"app_settings_dal",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -70,6 +70,14 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
|
||||
"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:
|
||||
from .dal.panel_sync_dal import get_panel_sync_status, update_panel_sync_status
|
||||
from sqlalchemy import text
|
||||
|
||||
@@ -531,6 +531,22 @@ MIGRATIONS: List[Migration] = [
|
||||
description="Add tariff catalog columns and traffic accounting tables",
|
||||
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
|
||||
)
|
||||
"""
|
||||
)
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -415,3 +415,17 @@ class AdAttribution(Base):
|
||||
|
||||
user = relationship("User")
|
||||
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)
|
||||
|
||||
@@ -7,6 +7,21 @@
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user