Refactor draft messaging to unified enum-based protocol

- Replaced scattered message strings with `DraftMessage` `StrEnum` and
  numeric `DraftPhase` `IntEnum` for clear, centralized definitions.
- Added Python→JS constants sync via `scripts/generate_js_constants.py`
  to ensure backend/frontend parity.
- Refactored WebSocket consumers to use `broadcast.*` and
  `direct.message` handlers with `_dispatch_broadcast` for consistent
  event delivery.
- Enhanced `DraftStateManager` to store `draft_index` and explicitly
  manage `connected_participants`.
- Added colored logging config in settings for improved debugging.
- Frontend: split UI into `ParticipantList` and `DraftMoviePool`,
  extracted message handlers (`handleDraftStatusMessages`,
  `handleUserIdentifyMessages`), and updated components to use new
  message/phase enums.
This commit is contained in:
2025-08-10 13:16:07 -05:00
parent 24700071ed
commit 28c98afc32
11 changed files with 509 additions and 341 deletions

View File

@@ -151,3 +151,28 @@ CHANNEL_LAYERS = {
} }
HASHIDS_SALT = os.getenv("BOF_HASHIDS_SALT", "your-very-secret-salt-string") HASHIDS_SALT = os.getenv("BOF_HASHIDS_SALT", "your-very-secret-salt-string")
COLOR_GREEN = "\033[92m"
COLOR_RESET = "\033[0m"
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'prefix': {
'format': f'{COLOR_GREEN}[%(name)s]{COLOR_RESET} %(levelname)s %(asctime)s %(name)s: %(message)s',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'prefix'
},
},
'loggers': {
'draft.consumers': {
'handlers': ['console'],
'level': 'INFO', # Only INFO and above
'propagate': False,
},
},
}

View File

@@ -1,43 +1,51 @@
from enum import IntEnum from enum import IntEnum, StrEnum
class DraftMessage: class DraftMessage(StrEnum):
# Server # Participant
INFORM_PHASE_CHANGE = "inform.phase.change" PARTICIPANT_JOIN_REQUEST = "participant.join.request" # client -> server
CONFIRM_PHASE_CHANGE = "confirm.phase.change" PARTICIPANT_JOIN_CONFIRM = "participant.join.confirm" # server -> client
INFORM_PHASE = "inform.phase" PARTICIPANT_JOIN_REJECT = "participant.join.reject" # server -> client
INFORM_DRAFT_STATUS = "inform.draft_status" PARTICIPANT_LEAVE_INFORM = "participant.leave.inform" # server -> client (broadcast)
# Client # User presence
REQUEST_PHASE_CHANGE = "request.phase.change" USER_JOIN_INFORM = "user.join.inform" # server -> client
REQUEST_DRAFT_STATUS = "request.draft_status" 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
# Waiting Phase # Phase control
## Server PHASE_CHANGE_INFORM = "phase.change.inform" # server -> client (target phase payload)
INFORM_JOIN_USER = "inform.join.user" PHASE_CHANGE_REQUEST = "phase.change.request" # server -> client (target phase payload)
REQUEST_JOIN_PARTICIPANT = "request.join.participant" PHASE_CHANGE_CONFIRM = "phase.change.confirm" # server -> client (target phase payload)
REQUEST_JOIN_ADMIN = "request.join.admin"
INFORM_LEAVE_PARTICIPANT = "inform.leave.participant"
## Client # Status / sync
NOTIFY_JOIN_USER = "notify.join.user" STATUS_SYNC_REQUEST = "status.sync.request" # client -> server
CONFIRM_JOIN_PARTICIPANT = "confirm.join.participant" STATUS_SYNC_INFORM = "status.sync.inform" # server -> client (full/partial state)
REJECT_JOIN_PARTICIPANT = "reject.join.participant"
CONFIRM_JOIN_ADMIN = "confirm.join.admin"
# Determine Order DRAFT_INDEX_ADVANCE_REQUEST = "draft.index.advance.request"
## Server DRAFT_INDEX_ADVANCE_CONFIRM = "draft.index.advance.confirm"
CONFIRM_DETERMINE_DRAFT_ORDER = "confirm.determine.draft_order"
## Client # Order determination
REQUEST_DETERMINE_DRAFT_ORDER = "request.determine.draft_order" ORDER_DETERMINE_REQUEST = "order.determine.request" # client -> server (admin)
ORDER_DETERMINE_CONFIRM = "order.determine.confirm" # server -> client
# Bidding (examples, adjust to your flow)
BID_START_INFORM = "bid.start.inform" # server -> client (movie, ends_at)
BID_PLACE_REQUEST = "bid.place.request" # client -> server (amount)
BID_UPDATE_INFORM = "bid.update.inform" # server -> client (high bid)
BID_END_INFORM = "bid.end.inform" # server -> client (winner)
# Nomination (examples)
NOMINATION_SUBMIT_REQUEST = "nomination.submit.request" # client -> server (movie_id)
NOMINATION_CONFIRM = "nomination.submit.confirm" # server -> client
class DraftPhase(IntEnum): class DraftPhase(IntEnum):
WAITING = 0 WAITING = 10
DETERMINE_ORDER = 10 DETERMINE_ORDER = 20
NOMINATION = 20 NOMINATING = 30
BIDDING = 30 BIDDING = 40
AWARD = 40 AWARDING = 50
FINALIZE = 50 FINALIZING = 60
def __str__(self): def __str__(self):
return self.name.lower() return self.name.lower()

