Compare commits

...

3 Commits

Author SHA1 Message Date
5e08fdc9a2 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
2025-08-24 17:16:22 -05:00
baddca8d50 Refactor draft app with improved state management and components
* Rename WebSocket message types for better organization
* Improve state handling with dedicated methods like broadcast_state
* Restructure frontend components and remove unused code
2025-08-24 12:06:41 -05:00
b38c779772 # Refactor DraftState for improved type safety and consistency
- Replaced manual cache key handling with `DraftCache` class using properties
- Fixed connected_participants serialization by converting sets to lists
- Updated countdown clock component to accept unified state prop
2025-08-23 13:55:04 -05:00
27 changed files with 858 additions and 620 deletions

View File

@@ -2,6 +2,7 @@ from rest_framework import serializers
from django.contrib.auth import get_user_model
from boxofficefantasy.models import Movie, Season
from draft.models import DraftSession, DraftSessionSettings, DraftPick
from boxofficefantasy.integrations.tmdb import get_tmdb_movie_by_imdb
User = get_user_model()
@@ -16,10 +17,27 @@ class UserSerializer(serializers.ModelSerializer):
return f"{obj.first_name} {obj.last_name}".strip()
class MovieSerializer(serializers.ModelSerializer):
tmdb_data = serializers.SerializerMethodField()
def get_tmdb_data(self, obj):
if hasattr(obj, 'imdb_id') and obj.imdb_id:
tmdb_movie = get_tmdb_movie_by_imdb(obj.imdb_id)
if tmdb_movie:
poster_url = None
if tmdb_movie.get('poster_path'):
poster_url = f"{tmdb_movie['poster_path']}"
return {
'id': tmdb_movie.get('id'),
'title': tmdb_movie.get('title'),
'overview': tmdb_movie.get('overview'),
'poster_url': tmdb_movie['poster_url'],
'release_date': tmdb_movie.get('release_date'),
}
return None
class Meta:
model = Movie
# fields = ("id", "imdb_id", "title", "year", "poster_url")
fields = ("id", "title")
fields = ("id", "title", "tmdb_data")
class DraftSessionSettingsSerializer(serializers.ModelSerializer):
class Meta:

View File

@@ -12,13 +12,15 @@ tmdb.language = "en"
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w500"
def get_tmdb_movie_by_imdb(imdb_id):
def get_tmdb_movie_by_imdb(imdb_id, cache_poster=True):
"""
Fetch TMDb metadata by IMDb ID, using cache to avoid redundant API calls.
"""
cache_key = f"tmdb:movie:{imdb_id}"
cached = cache.get(cache_key)
if cached:
if cache_poster and not cached.get('poster_url'):
cached['poster_url'] = cache_tmdb_poster(cached['poster_path'])
return cached
results = Movie().external(external_id=imdb_id, external_source="imdb_id")
@@ -27,6 +29,8 @@ def get_tmdb_movie_by_imdb(imdb_id):
movie_data = results.movie_results[0]
cache.set(cache_key, movie_data, timeout=60 * 60 * 24) # 1 day
if cache_poster:
movie_data['poster_url'] = cache_tmdb_poster(movie_data['poster_path'])
return movie_data

75
data/cache_concept.py Normal file
View File

@@ -0,0 +1,75 @@
import pickle
import os
from typing import Any
from pathlib import Path
import json
DEFAULT_PATH = Path("/Users/asc/Developer/boxofficefantasy/main/data/draft_cache.json")
class CachedDraftState:
participants: list
phase: str # Replace with Enum if needed
draft_order: list = []
draft_index: int
current_movie: str
bids: list
def __init__(self, cache_file: str = "draft_cache.json"):
super().__setattr__("_cache_file", cache_file)
super().__setattr__("_cache", self._load_cache())
def _load_cache(self) -> dict:
if os.path.exists(self._cache_file):
try:
with open(self._cache_file, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print(f"Failed to load cache: {e}")
return {}
return {}
def _save_cache(self):
try:
with open(self._cache_file, "w", encoding="utf-8") as f:
json.dump(self._cache, f, indent=2)
except Exception as e:
print(f"Failed to save cache: {e}")
def __getattr__(self, name: str) -> Any:
if name in self.__class__.__annotations__:
print(f"[GET] {name} -> {self._cache.get(name)}")
return self._cache.get(name, None)
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
def __setattr__(self, name: str, value: Any):
if name in self.__class__.__annotations__:
print(f"[SET] {name} = {value}")
self._cache[name] = value
self._save_cache()
else:
super().__setattr__(name, value)
if __name__ == "__main__":
# Clean start for testing
if os.path.exists("draft_cache.pkl"):
os.remove("draft_cache.pkl")
print("\n--- First Run: Setting Attributes ---")
state = CachedDraftState()
state.participants = ["Alice", "Bob"]
state.phase = "nominating"
# state.draft_order = ["Bob", "Alice"]
state.draft_index = 0
state.current_movie = "The Matrix"
state.bids = [{"Alice": 10}, {"Bob": 12}]
print("\n--- Second Run: Reading from Cache ---")
state2 = CachedDraftState()
print("participants:", state2.participants)
print("phase:", state2.phase)
print("draft_order:", state2.draft_order)
print("draft_index:", state2.draft_index)
print("current_movie:", state2.current_movie)
print("bids:", state2.bids)
pass

BIN
data/draft_cache.json Normal file

Binary file not shown.

View File

@@ -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)
@@ -18,8 +19,8 @@ class DraftMessage(StrEnum):
PHASE_CHANGE_CONFIRM = "phase.change.confirm" # server -> client (target phase payload)
# Status / sync
STATUS_SYNC_REQUEST = "status.sync.request" # client -> server
STATUS_SYNC_INFORM = "status.sync.inform" # server -> client (full/partial state)
DRAFT_STATUS_REQUEST = "draft.status.request" # client -> server
DRAFT_STATUS_INFORM = "draft.status.sync.inform" # server -> client (full/partial state)
DRAFT_INDEX_ADVANCE_REQUEST = "draft.index.advance.request"
DRAFT_INDEX_ADVANCE_CONFIRM = "draft.index.advance.confirm"
@@ -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)

View File

