refactor: split backend domains and add API behavior coverage

This commit is contained in:
3252a8
2026-05-13 18:38:10 +03:00
parent a96007d48a
commit 7401649841
103 changed files with 10558 additions and 9832 deletions
+46 -2101
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
"""Domain modules for the admin Mini App API."""
+65
View File
@@ -0,0 +1,65 @@
# ruff: noqa: F401,F403,F405,I001
"""HTTP API powering the admin section of the subscription Mini App.
All routes require an authenticated webapp session (cookie or Bearer
token) AND the resolved Telegram user id must appear in
``settings.ADMIN_IDS``. Authorization is enforced via the
``_require_admin_user_id`` helper, never trusted from the client.
"""
from __future__ import annotations
import csv
import io
import json
import logging
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qsl, urlsplit, urlunsplit
from aiohttp import web
from pydantic import ValidationError
from sqlalchemy import Float, and_, case, cast, or_, select
from sqlalchemy import func as sa_func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.app.web.admin_settings_manifest import (
manifest_payload,
)
from bot.services.referral_service import ReferralService
from bot.services.settings_override_service import (
current_value,
update_overrides,
)
from bot.utils import MessageContent, send_message_via_queue
from bot.utils.message_queue import get_queue_manager
from config.settings import Settings
from config.tariffs_config import TariffsConfig
from db.dal import (
ad_dal,
app_settings_dal,
message_log_dal,
panel_sync_dal,
payment_dal,
promo_code_dal,
subscription_dal,
user_dal,
)
from db.models import (
AdCampaign,
MessageLog,
Payment,
PromoCode,
Subscription,
User,
UserTelegramAvatar,
)
logger = logging.getLogger(__name__)
# ─── Auth ──────────────────────────────────────────────────────────
__all__ = [name for name in globals() if not name.startswith("__")]
+69
View File
@@ -0,0 +1,69 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_ads_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
campaigns = await ad_dal.list_campaigns(session)
totals = await ad_dal.get_totals(session)
results = []
for campaign in campaigns:
try:
stats = await ad_dal.get_campaign_stats(session, campaign.ad_campaign_id)
except Exception:
stats = {}
results.append(_serialize_ad(campaign, stats))
return _ok({"campaigns": results, "totals": totals})
async def admin_ad_create_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
payload = await _read_json(request)
source = str(payload.get("source") or "").strip()
start_param = str(payload.get("start_param") or "").strip()
cost = float(payload.get("cost") or 0.0)
if not source or not start_param:
return _error(400, "invalid_payload")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
existing = await ad_dal.get_campaign_by_start_param(session, start_param)
if existing:
return _error(409, "duplicate_start_param")
campaign = await ad_dal.create_campaign(
session,
source=source,
start_param=start_param,
cost=cost,
)
await session.commit()
await session.refresh(campaign)
return _ok({"campaign": _serialize_ad(campaign)})
async def admin_ad_toggle_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
campaign_id = int(request.match_info["campaign_id"])
payload = await _read_json(request)
is_active = bool(payload.get("is_active", True))
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
ok = await ad_dal.toggle_campaign_active(session, campaign_id, is_active)
if not ok:
return _error(404, "not_found")
await session.commit()
return _ok({})
async def admin_ad_delete_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
campaign_id = int(request.match_info["campaign_id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
ok = await ad_dal.delete_campaign(session, campaign_id)
if not ok:
return _error(404, "not_found")
await session.commit()
return _ok({})
+58
View File
@@ -0,0 +1,58 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
def _require_admin_user_id(request: web.Request) -> int:
"""Return the authenticated user id, or raise 401/403 for non-admins."""
from bot.app.web.session import extract_authenticated_user_id
settings: Settings = request.app["settings"]
user_id = extract_authenticated_user_id(request)
if not user_id:
raise web.HTTPUnauthorized(
text=json.dumps({"ok": False, "error": "unauthorized"}),
content_type="application/json",
)
admin_ids = settings.ADMIN_IDS or []
db_user_telegram_id = request.get("admin_telegram_id")
if db_user_telegram_id is None:
raise web.HTTPForbidden(
text=json.dumps({"ok": False, "error": "forbidden"}),
content_type="application/json",
)
if int(db_user_telegram_id) not in {int(x) for x in admin_ids}:
raise web.HTTPForbidden(
text=json.dumps({"ok": False, "error": "forbidden"}),
content_type="application/json",
)
return int(user_id)
@web.middleware
async def admin_auth_middleware(request: web.Request, handler):
"""Resolve the Telegram id of the current user and stash it on the request.
Doing this once per request lets every admin route call
``_require_admin_user_id`` without re-querying the DB.
"""
if not request.path.startswith("/api/admin"):
return await handler(request)
from bot.app.web.session import extract_authenticated_user_id
user_id = extract_authenticated_user_id(request)
if user_id:
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if db_user and db_user.telegram_id:
request["admin_telegram_id"] = int(db_user.telegram_id)
elif db_user:
# No telegram_id yet (email-only user) — can't be an admin
request["admin_telegram_id"] = None
return await handler(request)
+54
View File
@@ -0,0 +1,54 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_broadcast_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
payload = await _read_json(request)
text = str(payload.get("text") or "").strip()
target = str(payload.get("target") or "all").strip().lower()
if not text:
return _error(400, "empty_text")
if target not in {"all", "active", "inactive"}:
target = "all"
queue_manager = get_queue_manager()
if not queue_manager:
return _error(503, "queue_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
if target == "active":
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
elif target == "inactive":
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
else:
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
sent = 0
failed = 0
for uid in user_ids:
try:
await send_message_via_queue(
queue_manager,
int(uid),
MessageContent(content_type="text", text=text),
parse_mode="HTML",
disable_web_page_preview=True,
)
sent += 1
except Exception as exc:
failed += 1
logger.debug("Broadcast queue failed for %s: %s", uid, exc)
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_broadcast_webapp",
"content": f"target={target} sent={sent} failed={failed} text={text[:120]}",
"is_admin_event": True,
},
)
return _ok({"queued": sent, "failed": failed, "target": target})
+367
View File
@@ -0,0 +1,367 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
def _ok(payload: Dict[str, Any], **extra) -> web.Response:
body = {"ok": True, **payload, **extra}
return web.json_response(body)
def _error(status: int, code: str, message: str = "") -> web.Response:
return web.json_response(
{"ok": False, "error": code, "message": message or code},
status=status,
)
async def _read_json(request: web.Request) -> Dict[str, Any]:
try:
data = await request.json()
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _serialize_user(user: User) -> Dict[str, Any]:
return {
"user_id": int(user.user_id),
"telegram_id": int(user.telegram_id) if user.telegram_id else None,
"telegram_photo_url": user.telegram_photo_url,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"email": user.email,
"language_code": user.language_code,
"is_banned": bool(user.is_banned),
"registration_date": user.registration_date.isoformat() if user.registration_date else None,
"panel_user_uuid": user.panel_user_uuid,
"referral_code": user.referral_code,
"referred_by_id": int(user.referred_by_id) if user.referred_by_id else None,
}
def _premium_limit_bytes_from_subscription(sub: Subscription) -> int:
premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0)
return (
int(sub.premium_baseline_bytes or 0)
+ int(sub.premium_topup_balance_bytes or 0)
+ int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
+ premium_bonus_bytes
)
def _premium_traffic_list_payload(sub: Optional[Subscription]) -> Dict[str, Any]:
"""Premium traffic column when subscription has a finite premium quota (bytes > 0).
Note: ``Subscription.premium_is_limited`` in the DB means *quota exhausted* for panel
routing, not 'tariff includes premium traffic' — do not use it here.
"""
if sub is None:
return {"state": "none"}
if bool(getattr(sub, "premium_unlimited_override", False)):
return {
"state": "unlimited",
"unlimited": True,
"used_bytes": int(sub.premium_used_bytes or 0),
"limit_bytes": None,
"percent": None,
}
limit_bytes = _premium_limit_bytes_from_subscription(sub)
if limit_bytes <= 0:
return {"state": "none"}
used_bytes = int(sub.premium_used_bytes or 0)
ratio = float(used_bytes) / float(limit_bytes) if limit_bytes else 0.0
pct = int(max(0, min(100, round(ratio * 100))))
if ratio >= 1.0:
state = "critical"
elif ratio >= 0.85:
state = "warn"
else:
state = "good"
return {
"state": state,
"unlimited": False,
"used_bytes": used_bytes,
"limit_bytes": limit_bytes,
"percent": pct,
}
def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0)
regular_bonus_bytes = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False))
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
premium_limit_bytes = _premium_limit_bytes_from_subscription(sub)
return {
"subscription_id": int(sub.subscription_id),
"panel_user_uuid": sub.panel_user_uuid,
"panel_subscription_uuid": sub.panel_subscription_uuid,
"start_date": sub.start_date.isoformat() if sub.start_date else None,
"end_date": sub.end_date.isoformat() if sub.end_date else None,
"duration_months": sub.duration_months,
"is_active": bool(sub.is_active),
"status_from_panel": sub.status_from_panel,
"traffic_limit_bytes": sub.traffic_limit_bytes,
"traffic_used_bytes": sub.traffic_used_bytes,
"tier_baseline_bytes": sub.tier_baseline_bytes,
"topup_balance_bytes": sub.topup_balance_bytes,
"premium_used_bytes": sub.premium_used_bytes,
"premium_limit_bytes": premium_limit_bytes,
"premium_baseline_bytes": sub.premium_baseline_bytes,
"premium_topup_balance_bytes": sub.premium_topup_balance_bytes,
"premium_topup_used_bytes": getattr(sub, "premium_topup_used_bytes", 0),
"premium_bonus_bytes": premium_bonus_bytes,
"regular_bonus_bytes": regular_bonus_bytes,
"regular_unlimited_override": regular_unlimited_override,
"premium_unlimited_override": premium_unlimited_override,
"premium_is_limited": bool(sub.premium_is_limited),
"tariff_key": sub.tariff_key,
"auto_renew_enabled": bool(sub.auto_renew_enabled),
"provider": sub.provider,
"is_throttled": bool(sub.is_throttled),
}
def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Optional[float]]:
"""For traffic purchases: ``(regular_gb, premium_gb)``. Other payments → (None, None)."""
if payment.purchased_gb is None:
return None, None
try:
gb = float(payment.purchased_gb)
except (TypeError, ValueError):
return None, None
sm = (payment.sale_mode or "").strip()
if not sm:
return None, None
base = sm.split("@", 1)[0].split("|", 1)[0].lower()
if base == "premium_topup":
return None, gb
if base in {"traffic", "traffic_package", "topup"}:
return gb, None
return None, None
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
"""Human-facing name for payments tables: TG profile name, else email, else user id."""
if loaded_user is None:
return str(payment_user_id)
tid = getattr(loaded_user, "telegram_id", None)
if tid is not None:
fn = (getattr(loaded_user, "first_name", None) or "").strip()
ln = (getattr(loaded_user, "last_name", None) or "").strip()
full = f"{fn} {ln}".strip()
if full:
return full
un = (getattr(loaded_user, "username", None) or "").strip()
if un:
return un if un.startswith("@") else f"@{un}"
return str(payment_user_id)
email = (getattr(loaded_user, "email", None) or "").strip()
if email:
return email
return str(payment_user_id)
def _serialize_payment(payment: Payment) -> Dict[str, Any]:
# Avoid lazy-loading `payment.user` outside an active SQLAlchemy session.
# Some admin routes serialize payments after the session scope is closed.
telegram_id = None
loaded_user = payment.__dict__.get("user")
user_label = _payment_user_display_label(loaded_user, int(payment.user_id))
if loaded_user is not None:
tid = getattr(loaded_user, "telegram_id", None)
if tid is not None:
try:
telegram_id = int(tid)
except (TypeError, ValueError):
telegram_id = None
reg_gb, prem_gb = _payment_traffic_gb_split(payment)
return {
"payment_id": int(payment.payment_id),
"user_id": int(payment.user_id),
"user_label": user_label,
"telegram_id": telegram_id,
"traffic_regular_gb": reg_gb,
"traffic_premium_gb": prem_gb,
"provider": payment.provider,
"provider_payment_id": payment.provider_payment_id,
"amount": float(payment.amount),
"currency": payment.currency,
"status": payment.status,
"description": payment.description,
"subscription_duration_months": payment.subscription_duration_months,
"sale_mode": payment.sale_mode,
"tariff_key": payment.tariff_key,
"purchased_gb": payment.purchased_gb,
"purchased_hwid_devices": payment.purchased_hwid_devices,
"created_at": payment.created_at.isoformat() if payment.created_at else None,
}
def _serialize_promo(promo: PromoCode) -> Dict[str, Any]:
return {
"id": int(promo.promo_code_id),
"code": promo.code,
"bonus_days": int(promo.bonus_days),
"max_activations": int(promo.max_activations),
"current_activations": int(promo.current_activations or 0),
"is_active": bool(promo.is_active),
"valid_until": promo.valid_until.isoformat() if promo.valid_until else None,
"created_at": promo.created_at.isoformat() if promo.created_at else None,
"created_by_admin_id": int(promo.created_by_admin_id)
if promo.created_by_admin_id
else None,
}
def _serialize_ad(campaign: AdCampaign, totals: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return {
"id": int(campaign.ad_campaign_id),
"source": campaign.source,
"start_param": campaign.start_param,
"cost": float(campaign.cost or 0),
"is_active": bool(campaign.is_active),
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
"stats": totals or {},
}
def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
return {
"log_id": int(entry.log_id),
"user_id": int(entry.user_id) if entry.user_id else None,
"telegram_username": entry.telegram_username,
"telegram_first_name": entry.telegram_first_name,
"event_type": entry.event_type,
"content": entry.content,
"is_admin_event": bool(entry.is_admin_event),
"target_user_id": int(entry.target_user_id) if entry.target_user_id else None,
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
}
def _tariffs_config_path(settings: Settings) -> Path:
return Path(settings.TARIFFS_CONFIG_PATH).expanduser()
def _tariffs_config_payload(config: TariffsConfig) -> Dict[str, Any]:
return config.model_dump(mode="json", exclude_none=True)
def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None:
data = _tariffs_config_payload(config)
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
try:
tmp_path.write_text(payload, encoding="utf-8")
tmp_path.replace(path)
except PermissionError:
# A docker-compose single-file bind mount can make /app/config
# unwritable while the mounted tariffs.json itself is writable.
# Fall back to updating the existing file in-place.
if tmp_path.exists():
try:
tmp_path.unlink()
except OSError:
pass
path.write_text(payload, encoding="utf-8")
def _panel_node_uuid_key(node: Dict[str, Any]) -> str:
uid = node.get("nodeUuid") or node.get("node_uuid") or node.get("uuid") or node.get("id")
return str(uid).strip().lower() if uid else ""
def _panel_node_users_online(node: Dict[str, Any]) -> Optional[int]:
uo = node.get("usersOnline")
if uo is None:
uo = node.get("users_online")
if uo is None:
uo = node.get("onlineUsers") or node.get("online_users")
if uo is None:
mg = node.get("metricGroups")
if isinstance(mg, dict):
uo = mg.get("onlineUsers") or mg.get("online_users")
if uo is None:
return None
try:
return int(uo)
except (TypeError, ValueError):
return None
def _panel_nodes_online_by_uuid(nodes_payload: Any) -> Dict[str, int]:
"""Build node_uuid(lower) -> usersOnline from GET /system/stats/nodes payload."""
out: Dict[str, int] = {}
raw_list: Optional[List[Any]] = None
if isinstance(nodes_payload, list):
raw_list = nodes_payload
elif isinstance(nodes_payload, dict):
raw_list = nodes_payload.get("nodes")
if raw_list is None:
raw_list = nodes_payload.get("items") or nodes_payload.get("data")
if not isinstance(raw_list, list):
return out
for n in raw_list:
if not isinstance(n, dict):
continue
key = _panel_node_uuid_key(n)
if not key:
continue
online = _panel_node_users_online(n)
if online is not None:
out[key] = online
return out
def _enrich_bandwidth_nodes_with_online(
bw: Any,
online_by_uuid: Dict[str, int],
online_by_name: Optional[Dict[str, int]] = None,
) -> None:
"""Attach usersOnline to topNodes/series (UUID and optional node name)."""
if not isinstance(bw, dict):
return
if not online_by_uuid and not online_by_name:
return
for key in ("topNodes", "series"):
arr = bw.get(key)
if not isinstance(arr, list):
continue
for item in arr:
if not isinstance(item, dict):
continue
if item.get("usersOnline") is not None:
continue
uid = item.get("uuid") or item.get("nodeUuid") or item.get("node_uuid")
if uid and online_by_uuid:
hit = online_by_uuid.get(str(uid).strip().lower())
if hit is not None:
item["usersOnline"] = hit
continue
if online_by_name:
nm = item.get("name")
if nm and isinstance(nm, str):
hitn = online_by_name.get(nm.strip().lower())
if hitn is not None:
item["usersOnline"] = hitn
def _build_admin_webapp_referral_link(
base_url: Optional[str], referral_code: Optional[str]
) -> Optional[str]:
"""Mirror of ``subscription_webapp._build_webapp_referral_link``.
Kept local to avoid a cross-module import cycle (subscription_webapp
imports admin_api).
"""
if not base_url or not referral_code:
return None
parts = urlsplit(base_url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query["ref"] = f"u{referral_code}"
new_query = "&".join(f"{k}={v}" for k, v in query.items())
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
+36
View File
@@ -0,0 +1,36 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_logs_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
page = max(0, int(request.query.get("page", 0) or 0))
page_size = min(200, max(1, int(request.query.get("page_size", 50) or 50)))
user_filter = request.query.get("user_id")
async with async_session_factory() as session:
if user_filter:
try:
user_id = int(user_filter)
except (TypeError, ValueError):
return _error(400, "invalid_user_id")
entries = await message_log_dal.get_user_message_logs(
session, user_id, page_size, page * page_size
)
total = await message_log_dal.count_user_message_logs(session, user_id)
else:
entries = await message_log_dal.get_all_message_logs(
session, page_size, page * page_size
)
total = await message_log_dal.count_all_message_logs(session)
return _ok(
{
"logs": [_serialize_log(entry) for entry in entries],
"page": page,
"page_size": page_size,
"total": int(total or 0),
}
)
+35
View File
@@ -0,0 +1,35 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_panel_internal_squads_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
panel_service = request.app.get("panel_service")
if panel_service is None:
return _error(503, "panel_unavailable", "Panel service unavailable")
try:
squads = await panel_service.get_internal_squads()
except Exception as exc:
logger.exception("Failed to load internal squads from panel")
return _error(502, "panel_request_failed", str(exc))
if squads is None:
return _error(502, "panel_request_failed", "Unable to load internal squads")
items = []
for squad in squads:
if not isinstance(squad, dict):
continue
uuid = squad.get("uuid") or squad.get("id")
if not uuid:
continue
items.append(
{
"uuid": str(uuid),
"name": squad.get("name") or squad.get("title") or str(uuid),
"members_count": squad.get("membersCount")
or squad.get("usersCount")
or squad.get("members_count"),
"active_inbounds_count": squad.get("activeInboundsCount")
or squad.get("active_inbounds_count"),
}
)
return _ok({"squads": items})
+95
View File
@@ -0,0 +1,95 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_payments_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
page = max(0, int(request.query.get("page", 0) or 0))
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
async with async_session_factory() as session:
from sqlalchemy.orm import selectinload
stmt = (
select(Payment)
.options(selectinload(Payment.user))
.order_by(Payment.created_at.desc())
.offset(page * page_size)
.limit(page_size)
)
rows = (await session.execute(stmt)).scalars().all()
total = await payment_dal.get_payments_count(session)
return _ok(
{
"payments": [_serialize_payment(p) for p in rows],
"page": page,
"page_size": page_size,
"total": int(total or 0),
}
)
async def admin_payments_export_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
from sqlalchemy.orm import selectinload
stmt = (
select(Payment)
.options(selectinload(Payment.user))
.order_by(Payment.created_at.desc())
.limit(10000)
)
rows = (await session.execute(stmt)).scalars().all()
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(
[
"payment_id",
"user_id",
"user_label",
"provider",
"provider_payment_id",
"amount",
"currency",
"status",
"description",
"duration_months",
"sale_mode",
"tariff_key",
"created_at",
]
)
for p in rows:
label = _payment_user_display_label(p.user, int(p.user_id)) if p.user else str(p.user_id)
writer.writerow(
[
p.payment_id,
p.user_id,
label,
p.provider,
p.provider_payment_id or "",
p.amount,
p.currency,
p.status,
p.description or "",
p.subscription_duration_months or "",
p.sale_mode or "",
p.tariff_key or "",
p.created_at.isoformat() if p.created_at else "",
]
)
response = web.Response(
body=buffer.getvalue().encode("utf-8-sig"),
content_type="text/csv",
charset="utf-8",
)
response.headers["Content-Disposition"] = 'attachment; filename="payments.csv"'
return response
+96
View File
@@ -0,0 +1,96 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_promos_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
page = max(0, int(request.query.get("page", 0) or 0))
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
async with async_session_factory() as session:
promos = await promo_code_dal.get_all_promo_codes_with_details(
session, limit=page_size, offset=page * page_size
)
total = await promo_code_dal.get_promo_codes_count(session)
return _ok(
{
"promos": [_serialize_promo(p) for p in promos],
"page": page,
"page_size": page_size,
"total": int(total or 0),
}
)
async def admin_promo_create_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
payload = await _read_json(request)
code = str(payload.get("code") or "").strip().upper()
bonus_days = int(payload.get("bonus_days") or 0)
max_activations = int(payload.get("max_activations") or 0)
valid_days = payload.get("valid_days")
if not code or bonus_days <= 0 or max_activations <= 0:
return _error(400, "invalid_payload")
valid_until = None
if valid_days:
try:
valid_until = datetime.now(timezone.utc) + timedelta(days=int(valid_days))
except (TypeError, ValueError):
return _error(400, "invalid_valid_days")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
existing = await promo_code_dal.get_promo_code_by_code(session, code)
if existing:
return _error(409, "duplicate_code")
promo = await promo_code_dal.create_promo_code(
session,
{
"code": code,
"bonus_days": bonus_days,
"max_activations": max_activations,
"valid_until": valid_until,
"created_by_admin_id": actor_id,
"is_active": True,
},
)
await session.commit()
return _ok({"promo": _serialize_promo(promo)})
async def admin_promo_update_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
promo_id = int(request.match_info["promo_id"])
payload = await _read_json(request)
update_data: Dict[str, Any] = {}
if "is_active" in payload:
update_data["is_active"] = bool(payload["is_active"])
if "bonus_days" in payload and payload["bonus_days"] is not None:
update_data["bonus_days"] = int(payload["bonus_days"])
if "max_activations" in payload and payload["max_activations"] is not None:
update_data["max_activations"] = int(payload["max_activations"])
if not update_data:
return _error(400, "no_changes")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
promo = await promo_code_dal.update_promo_code(session, promo_id, update_data)
if not promo:
return _error(404, "not_found")
await session.commit()
await session.refresh(promo)
return _ok({"promo": _serialize_promo(promo)})
async def admin_promo_delete_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
promo_id = int(request.match_info["promo_id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
promo = await promo_code_dal.delete_promo_code(session, promo_id)
if not promo:
return _error(404, "not_found")
await session.commit()
return _ok({})
+57
View File
@@ -0,0 +1,57 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
def setup_admin_routes(app: web.Application) -> None:
router = app.router
router.add_get("/api/admin/me", admin_me_route)
router.add_get("/api/admin/stats", admin_stats_route)
router.add_get("/api/admin/users", admin_users_list_route)
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route
)
router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/premium-override",
admin_user_premium_override_route,
)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/regular-traffic-override",
admin_user_regular_traffic_override_route,
)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/traffic-grant",
admin_user_traffic_grant_route,
)
router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route)
router.add_get("/api/admin/payments", admin_payments_list_route)
router.add_get("/api/admin/payments/export.csv", admin_payments_export_route)
router.add_get("/api/admin/promos", admin_promos_list_route)
router.add_post("/api/admin/promos", admin_promo_create_route)
router.add_patch("/api/admin/promos/{promo_id:\\d+}", admin_promo_update_route)
router.add_delete("/api/admin/promos/{promo_id:\\d+}", admin_promo_delete_route)
router.add_get("/api/admin/logs", admin_logs_route)
router.add_post("/api/admin/broadcast", admin_broadcast_route)
router.add_post("/api/admin/sync", admin_sync_route)
router.add_get("/api/admin/ads", admin_ads_list_route)
router.add_post("/api/admin/ads", admin_ad_create_route)
router.add_post("/api/admin/ads/{campaign_id:\\d+}/toggle", admin_ad_toggle_route)
router.add_delete("/api/admin/ads/{campaign_id:\\d+}", admin_ad_delete_route)
router.add_get("/api/admin/settings", admin_settings_get_route)
router.add_patch("/api/admin/settings", admin_settings_patch_route)
router.add_get("/api/admin/tariffs", admin_tariffs_get_route)
router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
+74
View File
@@ -0,0 +1,74 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_settings_get_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
overrides = await app_settings_dal.get_overrides_with_meta(session)
overrides_by_key = {entry["key"]: entry for entry in overrides}
fields = manifest_payload()
sections: Dict[str, Dict[str, Any]] = {}
for field in fields:
key = field["key"]
section_id = field["section"]
if section_id not in sections:
sections[section_id] = {
"id": section_id,
"order": field["section_order"],
"fields": [],
}
override = overrides_by_key.get(key)
value = current_value(settings, key)
is_secret = bool(field.get("secret"))
response_field = {
**field,
"value": "" if is_secret else value,
"overridden": bool(override),
"updated_at": override.get("updated_at") if override else None,
}
if is_secret:
response_field["has_value"] = bool(value)
sections[section_id]["fields"].append(response_field)
ordered_sections = sorted(sections.values(), key=lambda s: s["order"])
return _ok({"sections": ordered_sections})
async def admin_settings_patch_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
payload = await _read_json(request)
updates = payload.get("updates") or {}
deletes = payload.get("deletes") or []
if not isinstance(updates, dict):
return _error(400, "invalid_updates")
if not isinstance(deletes, list):
return _error(400, "invalid_deletes")
result = await update_overrides(
settings,
async_session_factory,
updates=updates,
deletes=deletes,
actor_id=actor_id,
)
if not result.get("ok"):
return web.json_response(
{"ok": False, "error": "validation_failed", "errors": result.get("errors", {})},
status=400,
)
# Bust the public webapp settings cache so users see new values immediately.
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
+89
View File
@@ -0,0 +1,89 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_me_route(request: web.Request) -> web.Response:
user_id = _require_admin_user_id(request)
settings: Settings = request.app["settings"]
return _ok({}, user_id=user_id, admin_ids=list(settings.ADMIN_IDS or []))
async def admin_stats_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
user_stats = await user_dal.get_enhanced_user_statistics(session)
financial_stats = await payment_dal.get_financial_statistics(session)
sync_status = await panel_sync_dal.get_panel_sync_status(session)
recent_payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=10)
payload = {
"users": user_stats,
"financial": financial_stats,
"panel_sync": {
"status": sync_status.status if sync_status else "never_run",
"last_sync_time": sync_status.last_sync_time.isoformat()
if sync_status and sync_status.last_sync_time
else None,
"details": sync_status.details if sync_status else None,
"users_processed": sync_status.users_processed_from_panel if sync_status else 0,
"subscriptions_synced": sync_status.subscriptions_synced if sync_status else 0,
},
"recent_payments": [_serialize_payment(p) for p in recent_payments],
}
panel_service = request.app.get("panel_service")
if panel_service is not None:
try:
system = await panel_service.get_system_stats()
bandwidth = await panel_service.get_bandwidth_stats()
panel_body: Dict[str, Any] = {
"system": system or {},
"bandwidth": bandwidth or {},
}
try:
nodes = await panel_service.get_nodes_statistics()
panel_body["nodes"] = nodes or {}
except Exception as exc_nodes: # pragma: no cover - optional endpoint
logger.debug("Panel nodes stats unavailable: %s", exc_nodes)
panel_body["nodes"] = {}
try:
today = datetime.now(timezone.utc).date()
start_d = today - timedelta(days=7)
nodes_bw = await panel_service.get_nodes_bandwidth_usage(
start=start_d.isoformat(),
end=today.isoformat(),
top_nodes_limit=64,
)
panel_body["nodes_bandwidth"] = nodes_bw or {}
except Exception as exc_nb: # pragma: no cover - optional endpoint
logger.debug("Panel nodes bandwidth range unavailable: %s", exc_nb)
panel_body["nodes_bandwidth"] = {}
try:
online_map = _panel_nodes_online_by_uuid(panel_body.get("nodes"))
lookups = await panel_service.get_nodes_online_lookups()
for k, v in lookups.get("byUuid", {}).items():
online_map[k] = v
_enrich_bandwidth_nodes_with_online(
panel_body.get("nodes_bandwidth"),
online_map,
lookups.get("byName") or {},
)
except Exception as exc_merge: # pragma: no cover
logger.debug("Panel nodes online merge skipped: %s", exc_merge)
payload["panel"] = panel_body
except Exception as exc:
logger.debug("Panel stats unavailable: %s", exc)
payload["panel"] = {"error": "unavailable"}
queue_manager = get_queue_manager()
if queue_manager:
try:
payload["queue"] = queue_manager.get_queue_stats()
except Exception: # pragma: no cover - defensive
payload["queue"] = None
payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
return _ok(payload)
+23
View File
@@ -0,0 +1,23 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_sync_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
panel_service = request.app.get("panel_service")
if panel_service is None:
return _error(503, "panel_unavailable")
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
i18n = request.app.get("i18n")
from bot.handlers.admin.sync_admin import perform_sync
async with async_session_factory() as session:
result = await perform_sync(
panel_service=panel_service,
session=session,
settings=settings,
i18n_instance=i18n,
)
return _ok({"result": result or {}})
+63
View File
@@ -0,0 +1,63 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_tariffs_get_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
path = _tariffs_config_path(settings)
try:
config = settings.tariffs_config
except Exception as exc:
logger.warning("Invalid tariffs config requested from admin UI: %s", exc)
return _error(400, "invalid_tariffs_config", str(exc))
if config is None:
return _ok(
{
"exists": path.exists(),
"path": str(path),
"catalog": {
"default_tariff": "",
"topup_packages_default": {"rub": [], "stars": []},
"tariffs": [],
},
}
)
return _ok(
{
"exists": True,
"path": str(path),
"catalog": _tariffs_config_payload(config),
}
)
async def admin_tariffs_save_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
catalog = payload.get("catalog") if "catalog" in payload else payload
if not isinstance(catalog, dict):
return _error(400, "invalid_payload", "catalog must be an object")
try:
config = TariffsConfig.model_validate(catalog)
except (ValidationError, ValueError) as exc:
return _error(400, "invalid_tariffs_config", str(exc))
path = _tariffs_config_path(settings)
try:
_write_tariffs_config_file(path, config)
except OSError as exc:
logger.exception("Failed to write tariffs config to %s", path)
return _error(500, "write_failed", str(exc))
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
+925
View File
@@ -0,0 +1,925 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_users_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
page = max(0, int(request.query.get("page", 0) or 0))
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
query = (request.query.get("q") or "").strip()
filter_value = (request.query.get("filter") or "all").lower()
panel_status = (request.query.get("panel_status") or "all").lower()
premium_traffic = (request.query.get("premium_traffic") or "all").lower()
sort_value = (request.query.get("sort") or "registered_desc").lower()
async with async_session_factory() as session:
users, total = await _filter_and_sort_users(
session,
query=query,
filter_value=filter_value,
panel_status=panel_status,
premium_traffic=premium_traffic,
sort_value=sort_value,
page=page,
page_size=page_size,
)
statuses = await _bulk_user_statuses(session, [u.user_id for u in users])
cached_avatar_ids = await _bulk_user_avatar_keys(session, [u.user_id for u in users])
active_subs = await _bulk_active_subscriptions_for_users(
session, [u.user_id for u in users]
)
serialized = []
for user in users:
payload = _serialize_user(user)
status_payload = statuses.get(user.user_id) or {"status": "bot_only", "end_date": None}
payload["panel_status"] = status_payload.get("status")
if status_payload.get("status") == "expired" and status_payload.get("end_date"):
payload["panel_status_expired_at"] = status_payload["end_date"]
payload["avatar_url"] = (
f"/api/admin/users/{user.user_id}/avatar?v={cached_avatar_ids[user.user_id]}"
if user.user_id in cached_avatar_ids
else None
)
payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id))
serialized.append(payload)
return _ok(
{
"users": serialized,
"page": page,
"page_size": page_size,
"total": total,
}
)
async def _bulk_user_statuses(
session: AsyncSession, user_ids: List[int]
) -> Dict[int, Dict[str, Optional[str]]]:
"""Return active subscription status for a batch of users.
Returns the panel status (active/expired/limited/disabled) when an active
subscription exists, otherwise ``"bot_only"`` when the user is in the bot
but has no panel subscription.
"""
if not user_ids:
return {}
stmt = (
select(
Subscription.user_id,
Subscription.status_from_panel,
Subscription.is_active,
Subscription.end_date,
)
.where(Subscription.user_id.in_(user_ids))
.order_by(Subscription.is_active.desc(), Subscription.end_date.desc().nullslast())
)
rows = (await session.execute(stmt)).all()
out: Dict[int, Dict[str, Optional[str]]] = {}
for uid, panel_status, is_active, end_date in rows:
if uid in out:
continue
if is_active:
status = (panel_status or "active").lower()
else:
status = (panel_status or "expired").lower()
out[uid] = {
"status": status,
"end_date": end_date.isoformat() if end_date else None,
}
for uid in user_ids:
if uid not in out:
out[uid] = {"status": "bot_only", "end_date": None}
return out
async def _bulk_user_avatar_keys(session: AsyncSession, user_ids: List[int]) -> Dict[int, str]:
"""Return ``{user_id: cache_key}`` for users with a cached Telegram avatar.
The cache key is the row's ``updated_at`` timestamp — used as a
cache-buster query param so the browser refetches when the avatar
changes.
"""
if not user_ids:
return {}
stmt = select(UserTelegramAvatar.user_id, UserTelegramAvatar.updated_at).where(
UserTelegramAvatar.user_id.in_(user_ids)
)
rows = (await session.execute(stmt)).all()
return {int(uid): (updated_at.isoformat() if updated_at else "") for uid, updated_at in rows}
async def admin_user_avatar_route(request: web.Request) -> web.Response:
"""Serve the cached Telegram avatar for any user (admin-only).
Mirrors ``/api/account/avatar`` but takes a ``user_id`` from the URL
and uses admin auth. Only the cached blob from
``user_telegram_avatars`` is served — refreshing from Telegram is the
job of the user-facing endpoint, so the admin list never blocks on a
Telegram round-trip.
"""
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
avatar = await session.get(UserTelegramAvatar, target_id)
if not avatar:
raise web.HTTPNotFound(text="avatar_not_cached")
etag = (
f'W/"avatar-{target_id}-{int(avatar.updated_at.timestamp())}"'
if avatar.updated_at
else None
)
if etag and request.headers.get("If-None-Match") == etag:
return web.Response(status=304, headers={"ETag": etag})
response = web.Response(
body=bytes(avatar.image_bytes),
content_type=avatar.content_type or "image/jpeg",
)
response.headers["Cache-Control"] = "private, max-age=3600"
if etag:
response.headers["ETag"] = etag
return response
def _ranked_active_subscriptions_sq(now: datetime):
"""Latest active subscription per user (same ordering as subscription_dal)."""
rn = sa_func.row_number().over(
partition_by=Subscription.user_id,
order_by=(
Subscription.end_date.desc(),
Subscription.subscription_id.desc(),
),
)
inner = (
select(
Subscription.user_id,
Subscription.subscription_id,
Subscription.premium_used_bytes,
Subscription.premium_baseline_bytes,
Subscription.premium_topup_balance_bytes,
Subscription.premium_topup_used_bytes,
Subscription.premium_bonus_bytes,
Subscription.premium_unlimited_override,
rn.label("rn"),
)
.where(
Subscription.is_active.is_(True),
Subscription.end_date > now,
)
.subquery()
)
return select(inner).where(inner.c.rn == 1).subquery(name="ranked_active_sub")
async def _bulk_active_subscriptions_for_users(
session: AsyncSession, user_ids: List[int]
) -> Dict[int, Subscription]:
"""Active subscription row per user (for admin list premium traffic column)."""
if not user_ids:
return {}
now = datetime.now(timezone.utc)
stmt = (
select(Subscription)
.where(
Subscription.user_id.in_(user_ids),
Subscription.is_active.is_(True),
Subscription.end_date > now,
)
.order_by(
Subscription.user_id.asc(),
Subscription.end_date.desc(),
Subscription.subscription_id.desc(),
)
)
rows = (await session.execute(stmt)).scalars().all()
out: Dict[int, Subscription] = {}
for sub in rows:
uid = int(sub.user_id)
if uid not in out:
out[uid] = sub
return out
async def _filter_and_sort_users(
session: AsyncSession,
*,
query: str = "",
filter_value: str,
panel_status: str = "all",
premium_traffic: str = "all",
sort_value: str,
page: int,
page_size: int,
) -> tuple[List[User], int]:
"""Return paginated users with optional search, filter and sort applied."""
now = datetime.now(timezone.utc)
sort_key = (sort_value or "registered_desc").lower()
pt_filter = (premium_traffic or "all").lower()
needs_premium_sq = pt_filter != "all" or sort_key in {
"premium_ratio_asc",
"premium_ratio_desc",
}
stmt = select(User)
count_stmt = select(sa_func.count(User.user_id))
sq = None
ratio_expr = None
plim_expr = None
pu_expr = None
if needs_premium_sq:
sq = _ranked_active_subscriptions_sq(now)
stmt = stmt.outerjoin(sq, User.user_id == sq.c.user_id)
count_stmt = count_stmt.outerjoin(sq, User.user_id == sq.c.user_id)
pb = sa_func.coalesce(sq.c.premium_bonus_bytes, 0)
plim_expr = (
sa_func.coalesce(sq.c.premium_baseline_bytes, 0)
+ sa_func.coalesce(sq.c.premium_topup_balance_bytes, 0)
+ sa_func.coalesce(sq.c.premium_topup_used_bytes, 0)
+ pb
)
pu_expr = sa_func.coalesce(sq.c.premium_used_bytes, 0)
ratio_expr = case(
(sq.c.user_id.is_(None), None),
(sq.c.premium_unlimited_override.is_(True), None),
(plim_expr <= 0, None),
else_=cast(pu_expr, Float) / cast(plim_expr, Float),
)
search_cond = _user_search_condition(query)
if search_cond is not None:
stmt = stmt.where(search_cond)
count_stmt = count_stmt.where(search_cond)
f = (filter_value or "all").lower()
if f == "banned":
cond = User.is_banned.is_(True)
elif f == "active":
cond = User.is_banned.is_(False)
elif f == "tg_linked":
cond = User.telegram_id.is_not(None)
elif f == "no_tg":
cond = User.telegram_id.is_(None)
elif f == "email_linked":
cond = User.email.is_not(None)
elif f == "no_email":
cond = User.email.is_(None)
elif f == "panel_linked":
cond = User.panel_user_uuid.is_not(None)
else:
cond = None
if cond is not None:
stmt = stmt.where(cond)
count_stmt = count_stmt.where(cond)
panel_cond = _user_panel_status_condition(panel_status)
if panel_cond is not None:
stmt = stmt.where(panel_cond)
count_stmt = count_stmt.where(panel_cond)
if needs_premium_sq and sq is not None and plim_expr is not None and pu_expr is not None:
if pt_filter == "none":
premium_cond = or_(
sq.c.user_id.is_(None),
and_(
sq.c.premium_unlimited_override.is_(False),
plim_expr <= 0,
),
)
stmt = stmt.where(premium_cond)
count_stmt = count_stmt.where(premium_cond)
elif pt_filter == "unlimited":
premium_cond = and_(
sq.c.user_id.isnot(None),
sq.c.premium_unlimited_override.is_(True),
)
stmt = stmt.where(premium_cond)
count_stmt = count_stmt.where(premium_cond)
elif pt_filter == "good":
premium_cond = and_(
sq.c.user_id.isnot(None),
sq.c.premium_unlimited_override.is_(False),
plim_expr > 0,
(100 * pu_expr) < (85 * plim_expr),
)
stmt = stmt.where(premium_cond)
count_stmt = count_stmt.where(premium_cond)
elif pt_filter == "warn":
premium_cond = and_(
sq.c.user_id.isnot(None),
sq.c.premium_unlimited_override.is_(False),
plim_expr > 0,
(100 * pu_expr) >= (85 * plim_expr),
pu_expr < plim_expr,
)
stmt = stmt.where(premium_cond)
count_stmt = count_stmt.where(premium_cond)
elif pt_filter == "critical":
premium_cond = and_(
sq.c.user_id.isnot(None),
sq.c.premium_unlimited_override.is_(False),
plim_expr > 0,
pu_expr >= plim_expr,
)
stmt = stmt.where(premium_cond)
count_stmt = count_stmt.where(premium_cond)
sort_map = {
"registered_desc": User.registration_date.desc().nullslast(),
"registered_asc": User.registration_date.asc().nullslast(),
"name_asc": (
sa_func.coalesce(User.first_name, User.username, User.email).asc(),
User.user_id.asc(),
),
"name_desc": (
sa_func.coalesce(User.first_name, User.username, User.email).desc(),
User.user_id.desc(),
),
"id_asc": User.user_id.asc(),
"id_desc": User.user_id.desc(),
}
if needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_asc":
stmt = stmt.order_by(ratio_expr.asc().nullslast(), User.user_id.asc())
elif needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_desc":
stmt = stmt.order_by(ratio_expr.desc().nullslast(), User.user_id.desc())
else:
order = sort_map.get(sort_key, sort_map["registered_desc"])
if isinstance(order, tuple):
stmt = stmt.order_by(*order)
else:
stmt = stmt.order_by(order)
stmt = stmt.offset(max(page, 0) * max(page_size, 1)).limit(max(page_size, 1))
users = (await session.execute(stmt)).scalars().all()
total = (await session.execute(count_stmt)).scalar_one()
return users, int(total)
def _user_panel_status_condition(panel_status: str):
status = (panel_status or "all").lower()
if status not in {"active", "expired", "limited"}:
return None
normalized_status = sa_func.lower(sa_func.coalesce(Subscription.status_from_panel, ""))
blank_status = or_(
Subscription.status_from_panel.is_(None), Subscription.status_from_panel == ""
)
if status == "active":
status_cond = or_(
normalized_status == "active", blank_status & Subscription.is_active.is_(True)
)
elif status == "expired":
status_cond = or_(
normalized_status == "expired", blank_status & Subscription.is_active.is_(False)
)
else:
status_cond = normalized_status == "limited"
return (
select(Subscription.subscription_id)
.where(Subscription.user_id == User.user_id, status_cond)
.exists()
)
def _user_search_condition(query: str):
raw = (query or "").strip().lstrip("@")
if not raw:
return None
like = f"%{raw}%"
conditions = [
User.username.ilike(like),
User.first_name.ilike(like),
User.last_name.ilike(like),
User.email.ilike(like),
]
if raw.isdigit():
numeric = int(raw)
conditions.extend([User.user_id == numeric, User.telegram_id == numeric])
return or_(*conditions)
async def admin_user_detail_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
settings: Settings = request.app["settings"]
async with async_session_factory() as session:
user = await user_dal.get_user_by_id(session, target_id)
if not user:
return _error(404, "not_found", "User not found")
active_sub = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
latest_subs_stmt = (
select(Subscription)
.where(Subscription.user_id == target_id)
.order_by(Subscription.start_date.desc().nullslast())
.limit(20)
)
latest_subs = (await session.execute(latest_subs_stmt)).scalars().all()
total_paid = await payment_dal.get_user_total_paid(session, target_id)
recent_payments_stmt = (
select(Payment)
.where(Payment.user_id == target_id)
.order_by(Payment.created_at.desc())
.limit(20)
)
recent_payments = (await session.execute(recent_payments_stmt)).scalars().all()
log_count = await message_log_dal.count_user_message_logs(session, target_id)
avatar_keys = await _bulk_user_avatar_keys(session, [target_id])
# Referral links — both the bot deep-link and the webapp deep-link.
referral_code: Optional[str] = None
try:
referral_code = await user_dal.ensure_referral_code(session, user)
await session.commit()
except Exception as exc_ref: # pragma: no cover — defensive
logger.warning("Failed to ensure referral code for user %s: %s", target_id, exc_ref)
await session.rollback()
referral_service: Optional[ReferralService] = request.app.get("referral_service")
bot_username = request.app.get("bot_username") or ""
referral_bot_link: Optional[str] = None
if referral_service and bot_username and referral_code:
try:
async with async_session_factory() as session:
referral_bot_link = await referral_service.generate_referral_link(
session, bot_username, target_id
)
except Exception as exc_link: # pragma: no cover
logger.warning("Failed to build bot referral link for %s: %s", target_id, exc_link)
referral_webapp_link = _build_admin_webapp_referral_link(
getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None),
referral_code,
)
# Subscription page URL — the raw panel `subscriptionUrl` that the user
# imports into their VPN client. May be missing if the user has never
# been provisioned on the panel.
subscription_url: Optional[str] = None
panel_uuid = getattr(user, "panel_user_uuid", None)
if panel_uuid:
subscription_service = request.app.get("subscription_service")
panel_service = getattr(subscription_service, "panel_service", None)
if panel_service is not None:
try:
panel_data = await panel_service.get_user_by_uuid(panel_uuid)
if panel_data:
subscription_url = panel_data.get("subscriptionUrl") or None
except Exception as exc_panel: # pragma: no cover
logger.warning(
"Failed to fetch subscriptionUrl for user %s (uuid=%s): %s",
target_id,
panel_uuid,
exc_panel,
)
serialized_user = _serialize_user(user)
serialized_user["avatar_url"] = (
f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}"
if target_id in avatar_keys
else None
)
return _ok(
{
"user": serialized_user,
"active_subscription": _serialize_subscription(active_sub) if active_sub else None,
"subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])],
"total_paid": float(total_paid),
"recent_payments": [_serialize_payment(p) for p in recent_payments],
"log_count": int(log_count or 0),
"subscription_url": subscription_url,
"referral": {
"code": referral_code,
"bot_link": referral_bot_link,
"webapp_link": referral_webapp_link,
},
}
)
async def admin_user_ban_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
desired = bool(payload.get("banned"))
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
user = await user_dal.get_user_by_id(session, target_id)
if not user:
return _error(404, "not_found")
user.is_banned = bool(desired)
await session.commit()
await session.refresh(user)
return _ok({"user": _serialize_user(user)})
async def admin_user_message_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
text = str(payload.get("text") or "").strip()
if not text:
return _error(400, "empty_text")
queue_manager = get_queue_manager()
if not queue_manager:
return _error(503, "queue_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
target_user = await user_dal.get_user_by_id(session, target_id)
if not target_user or not target_user.telegram_id:
return _error(404, "no_telegram_account")
try:
await send_message_via_queue(
queue_manager,
int(target_user.telegram_id),
MessageContent(content_type="text", text=text),
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as exc:
logger.warning("Admin direct message failed: %s", exc)
return _error(502, "send_failed", str(exc))
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_direct_message_webapp",
"content": text[:4000],
"is_admin_event": True,
"target_user_id": target_id,
},
)
return _ok({})
async def admin_user_message_preview_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
admin_telegram_id = request.get("admin_telegram_id")
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
text = str(payload.get("text") or "").strip()
if not text:
return _error(400, "empty_text")
if not admin_telegram_id:
return _error(403, "admin_telegram_unavailable")
queue_manager = get_queue_manager()
if not queue_manager:
return _error(503, "queue_unavailable")
try:
await send_message_via_queue(
queue_manager,
int(admin_telegram_id),
MessageContent(content_type="text", text=text),
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as exc:
logger.warning("Admin direct message preview failed: %s", exc)
return _error(502, "preview_failed", str(exc))
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_direct_message_preview_webapp",
"content": text[:4000],
"is_admin_event": True,
"target_user_id": target_id,
},
)
return _ok({})
async def admin_user_delete_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
ok = await user_dal.delete_user_and_relations(session, target_id)
if not ok:
await session.rollback()
return _error(404, "not_found")
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_delete_user_webapp",
"content": f"Deleted user_id={target_id}",
"is_admin_event": True,
},
)
await session.commit()
return _ok({})
async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
panel_service = request.app.get("panel_service")
subscription_service = request.app.get("subscription_service")
if panel_service is None or subscription_service is None:
return _error(503, "service_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
user = await user_dal.get_user_by_id(session, target_id)
if not user:
return _error(404, "not_found")
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if active:
await session.delete(active)
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_reset_trial_webapp",
"content": f"Reset trial for user_id={target_id}",
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
return _ok({})
async def admin_user_premium_override_route(request: web.Request) -> web.Response:
"""Premium-squad traffic overrides only (unlimited toggle + bonus GB)."""
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
subscription_service = request.app.get("subscription_service")
unlimited = bool(payload.get("unlimited"))
bonus_bytes_raw = payload.get("bonus_bytes")
bonus_gb_raw = payload.get("bonus_gb")
if bonus_bytes_raw is None and bonus_gb_raw is None:
bonus_bytes = 0
elif bonus_bytes_raw is not None:
try:
bonus_bytes = int(bonus_bytes_raw)
except (TypeError, ValueError):
return _error(400, "invalid_bonus", "bonus_bytes must be an integer")
else:
try:
bonus_bytes = int(round(float(bonus_gb_raw) * (1024**3)))
except (TypeError, ValueError):
return _error(400, "invalid_bonus", "bonus_gb must be a number")
if bonus_bytes < 0:
return _error(400, "invalid_bonus", "bonus must be non-negative")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if not active:
return _error(404, "no_active_subscription")
active.premium_unlimited_override = bool(unlimited)
active.premium_bonus_bytes = int(bonus_bytes)
if active.premium_unlimited_override:
active.premium_is_limited = False
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_premium_override_webapp",
"content": (f"unlimited={bool(unlimited)} bonus_bytes={int(bonus_bytes)}"),
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
await session.refresh(active)
if subscription_service is not None:
await subscription_service.sync_premium_squad_access_to_panel(session, target_id)
await session.commit()
await session.refresh(active)
return _ok({"subscription": _serialize_subscription(active)})
async def admin_user_regular_traffic_override_route(request: web.Request) -> web.Response:
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
unlimited = bool(payload.get("unlimited"))
regular_bonus_bytes_raw = payload.get("regular_bonus_bytes")
regular_bonus_gb_raw = payload.get("regular_bonus_gb")
if regular_bonus_bytes_raw is None and regular_bonus_gb_raw is None:
regular_bonus_bytes = 0
elif regular_bonus_bytes_raw is not None:
try:
regular_bonus_bytes = int(regular_bonus_bytes_raw)
except (TypeError, ValueError):
return _error(400, "invalid_regular_bonus", "regular_bonus_bytes must be an integer")
else:
try:
regular_bonus_bytes = int(round(float(regular_bonus_gb_raw) * (1024**3)))
except (TypeError, ValueError):
return _error(400, "invalid_regular_bonus", "regular_bonus_gb must be a number")
if regular_bonus_bytes < 0:
return _error(400, "invalid_regular_bonus", "regular bonus must be non-negative")
subscription_service = request.app.get("subscription_service")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if not active:
return _error(404, "no_active_subscription")
active.regular_unlimited_override = bool(unlimited)
active.regular_bonus_bytes = int(regular_bonus_bytes)
if subscription_service is not None:
await subscription_service.sync_main_traffic_limit_to_panel(session, target_id)
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_regular_traffic_override_webapp",
"content": (
f"unlimited={bool(unlimited)} regular_bonus_bytes={int(regular_bonus_bytes)}"
),
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
await session.refresh(active)
return _ok({"subscription": _serialize_subscription(active)})
async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
"""Credit regular or premium traffic to a user without a payment.
Body: ``{"kind": "regular" | "premium", "gb": float}`` (alternatively
``"bytes": int``). Mirrors the same effect as a user-purchased top-up:
the chosen balance grows, panel limit/squads are refreshed, and an entry
is added to ``traffic_topups`` with ``kind="admin_topup"`` or
``kind="admin_premium_topup"`` and ``payment_id=NULL``.
"""
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
kind = str(payload.get("kind") or "regular").strip().lower()
if kind not in {"regular", "premium"}:
return _error(400, "invalid_kind", "kind must be 'regular' or 'premium'")
bytes_raw = payload.get("bytes")
gb_raw = payload.get("gb")
if bytes_raw is None and gb_raw is None:
return _error(400, "missing_amount", "either 'gb' or 'bytes' is required")
try:
if bytes_raw is not None:
grant_bytes = int(bytes_raw)
gb_value = grant_bytes / (1024**3)
else:
gb_value = float(gb_raw)
grant_bytes = int(round(gb_value * (1024**3)))
except (TypeError, ValueError):
return _error(400, "invalid_amount", "amount must be a positive number")
if gb_value <= 0 or grant_bytes <= 0:
return _error(400, "invalid_amount", "amount must be positive")
subscription_service = request.app.get("subscription_service")
if subscription_service is None:
return _error(503, "subscription_service_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if not active:
return _error(404, "no_active_subscription")
if kind == "regular":
result = await subscription_service.admin_grant_topup(session, target_id, gb_value)
else:
result = await subscription_service.admin_grant_premium_topup(
session, target_id, gb_value
)
if not result:
await session.rollback()
return _error(
422,
"grant_failed",
"Unable to credit traffic (missing tariff/squads or panel error)",
)
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_traffic_grant_webapp",
"content": f"kind={kind} bytes={grant_bytes}",
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
return _ok(
{
"subscription": _serialize_subscription(refreshed) if refreshed else None,
"grant": {
"kind": kind,
"granted_bytes": grant_bytes,
"granted_gb": gb_value,
},
}
)
async def admin_user_extend_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
try:
days = int(payload.get("days") or 0)
except (TypeError, ValueError):
return _error(400, "invalid_days")
if days <= 0:
return _error(400, "invalid_days")
subscription_service = request.app.get("subscription_service")
if subscription_service is None:
return _error(503, "subscription_service_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
new_end = await subscription_service.extend_active_subscription_days(
session,
target_id,
days,
"admin_extend_subscription_webapp",
)
if not new_end:
await session.rollback()
return _error(500, "extend_failed")
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_extend_subscription_webapp",
"content": f"+{days}d -> {new_end.isoformat()}",
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
return _ok(
{
"subscription": _serialize_subscription(refreshed) if refreshed else None,
}
)
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
from typing import Optional
from aiohttp import web
from bot.app.web.webapp_auth import verify_webapp_session_token
from config.settings import Settings
WEBAPP_SESSION_COOKIE_NAME = "rw_webapp_session"
def extract_authenticated_user_id(request: web.Request) -> Optional[int]:
settings: Settings = request.app["settings"]
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
user_id = verify_webapp_session_token(
settings,
auth_header.removeprefix("Bearer ").strip(),
)
if user_id:
return user_id
session_cookie = request.cookies.get(WEBAPP_SESSION_COOKIE_NAME)
if session_cookie:
return verify_webapp_session_token(settings, session_cookie)
return None
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -72,7 +72,7 @@ async def build_and_start_web_app(
secret_token=settings.WEBHOOK_SECRET_TOKEN, secret_token=settings.WEBHOOK_SECRET_TOKEN,
).register(app, path=telegram_webhook_path) ).register(app, path=telegram_webhook_path)
logging.info( logging.info(
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)" f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)" # noqa: E501
) )
from bot.handlers.user.payment import yookassa_webhook_route from bot.handlers.user.payment import yookassa_webhook_route
+1
View File
@@ -0,0 +1 @@
"""Domain modules for the subscription Mini App backend."""
+100
View File
@@ -0,0 +1,100 @@
# ruff: noqa: F401,F403,F405,I001
import asyncio
import base64
import hashlib
import hmac
import io
import ipaddress
import json
import logging
import os
import re
import secrets
import socket
import subprocess
import time
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from aiogram import Bot, Dispatcher
from aiogram.types import LabeledPrice
from aiohttp import ClientSession, ClientTimeout, web
from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, field_validator
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,
create_webapp_session_token,
validate_telegram_login_widget_data,
validate_telegram_oauth_id_token,
validate_telegram_webapp_init_data,
verify_signed_telegram_oauth_state,
verify_telegram_oauth_nonce,
verify_webapp_session_token,
)
from bot.services.crypto_pay_service import CryptoPayService
from bot.services.email_auth_service import EmailAuthService, normalize_email
from bot.services.email_templates import render_account_merged
from bot.services.freekassa_service import FreeKassaService
from bot.services.platega_service import PlategaService
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
from bot.services.severpay_service import SeverPayService
from bot.services.subscription_service import SubscriptionService
from bot.services.yookassa_service import YooKassaService
from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import parse_ip_entries, request_client_ip
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings
from db.dal import payment_dal, subscription_dal, user_dal
from db.dal.user_dal import UserMergeConflictError
from db.models import Payment, User, UserTelegramAvatar
logger = logging.getLogger(__name__)
TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscription_webapp.html"
ASSET_DIR = TEMPLATE_PATH.parent
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
WEBAPP_LOGO_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo"
WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-emoji"
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_SCRIPT -->"
APP_REPOSITORY_URL = "https://github.com/3252a8/remnawave-minishop"
DEV_MOCK_START_MARKER = "<!-- WEBAPP_DEV_MOCK_START -->"
DEV_MOCK_END_MARKER = "<!-- WEBAPP_DEV_MOCK_END -->"
WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024
WEBAPP_TELEGRAM_AVATAR_MAX_BYTES = 128 * 1024
WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60
WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4
WEBAPP_SESSION_COOKIE_NAME = "rw_webapp_session"
WEBAPP_CSRF_COOKIE_NAME = "rw_webapp_csrf"
WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME = "rw_tg_oauth_state"
WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token"
WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
_APP_VERSION_CACHE: Optional[str] = None
WEBAPP_CSRF_EXEMPT_PATHS = {
"/api/auth/telegram/nonce",
"/api/auth/token",
"/api/auth/email/request",
"/api/auth/email/verify",
"/api/auth/email/magic",
"/api/auth/logout",
}
_SHARED_HTTP_SESSION: Optional[ClientSession] = None
_SHARED_HTTP_SESSION_LOCK = asyncio.Lock()
__all__ = [name for name in globals() if not name.startswith("__")]
+534
View File
@@ -0,0 +1,534 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def account_email_request_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
email_payload, validation_error = _validate_model_payload(WebAppEmailPayload, payload)
if validation_error:
return validation_error
email = email_payload.email
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
if db_user.email == email and db_user.email_verified_at:
return web.json_response({"ok": True, "already_linked": True})
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
return await _request_email_code(
request,
email=email,
purpose="link_email",
language_code=lang,
target_user_id=user_id,
)
async def account_email_verify_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
rate_limit_response = await _enforce_webapp_rate_limit(
request,
user_id=user_id,
action="account_email_verify",
)
if rate_limit_response:
return rate_limit_response
payload = await _read_json(request)
email_payload, validation_error = _validate_model_payload(WebAppEmailCodePayload, payload)
if validation_error:
return validation_error
email = email_payload.email
code = str(email_payload.code or "")
email_service: EmailAuthService = request.app["email_auth_service"]
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
merge_notice: Optional[Dict[str, Any]] = None
source_panel_uuid: Optional[str] = None
final_user_id = user_id
final_email = email
final_telegram_id: Optional[int] = None
final_username: Optional[str] = None
final_first_name: Optional[str] = None
final_panel_uuid: Optional[str] = None
should_notify_email_linked = False
async with async_session_factory() as session:
try:
verify_result = await email_service.verify_code(
session,
email=email,
purpose="link_email",
code=code,
target_user_id=user_id,
)
if not verify_result.ok:
await session.commit()
status = 429 if verify_result.error == "rate_limited" else 400
return web.json_response(
{
"ok": False,
"error": verify_result.error or "invalid_code",
"retry_after": verify_result.retry_after,
"message": "Invalid code",
},
status=status,
)
current_user = await user_dal.get_user_by_id(session, user_id)
if not current_user or current_user.is_banned:
await session.rollback()
return _json_error(403, "access_denied", "Access denied")
should_notify_email_linked = (
bool(_telegram_id_for_user(current_user)) and not current_user.email
)
existing_email_user = await user_dal.get_user_by_email(session, email)
if existing_email_user and existing_email_user.user_id != current_user.user_id:
source_panel_uuid = existing_email_user.panel_user_uuid
current_user = await user_dal.merge_users(
session,
source_user_id=existing_email_user.user_id,
target_user_id=current_user.user_id,
)
merge_notice = await _build_account_merge_notice(
session,
merged_user=current_user,
source_user_id=existing_email_user.user_id,
source_panel_uuid=source_panel_uuid,
settings=settings,
)
current_user.email = email
current_user.email_verified_at = datetime.now(timezone.utc)
await _sync_panel_identity_for_user(request, current_user)
await session.commit()
final_user_id = int(current_user.user_id)
final_telegram_id = _telegram_id_for_user(current_user)
final_username = current_user.username
final_first_name = current_user.first_name
final_panel_uuid = current_user.panel_user_uuid
if merge_notice:
merge_end_date_raw = merge_notice.get("final_end_date")
merge_end_date = (
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
)
await _sync_panel_identity_for_user(
request,
current_user,
expire_at=merge_end_date,
)
# Best-effort cleanup of the removed panel account after the DB merge.
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
subscription_service: SubscriptionService = request.app.get(
"subscription_service"
)
if subscription_service and subscription_service.panel_service:
try:
await subscription_service.panel_service.delete_user_from_panel(
source_panel_uuid,
log_response=False,
)
except Exception as exc:
logger.warning(
"Failed to delete merged source panel user %s: %s",
source_panel_uuid,
exc,
)
email_service: EmailAuthService = request.app.get("email_auth_service")
if email_service and final_email:
email_content = render_account_merged(
settings,
language_code=merge_notice.get("language") or settings.DEFAULT_LANGUAGE,
primary_user_id=merge_notice.get("primary_user_id"),
removed_user_id=merge_notice.get("removed_user_id"),
final_end_date_text=str(
merge_notice.get("final_end_date_text")
or merge_notice.get("final_end_date")
or ""
),
)
try:
await email_service.send_rendered_email(
email=final_email,
content=email_content,
)
except Exception as exc:
logger.warning(
"Failed to send account merge email to %s: %s",
final_email,
exc,
)
except UserMergeConflictError as exc:
await session.rollback()
return _json_error(409, "account_merge_conflict", str(exc))
except Exception:
await session.rollback()
logger.exception("Email account link failed")
return _json_error(500, "link_failed", "Link failed")
if should_notify_email_linked:
try:
from bot.services.notification_service import NotificationService
bot: Bot = request.app["bot"]
notification_service = NotificationService(
bot,
settings,
request.app.get("i18n"),
)
await notification_service.notify_account_email_linked(
user_id=int(final_user_id),
email=final_email,
telegram_id=final_telegram_id,
username=final_username,
first_name=final_first_name,
)
except Exception:
logger.exception("Failed to send account email linked notification")
token = create_webapp_session_token(settings, int(final_user_id))
response_payload: Dict[str, Any] = {"ok": True}
if merge_notice:
response_payload["account_merge"] = merge_notice
response_payload["user_id"] = final_user_id
return _build_webapp_auth_response(settings, response_payload, token=token)
async def account_telegram_link_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
telegram_user = await _validate_telegram_auth_payload(request, payload)
if not telegram_user:
return _json_error(401, "invalid_auth", "Invalid Telegram auth data")
async_session_factory: sessionmaker = request.app["async_session_factory"]
merge_notice: Optional[Dict[str, Any]] = None
source_panel_uuid: Optional[str] = None
final_user_id = user_id
final_telegram_id: Optional[int] = None
final_email: Optional[str] = None
final_username: Optional[str] = None
final_first_name: Optional[str] = None
final_panel_uuid: Optional[str] = None
should_notify_telegram_linked = False
async with async_session_factory() as session:
try:
current_user_before_link = await user_dal.get_user_by_id(session, user_id)
if not current_user_before_link or current_user_before_link.is_banned:
await session.rollback()
return _json_error(403, "access_denied", "Access denied")
should_notify_telegram_linked = bool(
current_user_before_link.email
) and not _telegram_id_for_user(current_user_before_link)
source_panel_uuid = current_user_before_link.panel_user_uuid
db_user = await _link_telegram_to_user(
request,
session,
current_user_id=user_id,
telegram_user=telegram_user,
settings=settings,
)
if db_user.is_banned:
await session.rollback()
return _json_error(403, "banned", "Access denied")
final_user_id = int(db_user.user_id)
final_telegram_id = _telegram_id_for_user(db_user)
final_email = db_user.email
final_username = db_user.username
final_first_name = db_user.first_name
final_panel_uuid = db_user.panel_user_uuid
if final_user_id != user_id:
merge_notice = await _build_account_merge_notice(
session,
merged_user=db_user,
source_user_id=user_id,
source_panel_uuid=source_panel_uuid,
settings=settings,
)
await session.commit()
if merge_notice:
merge_end_date_raw = merge_notice.get("final_end_date")
merge_end_date = (
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
)
await _sync_panel_identity_for_user(
request,
db_user,
expire_at=merge_end_date,
)
# Best-effort cleanup of the removed panel account after the DB merge.
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
subscription_service: SubscriptionService = request.app.get(
"subscription_service"
)
if subscription_service and subscription_service.panel_service:
try:
await subscription_service.panel_service.delete_user_from_panel(
source_panel_uuid,
log_response=False,
)
except Exception as exc:
logger.warning(
"Failed to delete merged source panel user %s: %s",
source_panel_uuid,
exc,
)
email_service: EmailAuthService = request.app.get("email_auth_service")
if email_service and final_email:
email_content = render_account_merged(
settings,
language_code=merge_notice.get("language") or settings.DEFAULT_LANGUAGE,
primary_user_id=merge_notice.get("primary_user_id"),
removed_user_id=merge_notice.get("removed_user_id"),
final_end_date_text=str(
merge_notice.get("final_end_date_text")
or merge_notice.get("final_end_date")
or ""
),
)
try:
await email_service.send_rendered_email(
email=final_email,
content=email_content,
)
except Exception as exc:
logger.warning(
"Failed to send account merge email to %s: %s",
final_email,
exc,
)
except UserMergeConflictError as exc:
await session.rollback()
return _json_error(409, "account_merge_conflict", str(exc))
except Exception:
await session.rollback()
logger.exception("Telegram account link failed")
return _json_error(500, "link_failed", "Link failed")
if should_notify_telegram_linked and final_telegram_id:
try:
from bot.services.notification_service import NotificationService
bot: Bot = request.app["bot"]
notification_service = NotificationService(
bot,
settings,
request.app.get("i18n"),
)
await notification_service.notify_account_telegram_linked(
user_id=int(final_user_id),
email=final_email,
telegram_id=int(final_telegram_id),
username=final_username,
first_name=final_first_name,
)
except Exception:
logger.exception("Failed to send account Telegram linked notification")
token = create_webapp_session_token(settings, int(final_user_id))
response_payload: Dict[str, Any] = {
"ok": True,
"user_id": int(final_user_id),
"telegram_id": final_telegram_id,
}
if merge_notice:
response_payload["account_merge"] = merge_notice
return _build_webapp_auth_response(settings, response_payload, token=token)
async def me_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
data = await _build_user_payload(request, user_id)
return web.json_response({"ok": True, **data})
async def account_avatar_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
await session.rollback()
return _json_error(403, "access_denied", "Access denied")
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
await session.commit()
if not avatar:
raise web.HTTPNotFound(text="avatar_not_cached")
etag = _telegram_avatar_etag(avatar)
if etag and request.headers.get("If-None-Match") == etag:
return web.Response(status=304, headers={"ETag": etag})
response = web.Response(
body=bytes(avatar.image_bytes),
content_type=avatar.content_type or "image/jpeg",
)
response.headers["Cache-Control"] = "private, max-age=3600"
if etag:
response.headers["ETag"] = etag
return response
async def account_language_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
payload = await _read_json(request)
language_payload, validation_error = _validate_model_payload(WebAppLanguagePayload, payload)
if validation_error:
return validation_error
language = _normalize_language(str(language_payload.language or ""))
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
await session.rollback()
return _json_error(403, "access_denied", "Access denied")
if _normalize_language(db_user.language_code or "") != language:
db_user.language_code = language
await session.flush()
await session.commit()
return web.json_response({"ok": True, "language": language})
def _format_webapp_datetime(value: Optional[datetime]) -> Optional[str]:
if not value:
return None
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.strftime("%d.%m.%Y %H:%M")
def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
raw_value = telegram_user.get("photo_url")
if not raw_value:
return None
value = str(raw_value).strip()
return value or None
def _telegram_avatar_is_stale(avatar: Optional[UserTelegramAvatar]) -> bool:
if not avatar or not avatar.updated_at:
return True
updated_at = avatar.updated_at
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=timezone.utc)
return (
datetime.now(timezone.utc) - updated_at
).total_seconds() >= WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS
def _telegram_avatar_etag(avatar: UserTelegramAvatar) -> str:
digest = hashlib.sha256(bytes(avatar.image_bytes)).hexdigest()[:16]
return f'"tg-avatar-{int(avatar.user_id)}-{digest}"'
def _telegram_avatar_url(avatar: Optional[UserTelegramAvatar]) -> str:
if not avatar:
return ""
updated_at = avatar.updated_at
if updated_at and updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=timezone.utc)
version = (
int(updated_at.timestamp())
if updated_at
else hashlib.sha256(bytes(avatar.image_bytes)).hexdigest()[:8]
)
return f"/api/account/avatar?v={version}"
def _select_compact_telegram_photo_size(sizes: List[Any]) -> Optional[Any]:
if not sizes:
return None
suitable = [size for size in sizes if int(getattr(size, "width", 0) or 0) >= 160]
candidates = suitable or sizes
return min(
candidates,
key=lambda size: (
int(getattr(size, "file_size", 0) or 0)
or int(getattr(size, "width", 0) or 0) * int(getattr(size, "height", 0) or 0),
int(getattr(size, "width", 0) or 0),
),
)
def _telegram_file_content_type(file_path: Optional[str]) -> str:
path = str(file_path or "").lower()
if path.endswith(".png"):
return "image/png"
if path.endswith(".webp"):
return "image/webp"
return "image/jpeg"
async def _fetch_compact_telegram_avatar(
bot: Bot, telegram_id: int
) -> Optional[Tuple[bytes, str, Optional[str]]]:
photos = await bot.get_user_profile_photos(user_id=telegram_id, limit=1)
if not photos or not photos.photos:
return None
photo_size = _select_compact_telegram_photo_size(list(photos.photos[0] or []))
if not photo_size:
return None
file_info = await bot.get_file(photo_size.file_id)
destination = io.BytesIO()
await bot.download_file(file_info.file_path, destination=destination)
body = destination.getvalue()
if not body or len(body) > WEBAPP_TELEGRAM_AVATAR_MAX_BYTES:
return None
return (
body,
_telegram_file_content_type(file_info.file_path),
getattr(photo_size, "file_unique_id", None),
)
async def _ensure_cached_telegram_avatar(
request: web.Request,
session: AsyncSession,
user: User,
) -> Optional[UserTelegramAvatar]:
avatar = await user_dal.get_user_telegram_avatar(session, int(user.user_id))
telegram_id = _telegram_id_for_user(user)
if not telegram_id:
return avatar
if avatar and not _telegram_avatar_is_stale(avatar):
return avatar
bot: Bot = request.app["bot"]
try:
fetched = await asyncio.wait_for(
_fetch_compact_telegram_avatar(bot, int(telegram_id)),
timeout=WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS,
)
except Exception as exc:
logger.info("Failed to refresh Telegram avatar for user %s: %s", user.user_id, exc)
return avatar
if not fetched:
return avatar
body, content_type, file_unique_id = fetched
return await user_dal.upsert_user_telegram_avatar(
session,
user_id=int(user.user_id),
file_unique_id=file_unique_id,
content_type=content_type,
image_bytes=body,
)
+60
View File
@@ -0,0 +1,60 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
def create_subscription_webapp_application(
dp: Dispatcher,
bot: Bot,
settings: Settings,
async_session_factory: sessionmaker,
) -> web.Application:
app = web.Application(
middlewares=[
_security_headers_middleware,
_csrf_protection_middleware,
admin_auth_middleware,
]
)
app["bot"] = bot
app["dp"] = dp
app["settings"] = settings
app["async_session_factory"] = async_session_factory
app["i18n"] = dp.get("i18n_instance")
app["email_auth_service"] = EmailAuthService(settings)
app["webapp_logo_cache"] = None
app["webapp_logo_cache_lock"] = asyncio.Lock()
app["webapp_settings_cache"] = {"ts": 0.0, "data": {}}
app["webapp_rate_limit_buckets"] = {}
app["webapp_rate_limit_lock"] = asyncio.Lock()
async def _startup(app_obj: web.Application) -> None:
await _ensure_shared_http_session()
await _warm_webapp_logo_cache(app_obj)
await _warm_webapp_animated_emoji_cache(app_obj)
async def _shutdown(app_obj: web.Application) -> None:
await _close_shared_http_session()
app.on_startup.append(_startup)
app.on_shutdown.append(_shutdown)
for key in (
"subscription_service",
"yookassa_service",
"freekassa_service",
"cryptopay_service",
"platega_service",
"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]
# type: ignore[attr-defined]
if hasattr(dp, "workflow_data") and "bot_username" in dp.workflow_data:
app["bot_username"] = dp.workflow_data["bot_username"] # type: ignore[index]
setup_subscription_webapp_routes(app)
return app
+734
View File
@@ -0,0 +1,734 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def health_route(request: web.Request) -> web.Response:
return web.json_response({"ok": True})
async def css_asset_route(request: web.Request) -> web.Response:
return await _serve_template_asset(request, "subscription_webapp.css", "text/css")
def _resolve_webapp_logo_url(settings: Settings) -> str:
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw_logo_url:
return ""
parsed_logo_url = urlsplit(raw_logo_url)
if parsed_logo_url.scheme == "https":
cache_key = hashlib.sha256(raw_logo_url.encode("utf-8")).hexdigest()[:12]
return f"{WEBAPP_LOGO_PROXY_PATH}?v={cache_key}"
if parsed_logo_url.scheme in {"http", "data"}:
return raw_logo_url
if raw_logo_url.startswith("/"):
return raw_logo_url
return ""
def _webapp_logo_cache_key(logo_url: str) -> str:
return hashlib.sha256(logo_url.encode("utf-8")).hexdigest()
def _webapp_logo_disk_paths(logo_url: str) -> Tuple[Path, Path]:
cache_key = _webapp_logo_cache_key(logo_url)
return WEBAPP_LOGO_CACHE_DIR / f"{cache_key}.bin", WEBAPP_LOGO_CACHE_DIR / f"{cache_key}.json"
def _is_proxyable_webapp_logo_url(logo_url: str) -> bool:
parsed_logo_url = urlsplit(logo_url)
return parsed_logo_url.scheme == "https" and bool(parsed_logo_url.hostname)
def _emoji_to_codepoints(value: str) -> str:
return "_".join(f"{ord(char):x}" for char in str(value or "").strip())
def _webapp_emoji_disk_path(codepoints: str, ext: str) -> Path:
return WEBAPP_EMOJI_CACHE_DIR / f"{codepoints}.512.{ext}"
def _webapp_animated_emoji_source_url(codepoints: str, ext: str) -> str:
return f"https://fonts.gstatic.com/s/e/notoemoji/latest/{codepoints}/512.{ext}"
def _webapp_animated_emoji_asset_path(emoji: str, ext: str = "gif") -> str:
codepoints = _emoji_to_codepoints(emoji)
if not codepoints or ext not in {"gif", "webp"}:
return ""
return f"/webapp-emoji/{codepoints}/512.{ext}"
async def webapp_logo_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw_logo_url:
raise web.HTTPNotFound(text="webapp_logo_not_configured")
if not _is_proxyable_webapp_logo_url(raw_logo_url):
raise web.HTTPNotFound(text="webapp_logo_not_proxied")
parsed_logo_url = urlsplit(raw_logo_url)
if not await _hostname_resolves_to_public_address(parsed_logo_url.hostname):
raise web.HTTPNotFound(text="webapp_logo_not_proxied")
source_logo_url = raw_logo_url
logo_cache: Optional[Tuple[str, bytes, str]] = request.app.get("webapp_logo_cache")
if logo_cache is None or logo_cache[0] != source_logo_url:
cache_lock: asyncio.Lock = request.app["webapp_logo_cache_lock"]
async with cache_lock:
logo_cache = request.app.get("webapp_logo_cache")
if logo_cache is None or logo_cache[0] != source_logo_url:
fetched_logo = await _load_or_fetch_webapp_logo(source_logo_url)
logo_cache = (
(source_logo_url, fetched_logo[0], fetched_logo[1]) if fetched_logo else None
)
request.app["webapp_logo_cache"] = logo_cache
if not logo_cache:
raise web.HTTPNotFound(text="webapp_logo_unavailable")
_, body, content_type = logo_cache
response = web.Response(body=body, content_type=content_type)
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
async def webapp_animated_emoji_route(request: web.Request) -> web.Response:
codepoints = str(request.match_info.get("codepoints") or "").strip().lower()
ext = str(request.match_info.get("ext") or "").strip().lower()
if not re.fullmatch(r"[0-9a-f]+(?:_[0-9a-f]+)*", codepoints) or ext not in {"gif", "webp"}:
raise web.HTTPNotFound(text="webapp_emoji_not_found")
emoji_cache_key = f"{codepoints}:{ext}"
emoji_caches: Dict[str, Tuple[bytes, str]] = request.app.setdefault("webapp_emoji_cache", {})
emoji_cache = emoji_caches.get(emoji_cache_key)
if emoji_cache is None:
cache_lock: asyncio.Lock = request.app.setdefault("webapp_emoji_cache_lock", asyncio.Lock())
async with cache_lock:
emoji_cache = emoji_caches.get(emoji_cache_key)
if emoji_cache is None:
emoji_cache = await _load_or_fetch_webapp_animated_emoji(codepoints, ext)
if emoji_cache:
emoji_caches[emoji_cache_key] = emoji_cache
if not emoji_cache:
raise web.HTTPNotFound(text="webapp_emoji_unavailable")
body, content_type = emoji_cache
response = web.Response(body=body, content_type=content_type)
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
async def _warm_webapp_logo_cache(app: web.Application) -> None:
settings: Settings = app["settings"]
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url):
return
parsed_logo_url = urlsplit(raw_logo_url)
if not parsed_logo_url.hostname or not await _hostname_resolves_to_public_address(
parsed_logo_url.hostname
):
return
cache_lock: asyncio.Lock = app["webapp_logo_cache_lock"]
async with cache_lock:
logo_cache: Optional[Tuple[str, bytes, str]] = app.get("webapp_logo_cache")
if logo_cache and logo_cache[0] == raw_logo_url:
return
loaded_logo = await _load_or_fetch_webapp_logo(raw_logo_url)
app["webapp_logo_cache"] = (
(raw_logo_url, loaded_logo[0], loaded_logo[1]) if loaded_logo else None
)
async def _warm_webapp_animated_emoji_cache(app: web.Application) -> None:
settings: Settings = app["settings"]
if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated":
return
codepoints = _emoji_to_codepoints(settings.WEBAPP_LOGO_EMOJI)
if not codepoints:
return
app.setdefault("webapp_emoji_cache", {})
app.setdefault("webapp_emoji_cache_lock", asyncio.Lock())
emoji_caches: Dict[str, Tuple[bytes, str]] = app["webapp_emoji_cache"]
for ext in ("gif", "webp"):
emoji_cache_key = f"{codepoints}:{ext}"
if emoji_cache_key in emoji_caches:
continue
loaded_emoji = await _load_or_fetch_webapp_animated_emoji(codepoints, ext)
if loaded_emoji:
emoji_caches[emoji_cache_key] = loaded_emoji
if ext == "gif":
return
async def _load_or_fetch_webapp_animated_emoji(
codepoints: str, ext: str
) -> Optional[Tuple[bytes, str]]:
disk_emoji = await asyncio.to_thread(_read_webapp_animated_emoji_from_disk, codepoints, ext)
if disk_emoji:
return disk_emoji
fetched_emoji = await _fetch_webapp_animated_emoji(codepoints, ext)
if fetched_emoji:
await asyncio.to_thread(
_write_webapp_animated_emoji_to_disk, codepoints, ext, fetched_emoji
)
return fetched_emoji
def _read_webapp_animated_emoji_from_disk(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]:
path = _webapp_emoji_disk_path(codepoints, ext)
try:
body = path.read_bytes()
except OSError:
return None
if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES:
return None
return body, "image/gif" if ext == "gif" else "image/webp"
def _write_webapp_animated_emoji_to_disk(
codepoints: str, ext: str, emoji: Tuple[bytes, str]
) -> None:
body, _content_type = emoji
if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES:
return
path = _webapp_emoji_disk_path(codepoints, ext)
try:
WEBAPP_EMOJI_CACHE_DIR.mkdir(parents=True, exist_ok=True)
path.write_bytes(body)
except OSError as exc:
logger.warning("Failed to write WEBAPP animated emoji cache: %s", exc)
async def _fetch_webapp_animated_emoji(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]:
try:
session = await _get_shared_http_session()
timeout = ClientTimeout(total=4)
source_url = _webapp_animated_emoji_source_url(codepoints, ext)
async with session.get(
source_url,
allow_redirects=False,
headers={"Accept": "image/gif,image/webp,image/*,*/*;q=0.8"},
timeout=timeout,
) as response:
if response.status != 200:
return None
content_type = (
(response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
)
expected_content_type = "image/gif" if ext == "gif" else "image/webp"
if content_type and content_type != expected_content_type:
return None
body = bytearray()
async for chunk in response.content.iter_chunked(64 * 1024):
body.extend(chunk)
if len(body) > WEBAPP_EMOJI_MAX_BYTES:
logger.warning("WEBAPP animated emoji exceeded the 4 MiB limit.")
return None
if not body:
return None
return bytes(body), expected_content_type
except Exception as exc:
logger.warning("Failed to fetch WEBAPP animated emoji: %s", exc)
return None
async def _load_or_fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]:
disk_logo = await asyncio.to_thread(_read_webapp_logo_from_disk, logo_url)
if disk_logo:
return disk_logo
fetched_logo = await _fetch_webapp_logo(logo_url)
if fetched_logo:
await asyncio.to_thread(_write_webapp_logo_to_disk, logo_url, fetched_logo)
return fetched_logo
def _read_webapp_logo_from_disk(logo_url: str) -> Optional[Tuple[bytes, str]]:
body_path, meta_path = _webapp_logo_disk_paths(logo_url)
try:
metadata = json.loads(meta_path.read_text(encoding="utf-8"))
if metadata.get("source_url") != logo_url:
return None
content_type = str(metadata.get("content_type") or "").strip().lower()
if not content_type.startswith("image/"):
return None
body = body_path.read_bytes()
except (OSError, json.JSONDecodeError):
return None
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
return None
return body, content_type
def _write_webapp_logo_to_disk(logo_url: str, logo: Tuple[bytes, str]) -> None:
body, content_type = logo
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
return
body_path, meta_path = _webapp_logo_disk_paths(logo_url)
try:
WEBAPP_LOGO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
body_path.write_bytes(body)
meta_path.write_text(
json.dumps(
{
"source_url": logo_url,
"content_type": content_type,
"cached_at": datetime.now(timezone.utc).isoformat(),
"bytes": len(body),
},
ensure_ascii=False,
separators=(",", ":"),
),
encoding="utf-8",
)
except OSError as exc:
logger.warning("Failed to write WEBAPP_LOGO_URL cache: %s", exc)
async def _fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]:
"""Fetch and cache the configured logo on the server side."""
try:
session = await _get_shared_http_session()
timeout = ClientTimeout(total=3)
async with session.get(
logo_url,
allow_redirects=False,
headers={"Accept": "image/avif,image/webp,image/svg+xml,image/png,image/*,*/*;q=0.8"},
timeout=timeout,
) as response:
if response.status != 200:
logger.warning(
"WEBAPP_LOGO_URL returned HTTP %s; keeping the logo hidden.",
response.status,
)
return None
content_type = (
(response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
)
if content_type and not content_type.startswith("image/"):
logger.warning(
"WEBAPP_LOGO_URL returned non-image content type %s; keeping the logo hidden.",
content_type,
)
return None
body = bytearray()
async for chunk in response.content.iter_chunked(64 * 1024):
body.extend(chunk)
if len(body) > WEBAPP_LOGO_MAX_BYTES:
logger.warning("WEBAPP_LOGO_URL exceeded the 2 MiB limit.")
return None
if not body:
logger.warning("WEBAPP_LOGO_URL returned an empty response body.")
return None
return bytes(body), content_type or "image/png"
except Exception as exc:
logger.warning("Failed to fetch WEBAPP_LOGO_URL: %s", exc)
return None
async def _get_shared_http_session() -> ClientSession:
global _SHARED_HTTP_SESSION
async with _SHARED_HTTP_SESSION_LOCK:
if _SHARED_HTTP_SESSION is None or _SHARED_HTTP_SESSION.closed:
_SHARED_HTTP_SESSION = ClientSession(
timeout=ClientTimeout(total=30),
headers={
"User-Agent": "Mozilla/5.0",
"Accept": "*/*",
},
)
return _SHARED_HTTP_SESSION
async def _ensure_shared_http_session() -> None:
await _get_shared_http_session()
async def _close_shared_http_session() -> None:
global _SHARED_HTTP_SESSION
async with _SHARED_HTTP_SESSION_LOCK:
if _SHARED_HTTP_SESSION and not _SHARED_HTTP_SESSION.closed:
await _SHARED_HTTP_SESSION.close()
_SHARED_HTTP_SESSION = None
async def _hostname_resolves_to_public_address(hostname: str) -> bool:
if not hostname:
return False
try:
ip_obj = ipaddress.ip_address(hostname)
return not (
ip_obj.is_private
or ip_obj.is_loopback
or ip_obj.is_link_local
or ip_obj.is_unspecified
or ip_obj.is_reserved
)
except ValueError:
pass
loop = asyncio.get_running_loop()
try:
resolved = await loop.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
except Exception:
return False
found_public_ip = False
for entry in resolved:
sockaddr = entry[4]
if not sockaddr:
continue
candidate = sockaddr[0]
try:
ip_obj = ipaddress.ip_address(candidate)
except ValueError:
continue
if (
ip_obj.is_private
or ip_obj.is_loopback
or ip_obj.is_link_local
or ip_obj.is_unspecified
or ip_obj.is_reserved
):
return False
found_public_ip = True
return found_public_ip
@web.middleware
async def _security_headers_middleware(request: web.Request, handler):
request["csp_nonce"] = secrets.token_urlsafe(16)
try:
response = await handler(request)
except web.HTTPException as exc:
response = exc
nonce = request.get("csp_nonce", "")
response.headers.setdefault(
"Content-Security-Policy",
(
"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' https://telegram.org; "
"frame-src https://oauth.telegram.org; "
"frame-ancestors https://web.telegram.org https://t.me; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; " # noqa: E501
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; "
"img-src 'self' data: https:; "
"connect-src 'self' https://oauth.telegram.org; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'"
),
)
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault(
"Permissions-Policy",
(
"accelerometer=(), autoplay=(), camera=(), display-capture=(), "
"encrypted-media=(), geolocation=(), gyroscope=(), magnetometer=(), "
"microphone=(), midi=(), payment=(), usb=()"
),
)
return response
@web.middleware
async def _csrf_protection_middleware(request: web.Request, handler):
settings: Settings = request.app["settings"]
header = request.headers.get("Authorization", "")
prefix = "Bearer "
if header.startswith(prefix):
if verify_webapp_session_token(settings, header[len(prefix) :].strip()):
return await handler(request)
if (
request.method in WEBAPP_STATE_CHANGING_METHODS
and request.path not in WEBAPP_CSRF_EXEMPT_PATHS
and request.cookies.get(WEBAPP_SESSION_COOKIE_NAME)
):
csrf_cookie = request.cookies.get(WEBAPP_CSRF_COOKIE_NAME, "")
csrf_header = request.headers.get(WEBAPP_CSRF_HEADER_NAME, "")
if not csrf_cookie or not csrf_header or not hmac.compare_digest(csrf_header, csrf_cookie):
return _json_error(403, "csrf_failed", "Invalid CSRF token")
return await handler(request)
def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
settings: Settings = request.app["settings"]
cache = request.app["webapp_settings_cache"]
now = time.monotonic()
if now - float(cache.get("ts", 0.0)) >= 60 or not cache.get("data"):
cache["data"] = {
"logo_url": _resolve_webapp_logo_url(settings),
"subscription_options": settings.subscription_options,
"stars_subscription_options": settings.stars_subscription_options,
"traffic_packages": settings.traffic_packages,
"stars_traffic_packages": settings.stars_traffic_packages,
"support_url": settings.SUPPORT_LINK or "",
"terms_url": settings.TERMS_OF_SERVICE_URL or "",
"privacy_policy_url": settings.PRIVACY_POLICY_URL or "",
"user_agreement_url": settings.USER_AGREEMENT_URL or "",
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"email_auth_enabled": settings.email_auth_configured,
"language": _normalize_language(settings.DEFAULT_LANGUAGE),
}
cache["ts"] = now
return cache["data"]
def _run_git_command(*args: str) -> str:
repo_root = Path(__file__).resolve().parents[3]
try:
result = subprocess.run(
["git", *args],
cwd=repo_root,
check=True,
capture_output=True,
text=True,
timeout=1.5,
)
except (OSError, subprocess.SubprocessError):
return ""
return result.stdout.strip()
def _resolve_app_version() -> str:
global _APP_VERSION_CACHE
if _APP_VERSION_CACHE:
return _APP_VERSION_CACHE
env_version = os.getenv("REMNAWAVE_MINISHOP_VERSION", "").strip()
if env_version:
_APP_VERSION_CACHE = env_version
return env_version
build_version_path = Path(__file__).resolve().parents[3] / ".build-version"
try:
build_version = build_version_path.read_text(encoding="utf-8").strip()
except OSError:
build_version = ""
if build_version:
_APP_VERSION_CACHE = build_version
return build_version
tag = _run_git_command("describe", "--tags", "--abbrev=0")
sha = _run_git_command("rev-parse", "--short", "HEAD")
dirty = bool(_run_git_command("status", "--porcelain"))
if tag and sha:
commits_since_tag = _run_git_command("rev-list", f"{tag}..HEAD", "--count")
if commits_since_tag and commits_since_tag != "0":
version = f"{tag}+{commits_since_tag}.g{sha}"
else:
version = tag
elif sha:
version = f"dev+g{sha}"
else:
version = "dev+unknown"
if dirty:
version = f"{version}-dirty"
_APP_VERSION_CACHE = version
return version
async def _enforce_webapp_rate_limit(
request: web.Request,
*,
user_id: int,
action: str,
) -> Optional[web.Response]:
settings: Settings = request.app["settings"]
ip_address = (
request_client_ip(request, trusted_proxies=settings.trusted_proxies)
or request.remote
or "unknown"
)
key = f"{action}:{ip_address}:{int(user_id)}"
buckets: Dict[str, deque[float]] = request.app["webapp_rate_limit_buckets"]
lock: asyncio.Lock = request.app["webapp_rate_limit_lock"]
now = time.monotonic()
async with lock:
bucket = buckets.setdefault(key, deque())
while bucket and now - bucket[0] >= WEBAPP_RATE_LIMIT_WINDOW_SECONDS:
bucket.popleft()
if not bucket:
buckets.pop(key, None)
bucket = buckets.setdefault(key, deque())
if len(bucket) >= WEBAPP_RATE_LIMIT_MAX_REQUESTS:
retry_after = (
max(
1,
int(WEBAPP_RATE_LIMIT_WINDOW_SECONDS - (now - bucket[0])),
)
if bucket
else WEBAPP_RATE_LIMIT_WINDOW_SECONDS
)
return web.json_response(
{
"ok": False,
"error": "rate_limited",
"retry_after": retry_after,
},
status=429,
headers={"Retry-After": str(retry_after)},
)
bucket.append(now)
return None
async def js_asset_route(request: web.Request) -> web.Response:
asset_hash = request.match_info.get("asset_hash")
filename = (
f"subscription_webapp.min.{asset_hash}.js" if asset_hash else "subscription_webapp.js"
)
response = await _serve_template_asset(
request,
filename,
"application/javascript",
strip_dev_mock=not asset_hash,
)
response.headers["Cache-Control"] = (
"public, max-age=31536000, immutable" if asset_hash else "no-cache"
)
return response
async def index_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
raise web.HTTPNotFound(text="webapp_disabled")
html = TEMPLATE_PATH.read_text(encoding="utf-8")
cached = _get_cached_webapp_settings(request)
config = {
"title": settings.WEBAPP_TITLE,
"primaryColor": settings.WEBAPP_PRIMARY_COLOR,
"logoUrl": cached["logo_url"],
"logoEmoji": settings.WEBAPP_LOGO_EMOJI,
"logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT,
"apiBase": "/api",
"telegramLoginBotUsername": request.app.get("bot_username") or "",
"telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0,
"telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0,
"telegramOAuthRequestAccess": _resolve_telegram_oauth_request_access(settings),
"supportUrl": cached["support_url"],
"termsUrl": cached["terms_url"],
"privacyPolicyUrl": cached["privacy_policy_url"],
"userAgreementUrl": cached["user_agreement_url"],
"currency": cached["currency"],
"language": cached["language"],
"emailAuthEnabled": cached["email_auth_enabled"],
"appVersion": _resolve_app_version(),
"appRepositoryUrl": APP_REPOSITORY_URL,
}
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
i18n_instance: Optional[object] = request.app.get("i18n")
i18n_payload = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
nonce = request.get("csp_nonce", "")
html = html.replace(
WEBAPP_CONFIG_PLACEHOLDER,
(
f'<script id="webapp-config" type="application/json" nonce="{nonce}">'
+ json.dumps(config, ensure_ascii=False, separators=(",", ":"))
+ "</script>"
),
)
html = html.replace(
WEBAPP_I18N_PLACEHOLDER,
(
f'<script id="i18n" type="application/json" nonce="{nonce}">'
+ json.dumps(i18n_payload, ensure_ascii=False, separators=(",", ":"))
+ "</script>"
),
)
html = html.replace(
WEBAPP_JS_PLACEHOLDER,
f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>',
)
brand_asset_url = cached["logo_url"]
if not brand_asset_url and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated":
brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI)
if brand_asset_url:
html = html.replace(
'<link rel="preload" id="logo-preload" href="" as="image" fetchpriority="high" crossorigin="anonymous">', # noqa: E501
f'<link rel="preload" href="{brand_asset_url}" as="image" fetchpriority="high" crossorigin="anonymous">', # noqa: E501
)
else:
html = html.replace(
'<link rel="preload" id="logo-preload" href="" as="image" fetchpriority="high" crossorigin="anonymous">', # noqa: E501
"",
)
return web.Response(text=html, content_type="text/html", charset="utf-8")
async def _serve_template_asset(
request: web.Request,
filename: str,
content_type: str,
*,
strip_dev_mock: bool = False,
) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
raise web.HTTPNotFound(text="webapp_disabled")
path = ASSET_DIR / filename
text = path.read_text(encoding="utf-8")
if strip_dev_mock:
text = _strip_marked_block(
text,
"/* WEBAPP_DEV_MOCK_START */",
"/* WEBAPP_DEV_MOCK_END */",
)
return web.Response(text=text, content_type=content_type, charset="utf-8")
def _resolve_webapp_js_asset_name() -> str:
minified_assets = []
for path in ASSET_DIR.glob("subscription_webapp.min.*.js"):
try:
minified_assets.append((path.stat().st_mtime, path.name))
except OSError:
continue
if minified_assets:
minified_assets.sort(reverse=True)
return minified_assets[0][1]
return "subscription_webapp.js"
def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str:
start = html.find(start_marker)
if start == -1:
return html
end = html.find(end_marker, start)
if end == -1:
return html[:start]
return html[:start] + html[end + len(end_marker) :]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+156
View File
@@ -0,0 +1,156 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def _read_json(request: web.Request) -> Dict[str, Any]:
try:
data = await request.json()
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _json_error(status: int, code: str, message: str) -> web.Response:
return web.json_response(
{"ok": False, "error": code, "message": message},
status=status,
)
def _validation_error_response(exc: ValidationError) -> web.Response:
for error in exc.errors():
loc = error.get("loc") or ()
field = str(loc[0]) if loc else ""
error_type = str(error.get("type") or "")
message = str(error.get("msg") or "")
message_lower = message.lower()
if field == "email":
if (
"too_long" in message_lower
or "too long" in message_lower
or error_type == "string_too_long"
):
return _json_error(400, "email_too_long", "Email is too long")
return _json_error(400, "invalid_email", "Invalid email")
if field in {"description", "comment", "note"} and error_type == "string_too_long":
return _json_error(400, f"{field}_too_long", f"{field.capitalize()} is too long")
if error_type == "string_too_long":
return _json_error(400, "text_too_long", "Text is too long")
return _json_error(400, "invalid_request", "Invalid request")
def _validate_model_payload(
model_cls: type[BaseModel],
payload: Dict[str, Any],
) -> tuple[Optional[BaseModel], Optional[web.Response]]:
try:
return model_cls.model_validate(payload), None
except ValidationError as exc:
return None, _validation_error_response(exc)
def _normalize_language(lang: Optional[str]) -> str:
value = (lang or "ru").split("-")[0].lower()
return value if value in {"ru", "en"} else "ru"
def _format_remaining(seconds: int, lang: str) -> str:
if seconds <= 0:
if lang == "en":
return "Subscription inactive"
return "Подписка не активна"
days, rem = divmod(seconds, 86400)
hours, rem = divmod(rem, 3600)
minutes = rem // 60
if lang == "en":
if days > 0:
return f"{days} d. {hours} h."
if hours > 0:
return f"{hours} h. {minutes} min."
return f"{max(1, minutes)} min."
if days > 0:
return f"{days} д. {hours} ч."
if hours > 0:
return f"{hours} ч. {minutes} мин."
return f"{max(1, minutes)} мин."
def _coerce_int_or_none(value: Optional[Any]) -> Optional[int]:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _format_bytes(value: Optional[Any], *, zero_as_unlimited: bool = False) -> str:
if value is None:
return "N/A"
try:
size = float(value)
except (TypeError, ValueError):
return str(value)
if size <= 0 and zero_as_unlimited:
return ""
if size <= 0:
size = 0
units = ["B", "KB", "MB", "GB", "TB"]
index = 0
while size >= 1024 and index < len(units) - 1:
size /= 1024
index += 1
return f"{size:.2f} {units[index]}"
def _format_months_title(months: int, lang: str) -> str:
if lang == "en":
if months == 1:
return "1 month"
return f"{months} months"
if months == 1:
return "1 месяц"
if 2 <= months <= 4:
return f"{months} месяца"
return f"{months} месяцев"
def _format_number_for_payload(value: Any) -> str:
numeric = float(value or 0)
return str(int(numeric)) if numeric.is_integer() else f"{numeric:g}"
def _format_traffic_title(traffic_gb: float, lang: str) -> str:
return f"{_format_number_for_payload(traffic_gb)} GB"
def _traffic_payment_description(traffic_gb: float, lang: str) -> str:
if lang == "en":
return f"Traffic package {_format_traffic_title(traffic_gb, lang)}"
return f"Пакет трафика {_format_traffic_title(traffic_gb, lang)}"
def _hwid_devices_payment_description(device_count: int, lang: str) -> str:
if lang == "en":
return f"HWID device package +{device_count}"
return f"Докупка устройств HWID +{device_count}"
def _resolve_numeric_option_key(options: Dict[Any, Any], target: float) -> Optional[Any]:
for key in options:
try:
if abs(float(key) - float(target)) < 0.000001:
return key
except (TypeError, ValueError):
continue
return None
def _payment_description(months: int, lang: str) -> str:
if lang == "en":
return f"Subscription for {_format_months_title(months, lang)}"
return f"Подписка на {_format_months_title(months, lang)}"
+169
View File
@@ -0,0 +1,169 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def devices_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
if not settings.MY_DEVICES_SECTION_ENABLED:
return _json_error(404, "devices_disabled", "Devices section is disabled")
async_session_factory: sessionmaker = request.app["async_session_factory"]
subscription_service: SubscriptionService = request.app["subscription_service"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
active = await subscription_service.get_active_subscription_details(session, user_id)
panel_user_uuid = active.get("user_id") if active else None
if not panel_user_uuid:
return _json_error(400, "subscription_not_active", "Subscription is not active")
panel_service = getattr(subscription_service, "panel_service", None)
if not panel_service:
return _json_error(503, "panel_unavailable", "Panel service unavailable")
try:
devices_response = await panel_service.get_user_devices(panel_user_uuid)
except Exception:
logger.exception("Failed to load WebApp devices for user %s", user_id)
return _json_error(502, "devices_load_failed", "Failed to load devices")
devices = _normalize_devices_response(devices_response)
max_devices = _coerce_int_or_none(active.get("max_devices")) if active else None
return web.json_response(
{
"ok": True,
"enabled": True,
"current_devices": len(devices),
"max_devices": max_devices,
"max_devices_label": _format_devices_limit(max_devices),
"devices": [
_serialize_device(device, index) for index, device in enumerate(devices, start=1)
],
}
)
async def disconnect_device_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
rate_limit_response = await _enforce_webapp_rate_limit(
request,
user_id=user_id,
action="devices_disconnect",
)
if rate_limit_response:
return rate_limit_response
settings: Settings = request.app["settings"]
if not settings.MY_DEVICES_SECTION_ENABLED:
return _json_error(404, "devices_disabled", "Devices section is disabled")
payload = await _read_json(request)
disconnect_payload, validation_error = _validate_model_payload(
WebAppDeviceDisconnectPayload, payload
)
if validation_error:
return validation_error
token = str(disconnect_payload.token or "").strip()
async_session_factory: sessionmaker = request.app["async_session_factory"]
subscription_service: SubscriptionService = request.app["subscription_service"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
active = await subscription_service.get_active_subscription_details(session, user_id)
panel_user_uuid = active.get("user_id") if active else None
if not panel_user_uuid:
return _json_error(400, "subscription_not_active", "Subscription is not active")
panel_service = getattr(subscription_service, "panel_service", None)
if not panel_service:
return _json_error(503, "panel_unavailable", "Panel service unavailable")
try:
devices_response = await panel_service.get_user_devices(panel_user_uuid)
except Exception:
logger.exception("Failed to load WebApp devices before disconnect for user %s", user_id)
return _json_error(502, "devices_load_failed", "Failed to load devices")
target_hwid = None
for device in _normalize_devices_response(devices_response):
hwid = str(device.get("hwid") or "").strip()
if hwid and hmac.compare_digest(_device_hwid_token(hwid), token):
target_hwid = hwid
break
if not target_hwid:
return _json_error(404, "device_not_found", "Device not found")
success = await panel_service.disconnect_device(panel_user_uuid, target_hwid)
if not success:
return _json_error(502, "device_disconnect_failed", "Failed to disconnect device")
await session.commit()
return web.json_response({"ok": True})
def _device_hwid_token(hwid: str) -> str:
return hashlib.sha256(str(hwid or "").encode()).hexdigest()[:32]
def _shorten_hwid_for_display(hwid: Optional[str], max_length: int = 24) -> str:
value = str(hwid or "").strip()
if len(value) <= max_length:
return value
return f"{value[:8]}...{value[-6:]}"
def _normalize_devices_response(devices_response: Any) -> List[Dict[str, Any]]:
if isinstance(devices_response, dict):
devices = devices_response.get("devices") or []
else:
devices = devices_response or []
if not isinstance(devices, list):
return []
return [device for device in devices if isinstance(device, dict)]
def _format_devices_limit(max_devices: Optional[int]) -> str:
if max_devices in (None, 0):
return "Unlimited"
return str(max_devices)
def _format_device_datetime(value: Any) -> str:
if not value:
return ""
text = str(value)
try:
normalized = datetime.fromisoformat(text.replace("Z", "+00:00"))
return normalized.strftime("%d.%m.%Y %H:%M")
except Exception:
return text
def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]:
hwid = str(device.get("hwid") or "").strip()
model = str(device.get("deviceModel") or "").strip()
platform = str(device.get("platform") or "").strip()
os_version = str(device.get("osVersion") or "").strip()
user_agent = str(device.get("userAgent") or "").strip()
display_name = model or platform or f"Device {index}"
platform_label = " ".join(part for part in (platform, os_version) if part).strip()
return {
"index": index,
"display_name": display_name,
"platform": platform,
"os_version": os_version,
"platform_label": platform_label,
"user_agent": user_agent,
"created_at": device.get("createdAt"),
"created_at_text": _format_device_datetime(device.get("createdAt")),
"hwid_short": _shorten_hwid_for_display(hwid),
"token": _device_hwid_token(hwid) if hwid else "",
"can_disconnect": bool(hwid),
}
+59
View File
@@ -0,0 +1,59 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
class WebAppEmailPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
email: EmailStr
@field_validator("email")
@classmethod
def _normalize_and_limit_email(cls, value: EmailStr) -> str:
normalized = normalize_email(str(value))
if len(normalized) > 254:
raise ValueError("email_too_long")
return normalized
class WebAppEmailCodePayload(WebAppEmailPayload):
code: str = ""
class WebAppEmailMagicPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
token: constr(min_length=8, max_length=512)
class WebAppPaymentCreatePayload(BaseModel):
model_config = ConfigDict(extra="ignore")
method: str = ""
months: Any = None
traffic_gb: Any = None
device_count: Any = None
tariff_key: Optional[constr(max_length=128)] = None
sale_mode: Optional[constr(max_length=64)] = None
description: Optional[constr(max_length=4096)] = None
comment: Optional[constr(max_length=4096)] = None
note: Optional[constr(max_length=4096)] = None
class WebAppTariffChangePayload(BaseModel):
model_config = ConfigDict(extra="ignore")
tariff_key: constr(min_length=1, max_length=128)
mode: constr(min_length=1, max_length=64)
class WebAppLanguagePayload(BaseModel):
model_config = ConfigDict(extra="ignore")
language: constr(min_length=2, max_length=16)
class WebAppDeviceDisconnectPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
token: constr(min_length=8, max_length=128)
+48
View File
@@ -0,0 +1,48 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/", index_route)
app.router.add_get("/home", index_route)
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("/admin/users/{user_id:-?[0-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)
app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route)
app.router.add_get(
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
webapp_animated_emoji_route,
)
app.router.add_get("/subscription_webapp.css", css_asset_route)
app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route)
app.router.add_get("/subscription_webapp.js", js_asset_route)
app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route)
app.router.add_post("/api/auth/token", auth_token_route)
app.router.add_post("/api/auth/email/request", email_auth_request_route)
app.router.add_post("/api/auth/email/verify", email_auth_verify_route)
app.router.add_post("/api/auth/email/magic", email_auth_magic_route)
app.router.add_post("/api/auth/logout", logout_route)
app.router.add_get("/api/me", me_route)
app.router.add_get("/api/account/avatar", account_avatar_route)
app.router.add_post("/api/account/language", account_language_route)
app.router.add_post("/api/account/email/request", account_email_request_route)
app.router.add_post("/api/account/email/verify", account_email_verify_route)
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
app.router.add_post("/api/promo/apply", apply_promo_route)
app.router.add_post("/api/trial/activate", activate_trial_route)
app.router.add_get("/api/devices", devices_route)
app.router.add_post("/api/devices/disconnect", disconnect_device_route)
app.router.add_get("/api/devices/topup-options", device_topup_options_route)
app.router.add_get("/api/tariffs/topup-options", tariff_topup_options_route)
app.router.add_get("/api/tariffs/change-options", tariff_change_options_route)
app.router.add_post("/api/tariffs/change", tariff_change_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_get("/api/payments/{payment_id}", payment_status_route)
setup_admin_routes(app)
+612
View File
@@ -0,0 +1,612 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]:
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
subscription_service: SubscriptionService = request.app["subscription_service"]
cached = _get_cached_webapp_settings(request)
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
raise web.HTTPForbidden(
text=json.dumps({"ok": False, "error": "access_denied"}),
content_type="application/json",
)
active = await subscription_service.get_active_subscription_details(session, user_id)
referral_code = await user_dal.ensure_referral_code(session, db_user)
referral_service: Optional[ReferralService] = request.app.get("referral_service")
bot_username = request.app.get("bot_username") or ""
referral_link = None
if referral_service and bot_username:
referral_link = await referral_service.generate_referral_link(
session,
bot_username,
user_id,
)
webapp_referral_link = _build_webapp_referral_link(
request.app["settings"].SUBSCRIPTION_MINI_APP_URL,
referral_code,
)
referral_stats = (
await referral_service.get_referral_stats(session, user_id)
if referral_service
else {"invited_count": 0, "purchased_count": 0}
)
local_sub = (
await subscription_dal.get_active_subscription_by_user_id(
session,
user_id,
db_user.panel_user_uuid,
)
if db_user.panel_user_uuid
else None
)
trial_available = bool(
settings.TRIAL_ENABLED
and settings.TRIAL_DURATION_DAYS > 0
and not await subscription_service.has_had_any_subscription(session, user_id)
)
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
try:
await session.commit()
except Exception:
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,
"username": db_user.username,
"email": db_user.email,
"email_verified": bool(db_user.email_verified_at),
"telegram_id": db_user.telegram_id,
"telegram_linked": bool(_telegram_id_for_user(db_user)),
"telegram_photo_url": _telegram_avatar_url(avatar),
"first_name": db_user.first_name,
"language_code": lang,
"is_admin": is_admin,
},
"subscription": _serialize_subscription(settings, active, local_sub, lang),
"referral": {
"code": referral_code,
"bot_link": referral_link,
"webapp_link": webapp_referral_link,
"invited_count": referral_stats.get("invited_count", 0),
"purchased_count": referral_stats.get("purchased_count", 0),
"welcome_bonus_days": max(
0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)
),
"one_bonus_per_referee": bool(
getattr(settings, "REFERRAL_ONE_BONUS_PER_REFEREE", False)
),
"bonus_details": _serialize_referral_bonus_details(settings, lang),
},
"plans": _serialize_plans(
settings,
lang,
subscription_options=cached["subscription_options"],
stars_subscription_options=cached["stars_subscription_options"],
traffic_packages=cached["traffic_packages"],
stars_traffic_packages=cached["stars_traffic_packages"],
),
"payment_methods": _serialize_payment_methods(settings, request.app),
"settings": {
"support_url": settings.SUPPORT_LINK,
"traffic_mode": bool(settings.traffic_sale_mode),
"my_devices_enabled": bool(settings.MY_DEVICES_SECTION_ENABLED),
"user_hwid_device_limit": (
int(settings.USER_HWID_DEVICE_LIMIT)
if settings.USER_HWID_DEVICE_LIMIT is not None
else None
),
"trial_enabled": bool(settings.TRIAL_ENABLED),
"trial_available": trial_available,
"trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0),
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
"email_auth_enabled": settings.email_auth_configured,
},
}
def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
if getattr(settings, "traffic_sale_mode", False):
return []
details: List[Dict[str, Any]] = []
for months, _price in sorted(settings.subscription_options.items()):
inviter_days = settings.referral_bonus_inviter.get(months)
friend_days = settings.referral_bonus_referee.get(months)
if inviter_days is None and friend_days is None:
continue
details.append(
{
"months": int(months),
"title": _format_months_title(int(months), lang),
"inviter_days": int(inviter_days or 0),
"friend_days": int(friend_days or 0),
}
)
return details
def _build_webapp_referral_link(
base_url: Optional[str],
referral_code: Optional[str],
) -> Optional[str]:
if not base_url or not referral_code:
return None
parts = urlsplit(base_url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query["ref"] = f"u{referral_code}"
return urlunsplit(
(
parts.scheme,
parts.netloc,
parts.path or "/",
urlencode(query),
parts.fragment,
)
)
def _serialize_subscription(
settings: Settings,
active: Optional[Dict[str, Any]],
local_sub: Optional[Any],
lang: str,
) -> Dict[str, Any]:
if not active:
return {
"active": False,
"status": "INACTIVE",
"remaining_text": _format_remaining(0, lang),
"days_left": 0,
"config_link": None,
"connect_url": None,
}
end_date = active.get("end_date")
if end_date and end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=timezone.utc)
seconds_left = 0
if end_date:
seconds_left = max(
0,
int((end_date - datetime.now(timezone.utc)).total_seconds()),
)
can_topup_regular_traffic = False
can_topup_premium_traffic = False
can_topup_traffic = False
if settings.tariffs_config and active.get("tariff_key"):
try:
tariff = settings.tariffs_config.require(str(active.get("tariff_key")))
packages = settings.tariffs_config.topup_packages_for(tariff)
can_topup_regular_traffic = bool(packages and packages.has_any())
can_topup_premium_traffic = bool(
tariff.premium_squad_uuids
and tariff.premium_topup_packages
and tariff.premium_topup_packages.has_any()
)
can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic)
except Exception:
can_topup_regular_traffic = False
can_topup_premium_traffic = False
can_topup_traffic = False
return {
"active": seconds_left > 0,
"status": active.get("status_from_panel") or "UNKNOWN",
"end_date": end_date.isoformat() if end_date else None,
"end_date_text": end_date.strftime("%d.%m.%Y %H:%M") if end_date else "N/A",
"days_left": seconds_left // 86400,
"remaining_text": _format_remaining(seconds_left, lang),
"config_link": active.get("config_link"),
"connect_url": active.get("connect_button_url") or active.get("config_link"),
"traffic_limit": _format_bytes(active.get("traffic_limit_bytes"), zero_as_unlimited=True),
"traffic_used": _format_bytes(active.get("traffic_used_bytes")),
"traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")),
"traffic_used_bytes": _coerce_int_or_none(active.get("traffic_used_bytes")),
"tariff_key": active.get("tariff_key"),
"tariff_name": active.get("tariff_name"),
"tariff_description": active.get("tariff_description"),
"premium_title": active.get("premium_title"),
"billing_model": active.get("billing_model"),
"traffic_limit_strategy": str(active.get("traffic_limit_strategy") or ""),
"tier_baseline_bytes": _coerce_int_or_none(active.get("tier_baseline_bytes")),
"topup_balance_bytes": _coerce_int_or_none(active.get("topup_balance_bytes")),
"premium_limit": _format_bytes(active.get("premium_limit_bytes"), zero_as_unlimited=True),
"premium_used": _format_bytes(active.get("premium_used_bytes")),
"premium_limit_bytes": _coerce_int_or_none(active.get("premium_limit_bytes")),
"premium_used_bytes": _coerce_int_or_none(active.get("premium_used_bytes")),
"premium_baseline_bytes": _coerce_int_or_none(active.get("premium_baseline_bytes")),
"premium_topup_balance_bytes": _coerce_int_or_none(
active.get("premium_topup_balance_bytes")
),
"premium_topup_used_bytes": _coerce_int_or_none(active.get("premium_topup_used_bytes")),
"premium_bonus_bytes": _coerce_int_or_none(active.get("premium_bonus_bytes")) or 0,
"regular_bonus_bytes": _coerce_int_or_none(active.get("regular_bonus_bytes")) or 0,
"regular_unlimited_override": bool(active.get("regular_unlimited_override")),
"premium_unlimited_override": bool(active.get("premium_unlimited_override")),
"premium_is_limited": bool(active.get("premium_is_limited")),
"premium_squad_labels": list(active.get("premium_squad_labels") or []),
"premium_node_labels": list(active.get("premium_node_labels") or []),
"can_topup_traffic": can_topup_traffic,
"can_topup_regular_traffic": can_topup_regular_traffic,
"can_topup_premium_traffic": can_topup_premium_traffic,
"period_start_at": active.get("period_start_at").isoformat()
if active.get("period_start_at")
else None,
"is_throttled": bool(active.get("is_throttled")),
"max_devices": _coerce_int_or_none(active.get("max_devices")),
"base_hwid_device_limit": _coerce_int_or_none(active.get("base_hwid_device_limit")),
"extra_hwid_devices": _coerce_int_or_none(active.get("extra_hwid_devices")) or 0,
"auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)),
"provider": getattr(local_sub, "provider", None),
}
def _serialize_plans(
settings: Settings,
lang: str,
*,
subscription_options: Optional[Dict[int, float]] = None,
stars_subscription_options: Optional[Dict[int, int]] = None,
traffic_packages: Optional[Dict[float, float]] = None,
stars_traffic_packages: Optional[Dict[float, int]] = None,
) -> List[Dict[str, Any]]:
tariffs_config = settings.tariffs_config
if tariffs_config:
plans: List[Dict[str, Any]] = []
for tariff in tariffs_config.enabled_tariffs:
common = {
"tariff_key": tariff.key,
"tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model,
"description": tariff.description(lang),
"squad_uuids": tariff.squad_uuids,
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"hwid_device_limit": tariff.hwid_device_limit,
"hwid_device_packages": _serialize_hwid_device_packages(
settings,
tariff,
tariff.hwid_device_packages,
lang,
),
}
if tariff.billing_model == "period":
for months in sorted(tariff.enabled_periods):
price = tariff.period_price(int(months), "rub")
stars_price = tariff.period_price(int(months), "stars")
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
plan = {
**common,
"id": f"{tariff.key}:period:{int(months)}",
"sale_mode": "subscription",
"months": int(months),
"price": float(price or 0),
"title": tariff.name(lang),
"subtitle": _format_months_title(int(months), lang),
"monthly_gb": tariff.monthly_gb,
}
if stars_price is not None and int(stars_price) > 0:
plan["stars_price"] = int(stars_price)
plans.append(plan)
else:
rub_packages = {
float(package.gb): float(package.price)
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
}
stars_packages = {
float(package.gb): int(float(package.price))
for package in (
tariff.traffic_packages.stars if tariff.traffic_packages else []
)
}
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
price = rub_packages.get(traffic_gb)
stars_price = stars_packages.get(traffic_gb)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
traffic_value = float(traffic_gb)
plan = {
**common,
"id": f"{tariff.key}:traffic:{_format_number_for_payload(traffic_value)}",
"sale_mode": "traffic_package",
"months": int(traffic_value)
if traffic_value.is_integer()
else traffic_value,
"traffic_gb": traffic_value,
"price": float(price or 0),
"title": tariff.name(lang),
"subtitle": _format_traffic_title(traffic_value, lang),
}
if stars_price is not None and int(stars_price) > 0:
plan["stars_price"] = int(stars_price)
plans.append(plan)
return plans
if getattr(settings, "traffic_sale_mode", False):
active_traffic_packages = traffic_packages or settings.traffic_packages
active_stars_traffic_packages = stars_traffic_packages or settings.stars_traffic_packages
traffic_units = sorted(set(active_traffic_packages) | set(active_stars_traffic_packages))
plans: List[Dict[str, Any]] = []
for traffic_gb in traffic_units:
price = active_traffic_packages.get(traffic_gb)
stars_price = active_stars_traffic_packages.get(traffic_gb)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
traffic_value = float(traffic_gb)
plan = {
"months": int(traffic_value) if traffic_value.is_integer() else traffic_value,
"traffic_gb": traffic_value,
"price": float(price or 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"title": _format_traffic_title(traffic_value, lang),
"sale_mode": "traffic",
}
if stars_price is not None and int(stars_price) > 0:
plan["stars_price"] = int(stars_price)
plans.append(plan)
return plans
active_subscription_options = subscription_options or settings.subscription_options
active_stars_subscription_options = (
stars_subscription_options or settings.stars_subscription_options
)
plans: List[Dict[str, Any]] = []
for months in sorted(set(active_subscription_options) | set(active_stars_subscription_options)):
price = active_subscription_options.get(months)
stars_price = active_stars_subscription_options.get(months)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
plan = {
"months": int(months),
"price": float(price or 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"title": _format_months_title(int(months), lang),
"sale_mode": "subscription",
}
if stars_price is not None and int(stars_price) > 0:
plan["stars_price"] = int(stars_price)
plans.append(plan)
return plans
def _traffic_percent(used: Optional[int], limit: Optional[int]) -> int:
used_val = int(used or 0)
limit_val = int(limit or 0)
if limit_val <= 0:
return 0
return max(0, min(100, round((used_val / limit_val) * 100)))
def _serialize_topup_packages(
settings: Settings,
tariff: Any,
packages: Optional[Any],
lang: str,
*,
sale_mode: str = "topup",
title_prefix: str = "",
) -> List[Dict[str, Any]]:
rub_packages = {
float(package.gb): float(package.price) for package in (packages.rub if packages else [])
}
stars_packages = {
float(package.gb): int(float(package.price))
for package in (packages.stars if packages else [])
}
plans: List[Dict[str, Any]] = []
for traffic_gb in sorted(set(rub_packages) | set(stars_packages)):
price = rub_packages.get(traffic_gb)
stars_price = stars_packages.get(traffic_gb)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
traffic_value = float(traffic_gb)
plan: Dict[str, Any] = {
"id": f"{tariff.key}:{sale_mode}:{_format_number_for_payload(traffic_value)}",
"tariff_key": tariff.key,
"tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model,
"sale_mode": sale_mode,
"months": int(traffic_value) if traffic_value.is_integer() else traffic_value,
"traffic_gb": traffic_value,
"price": float(price or 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"title": f"{title_prefix}{_format_traffic_title(traffic_value, lang)}",
"subtitle": tariff.premium_name(lang)
if sale_mode == "premium_topup"
else tariff.name(lang),
}
if stars_price is not None and int(stars_price) > 0:
plan["stars_price"] = int(stars_price)
plans.append(plan)
return plans
def _serialize_hwid_device_packages(
settings: Settings,
tariff: Any,
packages: Optional[Any],
lang: str,
) -> List[Dict[str, Any]]:
rub_packages = {
int(package.count): float(package.price) for package in (packages.rub if packages else [])
}
stars_packages = {
int(package.count): int(float(package.price))
for package in (packages.stars if packages else [])
}
plans: List[Dict[str, Any]] = []
for count in sorted(set(rub_packages) | set(stars_packages)):
price = rub_packages.get(count)
stars_price = stars_packages.get(count)
if price is None and (stars_price is None or int(stars_price) <= 0):
continue
plan: Dict[str, Any] = {
"id": f"{tariff.key}:hwid:{count}",
"tariff_key": tariff.key,
"tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model,
"sale_mode": "hwid_devices",
"months": int(count),
"device_count": int(count),
"price": float(price or 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"title": f"+{count}",
"subtitle": tariff.name(lang),
}
if stars_price is not None and int(stars_price) > 0:
plan["stars_price"] = int(stars_price)
plans.append(plan)
return plans
def _serialize_tariff_change_target(
settings: Settings,
config: Any,
tariff: Any,
options: Dict[str, Any],
lang: str,
) -> Dict[str, Any]:
actions: List[Dict[str, Any]] = []
mode = str(options.get("mode") or "")
if mode == "period_to_period":
actions.append(
{
"mode": "recalc_days",
"kind": "free",
"title": "recalc_days",
"days_after": int(options.get("recalc_days") or 0),
"remaining_days": int(options.get("remaining_days") or 0),
}
)
paid_diff = float(options.get("paid_diff_rub") or 0)
if paid_diff > 0:
actions.append(
{
"mode": "paid_diff",
"kind": "payment",
"title": "paid_diff",
"price": paid_diff,
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
}
)
elif mode == "period_to_traffic":
actions.append(
{
"mode": "convert_days_to_gb",
"kind": "free",
"title": "convert_days_to_gb",
"converted_gb": float(options.get("converted_gb") or 0),
"remaining_days": int(options.get("remaining_days") or 0),
}
)
actions.extend(
{
"mode": "buy_package",
"kind": "payment",
"title": f"+{package.gb:g} GB",
"traffic_gb": float(package.gb),
"price": float(package.price),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
}
for package in (tariff.traffic_packages.rub if tariff.traffic_packages else [])
)
else:
for months in tariff.enabled_periods:
price = tariff.period_price(int(months), "rub")
if price:
actions.append(
{
"mode": "buy_period",
"kind": "payment",
"months": int(months),
"title": _format_months_title(int(months), lang),
"price": float(price),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
}
)
return {
"tariff_key": tariff.key,
"title": tariff.name(lang),
"description": tariff.description(lang),
"billing_model": tariff.billing_model,
"monthly_gb": tariff.monthly_gb,
"options": options,
"actions": actions,
}
def _serialize_payment_methods(
settings: Settings,
app: web.Application,
) -> List[Dict[str, Any]]:
labels = {
"severpay": "SeverPay",
"freekassa": "FreeKassa / СБП",
"platega_sbp": "Platega · СБП",
"platega_crypto": "Platega · Crypto",
"yookassa": "Банковская карта",
"stars": "Telegram Stars",
"cryptopay": "CryptoPay",
}
methods: List[Dict[str, Any]] = []
for method in settings.payment_methods_order:
method = method.lower()
if (
method == "severpay"
and settings.SEVERPAY_ENABLED
and _service_configured(app, "severpay_service")
):
methods.append({"id": method, "name": labels[method]})
elif (
method == "freekassa"
and settings.FREEKASSA_ENABLED
and _service_configured(app, "freekassa_service")
):
methods.append({"id": method, "name": labels[method]})
elif (
method == "platega_sbp"
and settings.PLATEGA_ENABLED
and settings.PLATEGA_SBP_ENABLED
and _service_configured(app, "platega_service")
):
methods.append({"id": method, "name": labels[method]})
elif (
method == "platega_crypto"
and settings.PLATEGA_ENABLED
and settings.PLATEGA_CRYPTO_ENABLED
and _service_configured(app, "platega_service")
):
methods.append({"id": method, "name": labels[method]})
elif (
method == "yookassa"
and settings.YOOKASSA_ENABLED
and _service_configured(app, "yookassa_service")
):
methods.append({"id": method, "name": labels[method]})
elif method == "stars" and settings.STARS_ENABLED:
methods.append({"id": method, "name": labels[method]})
elif (
method == "cryptopay"
and settings.CRYPTOPAY_ENABLED
and _service_configured(app, "cryptopay_service")
):
methods.append({"id": method, "name": labels[method]})
return methods
def _service_configured(app: web.Application, key: str) -> bool:
service = app.get(key)
return bool(service and getattr(service, "configured", False))
+3 -3
View File
@@ -160,7 +160,7 @@ async def change_broadcast_target_handler(
return return
await state.update_data(broadcast_target=new_target) await state.update_data(broadcast_target=new_target)
user_fsm_data = await state.get_data() await state.get_data()
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
confirmation_prompt = _("admin_broadcast_confirm_prompt_short") confirmation_prompt = _("admin_broadcast_confirm_prompt_short")
try: try:
@@ -254,7 +254,7 @@ async def confirm_broadcast_callback_handler(
failed_count = 0 failed_count = 0
admin_user = callback.from_user admin_user = callback.from_user
logging.info( logging.info(
f"Admin {admin_user.id} broadcasting '{(content.text or '')[:50]}...' to {len(user_ids)} users." f"Admin {admin_user.id} broadcasting '{(content.text or '')[:50]}...' to {len(user_ids)} users." # noqa: E501
) )
# Get message queue manager # Get message queue manager
@@ -297,7 +297,7 @@ async def confirm_broadcast_callback_handler(
"telegram_username": admin_user.username, "telegram_username": admin_user.username,
"telegram_first_name": admin_user.first_name, "telegram_first_name": admin_user.first_name,
"event_type": "admin_broadcast_queued", "event_type": "admin_broadcast_queued",
"content": f"To user {uid}: [{content.content_type}] {(content.text or '')[:70]}...", "content": f"To user {uid}: [{content.content_type}] {(content.text or '')[:70]}...", # noqa: E501
"is_admin_event": True, "is_admin_event": True,
"target_user_id": uid, "target_user_id": uid,
}, },
+1 -1
View File
@@ -78,7 +78,7 @@ async def admin_panel_actions_callback_handler(
if not callback.message: if not callback.message:
logging.error( logging.error(
f"CallbackQuery {callback.id} from {callback.from_user.id} has no message for admin_action {action}" f"CallbackQuery {callback.id} from {callback.from_user.id} has no message for admin_action {action}" # noqa: E501
) )
await callback.answer("Error processing action: message context lost.", show_alert=True) await callback.answer("Error processing action: message context lost.", show_alert=True)
return return
+1 -1
View File
@@ -148,7 +148,7 @@ async def _display_formatted_logs(
) )
except Exception as e: except Exception as e:
logging.warning( logging.warning(
f"Failed to edit message for logs display (len: {len(text)}): {e}. Sending new message(s)." f"Failed to edit message for logs display (len: {len(text)}): {e}. Sending new message(s)." # noqa: E501
) )
max_chunk_size = 4000 max_chunk_size = 4000
+1 -1
View File
@@ -138,7 +138,7 @@ async def view_payments_handler(
for i, payment in enumerate(payments, 1): for i, payment in enumerate(payments, 1):
text_parts.append( text_parts.append(
f"<b>{page * page_size + i}.</b> {format_payment_text(payment, i18n, current_lang, settings)}" f"<b>{page * page_size + i}.</b> {format_payment_text(payment, i18n, current_lang, settings)}" # noqa: E501
) )
text_parts.append("") # Empty line between payments text_parts.append("") # Empty line between payments
+1 -1
View File
@@ -486,7 +486,7 @@ async def create_bulk_promo_codes_final(
# Send CSV file if created # Send CSV file if created
if csv_file: if csv_file:
csv_caption = f"📄 Промокоды для массового создания\n💫 Всего: {len(created_codes)} промокодов\n🎁 Бонус: {data['bonus_days']} дней каждый" csv_caption = f"📄 Промокоды для массового создания\n💫 Всего: {len(created_codes)} промокодов\n🎁 Бонус: {data['bonus_days']} дней каждый" # noqa: E501
await message_obj.answer_document(csv_file, caption=csv_caption) await message_obj.answer_document(csv_file, caption=csv_caption)
await state.clear() await state.clear()
+2 -2
View File
@@ -113,7 +113,7 @@ async def view_promo_codes_handler(
else "\n".join( else "\n".join(
[_("admin_active_promos_list_header"), ""] [_("admin_active_promos_list_header"), ""]
+ [ + [
f"{get_promo_status_emoji_and_text(p, i18n, current_lang)[0]} <code>{p.code}</code> | 🎁 {p.bonus_days}д | 📊 {p.current_activations}/{p.max_activations} | ⏰ {p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')}" f"{get_promo_status_emoji_and_text(p, i18n, current_lang)[0]} <code>{p.code}</code> | 🎁 {p.bonus_days}д | 📊 {p.current_activations}/{p.max_activations} | ⏰ {p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')}" # noqa: E501
for p in promo_models for p in promo_models
] ]
) )
@@ -204,7 +204,7 @@ async def promo_management_handler(
# Формируем заголовок с информацией о страницах # Формируем заголовок с информацией о страницах
title = _("admin_promo_management_title") title = _("admin_promo_management_title")
if total_pages > 1: if total_pages > 1:
title += f"\n{_('admin_promo_list_page_info', current=page + 1, total=total_pages, count=total_count)}" title += f"\n{_('admin_promo_list_page_info', current=page + 1, total=total_pages, count=total_count)}" # noqa: E501
await callback.message.edit_text(title, reply_markup=builder.as_markup(), parse_mode="HTML") await callback.message.edit_text(title, reply_markup=builder.as_markup(), parse_mode="HTML")
await callback.answer() await callback.answer()
+5 -5
View File
@@ -97,7 +97,7 @@ async def show_statistics_handler(
nodes_stats = await panel_service.get_nodes_statistics() nodes_stats = await panel_service.get_nodes_statistics()
logging.info( logging.info(
f"Panel stats response: system={system_stats}, bandwidth={bandwidth_stats}, nodes={nodes_stats}" f"Panel stats response: system={system_stats}, bandwidth={bandwidth_stats}, nodes={nodes_stats}" # noqa: E501
) )
if system_stats: if system_stats:
@@ -173,7 +173,7 @@ async def show_statistics_handler(
total_nodes_count = len(unique_nodes) total_nodes_count = len(unique_nodes)
# Assume all nodes are active since we don't have status info # Assume all nodes are active since we don't have status info
stats_text_parts.append( stats_text_parts.append(
f"🔗 {_('admin_panel_nodes_label')}: <b>{total_nodes_count}/{total_nodes_count}</b>" f"🔗 {_('admin_panel_nodes_label')}: <b>{total_nodes_count}/{total_nodes_count}</b>" # noqa: E501
) )
else: else:
# Use nodes total from system stats as fallback # Use nodes total from system stats as fallback
@@ -191,7 +191,7 @@ async def show_statistics_handler(
stats_text_parts.append(f"\n<b>💰 {_('admin_financial_stats_header')}</b>") stats_text_parts.append(f"\n<b>💰 {_('admin_financial_stats_header')}</b>")
stats_text_parts.append( stats_text_parts.append(
f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" # noqa: E501
) )
stats_text_parts.append( stats_text_parts.append(
f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>" f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
@@ -200,7 +200,7 @@ async def show_statistics_handler(
f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>" f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
) )
stats_text_parts.append( stats_text_parts.append(
f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>" f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>" # noqa: E501
) )
last_payments_models: List[Payment] = await payment_dal.get_recent_payment_logs_with_user( last_payments_models: List[Payment] = await payment_dal.get_recent_payment_logs_with_user(
@@ -264,7 +264,7 @@ async def show_statistics_handler(
stats_text_parts.append(f" {_('admin_stats_sync_time')}: {sync_time_str}") stats_text_parts.append(f" {_('admin_stats_sync_time')}: {sync_time_str}")
stats_text_parts.append(f" {_('admin_stats_sync_status')}: {sync_status_model.status}") stats_text_parts.append(f" {_('admin_stats_sync_status')}: {sync_status_model.status}")
stats_text_parts.append( stats_text_parts.append(
f" {_('admin_stats_sync_users_processed')}: {sync_status_model.users_processed_from_panel}" f" {_('admin_stats_sync_users_processed')}: {sync_status_model.users_processed_from_panel}" # noqa: E501
) )
stats_text_parts.append( stats_text_parts.append(
f" {_('admin_stats_sync_subs_synced')}: {sync_status_model.subscriptions_synced}" f" {_('admin_stats_sync_subs_synced')}: {sync_status_model.subscriptions_synced}"
+26 -28
View File
@@ -79,7 +79,7 @@ async def _bind_panel_email_to_user(
if not merged_user.email_verified_at: if not merged_user.email_verified_at:
merged_user.email_verified_at = datetime.now(timezone.utc) merged_user.email_verified_at = datetime.now(timezone.utc)
logging.info( logging.info(
"Merged email-only user %s into user %s while binding panel email %s for panel UUID %s.", "Merged email-only user %s into user %s while binding panel email %s for panel UUID %s.", # noqa: E501
user_with_email.user_id, user_with_email.user_id,
merged_user.user_id, merged_user.user_id,
email_from_panel, email_from_panel,
@@ -169,9 +169,7 @@ async def perform_sync(
try: try:
panel_records_checked += 1 panel_records_checked += 1
panel_uuid = panel_user_dict.get("uuid") panel_uuid = panel_user_dict.get("uuid")
panel_subscription_uuid = panel_user_dict.get( panel_user_dict.get("subscriptionUuid") or panel_user_dict.get("shortUuid")
"subscriptionUuid"
) or panel_user_dict.get("shortUuid")
telegram_id_from_panel = panel_user_dict.get("telegramId") telegram_id_from_panel = panel_user_dict.get("telegramId")
email_from_panel = _normalize_panel_email(panel_user_dict.get("email")) email_from_panel = _normalize_panel_email(panel_user_dict.get("email"))
@@ -204,8 +202,8 @@ async def perform_sync(
if not existing_user: if not existing_user:
existing_user = await user_dal.get_user_by_panel_uuid(session, panel_uuid) existing_user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
if existing_user: if existing_user:
logging.info( logging.debug(
f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}" f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}" # noqa: E501
) )
# Update telegram ID if it was missing in panel data but we have local user # Update telegram ID if it was missing in panel data but we have local user
if ( if (
@@ -213,7 +211,7 @@ async def perform_sync(
and existing_user.user_id != telegram_id_from_panel and existing_user.user_id != telegram_id_from_panel
): ):
logging.warning( logging.warning(
f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}" f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}" # noqa: E501
) )
# Finally, fall back to email. This mainly catches panel users that # Finally, fall back to email. This mainly catches panel users that
@@ -235,7 +233,7 @@ async def perform_sync(
"email_verified_at": ( "email_verified_at": (
datetime.now(timezone.utc) if email_from_panel else None datetime.now(timezone.utc) if email_from_panel else None
), ),
"username": None, # Username will be updated when user interacts with bot "username": None, # Username will be updated when user interacts with bot # noqa: E501
"first_name": None, # Panel doesn't provide this info "first_name": None, # Panel doesn't provide this info
"last_name": None, # Panel doesn't provide this info "last_name": None, # Panel doesn't provide this info
"language_code": "ru", # Default language "language_code": "ru", # Default language
@@ -248,7 +246,7 @@ async def perform_sync(
if was_created: if was_created:
users_created += 1 users_created += 1
logging.info( logging.info(
f"Created new user {telegram_id_from_panel} from panel sync with UUID {panel_uuid}" f"Created new user {telegram_id_from_panel} from panel sync with UUID {panel_uuid}" # noqa: E501
) )
existing_user = new_user existing_user = new_user
@@ -272,12 +270,12 @@ async def perform_sync(
if was_created: if was_created:
users_created += 1 users_created += 1
logging.info( logging.info(
f"Created new email user {new_user.user_id} from panel sync with UUID {panel_uuid}" f"Created new email user {new_user.user_id} from panel sync with UUID {panel_uuid}" # noqa: E501
) )
existing_user = new_user existing_user = new_user
except Exception as e_create_email: except Exception as e_create_email:
sync_errors.append( sync_errors.append(
f"Error creating email user {email_from_panel}: {str(e_create_email)}" f"Error creating email user {email_from_panel}: {str(e_create_email)}" # noqa: E501
) )
logging.error( logging.error(
f"Error creating email user {email_from_panel}: {e_create_email}" f"Error creating email user {email_from_panel}: {e_create_email}"
@@ -285,7 +283,7 @@ async def perform_sync(
continue continue
else: else:
logging.debug( logging.debug(
f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping" f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping" # noqa: E501
) )
continue continue
@@ -359,7 +357,7 @@ async def perform_sync(
) )
except Exception as e_desc: except Exception as e_desc:
logging.warning( logging.warning(
f"Sync: Failed to update description for panel user {panel_uuid} (tg {actual_user_id}): {e_desc}" f"Sync: Failed to update description for panel user {panel_uuid} (tg {actual_user_id}): {e_desc}" # noqa: E501
) )
# Sync subscription data # Sync subscription data
@@ -378,7 +376,7 @@ async def perform_sync(
) or panel_user_dict.get("shortUuid") ) or panel_user_dict.get("shortUuid")
if subscription_uuid_from_panel: if subscription_uuid_from_panel:
# Если панель говорит, что подписка ACTIVE — сначала деактивируем все другие активные # Если панель говорит, что подписка ACTIVE — сначала деактивируем все другие активные # noqa: E501
if panel_status == "ACTIVE": if panel_status == "ACTIVE":
await session.execute( await session.execute(
update(Subscription) update(Subscription)
@@ -397,7 +395,7 @@ async def perform_sync(
) )
) )
# Try to find subscription by its panel_subscription_uuid first (idempotent) # Try to find subscription by its panel_subscription_uuid first (idempotent) # noqa: E501
existing_sub_by_uuid = ( existing_sub_by_uuid = (
await subscription_dal.get_subscription_by_panel_subscription_uuid( await subscription_dal.get_subscription_by_panel_subscription_uuid(
session, subscription_uuid_from_panel session, subscription_uuid_from_panel
@@ -420,12 +418,12 @@ async def perform_sync(
subscriptions_synced_count += 1 subscriptions_synced_count += 1
subscriptions_updated += 1 subscriptions_updated += 1
user_was_updated = True user_was_updated = True
logging.info( logging.debug(
f"Synced existing subscription {existing_sub_by_uuid.subscription_id} " f"Synced existing subscription {existing_sub_by_uuid.subscription_id} " # noqa: E501
f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}" f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}" # noqa: E501
) )
else: else:
# Create a new subscription only when we have a concrete subscription UUID # Create a new subscription only when we have a concrete subscription UUID # noqa: E501
sub_payload = { sub_payload = {
"user_id": actual_user_id, "user_id": actual_user_id,
"panel_user_uuid": panel_uuid, "panel_user_uuid": panel_uuid,
@@ -445,12 +443,12 @@ async def perform_sync(
subscriptions_synced_count += 1 subscriptions_synced_count += 1
subscriptions_created += 1 subscriptions_created += 1
user_was_updated = True user_was_updated = True
logging.info( logging.debug(
f"Created subscription {created_sub.subscription_id} " f"Created subscription {created_sub.subscription_id} "
f"for user {actual_user_id} by panel_sub_uuid {subscription_uuid_from_panel}" f"for user {actual_user_id} by panel_sub_uuid {subscription_uuid_from_panel}" # noqa: E501
) )
else: else:
# No subscription UUID from panel: only update an already active subscription for this user/panel UUID # No subscription UUID from panel: only update an already active subscription for this user/panel UUID # noqa: E501
active_sub = await subscription_dal.get_active_subscription_by_user_id( active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, actual_user_id, panel_uuid session, actual_user_id, panel_uuid
) )
@@ -467,14 +465,14 @@ async def perform_sync(
subscriptions_synced_count += 1 subscriptions_synced_count += 1
subscriptions_updated += 1 subscriptions_updated += 1
user_was_updated = True user_was_updated = True
logging.info( logging.debug(
f"Updated active subscription {active_sub.subscription_id} " f"Updated active subscription {active_sub.subscription_id} "
f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}" f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}" # noqa: E501
) )
else: else:
# Without a concrete subscription UUID we avoid creating new records to keep sync idempotent # Without a concrete subscription UUID we avoid creating new records to keep sync idempotent # noqa: E501
logging.debug( logging.debug(
f"No subscriptionUuid for panel user {panel_uuid}; skipped creation for user {actual_user_id}" f"No subscriptionUuid for panel user {panel_uuid}; skipped creation for user {actual_user_id}" # noqa: E501
) )
except Exception as e: except Exception as e:
@@ -488,7 +486,7 @@ async def perform_sync(
except Exception as e_user: except Exception as e_user:
sync_errors.append( sync_errors.append(
f"Error processing panel user {panel_user_dict.get('uuid', 'unknown')}: {str(e_user)}" f"Error processing panel user {panel_user_dict.get('uuid', 'unknown')}: {str(e_user)}" # noqa: E501
) )
logging.error(f"Error syncing user: {e_user}") logging.error(f"Error syncing user: {e_user}")
@@ -685,7 +683,7 @@ async def sync_status_command_handler(
f"<b>{_('admin_stats_last_sync_header')}</b>\n" f"<b>{_('admin_stats_last_sync_header')}</b>\n"
f" {_('admin_stats_sync_time')}: {last_time_str}\n" f" {_('admin_stats_sync_time')}: {last_time_str}\n"
f" {_('admin_stats_sync_status')}: {status_record_model.status}\n" f" {_('admin_stats_sync_status')}: {status_record_model.status}\n"
f" {_('admin_stats_sync_users_processed')}: {status_record_model.users_processed_from_panel}\n" f" {_('admin_stats_sync_users_processed')}: {status_record_model.users_processed_from_panel}\n" # noqa: E501
f" {_('admin_stats_sync_subs_synced')}: {status_record_model.subscriptions_synced}\n" f" {_('admin_stats_sync_subs_synced')}: {status_record_model.subscriptions_synced}\n"
f" {_('admin_stats_sync_details_label')}: {details_str}" f" {_('admin_stats_sync_details_label')}: {details_str}"
) )
+4 -5
View File
@@ -319,7 +319,7 @@ async def format_user_card(
# Panel info # Panel info
if user.panel_user_uuid: if user.panel_user_uuid:
card_parts.append( card_parts.append(
f"{_('admin_user_panel_uuid_label')} {hcode(user.panel_user_uuid[:8] + '...' if len(user.panel_user_uuid) > 8 else user.panel_user_uuid)}" f"{_('admin_user_panel_uuid_label')} {hcode(user.panel_user_uuid[:8] + '...' if len(user.panel_user_uuid) > 8 else user.panel_user_uuid)}" # noqa: E501
) )
card_parts.append("") # Empty line card_parts.append("") # Empty line
@@ -371,12 +371,12 @@ async def format_user_card(
premium_bonus_bytes = int(subscription_details.get("premium_bonus_bytes") or 0) premium_bonus_bytes = int(subscription_details.get("premium_bonus_bytes") or 0)
if premium_unlimited: if premium_unlimited:
card_parts.append( card_parts.append(
f"{_('admin_user_premium_override_label')} {hcode(_('admin_user_premium_override_unlimited'))}" f"{_('admin_user_premium_override_label')} {hcode(_('admin_user_premium_override_unlimited'))}" # noqa: E501
) )
elif premium_bonus_bytes > 0: elif premium_bonus_bytes > 0:
bonus_gb = premium_bonus_bytes / (1024**3) bonus_gb = premium_bonus_bytes / (1024**3)
card_parts.append( card_parts.append(
f"{_('admin_user_premium_override_label')} {hcode(_('admin_user_premium_override_bonus_value', gb=f'{bonus_gb:.2f}'))}" f"{_('admin_user_premium_override_label')} {hcode(_('admin_user_premium_override_bonus_value', gb=f'{bonus_gb:.2f}'))}" # noqa: E501
) )
else: else:
card_parts.append( card_parts.append(
@@ -929,7 +929,6 @@ async def handle_toggle_ban(
# Update on panel if user has panel UUID # Update on panel if user has panel UUID
if user.panel_user_uuid: if user.panel_user_uuid:
panel_status = "DISABLED" if new_ban_status else "ACTIVE"
await panel_service.update_user_status_on_panel( await panel_service.update_user_status_on_panel(
user.panel_user_uuid, not new_ban_status user.panel_user_uuid, not new_ban_status
) )
@@ -1431,7 +1430,7 @@ async def process_direct_message_handler(
await message.answer(_("admin_direct_empty_message")) await message.answer(_("admin_direct_empty_message"))
return return
caption_with_signature = (content.text + admin_signature) if content.text else None (content.text + admin_signature) if content.text else None
# Send to target user using our fancy match/case function # Send to target user using our fancy match/case function
try: try:
+18 -18
View File
@@ -71,7 +71,7 @@ async def process_successful_payment(
or (not payment_db_id_str and not auto_renew_subscription_id_str) or (not payment_db_id_str and not auto_renew_subscription_id_str)
): ):
logging.error( logging.error(
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}" f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}" # noqa: E501
) )
return return
@@ -103,7 +103,7 @@ async def process_successful_payment(
try: try:
if not yk_payment_id_from_hook: if not yk_payment_id_from_hook:
logging.error( logging.error(
"Auto-renew webhook missing YooKassa payment id; cannot ensure payment record." "Auto-renew webhook missing YooKassa payment id; cannot ensure payment record." # noqa: E501
) )
return return
from db.dal import payment_dal as _payment_dal from db.dal import payment_dal as _payment_dal
@@ -126,7 +126,7 @@ async def process_successful_payment(
payment_db_id = payment_record.payment_id payment_db_id = payment_record.payment_id
except Exception as e_ensure: except Exception as e_ensure:
logging.error( logging.error(
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}", f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}", # noqa: E501
exc_info=True, exc_info=True,
) )
return return
@@ -140,14 +140,14 @@ async def process_successful_payment(
if payment_record and payment_record.status == "succeeded": if payment_record and payment_record.status == "succeeded":
logging.info( logging.info(
f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})." f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})." # noqa: E501
) )
return return
db_user = await user_dal.get_user_by_id(session, user_id) db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user: if not db_user:
logging.error( logging.error(
f"User {user_id} not found in DB during successful payment processing for YK ID {payment_info_from_webhook.get('id')}. Payment record {payment_db_id}." f"User {user_id} not found in DB during successful payment processing for YK ID {payment_info_from_webhook.get('id')}. Payment record {payment_db_id}." # noqa: E501
) )
await payment_dal.update_payment_status_by_db_id( await payment_dal.update_payment_status_by_db_id(
@@ -260,7 +260,7 @@ async def process_successful_payment(
if not activation_details or not activation_details.get("end_date"): if not activation_details or not activation_details.get("end_date"):
logging.error( logging.error(
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}" f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}" # noqa: E501
) )
raise Exception(f"Subscription Error: Failed to activate for user {user_id}") raise Exception(f"Subscription Error: Failed to activate for user {user_id}")
@@ -272,7 +272,7 @@ async def process_successful_payment(
) )
if not updated_payment_record: if not updated_payment_record:
logging.error( logging.error(
f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}" f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}" # noqa: E501
) )
raise Exception(f"DB Error: Could not update payment record {payment_db_id}") raise Exception(f"DB Error: Could not update payment record {payment_db_id}")
@@ -403,7 +403,7 @@ async def process_successful_payment(
) )
else: else:
logging.error( logging.error(
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic." f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic." # noqa: E501
) )
details_message = _("payment_successful_error_details") details_message = _("payment_successful_error_details")
@@ -455,7 +455,7 @@ async def process_successful_payment(
except Exception as e_process: except Exception as e_process:
logging.error( logging.error(
f"Error during process_successful_payment main try block for user {user_id}: {e_process}", f"Error during process_successful_payment main try block for user {user_id}: {e_process}", # noqa: E501
exc_info=True, exc_info=True,
) )
@@ -496,11 +496,11 @@ async def process_cancelled_payment(
if updated_payment: if updated_payment:
logging.info( logging.info(
f"Payment {payment_db_id} (YK: {payment_info_from_webhook.get('id')}) status updated to cancelled for user {user_id}." f"Payment {payment_db_id} (YK: {payment_info_from_webhook.get('id')}) status updated to cancelled for user {user_id}." # noqa: E501
) )
else: else:
logging.warning( logging.warning(
f"Could not find payment record {payment_db_id} to update status to cancelled for user {user_id}." f"Could not find payment record {payment_db_id} to update status to cancelled for user {user_id}." # noqa: E501
) )
db_user = await user_dal.get_user_by_id(session, user_id) db_user = await user_dal.get_user_by_id(session, user_id)
@@ -513,7 +513,7 @@ async def process_cancelled_payment(
except Exception as e_process_cancel: except Exception as e_process_cancel:
logging.error( logging.error(
f"Error processing cancelled payment for user {user_id}, payment_db_id {payment_db_id}: {e_process_cancel}", f"Error processing cancelled payment for user {user_id}, payment_db_id {payment_db_id}: {e_process_cancel}", # noqa: E501
exc_info=True, exc_info=True,
) )
raise raise
@@ -547,7 +547,7 @@ async def yookassa_webhook_route(request: web.Request):
logging.info( logging.info(
f"YooKassa Webhook Parsed: Event='{notification_object.event}', " f"YooKassa Webhook Parsed: Event='{notification_object.event}', "
f"PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'" f"PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'" # noqa: E501
) )
if ( if (
@@ -556,7 +556,7 @@ async def yookassa_webhook_route(request: web.Request):
or payment_data_from_notification.metadata is None or payment_data_from_notification.metadata is None
): ):
logging.error( logging.error(
f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata. Cannot process." f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata. Cannot process." # noqa: E501
) )
return web.Response(status=200, text="ok_error_no_metadata") return web.Response(status=200, text="ok_error_no_metadata")
@@ -633,8 +633,8 @@ async def yookassa_webhook_route(request: web.Request):
await session.commit() await session.commit()
else: else:
logging.warning( logging.warning(
f"Payment Succeeded event for {payment_dict_for_processing.get('id')} " f"Payment Succeeded event for {payment_dict_for_processing.get('id')} " # noqa: E501
f"but data not as expected: status='{payment_dict_for_processing.get('status')}', " f"but data not as expected: status='{payment_dict_for_processing.get('status')}', " # noqa: E501
f"paid='{payment_dict_for_processing.get('paid')}'" f"paid='{payment_dict_for_processing.get('paid')}'"
) )
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED: elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
@@ -682,7 +682,7 @@ async def yookassa_webhook_route(request: web.Request):
"yoo-money", "yoo-money",
"wallet", "wallet",
}: }:
# Normalize wallet display name to avoid leaking full account from title # Normalize wallet display name to avoid leaking full account from title # noqa: E501
display_network = "YooMoney" display_network = "YooMoney"
if ( if (
isinstance(account_number, str) isinstance(account_number, str)
@@ -767,7 +767,7 @@ async def yookassa_webhook_route(request: web.Request):
except Exception: except Exception:
await session.rollback() await session.rollback()
logging.exception( logging.exception(
"Error processing YooKassa webhook event '%s' for YK Payment ID %s in DB transaction.", "Error processing YooKassa webhook event '%s' for YK Payment ID %s in DB transaction.", # noqa: E501
notification_object.event, notification_object.event,
payment_dict_for_processing.get("id"), payment_dict_for_processing.get("id"),
) )
+4 -4
View File
@@ -95,7 +95,7 @@ async def process_promo_code_input(
session: AsyncSession, session: AsyncSession,
): ):
logging.info( logging.info(
f"Processing promo code input from user {message.from_user.id} in state {await state.get_state()}: '{message.text}'" f"Processing promo code input from user {message.from_user.id} in state {await state.get_state()}: '{message.text}'" # noqa: E501
) )
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
@@ -122,7 +122,7 @@ async def process_promo_code_input(
): ):
is_suspicious = True is_suspicious = True
logging.warning( logging.warning(
f"Suspicious input for promo code by user {user.id} (len: {len(code_input)}): '{code_input}'" f"Suspicious input for promo code by user {user.id} (len: {len(code_input)}): '{code_input}'" # noqa: E501
) )
response_to_user_text = "" response_to_user_text = ""
@@ -182,7 +182,7 @@ async def process_promo_code_input(
) )
await state.clear() await state.clear()
logging.info( logging.info(
f"Promo code input '{code_input}' processing finished for user {message.from_user.id}. State cleared." f"Promo code input '{code_input}' processing finished for user {message.from_user.id}. State cleared." # noqa: E501
) )
@@ -203,7 +203,7 @@ async def cancel_promo_input_via_button(
return return
logging.info( logging.info(
f"User {callback.from_user.id} cancelled promo code input via button from state {await state.get_state()}. Clearing state." f"User {callback.from_user.id} cancelled promo code input via button from state {await state.get_state()}. Clearing state." # noqa: E501
) )
await state.clear() await state.clear()
+1 -1
View File
@@ -29,7 +29,7 @@ async def referral_command_handler(
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
if not target_message_obj: if not target_message_obj:
logging.error( logging.error(
"Target message is None in referral_command_handler (possibly from callback without message)." "Target message is None in referral_command_handler (possibly from callback without message)." # noqa: E501
) )
if isinstance(event, types.CallbackQuery): if isinstance(event, types.CallbackQuery):
await event.answer("Error displaying referral info.", show_alert=True) await event.answer("Error displaying referral info.", show_alert=True)
+5 -6
View File
@@ -115,7 +115,7 @@ async def send_main_menu(
await safe_answer_callback(target_event) await safe_answer_callback(target_event)
except Exception as e_send_edit: except Exception as e_send_edit:
logging.warning( logging.warning(
f"Failed to send/edit main menu (user: {user_id}, is_edit: {is_edit}): {type(e_send_edit).__name__} - {e_send_edit}." f"Failed to send/edit main menu (user: {user_id}, is_edit: {is_edit}): {type(e_send_edit).__name__} - {e_send_edit}." # noqa: E501
) )
if is_edit and target_message_obj: if is_edit and target_message_obj:
try: try:
@@ -530,7 +530,7 @@ async def start_command_handler(
return return
logging.info( logging.info(
f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}." f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}." # noqa: E501
) )
# Auto-grant referral welcome bonus to newly registered referred users. # Auto-grant referral welcome bonus to newly registered referred users.
@@ -550,7 +550,7 @@ async def start_command_handler(
if referral_bonus_end_date: if referral_bonus_end_date:
await session.commit() await session.commit()
logging.info( logging.info(
"Referral welcome bonus applied: user %s got %s days, new end date %s.", "Referral welcome bonus applied: user %s got %s days, new end date %s.", # noqa: E501
user_id, user_id,
referral_welcome_days, referral_welcome_days,
referral_bonus_end_date.isoformat(), referral_bonus_end_date.isoformat(),
@@ -566,7 +566,7 @@ async def start_command_handler(
else: else:
await session.rollback() await session.rollback()
logging.warning( logging.warning(
"Referral welcome bonus was not applied for user %s (referred by %s).", "Referral welcome bonus was not applied for user %s (referred by %s).", # noqa: E501
user_id, user_id,
referred_by_user_id, referred_by_user_id,
) )
@@ -708,7 +708,7 @@ async def start_command_handler(
else: else:
await session.commit() await session.commit()
logging.warning( logging.warning(
f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}" f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}" # noqa: E501
) )
await message.answer(str(result), parse_mode="HTML") await message.answer(str(result), parse_mode="HTML")
# Continue to show main menu if promo failed # Continue to show main menu if promo failed
@@ -916,7 +916,6 @@ async def main_action_callback_handler(
session: AsyncSession, session: AsyncSession,
): ):
action = callback.data.split(":")[1] action = callback.data.split(":")[1]
user_id = callback.from_user.id
if action in {"back_to_main", "back_to_main_keep", "bot_interface"}: if action in {"back_to_main", "back_to_main_keep", "bot_interface"}:
await state.clear() await state.clear()
+6 -6
View File
@@ -329,7 +329,7 @@ async def tariff_topup_list_callback(
carryover_lines = [] carryover_lines = []
if rub_packages or premium_packages: if rub_packages or premium_packages:
carryover_lines.append( carryover_lines.append(
"Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток." "Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток." # noqa: E501
) )
if int(active.get("premium_limit_bytes") or 0) > 0: if int(active.get("premium_limit_bytes") or 0) > 0:
premium_left = max( premium_left = max(
@@ -345,7 +345,7 @@ async def tariff_topup_list_callback(
if len(labels) > len(visible): if len(labels) > len(visible):
premium_lines.append(f"• ... еще {len(labels) - len(visible)}") premium_lines.append(f"• ... еще {len(labels) - len(visible)}")
premium_lines.append( premium_lines.append(
f"Premium использовано: {active.get('premium_used')} из {active.get('premium_limit')}. Осталось: {premium_left / 2**30:.2f} GB." f"Premium использовано: {active.get('premium_used')} из {active.get('premium_limit')}. Осталось: {premium_left / 2**30:.2f} GB." # noqa: E501
) )
text = get_text("choose_payment_method_traffic") text = get_text("choose_payment_method_traffic")
if carryover_lines: if carryover_lines:
@@ -647,7 +647,7 @@ async def tariff_change_confirm_apply_callback(
], ],
] ]
await callback.message.edit_text( await callback.message.edit_text(
f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nИзменение: {action_text}", f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nИзменение: {action_text}", # noqa: E501
reply_markup=InlineKeyboardMarkup(inline_keyboard=rows), reply_markup=InlineKeyboardMarkup(inline_keyboard=rows),
) )
await callback.answer() await callback.answer()
@@ -680,7 +680,7 @@ async def tariff_change_confirm_pay_callback(
], ],
] ]
await callback.message.edit_text( await callback.message.edit_text(
f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} RUB.", f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} RUB.", # noqa: E501
reply_markup=InlineKeyboardMarkup(inline_keyboard=rows), reply_markup=InlineKeyboardMarkup(inline_keyboard=rows),
) )
await callback.answer() await callback.answer()
@@ -905,7 +905,7 @@ async def my_subscription_command_handler(
f"Докупленный остаток: <b>{premium_balance / 2**30:.2f} GB</b>\n" f"Докупленный остаток: <b>{premium_balance / 2**30:.2f} GB</b>\n"
"Отдельный лимит действует на:\n" "Отдельный лимит действует на:\n"
f"{label_block}\n\n" f"{label_block}\n\n"
"Premium-докупка не сгорает: сначала расходуется месячный лимит premium-серверов, затем докупленный premium-трафик." "Premium-докупка не сгорает: сначала расходуется месячный лимит premium-серверов, затем докупленный premium-трафик." # noqa: E501
) )
base_markup = get_back_to_main_menu_markup( base_markup = get_back_to_main_menu_markup(
@@ -1028,7 +1028,7 @@ async def my_subscription_command_handler(
[ [
InlineKeyboardButton( InlineKeyboardButton(
text=toggle_text, text=toggle_text,
callback_data=f"toggle_autorenew:{local_sub.subscription_id}:{1 if not local_sub.auto_renew_enabled else 0}", callback_data=f"toggle_autorenew:{local_sub.subscription_id}:{1 if not local_sub.auto_renew_enabled else 0}", # noqa: E501
) )
] ]
) )
@@ -310,7 +310,7 @@ async def payment_method_view(
last_tx = lp.created_at.strftime("%Y-%m-%d") last_tx = lp.created_at.strftime("%Y-%m-%d")
except Exception: except Exception:
pass pass
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" # noqa: E501
await callback.message.edit_text( await callback.message.edit_text(
details, details,
reply_markup=get_payment_method_details_keyboard( reply_markup=get_payment_method_details_keyboard(
@@ -366,7 +366,7 @@ async def payment_method_view(
return _("payment_method_generic_title", network=network_name) return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(billing.card_network, billing.card_last4) title = _format_pm_title(billing.card_network, billing.card_last4)
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" # noqa: E501
await callback.message.edit_text( await callback.message.edit_text(
details, details,
reply_markup=get_payment_method_details_keyboard( reply_markup=get_payment_method_details_keyboard(
@@ -145,7 +145,7 @@ async def pay_fk_callback_handler(
except Exception as e_status: except Exception as e_status:
await session.rollback() await session.rollback()
logging.error( logging.error(
f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}", f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}", # noqa: E501
exc_info=True, exc_info=True,
) )
@@ -209,7 +209,7 @@ async def pay_fk_callback_handler(
return return
logging.error( logging.error(
"FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s", "FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s", # noqa: E501
payment_record.payment_id, payment_record.payment_id,
response_data, response_data,
) )
@@ -230,7 +230,7 @@ async def pay_fk_callback_handler(
except Exception as e_status: except Exception as e_status:
await session.rollback() await session.rollback()
logging.error( logging.error(
f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501
exc_info=True, exc_info=True,
) )
@@ -179,7 +179,7 @@ async def pay_platega_callback_handler(
except Exception as e_status: except Exception as e_status:
await session.rollback() await session.rollback()
logging.error( logging.error(
f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}", f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}", # noqa: E501
exc_info=True, exc_info=True,
) )
@@ -232,7 +232,7 @@ async def pay_platega_callback_handler(
return return
logging.error( logging.error(
"Platega: transaction created but missing transaction id or payment link for payment %s. Response: %s", "Platega: transaction created but missing transaction id or payment link for payment %s. Response: %s", # noqa: E501
payment_record.payment_id, payment_record.payment_id,
response_data, response_data,
) )
@@ -247,7 +247,7 @@ async def pay_platega_callback_handler(
except Exception as e_status: except Exception as e_status:
await session.rollback() await session.rollback()
logging.error( logging.error(
f"Platega: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", f"Platega: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501
exc_info=True, exc_info=True,
) )
@@ -138,7 +138,7 @@ async def pay_severpay_callback_handler(
except Exception as e_status: except Exception as e_status:
await session.rollback() await session.rollback()
logging.error( logging.error(
f"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}", f"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}", # noqa: E501
exc_info=True, exc_info=True,
) )
@@ -207,7 +207,7 @@ async def pay_severpay_callback_handler(
except Exception as e_status: except Exception as e_status:
await session.rollback() await session.rollback()
logging.error( logging.error(
f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501
exc_info=True, exc_info=True,
) )
@@ -64,7 +64,7 @@ async def select_subscription_period_callback_handler(
) )
if currency_methods_enabled: if currency_methods_enabled:
logging.error( logging.error(
"Currency price missing for traffic option %s while fiat providers are enabled.", "Currency price missing for traffic option %s while fiat providers are enabled.", # noqa: E501
months, months,
) )
try: try:
@@ -76,7 +76,7 @@ async def select_subscription_period_callback_handler(
currency_symbol_val = "" currency_symbol_val = ""
else: else:
logging.error( logging.error(
f"Price not found for option {months} using {'traffic_packages' if traffic_mode else 'subscription_options'}." f"Price not found for option {months} using {'traffic_packages' if traffic_mode else 'subscription_options'}." # noqa: E501
) )
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
@@ -115,7 +115,7 @@ async def _initiate_yk_payment(
db_payment_record = await payment_dal.create_payment_record(session, payment_record_data) db_payment_record = await payment_dal.create_payment_record(session, payment_record_data)
await session.commit() await session.commit()
logging.info( logging.info(
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'." f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'." # noqa: E501
) )
except Exception as e_db_payment: except Exception as e_db_payment:
await session.rollback() await session.rollback()
@@ -227,7 +227,7 @@ async def _initiate_yk_payment(
except Exception as e_db_update_ykid: except Exception as e_db_update_ykid:
await session.rollback() await session.rollback()
logging.error( logging.error(
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}", f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}", # noqa: E501
exc_info=True, exc_info=True,
) )
try: try:
@@ -300,7 +300,7 @@ async def _initiate_yk_payment(
except Exception as e_db_update_saved: except Exception as e_db_update_saved:
await session.rollback() await session.rollback()
logging.error( logging.error(
f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}", f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}", # noqa: E501
exc_info=True, exc_info=True,
) )
try: try:
@@ -334,11 +334,11 @@ async def _initiate_yk_payment(
except Exception as e_db_fail_create: except Exception as e_db_fail_create:
await session.rollback() await session.rollback()
logging.error( logging.error(
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}", f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}", # noqa: E501
exc_info=True, exc_info=True,
) )
logging.error( logging.error(
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}" f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}" # noqa: E501
) )
try: try:
await callback.message.edit_text(get_text("error_payment_gateway")) await callback.message.edit_text(get_text("error_payment_gateway"))
+1 -2
View File
@@ -40,10 +40,9 @@ async def request_trial_confirmation_handler(
pass pass
return return
show_trial_btn_in_menu_if_fail = False
if settings.TRIAL_ENABLED: if settings.TRIAL_ENABLED:
if not await subscription_service.has_had_any_subscription(session, user_id): if not await subscription_service.has_had_any_subscription(session, user_id):
show_trial_btn_in_menu_if_fail = True pass
if not settings.TRIAL_ENABLED: if not settings.TRIAL_ENABLED:
await callback.message.edit_text( await callback.message.edit_text(
+1 -1
View File
@@ -427,7 +427,7 @@ def get_yk_autopay_choice_keyboard(
has_saved_cards: bool = True, has_saved_cards: bool = True,
sale_mode: str = "subscription", sale_mode: str = "subscription",
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
"""Keyboard for choosing between saved card charge or new card payment when auto-renew is enabled.""" """Keyboard for choosing between saved card charge or new card payment when auto-renew is enabled.""" # noqa: E501
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
price_str = str(price) price_str = str(price)
+6 -6
View File
@@ -54,7 +54,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
try: try:
current_webhook_info = await bot.get_webhook_info() current_webhook_info = await bot.get_webhook_info()
logging.info( logging.info(
f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}" f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}" # noqa: E501
) )
set_success = await bot.set_webhook( set_success = await bot.set_webhook(
@@ -76,11 +76,11 @@ async def on_startup_configured(dispatcher: Dispatcher):
new_webhook_info = await bot.get_webhook_info() new_webhook_info = await bot.get_webhook_info()
logging.info( logging.info(
f"STARTUP: Telegram Webhook info AFTER setting: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}" f"STARTUP: Telegram Webhook info AFTER setting: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}" # noqa: E501
) )
if not new_webhook_info.url: if not new_webhook_info.url:
logging.error( logging.error(
"STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity." "STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity." # noqa: E501
) )
except Exception: except Exception:
@@ -144,11 +144,11 @@ async def on_startup_configured(dispatcher: Dispatcher):
if sync_result.get("status") == "completed": if sync_result.get("status") == "completed":
logging.info( logging.info(
f"STARTUP: Automatic sync completed successfully. Details: {sync_result.get('details', 'N/A')}" f"STARTUP: Automatic sync completed successfully. Details: {sync_result.get('details', 'N/A')}" # noqa: E501
) )
else: else:
logging.warning( logging.warning(
f"STARTUP: Automatic sync completed with issues. Status: {sync_result.get('status', 'unknown')}" f"STARTUP: Automatic sync completed with issues. Status: {sync_result.get('status', 'unknown')}" # noqa: E501
) )
except Exception: except Exception:
@@ -234,7 +234,7 @@ async def run_bot(settings_param: Settings):
logging.warning("Bot username is empty; Telegram Login Widget will be unavailable.") logging.warning("Bot username is empty; Telegram Login Widget will be unavailable.")
except Exception as e: except Exception as e:
logging.error( logging.error(
f"Failed to get bot info (e.g., for YooKassa default URL): {e}. Using fallback: {actual_bot_username}" f"Failed to get bot info (e.g., for YooKassa default URL): {e}. Using fallback: {actual_bot_username}" # noqa: E501
) )
services = build_core_services( services = build_core_services(
+2 -2
View File
@@ -76,7 +76,7 @@ class ActionLoggerMiddleware(BaseMiddleware):
user_exists = await user_dal.get_user_by_id(session, user_id) user_exists = await user_dal.get_user_by_id(session, user_id)
if not user_exists: if not user_exists:
logging.warning( logging.warning(
f"ActionLoggerMiddleware: User {user_id} not found in DB. Logging action with user_id=NULL." f"ActionLoggerMiddleware: User {user_id} not found in DB. Logging action with user_id=NULL." # noqa: E501
) )
log_user_id_for_db = None log_user_id_for_db = None
@@ -95,7 +95,7 @@ class ActionLoggerMiddleware(BaseMiddleware):
await message_log_dal.create_message_log_no_commit(session, log_payload) await message_log_dal.create_message_log_no_commit(session, log_payload)
except Exception as e_log: except Exception as e_log:
logging.error( logging.error(
f"ActionLoggerMiddleware: Failed to add log to session for user {user_id}, type {current_event_type}: {e_log}", f"ActionLoggerMiddleware: Failed to add log to session for user {user_id}, type {current_event_type}: {e_log}", # noqa: E501
exc_info=True, exc_info=True,
) )
+2 -2
View File
@@ -49,7 +49,7 @@ class BanCheckMiddleware(BaseMiddleware):
if db_user_model and db_user_model.is_banned: if db_user_model and db_user_model.is_banned:
logging.info( logging.info(
f"User {event_user.id} ({event_user.username or 'NoUsername'}) is banned. Blocking access." f"User {event_user.id} ({event_user.username or 'NoUsername'}) is banned. Blocking access." # noqa: E501
) )
i18n_data_from_event = data.get("i18n_data", {}) i18n_data_from_event = data.get("i18n_data", {})
@@ -113,7 +113,7 @@ class BanCheckMiddleware(BaseMiddleware):
logging.warning(f"BanCheck: Bot is blocked by user {event_user.id}.") logging.warning(f"BanCheck: Bot is blocked by user {event_user.id}.")
except Exception as e_send: except Exception as e_send:
logging.error( logging.error(
f"BanCheck: Failed to notify banned user {event_user.id}: {type(e_send).__name__} - {e_send}", f"BanCheck: Failed to notify banned user {event_user.id}: {type(e_send).__name__} - {e_send}", # noqa: E501
exc_info=True, exc_info=True,
) )
+2 -2
View File
@@ -19,7 +19,7 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
""" """
Blocks access to handlers for users who have not yet passed the required channel subscription check. Blocks access to handlers for users who have not yet passed the required channel subscription check.
The /start command is allowed through so that the handler can re-run the verification. The /start command is allowed through so that the handler can re-run the verification.
""" """ # noqa: E501
def __init__(self, settings: Settings, i18n_instance: JsonI18n): def __init__(self, settings: Settings, i18n_instance: JsonI18n):
super().__init__() super().__init__()
@@ -126,7 +126,7 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
await callback.message.answer(prompt_text, reply_markup=keyboard) await callback.message.answer(prompt_text, reply_markup=keyboard)
except Exception as send_error: except Exception as send_error:
logging.error( logging.error(
"ChannelSubscriptionMiddleware: failed to send prompt for callback in chat %s: %s", "ChannelSubscriptionMiddleware: failed to send prompt for callback in chat %s: %s", # noqa: E501
callback.message.chat.id, callback.message.chat.id,
send_error, send_error,
exc_info=True, exc_info=True,
+7 -7
View File
@@ -19,7 +19,7 @@ class JsonI18n:
self.locales_data: Dict[str, Dict[str, str]] = {} self.locales_data: Dict[str, Dict[str, str]] = {}
self._load_locales() self._load_locales()
logging.info( logging.info(
f"JsonI18n initialized. Loaded languages: {list(self.locales_data.keys())}. Default: {self.default_lang}" f"JsonI18n initialized. Loaded languages: {list(self.locales_data.keys())}. Default: {self.default_lang}" # noqa: E501
) )
def _load_locales(self): def _load_locales(self):
@@ -35,7 +35,7 @@ class JsonI18n:
self.locales_data[lang_code] = json.load(f) self.locales_data[lang_code] = json.load(f)
except json.JSONDecodeError as e_json_load: except json.JSONDecodeError as e_json_load:
logging.error( logging.error(
f"Error loading locale {lang_code} from {file_path} (JSON Decode Error): {e_json_load}" f"Error loading locale {lang_code} from {file_path} (JSON Decode Error): {e_json_load}" # noqa: E501
) )
except Exception as e_load: except Exception as e_load:
logging.error( logging.error(
@@ -66,7 +66,7 @@ class JsonI18n:
except Exception: except Exception:
return text return text
logging.warning( logging.warning(
f"No language data for '{effective_lang_code}' (default '{self.default_lang}' also missing). Key '{key}' will be returned as is." f"No language data for '{effective_lang_code}' (default '{self.default_lang}' also missing). Key '{key}' will be returned as is." # noqa: E501
) )
return key.format(**kwargs) if kwargs else key return key.format(**kwargs) if kwargs else key
@@ -78,19 +78,19 @@ class JsonI18n:
if text is None: if text is None:
logging.warning( logging.warning(
f"Translation key '{key}' not found for lang '{effective_lang_code}' or default '{self.default_lang}'. Returning key." f"Translation key '{key}' not found for lang '{effective_lang_code}' or default '{self.default_lang}'. Returning key." # noqa: E501
) )
return key.format(**kwargs) if kwargs else key return key.format(**kwargs) if kwargs else key
try: try:
return text.format(**kwargs) if kwargs else text return text.format(**kwargs) if kwargs else text
except KeyError as e_format: except KeyError as e_format:
logging.warning( logging.warning(
f"Missing format key '{e_format}' for i18n key '{key}' (lang: {effective_lang_code}). Original text: '{text}'" f"Missing format key '{e_format}' for i18n key '{key}' (lang: {effective_lang_code}). Original text: '{text}'" # noqa: E501
) )
return text return text
except Exception as e_general_format: except Exception as e_general_format:
logging.error( logging.error(
f"General error formatting i18n key '{key}' (lang: {effective_lang_code}): {e_general_format}. Original text: '{text}'", f"General error formatting i18n key '{key}' (lang: {effective_lang_code}): {e_general_format}. Original text: '{text}'", # noqa: E501
exc_info=True, exc_info=True,
) )
return text return text
@@ -147,7 +147,7 @@ class I18nMiddleware(BaseMiddleware):
current_language = event_user.language_code.lower() current_language = event_user.language_code.lower()
except Exception as e_db_lang: except Exception as e_db_lang:
logging.error( logging.error(
f"I18nMiddleware: Error fetching user lang from DB for {event_user.id}: {e_db_lang}. Falling back.", f"I18nMiddleware: Error fetching user lang from DB for {event_user.id}: {e_db_lang}. Falling back.", # noqa: E501
exc_info=True, exc_info=True,
) )
if event_user.language_code: if event_user.language_code:
+3 -3
View File
@@ -43,7 +43,7 @@ class ProfileSyncMiddleware(BaseMiddleware):
if update_payload: if update_payload:
await user_dal.update_user(session, db_user.user_id, update_payload) await user_dal.update_user(session, db_user.user_id, update_payload)
logging.info( logging.info(
f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}" f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}" # noqa: E501
) )
# Also update description on panel if linked # Also update description on panel if linked
@@ -72,11 +72,11 @@ class ProfileSyncMiddleware(BaseMiddleware):
) )
except Exception as e_upd_desc: except Exception as e_upd_desc:
logging.warning( logging.warning(
f"ProfileSyncMiddleware: Failed to update panel description for user {tg_user.id}: {e_upd_desc}" f"ProfileSyncMiddleware: Failed to update panel description for user {tg_user.id}: {e_upd_desc}" # noqa: E501
) )
except Exception as e: except Exception as e:
logging.error( logging.error(
f"ProfileSyncMiddleware: Failed to sync profile for user {getattr(tg_user, 'id', 'N/A')}: {e}", f"ProfileSyncMiddleware: Failed to sync profile for user {getattr(tg_user, 'id', 'N/A')}: {e}", # noqa: E501
exc_info=True, exc_info=True,
) )
+1 -1
View File
@@ -562,7 +562,7 @@ class EmailAuthService:
log_level = logging.WARNING if attempt_number < len(attempts) else logging.ERROR log_level = logging.WARNING if attempt_number < len(attempts) else logging.ERROR
logger.log( logger.log(
log_level, log_level,
"SMTP send attempt %s/%s failed for custom email via %s:%s (ssl=%s, starttls=%s): %s", "SMTP send attempt %s/%s failed for custom email via %s:%s (ssl=%s, starttls=%s): %s", # noqa: E501
attempt_number, attempt_number,
len(attempts), len(attempts),
smtp_host, smtp_host,
+21 -21
View File
@@ -140,7 +140,7 @@ def _layout(
</table> </table>
</body> </body>
</html> </html>
""" """ # noqa: E501
def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str: def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str:
@@ -152,13 +152,13 @@ def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str:
border = "" if index == last else f"border-bottom:1px solid {_BORDER};" border = "" if index == last else f"border-bottom:1px solid {_BORDER};"
cells.append( cells.append(
f"<tr>" f"<tr>"
f'<td style="padding:11px 0;{border}font-size:12px;color:{_TEXT_DIM};text-transform:uppercase;letter-spacing:0.04em;">{html.escape(label)}</td>' f'<td style="padding:11px 0;{border}font-size:12px;color:{_TEXT_DIM};text-transform:uppercase;letter-spacing:0.04em;">{html.escape(label)}</td>' # noqa: E501
f"<td align=\"right\" style=\"padding:11px 0;{border}font-family:'JetBrains Mono','SFMono-Regular',Menlo,Consolas,monospace;font-size:14px;font-weight:600;color:{_TEXT};\">{html.escape(value)}</td>" f"<td align=\"right\" style=\"padding:11px 0;{border}font-family:'JetBrains Mono','SFMono-Regular',Menlo,Consolas,monospace;font-size:14px;font-weight:600;color:{_TEXT};\">{html.escape(value)}</td>" # noqa: E501
f"</tr>" f"</tr>"
) )
return ( return (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" ' f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
f'style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:6px 16px;">' f'style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:6px 16px;">' # noqa: E501
+ "".join(cells) + "".join(cells)
+ "</table>" + "</table>"
) )
@@ -167,14 +167,14 @@ def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str:
def _cta_button_html(*, label: str, url: str, accent: str) -> str: def _cta_button_html(*, label: str, url: str, accent: str) -> str:
safe_label = html.escape(label) safe_label = html.escape(label)
safe_url = html.escape(url, quote=True) safe_url = html.escape(url, quote=True)
# Accent green is light, so contrast text is dark; works for the default and similar light accents. # Accent green is light, so contrast text is dark; works for the default and similar light accents. # noqa: E501
return ( return (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" ' f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
f'style="width:100%;margin:22px 0 18px 0;">' f'style="width:100%;margin:22px 0 18px 0;">'
f'<tr><td align="center" bgcolor="{accent}" style="background:{accent};border-radius:12px;">' f'<tr><td align="center" bgcolor="{accent}" style="background:{accent};border-radius:12px;">' # noqa: E501
f'<a href="{safe_url}" target="_blank" rel="noopener" ' f'<a href="{safe_url}" target="_blank" rel="noopener" '
f'style="display:block;width:100%;box-sizing:border-box;padding:15px 22px;' f'style="display:block;width:100%;box-sizing:border-box;padding:15px 22px;'
f"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;" f"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;" # noqa: E501
f'font-size:15px;font-weight:700;color:#05070a;text-decoration:none;letter-spacing:0.02em;text-align:center;">{safe_label}</a>' f'font-size:15px;font-weight:700;color:#05070a;text-decoration:none;letter-spacing:0.02em;text-align:center;">{safe_label}</a>'
f"</td></tr></table>" f"</td></tr></table>"
) )
@@ -228,9 +228,9 @@ def render_login_code(
text_lines.append(_t_text(i18n, lang, "email_login_code_text_magic", url=safe_magic_link)) text_lines.append(_t_text(i18n, lang, "email_login_code_text_magic", url=safe_magic_link))
code_block = ( code_block = (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 18px 0;">' f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 18px 0;">' # noqa: E501
f'<tr><td align="center" style="background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:22px 16px;">' f'<tr><td align="center" style="background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:22px 16px;">' # noqa: E501
f"<div style=\"font-family:'JetBrains Mono','SFMono-Regular',Menlo,Consolas,monospace;font-size:36px;line-height:1;font-weight:700;letter-spacing:10px;color:{accent};\">" f"<div style=\"font-family:'JetBrains Mono','SFMono-Regular',Menlo,Consolas,monospace;font-size:36px;line-height:1;font-weight:700;letter-spacing:10px;color:{accent};\">" # noqa: E501
f"{html.escape(code)}" f"{html.escape(code)}"
f"</div></td></tr></table>" f"</div></td></tr></table>"
) )
@@ -242,24 +242,24 @@ def render_login_code(
magic_intro = _t_text(i18n, lang, "email_login_code_magic_intro") magic_intro = _t_text(i18n, lang, "email_login_code_magic_intro")
magic_hint = _t_text(i18n, lang, "email_login_code_magic_hint") magic_hint = _t_text(i18n, lang, "email_login_code_magic_hint")
divider_html = ( divider_html = (
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:4px 0 14px 0;">' f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:4px 0 14px 0;">' # noqa: E501
f"<tr>" f"<tr>"
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;">&nbsp;</td>' f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;">&nbsp;</td>' # noqa: E501
f'<td align="center" style="padding:0 10px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:{_TEXT_DIM};white-space:nowrap;">{html.escape(divider_label)}</td>' f'<td align="center" style="padding:0 10px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:{_TEXT_DIM};white-space:nowrap;">{html.escape(divider_label)}</td>' # noqa: E501
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;">&nbsp;</td>' f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;">&nbsp;</td>' # noqa: E501
f"</tr></table>" f"</tr></table>"
) )
magic_block = ( magic_block = (
divider_html divider_html
+ f'<p style="margin:0 0 4px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};text-align:center;">{html.escape(magic_intro)}</p>' + f'<p style="margin:0 0 4px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};text-align:center;">{html.escape(magic_intro)}</p>' # noqa: E501
+ _cta_button_html(label=cta_label, url=safe_magic_link, accent=accent) + _cta_button_html(label=cta_label, url=safe_magic_link, accent=accent)
+ f'<p style="margin:0 0 6px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};text-align:center;">{html.escape(magic_hint)}</p>' + f'<p style="margin:0 0 6px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};text-align:center;">{html.escape(magic_hint)}</p>' # noqa: E501
) )
body_html = ( body_html = (
code_block code_block
+ f'<p style="margin:0 0 8px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};">{expiry_html}</p>' + f'<p style="margin:0 0 8px 0;font-size:13px;line-height:1.55;color:{_TEXT_MUTED};">{expiry_html}</p>' # noqa: E501
+ f'<p style="margin:0 0 4px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(security)}</p>' + f'<p style="margin:0 0 4px 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(security)}</p>' # noqa: E501
+ magic_block + magic_block
) )
@@ -312,7 +312,7 @@ def render_account_merged(
] ]
body_html = ( body_html = (
_info_rows_html(rows) _info_rows_html(rows)
+ f'<p style="margin:0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(note)}</p>' + f'<p style="margin:0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(note)}</p>' # noqa: E501
) )
rendered = _layout( rendered = _layout(
@@ -413,7 +413,7 @@ def render_payment_success(
if safe_dashboard_url: if safe_dashboard_url:
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent)) body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
body_parts.append( body_parts.append(
f'<p style="margin:6px 0 0 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(footer_note)}</p>' f'<p style="margin:6px 0 0 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(footer_note)}</p>' # noqa: E501
) )
rendered = _layout( rendered = _layout(
@@ -476,7 +476,7 @@ def render_subscription_expiring(
if safe_dashboard_url: if safe_dashboard_url:
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent)) body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
body_parts.append( body_parts.append(
f'<p style="margin:6px 0 0 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(note)}</p>' f'<p style="margin:6px 0 0 0;font-size:12px;line-height:1.55;color:{_TEXT_DIM};">{html.escape(note)}</p>' # noqa: E501
) )
rendered = _layout( rendered = _layout(
+1 -1
View File
@@ -63,7 +63,7 @@ class FreeKassaService:
) )
if settings.FREEKASSA_ENABLED and not self.server_ip: if settings.FREEKASSA_ENABLED and not self.server_ip:
logging.warning( logging.warning(
"FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider." "FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider." # noqa: E501
) )
@staticmethod @staticmethod
+17 -17
View File
@@ -106,15 +106,15 @@ class PanelApiService:
parsed_json_for_log, indent=2, ensure_ascii=False parsed_json_for_log, indent=2, ensure_ascii=False
) )
logging.info( logging.info(
f"{log_prefix} {log_suffix} | Full Response Body:\n{pretty_response_text}" f"{log_prefix} {log_suffix} | Full Response Body:\n{pretty_response_text}" # noqa: E501
) )
except json.JSONDecodeError: except json.JSONDecodeError:
logging.info( logging.info(
f"{log_prefix} {log_suffix} | Full Response Text (not JSON):\n{response_text[:2000]}{'...' if len(response_text) > 2000 else ''}" f"{log_prefix} {log_suffix} | Full Response Text (not JSON):\n{response_text[:2000]}{'...' if len(response_text) > 2000 else ''}" # noqa: E501
) )
else: else:
logging.debug( logging.debug(
f"{log_prefix} {log_suffix} | OK. Response Body Preview: {response_text[:200]}{'...' if len(response_text) > 200 else ''}" f"{log_prefix} {log_suffix} | OK. Response Body Preview: {response_text[:200]}{'...' if len(response_text) > 200 else ''}" # noqa: E501
) )
if 200 <= response_status < 300: if 200 <= response_status < 300:
@@ -130,7 +130,7 @@ class PanelApiService:
} }
except json.JSONDecodeError as e_json_ok: except json.JSONDecodeError as e_json_ok:
logging.error( logging.error(
f"{log_prefix} {log_suffix} | OK but JSON Parse Error. Error: {e_json_ok}. Body was logged above." f"{log_prefix} {log_suffix} | OK but JSON Parse Error. Error: {e_json_ok}. Body was logged above." # noqa: E501
) )
return { return {
"status": "success_parse_error", "status": "success_parse_error",
@@ -179,7 +179,7 @@ class PanelApiService:
if not response_data or response_data.get("error"): if not response_data or response_data.get("error"):
logging.error( logging.error(
f"Failed to fetch panel users batch (start: {start_offset}). Response: {response_data}" f"Failed to fetch panel users batch (start: {start_offset}). Response: {response_data}" # noqa: E501
) )
return None return None
users_batch = response_data.get("response", {}).get("users", []) users_batch = response_data.get("response", {}).get("users", [])
@@ -289,7 +289,7 @@ class PanelApiService:
return [] return []
logging.error( logging.error(
f"Failed to fetch panel users with filter ({filter_used_log}). Last API response: {response_data if not log_response else '(logged above)'}" f"Failed to fetch panel users with filter ({filter_used_log}). Last API response: {response_data if not log_response else '(logged above)'}" # noqa: E501
) )
return None return None
@@ -345,7 +345,7 @@ class PanelApiService:
payload["hwidDeviceLimit"] = hwid_limit_int payload["hwidDeviceLimit"] = hwid_limit_int
except (TypeError, ValueError): except (TypeError, ValueError):
logging.warning( logging.warning(
f"Ignoring invalid HWID device limit '{hwid_limit_value}' while creating panel user '{username_on_panel}'." f"Ignoring invalid HWID device limit '{hwid_limit_value}' while creating panel user '{username_on_panel}'." # noqa: E501
) )
if specific_squad_uuids: if specific_squad_uuids:
payload["activeInternalSquads"] = specific_squad_uuids payload["activeInternalSquads"] = specific_squad_uuids
@@ -365,17 +365,17 @@ class PanelApiService:
) )
if response and not response.get("error") and "response" in response: if response and not response.get("error") and "response" in response:
logging.info( logging.info(
f"Panel user '{username_on_panel}' created successfully (UUID: {response.get('response', {}).get('uuid')})." f"Panel user '{username_on_panel}' created successfully (UUID: {response.get('response', {}).get('uuid')})." # noqa: E501
) )
return response return response
logging.error( logging.error(
f"Failed to create panel user '{username_on_panel}'. Payload: {payload}, Response: {response if not log_response else '(full response logged above)'}" f"Failed to create panel user '{username_on_panel}'. Payload: {payload}, Response: {response if not log_response else '(full response logged above)'}" # noqa: E501
) )
return response return response
async def update_user_details_on_panel( async def update_user_details_on_panel(
self, user_uuid: str, update_payload: Dict[str, Any], log_response: bool = True self, user_uuid: str, update_payload: Dict[str, Any], log_response: bool = False
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
if "uuid" not in update_payload: if "uuid" not in update_payload:
update_payload["uuid"] = user_uuid update_payload["uuid"] = user_uuid
@@ -384,11 +384,11 @@ class PanelApiService:
"PATCH", "/users", json=update_payload, log_full_response=log_response "PATCH", "/users", json=update_payload, log_full_response=log_response
) )
if full_response and not full_response.get("error") and "response" in full_response: if full_response and not full_response.get("error") and "response" in full_response:
logging.info(f"User {user_uuid} details updated on panel.") logging.debug("User %s details updated on panel.", user_uuid)
return full_response.get("response") return full_response.get("response")
logging.error( logging.error(
f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Response: {full_response if not log_response else '(logged above)'}" f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Response: {full_response if not log_response else '(logged above)'}" # noqa: E501
) )
return None return None
@@ -404,17 +404,17 @@ class PanelApiService:
expected_status = "ACTIVE" if enable else "DISABLED" expected_status = "ACTIVE" if enable else "DISABLED"
if actual_status == expected_status: if actual_status == expected_status:
logging.info( logging.info(
f"User {user_uuid} status on panel successfully set to {action} (Actual: {actual_status})." f"User {user_uuid} status on panel successfully set to {action} (Actual: {actual_status})." # noqa: E501
) )
return True return True
else: else:
logging.warning( logging.warning(
f"User {user_uuid} status on panel action '{action}' called, but final status is '{actual_status}'." f"User {user_uuid} status on panel action '{action}' called, but final status is '{actual_status}'." # noqa: E501
) )
return False return False
logging.error( logging.error(
f"Failed to {action} user {user_uuid} on panel. Response: {response_data if not log_response else '(logged above)'}" f"Failed to {action} user {user_uuid} on panel. Response: {response_data if not log_response else '(logged above)'}" # noqa: E501
) )
return False return False
@@ -434,7 +434,7 @@ class PanelApiService:
error_code = details.get("errorCode") or response_data.get("errorCode") error_code = details.get("errorCode") or response_data.get("errorCode")
if error_code in {"A062", "A040"}: if error_code in {"A062", "A040"}:
logging.info( logging.info(
f"Panel user {user_uuid} already absent (errorCode {error_code}). Treating as deleted." f"Panel user {user_uuid} already absent (errorCode {error_code}). Treating as deleted." # noqa: E501
) )
return True return True
logging.error(f"Failed to delete user {user_uuid} on panel. Response: {response_data}") logging.error(f"Failed to delete user {user_uuid} on panel. Response: {response_data}")
@@ -469,7 +469,7 @@ class PanelApiService:
if response_data and not response_data.get("error") and "response" in response_data: if response_data and not response_data.get("error") and "response" in response_data:
return True return True
logging.error( logging.error(
f"Failed to disconnect device {hwid} for user {user_uuid}. Payload: {payload}, Response: {response_data}" f"Failed to disconnect device {hwid} for user {user_uuid}. Payload: {payload}, Response: {response_data}" # noqa: E501
) )
return False return False
+1 -1
View File
@@ -106,7 +106,7 @@ class PanelWebhookService:
ok = await subscription_service.charge_subscription_renewal( ok = await subscription_service.charge_subscription_renewal(
session, sub session, sub
) )
# If initiation succeeded, suppress the 24h reminder by returning early # If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501
if ok: if ok:
await session.commit() await session.commit()
return return
+2 -2
View File
@@ -61,7 +61,7 @@ class PlategaService:
) )
else: else:
logging.info( logging.info(
"PlategaService configured. SBP button: %s (method=%s), Crypto button: %s (method=%s)", "PlategaService configured. SBP button: %s (method=%s), Crypto button: %s (method=%s)", # noqa: E501
"ON" if settings.PLATEGA_SBP_ENABLED else "OFF", "ON" if settings.PLATEGA_SBP_ENABLED else "OFF",
self.sbp_method, self.sbp_method,
"ON" if settings.PLATEGA_CRYPTO_ENABLED else "OFF", "ON" if settings.PLATEGA_CRYPTO_ENABLED else "OFF",
@@ -205,7 +205,7 @@ class PlategaService:
) )
if incoming_amount != expected_amount: if incoming_amount != expected_amount:
logging.warning( logging.warning(
"Platega webhook: amount mismatch for payment %s (expected %s, got %s)", "Platega webhook: amount mismatch for payment %s (expected %s, got %s)", # noqa: E501
payment.payment_id, payment.payment_id,
expected_amount, expected_amount,
incoming_amount, incoming_amount,
+1 -1
View File
@@ -119,7 +119,7 @@ class PromoCodeService:
return True, new_end_date return True, new_end_date
else: else:
logging.error( logging.error(
f"Failed to record activation or increment usage for promo {promo_data.code} by user {user_id}" f"Failed to record activation or increment usage for promo {promo_data.code} by user {user_id}" # noqa: E501
) )
return False, _("error_applying_promo_bonus") return False, _("error_applying_promo_bonus")
else: else:
+15 -15
View File
@@ -43,7 +43,7 @@ class ReferralService:
referee_user_model = await user_dal.get_user_by_id(session, referee_user_id) referee_user_model = await user_dal.get_user_by_id(session, referee_user_id)
if not referee_user_model or referee_user_model.referred_by_id is None: if not referee_user_model or referee_user_model.referred_by_id is None:
logging.debug( logging.debug(
f"User {referee_user_id} not referred or inviter ID missing. No referral bonuses." f"User {referee_user_id} not referred or inviter ID missing. No referral bonuses." # noqa: E501
) )
return {"referee_bonus_applied_days": None, "referee_new_end_date": None} return {"referee_bonus_applied_days": None, "referee_new_end_date": None}
@@ -58,7 +58,7 @@ class ReferralService:
) )
if succeeded_count and succeeded_count > 0: if succeeded_count and succeeded_count > 0:
logging.info( logging.info(
f"Referral bonuses skipped for user {referee_user_id}: already has {succeeded_count} succeeded payments." f"Referral bonuses skipped for user {referee_user_id}: already has {succeeded_count} succeeded payments." # noqa: E501
) )
return {"referee_bonus_applied_days": None, "referee_new_end_date": None} return {"referee_bonus_applied_days": None, "referee_new_end_date": None}
except Exception as e_cnt: except Exception as e_cnt:
@@ -74,7 +74,7 @@ class ReferralService:
session, referee_user_id session, referee_user_id
): ):
logging.info( logging.info(
f"Referral bonuses skipped for user {referee_user_id}: user currently has an active subscription." f"Referral bonuses skipped for user {referee_user_id}: user currently has an active subscription." # noqa: E501
) )
return {"referee_bonus_applied_days": None, "referee_new_end_date": None} return {"referee_bonus_applied_days": None, "referee_new_end_date": None}
except Exception as e_sub: except Exception as e_sub:
@@ -104,7 +104,7 @@ class ReferralService:
if inviter_bonus_days and inviter_bonus_days > 0: if inviter_bonus_days and inviter_bonus_days > 0:
if not inviter_user_model: if not inviter_user_model:
logging.warning( logging.warning(
f"Inviter user {inviter_user_id} not found in local DB. Cannot apply inviter bonus." f"Inviter user {inviter_user_id} not found in local DB. Cannot apply inviter bonus." # noqa: E501
) )
else: else:
( (
@@ -118,7 +118,7 @@ class ReferralService:
if not inviter_panel_uuid: if not inviter_panel_uuid:
logging.warning( logging.warning(
f"Failed to get/create panel link for inviter {inviter_user_id}. Cannot apply inviter bonus directly to panel." f"Failed to get/create panel link for inviter {inviter_user_id}. Cannot apply inviter bonus directly to panel." # noqa: E501
) )
else: else:
@@ -134,7 +134,7 @@ class ReferralService:
if new_end_date_inviter: if new_end_date_inviter:
inviter_bonus_successfully_applied = True inviter_bonus_successfully_applied = True
logging.info( logging.info(
f"Bonus of {inviter_bonus_days} days successfully applied/extended for inviter {inviter_user_id}." f"Bonus of {inviter_bonus_days} days successfully applied/extended for inviter {inviter_user_id}." # noqa: E501
) )
try: try:
@@ -153,11 +153,11 @@ class ReferralService:
) )
except Exception as e_notify_inviter: except Exception as e_notify_inviter:
logging.error( logging.error(
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
) )
else: else:
logging.info( logging.info(
f"Inviter {inviter_user_id} has no active sub to extend. Creating new bonus subscription for {inviter_bonus_days} days." f"Inviter {inviter_user_id} has no active sub to extend. Creating new bonus subscription for {inviter_bonus_days} days." # noqa: E501
) )
bonus_start_date = datetime.now(timezone.utc) bonus_start_date = datetime.now(timezone.utc)
@@ -165,7 +165,7 @@ class ReferralService:
if not inviter_panel_sub_link_id: if not inviter_panel_sub_link_id:
logging.error( logging.error(
f"Cannot create bonus subscription for inviter {inviter_user_id}: panel_sub_link_id is missing even after link detail fetch." f"Cannot create bonus subscription for inviter {inviter_user_id}: panel_sub_link_id is missing even after link detail fetch." # noqa: E501
) )
else: else:
bonus_sub_payload = { bonus_sub_payload = {
@@ -188,7 +188,7 @@ class ReferralService:
session, bonus_sub_payload session, bonus_sub_payload
) )
panel_update_success = await self.subscription_service.panel_service.update_user_details_on_panel( panel_update_success = await self.subscription_service.panel_service.update_user_details_on_panel( # noqa: E501
inviter_panel_uuid, inviter_panel_uuid,
{ {
"expireAt": bonus_end_date.isoformat( "expireAt": bonus_end_date.isoformat(
@@ -200,7 +200,7 @@ class ReferralService:
if panel_update_success: if panel_update_success:
inviter_bonus_successfully_applied = True inviter_bonus_successfully_applied = True
logging.info( logging.info(
f"New bonus subscription for {inviter_bonus_days} days created for inviter {inviter_user_id}." f"New bonus subscription for {inviter_bonus_days} days created for inviter {inviter_user_id}." # noqa: E501
) )
inviter_lang = ( inviter_lang = (
@@ -221,12 +221,12 @@ class ReferralService:
) )
else: else:
logging.warning( logging.warning(
f"Failed to update panel for new bonus subscription for inviter {inviter_user_id}. Local bonus sub created (ID: {bonus_sub.subscription_id}) but may not be active on panel." f"Failed to update panel for new bonus subscription for inviter {inviter_user_id}. Local bonus sub created (ID: {bonus_sub.subscription_id}) but may not be active on panel." # noqa: E501
) )
except Exception as e_create_bonus_sub: except Exception as e_create_bonus_sub:
logging.error( logging.error(
f"Failed to create new bonus subscription for inviter {inviter_user_id}: {e_create_bonus_sub}", f"Failed to create new bonus subscription for inviter {inviter_user_id}: {e_create_bonus_sub}", # noqa: E501
exc_info=True, exc_info=True,
) )
@@ -243,11 +243,11 @@ class ReferralService:
referee_final_end_date = new_end_date_referee referee_final_end_date = new_end_date_referee
referee_bonus_applied_days = referee_bonus_days referee_bonus_applied_days = referee_bonus_days
logging.info( logging.info(
f"Bonus of {referee_bonus_days} days successfully applied to referee {referee_user_id}." f"Bonus of {referee_bonus_days} days successfully applied to referee {referee_user_id}." # noqa: E501
) )
else: else:
logging.warning( logging.warning(
f"Failed to apply referee bonus for {referee_user_id} (could not extend their new subscription)." f"Failed to apply referee bonus for {referee_user_id} (could not extend their new subscription)." # noqa: E501
) )
return { return {
+1 -1
View File
@@ -74,7 +74,7 @@ class StarsService:
title=description, title=description,
description=description, description=description,
payload=payload, payload=payload,
provider_token="", # Required to be empty for Telegram Stars (XTR) per Telegram Bot API. provider_token="", # Required to be empty for Telegram Stars (XTR) per Telegram Bot API. # noqa: E501
currency="XTR", currency="XTR",
prices=prices, prices=prices,
) )
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
"""Domain mixins for SubscriptionService."""
@@ -0,0 +1,29 @@
# ruff: noqa: F401,F403,F405,I001
import logging
import math
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple
from aiogram import Bot
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.utils.config_link import prepare_config_links
from bot.utils.date_utils import add_months, month_start
from config.settings import Settings
from config.tariffs_config import Tariff
from db.dal import (
payment_dal,
promo_code_dal,
subscription_dal,
tariff_dal,
user_billing_dal,
user_dal,
)
from db.models import Subscription, User
from bot.services.email_auth_service import EmailAuthService
from bot.services.email_templates import render_payment_success
from bot.services.panel_api_service import PanelApiService
__all__ = [name for name in globals() if not name.startswith("__")]
@@ -0,0 +1,34 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .devices import HwidDeviceMixin
from .lifecycle import SubscriptionLifecycleMixin
from .panel_identity import PanelIdentityMixin
from .payments import PaymentContextMixin
from .renewal import RenewalMixin
from .tariffs import TariffMixin
from .traffic import TrafficMixin
from .trial import TrialSubscriptionMixin
class SubscriptionService(
TrialSubscriptionMixin,
TrafficMixin,
HwidDeviceMixin,
SubscriptionLifecycleMixin,
RenewalMixin,
PaymentContextMixin,
PanelIdentityMixin,
TariffMixin,
):
def __init__(
self,
settings: Settings,
panel_service: PanelApiService,
bot: Optional[Bot] = None,
i18n: Optional[JsonI18n] = None,
):
self.settings = settings
self.panel_service = panel_service
self.bot = bot
self.i18n = i18n
self._premium_access_cache: Dict[Tuple[str, ...], Dict[str, Any]] = {}
@@ -0,0 +1,117 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
class HwidDeviceMixin:
async def activate_hwid_device_topup(
self,
session: AsyncSession,
user_id: int,
device_count: int,
payment_amount: float,
payment_db_id: int,
provider: str = "yookassa",
tariff_key: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
try:
purchased_devices = int(device_count)
except (TypeError, ValueError):
purchased_devices = 0
if purchased_devices <= 0:
logging.error("HWID device top-up requires positive device count for user %s", user_id)
return None
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or not db_user.panel_user_uuid:
return None
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub:
return None
tariff = None
if self._tariffs_config():
tariff = self._resolve_tariff(tariff_key or sub.tariff_key)
packages = (
[*tariff.hwid_device_packages.rub, *tariff.hwid_device_packages.stars]
if tariff.hwid_device_packages
else []
)
if packages and not any(pkg.count == purchased_devices for pkg in packages):
logging.error(
"HWID device package %s is not available for tariff %s",
purchased_devices,
tariff.key,
)
return None
base_hwid_limit = (
int(sub.hwid_device_limit)
if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
if base_hwid_limit == 0:
logging.info(
"Skipping HWID top-up for user %s because current limit is unlimited", user_id
)
return {
"subscription_id": sub.subscription_id,
"hwid_device_limit": 0,
"extra_hwid_devices": int(sub.extra_hwid_devices or 0),
"purchased_hwid_devices": 0,
}
new_extra_devices = int(sub.extra_hwid_devices or 0) + purchased_devices
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, new_extra_devices)
await self._record_payment_context(
session,
payment_db_id,
sale_mode="hwid_devices",
tariff_key=tariff.key if tariff else sub.tariff_key,
purchased_hwid_devices=purchased_devices,
)
updated_sub = await subscription_dal.update_subscription(
session,
sub.subscription_id,
{
"hwid_device_limit": base_hwid_limit,
"extra_hwid_devices": new_extra_devices,
"tariff_key": tariff.key if tariff else sub.tariff_key,
},
)
if not updated_sub:
return None
panel_payload = self._build_panel_update_payload(
panel_user_uuid=db_user.panel_user_uuid,
expire_at=updated_sub.end_date,
status="ACTIVE",
hwid_device_limit=effective_hwid_limit,
)
panel_payload.update(self._panel_identity_payload_for_user(db_user))
updated_panel = await self.panel_service.update_user_details_on_panel(
db_user.panel_user_uuid,
panel_payload,
)
if not updated_panel or updated_panel.get("error"):
logging.warning(
"Panel user HWID limit update failed for user %s. Response: %s",
user_id,
updated_panel,
)
return None
await tariff_dal.create_hwid_device_purchase(
session,
subscription_id=updated_sub.subscription_id,
payment_id=payment_db_id,
purchased_devices=purchased_devices,
)
return {
"subscription_id": updated_sub.subscription_id,
"hwid_device_limit": effective_hwid_limit,
"extra_hwid_devices": new_extra_devices,
"purchased_hwid_devices": purchased_devices,
"tariff_key": tariff.key if tariff else sub.tariff_key,
}
@@ -0,0 +1,838 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
class SubscriptionLifecycleMixin:
async def switch_tariff_without_payment(
self,
session: AsyncSession,
user_id: int,
target_tariff_key: str,
mode: str,
) -> Optional[Dict[str, Any]]:
config = self._tariffs_config()
if not config:
return None
target = config.require(target_tariff_key)
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or not db_user.panel_user_uuid:
return None
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub:
return None
before_tariff_key = sub.tariff_key
options = self.calculate_tariff_switch_options(sub, target)
now = datetime.now(timezone.utc)
premium_topup_balance = int(sub.premium_topup_balance_bytes or 0)
premium_topup_used = int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
premium_baseline = target.premium_monthly_bytes
premium_limit = self._premium_effective_limit_bytes(
premium_baseline,
premium_topup_balance,
premium_topup_used,
)
premium_used = int(sub.premium_used_bytes or 0)
update_data: Dict[str, Any] = {
"tariff_key": target.key,
"is_throttled": False,
"premium_baseline_bytes": premium_baseline,
"premium_topup_balance_bytes": premium_topup_balance,
"premium_topup_used_bytes": premium_topup_used,
"premium_is_limited": bool(premium_limit > 0 and premium_used >= premium_limit),
}
converted_bytes = None
base_hwid_limit = self._base_hwid_limit_for_tariff(target)
extra_hwid_devices = int(sub.extra_hwid_devices or 0)
update_data["hwid_device_limit"] = base_hwid_limit
if target.billing_model == "period":
update_data["tier_baseline_bytes"] = target.monthly_bytes
rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
runl = bool(getattr(sub, "regular_unlimited_override", False))
used_sub = int(sub.traffic_used_bytes or 0)
update_data["traffic_limit_bytes"] = self._compute_main_traffic_limit_bytes(
tier_baseline_bytes=target.monthly_bytes,
topup_balance_bytes=int(sub.topup_balance_bytes or 0),
regular_bonus_bytes=rb,
regular_unlimited_override=runl,
traffic_used_bytes=used_sub,
)
update_data["period_start_at"] = None
update_data["effective_monthly_price_rub"] = (
target.period_price(1, "rub") or target.min_period_price_rub()
)
if mode == "recalc_days" and options.get("recalc_days") is not None:
update_data["end_date"] = now + timedelta(days=int(options["recalc_days"]))
else:
converted_gb = float(options.get("converted_gb", 0))
converted_bytes = self.gb_to_bytes(converted_gb)
old_topup = int(sub.topup_balance_bytes or 0)
new_balance = old_topup + converted_bytes
rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
runl = bool(getattr(sub, "regular_unlimited_override", False))
panel_user = (
await self.panel_service.get_user_by_uuid(
db_user.panel_user_uuid, log_response=False
)
or {}
)
current_used, _, _ = self._extract_panel_traffic_details(panel_user)
cur_used_int = int(current_used or 0)
update_data.update(
{
"end_date": self._far_future(),
"period_start_at": None,
"tier_baseline_bytes": 0,
"topup_balance_bytes": new_balance,
"traffic_limit_bytes": self._compute_main_traffic_limit_bytes(
tier_baseline_bytes=0,
topup_balance_bytes=new_balance,
regular_bonus_bytes=rb,
regular_unlimited_override=runl,
traffic_used_bytes=cur_used_int,
),
"traffic_used_bytes": current_used,
"effective_monthly_price_rub": None,
"auto_renew_enabled": False,
"skip_notifications": True,
}
)
updated = await subscription_dal.update_subscription(
session, sub.subscription_id, update_data
)
if not updated:
return None
panel_payload = self._build_panel_update_payload(
panel_user_uuid=db_user.panel_user_uuid,
expire_at=updated.end_date,
status="ACTIVE",
traffic_limit_bytes=updated.traffic_limit_bytes,
traffic_limit_strategy="NO_RESET" if target.billing_model == "traffic" else "MONTH",
hwid_device_limit=self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices),
)
panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff(
target,
include_premium=not bool(updated.premium_is_limited),
)
panel_payload.update(self._panel_identity_payload_for_user(db_user))
await self.panel_service.update_user_details_on_panel(
db_user.panel_user_uuid, panel_payload
)
if converted_bytes:
await tariff_dal.create_traffic_topup(
session,
subscription_id=updated.subscription_id,
payment_id=None,
purchased_bytes=converted_bytes,
kind="conversion",
)
await tariff_dal.create_tariff_change(
session,
{
"subscription_id": updated.subscription_id,
"from_tariff_key": before_tariff_key,
"to_tariff_key": target.key,
"mode": mode,
"payment_id": None,
"days_before": options.get("remaining_days"),
"days_after": (updated.end_date - now).days
if updated.end_date and target.billing_model == "period"
else None,
"converted_bytes": converted_bytes,
"eff_price_before": sub.effective_monthly_price_rub,
"eff_price_after": updated.effective_monthly_price_rub,
},
)
return {"subscription_id": updated.subscription_id, "tariff_key": target.key}
async def activate_subscription(
self,
session: AsyncSession,
user_id: int,
months: int,
payment_amount: float,
payment_db_id: int,
promo_code_id_from_payment: Optional[int] = None,
provider: str = "yookassa",
sale_mode: str = "subscription",
traffic_gb: Optional[float] = None,
tariff_key: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
sale_mode_base, sale_mode_tariff_key = self._parse_sale_mode_context(sale_mode, tariff_key)
tariff_key = sale_mode_tariff_key
if sale_mode_base in {"traffic", "traffic_package"} or (
getattr(self.settings, "traffic_sale_mode", False) and not self._tariffs_config()
):
target_gb = traffic_gb if traffic_gb is not None else float(months)
return await self._activate_traffic_package(
session=session,
user_id=user_id,
traffic_gb=target_gb,
payment_amount=payment_amount,
payment_db_id=payment_db_id,
provider=provider,
tariff_key=tariff_key,
sale_mode="traffic_package" if self._tariffs_config() else "traffic",
)
if sale_mode_base == "topup":
if not tariff_key:
active_user = await user_dal.get_user_by_id(session, user_id)
active_sub = (
await subscription_dal.get_active_subscription_by_user_id(
session, user_id, active_user.panel_user_uuid
)
if active_user and active_user.panel_user_uuid
else None
)
tariff_key = active_sub.tariff_key if active_sub else None
if not tariff_key:
logging.error("Top-up activation requires tariff_key for user %s", user_id)
return None
return await self.activate_topup(
session=session,
user_id=user_id,
tariff_key=tariff_key,
traffic_gb=traffic_gb if traffic_gb is not None else float(months),
payment_amount=payment_amount,
payment_db_id=payment_db_id,
provider=provider,
)
if sale_mode_base == "premium_topup":
if not tariff_key:
active_user = await user_dal.get_user_by_id(session, user_id)
active_sub = (
await subscription_dal.get_active_subscription_by_user_id(
session, user_id, active_user.panel_user_uuid
)
if active_user and active_user.panel_user_uuid
else None
)
tariff_key = active_sub.tariff_key if active_sub else None
if not tariff_key:
logging.error("Premium top-up activation requires tariff_key for user %s", user_id)
return None
return await self.activate_premium_topup(
session=session,
user_id=user_id,
tariff_key=tariff_key,
traffic_gb=traffic_gb if traffic_gb is not None else float(months),
payment_amount=payment_amount,
payment_db_id=payment_db_id,
provider=provider,
)
if sale_mode_base in {"hwid_device", "hwid_devices"}:
target_devices = int(traffic_gb if traffic_gb is not None else months)
return await self.activate_hwid_device_topup(
session=session,
user_id=user_id,
device_count=target_devices,
payment_amount=payment_amount,
payment_db_id=payment_db_id,
provider=provider,
tariff_key=tariff_key,
)
if sale_mode_base == "tariff_upgrade":
if not tariff_key:
logging.error("Tariff upgrade activation requires tariff_key for user %s", user_id)
return None
await self._record_payment_context(
session,
payment_db_id,
sale_mode="tariff_upgrade",
tariff_key=tariff_key,
purchased_gb=None,
)
result = await self.switch_tariff_without_payment(
session,
user_id,
tariff_key,
"paid_diff",
)
if result:
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
if sub:
await tariff_dal.create_tariff_change(
session,
{
"subscription_id": sub.subscription_id,
"from_tariff_key": None,
"to_tariff_key": tariff_key,
"mode": "paid_diff",
"payment_id": payment_db_id,
"days_before": None,
"days_after": (sub.end_date - datetime.now(timezone.utc)).days
if sub.end_date
else None,
"converted_bytes": None,
"eff_price_before": None,
"eff_price_after": sub.effective_monthly_price_rub,
},
)
result["end_date"] = sub.end_date
result["is_active"] = sub.is_active
return result
tariff = self._resolve_tariff(tariff_key, "period") if self._tariffs_config() else None
await self._record_payment_context(
session,
payment_db_id,
sale_mode=sale_mode_base,
tariff_key=tariff.key if tariff else tariff_key,
purchased_gb=None,
)
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
logging.error(f"User {user_id} not found in DB for paid subscription activation.")
return None
(
panel_user_uuid,
panel_sub_link_id,
panel_short_uuid,
panel_user_created_now,
) = await self._get_or_create_panel_user_link_details(session, user_id, db_user)
if not panel_user_uuid or not panel_sub_link_id:
logging.error(f"Failed to ensure panel user for TG {user_id} during paid subscription.")
return None
try:
months_int = int(months)
except Exception:
months_int = 1
current_active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_user_uuid
)
start_date = datetime.now(timezone.utc)
if (
current_active_sub
and current_active_sub.end_date
and current_active_sub.end_date > start_date
):
start_date = current_active_sub.end_date
# base duration by months
end_after_months = add_months(start_date, months_int)
duration_days_total = (end_after_months - start_date).days
applied_promo_bonus_days = 0
if promo_code_id_from_payment:
promo_model = await promo_code_dal.get_promo_code_by_id(
session, promo_code_id_from_payment
)
if (
promo_model
and promo_model.is_active
and promo_model.current_activations < promo_model.max_activations
):
applied_promo_bonus_days = promo_model.bonus_days
duration_days_total += applied_promo_bonus_days
activation = await promo_code_dal.record_promo_activation(
session,
promo_code_id_from_payment,
user_id,
payment_id=payment_db_id,
)
if activation:
await promo_code_dal.increment_promo_code_usage(
session, promo_code_id_from_payment
)
else:
logging.warning(
f"Promo code {promo_code_id_from_payment} was already activated by user {user_id}, but bonus applied via payment {payment_db_id}." # noqa: E501
)
else:
logging.warning(
f"Promo code ID {promo_code_id_from_payment} (from payment) not found or invalid." # noqa: E501
)
promo_code_id_from_payment = None
final_end_date = start_date + timedelta(days=duration_days_total)
await subscription_dal.deactivate_other_active_subscriptions(
session, panel_user_uuid, panel_sub_link_id
)
auto_renew_should_enable = False
if provider == "yookassa" and self.settings.yookassa_autopayments_active:
auto_renew_should_enable = await user_billing_dal.user_has_saved_payment_method(
session, user_id
)
topup_balance_bytes = int(getattr(current_active_sub, "topup_balance_bytes", 0) or 0)
extra_hwid_devices = int(getattr(current_active_sub, "extra_hwid_devices", 0) or 0)
premium_topup_balance_bytes = int(
getattr(current_active_sub, "premium_topup_balance_bytes", 0) or 0
)
premium_topup_used_bytes = int(
getattr(current_active_sub, "premium_topup_used_bytes", 0) or 0
)
premium_used_bytes = int(getattr(current_active_sub, "premium_used_bytes", 0) or 0)
premium_period_start_at = getattr(current_active_sub, "premium_period_start_at", None)
tier_baseline_bytes = (
tariff.monthly_bytes if tariff else self.settings.user_traffic_limit_bytes
)
premium_baseline_bytes = tariff.premium_monthly_bytes if tariff else 0
premium_limit_bytes = self._premium_effective_limit_bytes(
premium_baseline_bytes,
premium_topup_balance_bytes,
premium_topup_used_bytes,
)
effective_monthly_price = float(payment_amount) / max(1, months_int)
regular_bonus_carry = int(getattr(current_active_sub, "regular_bonus_bytes", 0) or 0)
regular_unl_carry = bool(getattr(current_active_sub, "regular_unlimited_override", False))
traffic_limit_bytes = self._traffic_limit_for_period_tariff(
tariff,
topup_balance_bytes,
regular_bonus_carry,
regular_unlimited_override=regular_unl_carry,
traffic_used_bytes=0,
)
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
premium_is_limited = bool(
premium_limit_bytes > 0 and premium_used_bytes >= premium_limit_bytes
)
sub_payload = {
"user_id": user_id,
"panel_user_uuid": panel_user_uuid,
"panel_subscription_uuid": panel_sub_link_id,
"start_date": start_date,
"end_date": final_end_date,
"duration_months": months_int,
"is_active": True,
"status_from_panel": "ACTIVE",
"traffic_limit_bytes": traffic_limit_bytes,
"provider": provider,
"skip_notifications": False,
"auto_renew_enabled": auto_renew_should_enable,
"tariff_key": tariff.key if tariff else None,
"tier_baseline_bytes": tier_baseline_bytes,
"topup_balance_bytes": topup_balance_bytes,
"regular_bonus_bytes": regular_bonus_carry,
"regular_unlimited_override": regular_unl_carry,
"premium_baseline_bytes": premium_baseline_bytes,
"premium_topup_balance_bytes": premium_topup_balance_bytes,
"premium_topup_used_bytes": premium_topup_used_bytes,
"premium_used_bytes": premium_used_bytes,
"premium_is_limited": premium_is_limited,
"premium_period_start_at": premium_period_start_at,
"period_start_at": None,
"is_throttled": False,
"effective_monthly_price_rub": effective_monthly_price,
"hwid_device_limit": base_hwid_limit,
"extra_hwid_devices": extra_hwid_devices,
}
try:
new_or_updated_sub = await subscription_dal.upsert_subscription(session, sub_payload)
except Exception as e_upsert_sub:
logging.error(
f"Failed to upsert paid subscription for user {user_id}: {e_upsert_sub}",
exc_info=True,
)
return None
panel_update_payload = self._build_panel_update_payload(
panel_user_uuid=panel_user_uuid,
expire_at=final_end_date,
status="ACTIVE",
traffic_limit_bytes=traffic_limit_bytes,
traffic_limit_strategy="MONTH" if tariff else self.settings.USER_TRAFFIC_STRATEGY,
hwid_device_limit=effective_hwid_limit,
)
if tariff:
panel_update_payload["activeInternalSquads"] = self._panel_squads_for_tariff(
tariff,
include_premium=not premium_is_limited,
)
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload
)
if not updated_panel_user or updated_panel_user.get("error"):
logging.warning(
f"Panel user details update FAILED for paid sub user {panel_user_uuid}. Response: {updated_panel_user}" # noqa: E501
)
return None
final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
await self._send_payment_success_email(
db_user=db_user,
sale_mode="subscription",
months=months_int,
traffic_gb=None,
payment_amount=payment_amount,
end_date=final_end_date,
provider=provider,
)
return {
"subscription_id": new_or_updated_sub.subscription_id,
"end_date": final_end_date,
"is_active": True,
"panel_user_uuid": panel_user_uuid,
"panel_short_uuid": final_panel_short_uuid,
"subscription_url": final_subscription_url,
"applied_promo_bonus_days": applied_promo_bonus_days,
"tariff_key": tariff.key if tariff else None,
}
async def extend_active_subscription_days(
self,
session: AsyncSession,
user_id: int,
bonus_days: int,
reason: str = "bonus",
) -> Optional[datetime]:
reason_lower = (reason or "").lower()
apply_main_traffic_limit = any(
keyword in reason_lower for keyword in ("admin", "promo code", "referral", "bonus")
)
user = await user_dal.get_user_by_id(session, user_id)
if not user:
logging.warning(f"Cannot extend subscription for user {user_id}: user not found.")
return None
panel_uuid, panel_sub_uuid, _, _ = await self._get_or_create_panel_user_link_details(
session, user_id, user
)
if not panel_uuid or not panel_sub_uuid:
logging.error(
f"Failed to ensure panel user for subscription extension of user {user_id}."
)
return None
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_uuid
)
if not active_sub or not active_sub.end_date:
logging.info(
f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days." # noqa: E501
)
start_date = datetime.now(timezone.utc)
new_end_date_obj = start_date + timedelta(days=bonus_days)
# Apply main traffic limit for admin/referral/promo bonuses, fallback to trial limit otherwise # noqa: E501
traffic_limit = (
self.settings.user_traffic_limit_bytes
if apply_main_traffic_limit
else self.settings.trial_traffic_limit_bytes
)
bonus_sub_payload = {
"user_id": user_id,
"panel_user_uuid": panel_uuid,
"panel_subscription_uuid": panel_sub_uuid,
"start_date": start_date,
"end_date": new_end_date_obj,
"duration_months": 0,
"is_active": True,
"status_from_panel": "ACTIVE_BONUS",
"traffic_limit_bytes": traffic_limit,
"auto_renew_enabled": False,
}
await subscription_dal.deactivate_other_active_subscriptions(
session, panel_uuid, panel_sub_uuid
)
updated_sub_model = await subscription_dal.upsert_subscription(
session, bonus_sub_payload
)
else:
current_end_date = active_sub.end_date
now_utc = datetime.now(timezone.utc)
start_point_for_bonus = current_end_date if current_end_date > now_utc else now_utc
new_end_date_obj = start_point_for_bonus + timedelta(days=bonus_days)
updated_sub_model = await subscription_dal.update_subscription_end_date(
session, active_sub.subscription_id, new_end_date_obj
)
if (
apply_main_traffic_limit
and updated_sub_model
and updated_sub_model.traffic_limit_bytes != self.settings.user_traffic_limit_bytes
):
updated_sub_model = await subscription_dal.update_subscription(
session,
updated_sub_model.subscription_id,
{"traffic_limit_bytes": self.settings.user_traffic_limit_bytes},
)
if updated_sub_model:
# Prepare panel update payload
panel_update_payload = self._build_panel_update_payload(
expire_at=new_end_date_obj,
traffic_limit_bytes=(
self.settings.user_traffic_limit_bytes if apply_main_traffic_limit else None
),
include_uuid=False,
include_default_squads=False,
)
panel_update_success = await self.panel_service.update_user_details_on_panel(
panel_uuid,
panel_update_payload,
)
if not panel_update_success:
logging.warning(
f"Panel expiry update failed for {panel_uuid} after {reason} bonus. Local DB was updated to {new_end_date_obj}." # noqa: E501
)
logging.info(
f"Subscription for user {user_id} extended by {bonus_days} days ({reason}). New end date: {new_end_date_obj}." # noqa: E501
)
return new_end_date_obj
else:
logging.error(f"Failed to update subscription end date locally for user {user_id}.")
return None
async def get_active_subscription_details(
self, session: AsyncSession, user_id: int
) -> Optional[Dict[str, Any]]:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or not db_user.panel_user_uuid:
logging.info(
f"User {user_id} not found in DB or no panel_user_uuid for 'my_subscription'."
)
return None
panel_user_uuid = db_user.panel_user_uuid
local_active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_user_uuid
)
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid)
if not panel_user_data:
logging.warning(
f"Panel user {panel_user_uuid} not found on panel for user {user_id}. Clearing local linkage." # noqa: E501
)
await subscription_dal.deactivate_all_user_subscriptions(session, user_id)
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
return None
panel_lifetime_used = self._extract_lifetime_used_traffic(panel_user_data)
if (
panel_lifetime_used is not None
and db_user.lifetime_used_traffic_bytes != panel_lifetime_used
):
await user_dal.update_user(
session,
user_id,
{"lifetime_used_traffic_bytes": panel_lifetime_used},
)
if local_active_sub:
update_payload_local = {}
panel_status = panel_user_data.get("status", "UNKNOWN").upper()
panel_expire_at_str = panel_user_data.get("expireAt")
panel_traffic_used, panel_traffic_limit, _ = self._extract_panel_traffic_details(
panel_user_data
)
panel_sub_uuid_from_panel = panel_user_data.get(
"subscriptionUuid"
) or panel_user_data.get("shortUuid")
if local_active_sub.status_from_panel != panel_status:
update_payload_local["status_from_panel"] = panel_status
if panel_expire_at_str:
panel_expire_dt = datetime.fromisoformat(panel_expire_at_str.replace("Z", "+00:00"))
if local_active_sub.end_date.replace(microsecond=0) != panel_expire_dt.replace(
microsecond=0
):
update_payload_local["end_date"] = panel_expire_dt
update_payload_local["last_notification_sent"] = None
if (
panel_traffic_used is not None
and local_active_sub.traffic_used_bytes != panel_traffic_used
):
update_payload_local["traffic_used_bytes"] = panel_traffic_used
if (
panel_traffic_limit is not None
and local_active_sub.traffic_limit_bytes != panel_traffic_limit
):
update_payload_local["traffic_limit_bytes"] = panel_traffic_limit
if (
panel_sub_uuid_from_panel
and local_active_sub.panel_subscription_uuid != panel_sub_uuid_from_panel
):
update_payload_local["panel_subscription_uuid"] = panel_sub_uuid_from_panel
is_active_based_on_panel = panel_status == "ACTIVE" and (
panel_expire_dt > datetime.now(timezone.utc) if panel_expire_dt else False
)
if local_active_sub.is_active != is_active_based_on_panel:
update_payload_local["is_active"] = is_active_based_on_panel
if update_payload_local:
await subscription_dal.update_subscription(
session, local_active_sub.subscription_id, update_payload_local
)
panel_end_date = (
datetime.fromisoformat(panel_user_data["expireAt"].replace("Z", "+00:00"))
if panel_user_data.get("expireAt")
else None
)
panel_traffic_used, panel_traffic_limit, panel_traffic_strategy = (
self._extract_panel_traffic_details(panel_user_data)
)
config_link_raw = panel_user_data.get("subscriptionUrl")
display_link, connect_button_url = await prepare_config_links(
self.settings, config_link_raw
)
hwid_limit = panel_user_data.get("hwidDeviceLimit")
if hwid_limit is None:
if local_active_sub and local_active_sub.hwid_device_limit is not None:
hwid_limit = self._effective_hwid_limit(
local_active_sub.hwid_device_limit,
int(local_active_sub.extra_hwid_devices or 0),
)
else:
hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT
tariff = None
if local_active_sub and local_active_sub.tariff_key and self._tariffs_config():
try:
tariff = self._resolve_tariff(local_active_sub.tariff_key)
except Exception:
tariff = None
billing_model_display = (
tariff.billing_model
if tariff
else ("traffic" if getattr(self.settings, "traffic_sale_mode", False) else "period")
)
traffic_limit_strategy = panel_traffic_strategy
premium_access = (
await self.premium_access_for_tariff(tariff)
if tariff
else {
"squad_uuids": [],
"squad_labels": [],
"node_labels": [],
}
)
premium_baseline = (
int(local_active_sub.premium_baseline_bytes or 0) if local_active_sub else 0
)
premium_topup_balance = (
int(local_active_sub.premium_topup_balance_bytes or 0) if local_active_sub else 0
)
premium_topup_used = (
int(getattr(local_active_sub, "premium_topup_used_bytes", 0) or 0)
if local_active_sub
else 0
)
premium_bonus_bytes = (
int(getattr(local_active_sub, "premium_bonus_bytes", 0) or 0) if local_active_sub else 0
)
premium_unlimited_override = (
bool(getattr(local_active_sub, "premium_unlimited_override", False))
if local_active_sub
else False
)
regular_bonus_bytes = (
int(getattr(local_active_sub, "regular_bonus_bytes", 0) or 0) if local_active_sub else 0
)
regular_unlimited_override = (
bool(getattr(local_active_sub, "regular_unlimited_override", False))
if local_active_sub
else False
)
return {
"user_id": panel_user_data.get("uuid"),
"end_date": panel_end_date,
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
"config_link": display_link,
"connect_button_url": connect_button_url,
"traffic_limit_bytes": panel_traffic_limit,
"traffic_used_bytes": panel_traffic_used,
"traffic_limit_strategy": traffic_limit_strategy,
"tariff_key": local_active_sub.tariff_key if local_active_sub else None,
"tariff_name": tariff.name(db_user.language_code or self.settings.DEFAULT_LANGUAGE)
if tariff
else None,
"tariff_description": tariff.description(
db_user.language_code or self.settings.DEFAULT_LANGUAGE
)
if tariff
else None,
"premium_title": tariff.premium_name(
db_user.language_code or self.settings.DEFAULT_LANGUAGE
)
if tariff
else None,
"billing_model": billing_model_display,
"tier_baseline_bytes": local_active_sub.tier_baseline_bytes
if local_active_sub
else None,
"topup_balance_bytes": local_active_sub.topup_balance_bytes if local_active_sub else 0,
"regular_bonus_bytes": regular_bonus_bytes,
"regular_unlimited_override": regular_unlimited_override,
"premium_baseline_bytes": premium_baseline,
"premium_topup_balance_bytes": premium_topup_balance,
"premium_topup_used_bytes": premium_topup_used,
"premium_used_bytes": local_active_sub.premium_used_bytes if local_active_sub else 0,
"premium_bonus_bytes": premium_bonus_bytes,
"premium_unlimited_override": premium_unlimited_override,
"premium_limit_bytes": self._premium_effective_limit_bytes(
premium_baseline,
premium_topup_balance,
premium_topup_used,
premium_bonus_bytes,
),
"premium_is_limited": bool(local_active_sub.premium_is_limited)
if local_active_sub
else False,
"premium_period_start_at": getattr(local_active_sub, "premium_period_start_at", None)
if local_active_sub
else None,
"premium_squad_labels": premium_access.get("squad_labels") or [],
"premium_node_labels": premium_access.get("node_labels") or [],
"period_start_at": local_active_sub.period_start_at if local_active_sub else None,
"is_throttled": bool(local_active_sub.is_throttled) if local_active_sub else False,
"base_hwid_device_limit": local_active_sub.hwid_device_limit
if local_active_sub
else None,
"extra_hwid_devices": int(local_active_sub.extra_hwid_devices or 0)
if local_active_sub
else 0,
"user_bot_username": db_user.username,
"is_panel_data": True,
"max_devices": hwid_limit,
}
async def get_subscriptions_ending_soon(
self, session: AsyncSession, days_threshold: int
) -> List[Dict[str, Any]]:
subs_models_with_users = await subscription_dal.get_subscriptions_near_expiration(
session, days_threshold
)
results = []
for sub_model in subs_models_with_users:
if sub_model.user and sub_model.end_date and not sub_model.skip_notifications:
days_left = (sub_model.end_date - datetime.now(timezone.utc)).total_seconds() / (
24 * 3600
)
results.append(
{
"user_id": sub_model.user_id,
"first_name": sub_model.user.first_name or f"User {sub_model.user_id}",
"language_code": sub_model.user.language_code
or self.settings.DEFAULT_LANGUAGE,
"end_date_str": sub_model.end_date.strftime("%Y-%m-%d"),
"days_left": max(0, int(round(days_left))),
"subscription_end_date_iso_for_update": sub_model.end_date,
}
)
return results
@@ -0,0 +1,344 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
class PanelIdentityMixin:
def _extract_panel_traffic_details(
self, panel_user_data: Dict[str, Any]
) -> Tuple[Optional[int], Optional[int], Optional[str]]:
traffic_stats = panel_user_data.get("userTraffic") or {}
used = traffic_stats.get("usedTrafficBytes")
if used is None:
used = panel_user_data.get("usedTrafficBytes")
limit = panel_user_data.get("trafficLimitBytes")
strategy = panel_user_data.get("trafficLimitStrategy")
if strategy is None:
strategy = traffic_stats.get("trafficLimitStrategy")
return used, limit, strategy
def _extract_lifetime_used_traffic(self, panel_user_data: Dict[str, Any]) -> Optional[int]:
traffic_stats = panel_user_data.get("userTraffic") or {}
lifetime = traffic_stats.get("lifetimeUsedTrafficBytes")
if lifetime is None:
lifetime = panel_user_data.get("lifetimeUsedTrafficBytes")
try:
if lifetime is None:
return None
return int(lifetime)
except (TypeError, ValueError):
return None
async def _notify_admin_panel_user_creation_failed(self, user_id: int):
if not self.bot or not self.i18n or not self.settings.ADMIN_IDS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_adm = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw)
msg = _adm("admin_panel_user_creation_failed", user_id=user_id)
for admin_id in self.settings.ADMIN_IDS:
try:
await self.bot.send_message(admin_id, msg)
except Exception as e:
logging.error(
f"Failed to notify admin {admin_id} about panel user creation failure: {e}"
)
def _telegram_id_for_panel(self, db_user: User) -> Optional[int]:
if db_user.telegram_id:
return int(db_user.telegram_id)
if db_user.user_id and int(db_user.user_id) > 0:
return int(db_user.user_id)
return None
async def _panel_username_for_user(self, session: AsyncSession, db_user: User) -> str:
telegram_id = self._telegram_id_for_panel(db_user)
if telegram_id and int(db_user.user_id) == telegram_id:
return f"tg_{telegram_id}"
referral_code = await user_dal.ensure_referral_code(session, db_user)
return f"em_{referral_code}"
def _panel_description_for_user(self, db_user: User) -> str:
lines = [
db_user.email or "",
db_user.username or "",
db_user.first_name or "",
db_user.last_name or "",
]
return "\n".join(line for line in lines if line).strip()
def _panel_identity_payload_for_user(self, db_user: User) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"description": self._panel_description_for_user(db_user),
}
telegram_id = self._telegram_id_for_panel(db_user)
if telegram_id:
payload["telegramId"] = telegram_id
if db_user.email:
payload["email"] = db_user.email
return payload
async def _get_or_create_panel_user_link_details(
self, session: AsyncSession, user_id: int, db_user: Optional[User] = None
) -> Tuple[Optional[str], Optional[str], Optional[str], bool]:
if not db_user:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
logging.error(
f"_get_or_create_panel_user_link_details: User {user_id} not found in local DB. Cannot proceed." # noqa: E501
)
return None, None, None, False
current_local_panel_uuid = db_user.panel_user_uuid
panel_username_on_panel_standard = await self._panel_username_for_user(session, db_user)
telegram_id_for_panel = self._telegram_id_for_panel(db_user)
panel_user_obj_from_api = None
panel_user_created_or_linked_now = False
panel_users_by_tg_id_list = None
if telegram_id_for_panel:
panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter(
telegram_id=telegram_id_for_panel
)
if panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) == 1:
panel_user_obj_from_api = panel_users_by_tg_id_list[0]
logging.info(
f"Found panel user by telegramId {telegram_id_for_panel}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}" # noqa: E501
)
elif panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) > 1:
logging.error(
f"CRITICAL: Multiple panel users found for telegramId {telegram_id_for_panel}. Manual intervention needed." # noqa: E501
)
return None, None, None, False
if not panel_user_obj_from_api and db_user.email:
panel_users_by_email_list = await self.panel_service.get_users_by_filter(
email=db_user.email
)
if panel_users_by_email_list and len(panel_users_by_email_list) == 1:
panel_user_obj_from_api = panel_users_by_email_list[0]
logging.info(
f"Found panel user by email {db_user.email}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}" # noqa: E501
)
elif panel_users_by_email_list and len(panel_users_by_email_list) > 1:
logging.error(
f"CRITICAL: Multiple panel users found for email {db_user.email}. Manual intervention needed." # noqa: E501
)
return None, None, None, False
if not panel_user_obj_from_api:
if current_local_panel_uuid:
logging.info(
f"User {user_id} (local panel_uuid: {current_local_panel_uuid}) not found on panel by TG ID. Fetching by panel_uuid." # noqa: E501
)
panel_user_obj_from_api = await self.panel_service.get_user_by_uuid(
current_local_panel_uuid
)
if not panel_user_obj_from_api:
logging.warning(
f"Local panel_uuid {current_local_panel_uuid} for TG user {user_id} also not found on panel. User might be deleted from panel or UUID desynced." # noqa: E501
)
logging.info(
f"Creating new panel user '{panel_username_on_panel_standard}' for TG user {user_id}." # noqa: E501
)
creation_response = await self.panel_service.create_panel_user(
username_on_panel=panel_username_on_panel_standard,
telegram_id=telegram_id_for_panel,
email=db_user.email,
description=self._panel_description_for_user(db_user),
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
)
if (
creation_response
and not creation_response.get("error")
and creation_response.get("response")
):
panel_user_obj_from_api = creation_response.get("response")
panel_user_created_or_linked_now = True
else:
await self._notify_admin_panel_user_creation_failed(user_id)
return None, None, None, False
else:
logging.info(
f"No panel user by TG ID & no local panel_uuid for TG user {user_id}. Creating new panel user '{panel_username_on_panel_standard}'." # noqa: E501
)
creation_response = await self.panel_service.create_panel_user(
username_on_panel=panel_username_on_panel_standard,
telegram_id=telegram_id_for_panel,
email=db_user.email,
description=self._panel_description_for_user(db_user),
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
external_squad_uuid=self.settings.parsed_user_external_squad_uuid,
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
)
if (
creation_response
and not creation_response.get("error")
and creation_response.get("response")
):
panel_user_obj_from_api = creation_response.get("response")
panel_user_created_or_linked_now = True
elif creation_response and creation_response.get("errorCode") == "A019":
logging.warning(
f"Panel user '{panel_username_on_panel_standard}' already exists (errorCode A019). Fetching by username." # noqa: E501
)
fetched_by_username_list = await self.panel_service.get_users_by_filter(
username=panel_username_on_panel_standard
)
if fetched_by_username_list and len(fetched_by_username_list) == 1:
panel_user_obj_from_api = fetched_by_username_list[0]
if not panel_user_obj_from_api:
logging.error(
f"Failed to create or link panel user for TG_ID {user_id} with panel username '{panel_username_on_panel_standard}'. Response: {creation_response if 'creation_response' in locals() else 'N/A'}" # noqa: E501
)
await self._notify_admin_panel_user_creation_failed(user_id)
return None, None, None, False
if not panel_user_obj_from_api:
logging.error(
f"Could not obtain panel user object for TG user {user_id} after all checks."
)
return (
current_local_panel_uuid if current_local_panel_uuid else None,
None,
None,
panel_user_created_or_linked_now,
)
actual_panel_uuid_from_api = panel_user_obj_from_api.get("uuid")
panel_telegram_id_from_api = panel_user_obj_from_api.get("telegramId")
if not actual_panel_uuid_from_api:
logging.error(
f"Panel user object for TG user {user_id} does not contain 'uuid'. Data: {panel_user_obj_from_api}" # noqa: E501
)
return (
current_local_panel_uuid,
None,
None,
panel_user_created_or_linked_now,
)
needs_local_panel_uuid_update = False
if current_local_panel_uuid is None and actual_panel_uuid_from_api:
needs_local_panel_uuid_update = True
elif (
current_local_panel_uuid is not None
and current_local_panel_uuid != actual_panel_uuid_from_api
):
logging.warning(
f"Local panel_uuid for user {user_id} ('{current_local_panel_uuid}') "
f"differs from panel's UUID ('{actual_panel_uuid_from_api}') for their telegramId. "
f"Will attempt to update local to panel's version."
)
needs_local_panel_uuid_update = True
if needs_local_panel_uuid_update:
conflicting_user_record = await user_dal.get_user_by_panel_uuid(
session, actual_panel_uuid_from_api
)
if conflicting_user_record and conflicting_user_record.user_id != user_id:
logging.error(
f"CRITICAL CONFLICT: Panel UUID {actual_panel_uuid_from_api} (from panel for TG ID {user_id}) " # noqa: E501
f"is ALREADY LINKED in local DB to a different TG User {conflicting_user_record.user_id}. " # noqa: E501
f"Cannot update panel_user_uuid for user {user_id}. Manual data correction needed." # noqa: E501
)
return None, None, None, False
else:
update_data_for_local_user = {"panel_user_uuid": actual_panel_uuid_from_api}
# Do not overwrite Telegram username with panel username.
# Only update the local linkage to panel UUID here.
await user_dal.update_user(session, user_id, update_data_for_local_user)
db_user.panel_user_uuid = actual_panel_uuid_from_api
panel_user_created_or_linked_now = True
current_local_panel_uuid = actual_panel_uuid_from_api
else:
pass
panel_telegram_id_int = None
if panel_telegram_id_from_api is not None:
try:
panel_telegram_id_int = int(panel_telegram_id_from_api)
except ValueError:
pass
if (
panel_user_obj_from_api
and current_local_panel_uuid
and telegram_id_for_panel
and panel_telegram_id_int != telegram_id_for_panel
):
logging.info(
f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{telegram_id_for_panel}'." # noqa: E501
)
await self.panel_service.update_user_details_on_panel(
current_local_panel_uuid,
self._panel_identity_payload_for_user(db_user),
)
panel_sub_link_id = panel_user_obj_from_api.get(
"subscriptionUuid"
) or panel_user_obj_from_api.get("shortUuid")
panel_short_uuid = panel_user_obj_from_api.get("shortUuid")
if not panel_sub_link_id and current_local_panel_uuid:
logging.warning(
f"No subscriptionUuid or shortUuid found on panel for panel_user_uuid {current_local_panel_uuid} (TG ID: {user_id})." # noqa: E501
)
return (
current_local_panel_uuid,
panel_sub_link_id,
panel_short_uuid,
panel_user_created_or_linked_now,
)
def _build_panel_update_payload(
self,
*,
panel_user_uuid: Optional[str] = None,
expire_at: Optional[datetime] = None,
status: Optional[str] = None,
traffic_limit_bytes: Optional[int] = None,
include_uuid: bool = True,
traffic_limit_strategy: Optional[str] = None,
hwid_device_limit: Optional[int] = None,
include_default_squads: bool = True,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {}
if include_uuid and panel_user_uuid:
payload["uuid"] = panel_user_uuid
if expire_at is not None:
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace(
"+00:00", "Z"
)
if status is not None:
payload["status"] = status
if traffic_limit_bytes is not None:
payload["trafficLimitBytes"] = traffic_limit_bytes
payload["trafficLimitStrategy"] = (
traffic_limit_strategy or self.settings.USER_TRAFFIC_STRATEGY
)
if hwid_device_limit is not None:
try:
hwid_limit_int = int(hwid_device_limit)
if hwid_limit_int >= 0:
payload["hwidDeviceLimit"] = hwid_limit_int
except (TypeError, ValueError):
pass
if include_default_squads:
if self.settings.parsed_user_squad_uuids:
payload["activeInternalSquads"] = self.settings.parsed_user_squad_uuids
if self.settings.parsed_user_external_squad_uuid:
payload["externalSquadUuid"] = self.settings.parsed_user_external_squad_uuid
return payload
@@ -0,0 +1,111 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
class PaymentContextMixin:
async def _record_payment_context(
self,
session: AsyncSession,
payment_db_id: int,
*,
sale_mode: str,
tariff_key: Optional[str],
purchased_gb: Optional[float] = None,
purchased_hwid_devices: Optional[int] = None,
) -> None:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
return
payment.sale_mode = sale_mode
payment.tariff_key = tariff_key
payment.purchased_gb = purchased_gb
payment.purchased_hwid_devices = purchased_hwid_devices
await session.flush()
async def get_user_language(self, session: AsyncSession, user_id: int) -> str:
user_record = await user_dal.get_user_by_id(session, user_id)
return (
user_record.language_code
if user_record and user_record.language_code
else self.settings.DEFAULT_LANGUAGE
)
async def has_had_any_subscription(self, session: AsyncSession, user_id: int) -> bool:
return await subscription_dal.has_any_subscription_for_user(session, user_id)
async def has_active_subscription(self, session: AsyncSession, user_id: int) -> bool:
"""Return True if user currently has an active subscription (end_date in future)."""
try:
user_record = await user_dal.get_user_by_id(session, user_id)
if not user_record or not user_record.panel_user_uuid:
return False
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, user_record.panel_user_uuid
)
if not active_sub or not active_sub.end_date:
return False
from datetime import datetime, timezone
return active_sub.is_active and active_sub.end_date > datetime.now(timezone.utc)
except Exception:
return False
async def _send_payment_success_email(
self,
*,
db_user: User,
sale_mode: str,
months: int,
traffic_gb: Optional[float],
payment_amount: float,
end_date: Optional[datetime],
provider: str,
) -> None:
"""Best-effort branded email confirming the payment. No-op if SMTP or
the user's email aren't set. Failures are logged and swallowed so the
payment flow is never blocked by mail delivery."""
if not self.settings.email_auth_configured:
return
recipient = (db_user.email or "").strip() if db_user else ""
if not recipient:
return
end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
provider_label = self._PROVIDER_LABELS.get((provider or "").lower())
dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None
try:
content = render_payment_success(
self.settings,
language_code=db_user.language_code or self.settings.DEFAULT_LANGUAGE,
sale_mode=sale_mode,
months=int(months or 0),
traffic_gb=traffic_gb,
amount=float(payment_amount or 0),
currency=self.settings.DEFAULT_CURRENCY_SYMBOL,
end_date_text=end_date_text,
dashboard_url=dashboard_url,
provider_label=provider_label,
)
email_service = EmailAuthService(self.settings)
await email_service.send_rendered_email(email=recipient, content=content)
except Exception:
logging.exception("Failed to send payment success email to user %s", db_user.user_id)
async def update_last_notification_sent(
self, session: AsyncSession, user_id: int, subscription_end_date: datetime
):
sub_to_update = await subscription_dal.find_subscription_for_notification_update(
session, user_id, subscription_end_date
)
if sub_to_update:
await subscription_dal.update_subscription_notification_time(
session, sub_to_update.subscription_id, datetime.now(timezone.utc)
)
logging.info(
f"Updated last_notification_sent for user {user_id}, sub_id {sub_to_update.subscription_id}" # noqa: E501
)
else:
logging.warning(
f"Could not find subscription for user {user_id} ending at {subscription_end_date.isoformat()} to update notification time." # noqa: E501
)
@@ -0,0 +1,67 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
class RenewalMixin:
async def charge_subscription_renewal(
self,
session: AsyncSession,
sub: Subscription,
) -> bool:
"""Attempt to charge user using saved payment method. Return True on initiated/handled, False on failure.""" # noqa: E501
if getattr(self.settings, "traffic_sale_mode", False):
logging.info("Auto-renew skipped: traffic sale mode enabled")
return True
if not sub.auto_renew_enabled:
return True
# If autopayments are disabled globally, skip charging attempts
if not self.settings.yookassa_autopayments_active:
return True
if sub.provider != "yookassa":
logging.info(
"Auto-renew skipped: provider %s does not support auto-renew", sub.provider
)
return True
from db.dal.user_billing_dal import get_user_default_payment_method
default_pm = await get_user_default_payment_method(session, sub.user_id)
if not default_pm:
logging.info(f"Auto-renew skipped: no saved payment method for user {sub.user_id}")
return False
try:
from .yookassa_service import YooKassaService # local import to avoid cycles
yk: YooKassaService = self.yookassa_service # type: ignore[attr-defined]
except Exception:
yk = None # type: ignore
if not yk or not getattr(yk, "configured", False):
logging.warning("YooKassa unavailable for auto-renew")
return False
months = sub.duration_months or 1
amount = self.settings.subscription_options.get(months)
if not amount:
logging.error(f"Auto-renew price missing for {months} months")
return False
metadata = {
"user_id": str(sub.user_id),
"auto_renew_for_subscription_id": str(sub.subscription_id),
"subscription_months": str(months),
}
resp = await yk.create_payment(
amount=float(amount),
currency="RUB",
description=f"Auto-renewal for {months} months",
metadata=metadata,
payment_method_id=default_pm.provider_payment_method_id,
save_payment_method=False,
capture=True,
)
if not resp or resp.get("status") not in {"pending", "waiting_for_capture", "succeeded"}:
logging.error(f"Auto-renew create_payment failed: {resp}")
return False
logging.info(f"Auto-renew initiated for user {sub.user_id} payment_id={resp.get('id')}")
return True
@@ -0,0 +1,369 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
class TariffMixin:
@staticmethod
def gb_to_bytes(gb: float) -> int:
return int(float(gb) * (1024**3))
@staticmethod
def _far_future() -> datetime:
return datetime(2099, 1, 1, tzinfo=timezone.utc)
def _parse_sale_mode_context(
self,
sale_mode: str,
explicit_tariff_key: Optional[str] = None,
) -> Tuple[str, Optional[str]]:
mode = (sale_mode or "subscription").strip()
tariff_key = explicit_tariff_key
for separator in ("@", "|"):
if separator in mode:
base, suffix = mode.split(separator, 1)
mode = base or mode
tariff_key = tariff_key or suffix or None
break
return mode, tariff_key
def _tariffs_config(self):
return getattr(self.settings, "tariffs_config", None)
def _default_tariff(self) -> Optional[Tariff]:
config = self._tariffs_config()
return config.default if config else None
def _resolve_tariff(
self, tariff_key: Optional[str], billing_model: Optional[str] = None
) -> Optional[Tariff]:
config = self._tariffs_config()
if not config:
return None
tariff = config.require(tariff_key or config.default_tariff)
if billing_model and tariff.billing_model != billing_model:
raise ValueError(
f"Tariff {tariff.key} is {tariff.billing_model}, expected {billing_model}"
)
return tariff
def _panel_squads_for_tariff(
self,
tariff: Optional[Tariff],
*,
include_premium: bool = True,
) -> Optional[List[str]]:
if tariff:
squads = list(tariff.squad_uuids or [])
if include_premium:
squads.extend(tariff.premium_squad_uuids or [])
return list(dict.fromkeys(squads))
return self.settings.parsed_user_squad_uuids
def _traffic_limit_for_period_tariff(
self,
tariff: Optional[Tariff],
topup_balance_bytes: int = 0,
regular_bonus_bytes: int = 0,
regular_unlimited_override: bool = False,
traffic_used_bytes: int = 0,
) -> int:
if tariff:
baseline = int(tariff.monthly_bytes or 0)
else:
baseline = int(self.settings.user_traffic_limit_bytes)
return self._compute_main_traffic_limit_bytes(
tier_baseline_bytes=baseline,
topup_balance_bytes=topup_balance_bytes,
regular_bonus_bytes=regular_bonus_bytes,
regular_unlimited_override=regular_unlimited_override,
traffic_used_bytes=traffic_used_bytes,
)
def _premium_limit_for_tariff(
self, tariff: Optional[Tariff], topup_balance_bytes: int = 0
) -> int:
if not tariff:
return 0
return int(tariff.premium_monthly_bytes + max(0, topup_balance_bytes))
@staticmethod
def _premium_effective_limit_bytes(
premium_baseline_bytes: int,
premium_topup_balance_bytes: int = 0,
premium_topup_used_bytes: int = 0,
premium_bonus_bytes: int = 0,
) -> int:
return (
int(premium_baseline_bytes or 0)
+ max(0, int(premium_topup_balance_bytes or 0))
+ max(0, int(premium_topup_used_bytes or 0))
+ max(0, int(premium_bonus_bytes or 0))
)
def _compute_main_traffic_limit_bytes(
self,
*,
tier_baseline_bytes: int,
topup_balance_bytes: int,
regular_bonus_bytes: int,
regular_unlimited_override: bool,
traffic_used_bytes: int,
) -> int:
"""Numeric cap sent to the panel; ``regular_unlimited_override`` uses a large practical ceiling.""" # noqa: E501
floor = (
int(tier_baseline_bytes or 0)
+ max(0, int(topup_balance_bytes or 0))
+ max(0, int(regular_bonus_bytes or 0))
)
if regular_unlimited_override:
used = max(0, int(traffic_used_bytes or 0))
return max(floor, used + 512 * (1024**3), 1024**5)
return floor
async def premium_access_for_tariff(self, tariff: Optional[Tariff]) -> Dict[str, Any]:
if not tariff or not tariff.premium_squad_uuids:
return {"squad_uuids": [], "squad_labels": [], "node_labels": []}
cache_key = tuple(sorted(str(uuid) for uuid in tariff.premium_squad_uuids))
now_ts = datetime.now(timezone.utc).timestamp()
cached = self._premium_access_cache.get(cache_key)
if cached and now_ts - float(cached.get("ts", 0)) < 600:
return {
"squad_uuids": list(cached.get("squad_uuids") or []),
"squad_labels": list(cached.get("squad_labels") or []),
"node_labels": list(cached.get("node_labels") or []),
}
def _extract_inbound_uuids(squad_obj: Dict[str, Any]) -> List[str]:
collected: List[str] = []
for field in ("inbounds", "internalInbounds", "configProfileInbounds"):
value = squad_obj.get(field)
if not isinstance(value, list):
continue
for inbound in value:
if isinstance(inbound, dict):
ib_uuid = str(
inbound.get("uuid")
or inbound.get("inboundUuid")
or inbound.get("id")
or ""
)
else:
ib_uuid = str(inbound or "")
if ib_uuid:
collected.append(ib_uuid)
return collected
squad_name_map: Dict[str, str] = {}
squad_inbound_map: Dict[str, List[str]] = {}
try:
squads = await self.panel_service.get_internal_squads() or []
for squad in squads:
if not isinstance(squad, dict):
continue
squad_uuid = str(squad.get("uuid") or squad.get("id") or "")
if not squad_uuid:
continue
squad_name_map[squad_uuid] = str(
squad.get("name") or squad.get("title") or squad_uuid
)
squad_inbound_map[squad_uuid] = _extract_inbound_uuids(squad)
except Exception:
logging.debug("Failed to load internal squad names for premium display", exc_info=True)
for squad_uuid in tariff.premium_squad_uuids:
squad_uuid_str = str(squad_uuid)
if squad_inbound_map.get(squad_uuid_str):
continue
try:
detail = await self.panel_service.get_internal_squad(squad_uuid_str)
except Exception:
logging.debug(
"Failed to load internal squad detail for %s", squad_uuid_str, exc_info=True
)
detail = None
if isinstance(detail, dict):
squad_inbound_map[squad_uuid_str] = _extract_inbound_uuids(detail)
if squad_uuid_str not in squad_name_map:
squad_name_map[squad_uuid_str] = str(
detail.get("name") or detail.get("title") or squad_uuid_str
)
hosts_by_inbound: Dict[str, List[Dict[str, Any]]] = {}
try:
hosts = await self.panel_service.get_hosts() or []
for host in hosts:
if not isinstance(host, dict):
continue
inbound_field = host.get("inbound") if isinstance(host.get("inbound"), dict) else {}
inbound_uuid = (
host.get("inboundUuid")
or host.get("inbound_uuid")
or host.get("configProfileInboundUuid")
or inbound_field.get("configProfileInboundUuid")
or inbound_field.get("inboundUuid")
or inbound_field.get("uuid")
or ""
)
inbound_uuid = str(inbound_uuid)
if not inbound_uuid:
continue
hosts_by_inbound.setdefault(inbound_uuid, []).append(host)
logging.debug(
"Premium label resolution: %d hosts grouped across %d inbounds; squad inbound map: %s", # noqa: E501
len(hosts),
len(hosts_by_inbound),
{k: len(v) for k, v in squad_inbound_map.items()},
)
except Exception:
logging.debug("Failed to load hosts for premium display", exc_info=True)
def _host_remark(host: Dict[str, Any]) -> str:
for key in ("remark", "name", "label", "title"):
value = host.get(key)
if value is None:
continue
candidate = str(value).strip()
if candidate:
return candidate
return ""
node_labels: List[str] = []
for squad_uuid in tariff.premium_squad_uuids:
squad_uuid_str = str(squad_uuid)
inbound_uuids = squad_inbound_map.get(squad_uuid_str) or []
host_labels_for_squad: List[str] = []
for inbound_uuid in inbound_uuids:
for host in hosts_by_inbound.get(inbound_uuid, []):
remark = _host_remark(host)
if remark:
host_labels_for_squad.append(remark)
if host_labels_for_squad:
node_labels.extend(host_labels_for_squad)
continue
try:
nodes = (
await self.panel_service.get_internal_squad_accessible_nodes(squad_uuid) or []
)
except Exception:
logging.debug(
"Failed to load accessible nodes for premium squad %s",
squad_uuid,
exc_info=True,
)
nodes = []
for node in nodes:
if not isinstance(node, dict):
continue
node_uuid = str(
node.get("uuid") or node.get("nodeUuid") or node.get("node_uuid") or ""
)
node_name = ""
for key in (
"nodeName",
"name",
"nodeRemark",
"remark",
"label",
"title",
"address",
"host",
):
value = node.get(key)
if value is None:
continue
candidate = str(value).strip()
if candidate:
node_name = candidate
break
if node_name:
label = node_name
elif node_uuid:
label = f"{node_uuid[:8]}..."
else:
continue
node_labels.append(label)
squad_labels = [
squad_name_map.get(str(uuid), f"{str(uuid)[:8]}...")
for uuid in tariff.premium_squad_uuids
]
payload = {
"ts": now_ts,
"squad_uuids": list(tariff.premium_squad_uuids),
"squad_labels": list(dict.fromkeys(squad_labels)),
"node_labels": list(dict.fromkeys(node_labels)),
}
self._premium_access_cache[cache_key] = payload
return {
"squad_uuids": list(payload["squad_uuids"]),
"squad_labels": list(payload["squad_labels"]),
"node_labels": list(payload["node_labels"]),
}
def _base_hwid_limit_for_tariff(self, tariff: Optional[Tariff]) -> Optional[int]:
if tariff and tariff.hwid_device_limit is not None:
return int(tariff.hwid_device_limit)
value = self.settings.USER_HWID_DEVICE_LIMIT
return int(value) if value is not None else None
@staticmethod
def _effective_hwid_limit(base_limit: Optional[int], extra_devices: int = 0) -> Optional[int]:
if base_limit is None:
return None
base_int = max(0, int(base_limit))
if base_int == 0:
return 0
return base_int + max(0, int(extra_devices or 0))
def calculate_tariff_switch_options(
self, sub: Subscription, target_tariff: Tariff
) -> Dict[str, Any]:
current_tariff = (
self._resolve_tariff(sub.tariff_key) if sub.tariff_key else self._default_tariff()
)
now = datetime.now(timezone.utc)
remaining_days = max(0, (sub.end_date - now).days) if sub.end_date else 0
effective = float(sub.effective_monthly_price_rub or 0)
current_model = current_tariff.billing_model if current_tariff else "period"
if current_model == "period" and target_tariff.billing_model == "period":
target_monthly = (
target_tariff.period_price(1, "rub")
or target_tariff.min_period_price_rub()
or effective
or 1
)
remaining_value = remaining_days * (effective / 30) if effective else 0
days_after = (
math.floor((remaining_value / float(target_monthly)) * 30)
if target_monthly
else remaining_days
)
paid_diff = (
max(0, math.ceil((float(target_monthly) - effective) * remaining_days / 30))
if effective
else 0
)
return {
"mode": "period_to_period",
"remaining_days": remaining_days,
"recalc_days": max(0, days_after),
"paid_diff_rub": paid_diff,
"target_monthly_rub": float(target_monthly),
}
if current_model == "period" and target_tariff.billing_model == "traffic":
rub_per_gb = target_tariff.rub_per_gb_for_conversion()
remaining_value = remaining_days * (effective / 30) if effective else 0
converted_gb = math.floor(remaining_value / rub_per_gb) if rub_per_gb else 0
return {
"mode": "period_to_traffic",
"remaining_days": remaining_days,
"converted_gb": max(0, converted_gb),
"rub_per_gb": rub_per_gb,
}
return {"mode": "traffic_to_period", "remaining_days": remaining_days}
@@ -0,0 +1,693 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
class TrafficMixin:
async def _activate_traffic_package(
self,
session: AsyncSession,
user_id: int,
traffic_gb: float,
payment_amount: float,
payment_db_id: int,
provider: str = "yookassa",
tariff_key: Optional[str] = None,
sale_mode: str = "traffic",
) -> Optional[Dict[str, Any]]:
"""Activate or extend a traffic-based package instead of a time-based subscription."""
tariff = self._resolve_tariff(tariff_key, "traffic") if self._tariffs_config() else None
await self._record_payment_context(
session,
payment_db_id,
sale_mode=sale_mode,
tariff_key=tariff.key if tariff else tariff_key,
purchased_gb=float(traffic_gb),
)
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
logging.error("User %s not found for traffic package activation", user_id)
return None
(
panel_user_uuid,
panel_sub_link_id,
panel_short_uuid,
_,
) = await self._get_or_create_panel_user_link_details(session, user_id, db_user)
if not panel_user_uuid or not panel_sub_link_id:
logging.error(
"Failed to ensure panel linkage for user %s during traffic activation", user_id
)
return None
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) or {}
current_used, current_limit, _ = self._extract_panel_traffic_details(panel_user_data)
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_user_uuid
)
if current_limit is None and active_sub:
current_limit = active_sub.traffic_limit_bytes
if current_used is None and active_sub:
current_used = active_sub.traffic_used_bytes
purchase_bytes = self.gb_to_bytes(traffic_gb)
extra_hwid_devices = int(getattr(active_sub, "extra_hwid_devices", 0) or 0)
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
remaining_bytes = max(0, int(current_limit or 0) - int(current_used or 0))
new_balance = remaining_bytes + purchase_bytes
new_limit = int(current_used or 0) + new_balance
start_date = datetime.now(timezone.utc)
# Set a far-future expiry to satisfy panel requirements; keep the latest known expiry if it's further. # noqa: E501
far_future = self._far_future()
final_end_date = far_future
if active_sub and active_sub.end_date and active_sub.end_date > final_end_date:
final_end_date = active_sub.end_date
await subscription_dal.deactivate_other_active_subscriptions(
session, panel_user_uuid, panel_sub_link_id
)
sub_payload = {
"user_id": user_id,
"panel_user_uuid": panel_user_uuid,
"panel_subscription_uuid": panel_sub_link_id,
"start_date": start_date,
"end_date": final_end_date,
"duration_months": 0,
"is_active": True,
"status_from_panel": "ACTIVE",
"traffic_limit_bytes": new_limit,
"traffic_used_bytes": current_used,
"provider": provider,
"skip_notifications": True,
"auto_renew_enabled": False,
"tariff_key": tariff.key if tariff else None,
"tier_baseline_bytes": 0,
"topup_balance_bytes": new_balance,
"premium_baseline_bytes": self._premium_limit_for_tariff(tariff, 0),
"premium_topup_balance_bytes": 0,
"premium_topup_used_bytes": 0,
"premium_used_bytes": 0,
"premium_is_limited": False,
"premium_period_start_at": None,
"period_start_at": None,
"is_throttled": False,
"effective_monthly_price_rub": None,
"hwid_device_limit": base_hwid_limit,
"extra_hwid_devices": extra_hwid_devices,
}
try:
new_or_updated_sub = await subscription_dal.upsert_subscription(session, sub_payload)
except Exception as exc:
logging.error(
"Failed to upsert traffic subscription for user %s: %s", user_id, exc, exc_info=True
)
return None
panel_update_payload = self._build_panel_update_payload(
panel_user_uuid=panel_user_uuid,
expire_at=final_end_date,
status="ACTIVE",
traffic_limit_bytes=new_limit,
traffic_limit_strategy="NO_RESET",
hwid_device_limit=effective_hwid_limit,
)
if tariff:
panel_update_payload["activeInternalSquads"] = self._panel_squads_for_tariff(tariff)
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload
)
if not updated_panel_user or updated_panel_user.get("error"):
logging.warning(
"Panel user details update FAILED for traffic package user %s. Response: %s",
panel_user_uuid,
updated_panel_user,
)
return None
final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
await tariff_dal.create_traffic_topup(
session,
subscription_id=new_or_updated_sub.subscription_id,
payment_id=payment_db_id,
purchased_bytes=purchase_bytes,
kind="traffic_package",
)
await self._send_payment_success_email(
db_user=db_user,
sale_mode="traffic",
months=0,
traffic_gb=float(traffic_gb),
payment_amount=payment_amount,
end_date=None,
provider=provider,
)
return {
"subscription_id": new_or_updated_sub.subscription_id,
"end_date": final_end_date,
"is_active": True,
"panel_user_uuid": panel_user_uuid,
"panel_short_uuid": final_panel_short_uuid,
"subscription_url": final_subscription_url,
"applied_promo_bonus_days": 0,
"traffic_limit_bytes": new_limit,
"tariff_key": tariff.key if tariff else None,
}
async def activate_topup(
self,
session: AsyncSession,
user_id: int,
tariff_key: str,
traffic_gb: float,
payment_amount: float,
payment_db_id: int,
provider: str = "yookassa",
) -> Optional[Dict[str, Any]]:
tariff = self._resolve_tariff(tariff_key)
if tariff.billing_model == "traffic":
return await self._activate_traffic_package(
session=session,
user_id=user_id,
traffic_gb=traffic_gb,
payment_amount=payment_amount,
payment_db_id=payment_db_id,
provider=provider,
tariff_key=tariff.key,
sale_mode="traffic_package",
)
await self._record_payment_context(
session,
payment_db_id,
sale_mode="topup",
tariff_key=tariff.key,
purchased_gb=float(traffic_gb),
)
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or not db_user.panel_user_uuid:
return None
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub:
return None
purchase_bytes = self.gb_to_bytes(traffic_gb)
new_topup_balance = int(sub.topup_balance_bytes or 0) + purchase_bytes
baseline = int(sub.tier_baseline_bytes or tariff.monthly_bytes)
rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
runl = bool(getattr(sub, "regular_unlimited_override", False))
used_for_lim = int(getattr(sub, "traffic_used_bytes", 0) or 0)
new_limit = self._compute_main_traffic_limit_bytes(
tier_baseline_bytes=baseline,
topup_balance_bytes=new_topup_balance,
regular_bonus_bytes=rb,
regular_unlimited_override=runl,
traffic_used_bytes=used_for_lim,
)
base_hwid_limit = (
int(sub.hwid_device_limit)
if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
effective_hwid_limit = self._effective_hwid_limit(
base_hwid_limit,
int(sub.extra_hwid_devices or 0),
)
updated_sub = await subscription_dal.update_subscription(
session,
sub.subscription_id,
{
"topup_balance_bytes": new_topup_balance,
"traffic_limit_bytes": new_limit,
"is_throttled": False,
"tariff_key": tariff.key,
"hwid_device_limit": base_hwid_limit,
},
)
panel_payload = self._build_panel_update_payload(
panel_user_uuid=db_user.panel_user_uuid,
expire_at=updated_sub.end_date,
status="ACTIVE",
traffic_limit_bytes=new_limit,
hwid_device_limit=effective_hwid_limit,
)
panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff(
tariff,
include_premium=not bool(getattr(updated_sub, "premium_is_limited", False)),
)
panel_payload.update(self._panel_identity_payload_for_user(db_user))
await self.panel_service.update_user_details_on_panel(
db_user.panel_user_uuid, panel_payload
)
await tariff_dal.create_traffic_topup(
session,
subscription_id=sub.subscription_id,
payment_id=payment_db_id,
purchased_bytes=purchase_bytes,
kind="topup",
)
return {
"subscription_id": sub.subscription_id,
"traffic_limit_bytes": new_limit,
"topup_balance_bytes": new_topup_balance,
"tariff_key": tariff.key,
}
async def activate_premium_topup(
self,
session: AsyncSession,
user_id: int,
tariff_key: str,
traffic_gb: float,
payment_amount: float,
payment_db_id: int,
provider: str = "yookassa",
) -> Optional[Dict[str, Any]]:
tariff = self._resolve_tariff(tariff_key)
if not tariff or not tariff.premium_squad_uuids:
logging.error(
"Premium top-up requires a tariff with premium squads for user %s", user_id
)
return None
await self._record_payment_context(
session,
payment_db_id,
sale_mode="premium_topup",
tariff_key=tariff.key,
purchased_gb=float(traffic_gb),
)
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or not db_user.panel_user_uuid:
return None
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub:
return None
purchase_bytes = self.gb_to_bytes(traffic_gb)
now = datetime.now(timezone.utc)
premium_period_start = month_start(now)
current_period_start = getattr(sub, "premium_period_start_at", None)
same_period = bool(current_period_start and current_period_start == premium_period_start)
previous_topup_used = int(sub.premium_topup_used_bytes or 0) if same_period else 0
premium_used = int(sub.premium_used_bytes or 0) if same_period else 0
premium_baseline = int(tariff.premium_monthly_bytes or sub.premium_baseline_bytes or 0)
premium_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) or 0))
premium_topup_balance = int(sub.premium_topup_balance_bytes or 0) + purchase_bytes
overflow_to_cover = max(
0, premium_used - premium_baseline - previous_topup_used - premium_bonus
)
consume_now = min(premium_topup_balance, overflow_to_cover)
premium_topup_balance -= consume_now
premium_topup_used = previous_topup_used + consume_now
premium_limit = self._premium_effective_limit_bytes(
premium_baseline,
premium_topup_balance,
premium_topup_used,
premium_bonus,
)
premium_unlimited = bool(getattr(sub, "premium_unlimited_override", False))
premium_is_limited = (
not premium_unlimited and premium_limit > 0 and premium_used >= premium_limit
)
await subscription_dal.update_subscription(
session,
sub.subscription_id,
{
"premium_baseline_bytes": premium_baseline,
"premium_topup_balance_bytes": premium_topup_balance,
"premium_topup_used_bytes": premium_topup_used,
"premium_used_bytes": premium_used,
"premium_is_limited": premium_is_limited,
"premium_period_start_at": premium_period_start,
"tariff_key": tariff.key,
},
)
panel_payload = {
"uuid": db_user.panel_user_uuid,
"activeInternalSquads": self._panel_squads_for_tariff(
tariff,
include_premium=not premium_is_limited,
),
}
await self.panel_service.update_user_details_on_panel(
db_user.panel_user_uuid, panel_payload
)
await tariff_dal.create_traffic_topup(
session,
subscription_id=sub.subscription_id,
payment_id=payment_db_id,
purchased_bytes=purchase_bytes,
kind="premium_topup",
)
return {
"subscription_id": sub.subscription_id,
"premium_limit_bytes": premium_limit,
"premium_topup_balance_bytes": premium_topup_balance,
"premium_topup_used_bytes": premium_topup_used,
"premium_is_limited": premium_is_limited,
"tariff_key": tariff.key,
}
async def sync_premium_squad_access_to_panel(
self,
session: AsyncSession,
user_id: int,
) -> None:
"""Recompute premium quota flags from DB and push internal squads to Remnawave.
Used when admin overrides change without going through the traffic worker
(Telegram/Web admin premium bonus / unlimited).
"""
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or not db_user.panel_user_uuid:
return
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub:
return
tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None
if not tariff or not getattr(tariff, "premium_squad_uuids", None):
return
premium_baseline = int(tariff.premium_monthly_bytes or sub.premium_baseline_bytes or 0)
premium_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) or 0))
premium_topup_balance = int(sub.premium_topup_balance_bytes or 0)
premium_topup_used = int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
premium_used = int(sub.premium_used_bytes or 0)
premium_limit = self._premium_effective_limit_bytes(
premium_baseline,
premium_topup_balance,
premium_topup_used,
premium_bonus,
)
premium_unlimited = bool(getattr(sub, "premium_unlimited_override", False))
premium_is_limited = (
not premium_unlimited and premium_limit > 0 and premium_used >= premium_limit
)
if bool(getattr(sub, "premium_is_limited", False)) != premium_is_limited:
await subscription_dal.update_subscription(
session,
sub.subscription_id,
{"premium_is_limited": premium_is_limited},
)
squads = self._panel_squads_for_tariff(tariff, include_premium=not premium_is_limited)
try:
await self.panel_service.update_user_details_on_panel(
db_user.panel_user_uuid,
{"uuid": db_user.panel_user_uuid, "activeInternalSquads": squads},
log_response=False,
)
except Exception:
logging.exception(
"sync_premium_squad_access_to_panel: failed to push squads for user %s", user_id
)
async def admin_grant_topup(
self,
session: AsyncSession,
user_id: int,
traffic_gb: float,
) -> Optional[Dict[str, Any]]:
"""Credit regular traffic to a user as if they purchased a top-up.
Mirrors :meth:`activate_topup` but skips payment context and tariff
resolution: the grant simply increases ``topup_balance_bytes`` and
recomputes ``traffic_limit_bytes`` from the subscription's current
tier baseline. The audit row in ``traffic_topups`` is stored with
``kind="admin_topup"`` and ``payment_id=NULL`` so reports stay clean.
"""
try:
gb_value = float(traffic_gb)
except (TypeError, ValueError):
logging.error("admin_grant_topup: invalid traffic_gb=%r", traffic_gb)
return None
if gb_value <= 0:
return None
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or not db_user.panel_user_uuid:
return None
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub:
return None
tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None
purchase_bytes = self.gb_to_bytes(gb_value)
baseline_bytes = int(
sub.tier_baseline_bytes or (tariff.monthly_bytes if tariff else 0) or 0
)
new_topup_balance = int(sub.topup_balance_bytes or 0) + purchase_bytes
rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
runl = bool(getattr(sub, "regular_unlimited_override", False))
used_for_lim = int(getattr(sub, "traffic_used_bytes", 0) or 0)
new_limit = self._compute_main_traffic_limit_bytes(
tier_baseline_bytes=baseline_bytes,
topup_balance_bytes=new_topup_balance,
regular_bonus_bytes=rb,
regular_unlimited_override=runl,
traffic_used_bytes=used_for_lim,
)
base_hwid_limit = (
int(sub.hwid_device_limit)
if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
effective_hwid_limit = self._effective_hwid_limit(
base_hwid_limit,
int(sub.extra_hwid_devices or 0),
)
updated_sub = await subscription_dal.update_subscription(
session,
sub.subscription_id,
{
"topup_balance_bytes": new_topup_balance,
"traffic_limit_bytes": new_limit,
"is_throttled": False,
"hwid_device_limit": base_hwid_limit,
},
)
panel_payload = self._build_panel_update_payload(
panel_user_uuid=db_user.panel_user_uuid,
expire_at=updated_sub.end_date,
status="ACTIVE",
traffic_limit_bytes=new_limit,
hwid_device_limit=effective_hwid_limit,
)
if tariff is not None:
panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff(
tariff,
include_premium=not bool(getattr(updated_sub, "premium_is_limited", False)),
)
panel_payload.update(self._panel_identity_payload_for_user(db_user))
try:
await self.panel_service.update_user_details_on_panel(
db_user.panel_user_uuid, panel_payload
)
except Exception:
logging.exception("admin_grant_topup: failed to push panel update for user %s", user_id)
await tariff_dal.create_traffic_topup(
session,
subscription_id=sub.subscription_id,
payment_id=None,
purchased_bytes=purchase_bytes,
kind="admin_topup",
)
return {
"subscription_id": sub.subscription_id,
"traffic_limit_bytes": new_limit,
"topup_balance_bytes": new_topup_balance,
"granted_bytes": purchase_bytes,
}
async def sync_main_traffic_limit_to_panel(
self,
session: AsyncSession,
user_id: int,
) -> None:
"""Recompute main traffic limit from tier + topups + regular_bonus_bytes and push to panel.""" # noqa: E501
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or not db_user.panel_user_uuid:
return
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub:
return
tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None
baseline = int(sub.tier_baseline_bytes or (tariff.monthly_bytes if tariff else 0) or 0)
rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
runl = bool(getattr(sub, "regular_unlimited_override", False))
used_now = int(getattr(sub, "traffic_used_bytes", 0) or 0)
new_limit = self._compute_main_traffic_limit_bytes(
tier_baseline_bytes=baseline,
topup_balance_bytes=int(sub.topup_balance_bytes or 0),
regular_bonus_bytes=rb,
regular_unlimited_override=runl,
traffic_used_bytes=used_now,
)
sub.traffic_limit_bytes = new_limit
if runl:
sub.is_throttled = False
base_hwid_limit = (
int(sub.hwid_device_limit)
if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
effective_hwid_limit = self._effective_hwid_limit(
base_hwid_limit,
int(sub.extra_hwid_devices or 0),
)
panel_payload = self._build_panel_update_payload(
panel_user_uuid=db_user.panel_user_uuid,
expire_at=sub.end_date,
status="ACTIVE",
traffic_limit_bytes=new_limit,
hwid_device_limit=effective_hwid_limit,
)
if tariff is not None:
panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff(
tariff,
include_premium=not bool(getattr(sub, "premium_is_limited", False)),
)
panel_payload.update(self._panel_identity_payload_for_user(db_user))
try:
await self.panel_service.update_user_details_on_panel(
db_user.panel_user_uuid, panel_payload
)
except Exception:
logging.exception("sync_main_traffic_limit_to_panel failed for user %s", user_id)
async def admin_grant_premium_topup(
self,
session: AsyncSession,
user_id: int,
traffic_gb: float,
) -> Optional[Dict[str, Any]]:
"""Credit premium-squad traffic to a user as if they purchased a premium top-up.
Mirrors :meth:`activate_premium_topup` but skips payment context.
Requires the user's current tariff to expose premium squads. The
balance is absorbed into ``premium_topup_balance_bytes`` (backfilling
any current overuse first), ``premium_is_limited`` is recomputed and,
if access becomes available again, the premium squads are returned to
the user on the panel. The audit row in ``traffic_topups`` is stored
with ``kind="admin_premium_topup"`` and ``payment_id=NULL``.
"""
try:
gb_value = float(traffic_gb)
except (TypeError, ValueError):
logging.error("admin_grant_premium_topup: invalid traffic_gb=%r", traffic_gb)
return None
if gb_value <= 0:
return None
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or not db_user.panel_user_uuid:
return None
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub:
return None
tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None
if not tariff or not tariff.premium_squad_uuids:
logging.error(
"admin_grant_premium_topup: tariff %s has no premium squads (user %s)",
getattr(tariff, "key", None),
user_id,
)
return None
purchase_bytes = self.gb_to_bytes(gb_value)
now = datetime.now(timezone.utc)
premium_period_start = month_start(now)
current_period_start = getattr(sub, "premium_period_start_at", None)
same_period = bool(current_period_start and current_period_start == premium_period_start)
previous_topup_used = int(sub.premium_topup_used_bytes or 0) if same_period else 0
premium_used = int(sub.premium_used_bytes or 0) if same_period else 0
premium_baseline = int(tariff.premium_monthly_bytes or sub.premium_baseline_bytes or 0)
premium_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) or 0))
premium_topup_balance = int(sub.premium_topup_balance_bytes or 0) + purchase_bytes
overflow_to_cover = max(
0, premium_used - premium_baseline - previous_topup_used - premium_bonus
)
consume_now = min(premium_topup_balance, overflow_to_cover)
premium_topup_balance -= consume_now
premium_topup_used = previous_topup_used + consume_now
premium_limit = self._premium_effective_limit_bytes(
premium_baseline,
premium_topup_balance,
premium_topup_used,
premium_bonus,
)
premium_unlimited = bool(getattr(sub, "premium_unlimited_override", False))
premium_is_limited = (
not premium_unlimited and premium_limit > 0 and premium_used >= premium_limit
)
await subscription_dal.update_subscription(
session,
sub.subscription_id,
{
"premium_baseline_bytes": premium_baseline,
"premium_topup_balance_bytes": premium_topup_balance,
"premium_topup_used_bytes": premium_topup_used,
"premium_used_bytes": premium_used,
"premium_is_limited": premium_is_limited,
"premium_period_start_at": premium_period_start,
},
)
panel_payload = {
"uuid": db_user.panel_user_uuid,
"activeInternalSquads": self._panel_squads_for_tariff(
tariff,
include_premium=not premium_is_limited,
),
}
try:
await self.panel_service.update_user_details_on_panel(
db_user.panel_user_uuid, panel_payload
)
except Exception:
logging.exception(
"admin_grant_premium_topup: failed to push panel update for user %s",
user_id,
)
await tariff_dal.create_traffic_topup(
session,
subscription_id=sub.subscription_id,
payment_id=None,
purchased_bytes=purchase_bytes,
kind="admin_premium_topup",
)
return {
"subscription_id": sub.subscription_id,
"premium_limit_bytes": premium_limit,
"premium_topup_balance_bytes": premium_topup_balance,
"premium_topup_used_bytes": premium_topup_used,
"premium_is_limited": premium_is_limited,
"granted_bytes": purchase_bytes,
}
@@ -0,0 +1,119 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
class TrialSubscriptionMixin:
async def activate_trial_subscription(
self, session: AsyncSession, user_id: int
) -> Optional[Dict[str, Any]]:
if not self.settings.TRIAL_ENABLED or self.settings.TRIAL_DURATION_DAYS <= 0:
return {
"eligible": False,
"activated": False,
"message_key": "trial_feature_disabled",
}
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
logging.error(f"User {user_id} not found in DB, cannot activate trial.")
return {
"eligible": False,
"activated": False,
"message_key": "user_not_found_for_trial",
}
if await self.has_had_any_subscription(session, user_id):
return {
"eligible": False,
"activated": False,
"message_key": "trial_already_had_subscription_or_trial",
}
(
panel_user_uuid,
panel_sub_link_id,
panel_short_uuid,
panel_user_created_now,
) = await self._get_or_create_panel_user_link_details(session, user_id, db_user)
if not panel_user_uuid or not panel_sub_link_id:
logging.error(f"Failed to get panel link details for trial user {user_id}.")
return {
"eligible": True,
"activated": False,
"message_key": "trial_activation_failed_panel_link",
}
start_date = datetime.now(timezone.utc)
end_date = start_date + timedelta(days=self.settings.TRIAL_DURATION_DAYS)
await subscription_dal.deactivate_other_active_subscriptions(
session, panel_user_uuid, panel_sub_link_id
)
trial_sub_data = {
"user_id": user_id,
"panel_user_uuid": panel_user_uuid,
"panel_subscription_uuid": panel_sub_link_id,
"start_date": start_date,
"end_date": end_date,
"duration_months": 0,
"is_active": True,
"status_from_panel": "TRIAL",
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
"traffic_limit_strategy": self.settings.TRIAL_TRAFFIC_STRATEGY,
"auto_renew_enabled": False,
}
try:
await subscription_dal.upsert_subscription(session, trial_sub_data)
except Exception as e_upsert:
logging.error(
f"Failed to upsert trial subscription for user {user_id}: {e_upsert}",
exc_info=True,
)
await session.rollback()
return {
"eligible": True,
"activated": False,
"message_key": "trial_activation_failed_db",
}
panel_update_payload = self._build_panel_update_payload(
panel_user_uuid=panel_user_uuid,
expire_at=end_date,
status="ACTIVE",
traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
traffic_limit_strategy=self.settings.TRIAL_TRAFFIC_STRATEGY,
)
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload
)
if not updated_panel_user or updated_panel_user.get("error"):
logging.warning(
f"Panel user details update FAILED for trial user {panel_user_uuid}. Response: {updated_panel_user}" # noqa: E501
)
await session.rollback()
return {
"eligible": True,
"activated": False,
"message_key": "trial_activation_failed_panel_update",
}
await session.commit()
final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
return {
"eligible": True,
"activated": True,
"end_date": end_date,
"days": self.settings.TRIAL_DURATION_DAYS,
"traffic_gb": self.settings.TRIAL_TRAFFIC_LIMIT_GB,
"panel_user_uuid": panel_user_uuid,
"panel_short_uuid": final_panel_short_uuid,
"subscription_url": final_subscription_url,
}
+1 -1
View File
@@ -445,7 +445,7 @@ class TariffTrafficWorker:
ratio = used_val / limit_val ratio = used_val / limit_val
levels = list(getattr(self.settings, "tariff_traffic_warning_levels", [85, 90, 95])) levels = list(getattr(self.settings, "tariff_traffic_warning_levels", [85, 90, 95]))
# Fully exhausted or over quota — one message per period (same idea as regular traffic at 100%). # Fully exhausted or over quota — one message per period (same idea as regular traffic at 100%). # noqa: E501
if ratio >= 1.0: if ratio >= 1.0:
depleted_existing = await tariff_dal.get_warning( depleted_existing = await tariff_dal.get_warning(
session, session,
+7 -7
View File
@@ -25,7 +25,7 @@ class YooKassaService:
if self.settings and not self.settings.YOOKASSA_ENABLED: if self.settings and not self.settings.YOOKASSA_ENABLED:
logging.warning( logging.warning(
"YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED." "YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED." # noqa: E501
) )
self.configured = False self.configured = False
elif not shop_id or not secret_key: elif not shop_id or not secret_key:
@@ -48,7 +48,7 @@ class YooKassaService:
elif bot_username_for_default_return: elif bot_username_for_default_return:
self.return_url = f"https://t.me/{bot_username_for_default_return}" self.return_url = f"https://t.me/{bot_username_for_default_return}"
logging.info( logging.info(
f"YOOKASSA_RETURN_URL not set, using dynamic default based on bot username: {self.return_url}" f"YOOKASSA_RETURN_URL not set, using dynamic default based on bot username: {self.return_url}" # noqa: E501
) )
else: else:
self.return_url = "https://example.com/payment_error_no_return_url_configured" self.return_url = "https://example.com/payment_error_no_return_url_configured"
@@ -77,7 +77,7 @@ class YooKassaService:
if not self.settings: if not self.settings:
logging.error( logging.error(
"YooKassaService: Settings object not available. Cannot create payment with receipt details." "YooKassaService: Settings object not available. Cannot create payment with receipt details." # noqa: E501
) )
return { return {
"error": True, "error": True,
@@ -93,11 +93,11 @@ class YooKassaService:
customer_contact_for_receipt["email"] = self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL customer_contact_for_receipt["email"] = self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
else: else:
logging.error( logging.error(
"CRITICAL: No email/phone for YooKassa receipt provided and YOOKASSA_DEFAULT_RECEIPT_EMAIL is not set." "CRITICAL: No email/phone for YooKassa receipt provided and YOOKASSA_DEFAULT_RECEIPT_EMAIL is not set." # noqa: E501
) )
return { return {
"error": True, "error": True,
"internal_message": "YooKassa receipt customer contact (email/phone) missing and no default email configured.", "internal_message": "YooKassa receipt customer contact (email/phone) missing and no default email configured.", # noqa: E501
} }
try: try:
@@ -163,7 +163,7 @@ class YooKassaService:
) )
logging.info( logging.info(
f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}" f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}" # noqa: E501
) )
return { return {
@@ -203,7 +203,7 @@ class YooKassaService:
if payment_info_yk: if payment_info_yk:
logging.info( logging.info(
f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}" f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}" # noqa: E501
) )
pm = getattr(payment_info_yk, "payment_method", None) pm = getattr(payment_info_yk, "payment_method", None)
pm_payload: Dict[str, Any] = {} pm_payload: Dict[str, Any] = {}
+1 -1
View File
@@ -312,7 +312,7 @@ async def send_direct_message(
Отправляет прямое сообщение с дополнительной обработкой для sticker и video_note. Отправляет прямое сообщение с дополнительной обработкой для sticker и video_note.
Для этих типов медиа отправляется отдельное текстовое сообщение, т.к. они не поддерживают caption. Для этих типов медиа отправляется отдельное текстовое сообщение, т.к. они не поддерживают caption.
Автоматически фильтрует неподдерживаемые параметры. Автоматически фильтрует неподдерживаемые параметры.
""" """ # noqa: E501
match content.content_type: match content.content_type:
case "sticker": case "sticker":
# Отправляем стикер с отфильтрованными параметрами # Отправляем стикер с отфильтрованными параметрами
+1 -1
View File
@@ -25,7 +25,7 @@ def append_query_params(base_url: str, params: dict[str, str]) -> str:
def subscription_mini_app_topup_url(settings: Settings, kind: str) -> Optional[str]: def subscription_mini_app_topup_url(settings: Settings, kind: str) -> Optional[str]:
"""Return Mini App URL that opens the traffic top-up flow for ``kind`` (``regular`` or ``premium``).""" """Return Mini App URL that opens the traffic top-up flow for ``kind`` (``regular`` or ``premium``).""" # noqa: E501
base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None) or "").strip() base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None) or "").strip()
if not base: if not base:
return None return None
+27 -27
View File
@@ -138,11 +138,11 @@ class Settings(BaseSettings):
# Deprecated: explicit receipt fields are now derived from YOOKASSA_AUTOPAYMENTS_ENABLED # Deprecated: explicit receipt fields are now derived from YOOKASSA_AUTOPAYMENTS_ENABLED
YOOKASSA_PAYMENT_MODE: str = Field(default="full_prepayment") YOOKASSA_PAYMENT_MODE: str = Field(default="full_prepayment")
YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service") YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service")
# Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew) # Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew) # noqa: E501
YOOKASSA_AUTOPAYMENTS_ENABLED: bool = Field(default=False) YOOKASSA_AUTOPAYMENTS_ENABLED: bool = Field(default=False)
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING: bool = Field( YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING: bool = Field(
default=True, default=True,
description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox.", description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox.", # noqa: E501
) )
LKNPD_INN: Optional[str] = Field( LKNPD_INN: Optional[str] = Field(
@@ -163,18 +163,18 @@ class Settings(BaseSettings):
LKNPD_RECEIPT_NAME_SUBSCRIPTION: str = Field( LKNPD_RECEIPT_NAME_SUBSCRIPTION: str = Field(
default="subscription {months} months", default="subscription {months} months",
alias="NALOGO_RECEIPT_NAME_SUBSCRIPTION", alias="NALOGO_RECEIPT_NAME_SUBSCRIPTION",
description="Receipt item name for time-based subscriptions. Use {months} placeholder for duration.", description="Receipt item name for time-based subscriptions. Use {months} placeholder for duration.", # noqa: E501
) )
LKNPD_RECEIPT_NAME_TRAFFIC: str = Field( LKNPD_RECEIPT_NAME_TRAFFIC: str = Field(
default="traffic package {gb} GB", default="traffic package {gb} GB",
alias="NALOGO_RECEIPT_NAME_TRAFFIC", alias="NALOGO_RECEIPT_NAME_TRAFFIC",
description="Receipt item name for traffic packages. Use {gb} placeholder for traffic amount.", description="Receipt item name for traffic packages. Use {gb} placeholder for traffic amount.", # noqa: E501
) )
WEBHOOK_BASE_URL: Optional[str] = None WEBHOOK_BASE_URL: Optional[str] = None
TRUSTED_PROXIES: Optional[str] = Field( TRUSTED_PROXIES: Optional[str] = Field(
default="127.0.0.1,::1", default="127.0.0.1,::1",
description="Comma-separated list of reverse proxy IPs or CIDRs trusted to forward X-Forwarded-For.", description="Comma-separated list of reverse proxy IPs or CIDRs trusted to forward X-Forwarded-For.", # noqa: E501
) )
CRYPTOPAY_TOKEN: Optional[str] = None CRYPTOPAY_TOKEN: Optional[str] = None
@@ -188,7 +188,7 @@ class Settings(BaseSettings):
PLATEGA_SECRET: Optional[str] = None PLATEGA_SECRET: Optional[str] = None
PLATEGA_PAYMENT_METHOD: int = Field( PLATEGA_PAYMENT_METHOD: int = Field(
default=2, default=2,
description="Legacy Platega payment method ID. Used as fallback for PLATEGA_SBP_METHOD when the new field is unset.", description="Legacy Platega payment method ID. Used as fallback for PLATEGA_SBP_METHOD when the new field is unset.", # noqa: E501
) )
PLATEGA_SBP_ENABLED: bool = Field( PLATEGA_SBP_ENABLED: bool = Field(
default=False, default=False,
@@ -236,7 +236,7 @@ class Settings(BaseSettings):
STARS_ENABLED: bool = Field(default=True) STARS_ENABLED: bool = Field(default=True)
PAYMENT_METHODS_ORDER: Optional[str] = Field( PAYMENT_METHODS_ORDER: Optional[str] = Field(
default=None, default=None,
description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay)", description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay)", # noqa: E501
) )
MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED") MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
@@ -257,16 +257,16 @@ class Settings(BaseSettings):
TRAFFIC_PACKAGES: Optional[str] = Field( TRAFFIC_PACKAGES: Optional[str] = Field(
default=None, default=None,
description="Comma-separated list of traffic packages in the format '<GB>:<price>', e.g. '10:199,50:799'", description="Comma-separated list of traffic packages in the format '<GB>:<price>', e.g. '10:199,50:799'", # noqa: E501
) )
STARS_TRAFFIC_PACKAGES: Optional[str] = Field( STARS_TRAFFIC_PACKAGES: Optional[str] = Field(
default=None, default=None,
description="Comma-separated list of traffic packages priced in Stars, e.g. '5:500,20:1500'", description="Comma-separated list of traffic packages priced in Stars, e.g. '5:500,20:1500'", # noqa: E501
) )
TARIFFS_CONFIG_PATH: str = Field(default="data/tariffs.json") TARIFFS_CONFIG_PATH: str = Field(default="data/tariffs.json")
TARIFF_TRAFFIC_WARNING_LEVELS: str = Field( TARIFF_TRAFFIC_WARNING_LEVELS: str = Field(
default="85,90,95", default="85,90,95",
description="Comma-separated traffic usage warning levels for tariff traffic limits, e.g. '85,90,95'", description="Comma-separated traffic usage warning levels for tariff traffic limits, e.g. '85,90,95'", # noqa: E501
) )
SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True) SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
@@ -303,15 +303,15 @@ class Settings(BaseSettings):
# Referral program configuration # Referral program configuration
REFERRAL_ONE_BONUS_PER_REFEREE: bool = Field( REFERRAL_ONE_BONUS_PER_REFEREE: bool = Field(
default=True, default=True,
description="When true, referral bonuses (for inviter and referee) are applied only once per invited user - on their first successful payment.", description="When true, referral bonuses (for inviter and referee) are applied only once per invited user - on their first successful payment.", # noqa: E501
) )
REFERRAL_WELCOME_BONUS_DAYS: int = Field( REFERRAL_WELCOME_BONUS_DAYS: int = Field(
default=3, default=3,
description="Welcome bonus days granted to a newly registered user who joined via referral link.", description="Welcome bonus days granted to a newly registered user who joined via referral link.", # noqa: E501
) )
LEGACY_REFS: bool = Field( LEGACY_REFS: bool = Field(
default=True, default=True,
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset.", description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset.", # noqa: E501
) )
PANEL_API_URL: Optional[str] = None PANEL_API_URL: Optional[str] = None
@@ -367,15 +367,15 @@ class Settings(BaseSettings):
WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60) WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60)
TELEGRAM_OAUTH_CLIENT_ID: Optional[int] = Field( TELEGRAM_OAUTH_CLIENT_ID: Optional[int] = Field(
default=None, default=None,
description="Telegram Web Login Client ID from BotFather. Defaults to the numeric bot ID from BOT_TOKEN.", description="Telegram Web Login Client ID from BotFather. Defaults to the numeric bot ID from BOT_TOKEN.", # noqa: E501
) )
TELEGRAM_OAUTH_CLIENT_SECRET: Optional[str] = Field( TELEGRAM_OAUTH_CLIENT_SECRET: Optional[str] = Field(
default=None, default=None,
description="Telegram Web Login Client Secret from BotFather. Reserved for full OIDC authorization code integrations.", description="Telegram Web Login Client Secret from BotFather. Reserved for full OIDC authorization code integrations.", # noqa: E501
) )
TELEGRAM_OAUTH_REQUEST_ACCESS: Optional[str] = Field( TELEGRAM_OAUTH_REQUEST_ACCESS: Optional[str] = Field(
default="write", default="write",
description="Comma-separated Telegram Login permissions to request: write,phone. Leave empty to request only OpenID profile.", description="Comma-separated Telegram Login permissions to request: write,phone. Leave empty to request only OpenID profile.", # noqa: E501
) )
SMTP_HOST: str = Field(default="smtp-relay.brevo.com") SMTP_HOST: str = Field(default="smtp-relay.brevo.com")
@@ -393,7 +393,7 @@ class Settings(BaseSettings):
EMAIL_CODE_MAX_ATTEMPTS: int = Field(default=5) EMAIL_CODE_MAX_ATTEMPTS: int = Field(default=5)
BRUTE_FORCE_MAX_FAILURES: int = Field( BRUTE_FORCE_MAX_FAILURES: int = Field(
default=5, default=5,
description="Maximum failed code attempts allowed within the throttle window before a temporary lockout is applied.", description="Maximum failed code attempts allowed within the throttle window before a temporary lockout is applied.", # noqa: E501
) )
BRUTE_FORCE_WINDOW_SECONDS: int = Field( BRUTE_FORCE_WINDOW_SECONDS: int = Field(
default=15 * 60, default=15 * 60,
@@ -553,7 +553,7 @@ class Settings(BaseSettings):
] ]
except ValueError: except ValueError:
logging.error( logging.error(
f"Invalid ADMIN_IDS_STR format: '{self.ADMIN_IDS_STR}'. Expected comma-separated integers." f"Invalid ADMIN_IDS_STR format: '{self.ADMIN_IDS_STR}'. Expected comma-separated integers." # noqa: E501
) )
return [] return []
return [] return []
@@ -879,7 +879,7 @@ class Settings(BaseSettings):
@computed_field @computed_field
@property @property
def platega_sbp_method_resolved(self) -> int: def platega_sbp_method_resolved(self) -> int:
"""SBP method ID, falling back to legacy PLATEGA_PAYMENT_METHOD when SBP-specific value is the default.""" """SBP method ID, falling back to legacy PLATEGA_PAYMENT_METHOD when SBP-specific value is the default.""" # noqa: E501
if self.PLATEGA_SBP_METHOD != 2: if self.PLATEGA_SBP_METHOD != 2:
return self.PLATEGA_SBP_METHOD return self.PLATEGA_SBP_METHOD
return self.PLATEGA_PAYMENT_METHOD or 2 return self.PLATEGA_PAYMENT_METHOD or 2
@@ -1040,18 +1040,18 @@ def get_settings() -> Settings:
) )
if not os.getenv("WEBAPP_SESSION_SECRET"): if not os.getenv("WEBAPP_SESSION_SECRET"):
logging.warning( logging.warning(
"WEBAPP_SESSION_SECRET is not set. A generated secret will be used for this process only." "WEBAPP_SESSION_SECRET is not set. A generated secret will be used for this process only." # noqa: E501
) )
if not os.getenv("WEBHOOK_SECRET_TOKEN"): if not os.getenv("WEBHOOK_SECRET_TOKEN"):
logging.warning( logging.warning(
"WEBHOOK_SECRET_TOKEN is not set. A generated secret will be used for this process only." "WEBHOOK_SECRET_TOKEN is not set. A generated secret will be used for this process only." # noqa: E501
) )
if ( if (
not _settings_instance.YOOKASSA_SHOP_ID not _settings_instance.YOOKASSA_SHOP_ID
or not _settings_instance.YOOKASSA_SECRET_KEY or not _settings_instance.YOOKASSA_SECRET_KEY
): ):
logging.warning( logging.warning(
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work." "CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work." # noqa: E501
) )
if (_settings_instance.LKNPD_INN or _settings_instance.LKNPD_PASSWORD) and not ( if (_settings_instance.LKNPD_INN or _settings_instance.LKNPD_PASSWORD) and not (
_settings_instance.LKNPD_INN and _settings_instance.LKNPD_PASSWORD _settings_instance.LKNPD_INN and _settings_instance.LKNPD_PASSWORD
@@ -1065,15 +1065,15 @@ def get_settings() -> Settings:
or not _settings_instance.FREEKASSA_API_KEY or not _settings_instance.FREEKASSA_API_KEY
): ):
logging.warning( logging.warning(
"CRITICAL: FreeKassa is enabled but SHOP_ID or API key is missing. FreeKassa payments will not work." "CRITICAL: FreeKassa is enabled but SHOP_ID or API key is missing. FreeKassa payments will not work." # noqa: E501
) )
if not _settings_instance.FREEKASSA_SECOND_SECRET: if not _settings_instance.FREEKASSA_SECOND_SECRET:
logging.warning( logging.warning(
"WARNING: FreeKassa second secret is not set. Incoming payment notifications cannot be verified." "WARNING: FreeKassa second secret is not set. Incoming payment notifications cannot be verified." # noqa: E501
) )
if not _settings_instance.subscription_options: if not _settings_instance.subscription_options:
logging.warning( logging.warning(
"CRITICAL: FreeKassa is enabled but no subscription prices are configured (RUB_PRICE_*). Users will not see payment buttons." "CRITICAL: FreeKassa is enabled but no subscription prices are configured (RUB_PRICE_*). Users will not see payment buttons." # noqa: E501
) )
if _settings_instance.PLATEGA_ENABLED: if _settings_instance.PLATEGA_ENABLED:
@@ -1082,12 +1082,12 @@ def get_settings() -> Settings:
or not _settings_instance.PLATEGA_SECRET or not _settings_instance.PLATEGA_SECRET
): ):
logging.warning( logging.warning(
"CRITICAL: Platega is enabled but merchant credentials (PLATEGA_MERCHANT_ID/PLATEGA_SECRET) are missing. Platega payments will not work." "CRITICAL: Platega is enabled but merchant credentials (PLATEGA_MERCHANT_ID/PLATEGA_SECRET) are missing. Platega payments will not work." # noqa: E501
) )
if _settings_instance.SEVERPAY_ENABLED: if _settings_instance.SEVERPAY_ENABLED:
if not _settings_instance.SEVERPAY_MID or not _settings_instance.SEVERPAY_TOKEN: if not _settings_instance.SEVERPAY_MID or not _settings_instance.SEVERPAY_TOKEN:
logging.warning( logging.warning(
"CRITICAL: SeverPay is enabled but MID or TOKEN is missing. SeverPay payments will not work." "CRITICAL: SeverPay is enabled but MID or TOKEN is missing. SeverPay payments will not work." # noqa: E501
) )
except ValidationError as e: except ValidationError as e:
+2 -2
View File
@@ -112,7 +112,7 @@ class Tariff(BaseModel):
stars_price = self.prices_stars.get(str(months), 0) or 0 stars_price = self.prices_stars.get(str(months), 0) or 0
if rub_price <= 0 and stars_price <= 0: if rub_price <= 0 and stars_price <= 0:
raise ValueError( raise ValueError(
f"period tariff {self.key}: period {months} needs a non-zero rub or stars price" f"period tariff {self.key}: period {months} needs a non-zero rub or stars price" # noqa: E501
) )
return self return self
@@ -122,7 +122,7 @@ class Tariff(BaseModel):
raise ValueError(f"traffic tariff {self.key}: conversion_rate_rub_per_gb must be > 0") raise ValueError(f"traffic tariff {self.key}: conversion_rate_rub_per_gb must be > 0")
if not self.traffic_packages.rub and self.conversion_rate_rub_per_gb is None: if not self.traffic_packages.rub and self.conversion_rate_rub_per_gb is None:
raise ValueError( raise ValueError(
f"traffic tariff {self.key}: conversion_rate_rub_per_gb is required without RUB packages" f"traffic tariff {self.key}: conversion_rate_rub_per_gb is required without RUB packages" # noqa: E501
) )
return self return self
+1 -1
View File
@@ -20,7 +20,7 @@ async def create_campaign(
await session.flush() await session.flush()
await session.refresh(campaign) await session.refresh(campaign)
logging.info( logging.info(
f"AdCampaign created id={campaign.ad_campaign_id}, source={source}, start={start_param}, cost={cost}" f"AdCampaign created id={campaign.ad_campaign_id}, source={source}, start={start_param}, cost={cost}" # noqa: E501
) )
return campaign return campaign
+2 -2
View File
@@ -75,7 +75,7 @@ async def create_message_log_no_commit(session: AsyncSession, log_data: dict) ->
target_user = await get_user_by_id(session, log_data["target_user_id"]) target_user = await get_user_by_id(session, log_data["target_user_id"])
if not target_user: if not target_user:
logging.warning( logging.warning(
f"Target user {log_data['target_user_id']} not found for message log. Setting to NULL." f"Target user {log_data['target_user_id']} not found for message log. Setting to NULL." # noqa: E501
) )
log_data["target_user_id"] = None log_data["target_user_id"] = None
@@ -83,6 +83,6 @@ async def create_message_log_no_commit(session: AsyncSession, log_data: dict) ->
session.add(new_log) session.add(new_log)
logging.debug( logging.debug(
f"Message log added to session: user {log_data.get('user_id')}, event {log_data.get('event_type')}" f"Message log added to session: user {log_data.get('user_id')}, event {log_data.get('event_type')}" # noqa: E501
) )
return new_log return new_log
+1 -1
View File
@@ -165,7 +165,7 @@ async def update_provider_payment_and_status(
await session.flush() await session.flush()
await session.refresh(payment) await session.refresh(payment)
logging.info( logging.info(
f"Payment record {payment.payment_id} updated with provider id {provider_payment_id} and status {new_status}." f"Payment record {payment.payment_id} updated with provider id {provider_payment_id} and status {new_status}." # noqa: E501
) )
else: else:
logging.warning(f"Payment record with DB ID {payment_db_id} not found for provider update.") logging.warning(f"Payment record with DB ID {payment_db_id} not found for provider update.")
+2 -2
View File
@@ -170,7 +170,7 @@ async def record_promo_activation(
existing_activation = await get_user_activation_for_promo(session, promo_code_id, user_id) existing_activation = await get_user_activation_for_promo(session, promo_code_id, user_id)
if existing_activation: if existing_activation:
logging.info( logging.info(
f"User {user_id} has already activated promo code {promo_code_id}. Activation ID: {existing_activation.activation_id}" f"User {user_id} has already activated promo code {promo_code_id}. Activation ID: {existing_activation.activation_id}" # noqa: E501
) )
return existing_activation return existing_activation
@@ -203,6 +203,6 @@ async def record_promo_activation(
await session.flush() await session.flush()
await session.refresh(new_activation) await session.refresh(new_activation)
logging.info( logging.info(
f"Promo code {promo_code_id} activated by user {user_id}. Activation ID: {new_activation.activation_id}" f"Promo code {promo_code_id} activated by user {user_id}. Activation ID: {new_activation.activation_id}" # noqa: E501
) )
return new_activation return new_activation
+4 -4
View File
@@ -99,7 +99,7 @@ async def upsert_subscription(session: AsyncSession, sub_payload: Dict[str, Any]
if existing_sub: if existing_sub:
logging.info( logging.info(
f"Updating existing subscription {existing_sub.subscription_id} by panel_sub_uuid {panel_sub_uuid}" f"Updating existing subscription {existing_sub.subscription_id} by panel_sub_uuid {panel_sub_uuid}" # noqa: E501
) )
for key, value in sub_payload.items(): for key, value in sub_payload.items():
if hasattr(existing_sub, key): if hasattr(existing_sub, key):
@@ -120,7 +120,7 @@ async def upsert_subscription(session: AsyncSession, sub_payload: Dict[str, Any]
user = await get_user_by_id(session, sub_payload["user_id"]) user = await get_user_by_id(session, sub_payload["user_id"])
if not user: if not user:
raise ValueError( raise ValueError(
f"User {sub_payload['user_id']} not found for new subscription with panel_uuid {panel_sub_uuid}." f"User {sub_payload['user_id']} not found for new subscription with panel_uuid {panel_sub_uuid}." # noqa: E501
) )
new_sub = Subscription(**sub_payload) new_sub = Subscription(**sub_payload)
@@ -147,7 +147,7 @@ async def deactivate_other_active_subscriptions(
result = await session.execute(stmt) result = await session.execute(stmt)
if result.rowcount > 0: if result.rowcount > 0:
logging.info( logging.info(
f"Deactivated {result.rowcount} other active subscriptions for panel_user_uuid {panel_user_uuid}." f"Deactivated {result.rowcount} other active subscriptions for panel_user_uuid {panel_user_uuid}." # noqa: E501
) )
@@ -160,7 +160,7 @@ async def deactivate_all_user_subscriptions(session: AsyncSession, user_id: int)
result = await session.execute(stmt) result = await session.execute(stmt)
if result.rowcount > 0: if result.rowcount > 0:
logging.info( logging.info(
f"Deactivated {result.rowcount} subscriptions for user {user_id} due to missing panel user." f"Deactivated {result.rowcount} subscriptions for user {user_id} due to missing panel user." # noqa: E501
) )
return result.rowcount return result.rowcount
+1 -1
View File
@@ -169,7 +169,7 @@ async def delete_user_payment_method_by_provider_id(
"""Delete a saved payment method by its provider payment_method.id for a specific user. """Delete a saved payment method by its provider payment_method.id for a specific user.
Useful when callbacks pass the provider id (e.g., YooKassa pm_...) instead of our internal method_id. Useful when callbacks pass the provider id (e.g., YooKassa pm_...) instead of our internal method_id.
""" """ # noqa: E501
stmt = select(UserPaymentMethod).where( stmt = select(UserPaymentMethod).where(
UserPaymentMethod.user_id == user_id, UserPaymentMethod.user_id == user_id,
UserPaymentMethod.provider_payment_method_id == provider_payment_method_id, UserPaymentMethod.provider_payment_method_id == provider_payment_method_id,
+2 -2
View File
@@ -65,7 +65,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
logging.warning("init_db: async_engine was None, re-initializing via init_db_connection.") logging.warning("init_db: async_engine was None, re-initializing via init_db_connection.")
raise RuntimeError( raise RuntimeError(
"async_engine is not initialized. Call init_db_connection and get session_factory first." "async_engine is not initialized. Call init_db_connection and get session_factory first." # noqa: E501
) )
async with async_engine.begin() as conn: async with async_engine.begin() as conn:
@@ -136,7 +136,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
) )
WHERE s.is_active = TRUE WHERE s.is_active = TRUE
AND s.tariff_key IS NULL AND s.tariff_key IS NULL
""" """ # noqa: E501
), ),
{ {
"tariff_key": default_tariff.key, "tariff_key": default_tariff.key,
+25 -25
View File
@@ -244,7 +244,7 @@ def _migration_0008_add_email_verification_code_status(connection: Connection) -
if "status" not in columns: if "status" not in columns:
connection.execute( connection.execute(
text( text(
"ALTER TABLE email_verification_codes ADD COLUMN status VARCHAR NOT NULL DEFAULT 'active'" "ALTER TABLE email_verification_codes ADD COLUMN status VARCHAR NOT NULL DEFAULT 'active'" # noqa: E501
) )
) )
else: else:
@@ -331,11 +331,11 @@ def _migration_0012_add_tariffs_schema(connection: Connection) -> None:
) )
if "premium_topup_balance_bytes" not in sub_columns: if "premium_topup_balance_bytes" not in sub_columns:
sub_statements.append( sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_topup_balance_bytes BIGINT NOT NULL DEFAULT 0" "ALTER TABLE subscriptions ADD COLUMN premium_topup_balance_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501
) )
if "premium_topup_used_bytes" not in sub_columns: if "premium_topup_used_bytes" not in sub_columns:
sub_statements.append( sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" "ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501
) )
if "premium_used_bytes" not in sub_columns: if "premium_used_bytes" not in sub_columns:
sub_statements.append( sub_statements.append(
@@ -407,7 +407,7 @@ def _migration_0012_add_tariffs_schema(connection: Connection) -> None:
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_traffic_warning_period_level UNIQUE (subscription_id, period_start_at, level) CONSTRAINT uq_traffic_warning_period_level UNIQUE (subscription_id, period_start_at, level)
) )
""" """ # noqa: E501
) )
) )
connection.execute( connection.execute(
@@ -446,16 +446,16 @@ def _migration_0012_add_tariffs_schema(connection: Connection) -> None:
for stmt in [ for stmt in [
"CREATE INDEX IF NOT EXISTS ix_subscriptions_tariff_key ON subscriptions (tariff_key)", "CREATE INDEX IF NOT EXISTS ix_subscriptions_tariff_key ON subscriptions (tariff_key)",
"CREATE INDEX IF NOT EXISTS ix_subscriptions_is_throttled ON subscriptions (is_throttled)", "CREATE INDEX IF NOT EXISTS ix_subscriptions_is_throttled ON subscriptions (is_throttled)",
"CREATE INDEX IF NOT EXISTS ix_subscriptions_premium_is_limited ON subscriptions (premium_is_limited)", "CREATE INDEX IF NOT EXISTS ix_subscriptions_premium_is_limited ON subscriptions (premium_is_limited)", # noqa: E501
"CREATE INDEX IF NOT EXISTS ix_payments_sale_mode ON payments (sale_mode)", "CREATE INDEX IF NOT EXISTS ix_payments_sale_mode ON payments (sale_mode)",
"CREATE INDEX IF NOT EXISTS ix_payments_tariff_key ON payments (tariff_key)", "CREATE INDEX IF NOT EXISTS ix_payments_tariff_key ON payments (tariff_key)",
"CREATE INDEX IF NOT EXISTS ix_traffic_topups_subscription_id ON traffic_topups (subscription_id)", "CREATE INDEX IF NOT EXISTS ix_traffic_topups_subscription_id ON traffic_topups (subscription_id)", # noqa: E501
"CREATE INDEX IF NOT EXISTS ix_traffic_topups_payment_id ON traffic_topups (payment_id)", "CREATE INDEX IF NOT EXISTS ix_traffic_topups_payment_id ON traffic_topups (payment_id)",
"CREATE INDEX IF NOT EXISTS ix_traffic_topups_kind ON traffic_topups (kind)", "CREATE INDEX IF NOT EXISTS ix_traffic_topups_kind ON traffic_topups (kind)",
"CREATE INDEX IF NOT EXISTS ix_traffic_warnings_subscription_id ON traffic_warnings (subscription_id)", "CREATE INDEX IF NOT EXISTS ix_traffic_warnings_subscription_id ON traffic_warnings (subscription_id)", # noqa: E501
"CREATE INDEX IF NOT EXISTS ix_tariff_changes_subscription_id ON tariff_changes (subscription_id)", "CREATE INDEX IF NOT EXISTS ix_tariff_changes_subscription_id ON tariff_changes (subscription_id)", # noqa: E501
"CREATE INDEX IF NOT EXISTS ix_hwid_device_purchases_subscription_id ON hwid_device_purchases (subscription_id)", "CREATE INDEX IF NOT EXISTS ix_hwid_device_purchases_subscription_id ON hwid_device_purchases (subscription_id)", # noqa: E501
"CREATE INDEX IF NOT EXISTS ix_hwid_device_purchases_payment_id ON hwid_device_purchases (payment_id)", "CREATE INDEX IF NOT EXISTS ix_hwid_device_purchases_payment_id ON hwid_device_purchases (payment_id)", # noqa: E501
]: ]:
connection.execute(text(stmt)) connection.execute(text(stmt))
@@ -497,11 +497,11 @@ def _migration_0014_add_premium_squad_traffic_fields(connection: Connection) ->
) )
if "premium_topup_balance_bytes" not in sub_columns: if "premium_topup_balance_bytes" not in sub_columns:
statements.append( statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_topup_balance_bytes BIGINT NOT NULL DEFAULT 0" "ALTER TABLE subscriptions ADD COLUMN premium_topup_balance_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501
) )
if "premium_topup_used_bytes" not in sub_columns: if "premium_topup_used_bytes" not in sub_columns:
statements.append( statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" "ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501
) )
if "premium_used_bytes" not in sub_columns: if "premium_used_bytes" not in sub_columns:
statements.append( statements.append(
@@ -519,7 +519,7 @@ def _migration_0014_add_premium_squad_traffic_fields(connection: Connection) ->
connection.execute(text(stmt)) connection.execute(text(stmt))
connection.execute( connection.execute(
text( text(
"CREATE INDEX IF NOT EXISTS ix_subscriptions_premium_is_limited ON subscriptions (premium_is_limited)" "CREATE INDEX IF NOT EXISTS ix_subscriptions_premium_is_limited ON subscriptions (premium_is_limited)" # noqa: E501
) )
) )
@@ -530,7 +530,7 @@ def _migration_0015_add_premium_topup_carryover_fields(connection: Connection) -
statements: List[str] = [] statements: List[str] = []
if "premium_topup_used_bytes" not in sub_columns: if "premium_topup_used_bytes" not in sub_columns:
statements.append( statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" "ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501
) )
if "premium_period_start_at" not in sub_columns: if "premium_period_start_at" not in sub_columns:
statements.append( statements.append(
@@ -559,7 +559,7 @@ def _migration_0016_add_message_logs_admin_fields(connection: Connection) -> Non
connection.execute( connection.execute(
text( text(
"CREATE INDEX IF NOT EXISTS ix_message_logs_target_user_id ON message_logs (target_user_id)" "CREATE INDEX IF NOT EXISTS ix_message_logs_target_user_id ON message_logs (target_user_id)" # noqa: E501
) )
) )
@@ -571,7 +571,7 @@ def _migration_0018_add_premium_admin_overrides(connection: Connection) -> None:
statements: List[str] = [] statements: List[str] = []
if "premium_unlimited_override" not in sub_columns: if "premium_unlimited_override" not in sub_columns:
statements.append( statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_unlimited_override BOOLEAN NOT NULL DEFAULT FALSE" "ALTER TABLE subscriptions ADD COLUMN premium_unlimited_override BOOLEAN NOT NULL DEFAULT FALSE" # noqa: E501
) )
if "premium_bonus_bytes" not in sub_columns: if "premium_bonus_bytes" not in sub_columns:
statements.append( statements.append(
@@ -593,7 +593,7 @@ def _migration_0021_add_regular_unlimited_override(connection: Connection) -> No
if "regular_unlimited_override" not in sub_columns: if "regular_unlimited_override" not in sub_columns:
connection.execute( connection.execute(
text( text(
"ALTER TABLE subscriptions ADD COLUMN regular_unlimited_override BOOLEAN NOT NULL DEFAULT FALSE" "ALTER TABLE subscriptions ADD COLUMN regular_unlimited_override BOOLEAN NOT NULL DEFAULT FALSE" # noqa: E501
) )
) )
connection.execute( connection.execute(
@@ -670,15 +670,15 @@ def _migration_0017_reconcile_legacy_admin_api_schema(connection: Connection) ->
) )
if "premium_baseline_bytes" not in sub_columns: if "premium_baseline_bytes" not in sub_columns:
sub_statements.append( sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_baseline_bytes BIGINT NOT NULL DEFAULT 0" "ALTER TABLE subscriptions ADD COLUMN premium_baseline_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501
) )
if "premium_topup_balance_bytes" not in sub_columns: if "premium_topup_balance_bytes" not in sub_columns:
sub_statements.append( sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_topup_balance_bytes BIGINT NOT NULL DEFAULT 0" "ALTER TABLE subscriptions ADD COLUMN premium_topup_balance_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501
) )
if "premium_topup_used_bytes" not in sub_columns: if "premium_topup_used_bytes" not in sub_columns:
sub_statements.append( sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" "ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501
) )
if "premium_used_bytes" not in sub_columns: if "premium_used_bytes" not in sub_columns:
sub_statements.append( sub_statements.append(
@@ -686,7 +686,7 @@ def _migration_0017_reconcile_legacy_admin_api_schema(connection: Connection) ->
) )
if "premium_is_limited" not in sub_columns: if "premium_is_limited" not in sub_columns:
sub_statements.append( sub_statements.append(
"ALTER TABLE subscriptions ADD COLUMN premium_is_limited BOOLEAN NOT NULL DEFAULT FALSE" "ALTER TABLE subscriptions ADD COLUMN premium_is_limited BOOLEAN NOT NULL DEFAULT FALSE" # noqa: E501
) )
if "is_throttled" not in sub_columns: if "is_throttled" not in sub_columns:
sub_statements.append( sub_statements.append(
@@ -718,13 +718,13 @@ def _migration_0017_reconcile_legacy_admin_api_schema(connection: Connection) ->
) )
if "target_user_id" not in msg_columns: if "target_user_id" not in msg_columns:
msg_statements.append( msg_statements.append(
"ALTER TABLE message_logs ADD COLUMN target_user_id BIGINT REFERENCES users(user_id)" "ALTER TABLE message_logs ADD COLUMN target_user_id BIGINT REFERENCES users(user_id)" # noqa: E501
) )
for stmt in msg_statements: for stmt in msg_statements:
connection.execute(text(stmt)) connection.execute(text(stmt))
connection.execute( connection.execute(
text( text(
"CREATE INDEX IF NOT EXISTS ix_message_logs_target_user_id ON message_logs (target_user_id)" "CREATE INDEX IF NOT EXISTS ix_message_logs_target_user_id ON message_logs (target_user_id)" # noqa: E501
) )
) )
@@ -828,12 +828,12 @@ MIGRATIONS: List[Migration] = [
), ),
Migration( Migration(
id="0018_add_premium_admin_overrides", id="0018_add_premium_admin_overrides",
description="Per-subscription admin overrides for premium traffic (unlimited toggle + bonus bytes)", description="Per-subscription admin overrides for premium traffic (unlimited toggle + bonus bytes)", # noqa: E501
upgrade=_migration_0018_add_premium_admin_overrides, upgrade=_migration_0018_add_premium_admin_overrides,
), ),
Migration( Migration(
id="0019_clear_subscription_months_for_non_subscription_payments", id="0019_clear_subscription_months_for_non_subscription_payments",
description="Backfill: null out subscription_duration_months for legacy traffic/topup/hwid payments", description="Backfill: null out subscription_duration_months for legacy traffic/topup/hwid payments", # noqa: E501
upgrade=_migration_0019_clear_subscription_months_for_non_subscription_payments, upgrade=_migration_0019_clear_subscription_months_for_non_subscription_payments,
), ),
Migration( Migration(
+2 -2
View File
@@ -136,7 +136,7 @@ class Subscription(Base):
user = relationship("User", back_populates="subscriptions") user = relationship("User", back_populates="subscriptions")
def __repr__(self): def __repr__(self):
return f"<Subscription(id={self.subscription_id}, user_id={self.user_id}, panel_uuid='{self.panel_user_uuid}', ends='{self.end_date}')>" return f"<Subscription(id={self.subscription_id}, user_id={self.user_id}, panel_uuid='{self.panel_user_uuid}', ends='{self.end_date}')>" # noqa: E501
class EmailVerificationCode(Base): class EmailVerificationCode(Base):
@@ -402,7 +402,7 @@ class AdCampaign(Base):
) )
def __repr__(self): def __repr__(self):
return f"<AdCampaign(id={self.ad_campaign_id}, source='{self.source}', start_param='{self.start_param}', cost={self.cost})>" return f"<AdCampaign(id={self.ad_campaign_id}, source='{self.source}', start_param='{self.start_param}', cost={self.cost})>" # noqa: E501
class AdAttribution(Base): class AdAttribution(Base):
-3
View File
@@ -16,12 +16,9 @@ select = [
"I", # isort "I", # isort
] ]
ignore = [ ignore = [
"E501", # line length (legacy lines; tighten later if desired)
"E731", # lambda assignment (common in handlers) "E731", # lambda assignment (common in handlers)
"E701", # multiple statements on one line (compact early returns)
"E712", # `== True` / `== False` in SQLAlchemy filters "E712", # `== True` / `== False` in SQLAlchemy filters
"E711", # `== None` on ORM columns (use `.is_(None)` in refactors) "E711", # `== None` on ORM columns (use `.is_(None)` in refactors)
"F841", # unused locals (clean up incrementally)
] ]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]
+1 -1
View File
@@ -6,7 +6,7 @@ Target chat (first match):
3) First entry in ``ADMIN_IDS`` from app settings 3) First entry in ``ADMIN_IDS`` from app settings
No default Telegram ID is embedded in this script. No default Telegram ID is embedded in this script.
""" """ # noqa: E501
import asyncio import asyncio
import os import os
+51
View File
@@ -0,0 +1,51 @@
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from bot.services.panel_api_service import PanelApiService
class PanelApiServiceLoggingTests(unittest.IsolatedAsyncioTestCase):
def _make_service(self) -> PanelApiService:
return PanelApiService(
SimpleNamespace(
PANEL_API_URL="https://panel.example.test/api",
PANEL_API_KEY="panel-key",
USER_HWID_DEVICE_LIMIT=None,
)
)
async def test_update_user_details_does_not_log_full_response_by_default(self):
service = self._make_service()
service._request = AsyncMock(return_value={"response": {"uuid": "user-uuid"}})
with patch("bot.services.panel_api_service.logging.info") as info_log:
result = await service.update_user_details_on_panel(
"user-uuid",
{"description": "profile"},
)
self.assertEqual(result, {"uuid": "user-uuid"})
service._request.assert_awaited_once_with(
"PATCH",
"/users",
json={"description": "profile", "uuid": "user-uuid"},
log_full_response=False,
)
info_log.assert_not_called()
async def test_update_user_details_can_still_request_full_response_logging(self):
service = self._make_service()
service._request = AsyncMock(return_value={"response": {"uuid": "user-uuid"}})
await service.update_user_details_on_panel(
"user-uuid",
{"description": "profile"},
log_response=True,
)
self.assertTrue(service._request.await_args.kwargs["log_full_response"])
if __name__ == "__main__":
unittest.main()
+2 -1
View File
@@ -9,6 +9,7 @@ from urllib.parse import parse_qs, urlsplit
from aiohttp import web from aiohttp import web
from bot.app.web import admin_api, subscription_webapp from bot.app.web import admin_api, subscription_webapp
from bot.app.web.admin_api_impl import settings as admin_settings_routes
from bot.app.web.webapp_auth import ( from bot.app.web.webapp_auth import (
create_telegram_oauth_nonce, create_telegram_oauth_nonce,
create_webapp_session_token, create_webapp_session_token,
@@ -405,7 +406,7 @@ class AdminSettingsSecurityTests(unittest.IsolatedAsyncioTestCase):
request.get = lambda key, default=None: getattr(request, key, default) request.get = lambda key, default=None: getattr(request, key, default)
with ( with (
patch.object(admin_api, "_require_admin_user_id", return_value=1), patch.object(admin_settings_routes, "_require_admin_user_id", return_value=1),
patch.object( patch.object(
admin_api.app_settings_dal, admin_api.app_settings_dal,
"get_overrides_with_meta", "get_overrides_with_meta",

Some files were not shown because too many files have changed in this diff Show More