Integrate draft session support with phase handling and real-time updates

- Added user authentication UI in the base template for navbar.
- Expanded `league.dj.html` to include a new "Draft Sessions" tab showing active drafts.
- Refactored Django views and models to support `DraftSession` with participants and movies.
- Replaced deprecated models like `DraftParticipant` and `DraftMoviePool` with a new schema using `DraftSessionParticipant`.
- Introduced WebSocket consumers (`DraftAdminConsumer`, `DraftParticipantConsumer`) with structured phase logic and caching.
- Added `DraftStateManager` for managing draft state in Django cache.
- Created frontend UI components in React for draft admin and participants, including phase control and WebSocket message logging.
- Updated SCSS styles for improved UI structure and messaging area.
This commit is contained in:
2025-08-02 08:56:41 -05:00
parent 1a7a6a2d50
commit c9ce7a36d0
16 changed files with 811 additions and 484 deletions

View File

@@ -18,7 +18,7 @@
<script src="https://cdn.datatables.net/2.3.2/js/dataTables.bootstrap5.js"></script> <script src="https://cdn.datatables.net/2.3.2/js/dataTables.bootstrap5.js"></script>
</head> </head>
<body> <body>
<nav class="navbar justify-content-start"> <nav class="navbar justify-content-ends pe-2">
<div> <div>
<a class="navbar-brand" href="/"> <a class="navbar-brand" href="/">
<img src="{% static 'boxofficefantasy/logo.svg' %}" width="30" height="30"> <img src="{% static 'boxofficefantasy/logo.svg' %}" width="30" height="30">
@@ -29,6 +29,15 @@
{%block navbar%} {%block navbar%}
{%endblock%} {%endblock%}
</div> </div>
{% if user.is_authenticated %}
<div>
<div class="border border-secondary rounded p-1">{{ user.username }}</div>
</div>
{% else %}
<div>
<div class="btn btn-outline-secondary">Login</div>
</div>
{% endif %}
</nav> </nav>

View File

@@ -1,66 +1,67 @@
{% extends "base.dj.html" %}{% load humanize %} {% block content%} {% extends "base.dj.html" %}
<h3>{{season.league.name}}</h3> {% load humanize %}
<div> {% block content %}
<ul class="nav nav-underline"> <h3>{{ season.league.name }}</h3>
<li class="nav-item"> <div>
<button <ul class="nav nav-underline">
class="nav-link active" <li class="nav-item">
data-bs-toggle="tab" <button class="nav-link active"
data-bs-target="#seasons-tab-pane" data-bs-toggle="tab"
type="button" data-bs-target="#seasons-tab-pane"
> type="button">Seasons</button>
Seasons </li>
</button> <li class="nav-item">
</li> <button class="nav-link"
<li class="nav-item"> data-bs-toggle="tab"
<button data-bs-target="#winners-tab-pane"
class="nav-link" type="button">Winners</button>
data-bs-toggle="tab" </li>
data-bs-target="#winners-tab-pane" <li class="nav-item">
type="button" <button class="nav-link"
> data-bs-toggle="tab"
Winners data-bs-target="#players-tab-pane"
</button> type="button">Players</button>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<button <button class="nav-link"
class="nav-link" data-bs-toggle="tab"
data-bs-toggle="tab" data-bs-target="#movies-tab-pane"
data-bs-target="#players-tab-pane" type="button">Movies</button>
type="button" </li>
> <li class="nav-item">
Players <button class="nav-link"
</button> data-bs-toggle="tab"
</li> data-bs-target="#draft-session-tab-pane"
<li class="nav-item"> type="button">Draft Sessions</button>
<button </li>
class="nav-link" </ul>
data-bs-toggle="tab" <div class="tab-content">
data-bs-target="#movies-tab-pane" <div class="tab-pane fade show active" id="seasons-tab-pane">
type="button" <ol>
> {% for season in seasons %}
Movies <li>
</button> <a href="{% url 'season:season' season.league.slug season.slug %}">
</li> {{ season.league.name }} {{ season.label }} {{ season.year }}
</ul> </a>
<br>
Winner: {{ season.winner_team }} ${{ season.winner_score | floatformat:0 | intcomma }}
<br>
Top Movie: {{ season.top_movie }} {{ season.top_gross| floatformat:0 | intcomma }}
</li>
{% endfor %}
</ol>
</div>
<div class="tab-pane fade" id="winners-tab-pane">Winners</div>
<div class="tab-pane fade" id="players-tab-pane">players</div>
<div class="tab-pane fade" id="movies-tab-pane">movies</div>
<div class="tab-pane fade" id="draft-session-tab-pane">
<ul>
<div class="tab-content"> {% for draft in draft_sessions %}
<div class="tab-pane fade show active" id="seasons-tab-pane"> <li><a href="{% url "draft:session" draft.hashed_id %}">{{ draft.hashed_id }}</a></li>
<ol> {% endfor %}
{% for season in seasons %} </ul>
<li> </div>
<a href="{% url 'season:season' season.league.slug season.slug %}">
{{ season.league.name }} {{ season.label }} {{ season.year }}
</a>
<br>Winner: {{ season.winner_team }} ${{season.winner_score | floatformat:0 | intcomma}}
<br>Top Movie: {{season.top_movie}} {{season.top_gross| floatformat:0 | intcomma}}
</li>
{% endfor %}
</ol>
</div> </div>
<div class="tab-pane fade" id="winners-tab-pane">Winners</div>
<div class="tab-pane fade" id="players-tab-pane">players</div>
<div class="tab-pane fade" id="movies-tab-pane">movies</div>
</div> </div>
</div> {% endblock %}
{% endblock%}

View File

@@ -2,8 +2,8 @@
<h3>Leagues</h3> <h3>Leagues</h3>
<div> <div>
<ul class=""> <ul class="">
{% for league in leagues%} {% for league in leagues %}
<li><a href="{{url "league" league.slug}}">{{league.name}}</a></li> <li><a href="{%url 'league:league' league.slug%}">{{league.name}}</a></li>
{%endfor%} {%endfor%}
</ul> </ul>
</div> </div>

View File

