Add user state updates and bidding error handling in draft consumers
- Implement user state tracking and broadcasting on connect/disconnect and phase changes - Add bid start and place rejection handling with error messages to frontend and backend - Enhance movie serializer with TMDB integration and update relevant frontend components
This commit is contained in:
@@ -11,6 +11,7 @@ class DraftMessage(StrEnum):
|
||||
USER_JOIN_INFORM = "user.join.inform" # server -> client
|
||||
USER_LEAVE_INFORM = "user.leave.inform"
|
||||
USER_IDENTIFICATION_INFORM = "user.identification.inform" # server -> client (tells socket "you are X", e.g. after connect) # server -> client
|
||||
USER_STATE_INFORM = "user.state.inform"
|
||||
|
||||
# Phase control
|
||||
PHASE_CHANGE_INFORM = "phase.change.inform" # server -> client (target phase payload)
|
||||
@@ -31,8 +32,10 @@ class DraftMessage(StrEnum):
|
||||
# Bidding (examples, adjust to your flow)
|
||||
BID_START_INFORM = "bid.start.inform" # server -> client (movie, ends_at)
|
||||
BID_START_REQUEST = "bid.start.request" # server -> client (movie, ends_at)
|
||||
BID_START_REJECT = "bid.start.reject" # server -> client (movie, ends_at)
|
||||
BID_PLACE_REQUEST = "bid.place.request" # client -> server (amount)
|
||||
BID_PLACE_CONFIRM = "bid.update.confirm" # server -> client (high bid)
|
||||
BID_PLACE_REJECT = "bid.place.reject" # server -> client (high bid)
|
||||
BID_PLACE_CONFIRM = "bid.place.confirm" # server -> client (high bid)
|
||||
BID_UPDATE_INFORM = "bid.update.inform" # server -> client (high bid)
|
||||
BID_END_INFORM = "bid.end.inform" # server -> client (winner)
|
||||
|
||||
|
||||
@@ -4,23 +4,19 @@ from django.core.exceptions import PermissionDenied
|
||||
from boxofficefantasy.models import League, Season
|
||||
from boxofficefantasy.views import parse_season_slug
|
||||
from draft.models import DraftSession, DraftSessionParticipant
|
||||
import asyncio
|
||||
from django.contrib.auth.models import User
|
||||
from draft.constants import (
|
||||
DraftMessage,
|
||||
DraftPhase,
|
||||
DraftGroupChannelNames,
|
||||
)
|
||||
from draft.state import DraftStateManager
|
||||
from draft.state import DraftStateManager, DraftStateException
|
||||
from typing import Any
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__) # __name__ = module path
|
||||
|
||||
|
||||
import random
|
||||
|
||||
|
||||
class DraftConsumerBase(AsyncJsonWebsocketConsumer):
|
||||
group_names: DraftGroupChannelNames
|
||||
draft_state: DraftStateManager
|
||||
@@ -61,6 +57,7 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
|
||||
return
|
||||
else:
|
||||
await self.accept()
|
||||
self.draft_state.connect_participant(self.user.username)
|
||||
await self.channel_layer.group_add(
|
||||
self.group_names.session, self.channel_name
|
||||
)
|
||||
@@ -72,14 +69,6 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
|
||||
"payload": {"user": self.user.username},
|
||||
},
|
||||
)
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": "direct.message",
|
||||
"subtype": DraftMessage.DRAFT_STATUS_INFORM,
|
||||
"payload": self.draft_state.to_dict(),
|
||||
},
|
||||
)
|
||||
await self.channel_layer.send(
|
||||
self.channel_name,
|
||||
{
|
||||
@@ -88,6 +77,7 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
|
||||
"payload": {"user": self.user.username},
|
||||
},
|
||||
)
|
||||
await self.broadcast_state()
|
||||
|
||||
async def should_accept_user(self) -> bool:
|
||||
return self.user.is_authenticated
|
||||
@@ -106,6 +96,14 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
|
||||
# --- Convenience helpers ---
|
||||
async def send_draft_state(self):
|
||||
"""Send the current draft state only to this client."""
|
||||
await self.channel_layer.send(
|
||||
self.channel_name,
|
||||
{
|
||||
"type": "direct.message",
|
||||
"subtype": DraftMessage.USER_STATE_INFORM,
|
||||
"payload": self.draft_state.user_state(self.user),
|
||||
}
|
||||
)
|
||||
await self.channel_layer.send(
|
||||
self.channel_name,
|
||||
{
|
||||
@@ -117,6 +115,14 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
|
||||
|
||||
async def broadcast_state(self):
|
||||
"""Broadcast current draft state to all in session group."""
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": "broadcast.session",
|
||||
"subtype": DraftMessage.USER_STATE_INFORM,
|
||||
"payload": [self.draft_state.user_state(user) for user in self.draft_participants],
|
||||
}
|
||||
)
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
@@ -214,6 +220,18 @@ class DraftAdminConsumer(DraftConsumerBase):
|
||||
},
|
||||
)
|
||||
await self.broadcast_state()
|
||||
|
||||
case DraftPhase.BIDDING:
|
||||
await self.set_draft_phase(DraftPhase.BIDDING)
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": "broadcast.session",
|
||||
"subtype": DraftMessage.PHASE_CHANGE_CONFIRM,
|
||||
"payload": {"phase": self.draft_state.phase},
|
||||
},
|
||||
)
|
||||
await self.broadcast_state()
|
||||
|
||||
case DraftMessage.DRAFT_INDEX_ADVANCE_REQUEST:
|
||||
self.draft_state.draft_index_advance()
|
||||
@@ -245,16 +263,26 @@ class DraftAdminConsumer(DraftConsumerBase):
|
||||
await self.broadcast_state()
|
||||
|
||||
case DraftMessage.BID_START_REQUEST:
|
||||
self.draft_state.start_bidding()
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": "broadcast.session",
|
||||
"subtype": DraftMessage.BID_START_INFORM,
|
||||
"payload": {**self.draft_state},
|
||||
},
|
||||
)
|
||||
await self.broadcast_state()
|
||||
try:
|
||||
self.draft_state.start_bidding()
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": "broadcast.session",
|
||||
"subtype": DraftMessage.BID_START_INFORM,
|
||||
"payload": {**self.draft_state},
|
||||
},
|
||||
)
|
||||
await self.broadcast_state()
|
||||
except DraftStateException as e:
|
||||
await self.channel_layer.send(
|
||||
self.channel_name, {
|
||||
"type": "direct.message",
|
||||
"subtype": DraftMessage.BID_START_REJECT,
|
||||
"payload": {'message': str(e)}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# === Draft logic ===
|
||||
|
||||
@@ -314,12 +342,14 @@ class DraftParticipantConsumer(DraftConsumerBase):
|
||||
},
|
||||
},
|
||||
)
|
||||
await self.broadcast_state()
|
||||
await super().disconnect(close_code)
|
||||
self.draft_state.disconnect_participant(self.user.username)
|
||||
await self.channel_layer.group_discard(
|
||||
self.group_names.session, self.channel_name
|
||||
)
|
||||
|
||||
|
||||
def should_accept_user(self):
|
||||
return super().should_accept_user() and self.user in self.draft_participants
|
||||
|
||||
@@ -333,7 +363,7 @@ class DraftParticipantConsumer(DraftConsumerBase):
|
||||
"type": "broadcast.admin",
|
||||
"subtype": event_type,
|
||||
"payload": {
|
||||
"movie_id": content.get("payload", {}).get("id"),
|
||||
"movie_id": content.get("payload", {}).get("movie_id"),
|
||||
"user": content.get("payload", {}).get("user"),
|
||||
},
|
||||
},
|
||||
@@ -341,15 +371,26 @@ class DraftParticipantConsumer(DraftConsumerBase):
|
||||
|
||||
if event_type == DraftMessage.BID_PLACE_REQUEST:
|
||||
bid_amount = content.get("payload", {}).get("bid_amount")
|
||||
self.draft_state.place_bid(self.user, bid_amount)
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": "broadcast.session",
|
||||
"subtype": DraftMessage.BID_PLACE_CONFIRM,
|
||||
"payload": {**self.draft_state},
|
||||
},
|
||||
)
|
||||
try:
|
||||
self.draft_state.place_bid(self.user, bid_amount)
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": "broadcast.session",
|
||||
"subtype": DraftMessage.BID_PLACE_CONFIRM,
|
||||
"payload": {'user': self.user.username, 'bid': bid_amount},
|
||||
},
|
||||
)
|
||||
except DraftStateException as e:
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": "broadcast.session",
|
||||
"subtype": DraftMessage.BID_PLACE_REJECT,
|
||||
"payload": {'user': self.user.username, 'bid': bid_amount, 'error':str(e)},
|
||||
},
|
||||
)
|
||||
await self.broadcast_state()
|
||||
|
||||
# === Broadcast handlers ===
|
||||
|
||||
@@ -358,11 +399,8 @@ class DraftParticipantConsumer(DraftConsumerBase):
|
||||
|
||||
# === Draft ===
|
||||
|
||||
async def nominate(self, movie_title): ...
|
||||
|
||||
async def place_bid(self, amount, user): ...
|
||||
|
||||
# === Example DB Access ===
|
||||
# === DB Access ===
|
||||
|
||||
@database_sync_to_async
|
||||
def add_draft_participant(self):
|
||||
|
||||
@@ -22,9 +22,11 @@ class DraftCache:
|
||||
bids: str
|
||||
bid_timer_start: str
|
||||
bid_timer_end: str
|
||||
connected_participants: str
|
||||
|
||||
_cached_properties = {
|
||||
"participants",
|
||||
"connected_participants",
|
||||
"phase",
|
||||
"draft_order",
|
||||
"draft_index",
|
||||
@@ -32,7 +34,6 @@ class DraftCache:
|
||||
"bids",
|
||||
"bid_timer_start",
|
||||
"bid_timer_end",
|
||||
|
||||
}
|
||||
|
||||
def __init__(self, draft_id: str, cache: BaseCache = cache):
|
||||
@@ -71,8 +72,7 @@ class DraftStateManager:
|
||||
self.session_id: str = session.hashid
|
||||
self.cache: DraftCache = DraftCache(self.session_id, cache)
|
||||
self.settings: DraftSessionSettings = session.settings
|
||||
self.participants: set[User] = set(session.participants.all())
|
||||
self.connected_participants: set[User] = set()
|
||||
self._participants = list(session.participants.all())
|
||||
|
||||
# === Phase Management ===
|
||||
@property
|
||||
@@ -85,12 +85,21 @@ class DraftStateManager:
|
||||
|
||||
# === Connected Users ===
|
||||
|
||||
@property
|
||||
def connected_participants(self):
|
||||
return set(json.loads(self.cache.connected_participants or "[]"))
|
||||
|
||||
def connect_participant(self, username: str):
|
||||
self.connected_participants.add(username)
|
||||
return self.connected_participants
|
||||
connected_participants = self.connected_participants
|
||||
connected_participants.add(username)
|
||||
self.cache.connected_participants = json.dumps(list(connected_participants))
|
||||
return connected_participants
|
||||
|
||||
def disconnect_participant(self, username: str):
|
||||
self.connected_participants.discard(username)
|
||||
connected_participants = self.connected_participants
|
||||
connected_participants.discard(username)
|
||||
self.cache.connected_participants = json.dumps(list(connected_participants))
|
||||
return connected_participants
|
||||
|
||||
# === Draft Order ===
|
||||
@property
|
||||
@@ -107,7 +116,7 @@ class DraftStateManager:
|
||||
self.phase = DraftPhase.DETERMINE_ORDER
|
||||
self.draft_index = 0
|
||||
draft_order = random.sample(
|
||||
list(self.participants), len(self.participants)
|
||||
list(self._participants), len(self._participants)
|
||||
)
|
||||
self.draft_order = [user.username for user in draft_order]
|
||||
return self.draft_order
|
||||
@@ -169,18 +178,29 @@ class DraftStateManager:
|
||||
if isinstance(amount, str):
|
||||
amount = int(amount)
|
||||
bids = self.get_bids()
|
||||
bids.append({"user":user.username, "amount":amount})
|
||||
user_state = self.user_state(user)
|
||||
timestamp = int(time.time() * 1000)
|
||||
if not user_state['can_bid']:
|
||||
raise DraftStateException('Cannot bid')
|
||||
if not user_state['remaining_budget'] > amount:
|
||||
raise DraftStateException('No Budget Remaining')
|
||||
if not self.get_timer_end() or not timestamp < self.get_timer_end() * 1000:
|
||||
raise DraftStateException("Timer Error")
|
||||
bids.append({"user":user.username, "amount":amount, 'timestamp': timestamp})
|
||||
self.cache.bids = json.dumps(bids)
|
||||
|
||||
def get_bids(self) -> dict:
|
||||
return json.loads(self.cache.bids or "[]")
|
||||
|
||||
def current_movie(self) -> Movie | None:
|
||||
movie_id = self.current_movie
|
||||
return Movie.objects.filter(pk=movie_id).first() if movie_id else None
|
||||
movie_id = self.cache.current_movie
|
||||
return movie_id if movie_id else None
|
||||
|
||||
def start_bidding(self):
|
||||
|
||||
if not self.phase == DraftPhase.BIDDING:
|
||||
raise DraftStateException('Not the right phase for that')
|
||||
if not self.current_movie():
|
||||
raise DraftStateException('No movie nominated')
|
||||
seconds = self.settings.bidding_duration
|
||||
start_time = time.time()
|
||||
end_time = start_time + seconds
|
||||
@@ -202,6 +222,7 @@ class DraftStateManager:
|
||||
"draft_index": self.draft_index,
|
||||
"connected_participants": list(self.connected_participants),
|
||||
"current_movie": self.cache.current_movie,
|
||||
"awards": [],
|
||||
"bids": self.get_bids(),
|
||||
"bidding_timer_end": self.get_timer_end(),
|
||||
"bidding_timer_start": self.get_timer_start(),
|
||||
@@ -209,6 +230,17 @@ class DraftStateManager:
|
||||
"next_picks": picks[1:] if picks else []
|
||||
}
|
||||
|
||||
def user_state(self, user: User) -> dict:
|
||||
picks = self.next_picks(include_current=True)
|
||||
return {
|
||||
"is_admin": user.is_staff,
|
||||
"user": user.username,
|
||||
"can_bid": self.phase == DraftPhase.BIDDING,
|
||||
"can_nominate": self.phase == DraftPhase.NOMINATING and picks[0].get('participant') == user.username,
|
||||
"movies":[],
|
||||
"remaining_budget":100,
|
||||
}
|
||||
|
||||
# def __dict__(self):
|
||||
# return self.get_summary()
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
{% load static %}
|
||||
<script>
|
||||
window.draftSessionId = "{{ draft_id_hashed }}"
|
||||
window.isAdmin = "{{user.is_staff}}"
|
||||
console.log("{{user}}")
|
||||
</script>
|
||||
<div id="draft-participant-root" data-draft-id="{{ draft_id_hashed }}"></div>
|
||||
{% if user.is_staff %}
|
||||
<div id="draft-admin-bar-root" data-draft-id="{{ draft_id_hashed }}">You are admin!</div>
|
||||
{% endif %}
|
||||
<div id="draft-participant-root" data-draft-id="{{ draft_id_hashed }}"></div>
|
||||
{% endblock body %}
|
||||
Reference in New Issue
Block a user