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>
</head>
<body>
<nav class="navbar justify-content-start">
<nav class="navbar justify-content-ends pe-2">
<div>
<a class="navbar-brand" href="/">
<img src="{% static 'boxofficefantasy/logo.svg' %}" width="30" height="30">
@@ -29,6 +29,15 @@
{%block navbar%}
{%endblock%}
</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>

View File

@@ -1,66 +1,67 @@
{% extends "base.dj.html" %}{% load humanize %} {% block content%}
<h3>{{season.league.name}}</h3>
<div>
<ul class="nav nav-underline">
<li class="nav-item">
<button
class="nav-link active"
data-bs-toggle="tab"
data-bs-target="#seasons-tab-pane"
type="button"
>
Seasons
</button>
</li>
<li class="nav-item">
<button
class="nav-link"
data-bs-toggle="tab"
data-bs-target="#winners-tab-pane"
type="button"
>
Winners
</button>
</li>
<li class="nav-item">
<button
class="nav-link"
data-bs-toggle="tab"
data-bs-target="#players-tab-pane"
type="button"
>
Players
</button>
</li>
<li class="nav-item">
<button
class="nav-link"
data-bs-toggle="tab"
data-bs-target="#movies-tab-pane"
type="button"
>
Movies
</button>
</li>
</ul>
{% extends "base.dj.html" %}
{% load humanize %}
{% block content %}
<h3>{{ season.league.name }}</h3>
<div>
<ul class="nav nav-underline">
<li class="nav-item">
<button class="nav-link active"
data-bs-toggle="tab"
data-bs-target="#seasons-tab-pane"
type="button">Seasons</button>
</li>
<li class="nav-item">
<button class="nav-link"
data-bs-toggle="tab"
data-bs-target="#winners-tab-pane"
type="button">Winners</button>
</li>
<li class="nav-item">
<button class="nav-link"
data-bs-toggle="tab"
data-bs-target="#players-tab-pane"
type="button">Players</button>
</li>
<li class="nav-item">
<button class="nav-link"
data-bs-toggle="tab"
data-bs-target="#movies-tab-pane"
type="button">Movies</button>
</li>
<li class="nav-item">
<button class="nav-link"
data-bs-toggle="tab"
data-bs-target="#draft-session-tab-pane"
type="button">Draft Sessions</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="seasons-tab-pane">
<ol>
{% for season in seasons %}
<li>
<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 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">
<div class="tab-pane fade show active" id="seasons-tab-pane">
<ol>
{% for season in seasons %}
<li>
<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>
{% for draft in draft_sessions %}
<li><a href="{% url "draft:session" draft.hashed_id %}">{{ draft.hashed_id }}</a></li>
{% endfor %}
</ul>
</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>
{% endblock%}
{% endblock %}

View File

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

View File

