import random import uuid from datetime import datetime from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession 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 async def execute_draw(db: AsyncSession, lottery_id: uuid.UUID, trigger: str = "manual") -> list[dict]: """ Draw winners by randomly pairing shuffled participants with shuffled prizes. Both lists are shuffled independently so prize order has no bearing on results. n_winners = min(participants, total prize slots). Uses SELECT FOR UPDATE to prevent concurrent draws from producing duplicate winner sets. The status is transitioned to DRAWING inside the lock before any reads, so a second concurrent request will fail the status check. """ # Lock the lottery row - serialises all concurrent draw requests lottery_result = await db.execute( select(Lottery).where(Lottery.id == lottery_id).with_for_update() ) lottery = lottery_result.scalar_one() # Re-check status under lock; may have changed since the router pre-checked if lottery.status != LotteryStatus.ACTIVE: raise ValueError("Lottery is no longer active") # Mark as DRAWING immediately so any concurrent request sees the changed # status and aborts before we even touch participants/prizes lottery.status = LotteryStatus.DRAWING await db.flush() # Load participants with user info result = await db.execute( select(Participant, User) .join(User, Participant.user_id == User.id) .where(Participant.lottery_id == lottery_id) ) rows = result.all() if not rows: # Roll back the DRAWING status so the lottery stays usable lottery.status = LotteryStatus.ACTIVE await db.commit() raise ValueError("No participants in this lottery") # Load prizes prize_result = await db.execute( select(Prize).where(Prize.lottery_id == lottery_id) ) prizes = prize_result.scalars().all() if not prizes: lottery.status = LotteryStatus.ACTIVE await db.commit() raise ValueError("No prizes configured") # Expand prizes by quantity: Prize(qty=3) → [Prize, Prize, Prize] expanded_prizes: list[Prize] = [] for prize in prizes: expanded_prizes.extend([prize] * prize.quantity) random.shuffle(expanded_prizes) now = datetime.utcnow() winners_data = [] if lottery.allow_multiple_wins: # Draw with replacement: all prize slots awarded, same participant can win multiple for prize in expanded_prizes: participant, user = random.choice(rows) db.add(Winner( lottery_id=lottery_id, user_id=user.id, prize_id=prize.id, prize_name=prize.name, drawn_at=now, )) winners_data.append({ "user_id": user.id, "username": user.username, "first_name": user.first_name, "last_name": user.last_name, "prize_name": prize.name, "drawn_at": now, }) else: # No repeat wins: shuffle participants and pair 1-to-1 with prizes random.shuffle(rows) n_winners = min(len(rows), len(expanded_prizes)) for i in range(n_winners): participant, user = rows[i] prize = expanded_prizes[i] db.add(Winner( lottery_id=lottery_id, user_id=user.id, prize_id=prize.id, prize_name=prize.name, drawn_at=now, )) winners_data.append({ "user_id": user.id, "username": user.username, "first_name": user.first_name, "last_name": user.last_name, "prize_name": prize.name, "drawn_at": now, }) lottery.status = LotteryStatus.FINISHED lottery.drawn_at = now lottery.draw_trigger = trigger await db.commit() return winners_data