Files
remnawave-bedolaga-telegram…/app/cabinet/routes/contests.py
T
c0mrade 9a2aea038a chore: add uv package manager and ruff linter configuration
- 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
2026-01-24 17:45:27 +03:00

424 lines
13 KiB
Python

"""Contests routes for cabinet - user participation in games/contests."""
import logging
import random
from datetime import datetime, timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.contest import (
create_attempt,
get_active_rounds,
get_attempt,
increment_winner_count,
)
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.models import SubscriptionStatus, User
from app.services.contest_rotation_service import (
GAME_ANAGRAM,
GAME_BLITZ,
GAME_CIPHER,
GAME_EMOJI,
GAME_LOCKS,
GAME_QUEST,
GAME_SERVER,
)
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/contests', tags=['Cabinet Contests'])
# ============ Schemas ============
class ContestInfo(BaseModel):
"""Contest/game info."""
id: int
slug: str
name: str
description: str | None = None
prize_type: str
prize_value: str
is_available: bool
already_played: bool = False
class ContestGameData(BaseModel):
"""Data for playing a contest game."""
round_id: int
game_type: str
game_data: dict[str, Any]
instructions: str
class ContestAnswerRequest(BaseModel):
"""Request to submit contest answer."""
round_id: int
answer: str
class ContestResult(BaseModel):
"""Result of contest attempt."""
is_winner: bool
message: str
prize_type: str | None = None
prize_value: str | None = None
# ============ Helpers ============
def _user_allowed(subscription) -> bool:
"""Check if user is allowed to participate in contests."""
if not subscription:
return False
return subscription.status in {
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
}
async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_value: str) -> str:
"""Award prize to winner."""
if prize_type == 'days':
try:
days = int(prize_value)
except ValueError:
return 'Error: invalid prize value'
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
return 'Error: subscription not found'
subscription.end_date = subscription.end_date + timedelta(days=days)
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
logger.info(f'🎁 Extended subscription for user {user_id} by {days} days (contest prize)')
return f'Subscription extended by {days} days'
if prize_type == 'balance':
from app.database.crud.user import get_user_by_id
try:
amount = float(prize_value)
except ValueError:
return 'Error: invalid prize value'
user = await get_user_by_id(db, user_id)
if not user:
return 'Error: user not found'
user.balance += amount
await db.commit()
await db.refresh(user)
logger.info(f'🎁 Added {amount} to balance for user {user_id} (contest prize)')
return f'Balance increased by {amount}'
logger.warning(f'Unknown prize type: {prize_type}')
return f"Prize type '{prize_type}' not supported"
# ============ Routes ============
class ContestsCountResponse(BaseModel):
"""Count of available contests."""
count: int
@router.get('/count', response_model=ContestsCountResponse)
async def get_contests_count(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get count of contests available for the user."""
subscription = await get_subscription_by_user_id(db, user.id)
if not _user_allowed(subscription):
return ContestsCountResponse(count=0)
active_rounds = await get_active_rounds(db)
# Count unique available contests (not yet played)
count = 0
seen_templates = set()
for rnd in active_rounds:
if not rnd.template or not rnd.template.is_enabled:
continue
tpl_slug = rnd.template.slug if rnd.template else ''
if tpl_slug in seen_templates:
continue
seen_templates.add(tpl_slug)
# Check if user already played this round
attempt = await get_attempt(db, rnd.id, user.id)
if not attempt:
count += 1
return ContestsCountResponse(count=count)
@router.get('', response_model=list[ContestInfo])
async def get_contests(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of available contests/games."""
subscription = await get_subscription_by_user_id(db, user.id)
if not _user_allowed(subscription):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Contests are only available for users with active or trial subscriptions',
)
active_rounds = await get_active_rounds(db)
# Group by template to avoid duplicates
unique_templates = {}
for rnd in active_rounds:
if not rnd.template or not rnd.template.is_enabled:
continue
tpl_slug = rnd.template.slug if rnd.template else ''
if tpl_slug not in unique_templates:
unique_templates[tpl_slug] = rnd
contests = []
for tpl_slug, rnd in unique_templates.items():
# Check if user already played this round
attempt = await get_attempt(db, rnd.id, user.id)
contests.append(
ContestInfo(
id=rnd.id,
slug=tpl_slug,
name=rnd.template.name if rnd.template else tpl_slug,
description=rnd.template.description if rnd.template else None,
prize_type=rnd.template.prize_type if rnd.template else 'days',
prize_value=rnd.template.prize_value if rnd.template else '1',
is_available=True,
already_played=attempt is not None,
)
)
return contests
@router.get('/{round_id}', response_model=ContestGameData)
async def get_contest_game(
round_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get game data for a specific contest round."""
subscription = await get_subscription_by_user_id(db, user.id)
if not _user_allowed(subscription):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Contests are only available for users with active or trial subscriptions',
)
active_rounds = await get_active_rounds(db)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Contest round not found or already finished',
)
if not round_obj.template or not round_obj.template.is_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This contest is disabled',
)
# Check if already played
attempt = await get_attempt(db, round_id, user.id)
if attempt:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='You have already played this round',
)
tpl = round_obj.template
game_type = tpl.slug
game_data = {}
instructions = ''
if game_type == GAME_QUEST:
rows = round_obj.payload.get('rows', 3)
cols = round_obj.payload.get('cols', 3)
secret = random.randint(0, rows * cols - 1)
game_data = {
'rows': rows,
'cols': cols,
'secret': secret,
'grid_size': rows * cols,
}
instructions = 'Select one of the nodes in the grid. Find the hidden server!'
elif game_type == GAME_LOCKS:
total = round_obj.payload.get('total', 20)
secret = random.randint(0, total - 1)
game_data = {
'total': total,
'secret': secret,
}
instructions = 'Find the unlocked button among the locks!'
elif game_type == GAME_SERVER:
flags = round_obj.payload.get('flags') or []
shuffled_flags = flags.copy()
random.shuffle(shuffled_flags)
game_data = {
'flags': shuffled_flags,
}
instructions = 'Choose a server by clicking on a flag!'
elif game_type == GAME_CIPHER:
question = round_obj.payload.get('question', '')
game_data = {
'question': question,
'input_type': 'text',
}
instructions = 'Decrypt the cipher and enter the answer!'
elif game_type == GAME_EMOJI:
question = round_obj.payload.get('question', '🤔')
emoji_list = question.split()
random.shuffle(emoji_list)
game_data = {
'question': ' '.join(emoji_list),
'input_type': 'text',
}
instructions = 'Guess the service by emojis!'
elif game_type == GAME_ANAGRAM:
letters = round_obj.payload.get('letters', '')
game_data = {
'letters': letters,
'input_type': 'text',
}
instructions = 'Make a word from the given letters!'
elif game_type == GAME_BLITZ:
game_data = {
'button_text': "I'm here!",
}
instructions = 'Click the button as fast as you can!'
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Unknown contest type',
)
return ContestGameData(
round_id=round_id,
game_type=game_type,
game_data=game_data,
instructions=instructions,
)
@router.post('/{round_id}/answer', response_model=ContestResult)
async def submit_contest_answer(
round_id: int,
request: ContestAnswerRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Submit answer for a contest round."""
subscription = await get_subscription_by_user_id(db, user.id)
if not _user_allowed(subscription):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Contests are only available for users with active or trial subscriptions',
)
active_rounds = await get_active_rounds(db)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Contest round not found or already finished',
)
# Check if already played
attempt = await get_attempt(db, round_id, user.id)
if attempt:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='You have already played this round',
)
tpl = round_obj.template
answer = request.answer
is_winner = False
# Determine if winner based on game type
if tpl.slug == GAME_SERVER:
flags = round_obj.payload.get('flags') or []
secret_idx = round_obj.payload.get('secret_idx')
correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ''
is_winner = answer == correct_flag
elif tpl.slug in {GAME_QUEST, GAME_LOCKS}:
try:
parts = answer.split('_')
if len(parts) >= 2:
idx = int(parts[0])
secret = int(parts[1])
is_winner = idx == secret
except (ValueError, IndexError):
is_winner = False
elif tpl.slug == GAME_BLITZ:
is_winner = answer.lower() == 'blitz'
elif tpl.slug in {GAME_CIPHER, GAME_EMOJI, GAME_ANAGRAM}:
correct = (round_obj.payload.get('answer') or '').upper()
is_winner = correct and answer.upper() == correct
# Record attempt
await create_attempt(db, round_id=round_obj.id, user_id=user.id, answer=str(answer), is_winner=is_winner)
if is_winner:
await increment_winner_count(db, round_obj)
prize_text = await _award_prize(db, user.id, tpl.prize_type, tpl.prize_value)
return ContestResult(
is_winner=True,
message=f'🎉 Congratulations! You won! {prize_text}',
prize_type=tpl.prize_type,
prize_value=tpl.prize_value,
)
lose_messages = {
GAME_QUEST: ['Empty node', 'Wrong server', 'Try another'],
GAME_LOCKS: ['Locked', 'No access', 'Try again'],
GAME_SERVER: ['Server overloaded', 'No response', 'Try tomorrow'],
}
messages = lose_messages.get(tpl.slug, ['Incorrect', 'Try again next round'])
return ContestResult(
is_winner=False,
message=random.choice(messages),
)