mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 11:50:27 +00:00
Merge pull request #479 from Fr1ngg/revert-478-revert-477-bedolaga/add-api-for-bot-integration-vlrm04
Revert 478 revert 477 bedolaga/add api for bot integration vlrm04
This commit is contained in:
@@ -231,6 +231,19 @@ class Settings(BaseSettings):
|
||||
DEBUG: bool = False
|
||||
WEBHOOK_URL: Optional[str] = None
|
||||
WEBHOOK_PATH: str = "/webhook"
|
||||
|
||||
WEB_API_ENABLED: bool = False
|
||||
WEB_API_HOST: str = "0.0.0.0"
|
||||
WEB_API_PORT: int = 8080
|
||||
WEB_API_WORKERS: int = 1
|
||||
WEB_API_ALLOWED_ORIGINS: str = "*"
|
||||
WEB_API_DOCS_ENABLED: bool = False
|
||||
WEB_API_TITLE: str = "Remnawave Bot Admin API"
|
||||
WEB_API_VERSION: str = "1.0.0"
|
||||
WEB_API_DEFAULT_TOKEN: Optional[str] = None
|
||||
WEB_API_DEFAULT_TOKEN_NAME: str = "Bootstrap Token"
|
||||
WEB_API_TOKEN_HASH_ALGORITHM: str = "sha256"
|
||||
WEB_API_REQUEST_LOGGING: bool = True
|
||||
|
||||
APP_CONFIG_PATH: str = "app-config.json"
|
||||
ENABLE_DEEP_LINKS: bool = True
|
||||
@@ -954,7 +967,25 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_server_status_request_timeout(self) -> int:
|
||||
return max(1, self.SERVER_STATUS_REQUEST_TIMEOUT)
|
||||
|
||||
|
||||
def is_web_api_enabled(self) -> bool:
|
||||
return bool(self.WEB_API_ENABLED)
|
||||
|
||||
def get_web_api_allowed_origins(self) -> list[str]:
|
||||
raw = (self.WEB_API_ALLOWED_ORIGINS or "").split(",")
|
||||
origins = [origin.strip() for origin in raw if origin.strip()]
|
||||
return origins or ["*"]
|
||||
|
||||
def get_web_api_docs_config(self) -> Dict[str, Optional[str]]:
|
||||
if self.WEB_API_DOCS_ENABLED:
|
||||
return {
|
||||
"docs_url": "/docs",
|
||||
"redoc_url": "/redoc",
|
||||
"openapi_url": "/openapi.json",
|
||||
}
|
||||
|
||||
return {"docs_url": None, "redoc_url": None, "openapi_url": None}
|
||||
|
||||
def get_support_system_mode(self) -> str:
|
||||
mode = (self.SUPPORT_SYSTEM_MODE or "both").strip().lower()
|
||||
return mode if mode in {"tickets", "contact", "both"} else "both"
|
||||
|
||||
106
app/database/crud/web_api_token.py
Normal file
106
app/database/crud/web_api_token.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""CRUD операции для токенов административного веб-API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import WebApiToken
|
||||
|
||||
|
||||
async def list_tokens(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
) -> List[WebApiToken]:
|
||||
query = select(WebApiToken)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.where(WebApiToken.is_active.is_(True))
|
||||
|
||||
query = query.order_by(WebApiToken.created_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_token_by_id(db: AsyncSession, token_id: int) -> Optional[WebApiToken]:
|
||||
return await db.get(WebApiToken, token_id)
|
||||
|
||||
|
||||
async def get_token_by_hash(db: AsyncSession, token_hash: str) -> Optional[WebApiToken]:
|
||||
query = select(WebApiToken).where(
|
||||
WebApiToken.token_hash == token_hash
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_token(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
name: str,
|
||||
token_hash: str,
|
||||
token_prefix: str,
|
||||
description: Optional[str] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
created_by: Optional[str] = None,
|
||||
) -> WebApiToken:
|
||||
token = WebApiToken(
|
||||
name=name,
|
||||
token_hash=token_hash,
|
||||
token_prefix=token_prefix,
|
||||
description=description,
|
||||
expires_at=expires_at,
|
||||
created_by=created_by,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
db.add(token)
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
async def update_token(
|
||||
db: AsyncSession,
|
||||
token: WebApiToken,
|
||||
**kwargs,
|
||||
) -> WebApiToken:
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(token, key):
|
||||
setattr(token, key, value)
|
||||
token.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
async def set_tokens_active_status(
|
||||
db: AsyncSession,
|
||||
token_ids: Iterable[int],
|
||||
*,
|
||||
is_active: bool,
|
||||
) -> None:
|
||||
await db.execute(
|
||||
update(WebApiToken)
|
||||
.where(WebApiToken.id.in_(list(token_ids)))
|
||||
.values(is_active=is_active, updated_at=datetime.utcnow())
|
||||
)
|
||||
|
||||
|
||||
async def delete_token(db: AsyncSession, token: WebApiToken) -> None:
|
||||
await db.delete(token)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"list_tokens",
|
||||
"get_token_by_id",
|
||||
"get_token_by_hash",
|
||||
"create_token",
|
||||
"update_token",
|
||||
"set_tokens_active_status",
|
||||
"delete_token",
|
||||
]
|
||||
@@ -1091,7 +1091,7 @@ class Ticket(Base):
|
||||
|
||||
class TicketMessage(Base):
|
||||
__tablename__ = "ticket_messages"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
@@ -1118,6 +1118,27 @@ class TicketMessage(Base):
|
||||
@property
|
||||
def is_admin_message(self) -> bool:
|
||||
return self.is_from_admin
|
||||
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TicketMessage(id={self.id}, ticket_id={self.ticket_id}, is_admin={self.is_from_admin}, text='{self.message_text[:30]}...')>"
|
||||
return f"<TicketMessage(id={self.id}, ticket_id={self.ticket_id}, is_admin={self.is_from_admin}, text='{self.message_text[:30]}...')>"
|
||||
|
||||
|
||||
class WebApiToken(Base):
|
||||
__tablename__ = "web_api_tokens"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
token_hash = Column(String(128), nullable=False, unique=True, index=True)
|
||||
token_prefix = Column(String(32), nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
last_used_at = Column(DateTime, nullable=True)
|
||||
last_used_ip = Column(String(64), nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_by = Column(String(255), nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
status = "active" if self.is_active else "revoked"
|
||||
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
|
||||
@@ -1,7 +1,11 @@
|
||||
import logging
|
||||
from sqlalchemy import text, inspect
|
||||
from sqlalchemy import inspect, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.database import engine
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal, engine
|
||||
from app.database.models import WebApiToken
|
||||
from app.utils.security import hash_api_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1886,6 +1890,126 @@ async def create_system_settings_table() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def create_web_api_tokens_table() -> bool:
|
||||
table_exists = await check_table_exists("web_api_tokens")
|
||||
if table_exists:
|
||||
logger.info("ℹ️ Таблица web_api_tokens уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_sql = """
|
||||
CREATE TABLE web_api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
token_hash VARCHAR(128) NOT NULL UNIQUE,
|
||||
token_prefix VARCHAR(32) NOT NULL,
|
||||
description TEXT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NULL,
|
||||
last_used_at DATETIME NULL,
|
||||
last_used_ip VARCHAR(64) NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_by VARCHAR(255) NULL
|
||||
);
|
||||
CREATE INDEX idx_web_api_tokens_active ON web_api_tokens(is_active);
|
||||
CREATE INDEX idx_web_api_tokens_prefix ON web_api_tokens(token_prefix);
|
||||
CREATE INDEX idx_web_api_tokens_last_used ON web_api_tokens(last_used_at);
|
||||
"""
|
||||
elif db_type == "postgresql":
|
||||
create_sql = """
|
||||
CREATE TABLE web_api_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
token_hash VARCHAR(128) NOT NULL UNIQUE,
|
||||
token_prefix VARCHAR(32) NOT NULL,
|
||||
description TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
expires_at TIMESTAMP NULL,
|
||||
last_used_at TIMESTAMP NULL,
|
||||
last_used_ip VARCHAR(64) NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_by VARCHAR(255) NULL
|
||||
);
|
||||
CREATE INDEX idx_web_api_tokens_active ON web_api_tokens(is_active);
|
||||
CREATE INDEX idx_web_api_tokens_prefix ON web_api_tokens(token_prefix);
|
||||
CREATE INDEX idx_web_api_tokens_last_used ON web_api_tokens(last_used_at);
|
||||
"""
|
||||
else:
|
||||
create_sql = """
|
||||
CREATE TABLE web_api_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
token_hash VARCHAR(128) NOT NULL UNIQUE,
|
||||
token_prefix VARCHAR(32) NOT NULL,
|
||||
description TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NULL,
|
||||
last_used_at TIMESTAMP NULL,
|
||||
last_used_ip VARCHAR(64) NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_by VARCHAR(255) NULL
|
||||
) ENGINE=InnoDB;
|
||||
CREATE INDEX idx_web_api_tokens_active ON web_api_tokens(is_active);
|
||||
CREATE INDEX idx_web_api_tokens_prefix ON web_api_tokens(token_prefix);
|
||||
CREATE INDEX idx_web_api_tokens_last_used ON web_api_tokens(last_used_at);
|
||||
"""
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("✅ Таблица web_api_tokens создана")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблицы web_api_tokens: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_default_web_api_token() -> bool:
|
||||
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
|
||||
if not default_token:
|
||||
return True
|
||||
|
||||
token_name = (settings.WEB_API_DEFAULT_TOKEN_NAME or "Bootstrap Token").strip()
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
token_hash = hash_api_token(default_token, settings.WEB_API_TOKEN_HASH_ALGORITHM)
|
||||
result = await session.execute(
|
||||
select(WebApiToken).where(WebApiToken.token_hash == token_hash)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
if not existing.is_active:
|
||||
existing.is_active = True
|
||||
existing.updated_at = existing.updated_at or existing.created_at
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
token = WebApiToken(
|
||||
name=token_name or "Bootstrap Token",
|
||||
token_hash=token_hash,
|
||||
token_prefix=default_token[:12],
|
||||
description="Автоматически создан при миграции",
|
||||
created_by="migration",
|
||||
is_active=True,
|
||||
)
|
||||
session.add(token)
|
||||
await session.commit()
|
||||
logger.info("✅ Создан дефолтный токен веб-API из конфигурации")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания дефолтного веб-API токена: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def run_universal_migration():
|
||||
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
|
||||
|
||||
@@ -1904,6 +2028,20 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей system_settings")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ WEB_API_TOKENS ===")
|
||||
web_api_tokens_ready = await create_web_api_tokens_table()
|
||||
if web_api_tokens_ready:
|
||||
logger.info("✅ Таблица web_api_tokens готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей web_api_tokens")
|
||||
|
||||
logger.info("=== ПРОВЕРКА БАЗОВЫХ ТОКЕНОВ ВЕБ-API ===")
|
||||
default_token_ready = await ensure_default_web_api_token()
|
||||
if default_token_ready:
|
||||
logger.info("✅ Бутстрап токен веб-API готов")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось создать бутстрап токен веб-API")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ CRYPTOBOT ===")
|
||||
cryptobot_created = await create_cryptobot_payments_table()
|
||||
if cryptobot_created:
|
||||
|
||||
85
app/services/web_api_token_service.py
Normal file
85
app/services/web_api_token_service.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud import web_api_token as crud
|
||||
from app.database.models import WebApiToken
|
||||
from app.utils.security import generate_api_token, hash_api_token
|
||||
|
||||
|
||||
class WebApiTokenService:
|
||||
"""Сервис для управления токенами административного веб-API."""
|
||||
|
||||
def __init__(self):
|
||||
self.algorithm = settings.WEB_API_TOKEN_HASH_ALGORITHM or "sha256"
|
||||
|
||||
def hash_token(self, token: str) -> str:
|
||||
return hash_api_token(token, self.algorithm) # type: ignore[arg-type]
|
||||
|
||||
async def authenticate(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
token_value: str,
|
||||
*,
|
||||
remote_ip: Optional[str] = None,
|
||||
) -> Optional[WebApiToken]:
|
||||
token_hash = self.hash_token(token_value)
|
||||
token = await crud.get_token_by_hash(db, token_hash)
|
||||
|
||||
if not token or not token.is_active:
|
||||
return None
|
||||
|
||||
if token.expires_at and token.expires_at < datetime.utcnow():
|
||||
return None
|
||||
|
||||
token.last_used_at = datetime.utcnow()
|
||||
if remote_ip:
|
||||
token.last_used_ip = remote_ip
|
||||
await db.flush()
|
||||
return token
|
||||
|
||||
async def create_token(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
name: str,
|
||||
description: Optional[str] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
created_by: Optional[str] = None,
|
||||
token_value: Optional[str] = None,
|
||||
) -> Tuple[str, WebApiToken]:
|
||||
plain_token = token_value or generate_api_token()
|
||||
token_hash = self.hash_token(plain_token)
|
||||
|
||||
token = await crud.create_token(
|
||||
db,
|
||||
name=name,
|
||||
token_hash=token_hash,
|
||||
token_prefix=plain_token[:12],
|
||||
description=description,
|
||||
expires_at=expires_at,
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
return plain_token, token
|
||||
|
||||
async def revoke_token(self, db: AsyncSession, token: WebApiToken) -> WebApiToken:
|
||||
token.is_active = False
|
||||
token.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
async def activate_token(self, db: AsyncSession, token: WebApiToken) -> WebApiToken:
|
||||
token.is_active = True
|
||||
token.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
web_api_token_service = WebApiTokenService()
|
||||
28
app/utils/security.py
Normal file
28
app/utils/security.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Утилиты безопасности и генерации ключей."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from typing import Literal
|
||||
|
||||
|
||||
HashAlgorithm = Literal["sha256", "sha384", "sha512"]
|
||||
|
||||
|
||||
def hash_api_token(token: str, algorithm: HashAlgorithm = "sha256") -> str:
|
||||
"""Возвращает хеш токена в формате hex."""
|
||||
normalized = (algorithm or "sha256").lower()
|
||||
if normalized not in {"sha256", "sha384", "sha512"}:
|
||||
raise ValueError(f"Unsupported hash algorithm: {algorithm}")
|
||||
|
||||
digest = getattr(hashlib, normalized)
|
||||
return digest(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def generate_api_token(length: int = 48) -> str:
|
||||
"""Генерирует криптографически стойкий токен."""
|
||||
length = max(24, min(length, 128))
|
||||
return secrets.token_urlsafe(length)
|
||||
|
||||
|
||||
__all__ = ["hash_api_token", "generate_api_token", "HashAlgorithm"]
|
||||
5
app/webapi/__init__.py
Normal file
5
app/webapi/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Пакет административного веб-API."""
|
||||
from .app import create_web_api_app
|
||||
from .server import WebAPIServer
|
||||
|
||||
__all__ = ["create_web_api_app", "WebAPIServer"]
|
||||
55
app/webapi/app.py
Normal file
55
app/webapi/app.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
|
||||
from .middleware import RequestLoggingMiddleware
|
||||
from .routes import (
|
||||
config,
|
||||
health,
|
||||
promo_groups,
|
||||
stats,
|
||||
subscriptions,
|
||||
tickets,
|
||||
tokens,
|
||||
transactions,
|
||||
users,
|
||||
)
|
||||
|
||||
|
||||
def create_web_api_app() -> FastAPI:
|
||||
docs_config = settings.get_web_api_docs_config()
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.WEB_API_TITLE,
|
||||
version=settings.WEB_API_VERSION,
|
||||
docs_url=docs_config.get("docs_url"),
|
||||
redoc_url=docs_config.get("redoc_url"),
|
||||
openapi_url=docs_config.get("openapi_url"),
|
||||
)
|
||||
|
||||
allowed_origins = settings.get_web_api_allowed_origins()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"] if allowed_origins == ["*"] else allowed_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
if settings.WEB_API_REQUEST_LOGGING:
|
||||
app.add_middleware(RequestLoggingMiddleware)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(stats.router, prefix="/stats", tags=["stats"])
|
||||
app.include_router(config.router, prefix="/settings", tags=["settings"])
|
||||
app.include_router(users.router, prefix="/users", tags=["users"])
|
||||
app.include_router(subscriptions.router, prefix="/subscriptions", tags=["subscriptions"])
|
||||
app.include_router(tickets.router, prefix="/tickets", tags=["support"])
|
||||
app.include_router(transactions.router, prefix="/transactions", tags=["transactions"])
|
||||
app.include_router(promo_groups.router, prefix="/promo-groups", tags=["promo-groups"])
|
||||
app.include_router(tokens.router, prefix="/tokens", tags=["auth"])
|
||||
|
||||
return app
|
||||
54
app/webapi/dependencies.py
Normal file
54
app/webapi/dependencies.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security.utils import get_authorization_scheme_param
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import WebApiToken
|
||||
from app.services.web_api_token_service import web_api_token_service
|
||||
|
||||
|
||||
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def require_api_token(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> WebApiToken:
|
||||
api_key = request.headers.get("X-API-Key")
|
||||
|
||||
if not api_key:
|
||||
authorization = request.headers.get("Authorization")
|
||||
scheme, param = get_authorization_scheme_param(authorization)
|
||||
if scheme.lower() == "bearer" and param:
|
||||
api_key = param
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing API key",
|
||||
)
|
||||
|
||||
token = await web_api_token_service.authenticate(
|
||||
db,
|
||||
api_key,
|
||||
remote_ip=request.client.host if request.client else None,
|
||||
)
|
||||
|
||||
if not token:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired API key",
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return token
|
||||
31
app/webapi/middleware.py
Normal file
31
app/webapi/middleware.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from time import monotonic
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
|
||||
logger = logging.getLogger("web_api")
|
||||
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""Логирование входящих запросов в административный API."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
start = monotonic()
|
||||
response: Response | None = None
|
||||
try:
|
||||
response = await call_next(request)
|
||||
return response
|
||||
finally:
|
||||
duration_ms = (monotonic() - start) * 1000
|
||||
status = response.status_code if response else "error"
|
||||
logger.info(
|
||||
"%s %s -> %s (%.2f ms)",
|
||||
request.method,
|
||||
request.url.path,
|
||||
status,
|
||||
duration_ms,
|
||||
)
|
||||
0
app/webapi/routes/__init__.py
Normal file
0
app/webapi/routes/__init__.py
Normal file
171
app/webapi/routes/config.py
Normal file
171
app/webapi/routes/config.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _coerce_value(key: str, value: Any) -> Any:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
|
||||
if value is None:
|
||||
if definition.is_optional:
|
||||
return None
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Value is required")
|
||||
|
||||
python_type = definition.python_type
|
||||
|
||||
try:
|
||||
if python_type is bool:
|
||||
if isinstance(value, bool):
|
||||
normalized = value
|
||||
elif isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"true", "1", "yes", "on", "да"}:
|
||||
normalized = True
|
||||
elif lowered in {"false", "0", "no", "off", "нет"}:
|
||||
normalized = False
|
||||
else:
|
||||
raise ValueError("invalid bool")
|
||||
else:
|
||||
raise ValueError("invalid bool")
|
||||
|
||||
elif python_type is int:
|
||||
normalized = int(value)
|
||||
elif python_type is float:
|
||||
normalized = float(value)
|
||||
else:
|
||||
normalized = str(value)
|
||||
except ValueError:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid value type") from None
|
||||
|
||||
choices = bot_configuration_service.get_choice_options(key)
|
||||
if choices:
|
||||
allowed_values = {option.value for option in choices}
|
||||
if normalized not in allowed_values:
|
||||
readable = ", ".join(bot_configuration_service.format_value(opt.value) for opt in choices)
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Value must be one of: {readable}",
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _serialize_definition(definition, include_choices: bool = True) -> dict[str, Any]:
|
||||
current = bot_configuration_service.get_current_value(definition.key)
|
||||
original = bot_configuration_service.get_original_value(definition.key)
|
||||
has_override = bot_configuration_service.has_override(definition.key)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"key": definition.key,
|
||||
"name": definition.display_name,
|
||||
"category": {
|
||||
"key": definition.category_key,
|
||||
"label": definition.category_label,
|
||||
},
|
||||
"type": definition.type_label,
|
||||
"is_optional": definition.is_optional,
|
||||
"current": current,
|
||||
"original": original,
|
||||
"has_override": has_override,
|
||||
}
|
||||
|
||||
if include_choices:
|
||||
choices = [
|
||||
{
|
||||
"value": option.value,
|
||||
"label": option.label,
|
||||
"description": option.description,
|
||||
}
|
||||
for option in bot_configuration_service.get_choice_options(definition.key)
|
||||
]
|
||||
if choices:
|
||||
payload["choices"] = choices
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
async def list_categories(_: object = Depends(require_api_token)) -> list[dict[str, Any]]:
|
||||
categories = bot_configuration_service.get_categories()
|
||||
return [
|
||||
{"key": key, "label": label, "items": count}
|
||||
for key, label, count in categories
|
||||
]
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_settings(
|
||||
_: object = Depends(require_api_token),
|
||||
category: Optional[str] = Query(default=None, alias="category_key"),
|
||||
) -> list[dict[str, Any]]:
|
||||
items = []
|
||||
if category:
|
||||
definitions = bot_configuration_service.get_settings_for_category(category)
|
||||
items.extend(_serialize_definition(defn) for defn in definitions)
|
||||
return items
|
||||
|
||||
for category_key, _, _ in bot_configuration_service.get_categories():
|
||||
definitions = bot_configuration_service.get_settings_for_category(category_key)
|
||||
items.extend(_serialize_definition(defn) for defn in definitions)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/{key}")
|
||||
async def get_setting(
|
||||
key: str,
|
||||
_: object = Depends(require_api_token),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error: # pragma: no cover - защита от некорректного ключа
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
|
||||
return _serialize_definition(definition)
|
||||
|
||||
|
||||
@router.put("/{key}")
|
||||
async def update_setting(
|
||||
key: str,
|
||||
payload: dict[str, Any],
|
||||
_: object = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
|
||||
if "value" not in payload:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Missing value")
|
||||
|
||||
value = _coerce_value(key, payload["value"])
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
await db.commit()
|
||||
|
||||
return _serialize_definition(definition)
|
||||
|
||||
|
||||
@router.delete("/{key}")
|
||||
async def reset_setting(
|
||||
key: str,
|
||||
_: object = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
|
||||
await bot_configuration_service.reset_value(db, key)
|
||||
await db.commit()
|
||||
return _serialize_definition(definition)
|
||||
25
app/webapi/routes/health.py
Normal file
25
app/webapi/routes/health.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.config import settings
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import require_api_token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health", tags=["health"])
|
||||
async def health_check(_: object = Depends(require_api_token)) -> dict[str, object]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"api_version": settings.WEB_API_VERSION,
|
||||
"bot_version": version_service.current_version,
|
||||
"features": {
|
||||
"monitoring": settings.MONITORING_INTERVAL > 0,
|
||||
"maintenance": True,
|
||||
"reporting": True,
|
||||
"webhooks": bool(settings.WEBHOOK_URL),
|
||||
},
|
||||
}
|
||||
139
app/webapi/routes/promo_groups.py
Normal file
139
app/webapi/routes/promo_groups.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.promo_group import (
|
||||
count_promo_group_members,
|
||||
create_promo_group,
|
||||
delete_promo_group,
|
||||
get_promo_group_by_id,
|
||||
get_promo_groups_with_counts,
|
||||
update_promo_group,
|
||||
)
|
||||
from app.database.models import PromoGroup
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.promo_groups import (
|
||||
PromoGroupCreateRequest,
|
||||
PromoGroupResponse,
|
||||
PromoGroupUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _normalize_period_discounts(group: PromoGroup) -> dict[int, int]:
|
||||
raw = group.period_discounts or {}
|
||||
normalized: dict[int, int] = {}
|
||||
if isinstance(raw, dict):
|
||||
for key, value in raw.items():
|
||||
try:
|
||||
normalized[int(key)] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return normalized
|
||||
|
||||
|
||||
def _serialize(group: PromoGroup, members_count: int = 0) -> PromoGroupResponse:
|
||||
return PromoGroupResponse(
|
||||
id=group.id,
|
||||
name=group.name,
|
||||
server_discount_percent=group.server_discount_percent,
|
||||
traffic_discount_percent=group.traffic_discount_percent,
|
||||
device_discount_percent=group.device_discount_percent,
|
||||
period_discounts=_normalize_period_discounts(group),
|
||||
auto_assign_total_spent_kopeks=group.auto_assign_total_spent_kopeks,
|
||||
apply_discounts_to_addons=group.apply_discounts_to_addons,
|
||||
is_default=group.is_default,
|
||||
members_count=members_count,
|
||||
created_at=group.created_at,
|
||||
updated_at=group.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[PromoGroupResponse])
|
||||
async def list_promo_groups(
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[PromoGroupResponse]:
|
||||
groups_with_counts = await get_promo_groups_with_counts(db)
|
||||
return [_serialize(group, members_count=count) for group, count in groups_with_counts]
|
||||
|
||||
|
||||
@router.get("/{group_id}", response_model=PromoGroupResponse)
|
||||
async def get_promo_group(
|
||||
group_id: int,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoGroupResponse:
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo group not found")
|
||||
|
||||
members_count = await count_promo_group_members(db, group_id)
|
||||
return _serialize(group, members_count=members_count)
|
||||
|
||||
|
||||
@router.post("", response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_promo_group_endpoint(
|
||||
payload: PromoGroupCreateRequest,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoGroupResponse:
|
||||
group = await create_promo_group(
|
||||
db,
|
||||
name=payload.name,
|
||||
server_discount_percent=payload.server_discount_percent,
|
||||
traffic_discount_percent=payload.traffic_discount_percent,
|
||||
device_discount_percent=payload.device_discount_percent,
|
||||
period_discounts=payload.period_discounts,
|
||||
auto_assign_total_spent_kopeks=payload.auto_assign_total_spent_kopeks,
|
||||
apply_discounts_to_addons=payload.apply_discounts_to_addons,
|
||||
)
|
||||
return _serialize(group, members_count=0)
|
||||
|
||||
|
||||
@router.patch("/{group_id}", response_model=PromoGroupResponse)
|
||||
async def update_promo_group_endpoint(
|
||||
group_id: int,
|
||||
payload: PromoGroupUpdateRequest,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoGroupResponse:
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo group not found")
|
||||
|
||||
group = await update_promo_group(
|
||||
db,
|
||||
group,
|
||||
name=payload.name,
|
||||
server_discount_percent=payload.server_discount_percent,
|
||||
traffic_discount_percent=payload.traffic_discount_percent,
|
||||
device_discount_percent=payload.device_discount_percent,
|
||||
period_discounts=payload.period_discounts,
|
||||
auto_assign_total_spent_kopeks=payload.auto_assign_total_spent_kopeks,
|
||||
apply_discounts_to_addons=payload.apply_discounts_to_addons,
|
||||
)
|
||||
members_count = await count_promo_group_members(db, group_id)
|
||||
return _serialize(group, members_count=members_count)
|
||||
|
||||
|
||||
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_promo_group_endpoint(
|
||||
group_id: int,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo group not found")
|
||||
|
||||
success = await delete_promo_group(db, group)
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Cannot delete default promo group")
|
||||
|
||||
return None
|
||||
87
app/webapi/routes/stats.py
Normal file
87
app/webapi/routes/stats.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import (
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
Ticket,
|
||||
TicketStatus,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
UserStatus,
|
||||
)
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
async def stats_overview(
|
||||
_: object = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, object]:
|
||||
total_users = await db.scalar(select(func.count()).select_from(User)) or 0
|
||||
active_users = await db.scalar(
|
||||
select(func.count()).select_from(User).where(User.status == UserStatus.ACTIVE.value)
|
||||
) or 0
|
||||
blocked_users = await db.scalar(
|
||||
select(func.count()).select_from(User).where(User.status == UserStatus.BLOCKED.value)
|
||||
) or 0
|
||||
|
||||
total_balance_kopeks = await db.scalar(
|
||||
select(func.coalesce(func.sum(User.balance_kopeks), 0))
|
||||
) or 0
|
||||
|
||||
active_subscriptions = await db.scalar(
|
||||
select(func.count()).select_from(Subscription).where(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
) or 0
|
||||
|
||||
expired_subscriptions = await db.scalar(
|
||||
select(func.count()).select_from(Subscription).where(
|
||||
Subscription.status == SubscriptionStatus.EXPIRED.value,
|
||||
)
|
||||
) or 0
|
||||
|
||||
pending_tickets = await db.scalar(
|
||||
select(func.count()).select_from(Ticket).where(
|
||||
Ticket.status.in_([TicketStatus.OPEN.value, TicketStatus.ANSWERED.value])
|
||||
)
|
||||
) or 0
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
today_transactions = await db.scalar(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
func.date(Transaction.created_at) == today,
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
)
|
||||
) or 0
|
||||
|
||||
return {
|
||||
"users": {
|
||||
"total": total_users,
|
||||
"active": active_users,
|
||||
"blocked": blocked_users,
|
||||
"balance_kopeks": int(total_balance_kopeks),
|
||||
"balance_rubles": round(total_balance_kopeks / 100, 2),
|
||||
},
|
||||
"subscriptions": {
|
||||
"active": active_subscriptions,
|
||||
"expired": expired_subscriptions,
|
||||
},
|
||||
"support": {
|
||||
"open_tickets": pending_tickets,
|
||||
},
|
||||
"payments": {
|
||||
"today_kopeks": int(today_transactions),
|
||||
"today_rubles": round(today_transactions / 100, 2),
|
||||
},
|
||||
}
|
||||
205
app/webapi/routes/subscriptions.py
Normal file
205
app/webapi/routes/subscriptions.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import (
|
||||
add_subscription_devices,
|
||||
add_subscription_squad,
|
||||
add_subscription_traffic,
|
||||
create_paid_subscription,
|
||||
create_trial_subscription,
|
||||
extend_subscription,
|
||||
get_subscription_by_user_id,
|
||||
remove_subscription_squad,
|
||||
)
|
||||
from app.database.models import Subscription, SubscriptionStatus
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.subscriptions import (
|
||||
SubscriptionCreateRequest,
|
||||
SubscriptionDevicesRequest,
|
||||
SubscriptionExtendRequest,
|
||||
SubscriptionResponse,
|
||||
SubscriptionSquadRequest,
|
||||
SubscriptionTrafficRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse:
|
||||
return SubscriptionResponse(
|
||||
id=subscription.id,
|
||||
user_id=subscription.user_id,
|
||||
status=subscription.status,
|
||||
actual_status=subscription.actual_status,
|
||||
is_trial=subscription.is_trial,
|
||||
start_date=subscription.start_date,
|
||||
end_date=subscription.end_date,
|
||||
traffic_limit_gb=subscription.traffic_limit_gb,
|
||||
traffic_used_gb=subscription.traffic_used_gb,
|
||||
device_limit=subscription.device_limit,
|
||||
autopay_enabled=subscription.autopay_enabled,
|
||||
autopay_days_before=subscription.autopay_days_before,
|
||||
subscription_url=subscription.subscription_url,
|
||||
subscription_crypto_link=subscription.subscription_crypto_link,
|
||||
connected_squads=list(subscription.connected_squads or []),
|
||||
created_at=subscription.created_at,
|
||||
updated_at=subscription.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def _get_subscription(db: AsyncSession, subscription_id: int) -> Subscription:
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(Subscription.id == subscription_id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
if not subscription:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Subscription not found")
|
||||
return subscription
|
||||
|
||||
|
||||
@router.get("", response_model=list[SubscriptionResponse])
|
||||
async def list_subscriptions(
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
status_filter: Optional[SubscriptionStatus] = Query(default=None, alias="status"),
|
||||
user_id: Optional[int] = Query(default=None),
|
||||
is_trial: Optional[bool] = Query(default=None),
|
||||
) -> list[SubscriptionResponse]:
|
||||
query = select(Subscription).options(selectinload(Subscription.user))
|
||||
|
||||
if status_filter:
|
||||
query = query.where(Subscription.status == status_filter.value)
|
||||
if user_id:
|
||||
query = query.where(Subscription.user_id == user_id)
|
||||
if is_trial is not None:
|
||||
query = query.where(Subscription.is_trial.is_(is_trial))
|
||||
|
||||
query = query.order_by(Subscription.created_at.desc()).offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
subscriptions = result.scalars().all()
|
||||
return [_serialize_subscription(sub) for sub in subscriptions]
|
||||
|
||||
|
||||
@router.get("/{subscription_id}", response_model=SubscriptionResponse)
|
||||
async def get_subscription(
|
||||
subscription_id: int,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.post("", response_model=SubscriptionResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_subscription(
|
||||
payload: SubscriptionCreateRequest,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
existing = await get_subscription_by_user_id(db, payload.user_id)
|
||||
if existing:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "User already has a subscription")
|
||||
|
||||
if payload.is_trial:
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
duration_days=payload.duration_days,
|
||||
traffic_limit_gb=payload.traffic_limit_gb,
|
||||
device_limit=payload.device_limit,
|
||||
squad_uuid=payload.squad_uuid,
|
||||
)
|
||||
else:
|
||||
if payload.duration_days is None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "duration_days is required for paid subscriptions")
|
||||
subscription = await create_paid_subscription(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
duration_days=payload.duration_days,
|
||||
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||
device_limit=payload.device_limit or settings.DEFAULT_DEVICE_LIMIT,
|
||||
connected_squads=payload.connected_squads or [],
|
||||
)
|
||||
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.post("/{subscription_id}/extend", response_model=SubscriptionResponse)
|
||||
async def extend_subscription_endpoint(
|
||||
subscription_id: int,
|
||||
payload: SubscriptionExtendRequest,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
subscription = await extend_subscription(db, subscription, payload.days)
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.post("/{subscription_id}/traffic", response_model=SubscriptionResponse)
|
||||
async def add_subscription_traffic_endpoint(
|
||||
subscription_id: int,
|
||||
payload: SubscriptionTrafficRequest,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
subscription = await add_subscription_traffic(db, subscription, payload.gb)
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.post("/{subscription_id}/devices", response_model=SubscriptionResponse)
|
||||
async def add_subscription_devices_endpoint(
|
||||
subscription_id: int,
|
||||
payload: SubscriptionDevicesRequest,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
subscription = await add_subscription_devices(db, subscription, payload.devices)
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.post("/{subscription_id}/squads", response_model=SubscriptionResponse)
|
||||
async def add_subscription_squad_endpoint(
|
||||
subscription_id: int,
|
||||
payload: SubscriptionSquadRequest,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
if not payload.squad_uuid:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "squad_uuid is required")
|
||||
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
subscription = await add_subscription_squad(db, subscription, payload.squad_uuid)
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.delete("/{subscription_id}/squads/{squad_uuid}", response_model=SubscriptionResponse)
|
||||
async def remove_subscription_squad_endpoint(
|
||||
subscription_id: int,
|
||||
squad_uuid: str,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
subscription = await remove_subscription_squad(db, subscription, squad_uuid)
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
185
app/webapi/routes/tickets.py
Normal file
185
app/webapi/routes/tickets.py
Normal file
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.ticket import TicketCRUD
|
||||
from app.database.models import Ticket, TicketMessage, TicketStatus
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.tickets import (
|
||||
TicketMessageResponse,
|
||||
TicketPriorityUpdateRequest,
|
||||
TicketReplyBlockRequest,
|
||||
TicketResponse,
|
||||
TicketStatusUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_message(message: TicketMessage) -> TicketMessageResponse:
|
||||
return TicketMessageResponse(
|
||||
id=message.id,
|
||||
user_id=message.user_id,
|
||||
message_text=message.message_text,
|
||||
is_from_admin=message.is_from_admin,
|
||||
has_media=message.has_media,
|
||||
media_type=message.media_type,
|
||||
media_caption=message.media_caption,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_ticket(ticket: Ticket, include_messages: bool = False) -> TicketResponse:
|
||||
messages = []
|
||||
if include_messages:
|
||||
messages = sorted(ticket.messages, key=lambda m: m.created_at)
|
||||
|
||||
return TicketResponse(
|
||||
id=ticket.id,
|
||||
user_id=ticket.user_id,
|
||||
title=ticket.title,
|
||||
status=ticket.status,
|
||||
priority=ticket.priority,
|
||||
created_at=ticket.created_at,
|
||||
updated_at=ticket.updated_at,
|
||||
closed_at=ticket.closed_at,
|
||||
user_reply_block_permanent=ticket.user_reply_block_permanent,
|
||||
user_reply_block_until=ticket.user_reply_block_until,
|
||||
messages=[_serialize_message(message) for message in messages],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[TicketResponse])
|
||||
async def list_tickets(
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
status_filter: Optional[TicketStatus] = Query(default=None, alias="status"),
|
||||
priority: Optional[str] = Query(default=None),
|
||||
user_id: Optional[int] = Query(default=None),
|
||||
) -> list[TicketResponse]:
|
||||
status_value = status_filter.value if status_filter else None
|
||||
|
||||
if user_id:
|
||||
tickets = await TicketCRUD.get_user_tickets(
|
||||
db,
|
||||
user_id=user_id,
|
||||
status=status_value,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
else:
|
||||
tickets = await TicketCRUD.get_all_tickets(
|
||||
db,
|
||||
status=status_value,
|
||||
priority=priority,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return [_serialize_ticket(ticket) for ticket in tickets]
|
||||
|
||||
|
||||
@router.get("/{ticket_id}", response_model=TicketResponse)
|
||||
async def get_ticket(
|
||||
ticket_id: int,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TicketResponse:
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
if not ticket:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||
return _serialize_ticket(ticket, include_messages=True)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/status", response_model=TicketResponse)
|
||||
async def update_ticket_status(
|
||||
ticket_id: int,
|
||||
payload: TicketStatusUpdateRequest,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TicketResponse:
|
||||
try:
|
||||
status_value = TicketStatus(payload.status).value
|
||||
except ValueError as error:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid ticket status") from error
|
||||
|
||||
closed_at = datetime.utcnow() if status_value == TicketStatus.CLOSED.value else None
|
||||
success = await TicketCRUD.update_ticket_status(db, ticket_id, status_value, closed_at)
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
return _serialize_ticket(ticket, include_messages=True)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/priority", response_model=TicketResponse)
|
||||
async def update_ticket_priority(
|
||||
ticket_id: int,
|
||||
payload: TicketPriorityUpdateRequest,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TicketResponse:
|
||||
allowed_priorities = {"low", "normal", "high", "urgent"}
|
||||
if payload.priority not in allowed_priorities:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid priority")
|
||||
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
if not ticket:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||
|
||||
ticket.priority = payload.priority
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
return _serialize_ticket(ticket, include_messages=True)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/reply-block", response_model=TicketResponse)
|
||||
async def update_reply_block(
|
||||
ticket_id: int,
|
||||
payload: TicketReplyBlockRequest,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TicketResponse:
|
||||
until = payload.until
|
||||
if not payload.permanent and until and until <= datetime.utcnow():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Block expiration must be in the future")
|
||||
|
||||
success = await TicketCRUD.set_user_reply_block(
|
||||
db,
|
||||
ticket_id,
|
||||
permanent=payload.permanent,
|
||||
until=until,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
return _serialize_ticket(ticket, include_messages=True)
|
||||
|
||||
|
||||
@router.delete("/{ticket_id}/reply-block", response_model=TicketResponse)
|
||||
async def clear_reply_block(
|
||||
ticket_id: int,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TicketResponse:
|
||||
success = await TicketCRUD.set_user_reply_block(
|
||||
db,
|
||||
ticket_id,
|
||||
permanent=False,
|
||||
until=None,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
return _serialize_ticket(ticket, include_messages=True)
|
||||
107
app/webapi/routes/tokens.py
Normal file
107
app/webapi/routes/tokens.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.web_api_token import (
|
||||
delete_token,
|
||||
get_token_by_id,
|
||||
list_tokens,
|
||||
)
|
||||
from app.database.models import WebApiToken
|
||||
from app.services.web_api_token_service import web_api_token_service
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.tokens import TokenCreateRequest, TokenCreateResponse, TokenResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize(token: WebApiToken) -> TokenResponse:
|
||||
return TokenResponse(
|
||||
id=token.id,
|
||||
name=token.name,
|
||||
prefix=token.token_prefix,
|
||||
description=token.description,
|
||||
is_active=token.is_active,
|
||||
created_at=token.created_at,
|
||||
updated_at=token.updated_at,
|
||||
expires_at=token.expires_at,
|
||||
last_used_at=token.last_used_at,
|
||||
last_used_ip=token.last_used_ip,
|
||||
created_by=token.created_by,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[TokenResponse])
|
||||
async def get_tokens(
|
||||
_: WebApiToken = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[TokenResponse]:
|
||||
tokens = await list_tokens(db, include_inactive=True)
|
||||
return [_serialize(token) for token in tokens]
|
||||
|
||||
|
||||
@router.post("", response_model=TokenCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_token(
|
||||
payload: TokenCreateRequest,
|
||||
actor: WebApiToken = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TokenCreateResponse:
|
||||
token_value, token = await web_api_token_service.create_token(
|
||||
db,
|
||||
name=payload.name.strip(),
|
||||
description=payload.description,
|
||||
expires_at=payload.expires_at,
|
||||
created_by=actor.name,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
base = _serialize(token).model_dump()
|
||||
base["token"] = token_value
|
||||
return TokenCreateResponse(**base)
|
||||
|
||||
|
||||
@router.post("/{token_id}/revoke", response_model=TokenResponse)
|
||||
async def revoke_token(
|
||||
token_id: int,
|
||||
_: WebApiToken = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TokenResponse:
|
||||
token = await get_token_by_id(db, token_id)
|
||||
if not token:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Token not found")
|
||||
|
||||
await web_api_token_service.revoke_token(db, token)
|
||||
await db.commit()
|
||||
return _serialize(token)
|
||||
|
||||
|
||||
@router.post("/{token_id}/activate", response_model=TokenResponse)
|
||||
async def activate_token(
|
||||
token_id: int,
|
||||
_: WebApiToken = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TokenResponse:
|
||||
token = await get_token_by_id(db, token_id)
|
||||
if not token:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Token not found")
|
||||
|
||||
await web_api_token_service.activate_token(db, token)
|
||||
await db.commit()
|
||||
return _serialize(token)
|
||||
|
||||
|
||||
@router.delete("/{token_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_token_endpoint(
|
||||
token_id: int,
|
||||
_: WebApiToken = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
token = await get_token_by_id(db, token_id)
|
||||
if not token:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Token not found")
|
||||
|
||||
await delete_token(db, token)
|
||||
await db.commit()
|
||||
return None
|
||||
79
app/webapi/routes/transactions.py
Normal file
79
app/webapi/routes/transactions.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Transaction
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.transactions import TransactionListResponse, TransactionResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize(transaction: Transaction) -> TransactionResponse:
|
||||
return TransactionResponse(
|
||||
id=transaction.id,
|
||||
user_id=transaction.user_id,
|
||||
type=transaction.type,
|
||||
amount_kopeks=transaction.amount_kopeks,
|
||||
amount_rubles=round(transaction.amount_kopeks / 100, 2),
|
||||
description=transaction.description,
|
||||
payment_method=transaction.payment_method,
|
||||
external_id=transaction.external_id,
|
||||
is_completed=transaction.is_completed,
|
||||
created_at=transaction.created_at,
|
||||
completed_at=transaction.completed_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=TransactionListResponse)
|
||||
async def list_transactions(
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user_id: Optional[int] = Query(default=None),
|
||||
type_filter: Optional[str] = Query(default=None, alias="type"),
|
||||
payment_method: Optional[str] = Query(default=None),
|
||||
is_completed: Optional[bool] = Query(default=None),
|
||||
date_from: Optional[datetime] = Query(default=None),
|
||||
date_to: Optional[datetime] = Query(default=None),
|
||||
) -> TransactionListResponse:
|
||||
base_query = select(Transaction)
|
||||
conditions = []
|
||||
|
||||
if user_id:
|
||||
conditions.append(Transaction.user_id == user_id)
|
||||
if type_filter:
|
||||
conditions.append(Transaction.type == type_filter)
|
||||
if payment_method:
|
||||
conditions.append(Transaction.payment_method == payment_method)
|
||||
if is_completed is not None:
|
||||
conditions.append(Transaction.is_completed.is_(is_completed))
|
||||
if date_from:
|
||||
conditions.append(Transaction.created_at >= date_from)
|
||||
if date_to:
|
||||
conditions.append(Transaction.created_at <= date_to)
|
||||
|
||||
if conditions:
|
||||
base_query = base_query.where(and_(*conditions))
|
||||
|
||||
total_query = base_query.with_only_columns(func.count()).order_by(None)
|
||||
total = await db.scalar(total_query) or 0
|
||||
|
||||
result = await db.execute(
|
||||
base_query.order_by(Transaction.created_at.desc()).offset(offset).limit(limit)
|
||||
)
|
||||
transactions = result.scalars().all()
|
||||
|
||||
return TransactionListResponse(
|
||||
items=[_serialize(tx) for tx in transactions],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
277
app/webapi/routes/users.py
Normal file
277
app/webapi/routes/users.py
Normal file
@@ -0,0 +1,277 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.user import (
|
||||
add_user_balance,
|
||||
create_user,
|
||||
get_user_by_id,
|
||||
get_user_by_referral_code,
|
||||
get_user_by_telegram_id,
|
||||
update_user,
|
||||
)
|
||||
from app.database.models import PromoGroup, Subscription, User, UserStatus
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.users import (
|
||||
BalanceUpdateRequest,
|
||||
PromoGroupSummary,
|
||||
SubscriptionSummary,
|
||||
UserCreateRequest,
|
||||
UserListResponse,
|
||||
UserResponse,
|
||||
UserUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_promo_group(group: Optional[PromoGroup]) -> Optional[PromoGroupSummary]:
|
||||
if not group:
|
||||
return None
|
||||
return PromoGroupSummary(
|
||||
id=group.id,
|
||||
name=group.name,
|
||||
server_discount_percent=group.server_discount_percent,
|
||||
traffic_discount_percent=group.traffic_discount_percent,
|
||||
device_discount_percent=group.device_discount_percent,
|
||||
apply_discounts_to_addons=getattr(group, "apply_discounts_to_addons", True),
|
||||
)
|
||||
|
||||
|
||||
def _serialize_subscription(subscription: Optional[Subscription]) -> Optional[SubscriptionSummary]:
|
||||
if not subscription:
|
||||
return None
|
||||
|
||||
return SubscriptionSummary(
|
||||
id=subscription.id,
|
||||
status=subscription.status,
|
||||
actual_status=subscription.actual_status,
|
||||
is_trial=subscription.is_trial,
|
||||
start_date=subscription.start_date,
|
||||
end_date=subscription.end_date,
|
||||
traffic_limit_gb=subscription.traffic_limit_gb,
|
||||
traffic_used_gb=subscription.traffic_used_gb,
|
||||
device_limit=subscription.device_limit,
|
||||
autopay_enabled=subscription.autopay_enabled,
|
||||
autopay_days_before=subscription.autopay_days_before,
|
||||
subscription_url=subscription.subscription_url,
|
||||
subscription_crypto_link=subscription.subscription_crypto_link,
|
||||
connected_squads=list(subscription.connected_squads or []),
|
||||
)
|
||||
|
||||
|
||||
def _serialize_user(user: User) -> UserResponse:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
status=user.status,
|
||||
language=user.language,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
balance_rubles=round(user.balance_kopeks / 100, 2),
|
||||
referral_code=user.referral_code,
|
||||
referred_by_id=user.referred_by_id,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_made_first_topup=user.has_made_first_topup,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
last_activity=user.last_activity,
|
||||
promo_group=_serialize_promo_group(promo_group),
|
||||
subscription=_serialize_subscription(subscription),
|
||||
)
|
||||
|
||||
|
||||
def _apply_search_filter(query, search: str):
|
||||
search_lower = f"%{search.lower()}%"
|
||||
conditions = [
|
||||
func.lower(User.username).like(search_lower),
|
||||
func.lower(User.first_name).like(search_lower),
|
||||
func.lower(User.last_name).like(search_lower),
|
||||
func.lower(User.referral_code).like(search_lower),
|
||||
]
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
conditions.append(User.id == int(search))
|
||||
|
||||
return query.where(or_(*conditions))
|
||||
|
||||
|
||||
@router.get("", response_model=UserListResponse)
|
||||
async def list_users(
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
status_filter: Optional[UserStatus] = Query(default=None, alias="status"),
|
||||
promo_group_id: Optional[int] = Query(default=None),
|
||||
search: Optional[str] = Query(default=None),
|
||||
) -> UserListResponse:
|
||||
base_query = (
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
)
|
||||
|
||||
if status_filter:
|
||||
base_query = base_query.where(User.status == status_filter.value)
|
||||
|
||||
if promo_group_id:
|
||||
base_query = base_query.where(User.promo_group_id == promo_group_id)
|
||||
|
||||
if search:
|
||||
base_query = _apply_search_filter(base_query, search)
|
||||
|
||||
total_query = base_query.with_only_columns(func.count()).order_by(None)
|
||||
total = await db.scalar(total_query) or 0
|
||||
|
||||
result = await db.execute(
|
||||
base_query.order_by(User.created_at.desc()).offset(offset).limit(limit)
|
||||
)
|
||||
users = result.scalars().unique().all()
|
||||
|
||||
return UserListResponse(
|
||||
items=[_serialize_user(user) for user in users],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserResponse:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
return _serialize_user(user)
|
||||
|
||||
|
||||
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user_endpoint(
|
||||
payload: UserCreateRequest,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserResponse:
|
||||
existing = await get_user_by_telegram_id(db, payload.telegram_id)
|
||||
if existing:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "User with this telegram_id already exists")
|
||||
|
||||
user = await create_user(
|
||||
db,
|
||||
telegram_id=payload.telegram_id,
|
||||
username=payload.username,
|
||||
first_name=payload.first_name,
|
||||
last_name=payload.last_name,
|
||||
language=payload.language,
|
||||
referred_by_id=payload.referred_by_id,
|
||||
)
|
||||
|
||||
if payload.promo_group_id and payload.promo_group_id != user.promo_group_id:
|
||||
promo_group = await get_promo_group_by_id(db, payload.promo_group_id)
|
||||
if not promo_group:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo group not found")
|
||||
user = await update_user(db, user, promo_group_id=promo_group.id)
|
||||
|
||||
user = await get_user_by_id(db, user.id)
|
||||
return _serialize_user(user)
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserResponse)
|
||||
async def update_user_endpoint(
|
||||
user_id: int,
|
||||
payload: UserUpdateRequest,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserResponse:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
updates: dict[str, Any] = {}
|
||||
|
||||
if payload.username is not None:
|
||||
updates["username"] = payload.username
|
||||
if payload.first_name is not None:
|
||||
updates["first_name"] = payload.first_name
|
||||
if payload.last_name is not None:
|
||||
updates["last_name"] = payload.last_name
|
||||
if payload.language is not None:
|
||||
updates["language"] = payload.language
|
||||
if payload.has_had_paid_subscription is not None:
|
||||
updates["has_had_paid_subscription"] = payload.has_had_paid_subscription
|
||||
if payload.has_made_first_topup is not None:
|
||||
updates["has_made_first_topup"] = payload.has_made_first_topup
|
||||
|
||||
if payload.status is not None:
|
||||
try:
|
||||
status_value = UserStatus(payload.status).value
|
||||
except ValueError as error:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid status") from error
|
||||
updates["status"] = status_value
|
||||
|
||||
if payload.promo_group_id is not None:
|
||||
promo_group = await get_promo_group_by_id(db, payload.promo_group_id)
|
||||
if not promo_group:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo group not found")
|
||||
updates["promo_group_id"] = promo_group.id
|
||||
|
||||
if payload.referral_code is not None and payload.referral_code != user.referral_code:
|
||||
existing_code_owner = await get_user_by_referral_code(db, payload.referral_code)
|
||||
if existing_code_owner and existing_code_owner.id != user.id:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Referral code already in use")
|
||||
updates["referral_code"] = payload.referral_code
|
||||
|
||||
if not updates:
|
||||
return _serialize_user(user)
|
||||
|
||||
user = await update_user(db, user, **updates)
|
||||
user = await get_user_by_id(db, user.id)
|
||||
return _serialize_user(user)
|
||||
|
||||
|
||||
@router.post("/{user_id}/balance", response_model=UserResponse)
|
||||
async def update_balance(
|
||||
user_id: int,
|
||||
payload: BalanceUpdateRequest,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserResponse:
|
||||
if payload.amount_kopeks == 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Amount must be non-zero")
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
success = await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
amount_kopeks=payload.amount_kopeks,
|
||||
description=payload.description or "Корректировка через веб-API",
|
||||
create_transaction=payload.create_transaction,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to update balance")
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
return _serialize_user(user)
|
||||
0
app/webapi/schemas/__init__.py
Normal file
0
app/webapi/schemas/__init__.py
Normal file
41
app/webapi/schemas/promo_groups.py
Normal file
41
app/webapi/schemas/promo_groups.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PromoGroupResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
server_discount_percent: int
|
||||
traffic_discount_percent: int
|
||||
device_discount_percent: int
|
||||
period_discounts: Dict[int, int] = Field(default_factory=dict)
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None
|
||||
apply_discounts_to_addons: bool
|
||||
is_default: bool
|
||||
members_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PromoGroupCreateRequest(BaseModel):
|
||||
name: str
|
||||
server_discount_percent: int = 0
|
||||
traffic_discount_percent: int = 0
|
||||
device_discount_percent: int = 0
|
||||
period_discounts: Optional[Dict[int, int]] = None
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None
|
||||
apply_discounts_to_addons: bool = True
|
||||
|
||||
|
||||
class PromoGroupUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
server_discount_percent: Optional[int] = None
|
||||
traffic_discount_percent: Optional[int] = None
|
||||
device_discount_percent: Optional[int] = None
|
||||
period_discounts: Optional[Dict[int, int]] = None
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None
|
||||
apply_discounts_to_addons: Optional[bool] = None
|
||||
52
app/webapi/schemas/subscriptions.py
Normal file
52
app/webapi/schemas/subscriptions.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SubscriptionResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
status: str
|
||||
actual_status: str
|
||||
is_trial: bool
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
traffic_limit_gb: int
|
||||
traffic_used_gb: float
|
||||
device_limit: int
|
||||
autopay_enabled: bool
|
||||
autopay_days_before: int
|
||||
subscription_url: Optional[str] = None
|
||||
subscription_crypto_link: Optional[str] = None
|
||||
connected_squads: List[str] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class SubscriptionCreateRequest(BaseModel):
|
||||
user_id: int
|
||||
is_trial: bool = False
|
||||
duration_days: Optional[int] = None
|
||||
traffic_limit_gb: Optional[int] = None
|
||||
device_limit: Optional[int] = None
|
||||
squad_uuid: Optional[str] = None
|
||||
connected_squads: Optional[List[str]] = None
|
||||
|
||||
|
||||
class SubscriptionExtendRequest(BaseModel):
|
||||
days: int = Field(..., gt=0)
|
||||
|
||||
|
||||
class SubscriptionTrafficRequest(BaseModel):
|
||||
gb: int = Field(..., gt=0)
|
||||
|
||||
|
||||
class SubscriptionDevicesRequest(BaseModel):
|
||||
devices: int = Field(..., gt=0)
|
||||
|
||||
|
||||
class SubscriptionSquadRequest(BaseModel):
|
||||
squad_uuid: str
|
||||
44
app/webapi/schemas/tickets.py
Normal file
44
app/webapi/schemas/tickets.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TicketMessageResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
message_text: str
|
||||
is_from_admin: bool
|
||||
has_media: bool
|
||||
media_type: Optional[str] = None
|
||||
media_caption: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TicketResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
title: str
|
||||
status: str
|
||||
priority: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
closed_at: Optional[datetime] = None
|
||||
user_reply_block_permanent: bool
|
||||
user_reply_block_until: Optional[datetime] = None
|
||||
messages: List[TicketMessageResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TicketStatusUpdateRequest(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class TicketPriorityUpdateRequest(BaseModel):
|
||||
priority: str
|
||||
|
||||
|
||||
class TicketReplyBlockRequest(BaseModel):
|
||||
permanent: bool = False
|
||||
until: Optional[datetime] = None
|
||||
30
app/webapi/schemas/tokens.py
Normal file
30
app/webapi/schemas/tokens.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
prefix: str = Field(..., description="Первые символы токена для идентификации")
|
||||
description: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
last_used_at: Optional[datetime] = None
|
||||
last_used_ip: Optional[str] = None
|
||||
created_by: Optional[str] = None
|
||||
|
||||
|
||||
class TokenCreateRequest(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class TokenCreateResponse(TokenResponse):
|
||||
token: str = Field(..., description="Полное значение токена (возвращается один раз)")
|
||||
27
app/webapi/schemas/transactions.py
Normal file
27
app/webapi/schemas/transactions.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TransactionResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
type: str
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
description: Optional[str] = None
|
||||
payment_method: Optional[str] = None
|
||||
external_id: Optional[str] = None
|
||||
is_completed: bool
|
||||
created_at: datetime
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class TransactionListResponse(BaseModel):
|
||||
items: list[TransactionResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
88
app/webapi/schemas/users.py
Normal file
88
app/webapi/schemas/users.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PromoGroupSummary(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
server_discount_percent: int
|
||||
traffic_discount_percent: int
|
||||
device_discount_percent: int
|
||||
apply_discounts_to_addons: bool = True
|
||||
|
||||
|
||||
class SubscriptionSummary(BaseModel):
|
||||
id: int
|
||||
status: str
|
||||
actual_status: str
|
||||
is_trial: bool
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
traffic_limit_gb: int
|
||||
traffic_used_gb: float
|
||||
device_limit: int
|
||||
autopay_enabled: bool
|
||||
autopay_days_before: int
|
||||
subscription_url: Optional[str] = None
|
||||
subscription_crypto_link: Optional[str] = None
|
||||
connected_squads: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
telegram_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
status: str
|
||||
language: str
|
||||
balance_kopeks: int
|
||||
balance_rubles: float
|
||||
referral_code: Optional[str] = None
|
||||
referred_by_id: Optional[int] = None
|
||||
has_had_paid_subscription: bool
|
||||
has_made_first_topup: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
last_activity: Optional[datetime] = None
|
||||
promo_group: Optional[PromoGroupSummary] = None
|
||||
subscription: Optional[SubscriptionSummary] = None
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
items: List[UserResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
telegram_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
language: str = "ru"
|
||||
referred_by_id: Optional[int] = None
|
||||
promo_group_id: Optional[int] = None
|
||||
|
||||
|
||||
class UserUpdateRequest(BaseModel):
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
promo_group_id: Optional[int] = None
|
||||
referral_code: Optional[str] = None
|
||||
has_had_paid_subscription: Optional[bool] = None
|
||||
has_made_first_topup: Optional[bool] = None
|
||||
|
||||
|
||||
class BalanceUpdateRequest(BaseModel):
|
||||
amount_kopeks: int
|
||||
description: Optional[str] = Field(default="Корректировка через веб-API")
|
||||
create_transaction: bool = True
|
||||
68
app/webapi/server.py
Normal file
68
app/webapi/server.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import uvicorn
|
||||
|
||||
from app.config import settings
|
||||
|
||||
from .app import create_web_api_app
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebAPIServer:
|
||||
"""Асинхронный uvicorn-сервер для административного API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._app = create_web_api_app()
|
||||
|
||||
workers = max(1, int(settings.WEB_API_WORKERS or 1))
|
||||
if workers > 1:
|
||||
logger.warning("WEB_API_WORKERS > 1 не поддерживается в embed-режиме, используем 1")
|
||||
workers = 1
|
||||
|
||||
self._config = uvicorn.Config(
|
||||
app=self._app,
|
||||
host=settings.WEB_API_HOST,
|
||||
port=int(settings.WEB_API_PORT or 8080),
|
||||
log_level=settings.LOG_LEVEL.lower(),
|
||||
workers=workers,
|
||||
lifespan="on",
|
||||
)
|
||||
self._server = uvicorn.Server(self._config)
|
||||
self._task: Optional[asyncio.Task[None]] = None
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._task and not self._task.done():
|
||||
logger.info("🌐 Административное веб-API уже запущено")
|
||||
return
|
||||
|
||||
async def _serve() -> None:
|
||||
try:
|
||||
await self._server.serve()
|
||||
except Exception as error: # pragma: no cover - логируем ошибки сервера
|
||||
logger.exception("❌ Ошибка работы веб-API: %s", error)
|
||||
|
||||
logger.info(
|
||||
"🌐 Запуск административного API на %s:%s",
|
||||
settings.WEB_API_HOST,
|
||||
settings.WEB_API_PORT,
|
||||
)
|
||||
self._task = asyncio.create_task(_serve(), name="web-api-server")
|
||||
await self._server.started.wait()
|
||||
|
||||
if self._task.done() and self._task.exception():
|
||||
raise self._task.exception()
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self._task:
|
||||
return
|
||||
|
||||
logger.info("🛑 Остановка административного API")
|
||||
self._server.should_exit = True
|
||||
await self._task
|
||||
self._task = None
|
||||
24
main.py
24
main.py
@@ -63,6 +63,7 @@ async def main():
|
||||
maintenance_task = None
|
||||
version_check_task = None
|
||||
polling_task = None
|
||||
web_api_server = None
|
||||
|
||||
try:
|
||||
logger.info("📊 Инициализация базы данных...")
|
||||
@@ -182,6 +183,22 @@ async def main():
|
||||
else:
|
||||
logger.info("ℹ️ Проверка версий отключена")
|
||||
|
||||
if settings.is_web_api_enabled():
|
||||
try:
|
||||
from app.webapi import WebAPIServer
|
||||
|
||||
web_api_server = WebAPIServer()
|
||||
await web_api_server.start()
|
||||
logger.info(
|
||||
"🌐 Административное веб-API запущено: http://%s:%s",
|
||||
settings.WEB_API_HOST,
|
||||
settings.WEB_API_PORT,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Не удалось запустить веб-API: {error}")
|
||||
else:
|
||||
logger.info("ℹ️ Веб-API отключено")
|
||||
|
||||
logger.info("📄 Запуск polling...")
|
||||
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True))
|
||||
|
||||
@@ -320,6 +337,13 @@ async def main():
|
||||
if webhook_server:
|
||||
logger.info("ℹ️ Остановка webhook сервера...")
|
||||
await webhook_server.stop()
|
||||
|
||||
if web_api_server:
|
||||
try:
|
||||
await web_api_server.stop()
|
||||
logger.info("✅ Административное веб-API остановлено")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка остановки веб-API: {error}")
|
||||
|
||||
if 'bot' in locals():
|
||||
try:
|
||||
|
||||
@@ -6,12 +6,14 @@ SQLAlchemy==2.0.43
|
||||
alembic==1.16.5
|
||||
aiosqlite==0.21.0
|
||||
|
||||
# Дополнительные зависимости
|
||||
# Дополнительные зависимости
|
||||
pydantic==2.11.9
|
||||
pydantic-settings==2.10.1
|
||||
python-dotenv==1.1.1
|
||||
redis==5.0.1
|
||||
PyYAML==6.0.2
|
||||
fastapi==0.115.6
|
||||
uvicorn==0.32.1
|
||||
|
||||
# YooKassa SDK
|
||||
yookassa==3.7.0
|
||||
|
||||
Reference in New Issue
Block a user