Files
TGLotteryBot/backend/app/services/draw_service.py
T
Stardream eace48732d
Build and Push Docker Images / build (push) Successful in 17s
Initial release
2026-06-02 12:02:02 +10:00

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