@@ -4,10 +4,12 @@ from django.http.response import Http404, HttpResponse
from django.urls import reverse from django.urls import reverse
from django.db.models import OuterRef, Subquery, Sum, Q from django.db.models import OuterRef, Subquery, Sum, Q
from boxofficefantasy.models import League, Season, UserSeasonEntry, Movie, Pick from boxofficefantasy.models import League, Season, UserSeasonEntry, Movie, Pick
from draft.models import DraftSession
from .integrations.tmdb import get_tmdb_movie_by_imdb, cache_tmdb_poster from .integrations.tmdb import get_tmdb_movie_by_imdb, cache_tmdb_poster
User = get_user_model() User = get_user_model()
def parse_season_slug(season_slug: str) -> tuple[str, str]: def parse_season_slug(season_slug: str) -> tuple[str, str]:
try: try:
label, year = season_slug.rsplit("-", 1) label, year = season_slug.rsplit("-", 1)
@@ -16,6 +18,7 @@ def parse_season_slug(season_slug: str) -> tuple[str, str]:
except ValueError: except ValueError:
raise Http404("Invalid season format.") raise Http404("Invalid season format.")
def get_scoreboard(user_season_entries=list[UserSeasonEntry]): def get_scoreboard(user_season_entries=list[UserSeasonEntry]):
scoreboard = [] scoreboard = []
@@ -50,6 +53,7 @@ def get_scoreboard(user_season_entries=list[UserSeasonEntry]):
scoreboard.sort(key=lambda e: e["total"], reverse=True) scoreboard.sort(key=lambda e: e["total"], reverse=True)
return scoreboard return scoreboard
# Create your views here. # Create your views here.
def scoreboard_view(request, league_slug, season_slug): def scoreboard_view(request, league_slug, season_slug):
# season_slug is something like "summer-2025" # season_slug is something like "summer-2025"
@@ -70,66 +74,96 @@ def scoreboard_view(request, league_slug, season_slug):
"league": league, "league": league,
"season": season, "season": season,
"scoreboard": scoreboard, "scoreboard": scoreboard,
"breadcrumbs":[ "breadcrumbs": [
{"label":league.name, "url":"#"}, {"label": league.name, "url": "#"},
{"label":"seasons", "url":reverse('league:seasons', args=[league.slug])}, {
{"label":f"{season.label} {season.year}", "url":"#"}, "label": "seasons",
] "url": reverse("league:seasons", args=[league.slug]),
},
{"label": f"{season.label} {season.year}", "url": "#"},
],
}, },
) )
def team_view(request, league_slug=None, season_slug=None, username=None): def team_view(request, league_slug=None, season_slug=None, username=None):
if not league_slug: if not league_slug:
return HttpResponse("Team View: League not specified", content_type="text/plain") return HttpResponse(
"Team View: League not specified", content_type="text/plain"
)
league = get_object_or_404(League, slug=league_slug) league = get_object_or_404(League, slug=league_slug)
# 1⃣ League only all teams across all seasons in the league # 1⃣ League only all teams across all seasons in the league
if not season_slug: if not season_slug:
entries = UserSeasonEntry.objects.filter(season__league=league).select_related("user", "season") entries = UserSeasonEntry.objects.filter(season__league=league).select_related(
return render(request, "teams.dj.html", { "user", "season"
"entries": entries, )
"league": league, return render(
}) request,
"teams.dj.html",
{
"entries": entries,
"league": league,
},
)
# 2⃣ League + Season all teams in that season # 2⃣ League + Season all teams in that season
season_label, season_year = parse_season_slug(season_slug) season_label, season_year = parse_season_slug(season_slug)
season = get_object_or_404(Season, league=league, year=season_year, label__iexact=season_label) season = get_object_or_404(
Season, league=league, year=season_year, label__iexact=season_label
)
if not username: if not username:
entries = UserSeasonEntry.objects.filter(season=season).select_related("user") entries = UserSeasonEntry.objects.filter(season=season).select_related("user")
return render(request, "teams.dj.html", { return render(
"user_season_entries": [{ request,
"name": user_season_entry.user.get_full_name(), "teams.dj.html",
"team_name": user_season_entry.team_name, {
"username": user_season_entry.user.username "user_season_entries": [
} for user_season_entry in entries], {
"league": {'name': league.name}, "name": user_season_entry.user.get_full_name(),
"season": {'label':season.label, 'year':season.year}, "team_name": user_season_entry.team_name,
}) "username": user_season_entry.user.username,
}
for user_season_entry in entries
],
"league": {"name": league.name},
"season": {"label": season.label, "year": season.year},
},
)
# 3⃣ League + Season + Username one team and its picks # 3⃣ League + Season + Username one team and its picks
user = get_object_or_404(User, username=username) user = get_object_or_404(User, username=username)
entry = get_object_or_404(UserSeasonEntry, season=season, user=user) entry = get_object_or_404(UserSeasonEntry, season=season, user=user)
picks = Pick.objects.filter(season_entry=entry).select_related("movie").prefetch_related("movie__moviemetric_set") picks = (
Pick.objects.filter(season_entry=entry)
.select_related("movie")
.prefetch_related("movie__moviemetric_set")
)
movie_data = [] movie_data = []
for pick in picks: for pick in picks:
metrics = {m.key: m.value for m in pick.movie.moviemetric_set.all()} metrics = {m.key: m.value for m in pick.movie.moviemetric_set.all()}
movie_data.append({ movie_data.append(
"movie": pick.movie, {
"bid": pick.bid_amount, "movie": pick.movie,
"score": metrics.get("domestic_gross", 0) "bid": pick.bid_amount,
}) "score": metrics.get("domestic_gross", 0),
}
)
return render(request, "team_detail.dj.html", { return render(
"entry": entry, request,
"picks": movie_data, "team_detail.dj.html",
"league": league, {
"season": season, "entry": entry,
"user": user, "picks": movie_data,
}) "league": league,
"season": season,
"user": user,
},
)
def movie_view(request, league_slug=None, season_slug=None, imdb_id=None): def movie_view(request, league_slug=None, season_slug=None, imdb_id=None):
@@ -143,14 +177,22 @@ def movie_view(request, league_slug=None, season_slug=None, imdb_id=None):
movie_data = [ movie_data = [
{ {
"title": pick.movie.title, "title": pick.movie.title,
"score": pick.movie.moviemetric_set.filter(key="domestic_gross").first().value if pick.movie else 0, "score": (
pick.movie.moviemetric_set.filter(key="domestic_gross")
.first()
.value
if pick.movie
else 0
),
"bid": pick.bid_amount, "bid": pick.bid_amount,
"team_name": pick.season_entry.team_name, "team_name": pick.season_entry.team_name,
"user": pick.season_entry.user.username, "user": pick.season_entry.user.username,
} }
for pick in picks for pick in picks
] ]
return render(request, "movies.dj.html", {"movies": movie_data, "league": league}) return render(
request, "movies.dj.html", {"movies": movie_data, "league": league}
)
# 2⃣ League + Season — all movies in that season # 2⃣ League + Season — all movies in that season
if league_slug and season_slug and not imdb_id: if league_slug and season_slug and not imdb_id:
@@ -159,28 +201,44 @@ def movie_view(request, league_slug=None, season_slug=None, imdb_id=None):
season = get_object_or_404( season = get_object_or_404(
Season, league=league, year=season_year, label__iexact=season_label Season, league=league, year=season_year, label__iexact=season_label
) )
picks = season.pick_set.select_related("movie", "season_entry", "season_entry__user") picks = season.pick_set.select_related(
"movie", "season_entry", "season_entry__user"
)
movie_data = [ movie_data = [
{ {
"id": pick.movie.bom_legacy_id, "id": pick.movie.bom_legacy_id,
"title": pick.movie.title, "title": pick.movie.title,
"score": pick.movie.moviemetric_set.filter(key="domestic_gross").first().value if pick.movie else 0, "score": (
pick.movie.moviemetric_set.filter(key="domestic_gross")
.first()
.value
if pick.movie
else 0
),
"bid": pick.bid_amount, "bid": pick.bid_amount,
"team_name": pick.season_entry.team_name, "team_name": pick.season_entry.team_name,
"user": pick.season_entry.user.username, "user": pick.season_entry.user.username,
} }
for pick in picks for pick in picks
] ]
return render(request, "movies.dj.html", {"movies": movie_data, "season": season, "league": league}) return render(
request,
"movies.dj.html",
{"movies": movie_data, "season": season, "league": league},
)
# 3⃣ League + Season + Movie — show movie details # 3⃣ League + Season + Movie — show movie details
if league_slug and season_slug and imdb_id: if league_slug and season_slug and imdb_id:
season_label, season_year = parse_season_slug(season_slug) season_label, season_year = parse_season_slug(season_slug)
league = get_object_or_404(League, slug=league_slug) league = get_object_or_404(League, slug=league_slug)
season = get_object_or_404(Season, league=league, year=season_year, label__iexact=season_label) season = get_object_or_404(
Season, league=league, year=season_year, label__iexact=season_label
)
movie = get_object_or_404(Movie, imdb_id=imdb_id) movie = get_object_or_404(Movie, imdb_id=imdb_id)
picks = movie.pick_set.filter(season=season).select_related("season_entry", "season_entry__user") picks = movie.pick_set.filter(season=season).select_related(
"season_entry", "season_entry__user"
)
metrics = {m.key: m.value for m in movie.moviemetric_set.all()} metrics = {m.key: m.value for m in movie.moviemetric_set.all()}
tmdb_data = get_tmdb_movie_by_imdb(movie.imdb_id) tmdb_data = get_tmdb_movie_by_imdb(movie.imdb_id)
data = { data = {
@@ -214,10 +272,14 @@ def season_view(request, league_slug, season_slug=None):
} }
for season in league.season_set.all() for season in league.season_set.all()
] ]
return render(request, "seasons.dj.html", { return render(
"seasons": seasons, request,
"league": league, "seasons.dj.html",
}) {
"seasons": seasons,
"league": league,
},
)
# 2⃣ League + season show a basic detail page or placeholder # 2⃣ League + season show a basic detail page or placeholder
season_label, season_year = parse_season_slug(season_slug) season_label, season_year = parse_season_slug(season_slug)
@@ -226,73 +288,78 @@ def season_view(request, league_slug, season_slug=None):
) )
entries = UserSeasonEntry.objects.filter(season=season).select_related("user") entries = UserSeasonEntry.objects.filter(season=season).select_related("user")
picks = season.pick_set.select_related(
picks = season.pick_set.select_related("season_entry", "season_entry__user").annotate( "season_entry", "season_entry__user"
).annotate(
domestic_gross=Sum( domestic_gross=Sum(
'movie__moviemetric__value', "movie__moviemetric__value",
filter=Q(movie__moviemetric__key='domestic_gross') filter=Q(movie__moviemetric__key="domestic_gross"),
)
) )
)
return render(request, "season.dj.html", { return render(
"season": season, request,
"league": league, "season.dj.html",
"scoreboard": get_scoreboard(entries), {
"picks":picks "season": season,
}) "league": league,
"scoreboard": get_scoreboard(entries),
"picks": picks,
},
)
def league_view(request, league_slug=None): def league_view(request, league_slug=None):
# 1⃣ League only list all seasons in the league # 1⃣ League only list all seasons in the league
if not league_slug: if not league_slug:
return render( return render(request, "leagues.dj.html", {"leagues": League.objects.all()})
request,
{
"leagues": League.objects.all()
}
)
league = get_object_or_404(League, slug=league_slug) league = get_object_or_404(League, slug=league_slug)
# Subquery: top entry per season by total score # Subquery: top entry per season by total score
top_entry = ( top_entry = (
UserSeasonEntry.objects UserSeasonEntry.objects.filter(season=OuterRef("pk"))
.filter(season=OuterRef('pk')) .annotate(
.annotate(total_score=Sum( total_score=Sum(
'pick__movie__moviemetric__value', "pick__movie__moviemetric__value",
filter=Q(pick__movie__moviemetric__key='domestic_gross') filter=Q(pick__movie__moviemetric__key="domestic_gross"),
)) )
.order_by('-total_score') )
.order_by("-total_score")
) )
winner_team = top_entry.values('team_name')[:1] winner_team = top_entry.values("team_name")[:1]
winner_score = top_entry.values('total_score')[:1] winner_score = top_entry.values("total_score")[:1]
# Subquery: pick with top-grossing movie for the season # Subquery: pick with top-grossing movie for the season
top_pick = ( top_pick = (
Pick.objects Pick.objects.filter(season=OuterRef("pk"))
.filter(season=OuterRef('pk')) .annotate(
.annotate(gross=Sum( gross=Sum(
'movie__moviemetric__value', "movie__moviemetric__value",
filter=Q(movie__moviemetric__key='domestic_gross') filter=Q(movie__moviemetric__key="domestic_gross"),
)) )
.order_by('-gross') )
.order_by("-gross")
) )
top_movie = top_pick.values('movie__title')[:1] top_movie = top_pick.values("movie__title")[:1]
top_gross = top_pick.values('gross')[:1] top_gross = top_pick.values("gross")[:1]
seasons = ( seasons = Season.objects.annotate(
Season.objects
.annotate(
winner_team=Subquery(winner_team), winner_team=Subquery(winner_team),
winner_score=Subquery(winner_score), winner_score=Subquery(winner_score),
top_movie=Subquery(top_movie), top_movie=Subquery(top_movie),
top_movie_score=Subquery(top_gross), top_movie_score=Subquery(top_gross),
) ).order_by("-year")
.order_by('-year')
)
return render(request, "league.dj.html", { draft_sessions = DraftSession.objects.filter(season__league=league)
"league": league,
"seasons": seasons, return render(
}) request,
"league.dj.html",
{
"league": league,
"seasons": seasons,
"draft_sessions": draft_sessions
},
)

View File

@@ -1,20 +1,19 @@
from django.contrib import admin from django.contrib import admin
from draft.models import DraftSession, DraftParticipant, DraftMoviePool, DraftPick, DraftSessionSettings from draft.models import DraftSession, DraftPick, DraftSessionSettings, DraftSessionParticipant
from boxofficefantasy.models import User
class DraftSessionSettingsInline(admin.TabularInline): # or TabularInline class DraftSessionSettingsInline(admin.TabularInline): # or TabularInline
model = DraftSessionSettings model = DraftSessionSettings
can_delete = False can_delete = False
show_change_link = True show_change_link = True
class DraftParticipantInline(admin.TabularInline): class DrafteSessionUserInline(admin.TabularInline):
extra = 0 extra = 0
model = DraftParticipant model = DraftSessionParticipant
class DraftSessionAdmin(admin.ModelAdmin): class DraftSessionAdmin(admin.ModelAdmin):
inlines = [DraftSessionSettingsInline, DraftParticipantInline] inlines = [DraftSessionSettingsInline, DrafteSessionUserInline]
readonly_fields = ('hashed_id',) readonly_fields = ('hashid',)
# Register your models here. # Register your models here.
admin.site.register(DraftSession, DraftSessionAdmin) admin.site.register(DraftSession, DraftSessionAdmin)
admin.site.register(DraftSessionSettings) admin.site.register(DraftSessionSettings)
admin.site.register(DraftParticipant)
admin.site.register(DraftMoviePool)
admin.site.register(DraftPick) admin.site.register(DraftPick)

View File

@@ -3,11 +3,11 @@ from enum import IntEnum
class DraftMessage: class DraftMessage:
# Server # Server
INFORM_PHASE_CHANGE = "inform.phase.change" INFORM_PHASE_CHANGE = "inform.phase.change"
CONFIRM_PHASE_ADVANCE = "confirm.phase.advance" CONFIRM_PHASE_CHANGE = "confirm.phase.change"
INFORM_STATUS = "inform.status" INFORM_PHASE = "inform.phase"
# Client # Client
REQUEST_PHASE_ADVANCE = "request.phase.advance" REQUEST_PHASE_CHANGE = "request.phase.change"
REQUEST_INFORM_STATUS = "request.inform.status" REQUEST_INFORM_STATUS = "request.inform.status"
# Waiting Phase # Waiting Phase
@@ -15,10 +15,12 @@ class DraftMessage:
INFORM_JOIN_USER = "inform.join.user" INFORM_JOIN_USER = "inform.join.user"
REQUEST_JOIN_PARTICIPANT = "request.join.participant" REQUEST_JOIN_PARTICIPANT = "request.join.participant"
REQUEST_JOIN_ADMIN = "request.join.admin" REQUEST_JOIN_ADMIN = "request.join.admin"
INFORM_LEAVE_PARTICIPANT = "inform.leave.participant"
## Client ## Client
NOTIFY_JOIN_USER = "notify.join.user" NOTIFY_JOIN_USER = "notify.join.user"
CONFIRM_JOIN_PARTICIPANT = "confirm.join.participant" CONFIRM_JOIN_PARTICIPANT = "confirm.join.participant"
REJECT_JOIN_PARTICIPANT = "reject.join.participant"
CONFIRM_JOIN_ADMIN = "confirm.join.admin" CONFIRM_JOIN_ADMIN = "confirm.join.admin"
# Determine Order # Determine Order
@@ -54,42 +56,4 @@ class DraftGroupChannelNames:
@property @property
def participant(self): def participant(self):
return f"{self.prefix}.participant" return f"{self.prefix}.participant"
class DraftCacheKeys:
def __init__(self, id):
self.prefix = f"draft:{id}"
@property
def admins(self):
return f"{self.prefix}:admins"
@property
def participants(self):
return f"{self.prefix}:participants"
# @property
# def state(self):
# return f"{self.prefix}:state"
# @property
# def current_movie(self):
# return f"{self.prefix}:current_movie"
# @property
# def bids(self):
# return f"{self.prefix}:bids"
# @property
# def participants(self):
# return f"{self.prefix}:participants"
# @property
# def bid_timer_end(self):
# return f"{self.prefix}:bid_timer_end"
# def user_status(self, user_id):
# return f"{self.prefix}:user:{user_id}:status"
# def user_channel(self, user_id):
# return f"{self.prefix}:user:{user_id}:channel"

View File

@@ -3,61 +3,125 @@ from channels.db import database_sync_to_async
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from boxofficefantasy.models import League, Season from boxofficefantasy.models import League, Season
from boxofficefantasy.views import parse_season_slug from boxofficefantasy.views import parse_season_slug
from draft.models import DraftSession, DraftPick, DraftMoviePool, DraftParticipant from draft.models import DraftSession, DraftSessionParticipant
from django.core.cache import cache from django.core.cache import cache
from draft.constants import DraftMessage, DraftPhase, DraftGroupChannelNames import asyncio
from django.contrib.auth.models import User
from draft.constants import (
DraftMessage,
DraftPhase,
DraftGroupChannelNames,
)
from draft.state import DraftCacheKeys, DraftStateManager
import random import random
class DraftConsumerBase(AsyncJsonWebsocketConsumer): class DraftConsumerBase(AsyncJsonWebsocketConsumer):
group_names: DraftGroupChannelNames
cache_keys: DraftCacheKeys
draft_state: DraftStateManager
user: User
async def connect(self): async def connect(self):
draft_session_id_hashed = self.scope["url_route"]["kwargs"].get( draft_hashid = self.scope["url_route"]["kwargs"].get("draft_session_id_hashed")
"draft_session_id_hashed"
)
league_slug = self.scope["url_route"]["kwargs"].get("league_slug")
season_slug = self.scope["url_route"]["kwargs"].get("season_slug")
self.draft_session = await self.get_draft_session( self.draft_session = await self.get_draft_session(
draft_session_id_hashed=draft_session_id_hashed, draft_session_id_hashed=draft_hashid,
)
self.draft_participants = await self.get_draft_participants(
session=self.draft_session
) )
self.draft_group_names = f"draft_admin_{self.draft_session.hashed_id}" self.group_names = DraftGroupChannelNames(draft_hashid)
self.draft_participant_group_channels = DraftGroupChannelNames(draft_session_id_hashed) self.cache_keys = DraftCacheKeys(draft_hashid)
self.draft_state = DraftStateManager(draft_hashid)
self.user = self.scope["user"] self.user = self.scope["user"]
if not self.user.is_authenticated: if not self.should_accept_user():
await self.send_json({
"type": DraftMessage.REJECT_JOIN_PARTICIPANT,
"user": self.user.username
})
await self.close() await self.close()
await self.channel_layer.group_send(
self.group_names.session,
{
"type": DraftMessage.REJECT_JOIN_PARTICIPANT,
"user": self.user.username
},
)
return return
else: else:
await self.accept() await self.accept()
await self.channel_layer.group_add(
self.group_names.session, self.channel_name
)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": DraftMessage.INFORM_JOIN_USER,
"user": self.user.username
},
)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": DraftMessage.INFORM_PHASE,
"phase": str(self.draft_state.phase)
}
)
async def should_accept_user(self)->bool:
return self.user.is_authenticated
async def receive_json(self, content): async def receive_json(self, content):
event_type = content.get("type") event_type = content.get("type")
user = self.scope["user"]
async def user_joined(self, event): async def inform_leave_participant(self,event):
await self.send_json( await self.send_json(
{ {
"type": "user.joined", "type": event["type"],
"user": event["user"].username, "user": event["user"],
"user_type": event["user_type"], "participants": [user.username for user in self.draft_participants],
"users": event["users"], "connected_participants": self.draft_state.connected_users
} }
) )
async def send_draft_summary(self): async def inform_join_user(self, event):
state = cache.get(self.draft_status_cache_key, {})
await self.send_json( await self.send_json(
{ {
"type": "draft_summary", "type": event["type"],
"phase": state.get("phase", "not started"), "user": event["user"],
"movie": state.get("movie"), "participants": [user.username for user in self.draft_participants],
"current_bid": state.get("current_bid"), "connected_participants": self.draft_state.connected_users
"time_remaining": state.get("time_remaining"),
"you_are_next": state.get("you_are_next", False),
} }
) )
async def reject_join_participant(self,event):
await self.send_json(
{
"type": event["type"],
"user": event["user"],
"participants": [user.username for user in self.draft_participants],
"connected_participants": self.draft_state.connected_users
}
)
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 send_draft_summary(self): ...
# === Broadcast handlers === # === Broadcast handlers ===
async def draft_status(self, event): async def draft_status(self, event):
await self.send_json( await self.send_json(
@@ -69,36 +133,20 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
# === DB Access === # === DB Access ===
@database_sync_to_async @database_sync_to_async
def get_draft_session( def get_draft_session(self, draft_session_id_hashed) -> DraftSession:
self, draft_session_id_hashed, league_slug, season_slug
) -> DraftSession:
draft_session_id = DraftSession.decode_id(draft_session_id_hashed) draft_session_id = DraftSession.decode_id(draft_session_id_hashed)
if draft_session_id: if draft_session_id:
draft_session = DraftSession.objects.select_related( draft_session = DraftSession.objects.select_related(
"season", "season__league" "season", "season__league"
).get(pk=draft_session_id) ).get(pk=draft_session_id)
elif league_slug and season_slug:
label, year = parse_season_slug(season_slug)
season = Season.objects.filter(label=label, year=year).first()
draft_session = (
DraftSession.objects.select_related("season", "season__league")
.filter(season=season)
.first()
)
else: else:
raise Exception() raise Exception()
return draft_session return draft_session
@database_sync_to_async @database_sync_to_async
def get_draft_participants(self) -> list[DraftParticipant]: def get_draft_participants(self, session) -> list[DraftSessionParticipant]:
# Replace this with real queryset to fetch users in draft participants = session.participants.all()
participants = DraftParticipant.objects.select_related("user").filter(
draft=self.draft_session
)
connected_ids = cache.get(self.draft_connected_participants_cache_key, set())
for p in participants:
p.is_connected = p in connected_ids
return list(participants.all()) return list(participants.all())
@@ -109,192 +157,118 @@ class DraftAdminConsumer(DraftConsumerBase):
await self.close() await self.close()
return return
await self.channel_layer.group_add( await self.channel_layer.group_add(self.group_names.admin, self.channel_name)
self.draft_admin_group_name, self.channel_name
async def receive_json(self, content):
await super().receive_json(content)
event_type = content.get("type")
user = self.scope["user"]
destination = DraftPhase(content.get("destination"))
if (
event_type == DraftMessage.REQUEST_PHASE_CHANGE
and destination == DraftPhase.DETERMINE_ORDER
):
await self.determine_draft_order()
def should_accept_user(self):
return super().should_accept_user() and self.user.is_staff
# === Draft logic ===
async def determine_draft_order(self):
draft_order = random.sample(self.draft_participants, len(self.draft_participants))
self.draft_state.draft_order = [p.username for p in draft_order]
await self.set_draft_phase(DraftPhase.DETERMINE_ORDER)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": DraftMessage.CONFIRM_DETERMINE_DRAFT_ORDER,
"payload": {
"draft_order": self.draft_state.draft_order
},
},
) )
# await self.channel_layer.group_send(
# self.draft_participant_group_name, async def set_draft_phase(self, destination: DraftPhase):
# {"type": "user.joined", "user": self.user, "user_type": "admin"}, self.draft_state.phase = destination
# )
await self.channel_layer.group_send( await self.channel_layer.group_send(
self.draft_admin_group_name, self.group_names.session,
{"type": "user.joined", "user": self.user, "user_type": "admin"}, {
"type": DraftMessage.CONFIRM_PHASE_CHANGE,
"payload": {
"phase": self.draft_state.phase
},
},
) )
# === Broadcast Handlers ===
async def confirm_phase_change(self, event):
await self.send_json({
"type": event["type"],
"payload": event["payload"]
})
class DraftParticipantConsumer(DraftConsumerBase):
async def connect(self):
await super().connect()
self.draft_state.connect_user(self.user.username)
await self.channel_layer.group_add(
self.group_names.participant, self.channel_name
)
async def disconnect(self, close_code):
await self.channel_layer.group_send(
self.group_names.session,
{
"type": DraftMessage.INFORM_LEAVE_PARTICIPANT,
"user": self.user.username
},
)
await super().disconnect(close_code)
self.draft_state.disconnect_user(self.user.username)
await self.channel_layer.group_discard(
self.group_names.session, self.channel_name
)
def should_accept_user(self):
return super().should_accept_user() and self.user in self.draft_participants
async def receive_json(self, content): async def receive_json(self, content):
await super().receive_json(content) await super().receive_json(content)
event_type = content.get("type") event_type = content.get("type")
user = self.scope["user"] user = self.scope["user"]
if event_type == "start.draft": if event_type == DraftMessage.REQUEST_JOIN_PARTICIPANT:
await self.start_draft()
elif event_type == "user.joined":
pass
elif event_type == "nominate":
await self.nominate(content.get("movie"))
elif event_type == "bid":
await self.place_bid(content.get("amount"), self.scope["user"].username)
elif event_type == "message":
await self.channel_layer.group_send( await self.channel_layer.group_send(
self.draft_participant_group_name, self.group_names.admin,
{ {"type": DraftMessage.REQUEST_JOIN_PARTICIPANT, "user": user},
"type": "chat.message",
"user": self.scope["user"].username,
"message": content.get("message"),
},
)
# === Draft logic (stubbed for now) ===
async def start_draft(self):
# Example: shuffle draft order
participants = await self.get_draft_participants()
draft_order = random.sample(participants, len(participants))
connected_participants = cache.get(
self.draft_connected_participants_cache_key, ()
)
initial_state = {
"phase": "nominating",
"current_nominee": None,
"current_bid": None,
"participants": [
{"user": p.user.username, "is_connected": p in connected_participants}
for p in await self.get_draft_participants()
],
"draft_order": [p.user.username for p in draft_order],
"current_turn_index": 0,
"picks": [],
}
cache.set(self.draft_status_cache_key, initial_state)
for group_name in [
self.draft_admin_group_name,
self.draft_participant_group_name,
]:
# await self.channel_layer.group_send(
# group_name,
# {
# "type": "draft.start"
# }
# )
await self.channel_layer.group_send(
group_name,
{
"type": "draft.status",
"status": cache.get(self.draft_status_cache_key),
},
)
class DraftParticipantConsumer(DraftConsumerBase):
async def connect(self):
await super().connect()
await self.channel_layer.group_add(
self.draft_participant_group_name, self.channel_name
)
try:
await self.add_draft_participant()
except Exception as e:
await self.close()
return
await self.send_json(
{
"type": "connection.accepted",
"user": self.user.username,
"is_staff": self.user.is_staff,
}
)
await self.channel_layer.group_send(
self.draft_participant_group_name,
{
"type": "user.joined",
"user": self.user,
"user_type": "participant",
"participants": [],
},
)
await self.channel_layer.group_send(
self.draft_admin_group_name,
{"type": "user.joined", "user": self.user, "user_type": "participant"},
)
async def disconnect(self, close_code):
await self.channel_layer.group_discard(
self.draft_participant_group_name, self.channel_name
)
async def receive_json(self, content):
event_type = content.get("type")
user = self.scope["user"]
if event_type == "user.joined":
pass
elif event_type == "nominate":
await self.nominate(content.get("movie"))
elif event_type == "bid":
await self.place_bid(content.get("amount"), self.scope["user"].username)
elif event_type == "message":
await self.channel_layer.group_send(
self.draft_participant_group_name,
{
"type": "chat.message",
"user": self.scope["user"].username,
"message": content.get("message"),
},
) )
# === Broadcast handlers === # === Broadcast handlers ===
async def chat_message(self, event): async def request_join_participant(self, event):
await self.send_json( await self.send_json(
{ {
"type": "chat.message", "type": event["type"],
"user": event["user"], "user": event["user"],
} }
) )
async def draft_update(self, event): # === Draft ===
await self.send_json(
{
"type": "draft.update",
"state": event["state"],
}
)
# === Draft logic (stubbed for now) === async def nominate(self, movie_title): ...
async def nominate(self, movie_title): async def place_bid(self, amount, user): ...
await self.channel_layer.group_send(
self.draft_participant_group_name,
{
"type": "draft.update",
"state": {
"status": "nominating",
"movie": movie_title,
},
},
)
async def place_bid(self, amount, user):
await self.channel_layer.group_send(
self.draft_participant_group_name,
{
"type": "draft.update",
"state": {"status": "bidding", "bid": {"amount": amount, "user": user}},
},
)
# === Example DB Access === # === Example DB Access ===
@database_sync_to_async @database_sync_to_async
def add_draft_participant(self): def add_draft_participant(self):
self.participant, _ = DraftParticipant.objects.get_or_create( self.participant, _ = DraftSessionParticipant.objects.get_or_create(
user=self.user, user=self.user,
draft=self.draft_session, draft=self.draft_session,
defaults={"budget": self.draft_session.settings.starting_budget}, defaults={"budget": self.draft_session.settings.starting_budget},

View File

@@ -0,0 +1,64 @@
# Generated by Django 5.2.4 on 2025-08-02 00:47
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boxofficefantasy', '0009_alter_moviemetric_value_alter_pick_bid_amount_and_more'),
('draft', '0005_remove_draftsession_settings_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveField(
model_name='draftparticipant',
name='draft',
),
migrations.RemoveField(
model_name='draftparticipant',
name='user',
),
migrations.AlterModelOptions(
name='draftsessionsettings',
options={'verbose_name_plural': 'Draft session settings'},
),
migrations.RemoveField(
model_name='draftsession',
name='current_nomination_index',
),
migrations.RemoveField(
model_name='draftsession',
name='is_active',
),
migrations.AddField(
model_name='draftsession',
name='movies',
field=models.ManyToManyField(related_name='draft_sessions', to='boxofficefantasy.movie'),
),
migrations.CreateModel(
name='DraftSessionParticipant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('draft_session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='draft.draftsession')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('draft_session', 'user')},
},
),
migrations.AddField(
model_name='draftsession',
name='participants',
field=models.ManyToManyField(related_name='participant_entries', through='draft.DraftSessionParticipant', to=settings.AUTH_USER_MODEL),
),
migrations.DeleteModel(
name='DraftMoviePool',
),
migrations.DeleteModel(
name='DraftParticipant',
),
]

