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