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:
13
api/urls.py
13
api/urls.py
@@ -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"
|
||||||
|
),
|
||||||
|
]
|
||||||
35
api/views.py
35
api/views.py
@@ -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
|
||||||
|
})
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
29
frontend/src/apps/draft/common/utils.js
Normal file
29
frontend/src/apps/draft/common/utils.js
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user