2025-08-01

This commit is contained in:
2025-08-01 13:03:58 -05:00
parent f25a69cf78
commit 1a7a6a2d50
14 changed files with 512 additions and 114 deletions

View File

@@ -1,16 +1,39 @@
from django.contrib import admin
from .models import League, Season, UserSeasonEntry, Movie, MovieMetric, Pick
from draft.models import DraftSession
# Register your models here.
admin.site.register(League)
class PickInline(admin.TabularInline): # or TabularInline
extra = 0
model = Pick
can_delete = True
show_change_link = True
class UserSeasonEntryInline(admin.TabularInline):
extra = 0
model = UserSeasonEntry
class MovieMetricInline(admin.TabularInline):
extra = 0
model = MovieMetric
class DraftSessionInline(admin.TabularInline):
extra = 0
model = DraftSession
show_change_link = True
class SeasonAdmin(admin.ModelAdmin):
inlines = [UserSeasonEntryInline, DraftSessionInline, PickInline]
readonly_fields = ('slug',)
class MovieAdmin(admin.ModelAdmin):
inlines = [MovieMetricInline]
admin.site.register(Season, SeasonAdmin)
admin.site.register(UserSeasonEntry)
admin.site.register(Movie)
admin.site.register(Movie, MovieAdmin)
admin.site.register(MovieMetric)
admin.site.register(Pick)

View File

@@ -0,0 +1 @@
import pymojo as boxofficemojo

View File

@@ -148,4 +148,4 @@ CHANNEL_LAYERS = {
},
}
HASHIDS_SALT = "your-very-secret-salt-string"
HASHIDS_SALT = os.getenv("BOF_HASHIDS_SALT", "your-very-secret-salt-string")

View File

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

95
draft/constants.py Normal file
View File

@@ -0,0 +1,95 @@
from enum import IntEnum
class DraftMessage:
# Server
INFORM_PHASE_CHANGE = "inform.phase.change"
CONFIRM_PHASE_ADVANCE = "confirm.phase.advance"
INFORM_STATUS = "inform.status"
# Client
REQUEST_PHASE_ADVANCE = "request.phase.advance"
REQUEST_INFORM_STATUS = "request.inform.status"
# Waiting Phase
## Server
INFORM_JOIN_USER = "inform.join.user"
REQUEST_JOIN_PARTICIPANT = "request.join.participant"
REQUEST_JOIN_ADMIN = "request.join.admin"
## Client
NOTIFY_JOIN_USER = "notify.join.user"
CONFIRM_JOIN_PARTICIPANT = "confirm.join.participant"
CONFIRM_JOIN_ADMIN = "confirm.join.admin"
# Determine Order
## Server
CONFIRM_DETERMINE_DRAFT_ORDER = "confirm.determine.draft_order"
## Client
REQUEST_DETERMINE_DRAFT_ORDER = "request.determine.draft_order"
class DraftPhase(IntEnum):
WAITING = 0
DETERMINE_ORDER = 10
NOMINATION = 20
BIDDING = 30
AWARD = 40
FINALIZE = 50
def __str__(self):
return self.name.lower()
class DraftGroupChannelNames:
def __init__(self, id):
self.prefix = f"draft.{id}"
@property
def session(self):
return f"{self.prefix}.session"
@property
def admin(self):
return f"{self.prefix}.admin"
@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"

View File

@@ -4,24 +4,197 @@ 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 django.core.cache import cache
from draft.constants import DraftMessage, DraftPhase, DraftGroupChannelNames
import random
class DraftConsumer(AsyncJsonWebsocketConsumer):
class DraftConsumerBase(AsyncJsonWebsocketConsumer):
async def connect(self):
draft_session_id_hashed = self.scope["url_route"]["kwargs"].get("draft_session_id_hashed")
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")
self.draft_session = await self.get_draft_session(draft_session_id_hashed=draft_session_id_hashed, league_slug=league_slug, season_slug=season_slug)
self.draft_session = await self.get_draft_session(
draft_session_id_hashed=draft_session_id_hashed,
)
self.room_group_name = f"draft_{self.draft_session.season.league.slug}_{self.draft_session.season.slug}"
self.draft_group_names = f"draft_admin_{self.draft_session.hashed_id}"
self.draft_participant_group_channels = DraftGroupChannelNames(draft_session_id_hashed)
# Auth check (optional)
self.user = self.scope["user"]
if not self.user.is_authenticated:
await self.close()
return
else:
await self.accept()
async def receive_json(self, content):
event_type = content.get("type")
user = self.scope["user"]
async def user_joined(self, event):
await self.send_json(
{
"type": "user.joined",
"user": event["user"].username,
"user_type": event["user_type"],
"users": event["users"],
}
)
async def send_draft_summary(self):
state = cache.get(self.draft_status_cache_key, {})
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),
}
)
# === Broadcast handlers ===
async def draft_status(self, event):
await self.send_json(
{
"type": "draft.status",
"status": event["status"],
}
)
# === DB Access ===
@database_sync_to_async
def get_draft_session(
self, draft_session_id_hashed, league_slug, season_slug
) -> 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
return list(participants.all())
class DraftAdminConsumer(DraftConsumerBase):
async def connect(self):
await super().connect()
if not self.user.is_staff:
await self.close()
return
await self.channel_layer.group_add(
self.draft_admin_group_name, self.channel_name
)
# await self.channel_layer.group_send(
# self.draft_participant_group_name,
# {"type": "user.joined", "user": self.user, "user_type": "admin"},
# )
await self.channel_layer.group_send(
self.draft_admin_group_name,
{"type": "user.joined", "user": self.user, "user_type": "admin"},
)
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":
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()
@@ -29,42 +202,46 @@ class DraftConsumer(AsyncJsonWebsocketConsumer):
await self.close()
return
await self.channel_layer.group_add(self.room_group_name, self.channel_name)
await self.accept()
await self.send_json({"type": "connection.accepted", "user": self.user.username, "budget": self.participant.budget})
# Notify others (optional)
await self.channel_layer.group_send(
self.room_group_name,
await self.send_json(
{
"type": "user.joined",
"type": "connection.accepted",
"user": self.user.username,
"budget": self.participant.budget
"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.room_group_name, self.channel_name)
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 == "start_draft":
if user.is_staff:
await self.start_draft()
else:
await self.send_json({
"type": "error",
"message": "insufficient privelleges"
})
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.room_group_name,
self.draft_participant_group_name,
{
"type": "chat.message",
"user": self.scope["user"].username,
@@ -74,65 +251,43 @@ class DraftConsumer(AsyncJsonWebsocketConsumer):
# === Broadcast handlers ===
async def user_joined(self, event):
await self.send_json({
"type": "user.joined",
"user": event["user"]
})
async def chat_message(self, event):
await self.send_json({
await self.send_json(
{
"type": "chat.message",
"user": event["user"],
"message": event["message"],
})
async def draft_update(self, event):
await self.send_json({
"type": "draft.update",
"state": event["state"],
})
# === Draft logic (stubbed for now) ===
async def start_draft(self):
# Example: shuffle draft order
players = await self.get_draft_participants()
draft_order = random.sample(players, len(players))
await self.channel_layer.group_send(
self.room_group_name,
{
"type": "draft.update",
"state": {
"status": "started",
"order": [p.user.username for p in draft_order],
}
}
)
async def draft_update(self, event):
await self.send_json(
{
"type": "draft.update",
"state": event["state"],
}
)
# === Draft logic (stubbed for now) ===
async def nominate(self, movie_title):
await self.channel_layer.group_send(
self.room_group_name,
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.room_group_name,
self.draft_participant_group_name,
{
"type": "draft.update",
"state": {
"status": "bidding",
"bid": {"amount": amount, "user": user}
}
}
"state": {"status": "bidding", "bid": {"amount": amount, "user": user}},
},
)
# === Example DB Access ===
@@ -142,26 +297,5 @@ class DraftConsumer(AsyncJsonWebsocketConsumer):
self.participant, _ = DraftParticipant.objects.get_or_create(
user=self.user,
draft=self.draft_session,
defaults={
"budget":self.draft_session.settings.starting_budget
}
defaults={"budget": self.draft_session.settings.starting_budget},
)
@database_sync_to_async
def get_draft_participants(self):
# Replace this with real queryset to fetch users in draft
return list(DraftParticipant.objects.select_related('user').filter(draft=self.draft_session).all())
@database_sync_to_async
def get_draft_session(self, draft_session_id_hashed, league_slug, season_slug):
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

View File

@@ -1,7 +1,7 @@
from django.urls import path
from . import consumers
from draft.consumers import DraftParticipantConsumer, DraftAdminConsumer
websocket_urlpatterns = [
path(r"ws/draft/session/<str:draft_session_id_hashed>/", consumers.DraftConsumer.as_asgi()),
# path(r"ws/draft/<slug:league_slug>/<slug:season_slug>/", consumers.DraftConsumer.as_asgi()),
path(r"ws/draft/session/<str:draft_session_id_hashed>/participant", DraftParticipantConsumer.as_asgi()),
path(r"ws/draft/session/<str:draft_session_id_hashed>/admin", DraftAdminConsumer.as_asgi()),
]

View File

@@ -1,6 +1,6 @@
<h1>Draft Room: {{ league.name }} {{ season.label }} {{ season.year }}</h1>
{% load static %}
<div id="draft-app" data-draft-id="{{draft_id_hashed}}" data-room-name="{{ room_name }}"></div>
<div id="draft-app" data-draft-id="{{draft_id_hashed}}"></div>
{% if DEBUG %}
<script src="http://localhost:3000/dist/bundle.js"></script>
{% else %}

View File

@@ -0,0 +1,8 @@
<h1>Draft Room: {{ league.name }} {{ season.label }} {{ season.year }}</h1>
{% load static %}
<div id="draft-admin-app" data-draft-id="{{ draft_id_hashed }}"></div>
{% if DEBUG %}
<script src="http://localhost:3000/dist/bundle.js"></script>
{% else %}
<script src="{% static 'bundle.js' %}"></script>
{% endif %}

View File

@@ -6,5 +6,6 @@ app_name = "draft"
urlpatterns = [
# path("", views.draft_room, name="room"),
path("session/<str:draft_session_id_hashed>/", views.draft_room, name="session"),
path("session/<str:draft_session_id_hashed>/<str:is_admin>", views.draft_room, name="admin_session"),
# path("<slug:league_slug>/<slug:season_slug>/", views.draft_room_list, name="room"),
]

View File

@@ -5,9 +5,8 @@ from boxofficefantasy.views import parse_season_slug
from django.contrib.auth.decorators import login_required
from boxofficefantasy_project.utils import decode_id
# @login_required(login_url='/login/')
def draft_room(request, league_slug=None, season_slug=None, draft_session_id_hashed=None):
@login_required(login_url='/login/')
def draft_room(request, league_slug=None, season_slug=None, draft_session_id_hashed=None, is_admin=""):
if draft_session_id_hashed:
draft_session_id = decode_id(draft_session_id_hashed)
draft_session = get_object_or_404(DraftSession, id=draft_session_id)
@@ -20,9 +19,14 @@ def draft_room(request, league_slug=None, season_slug=None, draft_session_id_has
season = get_object_or_404(Season, league=league, label__iexact=label, year=year)
draft_session = get_object_or_404(DraftSession, season=season)
return render(request, "draft/room.dj.html", {
context = {
"draft_id_hashed": draft_session.hashed_id,
"league": league,
"season": season,
"room_name": f"{league.slug}-{season.slug}"
})
}
if is_admin == "admin":
return render(request, "draft/room_admin.dj.html", context)
else:
return render(request, "draft/room.dj.html", context)

View File

@@ -1,9 +1,112 @@
import React, { useEffect, useState, useRef } from "react";
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);
return () => socket.close();
}, [wsUrl]);
return isConnected;
};
export const WebSocketStatus = ({ wsUrl }) => {
const isConnected = useWebSocketStatus(wsUrl);
return (
<div className="d-flex align-items-center gap-2">
<span
className="status-dot"
style={{
width: "10px",
height: "10px",
borderRadius: "50%",
backgroundColor: isConnected ? "green" : "red",
display: "inline-block",
}}
></span>
<span>{isConnected ? "Connected" : "Disconnected"}</span>
</div>
);
};
export const DraftAdmin = ({ draftSessionId }) => {
const [latestMessage, setLatestMessage] = useState(null);
const [connectedParticipants, setConnectedParticipants] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const socketRef = useRef(null);
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/`;
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/admin`;
useEffect(() => {
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 =
}
else if (data.type == "draft_summary"){
console.log(data)
}
};
socketRef.current.onclose = () => {
console.warn("WebSocket connection closed.");
};
return () => {
socketRef.current.close();
};
}, [wsUrl]);
const handleStartDraft = () => {
socketRef.current.send(JSON.stringify({ type: "start.draft" }));
}
const handleRequestDraftSummary = () => {
socketRef.current.send(JSON.stringify({ type: 'request_summary' }))
}
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) : ""}
/>
<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
</button>
<button onClick={handleRequestDraftSummary} className="btn btn-primary mt-2">
Request status
</button>
</div>
);
};
export const DraftParticipant = ({ draftSessionId }) => {
const [latestMessage, setLatestMessage] = useState(null);
const socketRef = useRef(null);
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/participant`;
useEffect(() => {
socketRef.current = new WebSocket(wsUrl);
@@ -11,6 +114,9 @@ export const DraftAdmin = ({ draftSessionId }) => {
socketRef.current.onmessage = (event) => {
const data = JSON.parse(event.data);
setLatestMessage(data);
if (data.type == "draft_summary") {
console.log('draft_summary', data)
}
};
socketRef.current.onclose = () => {
@@ -27,18 +133,15 @@ export const DraftAdmin = ({ draftSessionId }) => {
}
return (
<div className="container mt-4">
<h1>Draft Admin Panel</h1>
<div className="container draft-panel">
<h3 >Draft Participant Panel</h3>
<WebSocketStatus wsUrl={wsUrl} />
<label>Latest Message</label>
<input
type="text"
className="form-control"
readOnly disabled
value={latestMessage ? JSON.stringify(latestMessage) : ""}
/>
<button onClick={handleStartDraft} className="btn btn-primary">
Start Draft
</button>
</div>
);
};

View File

@@ -3,13 +3,20 @@ console.log("Webpack HMR loaded!");
import React from "react";
import { createRoot } from "react-dom/client";
import {DraftAdmin} from './apps/draft/index.jsx'
import {DraftAdmin, DraftParticipant} from './apps/draft/index.jsx'
document.addEventListener("DOMContentLoaded", () => {
const draftApp = document.getElementById("draft-app");
const draftAdminApp = document.getElementById("draft-admin-app");
const draftApp = document.getElementById("draft-app")
if (draftApp) {
const root = createRoot(draftApp);
const draftId = draftApp.dataset.draftId
root.render(<DraftParticipant draftSessionId={draftId} />);
}
if (draftAdminApp) {
const root = createRoot(draftAdminApp);
const draftId = draftAdminApp.dataset.draftId
root.render(<DraftAdmin draftSessionId={draftId} />);
}
});

View File

@@ -16,3 +16,17 @@
font-size: x-large;
}
}
.draft-panel {
@extend .mt-4 ;
@extend .border ;
@extend .rounded-2 ;
@extend .p-2;
@extend .pt-1;
label {
@extend .form-label;
}
input {
@extend .form-control;
}
}