From 5f316f85b35ed3f78733b00456e0720940e4f864 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 24 Oct 2025 09:06:08 +0300 Subject: [PATCH 1/5] feat: expose monitoring logs through web api --- app/database/crud/ticket.py | 34 ++++++-- app/services/monitoring_service.py | 29 +++++-- app/webapi/app.py | 6 ++ app/webapi/routes/__init__.py | 2 + app/webapi/routes/logs.py | 120 +++++++++++++++++++++++++++++ app/webapi/schemas/logs.py | 65 ++++++++++++++++ docs/web-admin-integration.md | 15 ++++ 7 files changed, 260 insertions(+), 11 deletions(-) create mode 100644 app/webapi/routes/logs.py create mode 100644 app/webapi/schemas/logs.py diff --git a/app/database/crud/ticket.py b/app/database/crud/ticket.py index 7dd3ef52..1bea7b11 100644 --- a/app/database/crud/ticket.py +++ b/app/database/crud/ticket.py @@ -333,18 +333,42 @@ class TicketCRUD: *, limit: int = 50, offset: int = 0, + action: Optional[str] = None, ) -> List[SupportAuditLog]: from sqlalchemy import select, desc - result = await db.execute( - select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)).offset(offset).limit(limit) - ) + + query = select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)) + + if action: + query = query.where(SupportAuditLog.action == action) + + result = await db.execute(query.offset(offset).limit(limit)) return result.scalars().all() @staticmethod - async def count_support_audit(db: AsyncSession) -> int: + async def count_support_audit(db: AsyncSession, action: Optional[str] = None) -> int: from sqlalchemy import select, func - result = await db.execute(select(func.count()).select_from(SupportAuditLog)) + + query = select(func.count()).select_from(SupportAuditLog) + + if action: + query = query.where(SupportAuditLog.action == action) + + result = await db.execute(query) return int(result.scalar() or 0) + + @staticmethod + async def list_support_audit_actions(db: AsyncSession) -> List[str]: + from sqlalchemy import select + + result = await db.execute( + select(SupportAuditLog.action) + .where(SupportAuditLog.action.isnot(None)) + .distinct() + .order_by(SupportAuditLog.action) + ) + + return [row[0] for row in result.fetchall()] @staticmethod async def get_open_tickets_count(db: AsyncSession) -> int: diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 15c65e61..857e4ea4 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -1759,26 +1759,43 @@ class MonitoringService: return [] async def get_monitoring_logs_count( - self, + self, db: AsyncSession, event_type: Optional[str] = None ) -> int: try: from sqlalchemy import select, func - + query = select(func.count(MonitoringLog.id)) - + if event_type: query = query.where(MonitoringLog.event_type == event_type) - + result = await db.execute(query) count = result.scalar() - + return count or 0 - + except Exception as e: logger.error(f"Ошибка получения количества логов: {e}") return 0 + + async def get_monitoring_event_types(self, db: AsyncSession) -> List[str]: + try: + from sqlalchemy import select + + result = await db.execute( + select(MonitoringLog.event_type) + .where(MonitoringLog.event_type.isnot(None)) + .distinct() + .order_by(MonitoringLog.event_type) + ) + + return [row[0] for row in result.fetchall() if row[0]] + + except Exception as e: + logger.error(f"Ошибка получения списка типов событий мониторинга: {e}") + return [] async def cleanup_old_logs(self, db: AsyncSession, days: int = 30) -> int: try: diff --git a/app/webapi/app.py b/app/webapi/app.py index a976cd91..cd2b6697 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -25,6 +25,7 @@ from .routes import ( tokens, transactions, users, + logs, ) @@ -69,6 +70,10 @@ OPENAPI_TAGS = [ "name": "promo-offers", "description": "Управление промо-предложениями, шаблонами и журналом событий.", }, + { + "name": "logs", + "description": "Журналы мониторинга бота и действий модераторов поддержки.", + }, { "name": "auth", "description": "Управление токенами доступа к административному API.", @@ -138,5 +143,6 @@ def create_web_api_app() -> FastAPI: app.include_router(tokens.router, prefix="/tokens", tags=["auth"]) app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"]) app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"]) + app.include_router(logs.router, prefix="/logs", tags=["logs"]) return app diff --git a/app/webapi/routes/__init__.py b/app/webapi/routes/__init__.py index f8fb6fdc..23541d12 100644 --- a/app/webapi/routes/__init__.py +++ b/app/webapi/routes/__init__.py @@ -13,6 +13,7 @@ from . import ( tokens, transactions, users, + logs, ) __all__ = [ @@ -30,4 +31,5 @@ __all__ = [ "tokens", "transactions", "users", + "logs", ] diff --git a/app/webapi/routes/logs.py b/app/webapi/routes/logs.py new file mode 100644 index 00000000..c172a1f0 --- /dev/null +++ b/app/webapi/routes/logs.py @@ -0,0 +1,120 @@ +"""Маршруты административного API для просмотра логов.""" +from __future__ import annotations + +from typing import Any, Optional + +from fastapi import APIRouter, Depends, Query, Security +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.ticket import TicketCRUD +from app.services.monitoring_service import monitoring_service + +from ..dependencies import get_db_session, require_api_token +from ..schemas.logs import ( + MonitoringLogEntry, + MonitoringLogListResponse, + MonitoringLogTypeListResponse, + SupportAuditActionsResponse, + SupportAuditLogEntry, + SupportAuditLogListResponse, +) + +router = APIRouter() + + +@router.get("/monitoring", response_model=MonitoringLogListResponse) +async def list_monitoring_logs( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), + limit: int = Query(50, ge=1, le=200, description="Количество записей на странице"), + offset: int = Query(0, ge=0, description="Смещение от начала списка"), + event_type: Optional[str] = Query( + default=None, + max_length=100, + description="Фильтр по типу события", + ), +) -> MonitoringLogListResponse: + """Получить список логов мониторинга с пагинацией.""" + + per_page = limit + page = (offset // per_page) + 1 + + raw_logs = await monitoring_service.get_monitoring_logs( + db, + event_type=event_type, + page=page, + per_page=per_page, + ) + total = await monitoring_service.get_monitoring_logs_count(db, event_type=event_type) + + return MonitoringLogListResponse( + total=total, + limit=limit, + offset=offset, + items=[MonitoringLogEntry(**entry) for entry in raw_logs], + ) + + +@router.get("/monitoring/event-types", response_model=MonitoringLogTypeListResponse) +async def list_monitoring_event_types( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MonitoringLogTypeListResponse: + """Получить список доступных типов событий мониторинга.""" + + event_types = await monitoring_service.get_monitoring_event_types(db) + return MonitoringLogTypeListResponse(items=event_types) + + +@router.get("/support", response_model=SupportAuditLogListResponse) +async def list_support_audit_logs( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), + limit: int = Query(50, ge=1, le=200, description="Количество записей на странице"), + offset: int = Query(0, ge=0, description="Смещение от начала списка"), + action: Optional[str] = Query( + default=None, + max_length=50, + description="Фильтр по типу действия модератора", + ), +) -> SupportAuditLogListResponse: + """Получить список аудита действий модераторов поддержки.""" + + logs = await TicketCRUD.list_support_audit( + db, + limit=limit, + offset=offset, + action=action, + ) + total = await TicketCRUD.count_support_audit(db, action=action) + + return SupportAuditLogListResponse( + total=total, + limit=limit, + offset=offset, + items=[ + SupportAuditLogEntry( + id=log.id, + actor_user_id=log.actor_user_id, + actor_telegram_id=log.actor_telegram_id, + is_moderator=log.is_moderator, + action=log.action, + ticket_id=log.ticket_id, + target_user_id=log.target_user_id, + details=log.details, + created_at=log.created_at, + ) + for log in logs + ], + ) + + +@router.get("/support/actions", response_model=SupportAuditActionsResponse) +async def list_support_audit_actions( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> SupportAuditActionsResponse: + """Получить список действий, доступных в аудите поддержки.""" + + actions = await TicketCRUD.list_support_audit_actions(db) + return SupportAuditActionsResponse(items=actions) diff --git a/app/webapi/schemas/logs.py b/app/webapi/schemas/logs.py new file mode 100644 index 00000000..8a42573a --- /dev/null +++ b/app/webapi/schemas/logs.py @@ -0,0 +1,65 @@ +"""Pydantic-схемы для работы с логами административного API.""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class MonitoringLogEntry(BaseModel): + """Запись лога мониторинга.""" + + id: int + event_type: str = Field(..., description="Тип события мониторинга") + message: str = Field(..., description="Краткое описание события") + data: Optional[Dict[str, Any]] = Field( + default=None, + description="Дополнительные данные события", + ) + is_success: bool = Field(..., description="Флаг успешности выполнения операции") + created_at: datetime = Field(..., description="Дата и время создания записи") + + +class MonitoringLogListResponse(BaseModel): + """Ответ со списком логов мониторинга.""" + + total: int = Field(..., ge=0) + limit: int = Field(..., ge=1) + offset: int = Field(..., ge=0) + items: List[MonitoringLogEntry] + + +class MonitoringLogTypeListResponse(BaseModel): + """Ответ со списком доступных типов событий мониторинга.""" + + items: List[str] = Field(default_factory=list) + + +class SupportAuditLogEntry(BaseModel): + """Запись аудита модераторов поддержки.""" + + id: int + actor_user_id: Optional[int] + actor_telegram_id: int + is_moderator: bool + action: str + ticket_id: Optional[int] + target_user_id: Optional[int] + details: Optional[Dict[str, Any]] = None + created_at: datetime + + +class SupportAuditLogListResponse(BaseModel): + """Ответ со списком аудита поддержки.""" + + total: int = Field(..., ge=0) + limit: int = Field(..., ge=1) + offset: int = Field(..., ge=0) + items: List[SupportAuditLogEntry] + + +class SupportAuditActionsResponse(BaseModel): + """Ответ со списком доступных действий аудита поддержки.""" + + items: List[str] = Field(default_factory=list) diff --git a/docs/web-admin-integration.md b/docs/web-admin-integration.md index 26e2534b..353eb486 100644 --- a/docs/web-admin-integration.md +++ b/docs/web-admin-integration.md @@ -135,10 +135,25 @@ curl -X POST "http://127.0.0.1:8080/tokens" \ | `PATCH` | `/promo-offers/templates/{id}` | Обновить текст, кнопки и параметры шаблона. | `GET` | `/promo-offers/logs` | Журнал операций с промо-предложениями (активации, списания, выключения). | `GET` | `/tokens` | Управление токенами доступа. +| `GET` | `/logs/monitoring` | Логи мониторинга бота с пагинацией и фильтрами по типу события. +| `GET` | `/logs/monitoring/event-types` | Справочник доступных типов событий мониторинга. +| `GET` | `/logs/support` | Журнал действий модераторов поддержки (блокировки, закрытия тикетов). +| `GET` | `/logs/support/actions` | Справочник возможных действий в аудите поддержки. > Раздел **promo-offers** в Swagger объединяет работу с персональными предложениями: выдачу скидок/бонусов пользователям, настройку > текстов шаблонов и просмотр журнала операций (активации, автосписания, отключения просроченных акций). +### Логи бота + +В административном API появился раздел **logs**. Он позволяет: + +- Просматривать общие логи мониторинга (`GET /logs/monitoring`) с поддержкой пагинации (`limit`, `offset`) и фильтра по типу события (`event_type`). +- Получать справочник доступных типов событий (`GET /logs/monitoring/event-types`). Это удобно для построения фильтров во внешней админке. +- Отслеживать действия модераторов поддержки (`GET /logs/support`) с пагинацией и возможностью фильтровать по конкретному действию (`action`). +- Запрашивать список возможных действий для UI (`GET /logs/support/actions`). + +Все эндпоинты защищены токеном API и возвращают структуру с общим количеством записей, текущим `limit`/`offset` и массивом объектов. Это упрощает реализацию таблиц и постраничной навигации во внешних административных интерфейсах. + ### RemnaWave интеграция После включения веб-API в Swagger (`WEB_API_DOCS_ENABLED=true`) появится раздел **remnawave**. Он объединяет эндпоинты для управления панелью RemnaWave и синхронизации данных бота: From fa3ed3af81c9d4d59bf8a31218a291d667f2457e Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 24 Oct 2025 09:20:34 +0300 Subject: [PATCH 2/5] Add system log endpoints to admin API --- app/webapi/app.py | 4 +- app/webapi/routes/logs.py | 121 +++++++++++++++++++++++++++++++++- app/webapi/schemas/logs.py | 25 +++++++ docs/web-admin-integration.md | 4 ++ 4 files changed, 152 insertions(+), 2 deletions(-) diff --git a/app/webapi/app.py b/app/webapi/app.py index cd2b6697..79f23065 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -72,7 +72,9 @@ OPENAPI_TAGS = [ }, { "name": "logs", - "description": "Журналы мониторинга бота и действий модераторов поддержки.", + "description": ( + "Журналы мониторинга бота, действий модераторов поддержки и системный лог-файл." + ), }, { "name": "auth", diff --git a/app/webapi/routes/logs.py b/app/webapi/routes/logs.py index c172a1f0..81d07524 100644 --- a/app/webapi/routes/logs.py +++ b/app/webapi/routes/logs.py @@ -1,11 +1,17 @@ """Маршруты административного API для просмотра логов.""" from __future__ import annotations +import logging +from datetime import datetime, timezone +from pathlib import Path from typing import Any, Optional -from fastapi import APIRouter, Depends, Query, Security +from fastapi import APIRouter, Depends, HTTPException, Query, Security +from fastapi.concurrency import run_in_threadpool +from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession +from app.config import settings from app.database.crud.ticket import TicketCRUD from app.services.monitoring_service import monitoring_service @@ -17,10 +23,123 @@ from ..schemas.logs import ( SupportAuditActionsResponse, SupportAuditLogEntry, SupportAuditLogListResponse, + SystemLogPreviewResponse, ) router = APIRouter() +logger = logging.getLogger(__name__) + + +SYSTEM_LOG_PREVIEW_LIMIT_DEFAULT = 4000 +SYSTEM_LOG_PREVIEW_LIMIT_MAX = 20000 + + +def _resolve_system_log_path() -> Path: + path = Path(settings.LOG_FILE) + if not path.is_absolute(): + path = Path.cwd() / path + return path + + +async def _read_system_log(path: Path) -> tuple[str, int, Optional[float]]: + def _read() -> tuple[str, int, float]: + content = path.read_text(encoding="utf-8", errors="ignore") + stats = path.stat() + return content, stats.st_size, stats.st_mtime + + return await run_in_threadpool(_read) + + +def _format_timestamp(timestamp: Optional[float]) -> Optional[datetime]: + if timestamp is None: + return None + return datetime.fromtimestamp(timestamp, tz=timezone.utc) + + +@router.get("/system", response_model=SystemLogPreviewResponse) +async def get_system_log_preview( + _: Any = Security(require_api_token), + preview_limit: int = Query( + SYSTEM_LOG_PREVIEW_LIMIT_DEFAULT, + ge=500, + le=SYSTEM_LOG_PREVIEW_LIMIT_MAX, + description="Количество символов предпросмотра от конца файла", + ), +) -> SystemLogPreviewResponse: + """Получить предпросмотр системного лог-файла бота.""" + + log_path = _resolve_system_log_path() + + if not log_path.exists() or not log_path.is_file(): + return SystemLogPreviewResponse( + path=str(log_path), + exists=False, + updated_at=None, + size_bytes=0, + size_chars=0, + preview="", + preview_chars=0, + preview_truncated=False, + download_url="/logs/system/download", + ) + + try: + content, size_bytes, mtime = await _read_system_log(log_path) + except FileNotFoundError: + logger.warning("Лог-файл %s исчез во время чтения", log_path) + return SystemLogPreviewResponse( + path=str(log_path), + exists=False, + updated_at=None, + size_bytes=0, + size_chars=0, + preview="", + preview_chars=0, + preview_truncated=False, + download_url="/logs/system/download", + ) + except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения + logger.error("Ошибка чтения лог-файла %s: %s", log_path, error) + raise HTTPException(status_code=500, detail="Не удалось прочитать лог-файл") from error + + preview_text = content[-preview_limit:] if preview_limit > 0 else "" + truncated = len(content) > len(preview_text) + + return SystemLogPreviewResponse( + path=str(log_path), + exists=True, + updated_at=_format_timestamp(mtime), + size_bytes=size_bytes, + size_chars=len(content), + preview=preview_text, + preview_chars=len(preview_text), + preview_truncated=truncated, + download_url="/logs/system/download", + ) + + +@router.get("/system/download") +async def download_system_log( + _: Any = Security(require_api_token), +) -> FileResponse: + """Скачать полный лог-файл бота.""" + + log_path = _resolve_system_log_path() + + if not log_path.exists() or not log_path.is_file(): + raise HTTPException(status_code=404, detail="Лог-файл не найден") + + try: + return FileResponse( + log_path, + media_type="text/plain", + filename=log_path.name, + ) + except Exception as error: # pragma: no cover - защита от неожиданных ошибок отдачи файла + logger.error("Ошибка отправки лог-файла %s: %s", log_path, error) + raise HTTPException(status_code=500, detail="Не удалось отправить лог-файл") from error + @router.get("/monitoring", response_model=MonitoringLogListResponse) async def list_monitoring_logs( diff --git a/app/webapi/schemas/logs.py b/app/webapi/schemas/logs.py index 8a42573a..c21858a5 100644 --- a/app/webapi/schemas/logs.py +++ b/app/webapi/schemas/logs.py @@ -63,3 +63,28 @@ class SupportAuditActionsResponse(BaseModel): """Ответ со списком доступных действий аудита поддержки.""" items: List[str] = Field(default_factory=list) + + +class SystemLogPreviewResponse(BaseModel): + """Ответ с превью системного лог-файла бота.""" + + path: str = Field(..., description="Абсолютный путь до лог-файла") + exists: bool = Field(..., description="Флаг наличия лог-файла") + updated_at: Optional[datetime] = Field( + default=None, + description="Дата и время последнего изменения лог-файла", + ) + size_bytes: int = Field(..., ge=0, description="Размер лог-файла в байтах") + size_chars: int = Field(..., ge=0, description="Количество символов в лог-файле") + preview: str = Field( + default="", + description="Фрагмент содержимого лог-файла, возвращаемый для предпросмотра", + ) + preview_chars: int = Field(..., ge=0, description="Размер предпросмотра в символах") + preview_truncated: bool = Field( + ..., description="Флаг усечения предпросмотра относительно полного файла" + ) + download_url: Optional[str] = Field( + default=None, + description="Относительный путь до endpoint для скачивания лог-файла", + ) diff --git a/docs/web-admin-integration.md b/docs/web-admin-integration.md index 353eb486..9ad9048a 100644 --- a/docs/web-admin-integration.md +++ b/docs/web-admin-integration.md @@ -139,6 +139,8 @@ curl -X POST "http://127.0.0.1:8080/tokens" \ | `GET` | `/logs/monitoring/event-types` | Справочник доступных типов событий мониторинга. | `GET` | `/logs/support` | Журнал действий модераторов поддержки (блокировки, закрытия тикетов). | `GET` | `/logs/support/actions` | Справочник возможных действий в аудите поддержки. +| `GET` | `/logs/system` | Предпросмотр системного лог-файла бота с метаданными. +| `GET` | `/logs/system/download` | Скачивание полного лог-файла бота (`text/plain`). > Раздел **promo-offers** в Swagger объединяет работу с персональными предложениями: выдачу скидок/бонусов пользователям, настройку > текстов шаблонов и просмотр журнала операций (активации, автосписания, отключения просроченных акций). @@ -151,6 +153,8 @@ curl -X POST "http://127.0.0.1:8080/tokens" \ - Получать справочник доступных типов событий (`GET /logs/monitoring/event-types`). Это удобно для построения фильтров во внешней админке. - Отслеживать действия модераторов поддержки (`GET /logs/support`) с пагинацией и возможностью фильтровать по конкретному действию (`action`). - Запрашивать список возможных действий для UI (`GET /logs/support/actions`). +- Просматривать системный лог-файл бота (`GET /logs/system`). Endpoint возвращает метаданные (путь, время изменения, размер в байтах/символах) и фрагмент конца файла, размер которого можно регулировать параметром `preview_limit` (от 500 до 20 000 символов). +- Скачивать полный системный лог в текстовом формате (`GET /logs/system/download`). Все эндпоинты защищены токеном API и возвращают структуру с общим количеством записей, текущим `limit`/`offset` и массивом объектов. Это упрощает реализацию таблиц и постраничной навигации во внешних административных интерфейсах. From a0e00c373589d2256b8865fed51533d3744d63a0 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 24 Oct 2025 09:32:38 +0300 Subject: [PATCH 3/5] Document poll administration API --- app/database/crud/poll.py | 32 ++++ app/webapi/app.py | 8 +- app/webapi/routes/__init__.py | 2 + app/webapi/routes/polls.py | 290 ++++++++++++++++++++++++++++++++++ app/webapi/schemas/polls.py | 171 ++++++++++++++++++++ docs/web-admin-integration.md | 42 +++++ 6 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 app/webapi/routes/polls.py create mode 100644 app/webapi/schemas/polls.py diff --git a/app/database/crud/poll.py b/app/database/crud/poll.py index 5b3b35ad..db1a9b7e 100644 --- a/app/database/crud/poll.py +++ b/app/database/crud/poll.py @@ -263,3 +263,35 @@ async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict: "reward_sum_kopeks": reward_sum, "questions": questions, } + + +async def get_poll_responses_with_answers( + db: AsyncSession, + poll_id: int, + *, + limit: int, + offset: int, +) -> tuple[list[PollResponse], int]: + total_result = await db.execute( + select(func.count()).select_from(PollResponse).where(PollResponse.poll_id == poll_id) + ) + total = int(total_result.scalar_one() or 0) + + if total == 0: + return [], 0 + + result = await db.execute( + select(PollResponse) + .options( + selectinload(PollResponse.user), + selectinload(PollResponse.answers).selectinload(PollAnswer.question), + selectinload(PollResponse.answers).selectinload(PollAnswer.option), + ) + .where(PollResponse.poll_id == poll_id) + .order_by(PollResponse.sent_at.asc()) + .offset(offset) + .limit(limit) + ) + + responses = result.scalars().unique().all() + return responses, total diff --git a/app/webapi/app.py b/app/webapi/app.py index 79f23065..d5848663 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -13,8 +13,9 @@ from .routes import ( config, health, main_menu_buttons, - promocodes, miniapp, + polls, + promocodes, promo_groups, promo_offers, pages, @@ -91,6 +92,10 @@ OPENAPI_TAGS = [ "name": "miniapp", "description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.", }, + { + "name": "polls", + "description": "Создание опросов, удаление, статистика и ответы пользователей.", + }, { "name": "pages", "description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.", @@ -145,6 +150,7 @@ def create_web_api_app() -> FastAPI: app.include_router(tokens.router, prefix="/tokens", tags=["auth"]) app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"]) app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"]) + app.include_router(polls.router, prefix="/polls", tags=["polls"]) app.include_router(logs.router, prefix="/logs", tags=["logs"]) return app diff --git a/app/webapi/routes/__init__.py b/app/webapi/routes/__init__.py index 23541d12..85684d75 100644 --- a/app/webapi/routes/__init__.py +++ b/app/webapi/routes/__init__.py @@ -3,6 +3,7 @@ from . import ( health, main_menu_buttons, miniapp, + polls, promo_offers, pages, promo_groups, @@ -21,6 +22,7 @@ __all__ = [ "health", "main_menu_buttons", "miniapp", + "polls", "promo_offers", "pages", "promo_groups", diff --git a/app/webapi/routes/polls.py b/app/webapi/routes/polls.py new file mode 100644 index 00000000..7a6f39ce --- /dev/null +++ b/app/webapi/routes/polls.py @@ -0,0 +1,290 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Query, + Response, + Security, + status, +) +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.crud.poll import ( + create_poll, + delete_poll as delete_poll_record, + get_poll_by_id, + get_poll_responses_with_answers, + get_poll_statistics, +) +from app.database.models import Poll, PollAnswer, PollOption, PollQuestion, PollResponse + +from ..dependencies import get_db_session, require_api_token +from ..schemas.polls import ( + PollAnswerResponse, + PollCreateRequest, + PollDetailResponse, + PollListResponse, + PollOptionStats, + PollQuestionOptionResponse, + PollQuestionResponse, + PollQuestionStats, + PollResponsesListResponse, + PollStatisticsResponse, + PollSummaryResponse, + PollUserResponse, +) + +router = APIRouter() + + +def _format_price(kopeks: int) -> float: + return round(kopeks / 100, 2) + + +def _serialize_option(option: PollOption) -> PollQuestionOptionResponse: + return PollQuestionOptionResponse( + id=option.id, + text=option.text, + order=option.order, + ) + + +def _serialize_question(question: PollQuestion) -> PollQuestionResponse: + options = [ + _serialize_option(option) + for option in sorted(question.options, key=lambda item: item.order) + ] + return PollQuestionResponse( + id=question.id, + text=question.text, + order=question.order, + options=options, + ) + + +def _serialize_poll_summary(poll: Poll) -> PollSummaryResponse: + questions = getattr(poll, "questions", []) + responses = getattr(poll, "responses", []) + return PollSummaryResponse( + id=poll.id, + title=poll.title, + description=poll.description, + reward_enabled=poll.reward_enabled, + reward_amount_kopeks=poll.reward_amount_kopeks, + reward_amount_rubles=_format_price(poll.reward_amount_kopeks), + questions_count=len(questions), + responses_count=len(responses), + created_at=poll.created_at, + updated_at=poll.updated_at, + ) + + +def _serialize_poll_detail(poll: Poll) -> PollDetailResponse: + questions = [ + _serialize_question(question) + for question in sorted(poll.questions, key=lambda item: item.order) + ] + return PollDetailResponse( + id=poll.id, + title=poll.title, + description=poll.description, + reward_enabled=poll.reward_enabled, + reward_amount_kopeks=poll.reward_amount_kopeks, + reward_amount_rubles=_format_price(poll.reward_amount_kopeks), + questions=questions, + created_at=poll.created_at, + updated_at=poll.updated_at, + ) + + +def _serialize_answer(answer: PollAnswer) -> PollAnswerResponse: + question = getattr(answer, "question", None) + option = getattr(answer, "option", None) + return PollAnswerResponse( + question_id=question.id if question else answer.question_id, + question_text=question.text if question else None, + option_id=option.id if option else answer.option_id, + option_text=option.text if option else None, + created_at=answer.created_at, + ) + + +def _serialize_user_response(response: PollResponse) -> PollUserResponse: + user = getattr(response, "user", None) + answers = [ + _serialize_answer(answer) + for answer in sorted(response.answers, key=lambda item: item.created_at) + ] + return PollUserResponse( + id=response.id, + user_id=getattr(user, "id", None), + user_telegram_id=getattr(user, "telegram_id", None), + user_username=getattr(user, "username", None), + sent_at=response.sent_at, + started_at=response.started_at, + completed_at=response.completed_at, + reward_given=response.reward_given, + reward_amount_kopeks=response.reward_amount_kopeks, + reward_amount_rubles=_format_price(response.reward_amount_kopeks), + answers=answers, + ) + + +@router.get("", response_model=PollListResponse) +async def list_polls( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), +) -> PollListResponse: + total_result = await db.execute(select(func.count()).select_from(Poll)) + total = int(total_result.scalar_one() or 0) + + if total == 0: + return PollListResponse(items=[], total=0, limit=limit, offset=offset) + + result = await db.execute( + select(Poll) + .options( + selectinload(Poll.questions), + selectinload(Poll.responses), + ) + .order_by(Poll.created_at.desc()) + .offset(offset) + .limit(limit) + ) + polls = result.scalars().unique().all() + + return PollListResponse( + items=[_serialize_poll_summary(poll) for poll in polls], + total=total, + limit=limit, + offset=offset, + ) + + +@router.get("/{poll_id}", response_model=PollDetailResponse) +async def get_poll( + poll_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> PollDetailResponse: + poll = await get_poll_by_id(db, poll_id) + if not poll: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found") + + return _serialize_poll_detail(poll) + + +@router.post("", response_model=PollDetailResponse, status_code=status.HTTP_201_CREATED) +async def create_poll_endpoint( + payload: PollCreateRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> PollDetailResponse: + poll = await create_poll( + db, + title=payload.title, + description=payload.description, + reward_enabled=payload.reward_enabled, + reward_amount_kopeks=payload.reward_amount_kopeks, + created_by=None, + questions=[ + { + "text": question.text, + "options": [option.text for option in question.options], + } + for question in payload.questions + ], + ) + + poll = await get_poll_by_id(db, poll.id) + return _serialize_poll_detail(poll) + + +@router.delete("/{poll_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_poll( + poll_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> Response: + success = await delete_poll_record(db, poll_id) + if not success: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found") + + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/{poll_id}/stats", response_model=PollStatisticsResponse) +async def get_poll_stats( + poll_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> PollStatisticsResponse: + poll = await db.get(Poll, poll_id) + if not poll: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found") + + stats = await get_poll_statistics(db, poll_id) + + formatted_questions = [ + PollQuestionStats( + id=question_data["id"], + text=question_data["text"], + order=question_data["order"], + options=[ + PollOptionStats( + id=option_data["id"], + text=option_data["text"], + count=option_data["count"], + ) + for option_data in question_data.get("options", []) + ], + ) + for question_data in stats.get("questions", []) + ] + + return PollStatisticsResponse( + poll_id=poll.id, + poll_title=poll.title, + total_responses=stats.get("total_responses", 0), + completed_responses=stats.get("completed_responses", 0), + reward_sum_kopeks=stats.get("reward_sum_kopeks", 0), + reward_sum_rubles=_format_price(stats.get("reward_sum_kopeks", 0)), + questions=formatted_questions, + ) + + +@router.get("/{poll_id}/responses", response_model=PollResponsesListResponse) +async def get_poll_responses( + poll_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), +) -> PollResponsesListResponse: + poll_exists = await db.get(Poll, poll_id) + if not poll_exists: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found") + + responses, total = await get_poll_responses_with_answers( + db, + poll_id, + limit=limit, + offset=offset, + ) + + items = [_serialize_user_response(response) for response in responses] + + return PollResponsesListResponse( + items=items, + total=total, + limit=limit, + offset=offset, + ) diff --git a/app/webapi/schemas/polls.py b/app/webapi/schemas/polls.py new file mode 100644 index 00000000..3d95a715 --- /dev/null +++ b/app/webapi/schemas/polls.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field, field_validator, model_validator + + +class PollOptionCreate(BaseModel): + text: str = Field(..., min_length=1, max_length=500) + + @field_validator("text") + @classmethod + def strip_text(cls, value: str) -> str: + text = value.strip() + if not text: + raise ValueError("Option text cannot be empty") + return text + + +class PollQuestionCreate(BaseModel): + text: str = Field(..., min_length=1, max_length=1000) + options: list[PollOptionCreate] = Field(..., min_length=2) + + @field_validator("text") + @classmethod + def strip_question_text(cls, value: str) -> str: + text = value.strip() + if not text: + raise ValueError("Question text cannot be empty") + return text + + @field_validator("options") + @classmethod + def validate_options(cls, value: list[PollOptionCreate]) -> list[PollOptionCreate]: + seen: set[str] = set() + for option in value: + normalized = option.text.lower() + if normalized in seen: + raise ValueError("Option texts must be unique within a question") + seen.add(normalized) + return value + + +class PollCreateRequest(BaseModel): + title: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = Field(default=None, max_length=4000) + reward_enabled: bool = False + reward_amount_kopeks: int = Field(default=0, ge=0, le=1_000_000_000) + questions: list[PollQuestionCreate] = Field(..., min_length=1) + + @field_validator("title") + @classmethod + def strip_title(cls, value: str) -> str: + title = value.strip() + if not title: + raise ValueError("Title cannot be empty") + return title + + @field_validator("description") + @classmethod + def normalize_description(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return None + description = value.strip() + return description or None + + @model_validator(mode="after") + def validate_reward(self) -> "PollCreateRequest": + if self.reward_enabled and self.reward_amount_kopeks <= 0: + raise ValueError("Reward amount must be positive when rewards are enabled") + if not self.reward_enabled: + self.reward_amount_kopeks = 0 + return self + + +class PollQuestionOptionResponse(BaseModel): + id: int + text: str + order: int + + +class PollQuestionResponse(BaseModel): + id: int + text: str + order: int + options: list[PollQuestionOptionResponse] + + +class PollSummaryResponse(BaseModel): + id: int + title: str + description: Optional[str] + reward_enabled: bool + reward_amount_kopeks: int + reward_amount_rubles: float + questions_count: int + responses_count: int + created_at: datetime + updated_at: datetime + + +class PollDetailResponse(BaseModel): + id: int + title: str + description: Optional[str] + reward_enabled: bool + reward_amount_kopeks: int + reward_amount_rubles: float + questions: list[PollQuestionResponse] + created_at: datetime + updated_at: datetime + + +class PollListResponse(BaseModel): + items: list[PollSummaryResponse] + total: int + limit: int + offset: int + + +class PollOptionStats(BaseModel): + id: int + text: str + count: int + + +class PollQuestionStats(BaseModel): + id: int + text: str + order: int + options: list[PollOptionStats] + + +class PollStatisticsResponse(BaseModel): + poll_id: int + poll_title: str + total_responses: int + completed_responses: int + reward_sum_kopeks: int + reward_sum_rubles: float + questions: list[PollQuestionStats] + + +class PollAnswerResponse(BaseModel): + question_id: Optional[int] + question_text: Optional[str] + option_id: Optional[int] + option_text: Optional[str] + created_at: datetime + + +class PollUserResponse(BaseModel): + id: int + user_id: Optional[int] + user_telegram_id: Optional[int] + user_username: Optional[str] + sent_at: datetime + started_at: Optional[datetime] + completed_at: Optional[datetime] + reward_given: bool + reward_amount_kopeks: int + reward_amount_rubles: float + answers: list[PollAnswerResponse] + + +class PollResponsesListResponse(BaseModel): + items: list[PollUserResponse] + total: int + limit: int + offset: int diff --git a/docs/web-admin-integration.md b/docs/web-admin-integration.md index 9ad9048a..969f0033 100644 --- a/docs/web-admin-integration.md +++ b/docs/web-admin-integration.md @@ -141,6 +141,12 @@ curl -X POST "http://127.0.0.1:8080/tokens" \ | `GET` | `/logs/support/actions` | Справочник возможных действий в аудите поддержки. | `GET` | `/logs/system` | Предпросмотр системного лог-файла бота с метаданными. | `GET` | `/logs/system/download` | Скачивание полного лог-файла бота (`text/plain`). +| `GET` | `/polls` | Список опросов с пагинацией и краткой статистикой. +| `POST` | `/polls` | Создание нового опроса с набором вопросов и вариантов ответов. +| `GET` | `/polls/{id}` | Получить полный набор вопросов и метаданных опроса. +| `DELETE` | `/polls/{id}` | Удалить опрос и связанные ответы пользователей. +| `GET` | `/polls/{id}/stats` | Агрегированная статистика по ответам и выданным наградам. +| `GET` | `/polls/{id}/responses` | Ответы каждого пользователя с детализацией по вопросам. > Раздел **promo-offers** в Swagger объединяет работу с персональными предложениями: выдачу скидок/бонусов пользователям, настройку > текстов шаблонов и просмотр журнала операций (активации, автосписания, отключения просроченных акций). @@ -158,6 +164,42 @@ curl -X POST "http://127.0.0.1:8080/tokens" \ Все эндпоинты защищены токеном API и возвращают структуру с общим количеством записей, текущим `limit`/`offset` и массивом объектов. Это упрощает реализацию таблиц и постраничной навигации во внешних административных интерфейсах. +### Управление опросами + +Новые эндпоинты раздела **polls** позволяют создавать, просматривать и анализировать опросы, которые бот рассылает пользователям. Все маршруты требуют заголовок авторизации и возвращают структуры, описанные в схеме OpenAPI. + +#### Создание опроса + +`POST /polls` + +```json +{ + "title": "Feedback по запуску", + "description": "Расскажите, что можно улучшить в следующем релизе", + "reward_enabled": true, + "reward_amount_kopeks": 1500, + "questions": [ + { + "text": "Как вы оцениваете стабильность?", + "options": ["Отлично", "Хорошо", "Нормально", "Плохо"] + }, + { + "text": "Какие функции нужны дальше?", + "options": ["VPN", "Биллинг", "Поддержка"] + } + ] +} +``` + +Ответ содержит созданный опрос вместе с идентификаторами вопросов и вариантов. + +#### Получение статистики и ответов + +- `GET /polls/{id}/stats` — возвращает количество начатых и завершённых прохождений, суммарные выплаты и распределение ответов по вопросам. +- `GET /polls/{id}/responses` — выдаёт детальные ответы каждого пользователя, включая отметку о вручении награды и текст выбранных вариантов. Используйте параметры `limit` и `offset` для постраничной загрузки. + +Чтобы удалить опрос и связанные ответы, отправьте запрос `DELETE /polls/{id}`. Эндпоинт вернёт `204 No Content` в случае успеха. + ### RemnaWave интеграция После включения веб-API в Swagger (`WEB_API_DOCS_ENABLED=true`) появится раздел **remnawave**. Он объединяет эндпоинты для управления панелью RemnaWave и синхронизации данных бота: From 32e1f231d3e49894455fff4b9f091383bad0df7c Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 24 Oct 2025 09:36:28 +0300 Subject: [PATCH 4/5] Revert "Document poll management endpoints in admin guide" --- app/database/crud/poll.py | 32 ---- app/webapi/app.py | 8 +- app/webapi/routes/__init__.py | 2 - app/webapi/routes/polls.py | 290 ---------------------------------- app/webapi/schemas/polls.py | 171 -------------------- docs/web-admin-integration.md | 42 ----- 6 files changed, 1 insertion(+), 544 deletions(-) delete mode 100644 app/webapi/routes/polls.py delete mode 100644 app/webapi/schemas/polls.py diff --git a/app/database/crud/poll.py b/app/database/crud/poll.py index db1a9b7e..5b3b35ad 100644 --- a/app/database/crud/poll.py +++ b/app/database/crud/poll.py @@ -263,35 +263,3 @@ async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict: "reward_sum_kopeks": reward_sum, "questions": questions, } - - -async def get_poll_responses_with_answers( - db: AsyncSession, - poll_id: int, - *, - limit: int, - offset: int, -) -> tuple[list[PollResponse], int]: - total_result = await db.execute( - select(func.count()).select_from(PollResponse).where(PollResponse.poll_id == poll_id) - ) - total = int(total_result.scalar_one() or 0) - - if total == 0: - return [], 0 - - result = await db.execute( - select(PollResponse) - .options( - selectinload(PollResponse.user), - selectinload(PollResponse.answers).selectinload(PollAnswer.question), - selectinload(PollResponse.answers).selectinload(PollAnswer.option), - ) - .where(PollResponse.poll_id == poll_id) - .order_by(PollResponse.sent_at.asc()) - .offset(offset) - .limit(limit) - ) - - responses = result.scalars().unique().all() - return responses, total diff --git a/app/webapi/app.py b/app/webapi/app.py index d5848663..79f23065 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -13,9 +13,8 @@ from .routes import ( config, health, main_menu_buttons, - miniapp, - polls, promocodes, + miniapp, promo_groups, promo_offers, pages, @@ -92,10 +91,6 @@ OPENAPI_TAGS = [ "name": "miniapp", "description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.", }, - { - "name": "polls", - "description": "Создание опросов, удаление, статистика и ответы пользователей.", - }, { "name": "pages", "description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.", @@ -150,7 +145,6 @@ def create_web_api_app() -> FastAPI: app.include_router(tokens.router, prefix="/tokens", tags=["auth"]) app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"]) app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"]) - app.include_router(polls.router, prefix="/polls", tags=["polls"]) app.include_router(logs.router, prefix="/logs", tags=["logs"]) return app diff --git a/app/webapi/routes/__init__.py b/app/webapi/routes/__init__.py index 85684d75..23541d12 100644 --- a/app/webapi/routes/__init__.py +++ b/app/webapi/routes/__init__.py @@ -3,7 +3,6 @@ from . import ( health, main_menu_buttons, miniapp, - polls, promo_offers, pages, promo_groups, @@ -22,7 +21,6 @@ __all__ = [ "health", "main_menu_buttons", "miniapp", - "polls", "promo_offers", "pages", "promo_groups", diff --git a/app/webapi/routes/polls.py b/app/webapi/routes/polls.py deleted file mode 100644 index 7a6f39ce..00000000 --- a/app/webapi/routes/polls.py +++ /dev/null @@ -1,290 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from fastapi import ( - APIRouter, - Depends, - HTTPException, - Query, - Response, - Security, - status, -) -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload - -from app.database.crud.poll import ( - create_poll, - delete_poll as delete_poll_record, - get_poll_by_id, - get_poll_responses_with_answers, - get_poll_statistics, -) -from app.database.models import Poll, PollAnswer, PollOption, PollQuestion, PollResponse - -from ..dependencies import get_db_session, require_api_token -from ..schemas.polls import ( - PollAnswerResponse, - PollCreateRequest, - PollDetailResponse, - PollListResponse, - PollOptionStats, - PollQuestionOptionResponse, - PollQuestionResponse, - PollQuestionStats, - PollResponsesListResponse, - PollStatisticsResponse, - PollSummaryResponse, - PollUserResponse, -) - -router = APIRouter() - - -def _format_price(kopeks: int) -> float: - return round(kopeks / 100, 2) - - -def _serialize_option(option: PollOption) -> PollQuestionOptionResponse: - return PollQuestionOptionResponse( - id=option.id, - text=option.text, - order=option.order, - ) - - -def _serialize_question(question: PollQuestion) -> PollQuestionResponse: - options = [ - _serialize_option(option) - for option in sorted(question.options, key=lambda item: item.order) - ] - return PollQuestionResponse( - id=question.id, - text=question.text, - order=question.order, - options=options, - ) - - -def _serialize_poll_summary(poll: Poll) -> PollSummaryResponse: - questions = getattr(poll, "questions", []) - responses = getattr(poll, "responses", []) - return PollSummaryResponse( - id=poll.id, - title=poll.title, - description=poll.description, - reward_enabled=poll.reward_enabled, - reward_amount_kopeks=poll.reward_amount_kopeks, - reward_amount_rubles=_format_price(poll.reward_amount_kopeks), - questions_count=len(questions), - responses_count=len(responses), - created_at=poll.created_at, - updated_at=poll.updated_at, - ) - - -def _serialize_poll_detail(poll: Poll) -> PollDetailResponse: - questions = [ - _serialize_question(question) - for question in sorted(poll.questions, key=lambda item: item.order) - ] - return PollDetailResponse( - id=poll.id, - title=poll.title, - description=poll.description, - reward_enabled=poll.reward_enabled, - reward_amount_kopeks=poll.reward_amount_kopeks, - reward_amount_rubles=_format_price(poll.reward_amount_kopeks), - questions=questions, - created_at=poll.created_at, - updated_at=poll.updated_at, - ) - - -def _serialize_answer(answer: PollAnswer) -> PollAnswerResponse: - question = getattr(answer, "question", None) - option = getattr(answer, "option", None) - return PollAnswerResponse( - question_id=question.id if question else answer.question_id, - question_text=question.text if question else None, - option_id=option.id if option else answer.option_id, - option_text=option.text if option else None, - created_at=answer.created_at, - ) - - -def _serialize_user_response(response: PollResponse) -> PollUserResponse: - user = getattr(response, "user", None) - answers = [ - _serialize_answer(answer) - for answer in sorted(response.answers, key=lambda item: item.created_at) - ] - return PollUserResponse( - id=response.id, - user_id=getattr(user, "id", None), - user_telegram_id=getattr(user, "telegram_id", None), - user_username=getattr(user, "username", None), - sent_at=response.sent_at, - started_at=response.started_at, - completed_at=response.completed_at, - reward_given=response.reward_given, - reward_amount_kopeks=response.reward_amount_kopeks, - reward_amount_rubles=_format_price(response.reward_amount_kopeks), - answers=answers, - ) - - -@router.get("", response_model=PollListResponse) -async def list_polls( - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), -) -> PollListResponse: - total_result = await db.execute(select(func.count()).select_from(Poll)) - total = int(total_result.scalar_one() or 0) - - if total == 0: - return PollListResponse(items=[], total=0, limit=limit, offset=offset) - - result = await db.execute( - select(Poll) - .options( - selectinload(Poll.questions), - selectinload(Poll.responses), - ) - .order_by(Poll.created_at.desc()) - .offset(offset) - .limit(limit) - ) - polls = result.scalars().unique().all() - - return PollListResponse( - items=[_serialize_poll_summary(poll) for poll in polls], - total=total, - limit=limit, - offset=offset, - ) - - -@router.get("/{poll_id}", response_model=PollDetailResponse) -async def get_poll( - poll_id: int, - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> PollDetailResponse: - poll = await get_poll_by_id(db, poll_id) - if not poll: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found") - - return _serialize_poll_detail(poll) - - -@router.post("", response_model=PollDetailResponse, status_code=status.HTTP_201_CREATED) -async def create_poll_endpoint( - payload: PollCreateRequest, - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> PollDetailResponse: - poll = await create_poll( - db, - title=payload.title, - description=payload.description, - reward_enabled=payload.reward_enabled, - reward_amount_kopeks=payload.reward_amount_kopeks, - created_by=None, - questions=[ - { - "text": question.text, - "options": [option.text for option in question.options], - } - for question in payload.questions - ], - ) - - poll = await get_poll_by_id(db, poll.id) - return _serialize_poll_detail(poll) - - -@router.delete("/{poll_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_poll( - poll_id: int, - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> Response: - success = await delete_poll_record(db, poll_id) - if not success: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found") - - return Response(status_code=status.HTTP_204_NO_CONTENT) - - -@router.get("/{poll_id}/stats", response_model=PollStatisticsResponse) -async def get_poll_stats( - poll_id: int, - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> PollStatisticsResponse: - poll = await db.get(Poll, poll_id) - if not poll: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found") - - stats = await get_poll_statistics(db, poll_id) - - formatted_questions = [ - PollQuestionStats( - id=question_data["id"], - text=question_data["text"], - order=question_data["order"], - options=[ - PollOptionStats( - id=option_data["id"], - text=option_data["text"], - count=option_data["count"], - ) - for option_data in question_data.get("options", []) - ], - ) - for question_data in stats.get("questions", []) - ] - - return PollStatisticsResponse( - poll_id=poll.id, - poll_title=poll.title, - total_responses=stats.get("total_responses", 0), - completed_responses=stats.get("completed_responses", 0), - reward_sum_kopeks=stats.get("reward_sum_kopeks", 0), - reward_sum_rubles=_format_price(stats.get("reward_sum_kopeks", 0)), - questions=formatted_questions, - ) - - -@router.get("/{poll_id}/responses", response_model=PollResponsesListResponse) -async def get_poll_responses( - poll_id: int, - _: Any = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), -) -> PollResponsesListResponse: - poll_exists = await db.get(Poll, poll_id) - if not poll_exists: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found") - - responses, total = await get_poll_responses_with_answers( - db, - poll_id, - limit=limit, - offset=offset, - ) - - items = [_serialize_user_response(response) for response in responses] - - return PollResponsesListResponse( - items=items, - total=total, - limit=limit, - offset=offset, - ) diff --git a/app/webapi/schemas/polls.py b/app/webapi/schemas/polls.py deleted file mode 100644 index 3d95a715..00000000 --- a/app/webapi/schemas/polls.py +++ /dev/null @@ -1,171 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Optional - -from pydantic import BaseModel, Field, field_validator, model_validator - - -class PollOptionCreate(BaseModel): - text: str = Field(..., min_length=1, max_length=500) - - @field_validator("text") - @classmethod - def strip_text(cls, value: str) -> str: - text = value.strip() - if not text: - raise ValueError("Option text cannot be empty") - return text - - -class PollQuestionCreate(BaseModel): - text: str = Field(..., min_length=1, max_length=1000) - options: list[PollOptionCreate] = Field(..., min_length=2) - - @field_validator("text") - @classmethod - def strip_question_text(cls, value: str) -> str: - text = value.strip() - if not text: - raise ValueError("Question text cannot be empty") - return text - - @field_validator("options") - @classmethod - def validate_options(cls, value: list[PollOptionCreate]) -> list[PollOptionCreate]: - seen: set[str] = set() - for option in value: - normalized = option.text.lower() - if normalized in seen: - raise ValueError("Option texts must be unique within a question") - seen.add(normalized) - return value - - -class PollCreateRequest(BaseModel): - title: str = Field(..., min_length=1, max_length=255) - description: Optional[str] = Field(default=None, max_length=4000) - reward_enabled: bool = False - reward_amount_kopeks: int = Field(default=0, ge=0, le=1_000_000_000) - questions: list[PollQuestionCreate] = Field(..., min_length=1) - - @field_validator("title") - @classmethod - def strip_title(cls, value: str) -> str: - title = value.strip() - if not title: - raise ValueError("Title cannot be empty") - return title - - @field_validator("description") - @classmethod - def normalize_description(cls, value: Optional[str]) -> Optional[str]: - if value is None: - return None - description = value.strip() - return description or None - - @model_validator(mode="after") - def validate_reward(self) -> "PollCreateRequest": - if self.reward_enabled and self.reward_amount_kopeks <= 0: - raise ValueError("Reward amount must be positive when rewards are enabled") - if not self.reward_enabled: - self.reward_amount_kopeks = 0 - return self - - -class PollQuestionOptionResponse(BaseModel): - id: int - text: str - order: int - - -class PollQuestionResponse(BaseModel): - id: int - text: str - order: int - options: list[PollQuestionOptionResponse] - - -class PollSummaryResponse(BaseModel): - id: int - title: str - description: Optional[str] - reward_enabled: bool - reward_amount_kopeks: int - reward_amount_rubles: float - questions_count: int - responses_count: int - created_at: datetime - updated_at: datetime - - -class PollDetailResponse(BaseModel): - id: int - title: str - description: Optional[str] - reward_enabled: bool - reward_amount_kopeks: int - reward_amount_rubles: float - questions: list[PollQuestionResponse] - created_at: datetime - updated_at: datetime - - -class PollListResponse(BaseModel): - items: list[PollSummaryResponse] - total: int - limit: int - offset: int - - -class PollOptionStats(BaseModel): - id: int - text: str - count: int - - -class PollQuestionStats(BaseModel): - id: int - text: str - order: int - options: list[PollOptionStats] - - -class PollStatisticsResponse(BaseModel): - poll_id: int - poll_title: str - total_responses: int - completed_responses: int - reward_sum_kopeks: int - reward_sum_rubles: float - questions: list[PollQuestionStats] - - -class PollAnswerResponse(BaseModel): - question_id: Optional[int] - question_text: Optional[str] - option_id: Optional[int] - option_text: Optional[str] - created_at: datetime - - -class PollUserResponse(BaseModel): - id: int - user_id: Optional[int] - user_telegram_id: Optional[int] - user_username: Optional[str] - sent_at: datetime - started_at: Optional[datetime] - completed_at: Optional[datetime] - reward_given: bool - reward_amount_kopeks: int - reward_amount_rubles: float - answers: list[PollAnswerResponse] - - -class PollResponsesListResponse(BaseModel): - items: list[PollUserResponse] - total: int - limit: int - offset: int diff --git a/docs/web-admin-integration.md b/docs/web-admin-integration.md index 969f0033..9ad9048a 100644 --- a/docs/web-admin-integration.md +++ b/docs/web-admin-integration.md @@ -141,12 +141,6 @@ curl -X POST "http://127.0.0.1:8080/tokens" \ | `GET` | `/logs/support/actions` | Справочник возможных действий в аудите поддержки. | `GET` | `/logs/system` | Предпросмотр системного лог-файла бота с метаданными. | `GET` | `/logs/system/download` | Скачивание полного лог-файла бота (`text/plain`). -| `GET` | `/polls` | Список опросов с пагинацией и краткой статистикой. -| `POST` | `/polls` | Создание нового опроса с набором вопросов и вариантов ответов. -| `GET` | `/polls/{id}` | Получить полный набор вопросов и метаданных опроса. -| `DELETE` | `/polls/{id}` | Удалить опрос и связанные ответы пользователей. -| `GET` | `/polls/{id}/stats` | Агрегированная статистика по ответам и выданным наградам. -| `GET` | `/polls/{id}/responses` | Ответы каждого пользователя с детализацией по вопросам. > Раздел **promo-offers** в Swagger объединяет работу с персональными предложениями: выдачу скидок/бонусов пользователям, настройку > текстов шаблонов и просмотр журнала операций (активации, автосписания, отключения просроченных акций). @@ -164,42 +158,6 @@ curl -X POST "http://127.0.0.1:8080/tokens" \ Все эндпоинты защищены токеном API и возвращают структуру с общим количеством записей, текущим `limit`/`offset` и массивом объектов. Это упрощает реализацию таблиц и постраничной навигации во внешних административных интерфейсах. -### Управление опросами - -Новые эндпоинты раздела **polls** позволяют создавать, просматривать и анализировать опросы, которые бот рассылает пользователям. Все маршруты требуют заголовок авторизации и возвращают структуры, описанные в схеме OpenAPI. - -#### Создание опроса - -`POST /polls` - -```json -{ - "title": "Feedback по запуску", - "description": "Расскажите, что можно улучшить в следующем релизе", - "reward_enabled": true, - "reward_amount_kopeks": 1500, - "questions": [ - { - "text": "Как вы оцениваете стабильность?", - "options": ["Отлично", "Хорошо", "Нормально", "Плохо"] - }, - { - "text": "Какие функции нужны дальше?", - "options": ["VPN", "Биллинг", "Поддержка"] - } - ] -} -``` - -Ответ содержит созданный опрос вместе с идентификаторами вопросов и вариантов. - -#### Получение статистики и ответов - -- `GET /polls/{id}/stats` — возвращает количество начатых и завершённых прохождений, суммарные выплаты и распределение ответов по вопросам. -- `GET /polls/{id}/responses` — выдаёт детальные ответы каждого пользователя, включая отметку о вручении награды и текст выбранных вариантов. Используйте параметры `limit` и `offset` для постраничной загрузки. - -Чтобы удалить опрос и связанные ответы, отправьте запрос `DELETE /polls/{id}`. Эндпоинт вернёт `204 No Content` в случае успеха. - ### RemnaWave интеграция После включения веб-API в Swagger (`WEB_API_DOCS_ENABLED=true`) появится раздел **remnawave**. Он объединяет эндпоинты для управления панелью RemnaWave и синхронизации данных бота: From 30ec07f7fec2f31599e7721e01f709090a330cf3 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 24 Oct 2025 09:39:39 +0300 Subject: [PATCH 5/5] Avoid loading poll responses in list endpoint --- app/database/crud/poll.py | 32 ++++ app/webapi/app.py | 8 +- app/webapi/routes/__init__.py | 2 + app/webapi/routes/polls.py | 308 ++++++++++++++++++++++++++++++++++ app/webapi/schemas/polls.py | 171 +++++++++++++++++++ docs/web-admin-integration.md | 65 +++++++ 6 files changed, 585 insertions(+), 1 deletion(-) create mode 100644 app/webapi/routes/polls.py create mode 100644 app/webapi/schemas/polls.py diff --git a/app/database/crud/poll.py b/app/database/crud/poll.py index 5b3b35ad..db1a9b7e 100644 --- a/app/database/crud/poll.py +++ b/app/database/crud/poll.py @@ -263,3 +263,35 @@ async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict: "reward_sum_kopeks": reward_sum, "questions": questions, } + + +async def get_poll_responses_with_answers( + db: AsyncSession, + poll_id: int, + *, + limit: int, + offset: int, +) -> tuple[list[PollResponse], int]: + total_result = await db.execute( + select(func.count()).select_from(PollResponse).where(PollResponse.poll_id == poll_id) + ) + total = int(total_result.scalar_one() or 0) + + if total == 0: + return [], 0 + + result = await db.execute( + select(PollResponse) + .options( + selectinload(PollResponse.user), + selectinload(PollResponse.answers).selectinload(PollAnswer.question), + selectinload(PollResponse.answers).selectinload(PollAnswer.option), + ) + .where(PollResponse.poll_id == poll_id) + .order_by(PollResponse.sent_at.asc()) + .offset(offset) + .limit(limit) + ) + + responses = result.scalars().unique().all() + return responses, total diff --git a/app/webapi/app.py b/app/webapi/app.py index 79f23065..d5848663 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -13,8 +13,9 @@ from .routes import ( config, health, main_menu_buttons, - promocodes, miniapp, + polls, + promocodes, promo_groups, promo_offers, pages, @@ -91,6 +92,10 @@ OPENAPI_TAGS = [ "name": "miniapp", "description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.", }, + { + "name": "polls", + "description": "Создание опросов, удаление, статистика и ответы пользователей.", + }, { "name": "pages", "description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.", @@ -145,6 +150,7 @@ def create_web_api_app() -> FastAPI: app.include_router(tokens.router, prefix="/tokens", tags=["auth"]) app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"]) app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"]) + app.include_router(polls.router, prefix="/polls", tags=["polls"]) app.include_router(logs.router, prefix="/logs", tags=["logs"]) return app diff --git a/app/webapi/routes/__init__.py b/app/webapi/routes/__init__.py index 23541d12..85684d75 100644 --- a/app/webapi/routes/__init__.py +++ b/app/webapi/routes/__init__.py @@ -3,6 +3,7 @@ from . import ( health, main_menu_buttons, miniapp, + polls, promo_offers, pages, promo_groups, @@ -21,6 +22,7 @@ __all__ = [ "health", "main_menu_buttons", "miniapp", + "polls", "promo_offers", "pages", "promo_groups", diff --git a/app/webapi/routes/polls.py b/app/webapi/routes/polls.py new file mode 100644 index 00000000..8442cc81 --- /dev/null +++ b/app/webapi/routes/polls.py @@ -0,0 +1,308 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Query, + Response, + Security, + status, +) +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.crud.poll import ( + create_poll, + delete_poll as delete_poll_record, + get_poll_by_id, + get_poll_responses_with_answers, + get_poll_statistics, +) +from app.database.models import Poll, PollAnswer, PollOption, PollQuestion, PollResponse + +from ..dependencies import get_db_session, require_api_token +from ..schemas.polls import ( + PollAnswerResponse, + PollCreateRequest, + PollDetailResponse, + PollListResponse, + PollOptionStats, + PollQuestionOptionResponse, + PollQuestionResponse, + PollQuestionStats, + PollResponsesListResponse, + PollStatisticsResponse, + PollSummaryResponse, + PollUserResponse, +) + +router = APIRouter() + + +def _format_price(kopeks: int) -> float: + return round(kopeks / 100, 2) + + +def _serialize_option(option: PollOption) -> PollQuestionOptionResponse: + return PollQuestionOptionResponse( + id=option.id, + text=option.text, + order=option.order, + ) + + +def _serialize_question(question: PollQuestion) -> PollQuestionResponse: + options = [ + _serialize_option(option) + for option in sorted(question.options, key=lambda item: item.order) + ] + return PollQuestionResponse( + id=question.id, + text=question.text, + order=question.order, + options=options, + ) + + +def _serialize_poll_summary( + poll: Poll, + responses_count: int | None = None, +) -> PollSummaryResponse: + questions = getattr(poll, "questions", []) + if responses_count is None: + responses = getattr(poll, "responses", []) + responses_count = len(responses) + return PollSummaryResponse( + id=poll.id, + title=poll.title, + description=poll.description, + reward_enabled=poll.reward_enabled, + reward_amount_kopeks=poll.reward_amount_kopeks, + reward_amount_rubles=_format_price(poll.reward_amount_kopeks), + questions_count=len(questions), + responses_count=responses_count, + created_at=poll.created_at, + updated_at=poll.updated_at, + ) + + +def _serialize_poll_detail(poll: Poll) -> PollDetailResponse: + questions = [ + _serialize_question(question) + for question in sorted(poll.questions, key=lambda item: item.order) + ] + return PollDetailResponse( + id=poll.id, + title=poll.title, + description=poll.description, + reward_enabled=poll.reward_enabled, + reward_amount_kopeks=poll.reward_amount_kopeks, + reward_amount_rubles=_format_price(poll.reward_amount_kopeks), + questions=questions, + created_at=poll.created_at, + updated_at=poll.updated_at, + ) + + +def _serialize_answer(answer: PollAnswer) -> PollAnswerResponse: + question = getattr(answer, "question", None) + option = getattr(answer, "option", None) + return PollAnswerResponse( + question_id=question.id if question else answer.question_id, + question_text=question.text if question else None, + option_id=option.id if option else answer.option_id, + option_text=option.text if option else None, + created_at=answer.created_at, + ) + + +def _serialize_user_response(response: PollResponse) -> PollUserResponse: + user = getattr(response, "user", None) + answers = [ + _serialize_answer(answer) + for answer in sorted(response.answers, key=lambda item: item.created_at) + ] + return PollUserResponse( + id=response.id, + user_id=getattr(user, "id", None), + user_telegram_id=getattr(user, "telegram_id", None), + user_username=getattr(user, "username", None), + sent_at=response.sent_at, + started_at=response.started_at, + completed_at=response.completed_at, + reward_given=response.reward_given, + reward_amount_kopeks=response.reward_amount_kopeks, + reward_amount_rubles=_format_price(response.reward_amount_kopeks), + answers=answers, + ) + + +@router.get("", response_model=PollListResponse) +async def list_polls( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), +) -> PollListResponse: + total_result = await db.execute(select(func.count()).select_from(Poll)) + total = int(total_result.scalar_one() or 0) + + if total == 0: + return PollListResponse(items=[], total=0, limit=limit, offset=offset) + + result = await db.execute( + select(Poll) + .options(selectinload(Poll.questions)) + .order_by(Poll.created_at.desc()) + .offset(offset) + .limit(limit) + ) + polls = result.scalars().unique().all() + + if not polls: + return PollListResponse(items=[], total=total, limit=limit, offset=offset) + + poll_ids = [poll.id for poll in polls] + counts_result = await db.execute( + select(PollResponse.poll_id, func.count(PollResponse.id)) + .where(PollResponse.poll_id.in_(poll_ids)) + .group_by(PollResponse.poll_id) + ) + responses_counts = {poll_id: count for poll_id, count in counts_result.all()} + + return PollListResponse( + items=[ + _serialize_poll_summary( + poll, responses_counts.get(poll.id, 0) + ) + for poll in polls + ], + total=total, + limit=limit, + offset=offset, + ) + + +@router.get("/{poll_id}", response_model=PollDetailResponse) +async def get_poll( + poll_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> PollDetailResponse: + poll = await get_poll_by_id(db, poll_id) + if not poll: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found") + + return _serialize_poll_detail(poll) + + +@router.post("", response_model=PollDetailResponse, status_code=status.HTTP_201_CREATED) +async def create_poll_endpoint( + payload: PollCreateRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> PollDetailResponse: + poll = await create_poll( + db, + title=payload.title, + description=payload.description, + reward_enabled=payload.reward_enabled, + reward_amount_kopeks=payload.reward_amount_kopeks, + created_by=None, + questions=[ + { + "text": question.text, + "options": [option.text for option in question.options], + } + for question in payload.questions + ], + ) + + poll = await get_poll_by_id(db, poll.id) + return _serialize_poll_detail(poll) + + +@router.delete("/{poll_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_poll( + poll_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> Response: + success = await delete_poll_record(db, poll_id) + if not success: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found") + + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/{poll_id}/stats", response_model=PollStatisticsResponse) +async def get_poll_stats( + poll_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> PollStatisticsResponse: + poll = await db.get(Poll, poll_id) + if not poll: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found") + + stats = await get_poll_statistics(db, poll_id) + + formatted_questions = [ + PollQuestionStats( + id=question_data["id"], + text=question_data["text"], + order=question_data["order"], + options=[ + PollOptionStats( + id=option_data["id"], + text=option_data["text"], + count=option_data["count"], + ) + for option_data in question_data.get("options", []) + ], + ) + for question_data in stats.get("questions", []) + ] + + return PollStatisticsResponse( + poll_id=poll.id, + poll_title=poll.title, + total_responses=stats.get("total_responses", 0), + completed_responses=stats.get("completed_responses", 0), + reward_sum_kopeks=stats.get("reward_sum_kopeks", 0), + reward_sum_rubles=_format_price(stats.get("reward_sum_kopeks", 0)), + questions=formatted_questions, + ) + + +@router.get("/{poll_id}/responses", response_model=PollResponsesListResponse) +async def get_poll_responses( + poll_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), +) -> PollResponsesListResponse: + poll_exists = await db.get(Poll, poll_id) + if not poll_exists: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found") + + responses, total = await get_poll_responses_with_answers( + db, + poll_id, + limit=limit, + offset=offset, + ) + + items = [_serialize_user_response(response) for response in responses] + + return PollResponsesListResponse( + items=items, + total=total, + limit=limit, + offset=offset, + ) diff --git a/app/webapi/schemas/polls.py b/app/webapi/schemas/polls.py new file mode 100644 index 00000000..3d95a715 --- /dev/null +++ b/app/webapi/schemas/polls.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field, field_validator, model_validator + + +class PollOptionCreate(BaseModel): + text: str = Field(..., min_length=1, max_length=500) + + @field_validator("text") + @classmethod + def strip_text(cls, value: str) -> str: + text = value.strip() + if not text: + raise ValueError("Option text cannot be empty") + return text + + +class PollQuestionCreate(BaseModel): + text: str = Field(..., min_length=1, max_length=1000) + options: list[PollOptionCreate] = Field(..., min_length=2) + + @field_validator("text") + @classmethod + def strip_question_text(cls, value: str) -> str: + text = value.strip() + if not text: + raise ValueError("Question text cannot be empty") + return text + + @field_validator("options") + @classmethod + def validate_options(cls, value: list[PollOptionCreate]) -> list[PollOptionCreate]: + seen: set[str] = set() + for option in value: + normalized = option.text.lower() + if normalized in seen: + raise ValueError("Option texts must be unique within a question") + seen.add(normalized) + return value + + +class PollCreateRequest(BaseModel): + title: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = Field(default=None, max_length=4000) + reward_enabled: bool = False + reward_amount_kopeks: int = Field(default=0, ge=0, le=1_000_000_000) + questions: list[PollQuestionCreate] = Field(..., min_length=1) + + @field_validator("title") + @classmethod + def strip_title(cls, value: str) -> str: + title = value.strip() + if not title: + raise ValueError("Title cannot be empty") + return title + + @field_validator("description") + @classmethod + def normalize_description(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return None + description = value.strip() + return description or None + + @model_validator(mode="after") + def validate_reward(self) -> "PollCreateRequest": + if self.reward_enabled and self.reward_amount_kopeks <= 0: + raise ValueError("Reward amount must be positive when rewards are enabled") + if not self.reward_enabled: + self.reward_amount_kopeks = 0 + return self + + +class PollQuestionOptionResponse(BaseModel): + id: int + text: str + order: int + + +class PollQuestionResponse(BaseModel): + id: int + text: str + order: int + options: list[PollQuestionOptionResponse] + + +class PollSummaryResponse(BaseModel): + id: int + title: str + description: Optional[str] + reward_enabled: bool + reward_amount_kopeks: int + reward_amount_rubles: float + questions_count: int + responses_count: int + created_at: datetime + updated_at: datetime + + +class PollDetailResponse(BaseModel): + id: int + title: str + description: Optional[str] + reward_enabled: bool + reward_amount_kopeks: int + reward_amount_rubles: float + questions: list[PollQuestionResponse] + created_at: datetime + updated_at: datetime + + +class PollListResponse(BaseModel): + items: list[PollSummaryResponse] + total: int + limit: int + offset: int + + +class PollOptionStats(BaseModel): + id: int + text: str + count: int + + +class PollQuestionStats(BaseModel): + id: int + text: str + order: int + options: list[PollOptionStats] + + +class PollStatisticsResponse(BaseModel): + poll_id: int + poll_title: str + total_responses: int + completed_responses: int + reward_sum_kopeks: int + reward_sum_rubles: float + questions: list[PollQuestionStats] + + +class PollAnswerResponse(BaseModel): + question_id: Optional[int] + question_text: Optional[str] + option_id: Optional[int] + option_text: Optional[str] + created_at: datetime + + +class PollUserResponse(BaseModel): + id: int + user_id: Optional[int] + user_telegram_id: Optional[int] + user_username: Optional[str] + sent_at: datetime + started_at: Optional[datetime] + completed_at: Optional[datetime] + reward_given: bool + reward_amount_kopeks: int + reward_amount_rubles: float + answers: list[PollAnswerResponse] + + +class PollResponsesListResponse(BaseModel): + items: list[PollUserResponse] + total: int + limit: int + offset: int diff --git a/docs/web-admin-integration.md b/docs/web-admin-integration.md index 9ad9048a..79e2d298 100644 --- a/docs/web-admin-integration.md +++ b/docs/web-admin-integration.md @@ -135,6 +135,12 @@ curl -X POST "http://127.0.0.1:8080/tokens" \ | `PATCH` | `/promo-offers/templates/{id}` | Обновить текст, кнопки и параметры шаблона. | `GET` | `/promo-offers/logs` | Журнал операций с промо-предложениями (активации, списания, выключения). | `GET` | `/tokens` | Управление токенами доступа. +| `GET` | `/polls` | Список опросов с постраничной навигацией. +| `GET` | `/polls/{id}` | Детали опроса с вопросами и вариантами ответов. +| `POST` | `/polls` | Создать опрос: заголовок, описание, вопросы и варианты. +| `DELETE` | `/polls/{id}` | Удалить опрос целиком. +| `GET` | `/polls/{id}/stats` | Сводная статистика по ответам и начисленным наградам. +| `GET` | `/polls/{id}/responses` | Ответы пользователей с детализацией по вопросам. | `GET` | `/logs/monitoring` | Логи мониторинга бота с пагинацией и фильтрами по типу события. | `GET` | `/logs/monitoring/event-types` | Справочник доступных типов событий мониторинга. | `GET` | `/logs/support` | Журнал действий модераторов поддержки (блокировки, закрытия тикетов). @@ -158,6 +164,65 @@ curl -X POST "http://127.0.0.1:8080/tokens" \ Все эндпоинты защищены токеном API и возвращают структуру с общим количеством записей, текущим `limit`/`offset` и массивом объектов. Это упрощает реализацию таблиц и постраничной навигации во внешних административных интерфейсах. +### Управление опросами + +Раздел **polls** в административном API позволяет создавать и анализировать опросы, которые бот рассылает пользователям. + +#### Список и детали + +- `GET /polls` — возвращает массив объектов с базовой информацией: название, описание, флаги награды, количество вопросов и ответов. +- `GET /polls/{id}` — раскрывает структуру конкретного опроса, включая упорядоченные вопросы и варианты ответов. Подходит для предпросмотра перед публикацией. + +#### Создание опроса + +Для создания опроса отправьте JSON, соответствующий схеме: + +```json +{ + "title": "Оценка нового тарифа", + "description": "Помогите улучшить продукт — ответ займет до 2 минут", + "reward_enabled": true, + "reward_amount_kopeks": 1000, + "questions": [ + { + "text": "Насколько вы довольны скоростью соединения?", + "options": [ + { "text": "Очень доволен" }, + { "text": "Скорее доволен" }, + { "text": "Нейтрально" }, + { "text": "Скорее недоволен" }, + { "text": "Очень недоволен" } + ] + }, + { + "text": "Какие улучшения вы ждёте?", + "options": [ + { "text": "Стабильность" }, + { "text": "Скорость" }, + { "text": "Поддержка" } + ] + } + ] +} +``` + +Требования валидации: + +- Заголовок (`title`) — от 1 до 255 символов, не пустой после обрезки пробелов. +- Описание (`description`) — до 4000 символов, пробелы по краям удаляются. +- Если `reward_enabled=true`, сумма вознаграждения (`reward_amount_kopeks`) должна быть положительной. При `false` значение автоматически сбрасывается в `0`. +- Каждый вопрос содержит минимум два уникальных варианта ответа. + +В ответ API вернёт созданный опрос с назначенными идентификаторами и полем `reward_amount_rubles`, которое удобно показывать в интерфейсе. + +#### Удаление и статистика + +- `DELETE /polls/{id}` — удаляет опрос и связанные с ним вопросы/ответы. Используйте с осторожностью, операция необратима. +- `GET /polls/{id}/stats` — агрегированная статистика: общее количество ответов, завершённых прохождений и сумма выданных наград. Для каждого вопроса возвращается количество выборов по вариантам. +- `GET /polls/{id}/responses` — список ответов пользователей с пагинацией (`limit`, `offset`). Каждый элемент содержит временные метки (`sent_at`, `started_at`, `completed_at`), данные пользователя (ID, username, Telegram ID), информацию о выданной награде и массив ответов с текстами вопросов/вариантов. + +Такой формат позволяет без дополнительного запроса показать детализацию на фронтенде или выгрузить данные в CSV. + ### RemnaWave интеграция После включения веб-API в Swagger (`WEB_API_DOCS_ENABLED=true`) появится раздел **remnawave**. Он объединяет эндпоинты для управления панелью RemnaWave и синхронизации данных бота: