feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and fixes a real bug found while building it: payment provider credentials and enabled-flags were frozen in api.py's process at import time, so a Platega secret rotation via the settings UI would leave api.py verifying inbound webhooks against the OLD secret until a manual `mbs restart` — while bot.py (which does get restarted on save) already had the new one. Same class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and plan prices, neither of which had any settings UI at all before this. New `settings.py` module: get_plans()/get_plans_by_code() (live prices, falls back to config.py defaults), get_payment_settings(), get_hwid_settings(), yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed by a new batched legal.read_env_vars() (one file read for N keys instead of N reads) and legal.update_env_var() (moved out of api.py's private _update_env_var, which is now a one-line delegate to avoid duplicating the same env-file-rewrite logic in two places). api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/ HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants — every read goes through settings.py instead. payments.py no longer imports YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check payment, verify webhook signature) reads live credentials at call time. Every call site inside a loop hoists the live lookup before the loop first (same N+1 discipline as the rest of tonight), so this doesn't regress get_subscription's hot path — one settings.get_hwid_settings() call per request, same as before. New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices + a payments_enabled master toggle — there was previously no way to turn payment collection back off without deleting provider credentials), GET/POST /admin/api/hwid-settings. Both validate input strictly (prices: non-negative int; HWID limit: 1-1000) and reject the whole request instead of partially applying on bad input. admin.html: new "Тарифы" section in Платежи (price inputs rendered from the live plan list + payments toggle) and "Лимит устройств (HWID)" in Настройки, both using the existing .check checkbox / plain-input styling (no native <select>, per the earlier white-popup complaint). Removed the now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings hints and save-result messages, and added a doc-block for HWID (never had one) plus extended the Платежи doc-block to mention live-apply. Also dropped a dead `import links` in bot.py caught by pyflakes while verifying this. Verification: api.py/bot.py still can't be imported on this Windows machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again by a fresh pip attempt — same wall as every prior session), so relied on what's actually exercisable: py_compile + pyflakes (zero undefined names) across every module including api.py/bot.py, a real runtime test against an isolated .env fixture covering live price/toggle/HWID reads with zero reimport, write-idempotency (no duplicate .env lines on repeated saves), and the concrete bug this fixes end to end — computed an HMAC signature against an old Platega secret, rotated the secret via update_env_var (the same call the settings route makes), confirmed the old signature is now rejected and a new one computed against the rotated secret verifies, all in the same process with no reimport. Also ran the new CI step's exact heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions still won't trigger for this account (still under abuse-review, ticket open >2 days) so this is the same substitute-for-CI rigor used all night. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
1747d63539
commit
7d140711fd
8 changed files with 372 additions and 78 deletions
27
bot.py
27
bot.py
|
|
@ -8,11 +8,11 @@ from aiogram.client.default import DefaultBotProperties
|
|||
from aiogram.enums import ParseMode
|
||||
|
||||
import db
|
||||
import links
|
||||
import payments
|
||||
import settings
|
||||
import webhooks
|
||||
import xray_manager
|
||||
from config import BOT_TOKEN, ADMIN_IDS, PLANS, PLANS_BY_CODE, SUB_DOMAIN, SITE_DOMAIN, PAYMENTS_ENABLED
|
||||
from config import BOT_TOKEN, ADMIN_IDS, SUB_DOMAIN, SITE_DOMAIN
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
log = logging.getLogger("mbs-bot")
|
||||
|
|
@ -49,9 +49,10 @@ def nodes_kb(prefix: str) -> InlineKeyboardMarkup:
|
|||
|
||||
|
||||
def plans_kb(prefix: str, node_code: str) -> InlineKeyboardMarkup:
|
||||
payments_enabled = settings.get_payment_settings()["payments_enabled"]
|
||||
rows = []
|
||||
for p in PLANS:
|
||||
label = f"{p['label']} — {p['price']} ₽" if PAYMENTS_ENABLED and p["price"] > 0 else p["label"]
|
||||
for p in settings.get_plans():
|
||||
label = f"{p['label']} — {p['price']} ₽" if payments_enabled and p["price"] > 0 else p["label"]
|
||||
rows.append([InlineKeyboardButton(text=label, callback_data=f"{prefix}:{node_code}:{p['code']}")])
|
||||
rows.append([InlineKeyboardButton(text="Назад", callback_data="menu:get")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
|
@ -97,7 +98,7 @@ async def start_deeplink(message: Message, command: CommandObject):
|
|||
if err == "already_used":
|
||||
await message.answer("Этот код уже был использован.")
|
||||
return await send_main_menu(message)
|
||||
plan = PLANS_BY_CODE.get(gift["plan"])
|
||||
plan = settings.get_plans_by_code().get(gift["plan"])
|
||||
gift_node = db.get_node(gift["node"])
|
||||
if not plan or not gift_node:
|
||||
await message.answer("Этот подарок больше недоступен.")
|
||||
|
|
@ -162,10 +163,10 @@ def providers_kb(node_code: str, plan_code: str) -> InlineKeyboardMarkup:
|
|||
@dp.callback_query(F.data.startswith("plan:"))
|
||||
async def cb_plan(cb: CallbackQuery):
|
||||
_, node_code, plan_code = cb.data.split(":")
|
||||
plan = PLANS_BY_CODE[plan_code]
|
||||
plan = settings.get_plans_by_code()[plan_code]
|
||||
db.get_or_create_user(cb.from_user.id, cb.from_user.username)
|
||||
|
||||
if PAYMENTS_ENABLED and plan["price"] > 0 and payments.available_providers():
|
||||
if settings.get_payment_settings()["payments_enabled"] and plan["price"] > 0 and payments.available_providers():
|
||||
await cb.message.edit_text(
|
||||
f"<b>{plan['label']}</b> — {plan['price']} ₽\n\nВыбери способ оплаты:",
|
||||
reply_markup=providers_kb(node_code, plan_code),
|
||||
|
|
@ -194,7 +195,7 @@ async def cb_plan(cb: CallbackQuery):
|
|||
@dp.callback_query(F.data.startswith("pay:"))
|
||||
async def cb_pay(cb: CallbackQuery):
|
||||
_, provider, node_code, plan_code = cb.data.split(":")
|
||||
plan = PLANS_BY_CODE[plan_code]
|
||||
plan = settings.get_plans_by_code()[plan_code]
|
||||
node_row = db.get_node(node_code)
|
||||
payment_id = payments.new_payment_id()
|
||||
db.create_payment(payment_id, cb.from_user.id, node_code, plan_code, provider, plan["price"])
|
||||
|
|
@ -228,8 +229,9 @@ async def cb_mysub(cb: CallbackQuery):
|
|||
return await cb.answer()
|
||||
lines = ["<b>Твои подписки</b>\n"]
|
||||
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
||||
plans_by_code = settings.get_plans_by_code()
|
||||
for s in subs:
|
||||
plan = PLANS_BY_CODE.get(s["plan"], {}).get("label", s["plan"])
|
||||
plan = plans_by_code.get(s["plan"], {}).get("label", s["plan"])
|
||||
node_info = nodes_by_code.get(s["node"])
|
||||
node = node_info["label"] if node_info else s["node"]
|
||||
lines.append(f"{node} — {plan}, до {s['expires_at'][:10]}")
|
||||
|
|
@ -284,7 +286,7 @@ async def cb_admin_giftmake(cb: CallbackQuery):
|
|||
me = await bot.get_me()
|
||||
_bot_username = me.username
|
||||
link = f"https://t.me/{_bot_username}?start=gift_{code}"
|
||||
plan = PLANS_BY_CODE[plan_code]
|
||||
plan = settings.get_plans_by_code()[plan_code]
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="В админку", callback_data="menu:admin")]])
|
||||
await cb.message.edit_text(
|
||||
f"Гифт-ссылка готова ({db.get_node(node_code)['label']}, {plan['label']}):\n\n"
|
||||
|
|
@ -325,9 +327,10 @@ async def cb_admin_sync(cb: CallbackQuery):
|
|||
|
||||
|
||||
async def reconcile_pending_payments():
|
||||
if not PAYMENTS_ENABLED:
|
||||
if not settings.get_payment_settings()["payments_enabled"]:
|
||||
return
|
||||
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
||||
plans_by_code = settings.get_plans_by_code()
|
||||
for payment in db.list_payments():
|
||||
if payment["status"] != "pending" or not payment.get("external_id"):
|
||||
continue
|
||||
|
|
@ -336,7 +339,7 @@ async def reconcile_pending_payments():
|
|||
except Exception:
|
||||
continue
|
||||
if status in payments.PAID_STATUSES:
|
||||
plan = PLANS_BY_CODE.get(payment["plan"])
|
||||
plan = plans_by_code.get(payment["plan"])
|
||||
node_row = nodes_by_code.get(payment["node"])
|
||||
if not plan or not node_row:
|
||||
continue
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue