From 554fb28cdbacefad29504c974b1f676bbddd61b6 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 26 Sep 2025 05:16:36 +0300 Subject: [PATCH] Revert "Revert 474 revert 473 bedolaga/add api for bot integration" --- README.md | 9 - app/config.py | 52 +--- app/database/crud/api_token.py | 166 ------------ app/database/models.py | 43 +--- app/database/universal_migration.py | 83 ------ app/webapi/__init__.py | 5 - app/webapi/dependencies.py | 90 ------- app/webapi/routers/__init__.py | 16 -- app/webapi/routers/auth.py | 147 ----------- app/webapi/routers/health.py | 17 -- app/webapi/routers/payments.py | 152 ----------- app/webapi/routers/settings.py | 96 ------- app/webapi/routers/stats.py | 87 ------- app/webapi/routers/tickets.py | 128 --------- app/webapi/routers/transactions.py | 45 ---- app/webapi/routers/users.py | 385 ---------------------------- app/webapi/schemas.py | 364 -------------------------- app/webapi/server.py | 86 ------- main.py | 26 +- requirements.txt | 4 +- 20 files changed, 13 insertions(+), 1988 deletions(-) delete mode 100644 app/database/crud/api_token.py delete mode 100644 app/webapi/__init__.py delete mode 100644 app/webapi/dependencies.py delete mode 100644 app/webapi/routers/__init__.py delete mode 100644 app/webapi/routers/auth.py delete mode 100644 app/webapi/routers/health.py delete mode 100644 app/webapi/routers/payments.py delete mode 100644 app/webapi/routers/settings.py delete mode 100644 app/webapi/routers/stats.py delete mode 100644 app/webapi/routers/tickets.py delete mode 100644 app/webapi/routers/transactions.py delete mode 100644 app/webapi/routers/users.py delete mode 100644 app/webapi/schemas.py delete mode 100644 app/webapi/server.py diff --git a/README.md b/README.md index 3da58bcf..748e2e94 100644 --- a/README.md +++ b/README.md @@ -615,15 +615,6 @@ LOG_FILE=logs/bot.log DEBUG=false WEBHOOK_URL= WEBHOOK_PATH=/webhook - -# ===== WEB API ===== -WEBAPI_ENABLED=false -WEBAPI_HOST=0.0.0.0 -WEBAPI_PORT=8080 -WEBAPI_ALLOWED_ORIGINS=* -WEBAPI_ALLOWED_IPS= -WEBAPI_DOCS_ENABLED=false -WEBAPI_MASTER_KEY=super-secret-key ``` diff --git a/app/config.py b/app/config.py index 62258b68..3bd36774 100644 --- a/app/config.py +++ b/app/config.py @@ -3,7 +3,7 @@ import os import re import html from collections import defaultdict -from datetime import time, timedelta +from datetime import time from typing import List, Optional, Union, Dict from pydantic_settings import BaseSettings from pydantic import field_validator, Field @@ -231,27 +231,10 @@ class Settings(BaseSettings): DEBUG: bool = False WEBHOOK_URL: Optional[str] = None WEBHOOK_PATH: str = "/webhook" - - WEBAPI_ENABLED: bool = False - WEBAPI_HOST: str = "0.0.0.0" - WEBAPI_PORT: int = 8080 - WEBAPI_ROOT_PATH: str = "" - WEBAPI_ALLOWED_ORIGINS: str = "*" - WEBAPI_ALLOWED_IPS: str = "" - WEBAPI_LOG_LEVEL: str = "INFO" - WEBAPI_ACCESS_LOG: bool = False - WEBAPI_DOCS_ENABLED: bool = False - WEBAPI_TITLE: str = "Remnawave Bot Admin API" - WEBAPI_DESCRIPTION: str = ( - "REST API для управления ботом Remnawave и интеграции веб-админки" - ) - WEBAPI_VERSION: str = "1.0.0" - WEBAPI_MASTER_KEY: Optional[str] = None - WEBAPI_TOKEN_TTL_HOURS: int = 0 - + APP_CONFIG_PATH: str = "app-config.json" ENABLE_DEEP_LINKS: bool = True - APP_CONFIG_CACHE_TTL: int = 3600 + APP_CONFIG_CACHE_TTL: int = 3600 VERSION_CHECK_ENABLED: bool = True VERSION_CHECK_REPO: str = "fr1ngg/remnawave-bedolaga-telegram-bot" @@ -981,36 +964,11 @@ class Settings(BaseSettings): def is_support_contact_enabled(self) -> bool: return self.get_support_system_mode() in {"contact", "both"} - - def is_webapi_enabled(self) -> bool: - return bool(self.WEBAPI_ENABLED) - - def get_webapi_allowed_origins(self) -> List[str]: - raw = (self.WEBAPI_ALLOWED_ORIGINS or "").strip() - if not raw or raw == "*": - return ["*"] - return [origin.strip() for origin in raw.split(",") if origin.strip()] - - def get_webapi_allowed_ips(self) -> List[str]: - raw = (self.WEBAPI_ALLOWED_IPS or "").strip() - if not raw: - return [] - return [ip.strip() for ip in raw.split(",") if ip.strip()] - - def get_webapi_token_ttl(self) -> Optional[timedelta]: - try: - hours = int(self.WEBAPI_TOKEN_TTL_HOURS) - except (TypeError, ValueError): - hours = 0 - - if hours <= 0: - return None - return timedelta(hours=hours) - + model_config = { "env_file": ".env", "env_file_encoding": "utf-8", - "extra": "ignore" + "extra": "ignore" } diff --git a/app/database/crud/api_token.py b/app/database/crud/api_token.py deleted file mode 100644 index b0f230aa..00000000 --- a/app/database/crud/api_token.py +++ /dev/null @@ -1,166 +0,0 @@ -import hashlib -import secrets -from datetime import datetime -from typing import Iterable, List, Optional, Union - -from sqlalchemy import delete, select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.models import ApiToken - - -UNSET = object() - - -def _normalize_iterable(values: Optional[Iterable[str]]) -> Optional[List[str]]: - if not values: - return None - - normalized: List[str] = [] - for raw in values: - if raw is None: - continue - value = str(raw).strip() - if not value: - continue - if value not in normalized: - normalized.append(value) - return normalized or None - - -TOKEN_LENGTH = 64 -TOKEN_PREFIX_LENGTH = 12 - - -def generate_token(length: int = TOKEN_LENGTH) -> str: - """Генерирует случайный токен.""" - alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - return "".join(secrets.choice(alphabet) for _ in range(length)) - - -def hash_token(token: str) -> str: - return hashlib.sha256(token.encode("utf-8")).hexdigest() - - -def get_token_prefix(token: str, length: int = TOKEN_PREFIX_LENGTH) -> str: - return token[:length] - - -async def create_api_token( - db: AsyncSession, - *, - name: str, - token: str, - description: Optional[str] = None, - permissions: Optional[Iterable[str]] = None, - allowed_ips: Optional[Iterable[str]] = None, - expires_at: Optional[datetime] = None, -) -> ApiToken: - token_hash = hash_token(token) - token_prefix = get_token_prefix(token) - - api_token = ApiToken( - name=name, - token_hash=token_hash, - token_prefix=token_prefix, - description=description, - permissions=_normalize_iterable(permissions), - allowed_ips=_normalize_iterable(allowed_ips), - expires_at=expires_at, - ) - - db.add(api_token) - await db.commit() - await db.refresh(api_token) - return api_token - - -async def list_api_tokens(db: AsyncSession, *, active_only: bool = False) -> List[ApiToken]: - stmt = select(ApiToken) - if active_only: - stmt = stmt.where(ApiToken.is_active.is_(True)) - stmt = stmt.order_by(ApiToken.created_at.desc()) - result = await db.execute(stmt) - return list(result.scalars().all()) - - -async def get_api_token_by_hash(db: AsyncSession, token_hash: str) -> Optional[ApiToken]: - result = await db.execute( - select(ApiToken).where(ApiToken.token_hash == token_hash) - ) - return result.scalar_one_or_none() - - -async def get_api_token_by_id(db: AsyncSession, token_id: int) -> Optional[ApiToken]: - result = await db.execute( - select(ApiToken).where(ApiToken.id == token_id) - ) - return result.scalar_one_or_none() - - -async def update_last_used(db: AsyncSession, token: ApiToken) -> None: - token.last_used_at = datetime.utcnow() - await db.commit() - - -async def deactivate_api_token(db: AsyncSession, token: ApiToken) -> ApiToken: - token.is_active = False - token.updated_at = datetime.utcnow() - await db.commit() - await db.refresh(token) - return token - - -async def delete_api_token(db: AsyncSession, token_id: int) -> None: - await db.execute(delete(ApiToken).where(ApiToken.id == token_id)) - await db.commit() - - -async def update_api_token( - db: AsyncSession, - token: ApiToken, - *, - name: Optional[str] = None, - description: Optional[str] = None, - permissions: Union[Iterable[str], object] = UNSET, - allowed_ips: Union[Iterable[str], object] = UNSET, - expires_at: Union[Optional[datetime], object] = UNSET, - is_active: Optional[bool] = None, -) -> ApiToken: - if name is not None: - token.name = name - if description is not None: - token.description = description - if permissions is not UNSET: - token.permissions = ( - _normalize_iterable(permissions) if permissions is not None else None - ) - if allowed_ips is not UNSET: - token.allowed_ips = ( - _normalize_iterable(allowed_ips) if allowed_ips is not None else None - ) - if expires_at is not UNSET: - token.expires_at = expires_at - if is_active is not None: - token.is_active = is_active - - token.updated_at = datetime.utcnow() - await db.commit() - await db.refresh(token) - return token - - -async def purge_expired_tokens(db: AsyncSession) -> int: - now = datetime.utcnow() - result = await db.execute( - delete(ApiToken) - .where(ApiToken.expires_at.is_not(None)) - .where(ApiToken.expires_at < now) - .returning(ApiToken.id) - ) - deleted = result.scalars().all() - if deleted: - await db.commit() - return len(deleted) - await db.rollback() - return 0 diff --git a/app/database/models.py b/app/database/models.py index 6bf33413..bc36c94e 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -740,53 +740,22 @@ class Squad(Base): class ServiceRule(Base): __tablename__ = "service_rules" - + id = Column(Integer, primary_key=True, index=True) - + order = Column(Integer, default=0) title = Column(String(255), nullable=False) - + content = Column(Text, nullable=False) - + is_active = Column(Boolean, default=True) - + language = Column(String(5), default="ru") - + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) -class ApiToken(Base): - __tablename__ = "api_tokens" - - id = Column(Integer, primary_key=True, index=True) - name = Column(String(255), nullable=False) - token_hash = Column(String(128), unique=True, nullable=False) - token_prefix = Column(String(32), nullable=False) - description = Column(Text, nullable=True) - permissions = Column(JSON, nullable=True) - allowed_ips = Column(JSON, nullable=True) - is_active = Column(Boolean, default=True) - expires_at = Column(DateTime, nullable=True) - last_used_at = Column(DateTime, nullable=True) - created_at = Column(DateTime, default=func.now()) - updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - - __table_args__ = ( - UniqueConstraint("token_prefix", name="uq_api_tokens_token_prefix"), - ) - - def is_valid(self) -> bool: - if not self.is_active: - return False - if self.expires_at and self.expires_at < datetime.utcnow(): - return False - return True - - def __repr__(self) -> str: - return f"" - - class SystemSetting(Base): __tablename__ = "system_settings" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 4b89b0d0..ee1e5cee 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1886,82 +1886,6 @@ async def create_system_settings_table() -> bool: return False -async def create_api_tokens_table() -> bool: - table_exists = await check_table_exists("api_tokens") - if table_exists: - logger.info("ℹ️ Таблица 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 api_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - token_hash TEXT NOT NULL UNIQUE, - token_prefix TEXT NOT NULL UNIQUE, - description TEXT NULL, - permissions TEXT NULL, - allowed_ips TEXT NULL, - is_active BOOLEAN NOT NULL DEFAULT 1, - expires_at DATETIME NULL, - last_used_at DATETIME NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - """ - elif db_type == "postgresql": - create_sql = """ - CREATE TABLE api_tokens ( - id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL, - token_hash VARCHAR(128) NOT NULL UNIQUE, - token_prefix VARCHAR(32) NOT NULL UNIQUE, - description TEXT NULL, - permissions JSONB NULL, - allowed_ips JSONB NULL, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - expires_at TIMESTAMP NULL, - last_used_at TIMESTAMP NULL, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() - ); - """ - elif db_type == "mysql": - create_sql = """ - CREATE TABLE 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 UNIQUE, - description TEXT NULL, - permissions JSON NULL, - allowed_ips JSON NULL, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - expires_at DATETIME NULL, - last_used_at DATETIME NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - """ - else: - logger.warning( - f"⚠️ Неизвестный тип базы данных {db_type}, пропускаем создание api_tokens" - ) - return False - - await conn.execute(text(create_sql)) - logger.info("✅ Таблица api_tokens создана") - return True - - except Exception as error: - logger.error(f"Ошибка создания таблицы api_tokens: {error}") - return False - - async def run_universal_migration(): logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===") @@ -1980,13 +1904,6 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей system_settings") - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ API TOKENS ===") - api_tokens_ready = await create_api_tokens_table() - if api_tokens_ready: - logger.info("✅ Таблица api_tokens готова") - else: - logger.warning("⚠️ Проблемы с таблицей api_tokens") - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ CRYPTOBOT ===") cryptobot_created = await create_cryptobot_payments_table() if cryptobot_created: diff --git a/app/webapi/__init__.py b/app/webapi/__init__.py deleted file mode 100644 index ae25995b..00000000 --- a/app/webapi/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Пакет с FastAPI-приложением для веб-админки.""" - -from .server import WebAPIServer, create_app - -__all__ = ["WebAPIServer", "create_app"] diff --git a/app/webapi/dependencies.py b/app/webapi/dependencies.py deleted file mode 100644 index e13343dd..00000000 --- a/app/webapi/dependencies.py +++ /dev/null @@ -1,90 +0,0 @@ -from __future__ import annotations - -from typing import Callable, List, Optional - -from fastapi import Depends, HTTPException, Request, status -from fastapi.security import APIKeyHeader -from sqlalchemy.ext.asyncio import AsyncSession - -from app.config import settings -from app.database.crud.api_token import ( - get_api_token_by_hash, - hash_token, - update_last_used, -) -from app.database.database import AsyncSessionLocal -from app.database.models import ApiToken - - -api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) - - -async def get_db() -> AsyncSession: - async with AsyncSessionLocal() as session: - yield session - - -def _is_ip_allowed(client_ip: Optional[str], allowed_ips: List[str]) -> bool: - if not allowed_ips: - return True - if not client_ip: - return False - return client_ip in allowed_ips - - -async def get_current_token( - request: Request, - api_key: Optional[str] = Depends(api_key_header), - db: AsyncSession = Depends(get_db), -) -> ApiToken: - if not settings.is_webapi_enabled(): - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Web API отключен", - ) - - if not api_key: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Требуется API ключ", - ) - - token_hash = hash_token(api_key) - token = await get_api_token_by_hash(db, token_hash) - - if not token or not token.is_valid(): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Неверный или неактивный API ключ", - ) - - client_ip = request.client.host if request.client else None - global_allowed = settings.get_webapi_allowed_ips() - if global_allowed and not _is_ip_allowed(client_ip, global_allowed): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="IP адрес не разрешен глобальными настройками", - ) - - token_allowed_ips = token.allowed_ips or [] - if token_allowed_ips and not _is_ip_allowed(client_ip, token_allowed_ips): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="IP адрес не разрешен для этого токена", - ) - - await update_last_used(db, token) - return token - - -def require_permission(permission: str) -> Callable[[ApiToken], ApiToken]: - async def _dependency(token: ApiToken = Depends(get_current_token)) -> ApiToken: - permissions = token.permissions or [] - if "*" in permissions or permission in permissions: - return token - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Недостаточно прав для выполнения операции", - ) - - return _dependency diff --git a/app/webapi/routers/__init__.py b/app/webapi/routers/__init__.py deleted file mode 100644 index 87fa5233..00000000 --- a/app/webapi/routers/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -from fastapi import APIRouter - -from . import auth, health, payments, settings, stats, tickets, transactions, users - -api_router = APIRouter(prefix="/api") - -api_router.include_router(health.router, tags=["health"]) -api_router.include_router(auth.router, tags=["auth"]) -api_router.include_router(stats.router, tags=["stats"]) -api_router.include_router(users.router, tags=["users"]) -api_router.include_router(transactions.router, tags=["transactions"]) -api_router.include_router(payments.router, tags=["payments"]) -api_router.include_router(tickets.router, tags=["tickets"]) -api_router.include_router(settings.router, tags=["settings"]) - -__all__ = ["api_router"] diff --git a/app/webapi/routers/auth.py b/app/webapi/routers/auth.py deleted file mode 100644 index 9b865aaf..00000000 --- a/app/webapi/routers/auth.py +++ /dev/null @@ -1,147 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timedelta -from typing import List - -from fastapi import APIRouter, Depends, HTTPException, Path, status -from sqlalchemy.ext.asyncio import AsyncSession - -from app.config import settings -from app.database.crud import api_token as api_token_crud -from app.database.models import ApiToken -from app.webapi.dependencies import get_current_token, get_db, require_permission -from app.webapi.schemas import ( - APIMessage, - TokenCreateRequest, - TokenResponse, - TokenUpdateRequest, - TokenWithSecretResponse, -) - -router = APIRouter(prefix="/auth") - - -def _token_to_response(token: ApiToken) -> TokenResponse: - return TokenResponse( - id=token.id, - name=token.name, - description=token.description, - permissions=token.permissions or [], - allowed_ips=token.allowed_ips or [], - is_active=token.is_active, - token_prefix=token.token_prefix, - created_at=token.created_at, - updated_at=token.updated_at, - last_used_at=token.last_used_at, - expires_at=token.expires_at, - ) - - -@router.post("/token", response_model=TokenWithSecretResponse, status_code=status.HTTP_201_CREATED) -async def create_token( - payload: TokenCreateRequest, - db: AsyncSession = Depends(get_db), -): - if not settings.WEBAPI_MASTER_KEY: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="WEBAPI_MASTER_KEY не задан в конфигурации", - ) - - if payload.secret != settings.WEBAPI_MASTER_KEY: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Неверный секрет", - ) - - plain_token = api_token_crud.generate_token() - - expires_at = None - if payload.expires_in_hours: - expires_at = datetime.utcnow() + timedelta(hours=payload.expires_in_hours) - else: - ttl = settings.get_webapi_token_ttl() - if ttl: - expires_at = datetime.utcnow() + ttl - - api_token = await api_token_crud.create_api_token( - db, - name=payload.name, - token=plain_token, - description=payload.description, - permissions=payload.permissions, - allowed_ips=payload.allowed_ips, - expires_at=expires_at, - ) - - response = TokenWithSecretResponse( - **_token_to_response(api_token).model_dump(), - token=plain_token, - ) - return response - - -@router.get("/tokens", response_model=List[TokenResponse]) -async def list_tokens( - db: AsyncSession = Depends(get_db), - _: ApiToken = Depends(require_permission("webapi.tokens:read")), -) -> List[TokenResponse]: - tokens = await api_token_crud.list_api_tokens(db) - return [_token_to_response(token) for token in tokens] - - -@router.patch("/tokens/{token_id}", response_model=TokenResponse) -async def update_token( - payload: TokenUpdateRequest, - token_id: int = Path(..., ge=1), - db: AsyncSession = Depends(get_db), - _: ApiToken = Depends(require_permission("webapi.tokens:write")), -) -> TokenResponse: - token = await api_token_crud.get_api_token_by_id(db, token_id) - if not token: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Токен не найден") - - updated = await api_token_crud.update_api_token( - db, - token, - name=payload.name, - description=payload.description, - permissions=payload.permissions, - allowed_ips=payload.allowed_ips, - expires_at=payload.expires_at, - is_active=payload.is_active, - ) - return _token_to_response(updated) - - -@router.post("/tokens/{token_id}/revoke", response_model=TokenResponse) -async def revoke_token( - token_id: int = Path(..., ge=1), - db: AsyncSession = Depends(get_db), - _: ApiToken = Depends(require_permission("webapi.tokens:write")), -) -> TokenResponse: - token = await api_token_crud.get_api_token_by_id(db, token_id) - if not token: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Токен не найден") - - revoked = await api_token_crud.deactivate_api_token(db, token) - return _token_to_response(revoked) - - -@router.delete("/tokens/{token_id}", response_model=APIMessage) -async def delete_token( - token_id: int = Path(..., ge=1), - db: AsyncSession = Depends(get_db), - _: ApiToken = Depends(require_permission("webapi.tokens:write")), -) -> APIMessage: - token = await api_token_crud.get_api_token_by_id(db, token_id) - if not token: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Токен не найден") - - await api_token_crud.delete_api_token(db, token_id) - return APIMessage(message="Токен удален") - - -@router.get("/me", response_model=TokenResponse) -async def whoami(current_token: ApiToken = Depends(get_current_token)) -> TokenResponse: - return _token_to_response(current_token) diff --git a/app/webapi/routers/health.py b/app/webapi/routers/health.py deleted file mode 100644 index b13b8a13..00000000 --- a/app/webapi/routers/health.py +++ /dev/null @@ -1,17 +0,0 @@ -from datetime import datetime - -from fastapi import APIRouter - -from app.config import settings -from app.webapi.schemas import HealthResponse - -router = APIRouter() - - -@router.get("/health", response_model=HealthResponse) -async def health_check() -> HealthResponse: - return HealthResponse( - status="ok", - version=settings.WEBAPI_VERSION, - timestamp=datetime.utcnow(), - ) diff --git a/app/webapi/routers/payments.py b/app/webapi/routers/payments.py deleted file mode 100644 index c765056e..00000000 --- a/app/webapi/routers/payments.py +++ /dev/null @@ -1,152 +0,0 @@ -from __future__ import annotations - -from typing import Optional - -from fastapi import APIRouter, Depends, Query -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.models import CryptoBotPayment, MulenPayPayment, Pal24Payment, YooKassaPayment -from app.webapi.dependencies import get_db, require_permission -from app.webapi.schemas import ( - CryptoBotPaymentListResponse, - CryptoBotPaymentSchema, - MulenPayPaymentListResponse, - MulenPayPaymentSchema, - Pal24PaymentListResponse, - Pal24PaymentSchema, - Pagination, - YooKassaPaymentListResponse, - YooKassaPaymentSchema, -) - -router = APIRouter(prefix="/payments") - - -async def _paginate( - db: AsyncSession, - base_query, - count_query, - limit: int, - offset: int, -): - result = await db.execute(base_query.offset(offset).limit(limit)) - items = result.scalars().all() - total = await db.scalar(count_query) or 0 - return items, total - - -@router.get("/yookassa", response_model=YooKassaPaymentListResponse) -async def list_yookassa_payments( - user_id: Optional[int] = Query(default=None, ge=1), - status_filter: Optional[str] = Query(default=None, alias="status"), - is_paid: Optional[bool] = Query(default=None), - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.payments:read")), -): - stmt = select(YooKassaPayment) - count_stmt = select(func.count(YooKassaPayment.id)) - - if user_id: - stmt = stmt.where(YooKassaPayment.user_id == user_id) - count_stmt = count_stmt.where(YooKassaPayment.user_id == user_id) - if status_filter: - stmt = stmt.where(YooKassaPayment.status == status_filter) - count_stmt = count_stmt.where(YooKassaPayment.status == status_filter) - if is_paid is not None: - stmt = stmt.where(YooKassaPayment.is_paid.is_(is_paid)) - count_stmt = count_stmt.where(YooKassaPayment.is_paid.is_(is_paid)) - - items, total = await _paginate(db, stmt.order_by(YooKassaPayment.created_at.desc()), count_stmt, limit, offset) - return YooKassaPaymentListResponse( - pagination=Pagination(total=total, limit=limit, offset=offset), - items=[YooKassaPaymentSchema.model_validate(item) for item in items], - ) - - -@router.get("/cryptobot", response_model=CryptoBotPaymentListResponse) -async def list_cryptobot_payments( - user_id: Optional[int] = Query(default=None, ge=1), - status_filter: Optional[str] = Query(default=None, alias="status"), - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.payments:read")), -): - stmt = select(CryptoBotPayment) - count_stmt = select(func.count(CryptoBotPayment.id)) - - if user_id: - stmt = stmt.where(CryptoBotPayment.user_id == user_id) - count_stmt = count_stmt.where(CryptoBotPayment.user_id == user_id) - if status_filter: - stmt = stmt.where(CryptoBotPayment.status == status_filter) - count_stmt = count_stmt.where(CryptoBotPayment.status == status_filter) - - items, total = await _paginate(db, stmt.order_by(CryptoBotPayment.created_at.desc()), count_stmt, limit, offset) - return CryptoBotPaymentListResponse( - pagination=Pagination(total=total, limit=limit, offset=offset), - items=[CryptoBotPaymentSchema.model_validate(item) for item in items], - ) - - -@router.get("/mulenpay", response_model=MulenPayPaymentListResponse) -async def list_mulen_payments( - user_id: Optional[int] = Query(default=None, ge=1), - status_filter: Optional[str] = Query(default=None, alias="status"), - is_paid: Optional[bool] = Query(default=None), - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.payments:read")), -): - stmt = select(MulenPayPayment) - count_stmt = select(func.count(MulenPayPayment.id)) - - if user_id: - stmt = stmt.where(MulenPayPayment.user_id == user_id) - count_stmt = count_stmt.where(MulenPayPayment.user_id == user_id) - if status_filter: - stmt = stmt.where(MulenPayPayment.status == status_filter) - count_stmt = count_stmt.where(MulenPayPayment.status == status_filter) - if is_paid is not None: - stmt = stmt.where(MulenPayPayment.is_paid.is_(is_paid)) - count_stmt = count_stmt.where(MulenPayPayment.is_paid.is_(is_paid)) - - items, total = await _paginate(db, stmt.order_by(MulenPayPayment.created_at.desc()), count_stmt, limit, offset) - return MulenPayPaymentListResponse( - pagination=Pagination(total=total, limit=limit, offset=offset), - items=[MulenPayPaymentSchema.model_validate(item) for item in items], - ) - - -@router.get("/pal24", response_model=Pal24PaymentListResponse) -async def list_pal24_payments( - user_id: Optional[int] = Query(default=None, ge=1), - status_filter: Optional[str] = Query(default=None, alias="status"), - is_paid: Optional[bool] = Query(default=None), - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.payments:read")), -): - stmt = select(Pal24Payment) - count_stmt = select(func.count(Pal24Payment.id)) - - if user_id: - stmt = stmt.where(Pal24Payment.user_id == user_id) - count_stmt = count_stmt.where(Pal24Payment.user_id == user_id) - if status_filter: - stmt = stmt.where(Pal24Payment.status == status_filter) - count_stmt = count_stmt.where(Pal24Payment.status == status_filter) - if is_paid is not None: - stmt = stmt.where(Pal24Payment.is_paid.is_(is_paid)) - count_stmt = count_stmt.where(Pal24Payment.is_paid.is_(is_paid)) - - items, total = await _paginate(db, stmt.order_by(Pal24Payment.created_at.desc()), count_stmt, limit, offset) - return Pal24PaymentListResponse( - pagination=Pagination(total=total, limit=limit, offset=offset), - items=[Pal24PaymentSchema.model_validate(item) for item in items], - ) diff --git a/app/webapi/routers/settings.py b/app/webapi/routers/settings.py deleted file mode 100644 index dbcd1205..00000000 --- a/app/webapi/routers/settings.py +++ /dev/null @@ -1,96 +0,0 @@ -from __future__ import annotations - -from typing import Optional - -from fastapi import APIRouter, Depends, HTTPException, Path, Query, status -from sqlalchemy.ext.asyncio import AsyncSession - -from app.services.system_settings_service import bot_configuration_service -from app.webapi.dependencies import get_db, require_permission -from app.webapi.schemas import SettingResponse, SettingsListResponse, SettingUpdateRequest - -router = APIRouter(prefix="/settings") - - -def _build_setting_response(key: str) -> SettingResponse: - summary = bot_configuration_service.get_setting_summary(key) - return SettingResponse( - key=summary["key"], - name=summary["name"], - value=summary["current"], - original=summary["original"], - has_override=summary["has_override"], - category_key=summary["category_key"], - category_label=summary["category_label"], - type=summary["type"], - ) - - -@router.get("", response_model=SettingsListResponse) -async def list_settings( - category: Optional[str] = Query(default=None), - _: object = Depends(require_permission("webapi.settings:read")), -) -> SettingsListResponse: - bot_configuration_service.initialize_definitions() - - if category: - definitions = bot_configuration_service.get_settings_for_category(category) - else: - definitions = list(bot_configuration_service._definitions.values()) # type: ignore[attr-defined] - - definitions = sorted(definitions, key=lambda definition: definition.display_name) - - items = [_build_setting_response(definition.key) for definition in definitions] - return SettingsListResponse(items=items) - - -@router.get("/{key}", response_model=SettingResponse) -async def get_setting( - key: str = Path(..., min_length=1), - _: object = Depends(require_permission("webapi.settings:read")), -) -> SettingResponse: - try: - bot_configuration_service.get_definition(key) - except KeyError: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Настройка не найдена") - - return _build_setting_response(key) - - -@router.put("/{key}", response_model=SettingResponse) -async def update_setting( - payload: SettingUpdateRequest, - key: str = Path(..., min_length=1), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.settings:write")), -) -> SettingResponse: - try: - bot_configuration_service.get_definition(key) - except KeyError: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Настройка не найдена") - - try: - if payload.value is None: - parsed_value = None - else: - parsed_value = bot_configuration_service.parse_user_value(key, str(payload.value)) - except ValueError as error: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) - - await bot_configuration_service.set_value(db, key, parsed_value) - return _build_setting_response(key) - - -@router.delete("/{key}", response_model=SettingResponse) -async def reset_setting( - key: str = Path(..., min_length=1), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.settings:write")), -) -> SettingResponse: - try: - bot_configuration_service.get_definition(key) - except KeyError: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Настройка не найдена") - - await bot_configuration_service.reset_value(db, key) - return _build_setting_response(key) diff --git a/app/webapi/routers/stats.py b/app/webapi/routers/stats.py deleted file mode 100644 index 32c34706..00000000 --- a/app/webapi/routers/stats.py +++ /dev/null @@ -1,87 +0,0 @@ -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession -from fastapi import APIRouter, Depends - -from app.database.models import ( - CryptoBotPayment, - MulenPayPayment, - Pal24Payment, - Subscription, - SubscriptionStatus, - Ticket, - TicketStatus, - User, - UserStatus, - YooKassaPayment, -) -from app.webapi.dependencies import get_db, require_permission -from app.webapi.schemas import StatsResponse - -router = APIRouter(prefix="/stats") - - -@router.get("/overview", response_model=StatsResponse) -async def get_overview( - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.stats:read")), -) -> StatsResponse: - total_users = await db.scalar(select(func.count(User.id))) - - active_users = await db.scalar( - select(func.count(User.id)).where(User.status == UserStatus.ACTIVE.value) - ) - blocked_users = await db.scalar( - select(func.count(User.id)).where(User.status == UserStatus.BLOCKED.value) - ) - total_balance = await db.scalar(select(func.coalesce(func.sum(User.balance_kopeks), 0))) - - active_subscriptions = await db.scalar( - select(func.count(Subscription.id)).where( - Subscription.status == SubscriptionStatus.ACTIVE.value - ) - ) - expired_subscriptions = await db.scalar( - select(func.count(Subscription.id)).where( - Subscription.status == SubscriptionStatus.EXPIRED.value - ) - ) - - open_tickets = await db.scalar( - select(func.count(Ticket.id)).where(Ticket.status == TicketStatus.OPEN.value) - ) - - pending_yookassa = await db.scalar( - select(func.count(YooKassaPayment.id)).where( - YooKassaPayment.is_paid.is_(False) - ) - ) or 0 - pending_cryptobot = await db.scalar( - select(func.count(CryptoBotPayment.id)).where( - CryptoBotPayment.status.in_(["active", "pending"]) - ) - ) or 0 - pending_mulenpay = await db.scalar( - select(func.count(MulenPayPayment.id)).where( - MulenPayPayment.is_paid.is_(False) - ) - ) or 0 - pending_pal24 = await db.scalar( - select(func.count(Pal24Payment.id)).where( - Pal24Payment.is_paid.is_(False) - ) - ) or 0 - - pending_payments = ( - pending_yookassa + pending_cryptobot + pending_mulenpay + pending_pal24 - ) - - return StatsResponse( - total_users=total_users or 0, - active_users=active_users or 0, - blocked_users=blocked_users or 0, - total_balance_kopeks=total_balance or 0, - active_subscriptions=active_subscriptions or 0, - expired_subscriptions=expired_subscriptions or 0, - open_tickets=open_tickets or 0, - pending_payments=pending_payments or 0, - ) diff --git a/app/webapi/routers/tickets.py b/app/webapi/routers/tickets.py deleted file mode 100644 index 65afc048..00000000 --- a/app/webapi/routers/tickets.py +++ /dev/null @@ -1,128 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Optional - -from fastapi import APIRouter, Depends, HTTPException, Path, Query, status -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload - -from app.database.models import Ticket, TicketStatus -from app.webapi.dependencies import get_db, require_permission -from app.webapi.schemas import ( - Pagination, - TicketListResponse, - TicketMessageSchema, - TicketSchema, - TicketUpdateRequest, -) - -router = APIRouter(prefix="/tickets") - - -def _ticket_to_schema(ticket: Ticket) -> TicketSchema: - messages = sorted(ticket.messages, key=lambda message: message.created_at) - return TicketSchema( - 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=[TicketMessageSchema.model_validate(message) for message in messages], - ) - - -@router.get("", response_model=TicketListResponse) -async def list_tickets( - status_filter: Optional[TicketStatus] = Query(default=None, alias="status"), - user_id: Optional[int] = Query(default=None, ge=1), - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.tickets:read")), -) -> TicketListResponse: - stmt = select(Ticket).options(selectinload(Ticket.messages)) - count_stmt = select(func.count(Ticket.id)) - - if status_filter: - stmt = stmt.where(Ticket.status == status_filter.value) - count_stmt = count_stmt.where(Ticket.status == status_filter.value) - if user_id: - stmt = stmt.where(Ticket.user_id == user_id) - count_stmt = count_stmt.where(Ticket.user_id == user_id) - - stmt = stmt.order_by(Ticket.created_at.desc()).offset(offset).limit(limit) - - result = await db.execute(stmt) - tickets = result.scalars().unique().all() - - total = await db.scalar(count_stmt) or 0 - pagination = Pagination(total=total, limit=limit, offset=offset) - - return TicketListResponse( - pagination=pagination, - items=[_ticket_to_schema(ticket) for ticket in tickets], - ) - - -@router.get("/{ticket_id}", response_model=TicketSchema) -async def get_ticket( - ticket_id: int = Path(..., ge=1), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.tickets:read")), -) -> TicketSchema: - stmt = ( - select(Ticket) - .options(selectinload(Ticket.messages)) - .where(Ticket.id == ticket_id) - ) - result = await db.execute(stmt) - ticket = result.scalars().unique().one_or_none() - - if not ticket: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Тикет не найден") - - return _ticket_to_schema(ticket) - - -@router.patch("/{ticket_id}", response_model=TicketSchema) -async def update_ticket( - payload: TicketUpdateRequest, - ticket_id: int = Path(..., ge=1), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.tickets:write")), -) -> TicketSchema: - stmt = ( - select(Ticket) - .options(selectinload(Ticket.messages)) - .where(Ticket.id == ticket_id) - ) - result = await db.execute(stmt) - ticket = result.scalars().unique().one_or_none() - - if not ticket: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Тикет не найден") - - data = payload.model_dump(exclude_unset=True) - if "status" in data and data["status"] is not None: - new_status = data["status"].value if isinstance(data["status"], TicketStatus) else data["status"] - data["status"] = new_status - if new_status == TicketStatus.CLOSED.value: - ticket.closed_at = ticket.closed_at or datetime.utcnow() - else: - ticket.closed_at = None - - for field, value in data.items(): - setattr(ticket, field, value) - - ticket.updated_at = datetime.utcnow() - await db.commit() - await db.refresh(ticket) - - return _ticket_to_schema(ticket) diff --git a/app/webapi/routers/transactions.py b/app/webapi/routers/transactions.py deleted file mode 100644 index dc2c3749..00000000 --- a/app/webapi/routers/transactions.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -from typing import Optional - -from fastapi import APIRouter, Depends, Query -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.models import Transaction -from app.webapi.dependencies import get_db, require_permission -from app.webapi.schemas import Pagination, TransactionListResponse, TransactionSchema - -router = APIRouter(prefix="/transactions") - - -@router.get("", response_model=TransactionListResponse) -async def list_transactions( - user_id: Optional[int] = Query(default=None, ge=1), - tx_type: Optional[str] = Query(default=None, alias="type"), - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.transactions:read")), -) -> TransactionListResponse: - stmt = select(Transaction) - count_stmt = select(func.count(Transaction.id)) - - if user_id: - stmt = stmt.where(Transaction.user_id == user_id) - count_stmt = count_stmt.where(Transaction.user_id == user_id) - - if tx_type: - stmt = stmt.where(Transaction.type == tx_type) - count_stmt = count_stmt.where(Transaction.type == tx_type) - - stmt = stmt.order_by(Transaction.created_at.desc()).offset(offset).limit(limit) - - result = await db.execute(stmt) - transactions = result.scalars().all() - - total = await db.scalar(count_stmt) or 0 - - pagination = Pagination(total=total, limit=limit, offset=offset) - items = [TransactionSchema.model_validate(tx) for tx in transactions] - return TransactionListResponse(pagination=pagination, items=items) diff --git a/app/webapi/routers/users.py b/app/webapi/routers/users.py deleted file mode 100644 index 801e433a..00000000 --- a/app/webapi/routers/users.py +++ /dev/null @@ -1,385 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional - -from fastapi import APIRouter, Depends, HTTPException, Path, Query, status -from sqlalchemy import func, or_, select -from sqlalchemy.orm import selectinload -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.crud.subscription import ( - add_subscription_devices, - add_subscription_squad, - add_subscription_traffic, - extend_subscription, - get_subscription_by_user_id, - remove_subscription_squad, -) -from app.database.crud.user import ( - add_user_balance, - create_user, - get_user_by_id, - update_user, -) -from app.database.models import Subscription, Transaction, User, UserStatus -from app.webapi.dependencies import get_db, require_permission -from app.webapi.schemas import ( - Pagination, - SubscriptionDevicesRequest, - SubscriptionExtendRequest, - SubscriptionSchema, - SubscriptionSquadRequest, - SubscriptionTrafficRequest, - TransactionListResponse, - TransactionSchema, - UserBalanceUpdateRequest, - UserCreateRequest, - UserDetailResponse, - UserListItem, - UserListResponse, - UserStatusUpdateRequest, - UserUpdateRequest, -) - -router = APIRouter(prefix="/users") - - -def _user_to_list_item(user: User) -> UserListItem: - subscription = user.subscription - return UserListItem( - id=user.id, - telegram_id=user.telegram_id, - username=user.username, - first_name=user.first_name, - last_name=user.last_name, - language=user.language, - status=user.status, - balance_kopeks=user.balance_kopeks, - referral_code=user.referral_code, - created_at=user.created_at, - updated_at=user.updated_at, - subscription_status=subscription.status if subscription else None, - subscription_end_date=subscription.end_date if subscription else None, - ) - - -def _user_to_detail(user: User, transactions: List[Transaction]) -> UserDetailResponse: - subscription = user.subscription - return UserDetailResponse( - id=user.id, - telegram_id=user.telegram_id, - username=user.username, - first_name=user.first_name, - last_name=user.last_name, - language=user.language, - status=user.status, - balance_kopeks=user.balance_kopeks, - referred_by_id=user.referred_by_id, - referral_code=user.referral_code, - promo_group_id=user.promo_group_id, - created_at=user.created_at, - updated_at=user.updated_at, - subscription=SubscriptionSchema.model_validate(subscription) - if subscription - else None, - transactions=[TransactionSchema.model_validate(tx) for tx in transactions], - ) - - -@router.get("", response_model=UserListResponse) -async def list_users( - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), - status_filter: Optional[str] = Query(None, alias="status"), - search: Optional[str] = Query(None), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.users:read")), -) -> UserListResponse: - stmt = select(User).options(selectinload(User.subscription)) - count_stmt = select(func.count(User.id)) - - filters = [] - - if status_filter: - valid_statuses = {status.value for status in UserStatus} - if status_filter not in valid_statuses: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Недопустимый статус фильтра", - ) - filters.append(User.status == status_filter) - - if search: - search = search.strip() - if search.startswith("@"): - search = search[1:] - if search.isdigit(): - filters.append(User.telegram_id == int(search)) - else: - like_pattern = f"%{search.lower()}%" - filters.append( - or_( - func.lower(User.username).like(like_pattern), - func.lower(User.first_name).like(like_pattern), - func.lower(User.last_name).like(like_pattern), - func.lower(User.referral_code).like(like_pattern), - ) - ) - - for condition in filters: - stmt = stmt.where(condition) - count_stmt = count_stmt.where(condition) - - stmt = stmt.order_by(User.created_at.desc()).offset(offset).limit(limit) - - result = await db.execute(stmt) - users = result.scalars().unique().all() - - total = await db.scalar(count_stmt) or 0 - - items = [_user_to_list_item(user) for user in users] - pagination = Pagination(total=total, limit=limit, offset=offset) - return UserListResponse(pagination=pagination, items=items) - - -@router.get("/{user_id}", response_model=UserDetailResponse) -async def get_user_detail( - user_id: int = Path(..., ge=1), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.users:read")), -) -> UserDetailResponse: - user = await get_user_by_id(db, user_id) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден") - - transactions_stmt = ( - select(Transaction) - .where(Transaction.user_id == user.id) - .order_by(Transaction.created_at.desc()) - .limit(50) - ) - tx_result = await db.execute(transactions_stmt) - transactions = tx_result.scalars().all() - - return _user_to_detail(user, transactions) - - -@router.post("", response_model=UserDetailResponse, status_code=status.HTTP_201_CREATED) -async def create_user_endpoint( - payload: UserCreateRequest, - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.users:write")), -) -> UserDetailResponse: - existing_stmt = select(User).where(User.telegram_id == payload.telegram_id) - existing = await db.scalar(existing_stmt) - if existing: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Пользователь уже существует") - - 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 or "ru", - referred_by_id=payload.referred_by_id, - referral_code=payload.referral_code, - ) - - refreshed = await get_user_by_id(db, user.id) - return _user_to_detail(refreshed or user, []) - - -@router.patch("/{user_id}", response_model=UserDetailResponse) -async def update_user_endpoint( - payload: UserUpdateRequest, - user_id: int = Path(..., ge=1), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.users:write")), -) -> UserDetailResponse: - user = await get_user_by_id(db, user_id) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден") - - updates = payload.model_dump(exclude_unset=True) - - await update_user(db, user, **updates) - refreshed = await get_user_by_id(db, user_id) - - transactions_stmt = ( - select(Transaction) - .where(Transaction.user_id == user_id) - .order_by(Transaction.created_at.desc()) - .limit(50) - ) - tx_result = await db.execute(transactions_stmt) - transactions = tx_result.scalars().all() - - return _user_to_detail(refreshed or user, transactions) - - -@router.post("/{user_id}/balance", response_model=UserDetailResponse) -async def update_balance( - payload: UserBalanceUpdateRequest, - user_id: int = Path(..., ge=1), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.users:write")), -) -> UserDetailResponse: - user = await get_user_by_id(db, user_id) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден") - - success = await add_user_balance( - db, - user, - amount_kopeks=payload.amount_kopeks, - description=payload.description or "Изменение баланса через API", - create_transaction=True, - ) - - if not success: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Не удалось обновить баланс") - - refreshed = await get_user_by_id(db, user_id) - transactions_stmt = ( - select(Transaction) - .where(Transaction.user_id == user_id) - .order_by(Transaction.created_at.desc()) - .limit(50) - ) - tx_result = await db.execute(transactions_stmt) - transactions = tx_result.scalars().all() - - return _user_to_detail(refreshed or user, transactions) - - -@router.post("/{user_id}/status", response_model=UserDetailResponse) -async def update_status( - payload: UserStatusUpdateRequest, - user_id: int = Path(..., ge=1), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.users:write")), -) -> UserDetailResponse: - user = await get_user_by_id(db, user_id) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден") - - try: - new_status = UserStatus(payload.status).value - except ValueError: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Недопустимый статус") - - await update_user(db, user, status=new_status) - refreshed = await get_user_by_id(db, user_id) - - transactions_stmt = ( - select(Transaction) - .where(Transaction.user_id == user_id) - .order_by(Transaction.created_at.desc()) - .limit(50) - ) - tx_result = await db.execute(transactions_stmt) - transactions = tx_result.scalars().all() - - return _user_to_detail(refreshed or user, transactions) - - -def _get_subscription_or_404(subscription: Optional[Subscription]) -> Subscription: - if not subscription: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="У пользователя нет активной подписки") - return subscription - - -@router.post("/{user_id}/subscription/extend", response_model=SubscriptionSchema) -async def extend_user_subscription( - payload: SubscriptionExtendRequest, - user_id: int = Path(..., ge=1), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.subscriptions:write")), -) -> SubscriptionSchema: - subscription = await get_subscription_by_user_id(db, user_id) - subscription = _get_subscription_or_404(subscription) - updated = await extend_subscription(db, subscription, payload.days) - return SubscriptionSchema.model_validate(updated) - - -@router.post("/{user_id}/subscription/traffic", response_model=SubscriptionSchema) -async def add_subscription_traffic_api( - payload: SubscriptionTrafficRequest, - user_id: int = Path(..., ge=1), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.subscriptions:write")), -) -> SubscriptionSchema: - subscription = await get_subscription_by_user_id(db, user_id) - subscription = _get_subscription_or_404(subscription) - updated = await add_subscription_traffic(db, subscription, payload.gb) - return SubscriptionSchema.model_validate(updated) - - -@router.post("/{user_id}/subscription/devices", response_model=SubscriptionSchema) -async def add_subscription_devices_api( - payload: SubscriptionDevicesRequest, - user_id: int = Path(..., ge=1), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.subscriptions:write")), -) -> SubscriptionSchema: - subscription = await get_subscription_by_user_id(db, user_id) - subscription = _get_subscription_or_404(subscription) - updated = await add_subscription_devices(db, subscription, payload.devices) - return SubscriptionSchema.model_validate(updated) - - -@router.post("/{user_id}/subscription/squads", response_model=SubscriptionSchema) -async def add_subscription_squad_api( - payload: SubscriptionSquadRequest, - user_id: int = Path(..., ge=1), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.subscriptions:write")), -) -> SubscriptionSchema: - subscription = await get_subscription_by_user_id(db, user_id) - subscription = _get_subscription_or_404(subscription) - updated = await add_subscription_squad(db, subscription, payload.squad_uuid) - return SubscriptionSchema.model_validate(updated) - - -@router.delete("/{user_id}/subscription/squads/{squad_uuid}", response_model=SubscriptionSchema) -async def remove_subscription_squad_api( - squad_uuid: str, - user_id: int = Path(..., ge=1), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.subscriptions:write")), -) -> SubscriptionSchema: - subscription = await get_subscription_by_user_id(db, user_id) - subscription = _get_subscription_or_404(subscription) - updated = await remove_subscription_squad(db, subscription, squad_uuid) - return SubscriptionSchema.model_validate(updated) - - -@router.get("/{user_id}/transactions", response_model=TransactionListResponse) -async def list_user_transactions( - user_id: int = Path(..., ge=1), - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), - db: AsyncSession = Depends(get_db), - _: object = Depends(require_permission("webapi.transactions:read")), -) -> TransactionListResponse: - user = await get_user_by_id(db, user_id) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден") - - total_stmt = select(func.count(Transaction.id)).where(Transaction.user_id == user_id) - total = await db.scalar(total_stmt) or 0 - - stmt = ( - select(Transaction) - .where(Transaction.user_id == user_id) - .order_by(Transaction.created_at.desc()) - .offset(offset) - .limit(limit) - ) - result = await db.execute(stmt) - transactions = result.scalars().all() - - pagination = Pagination(total=total, limit=limit, offset=offset) - items = [TransactionSchema.model_validate(tx) for tx in transactions] - return TransactionListResponse(pagination=pagination, items=items) diff --git a/app/webapi/schemas.py b/app/webapi/schemas.py deleted file mode 100644 index 2865f765..00000000 --- a/app/webapi/schemas.py +++ /dev/null @@ -1,364 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Any, List, Optional - -from pydantic import BaseModel, ConfigDict, Field - -from app.database.models import TicketStatus - - -class APIMessage(BaseModel): - message: str - - -class TokenCreateRequest(BaseModel): - name: str - secret: str = Field(..., description="Секрет из конфигурации WEBAPI_MASTER_KEY") - description: Optional[str] = None - permissions: Optional[List[str]] = None - allowed_ips: Optional[List[str]] = None - expires_in_hours: Optional[int] = Field( - default=None, - ge=1, - description="Время жизни токена в часах. Если не указано, используется глобальное значение", - ) - - -class TokenUpdateRequest(BaseModel): - name: Optional[str] = None - description: Optional[str] = None - permissions: Optional[List[str]] = None - allowed_ips: Optional[List[str]] = None - expires_at: Optional[datetime] = None - is_active: Optional[bool] = None - - -class TokenResponse(BaseModel): - id: int - name: str - description: Optional[str] - permissions: List[str] - allowed_ips: List[str] - is_active: bool - token_prefix: str - created_at: datetime - updated_at: datetime - last_used_at: Optional[datetime] - expires_at: Optional[datetime] - - model_config = ConfigDict(from_attributes=True) - - -class TokenWithSecretResponse(TokenResponse): - token: str - - -class HealthResponse(BaseModel): - status: str = "ok" - version: str - timestamp: datetime - - -class StatsResponse(BaseModel): - total_users: int - active_users: int - blocked_users: int - total_balance_kopeks: int - active_subscriptions: int - expired_subscriptions: int - open_tickets: int - pending_payments: int - - -class Pagination(BaseModel): - total: int - limit: int - offset: int - - -class SubscriptionSchema(BaseModel): - id: int - status: str - is_trial: bool - start_date: Optional[datetime] - end_date: Optional[datetime] - traffic_limit_gb: Optional[int] - traffic_used_gb: Optional[int] - device_limit: Optional[int] - connected_squads: List[str] - - model_config = ConfigDict(from_attributes=True) - - -class TransactionSchema(BaseModel): - id: int - type: str - amount_kopeks: int - description: Optional[str] - payment_method: Optional[str] - external_id: Optional[str] - is_completed: bool - created_at: datetime - completed_at: Optional[datetime] - - model_config = ConfigDict(from_attributes=True) - - -class UserListItem(BaseModel): - id: int - telegram_id: int - username: Optional[str] - first_name: Optional[str] - last_name: Optional[str] - language: Optional[str] - status: str - balance_kopeks: int - referral_code: Optional[str] - created_at: datetime - updated_at: datetime - subscription_status: Optional[str] = None - subscription_end_date: Optional[datetime] = None - - model_config = ConfigDict(from_attributes=True) - - -class UserListResponse(BaseModel): - pagination: Pagination - items: List[UserListItem] - - -class UserCreateRequest(BaseModel): - telegram_id: int - username: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None - language: Optional[str] = Field(default="ru", max_length=5) - referred_by_id: Optional[int] = None - referral_code: Optional[str] = None - - -class UserUpdateRequest(BaseModel): - username: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None - language: Optional[str] = Field(default=None, max_length=5) - referral_code: Optional[str] = None - - -class UserBalanceUpdateRequest(BaseModel): - amount_kopeks: int - description: Optional[str] = Field(default="Пополнение/списание через API") - - -class UserStatusUpdateRequest(BaseModel): - status: str = Field(..., description="Новый статус пользователя") - - -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 = Field(..., min_length=3) - - -class UserDetailResponse(BaseModel): - id: int - telegram_id: int - username: Optional[str] - first_name: Optional[str] - last_name: Optional[str] - language: Optional[str] - status: str - balance_kopeks: int - referred_by_id: Optional[int] - referral_code: Optional[str] - promo_group_id: Optional[int] - created_at: datetime - updated_at: datetime - subscription: Optional[SubscriptionSchema] - transactions: List[TransactionSchema] - - -class TransactionListResponse(BaseModel): - pagination: Pagination - items: List[TransactionSchema] - - -class TicketMessageSchema(BaseModel): - id: int - author_type: str - content: str - created_at: datetime - - model_config = ConfigDict(from_attributes=True) - - -class TicketSchema(BaseModel): - id: int - user_id: int - title: str - status: str - priority: str - created_at: datetime - updated_at: datetime - closed_at: Optional[datetime] - user_reply_block_permanent: bool - user_reply_block_until: Optional[datetime] - messages: List[TicketMessageSchema] - - model_config = ConfigDict(from_attributes=True) - - -class TicketListResponse(BaseModel): - pagination: Pagination - items: List[TicketSchema] - - -class TicketUpdateRequest(BaseModel): - status: Optional[TicketStatus] = None - priority: Optional[str] = Field(default=None, regex=r"^(low|normal|high|urgent)$") - user_reply_block_permanent: Optional[bool] = None - user_reply_block_until: Optional[datetime] = None - - -class SettingUpdateRequest(BaseModel): - value: Optional[Any] - - -class SettingResponse(BaseModel): - key: str - name: str - value: Optional[str] - original: Optional[str] - has_override: bool - category_key: str - category_label: str - type: str - - -class SettingsListResponse(BaseModel): - items: List[SettingResponse] - - -class PromoGroupSchema(BaseModel): - id: int - name: str - server_discount_percent: int - traffic_discount_percent: int - device_discount_percent: int - is_default: bool - created_at: datetime - updated_at: datetime - - model_config = ConfigDict(from_attributes=True) - - -class PromoGroupListResponse(BaseModel): - items: List[PromoGroupSchema] - - -class PromoGroupUpdateRequest(BaseModel): - name: Optional[str] = None - server_discount_percent: Optional[int] = Field(default=None, ge=0, le=100) - traffic_discount_percent: Optional[int] = Field(default=None, ge=0, le=100) - device_discount_percent: Optional[int] = Field(default=None, ge=0, le=100) - is_default: Optional[bool] = None - - -class YooKassaPaymentSchema(BaseModel): - id: int - user_id: int - yookassa_payment_id: str - amount_kopeks: int - currency: str - description: Optional[str] - status: str - is_paid: bool - is_captured: bool - confirmation_url: Optional[str] - payment_method_type: Optional[str] - refundable: bool - created_at: datetime - updated_at: datetime - yookassa_created_at: Optional[datetime] - captured_at: Optional[datetime] - - model_config = ConfigDict(from_attributes=True) - - -class CryptoBotPaymentSchema(BaseModel): - id: int - user_id: int - invoice_id: str - amount: str - asset: str - status: str - description: Optional[str] - paid_at: Optional[datetime] - created_at: datetime - updated_at: datetime - - model_config = ConfigDict(from_attributes=True) - - -class MulenPayPaymentSchema(BaseModel): - id: int - user_id: int - mulen_payment_id: Optional[int] - uuid: str - amount_kopeks: int - currency: str - status: str - is_paid: bool - paid_at: Optional[datetime] - payment_url: Optional[str] - created_at: datetime - updated_at: datetime - - model_config = ConfigDict(from_attributes=True) - - -class Pal24PaymentSchema(BaseModel): - id: int - user_id: int - pal24_payment_id: Optional[str] - amount_kopeks: int - currency: str - status: str - is_paid: bool - paid_at: Optional[datetime] - payment_url: Optional[str] - created_at: datetime - updated_at: datetime - - model_config = ConfigDict(from_attributes=True) - - -class YooKassaPaymentListResponse(BaseModel): - pagination: Pagination - items: List[YooKassaPaymentSchema] - - -class CryptoBotPaymentListResponse(BaseModel): - pagination: Pagination - items: List[CryptoBotPaymentSchema] - - -class MulenPayPaymentListResponse(BaseModel): - pagination: Pagination - items: List[MulenPayPaymentSchema] - - -class Pal24PaymentListResponse(BaseModel): - pagination: Pagination - items: List[Pal24PaymentSchema] diff --git a/app/webapi/server.py b/app/webapi/server.py deleted file mode 100644 index 21263164..00000000 --- a/app/webapi/server.py +++ /dev/null @@ -1,86 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from typing import Optional - -import uvicorn -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware - -from app.config import settings -from app.webapi.routers import api_router - -logger = logging.getLogger(__name__) - - -def create_app() -> FastAPI: - docs_url = "/docs" if settings.WEBAPI_DOCS_ENABLED else None - redoc_url = "/redoc" if settings.WEBAPI_DOCS_ENABLED else None - - app = FastAPI( - title=settings.WEBAPI_TITLE, - description=settings.WEBAPI_DESCRIPTION, - version=settings.WEBAPI_VERSION, - docs_url=docs_url, - redoc_url=redoc_url, - ) - - origins = settings.get_webapi_allowed_origins() - allow_all_origins = origins == ["*"] - - app.add_middleware( - CORSMiddleware, - allow_origins=["*"] if allow_all_origins else origins, - allow_credentials=True, - allow_methods=["*"] if allow_all_origins else ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - allow_headers=["*"] if allow_all_origins else ["*"], - ) - - app.include_router(api_router) - - return app - - -class WebAPIServer: - def __init__(self) -> None: - self.app = create_app() - self._server: Optional[uvicorn.Server] = None - self._task: Optional[asyncio.Task[None]] = None - - async def start(self) -> None: - if self._task and not self._task.done(): - return - - config = uvicorn.Config( - self.app, - host=settings.WEBAPI_HOST, - port=settings.WEBAPI_PORT, - log_level=settings.WEBAPI_LOG_LEVEL.lower(), - access_log=settings.WEBAPI_ACCESS_LOG, - loop="asyncio", - lifespan="on", - timeout_keep_alive=15, - root_path=settings.WEBAPI_ROOT_PATH or "", - ) - server = uvicorn.Server(config) - server.install_signal_handlers = False - self._server = server - - logger.info( - "🚀 Запуск Web API на %s:%s", - settings.WEBAPI_HOST, - settings.WEBAPI_PORT, - ) - - self._task = asyncio.create_task(server.serve()) - - async def stop(self) -> None: - if not self._server or not self._task: - return - - logger.info("🛑 Остановка Web API") - self._server.should_exit = True - await self._task - self._task = None - self._server = None diff --git a/main.py b/main.py index 45ba95bb..cf19d0b5 100644 --- a/main.py +++ b/main.py @@ -22,7 +22,6 @@ from app.services.backup_service import backup_service from app.services.reporting_service import reporting_service from app.localization.loader import ensure_locale_templates from app.services.system_settings_service import bot_configuration_service -from app.webapi.server import WebAPIServer class GracefulExit: @@ -64,7 +63,6 @@ async def main(): maintenance_task = None version_check_task = None polling_task = None - webapi_server: WebAPIServer | None = None try: logger.info("📊 Инициализация базы данных...") @@ -167,17 +165,6 @@ async def main(): else: logger.info("ℹ️ PayPalych отключен, webhook сервер не запускается") - if settings.is_webapi_enabled(): - try: - logger.info("🌐 Запуск Web API сервера...") - webapi_server = WebAPIServer() - await webapi_server.start() - except Exception as webapi_error: - logger.error(f"❌ Не удалось запустить Web API: {webapi_error}") - webapi_server = None - else: - logger.info("ℹ️ Web API отключен") - logger.info("📊 Запуск службы мониторинга...") monitoring_task = asyncio.create_task(monitoring_service.start_monitoring()) @@ -213,10 +200,6 @@ async def main(): logger.info( f" PayPalych: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}" ) - if settings.is_webapi_enabled(): - logger.info( - " Web API: http://%s:%s/api", settings.WEBAPI_HOST, settings.WEBAPI_PORT - ) logger.info("📄 Активные фоновые сервисы:") logger.info(f" Мониторинг: {'Включен' if monitoring_task else 'Отключен'}") logger.info(f" Техработы: {'Включен' if maintenance_task else 'Отключен'}") @@ -296,14 +279,7 @@ async def main(): if pal24_server: logger.info("ℹ️ Остановка PayPalych webhook сервера...") await asyncio.get_running_loop().run_in_executor(None, pal24_server.stop) - - if webapi_server is not None: - logger.info("ℹ️ Остановка Web API...") - try: - await webapi_server.stop() - except Exception as error: - logger.error(f"Ошибка остановки Web API: {error}") - + if maintenance_task and not maintenance_task.done(): logger.info("ℹ️ Остановка службы техработ...") await maintenance_service.stop_monitoring() diff --git a/requirements.txt b/requirements.txt index 95eb3156..53f98559 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,14 +6,12 @@ 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[standard]==0.32.1 # YooKassa SDK yookassa==3.7.0