diff --git a/app/config.py b/app/config.py index 3bd36774..7cdbbf9e 100644 --- a/app/config.py +++ b/app/config.py @@ -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" diff --git a/app/database/crud/web_api_token.py b/app/database/crud/web_api_token.py new file mode 100644 index 00000000..c84b9426 --- /dev/null +++ b/app/database/crud/web_api_token.py @@ -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", +] diff --git a/app/database/models.py b/app/database/models.py index bc36c94e..d7f93679 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -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"" \ No newline at end of file + return f"" + + +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"" \ No newline at end of file diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index ee1e5cee..a563432a 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -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: diff --git a/app/services/web_api_token_service.py b/app/services/web_api_token_service.py new file mode 100644 index 00000000..dd6d83f3 --- /dev/null +++ b/app/services/web_api_token_service.py @@ -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() diff --git a/app/utils/security.py b/app/utils/security.py new file mode 100644 index 00000000..a2fe469d --- /dev/null +++ b/app/utils/security.py @@ -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"] diff --git a/app/webapi/__init__.py b/app/webapi/__init__.py new file mode 100644 index 00000000..39ce0c40 --- /dev/null +++ b/app/webapi/__init__.py @@ -0,0 +1,5 @@ +"""Пакет административного веб-API.""" +from .app import create_web_api_app +from .server import WebAPIServer + +__all__ = ["create_web_api_app", "WebAPIServer"] diff --git a/app/webapi/app.py b/app/webapi/app.py new file mode 100644 index 00000000..16411197 --- /dev/null +++ b/app/webapi/app.py @@ -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 diff --git a/app/webapi/dependencies.py b/app/webapi/dependencies.py new file mode 100644 index 00000000..67f876bf --- /dev/null +++ b/app/webapi/dependencies.py @@ -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 diff --git a/app/webapi/middleware.py b/app/webapi/middleware.py new file mode 100644 index 00000000..c3bbf42a --- /dev/null +++ b/app/webapi/middleware.py @@ -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, + ) diff --git a/app/webapi/routes/__init__.py b/app/webapi/routes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/webapi/routes/config.py b/app/webapi/routes/config.py new file mode 100644 index 00000000..873cdf0f --- /dev/null +++ b/app/webapi/routes/config.py @@ -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) diff --git a/app/webapi/routes/health.py b/app/webapi/routes/health.py new file mode 100644 index 00000000..c720d560 --- /dev/null +++ b/app/webapi/routes/health.py @@ -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), + }, + } diff --git a/app/webapi/routes/promo_groups.py b/app/webapi/routes/promo_groups.py new file mode 100644 index 00000000..4cf24983 --- /dev/null +++ b/app/webapi/routes/promo_groups.py @@ -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 diff --git a/app/webapi/routes/stats.py b/app/webapi/routes/stats.py new file mode 100644 index 00000000..6902283b --- /dev/null +++ b/app/webapi/routes/stats.py @@ -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), + }, + } diff --git a/app/webapi/routes/subscriptions.py b/app/webapi/routes/subscriptions.py new file mode 100644 index 00000000..f3117590 --- /dev/null +++ b/app/webapi/routes/subscriptions.py @@ -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) diff --git a/app/webapi/routes/tickets.py b/app/webapi/routes/tickets.py new file mode 100644 index 00000000..f6a6f24d --- /dev/null +++ b/app/webapi/routes/tickets.py @@ -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) diff --git a/app/webapi/routes/tokens.py b/app/webapi/routes/tokens.py new file mode 100644 index 00000000..c62097f5 --- /dev/null +++ b/app/webapi/routes/tokens.py @@ -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 diff --git a/app/webapi/routes/transactions.py b/app/webapi/routes/transactions.py new file mode 100644 index 00000000..c790de22 --- /dev/null +++ b/app/webapi/routes/transactions.py @@ -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, + ) diff --git a/app/webapi/routes/users.py b/app/webapi/routes/users.py new file mode 100644 index 00000000..0c0b5abb --- /dev/null +++ b/app/webapi/routes/users.py @@ -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) diff --git a/app/webapi/schemas/__init__.py b/app/webapi/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/webapi/schemas/promo_groups.py b/app/webapi/schemas/promo_groups.py new file mode 100644 index 00000000..af0be4a5 --- /dev/null +++ b/app/webapi/schemas/promo_groups.py @@ -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 diff --git a/app/webapi/schemas/subscriptions.py b/app/webapi/schemas/subscriptions.py new file mode 100644 index 00000000..f09b5405 --- /dev/null +++ b/app/webapi/schemas/subscriptions.py @@ -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 diff --git a/app/webapi/schemas/tickets.py b/app/webapi/schemas/tickets.py new file mode 100644 index 00000000..7334bbb8 --- /dev/null +++ b/app/webapi/schemas/tickets.py @@ -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 diff --git a/app/webapi/schemas/tokens.py b/app/webapi/schemas/tokens.py new file mode 100644 index 00000000..d923ab65 --- /dev/null +++ b/app/webapi/schemas/tokens.py @@ -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="Полное значение токена (возвращается один раз)") diff --git a/app/webapi/schemas/transactions.py b/app/webapi/schemas/transactions.py new file mode 100644 index 00000000..9408f6c9 --- /dev/null +++ b/app/webapi/schemas/transactions.py @@ -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 diff --git a/app/webapi/schemas/users.py b/app/webapi/schemas/users.py new file mode 100644 index 00000000..fbf910fc --- /dev/null +++ b/app/webapi/schemas/users.py @@ -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 diff --git a/app/webapi/server.py b/app/webapi/server.py new file mode 100644 index 00000000..a90fc718 --- /dev/null +++ b/app/webapi/server.py @@ -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 diff --git a/main.py b/main.py index cf19d0b5..8f0692d2 100644 --- a/main.py +++ b/main.py @@ -63,6 +63,7 @@ async def main(): maintenance_task = None version_check_task = None polling_task = None + web_api_server = None try: logger.info("📊 Инициализация базы данных...") @@ -182,6 +183,22 @@ async def main(): else: logger.info("ℹ️ Проверка версий отключена") + if settings.is_web_api_enabled(): + try: + from app.webapi import WebAPIServer + + web_api_server = WebAPIServer() + await web_api_server.start() + logger.info( + "🌐 Административное веб-API запущено: http://%s:%s", + settings.WEB_API_HOST, + settings.WEB_API_PORT, + ) + except Exception as error: + logger.error(f"❌ Не удалось запустить веб-API: {error}") + else: + logger.info("ℹ️ Веб-API отключено") + logger.info("📄 Запуск polling...") polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True)) @@ -320,6 +337,13 @@ async def main(): if webhook_server: logger.info("ℹ️ Остановка webhook сервера...") await webhook_server.stop() + + if web_api_server: + try: + await web_api_server.stop() + logger.info("✅ Административное веб-API остановлено") + except Exception as error: + logger.error(f"Ошибка остановки веб-API: {error}") if 'bot' in locals(): try: diff --git a/requirements.txt b/requirements.txt index 53f98559..54722b8d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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