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")
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:
# Server
INFORM_PHASE_CHANGE = "inform.phase.change"
CONFIRM_PHASE_CHANGE = "confirm.phase.change"
INFORM_PHASE = "inform.phase"
INFORM_DRAFT_STATUS = "inform.draft_status"
class DraftMessage(StrEnum):
# Participant
PARTICIPANT_JOIN_REQUEST = "participant.join.request" # client -> server
PARTICIPANT_JOIN_CONFIRM = "participant.join.confirm" # server -> client
PARTICIPANT_JOIN_REJECT = "participant.join.reject" # server -> client
PARTICIPANT_LEAVE_INFORM = "participant.leave.inform" # server -> client (broadcast)
# Client
REQUEST_PHASE_CHANGE = "request.phase.change"
REQUEST_DRAFT_STATUS = "request.draft_status"
# User presence
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
# Waiting Phase
## Server
INFORM_JOIN_USER = "inform.join.user"
REQUEST_JOIN_PARTICIPANT = "request.join.participant"
REQUEST_JOIN_ADMIN = "request.join.admin"
INFORM_LEAVE_PARTICIPANT = "inform.leave.participant"
# Phase control
PHASE_CHANGE_INFORM = "phase.change.inform" # server -> client (target phase payload)
PHASE_CHANGE_REQUEST = "phase.change.request" # server -> client (target phase payload)
PHASE_CHANGE_CONFIRM = "phase.change.confirm" # server -> client (target phase payload)
## Client
NOTIFY_JOIN_USER = "notify.join.user"
CONFIRM_JOIN_PARTICIPANT = "confirm.join.participant"
REJECT_JOIN_PARTICIPANT = "reject.join.participant"
CONFIRM_JOIN_ADMIN = "confirm.join.admin"
# Status / sync
STATUS_SYNC_REQUEST = "status.sync.request" # client -> server
STATUS_SYNC_INFORM = "status.sync.inform" # server -> client (full/partial state)
# Determine Order
## Server
CONFIRM_DETERMINE_DRAFT_ORDER = "confirm.determine.draft_order"
## Client
REQUEST_DETERMINE_DRAFT_ORDER = "request.determine.draft_order"
DRAFT_INDEX_ADVANCE_REQUEST = "draft.index.advance.request"
DRAFT_INDEX_ADVANCE_CONFIRM = "draft.index.advance.confirm"
# Order determination
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):
WAITING = 0
DETERMINE_ORDER = 10
NOMINATION = 20
BIDDING = 30
AWARD = 40
FINALIZE = 50
WAITING = 10
DETERMINE_ORDER = 20
NOMINATING = 30
BIDDING = 40
AWARDING = 50
FINALIZING = 60
def __str__(self):
return self.name.lower()

View File

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

View File

