This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
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
|
||||
Reference in New Issue
Block a user