View File

@@ -14,6 +14,10 @@ from draft.constants import (
) )
from draft.state import DraftCacheKeys, DraftStateManager from draft.state import DraftCacheKeys, DraftStateManager
from typing import Any from typing import Any
import logging
logger = logging.getLogger(__name__) # __name__ = module path
import random import random
@@ -40,18 +44,21 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
self.user = self.scope["user"] self.user = self.scope["user"]
if not self.should_accept_user(): if not self.should_accept_user():
await self.send_json( await self.channel_layer.send(
self.channel_name,
{ {
"type": DraftMessage.REJECT_JOIN_PARTICIPANT, "type": "direct.message",
"user": self.user.username, "subtype": DraftMessage.PARTICIPANT_JOIN_REJECT,
"payload":{"current_user": self.user.username}
} }
) )
await self.close() await self.close()
await self.channel_layer.group_send( await self.channel_layer.group_send(
self.group_names.session, self.group_names.admin,
{ {
"type": DraftMessage.REJECT_JOIN_PARTICIPANT, "type": "broadcast.admin",
"user": self.user.username, "subtype": DraftMessage.PARTICIPANT_JOIN_REJECT,
"payload":{"user": self.user.username}
}, },
) )
return return
@@ -62,81 +69,56 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
) )
await self.channel_layer.group_send( await self.channel_layer.group_send(
self.group_names.session, self.group_names.session,
{"type": DraftMessage.INFORM_JOIN_USER, "user": self.user.username},
)
await self.send_json(
{ {
"type": DraftMessage.INFORM_DRAFT_STATUS, "type": "broadcast.session",
"subtype": DraftMessage.USER_JOIN_INFORM,
"payload": {"user": self.user.username},
},
)
await self.channel_layer.send(
self.channel_name,
{
"type": "direct.message",
"subtype": DraftMessage.STATUS_SYNC_INFORM,
"payload": self.get_draft_status(), "payload": self.get_draft_status(),
}, },
) )
await self.channel_layer.send(
self.channel_name,
{
"type": "direct.message",
"subtype": DraftMessage.USER_IDENTIFICATION_INFORM,
"payload": {"user": self.user.username},
},
)
async def should_accept_user(self) -> bool: async def should_accept_user(self) -> bool:
return self.user.is_authenticated return self.user.is_authenticated
async def receive_json(self, content): async def receive_json(self, content):
event_type = content.get("type") event_type = content.get("type")
if event_type == DraftMessage.REQUEST_DRAFT_STATUS: if event_type == DraftMessage.STATUS_SYNC_REQUEST:
await self.send_json( await self.send_json(
{ {
"type": DraftMessage.INFORM_DRAFT_STATUS, "type": DraftMessage.STATUS_SYNC_INFORM,
"payload": self.get_draft_status(), "payload": self.get_draft_status(),
} }
) )
async def inform_leave_participant(self, event): # Broadcast Handlers
await self.send_json( async def direct_message(self, event):
{ await self._dispatch_broadcast(event)
"type": event["type"],
"user": event["user"],
"payload": {
"participants": [user.username for user in self.draft_participants],
"connected_participants": self.draft_state.connected_users,
},
}
)
async def inform_join_user(self, event): async def broadcast_session(self, event):
await self.send_json( await self._dispatch_broadcast(event)
{
"type": event["type"],
"payload": {
"user": event["user"],
"participants": [user.username for user in self.draft_participants],
"connected_participants": self.draft_state.connected_users,
},
}
)
async def inform_draft_status(self, event): async def _dispatch_broadcast(self, event):
await self.send_json( logger.info(f"dispatching message {event}")
{"type": event["type"], "payload": self.get_draft_status()} subtype = event.get("subtype")
) payload = event.get("payload", {})
await self.send_json({"type": subtype, "payload": payload})
async def reject_join_participant(self, event): # === Methods ===
await self.send_json(
{
"type": event["type"],
"user": event["user"],
}
)
async def inform_phase(self, event):
await self.send_json({"type": event["type"], "phase": event["phase"]})
async def confirm_determine_draft_order(self, event):
await self.send_json(
{
"type": DraftMessage.CONFIRM_DETERMINE_DRAFT_ORDER,
"payload": event["payload"],
}
)
async def confirm_phase_change(self, event):
await self.send_json({"type": event["type"], "payload": event["payload"]})
async def send_draft_summary(self): ...
def get_draft_status(self) -> dict[str, Any]: def get_draft_status(self) -> dict[str, Any]:
return { return {
@@ -145,8 +127,6 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
"participants": [user.username for user in self.draft_participants], "participants": [user.username for user in self.draft_participants],
} }
# === Broadcast handlers ===
# === DB Access === # === DB Access ===
@database_sync_to_async @database_sync_to_async
def get_draft_session(self, draft_session_id_hashed) -> DraftSession: def get_draft_session(self, draft_session_id_hashed) -> DraftSession:
@@ -177,29 +157,42 @@ class DraftAdminConsumer(DraftConsumerBase):
async def receive_json(self, content): async def receive_json(self, content):
await super().receive_json(content) await super().receive_json(content)
logger.info(f"Receive message {content}")
event_type = content.get("type") event_type = content.get("type")
if ( if (
event_type == DraftMessage.REQUEST_PHASE_CHANGE event_type == DraftMessage.PHASE_CHANGE_REQUEST
and content.get("destination") == DraftPhase.DETERMINE_ORDER and content.get("destination") == DraftPhase.DETERMINE_ORDER
): ):
await self.determine_draft_order() await self.determine_draft_order()
if ( if (
event_type == DraftMessage.REQUEST_PHASE_CHANGE event_type == DraftMessage.PHASE_CHANGE_REQUEST
and content.get("destination") == DraftPhase.NOMINATION and content.get("destination") == DraftPhase.NOMINATING
): ):
await self.start_nominate(); await self.start_nominate()
if event_type == DraftMessage.DRAFT_INDEX_ADVANCE_REQUEST:
self.draft_state.draft_index += 1
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.DRAFT_INDEX_ADVANCE_CONFIRM,
"payload": self.draft_state.get_summary(),
},
)
def should_accept_user(self): def should_accept_user(self):
return super().should_accept_user() and self.user.is_staff return super().should_accept_user() and self.user.is_staff
# === Draft logic === # === Draft logic ===
async def start_nominate(self): async def start_nominate(self):
await self.set_draft_phase(DraftPhase.NOMINATION) await self.set_draft_phase(DraftPhase.NOMINATING)
await self.channel_layer.group_send( await self.channel_layer.group_send(
self.group_names.session, self.group_names.session,
{ {
"type": DraftMessage.CONFIRM_PHASE_CHANGE, "type": "broadcast.session",
"subtype": DraftMessage.PHASE_CHANGE_CONFIRM,
"payload": {"phase": self.draft_state.phase}, "payload": {"phase": self.draft_state.phase},
}, },
) )
@@ -214,7 +207,8 @@ class DraftAdminConsumer(DraftConsumerBase):
await self.channel_layer.group_send( await self.channel_layer.group_send(
self.group_names.session, self.group_names.session,
{ {
"type": DraftMessage.CONFIRM_DETERMINE_DRAFT_ORDER, "type": "broadcast.session",
"subtype": DraftMessage.ORDER_DETERMINE_CONFIRM,
"payload": {"draft_order": self.draft_state.draft_order}, "payload": {"draft_order": self.draft_state.draft_order},
}, },
) )
@@ -224,31 +218,55 @@ class DraftAdminConsumer(DraftConsumerBase):
await self.channel_layer.group_send( await self.channel_layer.group_send(
self.group_names.session, self.group_names.session,
{ {
"type": DraftMessage.CONFIRM_PHASE_CHANGE, "type": "broadcast.session",
"subtype": DraftMessage.PHASE_CHANGE_CONFIRM,
"payload": {"phase": self.draft_state.phase}, "payload": {"phase": self.draft_state.phase},
}, },
) )
# === Broadcast Handlers === # === Broadcast Handlers ===
async def broadcast_admin(self, event):
await self._dispatch_broadcast(event)
class DraftParticipantConsumer(DraftConsumerBase): class DraftParticipantConsumer(DraftConsumerBase):
async def connect(self): async def connect(self):
await super().connect() await super().connect()
self.draft_state.connect_user(self.user.username) self.draft_state.connect_participant(self.user.username)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.PARTICIPANT_JOIN_CONFIRM,
"payload": {
"user": self.user.username,
"connected_participants": self.draft_state.connected_participants,
},
},
)
await self.channel_layer.group_add( await self.channel_layer.group_add(
self.group_names.participant, self.channel_name self.group_names.participant, self.channel_name
) )
async def disconnect(self, close_code): async def disconnect(self, close_code):
self.draft_state.disconnect_participant(self.user.username)
await self.channel_layer.group_send( await self.channel_layer.group_send(
self.group_names.session, self.group_names.session,
{"type": DraftMessage.INFORM_LEAVE_PARTICIPANT, "user": self.user.username}, {
"type": "broadcast.session",
"subtype": DraftMessage.PARTICIPANT_LEAVE_INFORM,
"payload": {
"user": self.user.username,
"connected_participants": self.draft_state.connected_participants,
},
},
) )
await super().disconnect(close_code) await super().disconnect(close_code)
self.draft_state.disconnect_user(self.user.username) self.draft_state.disconnect_participant(self.user.username)
await self.channel_layer.group_discard( await self.channel_layer.group_discard(
self.group_names.session, self.channel_name self.group_names.session, self.channel_name
) )
@@ -258,24 +276,11 @@ class DraftParticipantConsumer(DraftConsumerBase):
async def receive_json(self, content): async def receive_json(self, content):
await super().receive_json(content) await super().receive_json(content)
event_type = content.get("type")
user = self.scope["user"]
if event_type == DraftMessage.REQUEST_JOIN_PARTICIPANT:
await self.channel_layer.group_send(
self.group_names.admin,
{"type": DraftMessage.REQUEST_JOIN_PARTICIPANT, "user": user},
)
# === Broadcast handlers === # === Broadcast handlers ===
async def request_join_participant(self, event): async def broadcast_participant(self, event):
await self.send_json( await self._dispatch_broadcast(event)
{
"type": event["type"],
"user": event["user"],
}
)
# === Draft === # === Draft ===

