Avoid loading poll responses in list endpoint

This commit is contained in:
Egor
2025-10-24 09:39:39 +03:00
parent 51e68cf215
commit 30ec07f7fe
6 changed files with 585 additions and 1 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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",

308
app/webapi/routes/polls.py Normal file
View File

@@ -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,
)

171
app/webapi/schemas/polls.py Normal file
View File

@@ -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

View File

@@ -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 и синхронизации данных бота: