Add nomination submission and bidding start workflow

- Added `BID_START_REQUEST` and `NOMINATION_SUBMIT_REQUEST` handling in backend consumers.
- Extended draft state to include `current_movie` and `bids` cache keys.
- Updated frontend to:
  - Allow participants to nominate movies when it's their turn.
  - Enable admins to start bidding for the nominated movie.
  - Highlight the current nominated movie and the current user.
- Synced state updates across clients via WebSocket events.
This commit is contained in:
2025-08-10 16:30:27 -05:00
parent 28c98afc32
commit b08a345563
10 changed files with 151 additions and 28 deletions

View File

@@ -30,6 +30,7 @@ class DraftMessage(StrEnum):
# Bidding (examples, adjust to your flow)
BID_START_INFORM = "bid.start.inform" # server -> client (movie, ends_at)
BID_START_REQUEST = "bid.start.request" # server -> client (movie, ends_at)
BID_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)

View File

@@ -182,6 +182,34 @@ class DraftAdminConsumer(DraftConsumerBase):
},
)
if event_type == DraftMessage.NOMINATION_SUBMIT_REQUEST:
movie_id = content.get('payload',{}).get('movie_id')
user = content.get('payload',{}).get('user')
self.draft_state.start_nomination(movie_id)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.NOMINATION_CONFIRM,
"payload": {
"current_movie": self.draft_state.get_summary()['current_movie'],
"nominating_participant": user
}
}
)
if event_type == DraftMessage.BID_START_REQUEST:
self.draft_state
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.BID_START_INFORM,
"payload": {
"current_movie": self.draft_state.get_summary()['current_movie']
}
}
)
def should_accept_user(self):
return super().should_accept_user() and self.user.is_staff
@@ -276,6 +304,20 @@ class DraftParticipantConsumer(DraftConsumerBase):
async def receive_json(self, content):
await super().receive_json(content)
event_type = content.get('type')
if event_type == DraftMessage.NOMINATION_SUBMIT_REQUEST:
await self.channel_layer.group_send(
self.group_names.admin,
{
"type": "broadcast.admin",
"subtype": event_type,
"payload": {
"movie_id": content.get('payload',{}).get('id'),
"user": content.get('payload',{}).get('user')
}
}
)
# === Broadcast handlers ===

View File

@@ -37,6 +37,10 @@ class DraftCacheKeys:
def draft_index(self):
return f"{self.prefix}:draft_index"
@property
def current_movie(self):
return f"{self.prefix}:current_movie"
# @property
# def state(self):
# return f"{self.prefix}:state"
@@ -45,9 +49,9 @@ class DraftCacheKeys:
# def current_movie(self):
# return f"{self.prefix}:current_movie"
# @property
# def bids(self):
# return f"{self.prefix}:bids"
@property
def bids(self):
return f"{self.prefix}:bids"
# @property
# def participants(self):
@@ -146,7 +150,7 @@ class DraftStateManager:
"draft_order": self.draft_order,
"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(),
# "timer_end": self.get_timer_end(),
}

View File

@@ -6,6 +6,7 @@ import { ParticipantList } from "../common/ParticipantList.jsx";
import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from '../constants.js';
import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "../common/utils.js"
import { DraftMoviePool } from "../common/DraftMoviePool.jsx"
import { jsxs } from "react/jsx-runtime";
@@ -65,12 +66,28 @@ export const DraftAdmin = ({ draftSessionId }) => {
const draftStatusMessageHandler = (event) => handleDraftStatusMessages(event, setDraftState)
const userIdentifyMessageHandler = (event) => handleUserIdentifyMessages(event, setCurrentUser)
const handleNominationRequest = (event)=> {
const message = JSON.parse(event.data)
const { type, payload } = message;
console.log('passing through nomination request', message)
if (type == DraftMessage.NOMINATION_SUBMIT_REQUEST) {
socket.send(JSON.stringify(
{
type: DraftMessage.NOMINATION_SUBMIT_REQUEST,
payload
}
))
}
}
socket.addEventListener('message', draftStatusMessageHandler );
socket.addEventListener('message', userIdentifyMessageHandler );
socket.addEventListener('message', handleNominationRequest );
return () => {
socket.removeEventListener('message', draftStatusMessageHandler)
socket.removeEventListener('message', userIdentifyMessageHandler );
socket.remove('message', handleNominationRequest );
};
}, [socket]);
@@ -114,6 +131,14 @@ export const DraftAdmin = ({ draftSessionId }) => {
)
}
const handleStartBidding = () => {
socket.send(
JSON.stringify(
{type: DraftMessage.BID_START_REQUEST}
)
)
}
return (
<div className="container draft-panel admin">
<div className="d-flex justify-content-between border-bottom mb-2 p-1">
@@ -127,12 +152,16 @@ export const DraftAdmin = ({ draftSessionId }) => {
</div>
<ParticipantList
currentUser = {currentUser}
draftState={draftState}
draftDetails={draftDetails}
isAdmin={true}
/>
<button onClick={handleAdvanceDraft} className="btn btn-primary">Advance Draft</button>
<DraftMoviePool draftDetails={draftDetails}></DraftMoviePool>
<div className="d-flex gap-1 m-1">
<button onClick={handleAdvanceDraft} className="btn btn-primary">Advance Draft</button>
<button onClick={handleStartBidding} className="btn btn-primary">Start Bidding</button>
</div>
<DraftMoviePool draftDetails={draftDetails} draftState={draftState}></DraftMoviePool>
<DraftPhaseDisplay draftPhase={draftState.phase} nextPhaseHandler={() => { handlePhaseChange('next') }} prevPhaseHandler={() => { handlePhaseChange('previous') }}></DraftPhaseDisplay>
</div>

View File

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

View File

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

View File

@@ -40,7 +40,7 @@ export const handleDraftStatusMessages = (event, setDraftState) => {
console.log("Message: ", type, event?.data)
if (!payload) return
const {connected_participants, phase, draft_order, draft_index} = payload
const {connected_participants, phase, draft_order, draft_index, current_movie} = payload
if (type == DraftMessage.STATUS_SYNC_INFORM) {
setDraftState(payload)
@@ -52,6 +52,7 @@ export const handleDraftStatusMessages = (event, setDraftState) => {
...(draft_order ? { draft_order } : {}),
...(draft_index ? { draft_index } : {}),
...(phase ? { phase: Number(phase) } : {}),
...(current_movie ? {current_movie} : {}),
}))
}
@@ -59,9 +60,10 @@ export const handleDraftStatusMessages = (event, setDraftState) => {
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)
if (type==DraftMessage.USER_IDENTIFICATION_INFORM){
console.log("Message: ", type, event.data)
const {user} = payload
setUser(user)
}
}

View File

@@ -20,6 +20,7 @@ export const DraftMessage = {
ORDER_DETERMINE_REQUEST: "order.determine.request",
ORDER_DETERMINE_CONFIRM: "order.determine.confirm",
BID_START_INFORM: "bid.start.inform",
BID_START_REQUEST: "bid.start.request",
BID_PLACE_REQUEST: "bid.place.request",
BID_UPDATE_INFORM: "bid.update.inform",
BID_END_INFORM: "bid.end.inform",

View File

@@ -3,17 +3,53 @@ 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, handleUserIdentifyMessages, isEmptyObject } from "../common/utils.js";
import { DraftMoviePool } from "../common/DraftMoviePool.jsx";
import { ParticipantList } from "../common/ParticipantList.jsx";
import { handleDraftStatusMessages } from '../common/utils.js'
const NominateMenu = ({socket, draftState, draftDetails, currentUser}) => {
if (!socket || isEmptyObject(draftDetails) || isEmptyObject(draftState)) return;
const currentDrafter = draftState.draft_order[draftState.draft_index]
if (currentUser != currentDrafter) return;
const {movies} = draftDetails
const requestNomination = (event) => {
event.preventDefault()
const formData = new FormData(event.target)
socket.send(JSON.stringify({
type: DraftMessage.NOMINATION_SUBMIT_REQUEST,
payload: {
id: formData.get('movie'),
user: currentUser
}
}))
}
return (
<div>
<label>Nominate</label>
{draftState.draft_order[draftState.draft_index]}
<div className="d-flex">
<form onSubmit={requestNomination}>
<select className="form-control" name="movie">
{movies.map(m=>(
<option key={m.id} value={m.id}>{m.title}</option>
))}
</select>
<button className="btn btn-primary">Nominate</button>
</form>
</div>
</div>
)
}
export const DraftParticipant = ({ draftSessionId }) => {
const socket = useWebSocket();
const [draftState, setDraftState] = useState({});
const [draftDetails, setDraftDetails] = useState({});
const [currentUser, setCurrentUser] = useState(null);
const [movies, setMovies] = useState([]);
console.log(socket)
@@ -27,7 +63,7 @@ export const DraftParticipant = ({ draftSessionId }) => {
})
}, [draftSessionId])
useEffect(()=>{
useEffect(() => {
if (!socket) return;
socket.onclose = (event) => {
console.log('Websocket Closed')
@@ -37,12 +73,14 @@ export const DraftParticipant = ({ draftSessionId }) => {
useEffect(() => {
if (!socket) return;
const handler = (event) => handleDraftStatusMessages(event, setDraftState)
socket.addEventListener('message', handler );
const draftStatusMessageHandler = (event) => handleDraftStatusMessages(event, setDraftState)
const userIdentifyMessageHandler = (event) => handleUserIdentifyMessages(event, setCurrentUser)
socket.addEventListener('message', draftStatusMessageHandler);
socket.addEventListener('message', userIdentifyMessageHandler);
return () => {
socket.addEventListener('message', handler );
socket.close();
socket.removeEventListener('message', draftStatusMessageHandler)
socket.removeEventListener('message', userIdentifyMessageHandler);
};
}, [socket]);
@@ -53,11 +91,13 @@ export const DraftParticipant = ({ draftSessionId }) => {
<WebSocketStatus socket={socket} />
</div>
<ParticipantList
currentUser={currentUser}
draftState={draftState}
draftDetails={draftDetails}
isAdmin={false}
/>
<DraftMoviePool draftDetails={draftDetails}></DraftMoviePool>
<DraftMoviePool isParticipant={true} draftDetails={draftDetails} draftState={draftState}></DraftMoviePool>
<NominateMenu socket={socket} currentUser={currentUser} draftState={draftState} draftDetails={draftDetails}></NominateMenu>
</div>
);
};

View File

@@ -63,8 +63,6 @@
@extend .fs-3;
}
.change-phase {
button {
@extend .btn;
@extend .btn-light;
@@ -118,4 +116,10 @@
@extend .ps-1;
}
}
.current-user {
&::after {
content: " *";
font-size: 1em; // adjust as needed
}
}
}