View File

@@ -32,6 +32,10 @@ class DraftCacheKeys:
@property @property
def draft_order(self): def draft_order(self):
return f"{self.prefix}:draft_order" return f"{self.prefix}:draft_order"
@property
def draft_index(self):
return f"{self.prefix}:draft_index"
# @property # @property
# def state(self): # def state(self):
@@ -64,30 +68,30 @@ class DraftStateManager:
self.session_id = session_id self.session_id = session_id
self.cache = cache self.cache = cache
self.keys = DraftCacheKeys(session_id) self.keys = DraftCacheKeys(session_id)
self._phase = self.cache.get(self.keys.phase, DraftPhase.WAITING) self._initial_phase = self.cache.get(self.keys.phase, DraftPhase.WAITING.value)
# === Phase Management === # === Phase Management ===
@property @property
def phase(self) -> str: def phase(self) -> str:
return str(self.cache.get(self.keys.phase, self._phase)) return str(self.cache.get(self.keys.phase, self._initial_phase))
@phase.setter @phase.setter
def phase(self, new_phase: DraftPhase): def phase(self, new_phase: DraftPhase):
self.cache.set(self.keys.phase, new_phase) self.cache.set(self.keys.phase, new_phase.value)
# === Connected Users === # === Connected Users ===
@property @property
def connected_users(self) -> list[str]: def connected_participants(self) -> list[str]:
return json.loads(self.cache.get(self.keys.connected_users) or "[]") return json.loads(self.cache.get(self.keys.connected_users) or "[]")
def connect_user(self, username: str): def connect_participant(self, username: str):
users = set(self.connected_users) users = set(self.connected_participants)
users.add(username) users.add(username)
self.cache.set(self.keys.connected_users, json.dumps(list(users))) self.cache.set(self.keys.connected_users, json.dumps(list(users)))
def disconnect_user(self, username: str): def disconnect_participant(self, username: str):
users = set(self.connected_users) users = set(self.connected_participants)
users.discard(username) users.discard(username)
self.cache.set(self.keys.connected_users, json.dumps(list(users))) self.cache.set(self.keys.connected_users, json.dumps(list(users)))
@@ -102,6 +106,14 @@ class DraftStateManager:
return return
self.cache.set(self.keys.draft_order,json.dumps(draft_order)) self.cache.set(self.keys.draft_order,json.dumps(draft_order))
@property
def draft_index(self):
return self.cache.get(self.keys.draft_index,0)
@draft_index.setter
def draft_index(self, draft_index: int):
self.cache.set(self.keys.draft_index, int(draft_index))
# === Current Nomination / Bid === # === Current Nomination / Bid ===
def start_nomination(self, movie_id: int): def start_nomination(self, movie_id: int):
self.cache.set(self.keys.current_movie, movie_id) self.cache.set(self.keys.current_movie, movie_id)
@@ -132,7 +144,8 @@ class DraftStateManager:
return { return {
"phase": self.phase, "phase": self.phase,
"draft_order": self.draft_order, "draft_order": self.draft_order,
"connected_users": self.connected_users, "draft_index": self.draft_index,
"connected_participants": self.connected_participants,
# "current_movie": self.cache.get(self.keys.current_movie), # "current_movie": self.cache.get(self.keys.current_movie),
# "bids": self.get_bids(), # "bids": self.get_bids(),
# "timer_end": self.get_timer_end(), # "timer_end": self.get_timer_end(),

View File

@@ -1,53 +1,13 @@
// DraftAdmin.jsx
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useWebSocket } from "../WebSocketContext.jsx"; import { useWebSocket } from "../WebSocketContext.jsx";
import { WebSocketStatus } from "../common/WebSocketStatus.jsx"; import { WebSocketStatus } from "../common/WebSocketStatus.jsx";
import { DraftMessage, DraftPhases, DraftPhase } from '../constants.js'; import { ParticipantList } from "../common/ParticipantList.jsx";
import { fetchDraftDetails } from "../common/utils.js" import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from '../constants.js';
import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "../common/utils.js"
const ParticipantList = ({ socket, participants, draftOrder }) => { import { DraftMoviePool } from "../common/DraftMoviePool.jsx"
const [connectedParticipants, setConnectedParticipants] = useState([])
useEffect(() => {
const handleMessage = async ({ data }) => {
const message = JSON.parse(data)
const { type, payload } = message
console.log('socket changed', message)
if (payload?.connected_participants) {
const { connected_participants } = payload
setConnectedParticipants(connected_participants)
}
}
socket.addEventListener("message", handleMessage)
return () => {
socket.removeEventListener("message", handleMessage)
}
}, [socket])
const ListTag = draftOrder.length > 0 ? "ol" : "ul"
console.log
const listItems = draftOrder.length > 0 ? draftOrder.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}>
<span>{p?.full_name}</span>
<div
className={
`ms-2 stop-light ${connectedParticipants.includes(p?.username) ? "success" : "danger"}`
}
></div>
</li>
))}
</ListTag>
</div>
)
}
const DraftPhaseDisplay = ({ draftPhase, nextPhaseHandler, prevPhaseHandler }) => { const DraftPhaseDisplay = ({ draftPhase, nextPhaseHandler, prevPhaseHandler }) => {
return ( return (
@@ -57,14 +17,14 @@ const DraftPhaseDisplay = ({ draftPhase, nextPhaseHandler, prevPhaseHandler }) =
<div className="change-phase"><button onClick={prevPhaseHandler}><i className="bi bi-chevron-left"></i></button></div> <div className="change-phase"><button onClick={prevPhaseHandler}><i className="bi bi-chevron-left"></i></button></div>
<ol> <ol>
{ {
DraftPhases.map((p) => ( DraftPhasesOrdered.map((p) => (
<li key={p} className={p === draftPhase ? "current-phase" : ""}> <li key={p} className={p === draftPhase ? "current-phase" : ""}>
<span>{p}</span> <span>{DraftPhaseLabel[p]}</span>
</li> </li>
)) ))
} }
</ol> </ol>
<div className="change-phase"><button onClick={nextPhaseHandler}><i className="bi bi-chevron-right"></i></button></div> <div className="change-phase"><button onClick={nextPhaseHandler}><i className="bi bi-chevron-right"></i></button></div>
</div> </div>
</div> </div>
) )
@@ -72,86 +32,84 @@ const DraftPhaseDisplay = ({ draftPhase, nextPhaseHandler, prevPhaseHandler }) =
export const DraftAdmin = ({ draftSessionId }) => { export const DraftAdmin = ({ draftSessionId }) => {
const socket = useWebSocket(); const socket = useWebSocket();
const [connectedParticipants, setConnectedParticipants] = useState([]);
const [draftDetails, setDraftDetails] = useState(); const [draftDetails, setDraftDetails] = useState();
const [participants, setParticipants] = React.useState([]); const [draftState, setDraftState] = useState({})
const [draftPhase, setDraftPhase] = useState(); const [currentUser, setCurrentUser] = useState(null);
const [draftOrder, setDraftOrder] = useState([]);
console.log(socket)
useEffect(() => { useEffect(() => {
fetchDraftDetails(draftSessionId) fetchDraftDetails(draftSessionId)
.then((data) => { .then((data) => {
console.log("Fetched draft data", data) console.log("Fetched draft data", data)
setParticipants(data.participants) setDraftDetails(data)
}) })
}, []) }, [])
useEffect(() => { useEffect(()=>{
if (!socket) return;
const openHandler = (event)=>{
console.log('Websocket Opened')
}
const closeHandler = (event)=>{
console.log('Websocket Closed')
}
socket.addEventListener('open', openHandler );
socket.addEventListener('close', closeHandler );
return ()=>{
socket.removeEventListener('open', openHandler );
socket.removeEventListener('close', closeHandler );
}
}, [socket])
useEffect(() => {
if (!socket) return; if (!socket) return;
const handleMessage = (event) => { const draftStatusMessageHandler = (event) => handleDraftStatusMessages(event, setDraftState)
const message = JSON.parse(event.data) const userIdentifyMessageHandler = (event) => handleUserIdentifyMessages(event, setCurrentUser)
const { type, payload } = message; socket.addEventListener('message', draftStatusMessageHandler );
console.log(type, event) socket.addEventListener('message', userIdentifyMessageHandler );
if (!payload) return
if (type == DraftMessage.REQUEST.JOIN_PARTICIPANT) {
console.log('join request', data)
}
if (payload.phase) {
console.log('phase_change')
setDraftPhase(payload.phase)
}
if (payload.draft_order) {
console.log('draft_order', payload.draft_order)
setDraftOrder(payload.draft_order)
}
}
socket.addEventListener('message', handleMessage);
socket.onclose = (event) => {
console.log('Websocket Closed')
socket = null;
}
return () => { return () => {
socket.removeEventListener('message', handleMessage) socket.removeEventListener('message', draftStatusMessageHandler)
socket.close(); socket.removeEventListener('message', userIdentifyMessageHandler );
}; };
}, [socket]); }, [socket]);
const handlePhaseChange = (target) => { const handlePhaseChange = (target) => {
let destination let destination
const origin = draftPhase const origin = draftState.phase
if (target == "next") { const originPhaseIndex = DraftPhasesOrdered.findIndex(i => i == origin)
console.log(DraftPhase) console.log('origin phase index', originPhaseIndex)
console.log("phase to be changed", origin, target, DraftPhase.WAITING) if (target == "next" && originPhaseIndex < DraftPhasesOrdered.length) {
if (origin == "waiting"){ destination = DraftPhasesOrdered[originPhaseIndex + 1]
destination = DraftPhase.DETERMINE_ORDER
} else if (origin == "determine_order"){
destination = DraftPhase.NOMINATION
}
}
else if (target=="previous") {
} }
else if (target == "previous" && originPhaseIndex > 0) {
if (!destination) {return} destination = DraftPhasesOrdered[originPhaseIndex - 1]
}
console.log(destination)
socket.send( socket.send(
JSON.stringify( JSON.stringify(
{ type: DraftMessage.REQUEST.PHASE_CHANGE, origin, destination } { type: DraftMessage.PHASE_CHANGE_REQUEST, origin, destination }
) )
) )
} }
const handleStartDraft = () => {
}
const handleAdvanceDraft = () => {
socket.send(
JSON.stringify(
{ type: DraftMessage.DRAFT_INDEX_ADVANCE_REQUEST }
)
)
}
const handleRequestDraftSummary = () => { const handleRequestDraftSummary = () => {
socket.send( socket.send(
JSON.stringify( JSON.stringify(
{ type: DraftMessage.REQUEST.DRAFT_STATUS } { type: DraftMessage.STATUS_SYNC_REQUEST }
) )
) )
} }
@@ -169,12 +127,14 @@ export const DraftAdmin = ({ draftSessionId }) => {
</div> </div>
<ParticipantList <ParticipantList
socket={socket} draftState={draftState}
participants={participants} draftDetails={draftDetails}
draftOrder={draftOrder} isAdmin={true}
/> />
<button onClick={handleAdvanceDraft} className="btn btn-primary">Advance Draft</button>
<DraftMoviePool draftDetails={draftDetails}></DraftMoviePool>
<DraftPhaseDisplay draftPhase={draftPhase} nextPhaseHandler={ ()=>{handlePhaseChange('next')}} prevPhaseHandler= {() => {handlePhaseChange('previous')}}></DraftPhaseDisplay> <DraftPhaseDisplay draftPhase={draftState.phase} nextPhaseHandler={() => { handlePhaseChange('next') }} prevPhaseHandler={() => { handlePhaseChange('previous') }}></DraftPhaseDisplay>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,21 @@
import React from "react";
import { isEmptyObject } from "./utils";
export const DraftMoviePool = ({ draftDetails }) => {
if(isEmptyObject(draftDetails)) {return}
const {movies} = draftDetails
return (
<div className="movie-pool-container">
<ul>
{movies.map(m => (
<li key={m.id}>
<a href={`/api/movie/${m.id}/detail`}>
{m.title}
</a>
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import React from "react";
import { fetchDraftDetails, isEmptyObject } from "../common/utils.js"
export const ParticipantList = ({ isAdmin, draftState, draftDetails }) => {
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"
console.log
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>{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,3 +1,5 @@
import { DraftMessage } from "../constants"
export async function fetchDraftDetails(draftSessionId) { export async function fetchDraftDetails(draftSessionId) {
return fetch(`/api/draft/${draftSessionId}/`) return fetch(`/api/draft/${draftSessionId}/`)
.then((response) => { .then((response) => {
@@ -26,4 +28,40 @@ export async function fetchMovieDetails(draftSessionId) {
.catch((err) => { .catch((err) => {
console.error("Error fetching draft details", err) console.error("Error fetching draft details", err)
}) })
} }
export function isEmptyObject(obj) {
return obj == null || (Object.keys(obj).length === 0 && obj.constructor === Object);
}
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} = payload
if (type == DraftMessage.STATUS_SYNC_INFORM) {
setDraftState(payload)
}
setDraftState(prev=>({
...prev,
...(connected_participants ? { connected_participants } : {}),
...(draft_order ? { draft_order } : {}),
...(draft_index ? { draft_index } : {}),
...(phase ? { phase: Number(phase) } : {}),
}))
}
export const handleUserIdentifyMessages = (event, setUser) => {
const message = JSON.parse(event.data)
const { type, payload } = message;
console.log("Message: ", type, event?.data)
if (!payload) return
const {current_user} = payload
setUser(current_user)
}

View File

@@ -1,51 +1,48 @@
// AUTO-GENERATED. Do not edit by hand.
// Run: python scripts/generate_js_constants.py
export const DraftMessage = { export const DraftMessage = {
// Server to Client PARTICIPANT_JOIN_REQUEST: "participant.join.request",
INFORM: { PARTICIPANT_JOIN_CONFIRM: "participant.join.confirm",
PHASE_CHANGE: "inform.phase.change", PARTICIPANT_JOIN_REJECT: "participant.join.reject",
PHASE: "inform.phase", PARTICIPANT_LEAVE_INFORM: "participant.leave.inform",
STATUS: "inform.status", USER_JOIN_INFORM: "user.join.inform",
JOIN_USER: "inform.join.user", USER_LEAVE_INFORM: "user.leave.inform",
DRAFT_STATUS: "inform.draft_status" USER_IDENTIFICATION_INFORM: "user.identification.inform",
}, PHASE_CHANGE_INFORM: "phase.change.inform",
PHASE_CHANGE_REQUEST: "phase.change.request",
// Client to Server PHASE_CHANGE_CONFIRM: "phase.change.confirm",
REQUEST: { STATUS_SYNC_REQUEST: "status.sync.request",
PHASE_CHANGE: "request.phase.change", STATUS_SYNC_INFORM: "status.sync.inform",
INFORM_STATUS: "request.inform.status", DRAFT_INDEX_ADVANCE_REQUEST: "draft.index.advance.request",
JOIN_PARTICIPANT: "request.join.participant", DRAFT_INDEX_ADVANCE_CONFIRM: "draft.index.advance.confirm",
JOIN_ADMIN: "request.join.admin", ORDER_DETERMINE_REQUEST: "order.determine.request",
DETERMINE_DRAFT_ORDER: "request.determine.draft_order", ORDER_DETERMINE_CONFIRM: "order.determine.confirm",
DRAFT_STATUS: "request.draft_status" BID_START_INFORM: "bid.start.inform",
}, BID_PLACE_REQUEST: "bid.place.request",
BID_UPDATE_INFORM: "bid.update.inform",
// Confirmation messages (Server to Client) BID_END_INFORM: "bid.end.inform",
CONFIRM: { NOMINATION_SUBMIT_REQUEST: "nomination.submit.request",
PHASE_CHANGE: "confirm.phase.change", NOMINATION_CONFIRM: "nomination.submit.confirm",
JOIN_PARTICIPANT: "confirm.join.participant",
JOIN_ADMIN: "confirm.join.admin",
DETERMINE_DRAFT_ORDER: "confirm.determine.draft_order",
},
// Client-side notification (to server)
NOTIFY: {
JOIN_USER: "notify.join.user",
},
}; };
export const DraftPhase = { export const DraftPhase = {
WAITING: 0, WAITING: 10,
DETERMINE_ORDER: 10, DETERMINE_ORDER: 20,
NOMINATION: 20, NOMINATING: 30,
BIDDING: 30, BIDDING: 40,
AWARD: 40, AWARDING: 50,
FINALIZE: 50, FINALIZING: 60,
} };
export const DraftPhases = [ export const DraftPhaseLabel = {
"waiting", [DraftPhase.WAITING]: "waiting",
"determine_order", [DraftPhase.DETERMINE_ORDER]: "determine_order",
"nomination", [DraftPhase.NOMINATING]: "nominating",
"bidding", [DraftPhase.BIDDING]: "bidding",
"award", [DraftPhase.AWARDING]: "awarding",
"finalize", [DraftPhase.FINALIZING]: "finalizing",
] };
export const DraftPhasesOrdered = [DraftPhase.WAITING, DraftPhase.DETERMINE_ORDER, DraftPhase.NOMINATING, DraftPhase.BIDDING, DraftPhase.AWARDING, DraftPhase.FINALIZING];

View File

@@ -3,29 +3,18 @@ import React, { useEffect, useState } from "react";
import { useWebSocket } from "../WebSocketContext.jsx"; import { useWebSocket } from "../WebSocketContext.jsx";
import { WebSocketStatus } from "../common/WebSocketStatus.jsx"; import { WebSocketStatus } from "../common/WebSocketStatus.jsx";
import { DraftMessage, DraftPhases } from '../constants.js'; import { DraftMessage, DraftPhases} from '../constants.js';
import { fetchDraftDetails } from "../common/utils.js" import { fetchDraftDetails } from "../common/utils.js";
import { DraftMoviePool } from "../common/DraftMoviePool.jsx";
import { ParticipantList } from "../common/ParticipantList.jsx";
import { handleDraftStatusMessages } from '../common/utils.js'
const DraftMoviePool = ({ movies }) => {
return (
<div className="movie-pool-container">
<ul>
{movies.map(m => (
<li id={m?.id}>
<a href={`/api/movie/${m.id}/detail`}>
{m.title}
</a>
</li>
))}
</ul>
</div>
)
}
export const DraftParticipant = ({ draftSessionId }) => { export const DraftParticipant = ({ draftSessionId }) => {
const socket = useWebSocket(); const socket = useWebSocket();
const [participants, setParticipants] = useState([]); const [draftState, setDraftState] = useState({});
const [draftPhase, setDraftPhase] = useState(); const [draftDetails, setDraftDetails] = useState({});
const [movies, setMovies] = useState([]); const [movies, setMovies] = useState([]);
console.log(socket) console.log(socket)
@@ -34,65 +23,41 @@ export const DraftParticipant = ({ draftSessionId }) => {
.then((data) => { .then((data) => {
console.log("Fetched draft data", data) console.log("Fetched draft data", data)
setMovies(data.movies) setMovies(data.movies)
setDraftDetails(data)
}) })
}, []) }, [draftSessionId])
useEffect(()=>{
if (!socket) return;
socket.onclose = (event) => {
console.log('Websocket Closed')
}
}, [socket])
useEffect(() => { useEffect(() => {
if (!socket) return; if (!socket) return;
else {
console.warn("socket doesn't exist")
}
console.log('socket created', socket)
const handleMessage = (event) => { const handler = (event) => handleDraftStatusMessages(event, setDraftState)
const message = JSON.parse(event.data) socket.addEventListener('message', handler );
const { type, payload } = message;
console.log(type, event)
if (type == DraftMessage.REQUEST.JOIN_PARTICIPANT) {
console.log('join request', data)
}
else if (type == DraftMessage.CONFIRM.JOIN_PARTICIPANT) {
setConnectedParticipants(data.connected_participants)
}
else if (type == DraftMessage.CONFIRM.PHASE_CHANGE || type == DraftMessage.INFORM.PHASE) {
console.log('phase_change')
setDraftPhase(payload.phase)
}
}
socket.addEventListener('message', handleMessage);
socket.onclose = (event) => {
console.log('Websocket Closed')
socket = null;
}
return () => { return () => {
socket.removeEventListener('message', handleMessage) socket.addEventListener('message', handler );
socket.close(); socket.close();
}; };
}, [socket]); }, [socket]);
const handlePhaseChange = (destinationPhase) => {
socket.send(
JSON.stringify({ type: DraftMessage.REQUEST.PHASE_CHANGE, "destination": destinationPhase })
);
}
const handleRequestDraftSummary = () => {
socket.send(JSON.stringify({ type: 'request_summary' }))
}
return ( return (
<div className="container draft-panel"> <div className="container draft-panel">
<div className="d-flex justify-content-between border-bottom mb-2 p-1"> <div className="d-flex justify-content-between border-bottom mb-2 p-1">
<h3>Draft Panel</h3> <h3>Draft Panel</h3>
<WebSocketStatus socket={socket} /> <WebSocketStatus socket={socket} />
</div> </div>
<ParticipantList
<DraftMoviePool movies={movies}></DraftMoviePool> draftState={draftState}
draftDetails={draftDetails}
isAdmin={false}
/>
<DraftMoviePool draftDetails={draftDetails}></DraftMoviePool>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python
import importlib
import inspect
import os
import sys
from enum import Enum, IntEnum, StrEnum
# Adjust these for your project
PY_MODULE = "draft.constants" # where your enums live
OUTPUT_PATH = "frontend/src/apps/draft/constants.js"
# Optionally allow running from any cwd
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.abspath(os.path.join(PROJECT_ROOT, ".."))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
def js_quote(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"')
def titleize(name: str) -> str:
# e.g., "DETERMINE_ORDER" -> "Determine Order"
return name.replace("_", " ").title()
def emit_header():
return "// AUTO-GENERATED. Do not edit by hand.\n" \
"// Run: python scripts/generate_js_constants.py\n\n"
def emit_str_enum(name: str, enum_cls) -> str:
"""
Emit a JS object for StrEnum:
export const DraftMessage = { KEY: "value", ... };
"""
lines = [f"export const {name} = {{"] # ESM export
for member in enum_cls:
lines.append(f' {member.name}: "{js_quote(member.value)}",')
lines.append("};\n")
return "\n".join(lines)
def emit_int_enum(name: str, enum_cls) -> str:
"""
Emit a JS object + labels + ordered list for IntEnum:
export const DraftPhase = { KEY: number, ... };
export const DraftPhaseLabel = { [number]: "Pretty", ... };
export const DraftPhasesOrdered = [numbers...];
"""
lines = [f"export const {name} = {{"] # ESM export
items = list(enum_cls)
# object map
for member in items:
lines.append(f" {member.name}: {int(member.value)},")
lines.append("};\n")
# label map (use .pretty_name if you added it; else derive from name or __str__)
lines.append(f"export const {name}Label = {{")
for member in items:
if hasattr(member, "pretty_name"):
label = getattr(member, "pretty_name")
else:
# fall back: __str__ if you overload it, else Title Case of name
label = str(member)
if label == f"{enum_cls.__name__}.{member.name}":
label = titleize(member.name)
lines.append(f' [{name}.{member.name}]: "{js_quote(label)}",')
lines.append("};\n")
# ordered list
ordered = sorted(items, key=lambda m: int(m.value))
ordered_vals = ", ".join(f"{name}.{m.name}" for m in ordered)
lines.append(f"export const {name}sOrdered = [{ordered_vals}];\n")
return "\n".join(lines)
def main():
mod = importlib.import_module(PY_MODULE)
out = [emit_header()]
# Pick which enums to export. You can filter here if you dont want all.
for name, obj in inspect.getmembers(mod):
ignore_classes = [Enum, IntEnum, StrEnum]
if inspect.isclass(obj) and issubclass(obj, Enum) and not obj in ignore_classes:
# Skip helper classes that arent actual Enums
if name.startswith("_"):
continue
if issubclass(obj, IntEnum):
out.append(emit_int_enum(name, obj))
else:
out.append(emit_str_enum(name, obj))
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
f.write("\n".join(out))
print(f"✅ Wrote {OUTPUT_PATH}")
if __name__ == "__main__":
main()