From 30ec07f7fec2f31599e7721e01f709090a330cf3 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 24 Oct 2025 09:39:39 +0300 Subject: [PATCH] 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 и синхронизации данных бота: