Compare commits

...

10 Commits

Author SHA1 Message Date
71f0f01abc Improve draft UI state handling, layout, and order logic
- Added current/next pick info, updated server draft logic for order/snake
- Refactored WebSocketContext, removed dead code, improved CSS/layout
- Cleaned up template blocks, admin, and participant panel structure
2025-08-12 21:34:02 -05:00
cd4d974fce Add timed bidding support with countdown displays and debug view
- Added `bidding_duration` field to `DraftSessionSettings` model and migration.
- Updated `DraftStateManager` to manage bidding start/end times using session settings.
- Extended WebSocket payloads to include bidding timer data.
- Added `DraftCountdownClock` React component and integrated into admin and participant UIs.
- Created new `DraftDebug` view, template, and front-end component for real-time state debugging.
- Updated utility functions to handle new timer fields in draft state.
- Changed script tags in templates to load with `defer` for non-blocking execution.
2025-08-10 18:19:54 -05:00
b08a345563 Add nomination submission and bidding start workflow
- Added `BID_START_REQUEST` and `NOMINATION_SUBMIT_REQUEST` handling in backend consumers.
- Extended draft state to include `current_movie` and `bids` cache keys.
- Updated frontend to:
  - Allow participants to nominate movies when it's their turn.
  - Enable admins to start bidding for the nominated movie.
  - Highlight the current nominated movie and the current user.
- Synced state updates across clients via WebSocket events.
2025-08-10 16:30:27 -05:00
28c98afc32 Refactor draft messaging to unified enum-based protocol
- Replaced scattered message strings with `DraftMessage` `StrEnum` and
  numeric `DraftPhase` `IntEnum` for clear, centralized definitions.
- Added Python→JS constants sync via `scripts/generate_js_constants.py`
  to ensure backend/frontend parity.
- Refactored WebSocket consumers to use `broadcast.*` and
  `direct.message` handlers with `_dispatch_broadcast` for consistent
  event delivery.
- Enhanced `DraftStateManager` to store `draft_index` and explicitly
  manage `connected_participants`.
- Added colored logging config in settings for improved debugging.
- Frontend: split UI into `ParticipantList` and `DraftMoviePool`,
  extracted message handlers (`handleDraftStatusMessages`,
  `handleUserIdentifyMessages`), and updated components to use new
  message/phase enums.
2025-08-10 13:16:07 -05:00
24700071ed Add movie detail API and enhance draft admin/participant UI
- Introduced `/api/movie/<id>/detail` endpoint returning TMDB data for a movie.
- Moved draft detail fetching logic into `common/utils.js` for reuse.
- Updated Draft Admin panel:
  - Added phase navigation buttons with bootstrap icons.
  - Improved layout with refresh and status controls.
- Updated Draft Participant panel:
  - Added movie pool display with links to movie details.
- Added bootstrap-icons stylesheet and corresponding SCSS styles for new UI.
2025-08-08 15:12:40 -05:00
9b6b3391e6 Add DRF API app and real-time draft management UI
- Created new `api` Django app with serializers, viewsets, and routers
  to expose draft sessions, participants, and movie data.
- Registered `api` app in settings and updated root URL configuration.
- Extended WebSocket consumers with `inform.draft_status` /
  `request.draft_status` to allow fetching current draft state.
- Updated `DraftSession` and related models to support reverse lookups
  for draft picks.
- Enhanced draft state manager to include `draft_order` in summaries.
- Added React WebSocket context provider, connection status component,
  and new admin/participant panels with phase and participant tracking.
- Updated SCSS for participant lists, phase indicators, and status badges.
- Modified Django templates to mount new React roots for admin and
  participant views.
- Updated Webpack dev server config to proxy WebSocket connections.
2025-08-08 12:50:33 -05:00
c9ce7a36d0 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.
2025-08-02 08:56:41 -05:00
1a7a6a2d50 2025-08-01 2025-08-01 13:03:58 -05:00
f25a69cf78 2025-07-26 2025-07-26 15:35:53 -05:00
c543c98bf3 2025-07-26 2025-07-26 14:52:54 -05:00
63 changed files with 4976 additions and 393 deletions

0
api/__init__.py Normal file
View File

3
api/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
api/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'api'

View File

3
api/models.py Normal file
View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

59
api/serializers.py Normal file
View File

@@ -0,0 +1,59 @@
from rest_framework import serializers
from django.contrib.auth import get_user_model
from boxofficefantasy.models import Movie, Season
from draft.models import DraftSession, DraftSessionSettings, DraftPick
User = get_user_model()
class UserSerializer(serializers.ModelSerializer):
full_name = serializers.SerializerMethodField()
class Meta:
model = User
fields = ("username", "first_name", "last_name", "email", "full_name")
def get_full_name(self, obj):
return f"{obj.first_name} {obj.last_name}".strip()
class MovieSerializer(serializers.ModelSerializer):
class Meta:
model = Movie
# fields = ("id", "imdb_id", "title", "year", "poster_url")
fields = ("id", "title")
class DraftSessionSettingsSerializer(serializers.ModelSerializer):
class Meta:
model = DraftSessionSettings
fields = ("starting_budget",) # add any others you have
class DraftPickSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
movie = MovieSerializer(read_only=True)
class Meta:
model = DraftPick
fields = ("id", "movie", "winner", "bid_amount")
class DraftSessionSerializer(serializers.ModelSerializer):
participants = UserSerializer(many=True, read_only=True)
movies = MovieSerializer(many=True, read_only=True)
settings = DraftSessionSettingsSerializer(read_only=True)
draft_picks = DraftPickSerializer(many=True, read_only=True)
def hashid(self, obj):
return f"{obj.hashid}".strip()
class Meta:
model = DraftSession
# include whatever else you want (phase, season info, hashed_id, etc.)
fields = (
"id",
"hashid",
"season", # will use __str__ unless you customize
"participants",
"movies",
"settings",
"draft_picks",
# optionally include server time for client clock sync
)

3
api/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

18
api/urls.py Normal file
View File

@@ -0,0 +1,18 @@
from rest_framework.routers import DefaultRouter
from .views import UserViewSet, MovieViewSet, DraftSessionViewSet, movie_detail
from django.urls import path
router = DefaultRouter()
router.register(r'users', UserViewSet, basename='user')
router.register(r'movies', MovieViewSet, basename='movie')
router.register(r'draft', DraftSessionViewSet, basename='draft')
urlpatterns = [
*router.urls,
path(
"movie/<int:movie_id>/detail",
movie_detail,
name="movie-detail"
),
]

93
api/views.py Normal file
View File

@@ -0,0 +1,93 @@
from rest_framework import viewsets, permissions
from rest_framework.exceptions import NotFound
from django.contrib.auth import get_user_model
from boxofficefantasy.models import Movie
from draft.models import DraftSession, DraftPick
from django.shortcuts import get_object_or_404
from rest_framework.response import Response
from boxofficefantasy.integrations.tmdb import get_tmdb_movie_by_imdb
from rest_framework.decorators import api_view
from django.db.models import Prefetch
from .serializers import (
UserSerializer, MovieSerializer, DraftSessionSerializer
)
User = get_user_model()
class UserViewSet(viewsets.ReadOnlyModelViewSet):
queryset = User.objects.all()
serializer_class = UserSerializer
permission_classes = [permissions.IsAuthenticated]
lookup_field = "username"
class MovieViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Movie.objects.all().order_by('id')
serializer_class = MovieSerializer
permission_classes = [permissions.IsAuthenticated]
class DraftSessionViewSet(viewsets.ReadOnlyModelViewSet):
"""
GET /api/drafts/<hashed_id>/
Returns participants, movies, settings, and picks for a draft session.
Access limited to participants or staff.
"""
serializer_class = DraftSessionSerializer
# permission_classes = [permissions.IsAuthenticated, IsParticipantOfDraft]
lookup_field = "hashid" # use hashed id instead of pk
lookup_url_kwarg = "hid" # url kwarg name matches urls.py
def get_object(self):
hashid = self.kwargs[self.lookup_url_kwarg]
pk = DraftSession.decode_id(hashid)
if pk is None:
raise NotFound("Invalid draft id.")
obj = get_object_or_404(self.get_queryset(), pk=pk)
# Trigger object-level permissions (participant check happens here)
self.check_object_permissions(self.request, obj)
return obj
def get_queryset(self):
# Optimize queries
return (
DraftSession.objects
.select_related("season", "settings")
.prefetch_related(
"participants",
"movies",
Prefetch("draft_picks", queryset=DraftPick.objects.select_related("winner", "movie")),
)
)
@api_view(["GET"])
def movie_detail(request, movie_id):
"""
GET /api/movie/{movie_id}/detail
Returns TMDB movie details
and the movie is in that session.
"""
# Lookup DraftSession by hashid or pk
# draft_session = get_object_or_404(DraftSession, hashid=draft_session_id)
# # Ensure requesting user is a participant
# if request.user not in draft_session.participants.all():
# return Response({"detail": "Not authorized for this draft session."},
# status=status.HTTP_403_FORBIDDEN)
# # Get movie in this session
movie = get_object_or_404(Movie, pk=movie_id)
# Call TMDB integration
tmdb_data = get_tmdb_movie_by_imdb(movie.imdb_id)
if not tmdb_data:
return Response({"detail": "Movie details not found."},
status=404)
return Response({
"id": movie.id,
"title": movie.title,
"tmdb": tmdb_data
})

View File

@@ -17,12 +17,25 @@
"console": "integratedTerminal",
"envFile": "${workspaceFolder}/.env"
},
{
"name": "Run Uvicorn Django Server",
"type": "debugpy",
"request": "launch",
"module": "uvicorn",
"args": ["boxofficefantasy_project.asgi:application", "--reload",],
"django": true,
"console": "integratedTerminal",
"envFile": "${workspaceFolder}/.env"
},
{
"name": "Start Webpack Dev Server",
"type": "node",
"request": "launch",
"program": "npm",
"args": ["run", "dev", "--config", "${workspaceFolder}/frontend/webpack.config.js"],
"runtimeExecutable": "npm",
"args": [
"run",
"dev"
],
"cwd": "${workspaceFolder}/frontend",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
@@ -31,7 +44,7 @@
"name": "Launch Chrome",
"type": "chrome",
"request": "launch",
"url": "http://127.0.0.1:8000", // adjust based on your local server
"url": "http://localhost:3000", // adjust based on your local server
"webRoot": "${workspaceFolder}",
"sourceMaps": true,
"trace": true
@@ -48,8 +61,8 @@
],
"compounds": [
{
"name": "Django + Chrome",
"configurations": ["Run Django Server", "Launch Chrome"],
"name": "Django + Chrome + Webpack",
"configurations": ["Run Django Server", "Launch Chrome", "Start Webpack Dev Server"],
"type": "compound"
}
]
@@ -57,6 +70,26 @@
"tasks": {
"version": "2.0.0",
"tasks": [
{
"label": "Start Redis",
"type": "process",
"command": "docker",
"args": [
"run",
"--rm",
"--name",
"redis-boxofficefantasy-dev",
"-p",
"6379:6379",
"redis"
],
"isBackground": true,
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": false
}
},
{
"label": "🗑️ Delete all Movies",
"type": "shell",
@@ -119,19 +152,24 @@
"editor.defaultFormatter": "ms-python.black-formatter"
},
"[django-html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.quickSuggestions": {
"other": true,
"comments": true,
"strings": true
},
"editor.defaultFormatter": "monosans.djlint",
},
"emmet.includeLanguages": {
"django-html": "html"
},
"files.associations": {
"*.dj.html": "django-html"
},
"html.autoClosingTags": true,
"emmet.includeLanguages": {
"django-html": "html"
}
"files.exclude": {
"**/__pycache__":true,
".venv":false
},
"auto-close-tag.activationOnLanguage": [
"django-html"
],
"terminal.integrated.env.osx": {
"VSCODE_HISTFILE":"${workspaceFolder}/.venv/.term_history"
},
// "html.autoClosingTags": true,
}
}

View File

@@ -1,11 +1,39 @@
from django.contrib import admin
from .models import League, Season, UserSeasonEntry, Movie, MovieMetric, ScoringRule, Pick
from .models import League, Season, UserSeasonEntry, Movie, MovieMetric, Pick
from draft.models import DraftSession
# Register your models here.
admin.site.register(League)
admin.site.register(Season)
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(ScoringRule)
admin.site.register(Pick)
admin.site.register(Pick)

View File

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

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.2.4 on 2025-07-26 13:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boxofficefantasy', '0008_alter_movie_imdb_id'),
]
operations = [
migrations.AlterField(
model_name='moviemetric',
name='value',
field=models.IntegerField(),
),
migrations.AlterField(
model_name='pick',
name='bid_amount',
field=models.IntegerField(),
),
migrations.DeleteModel(
name='ScoringRule',
),
]

View File

@@ -1,50 +1,64 @@
from django.db import models
from django.db.models import (
ForeignKey,
CharField,
SlugField,
Model,
IntegerField,
DateField,
CASCADE,
UniqueConstraint,
DateTimeField,
)
from django.contrib.auth.models import User
from django.utils.text import slugify
from django.core.exceptions import ValidationError
from django.conf import settings
# Create your models here.
class League(models.Model):
name = models.CharField(max_length=100)
commissioner = models.ForeignKey(User, on_delete=models.CASCADE)
slug = models.SlugField(unique=True, blank=True)
class League(Model):
name = CharField(max_length=100)
commissioner = ForeignKey(User, on_delete=CASCADE)
slug = SlugField(unique=True, blank=True)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
class Season(models.Model):
league = models.ForeignKey(League, on_delete=models.CASCADE)
year = models.IntegerField()
start_date = models.DateField(null=True)
end_date = models.DateField(null=True)
label = models.CharField(max_length=50)
class Season(Model):
league = ForeignKey(League, on_delete=CASCADE)
year = IntegerField()
start_date = DateField(null=True)
end_date = DateField(null=True)
label = CharField(max_length=50)
class Meta:
unique_together = ('league', 'year')
unique_together = ("league", "year")
def __str__(self):
return f"{self.league.name} - {self.year}"
@property
def slug(self):
return f"{slugify(self.label)}-{self.year}"
class UserSeasonEntry(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
season = models.ForeignKey(Season, on_delete=models.CASCADE)
team_name = models.CharField(max_length=100)
class UserSeasonEntry(Model):
user = ForeignKey(User, on_delete=CASCADE)
season = ForeignKey(Season, on_delete=CASCADE)
team_name = CharField(max_length=100)
class Meta:
unique_together = ('user', 'season')
unique_together = ("user", "season")
constraints = [
models.UniqueConstraint(fields=['season', 'team_name'], name='unique_team_name_per_season')
UniqueConstraint(
fields=["season", "team_name"], name="unique_team_name_per_season"
)
]
verbose_name = "User Season Entry"
verbose_name_plural = "User Season Entries"
@@ -53,54 +67,47 @@ class UserSeasonEntry(models.Model):
return f"{self.team_name} ({self.user.username})"
class Movie(models.Model):
title = models.CharField(max_length=255)
imdb_id = models.CharField(max_length=20, blank=True, null=True)
bom_id = models.CharField(max_length=50, blank=True, null=True)
bom_legacy_id = models.CharField(max_length=50, blank=True, null=True)
class Movie(Model):
title = CharField(max_length=255)
imdb_id = CharField(max_length=20, blank=True, null=True)
bom_id = CharField(max_length=50, blank=True, null=True)
bom_legacy_id = CharField(max_length=50, blank=True, null=True)
def __str__(self):
return self.title
def clean(self):
for field in ["imdb_id", "bom_id", "bom_legacy_id"]:
if getattr(self, field):
if Movie.objects.exclude(pk=self.pk).filter(**{field:getattr(self,field)}).exists():
raise ValidationError({field: f'{field} must be unique.'})
if (
Movie.objects.exclude(pk=self.pk)
.filter(**{field: getattr(self, field)})
.exists()
):
raise ValidationError({field: f"{field} must be unique."})
class Pick(models.Model):
season_entry = models.ForeignKey(UserSeasonEntry, on_delete=models.CASCADE)
season = models.ForeignKey(Season, on_delete=models.CASCADE)
movie = models.ForeignKey(Movie, on_delete=models.CASCADE)
bid_amount = models.DecimalField(max_digits=10, decimal_places=2)
class Pick(Model):
season_entry = ForeignKey(UserSeasonEntry, on_delete=CASCADE)
season = ForeignKey(Season, on_delete=CASCADE)
movie = ForeignKey(Movie, on_delete=CASCADE)
bid_amount = IntegerField()
class Meta:
unique_together = ('season_entry', 'movie')
unique_together = ("season_entry", "movie")
def __str__(self):
return f"{self.season_entry} - {self.movie}"
class MovieMetric(models.Model):
movie = models.ForeignKey(Movie, on_delete=models.CASCADE)
key = models.CharField(max_length=100)
value = models.FloatField()
updated_at = models.DateTimeField(auto_now=True)
class MovieMetric(Model):
movie = ForeignKey(Movie, on_delete=CASCADE)
key = CharField(max_length=100)
value = IntegerField()
updated_at = DateTimeField(auto_now=True)
class Meta:
unique_together = ('movie', 'key')
unique_together = ("movie", "key")
def __str__(self):
return f"{self.movie.title} - {self.key}: {self.value}"
class ScoringRule(models.Model):
season = models.OneToOneField(Season, on_delete=models.CASCADE)
formula = models.TextField(
help_text="Python expression using keys like 'domestic_gross', 'oscars', 'multiplier'."
)
def __str__(self):
return f"Scoring for {self.season}"

View File

@@ -2,15 +2,17 @@
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}My Site{% endblock %}</title>
<link
rel="stylesheet"
href="https://cdn.datatables.net/2.3.2/css/dataTables.bootstrap5.css"
/>
<title>
{% block title %}My Site{% endblock %}
</title>
<link rel="stylesheet"
href="https://cdn.datatables.net/2.3.2/css/dataTables.bootstrap5.css" />
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
{% if DEBUG %}
<script src="http://localhost:3000/dist/bundle.js"></script>
<script defer src="http://localhost:3000/dist/bundle.js"></script>
{% else %}
<script src="{% static 'bundle.js' %}"></script>
<script defer src="{% static 'bundle.js' %}"></script>
{% endif %}
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.3/js/bootstrap.bundle.min.js"></script>
@@ -18,7 +20,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">
@@ -26,30 +28,40 @@
</a>
</div>
<div>
{%block navbar%}
{%endblock%}
{% 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>
<main class="container mt-4">
{% block breadcrumbs%}
<nav aria-label="breadcrumb">
{%if breadcrumbs%}
<ol class="breadcrumb">
{% for crumb in breadcrumbs %}
<li class="breadcrumb-item {% if forloop.last %}active{% endif %}" aria-current="page">{% if not forloop.last %}<a href="{{crumb.url}}">{{crumb.label}}</a>{%else%}{{crumb.label}}{%endif%}</li>
{%endfor%}
</ol>
{%endif%}
</nav>
{% endblock%} {% block content %}
<!-- Default content -->
{% endblock %}
</main>
<footer class="text-muted text-center mt-5">
<small>&copy; Sack Lunch</small>
</footer>
</body>
</html>
{% block body %}
<main class="container mt-4">
{% block breadcrumbs %}
<nav aria-label="breadcrumb">
{% if breadcrumbs %}
<ol class="breadcrumb">
{% for crumb in breadcrumbs %}
<li class="breadcrumb-item {% if forloop.last %}active{% endif %}"
aria-current="page">
{% if not forloop.last %}
<a href="{{ crumb.url }}">{{ crumb.label }}</a>{% else %}{{ crumb.label }}{% endif %}
</li>
{% endfor %}
</ol>
{% endif %}
</nav>
{% endblock breadcrumbs %}
{% block content %}{% endblock content %}
{% endblock body %}
</main>
<footer class="text-muted text-center mt-5">
<small>&copy; Sack Lunch</small>
</footer>
</body>
</html>

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.hashid %}">{{ draft.hashid }}</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

