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'{full_name}' safe_prize = html.escape(w["prize_name"]) lines.append(f"šŸ† {safe_name} - {safe_prize}") text = ( f"šŸŽ‰ Draw Results: {html.escape(lottery.title)}\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"šŸŽ° {html.escape(lottery.title)}" 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šŸ“® Requirements:" 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'{html.escape(title)}' 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 ļ¹{html.escape(required_passphrases[i])}ļ¹ž" elif lottery.require_membership and lottery.target_chat_title: text += f"\n\nšŸ“® Requirements:\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šŸŽ Prizes:\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šŸ“… Draw time: {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"}