Add movie detail API and enhance draft admin/participant UI

- Introduced `/api/movie/<id>/detail` endpoint returning TMDB data for a movie.
- Moved draft detail fetching logic into `common/utils.js` for reuse.
- Updated Draft Admin panel:
  - Added phase navigation buttons with bootstrap icons.
  - Improved layout with refresh and status controls.
- Updated Draft Participant panel:
  - Added movie pool display with links to movie details.
- Added bootstrap-icons stylesheet and corresponding SCSS styles for new UI.
This commit is contained in:
2025-08-08 15:12:40 -05:00
parent 9b6b3391e6
commit 24700071ed
8 changed files with 186 additions and 101 deletions

View File

@@ -1,9 +1,18 @@
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .views import UserViewSet, MovieViewSet, DraftSessionViewSet from .views import UserViewSet, MovieViewSet, DraftSessionViewSet, movie_detail
from django.urls import path
router = DefaultRouter() router = DefaultRouter()
router.register(r'users', UserViewSet, basename='user') router.register(r'users', UserViewSet, basename='user')
router.register(r'movies', MovieViewSet, basename='movie') router.register(r'movies', MovieViewSet, basename='movie')
router.register(r'draft', DraftSessionViewSet, basename='draft') router.register(r'draft', DraftSessionViewSet, basename='draft')
urlpatterns = router.urls
urlpatterns = [
*router.urls,
path(
"movie/<int:movie_id>/detail",
movie_detail,
name="movie-detail"
),
]

View File

@@ -4,6 +4,9 @@ from django.contrib.auth import get_user_model
from boxofficefantasy.models import Movie from boxofficefantasy.models import Movie
from draft.models import DraftSession, DraftPick from draft.models import DraftSession, DraftPick
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from rest_framework.response import Response
from boxofficefantasy.integrations.tmdb import get_tmdb_movie_by_imdb
from rest_framework.decorators import api_view
from django.db.models import Prefetch from django.db.models import Prefetch
@@ -57,4 +60,34 @@ class DraftSessionViewSet(viewsets.ReadOnlyModelViewSet):
"movies", "movies",
Prefetch("draft_picks", queryset=DraftPick.objects.select_related("winner", "movie")), Prefetch("draft_picks", queryset=DraftPick.objects.select_related("winner", "movie")),
) )
) )
@api_view(["GET"])
def movie_detail(request, movie_id):
"""
GET /api/movie/{movie_id}/detail
Returns TMDB movie details
and the movie is in that session.
"""
# Lookup DraftSession by hashid or pk
# draft_session = get_object_or_404(DraftSession, hashid=draft_session_id)
# # Ensure requesting user is a participant
# if request.user not in draft_session.participants.all():
# return Response({"detail": "Not authorized for this draft session."},
# status=status.HTTP_403_FORBIDDEN)
# # Get movie in this session
movie = get_object_or_404(Movie, pk=movie_id)
# Call TMDB integration
tmdb_data = get_tmdb_movie_by_imdb(movie.imdb_id)
if not tmdb_data:
return Response({"detail": "Movie details not found."},
status=404)
return Response({
"id": movie.id,
"title": movie.title,
"tmdb": tmdb_data
})

View File

@@ -7,6 +7,7 @@
rel="stylesheet" rel="stylesheet"
href="https://cdn.datatables.net/2.3.2/css/dataTables.bootstrap5.css" href="https://cdn.datatables.net/2.3.2/css/dataTables.bootstrap5.css"
/> />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
{% if DEBUG %} {% if DEBUG %}
<script src="http://localhost:3000/dist/bundle.js"></script> <script src="http://localhost:3000/dist/bundle.js"></script>
{% else %} {% else %}

View File

@@ -4,6 +4,7 @@ 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 { DraftMessage, DraftPhases, DraftPhase } from '../constants.js';
import { fetchDraftDetails } from "../common/utils.js"
const ParticipantList = ({ socket, participants, draftOrder }) => { const ParticipantList = ({ socket, participants, draftOrder }) => {
const [connectedParticipants, setConnectedParticipants] = useState([]) const [connectedParticipants, setConnectedParticipants] = useState([])
@@ -48,37 +49,23 @@ const ParticipantList = ({ socket, participants, draftOrder }) => {
) )
} }
const DraftPhaseDisplay = ({ draftPhase }) => { const DraftPhaseDisplay = ({ draftPhase, nextPhaseHandler, prevPhaseHandler }) => {
return ( return (
<div className="draft-phase-container"> <div className="draft-phase-container">
<label>Phase</label> <label>Phase</label>
<ol> <div className="d-flex">
{ <div className="change-phase"><button onClick={prevPhaseHandler}><i className="bi bi-chevron-left"></i></button></div>
DraftPhases.map((p) => ( <ol>
<li key={p} className={p === draftPhase ? "current-phase" : ""}> {
<span>{p}</span> DraftPhases.map((p) => (
</li> <li key={p} className={p === draftPhase ? "current-phase" : ""}>
)) <span>{p}</span>
} </li>
</ol> ))
</div> }
) </ol>
} <div className="change-phase"><button onClick={nextPhaseHandler}><i className="bi bi-chevron-right"></i></button></div>
</div>
const DraftOrder = ({ socket, draftOrder }) => {
console.log("in component", draftOrder)
return (
<div>
<label>Draft Order</label>
<ol>
{
draftOrder.map((p) => (
<li key={p}>
{p}
</li>
))
}
</ol>
</div> </div>
) )
} }
@@ -93,34 +80,16 @@ export const DraftAdmin = ({ draftSessionId }) => {
console.log(socket) console.log(socket)
useEffect(() => { useEffect(() => {
async function fetchDraftDetails(draftSessionId) {
fetch(`/api/draft/${draftSessionId}/`)
.then((response) => {
if (response.ok) {
return response.json()
}
else {
throw new Error()
}
})
.then((data) => {
console.log(data)
setParticipants(data.participants)
})
.catch((err) => {
console.error("Error fetching draft details", err)
})
}
fetchDraftDetails(draftSessionId) fetchDraftDetails(draftSessionId)
.then((data) => {
console.log("Fetched draft data", data)
setParticipants(data.participants)
})
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!socket) return; if (!socket) return;
else {
console.warn("socket doesn't exist")
}
console.log('socket created', socket)
const handleMessage = (event) => { const handleMessage = (event) => {
const message = JSON.parse(event.data) const message = JSON.parse(event.data)
@@ -153,12 +122,29 @@ export const DraftAdmin = ({ draftSessionId }) => {
}; };
}, [socket]); }, [socket]);
const handlePhaseChange = (destinationPhase) => { 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") {
}
if (!destination) {return}
socket.send( socket.send(
JSON.stringify( JSON.stringify(
{ type: DraftMessage.REQUEST.PHASE_CHANGE, "origin": draftPhase, "destination": destinationPhase } { type: DraftMessage.REQUEST.PHASE_CHANGE, origin, destination }
) )
); )
} }
@@ -170,29 +156,25 @@ export const DraftAdmin = ({ draftSessionId }) => {
) )
} }
return ( return (
<div className="container draft-panel admin"> <div className="container draft-panel admin">
<h3>Draft Admin Panel</h3> <div className="d-flex justify-content-between border-bottom mb-2 p-1">
<WebSocketStatus socket={socket} /> <h3>Draft Panel</h3>
{/* <MessageLogger socket={socketRef.current} /> */} <div className="d-flex gap-1">
<WebSocketStatus socket={socket} />
<button onClick={() => handleRequestDraftSummary()} className="btn btn-small btn-light">
<i className="bi bi-arrow-clockwise"></i>
</button>
</div>
</div>
<ParticipantList <ParticipantList
socket={socket} socket={socket}
participants={participants} participants={participants}
draftOrder={draftOrder} draftOrder={draftOrder}
/> />
<DraftPhaseDisplay draftPhase={draftPhase}></DraftPhaseDisplay>
<button onClick={() => handlePhaseChange(DraftPhase.DETERMINE_ORDER)} className="btn btn-primary mt-2 me-2"> <DraftPhaseDisplay draftPhase={draftPhase} nextPhaseHandler={ ()=>{handlePhaseChange('next')}} prevPhaseHandler= {() => {handlePhaseChange('previous')}}></DraftPhaseDisplay>
Determine Draft Order
</button>
<button onClick={() => handleRequestDraftSummary()} className="btn btn-primary mt-2">
Request status
</button>
<button onClick={() => handlePhaseChange(DraftPhase.NOMINATION)} className="btn btn-primary mt-2 me-2">
Go to Nominate
</button>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,29 @@
export async function fetchDraftDetails(draftSessionId) {
return fetch(`/api/draft/${draftSessionId}/`)
.then((response) => {
if (response.ok) {
return response.json()
}
else {
throw new Error()
}
})
.catch((err) => {
console.error("Error fetching draft details", err)
})
}
export async function fetchMovieDetails(draftSessionId) {
return fetch(`/api/draft/${draftSessionId}/movie/`)
.then((response) => {
if (response.ok) {
return response.json()
}
else {
throw new Error()
}
})
.catch((err) => {
console.error("Error fetching draft details", err)
})
}

View File

@@ -4,13 +4,40 @@ 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"
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 [connectedParticipants, setConnectedParticipants] = useState([]); const [participants, setParticipants] = useState([]);
const [draftPhase, setDraftPhase] = useState(); const [draftPhase, setDraftPhase] = useState();
const [movies, setMovies] = useState([]);
console.log(socket) console.log(socket)
useEffect(() => {
fetchDraftDetails(draftSessionId)
.then((data) => {
console.log("Fetched draft data", data)
setMovies(data.movies)
})
}, [])
useEffect(() => { useEffect(() => {
if (!socket) return; if (!socket) return;
else { else {
@@ -60,27 +87,12 @@ export const DraftParticipant = ({ draftSessionId }) => {
return ( return (
<div className="container draft-panel"> <div className="container draft-panel">
<h3>Draft Admin Panel</h3> <div className="d-flex justify-content-between border-bottom mb-2 p-1">
<WebSocketStatus socket={socket} /> <h3>Draft Panel</h3>
{/* <MessageLogger socket={socketRef.current} /> */} <WebSocketStatus socket={socket} />
<label>Connected Particpants</label> </div>
<input
type="text" <DraftMoviePool movies={movies}></DraftMoviePool>
readOnly disabled
value={connectedParticipants ? JSON.stringify(connectedParticipants) : ""}
/>
<label>Draft Phase</label>
<input
type="text"
readOnly disabled
value={draftPhase ? draftPhase : ""}
/>
<button onClick={() => handlePhaseChange(DraftPhases.DETERMINE_ORDER)} className="btn btn-primary mt-2 me-2">
Determine Draft Order
</button>
<button onClick={handleRequestDraftSummary} className="btn btn-primary mt-2">
Request status
</button>
</div> </div>
); );
}; };

View File

@@ -15,7 +15,7 @@ if (draftPartipantRoot) {
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/participant`; const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/participant`;
createRoot(draftPartipantRoot).render( createRoot(draftPartipantRoot).render(
<WebSocketProvider url={wsUrl}> <WebSocketProvider url={wsUrl}>
<DraftParticipant /> <DraftParticipant draftSessionId={draftSessionId} />
</WebSocketProvider> </WebSocketProvider>
); );
} }

View File

@@ -55,14 +55,32 @@
.danger { .danger {
@extend .bg-danger; @extend .bg-danger;
} }
.draft-panel {
}
.draft-phase-container { .draft-phase-container {
label { label {
@extend .fs-3; @extend .fs-3;
} }
ol, ul { .change-phase {
button {
@extend .btn;
@extend .btn-light;
@extend .p-0;
height: 100%;
}
}
ol,
ul {
--bs-list-group-active-bg: var(--bs-primary-bg-subtle);
--bs-list-group-active-color: $dark;
@extend .list-group; @extend .list-group;
@extend .list-group-horizontal; @extend .list-group-horizontal;
@extend .ms-1;
@extend .me-1;
li { li {
@extend .list-group-item; @extend .list-group-item;
@extend .p-1; @extend .p-1;
@@ -70,18 +88,23 @@
@extend .pe-2; @extend .pe-2;
&.current-phase { &.current-phase {
@extend .active @extend .active;
} }
} }
} }
} }
.participant-list-container { .participant-list-container,
.movie-pool-container {
max-width: 575.98px; max-width: 575.98px;
label { label {
@extend .fs-3; @extend .fs-3;
} }
@extend .list-group; @extend .list-group;
ol,
ul {
@extend .p-0;
}
ol { ol {
@extend .list-group-numbered; @extend .list-group-numbered;
} }
@@ -94,9 +117,5 @@
@extend .me-auto; @extend .me-auto;
@extend .ps-1; @extend .ps-1;
} }
&::marker{
content:">";
color:green;
}
} }
} }