Compare commits

...

15 Commits

Author SHA1 Message Date
5e08fdc9a2 Add user state updates and bidding error handling in draft consumers
- Implement user state tracking and broadcasting on connect/disconnect and phase changes
- Add bid start and place rejection handling with error messages to frontend and backend
- Enhance movie serializer with TMDB integration and update relevant frontend components
2025-08-24 17:16:22 -05:00
baddca8d50 Refactor draft app with improved state management and components
* Rename WebSocket message types for better organization
* Improve state handling with dedicated methods like broadcast_state
* Restructure frontend components and remove unused code
2025-08-24 12:06:41 -05:00
b38c779772 # Refactor DraftState for improved type safety and consistency
- Replaced manual cache key handling with `DraftCache` class using properties
- Fixed connected_participants serialization by converting sets to lists
- Updated countdown clock component to accept unified state prop
2025-08-23 13:55:04 -05:00
e8bf313f53 Add live bidding UI and backend support; integrate react-bootstrap
- Added 'react-bootstrap' to frontend dependencies for improved UI components.
- Updated bid placement mechanics: backend now stores bids as a list of {user, amount}; frontend displays live bid leaderboard, including highest bid.
- Implemented bid placement form and UI in participant draft screen.
- Used React-Bootstrap Collapse for nominee menu accordion behavior.
- Expanded DraftStateManager and websocket consumers to broadcast bid updates in the new format.
- Added missing 'bids' syncing to all relevant state handling code.
- Improved styling for bidding, panel headers, and pick lists in SCSS; leveraged Bootstrap variables/utilities more extensively.
- Other minor JS, Python, and style tweaks for better stability and robustness.
2025-08-15 15:38:39 -05:00
9ddc8663a9 feat: improve draft admin UI, draft state sync, and styling
Major refactor of Draft admin and participant Websocket state sync
Use consistent state dict serialization in DraftStateManager (to_dict, dict-like access, etc.)
Always include up-to-date participants and draft status in sync payloads
Draft phase/order summary now sent as objects instead of calling .get_summary()
UI/UX updates:
Updated DraftAdmin.jsx:
Connects DraftParticipant panel for real-time participant state
Centralizes phase advance, bidding, and sync controls
Moves phase selector into a dedicated panel
Refine markup/extends in room_admin.dj.html (use block body, fix root data attribute)
Minor fixes to DraftCountdownClock.jsx to robustly handle NaN time
CSS/layout:
Refactor .draft-participant styling to .wrapper within #draft-participant-root and #draft-admin-root for better responsive layout and code clarity
Server code:
Simplify draft consumer/manager state interaction, drop unused cache keys, update order determination and phase management, and ensure DRY status object responses
Small code style and consistency cleanups
Misc:
Add debugpy launch task in code-workspace and clean workspace JSON (style/consistency)
Minor formatting and error handling improvements
2025-08-15 11:06:27 -05:00
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
68 changed files with 5657 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.

77
api/serializers.py Normal file
View File

@@ -0,0 +1,77 @@
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
from boxofficefantasy.integrations.tmdb import get_tmdb_movie_by_imdb
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):
tmdb_data = serializers.SerializerMethodField()
def get_tmdb_data(self, obj):
if hasattr(obj, 'imdb_id') and obj.imdb_id:
tmdb_movie = get_tmdb_movie_by_imdb(obj.imdb_id)
if tmdb_movie:
poster_url = None
if tmdb_movie.get('poster_path'):
poster_url = f"{tmdb_movie['poster_path']}"
return {
'id': tmdb_movie.get('id'),
'title': tmdb_movie.get('title'),
'overview': tmdb_movie.get('overview'),
'poster_url': tmdb_movie['poster_url'],
'release_date': tmdb_movie.get('release_date'),
}
return None
class Meta:
model = Movie
# fields = ("id", "imdb_id", "title", "year", "poster_url")
fields = ("id", "title", "tmdb_data")
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

@@ -7,6 +7,18 @@
"launch": {
"version": "0.2.0",
"configurations": [
{
"name": "Debug current file with debugpy",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"justMyCode": false,
"args": [],
"env": {
"PYTHONPATH": "${workspaceFolder}"
}
},
{
"name": "Run Django Server",
"type": "debugpy",
@@ -17,12 +29,22 @@
"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 +53,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 +70,12 @@
],
"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 +83,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 +165,22 @@
"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

@@ -12,13 +12,15 @@ tmdb.language = "en"
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w500"
def get_tmdb_movie_by_imdb(imdb_id):
def get_tmdb_movie_by_imdb(imdb_id, cache_poster=True):
"""
Fetch TMDb metadata by IMDb ID, using cache to avoid redundant API calls.
"""
cache_key = f"tmdb:movie:{imdb_id}"
cached = cache.get(cache_key)
if cached:
if cache_poster and not cached.get('poster_url'):
cached['poster_url'] = cache_tmdb_poster(cached['poster_path'])
return cached
results = Movie().external(external_id=imdb_id, external_source="imdb_id")
@@ -27,6 +29,8 @@ def get_tmdb_movie_by_imdb(imdb_id):
movie_data = results.movie_results[0]
cache.set(cache_key, movie_data, timeout=60 * 60 * 24) # 1 day
if cache_poster:
movie_data['poster_url'] = cache_tmdb_poster(movie_data['poster_path'])
return movie_data

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,23 +2,25 @@
<!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>
<script src="https://cdn.datatables.net/2.3.2/js/dataTables.js"></script>
<script src="https://cdn.datatables.net/2.3.2/js/dataTables.bootstrap5.js"></script>
</head>
<body>
<nav class="navbar justify-content-start">
<body class="d-flex flex-column vh-100">
<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,28 +28,42 @@
</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>
{% 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 %}
</main>
{% endblock body %}
<footer class="text-muted text-center mt-5">
<small>&copy; Sack Lunch</small>
</footer>

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,12 +21,12 @@ 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
ALLOWED_HOSTS = ["localhost"]
ALLOWED_HOSTS = ["localhost", "kif.local"]
# TMDB API KEY
TMDB_API_KEY = os.environ.get("TMDB_API_KEY")
@@ -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

75
data/cache_concept.py Normal file
View File

@@ -0,0 +1,75 @@
import pickle
import os
from typing import Any
from pathlib import Path
import json
DEFAULT_PATH = Path("/Users/asc/Developer/boxofficefantasy/main/data/draft_cache.json")
class CachedDraftState:
participants: list
phase: str # Replace with Enum if needed
draft_order: list = []
draft_index: int
current_movie: str
bids: list
def __init__(self, cache_file: str = "draft_cache.json"):
super().__setattr__("_cache_file", cache_file)
super().__setattr__("_cache", self._load_cache())
def _load_cache(self) -> dict:
if os.path.exists(self._cache_file):
try:
with open(self._cache_file, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print(f"Failed to load cache: {e}")
return {}
return {}
def _save_cache(self):
try:
with open(self._cache_file, "w", encoding="utf-8") as f:
json.dump(self._cache, f, indent=2)
except Exception as e:
print(f"Failed to save cache: {e}")
def __getattr__(self, name: str) -> Any:
if name in self.__class__.__annotations__:
print(f"[GET] {name} -> {self._cache.get(name)}")
return self._cache.get(name, None)
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
def __setattr__(self, name: str, value: Any):
if name in self.__class__.__annotations__:
print(f"[SET] {name} = {value}")
self._cache[name] = value
self._save_cache()
else:
super().__setattr__(name, value)
if __name__ == "__main__":
# Clean start for testing
if os.path.exists("draft_cache.pkl"):
os.remove("draft_cache.pkl")
print("\n--- First Run: Setting Attributes ---")
state = CachedDraftState()
state.participants = ["Alice", "Bob"]
state.phase = "nominating"
# state.draft_order = ["Bob", "Alice"]
state.draft_index = 0
state.current_movie = "The Matrix"
state.bids = [{"Alice": 10}, {"Bob": 12}]
print("\n--- Second Run: Reading from Cache ---")
state2 = CachedDraftState()
print("participants:", state2.participants)
print("phase:", state2.phase)
print("draft_order:", state2.draft_order)
print("draft_index:", state2.draft_index)
print("current_movie:", state2.current_movie)
print("bids:", state2.bids)
pass

BIN
data/draft_cache.json Normal file

Binary file not shown.

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'

73
draft/constants.py Normal file
View File

@@ -0,0 +1,73 @@
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
USER_STATE_INFORM = "user.state.inform"
# 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
DRAFT_STATUS_REQUEST = "draft.status.request" # client -> server
DRAFT_STATUS_INFORM = "draft.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_START_REJECT = "bid.start.reject" # server -> client (movie, ends_at)
BID_PLACE_REQUEST = "bid.place.request" # client -> server (amount)
BID_PLACE_REJECT = "bid.place.reject" # server -> client (high bid)
BID_PLACE_CONFIRM = "bid.place.confirm" # server -> client (high bid)
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"

411
draft/consumers.py Normal file
View File

@@ -0,0 +1,411 @@
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.contrib.auth.models import User
from draft.constants import (
DraftMessage,
DraftPhase,
DraftGroupChannelNames,
)
from draft.state import DraftStateManager, DraftStateException
from typing import Any
import logging
logger = logging.getLogger(__name__) # __name__ = module path
class DraftConsumerBase(AsyncJsonWebsocketConsumer):
group_names: DraftGroupChannelNames
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.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()
self.draft_state.connect_participant(self.user.username)
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.USER_IDENTIFICATION_INFORM,
"payload": {"user": self.user.username},
},
)
await self.broadcast_state()
async def should_accept_user(self) -> bool:
return self.user.is_authenticated
async def receive_json(self, content):
logger.info(f"receiving message {content}")
event_type = content.get("type")
if event_type == DraftMessage.DRAFT_STATUS_REQUEST:
await self.send_json(
{
"type": DraftMessage.DRAFT_STATUS_INFORM,
"payload": self.get_draft_status(),
}
)
# --- Convenience helpers ---
async def send_draft_state(self):
"""Send the current draft state only to this client."""
await self.channel_layer.send(
self.channel_name,
{
"type": "direct.message",
"subtype": DraftMessage.USER_STATE_INFORM,
"payload": self.draft_state.user_state(self.user),
}
)
await self.channel_layer.send(
self.channel_name,
{
"type": "direct.message",
"subtype": DraftMessage.DRAFT_STATUS_INFORM,
"payload": self.draft_state.to_dict(),
},
)
async def broadcast_state(self):
"""Broadcast current draft state to all in session group."""
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.USER_STATE_INFORM,
"payload": [self.draft_state.user_state(user) for user in self.draft_participants],
}
)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.DRAFT_STATUS_INFORM,
"payload": self.draft_state.to_dict(),
},
)
# 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
# === 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",
)
.prefetch_related("participants")
.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)
def should_accept_user(self):
return super().should_accept_user() and self.user.is_staff
async def receive_json(self, content):
await super().receive_json(content)
logger.info(f"Receive message {content}")
event_type = content.get("type")
match event_type:
case DraftMessage.PHASE_CHANGE_REQUEST:
destination = content.get('destination')
match destination:
case DraftPhase.DETERMINE_ORDER:
await self.set_draft_phase(DraftPhase.DETERMINE_ORDER)
self.draft_state.determine_draft_order()
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.ORDER_DETERMINE_CONFIRM,
"payload": {"draft_order": self.draft_state.draft_order},
},
)
await self.broadcast_state()
case DraftPhase.NOMINATING:
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},
},
)
await self.broadcast_state()
case DraftPhase.BIDDING:
await self.set_draft_phase(DraftPhase.BIDDING)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.PHASE_CHANGE_CONFIRM,
"payload": {"phase": self.draft_state.phase},
},
)
await self.broadcast_state()
case 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": {"draft_index": self.draft_state.draft_index},
},
)
await self.broadcast_state()
case 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["current_movie"],
"nominating_participant": user,
},
},
)
await self.broadcast_state()
case DraftMessage.BID_START_REQUEST:
try:
self.draft_state.start_bidding()
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.BID_START_INFORM,
"payload": {**self.draft_state},
},
)
await self.broadcast_state()
except DraftStateException as e:
await self.channel_layer.send(
self.channel_name, {
"type": "direct.message",
"subtype": DraftMessage.BID_START_REJECT,
"payload": {'message': str(e)}
}
)
# === Draft logic ===
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": list(
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": list(
self.draft_state.connected_participants
),
},
},
)
await self.broadcast_state()
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("movie_id"),
"user": content.get("payload", {}).get("user"),
},
},
)
if event_type == DraftMessage.BID_PLACE_REQUEST:
bid_amount = content.get("payload", {}).get("bid_amount")
try:
self.draft_state.place_bid(self.user, bid_amount)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.BID_PLACE_CONFIRM,
"payload": {'user': self.user.username, 'bid': bid_amount},
},
)
except DraftStateException as e:
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.BID_PLACE_REJECT,
"payload": {'user': self.user.username, 'bid': bid_amount, 'error':str(e)},
},
)
await self.broadcast_state()
# === Broadcast handlers ===
async def broadcast_participant(self, event):
await self._dispatch_broadcast(event)
# === Draft ===
# === 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()),
]