@@ -0,0 +1,22 @@
{% extends "base.dj.html" %}
{% block content %}
<h2>Login</h2>
<form method="post">
{% csrf_token %}
<div class="mb-3">
{{ form.username.label_tag }}
{{ form.username }}
{{ form.username.errors }}
</div>
<div class="mb-3">
{{ form.password.label_tag }}
{{ form.password }}
{{ form.password.errors }}
</div>
<button type="submit" class="btn btn-primary">Login</button>
</form>
{% if form.errors %}
<p class="text-danger">Invalid credentials, please try again.</p>
{% endif %}
{% endblock %}

View File

@@ -1,15 +1,16 @@
from django.urls import path, include
from . import views
from django.contrib.auth import views as auth_views
league_patterns = [
path("/", views.league_view, name="league" ),
path("", views.league_view, name="league" ),
path("season/", views.season_view, name="seasons"),
path("movie/", views.movie_view, name="movies"),
path("team/", views.team_view, name="teams"),
]
season_patterns = [
path("/", views.season_view, name="season"),
path("", views.season_view, name="season"),
path("scoreboard/", views.scoreboard_view, name="scoreboard"),
path("team/<str:username>/", views.team_view, name="team"),
path("team/", views.team_view, name="teams"),
@@ -18,6 +19,8 @@ season_patterns = [
]
urlpatterns = [
path('login/', auth_views.LoginView.as_view(template_name='login.dj.html'), name='login'),
path('logout/', auth_views.LogoutView.as_view(next_page='login'), name='logout'),
path(
"league/<slug:league_slug>/season/<slug:season_slug>/",
include((season_patterns, "boxofficefantasy"), namespace="season")
@@ -27,6 +30,6 @@ urlpatterns = [
include((league_patterns, "boxofficefantasy"), namespace="league")
),
path(
"/", views.league_view, name="leagues"
"", views.league_view, name="leagues"
)
]

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

@@ -10,7 +10,14 @@ https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
import os
from django.core.asgi import get_asgi_application
from draft.routing import websocket_urlpatterns
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'boxofficefantasy_project.settings')
application = get_asgi_application()
application = ProtocolTypeRouter({
"http": get_asgi_application(),
"websocket":AuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
})
print("✅ ASGI server is running")

View File

@@ -21,7 +21,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-_rrxhe5i6uqap!52u(1zi8x$820duvf5s_!9!bc4ghbyyktol0'
SECRET_KEY = "django-insecure-_rrxhe5i6uqap!52u(1zi8x$820duvf5s_!9!bc4ghbyyktol0"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
@@ -34,55 +34,60 @@ TMDB_API_KEY = os.environ.get("TMDB_API_KEY")
# Application definition
INSTALLED_APPS = [
'boxofficefantasy',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.humanize'
"rest_framework",
"daphne",
"boxofficefantasy",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.humanize",
"draft",
"channels",
"api"
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = 'boxofficefantasy_project.urls'
ROOT_URLCONF = "boxofficefantasy_project.urls"
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'boxofficefantasy.context_processors.debug_flag'
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"boxofficefantasy.context_processors.debug_flag",
],
},
},
]
WSGI_APPLICATION = 'boxofficefantasy_project.wsgi.application'
WSGI_APPLICATION = "boxofficefantasy_project.wsgi.application"
# Database
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
@@ -92,16 +97,16 @@ DATABASES = {
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
@@ -109,9 +114,9 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization
# https://docs.djangoproject.com/en/5.1/topics/i18n/
LANGUAGE_CODE = 'en-us'
LANGUAGE_CODE = "en-us"
TIME_ZONE = 'UTC'
TIME_ZONE = "UTC"
USE_I18N = True
@@ -121,7 +126,7 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.1/howto/static-files/
STATIC_URL = 'static/'
STATIC_URL = "static/"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
@@ -129,9 +134,45 @@ MEDIA_ROOT = BASE_DIR / "media"
# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
CSRF_TRUSTED_ORIGINS = [
"http://localhost:3000",
"http://127.0.0.1:3000",
]
ASGI_APPLICATION = "boxofficefantasy_project.asgi.application"
# Channel layers
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels.layers.InMemoryChannelLayer",
},
}
HASHIDS_SALT = os.getenv("BOF_HASHIDS_SALT", "your-very-secret-salt-string")
COLOR_GREEN = "\033[92m"
COLOR_RESET = "\033[0m"
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'prefix': {
'format': f'{COLOR_GREEN}[%(name)s]{COLOR_RESET} %(levelname)s %(asctime)s %(name)s: %(message)s',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'prefix'
},
},
'loggers': {
'draft.consumers': {
'handlers': ['console'],
'level': 'INFO', # Only INFO and above
'propagate': False,
},
},
}

View File

@@ -22,7 +22,9 @@ from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path("", include("boxofficefantasy.urls"))
path("", include("boxofficefantasy.urls")),
path("draft/", include("draft.urls")),
path("api/", include("api.urls")),
]
if settings.DEBUG:

View File

@@ -0,0 +1,11 @@
from hashids import Hashids
from django.conf import settings
hashids = Hashids(min_length=8, salt=settings.HASHIDS_SALT)
def encode_id(id):
return hashids.encode(id)
def decode_id(hashid):
decoded = hashids.decode(hashid)
return int(decoded[0]) if decoded else None

0
draft/__init__.py Normal file
View File

19
draft/admin.py Normal file
View File

@@ -0,0 +1,19 @@
from django.contrib import admin
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 DrafteSessionUserInline(admin.TabularInline):
extra = 0
model = DraftSessionParticipant
class DraftSessionAdmin(admin.ModelAdmin):
inlines = [DraftSessionSettingsInline, DrafteSessionUserInline]
readonly_fields = ('hashid',)
# Register your models here.
admin.site.register(DraftSession, DraftSessionAdmin)
admin.site.register(DraftSessionSettings)
admin.site.register(DraftPick)

6
draft/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class DraftConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'draft'

69
draft/constants.py Normal file
View File

@@ -0,0 +1,69 @@
from enum import IntEnum, StrEnum
class DraftMessage(StrEnum):
# Participant
PARTICIPANT_JOIN_REQUEST = "participant.join.request" # client -> server
PARTICIPANT_JOIN_CONFIRM = "participant.join.confirm" # server -> client
PARTICIPANT_JOIN_REJECT = "participant.join.reject" # server -> client
PARTICIPANT_LEAVE_INFORM = "participant.leave.inform" # server -> client (broadcast)
# User presence
USER_JOIN_INFORM = "user.join.inform" # server -> client
USER_LEAVE_INFORM = "user.leave.inform"
USER_IDENTIFICATION_INFORM = "user.identification.inform" # server -> client (tells socket "you are X", e.g. after connect) # server -> client
# Phase control
PHASE_CHANGE_INFORM = "phase.change.inform" # server -> client (target phase payload)
PHASE_CHANGE_REQUEST = "phase.change.request" # server -> client (target phase payload)
PHASE_CHANGE_CONFIRM = "phase.change.confirm" # server -> client (target phase payload)
# Status / sync
STATUS_SYNC_REQUEST = "status.sync.request" # client -> server
STATUS_SYNC_INFORM = "status.sync.inform" # server -> client (full/partial state)
DRAFT_INDEX_ADVANCE_REQUEST = "draft.index.advance.request"
DRAFT_INDEX_ADVANCE_CONFIRM = "draft.index.advance.confirm"
# Order determination
ORDER_DETERMINE_REQUEST = "order.determine.request" # client -> server (admin)
ORDER_DETERMINE_CONFIRM = "order.determine.confirm" # server -> client
# Bidding (examples, adjust to your flow)
BID_START_INFORM = "bid.start.inform" # server -> client (movie, ends_at)
BID_START_REQUEST = "bid.start.request" # server -> client (movie, ends_at)
BID_PLACE_REQUEST = "bid.place.request" # client -> server (amount)
BID_UPDATE_INFORM = "bid.update.inform" # server -> client (high bid)
BID_END_INFORM = "bid.end.inform" # server -> client (winner)
# Nomination (examples)
NOMINATION_SUBMIT_REQUEST = "nomination.submit.request" # client -> server (movie_id)
NOMINATION_CONFIRM = "nomination.submit.confirm" # server -> client
class DraftPhase(IntEnum):
WAITING = 10
DETERMINE_ORDER = 20
NOMINATING = 30
BIDDING = 40
AWARDING = 50
FINALIZING = 60
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"

348
draft/consumers.py Normal file
View File

@@ -0,0 +1,348 @@
from channels.generic.websocket import AsyncJsonWebsocketConsumer
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, DraftSessionParticipant
from django.core.cache import cache
import asyncio
from django.contrib.auth.models import User
from draft.constants import (
DraftMessage,
DraftPhase,
DraftGroupChannelNames,
)
from draft.state import DraftCacheKeys, DraftStateManager
from typing import Any
import logging
logger = logging.getLogger(__name__) # __name__ = module path
import random
class DraftConsumerBase(AsyncJsonWebsocketConsumer):
group_names: DraftGroupChannelNames
cache_keys: DraftCacheKeys
draft_state: DraftStateManager
user: User
async def connect(self):
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_hashid,
)
self.draft_participants = await self.get_draft_participants(
session=self.draft_session
)
self.group_names = DraftGroupChannelNames(draft_hashid)
self.cache_keys = DraftCacheKeys(draft_hashid)
self.draft_state = DraftStateManager(self.draft_session)
self.user = self.scope["user"]
if not self.should_accept_user():
await self.channel_layer.send(
self.channel_name,
{
"type": "direct.message",
"subtype": DraftMessage.PARTICIPANT_JOIN_REJECT,
"payload":{"current_user": self.user.username}
}
)
await self.close()
await self.channel_layer.group_send(
self.group_names.admin,
{
"type": "broadcast.admin",
"subtype": DraftMessage.PARTICIPANT_JOIN_REJECT,
"payload":{"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": "broadcast.session",
"subtype": DraftMessage.USER_JOIN_INFORM,
"payload": {"user": self.user.username},
},
)
await self.channel_layer.send(
self.channel_name,
{
"type": "direct.message",
"subtype": DraftMessage.STATUS_SYNC_INFORM,
"payload": self.get_draft_status(),
},
)
await self.channel_layer.send(
self.channel_name,
{
"type": "direct.message",
"subtype": DraftMessage.USER_IDENTIFICATION_INFORM,
"payload": {"user": self.user.username},
},
)
async def should_accept_user(self) -> bool:
return self.user.is_authenticated
async def receive_json(self, content):
event_type = content.get("type")
if event_type == DraftMessage.STATUS_SYNC_REQUEST:
await self.send_json(
{
"type": DraftMessage.STATUS_SYNC_INFORM,
"payload": self.get_draft_status(),
}
)
# Broadcast Handlers
async def direct_message(self, event):
await self._dispatch_broadcast(event)
async def broadcast_session(self, event):
await self._dispatch_broadcast(event)
async def _dispatch_broadcast(self, event):
logger.info(f"dispatching message {event}")
subtype = event.get("subtype")
payload = event.get("payload", {})
await self.send_json({"type": subtype, "payload": payload})
# === Methods ===
def get_draft_status(self) -> dict[str, Any]:
return {
**self.draft_state.get_summary(),
"user": self.user.username,
"participants": [user.username for user in self.draft_participants],
}
# === DB Access ===
@database_sync_to_async
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", "settings"
).get(pk=draft_session_id)
else:
raise Exception()
return draft_session
@database_sync_to_async
def get_draft_participants(self, session) -> list[DraftSessionParticipant]:
participants = session.participants.all()
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.group_names.admin, self.channel_name)
async def receive_json(self, content):
await super().receive_json(content)
logger.info(f"Receive message {content}")
event_type = content.get("type")
if (
event_type == DraftMessage.PHASE_CHANGE_REQUEST
and content.get("destination") == DraftPhase.DETERMINE_ORDER
):
await self.determine_draft_order()
if (
event_type == DraftMessage.PHASE_CHANGE_REQUEST
and content.get("destination") == DraftPhase.NOMINATING
):
await self.start_nominate()
if event_type == DraftMessage.DRAFT_INDEX_ADVANCE_REQUEST:
self.draft_state.draft_index_advance()
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.DRAFT_INDEX_ADVANCE_CONFIRM,
"payload": self.draft_state.get_summary(),
},
)
if event_type == DraftMessage.NOMINATION_SUBMIT_REQUEST:
movie_id = content.get('payload',{}).get('movie_id')
user = content.get('payload',{}).get('user')
self.draft_state.start_nomination(movie_id)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.NOMINATION_CONFIRM,
"payload": {
"current_movie": self.draft_state.get_summary()['current_movie'],
"nominating_participant": user
}
}
)
if event_type == DraftMessage.BID_START_REQUEST:
self.draft_state.start_timer()
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.BID_START_INFORM,
"payload": {
"current_movie": self.draft_state.get_summary()['current_movie'],
"bidding_duration": self.draft_state.settings.bidding_duration,
"bidding_timer_end": self.draft_state.get_timer_end(),
"bidding_timer_start": self.draft_state.get_timer_start()
}
}
)
def should_accept_user(self):
return super().should_accept_user() and self.user.is_staff
# === Draft logic ===
async def start_nominate(self):
await self.set_draft_phase(DraftPhase.NOMINATING)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.PHASE_CHANGE_CONFIRM,
"payload": {"phase": self.draft_state.phase},
},
)
async def determine_draft_order(self):
draft_order = self.draft_state.determine_draft_order(self.draft_participants)
self.draft_state.draft_index = 0
await self.set_draft_phase(DraftPhase.DETERMINE_ORDER)
next_picks = self.draft_state.next_picks(include_current=True)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.ORDER_DETERMINE_CONFIRM,
"payload": {
"draft_order": draft_order,
"draft_index": self.draft_state.draft_index,
"current_pick": next_picks[0],
"next_picks": next_picks[1:]
},
},
)
async def set_draft_phase(self, destination: DraftPhase):
self.draft_state.phase = destination
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.PHASE_CHANGE_CONFIRM,
"payload": {"phase": self.draft_state.phase},
},
)
# === Broadcast Handlers ===
async def broadcast_admin(self, event):
await self._dispatch_broadcast(event)
class DraftParticipantConsumer(DraftConsumerBase):
async def connect(self):
await super().connect()
self.draft_state.connect_participant(self.user.username)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.PARTICIPANT_JOIN_CONFIRM,
"payload": {
"user": self.user.username,
"connected_participants": self.draft_state.connected_participants,
},
},
)
await self.channel_layer.group_add(
self.group_names.participant, self.channel_name
)
async def disconnect(self, close_code):
self.draft_state.disconnect_participant(self.user.username)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.PARTICIPANT_LEAVE_INFORM,
"payload": {
"user": self.user.username,
"connected_participants": self.draft_state.connected_participants,
},
},
)
await super().disconnect(close_code)
self.draft_state.disconnect_participant(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')
if event_type == DraftMessage.NOMINATION_SUBMIT_REQUEST:
await self.channel_layer.group_send(
self.group_names.admin,
{
"type": "broadcast.admin",
"subtype": event_type,
"payload": {
"movie_id": content.get('payload',{}).get('id'),
"user": content.get('payload',{}).get('user')
}
}
)
# === Broadcast handlers ===
async def broadcast_participant(self, event):
await self._dispatch_broadcast(event)
# === Draft ===
async def nominate(self, movie_title): ...
async def place_bid(self, amount, user): ...
# === Example DB Access ===
@database_sync_to_async
def add_draft_participant(self):
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,56 @@
# Generated by Django 5.2.4 on 2025-07-26 13:41
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('boxofficefantasy', '0009_alter_moviemetric_value_alter_pick_bid_amount_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='DraftSession',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_active', models.BooleanField()),
('current_nomination_index', models.IntegerField()),
('season', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='boxofficefantasy.season')),
],
),
migrations.CreateModel(
name='DraftPick',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('bid_amount', models.IntegerField()),
('nomination_order', models.IntegerField()),
('movie', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='boxofficefantasy.movie')),
('winner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('draft', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='draft.draftsession')),
],
),
migrations.CreateModel(
name='DraftParticipant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('budget', models.IntegerField()),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('draft', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='draft.draftsession')),
],
),
migrations.CreateModel(
name='DraftMoviePool',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('nominated', models.BooleanField()),
('movie', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='boxofficefantasy.movie')),
('draft', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='draft.draftsession')),
],
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.2.4 on 2025-07-26 20:03
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('draft', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='DraftSessionSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('starting_budget', models.IntegerField(default=100)),
('draft_session', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='draft.draftsession')),
],
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.2.4 on 2025-07-26 20:12
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('draft', '0002_draftsessionsettings'),
]
operations = [
migrations.AlterModelOptions(
name='draftsessionsettings',
options={'verbose_name_plural': 'Draft Session Settings'},
),
migrations.RemoveField(
model_name='draftsessionsettings',
name='draft_session',
),
migrations.AddField(
model_name='draftsession',
name='settings',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='session', to='draft.draftsessionsettings'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.4 on 2025-07-26 20:18
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('draft', '0003_alter_draftsessionsettings_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='draftsession',
name='settings',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='session', to='draft.draftsessionsettings'),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.2.4 on 2025-07-26 20:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('draft', '0004_alter_draftsession_settings'),
]
operations = [
migrations.RemoveField(
model_name='draftsession',
name='settings',
),
migrations.AddField(
model_name='draftsessionsettings',
name='draft_session',
field=models.OneToOneField(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='draft.draftsession'),
preserve_default=False,
),
]

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

@@ -0,0 +1,35 @@
# Generated by Django 5.2.4 on 2025-08-10 22:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boxofficefantasy', '0009_alter_moviemetric_value_alter_pick_bid_amount_and_more'),
('draft', '0006_remove_draftparticipant_draft_and_more'),
]
operations = [
migrations.AddField(
model_name='draftsessionsettings',
name='bidding_duration',
field=models.IntegerField(default=90),
),
migrations.AlterField(
model_name='draftpick',
name='draft',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='draft_picks', to='draft.draftsession'),
),
migrations.AlterField(
model_name='draftsession',
name='movies',
field=models.ManyToManyField(blank=True, related_name='draft_sessions', to='boxofficefantasy.movie'),
),
migrations.AlterField(
model_name='draftsessionparticipant',
name='draft_session',
field=models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, to='draft.draftsession'),
),
]

View File

71
draft/models.py Normal file
View File

@@ -0,0 +1,71 @@
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)
participants: ManyToManyField = ManyToManyField(
User, through="DraftSessionParticipant", related_name="participant_entries"
)
movies: ManyToManyField = ManyToManyField(Movie, related_name="draft_sessions", blank=True)
@property
def hashid(self):
if not self.pk:
return ""
return f"{encode_id(self.pk)}"
@classmethod
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"):
DraftSessionSettings.objects.create(draft_session=self)
class DraftSessionParticipant(Model):
draft_session = ForeignKey(DraftSession, on_delete=CASCADE, blank=True)
user = ForeignKey(User, on_delete=CASCADE)
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, related_name="draft_picks")
movie = ForeignKey(Movie, on_delete=CASCADE)
winner = ForeignKey(User, on_delete=CASCADE)
bid_amount = IntegerField()
nomination_order = IntegerField()
class DraftSessionSettings(Model):
starting_budget = IntegerField(default=100)
draft_session = OneToOneField(
DraftSession, on_delete=CASCADE, related_name="settings"
)
bidding_duration = IntegerField(default=90)
def __str__(self):
return f"Settings for {self.draft_session}"
class Meta:
verbose_name_plural = "Draft session settings"

7
draft/routing.py Normal file
View File

@@ -0,0 +1,7 @@
from django.urls import path
from draft.consumers import DraftParticipantConsumer, DraftAdminConsumer
websocket_urlpatterns = [
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()),
]

228
draft/state.py Normal file
View File

@@ -0,0 +1,228 @@
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
from draft.models import DraftSession
import time
from dataclasses import dataclass
from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple
import random
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 draft_index(self):
return f"{self.prefix}:draft_index"
@property
def current_movie(self):
return f"{self.prefix}:current_movie"
# @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"
@property
def bid_timer_start(self):
return f"{self.prefix}:bid_timer_start"
# 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: DraftSession):
self.session_id = session.hashid
self.cache = cache
self.keys = DraftCacheKeys(self.session_id)
self._initial_phase = self.cache.get(self.keys.phase, DraftPhase.WAITING.value)
self.settings = session.settings
# === Phase Management ===
@property
def phase(self) -> str:
return str(self.cache.get(self.keys.phase, self._initial_phase))
@phase.setter
def phase(self, new_phase: DraftPhase):
self.cache.set(self.keys.phase, new_phase.value)
# === Connected Users ===
@property
def connected_participants(self) -> list[str]:
return json.loads(self.cache.get(self.keys.connected_users) or "[]")
def connect_participant(self, username: str):
users = set(self.connected_participants)
users.add(username)
self.cache.set(self.keys.connected_users, json.dumps(list(users)))
def disconnect_participant(self, username: str):
users = set(self.connected_participants)
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[str]):
if not isinstance(draft_order, list):
return
self.cache.set(self.keys.draft_order,json.dumps(draft_order))
def determine_draft_order(self, users: list[User]):
draft_order = random.sample(
users, len(users)
)
self.draft_order = [user.username for user in draft_order]
return self.draft_order
@property
def draft_index(self):
return self.cache.get(self.keys.draft_index,0)
@draft_index.setter
def draft_index(self, draft_index: int):
self.cache.set(self.keys.draft_index, int(draft_index))
def draft_index_advance(self, n: int = 1):
self.draft_index += n
return self.draft_index
def next_picks(
self,
*,
from_overall: int | None = None,
count: int | None = None,
include_current: bool = False,
) -> List[dict]:
"""
Convenience: return the next `count` picks starting after `from_overall`
(or after current draft_index if omitted). Each item:
{overall, round, pick_in_round, participant}
"""
if not self.draft_order:
return []
n = len(self.draft_order)
count = count if count else len(self.draft_order)
start = self.draft_index if from_overall is None else int(from_overall)
start = start if include_current else start + 1
out: List[dict] = []
for overall in range(start, start + count):
r, p = _round_and_pick(overall, n)
order_type = "snake"
order = _round_order(r, order_type, self.draft_order)
out.append({
"overall": overall,
"round": r,
"pick_in_round": p,
"participant": order[p - 1],
})
return out
# === 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 = self.settings.bidding_duration
start_time = time.time()
end_time = start_time + seconds
self.cache.set(self.keys.bid_timer_end, end_time)
self.cache.set(self.keys.bid_timer_start, start_time)
def get_timer_end(self) -> str | None:
return self.cache.get(self.keys.bid_timer_end)
def get_timer_start(self) -> str | None:
return self.cache.get(self.keys.bid_timer_start)
# === Sync Snapshot ===
def get_summary(self) -> dict:
picks = self.next_picks(include_current=True)
return {
"phase": self.phase,
"draft_order": self.draft_order,
"draft_index": self.draft_index,
"connected_participants": self.connected_participants,
"current_movie": self.cache.get(self.keys.current_movie),
# "bids": self.get_bids(),
"bidding_timer_end": self.get_timer_end(),
"bidding_timer_start": self.get_timer_start(),
"current_pick": picks[0] if picks else None,
"next_picks": picks[1:] if picks else []
}
OrderType = Literal["snake", "linear"]
def _round_and_pick(overall: int, n: int) -> Tuple[int, int]:
"""overall -> (round_1_based, pick_in_round_1_based)"""
r = overall // n + 1
p = overall % n + 1
return r, p
def _round_order(round_num: int, order_type: OrderType, r1: Sequence[Any]) -> Sequence[Any]:
if order_type == "linear" or (round_num % 2 == 1):
return r1
return list(reversed(r1)) # even rounds in snake

View File

@@ -0,0 +1,8 @@
{% extends "base.dj.html" %}
{% block body %}
{% load static %}
<script>
window.draftSessionId = "{{ draft_id_hashed }}"
</script>
<div id="draft-participant-root" data-draft-id="{{ draft_id_hashed }}"></div>
{% endblock body %}

View File

@@ -0,0 +1,10 @@
{% extends "base.dj.html" %}
{% block content %}
<h1>Draft Room: {{ league.name }} {{ season.label }} {{ season.year }}</h1>
{% load static %}
<script>
window.draftSessionId = "{{ draft_id_hashed }}"
</script>
<div id="draft-admin-root" data-draft-hid="{{ draft_id_hashed }}"></div>
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% load static %}
<head>
{% if DEBUG %}
<script defer src="http://localhost:3000/dist/bundle.js"></script>
{% else %}
<script src="{% static 'bundle.js' %}"></script>
{% endif %}
</head>
<body>
<script>
window.draftSessionId = "{{ draft_id_hashed }}"
</script>
<div id="draft-debug-root" data-draft-hid="{{ draft_id_hashed }}"></div>
</body>

3
draft/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

11
draft/urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.urls import path
from . import views
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:subpage>", views.draft_room, name="admin_session"),
# path("<slug:league_slug>/<slug:season_slug>/", views.draft_room_list, name="room"),
]

33
draft/views.py Normal file
View File

@@ -0,0 +1,33 @@
from django.shortcuts import render, get_object_or_404
from boxofficefantasy.models import League, Season
from draft.models import DraftSession
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, subpage=""):
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)
league = draft_session.season.league
season = draft_session.season
elif league_slug and season_slug:
raise NotImplementedError
league = get_object_or_404(League, slug=league_slug)
label, year = parse_season_slug(season_slug)
season = get_object_or_404(Season, league=league, label__iexact=label, year=year)
draft_session = get_object_or_404(DraftSession, season=season)
context = {
"draft_id_hashed": draft_session.hashid,
"league": league,
"season": season,
}
if subpage == "admin":
return render(request, "draft/room_admin.dj.html", context)
elif subpage == "debug":
return render(request, "draft/room_debug.dj.html", context)
else:
return render(request, "draft/room.dj.html", context)

3
frontend/.babelrc Normal file
View File

@@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,14 +3,21 @@
"dev": "SASS_LOG_LEVEL=error webpack serve --config webpack.config.js"
},
"devDependencies": {
"@babel/core": "^7.28.0",
"@babel/preset-env": "^7.28.0",
"@babel/preset-react": "^7.27.1",
"babel-loader": "^10.0.0",
"css-loader": "^7.1.2",
"sass": "^1.89.2",
"sass-loader": "^16.0.5",
"style-loader": "^4.0.0",
"webpack": "^5.100.2",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.2"
},
"dependencies": {
"bootstrap": "^5.3.7"
"bootstrap": "^5.3.7",
"react": "^18.3.1",
"react-dom": "^18.3.1"
}
}

View File

@@ -0,0 +1,45 @@
import React, { useEffect, useState } from "react";;
import { useWebSocket } from "./common/WebSocketContext.jsx";
import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from './constants.js';
import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "./common/utils.js"
export const DraftDebug = ({ draftSessionId }) => {
const [draftState, setDraftState] = useState({})
const socket = useWebSocket();
if (!socket) return;
useEffect(() => {
if (!socket) return;
const openHandler = (event) => {
console.log('Websocket Opened')
}
const closeHandler = (event) => {
console.log('Websocket Closed')
}
socket.addEventListener('open', openHandler);
socket.addEventListener('close', closeHandler);
return () => {
socket.removeEventListener('open', openHandler);
socket.removeEventListener('close', closeHandler);
}
}, [socket])
useEffect(() => {
if (!socket) return;
const draftStatusMessageHandler = (event) => handleDraftStatusMessages(event, setDraftState)
socket.addEventListener('message', draftStatusMessageHandler);
return () => {
socket.removeEventListener('message', draftStatusMessageHandler)
};
}, [socket]);
const data = { 'message': 'test' }
return (<pre style={{margin: "1em"}}>
{JSON.stringify(draftState, null, 2)}
</pre>
)
}

View File

@@ -0,0 +1,169 @@
import React, { useEffect, useState } from "react";
import { useWebSocket } from "../common/WebSocketContext.jsx";
import { WebSocketStatus } from "../common/WebSocketStatus.jsx";
import { ParticipantList } from "../common/ParticipantList.jsx";
import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from '../constants.js';
import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "../common/utils.js"
import { DraftMoviePool } from "../common/DraftMoviePool.jsx"
import { DraftCountdownClock } from "../common/DraftCountdownClock.jsx"
import { jsxs } from "react/jsx-runtime";
const DraftPhaseDisplay = ({ draftPhase, nextPhaseHandler, prevPhaseHandler }) => {
return (
<div className="draft-phase-container">
<label>Phase</label>
<div className="d-flex">
<div className="change-phase"><button onClick={prevPhaseHandler}><i className="bi bi-chevron-left"></i></button></div>
<ol>
{
DraftPhasesOrdered.map((p) => (
<li key={p} className={p === draftPhase ? "current-phase" : ""}>
<span>{DraftPhaseLabel[p]}</span>
</li>
))
}
</ol>
<div className="change-phase"><button onClick={nextPhaseHandler}><i className="bi bi-chevron-right"></i></button></div>
</div>
</div>
)
}
export const DraftAdmin = ({ draftSessionId }) => {
const socket = useWebSocket();
const [draftDetails, setDraftDetails] = useState();
const [draftState, setDraftState] = useState({})
const [currentUser, setCurrentUser] = useState(null);
useEffect(() => {
fetchDraftDetails(draftSessionId)
.then((data) => {
console.log("Fetched draft data", data)
setDraftDetails(data)
})
}, [])
useEffect(()=>{
if (!socket) return;
const openHandler = (event)=>{
console.log('Websocket Opened')
}
const closeHandler = (event)=>{
console.log('Websocket Closed')
}
socket.addEventListener('open', openHandler );
socket.addEventListener('close', closeHandler );
return ()=>{
socket.removeEventListener('open', openHandler );
socket.removeEventListener('close', closeHandler );
}
}, [socket])
useEffect(() => {
if (!socket) return;
const draftStatusMessageHandler = (event) => handleDraftStatusMessages(event, setDraftState)
const userIdentifyMessageHandler = (event) => handleUserIdentifyMessages(event, setCurrentUser)
const handleNominationRequest = (event)=> {
const message = JSON.parse(event.data)
const { type, payload } = message;
if (type == DraftMessage.NOMINATION_SUBMIT_REQUEST) {
socket.send(JSON.stringify(
{
type: DraftMessage.NOMINATION_SUBMIT_REQUEST,
payload
}
))
}
}
socket.addEventListener('message', draftStatusMessageHandler );
socket.addEventListener('message', userIdentifyMessageHandler );
socket.addEventListener('message', handleNominationRequest );
return () => {
socket.removeEventListener('message', draftStatusMessageHandler)
socket.removeEventListener('message', userIdentifyMessageHandler );
socket.removeEventListener('message', handleNominationRequest );
};
}, [socket]);
const handlePhaseChange = (target) => {
let destination
const origin = draftState.phase
const originPhaseIndex = DraftPhasesOrdered.findIndex(i => i == origin)
console.log('origin phase index', originPhaseIndex)
if (target == "next" && originPhaseIndex < DraftPhasesOrdered.length) {
destination = DraftPhasesOrdered[originPhaseIndex + 1]
}
else if (target == "previous" && originPhaseIndex > 0) {
destination = DraftPhasesOrdered[originPhaseIndex - 1]
}
console.log(destination)
socket.send(
JSON.stringify(
{ type: DraftMessage.PHASE_CHANGE_REQUEST, origin, destination }
)
)
}
const handleStartDraft = () => {
}
const handleAdvanceDraft = () => {
socket.send(
JSON.stringify(
{ type: DraftMessage.DRAFT_INDEX_ADVANCE_REQUEST }
)
)
}
const handleRequestDraftSummary = () => {
socket.send(
JSON.stringify(
{ type: DraftMessage.STATUS_SYNC_REQUEST }
)
)
}
const handleStartBidding = () => {
socket.send(
JSON.stringify(
{type: DraftMessage.BID_START_REQUEST}
)
)
}
return (
<div className="container draft-panel admin">
<div className="d-flex justify-content-between border-bottom mb-2 p-1">
<h3>Draft Panel</h3>
<div className="d-flex gap-1">
<WebSocketStatus socket={socket} />
<button onClick={() => handleRequestDraftSummary()} className="btn btn-small btn-light">
<i className="bi bi-arrow-clockwise"></i>
</button>
</div>
</div>
<ParticipantList
currentUser = {currentUser}
draftState={draftState}
draftDetails={draftDetails}
isAdmin={true}
/>
<div className="d-flex gap-1 m-1">
<button onClick={handleAdvanceDraft} className="btn btn-primary">Advance Draft</button>
<button onClick={handleStartBidding} className="btn btn-primary">Start Bidding</button>
</div>
<DraftMoviePool draftDetails={draftDetails} draftState={draftState}></DraftMoviePool>
<DraftPhaseDisplay draftPhase={draftState.phase} nextPhaseHandler={() => { handlePhaseChange('next') }} prevPhaseHandler={() => { handlePhaseChange('previous') }}></DraftPhaseDisplay>
<DraftCountdownClock endTime={draftState.bidding_timer_end}></DraftCountdownClock>
</div>
);
};

View File

@@ -0,0 +1,34 @@
import React, { useEffect, useState } from "react";
export function DraftCountdownClock({ endTime, onFinish }) {
// endTime is in seconds (Unix time)
const getTimeLeft = (et) => Math.max(0, Math.floor(et - Date.now() / 1000));
const [timeLeft, setTimeLeft] = useState(getTimeLeft(endTime));
useEffect(() => {
if (timeLeft <= 0) {
if (onFinish) onFinish();
return;
}
const timer = setInterval(() => {
const t = getTimeLeft(endTime);
setTimeLeft(t);
if (t <= 0 && onFinish) onFinish();
}, 100);
return () => clearInterval(timer);
// eslint-disable-next-line
}, [endTime, onFinish, timeLeft]);
const minutes = Math.floor(timeLeft / 60);
const secs = timeLeft % 60;
const pad = n => String(n).padStart(2, "0");
return (
<div className="countdown-clock">
<span>
{minutes}:{pad(secs)}
</span>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import React from "react";
import { isEmptyObject } from "./utils";
export const DraftMoviePool = ({ isParticipant, draftDetails, draftState }) => {
if(isEmptyObject(draftDetails)) {return}
const {movies} = draftDetails
const {current_movie} = draftState
return (
<div className="movie-pool-container">
<label>Movies</label>
<ul>
{movies.map(m => (
<li key={m.id} className={`${current_movie == m.id ? "current-movie fw-bold" : null }`}>
<a href={`/api/movie/${m.id}/detail`}>
{m.title}
</a>
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import React from "react";
import { fetchDraftDetails, isEmptyObject } from "../common/utils.js"
export const ParticipantList = ({ isAdmin, draftState, draftDetails, currentUser }) => {
if (isEmptyObject(draftState) || isEmptyObject(draftDetails)) { console.warn('empty draft state', draftState); return }
const { draft_order, draft_index, connected_participants } = draftState
const { participants } = draftDetails
const ListTag = draft_order.length > 0 ? "ol" : "ul"
const listItems = draft_order.length > 0 ? draft_order.map(d => participants.find(p => p.username == d)) : participants
return (
<div className="participant-list-container">
<label>Particpants</label>
<ListTag className="participant-list">
{listItems.map((p, i) => (
<li key={i} className={`${i == draft_index ? "fw-bold" : ""}`}>
<span className={`${p.username == currentUser ? "current-user" : ""}`}>{p?.full_name}</span>
{isAdmin ? (
<div
className={
`ms-2 stop-light ${connected_participants.includes(p?.username) ? "success" : "danger"}`
}
></div>
) : null}
</li>
))}
</ListTag>
</div>
)
}

View File

@@ -0,0 +1,16 @@
// WebSocketContext.jsx
import React, { useState, createContext, useContext } from "react";
const WebSocketContext = createContext(null);
export const WebSocketProvider = ({ url, children }) => {
const [socket] = useState(() => new WebSocket(url));
return (
<WebSocketContext.Provider value={socket}>
{children}
</WebSocketContext.Provider>
);
};
export const useWebSocket = () => useContext(WebSocketContext);

View File

@@ -0,0 +1,34 @@
import React, { useEffect, useState } from "react";
export const WebSocketStatus = ({ socket }) => {
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
console.log('socket changed', socket)
if (!socket) return;
const handleOpen = () => { console.log('socket open'); setIsConnected(true) };
const handleClose = () => { console.log('socket close'); setIsConnected(false) };
const handleError = () => { console.log('socket error'); setIsConnected(false) };
socket.addEventListener("open", handleOpen);
socket.addEventListener("close", handleClose);
socket.addEventListener("error", handleError);
// 🧹 Cleanup to remove listeners when component unmounts or socket changes
return () => {
socket.removeEventListener("open", handleOpen);
socket.removeEventListener("close", handleClose);
socket.removeEventListener("error", handleError);
};
}, [socket])
return (
<div className="d-flex align-items-center gap-2">
<span
className={`badge ${isConnected ? "text-bg-success" : "text-bg-danger"}`}
>
{isConnected ? "Connected" : "Disconnected"}
</span>
</div>
);
};

View File

@@ -0,0 +1,81 @@
import { DraftMessage } from "../constants";
export async function fetchDraftDetails(draftSessionId) {
return fetch(`/api/draft/${draftSessionId}/`)
.then((response) => {
if (response.ok) {
return response.json();
} else {
throw new Error();
}
})
.catch((err) => {
console.error("Error fetching draft details", err);
});
}
export async function fetchMovieDetails(draftSessionId) {
return fetch(`/api/draft/${draftSessionId}/movie/`)
.then((response) => {
if (response.ok) {
return response.json();
} else {
throw new Error();
}
})
.catch((err) => {
console.error("Error fetching draft details", err);
});
}
export function isEmptyObject(obj) {
return (
obj == null || (Object.keys(obj).length === 0 && obj.constructor === Object)
);
}
export const handleDraftStatusMessages = (event, setDraftState) => {
const message = JSON.parse(event.data);
const { type, payload } = message;
console.log("Message: ", type, event?.data);
if (!payload) return;
const {
connected_participants,
phase,
draft_order,
draft_index,
current_movie,
bidding_timer_end,
bidding_timer_start,
current_pick,
next_picks
} = payload;
if (type == DraftMessage.STATUS_SYNC_INFORM) {
setDraftState(payload);
}
setDraftState((prev) => ({
...prev,
...(connected_participants ? { connected_participants } : {}),
...(draft_order ? { draft_order } : {}),
...(draft_index ? { draft_index } : {}),
...(phase ? { phase: Number(phase) } : {}),
...(current_movie ? { current_movie } : {}),
...(bidding_timer_end ? { bidding_timer_end: Number(bidding_timer_end) } : {}),
...(current_pick ? { current_pick } : {}),
...(next_picks ? { next_picks } : {}),
}));
};
export const handleUserIdentifyMessages = (event, setUser) => {
const message = JSON.parse(event.data);
const { type, payload } = message;
if (type == DraftMessage.USER_IDENTIFICATION_INFORM) {
console.log("Message: ", type, event.data);
const { user } = payload;
setUser(user);
}
};

View File

@@ -0,0 +1,49 @@
// AUTO-GENERATED. Do not edit by hand.
// Run: python scripts/generate_js_constants.py
export const DraftMessage = {
PARTICIPANT_JOIN_REQUEST: "participant.join.request",
PARTICIPANT_JOIN_CONFIRM: "participant.join.confirm",
PARTICIPANT_JOIN_REJECT: "participant.join.reject",
PARTICIPANT_LEAVE_INFORM: "participant.leave.inform",
USER_JOIN_INFORM: "user.join.inform",
USER_LEAVE_INFORM: "user.leave.inform",
USER_IDENTIFICATION_INFORM: "user.identification.inform",
PHASE_CHANGE_INFORM: "phase.change.inform",
PHASE_CHANGE_REQUEST: "phase.change.request",
PHASE_CHANGE_CONFIRM: "phase.change.confirm",
STATUS_SYNC_REQUEST: "status.sync.request",
STATUS_SYNC_INFORM: "status.sync.inform",
DRAFT_INDEX_ADVANCE_REQUEST: "draft.index.advance.request",
DRAFT_INDEX_ADVANCE_CONFIRM: "draft.index.advance.confirm",
ORDER_DETERMINE_REQUEST: "order.determine.request",
ORDER_DETERMINE_CONFIRM: "order.determine.confirm",
BID_START_INFORM: "bid.start.inform",
BID_START_REQUEST: "bid.start.request",
BID_PLACE_REQUEST: "bid.place.request",
BID_UPDATE_INFORM: "bid.update.inform",
BID_END_INFORM: "bid.end.inform",
NOMINATION_SUBMIT_REQUEST: "nomination.submit.request",
NOMINATION_CONFIRM: "nomination.submit.confirm",
};
export const DraftPhase = {
WAITING: 10,
DETERMINE_ORDER: 20,
NOMINATING: 30,
BIDDING: 40,
AWARDING: 50,
FINALIZING: 60,
};
export const DraftPhaseLabel = {
[DraftPhase.WAITING]: "waiting",
[DraftPhase.DETERMINE_ORDER]: "determine_order",
[DraftPhase.NOMINATING]: "nominating",
[DraftPhase.BIDDING]: "bidding",
[DraftPhase.AWARDING]: "awarding",
[DraftPhase.FINALIZING]: "finalizing",
};
export const DraftPhasesOrdered = [DraftPhase.WAITING, DraftPhase.DETERMINE_ORDER, DraftPhase.NOMINATING, DraftPhase.BIDDING, DraftPhase.AWARDING, DraftPhase.FINALIZING];

View File

@@ -0,0 +1,164 @@
// DraftAdmin.jsx
import React, { useEffect, useState } from "react";
import { useWebSocket } from "../common/WebSocketContext.jsx";
import { WebSocketStatus } from "../common/WebSocketStatus.jsx";
import { DraftMessage, DraftPhaseLabel, DraftPhases } from '../constants.js';
import { fetchDraftDetails, handleUserIdentifyMessages, isEmptyObject } from "../common/utils.js";
import { DraftMoviePool } from "../common/DraftMoviePool.jsx";
import { ParticipantList } from "../common/ParticipantList.jsx";
import { DraftCountdownClock } from "../common/DraftCountdownClock.jsx"
import { handleDraftStatusMessages } from '../common/utils.js'
const NominateMenu = ({ socket, draftState, draftDetails, currentUser }) => {
if (!socket || isEmptyObject(draftDetails) || isEmptyObject(draftState)) return;
const currentDrafter = draftState.draft_order[draftState.draft_index]
if (currentUser != currentDrafter) return;
const { movies } = draftDetails
const requestNomination = (event) => {
event.preventDefault()
const formData = new FormData(event.target)
socket.send(JSON.stringify({
type: DraftMessage.NOMINATION_SUBMIT_REQUEST,
payload: {
id: formData.get('movie'),
user: currentUser
}
}))
}
return (
<div>
<label>Nominate</label>
<div className="d-flex">
<form onSubmit={requestNomination}>
<select className="form-control" name="movie">
{movies.map(m => (
<option key={m.id} value={m.id}>{m.title}</option>
))}
</select>
<button className="btn btn-primary">Nominate</button>
</form>
</div>
</div>
)
}
export const DraftParticipant = ({ draftSessionId }) => {
const socket = useWebSocket();
const [draftState, setDraftState] = useState({});
const [draftDetails, setDraftDetails] = useState({});
const [currentUser, setCurrentUser] = useState(null);
const [movies, setMovies] = useState([]);
console.log(socket)
useEffect(() => {
fetchDraftDetails(draftSessionId)
.then((data) => {
console.log("Fetched draft data", data)
setMovies(data.movies)
setDraftDetails(data)
})
}, [draftSessionId])
useEffect(() => {
if (!socket) return;
socket.onclose = (event) => {
console.log('Websocket Closed')
}
}, [socket])
useEffect(() => {
if (!socket) return;
const draftStatusMessageHandler = (event) => handleDraftStatusMessages(event, setDraftState)
const userIdentifyMessageHandler = (event) => handleUserIdentifyMessages(event, setCurrentUser)
socket.addEventListener('message', draftStatusMessageHandler);
socket.addEventListener('message', userIdentifyMessageHandler);
return () => {
socket.removeEventListener('message', draftStatusMessageHandler);
socket.removeEventListener('message', userIdentifyMessageHandler);
};
}, [socket]);
return (
<div className="draft-participant">
<section class="panel draft-live">
<header class="panel-header d-flex justify-content-between align-items-center">
<h2 class="panel-title">Draft Live</h2>
<div class="d-flex gap-1">
<div class="phase-indicator badge bg-primary">{DraftPhaseLabel[draftState.phase]}</div>
<WebSocketStatus socket={socket} />
</div>
</header>
<div class="panel-body">
<div class="draft-live-state-container">
<DraftCountdownClock endTime={draftState.bidding_timer_end}></DraftCountdownClock>
<div class="pick-description">
{console.log("draft_state", draftState)}
<div>Round {draftState.current_pick?.round}</div>
<div>Pick {draftState.current_pick?.pick_in_round}</div>
<div>{draftState.current_pick?.overall+1} Overall</div>
</div>
</div>
<div class="current-movie card"></div>
<div class="bid-controls btn-group"></div>
<ParticipantList
currentUser={draftState.current_pick?.participant}
draftState={draftState}
draftDetails={draftDetails}
/>
</div>
</section>
<section class="panel draft-board">
<header class="panel-header">
<h2 class="panel-title">Draft Board</h2>
</header>
<div class="panel-body">
<div class="current-movie-detail card"></div>
<div class="movie-filters"></div>
<DraftMoviePool isParticipant={true} draftDetails={draftDetails} draftState={draftState}></DraftMoviePool>
</div>
</section>
<section class="panel my-team">
<header class="panel-header">
<h2 class="panel-title">My Team</h2>
</header>
<div class="panel-body">
<ul class="team-movie-list list-group">
<li class="team-movie-item list-group-item"></li>
</ul>
<div class="budget-status"></div>
</div>
</section>
<section class="panel teams">
<header class="panel-header">
<h2 class="panel-title">Teams</h2>
</header>
<div class="panel-body">
<ul class="team-list list-group">
<li class="team-item list-group-item">
<div class="team-name fw-bold"></div>
<ul class="team-movie-list list-group list-group-flush">
<li class="team-movie-item list-group-item"></li>
</ul>
</li>
</ul>
</div>
</section>
<NominateMenu socket={socket} currentUser={currentUser} draftState={draftState} draftDetails={draftDetails}></NominateMenu>
</div>
);
};

View File

@@ -1,2 +1,40 @@
import './scss/styles.scss'
console.log("Webpack HMR loaded!");
import React from "react";
import { createRoot } from "react-dom/client";
import { WebSocketProvider } from "./apps/draft/common/WebSocketContext.jsx";
import { DraftAdmin } from "./apps/draft/admin/DraftAdmin.jsx";
import { DraftParticipant} from './apps/draft/participant/DraftParticipant.jsx'
import { DraftDebug} from './apps/draft/DraftDebug.jsx'
const draftAdminRoot = document.getElementById("draft-admin-root");
const draftPartipantRoot = document.getElementById("draft-participant-root")
const draftDebugRoot = document.getElementById("draft-debug-root")
const {draftSessionId} = window; // from backend template
if (draftPartipantRoot) {
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/participant`;
createRoot(draftPartipantRoot).render(
<WebSocketProvider url={wsUrl}>
<DraftParticipant draftSessionId={draftSessionId} />
</WebSocketProvider>
);
}
if (draftAdminRoot) {
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/admin`;
createRoot(draftAdminRoot).render(
<WebSocketProvider url={wsUrl}>
<DraftAdmin draftSessionId={draftSessionId}/>
</WebSocketProvider>
);
}
if (draftDebugRoot) {
console.log('draft-debug')
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/admin`;
createRoot(draftDebugRoot).render(
<WebSocketProvider url={wsUrl}>
<DraftDebug draftSessionId={draftSessionId}/>
</WebSocketProvider>
)
}

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";
@@ -15,4 +13,141 @@
font-family: "Graphique";
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;
}
}
.message-log {
max-height: 300px;
overflow-y: scroll;
font-family: monospace;
background: #f8f9fa;
padding: 1em;
border: 1px solid #ccc;
}
.stop-light {
@extend .me-2;
// @extend .badge;
// @extend .rounded-pill;
display: inline-block;
width: 1em;
height: 1em;
border-radius: 50%;
}
.success {
@extend .bg-success;
}
.danger {
@extend .bg-danger;
}
.draft-panel {
}
.draft-phase-container {
label {
@extend .fs-3;
}
.change-phase {
button {
@extend .btn;
@extend .btn-light;
@extend .p-0;
height: 100%;
}
}
ol,
ul {
--bs-list-group-active-bg: var(--bs-primary-bg-subtle);
--bs-list-group-active-color: $dark;
@extend .list-group;
@extend .list-group-horizontal;
@extend .ms-1;
@extend .me-1;
li {
@extend .list-group-item;
@extend .p-1;
@extend .ps-2;
@extend .pe-2;
&.current-phase {
@extend .active;
}
}
}
}
.participant-list-container,
.movie-pool-container {
max-width: 575.98px;
label {
@extend .fs-3;
}
@extend .list-group;
ol,
ul {
@extend .p-0;
}
ol {
@extend .list-group-numbered;
}
li {
@extend .list-group-item;
@extend .d-flex;
@extend .justify-content-between;
@extend .align-items-center;
span {
@extend .me-auto;
@extend .ps-1;
}
}
.current-user {
&::after {
content: " *";
font-size: 1em; // adjust as needed
}
}
}
.draft-participant {
display: flex;
flex-wrap: wrap; /* allow panels to wrap */
gap: 1rem; /* space between panels */
justify-content: center; /* center the panels horizontally */
.panel {
flex: 1 1 350px; /* grow/shrink, base width */
max-width: 450px; /* never go beyond this */
min-width: 300px; /* keeps them from getting too small */
}
.panel.draft-live {
.draft-live-state-container {
@extend .d-flex;
.countdown-clock {
@extend .fs-1;
@extend .fw-bold;
@extend .col;
@extend .align-content-center;
@extend .text-center;
}
.pick-description{
@extend .col;
}
}
}
}

View File

@@ -10,6 +10,13 @@ module.exports = {
mode: "development",
module: {
rules: [
{
test: /\.(js|jsx)$/, // include .jsx files
exclude: /node_modules/,
use: {
loader: "babel-loader",
},
},
{
test: /\.scss$/,
use: [
@@ -41,6 +48,13 @@ module.exports = {
changeOrigin: true,
secure: false,
},
{
context: (pathname) => pathname.startsWith('/ws/'),
target: 'ws://localhost:8000',
ws: true, // <-- enable websocket proxying
changeOrigin: true,
secure: false,
},
],
},
ignoreWarnings: [

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python
import importlib
import inspect
import os
import sys
from enum import Enum, IntEnum, StrEnum
# Adjust these for your project
PY_MODULE = "draft.constants" # where your enums live
OUTPUT_PATH = "frontend/src/apps/draft/constants.js"
# Optionally allow running from any cwd
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.abspath(os.path.join(PROJECT_ROOT, ".."))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
def js_quote(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"')
def titleize(name: str) -> str:
# e.g., "DETERMINE_ORDER" -> "Determine Order"
return name.replace("_", " ").title()
def emit_header():
return "// AUTO-GENERATED. Do not edit by hand.\n" \
"// Run: python scripts/generate_js_constants.py\n\n"
def emit_str_enum(name: str, enum_cls) -> str:
"""
Emit a JS object for StrEnum:
export const DraftMessage = { KEY: "value", ... };
"""
lines = [f"export const {name} = {{"] # ESM export
for member in enum_cls:
lines.append(f' {member.name}: "{js_quote(member.value)}",')
lines.append("};\n")
return "\n".join(lines)
def emit_int_enum(name: str, enum_cls) -> str:
"""
Emit a JS object + labels + ordered list for IntEnum:
export const DraftPhase = { KEY: number, ... };
export const DraftPhaseLabel = { [number]: "Pretty", ... };
export const DraftPhasesOrdered = [numbers...];
"""
lines = [f"export const {name} = {{"] # ESM export
items = list(enum_cls)
# object map
for member in items:
lines.append(f" {member.name}: {int(member.value)},")
lines.append("};\n")
# label map (use .pretty_name if you added it; else derive from name or __str__)
lines.append(f"export const {name}Label = {{")
for member in items:
if hasattr(member, "pretty_name"):
label = getattr(member, "pretty_name")
else:
# fall back: __str__ if you overload it, else Title Case of name
label = str(member)
if label == f"{enum_cls.__name__}.{member.name}":
label = titleize(member.name)
lines.append(f' [{name}.{member.name}]: "{js_quote(label)}",')
lines.append("};\n")
# ordered list
ordered = sorted(items, key=lambda m: int(m.value))
ordered_vals = ", ".join(f"{name}.{m.name}" for m in ordered)
lines.append(f"export const {name}sOrdered = [{ordered_vals}];\n")
return "\n".join(lines)
def main():
mod = importlib.import_module(PY_MODULE)
out = [emit_header()]
# Pick which enums to export. You can filter here if you dont want all.
for name, obj in inspect.getmembers(mod):
ignore_classes = [Enum, IntEnum, StrEnum]
if inspect.isclass(obj) and issubclass(obj, Enum) and not obj in ignore_classes:
# Skip helper classes that arent actual Enums
if name.startswith("_"):
continue
if issubclass(obj, IntEnum):
out.append(emit_int_enum(name, obj))
else:
out.append(emit_str_enum(name, obj))
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
f.write("\n".join(out))
print(f"✅ Wrote {OUTPUT_PATH}")
if __name__ == "__main__":
main()