196 lines
6.6 KiB
Python
196 lines
6.6 KiB
Python
from typing import List
|
|
from ninja import Router
|
|
from ninja.errors import HttpError
|
|
from django.shortcuts import get_object_or_404
|
|
from django.db.models import Sum, Prefetch
|
|
from django.db.models.functions import Coalesce
|
|
from django.db import transaction
|
|
|
|
from market.models import (
|
|
Market,
|
|
MarketOption,
|
|
UserBet,
|
|
UserPointChange,
|
|
get_event_points,
|
|
)
|
|
from market.schemas import (
|
|
MarketListSchema,
|
|
ResolveMarketSchema,
|
|
UserBetCreateSchema,
|
|
UserBetSchema,
|
|
UserPointsSchema,
|
|
)
|
|
|
|
|
|
router = Router(tags=["market"])
|
|
|
|
PREFIX = "/events/{event_path}/market"
|
|
|
|
|
|
@router.get(f"{PREFIX}/", response=List[MarketListSchema])
|
|
def list_markets(request, event_path: str):
|
|
"""List all markets (excludes draft markets)."""
|
|
markets = Market.for_event.exclude(status=Market.Status.DRAFT)
|
|
# Prefetch options with total_bets annotation sorted by total_bets desc, then text asc
|
|
options_queryset = MarketOption.objects.annotate(
|
|
total_bets=Coalesce(Sum("user_bets__amount"), 0)
|
|
).order_by("-total_bets", "text")
|
|
|
|
return markets.prefetch_related(Prefetch("options", queryset=options_queryset))
|
|
|
|
|
|
@router.get(f"{PREFIX}/user/bets", response=List[UserBetSchema])
|
|
def list_user_bets(request, event_path: str):
|
|
"""List all bets placed by the current user."""
|
|
if not request.user.is_authenticated:
|
|
raise HttpError(401, "Authentication required")
|
|
|
|
return (
|
|
UserBet.objects.filter(user=request.user, option__market__event=request.event)
|
|
.select_related("option__market")
|
|
.prefetch_related("option__market__options")
|
|
)
|
|
|
|
|
|
@router.get(f"{PREFIX}/user/points", response=UserPointsSchema)
|
|
def get_user_points(request, event_path: str):
|
|
"""Get the current user's points balance for this event."""
|
|
if not request.user.is_authenticated:
|
|
raise HttpError(401, "Authentication required")
|
|
|
|
return {"points": get_event_points(request.user, request.event)}
|
|
|
|
|
|
@router.post(f"{PREFIX}/{{market_uuid}}/actions/close")
|
|
def close_market(request, event_path: str, market_uuid: str):
|
|
"""Close a market. Admin only."""
|
|
if not request.user.is_staff:
|
|
raise HttpError(403, "Permission denied")
|
|
|
|
market = get_object_or_404(Market, uuid=market_uuid, event=request.event)
|
|
market.status = Market.Status.CLOSED
|
|
market.save(update_fields=["status", "updated_at"])
|
|
return {"status": Market.Status.CLOSED}
|
|
|
|
|
|
@router.post(f"{PREFIX}/{{market_uuid}}/actions/resolve", response=MarketListSchema)
|
|
def resolve_market(
|
|
request, event_path: str, market_uuid: str, payload: ResolveMarketSchema
|
|
):
|
|
"""Resolve a market with a winning option. Admin only."""
|
|
if not request.user.is_staff:
|
|
raise HttpError(403, "Permission denied")
|
|
|
|
market = get_object_or_404(Market, uuid=market_uuid, event=request.event)
|
|
winning_option = get_object_or_404(MarketOption, uuid=payload.winning_option_uuid)
|
|
|
|
if winning_option.market_id != market.id:
|
|
raise HttpError(400, "Option does not belong to this market")
|
|
|
|
market.winning_option = winning_option
|
|
market.status = Market.Status.RESOLVED
|
|
market.save(update_fields=["winning_option", "status", "updated_at"])
|
|
|
|
# Calculate and distribute winnings
|
|
all_bets = list(UserBet.objects.filter(option__market=market))
|
|
|
|
# Calculate total pot
|
|
total_pot = sum(bet.amount for bet in all_bets)
|
|
if total_pot == 0:
|
|
return market
|
|
|
|
# Separate winning and losing bets
|
|
winning_bets = [bet for bet in all_bets if bet.option_id == winning_option.id]
|
|
losing_bets = [bet for bet in all_bets if bet.option_id != winning_option.id]
|
|
|
|
total_winning = sum(bet.amount for bet in winning_bets)
|
|
|
|
point_changes = []
|
|
|
|
with transaction.atomic():
|
|
# Award payouts to winners with multiplier
|
|
if total_winning > 0:
|
|
for bet in winning_bets:
|
|
payout = round(
|
|
bet.amount / total_winning * total_pot * market.multiplier
|
|
)
|
|
point_changes.append(
|
|
UserPointChange(
|
|
user_id=bet.user_id,
|
|
market=market,
|
|
amount=payout,
|
|
reason=UserPointChange.Reason.BET_WON,
|
|
)
|
|
)
|
|
|
|
# Record losing bets (points already deducted)
|
|
for bet in losing_bets:
|
|
point_changes.append(
|
|
UserPointChange(
|
|
user_id=bet.user_id,
|
|
market=market,
|
|
amount=-bet.amount,
|
|
reason=UserPointChange.Reason.BET_LOST,
|
|
)
|
|
)
|
|
|
|
# Bulk create point changes
|
|
UserPointChange.objects.bulk_create(point_changes)
|
|
|
|
return market
|
|
|
|
|
|
@router.post(f"{PREFIX}/{{market_uuid}}/bets", response=UserBetSchema)
|
|
def create_bet(
|
|
request, event_path: str, market_uuid: str, payload: UserBetCreateSchema
|
|
):
|
|
"""Place a bet on a market option."""
|
|
if not request.user.is_authenticated:
|
|
raise HttpError(401, "Authentication required")
|
|
|
|
market = get_object_or_404(Market, uuid=market_uuid, event=request.event)
|
|
option = get_object_or_404(MarketOption, uuid=payload.option_uuid)
|
|
|
|
if option.market_id != market.id:
|
|
raise HttpError(400, "Option does not belong to this market")
|
|
|
|
if market.status != Market.Status.OPEN:
|
|
raise HttpError(400, "Market is not open for betting")
|
|
|
|
# Check if user already has a bet on a different option in this market
|
|
existing_bet_on_market = (
|
|
UserBet.objects.filter(user=request.user, option__market=market)
|
|
.exclude(option=option)
|
|
.first()
|
|
)
|
|
if existing_bet_on_market:
|
|
raise HttpError(400, "You can only bet on one option per market")
|
|
|
|
# Check if user already has a bet on this option
|
|
existing_bet = UserBet.objects.filter(user=request.user, option=option).first()
|
|
if existing_bet and payload.amount < existing_bet.amount:
|
|
raise HttpError(400, "Cannot decrease bet amount. You can only increase it.")
|
|
|
|
# Calculate delta (amount to deduct from user's points)
|
|
delta = payload.amount - (existing_bet.amount if existing_bet else 0)
|
|
|
|
# Check if user has enough points for this event
|
|
if get_event_points(request.user, request.event) < delta:
|
|
raise HttpError(400, "Insufficient points for this bet")
|
|
|
|
user_bet, created = UserBet.objects.update_or_create(
|
|
user=request.user,
|
|
option=option,
|
|
defaults={"amount": payload.amount},
|
|
)
|
|
|
|
# Record the change (the event balance is derived from these)
|
|
UserPointChange.objects.create(
|
|
user=request.user,
|
|
market=market,
|
|
amount=-delta,
|
|
reason=UserPointChange.Reason.BET_PLACED,
|
|
)
|
|
|
|
return user_bet
|