@@ -33,6 +33,10 @@ class DraftCacheKeys:
def draft_order(self):
return f"{self.prefix}:draft_order"
@property
def draft_index(self):
return f"{self.prefix}:draft_index"
# @property
# def state(self):
# return f"{self.prefix}:state"
@@ -64,30 +68,30 @@ class DraftStateManager:
self.session_id = session_id
self.cache = cache
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 ===
@property
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
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 ===
@property
def connected_users(self) -> list[str]:
def connected_participants(self) -> list[str]:
return json.loads(self.cache.get(self.keys.connected_users) or "[]")
def connect_user(self, username: str):
users = set(self.connected_users)
def connect_participant(self, username: str):
users = set(self.connected_participants)
users.add(username)
self.cache.set(self.keys.connected_users, json.dumps(list(users)))
def disconnect_user(self, username: str):
users = set(self.connected_users)
def disconnect_participant(self, username: str):
users = set(self.connected_participants)
users.discard(username)
self.cache.set(self.keys.connected_users, json.dumps(list(users)))
@@ -102,6 +106,14 @@ class DraftStateManager:
return
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 ===
def start_nomination(self, movie_id: int):
self.cache.set(self.keys.current_movie, movie_id)
@@ -132,7 +144,8 @@ class DraftStateManager:
return {
"phase": self.phase,
"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),
# "bids": self.get_bids(),
# "timer_end": self.get_timer_end(),

View File

@@ -1,53 +1,13 @@
// DraftAdmin.jsx
import React, { useEffect, useState } from "react";
import { useWebSocket } from "../WebSocketContext.jsx";
import { WebSocketStatus } from "../common/WebSocketStatus.jsx";
import { DraftMessage, DraftPhases, DraftPhase } from '../constants.js';
import { fetchDraftDetails } from "../common/utils.js"
const ParticipantList = ({ socket, participants, draftOrder }) => {
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
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"
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 }) => {
return (
@@ -57,9 +17,9 @@ const DraftPhaseDisplay = ({ draftPhase, nextPhaseHandler, prevPhaseHandler }) =
<div className="change-phase"><button onClick={prevPhaseHandler}><i className="bi bi-chevron-left"></i></button></div>
<ol>
{
DraftPhases.map((p) => (
DraftPhasesOrdered.map((p) => (
<li key={p} className={p === draftPhase ? "current-phase" : ""}>
<span>{p}</span>
<span>{DraftPhaseLabel[p]}</span>
</li>
))
}
@@ -72,86 +32,84 @@ const DraftPhaseDisplay = ({ draftPhase, nextPhaseHandler, prevPhaseHandler }) =
export const DraftAdmin = ({ draftSessionId }) => {
const socket = useWebSocket();
const [connectedParticipants, setConnectedParticipants] = useState([]);
const [draftDetails, setDraftDetails] = useState();
const [participants, setParticipants] = React.useState([]);
const [draftPhase, setDraftPhase] = useState();
const [draftOrder, setDraftOrder] = useState([]);
console.log(socket)
const [draftState, setDraftState] = useState({})
const [currentUser, setCurrentUser] = useState(null);
useEffect(() => {
fetchDraftDetails(draftSessionId)
.then((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;
const handleMessage = (event) => {
const message = JSON.parse(event.data)
const { type, payload } = message;
console.log(type, event)
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;
}
const draftStatusMessageHandler = (event) => handleDraftStatusMessages(event, setDraftState)
const userIdentifyMessageHandler = (event) => handleUserIdentifyMessages(event, setCurrentUser)
socket.addEventListener('message', draftStatusMessageHandler );
socket.addEventListener('message', userIdentifyMessageHandler );
return () => {
socket.removeEventListener('message', handleMessage)
socket.close();
socket.removeEventListener('message', draftStatusMessageHandler)
socket.removeEventListener('message', userIdentifyMessageHandler );
};
}, [socket]);
const handlePhaseChange = (target) => {
let destination
const origin = draftPhase
if (target == "next") {
console.log(DraftPhase)
console.log("phase to be changed", origin, target, DraftPhase.WAITING)
if (origin == "waiting"){
destination = DraftPhase.DETERMINE_ORDER
} else if (origin == "determine_order"){
destination = DraftPhase.NOMINATION
const origin = draftState.phase
const originPhaseIndex = DraftPhasesOrdered.findIndex(i => i == origin)
console.log('origin phase index', originPhaseIndex)
if (target == "next" && originPhaseIndex < DraftPhasesOrdered.length) {
destination = DraftPhasesOrdered[originPhaseIndex + 1]
}
else if (target == "previous" && originPhaseIndex > 0) {
destination = DraftPhasesOrdered[originPhaseIndex - 1]
}
else if (target=="previous") {
}
if (!destination) {return}
console.log(destination)
socket.send(
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 = () => {
socket.send(
JSON.stringify(
{ type: DraftMessage.REQUEST.DRAFT_STATUS }
{ type: DraftMessage.STATUS_SYNC_REQUEST }
)
)
}
@@ -169,12 +127,14 @@ export const DraftAdmin = ({ draftSessionId }) => {
</div>
<ParticipantList
socket={socket}
participants={participants}
draftOrder={draftOrder}
draftState={draftState}
draftDetails={draftDetails}
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>
);
};

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) {
return fetch(`/api/draft/${draftSessionId}/`)
.then((response) => {
@@ -27,3 +29,39 @@ export async function fetchMovieDetails(draftSessionId) {
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 = {
// Server to Client
INFORM: {
PHASE_CHANGE: "inform.phase.change",
PHASE: "inform.phase",
STATUS: "inform.status",
JOIN_USER: "inform.join.user",
DRAFT_STATUS: "inform.draft_status"
},
// Client to Server
REQUEST: {
PHASE_CHANGE: "request.phase.change",
INFORM_STATUS: "request.inform.status",
JOIN_PARTICIPANT: "request.join.participant",
JOIN_ADMIN: "request.join.admin",
DETERMINE_DRAFT_ORDER: "request.determine.draft_order",
DRAFT_STATUS: "request.draft_status"
},
// Confirmation messages (Server to Client)
CONFIRM: {
PHASE_CHANGE: "confirm.phase.change",
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",
},
PARTICIPANT_JOIN_REQUEST: "participant.join.request",
PARTICIPANT_JOIN_CONFIRM: "participant.join.confirm",
PARTICIPANT_JOIN_REJECT: "participant.join.reject",
PARTICIPANT_LEAVE_INFORM: "participant.leave.inform",
USER_JOIN_INFORM: "user.join.inform",
USER_LEAVE_INFORM: "user.leave.inform",
USER_IDENTIFICATION_INFORM: "user.identification.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_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_PLACE_REQUEST: "bid.place.request",
BID_UPDATE_INFORM: "bid.update.inform",
BID_END_INFORM: "bid.end.inform",
NOMINATION_SUBMIT_REQUEST: "nomination.submit.request",
NOMINATION_CONFIRM: "nomination.submit.confirm",
};
export const DraftPhase = {
WAITING: 0,
DETERMINE_ORDER: 10,
NOMINATION: 20,
BIDDING: 30,
AWARD: 40,
FINALIZE: 50,
}
WAITING: 10,
DETERMINE_ORDER: 20,
NOMINATING: 30,
BIDDING: 40,
AWARDING: 50,
FINALIZING: 60,
};
export const DraftPhases = [
"waiting",
"determine_order",
"nomination",
"bidding",
"award",
"finalize",
]
export const DraftPhaseLabel = {
[DraftPhase.WAITING]: "waiting",
[DraftPhase.DETERMINE_ORDER]: "determine_order",
[DraftPhase.NOMINATING]: "nominating",
[DraftPhase.BIDDING]: "bidding",
[DraftPhase.AWARDING]: "awarding",
[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 { WebSocketStatus } from "../common/WebSocketStatus.jsx";
import { DraftMessage, DraftPhases } from '../constants.js';
import { fetchDraftDetails } from "../common/utils.js"
import { DraftMessage, DraftPhases} from '../constants.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 }) => {
const socket = useWebSocket();
const [participants, setParticipants] = useState([]);
const [draftPhase, setDraftPhase] = useState();
const [draftState, setDraftState] = useState({});
const [draftDetails, setDraftDetails] = useState({});
const [movies, setMovies] = useState([]);
console.log(socket)
@@ -34,65 +23,41 @@ export const DraftParticipant = ({ 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;
else {
console.warn("socket doesn't exist")
}
console.log('socket created', socket)
const handleMessage = (event) => {
const message = JSON.parse(event.data)
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;
}
const handler = (event) => handleDraftStatusMessages(event, setDraftState)
socket.addEventListener('message', handler );
return () => {
socket.removeEventListener('message', handleMessage)
socket.addEventListener('message', handler );
socket.close();
};
}, [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 (
<div className="container draft-panel">
<div className="d-flex justify-content-between border-bottom mb-2 p-1">
<h3>Draft Panel</h3>
<WebSocketStatus socket={socket} />
</div>
<DraftMoviePool movies={movies}></DraftMoviePool>
<ParticipantList
draftState={draftState}
draftDetails={draftDetails}
isAdmin={false}
/>
<DraftMoviePool draftDetails={draftDetails}></DraftMoviePool>
</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()