eb18994b7d
- Migrate 660+ datetime.utcnow() across 153 files to datetime.now(UTC) - Migrate 30+ datetime.now() without UTC to datetime.now(UTC) - Convert all 170 DateTime columns to DateTime(timezone=True) - Add migrate_datetime_to_timestamptz() in universal_migration with SET LOCAL timezone='UTC' safety - Remove 70+ .replace(tzinfo=None) workarounds - Fix utcfromtimestamp → fromtimestamp(..., tz=UTC) - Fix fromtimestamp() without tz= (system_logs, backup_service, referral_diagnostics) - Fix fromisoformat/isoparse to ensure aware output (platega, yookassa, wata, miniapp, nalogo) - Fix strptime() to add .replace(tzinfo=UTC) (backup_service, referral_diagnostics) - Fix datetime.combine() to include tzinfo=UTC (remnawave_sync, traffic_monitoring) - Fix datetime.max/datetime.min sentinels with .replace(tzinfo=UTC) - Rename panel_datetime_to_naive_utc → panel_datetime_to_utc - Remove DTZ003 from ruff ignore list
366 lines
10 KiB
Python
366 lines
10 KiB
Python
"""Polls routes for cabinet - user participation in polls/surveys."""
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
import structlog
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.config import settings
|
|
from app.database.crud.poll import (
|
|
get_poll_response_by_id,
|
|
record_poll_answer,
|
|
)
|
|
from app.database.models import Poll, PollQuestion, PollResponse, User
|
|
from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll
|
|
|
|
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
|
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
router = APIRouter(prefix='/polls', tags=['Cabinet Polls'])
|
|
|
|
|
|
# ============ Schemas ============
|
|
|
|
|
|
class PollOptionResponse(BaseModel):
|
|
"""Poll option."""
|
|
|
|
id: int
|
|
text: str
|
|
order: int
|
|
|
|
|
|
class PollQuestionResponse(BaseModel):
|
|
"""Poll question with options."""
|
|
|
|
id: int
|
|
text: str
|
|
order: int
|
|
options: list[PollOptionResponse]
|
|
|
|
|
|
class PollInfo(BaseModel):
|
|
"""Poll info for user."""
|
|
|
|
id: int
|
|
response_id: int
|
|
title: str
|
|
description: str | None = None
|
|
total_questions: int
|
|
answered_questions: int
|
|
is_completed: bool
|
|
reward_amount: int | None = None
|
|
|
|
|
|
class PollStartResponse(BaseModel):
|
|
"""Response when starting a poll."""
|
|
|
|
response_id: int
|
|
current_question_index: int
|
|
total_questions: int
|
|
question: PollQuestionResponse
|
|
|
|
|
|
class AnswerRequest(BaseModel):
|
|
"""Request to answer a poll question."""
|
|
|
|
option_id: int
|
|
|
|
|
|
class AnswerResponse(BaseModel):
|
|
"""Response after answering."""
|
|
|
|
success: bool
|
|
is_completed: bool
|
|
next_question: PollQuestionResponse | None = None
|
|
current_question_index: int | None = None
|
|
total_questions: int
|
|
reward_granted: int | None = None
|
|
message: str | None = None
|
|
|
|
|
|
# ============ Helpers ============
|
|
|
|
|
|
def _question_to_response(question: PollQuestion) -> PollQuestionResponse:
|
|
"""Convert question model to response."""
|
|
options = [
|
|
PollOptionResponse(
|
|
id=opt.id,
|
|
text=opt.text,
|
|
order=opt.order,
|
|
)
|
|
for opt in sorted(question.options, key=lambda o: o.order)
|
|
]
|
|
return PollQuestionResponse(
|
|
id=question.id,
|
|
text=question.text,
|
|
order=question.order,
|
|
options=options,
|
|
)
|
|
|
|
|
|
# ============ Routes ============
|
|
|
|
|
|
class PollsCountResponse(BaseModel):
|
|
"""Count of available polls."""
|
|
|
|
count: int
|
|
|
|
|
|
@router.get('/count', response_model=PollsCountResponse)
|
|
async def get_polls_count(
|
|
user: User = Depends(get_current_cabinet_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
):
|
|
"""Get count of polls available for the user."""
|
|
result = await db.execute(
|
|
select(PollResponse)
|
|
.where(PollResponse.user_id == user.id)
|
|
.where(PollResponse.completed_at.is_(None)) # Only incomplete polls
|
|
)
|
|
responses = result.scalars().all()
|
|
return PollsCountResponse(count=len(responses))
|
|
|
|
|
|
@router.get('', response_model=list[PollInfo])
|
|
async def get_available_polls(
|
|
user: User = Depends(get_current_cabinet_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
):
|
|
"""Get list of polls available for the user."""
|
|
# Get user's poll responses with eager loading of relationships
|
|
result = await db.execute(
|
|
select(PollResponse)
|
|
.where(PollResponse.user_id == user.id)
|
|
.options(
|
|
selectinload(PollResponse.poll).selectinload(Poll.questions),
|
|
selectinload(PollResponse.answers),
|
|
)
|
|
.order_by(PollResponse.created_at.desc())
|
|
)
|
|
responses = result.scalars().all()
|
|
|
|
polls = []
|
|
for response in responses:
|
|
if not response.poll:
|
|
continue
|
|
|
|
answered_count = len(response.answers) if response.answers else 0
|
|
total_questions = len(response.poll.questions) if response.poll.questions else 0
|
|
|
|
# Convert kopeks to rubles for display
|
|
reward_amount = None
|
|
if response.poll.reward_amount_kopeks:
|
|
reward_amount = response.poll.reward_amount_kopeks // 100
|
|
|
|
polls.append(
|
|
PollInfo(
|
|
id=response.poll.id,
|
|
response_id=response.id,
|
|
title=response.poll.title,
|
|
description=response.poll.description,
|
|
total_questions=total_questions,
|
|
answered_questions=answered_count,
|
|
is_completed=response.completed_at is not None,
|
|
reward_amount=reward_amount,
|
|
)
|
|
)
|
|
|
|
return polls
|
|
|
|
|
|
@router.get('/{response_id}', response_model=PollInfo)
|
|
async def get_poll_details(
|
|
response_id: int,
|
|
user: User = Depends(get_current_cabinet_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
):
|
|
"""Get details of a specific poll response."""
|
|
response = await get_poll_response_by_id(db, response_id)
|
|
|
|
if not response or response.user_id != user.id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail='Poll not found',
|
|
)
|
|
|
|
if not response.poll:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail='Poll data not available',
|
|
)
|
|
|
|
answered_count = len(response.answers) if response.answers else 0
|
|
total_questions = len(response.poll.questions) if response.poll.questions else 0
|
|
|
|
# Convert kopeks to rubles for display
|
|
reward_amount = None
|
|
if response.poll.reward_amount_kopeks:
|
|
reward_amount = response.poll.reward_amount_kopeks // 100
|
|
|
|
return PollInfo(
|
|
id=response.poll.id,
|
|
response_id=response.id,
|
|
title=response.poll.title,
|
|
description=response.poll.description,
|
|
total_questions=total_questions,
|
|
answered_questions=answered_count,
|
|
is_completed=response.completed_at is not None,
|
|
reward_amount=reward_amount,
|
|
)
|
|
|
|
|
|
@router.post('/{response_id}/start', response_model=PollStartResponse)
|
|
async def start_poll(
|
|
response_id: int,
|
|
user: User = Depends(get_current_cabinet_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
):
|
|
"""Start or continue a poll."""
|
|
response = await get_poll_response_by_id(db, response_id)
|
|
|
|
if not response or response.user_id != user.id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail='Poll not found',
|
|
)
|
|
|
|
if response.completed_at:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='This poll has already been completed',
|
|
)
|
|
|
|
if not response.poll or not response.poll.questions:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='Poll is not available',
|
|
)
|
|
|
|
# Mark as started if not already
|
|
if not response.started_at:
|
|
response.started_at = datetime.now(UTC)
|
|
await db.commit()
|
|
|
|
# Get next unanswered question
|
|
index, question = await get_next_question(response)
|
|
|
|
if not question:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='No questions available',
|
|
)
|
|
|
|
return PollStartResponse(
|
|
response_id=response.id,
|
|
current_question_index=index,
|
|
total_questions=len(response.poll.questions),
|
|
question=_question_to_response(question),
|
|
)
|
|
|
|
|
|
@router.post('/{response_id}/questions/{question_id}/answer', response_model=AnswerResponse)
|
|
async def answer_question(
|
|
response_id: int,
|
|
question_id: int,
|
|
request: AnswerRequest,
|
|
user: User = Depends(get_current_cabinet_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
):
|
|
"""Submit answer for a poll question."""
|
|
response = await get_poll_response_by_id(db, response_id)
|
|
|
|
if not response or response.user_id != user.id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail='Poll not found',
|
|
)
|
|
|
|
if response.completed_at:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='This poll has already been completed',
|
|
)
|
|
|
|
if not response.poll:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='Poll is not available',
|
|
)
|
|
|
|
# Find the question
|
|
question = next((q for q in response.poll.questions if q.id == question_id), None)
|
|
if not question:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail='Question not found',
|
|
)
|
|
|
|
# Validate option
|
|
option = await get_question_option(question, request.option_id)
|
|
if not option:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='Invalid answer option',
|
|
)
|
|
|
|
# Record the answer
|
|
await record_poll_answer(
|
|
db,
|
|
response_id=response.id,
|
|
question_id=question.id,
|
|
option_id=option.id,
|
|
)
|
|
|
|
# Refresh to get updated answers
|
|
try:
|
|
await db.refresh(response, attribute_names=['answers'])
|
|
except Exception:
|
|
response = await get_poll_response_by_id(db, response_id)
|
|
if not response:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Failed to process answer',
|
|
)
|
|
|
|
# Get next question
|
|
index, next_question = await get_next_question(response)
|
|
total_questions = len(response.poll.questions)
|
|
|
|
if next_question:
|
|
# More questions to answer
|
|
return AnswerResponse(
|
|
success=True,
|
|
is_completed=False,
|
|
next_question=_question_to_response(next_question),
|
|
current_question_index=index,
|
|
total_questions=total_questions,
|
|
)
|
|
|
|
# Poll completed
|
|
response.completed_at = datetime.now(UTC)
|
|
await db.commit()
|
|
|
|
# Award reward if any
|
|
reward_amount = await reward_user_for_poll(db, response)
|
|
|
|
message = 'Thank you for completing the poll!'
|
|
if reward_amount:
|
|
message += f' Reward of {settings.format_price(reward_amount)} has been added to your balance.'
|
|
|
|
return AnswerResponse(
|
|
success=True,
|
|
is_completed=True,
|
|
total_questions=total_questions,
|
|
reward_granted=reward_amount,
|
|
message=message,
|
|
)
|