refactor: split backend domains and add API behavior coverage
This commit is contained in:
+46
-2101
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
"""Domain modules for the admin Mini App API."""
|
||||||
@@ -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("__")]
|
||||||
@@ -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({})
|
||||||
@@ -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)
|
||||||
@@ -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})
|
||||||
@@ -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))
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -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})
|
||||||
@@ -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
|
||||||
@@ -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({})
|
||||||
@@ -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)
|
||||||
@@ -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)})
|
||||||
@@ -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)
|
||||||
@@ -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 {}})
|
||||||
@@ -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)})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -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
|
||||||
+37
-4773
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Domain modules for the subscription Mini App backend."""
|
||||||
@@ -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("__")]
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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)}"
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;"> </td>'
|
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;"> </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;"> </td>'
|
f'<td width="40%" style="border-bottom:1px solid {_BORDER};font-size:0;line-height:0;"> </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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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] = {}
|
||||||
|
|||||||
@@ -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":
|
||||||
# Отправляем стикер с отфильтрованными параметрами
|
# Отправляем стикер с отфильтрованными параметрами
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
Reference in New Issue
Block a user