@@ -4,10 +4,12 @@ from django.http.response import Http404, HttpResponse
from django.urls import reverse
from django.db.models import OuterRef, Subquery, Sum, Q
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
User = get_user_model()
def parse_season_slug(season_slug: str) -> tuple[str, str]:
try:
label, year = season_slug.rsplit("-", 1)
@@ -16,6 +18,7 @@ def parse_season_slug(season_slug: str) -> tuple[str, str]:
except ValueError:
raise Http404("Invalid season format.")
def get_scoreboard(user_season_entries=list[UserSeasonEntry]):
scoreboard = []
@@ -50,6 +53,7 @@ def get_scoreboard(user_season_entries=list[UserSeasonEntry]):
scoreboard.sort(key=lambda e: e["total"], reverse=True)
return scoreboard
# Create your views here.
def scoreboard_view(request, league_slug, season_slug):
# season_slug is something like "summer-2025"
@@ -70,66 +74,96 @@ def scoreboard_view(request, league_slug, season_slug):
"league": league,
"season": season,
"scoreboard": scoreboard,
"breadcrumbs":[
{"label":league.name, "url":"#"},
{"label":"seasons", "url":reverse('league:seasons', args=[league.slug])},
{"label":f"{season.label} {season.year}", "url":"#"},
]
"breadcrumbs": [
{"label": league.name, "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):
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)
# 1⃣ League only all teams across all seasons in the league
if not season_slug:
entries = UserSeasonEntry.objects.filter(season__league=league).select_related("user", "season")
return render(request, "teams.dj.html", {
"entries": entries,
"league": league,
})
entries = UserSeasonEntry.objects.filter(season__league=league).select_related(
"user", "season"
)
return render(
request,
"teams.dj.html",
{
"entries": entries,
"league": league,
},
)
# 2⃣ League + Season all teams in that season
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:
entries = UserSeasonEntry.objects.filter(season=season).select_related("user")
return render(request, "teams.dj.html", {
"user_season_entries": [{
"name": user_season_entry.user.get_full_name(),
"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},
})
return render(
request,
"teams.dj.html",
{
"user_season_entries": [
{
"name": user_season_entry.user.get_full_name(),
"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
user = get_object_or_404(User, username=username)
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 = []
for pick in picks:
metrics = {m.key: m.value for m in pick.movie.moviemetric_set.all()}
movie_data.append({
"movie": pick.movie,
"bid": pick.bid_amount,
"score": metrics.get("domestic_gross", 0)
})
movie_data.append(
{
"movie": pick.movie,
"bid": pick.bid_amount,
"score": metrics.get("domestic_gross", 0),
}
)
return render(request, "team_detail.dj.html", {
"entry": entry,
"picks": movie_data,
"league": league,
"season": season,
"user": user,
})
return render(
request,
"team_detail.dj.html",
{
"entry": entry,
"picks": movie_data,
"league": league,
"season": season,
"user": user,
},
)
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 = [
{
"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,
"team_name": pick.season_entry.team_name,
"user": pick.season_entry.user.username,
}
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
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, 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 = [
{
"id": pick.movie.bom_legacy_id,
"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,
"team_name": pick.season_entry.team_name,
"user": pick.season_entry.user.username,
}
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
if league_slug and season_slug and imdb_id:
season_label, season_year = parse_season_slug(season_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)
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()}
tmdb_data = get_tmdb_movie_by_imdb(movie.imdb_id)
data = {
@@ -214,10 +272,14 @@ def season_view(request, league_slug, season_slug=None):
}
for season in league.season_set.all()
]
return render(request, "seasons.dj.html", {
"seasons": seasons,
"league": league,
})
return render(
request,
"seasons.dj.html",
{
"seasons": seasons,
"league": league,
},
)
# 2⃣ League + season show a basic detail page or placeholder
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")
picks = season.pick_set.select_related("season_entry", "season_entry__user").annotate(
picks = season.pick_set.select_related(
"season_entry", "season_entry__user"
).annotate(
domestic_gross=Sum(
'movie__moviemetric__value',
filter=Q(movie__moviemetric__key='domestic_gross')
)
"movie__moviemetric__value",
filter=Q(movie__moviemetric__key="domestic_gross"),
)
)
return render(request, "season.dj.html", {
"season": season,
"league": league,
"scoreboard": get_scoreboard(entries),
"picks":picks
})
return render(
request,
"season.dj.html",
{
"season": season,
"league": league,
"scoreboard": get_scoreboard(entries),
"picks": picks,
},
)
def league_view(request, league_slug=None):
# 1⃣ League only list all seasons in the league
if not league_slug:
return render(
request,
{
"leagues": League.objects.all()
}
)
return render(request, "leagues.dj.html", {"leagues": League.objects.all()})
league = get_object_or_404(League, slug=league_slug)
# Subquery: top entry per season by total score
top_entry = (
UserSeasonEntry.objects
.filter(season=OuterRef('pk'))
.annotate(total_score=Sum(
'pick__movie__moviemetric__value',
filter=Q(pick__movie__moviemetric__key='domestic_gross')
))
.order_by('-total_score')
UserSeasonEntry.objects.filter(season=OuterRef("pk"))
.annotate(
total_score=Sum(
"pick__movie__moviemetric__value",
filter=Q(pick__movie__moviemetric__key="domestic_gross"),
)
)
.order_by("-total_score")
)
winner_team = top_entry.values('team_name')[:1]
winner_score = top_entry.values('total_score')[:1]
winner_team = top_entry.values("team_name")[:1]
winner_score = top_entry.values("total_score")[:1]
# Subquery: pick with top-grossing movie for the season
top_pick = (
Pick.objects
.filter(season=OuterRef('pk'))
.annotate(gross=Sum(
'movie__moviemetric__value',
filter=Q(movie__moviemetric__key='domestic_gross')
))
.order_by('-gross')
Pick.objects.filter(season=OuterRef("pk"))
.annotate(
gross=Sum(
"movie__moviemetric__value",
filter=Q(movie__moviemetric__key="domestic_gross"),
)
)
.order_by("-gross")
)
top_movie = top_pick.values('movie__title')[:1]
top_gross = top_pick.values('gross')[:1]
top_movie = top_pick.values("movie__title")[:1]
top_gross = top_pick.values("gross")[:1]
seasons = (
Season.objects
.annotate(
seasons = Season.objects.annotate(
winner_team=Subquery(winner_team),
winner_score=Subquery(winner_score),
top_movie=Subquery(top_movie),
top_movie_score=Subquery(top_gross),
)
.order_by('-year')
)
).order_by("-year")
return render(request, "league.dj.html", {
"league": league,
"seasons": seasons,
})
draft_sessions = DraftSession.objects.filter(season__league=league)
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 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
model = DraftSessionSettings
can_delete = False
show_change_link = True
class DraftParticipantInline(admin.TabularInline):
class DrafteSessionUserInline(admin.TabularInline):
extra = 0
model = DraftParticipant
model = DraftSessionParticipant
class DraftSessionAdmin(admin.ModelAdmin):
inlines = [DraftSessionSettingsInline, DraftParticipantInline]
readonly_fields = ('hashed_id',)
inlines = [DraftSessionSettingsInline, DrafteSessionUserInline]
readonly_fields = ('hashid',)
# Register your models here.
admin.site.register(DraftSession, DraftSessionAdmin)
admin.site.register(DraftSessionSettings)
admin.site.register(DraftParticipant)
admin.site.register(DraftMoviePool)
admin.site.register(DraftPick)

View File

@@ -3,11 +3,11 @@ from enum import IntEnum
class DraftMessage:
# Server
INFORM_PHASE_CHANGE = "inform.phase.change"
CONFIRM_PHASE_ADVANCE = "confirm.phase.advance"
INFORM_STATUS = "inform.status"
CONFIRM_PHASE_CHANGE = "confirm.phase.change"
INFORM_PHASE = "inform.phase"
# Client
REQUEST_PHASE_ADVANCE = "request.phase.advance"
REQUEST_PHASE_CHANGE = "request.phase.change"
REQUEST_INFORM_STATUS = "request.inform.status"
# Waiting Phase
@@ -15,10 +15,12 @@ class DraftMessage:
INFORM_JOIN_USER = "inform.join.user"
REQUEST_JOIN_PARTICIPANT = "request.join.participant"
REQUEST_JOIN_ADMIN = "request.join.admin"
INFORM_LEAVE_PARTICIPANT = "inform.leave.participant"
## Client
NOTIFY_JOIN_USER = "notify.join.user"
CONFIRM_JOIN_PARTICIPANT = "confirm.join.participant"
REJECT_JOIN_PARTICIPANT = "reject.join.participant"
CONFIRM_JOIN_ADMIN = "confirm.join.admin"
# Determine Order
@@ -55,41 +57,3 @@ class DraftGroupChannelNames:
def participant(self):
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 boxofficefantasy.models import League, Season
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 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
class DraftConsumerBase(AsyncJsonWebsocketConsumer):
group_names: DraftGroupChannelNames
cache_keys: DraftCacheKeys
draft_state: DraftStateManager
user: User
async def connect(self):
draft_session_id_hashed = self.scope["url_route"]["kwargs"].get(
"draft_session_id_hashed"
)
league_slug = self.scope["url_route"]["kwargs"].get("league_slug")
season_slug = self.scope["url_route"]["kwargs"].get("season_slug")
draft_hashid = self.scope["url_route"]["kwargs"].get("draft_session_id_hashed")
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.draft_participant_group_channels = DraftGroupChannelNames(draft_session_id_hashed)
self.group_names = DraftGroupChannelNames(draft_hashid)
self.cache_keys = DraftCacheKeys(draft_hashid)
self.draft_state = DraftStateManager(draft_hashid)
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.channel_layer.group_send(
self.group_names.session,
{
"type": DraftMessage.REJECT_JOIN_PARTICIPANT,
"user": self.user.username
},
)
return
else:
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):
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(
{
"type": "user.joined",
"user": event["user"].username,
"user_type": event["user_type"],
"users": event["users"],
"type": event["type"],
"user": event["user"],
"participants": [user.username for user in self.draft_participants],
"connected_participants": self.draft_state.connected_users
}
)
async def send_draft_summary(self):
state = cache.get(self.draft_status_cache_key, {})
async def inform_join_user(self, event):
await self.send_json(
{
"type": "draft_summary",
"phase": state.get("phase", "not started"),
"movie": state.get("movie"),
"current_bid": state.get("current_bid"),
"time_remaining": state.get("time_remaining"),
"you_are_next": state.get("you_are_next", False),
"type": event["type"],
"user": event["user"],
"participants": [user.username for user in self.draft_participants],
"connected_participants": self.draft_state.connected_users
}
)
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 ===
async def draft_status(self, event):
await self.send_json(
@@ -69,36 +133,20 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
# === DB Access ===
@database_sync_to_async
def get_draft_session(
self, draft_session_id_hashed, league_slug, season_slug
) -> DraftSession:
def get_draft_session(self, draft_session_id_hashed) -> DraftSession:
draft_session_id = DraftSession.decode_id(draft_session_id_hashed)
if draft_session_id:
draft_session = DraftSession.objects.select_related(
"season", "season__league"
).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:
raise Exception()
return draft_session
@database_sync_to_async
def get_draft_participants(self) -> list[DraftParticipant]:
# Replace this with real queryset to fetch users in draft
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
def get_draft_participants(self, session) -> list[DraftSessionParticipant]:
participants = session.participants.all()
return list(participants.all())
@@ -109,192 +157,118 @@ class DraftAdminConsumer(DraftConsumerBase):
await self.close()
return
await self.channel_layer.group_add(
self.draft_admin_group_name, self.channel_name
await self.channel_layer.group_add(self.group_names.admin, 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,
# {"type": "user.joined", "user": self.user, "user_type": "admin"},
# )
async def set_draft_phase(self, destination: DraftPhase):
self.draft_state.phase = destination
await self.channel_layer.group_send(
self.draft_admin_group_name,
{"type": "user.joined", "user": self.user, "user_type": "admin"},
self.group_names.session,
{
"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):
await super().receive_json(content)
event_type = content.get("type")
user = self.scope["user"]
if event_type == "start.draft":
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":
if event_type == DraftMessage.REQUEST_JOIN_PARTICIPANT:
await self.channel_layer.group_send(
self.draft_participant_group_name,
{
"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"),
},
self.group_names.admin,
{"type": DraftMessage.REQUEST_JOIN_PARTICIPANT, "user": user},
)
# === Broadcast handlers ===
async def chat_message(self, event):
async def request_join_participant(self, event):
await self.send_json(
{
"type": "chat.message",
"type": event["type"],
"user": event["user"],
}
)
async def draft_update(self, event):
await self.send_json(
{
"type": "draft.update",
"state": event["state"],
}
)
# === Draft ===
# === Draft logic (stubbed for now) ===
async def nominate(self, movie_title): ...
async def nominate(self, movie_title):
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}},
},
)
async def place_bid(self, amount, user): ...
# === Example DB Access ===
@database_sync_to_async
def add_draft_participant(self):
self.participant, _ = DraftParticipant.objects.get_or_create(
self.participant, _ = DraftSessionParticipant.objects.get_or_create(
user=self.user,
draft=self.draft_session,
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_project.utils import encode_id, decode_id
# Create your models here.
class DraftSession(Model):
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
def hashed_id(self):
if not self.pk: return ""
def hashid(self):
if not self.pk:
return ""
return f"{encode_id(self.pk)}"
@classmethod
def decode_id(cls, hashed_id:str) -> id:
def decode_id(cls, hashed_id: str) -> id:
return decode_id(hashed_id)
def save(self, *args, **kwargs):
is_new = self.pk is None
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)
class DraftParticipant(Model):
draft = ForeignKey(DraftSession, on_delete=CASCADE)
class DraftSessionParticipant(Model):
draft_session = ForeignKey(DraftSession, on_delete=CASCADE, blank=True)
user = ForeignKey(User, on_delete=CASCADE)
budget = IntegerField()
class DraftMoviePool(Model):
draft = ForeignKey(DraftSession, on_delete=CASCADE)
movie = ForeignKey(Movie, on_delete=CASCADE)
nominated = BooleanField()
class Meta:
unique_together = [("draft_session", "user")]
def __str__(self):
return f"{self.user} in {self.draft_session}"
class DraftPick(Model):
draft = ForeignKey(DraftSession, on_delete=CASCADE)
@@ -41,12 +56,11 @@ class DraftPick(Model):
bid_amount = IntegerField()
nomination_order = IntegerField()
class DraftSessionSettings(Model):
starting_budget = IntegerField(default=100)
draft_session = OneToOneField(
DraftSession,
on_delete=CASCADE,
related_name="settings"
DraftSession, on_delete=CASCADE, related_name="settings"
)
def __str__(self):

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>
{% load static %}
<div id="draft-app" data-draft-id="{{draft_id_hashed}}"></div>
@@ -6,3 +8,4 @@
{% else %}
<script src="{% static 'bundle.js' %}"></script>
{% endif %}
{% endblock %}

View File

@@ -1,3 +1,5 @@
{% extends "base.dj.html" %}
{% block content %}
<h1>Draft Room: {{ league.name }} {{ season.label }} {{ season.year }}</h1>
{% load static %}
<div id="draft-admin-app" data-draft-id="{{ draft_id_hashed }}"></div>
@@ -6,3 +8,4 @@
{% else %}
<script src="{% static 'bundle.js' %}"></script>
{% 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)
context = {
"draft_id_hashed": draft_session.hashed_id,
"draft_id_hashed": draft_session.hashid,
"league": league,
"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 { DraftMessage, DraftPhase } from './constants.js';
export const WebSocketStatus = ({ socket }) => {
export const useWebSocketStatus = (wsUrl) => {
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const socket = new WebSocket(wsUrl);
socket.onopen = () => setIsConnected(true);
socket.onclose = () => setIsConnected(false);
socket.onerror = () => setIsConnected(false);
if (!socket) return;
return () => socket.close();
}, [wsUrl]);
return isConnected;
};
export const WebSocketStatus = ({ wsUrl }) => {
const isConnected = useWebSocketStatus(wsUrl);
if (socket.readyState === WebSocket.OPEN) {
setIsConnected(true);
}
socket.addEventListener("open", () => setIsConnected(true));
socket.addEventListener("close", () => setIsConnected(false));
socket.addEventListener("error", () => setIsConnected(false));
}, [socket])
return (
<div className="d-flex align-items-center gap-2">
<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 }) => {
const [latestMessage, setLatestMessage] = useState(null);
const [connectedParticipants, setConnectedParticipants] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const [draftPhase, setDraftPhase] = useState();
const socketRef = useRef(null);
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.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log(event)
setLatestMessage(data);
if (data.type == "user.joined") {
// setConnectedParticipants =
const message = JSON.parse(event.data)
const { type, payload } = message;
console.log(type, event)
setLatestMessage(message);
if (type == DraftMessage.REQUEST.JOIN_PARTICIPANT) {
console.log('join request', data)
}
else if (data.type == "draft_summary"){
console.log(data)
else if (type == DraftMessage.CONFIRM.JOIN_PARTICIPANT) {
setConnectedParticipants(data.connected_participants)
}
else if (type == DraftMessage.CONFIRM.PHASE_CHANGE) {
console.log('phase_change')
setDraftPhase(payload.phase)
}
};
socketRef.current.onclose = () => {
console.warn("WebSocket connection closed.");
};
socketRef.current.onclose = (event) => {
console.log('Websocket Closed')
socketRef.current = null;
}
return () => {
socketRef.current.close();
};
}, [wsUrl]);
const handleStartDraft = () => {
socketRef.current.send(JSON.stringify({ type: "start.draft" }));
const handlePhaseChange = (destinationPhase) => {
socketRef.current.send(JSON.stringify({ type: DraftMessage.REQUEST.PHASE_CHANGE, "destination": destinationPhase }));
}
@@ -80,21 +126,22 @@ export const DraftAdmin = ({ draftSessionId }) => {
return (
<div className="container draft-panel">
<h3>Draft Admin Panel</h3>
<WebSocketStatus wsUrl={wsUrl} />
<label>Latest Message</label>
<input
type="text"
readOnly disabled
value={latestMessage ? JSON.stringify(latestMessage) : ""}
/>
<WebSocketStatus socket={socketRef.current} />
<MessageLogger socket={socketRef.current}></MessageLogger>
<label>Connected Particpants</label>
<input
type="text"
readOnly disabled
value={connectedParticipants ? JSON.stringify(connectedParticipants) : ""}
/>
<button onClick={handleStartDraft} className="btn btn-primary mt-2">
Start Draft
<label>Draft Phase</label>
<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 onClick={handleRequestDraftSummary} className="btn btn-primary mt-2">
Request status
@@ -111,16 +158,15 @@ export const DraftParticipant = ({ draftSessionId }) => {
useEffect(() => {
socketRef.current = new WebSocket(wsUrl);
socketRef.current.onmessage = (event) => {
const data = JSON.parse(event.data);
socketRef.current.onmessage = (evt) => {
const data = JSON.parse(evt.data);
console.log(data)
setLatestMessage(data);
if (data.type == "draft_summary") {
console.log('draft_summary', data)
}
};
socketRef.current.onclose = () => {
console.warn("WebSocket connection closed.");
socketRef.current = null;
};
return () => {
@@ -135,7 +181,7 @@ export const DraftParticipant = ({ draftSessionId }) => {
return (
<div className="container draft-panel">
<h3 >Draft Participant Panel</h3>
<WebSocketStatus wsUrl={wsUrl} />
<WebSocketStatus socket={socketRef.current} />
<label>Latest Message</label>
<input
type="text"

View File

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