Ruff format/ Cleanup
This commit is contained in:
+5
-2
@@ -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,5 +1,6 @@
|
||||
from api.v1.router import router
|
||||
|
||||
|
||||
VERSION = "1.0.0"
|
||||
|
||||
__all__ = ("router", "VERSION")
|
||||
|
||||
+9
-8
@@ -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
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
import pkgutil
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from collections import deque
|
||||
from threading import Lock
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from api.v2.schemas.identities import (
|
||||
)
|
||||
from database import identities as idb
|
||||
|
||||
|
||||
router = APIRouter(tags=["Identities"])
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,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="Ошибка при создании ключа")
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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="Подписка не найдена")
|
||||
|
||||
@@ -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="Подписка не найдена")
|
||||
|
||||
@@ -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="Подписка не найдена")
|
||||
|
||||
@@ -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="Подписка не найдена")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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,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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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 *
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
+5
-13
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
from . import cluster_manage, cluster_sync, cluster_tariffs, cluster_transfers, cluster_wizard
|
||||
from .base import AdminClusterStates, router
|
||||
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"AdminClusterStates",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user