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:
Egor
2025-09-26 05:18:55 +03:00
committed by GitHub
30 changed files with 2212 additions and 7 deletions

View File

@@ -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"

View 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",
]

View File

@@ -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}>"

View File

@@ -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:

View 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
View 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
View 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
View 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

View 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
View 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,
)

View File

171
app/webapi/routes/config.py Normal file
View 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)

View 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),
},
}

View 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

View 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),
},
}

View 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)

View 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
View 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

View 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
View 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)

View File

View 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

View 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

View 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

View 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="Полное значение токена (возвращается один раз)")

View 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

View 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
View 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
View File

@@ -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:

View File

@@ -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