@@ -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,20 +69,6 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
"payload": {"user": self.user.username},
},
)
await self.channel_layer.send(
self.channel_name,
{
"type": "direct.message",
"subtype": DraftMessage.STATUS_SYNC_INFORM,
"payload": {
**self.draft_state,
"user": self.user.username,
"participants": [
user.username for user in self.draft_participants
],
},
},
)
await self.channel_layer.send(
self.channel_name,
{
@@ -94,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
@@ -101,14 +85,53 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
async def receive_json(self, content):
logger.info(f"receiving message {content}")
event_type = content.get("type")
if event_type == DraftMessage.STATUS_SYNC_REQUEST:
if event_type == DraftMessage.DRAFT_STATUS_REQUEST:
await self.send_json(
{
"type": DraftMessage.STATUS_SYNC_INFORM,
"type": DraftMessage.DRAFT_STATUS_INFORM,
"payload": self.get_draft_status(),
}
)
# --- 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,
{
"type": "direct.message",
"subtype": DraftMessage.DRAFT_STATUS_INFORM,
"payload": self.draft_state.to_dict(),
},
)
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,
{
"type": "broadcast.session",
"subtype": DraftMessage.DRAFT_STATUS_INFORM,
"payload": self.draft_state.to_dict(),
},
)
# Broadcast Handlers
async def direct_message(self, event):
await self._dispatch_broadcast(event)
@@ -132,9 +155,15 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
def get_draft_session(self, draft_session_id_hashed) -> DraftSession:
draft_session_id = DraftSession.decode_id(draft_session_id_hashed)
if draft_session_id:
draft_session = DraftSession.objects.select_related(
"season", "season__league", "settings",
).prefetch_related("participants").get(pk=draft_session_id)
draft_session = (
DraftSession.objects.select_related(
"season",
"season__league",
"settings",
)
.prefetch_related("participants")
.get(pk=draft_session_id)
)
else:
raise Exception()
@@ -155,89 +184,107 @@ class DraftAdminConsumer(DraftConsumerBase):
await self.channel_layer.group_add(self.group_names.admin, self.channel_name)
def should_accept_user(self):
return super().should_accept_user() and self.user.is_staff
async def receive_json(self, content):
await super().receive_json(content)
logger.info(f"Receive message {content}")
event_type = content.get("type")
if (
event_type == DraftMessage.PHASE_CHANGE_REQUEST
and content.get("destination") == DraftPhase.DETERMINE_ORDER
):
await self.determine_draft_order()
if (
event_type == DraftMessage.PHASE_CHANGE_REQUEST
and content.get("destination") == DraftPhase.NOMINATING
):
await self.start_nominate()
match event_type:
case DraftMessage.PHASE_CHANGE_REQUEST:
destination = content.get('destination')
match destination:
case DraftPhase.DETERMINE_ORDER:
await self.set_draft_phase(DraftPhase.DETERMINE_ORDER)
self.draft_state.determine_draft_order()
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.ORDER_DETERMINE_CONFIRM,
"payload": {"draft_order": self.draft_state.draft_order},
},
)
await self.broadcast_state()
if event_type == DraftMessage.DRAFT_INDEX_ADVANCE_REQUEST:
self.draft_state.draft_index_advance()
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.DRAFT_INDEX_ADVANCE_CONFIRM,
"payload": {**self.draft_state},
},
)
case DraftPhase.NOMINATING:
await self.set_draft_phase(DraftPhase.NOMINATING)
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 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()
if event_type == DraftMessage.NOMINATION_SUBMIT_REQUEST:
movie_id = content.get("payload", {}).get("movie_id")
user = content.get("payload", {}).get("user")
self.draft_state.start_nomination(movie_id)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.NOMINATION_CONFIRM,
"payload": {
"current_movie": self.draft_state[
"current_movie"
],
"nominating_participant": user,
case DraftMessage.DRAFT_INDEX_ADVANCE_REQUEST:
self.draft_state.draft_index_advance()
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.DRAFT_INDEX_ADVANCE_CONFIRM,
"payload": {"draft_index": self.draft_state.draft_index},
},
},
)
if event_type == DraftMessage.BID_START_REQUEST:
)
await self.broadcast_state()
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},
},
)
case DraftMessage.NOMINATION_SUBMIT_REQUEST:
movie_id = content.get("payload", {}).get("movie_id")
user = content.get("payload", {}).get("user")
self.draft_state.start_nomination(movie_id)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.NOMINATION_CONFIRM,
"payload": {
"current_movie": self.draft_state["current_movie"],
"nominating_participant": user,
},
},
)
await self.broadcast_state()
def should_accept_user(self):
return super().should_accept_user() and self.user.is_staff
case DraftMessage.BID_START_REQUEST:
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 ===
async def start_nominate(self):
await self.set_draft_phase(DraftPhase.NOMINATING)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.PHASE_CHANGE_CONFIRM,
"payload": {"phase": self.draft_state.phase},
},
)
async def determine_draft_order(self):
self.draft_state.determine_draft_order()
next_picks = self.draft_state.next_picks(include_current=True)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.ORDER_DETERMINE_CONFIRM,
"payload": {**self.draft_state},
},
)
# === Draft logic ===
async def set_draft_phase(self, destination: DraftPhase):
self.draft_state.phase = destination
@@ -269,7 +316,9 @@ class DraftParticipantConsumer(DraftConsumerBase):
"subtype": DraftMessage.PARTICIPANT_JOIN_CONFIRM,
"payload": {
"user": self.user.username,
"connected_participants": self.draft_state.connected_participants,
"connected_participants": list(
self.draft_state.connected_participants
),
},
},
)
@@ -287,16 +336,20 @@ class DraftParticipantConsumer(DraftConsumerBase):
"subtype": DraftMessage.PARTICIPANT_LEAVE_INFORM,
"payload": {
"user": self.user.username,
"connected_participants": self.draft_state.connected_participants,
"connected_participants": list(
self.draft_state.connected_participants
),
},
},
)
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
@@ -310,23 +363,34 @@ 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"),
},
},
)
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},
},
)
bid_amount = content.get("payload", {}).get("bid_amount")
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 ===
@@ -335,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):

View File

