Files
TGLotteryBot/backend/app/api/routers/lotteries.py
T
Stardream eace48732d
Build and Push Docker Images / build (push) Successful in 17s
Initial release
2026-06-02 12:02:02 +10:00

610 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import html
import uuid
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.api.schemas import LotteryCreate, LotteryResponse, LotteryUpdate, PrizeResponse, WinnerResponse
from app.core.database import get_db
from app.models.lottery import Lottery, LotteryStatus
from app.models.participant import Participant
from app.models.prize import Prize
from app.models.user import User
from app.models.winner import Winner
from app.services.draw_service import execute_draw
router = APIRouter(prefix="/lotteries", tags=["lotteries"])
async def _revoke_invite_links(db: AsyncSession, chat_ids: set[int]) -> None:
from app.bot.router import bot
from app.models.managed_chat import ManagedChat
for chat_id in chat_ids:
result = await db.execute(select(ManagedChat).where(ManagedChat.id == chat_id))
managed = result.scalar_one_or_none()
if managed and managed.invite_link:
try:
await bot.revoke_chat_invite_link(chat_id, managed.invite_link)
except Exception:
pass
managed.invite_link = None
await db.commit()
async def _build_lottery_response(
db: AsyncSession, lottery: Lottery, current_user_id: int | None = None
) -> LotteryResponse:
count_result = await db.execute(
select(func.count(Participant.id)).where(Participant.lottery_id == lottery.id)
)
participant_count = count_result.scalar() or 0
prize_result = await db.execute(
select(Prize).where(Prize.lottery_id == lottery.id).order_by(Prize.sort_order)
)
prizes = prize_result.scalars().all()
total_prizes = sum(p.quantity for p in prizes)
is_winner: bool | None = None
if current_user_id is not None and lottery.status == LotteryStatus.FINISHED:
w = await db.execute(
select(Winner).where(Winner.lottery_id == lottery.id, Winner.user_id == current_user_id).limit(1)
)
is_winner = w.scalar_one_or_none() is not None
return LotteryResponse(
id=lottery.id,
creator_id=lottery.creator_id,
title=lottery.title,
description=lottery.description,
contact_info=lottery.contact_info,
status=lottery.status,
target_chat_id=lottery.target_chat_id,
target_chat_title=lottery.target_chat_title,
require_membership=lottery.require_membership,
required_chat_ids=lottery.required_chat_ids or [],
required_chat_titles=lottery.required_chat_titles or [],
required_chat_types=lottery.required_chat_types or [],
required_chat_usernames=lottery.required_chat_usernames or [],
announcement_targets=lottery.announcement_targets or [],
allow_multiple_wins=lottery.allow_multiple_wins,
invite_link_chat_ids=lottery.invite_link_chat_ids or [],
required_chat_passphrases=lottery.required_chat_passphrases or [],
max_participants=lottery.max_participants,
draw_at=lottery.draw_at,
auto_draw_count=lottery.auto_draw_count,
creator_timezone=lottery.creator_timezone,
participant_count=participant_count,
prize_count=len(prizes),
total_prizes=total_prizes,
created_at=lottery.created_at,
drawn_at=lottery.drawn_at,
draw_trigger=lottery.draw_trigger,
prizes=[PrizeResponse.model_validate(p) for p in prizes],
is_winner=is_winner,
)
@router.get("", response_model=list[LotteryResponse])
async def list_my_lotteries(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[LotteryResponse]:
result = await db.execute(
select(Lottery)
.where(Lottery.creator_id == current_user.id)
.order_by(Lottery.created_at.desc())
)
lotteries = result.scalars().all()
return [await _build_lottery_response(db, l, current_user.id) for l in lotteries]
@router.get("/joined", response_model=list[LotteryResponse])
async def list_joined_lotteries(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[LotteryResponse]:
result = await db.execute(
select(Lottery)
.join(Participant, Participant.lottery_id == Lottery.id)
.where(Participant.user_id == current_user.id)
.order_by(Lottery.created_at.desc())
)
lotteries = result.scalars().all()
return [await _build_lottery_response(db, l, current_user.id) for l in lotteries]
@router.get("/active", response_model=list[LotteryResponse])
async def list_active_lotteries(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[LotteryResponse]:
"""Active lotteries created by others that the user can join."""
result = await db.execute(
select(Lottery)
.where(Lottery.status == LotteryStatus.ACTIVE)
.where(Lottery.creator_id != current_user.id)
.order_by(Lottery.created_at.desc())
)
lotteries = result.scalars().all()
return [await _build_lottery_response(db, l) for l in lotteries]
@router.post("", response_model=LotteryResponse)
async def create_lottery(
data: LotteryCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> LotteryResponse:
lottery = Lottery(creator_id=current_user.id, **data.model_dump())
db.add(lottery)
await db.commit()
await db.refresh(lottery)
return await _build_lottery_response(db, lottery)
@router.get("/{lottery_id}", response_model=LotteryResponse)
async def get_lottery(
lottery_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> LotteryResponse:
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
lottery = result.scalar_one_or_none()
if lottery is None:
raise HTTPException(404, "Lottery not found")
return await _build_lottery_response(db, lottery)
@router.put("/{lottery_id}", response_model=LotteryResponse)
async def update_lottery(
lottery_id: uuid.UUID,
data: LotteryUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> LotteryResponse:
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
lottery = result.scalar_one_or_none()
if lottery is None:
raise HTTPException(404, "Lottery not found")
if lottery.creator_id != current_user.id:
raise HTTPException(403, "Not authorized")
if lottery.status in (LotteryStatus.FINISHED, LotteryStatus.CANCELLED):
raise HTTPException(400, "Cannot edit a finished or cancelled lottery")
old_invite_ids: set[int] = set(lottery.invite_link_chat_ids or [])
for field, value in data.model_dump(exclude_unset=True).items():
setattr(lottery, field, value)
await db.commit()
await db.refresh(lottery)
new_invite_ids: set[int] = set(lottery.invite_link_chat_ids or [])
removed_ids = old_invite_ids - new_invite_ids
if removed_ids:
await _revoke_invite_links(db, removed_ids)
# Auto-sync announcement messages whenever the lottery is edited
if lottery.announcement_targets or lottery.announcement_message_id:
try:
await sync_announcement(db, lottery, force_send=False)
except Exception:
pass
return await _build_lottery_response(db, lottery)
@router.post("/{lottery_id}/activate", response_model=LotteryResponse)
async def activate_lottery(
lottery_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> LotteryResponse:
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
lottery = result.scalar_one_or_none()
if lottery is None:
raise HTTPException(404, "Lottery not found")
if lottery.creator_id != current_user.id:
raise HTTPException(403, "Not authorized")
if lottery.status != LotteryStatus.DRAFT:
raise HTTPException(400, "Lottery is already activated")
prize_result = await db.execute(
select(Prize).where(Prize.lottery_id == lottery_id).limit(1)
)
if prize_result.scalar_one_or_none() is None:
raise HTTPException(400, "Add at least one prize before activating")
lottery.status = LotteryStatus.ACTIVE
await db.commit()
await db.refresh(lottery)
return await _build_lottery_response(db, lottery)
@router.post("/{lottery_id}/draw", response_model=list[WinnerResponse])
async def draw_lottery(
lottery_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[WinnerResponse]:
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
lottery = result.scalar_one_or_none()
if lottery is None:
raise HTTPException(404, "Lottery not found")
if lottery.creator_id != current_user.id:
raise HTTPException(403, "Not authorized")
if lottery.status != LotteryStatus.ACTIVE:
raise HTTPException(400, "Lottery is not active")
try:
winners_data = await execute_draw(db, lottery_id, trigger="manual")
except ValueError as e:
raise HTTPException(400, str(e))
# Announce winners to all announcement targets
targets = lottery.announcement_targets or (
[{"chat_id": lottery.target_chat_id, "chat_title": lottery.target_chat_title or "", "message_id": lottery.announcement_message_id}]
if lottery.target_chat_id else []
)
if targets and winners_data:
from app.bot.router import bot
from aiogram.types import ReplyParameters
lines = []
for w in winners_data:
full_name = html.escape(
w["first_name"] + (f" {w['last_name']}" if w.get("last_name") else "")
)
safe_name = f'<a href="tg://user?id={w["user_id"]}">{full_name}</a>'
safe_prize = html.escape(w["prize_name"])
lines.append(f"🏆 {safe_name} - {safe_prize}")
text = (
f"🎉 <b>Draw Results: {html.escape(lottery.title)}</b>\n\n"
+ "\n".join(lines)
+ "\n\nCongratulations to all winners! 🎊"
)
for t in targets:
try:
reply = (
ReplyParameters(message_id=t["message_id"], allow_sending_without_reply=True)
if t.get("message_id")
else None
)
await bot.send_message(
t["chat_id"],
text,
parse_mode="HTML",
reply_parameters=reply,
)
except Exception:
pass
if winners_data:
from app.services.scheduler import notify_winners
import asyncio
asyncio.create_task(notify_winners(lottery.title, winners_data, lottery.contact_info, lottery_id=lottery.id))
return [WinnerResponse(**w) for w in winners_data]
@router.get("/{lottery_id}/winners", response_model=list[WinnerResponse])
async def get_winners(
lottery_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[WinnerResponse]:
result = await db.execute(
select(Winner, User)
.join(User, Winner.user_id == User.id)
.where(Winner.lottery_id == lottery_id)
.order_by(Winner.drawn_at)
)
return [
WinnerResponse(
user_id=user.id,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
prize_name=winner.prize_name,
drawn_at=winner.drawn_at,
)
for winner, user in result.all()
]
async def _build_announce_text(db: AsyncSession, lottery: Lottery, include_count: bool = True) -> str:
prize_result = await db.execute(
select(Prize).where(Prize.lottery_id == lottery.id).order_by(Prize.sort_order)
)
prizes = prize_result.scalars().all()
count_result = await db.execute(
select(func.count(Participant.id)).where(Participant.lottery_id == lottery.id)
)
participant_count = count_result.scalar() or 0
text = f"🎰 <b>{html.escape(lottery.title)}</b>"
if lottery.description:
text += f"\n\n{html.escape(lottery.description)}"
# Membership requirements
required_ids = lottery.required_chat_ids or []
required_titles = lottery.required_chat_titles or []
required_usernames = lottery.required_chat_usernames or []
required_passphrases = lottery.required_chat_passphrases or []
invite_link_ids = set(lottery.invite_link_chat_ids or [])
if lottery.require_membership and required_ids:
from app.models.managed_chat import ManagedChat
mc_result = await db.execute(select(ManagedChat).where(ManagedChat.id.in_(required_ids)))
managed_chats = {mc.id: mc for mc in mc_result.scalars().all()}
text += "\n\n📮 <b>Requirements:</b>"
for i, chat_id in enumerate(required_ids):
title = required_titles[i] if i < len(required_titles) else str(chat_id)
username = required_usernames[i] if i < len(required_usernames) else None
mc = managed_chats.get(chat_id)
if chat_id in invite_link_ids and mc and mc.invite_link:
link: str | None = mc.invite_link
elif username:
link = f"https://t.me/{username}"
elif mc:
channel_id = str(abs(chat_id))[3:]
link = f"https://t.me/c/{channel_id}/{mc.last_message_id or 1}"
else:
link = None
label = f'<a href="{link}">{html.escape(title)}</a>' if link else html.escape(title)
text += f"\n 🎫 Join - {label}"
if i < len(required_passphrases) and required_passphrases[i]:
text += f"\n └ 🔑 Send passphrase ﹝<code>{html.escape(required_passphrases[i])}</code>﹞"
elif lottery.require_membership and lottery.target_chat_title:
text += f"\n\n📮 <b>Requirements:</b>\n 🎫 Join - {html.escape(lottery.target_chat_title)}"
# Prizes
prize_lines = "\n".join(f" - {html.escape(p.name)} ×{p.quantity}" for p in prizes)
text += f"\n\n🎁 <b>Prizes:</b>\n{prize_lines}"
# Participant count
if include_count:
text += f"\n\n👥 Participants: {participant_count}"
# Draw conditions
if lottery.draw_at:
from datetime import timezone as _tz
draw_dt = lottery.draw_at.replace(tzinfo=_tz.utc)
tz_label = "UTC"
if lottery.creator_timezone:
try:
from zoneinfo import ZoneInfo
draw_dt = draw_dt.astimezone(ZoneInfo(lottery.creator_timezone))
offset = draw_dt.utcoffset()
if offset is not None:
total_min = int(offset.total_seconds() / 60)
sign = "+" if total_min >= 0 else "-"
h, m = divmod(abs(total_min), 60)
tz_label = f"UTC{sign}{h}" if m == 0 else f"UTC{sign}{h}:{m:02d}"
except Exception:
pass
text += f"\n\n📅 <b>Draw time:</b> {draw_dt.strftime('%Y/%m/%d %H:%M')} ({tz_label})"
if lottery.auto_draw_count:
text += f"\n🎯 Auto-draw at {lottery.auto_draw_count} participants"
return text
async def _sync_single_target(
bot,
text: str,
keyboard,
target: dict,
force_send: bool,
) -> int | None:
"""Sync announcement to one chat. Returns the resulting message_id (or None)."""
from aiogram.exceptions import TelegramBadRequest
chat_id = target["chat_id"]
msg_id = target.get("message_id")
if msg_id:
try:
await bot.edit_message_text(
chat_id=chat_id,
message_id=msg_id,
text=text,
reply_markup=keyboard,
parse_mode="HTML",
)
return msg_id
except TelegramBadRequest as e:
if "message is not modified" in str(e).lower():
return msg_id
# Message deleted or inaccessible
if not force_send:
return msg_id # keep existing id, silently skip
msg_id = None # will resend below
if force_send:
msg = await bot.send_message(
chat_id,
text,
reply_markup=keyboard,
parse_mode="HTML",
)
await _update_chat_last_message(chat_id, msg.message_id)
return msg.message_id
return None
async def _update_chat_last_message(chat_id: int, message_id: int) -> None:
from app.core.database import AsyncSessionLocal
from app.models.managed_chat import ManagedChat
from sqlalchemy import select
async with AsyncSessionLocal() as db:
result = await db.execute(select(ManagedChat).where(ManagedChat.id == chat_id))
managed = result.scalar_one_or_none()
if managed and message_id > (managed.last_message_id or 0):
managed.last_message_id = message_id
await db.commit()
async def sync_announcement(db: AsyncSession, lottery: Lottery, force_send: bool = False) -> None:
"""Edit or send announcement messages to all announcement_targets."""
from app.bot.router import bot
from app.bot.keyboards.inline import lottery_announce_keyboard
# Build target list - backward compat with old single-target lotteries
targets: list[dict] = lottery.announcement_targets or []
if not targets and lottery.target_chat_id:
targets = [{
"chat_id": lottery.target_chat_id,
"chat_title": lottery.target_chat_title or "",
"message_id": lottery.announcement_message_id,
}]
if not targets:
return
text = await _build_announce_text(db, lottery)
keyboard = lottery_announce_keyboard(str(lottery.id))
updated: list[dict] = []
changed = False
for t in targets:
new_msg_id = await _sync_single_target(bot, text, keyboard, t, force_send)
updated.append({**t, "message_id": new_msg_id})
if new_msg_id != t.get("message_id"):
changed = True
if changed:
lottery.announcement_targets = updated
await db.commit()
class _AnnounceChatItem(BaseModel):
id: int
title: str
class _AnnounceRequest(BaseModel):
chats: list[_AnnounceChatItem]
@router.post("/{lottery_id}/announce")
async def announce_lottery(
lottery_id: uuid.UUID,
data: _AnnounceRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
from aiogram.exceptions import TelegramAPIError
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
lottery = result.scalar_one_or_none()
if lottery is None:
raise HTTPException(404, "Lottery not found")
if lottery.creator_id != current_user.id:
raise HTTPException(403, "Not authorized")
if not data.chats:
raise HTTPException(400, "Select at least one chat")
if lottery.status == LotteryStatus.CANCELLED:
raise HTTPException(400, "Cannot announce a cancelled lottery")
# Build new targets, preserving existing message_ids for same chats
existing = {t["chat_id"]: t for t in (lottery.announcement_targets or [])}
new_targets = [
{
"chat_id": c.id,
"chat_title": c.title,
"message_id": existing.get(c.id, {}).get("message_id"),
}
for c in data.chats
]
lottery.announcement_targets = new_targets
# Keep legacy target_chat_id pointing to first chat for draw-result compat
lottery.target_chat_id = data.chats[0].id
lottery.target_chat_title = data.chats[0].title
await db.commit()
await db.refresh(lottery)
try:
await sync_announcement(db, lottery, force_send=True)
except TelegramAPIError as e:
raise HTTPException(500, f"Failed to send message: {e}")
return {"status": "announced"}
@router.post("/{lottery_id}/prepare-share")
async def prepare_share(
lottery_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
lottery = result.scalar_one_or_none()
if lottery is None:
raise HTTPException(404, "Lottery not found")
if lottery.status == LotteryStatus.CANCELLED:
raise HTTPException(400, "Cannot share a cancelled lottery")
from app.bot.router import bot
from app.bot.keyboards.inline import lottery_announce_keyboard
from aiogram.types import InlineQueryResultArticle, InputTextMessageContent
text = await _build_announce_text(db, lottery, include_count=False)
keyboard = lottery_announce_keyboard(str(lottery.id))
try:
prepared = await bot.save_prepared_inline_message(
user_id=current_user.id,
result=InlineQueryResultArticle(
id=str(lottery.id),
title=lottery.title,
input_message_content=InputTextMessageContent(
message_text=text,
parse_mode="HTML",
),
reply_markup=keyboard,
),
allow_user_chats=True,
allow_bot_chats=False,
allow_group_chats=True,
allow_channel_chats=True,
)
except Exception as e:
raise HTTPException(500, str(e))
return {"prepared_message_id": prepared.id}
@router.delete("/{lottery_id}")
async def cancel_lottery(
lottery_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
lottery = result.scalar_one_or_none()
if lottery is None:
raise HTTPException(404, "Lottery not found")
if lottery.creator_id != current_user.id:
raise HTTPException(403, "Not authorized")
if lottery.status == LotteryStatus.FINISHED:
raise HTTPException(400, "Cannot cancel a finished lottery")
lottery.status = LotteryStatus.CANCELLED
await db.commit()
return {"status": "cancelled"}