View File

@@ -1,38 +1,53 @@
from django.db.models import ForeignKey, Model, IntegerField, BooleanField, CASCADE, PROTECT, OneToOneField from django.db.models import (
ForeignKey,
Model,
IntegerField,
BooleanField,
CASCADE,
PROTECT,
OneToOneField,
ManyToManyField,
)
from boxofficefantasy.models import Season, User, Movie from boxofficefantasy.models import Season, User, Movie
from boxofficefantasy_project.utils import encode_id, decode_id from boxofficefantasy_project.utils import encode_id, decode_id
# Create your models here. # Create your models here.
class DraftSession(Model): class DraftSession(Model):
season = ForeignKey(Season, on_delete=CASCADE) season = ForeignKey(Season, on_delete=CASCADE)
is_active = BooleanField()
current_nomination_index = IntegerField() participants: ManyToManyField = ManyToManyField(
User, through="DraftSessionParticipant", related_name="participant_entries"
)
movies: ManyToManyField = ManyToManyField(Movie, related_name="draft_sessions", blank=True)
@property @property
def hashed_id(self): def hashid(self):
if not self.pk: return "" if not self.pk:
return ""
return f"{encode_id(self.pk)}" return f"{encode_id(self.pk)}"
@classmethod @classmethod
def decode_id(cls, hashed_id:str) -> id: def decode_id(cls, hashed_id: str) -> id:
return decode_id(hashed_id) return decode_id(hashed_id)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
is_new = self.pk is None is_new = self.pk is None
super().save(*args, **kwargs) super().save(*args, **kwargs)
if is_new and not hasattr(self, 'settings'): if is_new and not hasattr(self, "settings"):
DraftSessionSettings.objects.create(draft_session=self) DraftSessionSettings.objects.create(draft_session=self)
class DraftParticipant(Model): class DraftSessionParticipant(Model):
draft = ForeignKey(DraftSession, on_delete=CASCADE) draft_session = ForeignKey(DraftSession, on_delete=CASCADE, blank=True)
user = ForeignKey(User, on_delete=CASCADE) user = ForeignKey(User, on_delete=CASCADE)
budget = IntegerField()
class DraftMoviePool(Model): class Meta:
draft = ForeignKey(DraftSession, on_delete=CASCADE) unique_together = [("draft_session", "user")]
movie = ForeignKey(Movie, on_delete=CASCADE)
nominated = BooleanField() def __str__(self):
return f"{self.user} in {self.draft_session}"
class DraftPick(Model): class DraftPick(Model):
draft = ForeignKey(DraftSession, on_delete=CASCADE) draft = ForeignKey(DraftSession, on_delete=CASCADE)
@@ -41,16 +56,15 @@ class DraftPick(Model):
bid_amount = IntegerField() bid_amount = IntegerField()
nomination_order = IntegerField() nomination_order = IntegerField()
class DraftSessionSettings(Model): class DraftSessionSettings(Model):
starting_budget = IntegerField(default=100) starting_budget = IntegerField(default=100)
draft_session = OneToOneField( draft_session = OneToOneField(
DraftSession, DraftSession, on_delete=CASCADE, related_name="settings"
on_delete=CASCADE,
related_name="settings"
) )
def __str__(self): def __str__(self):
return f"Settings for {self.draft_session}" return f"Settings for {self.draft_session}"
class Meta: class Meta:
verbose_name_plural = "Draft session settings" verbose_name_plural = "Draft session settings"

137
draft/state.py Normal file
View File