271
draft/state.py Normal file
View File

@@ -0,0 +1,271 @@
from django.core.cache import cache, BaseCache
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, DraftSessionSettings
import time
from dataclasses import dataclass
from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple
import random
class DraftStateException(Exception):
"""Raised when an action is not allowed due to the current draft state or phase."""
pass
class DraftCache:
phase: str
draft_order: str
draft_index: str
current_movie: str
bids: str
bid_timer_start: str
bid_timer_end: str
connected_participants: str
_cached_properties = {
"participants",
"connected_participants",
"phase",
"draft_order",
"draft_index",
"current_movie",
"bids",
"bid_timer_start",
"bid_timer_end",
}
def __init__(self, draft_id: str, cache: BaseCache = cache):
super().__setattr__("_cache", self._load_cache(cache))
super().__setattr__("_prefix", f"draft:{draft_id}:")
def _load_cache(self, cache) -> BaseCache:
return cache
def _save_cache(self) -> None:
# Django cache saves itself
return
def __getattr__(self, name: str) -> Any:
if name == "_prefix": return super().__getattribute__('_prefix')
if name in self._cached_properties:
return self._cache.get(self._prefix+name, None)
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
def __setattr__(self, name: str, value: Any):
if name in self._cached_properties:
self._cache.set(self._prefix+name, value)
self._save_cache()
else:
super().__setattr__(name, value)
def __delattr__(self, name):
if name in self._cached_properties:
self._cache.delete(name)
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
class DraftStateManager:
_initial_phase: DraftPhase = DraftPhase.WAITING.value
def __init__(self, session: DraftSession):
self.session_id: str = session.hashid
self.cache: DraftCache = DraftCache(self.session_id, cache)
self.settings: DraftSessionSettings = session.settings
self._participants = list(session.participants.all())
# === Phase Management ===
@property
def phase(self) -> str:
return self.cache.phase or self._initial_phase
@phase.setter
def phase(self, new_phase: DraftPhase) -> None:
self.cache.phase = new_phase
# === Connected Users ===
@property
def connected_participants(self):
return set(json.loads(self.cache.connected_participants or "[]"))
def connect_participant(self, username: str):
connected_participants = self.connected_participants
connected_participants.add(username)
self.cache.connected_participants = json.dumps(list(connected_participants))
return connected_participants
def disconnect_participant(self, username: str):
connected_participants = self.connected_participants
connected_participants.discard(username)
self.cache.connected_participants = json.dumps(list(connected_participants))
return connected_participants
# === Draft Order ===
@property
def draft_order(self):
return json.loads(self.cache.draft_order or "[]")
@draft_order.setter
def draft_order(self, draft_order: list[str]):
if not isinstance(draft_order, list):
return
self.cache.draft_order = json.dumps(draft_order)
def determine_draft_order(self) -> List[User]:
self.phase = DraftPhase.DETERMINE_ORDER
self.draft_index = 0
draft_order = random.sample(
list(self._participants), len(self._participants)
)
self.draft_order = [user.username for user in draft_order]
return self.draft_order
@property
def draft_index(self):
draft_index = self.cache.draft_index
if not draft_index:
draft_index = 0
self.cache.draft_index = draft_index
return self.cache.draft_index
@draft_index.setter
def draft_index(self, draft_index: int):
self.cache.draft_index = 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.current_movie = movie_id
self.cache.bids = []
def place_bid(self, user: User, amount: int|str):
if isinstance(amount, str):
amount = int(amount)
bids = self.get_bids()
user_state = self.user_state(user)
timestamp = int(time.time() * 1000)
if not user_state['can_bid']:
raise DraftStateException('Cannot bid')
if not user_state['remaining_budget'] > amount:
raise DraftStateException('No Budget Remaining')
if not self.get_timer_end() or not timestamp < self.get_timer_end() * 1000:
raise DraftStateException("Timer Error")
bids.append({"user":user.username, "amount":amount, 'timestamp': timestamp})
self.cache.bids = json.dumps(bids)
def get_bids(self) -> dict:
return json.loads(self.cache.bids or "[]")
def current_movie(self) -> Movie | None:
movie_id = self.cache.current_movie
return movie_id if movie_id else None
def start_bidding(self):
if not self.phase == DraftPhase.BIDDING:
raise DraftStateException('Not the right phase for that')
if not self.current_movie():
raise DraftStateException('No movie nominated')
seconds = self.settings.bidding_duration
start_time = time.time()
end_time = start_time + seconds
self.cache.bid_timer_end = end_time
self.cache.bid_timer_start = start_time
def get_timer_end(self) -> str | None:
return self.cache.bid_timer_end
def get_timer_start(self) -> str | None:
return self.cache.bid_timer_start
# === Sync Snapshot ===
def to_dict(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": list(self.connected_participants),
"current_movie": self.cache.current_movie,
"awards": [],
"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 []
}
def user_state(self, user: User) -> dict:
picks = self.next_picks(include_current=True)
return {
"is_admin": user.is_staff,
"user": user.username,
"can_bid": self.phase == DraftPhase.BIDDING,
"can_nominate": self.phase == DraftPhase.NOMINATING and picks[0].get('participant') == user.username,
"movies":[],
"remaining_budget":100,
}
# def __dict__(self):
# return self.get_summary()
def keys(self):
# return an iterable of keys
return self.to_dict().keys()
def __getitem__(self, key):
return self.to_dict()[key]
def __iter__(self):
# used for `dict(self.draft_state)` and iteration
return iter(self.to_dict())
def __len__(self):
return len(self.to_dict())
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,13 @@
{% extends "base.dj.html" %}
{% block body %}
{% load static %}
<script>
window.draftSessionId = "{{ draft_id_hashed }}"
window.isAdmin = "{{user.is_staff}}"
console.log("{{user}}")
</script>
{% if user.is_staff %}
<div id="draft-admin-bar-root" data-draft-id="{{ draft_id_hashed }}">You are admin!</div>
{% endif %}
<div id="draft-participant-root" data-draft-id="{{ draft_id_hashed }}"></div>
{% endblock body %}

View File

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

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.

12
draft/urls.py Normal file
View File

@@ -0,0 +1,12 @@
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>/debug", views.draft_room_debug, 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"),
]

27
draft/views.py Normal file
View File

@@ -0,0 +1,27 @@
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, draft_session_id_hashed=None):
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
context = {
"draft_id_hashed": draft_session.hashid,
"league": league,
"season": season,
}
return render(request, "draft/room.dj.html", context)
def draft_room_debug(request, draft_session_id_hashed=None):
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)
return render(request, "draft/room_debug.dj.html", {"draft_id_hashed": draft_session.hashid,})

21
draft_cache.json Normal file
View File

@@ -0,0 +1,21 @@
{
"participants": [
"Alice",
"Bob"
],
"phase": "nominating",
"draft_order": [
"Bob",
"Alice"
],
"draft_index": 0,
"current_movie": "The Matrix",
"bids": [
{
"Alice": 10
},
{
"Bob": 12
}
]
}

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,22 @@
"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-bootstrap": "^2.10.10",
"react-dom": "^18.3.1"
}
}

View File

@@ -0,0 +1,152 @@
import React, { useEffect, useState } from "react";
import { useWebSocket } from "./components/WebSocketContext.jsx";
import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from './constants.js';
import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "./utils.js"
import { jsxs } from "react/jsx-runtime";
const DraftPhaseDisplay = ({ draftPhase, nextPhaseHandler, prevPhaseHandler }) => {
return (
<div className="draft-phase-container">
<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) => {
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]
}
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 id="draft-admin-bar">
<div>
<button onClick={() => handleRequestDraftSummary()} className="btn btn-small btn-light mx-1">
<i className="bi bi-arrow-clockwise"></i>
</button>
<button onClick={handleAdvanceDraft} className="btn btn-primary mx-1">Advance Index</button>
<button onClick={handleStartBidding} className="btn btn-primary mx-1">Start Bidding</button>
</div>
<div>
<DraftPhaseDisplay draftPhase={draftState.phase} nextPhaseHandler={() => { handlePhaseChange('next') }} prevPhaseHandler={() => { handlePhaseChange('previous') }}></DraftPhaseDisplay>
</div>
</div>
);
};

View File

@@ -0,0 +1,169 @@
// DraftAdmin.jsx
import React, { useEffect, useState, useRef } from "react";
import { useWebSocket } from "./components/WebSocketContext.jsx";
import { WebSocketStatus } from "./components/WebSocketStatus.jsx";
import { DraftMessage, DraftPhaseLabel, DraftPhase } from './constants.js';
import { fetchDraftDetails, isEmptyObject } from "./utils.js";
import { DraftMoviePool } from "./components/DraftMoviePool.jsx";
import { ParticipantList } from "./components/ParticipantList.jsx";
import { DraftCountdownClock } from "./components/DraftCountdownClock.jsx"
import { handleDraftStatusMessages, handleUserStatusMessages, handleUserIdentifyMessages } from './utils.js'
// import { Collapse } from 'bootstrap/dist/js/bootstrap.bundle.min.js';
import { Collapse } from "react-bootstrap";
export const DraftParticipant = ({ draftSessionId }) => {
const socket = useWebSocket();
const [draftState, setDraftState] = useState({});
const [userStatus, setUserState] = useState([]);
const [draftDetails, setDraftDetails] = useState({});
const [currentUser, setCurrentUser] = useState(null);
const [movies, setMovies] = useState([]);
useEffect(() => {
fetchDraftDetails(draftSessionId)
.then((data) => {
console.log("Fetched draft data", data)
setMovies(data.movies)
setDraftDetails(data)
})
}, [draftSessionId])
useEffect(() => {
if (!socket) return;
const draftStatusMessageHandler = (event) => handleDraftStatusMessages(event, setDraftState)
const userIdentifyMessageHandler = (event) => handleUserIdentifyMessages(event, setCurrentUser)
const userStatusMessageHandler = (event) => handleUserStatusMessages(event, setUserState)
socket.addEventListener('message', draftStatusMessageHandler);
socket.addEventListener('message', userIdentifyMessageHandler);
socket.addEventListener('message', userStatusMessageHandler);
return () => {
socket.removeEventListener('message', draftStatusMessageHandler);
socket.removeEventListener('message', userIdentifyMessageHandler);
socket.removeEventListener('message', userStatusMessageHandler);
};
}, [socket]);
function submitBidRequest(event) {
event.preventDefault()
const form = event.target
const formData = new FormData(form)
socket.send(JSON.stringify({
type: DraftMessage.BID_PLACE_REQUEST,
payload: {
bid_amount: formData.get('bidAmount'),
user: currentUser
}
}))
}
const currentUserStatus = userStatus.find(u => u.user == currentUser)
const currentMovie = movies.find(i => draftState.current_movie == i.id)
return (
<div className={`wrapper`}>
<section id="draft-live">
<div className="panel">
<header className="panel-header">
<div className="panel-title"><span>Draft Live</span></div>
<div className="d-flex gap-1">
<div className="phase-indicator badge bg-primary">{DraftPhaseLabel[draftState.phase]}</div>
<WebSocketStatus socket={socket} />
</div>
</header>
<div className="panel-body">
<div>
<div id="draft-clock">
<DraftCountdownClock draftState={draftState}></DraftCountdownClock>
<div className="pick-description">
<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 className="bid-controls btn-group d-flex flex-column">
<Collapse in={draftState.phase == DraftPhase.BIDDING}>
<div>
<div>
<div className="row g-0 border rounded-2 m-2">
<div className="col-3">
<img className="img-fluid flex-fill" src={currentMovie?.tmdb_data?.poster_url}/>
</div>
<div className="col d-flex justify-content-center align-items-center">
<span className="fw-bold">{currentMovie?.title}</span>
</div>
</div>
</div>
<div>
<div className="lh-sm text-center border p-0 border-bottom"><span>Bids</span></div>
<div className="bids-container">
<ol className="list-group list-group-flush">
{draftState.bids?.reverse().map((b, idx) => (
<li key={idx} className="list-group-item p-0">
<div className="row g-0">
<div className="col-8 col-xl-9 col-xxl-10"><span>{b.user}</span></div>
<div className="col"><span>{b.amount}</span></div>
</div>
</li>
))}
</ol>
</div>
</div>
<div className="p-1">
<form id="bid" onSubmit={submitBidRequest}>
<div className="input-group input-group-sm">
<span className="input-group-text">Bid</span>
<input className="form-control" type="number" id="bidAmount" name="bidAmount" />
<button className="btn btn-primary" type="submit">Submit</button>
</div>
</form>
</div>
</div>
</Collapse>
</div>
</div>
</div>
</div>
</section>
<section className="panel" id="draft-slate">
<header className="panel-header">
<div className="panel-title"><span>Films</span></div>
</header>
<div className="panel-body">
<div className="movie-filters"></div>
<DraftMoviePool currentUserStatus={currentUserStatus} currentUser={currentUser} socket={socket} isParticipant={true} draftDetails={draftDetails} draftState={draftState}></DraftMoviePool>
</div>
</section>
<section className="panel my-team">
<header className="panel-header">
<div className="panel-title"><span>My Team</span></div>
</header>
<div className="panel-body">
<ul className="team-movie-list list-group">
<li className="team-movie-item list-group-item"></li>
</ul>
<div className="budget-status"></div>
</div>
</section>
<section className="panel teams">
<header className="panel-header">
<div className="panel-title"><span>Teams</span></div>
</header>
<div className="panel-body">
<ParticipantList currentUser={currentUser} className="team-list" draftDetails={draftDetails} draftState={draftState} isAdmin={isAdmin}></ParticipantList>
</div>
</section>
</div>
);
};

