Files
remnawave-bedolaga-telegram…/app/webapi/routes/polls.py

368 lines
11 KiB
Python

from __future__ import annotations
from typing import Any
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
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 app.handlers.admin.messages import get_custom_users, get_target_users
from app.services.poll_service import send_poll_to_users
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,
PollSendRequest,
PollSendResponse,
)
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,
)
@router.post("/{poll_id}/send", response_model=PollSendResponse)
async def send_poll(
poll_id: int,
payload: PollSendRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PollSendResponse:
poll = await get_poll_by_id(db, poll_id)
if not poll:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
target = payload.target.strip()
if not target:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Target must not be empty")
if target.startswith("custom_"):
users = await get_custom_users(db, target.replace("custom_", ""))
else:
users = await get_target_users(db, target)
if not users:
return PollSendResponse(
poll_id=poll_id,
target=target,
sent=0,
failed=0,
skipped=0,
total=0,
)
from app.config import settings
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
try:
result = await send_poll_to_users(bot, db, poll, users)
finally:
await bot.session.close()
return PollSendResponse(
poll_id=poll_id,
target=target,
sent=result.get("sent", 0),
failed=result.get("failed", 0),
skipped=result.get("skipped", 0),
total=result.get("total", 0),
)