mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
Avoid loading poll responses in list endpoint
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
308
app/webapi/routes/polls.py
Normal 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
171
app/webapi/schemas/polls.py
Normal 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
|
||||
@@ -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 и синхронизации данных бота:
|
||||
|
||||
Reference in New Issue
Block a user