View File

@@ -0,0 +1,45 @@
import React, { useEffect, useState } from "react";;
import { useWebSocket } from "./components/WebSocketContext.jsx";
import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from './constants.js';
import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "./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,36 @@
import React, { useEffect, useState } from "react";
export function DraftCountdownClock({ draftState }) {
const { bidding_timer_end, onFinish } = draftState;
const getTimeLeft = (et) => Math.max(0, Math.floor(et - Date.now() / 1000));
const [timeLeft, setTimeLeft] = useState(getTimeLeft(bidding_timer_end));
useEffect(() => {
setTimeLeft(getTimeLeft(bidding_timer_end)); // reset timer when bidding_timer_end changes
if (getTimeLeft(bidding_timer_end) <= 0) {
if (onFinish) onFinish();
return;
}
const timer = setInterval(() => {
const t = getTimeLeft(bidding_timer_end);
setTimeLeft(t);
if (t <= 0 && onFinish) onFinish();
}, 100);
return () => clearInterval(timer);
}, [bidding_timer_end, onFinish]);
const minutes = Math.floor(timeLeft / 60);
const secs = timeLeft % 60;
const pad = n => String(n).padStart(2, "0");
return (
<div className="countdown-clock">
<span>
{!isNaN(minutes) && !isNaN(secs) ? `${minutes}:${pad(secs)}` : "0:00"}
</span>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import React from "react";
import { isEmptyObject } from "../utils";
import { DraftMessage } from "../constants";
const NominateForm = ({ socket, currentUser, movie, className}) => {
const requestNomination = (event) => {
event.preventDefault()
const formData = new FormData(event.target)
socket.send(JSON.stringify({
type: DraftMessage.NOMINATION_SUBMIT_REQUEST,
payload: {
movie_id: formData.get('movie_id'),
user: currentUser
}
}))
}
return (
<form onSubmit={requestNomination} className={className}>
<input type="hidden" name="movie_id" value={movie.id} />
<button type="submit" className="btn btn-primary nominate">
Nominate
</button>
</form>
);
}
export const DraftMoviePool = ({ socket, currentUser, currentUserStatus, draftDetails, draftState, isNominating = false }) => {
if (isEmptyObject(draftDetails)) { return }
const { movies } = draftDetails
const { current_movie } = draftState
const can_nominate = currentUserStatus?.can_nominate
const is_admin = currentUserStatus?.is_admin
const nominateHandler = (event) => {
event.preventDefault()
const formData = new FormData(event.target)
const movieId = formData.get('movie_id');
socket.send(JSON.stringify({
type: DraftMessage.NOMINATION_SUBMIT_REQUEST,
payload: {
movie_id: movieId,
user: currentUser
}
}))
}
return (
<div className="movie-pool-container">
<table className="table">
<thead>
<tr>
<th>Poster</th>
<th>Title</th>
<th>Release Date</th>
</tr>
</thead>
<tbody>
{movies.map(m => (
<tr key={m.id} className={`${current_movie == m.id ? "current-movie fw-bold" : null}`}>
<td><img src={m.tmdb_data.poster_url}></img></td>
<td>
<div>
<a href={`/api/movie/${m.id}/detail`} className="fs-5">
{m.title}
</a>
</div>
<div>
<a href={`https://www.themoviedb.org/movie/${m.tmdb_data.id}`}>
TMDB
</a>
</div>
<div>
{can_nominate || is_admin ? (
<NominateForm socket={socket} currentUser={currentUser} movie={m} className={!can_nominate && is_admin ? 'admin-override' : ''}></NominateForm>
) : null}
</div>
</td>
<td>{m.tmdb_data.release_date}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,38 @@
import React from "react";
import { fetchDraftDetails, isEmptyObject } from "../utils.js"
import Badge from 'react-bootstrap/Badge';
export const ParticipantList = ({ isAdmin, draftState, draftDetails, currentUser }) => {
if (isEmptyObject(draftState) || isEmptyObject(draftDetails)) { 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 (
<ListTag className="participant-list">
{listItems.map((p, idx) => (
<li className="team-item" key={idx}>
<div className={`team-name ${p.username == currentUser ? "current-user" : ""}`}>
<div>
{p.full_name}
{p.username == draftState.current_pick?.participant ? (<Badge bg="warning" className="ms-1">Current Pick</Badge>) : null}
</div>
<ul className="team-movie-list list-group list-group-flush">
<li className="team-movie-item list-group-item"></li>
</ul>
</div>
{isAdmin === "True" ? (
<div
className={
`ms-2 stop-light ${connected_participants.includes(p?.username) ? "success" : "danger"}`
}
></div>
) : null}
</li>
))}
</ListTag>
)
}

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,37 @@
import React, { useEffect, useState } from "react";
export const WebSocketStatus = ({ socket }) => {
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
console.log('socket changed', socket)
if (!socket) return;
// Set initial state according to readyState
setIsConnected(socket.readyState === 1);
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,53 @@
// 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",
USER_STATE_INFORM: "user.state.inform",
PHASE_CHANGE_INFORM: "phase.change.inform",
PHASE_CHANGE_REQUEST: "phase.change.request",
PHASE_CHANGE_CONFIRM: "phase.change.confirm",
DRAFT_STATUS_REQUEST: "draft.status.request",
DRAFT_STATUS_INFORM: "draft.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_START_REJECT: "bid.start.reject",
BID_PLACE_REQUEST: "bid.place.request",
BID_PLACE_REJECT: "bid.place.reject",
BID_PLACE_CONFIRM: "bid.place.confirm",
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,65 @@
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;
if (!payload) return;
if (type == DraftMessage.DRAFT_STATUS_INFORM) {
setDraftState(payload);
}
};
export const handleUserIdentifyMessages = (event, setUser) => {
const message = JSON.parse(event.data);
const { type, payload } = message;
if (type == DraftMessage.USER_IDENTIFICATION_INFORM) {
const { user } = payload;
setUser(user);
}
};
export const handleUserStatusMessages = (event, setUserStatus) => {
const message = JSON.parse(event.data);
const { type, payload } = message;
if (type == DraftMessage.USER_STATE_INFORM) {
setUserStatus(payload);
}
};

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/components/WebSocketContext.jsx";
import { DraftAdmin } from "./apps/draft/DraftAdminBar.jsx";
import { DraftParticipant} from './apps/draft/DraftDashboard.jsx'
import { DraftDebug} from './apps/draft/DraftDebug.jsx'
const draftAdminBarRoot = document.getElementById("draft-admin-bar-root");
const draftPartipantRoot = document.getElementById("draft-participant-root")
const draftDebugRoot = document.getElementById("draft-debug-root")
const {draftSessionId, isAdmin} = 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} className={`${isAdmin ? 'admin':''}`}/>
</WebSocketProvider>
);
}
if (draftAdminBarRoot) {
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/admin`;
createRoot(draftAdminBarRoot).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,11 @@
@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=League+Gothic&family=Oswald:wght@200..700&display=swap");
// Import only functions & variables
@import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
.navbar{
.navbar {
// background-color: #582f0e;
@extend .border-bottom;
// font-family: "Bebas Neue";
@@ -15,4 +16,248 @@
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;
}
}
}
}
ol.participant-list {
@extend .list-group-numbered;
}
ol.participant-list,
ul.participant-list {
@extend .list-group;
li {
@extend .list-group-item;
@extend .d-flex;
@extend .justify-content-between;
@extend .align-items-center;
.team-name {
@extend .flex-grow-1;
@extend .ps-2;
}
.team-movie-list {
li {
@extend .p-0;
}
}
}
.current-user {
@extend .fw-bold;
&::after {
// content: " *";
font-size: 1em; // adjust as needed
}
}
}
.movie-pool-container {
img {
height: 128px;
}
a {
@extend .text-decoration-none;
@extend .text-reset;
}
thead {
display: block;
}
tbody {
display: block;
// height: 200px; /* or any desired height */
overflow-y: auto;
}
th,
td {
width: 150px; /* Set consistent widths to align columns */
box-sizing: border-box;
}
}
#draft-admin-bar {
@extend .d-flex;
@extend .flex-column;
@extend .border-top;
@extend .border-bottom;
@extend .gap-2;
@extend .p-2;
@extend .shadow-sm;
div {
@extend .d-flex;
@extend .justify-content-center;
}
}
.admin-override {
button {
@extend .btn-warning;
}
}
#draft-participant-root {
@extend .flex-grow-1;
.wrapper:first-child {
@extend .p-2;
display: flex;
flex-wrap: wrap; /* allow panels to wrap */
gap: 1rem; /* space between panels */
justify-content: center; /* center the panels horizontally */
section {
max-width: 450px; /* never go beyond this */
min-width: 300px; /* keeps them from getting too small */
flex: 1 1 350px; /* grow/shrink, base width */
}
.panel {
@extend .border;
@extend .shadow-sm;
@extend .rounded-2;
header.panel-header {
@extend .p-1;
@extend .text-uppercase;
@extend .align-items-center;
@extend .border-bottom;
@extend .border-2;
@extend .border-secondary-subtle;
// background-color: $blue-100;
@extend .bg-dark;
@extend .bg-gradient;
@extend .text-light;
@extend .rounded-top-2;
.panel-title {
@extend .ms-2;
@extend .fw-bold;
@extend .fs-5;
}
}
}
.bids-container {
overflow: scroll;
height: 85px;
}
#draft-live {
header.panel-header {
@extend .d-flex;
@extend .justify-content-between;
}
#draft-clock {
@extend .row;
@extend .g-0;
// background-color: $green-100;
@extend .text-light;
@extend .text-bg-dark;
@extend .lh-1;
.countdown-clock {
font-family: "League Gothic";
font-size: $font-size-base * 5;
@extend .fw-bolder;
@extend .col;
@extend .align-content-center;
@extend .text-center;
}
.pick-description {
@extend .col;
@extend .align-content-center;
}
}
div:has(.pick-list),
div:has(.bid-list) {
ul {
@extend .list-group;
}
li {
@extend .list-group-item;
}
}
.bid-status {
min-height: 50px;
}
.bid-controls {
button {
@extend .btn;
@extend .btn-primary;
}
input {
@extend .form-control;
}
}
}
}
}

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: [

6
package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"dependencies": {
"bootstrap": "^5.3.7",
"react-bootstrap": "^2.10.10"
}
}

103
scripts/generate_js_constants.py Executable file
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()