Ruff format/ Cleanup

This commit is contained in:
Vladless
2026-04-14 07:19:14 +00:00
parent 0e8b0d04f0
commit 39dd2432cc
224 changed files with 1583 additions and 1182 deletions
+5 -2
View File
@@ -1,14 +1,17 @@
import hashlib
from urllib.parse import urlparse
from collections.abc import AsyncGenerator
from urllib.parse import urlparse
from fastapi import Depends, HTTPException, Header, Query, Request, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from audit import set_api_actor
from database import async_session_maker, identities as idb
from database import (
async_session_maker,
identities as idb,
)
from database.access.resolution import ResolvedActor, resolve_actor_from_identity
from database.models import Admin, Identity
+1
View File
@@ -1,5 +1,6 @@
from api.v1.router import router
VERSION = "1.0.0"
__all__ = ("router", "VERSION")
+9 -8
View File
@@ -1,20 +1,21 @@
from fastapi import APIRouter
from api.v1.routes import (
users,
keys,
coupons,
servers,
tariffs,
gifts,
referrals,
misc,
partners,
modules,
keys,
management,
misc,
modules,
partners,
referrals,
servers,
settings,
tariffs,
users,
)
router = APIRouter()
+14 -16
View File
@@ -1,15 +1,17 @@
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from sqlalchemy import inspect as sa_inspect
from sqlalchemy import select
from sqlalchemy import (
inspect as sa_inspect,
select,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.attributes import InstrumentedAttribute
from api.depends import get_session, verify_admin_token
from database.models import Admin
from database.access.resolution import resolve_user_optional
from database.models import Admin
from handlers.texts import get_site_gift_link, get_telegram_gift_link
@@ -27,23 +29,23 @@ def cast_identifier_type(field: InstrumentedAttribute, value: int | str):
def normalize_outgoing_object(obj: object) -> None:
if hasattr(obj, "vless") and getattr(obj, "vless") is None:
setattr(obj, "vless", False)
if hasattr(obj, "vless") and obj.vless is None:
obj.vless = False
cls_name = type(obj).__name__
if cls_name == "Gift":
gift_id = getattr(obj, "gift_id", None)
if gift_id:
setattr(obj, "telegram_gift_link", get_telegram_gift_link(gift_id))
setattr(obj, "site_gift_link", get_site_gift_link(gift_id))
obj.telegram_gift_link = get_telegram_gift_link(gift_id)
obj.site_gift_link = get_site_gift_link(gift_id)
if cls_name in ("ManualBan", "BlockedUser", "TemporaryData"):
insp = sa_inspect(obj)
stored = getattr(obj, "tg_id", None)
if "user" in insp.unloaded:
setattr(obj, "tg_id", stored)
obj.tg_id = stored
return
rel = getattr(obj, "user", None)
rel_tg = getattr(rel, "tg_id", None) if rel is not None else None
setattr(obj, "tg_id", stored if stored is not None else rel_tg)
obj.tg_id = stored if stored is not None else rel_tg
def to_schema(schema_response: type, obj: object):
@@ -70,7 +72,7 @@ def generate_crud_router(
u = await resolve_user_optional(session, int(value))
if u is None:
return None
return getattr(model, "user_id"), u.id
return model.user_id, u.id
field = getattr(model, identifier_field)
return field, cast_identifier_type(field, value)
@@ -113,9 +115,7 @@ def generate_crud_router(
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(
_apply_user_relationship_loader(model, select(model).where(field == casted))
)
result = await session.execute(_apply_user_relationship_loader(model, select(model).where(field == casted)))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
@@ -133,9 +133,7 @@ def generate_crud_router(
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(
_apply_user_relationship_loader(model, select(model).where(field == casted))
)
result = await session.execute(_apply_user_relationship_loader(model, select(model).where(field == casted)))
objs = result.scalars().all()
if not objs:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
+1 -1
View File
@@ -5,8 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
from database.models import Admin, Gift, GiftUsage
from database.access.resolution import resolve_user_optional
from database.models import Admin, Gift, GiftUsage
router = APIRouter()
+2 -2
View File
@@ -7,10 +7,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
from database.models import Admin, Key, Tariff
from database.access.resolution import resolve_user_optional
from services.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
from database.models import Admin, Key, Tariff
from logger import logger
from services.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
router = generate_crud_router(
+5 -4
View File
@@ -1,12 +1,14 @@
import asyncio
import os
import re
import subprocess
import sys
import asyncio
from datetime import datetime, timezone
from typing import Literal
import psutil
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
@@ -17,12 +19,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token, verify_admin_token_short
from config import API_TOKEN, BOT_SERVICE
from database import async_session_maker
from core.bootstrap import MANAGEMENT_CONFIG
from core.executor import run_io
from core.settings.management_config import update_management_config
from database.models import Key, ScheduledBroadcast, User
from database.models import Server
from database import async_session_maker
from database.models import Key, ScheduledBroadcast, Server, User
from database.scheduled_broadcasts import (
cancel_scheduled_broadcast,
create_scheduled_broadcast,
+1
View File
@@ -1,4 +1,5 @@
import pkgutil
from pathlib import Path
from typing import Literal
+4 -2
View File
@@ -1,5 +1,6 @@
from datetime import datetime
import csv
from datetime import datetime
from io import StringIO
from fastapi import APIRouter, Depends, Path, Query
@@ -9,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
try:
from modules.partner_program.settings import PARTNER_BONUS_PERCENTAGES
except Exception:
@@ -880,13 +882,13 @@ async def reset_disabled_payout_methods(
"""Сбрасывает реквизиты для отключённых способов вывода."""
try:
from modules.partner_program import buttons as B
from modules.partner_program.settings import (
ENABLE_PAYOUT_CARD,
ENABLE_PAYOUT_SBP,
ENABLE_PAYOUT_TON,
ENABLE_PAYOUT_USDT,
)
from modules.partner_program import buttons as B
except Exception:
ENABLE_PAYOUT_CARD = True
ENABLE_PAYOUT_USDT = True
+1 -1
View File
@@ -5,8 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas import ReferralResponse
from database.models import Admin, Referral
from database.access.resolution import resolve_user_optional
from database.models import Admin, Referral
router = generate_crud_router(
+1 -1
View File
@@ -7,7 +7,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.v1.schemas.settings import SettingResponse, SettingUpsert
from database.settings_cache import settings_cache
from core.settings.buttons_config import BUTTONS_CONFIG, update_buttons_config
from core.settings.modes_config import MODES_CONFIG, update_modes_config
from core.settings.money_config import MONEY_CONFIG, update_money_config
@@ -17,6 +16,7 @@ from core.settings.providers_order_config import PROVIDERS_ORDER, update_provide
from core.settings.tariffs_config import TARIFFS_CONFIG, update_tariffs_config
from database.models import Setting
from database.settings import set_setting
from database.settings_cache import settings_cache
router = APIRouter()
+2 -2
View File
@@ -8,10 +8,10 @@ from api.depends import get_session, verify_admin_token
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas.users import UserBase, UserResponse, UserUpdate
from database import async_session_maker, delete_user_data, get_servers
from database.models import Key, User
from database.access.resolution import resolve_user_optional
from services.operations import delete_key_from_cluster
from database.models import Key, User
from logger import logger
from services.operations import delete_key_from_cluster
router = generate_crud_router(
+1
View File
@@ -5,5 +5,6 @@ __all__ = ("router", "VERSION")
def __getattr__(name: str):
if name == "router":
from api.v2.router import router
return router
raise AttributeError(name)
+3 -7
View File
@@ -34,7 +34,7 @@ def generate_crud_router(
u = await resolve_user_optional(session, int(value))
if u is None:
return None
return getattr(model, "user_id"), u.id
return model.user_id, u.id
field = getattr(model, identifier_field)
return field, cast_identifier_type(field, value)
@@ -77,9 +77,7 @@ def generate_crud_router(
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(
_apply_user_relationship_loader(model, select(model).where(field == casted))
)
result = await session.execute(_apply_user_relationship_loader(model, select(model).where(field == casted)))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
@@ -97,9 +95,7 @@ def generate_crud_router(
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(
_apply_user_relationship_loader(model, select(model).where(field == casted))
)
result = await session.execute(_apply_user_relationship_loader(model, select(model).where(field == casted)))
objs = result.scalars().all()
if not objs:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
+16 -15
View File
@@ -1,27 +1,28 @@
from fastapi import APIRouter
from api.v2.routes import (
root_router,
auth,
users,
keys,
coupons,
servers,
tariffs,
gifts,
referrals,
misc,
partners,
modules,
management,
settings,
payment_links,
identities,
web,
flows,
gifts,
identities,
keys,
management,
misc,
modules,
notifications,
partners,
payment_links,
referrals,
root_router,
servers,
settings,
tariffs,
users,
web,
)
router = APIRouter()
router.include_router(root_router)
+1
View File
@@ -6,6 +6,7 @@
"""
import time
from collections import deque
from threading import Lock
+2 -3
View File
@@ -73,7 +73,6 @@ async def verify_email(
from sqlalchemy import update
from database.models import Identity as IdentityModel
await session.execute(
update(IdentityModel).where(IdentityModel.id == identity.id).values(email_verified=True)
)
await session.execute(update(IdentityModel).where(IdentityModel.id == identity.id).values(email_verified=True))
return {"ok": True}
+5 -1
View File
@@ -3,9 +3,11 @@ import hashlib
import hmac
import secrets
import time
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
@@ -192,7 +194,9 @@ async def google_callback(
token = await idb.issue_token_for_identity(session, identity)
logger.info(
"[Auth] Login success: identity={}, google_sub={}, ip={}, method=google",
identity.id, google_sub, _client_ip(request),
identity.id,
google_sub,
_client_ip(request),
)
redirect = RedirectResponse(return_to, status_code=302)
set_auth_cookie(redirect, token, request)
+1 -3
View File
@@ -49,9 +49,7 @@ async def link_email_send_code(
if existing and existing.id != identity.id:
our_tg = identity.tg_id
their_tg = existing.tg_id
can_merge = their_tg is None or (
our_tg is not None and int(their_tg) == int(our_tg)
)
can_merge = their_tg is None or (our_tg is not None and int(their_tg) == int(our_tg))
if not can_merge:
raise HTTPException(
status_code=409,
+12 -4
View File
@@ -75,8 +75,9 @@ async def register_by_email(
)
ip = _client_ip(request)
try:
from core.redis_cache import cache_incr_checked
from api.v2.routes.auth._fallback_limiter import check_and_increment
from core.redis_cache import cache_incr_checked
count, redis_ok = await cache_incr_checked(f"register_rate:{ip}", 3600)
if not redis_ok:
count = check_and_increment(f"register_rate:{ip}", 5, 3600)
@@ -144,8 +145,9 @@ async def login(
raise HTTPException(status_code=400, detail="Email обязателен")
ip = _client_ip(request)
try:
from core.redis_cache import cache_get, cache_incr_checked
from api.v2.routes.auth._fallback_limiter import check_and_increment
from core.redis_cache import cache_get, cache_incr_checked
lockout_key = f"login_lockout:{email}"
locked = await cache_get(lockout_key)
if locked:
@@ -164,6 +166,7 @@ async def login(
if not result:
try:
from core.redis_cache import cache_incr, cache_set
fail_key = f"login_fail:{email}"
fails = await cache_incr(fail_key, 900)
if fails >= 10:
@@ -173,6 +176,7 @@ async def login(
raise HTTPException(status_code=401, detail="Неверный email или пароль")
try:
from core.redis_cache import cache_delete
await cache_delete(f"login_fail:{email}")
except Exception:
pass
@@ -193,8 +197,9 @@ async def send_login_code(
"""Отправить код входа на email (SMTP + Redis)."""
ip = _client_ip(request)
try:
from core.redis_cache import cache_incr_checked
from api.v2.routes.auth._fallback_limiter import check_and_increment
from core.redis_cache import cache_incr_checked
count, redis_ok = await cache_incr_checked(f"send_code_rate:{ip}", 3600)
if not redis_ok:
count = check_and_increment(f"send_code_rate:{ip}", 10, 3600)
@@ -292,7 +297,10 @@ async def login_by_code(
from sqlalchemy import update as sa_update
from database.models import Identity as IdentityModel
await session.execute(sa_update(IdentityModel).where(IdentityModel.id == identity.id).values(email_verified=True))
await session.execute(
sa_update(IdentityModel).where(IdentityModel.id == identity.id).values(email_verified=True)
)
await bind_identity_actor(request, session, identity)
token = await idb.issue_token_for_identity(session, identity)
logger.info("[Auth] Login success: identity={}, email={}, method=code", identity.id, email_norm)
+13 -2
View File
@@ -50,7 +50,12 @@ async def login_telegram(
identity = await idb.get_or_create_identity_for_tg(session, body.id)
await bind_identity_actor(request, session, identity)
token = await idb.issue_token_for_identity(session, identity)
logger.info("[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_widget", identity.id, body.id, _client_ip(request))
logger.info(
"[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_widget",
identity.id,
body.id,
_client_ip(request),
)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return LoginResponse(identity_id=identity.id)
@@ -65,6 +70,7 @@ async def login_telegram_webapp(
):
"""Вход через Telegram WebApp initData. Валидирует HMAC, находит/создаёт Identity по tg_id."""
from utils.telegram_login import verify_webapp_init_data
result = verify_webapp_init_data(body.init_data, API_TOKEN)
if not result:
raise HTTPException(status_code=401, detail="Неверная подпись initData")
@@ -74,7 +80,12 @@ async def login_telegram_webapp(
identity = await idb.get_or_create_identity_for_tg(session, int(tg_id))
await bind_identity_actor(request, session, identity)
token = await idb.issue_token_for_identity(session, identity)
logger.info("[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_webapp", identity.id, tg_id, _client_ip(request))
logger.info(
"[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_webapp",
identity.id,
tg_id,
_client_ip(request),
)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return LoginResponse(identity_id=identity.id)
+5 -1
View File
@@ -3,9 +3,11 @@ import hashlib
import hmac
import secrets
import time
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
@@ -189,7 +191,9 @@ async def yandex_callback(
token = await idb.issue_token_for_identity(session, identity)
logger.info(
"[Auth] Login success: identity={}, yandex_sub={}, ip={}, method=yandex",
identity.id, yandex_sub, _client_ip(request),
identity.id,
yandex_sub,
_client_ip(request),
)
redirect = RedirectResponse(return_to, status_code=302)
set_auth_cookie(redirect, token, request)
+3 -2
View File
@@ -1,14 +1,15 @@
from fastapi import Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_request_actor, get_session, verify_identity_token
from api.v2.base_crud import generate_crud_router
from api.v2.schemas import CouponBase, CouponResponse, CouponUpdate
from api.v2.schemas.web_public import CouponApplyRequest, CouponApplyResponse
from api.depends import get_request_actor, get_session, verify_identity_token
from database import identities as idb
from database.models import Coupon
from services.coupons import apply_fixed_coupon
from services.errors import LimitExceededError, NotFoundError, ServiceError, ValidationError
from sqlalchemy.ext.asyncio import AsyncSession
router = generate_crud_router(
model=Coupon,
+2 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import datetime, UTC
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
@@ -10,6 +10,7 @@ from api.depends import get_session, verify_identity_admin
from api.v2.schemas.flows import FlowCreate, FlowResponse, FlowUpdate
from database.models import WebFlow
router = APIRouter()
+4 -2
View File
@@ -36,8 +36,10 @@ from database.tariffs import get_tariff_by_id
from database.temporary_data import create_temporary_data
from services.errors import NotFoundError, ValidationError
from services.formatting import get_site_gift_link
from services.gifts import create_gift as service_create_gift
from services.gifts import redeem_gift as service_redeem_gift
from services.gifts import (
create_gift as service_create_gift,
redeem_gift as service_redeem_gift,
)
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
from services.tariffs import calculate_config_price
+1
View File
@@ -10,6 +10,7 @@ from api.v2.schemas.identities import (
)
from database import identities as idb
router = APIRouter(tags=["Identities"])
+2 -1
View File
@@ -1,4 +1,5 @@
from ._common import router, user_router
from . import admin, user # noqa: F401 — import triggers endpoint registration
from ._common import router, user_router
__all__ = ["router", "user_router"]
+3 -12
View File
@@ -92,7 +92,6 @@ from services.tariffs import calculate_config_price
from services.tariffs.tariff_display import GB, get_effective_limits_for_key, get_key_tariff_addons_state
router = generate_crud_router(
model=Key,
schema_response=KeyResponse,
@@ -105,8 +104,6 @@ router = generate_crud_router(
user_router = APIRouter()
def _key_actions_config() -> AccountKeyActionsConfigResponse:
addons_mode = str(TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE", "") or "").strip().lower()
if addons_mode not in {"", "traffic", "devices", "all"}:
@@ -223,13 +220,9 @@ async def _resolve_available_location_servers(session: AsyncSession, db_key: Key
bound_servers = [s for s in available_servers if special in (s.get("special_groups") or [])]
if bound_servers:
available_servers = bound_servers
names = sorted(
{
str(s.get("server_name") or "").strip()
for s in available_servers
if str(s.get("server_name") or "").strip()
}
)
names = sorted({
str(s.get("server_name") or "").strip() for s in available_servers if str(s.get("server_name") or "").strip()
})
return names
@@ -275,5 +268,3 @@ def _normalize_expiry_ms(raw_value: int | float | None) -> int:
elif value < 10**10:
value *= 1000
return value
+1 -1
View File
@@ -1,6 +1,7 @@
from ._common import * # noqa: F401,F403
from ._common import router, user_router # noqa: F401
@router.delete("/by_email/{email}", response_model=dict)
async def delete_key_by_email(
email: str = Path(..., description="Email клиента"),
@@ -114,4 +115,3 @@ async def create_key_api(
except Exception as e:
logger.error(f"[API] Ошибка при создании ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при создании ключа")
+100 -56
View File
@@ -7,11 +7,11 @@
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
_normalize_expiry_ms,
_resolve_available_location_servers,
_resolve_billing_user_id,
_resolve_default_web_payment_provider,
_resolve_public_base_url,
_normalize_expiry_ms,
router,
user_router,
)
@@ -35,9 +35,7 @@ async def user_key_addons_preview(
raise HTTPException(status_code=403, detail="Доп. опции отключены в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
)
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
@@ -118,26 +116,32 @@ async def user_key_addons_preview(
if not has_device_option and not has_traffic_option:
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
if pack_mode:
include_device_effective = bool(include_device) if include_device is not None else selected_device_limit is not None
include_traffic_effective = bool(include_traffic) if include_traffic is not None else selected_traffic_gb is not None
selected_device = (
selected_device_limit
if selected_device_limit is not None
else None
include_device_effective = (
bool(include_device) if include_device is not None else selected_device_limit is not None
)
selected_traffic = (
selected_traffic_gb
if selected_traffic_gb is not None
else None
include_traffic_effective = (
bool(include_traffic) if include_traffic is not None else selected_traffic_gb is not None
)
selected_device = selected_device_limit if selected_device_limit is not None else None
selected_traffic = selected_traffic_gb if selected_traffic_gb is not None else None
else:
include_device_effective = has_device_option
include_traffic_effective = has_traffic_option
selected_device = selected_device_limit if selected_device_limit is not None else current_device_limit
selected_traffic = selected_traffic_gb if selected_traffic_gb is not None else current_traffic_gb
if has_device_option and include_device_effective and selected_device is not None and int(selected_device) not in device_options:
if (
has_device_option
and include_device_effective
and selected_device is not None
and int(selected_device) not in device_options
):
raise HTTPException(status_code=400, detail="Выбранный пакет устройств недоступен")
if has_traffic_option and include_traffic_effective and selected_traffic is not None and int(selected_traffic) not in traffic_options:
if (
has_traffic_option
and include_traffic_effective
and selected_traffic is not None
and int(selected_traffic) not in traffic_options
):
raise HTTPException(status_code=400, detail="Выбранный пакет трафика недоступен")
current_devices_for_price = int(current_device_limit) if current_device_limit is not None else None
current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None
@@ -154,8 +158,12 @@ async def user_key_addons_preview(
tariff=tariff,
has_device_option=bool(has_device_option and include_device_effective),
has_traffic_option=bool(has_traffic_option and include_traffic_effective),
selected_devices=int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
selected_traffic_gb=int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
selected_devices=int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
selected_traffic_gb=int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
)
)
recalc_enabled = bool(
@@ -177,8 +185,12 @@ async def user_key_addons_preview(
total_price_rub = int(
calculate_config_price(
tariff=tariff,
selected_device_limit=int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
selected_traffic_gb=int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
selected_device_limit=int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
selected_traffic_gb=int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
)
)
extra_price_rub = int(max(0, total_price_rub - base_price_for_current))
@@ -196,8 +208,12 @@ async def user_key_addons_preview(
has_traffic_option=bool(has_traffic_option),
current_device_limit=int(current_device_limit) if current_device_limit is not None else None,
current_traffic_gb=int(current_traffic_gb) if current_traffic_gb is not None else None,
selected_device_limit=int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
selected_traffic_gb=int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
selected_device_limit=int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
selected_traffic_gb=int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
device_options=[
AccountKeyAddonOptionResponse(
value=int(val),
@@ -212,11 +228,7 @@ async def user_key_addons_preview(
traffic_options=[
AccountKeyAddonOptionResponse(
value=int(val),
label=(
"Безлимит трафика"
if int(val) <= 0
else (f"+{int(val)} ГБ" if pack_mode else f"{int(val)} ГБ")
),
label=("Безлимит трафика" if int(val) <= 0 else (f"+{int(val)} ГБ" if pack_mode else f"{int(val)} ГБ")),
)
for val in traffic_options
],
@@ -243,9 +255,7 @@ async def user_key_apply_addons(
raise HTTPException(status_code=403, detail="Доп. опции отключены в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
)
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
@@ -332,24 +342,26 @@ async def user_key_apply_addons(
include_traffic_effective = (
bool(body.include_traffic) if body.include_traffic is not None else body.selected_traffic_gb is not None
)
selected_device = (
body.selected_device_limit
if body.selected_device_limit is not None
else None
)
selected_traffic = (
body.selected_traffic_gb
if body.selected_traffic_gb is not None
else None
)
selected_device = body.selected_device_limit if body.selected_device_limit is not None else None
selected_traffic = body.selected_traffic_gb if body.selected_traffic_gb is not None else None
else:
include_device_effective = has_device_option
include_traffic_effective = has_traffic_option
selected_device = body.selected_device_limit if body.selected_device_limit is not None else current_device_limit
selected_traffic = body.selected_traffic_gb if body.selected_traffic_gb is not None else current_traffic_gb
if has_device_option and include_device_effective and selected_device is not None and int(selected_device) not in device_options:
if (
has_device_option
and include_device_effective
and selected_device is not None
and int(selected_device) not in device_options
):
raise HTTPException(status_code=400, detail="Выбранный пакет устройств недоступен")
if has_traffic_option and include_traffic_effective and selected_traffic is not None and int(selected_traffic) not in traffic_options:
if (
has_traffic_option
and include_traffic_effective
and selected_traffic is not None
and int(selected_traffic) not in traffic_options
):
raise HTTPException(status_code=400, detail="Выбранный пакет трафика недоступен")
current_devices_for_price = int(current_device_limit) if current_device_limit is not None else None
current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None
@@ -367,8 +379,12 @@ async def user_key_apply_addons(
tariff=tariff,
has_device_option=bool(has_device_option and include_device_effective),
has_traffic_option=bool(has_traffic_option and include_traffic_effective),
selected_devices=int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
selected_traffic_gb=int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
selected_devices=int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
selected_traffic_gb=int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
)
)
recalc_enabled = bool(
@@ -390,8 +406,12 @@ async def user_key_apply_addons(
selected_total_price = int(
calculate_config_price(
tariff=tariff,
selected_device_limit=int(selected_device) if has_device_option and selected_device is not None else None,
selected_traffic_gb=int(selected_traffic) if has_traffic_option and selected_traffic is not None else None,
selected_device_limit=int(selected_device)
if has_device_option and selected_device is not None
else None,
selected_traffic_gb=int(selected_traffic)
if has_traffic_option and selected_traffic is not None
else None,
)
)
extra_price_rub = int(max(0, selected_total_price - base_price_for_current))
@@ -458,8 +478,12 @@ async def user_key_apply_addons(
"payment_flow": "key_addons",
"tariff_id": int(tariff_id),
"email": email,
"selected_device_limit": int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
"selected_traffic_gb": int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
"selected_device_limit": int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
"selected_traffic_gb": int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
"current_device_limit": int(current_device_limit) if current_device_limit is not None else None,
"current_traffic_gb": int(current_traffic_gb) if current_traffic_gb is not None else None,
"original_price": int(base_price_for_current),
@@ -480,8 +504,12 @@ async def user_key_apply_addons(
"tariff_id": int(tariff_id),
"email": email,
"required_amount": int(required_amount),
"selected_device_limit": int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
"selected_traffic_gb": int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
"selected_device_limit": int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
"selected_traffic_gb": int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
"current_device_limit": int(current_device_limit) if current_device_limit is not None else None,
"current_traffic_gb": int(current_traffic_gb) if current_traffic_gb is not None else None,
"original_price": int(base_price_for_current),
@@ -561,22 +589,34 @@ async def user_key_apply_addons(
session=session,
email=email,
selected_devices=int(new_device_limit_effective) if new_device_limit_effective is not None else None,
selected_traffic_gb=int(new_traffic_limit_gb_effective) if new_traffic_limit_gb_effective is not None else None,
selected_traffic_gb=int(new_traffic_limit_gb_effective)
if new_traffic_limit_gb_effective is not None
else None,
total_price=int(total_price_after_purchase),
has_device_choice=bool(has_device_option and include_device_effective),
has_traffic_choice=bool(has_traffic_option and include_traffic_effective),
config_mode="pack",
)
else:
selected_device_for_effective = int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None
selected_traffic_for_effective = int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else 0
selected_device_for_effective = (
int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None
)
selected_traffic_for_effective = (
int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else 0
)
device_limit_effective_new, traffic_limit_bytes_effective_new = await get_effective_limits_for_key(
session=session,
tariff_id=int(tariff_id),
selected_device_limit=selected_device_for_effective,
selected_traffic_gb=selected_traffic_for_effective,
)
traffic_limit_gb_effective = int(traffic_limit_bytes_effective_new / GB) if traffic_limit_bytes_effective_new else 0
traffic_limit_gb_effective = (
int(traffic_limit_bytes_effective_new / GB) if traffic_limit_bytes_effective_new else 0
)
await renew_key_in_cluster(
cluster_id=server_id,
email=email,
@@ -593,8 +633,12 @@ async def user_key_apply_addons(
await save_key_config_with_mode(
session=session,
email=email,
selected_devices=int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
selected_traffic_gb=int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
selected_devices=int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
selected_traffic_gb=int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
total_price=int(total_price_after_purchase),
has_device_choice=bool(has_device_option and include_device_effective),
has_traffic_choice=bool(has_traffic_option and include_traffic_effective),
+5 -13
View File
@@ -7,11 +7,11 @@
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
_normalize_expiry_ms,
_resolve_available_location_servers,
_resolve_billing_user_id,
_resolve_default_web_payment_provider,
_resolve_public_base_url,
_normalize_expiry_ms,
router,
user_router,
)
@@ -69,9 +69,7 @@ async def user_key_details(
):
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
)
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
@@ -153,9 +151,7 @@ async def user_key_qr(
raise HTTPException(status_code=403, detail="QR для подписок отключен в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
)
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
@@ -194,9 +190,7 @@ async def user_key_update_alias(
raise HTTPException(status_code=400, detail="Alias содержит недопустимые символы")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
)
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
@@ -229,9 +223,7 @@ async def user_key_delete(
raise HTTPException(status_code=403, detail="Удаление подписки отключено в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
)
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
+2 -4
View File
@@ -7,11 +7,11 @@
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
_normalize_expiry_ms,
_resolve_available_location_servers,
_resolve_billing_user_id,
_resolve_default_web_payment_provider,
_resolve_public_base_url,
_normalize_expiry_ms,
router,
user_router,
)
@@ -30,9 +30,7 @@ async def user_key_reset_hwid(
raise HTTPException(status_code=403, detail="Сброс устройств отключен в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
)
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
+3 -7
View File
@@ -7,11 +7,11 @@
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
_normalize_expiry_ms,
_resolve_available_location_servers,
_resolve_billing_user_id,
_resolve_default_web_payment_provider,
_resolve_public_base_url,
_normalize_expiry_ms,
router,
user_router,
)
@@ -30,9 +30,7 @@ async def user_key_locations(
raise HTTPException(status_code=403, detail="Смена локации отключена в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
)
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
@@ -61,9 +59,7 @@ async def user_key_change_location(
raise HTTPException(status_code=400, detail="Укажите целевую локацию")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
)
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
+2 -4
View File
@@ -7,11 +7,11 @@
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
_normalize_expiry_ms,
_resolve_available_location_servers,
_resolve_billing_user_id,
_resolve_default_web_payment_provider,
_resolve_public_base_url,
_normalize_expiry_ms,
router,
user_router,
)
@@ -39,9 +39,7 @@ async def user_key_renew(
raise HTTPException(status_code=403, detail="Продление подписки отключено в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
)
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
+2 -1
View File
@@ -3,6 +3,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.base_crud import generate_crud_router
from api.v2.schemas import (
BlockedUserResponse,
ManualBanResponse,
@@ -11,7 +12,6 @@ from api.v2.schemas import (
TemporaryDataResponse,
TrackingSourceResponse,
)
from api.v2.base_crud import generate_crud_router
from database import get_tracking_source_stats
from database.access.resolution import resolve_user_optional
from database.models import (
@@ -23,6 +23,7 @@ from database.models import (
TrackingSource,
)
router = APIRouter()
router.include_router(
+5 -2
View File
@@ -3,8 +3,9 @@ from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_token
from database.models import Identity
from database import web_notifications as wn_db
from database.models import Identity
router = APIRouter()
@@ -55,7 +56,9 @@ async def get_notifications(
identity: Identity = Depends(verify_identity_token),
):
notifications = await wn_db.get_notifications_for_identity(
session, identity.id, limit=limit,
session,
identity.id,
limit=limit,
)
unread_count = await wn_db.count_unread_for_identity(session, identity.id)
+15 -14
View File
@@ -1,13 +1,15 @@
import csv
import re
from base64 import b64encode
from datetime import datetime
from io import BytesIO, StringIO
import re
from urllib.parse import urlsplit
import qrcode
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
from fastapi.responses import JSONResponse, StreamingResponse
import qrcode
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,17 +18,18 @@ from api.v2.schemas.web_public import (
PartnerApplyRequest,
PartnerApplyResponse,
PartnerConditionsResponse,
PartnerQrResponse,
PartnerTopEntryResponse,
PartnerTopResponse,
PartnerPayoutEntryResponse,
PartnerPayoutHistoryResponse,
PartnerPayoutRequestCreate,
PartnerPayoutRequestResponse,
PartnerQrResponse,
PartnerTopEntryResponse,
PartnerTopResponse,
)
from database import identities as idb
from utils.referral_codes import decode_partner_code, encode_partner_code
try:
from modules.partner_program.settings import PARTNER_BONUS_PERCENTAGES
except Exception:
@@ -63,10 +66,10 @@ def _row_dt_iso(value) -> str | None:
def _resolve_public_base_url(request: Request) -> str:
origin = str(request.headers.get("origin") or "").strip()
if origin.startswith("http://") or origin.startswith("https://"):
if origin.startswith(("http://", "https://")):
return origin.rstrip("/")
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
if referer.startswith("http://") or referer.startswith("https://"):
if referer.startswith(("http://", "https://")):
parsed = urlsplit(referer)
if parsed.scheme and parsed.netloc:
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
@@ -351,9 +354,9 @@ async def partner_conditions(
("ENABLE_PAYOUT_USDT", "USDT"),
("ENABLE_PAYOUT_TON", "TON"),
]
payout_methods = [
title for key, title in method_map if bool(getattr(partner_settings, key, False))
] if partner_settings else []
payout_methods = (
[title for key, title in method_map if bool(getattr(partner_settings, key, False))] if partner_settings else []
)
level_lines: list[str] = []
all_levels = sorted({int(k) for k in [*percent_levels_raw.keys(), *flat_levels_raw.keys()] if str(k).isdigit()})
for level in all_levels:
@@ -422,9 +425,7 @@ async def partner_payouts_me(
"""
)
total = int((await session.scalar(count_sql, {"tg_id": tg_id})) or 0)
rows = (
await session.execute(rows_sql, {"tg_id": tg_id, "limit": int(limit), "offset": int(offset)})
).fetchall()
rows = (await session.execute(rows_sql, {"tg_id": tg_id, "limit": int(limit), "offset": int(offset)})).fetchall()
items = [
PartnerPayoutEntryResponse(
id=int(row[0]),
@@ -1094,13 +1095,13 @@ async def reset_disabled_payout_methods(
):
"""Сбрасывает реквизиты для отключённых способов вывода."""
try:
from modules.partner_program import buttons as B
from modules.partner_program.settings import (
ENABLE_PAYOUT_CARD,
ENABLE_PAYOUT_SBP,
ENABLE_PAYOUT_TON,
ENABLE_PAYOUT_USDT,
)
from modules.partner_program import buttons as B
except Exception:
ENABLE_PAYOUT_CARD = True
ENABLE_PAYOUT_USDT = True
+21 -7
View File
@@ -3,6 +3,7 @@ from io import BytesIO
from urllib.parse import urlsplit
import qrcode
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
@@ -15,14 +16,25 @@ from api.v2.schemas.web_public import (
ReferralTopEntryResponse,
ReferralTopResponse,
)
from config import CHECK_REFERRAL_REWARD_ISSUED, REFERRAL_BONUS_PERCENTAGES, REFERRAL_BUTTON, REFERRAL_QR, TOP_REFERRAL_BUTTON
from config import (
CHECK_REFERRAL_REWARD_ISSUED,
REFERRAL_BONUS_PERCENTAGES,
REFERRAL_BUTTON,
REFERRAL_QR,
TOP_REFERRAL_BUTTON,
)
from core.bootstrap import BUTTONS_CONFIG
from database import add_referral, get_referral_by_referred_id, get_user_referral_count
from database.referrals import get_referral_position, get_top_referrals
from database import identities as idb
from database import (
add_referral,
get_referral_by_referred_id,
get_user_referral_count,
identities as idb,
)
from database.access.resolution import resolve_user_optional
from database.referrals import get_referral_position, get_top_referrals
from utils.referral_codes import decode_referral_code, encode_referral_code
router = APIRouter()
@@ -44,10 +56,10 @@ def _normalize_referrer_code(value: str | None, fallback_tg_id: int | None) -> i
def _resolve_public_base_url(request: Request) -> str:
origin = str(request.headers.get("origin") or "").strip()
if origin.startswith("http://") or origin.startswith("https://"):
if origin.startswith(("http://", "https://")):
return origin.rstrip("/")
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
if referer.startswith("http://") or referer.startswith("https://"):
if referer.startswith(("http://", "https://")):
parsed = urlsplit(referer)
if parsed.scheme and parsed.netloc:
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
@@ -170,7 +182,9 @@ async def referral_conditions(
level_lines.append(f"{level} уровень: {label}")
one_time_mode = bool(CHECK_REFERRAL_REWARD_ISSUED)
bonus_mode = "one_time" if one_time_mode else "each_payment"
bonus_mode_label = "Бонус за первую успешную оплату реферала" if one_time_mode else "Бонус за каждую успешную оплату реферала"
bonus_mode_label = (
"Бонус за первую успешную оплату реферала" if one_time_mode else "Бонус за каждую успешную оплату реферала"
)
rules = [
"Бонус начисляется только за реальных приглашённых пользователей.",
"Нельзя использовать собственную реферальную ссылку.",
+11 -13
View File
@@ -4,6 +4,7 @@ import re
import time
import aiohttp
from fastapi import APIRouter
from config import (
@@ -21,18 +22,19 @@ from config import (
REFERRAL_QR,
REMNAWAVE_WEBAPP,
REMNAWAVE_WEBAPP_OPEN_IN_BROWSER,
TOP_REFERRAL_BUTTON,
TRIAL_TIME_DISABLE,
USE_COUNTRY_SELECTION,
USERNAME_BOT,
TELEGRAM_WEBAPP_DIRECT_LINK,
TELEGRAM_WEBAPP_SHORT_NAME,
TOP_REFERRAL_BUTTON,
TRIAL_TIME_DISABLE,
USERNAME_BOT,
USE_COUNTRY_SELECTION,
)
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG, MONEY_CONFIG, PAYMENTS_CONFIG
from core.settings.web_config import WEB_CONFIG
from core.settings.money_config import get_currency_mode
from core.settings.web_config import WEB_CONFIG
from services.payments.providers import PROVIDERS_BASE, TELEGRAM_ONLY_PROVIDER_IDS, WEB_LINK_PROVIDER_IDS
router = APIRouter(tags=["Root"])
@@ -113,9 +115,7 @@ async def site_config():
"email_code_login_enabled": bool(MODES_CONFIG.get("WEB_EMAIL_CODE_LOGIN_ENABLED", True)),
},
"mobile": {
"prefer_mini_app_on_telegram_mobile": bool(
MODES_CONFIG.get("PREFER_MINI_APP_ON_TELEGRAM_MOBILE", False)
),
"prefer_mini_app_on_telegram_mobile": bool(MODES_CONFIG.get("PREFER_MINI_APP_ON_TELEGRAM_MOBILE", False)),
},
"features": {
"channel_enabled": bool(BUTTONS_CONFIG.get("CHANNEL_BUTTON_ENABLE", CHANNEL_EXISTS)),
@@ -129,9 +129,7 @@ async def site_config():
"coupon_enabled": bool(BUTTONS_CONFIG.get("COUPON_BUTTON_ENABLE", True)),
"qr_subscription_enabled": bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK)),
"hwid_reset_enabled": bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON)),
"country_selection_enabled": bool(
MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)
),
"country_selection_enabled": bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)),
"captcha_enabled": bool(MODES_CONFIG.get("CAPTCHA_ENABLED", CAPTCHA_ENABLE)),
"channel_check_enabled": bool(MODES_CONFIG.get("CHANNEL_CHECK_ENABLED", CHANNEL_REQUIRED)),
"trial_enabled": not bool(MODES_CONFIG.get("TRIAL_TIME_DISABLED", TRIAL_TIME_DISABLE)),
@@ -201,7 +199,7 @@ def _parse_semver(tag: str) -> tuple[int, int, int, int, tuple[tuple[int, int |
async def _fetch_ghcr_latest_tag(image: str) -> str | None:
"""Анонимно тянем список тегов публичного GHCR-пакета и возвращаем максимальный семвер."""
"""Возвращает максимальный semver-тег образа в GHCR."""
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
token_url = f"https://ghcr.io/token?scope=repository:{image}:pull"
async with session.get(token_url) as token_resp:
@@ -231,7 +229,7 @@ async def _fetch_ghcr_latest_tag(image: str) -> str | None:
@router.get("/api/meta/update-check", include_in_schema=True)
async def update_check():
"""Сравнивает текущую версию Solo-brick с последним тегом публичного GHCR-образа."""
"""Сравнивает текущую версию Solo-brick с последним доступным релизом."""
current = (os.environ.get("APP_VERSION") or "").strip()
image = (os.environ.get("GHCR_IMAGE") or "").strip()
now = time.time()
+2 -1
View File
@@ -1,9 +1,10 @@
from fastapi import APIRouter
from api.v2.schemas import ServerBase, ServerResponse, ServerUpdate
from api.v2.base_crud import generate_crud_router
from api.v2.schemas import ServerBase, ServerResponse, ServerUpdate
from database.models import Server
router = generate_crud_router(
model=Server,
schema_response=ServerResponse,
+2 -1
View File
@@ -7,7 +7,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.schemas import SettingResponse, SettingUpsert
from database.settings_cache import settings_cache
from core.settings.buttons_config import BUTTONS_CONFIG, update_buttons_config
from core.settings.modes_config import MODES_CONFIG, update_modes_config
from core.settings.money_config import MONEY_CONFIG, update_money_config
@@ -17,6 +16,8 @@ from core.settings.providers_order_config import PROVIDERS_ORDER, update_provide
from core.settings.tariffs_config import TARIFFS_CONFIG, update_tariffs_config
from database.models import Setting
from database.settings import set_setting
from database.settings_cache import settings_cache
router = APIRouter()
+6 -2
View File
@@ -27,8 +27,8 @@ from database.coupons import mark_coupon_used
from database.models import Tariff
from database.tariffs import get_tariff_by_id
from database.temporary_data import create_temporary_data
from services.keys import create_vpn_key_headless
from logger import logger
from services.keys import create_vpn_key_headless
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
from services.payments.providers import WEB_LINK_PROVIDER_IDS
from services.tariffs import calculate_config_price
@@ -143,7 +143,11 @@ async def get_tariffs_public(
if isinstance(cached, list):
return cached
q = select(Tariff).where(Tariff.is_active.is_(True)).order_by(Tariff.sort_order.asc().nulls_last(), Tariff.price_rub.asc())
q = (
select(Tariff)
.where(Tariff.is_active.is_(True))
.order_by(Tariff.sort_order.asc().nulls_last(), Tariff.price_rub.asc())
)
if tariff_ids:
try:
ids = [int(x.strip()) for x in tariff_ids.split(",") if x.strip()]
+4 -3
View File
@@ -5,13 +5,14 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.schemas import UserBase, UserResponse, UserUpdate
from api.v2.base_crud import generate_crud_router
from api.v2.schemas import UserBase, UserResponse, UserUpdate
from database import async_session_maker, delete_user_data, get_servers
from database.models import Key, User
from database.access.resolution import resolve_user_optional
from services.operations import delete_key_from_cluster
from database.models import Key, User
from logger import logger
from services.operations import delete_key_from_cluster
router = generate_crud_router(
model=User,
+10 -13
View File
@@ -2,12 +2,11 @@ import hashlib
import re
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from pydantic import BaseModel
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -54,6 +53,7 @@ EXTENSION_CONTENT_TYPES: dict[str, frozenset[str]] = {
def _sanitize_svg(data: bytes) -> bytes:
import re as _re
text = data.decode("utf-8", errors="replace")
text = _re.sub(r"<script[^>]*>.*?</script>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
text = _re.sub(r"<style[^>]*>.*?</style>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
@@ -535,9 +535,7 @@ async def list_custom_element_builds(
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
result = await session.execute(
select(WebCustomElementBuild).order_by(WebCustomElementBuild.created_at.desc())
)
result = await session.execute(select(WebCustomElementBuild).order_by(WebCustomElementBuild.created_at.desc()))
builds = result.scalars().all()
return [_build_to_dict(b) for b in builds]
@@ -630,8 +628,9 @@ async def ingest_flow_events(
session: AsyncSession = Depends(get_session),
):
try:
from core.redis_cache import cache_incr_checked
from api.v2.routes.auth._fallback_limiter import check_and_increment
from core.redis_cache import cache_incr_checked
ip = (request.client.host if request.client else "") or "unknown"
count, redis_ok = await cache_incr_checked(f"analytics_rate:{ip}", 60)
if not redis_ok:
@@ -709,9 +708,7 @@ async def get_flow_funnel(
for i, node in enumerate(funnel):
prev_entered = funnel[i - 1]["entered"] if i > 0 else node["entered"]
node["dropOff"] = round(
(1 - node["entered"] / prev_entered) * 100, 1
) if prev_entered > 0 else 0
node["dropOff"] = round((1 - node["entered"] / prev_entered) * 100, 1) if prev_entered > 0 else 0
return {"flowId": flow_id, "days": days, "funnel": funnel}
@@ -732,6 +729,7 @@ def _error_signature(name: str, message: str, stack: str | None, url: str | None
if url:
try:
from urllib.parse import urlparse
pathname = urlparse(url).path[:100]
except Exception:
pass
@@ -757,8 +755,9 @@ async def ingest_error_report(
session: AsyncSession = Depends(get_session),
):
try:
from core.redis_cache import cache_incr_checked
from api.v2.routes.auth._fallback_limiter import check_and_increment
from core.redis_cache import cache_incr_checked
ip = (request.client.host if request.client else "") or "unknown"
count, redis_ok = await cache_incr_checked(f"error_report_rate:{ip}", 60)
if not redis_ok:
@@ -773,9 +772,7 @@ async def ingest_error_report(
signature = _error_signature(body.name, body.message, body.stack, body.url)
existing = (
await session.execute(
select(WebErrorReport).where(WebErrorReport.signature == signature)
)
await session.execute(select(WebErrorReport).where(WebErrorReport.signature == signature))
).scalar_one_or_none()
if existing:
+7 -7
View File
@@ -1,4 +1,5 @@
from api.v1.schemas import (
BlockedUserResponse,
CouponBase,
CouponResponse,
CouponUpdate,
@@ -9,6 +10,9 @@ from api.v1.schemas import (
GiftUsageResponse,
KeyDetailsResponse,
KeyResponse,
ManualBanResponse,
NotificationResponse,
PaymentResponse,
ReferralResponse,
ServerBase,
ServerResponse,
@@ -16,25 +20,21 @@ from api.v1.schemas import (
TariffBase,
TariffResponse,
TariffUpdate,
TemporaryDataResponse,
TrackingSourceResponse,
UserBase,
UserResponse,
UserUpdate,
BlockedUserResponse,
ManualBanResponse,
NotificationResponse,
PaymentResponse,
TemporaryDataResponse,
TrackingSourceResponse,
)
from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyUpdate
from api.v1.schemas.settings import SettingResponse, SettingUpsert
from api.v2.schemas.web import (
WebBlockResponse,
WebTheme,
WebPageResponse,
WebPageUpdate,
WebPageVariantCreate,
WebPageVariantSummary,
WebPageVariantUpdate,
WebPageVariantsResponse,
WebTheme,
)
+2
View File
@@ -3,11 +3,13 @@ from pydantic import BaseModel
class TariffGroup(BaseModel):
"""Группа тарифов (group_code) для выбора в лендинге и др."""
group_code: str
class TariffPublic(BaseModel):
"""Публичный список тарифов (без авторизации)."""
id: int
name: str
group_code: str
+2
View File
@@ -1,8 +1,10 @@
import json
from typing import Any
from pydantic import BaseModel, Field, model_validator
_MAX_BLOCK_DATA_SIZE = 256 * 1024
+82 -72
View File
@@ -3,10 +3,11 @@ from __future__ import annotations
import json
import uuid
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Any, Iterable
from typing import Any
from aiogram.types import CallbackQuery, InlineQuery, Message, TelegramObject, User
from fastapi import Request
@@ -16,11 +17,11 @@ from database.audit import (
create_audit_reset_marker_db,
delete_old_audit_events_db,
ensure_audit_table,
fetch_existing_audit_request_ids_db,
fetch_latest_audit_reset_db,
fetch_audit_events_db,
fetch_audit_events_db_window,
fetch_audit_rows_db,
fetch_existing_audit_request_ids_db,
fetch_latest_audit_reset_db,
fetch_successful_payment_rows_db,
)
from database.models import AuditEvent
@@ -35,6 +36,7 @@ from .rules import (
_normalize_path_to_steps,
)
try:
from core.cache_config import (
AUDIT_REDIS_BUFFER_ENABLED,
@@ -57,6 +59,8 @@ _MAX_TEXT_LEN = 160
_AUDIT_REDIS_PROCESSING_KEY = f"{AUDIT_REDIS_FLUSH_KEY}:processing"
_AUDIT_REDIS_DRAIN_LOCK_KEY = f"{AUDIT_REDIS_FLUSH_KEY}:drain_lock"
_AUDIT_REDIS_DRAIN_LOCK_TTL_SEC = 15 * 60
@dataclass
class AuditContext:
request_id: str
@@ -89,13 +93,13 @@ def _trim(value: Any, limit: int = _MAX_TEXT_LEN) -> str | None:
def _jsonable(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
if value is None or isinstance(value, str | int | float | bool):
return value
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, dict):
return {str(k): _jsonable(v) for k, v in value.items()}
if isinstance(value, (list, tuple, set)):
if isinstance(value, list | tuple | set):
return [_jsonable(item) for item in value]
return _trim(value, 500)
@@ -114,7 +118,9 @@ async def get_audit_db_reset_at(session: AsyncSession) -> datetime | None:
async def set_audit_db_reset_at(session: AsyncSession, at: datetime | None = None) -> datetime:
created = at.astimezone(timezone.utc).replace(tzinfo=None) if at is not None and at.tzinfo else (at or datetime.utcnow())
created = (
at.astimezone(timezone.utc).replace(tzinfo=None) if at is not None and at.tzinfo else (at or datetime.utcnow())
)
await create_audit_reset_marker_db(session, source="db", created_at=created)
await session.commit()
return created.replace(tzinfo=timezone.utc)
@@ -268,19 +274,21 @@ def log_api_access(
context = ensure_api_context(request)
client_ip = request.client.host if request.client else "-"
logger.debug(
f"[AUDIT_ACCESS] {_serialize({
'request_id': context.request_id,
'channel': 'api',
'method': request.method,
'path': context.path_or_handler,
'status_code': status_code,
'duration_ms': duration_ms,
'result': result,
'reason': reason,
'client_ip': client_ip,
'actor_identity_id': context.actor_identity_id,
'actor_tg_id': context.actor_tg_id,
})}"
f"[AUDIT_ACCESS] {
_serialize({
'request_id': context.request_id,
'channel': 'api',
'method': request.method,
'path': context.path_or_handler,
'status_code': status_code,
'duration_ms': duration_ms,
'result': result,
'reason': reason,
'client_ip': client_ip,
'actor_identity_id': context.actor_identity_id,
'actor_tg_id': context.actor_tg_id,
})
}"
)
@@ -298,18 +306,20 @@ def log_telegram_access(
)
user = _event_user(event)
logger.debug(
f"[AUDIT_ACCESS] {_serialize({
'request_id': context.request_id,
'channel': 'telegram',
'path_or_handler': context.path_or_handler,
'event_type': type(event).__name__,
'message': _message_text(event),
'result': result,
'reason': reason,
'actor_identity_id': context.actor_identity_id,
'actor_tg_id': context.actor_tg_id or (user.id if user else None),
'username': getattr(user, 'username', None) if user else None,
})}"
f"[AUDIT_ACCESS] {
_serialize({
'request_id': context.request_id,
'channel': 'telegram',
'path_or_handler': context.path_or_handler,
'event_type': type(event).__name__,
'message': _message_text(event),
'result': result,
'reason': reason,
'actor_identity_id': context.actor_identity_id,
'actor_tg_id': context.actor_tg_id or (user.id if user else None),
'username': getattr(user, 'username', None) if user else None,
})
}"
)
@@ -345,20 +355,22 @@ async def record_audit_event(
session.add(event)
await session.flush()
logger.debug(
f"[AUDIT_EVENT] {_serialize({
'id': event.id,
'request_id': event.request_id,
'channel': event.channel,
'event_type': event.event_type,
'actor_identity_id': event.actor_identity_id,
'actor_tg_id': event.actor_tg_id,
'path_or_handler': event.path_or_handler,
'entity_type': event.entity_type,
'entity_id': event.entity_id,
'result': event.result,
'reason': event.reason,
'metadata': event.metadata_,
})}"
f"[AUDIT_EVENT] {
_serialize({
'id': event.id,
'request_id': event.request_id,
'channel': event.channel,
'event_type': event.event_type,
'actor_identity_id': event.actor_identity_id,
'actor_tg_id': event.actor_tg_id,
'path_or_handler': event.path_or_handler,
'entity_type': event.entity_type,
'entity_id': event.entity_id,
'result': event.result,
'reason': event.reason,
'metadata': event.metadata_,
})
}"
)
return event
@@ -658,7 +670,9 @@ async def safe_record_telegram_event(
event_type=event_type,
channel="telegram",
path_or_handler=path_or_handler or (context.path_or_handler if context else "telegram"),
actor_identity_id=actor_identity_id if actor_identity_id is not None else (context.actor_identity_id if context else None),
actor_identity_id=actor_identity_id
if actor_identity_id is not None
else (context.actor_identity_id if context else None),
actor_tg_id=actor_tg_id if actor_tg_id is not None else (context.actor_tg_id if context else None),
entity_type=entity_type,
entity_id=entity_id,
@@ -777,17 +791,15 @@ def _aggregate_audit_rows(
fail = data["fail"]
unique = len(data["actors"])
fail_rate = round(100.0 * fail / total, 1) if total else 0
by_path_list.append(
{
"step": step,
"label": AUDIT_STEP_LABELS.get(step, step),
"total": total,
"success": data["success"],
"fail": fail,
"unique_users": unique,
"fail_rate_pct": fail_rate,
}
)
by_path_list.append({
"step": step,
"label": AUDIT_STEP_LABELS.get(step, step),
"total": total,
"success": data["success"],
"fail": fail,
"unique_users": unique,
"fail_rate_pct": fail_rate,
})
return by_step, by_path_list, all_actors
@@ -859,9 +871,7 @@ async def list_audit_events(
offset=offset,
)
redis_events = await _list_audit_events_from_redis(
tg_id, identity_id, channel, event_types_list, max_events=3000
)
redis_events = await _list_audit_events_from_redis(tg_id, identity_id, channel, event_types_list, max_events=3000)
need = offset + limit + len(redis_events)
db_events = await fetch_audit_events_db_window(
session,
@@ -899,7 +909,7 @@ async def get_audit_stats(
d_to = _naive_utc(date_to)
rows = await fetch_audit_rows_db(session, date_from=d_from, date_to=d_to, limit=max_events)
rows.extend(await fetch_successful_payment_rows_db(session, date_from=d_from, date_to=d_to, limit=max_events))
by_step, by_path_list, all_actors = _aggregate_audit_rows(rows)
_by_step, by_path_list, all_actors = _aggregate_audit_rows(rows)
raw_total_events = sum(1 for row in rows if not _is_ignored_analytics_event(row[0] or ""))
analytics_total_events = sum(row["total"] for row in by_path_list)
return {
@@ -921,7 +931,7 @@ async def get_audit_stats_from_redis(max_events: int = 5000) -> dict[str, Any] |
events = await list_audit_events_from_redis_buffer(max_events=max_events)
filtered_events = events
rows = [(e.path_or_handler, e.result, e.actor_tg_id, e.actor_identity_id) for e in filtered_events]
by_step, by_path_list, all_actors = _aggregate_audit_rows(rows)
_by_step, by_path_list, all_actors = _aggregate_audit_rows(rows)
raw_total_events = sum(1 for row in rows if not _is_ignored_analytics_event(row[0] or ""))
analytics_total_events = sum(row["total"] for row in by_path_list)
return {
@@ -949,7 +959,7 @@ async def get_audit_stats_from_redis_since(
threshold = date_from.astimezone(timezone.utc) if date_from.tzinfo else date_from.replace(tzinfo=timezone.utc)
filtered_events = [e for e in events if getattr(e, "created_at", None) and e.created_at >= threshold]
rows = [(e.path_or_handler, e.result, e.actor_tg_id, e.actor_identity_id) for e in filtered_events]
by_step, by_path_list, all_actors = _aggregate_audit_rows(rows)
_by_step, by_path_list, all_actors = _aggregate_audit_rows(rows)
raw_total_events = sum(1 for row in rows if not _is_ignored_analytics_event(row[0] or ""))
analytics_total_events = sum(row["total"] for row in by_path_list)
return {
@@ -1012,14 +1022,12 @@ def _funnel_from_rows(
if prev_actors is not None and prev_actors:
overlap = len(prev_actors & actors)
conversion = round(100.0 * overlap / len(prev_actors), 1)
funnel_list.append(
{
"step": step,
"label": AUDIT_STEP_LABELS.get(step, step),
"count": count,
"conversion_from_prev_pct": conversion,
}
)
funnel_list.append({
"step": step,
"label": AUDIT_STEP_LABELS.get(step, step),
"count": count,
"conversion_from_prev_pct": conversion,
})
prev_actors = actors
return funnel_list
@@ -1072,7 +1080,9 @@ async def drain_audit_redis_to_db(session_factory: Any) -> int:
break
batch = [rec for rec in raw_batch if isinstance(rec, dict)]
if not batch:
logger.warning("[Audit] drain_audit_redis_to_db: отброшен пустой/битый батч ({} элементов)", len(raw_batch))
logger.warning(
"[Audit] drain_audit_redis_to_db: отброшен пустой/битый батч ({} элементов)", len(raw_batch)
)
await cache_lpop_batch(_AUDIT_REDIS_PROCESSING_KEY, len(raw_batch))
continue
try:
+11 -8
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Iterable
from collections.abc import Iterable
def _get_bot_webhook_path() -> str:
@@ -203,7 +203,7 @@ _IGNORED_CALLBACK_PREFIX: tuple[str, ...] = (
"gifts_page|",
)
_IGNORED_CALLBACK_EXACT_RULES: dict[str, str] = {key: "ignore" for key in _IGNORED_CALLBACK_EXACT}
_IGNORED_CALLBACK_EXACT_RULES: dict[str, str] = dict.fromkeys(_IGNORED_CALLBACK_EXACT, "ignore")
_IGNORED_CALLBACK_PREFIX_RULES: tuple[tuple[str, str], ...] = tuple(
(prefix, "ignore") for prefix in _IGNORED_CALLBACK_PREFIX
)
@@ -323,11 +323,14 @@ def _is_ignored_analytics_event(path: str) -> bool:
if not p.startswith("callback:"):
return False
callback_data = p.split(":", 1)[-1]
return _match_step_rules(
callback_data,
exact=_IGNORED_CALLBACK_EXACT_RULES,
prefixes=_IGNORED_CALLBACK_PREFIX_RULES,
) is not None
return (
_match_step_rules(
callback_data,
exact=_IGNORED_CALLBACK_EXACT_RULES,
prefixes=_IGNORED_CALLBACK_PREFIX_RULES,
)
is not None
)
def _message_command_step(path: str) -> str | None:
@@ -442,6 +445,6 @@ def _normalize_path_to_steps(path: str) -> list[str]:
return message_steps
if p.startswith("message:"):
return ["other"]
if p.startswith("post ") or p.startswith("get "):
if p.startswith(("post ", "get ")):
return [_api_step(p)]
return [_handler_step(p) or "other"]
+4
View File
@@ -1,5 +1,6 @@
from importlib import import_module
version = "0.5.3"
from aiogram import Bot, Dispatcher
@@ -15,6 +16,7 @@ from utils.custom_emojis import initialize_custom_emojis
from utils.errors import setup_error_handlers
from utils.modules_loader import load_modules_from_folder, modules_hub
apply_button_icons_patch()
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
@@ -32,11 +34,13 @@ load_modules_from_folder()
from handlers.buttons import BUTTON_ICON_CONFIG
set_button_icon_config(BUTTON_ICON_CONFIG)
dp.message.filter(IsPrivateFilter())
dp.callback_query.filter(IsPrivateFilter())
async def _on_dispatcher_startup(*_args, **_kwargs):
from core.tasks import ensure_periodic_task_manager_started
+87 -52
View File
@@ -1,5 +1,5 @@
import locale
import json
import locale
import os
import re
import shutil
@@ -13,6 +13,7 @@ from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
try:
import requests
except ImportError:
@@ -26,30 +27,28 @@ try:
from rich.prompt import Confirm, Prompt
from rich.table import Table
except ImportError:
def _strip_markup(value):
if not isinstance(value, str):
return str(value)
return re.sub(r"\[[^\]]+\]", "", value)
class Group:
def __init__(self, *items):
def __init__(self, *items) -> None:
self.items = items
def __str__(self):
def __str__(self) -> str:
return "\n".join(_strip_markup(item) for item in self.items)
class Panel:
def __init__(self, renderable, **kwargs):
def __init__(self, renderable, **kwargs) -> None:
self.renderable = renderable
def __str__(self):
def __str__(self) -> str:
return _strip_markup(self.renderable)
class Table:
def __init__(self, title=None, **kwargs):
def __init__(self, title=None, **kwargs) -> None:
self.title = title
self.rows = []
@@ -59,16 +58,15 @@ except ImportError:
def add_row(self, *row):
self.rows.append(row)
def __str__(self):
def __str__(self) -> str:
lines = []
if self.title:
lines.append(_strip_markup(self.title))
lines.extend(" | ".join(_strip_markup(cell) for cell in row) for row in self.rows)
return "\n".join(lines)
class Live:
def __init__(self, **kwargs):
def __init__(self, **kwargs) -> None:
self.last_renderable = None
def __enter__(self):
@@ -81,18 +79,15 @@ except ImportError:
self.last_renderable = renderable
print(_strip_markup(str(renderable)))
class SpinnerColumn:
pass
class TextColumn:
def __init__(self, *args, **kwargs):
def __init__(self, *args, **kwargs) -> None:
pass
class Progress:
def __init__(self, *args, **kwargs):
def __init__(self, *args, **kwargs) -> None:
self.last_description = None
def __enter__(self):
@@ -111,7 +106,6 @@ except ImportError:
self.last_description = description
print(_strip_markup(description))
class Prompt:
@staticmethod
def ask(message, choices=None, default=None, show_choices=True, **kwargs):
@@ -127,7 +121,6 @@ except ImportError:
raise ValueError(f"Ожидается одно из значений: {', '.join(choices)}")
return value
class Confirm:
@staticmethod
def ask(message, default=False, **kwargs):
@@ -137,7 +130,6 @@ except ImportError:
return default
return value in {"y", "yes", "1", "true"}
class Console:
def print(self, *args, **kwargs):
print(*(_strip_markup(str(arg)) for arg in args))
@@ -197,7 +189,7 @@ VENV_PYTHON = os.path.join(PROJECT_DIR, "venv", "bin", "python")
class HttpResponse:
def __init__(self, status_code: int, text: str):
def __init__(self, status_code: int, text: str) -> None:
self.status_code = status_code
self.text = text
@@ -1127,7 +1119,8 @@ def _copy_local_web_source(src: str, dst: str) -> bool:
if shutil.which("rsync"):
result = subprocess.run(
[
"rsync", "-a",
"rsync",
"-a",
"--exclude=node_modules",
"--exclude=.next",
"--exclude=.git",
@@ -1151,8 +1144,16 @@ def _copy_local_web_source(src: str, dst: str) -> bool:
src,
dst,
ignore=shutil.ignore_patterns(
"node_modules", ".next", ".git", ".env", ".env.local",
".env.production", "logs", ".deploy", ".data", ".claude",
"node_modules",
".next",
".git",
".env",
".env.local",
".env.production",
"logs",
".deploy",
".data",
".claude",
),
)
except Exception:
@@ -1170,7 +1171,9 @@ def _prepare_web_sources(dst: str) -> bool:
console.print("[yellow]Не удалось скопировать локальные исходники.[/yellow]")
console.print("[red]❌ Локальные исходники web-app не найдены и не удалось использовать.[/red]")
console.print("[yellow]Проверьте, что пакет ghcr.io/vladless/solo-brick публичен, либо что рядом с CLI лежит каталог web-app.[/yellow]")
console.print(
"[yellow]Проверьте, что пакет ghcr.io/vladless/solo-brick публичен, либо что рядом с CLI лежит каталог web-app.[/yellow]"
)
return False
@@ -1193,7 +1196,8 @@ def _build_web_image(src_dir: str) -> bool:
console.print("[cyan]Сборка Docker-образа (несколько минут)...[/cyan]")
result = subprocess.run(
["docker", "build", "-t", WEB_IMAGE, "."],
cwd=src_dir, check=False,
cwd=src_dir,
check=False,
)
if result.returncode != 0:
console.print("[red]❌ Ошибка сборки. Проверьте логи выше.[/red]")
@@ -1213,6 +1217,7 @@ def _ensure_web_image(src_dir: str, force_pull: bool = False) -> bool:
def _check_feature(name: str) -> bool:
try:
from core.rpc import check_feature
return check_feature(name)
except Exception:
return False
@@ -1221,6 +1226,7 @@ def _check_feature(name: str) -> bool:
def _verify_license_for_web(code: str, password: str) -> tuple[bool, str]:
try:
from core.rpc import verify_web_license
return verify_web_license(code, password)
except Exception:
return False, ""
@@ -1313,16 +1319,29 @@ def _setup_ssl(domain):
"""Получает SSL сертификат через certbot."""
if not shutil.which("certbot"):
try:
subprocess.run(["sudo", "apt-get", "install", "-y", "-qq", "certbot", "python3-certbot-nginx"],
check=True, stdout=subprocess.DEVNULL)
subprocess.run(
["sudo", "apt-get", "install", "-y", "-qq", "certbot", "python3-certbot-nginx"],
check=True,
stdout=subprocess.DEVNULL,
)
except subprocess.CalledProcessError:
console.print("[yellow]Не удалось установить certbot.[/yellow]")
return False
try:
subprocess.run([
"sudo", "certbot", "--nginx", "-d", domain,
"--non-interactive", "--agree-tos", "--register-unsafely-without-email", "--redirect",
], check=True)
subprocess.run(
[
"sudo",
"certbot",
"--nginx",
"-d",
domain,
"--non-interactive",
"--agree-tos",
"--register-unsafely-without-email",
"--redirect",
],
check=True,
)
return True
except subprocess.CalledProcessError:
console.print(f"[yellow]Не удалось получить SSL. Убедитесь что {domain} указывает на этот сервер.[/yellow]")
@@ -1374,6 +1393,7 @@ def install_website():
try:
import getpass
lc_pass = getpass.getpass(" Пароль: ")
except Exception:
lc_pass = safe_prompt("[cyan]Пароль[/cyan]")
@@ -1398,7 +1418,9 @@ def install_website():
console.print("\n[bold][2/5] Настройки[/bold]\n")
console.print("[dim]Домен, по которому будет открываться сайт.\nDNS (A-запись) должна уже указывать на IP этого сервера.[/dim]")
console.print(
"[dim]Домен, по которому будет открываться сайт.\nDNS (A-запись) должна уже указывать на IP этого сервера.[/dim]"
)
domain = safe_prompt("[cyan]Домен сайта[/cyan] (например vpn.example.com)")
if not domain or not domain.strip():
console.print("[red]Домен обязателен.[/red]")
@@ -1407,26 +1429,39 @@ def install_website():
try:
from config import API_PORT as _BOT_API_PORT
_bot_api_port = int(_BOT_API_PORT)
except Exception:
_bot_api_port = 3004
_api_default = f"http://localhost:{_bot_api_port}"
console.print(f"\n[dim]Адрес API вашего бота (FastAPI).\nЕсли бот на этом же сервере — оставьте по умолчанию.\nЕсли на другом — укажите полный адрес, например http://123.45.67.89:{_bot_api_port}[/dim]")
console.print(
f"\n[dim]Адрес API вашего бота (FastAPI).\nЕсли бот на этом же сервере — оставьте по умолчанию.\nЕсли на другом — укажите полный адрес, например http://123.45.67.89:{_bot_api_port}[/dim]"
)
api_url = safe_prompt("[cyan]Адрес backend API[/cyan]", default=_api_default)
console.print("\n[dim]Внутренний порт, на котором запустится сайт.\nNginx проксирует на него запросы. Менять нужно только если порт занят.[/dim]")
console.print(
"\n[dim]Внутренний порт, на котором запустится сайт.\nNginx проксирует на него запросы. Менять нужно только если порт занят.[/dim]"
)
web_port = safe_prompt("[cyan]Порт сайта[/cyan]", default="3000")
console.print("\n[dim]Для push-уведомлений на сайте (колокольчик).\nГенерируется командой: npx web-push generate-vapid-keys\nЕсли не нужны — пропустите.[/dim]")
console.print(
"\n[dim]Для push-уведомлений на сайте (колокольчик).\nГенерируется командой: npx web-push generate-vapid-keys\nЕсли не нужны — пропустите.[/dim]"
)
vapid_key = safe_prompt("[cyan]VAPID Public Key[/cyan] (Enter — пропустить)", default="")
console.print("\n[dim]Cloudflare Turnstile защищает формы логина от ботов.\nПолучите ключ на dash.cloudflare.com → Turnstile.\nЕсли не нужно — пропустите, формы будут работать без CAPTCHA.[/dim]")
console.print(
"\n[dim]Cloudflare Turnstile защищает формы логина от ботов.\nПолучите ключ на dash.cloudflare.com → Turnstile.\nЕсли не нужно — пропустите, формы будут работать без CAPTCHA.[/dim]"
)
turnstile_key = safe_prompt("[cyan]Turnstile Site Key[/cyan] (Enter — пропустить)", default="")
console.print("\n[dim]Username Telegram-бота (без @) для кнопки «Войти через Telegram» на сайте.\nЕсли не нужно — пропустите.[/dim]")
console.print(
"\n[dim]Username Telegram-бота (без @) для кнопки «Войти через Telegram» на сайте.\nЕсли не нужно — пропустите.[/dim]"
)
tg_bot_username = safe_prompt("[cyan]Telegram Bot Username[/cyan] (Enter — пропустить)", default="")
console.print("\n[dim]Для отправки email-кодов (логин, подтверждение, сброс пароля).\nЕсли не нужно — пропустите, регистрация по email+паролю будет работать без этого.[/dim]")
console.print(
"\n[dim]Для отправки email-кодов (логин, подтверждение, сброс пароля).\nЕсли не нужно — пропустите, регистрация по email+паролю будет работать без этого.[/dim]"
)
smtp_host = safe_prompt("[cyan]SMTP Host[/cyan] (Enter — пропустить)", default="")
smtp_user = ""
smtp_password = ""
@@ -1435,6 +1470,7 @@ def install_website():
smtp_user = safe_prompt("[cyan]SMTP User[/cyan]", default="")
try:
import getpass
smtp_password = getpass.getpass(" SMTP Password: ")
except Exception:
smtp_password = safe_prompt("[cyan]SMTP Password[/cyan]", default="")
@@ -1455,6 +1491,7 @@ def install_website():
os.makedirs(WEB_DIR, exist_ok=True)
from urllib.parse import urlparse
parsed_api = urlparse(api_url)
api_port_from_url = ""
if parsed_api.port is not None:
@@ -1474,13 +1511,13 @@ def install_website():
f.write(f"NEXT_PUBLIC_SITE_URL={site_url}\n")
f.write(f"NEXT_PUBLIC_VAPID_PUBLIC_KEY={vapid_key}\n")
f.write(f"NEXT_PUBLIC_TURNSTILE_SITE_KEY={turnstile_key}\n")
f.write(f"NEXT_PUBLIC_LOG_LEVEL=info\n")
f.write("NEXT_PUBLIC_LOG_LEVEL=info\n")
f.write(f"WEB_PORT={web_port}\n")
if tg_bot_username:
f.write(f"NEXT_PUBLIC_TELEGRAM_BOT_USERNAME={tg_bot_username}\n")
if smtp_host:
f.write(f"EMAIL_SMTP_HOST={smtp_host}\n")
f.write(f"EMAIL_SMTP_PORT=465\n")
f.write("EMAIL_SMTP_PORT=465\n")
f.write(f"EMAIL_SMTP_USER={smtp_user}\n")
f.write(f"EMAIL_SMTP_PASSWORD={smtp_password}\n")
f.write(f"EMAIL_FROM={smtp_from}\n")
@@ -1573,8 +1610,9 @@ def manage_website():
table.add_row("8", "Назад")
console.print(table)
choice = safe_prompt("[bold blue]👉 Выберите действие[/bold blue]",
choices=[str(i) for i in range(1, 9)], show_choices=False)
choice = safe_prompt(
"[bold blue]👉 Выберите действие[/bold blue]", choices=[str(i) for i in range(1, 9)], show_choices=False
)
if choice == "1":
subprocess.run(["docker", "compose", "ps"], cwd=WEB_DIR)
@@ -1653,11 +1691,7 @@ def _parse_solo_brick_semver(tag: str):
def read_installed_solo_brick_version() -> str | None:
"""Читает установленную версию сайта из лейбла скачанного докер-образа.
На клиентской машине исходников нет единственный источник истины это
лейбл `org.opencontainers.image.version`, который CI проставляет при сборке.
"""
"""Версия установленного Solo-brick по лейблу докер-образа."""
for image_ref in (f"ghcr.io/{GHCR_IMAGE}:latest", f"ghcr.io/{GHCR_IMAGE}"):
try:
result = subprocess.run(
@@ -1666,7 +1700,7 @@ def read_installed_solo_brick_version() -> str | None:
"image",
"inspect",
"--format",
"{{index .Config.Labels \"org.opencontainers.image.version\"}}",
'{{index .Config.Labels "org.opencontainers.image.version"}}',
image_ref,
],
capture_output=True,
@@ -1725,8 +1759,7 @@ def show_website_version_banner():
elif cur and nxt:
tag = " [green]✅ Актуально[/green]"
console.print(
f"[dim]Solo-brick:[/dim] установлено [bold]{installed_str}[/bold] · "
f"доступно [bold]{latest_str}[/bold]{tag}"
f"[dim]Solo-brick:[/dim] установлено [bold]{installed_str}[/bold] · доступно [bold]{latest_str}[/bold]{tag}"
)
@@ -1772,7 +1805,9 @@ def main():
elif choice == "2":
if not os.path.exists(VENV_PYTHON):
console.print("[yellow]Виртуальное окружение ещё не создано.[/yellow]")
if safe_confirm("[green]Подготовить окружение через автоматическую установку?[/green]", default=True):
if safe_confirm(
"[green]Подготовить окружение через автоматическую установку?[/green]", default=True
):
install_bot()
continue
if safe_confirm("[green]Вы действительно хотите запустить main.py вручную?[/green]"):
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
from database import async_session_maker
from database.settings_cache import settings_cache
from database.db import warm_pool
from database.settings_cache import settings_cache
from database.tariffs import initialize_all_tariff_weights
from .settings.buttons_config import BUTTONS_CONFIG, load_buttons_config, update_buttons_config
+10 -4
View File
@@ -1,12 +1,15 @@
import asyncio
import atexit
import signal
import multiprocessing
import signal
from collections.abc import Callable
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
from typing import Callable, TypeVar
from typing import TypeVar
from logger import logger
T = TypeVar("T")
_thread_pool: ThreadPoolExecutor | None = None
@@ -32,6 +35,7 @@ def get_thread_pool() -> ThreadPoolExecutor:
global _thread_pool
if _thread_pool is None:
from config import EXECUTOR_POOL_SIZE
size = max(1, int(EXECUTOR_POOL_SIZE))
_thread_pool = ThreadPoolExecutor(max_workers=size, thread_name_prefix="bot-thread")
logger.debug("[Executor] Пул потоков: {} воркеров", size)
@@ -55,6 +59,7 @@ def get_process_pool() -> ProcessPoolExecutor:
global _process_pool
if _process_pool is None:
from config import PROCESS_POOL_SIZE
size = max(1, min(int(PROCESS_POOL_SIZE), multiprocessing.cpu_count() or 4))
ctx = multiprocessing.get_context("spawn")
ctx.Process = _IgnoreSIGINTProcess
@@ -84,6 +89,7 @@ def should_run_heavy_tasks_separately() -> bool:
"""
try:
from config import EXECUTOR_POOL_SIZE
pool_size = max(1, int(EXECUTOR_POOL_SIZE))
except Exception:
pool_size = 1
@@ -91,13 +97,13 @@ def should_run_heavy_tasks_separately() -> bool:
return cpu_count >= 2 or pool_size >= 2
async def run_io(fn: Callable[..., T], *args: object) -> T:
async def run_io[T](fn: Callable[..., T], *args: object) -> T:
"""Выполняет fn(*args) в пуле потоков (I/O). Один вызов для всех блокирующих операций."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(get_thread_pool(), lambda: fn(*args))
async def run_cpu(fn: Callable[..., T], *args: object) -> T:
async def run_cpu[T](fn: Callable[..., T], *args: object) -> T:
"""Выполняет fn(*args) в пуле процессов (CPU). fn — функция уровня модуля (для pickle)."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(get_process_pool(), fn, *args)
Binary file not shown.
+1 -3
View File
@@ -55,9 +55,7 @@ async def _get_redis() -> Any | None:
return client
except Exception as exc:
url_display = REDIS_URL.split("@")[-1] if "@" in REDIS_URL else REDIS_URL
logger.warning(
f"[Redis] Подключение не удалось ({url_display}): {exc}. Повтор через {_REDIS_BACKOFF_SEC} с."
)
logger.warning(f"[Redis] Подключение не удалось ({url_display}): {exc}. Повтор через {_REDIS_BACKOFF_SEC} с.")
_REDIS_UNAVAILABLE_UNTIL = _now() + _REDIS_BACKOFF_SEC
_drop_client(client_key)
return None
Binary file not shown.
+2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import time
from typing import Any
from core.cache_config import (
@@ -10,6 +11,7 @@ from core.cache_config import (
)
from core.redis_cache import cache_get, cache_key, cache_set
_RUNTIME_CONFIGS_KEY = cache_key("runtime_configs")
_REGISTRY: dict[str, dict[str, Any]] = {}
_LOCAL_VERSION = 0.0
+1 -1
View File
@@ -1,3 +1,3 @@
from .lifecycle import ensure_periodic_task_manager_started, ensure_periodic_task_manager_stopped
from .periodic_manager import PeriodicTaskManager, periodic_task_manager
from .registry import register_periodic_tasks
from .lifecycle import ensure_periodic_task_manager_started, ensure_periodic_task_manager_stopped
+1 -3
View File
@@ -51,9 +51,7 @@ async def cleanup_expired_gifts_job() -> None:
async with async_session_maker() as session:
try:
result = await session.execute(
sa_update(Gift)
.where(Gift.expiry_time < datetime.utcnow(), Gift.is_used == False)
.values(is_used=True)
sa_update(Gift).where(Gift.expiry_time < datetime.utcnow(), Gift.is_used is False).values(is_used=True)
)
count = result.rowcount
await session.commit()
+1
View File
@@ -33,6 +33,7 @@ def backup_thread_loop(stop_event, _bot, _sessionmaker) -> None:
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from config import API_TOKEN, BACKUP_TIME
from utils.backup import backup_database
+7 -7
View File
@@ -14,8 +14,10 @@ from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from apscheduler.executors.asyncio import AsyncIOExecutor
from apscheduler.executors.pool import ProcessPoolExecutor as APSchedulerProcessPoolExecutor
from apscheduler.executors.pool import ThreadPoolExecutor as APSchedulerThreadPoolExecutor
from apscheduler.executors.pool import (
ProcessPoolExecutor as APSchedulerProcessPoolExecutor,
ThreadPoolExecutor as APSchedulerThreadPoolExecutor,
)
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.base import BaseTrigger
from sqlalchemy.ext.asyncio import async_sessionmaker
@@ -71,10 +73,10 @@ def _run_process_loop_task(task_id: str, runner: LoopRunner) -> None:
async def _run_process_loop_task_async(task_id: str, runner: LoopRunner) -> None:
from database.db import reset_async_db_engine
from core.bootstrap import bootstrap
from config import API_TOKEN
from core.bootstrap import bootstrap
from database import async_session_maker, init_db
from database.db import reset_async_db_engine
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
logger.info("[PeriodicManager] Process-loop задача {} запущена, PID={}", task_id, os.getpid())
@@ -145,9 +147,7 @@ class PeriodicTaskManager:
if execution_mode not in {"async", "thread", "process"}:
raise ValueError(f"Unsupported execution_mode: {execution_mode}")
if execution_mode != "async" and inspect.iscoroutinefunction(runner):
raise ValueError(
f"Cron task {task_id} with execution_mode={execution_mode} must be a sync function"
)
raise ValueError(f"Cron task {task_id} with execution_mode={execution_mode} must be a sync function")
self._cron_tasks[task_id] = ManagedCronTask(
task_id=task_id,
runner=runner,
+1 -2
View File
@@ -1,13 +1,12 @@
from aiohttp import web
from logger import logger
from core.cache_config import (
WEBHOOK_ABUSE_BLOCK_TTL_SEC,
WEBHOOK_ABUSE_FAIL_THRESHOLD,
WEBHOOK_ABUSE_FAIL_WINDOW_SEC,
)
from core.redis_cache import cache_delete, cache_get, cache_incr, cache_key, cache_set
from logger import logger
def get_webhook_client_ip(request: web.Request) -> str:
+3 -3
View File
@@ -1,18 +1,18 @@
from . import identities
from .audit import *
from .bans import *
from .coupons import *
from .db import Base, async_session_maker, engine, reset_async_db_engine
from .gifts import *
from . import identities
from .hot_leads import *
from .setup.init_db import *
from .keys import *
from .notifications import *
from .payments import *
from .referrals import *
from .servers import *
from .scheduled_broadcasts import *
from .servers import *
from .settings_cache import settings_cache
from .setup.init_db import *
from .statistics import *
from .tariffs import *
from .temporary_data import *
+2 -2
View File
@@ -1,5 +1,5 @@
from dataclasses import dataclass
from enum import Enum
from enum import Enum, StrEnum
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Identity, User
class ActorSurface(str, Enum):
class ActorSurface(StrEnum):
TELEGRAM = "telegram"
WEB = "web"
UNKNOWN = "unknown"
+3 -9
View File
@@ -37,14 +37,8 @@ async def refresh_tg_mirrors_for_user(session: AsyncSession, user_id: int) -> No
await session.execute(update(BlockedUser).where(BlockedUser.user_id == user_id).values(tg_id=tg))
await session.execute(update(ManualBan).where(ManualBan.user_id == user_id).values(tg_id=tg))
await session.execute(
update(Referral).where(Referral.referred_user_id == user_id).values(referred_tg_id=tg)
)
await session.execute(
update(Referral).where(Referral.referrer_user_id == user_id).values(referrer_tg_id=tg)
)
await session.execute(update(Referral).where(Referral.referred_user_id == user_id).values(referred_tg_id=tg))
await session.execute(update(Referral).where(Referral.referrer_user_id == user_id).values(referrer_tg_id=tg))
await session.execute(update(Gift).where(Gift.sender_user_id == user_id).values(sender_tg_id=tg))
await session.execute(
update(Gift).where(Gift.recipient_user_id == user_id).values(recipient_tg_id=tg)
)
await session.execute(update(Gift).where(Gift.recipient_user_id == user_id).values(recipient_tg_id=tg))
+12 -2
View File
@@ -1,13 +1,23 @@
from __future__ import annotations
from collections.abc import Iterable
from datetime import datetime, timedelta, timezone
from typing import Iterable
from sqlalchemy import DateTime as SQLADateTime, and_, cast, delete, desc, func, or_, select
from sqlalchemy import (
DateTime as SQLADateTime,
and_,
cast,
delete,
desc,
func,
or_,
select,
)
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import AuditEvent, Payment
try:
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
except ImportError:
+2 -6
View File
@@ -2,8 +2,8 @@ from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import BlockedUser, User
from database.access.resolution import resolve_user_optional
from database.models import BlockedUser, User
from logger import logger
@@ -30,11 +30,7 @@ async def save_blocked_user_ids(session: AsyncSession, tg_ids: list[int]) -> Non
res = await session.execute(select(User.id, User.tg_id).where(User.tg_id.in_(batch)))
rows = res.all()
uid_by_tg = {int(tgid): int(uid) for uid, tgid in rows if tgid is not None}
values = [
{"user_id": uid_by_tg[int(tg)], "tg_id": int(tg)}
for tg in batch
if int(tg) in uid_by_tg
]
values = [{"user_id": uid_by_tg[int(tg)], "tg_id": int(tg)} for tg in batch if int(tg) in uid_by_tg]
if not values:
continue
stmt = insert(BlockedUser).values(values).on_conflict_do_nothing(index_elements=[BlockedUser.user_id])
+1 -3
View File
@@ -74,9 +74,7 @@ async def get_gift_usage(session: AsyncSession, gift_id: str, user_id: int) -> G
async def count_gift_usages(session: AsyncSession, gift_id: str) -> int:
"""Сколько раз подарок был активирован (для `is_unlimited=False` с лимитом)."""
result = await session.execute(
select(func.count()).select_from(GiftUsage).where(GiftUsage.gift_id == gift_id)
)
result = await session.execute(select(func.count()).select_from(GiftUsage).where(GiftUsage.gift_id == gift_id))
return int(result.scalar_one() or 0)
+21 -29
View File
@@ -1,8 +1,10 @@
import hashlib
import secrets
from datetime import datetime, timedelta
import bcrypt
from sqlalchemy import delete, func, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -422,9 +424,7 @@ async def _transfer_user_data(
await session.execute(update(Notification).where(Notification.user_id == src_uid).values(user_id=dst_uid))
await session.execute(update(Gift).where(Gift.sender_user_id == src_uid).values(sender_user_id=dst_uid))
await session.execute(
update(Gift).where(Gift.recipient_user_id == src_uid).values(recipient_user_id=dst_uid)
)
await session.execute(update(Gift).where(Gift.recipient_user_id == src_uid).values(recipient_user_id=dst_uid))
await session.execute(
text(
@@ -468,31 +468,27 @@ async def _transfer_user_data(
),
{"src": src_uid, "dst": dst_uid},
)
await session.execute(
update(Referral).where(Referral.referred_user_id == src_uid).values(referred_user_id=dst_uid)
)
await session.execute(
update(Referral).where(Referral.referrer_user_id == src_uid).values(referrer_user_id=dst_uid)
)
await session.execute(update(Referral).where(Referral.referred_user_id == src_uid).values(referred_user_id=dst_uid))
await session.execute(update(Referral).where(Referral.referrer_user_id == src_uid).values(referrer_user_id=dst_uid))
await session.execute(
update(WebPushSubscription).where(WebPushSubscription.user_id == src_uid).values(user_id=dst_uid)
)
await session.execute(
update(WebNotification).where(WebNotification.user_id == src_uid).values(user_id=dst_uid)
)
await session.execute(update(WebNotification).where(WebNotification.user_id == src_uid).values(user_id=dst_uid))
dst_ban = (await session.execute(select(ManualBan).where(ManualBan.user_id == dst_uid))).scalar_one_or_none()
src_ban = (await session.execute(select(ManualBan).where(ManualBan.user_id == src_uid))).scalar_one_or_none()
if src_ban is not None and dst_ban is None:
session.add(ManualBan(
user_id=dst_uid,
tg_id=dst_tg,
banned_at=src_ban.banned_at,
reason=src_ban.reason,
banned_by=src_ban.banned_by,
until=src_ban.until,
))
session.add(
ManualBan(
user_id=dst_uid,
tg_id=dst_tg,
banned_at=src_ban.banned_at,
reason=src_ban.reason,
banned_by=src_ban.banned_by,
until=src_ban.until,
)
)
dst_block = (await session.execute(select(BlockedUser).where(BlockedUser.user_id == dst_uid))).scalar_one_or_none()
src_block = (await session.execute(select(BlockedUser).where(BlockedUser.user_id == src_uid))).scalar_one_or_none()
@@ -518,8 +514,8 @@ async def _transfer_user_data(
async def merge_billing_user_into_telegram(session: AsyncSession, identity_id: str, telegram_tg_id: int) -> None:
from database.models import User as _User # noqa: F401
from database.access.resolution import resolve_user_optional
from database.models import User as _User # noqa: F401
from database.users import update_balance
res = await session.execute(select(User).where(User.identity_id == identity_id))
@@ -593,9 +589,7 @@ async def attach_email(session: AsyncSession, identity_id: str, email: str) -> I
if not can_merge:
return None
src_user = (
await session.execute(select(User).where(User.identity_id == existing.id))
).scalars().first()
src_user = (await session.execute(select(User).where(User.identity_id == existing.id))).scalars().first()
dst_uid = await ensure_billing_user_for_identity(session, identity)
dst_tg = int(identity.tg_id) if identity.tg_id is not None else None
@@ -633,8 +627,8 @@ async def attach_telegram(session: AsyncSession, identity_id: str, tg_id: int) -
return None
existing = await get_identity_by_tg_id(session, tg_id)
if existing and existing.id != identity_id:
our_email = (str(identity.email).strip().lower() if identity.email else None)
their_email = (str(existing.email).strip().lower() if existing.email else None)
our_email = str(identity.email).strip().lower() if identity.email else None
their_email = str(existing.email).strip().lower() if existing.email else None
can_merge = their_email is None or (our_email is not None and their_email == our_email)
if not can_merge:
return None
@@ -687,9 +681,7 @@ async def detach_telegram(session: AsyncSession, identity_id: str) -> Identity |
old_tg = int(identity.tg_id)
identity.tg_id = None
identity.is_admin = False
await session.execute(
update(User).where(User.identity_id == identity_id, User.tg_id == old_tg).values(tg_id=None)
)
await session.execute(update(User).where(User.identity_id == identity_id, User.tg_id == old_tg).values(tg_id=None))
await session.refresh(identity)
return identity
+12 -25
View File
@@ -1,4 +1,5 @@
import asyncio
from datetime import UTC, datetime
from types import SimpleNamespace
@@ -6,9 +7,9 @@ from sqlalchemy import delete, func, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from core.cache_config import (
KEYS_LIST_CACHE_TTL_SEC,
KEY_COUNT_CACHE_TTL_SEC,
KEY_DETAILS_CACHE_TTL_SEC,
KEYS_LIST_CACHE_TTL_SEC,
)
from core.redis_cache import cache_delete, cache_get, cache_key, cache_set
from database.access.resolution import resolve_user_optional
@@ -312,17 +313,13 @@ async def get_key_count(session: AsyncSession, legacy_user_ref: int) -> int:
async def get_key_by_user_and_email(session: AsyncSession, user_id: int, email: str) -> Key | None:
"""Возвращает ORM-объект Key по паре (users.id, email) или None."""
result = await session.execute(
select(Key).where(Key.user_id == int(user_id), Key.email == email)
)
result = await session.execute(select(Key).where(Key.user_id == int(user_id), Key.email == email))
return result.scalar_one_or_none()
async def delete_key_by_user_and_email(session: AsyncSession, user_id: int, email: str) -> None:
"""Удаляет ключ по паре (users.id, email). Commit — ответственность caller'а."""
await session.execute(
delete(Key).where(Key.user_id == int(user_id), Key.email == email)
)
await session.execute(delete(Key).where(Key.user_id == int(user_id), Key.email == email))
async def get_user_keys_with_servers_by_email(
@@ -366,19 +363,13 @@ async def get_user_keys_with_servers_by_email(
return rows
async def get_key_client_id_by_email_and_server(
session: AsyncSession, email: str, server_id: str
) -> str | None:
async def get_key_client_id_by_email_and_server(session: AsyncSession, email: str, server_id: str) -> str | None:
"""Возвращает ``client_id`` первого ключа для пары (email, server_id).
Используется для remnawave traffic reset, где нам нужен только client_id,
без остальных полей ключа.
"""
result = await session.execute(
select(Key.client_id)
.where(Key.email == email, Key.server_id == server_id)
.limit(1)
)
result = await session.execute(select(Key.client_id).where(Key.email == email, Key.server_id == server_id).limit(1))
return result.scalar()
@@ -389,9 +380,7 @@ async def count_keys_by_server_id(session: AsyncSession, server_id: str) -> int:
(у ``keys.server_id`` колонка типа String, содержит либо cluster_name,
либо server_name в зависимости от страны/кластера).
"""
result = await session.execute(
select(func.count()).select_from(Key).where(Key.server_id == server_id)
)
result = await session.execute(select(func.count()).select_from(Key).where(Key.server_id == server_id))
return int(result.scalar() or 0)
@@ -413,9 +402,7 @@ async def count_active_keys_for_user(session: AsyncSession, user_id: int) -> int
Используется в проверке "новый пользователь" для купонных правил.
"""
result = await session.execute(
select(func.count())
.select_from(Key)
.where(Key.user_id == int(user_id), Key.is_frozen.is_(False))
select(func.count()).select_from(Key).where(Key.user_id == int(user_id), Key.is_frozen.is_(False))
)
return int(result.scalar() or 0)
@@ -424,9 +411,7 @@ async def delete_key(session: AsyncSession, identifier: int | str):
legacy_for_cache = None
email_for_cache = None
if isinstance(identifier, str):
res = await session.execute(
select(Key.user_id, Key.email).where(Key.client_id == identifier).limit(1)
)
res = await session.execute(select(Key.user_id, Key.email).where(Key.client_id == identifier).limit(1))
row = res.first()
if row:
legacy_for_cache, email_for_cache = row[0], row[1]
@@ -675,7 +660,9 @@ async def save_key_tariff_selection(
if u is None:
return
selected_devices_val = int(selected_devices) if selected_devices is not None else None
selected_traffic_val = int(selected_traffic_gb) if selected_traffic_gb is not None and int(selected_traffic_gb) > 0 else None
selected_traffic_val = (
int(selected_traffic_gb) if selected_traffic_gb is not None and int(selected_traffic_gb) > 0 else None
)
await session.execute(
update(Key)
+13 -29
View File
@@ -45,9 +45,7 @@ async def _ensure_migrations_table(conn: AsyncConnection) -> None:
async def _get_current_version(conn: AsyncConnection) -> int:
await _ensure_migrations_table(conn)
r = await conn.execute(
text("SELECT COALESCE(MAX(version), 0) FROM schema_migrations")
)
r = await conn.execute(text("SELECT COALESCE(MAX(version), 0) FROM schema_migrations"))
row = r.first()
return int(row[0]) if row else 0
@@ -186,9 +184,7 @@ async def _drop_pk(conn: AsyncConnection, table: str) -> None:
async def _column_has_nulls(conn: AsyncConnection, table: str, column: str) -> bool:
r = await conn.execute(
text(f'SELECT 1 FROM "{table}" WHERE "{column}" IS NULL LIMIT 1')
)
r = await conn.execute(text(f'SELECT 1 FROM "{table}" WHERE "{column}" IS NULL LIMIT 1'))
return r.first() is not None
@@ -309,9 +305,7 @@ async def _migration_v2_add_user_id_columns(conn: AsyncConnection) -> None:
await conn.execute(text(f'ALTER TABLE "{table}" ADD COLUMN {column} BIGINT'))
async def _backfill_users_from_table(
conn: AsyncConnection, table: str, tg_col: str = "tg_id"
) -> int:
async def _backfill_users_from_table(conn: AsyncConnection, table: str, tg_col: str = "tg_id") -> int:
"""Auto-создание users для orphan tg_id'ов из указанной таблицы.
Legacy клиенты обновляются с TG-only схемы (где только tg_id), и в связанных
@@ -358,9 +352,7 @@ async def _backfill_users_from_table(
)
created = result.rowcount or 0
if created > 0:
logger.info(
f"[schema_upgrade] users backfill: создано {created} юзеров из orphan {table}.{tg_col}"
)
logger.info(f"[schema_upgrade] users backfill: создано {created} юзеров из orphan {table}.{tg_col}")
return created
@@ -400,9 +392,7 @@ async def _migration_v3_populate_user_ids(conn: AsyncConnection) -> None:
if updated > 0:
logger.debug(f"[schema_upgrade] v3: заполнено {updated} записей {user_col} в {table}")
null_count = await conn.execute(
text(f'SELECT COUNT(*) FROM "{table}" WHERE {user_col} IS NULL')
)
null_count = await conn.execute(text(f'SELECT COUNT(*) FROM "{table}" WHERE {user_col} IS NULL'))
nulls = null_count.scalar()
if nulls > 0:
logger.warning(f"[schema_upgrade] v3: в {table} осталось {nulls} записей с NULL {user_col}")
@@ -511,17 +501,13 @@ async def _migration_v5_switch_pks_to_user_id(conn: AsyncConnection) -> None:
await _drop_pk(conn, "referrals")
await conn.execute(text("ALTER TABLE referrals ALTER COLUMN referred_user_id SET NOT NULL"))
await conn.execute(text("ALTER TABLE referrals ALTER COLUMN referrer_user_id SET NOT NULL"))
await conn.execute(
text("ALTER TABLE referrals ADD PRIMARY KEY (referred_user_id, referrer_user_id)")
)
await conn.execute(text("ALTER TABLE referrals ADD PRIMARY KEY (referred_user_id, referrer_user_id)"))
else:
logger.warning("[schema_upgrade] referrals содержит NULL user_id, пропуск перевода PK")
if await _table_exists(conn, "notifications") and await _safe_set_not_null(conn, "notifications", "user_id"):
await _drop_pk(conn, "notifications")
await conn.execute(
text("ALTER TABLE notifications ADD PRIMARY KEY (user_id, notification_type)")
)
await conn.execute(text("ALTER TABLE notifications ADD PRIMARY KEY (user_id, notification_type)"))
if await _table_exists(conn, "gift_usages") and await _safe_set_not_null(conn, "gift_usages", "user_id"):
await _drop_pk(conn, "gift_usages")
@@ -980,6 +966,7 @@ async def _migration_v14_web_flow_graph_model(conn: AsyncConnection) -> None:
"target": node_id,
})
import json
await conn.execute(
text("UPDATE web_flows SET nodes = :nodes, edges = :edges, entry_node_id = :entry WHERE id = :fid"),
{"nodes": json.dumps(new_nodes), "edges": json.dumps(new_edges), "entry": entry_id, "fid": flow_id},
@@ -1054,10 +1041,7 @@ async def _migration_v15_recover_orphan_users(conn: AsyncConnection) -> None:
)
)
if result.rowcount and result.rowcount > 0:
logger.info(
f"[schema_upgrade] v15: повторно заполнено {result.rowcount} записей "
f"{table}.{user_col}"
)
logger.info(f"[schema_upgrade] v15: повторно заполнено {result.rowcount} записей {table}.{user_col}")
async def _migration_v18_web_error_reports(conn: AsyncConnection) -> None:
@@ -1206,10 +1190,10 @@ async def _migration_v20_add_identity_google_sub(conn: AsyncConnection) -> None:
if not await _table_exists(conn, "identities"):
return
if not await _column_exists(conn, "identities", "google_sub"):
await _exec_ignore(conn, 'ALTER TABLE identities ADD COLUMN google_sub VARCHAR(64)')
await _exec_ignore(conn, "ALTER TABLE identities ADD COLUMN google_sub VARCHAR(64)")
await _exec_ignore(
conn,
'CREATE UNIQUE INDEX IF NOT EXISTS ix_identities_google_sub ON identities (google_sub) WHERE google_sub IS NOT NULL',
"CREATE UNIQUE INDEX IF NOT EXISTS ix_identities_google_sub ON identities (google_sub) WHERE google_sub IS NOT NULL",
)
@@ -1218,10 +1202,10 @@ async def _migration_v21_add_identity_yandex_sub(conn: AsyncConnection) -> None:
if not await _table_exists(conn, "identities"):
return
if not await _column_exists(conn, "identities", "yandex_sub"):
await _exec_ignore(conn, 'ALTER TABLE identities ADD COLUMN yandex_sub VARCHAR(64)')
await _exec_ignore(conn, "ALTER TABLE identities ADD COLUMN yandex_sub VARCHAR(64)")
await _exec_ignore(
conn,
'CREATE UNIQUE INDEX IF NOT EXISTS ix_identities_yandex_sub ON identities (yandex_sub) WHERE yandex_sub IS NOT NULL',
"CREATE UNIQUE INDEX IF NOT EXISTS ix_identities_yandex_sub ON identities (yandex_sub) WHERE yandex_sub IS NOT NULL",
)
+1
View File
@@ -18,6 +18,7 @@ from ._base import Base, DictLikeMixin
class AuditEvent(DictLikeMixin, Base):
"""События аудита (флоу пользователя)."""
__tablename__ = "audit_events"
__table_args__ = (
Index("ix_audit_events_tg_created", "actor_tg_id", "created_at"),
+3 -1
View File
@@ -6,7 +6,9 @@ from ._base import Base, DictLikeMixin
class Key(DictLikeMixin, Base):
__tablename__ = "keys"
user_id = Column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True, nullable=False, index=True)
user_id = Column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True, nullable=False, index=True
)
client_id = Column(String, primary_key=True)
tg_id = Column(BigInteger, ForeignKey("users.tg_id"), nullable=True, index=True)
email = Column(String, unique=True)
+4 -4
View File
@@ -74,9 +74,7 @@ class WebPageVariantBlock(DictLikeMixin, Base):
class WebPushSubscription(DictLikeMixin, Base):
__tablename__ = "web_push_subscriptions"
__table_args__ = (
Index("ix_web_push_subscriptions_user_id", "user_id"),
)
__table_args__ = (Index("ix_web_push_subscriptions_user_id", "user_id"),)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(BigInteger, nullable=False)
@@ -124,7 +122,9 @@ class WebErrorReport(DictLikeMixin, Base):
count = Column(Integer, nullable=False, default=1)
resolved = Column(Boolean, nullable=False, default=False)
first_seen_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
last_seen_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
last_seen_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
)
class WebFlowEvent(DictLikeMixin, Base):
+22 -31
View File
@@ -7,8 +7,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from config import DISCOUNT_ACTIVE_HOURS
from core.bootstrap import NOTIFICATIONS_CONFIG
from database.models import BlockedUser, Key, Notification, User
from database.access.resolution import resolve_user_optional
from database.models import BlockedUser, Key, Notification, User
from logger import logger
@@ -93,17 +93,15 @@ async def bulk_add_notifications(session: AsyncSession, items: list[tuple[int, s
total = 0
for i in range(0, len(mapped), _BULK_ADD_NOTIFICATIONS_BATCH_SIZE):
batch = mapped[i : i + _BULK_ADD_NOTIFICATIONS_BATCH_SIZE]
ins = insert(Notification).values(
[
{
"user_id": uid,
"tg_id": tg_by_uid.get(uid),
"notification_type": ntype,
"last_notification_time": now,
}
for uid, ntype in batch
]
)
ins = insert(Notification).values([
{
"user_id": uid,
"tg_id": tg_by_uid.get(uid),
"notification_type": ntype,
"last_notification_time": now,
}
for uid, ntype in batch
])
stmt = ins.on_conflict_do_update(
index_elements=[Notification.user_id, Notification.notification_type],
set_={
@@ -130,15 +128,15 @@ async def bulk_delete_notifications(session: AsyncSession, items: list[tuple[int
total = 0
for i in range(0, len(mapped), _BULK_ADD_NOTIFICATIONS_BATCH_SIZE):
batch = mapped[i : i + _BULK_ADD_NOTIFICATIONS_BATCH_SIZE]
stmt = delete(Notification).where(
tuple_(Notification.user_id, Notification.notification_type).in_(batch)
)
stmt = delete(Notification).where(tuple_(Notification.user_id, Notification.notification_type).in_(batch))
await session.execute(stmt)
total += len(batch)
logger.debug(f"🗑 Bulk: удалено {total} уведомлений")
async def check_notification_time(session: AsyncSession, legacy_user_ref: int, notification_type: str, hours: int = 12) -> bool:
async def check_notification_time(
session: AsyncSession, legacy_user_ref: int, notification_type: str, hours: int = 12
) -> bool:
u = await resolve_user_optional(session, legacy_user_ref)
if u is None:
return True
@@ -170,8 +168,7 @@ async def check_notification_time_bulk(
can_notify = set()
found = set()
for batch in (
items[i : i + _NOTIFICATION_TIME_BATCH_SIZE]
for i in range(0, len(items), _NOTIFICATION_TIME_BATCH_SIZE)
items[i : i + _NOTIFICATION_TIME_BATCH_SIZE] for i in range(0, len(items), _NOTIFICATION_TIME_BATCH_SIZE)
):
id_map = await _map_legacy_refs_to_user_ids(session, [p[0] for p in batch])
mapped_batch = [(id_map[r], n) for r, n in batch if r in id_map]
@@ -255,9 +252,7 @@ _HOT_LEAD_NOTIFICATION_TYPES = (
)
async def get_hot_lead_notification_flags(
session: AsyncSession, tg_ids: list[int]
) -> dict[int, set[str]]:
async def get_hot_lead_notification_flags(session: AsyncSession, tg_ids: list[int]) -> dict[int, set[str]]:
"""
Один запрос: для каждого tg_id возвращает множество типов уведомлений hot_lead_*,
которые у него уже есть. Используется в notify_hot_leads для устранения N+1.
@@ -336,15 +331,12 @@ async def check_notifications_bulk(
now = _utc_now()
if notification_type == "inactive_trial":
stmt_inactive = (
select(User.id)
.where(
and_(
User.trial.in_([0, -1]),
User.tg_id.isnot(None),
~User.id.in_(select(BlockedUser.user_id)),
~User.id.in_(select(Key.user_id.distinct())),
)
stmt_inactive = select(User.id).where(
and_(
User.trial.in_([0, -1]),
User.tg_id.isnot(None),
~User.id.in_(select(BlockedUser.user_id)),
~User.id.in_(select(Key.user_id.distinct())),
)
)
result_inactive = await session.execute(stmt_inactive)
@@ -559,4 +551,3 @@ async def check_notifications_bulk(
logger.info(f"Найдено {len(users)} пользователей, готовых к уведомлению типа {notification_type}")
return users
+4 -12
View File
@@ -128,17 +128,13 @@ async def check_server_name_by_cluster(session: AsyncSession, server_name: str)
async def get_panel_types_for_cluster(session: AsyncSession, cluster_name: str) -> list[str]:
"""Список panel_type всех серверов кластера (для проверки "весь remnawave")."""
result = await session.execute(
select(Server.panel_type).where(Server.cluster_name == cluster_name)
)
result = await session.execute(select(Server.panel_type).where(Server.cluster_name == cluster_name))
return list(result.scalars().all())
async def get_panel_type_for_server(session: AsyncSession, server_name: str) -> str | None:
"""Возвращает panel_type конкретного сервера по его имени."""
result = await session.execute(
select(Server.panel_type).where(Server.server_name == server_name)
)
result = await session.execute(select(Server.panel_type).where(Server.server_name == server_name))
return result.scalar_one_or_none()
@@ -152,17 +148,13 @@ async def get_enabled_server_subscription_url(session: AsyncSession, server_name
async def cluster_name_exists(session: AsyncSession, cluster_name: str) -> bool:
"""Есть ли хоть один сервер с таким cluster_name."""
result = await session.execute(
select(Server).where(Server.cluster_name == cluster_name).limit(1)
)
result = await session.execute(select(Server).where(Server.cluster_name == cluster_name).limit(1))
return result.scalars().first() is not None
async def get_cluster_name_for_server_name(session: AsyncSession, server_name: str) -> str | None:
"""Возвращает cluster_name для указанного server_name (строго по server_name)."""
result = await session.execute(
select(Server.cluster_name).where(Server.server_name == server_name).limit(1)
)
result = await session.execute(select(Server.cluster_name).where(Server.server_name == server_name).limit(1))
return result.scalar()
+1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import threading
from datetime import datetime
from typing import Any
+5 -13
View File
@@ -6,7 +6,7 @@ from datetime import datetime
from sqlalchemy import delete, func, insert, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from core.cache_config import TARIFF_BY_ID_CACHE_TTL_SEC, TARIFFS_FOR_CLUSTER_CACHE_TTL_SEC
from core.cache_config import TARIFFS_FOR_CLUSTER_CACHE_TTL_SEC, TARIFF_BY_ID_CACHE_TTL_SEC
from core.redis_cache import cache_delete, cache_delete_pattern, cache_get, cache_key, cache_set
from database.models import Server, Tariff
from logger import logger
@@ -143,9 +143,7 @@ async def get_tariff_group_codes(session: AsyncSession) -> list[str]:
async def get_active_tariff_by_id(session: AsyncSession, tariff_id: int) -> Tariff | None:
"""Возвращает ORM-объект Tariff по id, если тариф активен (is_active=True)."""
result = await session.execute(
select(Tariff).where(Tariff.id == int(tariff_id), Tariff.is_active.is_(True))
)
result = await session.execute(select(Tariff).where(Tariff.id == int(tariff_id), Tariff.is_active.is_(True)))
return result.scalar_one_or_none()
@@ -161,9 +159,7 @@ async def get_tariffs_for_cluster(session: AsyncSession, cluster_name: str):
cached = await cache_get(key)
if isinstance(cached, list):
return cached
server_row = await session.execute(
select(Server.tariff_group).where(Server.cluster_name == cluster_name).limit(1)
)
server_row = await session.execute(select(Server.tariff_group).where(Server.cluster_name == cluster_name).limit(1))
row = server_row.first()
if not row:
@@ -245,9 +241,7 @@ async def get_vless_enabled(session: AsyncSession, tariff_id: int | None) -> boo
return bool(tariff.get("vless"))
async def get_vless_enabled_batch(
session: AsyncSession, tariff_ids: list[int]
) -> dict[int, bool]:
async def get_vless_enabled_batch(session: AsyncSession, tariff_ids: list[int]) -> dict[int, bool]:
"""
Один запрос: для списка tariff_id возвращает dict[tariff_id -> vless].
Использовать в списках ключей вместо N вызовов get_vless_enabled.
@@ -255,9 +249,7 @@ async def get_vless_enabled_batch(
if not tariff_ids:
return {}
unique_ids = list(dict.fromkeys(tariff_ids))
result = await session.execute(
select(Tariff.id, Tariff.vless).where(Tariff.id.in_(unique_ids))
)
result = await session.execute(select(Tariff.id, Tariff.vless).where(Tariff.id.in_(unique_ids)))
return {row[0]: bool(row[1]) for row in result.all()}
+3 -9
View File
@@ -156,9 +156,7 @@ async def set_user_balance(
async def get_user_preferred_currency(session: AsyncSession, tg_id: int) -> str | None:
"""Предпочитаемая валюта пользователя по ``tg_id``, если установлена."""
result = await session.execute(
select(User.preferred_currency).where(User.tg_id == int(tg_id))
)
result = await session.execute(select(User.preferred_currency).where(User.tg_id == int(tg_id)))
return result.scalar()
@@ -169,9 +167,7 @@ async def mark_trial_started_if_eligible(session: AsyncSession, tg_id: int) -> N
Используется в `services.operations.creation.create_key_on_cluster` после
успешного создания ключа.
"""
await session.execute(
update(User).where(User.tg_id == tg_id, User.trial.in_([0, -1])).values(trial=1)
)
await session.execute(update(User).where(User.tg_id == tg_id, User.trial.in_([0, -1])).values(trial=1))
async def update_trial(session: AsyncSession, legacy_user_ref: int, status: int):
@@ -354,9 +350,7 @@ async def delete_user_data(session: AsyncSession, legacy_user_ref: int):
await session.execute(delete(WebPushSubscription).where(WebPushSubscription.user_id == uid))
await session.execute(delete(WebNotification).where(WebNotification.user_id == uid))
await session.execute(
update(ScheduledBroadcast)
.where(ScheduledBroadcast.created_by_user_id == uid)
.values(created_by_user_id=None)
update(ScheduledBroadcast).where(ScheduledBroadcast.created_by_user_id == uid).values(created_by_user_id=None)
)
await session.execute(delete(User).where(User.id == uid))
+39 -38
View File
@@ -17,49 +17,51 @@ async def upsert_push_subscription(
keys_json: dict,
) -> WebPushSubscription:
"""Upsert push subscription by endpoint (unique)."""
stmt = pg_insert(WebPushSubscription).values(
user_id=user_id,
identity_id=identity_id,
endpoint=endpoint,
keys_json=keys_json,
created_at=datetime.now(UTC),
).on_conflict_do_update(
index_elements=["endpoint"],
set_={
"user_id": user_id,
"identity_id": identity_id,
"keys_json": keys_json,
"created_at": datetime.now(UTC),
},
).returning(WebPushSubscription)
stmt = (
pg_insert(WebPushSubscription)
.values(
user_id=user_id,
identity_id=identity_id,
endpoint=endpoint,
keys_json=keys_json,
created_at=datetime.now(UTC),
)
.on_conflict_do_update(
index_elements=["endpoint"],
set_={
"user_id": user_id,
"identity_id": identity_id,
"keys_json": keys_json,
"created_at": datetime.now(UTC),
},
)
.returning(WebPushSubscription)
)
result = await session.execute(stmt)
return result.scalar_one()
async def get_push_subscriptions_by_user(
session: AsyncSession, user_id: int,
session: AsyncSession,
user_id: int,
) -> list[WebPushSubscription]:
result = await session.execute(
select(WebPushSubscription).where(WebPushSubscription.user_id == user_id)
)
result = await session.execute(select(WebPushSubscription).where(WebPushSubscription.user_id == user_id))
return list(result.scalars().all())
async def get_push_subscriptions_by_identity(
session: AsyncSession, identity_id: str,
session: AsyncSession,
identity_id: str,
) -> list[WebPushSubscription]:
result = await session.execute(
select(WebPushSubscription).where(WebPushSubscription.identity_id == identity_id)
)
result = await session.execute(select(WebPushSubscription).where(WebPushSubscription.identity_id == identity_id))
return list(result.scalars().all())
async def delete_push_subscription_by_endpoint(
session: AsyncSession, endpoint: str,
session: AsyncSession,
endpoint: str,
) -> None:
await session.execute(
delete(WebPushSubscription).where(WebPushSubscription.endpoint == endpoint)
)
await session.execute(delete(WebPushSubscription).where(WebPushSubscription.endpoint == endpoint))
async def get_notifications_for_identity(
@@ -79,7 +81,8 @@ async def get_notifications_for_identity(
async def count_unread_for_identity(
session: AsyncSession, identity_id: str,
session: AsyncSession,
identity_id: str,
) -> int:
result = await session.execute(
select(func.count())
@@ -93,7 +96,8 @@ async def count_unread_for_identity(
async def mark_all_read_for_identity(
session: AsyncSession, identity_id: str,
session: AsyncSession,
identity_id: str,
) -> int:
result = await session.execute(
update(WebNotification)
@@ -107,12 +111,11 @@ async def mark_all_read_for_identity(
async def resolve_identity_id_by_tg_id(
session: AsyncSession, tg_id: int,
session: AsyncSession,
tg_id: int,
) -> str | None:
"""Resolve identity_id from user's tg_id."""
result = await session.execute(
select(User.identity_id).where(User.tg_id == tg_id)
)
result = await session.execute(select(User.identity_id).where(User.tg_id == tg_id))
return result.scalar_one_or_none()
@@ -153,6 +156,7 @@ def _render_template(template: str, **kwargs: object) -> str:
def _get_web_config_str(key: str, default: str) -> str:
try:
from core.settings.web_config import WEB_CONFIG
val = WEB_CONFIG.get(key)
return str(val).strip() if val else default
except Exception:
@@ -209,16 +213,13 @@ async def notify_web(
data=data,
)
try:
from services.web_push import push_enabled, send_push_to_many
if push_enabled():
subs = await get_push_subscriptions_by_identity(session, identity_id)
if subs:
sub_infos = [
{"endpoint": s.endpoint, "keys": s.keys_json}
for s in subs
]
sub_infos = [{"endpoint": s.endpoint, "keys": s.keys_json} for s in subs]
sent = await send_push_to_many(
sub_infos,
title=resolved_title,
+2 -2
View File
@@ -44,7 +44,7 @@ class IsAdminFilter(BaseFilter):
_set_cached_admin(user_id, is_admin, is_super)
await session.commit()
return is_admin
except (Exception,):
except Exception:
return False
@@ -69,5 +69,5 @@ class IsSuperAdminFilter(BaseFilter):
_set_cached_admin(user_id, True, is_super)
await session.commit()
return is_super
except (Exception,):
except Exception:
return False
+1 -1
View File
@@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from config import USERNAME_BOT
from core.cache_config import START_UTM_EXISTS_TTL_SEC
from core.redis_cache import cache_key, cache_delete, cache_set
from core.redis_cache import cache_delete, cache_key, cache_set
from database import create_tracking_source, get_tracking_source_stats
from database.models import TrackingSource, User
from filters.admin import IsAdminFilter
+2 -1
View File
@@ -2,9 +2,10 @@ from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import build_admin_back_btn
from handlers.buttons import BACK
from ..panel.keyboard import build_admin_back_btn
class AdminAdsCallback(CallbackData, prefix="admin_ads"):
action: str
+12 -12
View File
@@ -13,8 +13,8 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from database import delete_user_data
from database.models import BlockedUser, Key, ManualBan, User
from database.access.resolution import resolve_user_optional
from database.models import BlockedUser, Key, ManualBan, User
from database.users import add_user
from filters.admin import IsAdminFilter
from logger import logger
@@ -115,7 +115,9 @@ async def handle_bans_export(callback_query: CallbackQuery, session: AsyncSessio
async def handle_bans_delete_banned(callback_query: CallbackQuery, session: AsyncSession):
kb = build_blocked_users_kb()
try:
stmt = select(BlockedUser.user_id).outerjoin(Key, BlockedUser.user_id == Key.user_id).where(Key.user_id.is_(None))
stmt = (
select(BlockedUser.user_id).outerjoin(Key, BlockedUser.user_id == Key.user_id).where(Key.user_id.is_(None))
)
result = await session.execute(stmt)
blocked_ids = [row[0] for row in result.all()]
@@ -377,16 +379,14 @@ async def handle_preemptive_ids_input(message: Message, state: FSMContext, sessi
u = await resolve_user_optional(session, raw_tg)
if u is None:
continue
rows.append(
{
"user_id": u.id,
"tg_id": u.tg_id,
"reason": "shadow",
"banned_by": message.from_user.id,
"until": None,
"banned_at": now,
}
)
rows.append({
"user_id": u.id,
"tg_id": u.tg_id,
"reason": "shadow",
"banned_by": message.from_user.id,
"until": None,
"banned_at": now,
})
if u.tg_id is not None:
cache_tg_ids.append(u.tg_id)
+2 -1
View File
@@ -1,8 +1,9 @@
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback
from handlers.buttons import BACK
from ..panel.keyboard import AdminPanelCallback
def build_bans_kb():
builder = InlineKeyboardBuilder()
+1
View File
@@ -1,6 +1,7 @@
from . import cluster_manage, cluster_sync, cluster_tariffs, cluster_transfers, cluster_wizard
from .base import AdminClusterStates, router
__all__ = [
"router",
"AdminClusterStates",
+1 -1
View File
@@ -9,9 +9,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from database import get_servers, update_key_expiry
from database.models import Key, Server, Tariff
from filters.admin import IsAdminFilter
from logger import logger
from middlewares.session import release_session_early
from services.operations import renew_key_in_cluster
from logger import logger
from ..panel.keyboard import build_admin_back_kb
from .base import AdminClusterStates, router
+3 -3
View File
@@ -20,15 +20,15 @@ from core.bootstrap import MODES_CONFIG
from database import get_servers
from database.models import Key, Server, Tariff, User
from filters.admin import IsAdminFilter
from handlers.utils import ALLOWED_GROUP_CODES
from logger import logger
from panels.remnawave import RemnawaveAPI
from services.operations import (
create_client_on_server,
create_key_on_cluster,
delete_key_from_cluster,
)
from services.operations.aggregated_links import make_aggregated_link
from handlers.utils import ALLOWED_GROUP_CODES
from logger import logger
from panels.remnawave import RemnawaveAPI
from utils.backup import create_backup_and_send_to_admins
from ..panel.keyboard import build_admin_back_kb
+1 -1
View File
@@ -3,11 +3,11 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from sqlalchemy import delete, update
from sqlalchemy.ext.asyncio import AsyncSession
from filters.admin import IsAdminFilter
from config import USE_COUNTRY_SELECTION
from core.bootstrap import MODES_CONFIG
from database.models import Key, Server
from filters.admin import IsAdminFilter
from logger import logger
from ..panel.keyboard import build_admin_back_kb
+2 -1
View File
@@ -2,9 +2,10 @@ from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
from ..servers.keyboard import AdminServerCallback
from handlers.buttons import BACK
class AdminClusterCallback(CallbackData, prefix="admin_cluster"):
@@ -28,6 +28,7 @@ from .keyboard import (
format_coupons_list,
)
router = Router()
+3 -2
View File
@@ -1,16 +1,17 @@
from __future__ import annotations
from typing import Iterable
from collections.abc import Iterable
from aiogram import F, Router
from aiogram.enums import MessageEntityType
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.enums import MessageEntityType
from aiogram.types import CallbackQuery, Message, MessageEntity
from aiogram.utils.keyboard import InlineKeyboardBuilder
from filters.admin import IsAdminFilter
from handlers.buttons import BACK
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
+3 -3
View File
@@ -8,16 +8,16 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from filters.admin import IsAdminFilter
from database.models import Gift, GiftUsage
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash, get_tariffs
from filters.admin import IsAdminFilter
from handlers.buttons import BACK
from handlers.texts import get_site_gift_link
from handlers.utils import edit_or_send_message, format_days, format_months
from logger import logger
from ..panel.keyboard import AdminPanelCallback
from .keyboard import build_admin_gifts_kb, build_gifts_list_kb
from handlers.buttons import BACK
from handlers.texts import get_site_gift_link
router = Router()
+2
View File
@@ -1,9 +1,11 @@
from aiogram import Router
router = Router()
from . import admins, database, domain, file_upload, import_3xui, import_remnawave, maintenance
__all__ = (
"router",
"admins",
+13 -2
View File
@@ -18,6 +18,7 @@ from filters.admin import IsAdminFilter
from logger import logger
from utils.backup import _find_docker_postgres_container
_PG_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
@@ -26,6 +27,7 @@ def _safe_pg_identifier(value: str, label: str) -> str:
raise ValueError(f"Недопустимый PostgreSQL-идентификатор ({label}): {value!r}")
return value
from . import router
from .keyboard import AdminPanelCallback, build_back_to_db_menu, build_database_kb, build_export_db_sources_kb
@@ -174,8 +176,17 @@ def sync_restore_database(
return False, "pg_restore не найден на хосте и контейнер PostgreSQL не обнаружен"
result = subprocess.run(
[
"pg_restore", f"--dbname={db_name}", "-U", db_user,
"-h", pg_host, "-p", pg_port, "--no-owner", "--exit-on-error", tmp_path,
"pg_restore",
f"--dbname={db_name}",
"-U",
db_user,
"-h",
pg_host,
"-p",
pg_port,
"--no-owner",
"--exit-on-error",
tmp_path,
],
capture_output=True,
text=True,
+3 -6
View File
@@ -5,10 +5,10 @@ from aiogram.types import CallbackQuery, Message
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from filters.admin import IsAdminFilter
from database.models import Key, User
from services.operations import update_subscription
from filters.admin import IsAdminFilter
from logger import logger
from services.operations import update_subscription
from . import router
from .keyboard import AdminPanelCallback, build_back_to_db_menu, build_post_import_kb
@@ -70,10 +70,7 @@ async def handle_resync_after_import(callback: CallbackQuery, session: AsyncSess
await callback.answer("🔁 Начинаю перевыпуск подписок...")
result = await session.execute(
select(User.tg_id, Key.email)
.select_from(Key)
.join(User, Key.user_id == User.id)
.where(User.tg_id.isnot(None))
select(User.tg_id, Key.email).select_from(Key).join(User, Key.user_id == User.id).where(User.tg_id.isnot(None))
)
keys = result.all()
+1 -1
View File
@@ -6,10 +6,10 @@ from aiogram.types import CallbackQuery, Message
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.executor import run_io
from database.models import Admin
from filters.admin import IsAdminFilter
from logger import logger
from core.executor import run_io
from utils.versioning import get_version
from .keyboard import AdminPanelCallback, build_panel_kb
+1 -1
View File
@@ -6,9 +6,9 @@ import sys
import psutil
from aiogram import F, Router
from core.executor import run_io
from aiogram.types import CallbackQuery
from core.executor import run_io
from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
+3 -1
View File
@@ -153,7 +153,9 @@ def build_scheduled_broadcast_detail_kb(item, page: int = 0) -> InlineKeyboardMa
builder.row(
InlineKeyboardButton(
text="👥 Аудитория",
callback_data=ScheduledBroadcastCallback(action="edit_audience", broadcast_id=item.id, page=page).pack(),
callback_data=ScheduledBroadcastCallback(
action="edit_audience", broadcast_id=item.id, page=page
).pack(),
),
InlineKeyboardButton(
text="⚡ Отправить сейчас",

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