From b5f7e06605c781cea6ef14a94ecc85eef204fa3b Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 26 Sep 2025 05:16:58 +0300 Subject: [PATCH 1/3] Add administrative web API and database support --- app/config.py | 33 ++- app/database/crud/web_api_token.py | 106 ++++++++++ app/database/models.py | 27 ++- app/database/universal_migration.py | 142 ++++++++++++- app/services/web_api_token_service.py | 85 ++++++++ app/utils/security.py | 28 +++ app/webapi/__init__.py | 5 + app/webapi/app.py | 55 +++++ app/webapi/dependencies.py | 54 +++++ app/webapi/middleware.py | 31 +++ app/webapi/routes/__init__.py | 0 app/webapi/routes/config.py | 171 ++++++++++++++++ app/webapi/routes/health.py | 25 +++ app/webapi/routes/promo_groups.py | 139 +++++++++++++ app/webapi/routes/stats.py | 87 ++++++++ app/webapi/routes/subscriptions.py | 205 +++++++++++++++++++ app/webapi/routes/tickets.py | 185 +++++++++++++++++ app/webapi/routes/tokens.py | 107 ++++++++++ app/webapi/routes/transactions.py | 79 ++++++++ app/webapi/routes/users.py | 277 ++++++++++++++++++++++++++ app/webapi/schemas/__init__.py | 0 app/webapi/schemas/promo_groups.py | 41 ++++ app/webapi/schemas/subscriptions.py | 52 +++++ app/webapi/schemas/tickets.py | 44 ++++ app/webapi/schemas/tokens.py | 30 +++ app/webapi/schemas/transactions.py | 27 +++ app/webapi/schemas/users.py | 88 ++++++++ app/webapi/server.py | 68 +++++++ main.py | 24 +++ requirements.txt | 4 +- 30 files changed, 2212 insertions(+), 7 deletions(-) create mode 100644 app/database/crud/web_api_token.py create mode 100644 app/services/web_api_token_service.py create mode 100644 app/utils/security.py create mode 100644 app/webapi/__init__.py create mode 100644 app/webapi/app.py create mode 100644 app/webapi/dependencies.py create mode 100644 app/webapi/middleware.py create mode 100644 app/webapi/routes/__init__.py create mode 100644 app/webapi/routes/config.py create mode 100644 app/webapi/routes/health.py create mode 100644 app/webapi/routes/promo_groups.py create mode 100644 app/webapi/routes/stats.py create mode 100644 app/webapi/routes/subscriptions.py create mode 100644 app/webapi/routes/tickets.py create mode 100644 app/webapi/routes/tokens.py create mode 100644 app/webapi/routes/transactions.py create mode 100644 app/webapi/routes/users.py create mode 100644 app/webapi/schemas/__init__.py create mode 100644 app/webapi/schemas/promo_groups.py create mode 100644 app/webapi/schemas/subscriptions.py create mode 100644 app/webapi/schemas/tickets.py create mode 100644 app/webapi/schemas/tokens.py create mode 100644 app/webapi/schemas/transactions.py create mode 100644 app/webapi/schemas/users.py create mode 100644 app/webapi/server.py 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 From 0a3913a2aab66989d4a48d6a9b067bc25b35d6be Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 26 Sep 2025 05:18:21 +0300 Subject: [PATCH 2/3] Revert "Add administrative web API and database support" --- app/config.py | 33 +-- app/database/crud/web_api_token.py | 106 ---------- app/database/models.py | 27 +-- app/database/universal_migration.py | 142 +------------ app/services/web_api_token_service.py | 85 -------- app/utils/security.py | 28 --- app/webapi/__init__.py | 5 - app/webapi/app.py | 55 ----- app/webapi/dependencies.py | 54 ----- app/webapi/middleware.py | 31 --- app/webapi/routes/__init__.py | 0 app/webapi/routes/config.py | 171 ---------------- app/webapi/routes/health.py | 25 --- app/webapi/routes/promo_groups.py | 139 ------------- app/webapi/routes/stats.py | 87 -------- app/webapi/routes/subscriptions.py | 205 ------------------- app/webapi/routes/tickets.py | 185 ----------------- app/webapi/routes/tokens.py | 107 ---------- app/webapi/routes/transactions.py | 79 -------- app/webapi/routes/users.py | 277 -------------------------- app/webapi/schemas/__init__.py | 0 app/webapi/schemas/promo_groups.py | 41 ---- app/webapi/schemas/subscriptions.py | 52 ----- app/webapi/schemas/tickets.py | 44 ---- app/webapi/schemas/tokens.py | 30 --- app/webapi/schemas/transactions.py | 27 --- app/webapi/schemas/users.py | 88 -------- app/webapi/server.py | 68 ------- main.py | 24 --- requirements.txt | 4 +- 30 files changed, 7 insertions(+), 2212 deletions(-) delete mode 100644 app/database/crud/web_api_token.py delete mode 100644 app/services/web_api_token_service.py delete mode 100644 app/utils/security.py delete mode 100644 app/webapi/__init__.py delete mode 100644 app/webapi/app.py delete mode 100644 app/webapi/dependencies.py delete mode 100644 app/webapi/middleware.py delete mode 100644 app/webapi/routes/__init__.py delete mode 100644 app/webapi/routes/config.py delete mode 100644 app/webapi/routes/health.py delete mode 100644 app/webapi/routes/promo_groups.py delete mode 100644 app/webapi/routes/stats.py delete mode 100644 app/webapi/routes/subscriptions.py delete mode 100644 app/webapi/routes/tickets.py delete mode 100644 app/webapi/routes/tokens.py delete mode 100644 app/webapi/routes/transactions.py delete mode 100644 app/webapi/routes/users.py delete mode 100644 app/webapi/schemas/__init__.py delete mode 100644 app/webapi/schemas/promo_groups.py delete mode 100644 app/webapi/schemas/subscriptions.py delete mode 100644 app/webapi/schemas/tickets.py delete mode 100644 app/webapi/schemas/tokens.py delete mode 100644 app/webapi/schemas/transactions.py delete mode 100644 app/webapi/schemas/users.py delete mode 100644 app/webapi/server.py diff --git a/app/config.py b/app/config.py index 7cdbbf9e..3bd36774 100644 --- a/app/config.py +++ b/app/config.py @@ -231,19 +231,6 @@ 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 @@ -967,25 +954,7 @@ 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 deleted file mode 100644 index c84b9426..00000000 --- a/app/database/crud/web_api_token.py +++ /dev/null @@ -1,106 +0,0 @@ -"""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 d7f93679..bc36c94e 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,27 +1118,6 @@ class TicketMessage(Base): @property def is_admin_message(self) -> bool: return self.is_from_admin - + def __repr__(self): - 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 + return f"" \ No newline at end of file diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index a563432a..ee1e5cee 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1,11 +1,7 @@ import logging -from sqlalchemy import inspect, select, text +from sqlalchemy import text, inspect from sqlalchemy.ext.asyncio import AsyncSession - -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 +from app.database.database import engine logger = logging.getLogger(__name__) @@ -1890,126 +1886,6 @@ 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("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===") @@ -2028,20 +1904,6 @@ 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 deleted file mode 100644 index dd6d83f3..00000000 --- a/app/services/web_api_token_service.py +++ /dev/null @@ -1,85 +0,0 @@ -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 deleted file mode 100644 index a2fe469d..00000000 --- a/app/utils/security.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Утилиты безопасности и генерации ключей.""" -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 deleted file mode 100644 index 39ce0c40..00000000 --- a/app/webapi/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Пакет административного веб-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 deleted file mode 100644 index 16411197..00000000 --- a/app/webapi/app.py +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 67f876bf..00000000 --- a/app/webapi/dependencies.py +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index c3bbf42a..00000000 --- a/app/webapi/middleware.py +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index e69de29b..00000000 diff --git a/app/webapi/routes/config.py b/app/webapi/routes/config.py deleted file mode 100644 index 873cdf0f..00000000 --- a/app/webapi/routes/config.py +++ /dev/null @@ -1,171 +0,0 @@ -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 deleted file mode 100644 index c720d560..00000000 --- a/app/webapi/routes/health.py +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 4cf24983..00000000 --- a/app/webapi/routes/promo_groups.py +++ /dev/null @@ -1,139 +0,0 @@ -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 deleted file mode 100644 index 6902283b..00000000 --- a/app/webapi/routes/stats.py +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index f3117590..00000000 --- a/app/webapi/routes/subscriptions.py +++ /dev/null @@ -1,205 +0,0 @@ -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 deleted file mode 100644 index f6a6f24d..00000000 --- a/app/webapi/routes/tickets.py +++ /dev/null @@ -1,185 +0,0 @@ -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 deleted file mode 100644 index c62097f5..00000000 --- a/app/webapi/routes/tokens.py +++ /dev/null @@ -1,107 +0,0 @@ -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 deleted file mode 100644 index c790de22..00000000 --- a/app/webapi/routes/transactions.py +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index 0c0b5abb..00000000 --- a/app/webapi/routes/users.py +++ /dev/null @@ -1,277 +0,0 @@ -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 deleted file mode 100644 index e69de29b..00000000 diff --git a/app/webapi/schemas/promo_groups.py b/app/webapi/schemas/promo_groups.py deleted file mode 100644 index af0be4a5..00000000 --- a/app/webapi/schemas/promo_groups.py +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index f09b5405..00000000 --- a/app/webapi/schemas/subscriptions.py +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 7334bbb8..00000000 --- a/app/webapi/schemas/tickets.py +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index d923ab65..00000000 --- a/app/webapi/schemas/tokens.py +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 9408f6c9..00000000 --- a/app/webapi/schemas/transactions.py +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index fbf910fc..00000000 --- a/app/webapi/schemas/users.py +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index a90fc718..00000000 --- a/app/webapi/server.py +++ /dev/null @@ -1,68 +0,0 @@ -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 8f0692d2..cf19d0b5 100644 --- a/main.py +++ b/main.py @@ -63,7 +63,6 @@ async def main(): maintenance_task = None version_check_task = None polling_task = None - web_api_server = None try: logger.info("📊 Инициализация базы данных...") @@ -183,22 +182,6 @@ 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)) @@ -337,13 +320,6 @@ 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 54722b8d..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==0.32.1 # YooKassa SDK yookassa==3.7.0 From e71b7f4533c797d0d4145bfa7604913d7dc5317d Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 26 Sep 2025 05:18:41 +0300 Subject: [PATCH 3/3] Revert "Revert "Add administrative web API and database support"" --- app/config.py | 33 ++- app/database/crud/web_api_token.py | 106 ++++++++++ app/database/models.py | 27 ++- app/database/universal_migration.py | 142 ++++++++++++- app/services/web_api_token_service.py | 85 ++++++++ app/utils/security.py | 28 +++ app/webapi/__init__.py | 5 + app/webapi/app.py | 55 +++++ app/webapi/dependencies.py | 54 +++++ app/webapi/middleware.py | 31 +++ app/webapi/routes/__init__.py | 0 app/webapi/routes/config.py | 171 ++++++++++++++++ app/webapi/routes/health.py | 25 +++ app/webapi/routes/promo_groups.py | 139 +++++++++++++ app/webapi/routes/stats.py | 87 ++++++++ app/webapi/routes/subscriptions.py | 205 +++++++++++++++++++ app/webapi/routes/tickets.py | 185 +++++++++++++++++ app/webapi/routes/tokens.py | 107 ++++++++++ app/webapi/routes/transactions.py | 79 ++++++++ app/webapi/routes/users.py | 277 ++++++++++++++++++++++++++ app/webapi/schemas/__init__.py | 0 app/webapi/schemas/promo_groups.py | 41 ++++ app/webapi/schemas/subscriptions.py | 52 +++++ app/webapi/schemas/tickets.py | 44 ++++ app/webapi/schemas/tokens.py | 30 +++ app/webapi/schemas/transactions.py | 27 +++ app/webapi/schemas/users.py | 88 ++++++++ app/webapi/server.py | 68 +++++++ main.py | 24 +++ requirements.txt | 4 +- 30 files changed, 2212 insertions(+), 7 deletions(-) create mode 100644 app/database/crud/web_api_token.py create mode 100644 app/services/web_api_token_service.py create mode 100644 app/utils/security.py create mode 100644 app/webapi/__init__.py create mode 100644 app/webapi/app.py create mode 100644 app/webapi/dependencies.py create mode 100644 app/webapi/middleware.py create mode 100644 app/webapi/routes/__init__.py create mode 100644 app/webapi/routes/config.py create mode 100644 app/webapi/routes/health.py create mode 100644 app/webapi/routes/promo_groups.py create mode 100644 app/webapi/routes/stats.py create mode 100644 app/webapi/routes/subscriptions.py create mode 100644 app/webapi/routes/tickets.py create mode 100644 app/webapi/routes/tokens.py create mode 100644 app/webapi/routes/transactions.py create mode 100644 app/webapi/routes/users.py create mode 100644 app/webapi/schemas/__init__.py create mode 100644 app/webapi/schemas/promo_groups.py create mode 100644 app/webapi/schemas/subscriptions.py create mode 100644 app/webapi/schemas/tickets.py create mode 100644 app/webapi/schemas/tokens.py create mode 100644 app/webapi/schemas/transactions.py create mode 100644 app/webapi/schemas/users.py create mode 100644 app/webapi/server.py 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