122 lines
4.2 KiB
Python
122 lines
4.2 KiB
Python
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
|