From 28c98afc324ca4bf374a475ddbbf688a981373e5 Mon Sep 17 00:00:00 2001 From: Anthony Correa Date: Sun, 10 Aug 2025 13:16:07 -0500 Subject: [PATCH] Refactor draft messaging to unified enum-based protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- boxofficefantasy_project/settings.py | 25 +++ draft/constants.py | 72 ++++--- draft/consumers.py | 187 +++++++++--------- draft/state.py | 31 ++- frontend/src/apps/draft/admin/DraftAdmin.jsx | 164 ++++++--------- .../src/apps/draft/common/DraftMoviePool.jsx | 21 ++ .../src/apps/draft/common/ParticipantList.jsx | 33 ++++ frontend/src/apps/draft/common/utils.js | 40 +++- frontend/src/apps/draft/constants.js | 89 ++++----- .../draft/participant/DraftParticipant.jsx | 85 +++----- scripts/generate_js_constants.py | 103 ++++++++++ 11 files changed, 509 insertions(+), 341 deletions(-) create mode 100644 frontend/src/apps/draft/common/DraftMoviePool.jsx create mode 100644 frontend/src/apps/draft/common/ParticipantList.jsx create mode 100644 scripts/generate_js_constants.py diff --git a/boxofficefantasy_project/settings.py b/boxofficefantasy_project/settings.py index 5dde478..1c13048 100644 --- a/boxofficefantasy_project/settings.py +++ b/boxofficefantasy_project/settings.py @@ -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, + }, + }, +} diff --git a/draft/constants.py b/draft/constants.py index 8a951fb..c956212 100644 --- a/draft/constants.py +++ b/draft/constants.py @@ -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() diff --git a/draft/consumers.py b/draft/consumers.py index a328d00..dc2f884 100644 --- a/draft/consumers.py +++ b/draft/consumers.py @@ -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 === diff --git a/draft/state.py b/draft/state.py index d3279c2..66ddb5d 100644 --- a/draft/state.py +++ b/draft/state.py @@ -32,6 +32,10 @@ class DraftCacheKeys: @property 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): @@ -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(), diff --git a/frontend/src/apps/draft/admin/DraftAdmin.jsx b/frontend/src/apps/draft/admin/DraftAdmin.jsx index 715c24a..899174f 100644 --- a/frontend/src/apps/draft/admin/DraftAdmin.jsx +++ b/frontend/src/apps/draft/admin/DraftAdmin.jsx @@ -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 ( -
- - - {listItems.map((p, i) => ( -
  • - {p?.full_name} -
    -
  • - ))} -
    -
    - ) -} const DraftPhaseDisplay = ({ draftPhase, nextPhaseHandler, prevPhaseHandler }) => { return ( @@ -57,14 +17,14 @@ const DraftPhaseDisplay = ({ draftPhase, nextPhaseHandler, prevPhaseHandler }) =
      { - DraftPhases.map((p) => ( + DraftPhasesOrdered.map((p) => (
    1. - {p} + {DraftPhaseLabel[p]}
    2. )) }
    -
    +
    ) @@ -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 - } - } - else if (target=="previous") { - + 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] } - - if (!destination) {return} + else if (target == "previous" && originPhaseIndex > 0) { + destination = DraftPhasesOrdered[originPhaseIndex - 1] + } + 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 }) => { + + - {handlePhaseChange('next')}} prevPhaseHandler= {() => {handlePhaseChange('previous')}}> + { handlePhaseChange('next') }} prevPhaseHandler={() => { handlePhaseChange('previous') }}> ); }; \ No newline at end of file diff --git a/frontend/src/apps/draft/common/DraftMoviePool.jsx b/frontend/src/apps/draft/common/DraftMoviePool.jsx new file mode 100644 index 0000000..6185807 --- /dev/null +++ b/frontend/src/apps/draft/common/DraftMoviePool.jsx @@ -0,0 +1,21 @@ +import React from "react"; +import { isEmptyObject } from "./utils"; + +export const DraftMoviePool = ({ draftDetails }) => { + if(isEmptyObject(draftDetails)) {return} + const {movies} = draftDetails + + return ( +
    + +
    + ) +} \ No newline at end of file diff --git a/frontend/src/apps/draft/common/ParticipantList.jsx b/frontend/src/apps/draft/common/ParticipantList.jsx new file mode 100644 index 0000000..9f80b55 --- /dev/null +++ b/frontend/src/apps/draft/common/ParticipantList.jsx @@ -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 ( +
    + + + {listItems.map((p, i) => ( +
  • + {p?.full_name} + {isAdmin ? ( +
    + ) : null} +
  • + ))} +
    +
    + ) +} \ No newline at end of file diff --git a/frontend/src/apps/draft/common/utils.js b/frontend/src/apps/draft/common/utils.js index 26bdac4..39831c8 100644 --- a/frontend/src/apps/draft/common/utils.js +++ b/frontend/src/apps/draft/common/utils.js @@ -1,3 +1,5 @@ +import { DraftMessage } from "../constants" + export async function fetchDraftDetails(draftSessionId) { return fetch(`/api/draft/${draftSessionId}/`) .then((response) => { @@ -26,4 +28,40 @@ export async function fetchMovieDetails(draftSessionId) { .catch((err) => { console.error("Error fetching draft details", err) }) - } \ No newline at end of file + } + +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) +} \ No newline at end of file diff --git a/frontend/src/apps/draft/constants.js b/frontend/src/apps/draft/constants.js index d0d23c1..b382eaa 100644 --- a/frontend/src/apps/draft/constants.js +++ b/frontend/src/apps/draft/constants.js @@ -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", -] \ No newline at end of file +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]; diff --git a/frontend/src/apps/draft/participant/DraftParticipant.jsx b/frontend/src/apps/draft/participant/DraftParticipant.jsx index 156da54..09e288f 100644 --- a/frontend/src/apps/draft/participant/DraftParticipant.jsx +++ b/frontend/src/apps/draft/participant/DraftParticipant.jsx @@ -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 ( -
    - -
    - ) -} 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 (

    Draft Panel

    - - + +
    ); }; \ No newline at end of file diff --git a/scripts/generate_js_constants.py b/scripts/generate_js_constants.py new file mode 100644 index 0000000..00574b7 --- /dev/null +++ b/scripts/generate_js_constants.py @@ -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 don’t 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 aren’t 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() \ No newline at end of file