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 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
@@ -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": {
|
||||||
|
|||||||
@@ -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 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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
"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
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user