Initial release
Build and Push Docker Images / build (push) Successful in 17s

This commit is contained in:
Stardream
2026-06-02 10:58:24 +10:00
commit eace48732d
88 changed files with 11140 additions and 0 deletions
+609
View File
@@ -0,0 +1,609 @@
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"}