+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+
+
Winners
+
players
+
movies
+
+
-
- Winners
- players
- movies
-
-{% endblock%}
+{% endblock %}
diff --git a/boxofficefantasy/templates/leagues.dj.html b/boxofficefantasy/templates/leagues.dj.html
index 3425488..2d57267 100644
--- a/boxofficefantasy/templates/leagues.dj.html
+++ b/boxofficefantasy/templates/leagues.dj.html
@@ -2,8 +2,8 @@
Leagues
diff --git a/boxofficefantasy/views.py b/boxofficefantasy/views.py
index 056f6ff..1606810 100644
--- a/boxofficefantasy/views.py
+++ b/boxofficefantasy/views.py
@@ -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,
- })
\ No newline at end of file
+ draft_sessions = DraftSession.objects.filter(season__league=league)
+
+ return render(
+ request,
+ "league.dj.html",
+ {
+ "league": league,
+ "seasons": seasons,
+ "draft_sessions": draft_sessions
+ },
+ )
diff --git a/draft/admin.py b/draft/admin.py
index 5d0b126..6a138cb 100644
--- a/draft/admin.py
+++ b/draft/admin.py
@@ -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)
diff --git a/draft/constants.py b/draft/constants.py
index b734089..952ae53 100644
--- a/draft/constants.py
+++ b/draft/constants.py
@@ -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
@@ -54,42 +56,4 @@ class DraftGroupChannelNames:
@property
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"
\ No newline at end of file
+
\ No newline at end of file
diff --git a/draft/consumers.py b/draft/consumers.py
index 58ab78e..532a9fc 100644
--- a/draft/consumers.py
+++ b/draft/consumers.py
@@ -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},
diff --git a/draft/migrations/0006_remove_draftparticipant_draft_and_more.py b/draft/migrations/0006_remove_draftparticipant_draft_and_more.py
new file mode 100644
index 0000000..36f8951
--- /dev/null
+++ b/draft/migrations/0006_remove_draftparticipant_draft_and_more.py
@@ -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',
+ ),
+ ]
diff --git a/draft/models.py b/draft/models.py
index 82819ae..1ecbaa6 100644
--- a/draft/models.py
+++ b/draft/models.py
@@ -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,16 +56,15 @@ 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):
return f"Settings for {self.draft_session}"
-
+
class Meta:
- verbose_name_plural = "Draft session settings"
\ No newline at end of file
+ verbose_name_plural = "Draft session settings"
diff --git a/draft/state.py b/draft/state.py
new file mode 100644
index 0000000..4f52b87
--- /dev/null
+++ b/draft/state.py
@@ -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(),
+ }
\ No newline at end of file
diff --git a/draft/templates/draft/room.dj.html b/draft/templates/draft/room.dj.html
index b436433..054f072 100644
--- a/draft/templates/draft/room.dj.html
+++ b/draft/templates/draft/room.dj.html
@@ -1,3 +1,5 @@
+{% extends "base.dj.html" %}
+{% block content %}
Draft Room: {{ league.name }} – {{ season.label }} {{ season.year }}
{% load static %}
@@ -5,4 +7,5 @@
{% else %}
-{% endif %}
\ No newline at end of file
+{% endif %}
+{% endblock %}
\ No newline at end of file
diff --git a/draft/templates/draft/room_admin.dj.html b/draft/templates/draft/room_admin.dj.html
index 80a2f1f..09d054c 100644
--- a/draft/templates/draft/room_admin.dj.html
+++ b/draft/templates/draft/room_admin.dj.html
@@ -1,3 +1,5 @@
+{% extends "base.dj.html" %}
+{% block content %}
Draft Room: {{ league.name }} – {{ season.label }} {{ season.year }}
{% load static %}
@@ -6,3 +8,4 @@
{% else %}
{% endif %}
+{% endblock %}
\ No newline at end of file
diff --git a/draft/views.py b/draft/views.py
index c91ac90..0bb8b9e 100644
--- a/draft/views.py
+++ b/draft/views.py
@@ -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,
}
diff --git a/frontend/src/apps/draft/constants.js b/frontend/src/apps/draft/constants.js
new file mode 100644
index 0000000..7382a15
--- /dev/null
+++ b/frontend/src/apps/draft/constants.js
@@ -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,
+}
\ No newline at end of file
diff --git a/frontend/src/apps/draft/index.jsx b/frontend/src/apps/draft/index.jsx
index 577b512..b75a414 100644
--- a/frontend/src/apps/draft/index.jsx
+++ b/frontend/src/apps/draft/index.jsx
@@ -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 (
{
);
};
+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 (
+
+
+
+ {messages.map((msg, i) => (
+
+
{JSON.stringify(msg, null, 2)}
+
+
+ ))}
+
+
+
+ );
+};
+
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 (