@@ -1,10 +1,10 @@
from django.core.cache import cache
from django.core.cache import cache, BaseCache
import json
from datetime import datetime, timedelta
from boxofficefantasy.models import Movie
from django.contrib.auth.models import User
from draft.constants import DraftPhase
from draft.models import DraftSession
from draft.models import DraftSession, DraftSessionSettings
import time
from dataclasses import dataclass
from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple
@@ -14,131 +14,124 @@ class DraftStateException(Exception):
"""Raised when an action is not allowed due to the current draft state or phase."""
pass
class DraftCacheKeys:
def __init__(self, id):
self.prefix = f"draft:{id}"
class DraftCache:
phase: str
draft_order: str
draft_index: str
current_movie: str
bids: str
bid_timer_start: str
bid_timer_end: str
connected_participants: str
@property
def admins(self):
return f"{self.prefix}:admins"
_cached_properties = {
"participants",
"connected_participants",
"phase",
"draft_order",
"draft_index",
"current_movie",
"bids",
"bid_timer_start",
"bid_timer_end",
}
@property
def participants(self):
return f"{self.prefix}:participants"
@property
def users(self):
return f"{self.prefix}:users"
@property
def connected_users(self):
return f"{self.prefix}:connected_users"
@property
def phase(self):
return f"{self.prefix}:phase"
@property
def draft_order(self):
return f"{self.prefix}:draft_order"
def __init__(self, draft_id: str, cache: BaseCache = cache):
super().__setattr__("_cache", self._load_cache(cache))
super().__setattr__("_prefix", f"draft:{draft_id}:")
@property
def draft_index(self):
return f"{self.prefix}:draft_index"
@property
def current_movie(self):
return f"{self.prefix}:current_movie"
# @property
# def state(self):
# return f"{self.prefix}:state"
def _load_cache(self, cache) -> BaseCache:
return cache
# @property
# def current_movie(self):
# return f"{self.prefix}:current_movie"
def _save_cache(self) -> None:
# Django cache saves itself
return
@property
def bids(self):
return f"{self.prefix}:bids"
def __getattr__(self, name: str) -> Any:
if name == "_prefix": return super().__getattribute__('_prefix')
if name in self._cached_properties:
return self._cache.get(self._prefix+name, None)
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
# @property
# def participants(self):
# return f"{self.prefix}:participants"
def __setattr__(self, name: str, value: Any):
if name in self._cached_properties:
self._cache.set(self._prefix+name, value)
self._save_cache()
else:
super().__setattr__(name, value)
@property
def bid_timer_end(self):
return f"{self.prefix}:bid_timer_end"
@property
def bid_timer_start(self):
return f"{self.prefix}:bid_timer_start"
# def user_status(self, user_id):
# return f"{self.prefix}:user:{user_id}:status"
# def user_channel(self, user_id):
# return f"{self.prefix}:user:{user_id}:channel"
def __delattr__(self, name):
if name in self._cached_properties:
self._cache.delete(name)
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
class DraftStateManager:
_initial_phase: DraftPhase = DraftPhase.WAITING.value
def __init__(self, session: DraftSession):
self.session_id = session.hashid
self.cache = cache
self.cache_keys = DraftCacheKeys(self.session_id)
self._initial_phase = self.cache.get(self.cache_keys.phase, DraftPhase.WAITING.value)
self.settings = session.settings
self.participants = list(session.participants.all())
self.session_id: str = session.hashid
self.cache: DraftCache = DraftCache(self.session_id, cache)
self.settings: DraftSessionSettings = session.settings
self._participants = list(session.participants.all())
# === Phase Management ===
@property
def phase(self) -> str:
return str(self.cache.get(self.cache_keys.phase, self._initial_phase))
return self.cache.phase or self._initial_phase
@phase.setter
def phase(self, new_phase: DraftPhase):
self.cache.set(self.cache_keys.phase, new_phase.value)
def phase(self, new_phase: DraftPhase) -> None:
self.cache.phase = new_phase
# === Connected Users ===
@property
def connected_participants(self) -> list[str]:
return json.loads(self.cache.get(self.cache_keys.connected_users) or "[]")
def connected_participants(self):
return set(json.loads(self.cache.connected_participants or "[]"))
def connect_participant(self, username: str):
users = set(self.connected_participants)
users.add(username)
self.cache.set(self.cache_keys.connected_users, json.dumps(list(users)))
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):
users = set(self.connected_participants)
users.discard(username)
self.cache.set(self.cache_keys.connected_users, json.dumps(list(users)))
connected_participants = self.connected_participants
connected_participants.discard(username)
self.cache.connected_participants = json.dumps(list(connected_participants))
return connected_participants
# === Draft Order ===
@property
def draft_order(self):
return json.loads(self.cache.get(self.cache_keys.draft_order,"[]"))
return json.loads(self.cache.draft_order or "[]")
@draft_order.setter
def draft_order(self, draft_order: list[str]):
if not isinstance(draft_order, list):
return
self.cache.set(self.cache_keys.draft_order,json.dumps(draft_order))
self.cache.draft_order = json.dumps(draft_order)
def determine_draft_order(self) -> List[User]:
self.phase = DraftPhase.DETERMINE_ORDER
self.draft_index = 0
draft_order = random.sample(
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
@property
def draft_index(self):
return self.cache.get(self.cache_keys.draft_index,0)
draft_index = self.cache.draft_index
if not draft_index:
draft_index = 0
self.cache.draft_index = draft_index
return self.cache.draft_index
@draft_index.setter
def draft_index(self, draft_index: int):
self.cache.set(self.cache_keys.draft_index, int(draft_index))
self.cache.draft_index = draft_index
def draft_index_advance(self, n: int = 1):
self.draft_index += n
@@ -178,36 +171,47 @@ class DraftStateManager:
# === Current Nomination / Bid ===
def start_nomination(self, movie_id: int):
self.cache.set(self.cache_keys.current_movie, movie_id)
self.cache.delete(self.cache_keys.bids)
self.cache.current_movie = movie_id
self.cache.bids = []
def place_bid(self, user: User, amount: int|str):
if isinstance(amount, str):
amount = int(amount)
bids = self.get_bids()
bids.append({"user":user.username, "amount":amount})
self.cache.set(self.cache_keys.bids, json.dumps(bids))
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.get(self.cache_keys.bids) or "[]")
return json.loads(self.cache.bids or "[]")
def current_movie(self) -> Movie | None:
movie_id = self.cache.get(self.cache_keys.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
self.cache.set(self.cache_keys.bid_timer_end, end_time)
self.cache.set(self.cache_keys.bid_timer_start, start_time)
self.cache.bid_timer_end = end_time
self.cache.bid_timer_start = start_time
def get_timer_end(self) -> str | None:
return self.cache.get(self.cache_keys.bid_timer_end)
return self.cache.bid_timer_end
def get_timer_start(self) -> str | None:
return self.cache.get(self.cache_keys.bid_timer_start)
return self.cache.bid_timer_start
# === Sync Snapshot ===
def to_dict(self) -> dict:
@@ -216,8 +220,9 @@ class DraftStateManager:
"phase": self.phase,
"draft_order": self.draft_order,
"draft_index": self.draft_index,
"connected_participants": self.connected_participants,
"current_movie": self.cache.get(self.cache_keys.current_movie),
"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(),
@@ -225,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()

View File

@@ -3,6 +3,11 @@
{% load static %}
<script>
window.draftSessionId = "{{ draft_id_hashed }}"
window.isAdmin = "{{user.is_staff}}"
console.log("{{user}}")
</script>
{% 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 %}

View File

@@ -6,6 +6,7 @@ app_name = "draft"
urlpatterns = [
# path("", views.draft_room, name="room"),
path("session/<str:draft_session_id_hashed>/", views.draft_room, name="session"),
path("session/<str:draft_session_id_hashed>/<str:subpage>", views.draft_room, name="admin_session"),
path("session/<str:draft_session_id_hashed>/debug", views.draft_room_debug, name="session"),
# path("session/<str:draft_session_id_hashed>/<str:subpage>", views.draft_room, name="admin_session"),
# path("<slug:league_slug>/<slug:season_slug>/", views.draft_room_list, name="room"),
]

View File

@@ -6,28 +6,22 @@ from django.contrib.auth.decorators import login_required
from boxofficefantasy_project.utils import decode_id
@login_required(login_url='/login/')
def draft_room(request, league_slug=None, season_slug=None, draft_session_id_hashed=None, subpage=""):
def draft_room(request, draft_session_id_hashed=None):
if draft_session_id_hashed:
draft_session_id = decode_id(draft_session_id_hashed)
draft_session = get_object_or_404(DraftSession, id=draft_session_id)
league = draft_session.season.league
season = draft_session.season
elif league_slug and season_slug:
raise NotImplementedError
league = get_object_or_404(League, slug=league_slug)
label, year = parse_season_slug(season_slug)
season = get_object_or_404(Season, league=league, label__iexact=label, year=year)
draft_session = get_object_or_404(DraftSession, season=season)
context = {
"draft_id_hashed": draft_session.hashid,
"league": league,
"season": season,
}
return render(request, "draft/room.dj.html", context)
if subpage == "admin":
return render(request, "draft/room_admin.dj.html", context)
elif subpage == "debug":
return render(request, "draft/room_debug.dj.html", context)
else:
return render(request, "draft/room.dj.html", context)
def draft_room_debug(request, draft_session_id_hashed=None):
if draft_session_id_hashed:
draft_session_id = decode_id(draft_session_id_hashed)
draft_session = get_object_or_404(DraftSession, id=draft_session_id)
return render(request, "draft/room_debug.dj.html", {"draft_id_hashed": draft_session.hashid,})

21
draft_cache.json Normal file
View File

@@ -0,0 +1,21 @@
{
"participants": [
"Alice",
"Bob"
],
"phase": "nominating",
"draft_order": [
"Bob",
"Alice"
],
"draft_index": 0,
"current_movie": "The Matrix",
"bids": [
{
"Alice": 10
},
{
"Bob": 12
}
]
}

View File

@@ -1,13 +1,10 @@
import React, { useEffect, useState } from "react";
import { useWebSocket } from "../common/WebSocketContext.jsx";
import { WebSocketStatus } from "../common/WebSocketStatus.jsx";
import { ParticipantList } from "../common/ParticipantList.jsx";
import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from '../constants.js';
import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "../common/utils.js"
import { DraftMoviePool } from "../common/DraftMoviePool.jsx"
import { DraftCountdownClock } from "../common/DraftCountdownClock.jsx"
import { DraftParticipant } from "../participant/DraftParticipant.jsx";
import { useWebSocket } from "./components/WebSocketContext.jsx";
import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from './constants.js';
import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "./utils.js"
import { jsxs } from "react/jsx-runtime";
@@ -41,7 +38,6 @@ export const DraftAdmin = ({ draftSessionId }) => {
useEffect(() => {
fetchDraftDetails(draftSessionId)
.then((data) => {
console.log("Fetched draft data", data)
setDraftDetails(data)
})
}, [])
@@ -102,7 +98,6 @@ export const DraftAdmin = ({ draftSessionId }) => {
else if (target == "previous" && originPhaseIndex > 0) {
destination = DraftPhasesOrdered[originPhaseIndex - 1]
}
console.log(destination)
socket.send(
JSON.stringify(
{ type: DraftMessage.PHASE_CHANGE_REQUEST, origin, destination }
@@ -140,22 +135,15 @@ export const DraftAdmin = ({ draftSessionId }) => {
}
return (
<div className="">
<div className="">
<DraftParticipant draftSessionId={draftSessionId}></DraftParticipant>
<div className="d-flex justify-content-between border-bottom mb-2 p-1">
</div>
</div>
<section className="d-flex justify-content-center mt-3">
<div id="draft-admin-bar">
<div>
<button onClick={() => handleRequestDraftSummary()} className="btn btn-small btn-light mx-1">
<i className="bi bi-arrow-clockwise"></i>
</button>
<button onClick={handleAdvanceDraft} className="btn btn-primary mx-1">Advance Index</button>
<button onClick={handleStartBidding} className="btn btn-primary mx-1">Start Bidding</button>
</section>
<div class="d-flex justify-content-center mt-3">
</div>
<div>
<DraftPhaseDisplay draftPhase={draftState.phase} nextPhaseHandler={() => { handlePhaseChange('next') }} prevPhaseHandler={() => { handlePhaseChange('previous') }}></DraftPhaseDisplay>
</div>

View File

@@ -0,0 +1,169 @@
// DraftAdmin.jsx
import React, { useEffect, useState, useRef } from "react";
import { useWebSocket } from "./components/WebSocketContext.jsx";
import { WebSocketStatus } from "./components/WebSocketStatus.jsx";
import { DraftMessage, DraftPhaseLabel, DraftPhase } from './constants.js';
import { fetchDraftDetails, isEmptyObject } from "./utils.js";
import { DraftMoviePool } from "./components/DraftMoviePool.jsx";
import { ParticipantList } from "./components/ParticipantList.jsx";
import { DraftCountdownClock } from "./components/DraftCountdownClock.jsx"
import { handleDraftStatusMessages, handleUserStatusMessages, handleUserIdentifyMessages } from './utils.js'
// import { Collapse } from 'bootstrap/dist/js/bootstrap.bundle.min.js';
import { Collapse } from "react-bootstrap";
export const DraftParticipant = ({ draftSessionId }) => {
const socket = useWebSocket();
const [draftState, setDraftState] = useState({});
const [userStatus, setUserState] = useState([]);
const [draftDetails, setDraftDetails] = useState({});
const [currentUser, setCurrentUser] = useState(null);
const [movies, setMovies] = useState([]);
useEffect(() => {
fetchDraftDetails(draftSessionId)
.then((data) => {
console.log("Fetched draft data", data)
setMovies(data.movies)
setDraftDetails(data)
})
}, [draftSessionId])
useEffect(() => {
if (!socket) return;
const draftStatusMessageHandler = (event) => handleDraftStatusMessages(event, setDraftState)
const userIdentifyMessageHandler = (event) => handleUserIdentifyMessages(event, setCurrentUser)
const userStatusMessageHandler = (event) => handleUserStatusMessages(event, setUserState)
socket.addEventListener('message', draftStatusMessageHandler);
socket.addEventListener('message', userIdentifyMessageHandler);
socket.addEventListener('message', userStatusMessageHandler);
return () => {
socket.removeEventListener('message', draftStatusMessageHandler);
socket.removeEventListener('message', userIdentifyMessageHandler);
socket.removeEventListener('message', userStatusMessageHandler);
};
}, [socket]);
function submitBidRequest(event) {
event.preventDefault()
const form = event.target
const formData = new FormData(form)
socket.send(JSON.stringify({
type: DraftMessage.BID_PLACE_REQUEST,
payload: {
bid_amount: formData.get('bidAmount'),
user: currentUser
}
}))
}
const currentUserStatus = userStatus.find(u => u.user == currentUser)
const currentMovie = movies.find(i => draftState.current_movie == i.id)
return (
<div className={`wrapper`}>
<section id="draft-live">
<div className="panel">
<header className="panel-header">
<div className="panel-title"><span>Draft Live</span></div>
<div className="d-flex gap-1">
<div className="phase-indicator badge bg-primary">{DraftPhaseLabel[draftState.phase]}</div>
<WebSocketStatus socket={socket} />
</div>
</header>
<div className="panel-body">
<div>
<div id="draft-clock">
<DraftCountdownClock draftState={draftState}></DraftCountdownClock>
<div className="pick-description">
<div>Round {draftState.current_pick?.round}</div>
<div>Pick {draftState.current_pick?.pick_in_round}</div>
<div>{draftState.current_pick?.overall + 1} Overall</div>
</div>
</div>
<div className="bid-controls btn-group d-flex flex-column">
<Collapse in={draftState.phase == DraftPhase.BIDDING}>
<div>
<div>
<div className="row g-0 border rounded-2 m-2">
<div className="col-3">
<img className="img-fluid flex-fill" src={currentMovie?.tmdb_data?.poster_url}/>
</div>
<div className="col d-flex justify-content-center align-items-center">
<span className="fw-bold">{currentMovie?.title}</span>
</div>
</div>
</div>
<div>
<div className="lh-sm text-center border p-0 border-bottom"><span>Bids</span></div>
<div className="bids-container">
<ol className="list-group list-group-flush">
{draftState.bids?.reverse().map((b, idx) => (
<li key={idx} className="list-group-item p-0">
<div className="row g-0">
<div className="col-8 col-xl-9 col-xxl-10"><span>{b.user}</span></div>
<div className="col"><span>{b.amount}</span></div>
</div>
</li>
))}
</ol>
</div>
</div>
<div className="p-1">
<form id="bid" onSubmit={submitBidRequest}>
<div className="input-group input-group-sm">
<span className="input-group-text">Bid</span>
<input className="form-control" type="number" id="bidAmount" name="bidAmount" />
<button className="btn btn-primary" type="submit">Submit</button>
</div>
</form>
</div>
</div>
</Collapse>
</div>
</div>
</div>
</div>
</section>
<section className="panel" id="draft-slate">
<header className="panel-header">
<div className="panel-title"><span>Films</span></div>
</header>
<div className="panel-body">
<div className="movie-filters"></div>
<DraftMoviePool currentUserStatus={currentUserStatus} currentUser={currentUser} socket={socket} isParticipant={true} draftDetails={draftDetails} draftState={draftState}></DraftMoviePool>
</div>
</section>
<section className="panel my-team">
<header className="panel-header">
<div className="panel-title"><span>My Team</span></div>
</header>
<div className="panel-body">
<ul className="team-movie-list list-group">
<li className="team-movie-item list-group-item"></li>
</ul>
<div className="budget-status"></div>
</div>
</section>
<section className="panel teams">
<header className="panel-header">
<div className="panel-title"><span>Teams</span></div>
</header>
<div className="panel-body">
<ParticipantList currentUser={currentUser} className="team-list" draftDetails={draftDetails} draftState={draftState} isAdmin={isAdmin}></ParticipantList>
</div>
</section>
</div>
);
};

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";;
import { useWebSocket } from "./common/WebSocketContext.jsx";
import { useWebSocket } from "./components/WebSocketContext.jsx";
import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from './constants.js';
import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "./common/utils.js"
import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "./utils.js"
export const DraftDebug = ({ draftSessionId }) => {
const [draftState, setDraftState] = useState({})

View File

@@ -1,23 +0,0 @@
import React from "react";
import { isEmptyObject } from "./utils";
export const DraftMoviePool = ({ isParticipant, draftDetails, draftState }) => {
if(isEmptyObject(draftDetails)) {return}
const {movies} = draftDetails
const {current_movie} = draftState
return (
<div className="movie-pool-container">
<label>Movies</label>
<ul>
{movies.map(m => (
<li key={m.id} className={`${current_movie == m.id ? "current-movie fw-bold" : null }`}>
<a href={`/api/movie/${m.id}/detail`}>
{m.title}
</a>
</li>
))}
</ul>
</div>
)
}

View File

@@ -1,32 +0,0 @@
import React from "react";
import { fetchDraftDetails, isEmptyObject } from "../common/utils.js"
export const ParticipantList = ({ isAdmin, draftState, draftDetails, currentUser }) => {
if (isEmptyObject(draftState) || isEmptyObject(draftDetails)) { console.warn('empty draft state', draftState); return }
const { draft_order, draft_index, connected_participants } = draftState
const { participants } = draftDetails
const ListTag = draft_order?.length > 0 ? "ol" : "ul"
const listItems = draft_order?.length > 0 ? draft_order.map(d => participants.find(p => p.username == d)) : participants
return (
<div className="participant-list-container">
<label>Particpants</label>
<ListTag className="participant-list">
{listItems.map((p, i) => (
<li key={i} className={`${i == draft_index ? "fw-bold" : ""}`}>
<span className={`${p.username == currentUser ? "current-user" : ""}`}>{p?.full_name}</span>
{isAdmin ? (
<div
className={
`ms-2 stop-light ${connected_participants.includes(p?.username) ? "success" : "danger"}`
}
></div>
) : null}
</li>
))}
</ListTag>
</div>
)
}

View File

@@ -1,24 +1,26 @@
import React, { useEffect, useState } from "react";
export function DraftCountdownClock({ endTime, onFinish }) {
// endTime is in seconds (Unix time)
export function DraftCountdownClock({ draftState }) {
const { bidding_timer_end, onFinish } = draftState;
const getTimeLeft = (et) => Math.max(0, Math.floor(et - Date.now() / 1000));
const [timeLeft, setTimeLeft] = useState(getTimeLeft(endTime));
const [timeLeft, setTimeLeft] = useState(getTimeLeft(bidding_timer_end));
useEffect(() => {
if (timeLeft <= 0) {
setTimeLeft(getTimeLeft(bidding_timer_end)); // reset timer when bidding_timer_end changes
if (getTimeLeft(bidding_timer_end) <= 0) {
if (onFinish) onFinish();
return;
}
const timer = setInterval(() => {
const t = getTimeLeft(endTime);
const t = getTimeLeft(bidding_timer_end);
setTimeLeft(t);
if (t <= 0 && onFinish) onFinish();
}, 100);
return () => clearInterval(timer);
// eslint-disable-next-line
}, [endTime, onFinish, timeLeft]);
}, [bidding_timer_end, onFinish]);
const minutes = Math.floor(timeLeft / 60);
const secs = timeLeft % 60;

View File

@@ -0,0 +1,87 @@
import React from "react";
import { isEmptyObject } from "../utils";
import { DraftMessage } from "../constants";
const NominateForm = ({ socket, currentUser, movie, className}) => {
const requestNomination = (event) => {
event.preventDefault()
const formData = new FormData(event.target)
socket.send(JSON.stringify({
type: DraftMessage.NOMINATION_SUBMIT_REQUEST,
payload: {
movie_id: formData.get('movie_id'),
user: currentUser
}
}))
}
return (
<form onSubmit={requestNomination} className={className}>
<input type="hidden" name="movie_id" value={movie.id} />
<button type="submit" className="btn btn-primary nominate">
Nominate
</button>
</form>
);
}
export const DraftMoviePool = ({ socket, currentUser, currentUserStatus, draftDetails, draftState, isNominating = false }) => {
if (isEmptyObject(draftDetails)) { return }
const { movies } = draftDetails
const { current_movie } = draftState
const can_nominate = currentUserStatus?.can_nominate
const is_admin = currentUserStatus?.is_admin
const nominateHandler = (event) => {
event.preventDefault()
const formData = new FormData(event.target)
const movieId = formData.get('movie_id');
socket.send(JSON.stringify({
type: DraftMessage.NOMINATION_SUBMIT_REQUEST,
payload: {
movie_id: movieId,
user: currentUser
}
}))
}
return (
<div className="movie-pool-container">
<table className="table">
<thead>
<tr>
<th>Poster</th>
<th>Title</th>
<th>Release Date</th>
</tr>
</thead>
<tbody>
{movies.map(m => (
<tr key={m.id} className={`${current_movie == m.id ? "current-movie fw-bold" : null}`}>
<td><img src={m.tmdb_data.poster_url}></img></td>
<td>
<div>
<a href={`/api/movie/${m.id}/detail`} className="fs-5">
{m.title}
</a>
</div>
<div>
<a href={`https://www.themoviedb.org/movie/${m.tmdb_data.id}`}>
TMDB
</a>
</div>
<div>
{can_nominate || is_admin ? (
<NominateForm socket={socket} currentUser={currentUser} movie={m} className={!can_nominate && is_admin ? 'admin-override' : ''}></NominateForm>
) : null}
</div>
</td>
<td>{m.tmdb_data.release_date}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,38 @@
import React from "react";
import { fetchDraftDetails, isEmptyObject } from "../utils.js"
import Badge from 'react-bootstrap/Badge';
export const ParticipantList = ({ isAdmin, draftState, draftDetails, currentUser }) => {
if (isEmptyObject(draftState) || isEmptyObject(draftDetails)) { return }
const { draft_order, draft_index, connected_participants } = draftState
const { participants } = draftDetails
const ListTag = draft_order?.length > 0 ? "ol" : "ul"
const listItems = draft_order?.length > 0 ? draft_order.map(d => participants.find(p => p.username == d)) : participants
return (
<ListTag className="participant-list">
{listItems.map((p, idx) => (
<li className="team-item" key={idx}>
<div className={`team-name ${p.username == currentUser ? "current-user" : ""}`}>
<div>
{p.full_name}
{p.username == draftState.current_pick?.participant ? (<Badge bg="warning" className="ms-1">Current Pick</Badge>) : null}
</div>
<ul className="team-movie-list list-group list-group-flush">
<li className="team-movie-item list-group-item"></li>
</ul>
</div>
{isAdmin === "True" ? (
<div
className={
`ms-2 stop-light ${connected_participants.includes(p?.username) ? "success" : "danger"}`
}
></div>
) : null}
</li>
))}
</ListTag>
)
}

View File

@@ -10,18 +10,22 @@ export const DraftMessage = {
USER_JOIN_INFORM: "user.join.inform",
USER_LEAVE_INFORM: "user.leave.inform",
USER_IDENTIFICATION_INFORM: "user.identification.inform",
USER_STATE_INFORM: "user.state.inform",
PHASE_CHANGE_INFORM: "phase.change.inform",
PHASE_CHANGE_REQUEST: "phase.change.request",
PHASE_CHANGE_CONFIRM: "phase.change.confirm",
STATUS_SYNC_REQUEST: "status.sync.request",
STATUS_SYNC_INFORM: "status.sync.inform",
DRAFT_STATUS_REQUEST: "draft.status.request",
DRAFT_STATUS_INFORM: "draft.status.sync.inform",
DRAFT_INDEX_ADVANCE_REQUEST: "draft.index.advance.request",
DRAFT_INDEX_ADVANCE_CONFIRM: "draft.index.advance.confirm",
ORDER_DETERMINE_REQUEST: "order.determine.request",
ORDER_DETERMINE_CONFIRM: "order.determine.confirm",
BID_START_INFORM: "bid.start.inform",
BID_START_REQUEST: "bid.start.request",
BID_START_REJECT: "bid.start.reject",
BID_PLACE_REQUEST: "bid.place.request",
BID_PLACE_REJECT: "bid.place.reject",
BID_PLACE_CONFIRM: "bid.place.confirm",
BID_UPDATE_INFORM: "bid.update.inform",
BID_END_INFORM: "bid.end.inform",
NOMINATION_SUBMIT_REQUEST: "nomination.submit.request",

View File

@@ -1,236 +0,0 @@
// DraftAdmin.jsx
import React, { useEffect, useState, useRef } from "react";
import { useWebSocket } from "../common/WebSocketContext.jsx";
import { WebSocketStatus } from "../common/WebSocketStatus.jsx";
import { DraftMessage, DraftPhaseLabel, DraftPhases } from '../constants.js';
import { fetchDraftDetails, handleUserIdentifyMessages, isEmptyObject } from "../common/utils.js";
import { DraftMoviePool } from "../common/DraftMoviePool.jsx";
import { ParticipantList } from "../common/ParticipantList.jsx";
import { DraftCountdownClock } from "../common/DraftCountdownClock.jsx"
import { handleDraftStatusMessages } from '../common/utils.js'
// import { Collapse } from 'bootstrap/dist/js/bootstrap.bundle.min.js';
import { Collapse, ListGroup } from "react-bootstrap";
const NominateMenu = ({ socket, draftState, draftDetails, currentUser, }) => {
if (!socket || isEmptyObject(draftDetails) || isEmptyObject(draftState)) return;
const [open, setOpen] = useState(false);
const { movies } = draftDetails
const requestNomination = (event) => {
event.preventDefault()
const formData = new FormData(event.target)
socket.send(JSON.stringify({
type: DraftMessage.NOMINATION_SUBMIT_REQUEST,
payload: {
id: formData.get('movie'),
user: currentUser
}
}))
}
useEffect(() => {
if (isEmptyObject(draftState) || isEmptyObject(draftState.current_pick)) return;
if (currentUser == draftState.current_pick.participant) {
setOpen(true)
} else {
setOpen(false)
}
// collapse.toggle()
}, [draftState])
return (
<Collapse in={open} className="nominate-menu">
<div> {/* Everything must be wrapped in one parent */}
<label>Nominate</label>
<div className="d-flex">
<form onSubmit={requestNomination}>
<select className="form-control" name="movie">
{movies.map(m => (
<option key={m.id} value={m.id}>{m.title}</option>
))}
</select>
<button className="btn btn-primary">Nominate</button>
</form>
</div>
</div>
</Collapse>
);
}
export const DraftParticipant = ({ draftSessionId }) => {
const socket = useWebSocket();
const [draftState, setDraftState] = useState({});
const [draftDetails, setDraftDetails] = useState({});
const [currentUser, setCurrentUser] = useState(null);
const [movies, setMovies] = useState([]);
console.log(socket)
useEffect(() => {
fetchDraftDetails(draftSessionId)
.then((data) => {
console.log("Fetched draft data", data)
setMovies(data.movies)
setDraftDetails(data)
})
}, [draftSessionId])
useEffect(() => {
if (!socket) return;
socket.onclose = (event) => {
console.log('Websocket Closed')
}
}, [socket])
useEffect(() => {
if (!socket) return;
const draftStatusMessageHandler = (event) => handleDraftStatusMessages(event, setDraftState)
const userIdentifyMessageHandler = (event) => handleUserIdentifyMessages(event, setCurrentUser)
socket.addEventListener('message', draftStatusMessageHandler);
socket.addEventListener('message', userIdentifyMessageHandler);
return () => {
socket.removeEventListener('message', draftStatusMessageHandler);
socket.removeEventListener('message', userIdentifyMessageHandler);
};
}, [socket]);
function submitBidRequest(event) {
event.preventDefault()
const form = event.target
const formData = new FormData(form)
console.log('submitting bid...')
socket.send(JSON.stringify({
type: DraftMessage.BID_PLACE_REQUEST,
payload: {
bid_amount: formData.get('bidAmount'),
user: currentUser
}
}))
}
return (
<div className="wrapper">
<section className="panel draft-live">
<header className="panel-header">
<h2 className="panel-title">Draft Live</h2>
<div className="d-flex gap-1">
<div className="phase-indicator badge bg-primary">{DraftPhaseLabel[draftState.phase]}</div>
<WebSocketStatus socket={socket} />
</div>
</header>
<div className="panel-body">
<div className="draft-live-state-container">
<DraftCountdownClock endTime={draftState.bidding_timer_end}></DraftCountdownClock>
<div className="pick-description">
{console.log("draft_state", draftState)}
<div>Round {draftState.current_pick?.round}</div>
<div>Pick {draftState.current_pick?.pick_in_round}</div>
<div>{draftState.current_pick?.overall + 1} Overall</div>
</div>
</div>
<div className="bid-status">
<div className="d-flex">
<div className="flex-grow-1 text-center">
{draftState.bids?.length > 0 ? Math.max(draftState.bids?.map(i=>i.bid_amount)) : ""}
</div>
<div className="flex-grow-1 text-center">
highest bid
</div>
</div>
<div>
<ol className="bid-list">
{draftState.bids?.map((bid, idx) => (
<li key={idx}>{bid.user}: {bid.amount}</li>
))}
</ol>
</div>
</div>
<div className="bid-controls btn-group d-flex flex-column">
<form id="bid" onSubmit={submitBidRequest}>
<div className="d-flex">
<div className="flex-grow-1 text-center">
<input type="number" id="bidAmount" name="bidAmount"></input>
</div>
<div className="flex-grow-1 text-center">
<button className="flex-grow-1">Submit</button>
</div>
</div>
<div className="d-flex">
</div>
</form>
</div>
<div>
<ul className="pick-list">
<li>
<div>Current Pick: {draftState.current_pick?.participant}</div>
</li>
<li
>
<div>Next Pick: {draftState.next_picks ? draftState.next_picks[0]?.participant : ""}</div>
</li>
</ul>
</div>
</div>
</section>
<section className="panel draft-catalog">
<header className="panel-header">
<h2 className="panel-title">Draft Catalog</h2>
</header>
<div className="panel-body">
<div className="current-movie card">
<span>Current Nomination: {movies.find(i => draftState.current_movie == i.id)?.title}</span>
</div>
<NominateMenu socket={socket} currentUser={currentUser} draftState={draftState} draftDetails={draftDetails}></NominateMenu>
<div className="movie-filters"></div>
<DraftMoviePool isParticipant={true} draftDetails={draftDetails} draftState={draftState}></DraftMoviePool>
</div>
</section>
<section className="panel my-team">
<header className="panel-header">
<h2 className="panel-title">My Team</h2>
</header>
<div className="panel-body">
<ul className="team-movie-list list-group">
<li className="team-movie-item list-group-item"></li>
</ul>
<div className="budget-status"></div>
</div>
</section>
<section className="panel teams">
<header className="panel-header">
<h2 className="panel-title">Teams</h2>
</header>
<div className="panel-body">
<ParticipantList
currentUser={currentUser}
draftState={draftState}
draftDetails={draftDetails}
/>
<ul className="team-list list-group">
<li className="team-item list-group-item">
<div className="team-name fw-bold"></div>
<ul className="team-movie-list list-group list-group-flush">
<li className="team-movie-item list-group-item"></li>
</ul>
</li>
</ul>
</div>
</section>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import { DraftMessage } from "../constants";
import { DraftMessage } from "./constants";
export async function fetchDraftDetails(draftSessionId) {
return fetch(`/api/draft/${draftSessionId}/`)
@@ -37,38 +37,12 @@ export function isEmptyObject(obj) {
export const handleDraftStatusMessages = (event, setDraftState) => {
const message = JSON.parse(event.data);
const { type, payload } = message;
console.log("Message: ", type, event?.data);
if (!payload) return;
const {
connected_participants,
phase,
draft_order,
draft_index,
current_movie,
bidding_timer_end,
bidding_timer_start,
current_pick,
next_picks,
bids
} = payload;
if (type == DraftMessage.STATUS_SYNC_INFORM) {
if (type == DraftMessage.DRAFT_STATUS_INFORM) {
setDraftState(payload);
}
setDraftState((prev) => ({
...prev,
...(connected_participants ? { connected_participants } : {}),
...(draft_order ? { draft_order } : {}),
...(draft_index ? { draft_index } : {}),
...(phase ? { phase: Number(phase) } : {}),
...(current_movie ? { current_movie } : {}),
...(bidding_timer_end ? { bidding_timer_end: Number(bidding_timer_end) } : {}),
...(current_pick ? { current_pick } : {}),
...(next_picks ? { next_picks } : {}),
...(bids ? {bids} : {})
}));
};
export const handleUserIdentifyMessages = (event, setUser) => {
@@ -76,8 +50,16 @@ export const handleUserIdentifyMessages = (event, setUser) => {
const { type, payload } = message;
if (type == DraftMessage.USER_IDENTIFICATION_INFORM) {
console.log("Message: ", type, event.data);
const { user } = payload;
setUser(user);
}
};
export const handleUserStatusMessages = (event, setUserStatus) => {
const message = JSON.parse(event.data);
const { type, payload } = message;
if (type == DraftMessage.USER_STATE_INFORM) {
setUserStatus(payload);
}
};

View File

@@ -2,28 +2,28 @@ import './scss/styles.scss'
import React from "react";
import { createRoot } from "react-dom/client";
import { WebSocketProvider } from "./apps/draft/common/WebSocketContext.jsx";
import { DraftAdmin } from "./apps/draft/admin/DraftAdmin.jsx";
import { DraftParticipant} from './apps/draft/participant/DraftParticipant.jsx'
import { WebSocketProvider } from "./apps/draft/components/WebSocketContext.jsx";
import { DraftAdmin } from "./apps/draft/DraftAdminBar.jsx";
import { DraftParticipant} from './apps/draft/DraftDashboard.jsx'
import { DraftDebug} from './apps/draft/DraftDebug.jsx'
const draftAdminRoot = document.getElementById("draft-admin-root");
const draftAdminBarRoot = document.getElementById("draft-admin-bar-root");
const draftPartipantRoot = document.getElementById("draft-participant-root")
const draftDebugRoot = document.getElementById("draft-debug-root")
const {draftSessionId} = window; // from backend template
const {draftSessionId, isAdmin} = window; // from backend template
if (draftPartipantRoot) {
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/participant`;
createRoot(draftPartipantRoot).render(
<WebSocketProvider url={wsUrl}>
<DraftParticipant draftSessionId={draftSessionId} />
<DraftParticipant draftSessionId={draftSessionId} className={`${isAdmin ? 'admin':''}`}/>
</WebSocketProvider>
);
}
if (draftAdminRoot) {
if (draftAdminBarRoot) {
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/admin`;
createRoot(draftAdminRoot).render(
createRoot(draftAdminBarRoot).render(
<WebSocketProvider url={wsUrl}>
<DraftAdmin draftSessionId={draftSessionId}/>
</WebSocketProvider>

View File

@@ -1,6 +1,6 @@
@use "../../node_modules/bootstrap/scss/bootstrap.scss";
@use "./fonts/graphique.css";
@import url("https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Oswald:wght@200..700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Bebas+Neue&family=League+Gothic&family=Oswald:wght@200..700&display=swap");
// Import only functions & variables
@import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
@@ -95,40 +95,83 @@
}
}
.participant-list-container,
.movie-pool-container {
max-width: 575.98px;
label {
@extend .fs-3;
}
ol.participant-list {
@extend .list-group-numbered;
}
ol.participant-list,
ul.participant-list {
@extend .list-group;
ol,
ul {
@extend .p-0;
}
ol {
@extend .list-group-numbered;
}
li {
@extend .list-group-item;
@extend .d-flex;
@extend .justify-content-between;
@extend .align-items-center;
span {
@extend .me-auto;
@extend .ps-1;
.team-name {
@extend .flex-grow-1;
@extend .ps-2;
}
.team-movie-list {
li {
@extend .p-0;
}
}
}
.current-user {
@extend .fw-bold;
&::after {
content: " *";
// content: " *";
font-size: 1em; // adjust as needed
}
}
}
#draft-participant-root,
#draft-admin-root {
.movie-pool-container {
img {
height: 128px;
}
a {
@extend .text-decoration-none;
@extend .text-reset;
}
thead {
display: block;
}
tbody {
display: block;
// height: 200px; /* or any desired height */
overflow-y: auto;
}
th,
td {
width: 150px; /* Set consistent widths to align columns */
box-sizing: border-box;
}
}
#draft-admin-bar {
@extend .d-flex;
@extend .flex-column;
@extend .border-top;
@extend .border-bottom;
@extend .gap-2;
@extend .p-2;
@extend .shadow-sm;
div {
@extend .d-flex;
@extend .justify-content-center;
}
}
.admin-override {
button {
@extend .btn-warning;
}
}
#draft-participant-root {
@extend .flex-grow-1;
.wrapper:first-child {
@extend .p-2;
@@ -137,47 +180,65 @@
gap: 1rem; /* space between panels */
justify-content: center; /* center the panels horizontally */
section {
max-width: 450px; /* never go beyond this */
min-width: 300px; /* keeps them from getting too small */
flex: 1 1 350px; /* grow/shrink, base width */
}
.panel {
@extend .border;
@extend .shadow-sm;
@extend .rounded-2;
flex: 1 1 350px; /* grow/shrink, base width */
max-width: 450px; /* never go beyond this */
min-width: 300px; /* keeps them from getting too small */
header.panel-header {
@extend .p-1;
@extend .text-uppercase;
@extend .align-items-center;
@extend .border-bottom;
@extend .border-secondary;
background-color: $blue-100;
@extend .border-2;
@extend .border-secondary-subtle;
// background-color: $blue-100;
@extend .bg-dark;
@extend .bg-gradient;
@extend .text-light;
@extend .rounded-top-2;
.panel-title {
@extend .ms-2;
@extend .fw-bold;
@extend .fs-5;
}
}
}
.panel.draft-live {
.bids-container {
overflow: scroll;
height: 85px;
}
#draft-live {
header.panel-header {
@extend .d-flex;
@extend .justify-content-between;
}
.draft-live-state-container {
@extend .d-flex;
background-color: $green-100;
#draft-clock {
@extend .row;
@extend .g-0;
// background-color: $green-100;
@extend .text-light;
@extend .text-bg-dark;
@extend .lh-1;
.countdown-clock {
@extend .fs-1;
@extend .fw-bold;
font-family: "League Gothic";
font-size: $font-size-base * 5;
@extend .fw-bolder;
@extend .col;
@extend .align-content-center;
@extend .text-center;
}
.pick-description {
@extend .col;
@extend .align-content-center;
}
}
div:has(.pick-list), div:has(.bid-list){
div:has(.pick-list),
div:has(.bid-list) {
ul {
@extend .list-group;
}

0
scripts/generate_js_constants.py Normal file → Executable file
View File