@@ -0,0 +1,137 @@
from django.core.cache import cache
import json
from datetime import datetime, timedelta
from boxofficefantasy.models import Movie
from django.contrib.auth.models import User
from draft.constants import DraftPhase
class DraftCacheKeys:
def __init__(self, id):
self.prefix = f"draft:{id}"
@property
def admins(self):
return f"{self.prefix}:admins"
@property
def participants(self):
return f"{self.prefix}:participants"
@property
def users(self):
return f"{self.prefix}:users"
@property
def connected_users(self):
return f"{self.prefix}:connected_users"
@property
def phase(self):
return f"{self.prefix}:phase"
@property
def draft_order(self):
return f"{self.prefix}:draft_order"
# @property
# def state(self):
# return f"{self.prefix}:state"
# @property
# def current_movie(self):
# return f"{self.prefix}:current_movie"
# @property
# def bids(self):
# return f"{self.prefix}:bids"
# @property
# def participants(self):
# return f"{self.prefix}:participants"
# @property
# def bid_timer_end(self):
# return f"{self.prefix}:bid_timer_end"
# def user_status(self, user_id):
# return f"{self.prefix}:user:{user_id}:status"
# def user_channel(self, user_id):
# return f"{self.prefix}:user:{user_id}:channel"
class DraftStateManager:
def __init__(self, session_id: int):
self.session_id = session_id
self.cache = cache
self.keys = DraftCacheKeys(session_id)
self._phase = self.cache.get(self.keys.phase, DraftPhase.WAITING)
self.draft_order = self.cache.get(self.keys.draft_order)
# === Phase Management ===
@property
def phase(self) -> str:
return str(self.cache.get(self.keys.phase, self._phase))
@phase.setter
def phase(self, new_phase: DraftPhase):
self.cache.set(self.keys.phase, new_phase)
# === Connected Users ===
@property
def connected_users(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)
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)
users.discard(username)
self.cache.set(self.keys.connected_users, json.dumps(list(users)))
# === Draft Order ===
@property
def draft_order(self):
return json.loads(self.cache.get(self.keys.draft_order,"[]"))
@draft_order.setter
def draft_order(self, draft_order: list[User]):
self.cache.set(self.keys.draft_order,json.dumps(draft_order))
# === Current Nomination / Bid ===
def start_nomination(self, movie_id: int):
self.cache.set(self.keys.current_movie, movie_id)
self.cache.delete(self.keys.bids)
def place_bid(self, user_id: int, amount: int):
bids = self.get_bids()
bids[user_id] = amount
self.cache.set(self.keys.bids, json.dumps(bids))
def get_bids(self) -> dict:
return json.loads(self.cache.get(self.keys.bids) or "{}")
def current_movie(self) -> Movie | None:
movie_id = self.cache.get(self.keys.current_movie)
return Movie.objects.filter(pk=movie_id).first() if movie_id else None
def start_timer(self, seconds: int):
end_time = (datetime.now() + timedelta(seconds=seconds)).isoformat()
self.cache.set(self.keys.timer_end, end_time)
self.cache.set(self.keys.timer_end, end_time)
def get_timer_end(self) -> str | None:
return self.cache.get(self.keys.timer_end).decode("utf-8") if self.cache.get(self.keys.timer_end) else None
# === Sync Snapshot ===
def get_summary(self) -> dict:
return {
"phase": self.phase,
"connected_users": self.connected_users,
"current_movie": self.cache.get(self.keys.current_movie),
"bids": self.get_bids(),
"timer_end": self.get_timer_end(),
}

View File

@@ -1,3 +1,5 @@
{% extends "base.dj.html" %}
{% block content %}
<h1>Draft Room: {{ league.name }} {{ season.label }} {{ season.year }}</h1> <h1>Draft Room: {{ league.name }} {{ season.label }} {{ season.year }}</h1>
{% load static %} {% load static %}
<div id="draft-app" data-draft-id="{{draft_id_hashed}}"></div> <div id="draft-app" data-draft-id="{{draft_id_hashed}}"></div>
@@ -5,4 +7,5 @@
<script src="http://localhost:3000/dist/bundle.js"></script> <script src="http://localhost:3000/dist/bundle.js"></script>
{% else %} {% else %}
<script src="{% static 'bundle.js' %}"></script> <script src="{% static 'bundle.js' %}"></script>
{% endif %} {% endif %}
{% endblock %}

View File

@@ -1,3 +1,5 @@
{% extends "base.dj.html" %}
{% block content %}
<h1>Draft Room: {{ league.name }} {{ season.label }} {{ season.year }}</h1> <h1>Draft Room: {{ league.name }} {{ season.label }} {{ season.year }}</h1>
{% load static %} {% load static %}
<div id="draft-admin-app" data-draft-id="{{ draft_id_hashed }}"></div> <div id="draft-admin-app" data-draft-id="{{ draft_id_hashed }}"></div>
@@ -6,3 +8,4 @@
{% else %} {% else %}
<script src="{% static 'bundle.js' %}"></script> <script src="{% static 'bundle.js' %}"></script>
{% endif %} {% endif %}
{% endblock %}

View File

@@ -20,7 +20,7 @@ def draft_room(request, league_slug=None, season_slug=None, draft_session_id_has
draft_session = get_object_or_404(DraftSession, season=season) draft_session = get_object_or_404(DraftSession, season=season)
context = { context = {
"draft_id_hashed": draft_session.hashed_id, "draft_id_hashed": draft_session.hashid,
"league": league, "league": league,
"season": season, "season": season,
} }

View File

@@ -0,0 +1,39 @@
export const DraftMessage = {
// Server to Client
INFORM: {
PHASE_CHANGE: "inform.phase.change",
STATUS: "inform.status",
JOIN_USER: "inform.join.user",
},
// 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",
},
// 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",
},
};
export const DraftPhase = {
WAITING: 0,
DETERMINE_ORDER: 10,
NOMINATION: 20,
BIDDING: 30,
AWARD: 40,
FINALIZE: 50,
}

View File

