mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-27 14:51:19 +00:00
- Add pyproject.toml with uv and ruff configuration - Pin Python version to 3.13 via .python-version - Add Makefile commands: lint, format, fix - Apply ruff formatting to entire codebase - Remove unused imports (base64 in yookassa/simple_subscription) - Update .gitignore for new config files
351 lines
11 KiB
Python
351 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,
|
|
PollSendRequest,
|
|
PollSendResponse,
|
|
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,
|
|
)
|
|
|
|
|
|
@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),
|
|
)
|