@@ -1,24 +1,22 @@
import React, { useEffect, useState, useRef } from "react"; import React, { useEffect, useState, useRef } from "react";
import { DraftMessage, DraftPhase } from './constants.js';
export const WebSocketStatus = ({ socket }) => {
export const useWebSocketStatus = (wsUrl) => {
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
useEffect(() => { useEffect(() => {
const socket = new WebSocket(wsUrl);
socket.onopen = () => setIsConnected(true); if (!socket) return;
socket.onclose = () => setIsConnected(false);
socket.onerror = () => setIsConnected(false);
return () => socket.close(); if (socket.readyState === WebSocket.OPEN) {
}, [wsUrl]); setIsConnected(true);
}
return isConnected;
};
export const WebSocketStatus = ({ wsUrl }) => {
const isConnected = useWebSocketStatus(wsUrl);
socket.addEventListener("open", () => setIsConnected(true));
socket.addEventListener("close", () => setIsConnected(false));
socket.addEventListener("error", () => setIsConnected(false));
}, [socket])
return ( return (
<div className="d-flex align-items-center gap-2"> <div className="d-flex align-items-center gap-2">
<span <span
@@ -36,10 +34,52 @@ export const WebSocketStatus = ({ wsUrl }) => {
); );
}; };
export const MessageLogger = ({ socket }) => {
const [messages, setMessages] = useState([]);
const bottomRef = useRef(null);
useEffect(() => {
if (!socket) return;
const handleMessage = (event) => {
const data = JSON.parse(event.data);
setMessages((prev) => [...prev, data]);
};
socket.addEventListener("message", handleMessage);
return () => {
socket.removeEventListener("message", handleMessage);
};
}, [socket]);
useEffect(() => {
// Scroll to bottom when messages update
if (bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
return (
<div className="message-logger mt-4">
<label>📥 Received Messages</label>
<div style={{ maxHeight: '300px', overflowY: 'scroll', fontFamily: 'monospace', background: '#f8f9fa', padding: '1em', border: '1px solid #ccc' }}>
{messages.map((msg, i) => (
<div key={i}>
<pre style={{ margin: 0 }}>{JSON.stringify(msg, null, 2)}</pre>
<hr />
</div>
))}
<div ref={bottomRef} />
</div>
</div>
);
};
export const DraftAdmin = ({ draftSessionId }) => { export const DraftAdmin = ({ draftSessionId }) => {
const [latestMessage, setLatestMessage] = useState(null); const [latestMessage, setLatestMessage] = useState(null);
const [connectedParticipants, setConnectedParticipants] = useState([]); const [connectedParticipants, setConnectedParticipants] = useState([]);
const [isConnected, setIsConnected] = useState(false); const [draftPhase, setDraftPhase] = useState();
const socketRef = useRef(null); const socketRef = useRef(null);
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/admin`; const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/admin`;
@@ -48,28 +88,34 @@ export const DraftAdmin = ({ draftSessionId }) => {
socketRef.current = new WebSocket(wsUrl); socketRef.current = new WebSocket(wsUrl);
socketRef.current.onmessage = (event) => { socketRef.current.onmessage = (event) => {
const data = JSON.parse(event.data); const message = JSON.parse(event.data)
console.log(event) const { type, payload } = message;
setLatestMessage(data); console.log(type, event)
if (data.type == "user.joined") { setLatestMessage(message);
// setConnectedParticipants = if (type == DraftMessage.REQUEST.JOIN_PARTICIPANT) {
console.log('join request', data)
} }
else if (data.type == "draft_summary"){ else if (type == DraftMessage.CONFIRM.JOIN_PARTICIPANT) {
console.log(data) setConnectedParticipants(data.connected_participants)
}
else if (type == DraftMessage.CONFIRM.PHASE_CHANGE) {
console.log('phase_change')
setDraftPhase(payload.phase)
} }
}; };
socketRef.current.onclose = () => { socketRef.current.onclose = (event) => {
console.warn("WebSocket connection closed."); console.log('Websocket Closed')
}; socketRef.current = null;
}
return () => { return () => {
socketRef.current.close(); socketRef.current.close();
}; };
}, [wsUrl]); }, [wsUrl]);
const handleStartDraft = () => { const handlePhaseChange = (destinationPhase) => {
socketRef.current.send(JSON.stringify({ type: "start.draft" })); socketRef.current.send(JSON.stringify({ type: DraftMessage.REQUEST.PHASE_CHANGE, "destination": destinationPhase }));
} }
@@ -80,21 +126,22 @@ export const DraftAdmin = ({ draftSessionId }) => {
return ( return (
<div className="container draft-panel"> <div className="container draft-panel">
<h3>Draft Admin Panel</h3> <h3>Draft Admin Panel</h3>
<WebSocketStatus wsUrl={wsUrl} /> <WebSocketStatus socket={socketRef.current} />
<label>Latest Message</label> <MessageLogger socket={socketRef.current}></MessageLogger>
<input
type="text"
readOnly disabled
value={latestMessage ? JSON.stringify(latestMessage) : ""}
/>
<label>Connected Particpants</label> <label>Connected Particpants</label>
<input <input
type="text" type="text"
readOnly disabled readOnly disabled
value={connectedParticipants ? JSON.stringify(connectedParticipants) : ""} value={connectedParticipants ? JSON.stringify(connectedParticipants) : ""}
/> />
<button onClick={handleStartDraft} className="btn btn-primary mt-2"> <label>Draft Phase</label>
Start Draft <input
type="text"
readOnly disabled
value={draftPhase ? JSON.stringify(draftPhase) : ""}
/>
<button onClick={() => handlePhaseChange(DraftPhase.DETERMINE_ORDER)} className="btn btn-primary mt-2 me-2">
Determine Draft Order
</button> </button>
<button onClick={handleRequestDraftSummary} className="btn btn-primary mt-2"> <button onClick={handleRequestDraftSummary} className="btn btn-primary mt-2">
Request status Request status
@@ -111,16 +158,15 @@ export const DraftParticipant = ({ draftSessionId }) => {
useEffect(() => { useEffect(() => {
socketRef.current = new WebSocket(wsUrl); socketRef.current = new WebSocket(wsUrl);
socketRef.current.onmessage = (event) => { socketRef.current.onmessage = (evt) => {
const data = JSON.parse(event.data); const data = JSON.parse(evt.data);
console.log(data)
setLatestMessage(data); setLatestMessage(data);
if (data.type == "draft_summary") {
console.log('draft_summary', data)
}
}; };
socketRef.current.onclose = () => { socketRef.current.onclose = () => {
console.warn("WebSocket connection closed."); console.warn("WebSocket connection closed.");
socketRef.current = null;
}; };
return () => { return () => {
@@ -135,7 +181,7 @@ export const DraftParticipant = ({ draftSessionId }) => {
return ( return (
<div className="container draft-panel"> <div className="container draft-panel">
<h3 >Draft Participant Panel</h3> <h3 >Draft Participant Panel</h3>
<WebSocketStatus wsUrl={wsUrl} /> <WebSocketStatus socket={socketRef.current} />
<label>Latest Message</label> <label>Latest Message</label>
<input <input
type="text" type="text"

View File

@@ -1,10 +1,8 @@
@use '../../node_modules/bootstrap/scss/bootstrap.scss'; @use "../../node_modules/bootstrap/scss/bootstrap.scss";
@use './fonts/graphique.css'; @use "./fonts/graphique.css";
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Oswald:wght@200..700&display=swap'); @import url("https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Oswald:wght@200..700&display=swap");
.navbar {
.navbar{
// background-color: #582f0e; // background-color: #582f0e;
@extend .border-bottom; @extend .border-bottom;
// font-family: "Bebas Neue"; // font-family: "Bebas Neue";
@@ -18,9 +16,9 @@
} }
.draft-panel { .draft-panel {
@extend .mt-4 ; @extend .mt-4;
@extend .border ; @extend .border;
@extend .rounded-2 ; @extend .rounded-2;
@extend .p-2; @extend .p-2;
@extend .pt-1; @extend .pt-1;
label { label {
@@ -29,4 +27,13 @@
input { input {
@extend .form-control; @extend .form-control;
} }
} }
.message-log {
max-height: 300px;
overflow-y: scroll;
font-family: monospace;
background: #f8f9fa;
padding: 1em;
border: 1px solid #ccc;
}