Compare commits
10 Commits
55c03bcafb
...
71f0f01abc
| Author | SHA1 | Date | |
|---|---|---|---|
|
71f0f01abc
|
|||
|
cd4d974fce
|
|||
|
b08a345563
|
|||
|
28c98afc32
|
|||
|
24700071ed
|
|||
|
9b6b3391e6
|
|||
|
c9ce7a36d0
|
|||
|
1a7a6a2d50
|
|||
|
f25a69cf78
|
|||
|
c543c98bf3
|
0
api/__init__.py
Normal file
0
api/__init__.py
Normal file
3
api/admin.py
Normal file
3
api/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
api/apps.py
Normal file
6
api/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'api'
|
||||
0
api/migrations/__init__.py
Normal file
0
api/migrations/__init__.py
Normal file
3
api/models.py
Normal file
3
api/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
59
api/serializers.py
Normal file
59
api/serializers.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
from boxofficefantasy.models import Movie, Season
|
||||
from draft.models import DraftSession, DraftSessionSettings, DraftPick
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
full_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ("username", "first_name", "last_name", "email", "full_name")
|
||||
|
||||
def get_full_name(self, obj):
|
||||
return f"{obj.first_name} {obj.last_name}".strip()
|
||||
|
||||
class MovieSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Movie
|
||||
# fields = ("id", "imdb_id", "title", "year", "poster_url")
|
||||
fields = ("id", "title")
|
||||
|
||||
class DraftSessionSettingsSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = DraftSessionSettings
|
||||
fields = ("starting_budget",) # add any others you have
|
||||
|
||||
|
||||
class DraftPickSerializer(serializers.ModelSerializer):
|
||||
user = UserSerializer(read_only=True)
|
||||
movie = MovieSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DraftPick
|
||||
fields = ("id", "movie", "winner", "bid_amount")
|
||||
|
||||
class DraftSessionSerializer(serializers.ModelSerializer):
|
||||
participants = UserSerializer(many=True, read_only=True)
|
||||
movies = MovieSerializer(many=True, read_only=True)
|
||||
settings = DraftSessionSettingsSerializer(read_only=True)
|
||||
draft_picks = DraftPickSerializer(many=True, read_only=True)
|
||||
|
||||
def hashid(self, obj):
|
||||
return f"{obj.hashid}".strip()
|
||||
|
||||
class Meta:
|
||||
model = DraftSession
|
||||
# include whatever else you want (phase, season info, hashed_id, etc.)
|
||||
fields = (
|
||||
"id",
|
||||
"hashid",
|
||||
"season", # will use __str__ unless you customize
|
||||
"participants",
|
||||
"movies",
|
||||
"settings",
|
||||
"draft_picks",
|
||||
# optionally include server time for client clock sync
|
||||
)
|
||||
3
api/tests.py
Normal file
3
api/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
18
api/urls.py
Normal file
18
api/urls.py
Normal 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
93
api/views.py
Normal 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
|
||||
})
|
||||
@@ -17,12 +17,25 @@
|
||||
"console": "integratedTerminal",
|
||||
"envFile": "${workspaceFolder}/.env"
|
||||
},
|
||||
{
|
||||
"name": "Run Uvicorn Django Server",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "uvicorn",
|
||||
"args": ["boxofficefantasy_project.asgi:application", "--reload",],
|
||||
"django": true,
|
||||
"console": "integratedTerminal",
|
||||
"envFile": "${workspaceFolder}/.env"
|
||||
},
|
||||
{
|
||||
"name": "Start Webpack Dev Server",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "npm",
|
||||
"args": ["run", "dev", "--config", "${workspaceFolder}/frontend/webpack.config.js"],
|
||||
"runtimeExecutable": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/frontend",
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen"
|
||||
@@ -31,7 +44,7 @@
|
||||
"name": "Launch Chrome",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://127.0.0.1:8000", // adjust based on your local server
|
||||
"url": "http://localhost:3000", // adjust based on your local server
|
||||
"webRoot": "${workspaceFolder}",
|
||||
"sourceMaps": true,
|
||||
"trace": true
|
||||
@@ -48,8 +61,8 @@
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Django + Chrome",
|
||||
"configurations": ["Run Django Server", "Launch Chrome"],
|
||||
"name": "Django + Chrome + Webpack",
|
||||
"configurations": ["Run Django Server", "Launch Chrome", "Start Webpack Dev Server"],
|
||||
"type": "compound"
|
||||
}
|
||||
]
|
||||
@@ -57,6 +70,26 @@
|
||||
"tasks": {
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Start Redis",
|
||||
"type": "process",
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"--rm",
|
||||
"--name",
|
||||
"redis-boxofficefantasy-dev",
|
||||
"-p",
|
||||
"6379:6379",
|
||||
"redis"
|
||||
],
|
||||
"isBackground": true,
|
||||
"problemMatcher": [],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "🗑️ Delete all Movies",
|
||||
"type": "shell",
|
||||
@@ -119,19 +152,24 @@
|
||||
"editor.defaultFormatter": "ms-python.black-formatter"
|
||||
},
|
||||
"[django-html]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.quickSuggestions": {
|
||||
"other": true,
|
||||
"comments": true,
|
||||
"strings": true
|
||||
},
|
||||
"editor.defaultFormatter": "monosans.djlint",
|
||||
},
|
||||
"emmet.includeLanguages": {
|
||||
"django-html": "html"
|
||||
},
|
||||
"files.associations": {
|
||||
"*.dj.html": "django-html"
|
||||
},
|
||||
"html.autoClosingTags": true,
|
||||
"emmet.includeLanguages": {
|
||||
"django-html": "html"
|
||||
}
|
||||
"files.exclude": {
|
||||
"**/__pycache__":true,
|
||||
".venv":false
|
||||
},
|
||||
"auto-close-tag.activationOnLanguage": [
|
||||
"django-html"
|
||||
],
|
||||
"terminal.integrated.env.osx": {
|
||||
"VSCODE_HISTFILE":"${workspaceFolder}/.venv/.term_history"
|
||||
},
|
||||
// "html.autoClosingTags": true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
1
boxofficefantasy/integrations/boxofficemojo.py
Normal file
1
boxofficefantasy/integrations/boxofficemojo.py
Normal file
@@ -0,0 +1 @@
|
||||
import pymojo as boxofficemojo
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -2,15 +2,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{% block title %}My Site{% endblock %}</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.datatables.net/2.3.2/css/dataTables.bootstrap5.css"
|
||||
/>
|
||||
<title>
|
||||
{% block title %}My Site{% endblock %}
|
||||
</title>
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.datatables.net/2.3.2/css/dataTables.bootstrap5.css" />
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
|
||||
{% if DEBUG %}
|
||||
<script src="http://localhost:3000/dist/bundle.js"></script>
|
||||
<script defer src="http://localhost:3000/dist/bundle.js"></script>
|
||||
{% else %}
|
||||
<script src="{% static 'bundle.js' %}"></script>
|
||||
<script defer src="{% static 'bundle.js' %}"></script>
|
||||
{% endif %}
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.3/js/bootstrap.bundle.min.js"></script>
|
||||
@@ -18,7 +20,7 @@
|
||||
<script src="https://cdn.datatables.net/2.3.2/js/dataTables.bootstrap5.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar justify-content-start">
|
||||
<nav class="navbar justify-content-ends pe-2">
|
||||
<div>
|
||||
<a class="navbar-brand" href="/">
|
||||
<img src="{% static 'boxofficefantasy/logo.svg' %}" width="30" height="30">
|
||||
@@ -26,30 +28,40 @@
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
{%block navbar%}
|
||||
{%endblock%}
|
||||
{% block navbar %}{% endblock %}
|
||||
</div>
|
||||
{% if user.is_authenticated %}
|
||||
<div>
|
||||
<div class="border border-secondary rounded p-1">{{ user.username }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div>
|
||||
<div class="btn btn-outline-secondary">Login</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
|
||||
<main class="container mt-4">
|
||||
{% block breadcrumbs%}
|
||||
<nav aria-label="breadcrumb">
|
||||
{%if breadcrumbs%}
|
||||
<ol class="breadcrumb">
|
||||
{% for crumb in breadcrumbs %}
|
||||
<li class="breadcrumb-item {% if forloop.last %}active{% endif %}" aria-current="page">{% if not forloop.last %}<a href="{{crumb.url}}">{{crumb.label}}</a>{%else%}{{crumb.label}}{%endif%}</li>
|
||||
{%endfor%}
|
||||
</ol>
|
||||
{%endif%}
|
||||
</nav>
|
||||
{% endblock%} {% block content %}
|
||||
<!-- Default content -->
|
||||
{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="text-muted text-center mt-5">
|
||||
<small>© Sack Lunch</small>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
{% block body %}
|
||||
<main class="container mt-4">
|
||||
{% block breadcrumbs %}
|
||||
<nav aria-label="breadcrumb">
|
||||
{% if breadcrumbs %}
|
||||
<ol class="breadcrumb">
|
||||
{% for crumb in breadcrumbs %}
|
||||
<li class="breadcrumb-item {% if forloop.last %}active{% endif %}"
|
||||
aria-current="page">
|
||||
{% if not forloop.last %}
|
||||
<a href="{{ crumb.url }}">{{ crumb.label }}</a>{% else %}{{ crumb.label }}{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endblock breadcrumbs %}
|
||||
{% block content %}{% endblock content %}
|
||||
{% endblock body %}
|
||||
</main>
|
||||
<footer class="text-muted text-center mt-5">
|
||||
<small>© Sack Lunch</small>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
22
boxofficefantasy/templates/login.dj.html
Normal file
22
boxofficefantasy/templates/login.dj.html
Normal 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 %}
|
||||
@@ -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"
|
||||
)
|
||||
]
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
@@ -21,7 +21,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = 'django-insecure-_rrxhe5i6uqap!52u(1zi8x$820duvf5s_!9!bc4ghbyyktol0'
|
||||
SECRET_KEY = "django-insecure-_rrxhe5i6uqap!52u(1zi8x$820duvf5s_!9!bc4ghbyyktol0"
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
@@ -34,55 +34,60 @@ TMDB_API_KEY = os.environ.get("TMDB_API_KEY")
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'boxofficefantasy',
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.humanize'
|
||||
"rest_framework",
|
||||
"daphne",
|
||||
"boxofficefantasy",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.humanize",
|
||||
"draft",
|
||||
"channels",
|
||||
"api"
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'boxofficefantasy_project.urls'
|
||||
ROOT_URLCONF = "boxofficefantasy_project.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [BASE_DIR / 'templates'],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'boxofficefantasy.context_processors.debug_flag'
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"boxofficefantasy.context_processors.debug_flag",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'boxofficefantasy_project.wsgi.application'
|
||||
WSGI_APPLICATION = "boxofficefantasy_project.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,16 +97,16 @@ DATABASES = {
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -109,9 +114,9 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.1/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
TIME_ZONE = "UTC"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
@@ -121,7 +126,7 @@ USE_TZ = True
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.1/howto/static-files/
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
STATIC_URL = "static/"
|
||||
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
@@ -129,9 +134,45 @@ MEDIA_ROOT = BASE_DIR / "media"
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
]
|
||||
|
||||
ASGI_APPLICATION = "boxofficefantasy_project.asgi.application"
|
||||
|
||||
# Channel layers
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels.layers.InMemoryChannelLayer",
|
||||
},
|
||||
}
|
||||
|
||||
HASHIDS_SALT = os.getenv("BOF_HASHIDS_SALT", "your-very-secret-salt-string")
|
||||
|
||||
COLOR_GREEN = "\033[92m"
|
||||
COLOR_RESET = "\033[0m"
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'prefix': {
|
||||
'format': f'{COLOR_GREEN}[%(name)s]{COLOR_RESET} %(levelname)s %(asctime)s %(name)s: %(message)s',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'prefix'
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'draft.consumers': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO', # Only INFO and above
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
11
boxofficefantasy_project/utils.py
Normal file
11
boxofficefantasy_project/utils.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from hashids import Hashids
|
||||
from django.conf import settings
|
||||
|
||||
hashids = Hashids(min_length=8, salt=settings.HASHIDS_SALT)
|
||||
|
||||
def encode_id(id):
|
||||
return hashids.encode(id)
|
||||
|
||||
def decode_id(hashid):
|
||||
decoded = hashids.decode(hashid)
|
||||
return int(decoded[0]) if decoded else None
|
||||
0
draft/__init__.py
Normal file
0
draft/__init__.py
Normal file
19
draft/admin.py
Normal file
19
draft/admin.py
Normal 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
6
draft/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DraftConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'draft'
|
||||
69
draft/constants.py
Normal file
69
draft/constants.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from enum import IntEnum, StrEnum
|
||||
|
||||
class DraftMessage(StrEnum):
|
||||
# Participant
|
||||
PARTICIPANT_JOIN_REQUEST = "participant.join.request" # client -> server
|
||||
PARTICIPANT_JOIN_CONFIRM = "participant.join.confirm" # server -> client
|
||||
PARTICIPANT_JOIN_REJECT = "participant.join.reject" # server -> client
|
||||
PARTICIPANT_LEAVE_INFORM = "participant.leave.inform" # server -> client (broadcast)
|
||||
|
||||
# User presence
|
||||
USER_JOIN_INFORM = "user.join.inform" # server -> client
|
||||
USER_LEAVE_INFORM = "user.leave.inform"
|
||||
USER_IDENTIFICATION_INFORM = "user.identification.inform" # server -> client (tells socket "you are X", e.g. after connect) # server -> client
|
||||
|
||||
# Phase control
|
||||
PHASE_CHANGE_INFORM = "phase.change.inform" # server -> client (target phase payload)
|
||||
PHASE_CHANGE_REQUEST = "phase.change.request" # server -> client (target phase payload)
|
||||
PHASE_CHANGE_CONFIRM = "phase.change.confirm" # server -> client (target phase payload)
|
||||
|
||||
# Status / sync
|
||||
STATUS_SYNC_REQUEST = "status.sync.request" # client -> server
|
||||
STATUS_SYNC_INFORM = "status.sync.inform" # server -> client (full/partial state)
|
||||
|
||||
DRAFT_INDEX_ADVANCE_REQUEST = "draft.index.advance.request"
|
||||
DRAFT_INDEX_ADVANCE_CONFIRM = "draft.index.advance.confirm"
|
||||
|
||||
# Order determination
|
||||
ORDER_DETERMINE_REQUEST = "order.determine.request" # client -> server (admin)
|
||||
ORDER_DETERMINE_CONFIRM = "order.determine.confirm" # server -> client
|
||||
|
||||
# Bidding (examples, adjust to your flow)
|
||||
BID_START_INFORM = "bid.start.inform" # server -> client (movie, ends_at)
|
||||
BID_START_REQUEST = "bid.start.request" # server -> client (movie, ends_at)
|
||||
BID_PLACE_REQUEST = "bid.place.request" # client -> server (amount)
|
||||
BID_UPDATE_INFORM = "bid.update.inform" # server -> client (high bid)
|
||||
BID_END_INFORM = "bid.end.inform" # server -> client (winner)
|
||||
|
||||
# Nomination (examples)
|
||||
NOMINATION_SUBMIT_REQUEST = "nomination.submit.request" # client -> server (movie_id)
|
||||
NOMINATION_CONFIRM = "nomination.submit.confirm" # server -> client
|
||||
|
||||
|
||||
class DraftPhase(IntEnum):
|
||||
WAITING = 10
|
||||
DETERMINE_ORDER = 20
|
||||
NOMINATING = 30
|
||||
BIDDING = 40
|
||||
AWARDING = 50
|
||||
FINALIZING = 60
|
||||
|
||||
def __str__(self):
|
||||
return self.name.lower()
|
||||
|
||||
class DraftGroupChannelNames:
|
||||
def __init__(self, id):
|
||||
self.prefix = f"draft.{id}"
|
||||
|
||||
@property
|
||||
def session(self):
|
||||
return f"{self.prefix}.session"
|
||||
|
||||
@property
|
||||
def admin(self):
|
||||
return f"{self.prefix}.admin"
|
||||
|
||||
@property
|
||||
def participant(self):
|
||||
return f"{self.prefix}.participant"
|
||||
|
||||
348
draft/consumers.py
Normal file
348
draft/consumers.py
Normal file
@@ -0,0 +1,348 @@
|
||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
from channels.db import database_sync_to_async
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from boxofficefantasy.models import League, Season
|
||||
from boxofficefantasy.views import parse_season_slug
|
||||
from draft.models import DraftSession, DraftSessionParticipant
|
||||
from django.core.cache import cache
|
||||
import asyncio
|
||||
from django.contrib.auth.models import User
|
||||
from draft.constants import (
|
||||
DraftMessage,
|
||||
DraftPhase,
|
||||
DraftGroupChannelNames,
|
||||
)
|
||||
from draft.state import DraftCacheKeys, DraftStateManager
|
||||
from typing import Any
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__) # __name__ = module path
|
||||
|
||||
|
||||
import random
|
||||
|
||||
|
||||
class DraftConsumerBase(AsyncJsonWebsocketConsumer):
|
||||
group_names: DraftGroupChannelNames
|
||||
cache_keys: DraftCacheKeys
|
||||
draft_state: DraftStateManager
|
||||
user: User
|
||||
|
||||
async def connect(self):
|
||||
draft_hashid = self.scope["url_route"]["kwargs"].get("draft_session_id_hashed")
|
||||
|
||||
self.draft_session = await self.get_draft_session(
|
||||
draft_session_id_hashed=draft_hashid,
|
||||
)
|
||||
self.draft_participants = await self.get_draft_participants(
|
||||
session=self.draft_session
|
||||
)
|
||||
|
||||
self.group_names = DraftGroupChannelNames(draft_hashid)
|
||||
self.cache_keys = DraftCacheKeys(draft_hashid)
|
||||
self.draft_state = DraftStateManager(self.draft_session)
|
||||
|
||||
self.user = self.scope["user"]
|
||||
if not self.should_accept_user():
|
||||
await self.channel_layer.send(
|
||||
self.channel_name,
|
||||
{
|
||||
"type": "direct.message",
|
||||
"subtype": DraftMessage.PARTICIPANT_JOIN_REJECT,
|
||||
"payload":{"current_user": self.user.username}
|
||||
}
|
||||
)
|
||||
await self.close()
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.admin,
|
||||
{
|
||||
"type": "broadcast.admin",
|
||||
"subtype": DraftMessage.PARTICIPANT_JOIN_REJECT,
|
||||
"payload":{"user": self.user.username}
|
||||
},
|
||||
)
|
||||
return
|
||||
else:
|
||||
await self.accept()
|
||||
await self.channel_layer.group_add(
|
||||
self.group_names.session, self.channel_name
|
||||
)
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": "broadcast.session",
|
||||
"subtype": DraftMessage.USER_JOIN_INFORM,
|
||||
"payload": {"user": self.user.username},
|
||||
},
|
||||
)
|
||||
await self.channel_layer.send(
|
||||
self.channel_name,
|
||||
{
|
||||
"type": "direct.message",
|
||||
"subtype": DraftMessage.STATUS_SYNC_INFORM,
|
||||
"payload": self.get_draft_status(),
|
||||
},
|
||||
)
|
||||
await self.channel_layer.send(
|
||||
self.channel_name,
|
||||
{
|
||||
"type": "direct.message",
|
||||
"subtype": DraftMessage.USER_IDENTIFICATION_INFORM,
|
||||
"payload": {"user": self.user.username},
|
||||
},
|
||||
)
|
||||
|
||||
async def should_accept_user(self) -> bool:
|
||||
return self.user.is_authenticated
|
||||
|
||||
async def receive_json(self, content):
|
||||
event_type = content.get("type")
|
||||
if event_type == DraftMessage.STATUS_SYNC_REQUEST:
|
||||
await self.send_json(
|
||||
{
|
||||
"type": DraftMessage.STATUS_SYNC_INFORM,
|
||||
"payload": self.get_draft_status(),
|
||||
}
|
||||
)
|
||||
|
||||
# Broadcast Handlers
|
||||
async def direct_message(self, event):
|
||||
await self._dispatch_broadcast(event)
|
||||
|
||||
async def broadcast_session(self, event):
|
||||
await self._dispatch_broadcast(event)
|
||||
|
||||
async def _dispatch_broadcast(self, event):
|
||||
logger.info(f"dispatching message {event}")
|
||||
subtype = event.get("subtype")
|
||||
payload = event.get("payload", {})
|
||||
await self.send_json({"type": subtype, "payload": payload})
|
||||
|
||||
# === Methods ===
|
||||
|
||||
def get_draft_status(self) -> dict[str, Any]:
|
||||
return {
|
||||
**self.draft_state.get_summary(),
|
||||
"user": self.user.username,
|
||||
"participants": [user.username for user in self.draft_participants],
|
||||
}
|
||||
|
||||
# === DB Access ===
|
||||
@database_sync_to_async
|
||||
def get_draft_session(self, draft_session_id_hashed) -> DraftSession:
|
||||
draft_session_id = DraftSession.decode_id(draft_session_id_hashed)
|
||||
if draft_session_id:
|
||||
draft_session = DraftSession.objects.select_related(
|
||||
"season", "season__league", "settings"
|
||||
).get(pk=draft_session_id)
|
||||
else:
|
||||
raise Exception()
|
||||
|
||||
return draft_session
|
||||
|
||||
@database_sync_to_async
|
||||
def get_draft_participants(self, session) -> list[DraftSessionParticipant]:
|
||||
participants = session.participants.all()
|
||||
return list(participants.all())
|
||||
|
||||
|
||||
class DraftAdminConsumer(DraftConsumerBase):
|
||||
async def connect(self):
|
||||
await super().connect()
|
||||
if not self.user.is_staff:
|
||||
await self.close()
|
||||
return
|
||||
|
||||
await self.channel_layer.group_add(self.group_names.admin, self.channel_name)
|
||||
|
||||
async def receive_json(self, content):
|
||||
await super().receive_json(content)
|
||||
logger.info(f"Receive message {content}")
|
||||
event_type = content.get("type")
|
||||
if (
|
||||
event_type == DraftMessage.PHASE_CHANGE_REQUEST
|
||||
and content.get("destination") == DraftPhase.DETERMINE_ORDER
|
||||
):
|
||||
await self.determine_draft_order()
|
||||
|
||||
if (
|
||||
event_type == DraftMessage.PHASE_CHANGE_REQUEST
|
||||
and content.get("destination") == DraftPhase.NOMINATING
|
||||
):
|
||||
await self.start_nominate()
|
||||
|
||||
if event_type == DraftMessage.DRAFT_INDEX_ADVANCE_REQUEST:
|
||||
self.draft_state.draft_index_advance()
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": "broadcast.session",
|
||||
"subtype": DraftMessage.DRAFT_INDEX_ADVANCE_CONFIRM,
|
||||
"payload": self.draft_state.get_summary(),
|
||||
},
|
||||
)
|
||||
|
||||
if event_type == DraftMessage.NOMINATION_SUBMIT_REQUEST:
|
||||
movie_id = content.get('payload',{}).get('movie_id')
|
||||
user = content.get('payload',{}).get('user')
|
||||
self.draft_state.start_nomination(movie_id)
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": "broadcast.session",
|
||||
"subtype": DraftMessage.NOMINATION_CONFIRM,
|
||||
"payload": {
|
||||
"current_movie": self.draft_state.get_summary()['current_movie'],
|
||||
"nominating_participant": user
|
||||
}
|
||||
}
|
||||
)
|
||||
if event_type == DraftMessage.BID_START_REQUEST:
|
||||
self.draft_state.start_timer()
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": "broadcast.session",
|
||||
"subtype": DraftMessage.BID_START_INFORM,
|
||||
"payload": {
|
||||
"current_movie": self.draft_state.get_summary()['current_movie'],
|
||||
"bidding_duration": self.draft_state.settings.bidding_duration,
|
||||
"bidding_timer_end": self.draft_state.get_timer_end(),
|
||||
"bidding_timer_start": self.draft_state.get_timer_start()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
def should_accept_user(self):
|
||||
return super().should_accept_user() and self.user.is_staff
|
||||
|
||||
# === Draft logic ===
|
||||
async def start_nominate(self):
|
||||
await self.set_draft_phase(DraftPhase.NOMINATING)
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": "broadcast.session",
|
||||
"subtype": DraftMessage.PHASE_CHANGE_CONFIRM,
|
||||
"payload": {"phase": self.draft_state.phase},
|
||||
},
|
||||
)
|
||||
|
||||
async def determine_draft_order(self):
|
||||
draft_order = self.draft_state.determine_draft_order(self.draft_participants)
|
||||
self.draft_state.draft_index = 0
|
||||
await self.set_draft_phase(DraftPhase.DETERMINE_ORDER)
|
||||
next_picks = self.draft_state.next_picks(include_current=True)
|
||||
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": "broadcast.session",
|
||||
"subtype": DraftMessage.ORDER_DETERMINE_CONFIRM,
|
||||
"payload": {
|
||||
"draft_order": draft_order,
|
||||
"draft_index": self.draft_state.draft_index,
|
||||
"current_pick": next_picks[0],
|
||||
"next_picks": next_picks[1:]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async def set_draft_phase(self, destination: DraftPhase):
|
||||
self.draft_state.phase = destination
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": "broadcast.session",
|
||||
"subtype": DraftMessage.PHASE_CHANGE_CONFIRM,
|
||||
"payload": {"phase": self.draft_state.phase},
|
||||
},
|
||||
)
|
||||
|
||||
# === Broadcast Handlers ===
|
||||
|
||||
async def broadcast_admin(self, event):
|
||||
await self._dispatch_broadcast(event)
|
||||
|
||||
|
||||
class DraftParticipantConsumer(DraftConsumerBase):
|
||||
async def connect(self):
|
||||
await super().connect()
|
||||
|
||||
self.draft_state.connect_participant(self.user.username)
|
||||
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": "broadcast.session",
|
||||
"subtype": DraftMessage.PARTICIPANT_JOIN_CONFIRM,
|
||||
"payload": {
|
||||
"user": self.user.username,
|
||||
"connected_participants": self.draft_state.connected_participants,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await self.channel_layer.group_add(
|
||||
self.group_names.participant, self.channel_name
|
||||
)
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
self.draft_state.disconnect_participant(self.user.username)
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": "broadcast.session",
|
||||
"subtype": DraftMessage.PARTICIPANT_LEAVE_INFORM,
|
||||
"payload": {
|
||||
"user": self.user.username,
|
||||
"connected_participants": self.draft_state.connected_participants,
|
||||
},
|
||||
},
|
||||
)
|
||||
await super().disconnect(close_code)
|
||||
self.draft_state.disconnect_participant(self.user.username)
|
||||
await self.channel_layer.group_discard(
|
||||
self.group_names.session, self.channel_name
|
||||
)
|
||||
|
||||
def should_accept_user(self):
|
||||
return super().should_accept_user() and self.user in self.draft_participants
|
||||
|
||||
async def receive_json(self, content):
|
||||
await super().receive_json(content)
|
||||
event_type = content.get('type')
|
||||
if event_type == DraftMessage.NOMINATION_SUBMIT_REQUEST:
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.admin,
|
||||
{
|
||||
"type": "broadcast.admin",
|
||||
"subtype": event_type,
|
||||
"payload": {
|
||||
"movie_id": content.get('payload',{}).get('id'),
|
||||
"user": content.get('payload',{}).get('user')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# === Broadcast handlers ===
|
||||
|
||||
async def broadcast_participant(self, event):
|
||||
await self._dispatch_broadcast(event)
|
||||
|
||||
# === Draft ===
|
||||
|
||||
async def nominate(self, movie_title): ...
|
||||
|
||||
async def place_bid(self, amount, user): ...
|
||||
|
||||
# === Example DB Access ===
|
||||
|
||||
@database_sync_to_async
|
||||
def add_draft_participant(self):
|
||||
self.participant, _ = DraftSessionParticipant.objects.get_or_create(
|
||||
user=self.user,
|
||||
draft=self.draft_session,
|
||||
defaults={"budget": self.draft_session.settings.starting_budget},
|
||||
)
|
||||
56
draft/migrations/0001_initial.py
Normal file
56
draft/migrations/0001_initial.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
22
draft/migrations/0002_draftsessionsettings.py
Normal file
22
draft/migrations/0002_draftsessionsettings.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
19
draft/migrations/0004_alter_draftsession_settings.py
Normal file
19
draft/migrations/0004_alter_draftsession_settings.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
0
draft/migrations/__init__.py
Normal file
0
draft/migrations/__init__.py
Normal file
71
draft/models.py
Normal file
71
draft/models.py
Normal 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
7
draft/routing.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
from draft.consumers import DraftParticipantConsumer, DraftAdminConsumer
|
||||
|
||||
websocket_urlpatterns = [
|
||||
path(r"ws/draft/session/<str:draft_session_id_hashed>/participant", DraftParticipantConsumer.as_asgi()),
|
||||
path(r"ws/draft/session/<str:draft_session_id_hashed>/admin", DraftAdminConsumer.as_asgi()),
|
||||
]
|
||||
228
draft/state.py
Normal file
228
draft/state.py
Normal file
@@ -0,0 +1,228 @@
|
||||
from django.core.cache import cache
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from boxofficefantasy.models import Movie
|
||||
from django.contrib.auth.models import User
|
||||
from draft.constants import DraftPhase
|
||||
from draft.models import DraftSession
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple
|
||||
import random
|
||||
|
||||
class DraftCacheKeys:
|
||||
def __init__(self, id):
|
||||
self.prefix = f"draft:{id}"
|
||||
|
||||
@property
|
||||
def admins(self):
|
||||
return f"{self.prefix}:admins"
|
||||
|
||||
@property
|
||||
def participants(self):
|
||||
return f"{self.prefix}:participants"
|
||||
|
||||
@property
|
||||
def users(self):
|
||||
return f"{self.prefix}:users"
|
||||
|
||||
@property
|
||||
def connected_users(self):
|
||||
return f"{self.prefix}:connected_users"
|
||||
|
||||
@property
|
||||
def phase(self):
|
||||
return f"{self.prefix}:phase"
|
||||
|
||||
@property
|
||||
def draft_order(self):
|
||||
return f"{self.prefix}:draft_order"
|
||||
|
||||
@property
|
||||
def draft_index(self):
|
||||
return f"{self.prefix}:draft_index"
|
||||
|
||||
@property
|
||||
def current_movie(self):
|
||||
return f"{self.prefix}:current_movie"
|
||||
|
||||
# @property
|
||||
# def state(self):
|
||||
# return f"{self.prefix}:state"
|
||||
|
||||
# @property
|
||||
# def current_movie(self):
|
||||
# return f"{self.prefix}:current_movie"
|
||||
|
||||
@property
|
||||
def bids(self):
|
||||
return f"{self.prefix}:bids"
|
||||
|
||||
# @property
|
||||
# def participants(self):
|
||||
# return f"{self.prefix}:participants"
|
||||
|
||||
@property
|
||||
def bid_timer_end(self):
|
||||
return f"{self.prefix}:bid_timer_end"
|
||||
@property
|
||||
def bid_timer_start(self):
|
||||
return f"{self.prefix}:bid_timer_start"
|
||||
|
||||
# def user_status(self, user_id):
|
||||
# return f"{self.prefix}:user:{user_id}:status"
|
||||
|
||||
# def user_channel(self, user_id):
|
||||
# return f"{self.prefix}:user:{user_id}:channel"
|
||||
|
||||
class DraftStateManager:
|
||||
def __init__(self, session: DraftSession):
|
||||
self.session_id = session.hashid
|
||||
self.cache = cache
|
||||
self.keys = DraftCacheKeys(self.session_id)
|
||||
self._initial_phase = self.cache.get(self.keys.phase, DraftPhase.WAITING.value)
|
||||
self.settings = session.settings
|
||||
|
||||
# === Phase Management ===
|
||||
@property
|
||||
def phase(self) -> str:
|
||||
return str(self.cache.get(self.keys.phase, self._initial_phase))
|
||||
|
||||
@phase.setter
|
||||
def phase(self, new_phase: DraftPhase):
|
||||
self.cache.set(self.keys.phase, new_phase.value)
|
||||
|
||||
# === Connected Users ===
|
||||
@property
|
||||
def connected_participants(self) -> list[str]:
|
||||
return json.loads(self.cache.get(self.keys.connected_users) or "[]")
|
||||
|
||||
def connect_participant(self, username: str):
|
||||
users = set(self.connected_participants)
|
||||
users.add(username)
|
||||
self.cache.set(self.keys.connected_users, json.dumps(list(users)))
|
||||
|
||||
def disconnect_participant(self, username: str):
|
||||
users = set(self.connected_participants)
|
||||
users.discard(username)
|
||||
self.cache.set(self.keys.connected_users, json.dumps(list(users)))
|
||||
|
||||
# === Draft Order ===
|
||||
@property
|
||||
def draft_order(self):
|
||||
return json.loads(self.cache.get(self.keys.draft_order,"[]"))
|
||||
|
||||
@draft_order.setter
|
||||
def draft_order(self, draft_order: list[str]):
|
||||
if not isinstance(draft_order, list):
|
||||
return
|
||||
self.cache.set(self.keys.draft_order,json.dumps(draft_order))
|
||||
|
||||
def determine_draft_order(self, users: list[User]):
|
||||
draft_order = random.sample(
|
||||
users, len(users)
|
||||
)
|
||||
self.draft_order = [user.username for user in draft_order]
|
||||
return self.draft_order
|
||||
|
||||
@property
|
||||
def draft_index(self):
|
||||
return self.cache.get(self.keys.draft_index,0)
|
||||
|
||||
@draft_index.setter
|
||||
def draft_index(self, draft_index: int):
|
||||
self.cache.set(self.keys.draft_index, int(draft_index))
|
||||
|
||||
def draft_index_advance(self, n: int = 1):
|
||||
self.draft_index += n
|
||||
return self.draft_index
|
||||
|
||||
def next_picks(
|
||||
self,
|
||||
*,
|
||||
from_overall: int | None = None,
|
||||
count: int | None = None,
|
||||
include_current: bool = False,
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Convenience: return the next `count` picks starting after `from_overall`
|
||||
(or after current draft_index if omitted). Each item:
|
||||
{overall, round, pick_in_round, participant}
|
||||
"""
|
||||
if not self.draft_order:
|
||||
return []
|
||||
n = len(self.draft_order)
|
||||
count = count if count else len(self.draft_order)
|
||||
start = self.draft_index if from_overall is None else int(from_overall)
|
||||
start = start if include_current else start + 1
|
||||
|
||||
out: List[dict] = []
|
||||
for overall in range(start, start + count):
|
||||
r, p = _round_and_pick(overall, n)
|
||||
order_type = "snake"
|
||||
order = _round_order(r, order_type, self.draft_order)
|
||||
out.append({
|
||||
"overall": overall,
|
||||
"round": r,
|
||||
"pick_in_round": p,
|
||||
"participant": order[p - 1],
|
||||
})
|
||||
return out
|
||||
|
||||
# === Current Nomination / Bid ===
|
||||
def start_nomination(self, movie_id: int):
|
||||
self.cache.set(self.keys.current_movie, movie_id)
|
||||
self.cache.delete(self.keys.bids)
|
||||
|
||||
def place_bid(self, user_id: int, amount: int):
|
||||
bids = self.get_bids()
|
||||
bids[user_id] = amount
|
||||
self.cache.set(self.keys.bids, json.dumps(bids))
|
||||
|
||||
def get_bids(self) -> dict:
|
||||
return json.loads(self.cache.get(self.keys.bids) or "{}")
|
||||
|
||||
def current_movie(self) -> Movie | None:
|
||||
movie_id = self.cache.get(self.keys.current_movie)
|
||||
return Movie.objects.filter(pk=movie_id).first() if movie_id else None
|
||||
|
||||
def start_timer(self):
|
||||
seconds = self.settings.bidding_duration
|
||||
start_time = time.time()
|
||||
end_time = start_time + seconds
|
||||
self.cache.set(self.keys.bid_timer_end, end_time)
|
||||
self.cache.set(self.keys.bid_timer_start, start_time)
|
||||
|
||||
def get_timer_end(self) -> str | None:
|
||||
return self.cache.get(self.keys.bid_timer_end)
|
||||
|
||||
def get_timer_start(self) -> str | None:
|
||||
return self.cache.get(self.keys.bid_timer_start)
|
||||
|
||||
# === Sync Snapshot ===
|
||||
def get_summary(self) -> dict:
|
||||
picks = self.next_picks(include_current=True)
|
||||
return {
|
||||
"phase": self.phase,
|
||||
"draft_order": self.draft_order,
|
||||
"draft_index": self.draft_index,
|
||||
"connected_participants": self.connected_participants,
|
||||
"current_movie": self.cache.get(self.keys.current_movie),
|
||||
# "bids": self.get_bids(),
|
||||
"bidding_timer_end": self.get_timer_end(),
|
||||
"bidding_timer_start": self.get_timer_start(),
|
||||
"current_pick": picks[0] if picks else None,
|
||||
"next_picks": picks[1:] if picks else []
|
||||
}
|
||||
|
||||
OrderType = Literal["snake", "linear"]
|
||||
def _round_and_pick(overall: int, n: int) -> Tuple[int, int]:
|
||||
"""overall -> (round_1_based, pick_in_round_1_based)"""
|
||||
r = overall // n + 1
|
||||
p = overall % n + 1
|
||||
return r, p
|
||||
|
||||
def _round_order(round_num: int, order_type: OrderType, r1: Sequence[Any]) -> Sequence[Any]:
|
||||
if order_type == "linear" or (round_num % 2 == 1):
|
||||
return r1
|
||||
return list(reversed(r1)) # even rounds in snake
|
||||
8
draft/templates/draft/room.dj.html
Normal file
8
draft/templates/draft/room.dj.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{% extends "base.dj.html" %}
|
||||
{% block body %}
|
||||
{% load static %}
|
||||
<script>
|
||||
window.draftSessionId = "{{ draft_id_hashed }}"
|
||||
</script>
|
||||
<div id="draft-participant-root" data-draft-id="{{ draft_id_hashed }}"></div>
|
||||
{% endblock body %}
|
||||
10
draft/templates/draft/room_admin.dj.html
Normal file
10
draft/templates/draft/room_admin.dj.html
Normal file
@@ -0,0 +1,10 @@
|
||||
{% extends "base.dj.html" %}
|
||||
{% block content %}
|
||||
<h1>Draft Room: {{ league.name }} – {{ season.label }} {{ season.year }}</h1>
|
||||
{% load static %}
|
||||
<script>
|
||||
window.draftSessionId = "{{ draft_id_hashed }}"
|
||||
</script>
|
||||
<div id="draft-admin-root" data-draft-hid="{{ draft_id_hashed }}"></div>
|
||||
|
||||
{% endblock %}
|
||||
14
draft/templates/draft/room_debug.dj.html
Normal file
14
draft/templates/draft/room_debug.dj.html
Normal 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
3
draft/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
11
draft/urls.py
Normal file
11
draft/urls.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = "draft"
|
||||
|
||||
urlpatterns = [
|
||||
# path("", views.draft_room, name="room"),
|
||||
path("session/<str:draft_session_id_hashed>/", views.draft_room, name="session"),
|
||||
path("session/<str:draft_session_id_hashed>/<str:subpage>", views.draft_room, name="admin_session"),
|
||||
# path("<slug:league_slug>/<slug:season_slug>/", views.draft_room_list, name="room"),
|
||||
]
|
||||
33
draft/views.py
Normal file
33
draft/views.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from boxofficefantasy.models import League, Season
|
||||
from draft.models import DraftSession
|
||||
from boxofficefantasy.views import parse_season_slug
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from boxofficefantasy_project.utils import decode_id
|
||||
|
||||
@login_required(login_url='/login/')
|
||||
def draft_room(request, league_slug=None, season_slug=None, draft_session_id_hashed=None, subpage=""):
|
||||
if draft_session_id_hashed:
|
||||
draft_session_id = decode_id(draft_session_id_hashed)
|
||||
draft_session = get_object_or_404(DraftSession, id=draft_session_id)
|
||||
league = draft_session.season.league
|
||||
season = draft_session.season
|
||||
elif league_slug and season_slug:
|
||||
raise NotImplementedError
|
||||
league = get_object_or_404(League, slug=league_slug)
|
||||
label, year = parse_season_slug(season_slug)
|
||||
season = get_object_or_404(Season, league=league, label__iexact=label, year=year)
|
||||
draft_session = get_object_or_404(DraftSession, season=season)
|
||||
|
||||
context = {
|
||||
"draft_id_hashed": draft_session.hashid,
|
||||
"league": league,
|
||||
"season": season,
|
||||
}
|
||||
|
||||
if subpage == "admin":
|
||||
return render(request, "draft/room_admin.dj.html", context)
|
||||
elif subpage == "debug":
|
||||
return render(request, "draft/room_debug.dj.html", context)
|
||||
else:
|
||||
return render(request, "draft/room.dj.html", context)
|
||||
3
frontend/.babelrc
Normal file
3
frontend/.babelrc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react"]
|
||||
}
|
||||
2269
frontend/package-lock.json
generated
2269
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,14 +3,21 @@
|
||||
"dev": "SASS_LOG_LEVEL=error webpack serve --config webpack.config.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.0",
|
||||
"@babel/preset-env": "^7.28.0",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"babel-loader": "^10.0.0",
|
||||
"css-loader": "^7.1.2",
|
||||
"sass": "^1.89.2",
|
||||
"sass-loader": "^16.0.5",
|
||||
"style-loader": "^4.0.0",
|
||||
"webpack": "^5.100.2",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^5.3.7"
|
||||
"bootstrap": "^5.3.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
45
frontend/src/apps/draft/DraftDebug.jsx
Normal file
45
frontend/src/apps/draft/DraftDebug.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { useEffect, useState } from "react";;
|
||||
import { useWebSocket } from "./common/WebSocketContext.jsx";
|
||||
import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from './constants.js';
|
||||
import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "./common/utils.js"
|
||||
|
||||
export const DraftDebug = ({ draftSessionId }) => {
|
||||
const [draftState, setDraftState] = useState({})
|
||||
const socket = useWebSocket();
|
||||
if (!socket) return;
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
const openHandler = (event) => {
|
||||
console.log('Websocket Opened')
|
||||
}
|
||||
const closeHandler = (event) => {
|
||||
console.log('Websocket Closed')
|
||||
}
|
||||
socket.addEventListener('open', openHandler);
|
||||
socket.addEventListener('close', closeHandler);
|
||||
return () => {
|
||||
socket.removeEventListener('open', openHandler);
|
||||
socket.removeEventListener('close', closeHandler);
|
||||
}
|
||||
}, [socket])
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const draftStatusMessageHandler = (event) => handleDraftStatusMessages(event, setDraftState)
|
||||
|
||||
socket.addEventListener('message', draftStatusMessageHandler);
|
||||
|
||||
|
||||
return () => {
|
||||
socket.removeEventListener('message', draftStatusMessageHandler)
|
||||
};
|
||||
}, [socket]);
|
||||
const data = { 'message': 'test' }
|
||||
return (<pre style={{margin: "1em"}}>
|
||||
{JSON.stringify(draftState, null, 2)}
|
||||
</pre>
|
||||
)
|
||||
|
||||
}
|
||||
169
frontend/src/apps/draft/admin/DraftAdmin.jsx
Normal file
169
frontend/src/apps/draft/admin/DraftAdmin.jsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { useWebSocket } from "../common/WebSocketContext.jsx";
|
||||
import { WebSocketStatus } from "../common/WebSocketStatus.jsx";
|
||||
import { ParticipantList } from "../common/ParticipantList.jsx";
|
||||
import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from '../constants.js';
|
||||
import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "../common/utils.js"
|
||||
import { DraftMoviePool } from "../common/DraftMoviePool.jsx"
|
||||
import { DraftCountdownClock } from "../common/DraftCountdownClock.jsx"
|
||||
import { jsxs } from "react/jsx-runtime";
|
||||
|
||||
|
||||
|
||||
const DraftPhaseDisplay = ({ draftPhase, nextPhaseHandler, prevPhaseHandler }) => {
|
||||
return (
|
||||
<div className="draft-phase-container">
|
||||
<label>Phase</label>
|
||||
<div className="d-flex">
|
||||
<div className="change-phase"><button onClick={prevPhaseHandler}><i className="bi bi-chevron-left"></i></button></div>
|
||||
<ol>
|
||||
{
|
||||
DraftPhasesOrdered.map((p) => (
|
||||
<li key={p} className={p === draftPhase ? "current-phase" : ""}>
|
||||
<span>{DraftPhaseLabel[p]}</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
<div className="change-phase"><button onClick={nextPhaseHandler}><i className="bi bi-chevron-right"></i></button></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const DraftAdmin = ({ draftSessionId }) => {
|
||||
const socket = useWebSocket();
|
||||
const [draftDetails, setDraftDetails] = useState();
|
||||
const [draftState, setDraftState] = useState({})
|
||||
const [currentUser, setCurrentUser] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDraftDetails(draftSessionId)
|
||||
.then((data) => {
|
||||
console.log("Fetched draft data", data)
|
||||
setDraftDetails(data)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(()=>{
|
||||
if (!socket) return;
|
||||
const openHandler = (event)=>{
|
||||
console.log('Websocket Opened')
|
||||
}
|
||||
const closeHandler = (event)=>{
|
||||
console.log('Websocket Closed')
|
||||
}
|
||||
socket.addEventListener('open', openHandler );
|
||||
socket.addEventListener('close', closeHandler );
|
||||
return ()=>{
|
||||
socket.removeEventListener('open', openHandler );
|
||||
socket.removeEventListener('close', closeHandler );
|
||||
}
|
||||
}, [socket])
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const draftStatusMessageHandler = (event) => handleDraftStatusMessages(event, setDraftState)
|
||||
const userIdentifyMessageHandler = (event) => handleUserIdentifyMessages(event, setCurrentUser)
|
||||
const handleNominationRequest = (event)=> {
|
||||
const message = JSON.parse(event.data)
|
||||
const { type, payload } = message;
|
||||
if (type == DraftMessage.NOMINATION_SUBMIT_REQUEST) {
|
||||
socket.send(JSON.stringify(
|
||||
{
|
||||
type: DraftMessage.NOMINATION_SUBMIT_REQUEST,
|
||||
payload
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
socket.addEventListener('message', draftStatusMessageHandler );
|
||||
socket.addEventListener('message', userIdentifyMessageHandler );
|
||||
socket.addEventListener('message', handleNominationRequest );
|
||||
|
||||
|
||||
return () => {
|
||||
socket.removeEventListener('message', draftStatusMessageHandler)
|
||||
socket.removeEventListener('message', userIdentifyMessageHandler );
|
||||
socket.removeEventListener('message', handleNominationRequest );
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
const handlePhaseChange = (target) => {
|
||||
let destination
|
||||
const origin = draftState.phase
|
||||
const originPhaseIndex = DraftPhasesOrdered.findIndex(i => i == origin)
|
||||
console.log('origin phase index', originPhaseIndex)
|
||||
if (target == "next" && originPhaseIndex < DraftPhasesOrdered.length) {
|
||||
destination = DraftPhasesOrdered[originPhaseIndex + 1]
|
||||
}
|
||||
else if (target == "previous" && originPhaseIndex > 0) {
|
||||
destination = DraftPhasesOrdered[originPhaseIndex - 1]
|
||||
}
|
||||
console.log(destination)
|
||||
socket.send(
|
||||
JSON.stringify(
|
||||
{ type: DraftMessage.PHASE_CHANGE_REQUEST, origin, destination }
|
||||
)
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
const handleStartDraft = () => {
|
||||
|
||||
}
|
||||
|
||||
const handleAdvanceDraft = () => {
|
||||
socket.send(
|
||||
JSON.stringify(
|
||||
{ type: DraftMessage.DRAFT_INDEX_ADVANCE_REQUEST }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const handleRequestDraftSummary = () => {
|
||||
socket.send(
|
||||
JSON.stringify(
|
||||
{ type: DraftMessage.STATUS_SYNC_REQUEST }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const handleStartBidding = () => {
|
||||
socket.send(
|
||||
JSON.stringify(
|
||||
{type: DraftMessage.BID_START_REQUEST}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container draft-panel admin">
|
||||
<div className="d-flex justify-content-between border-bottom mb-2 p-1">
|
||||
<h3>Draft Panel</h3>
|
||||
<div className="d-flex gap-1">
|
||||
<WebSocketStatus socket={socket} />
|
||||
<button onClick={() => handleRequestDraftSummary()} className="btn btn-small btn-light">
|
||||
<i className="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ParticipantList
|
||||
currentUser = {currentUser}
|
||||
draftState={draftState}
|
||||
draftDetails={draftDetails}
|
||||
isAdmin={true}
|
||||
/>
|
||||
<div className="d-flex gap-1 m-1">
|
||||
<button onClick={handleAdvanceDraft} className="btn btn-primary">Advance Draft</button>
|
||||
<button onClick={handleStartBidding} className="btn btn-primary">Start Bidding</button>
|
||||
</div>
|
||||
<DraftMoviePool draftDetails={draftDetails} draftState={draftState}></DraftMoviePool>
|
||||
<DraftPhaseDisplay draftPhase={draftState.phase} nextPhaseHandler={() => { handlePhaseChange('next') }} prevPhaseHandler={() => { handlePhaseChange('previous') }}></DraftPhaseDisplay>
|
||||
<DraftCountdownClock endTime={draftState.bidding_timer_end}></DraftCountdownClock>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
34
frontend/src/apps/draft/common/DraftCountdownClock.jsx
Normal file
34
frontend/src/apps/draft/common/DraftCountdownClock.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
export function DraftCountdownClock({ endTime, onFinish }) {
|
||||
// endTime is in seconds (Unix time)
|
||||
|
||||
const getTimeLeft = (et) => Math.max(0, Math.floor(et - Date.now() / 1000));
|
||||
const [timeLeft, setTimeLeft] = useState(getTimeLeft(endTime));
|
||||
|
||||
useEffect(() => {
|
||||
if (timeLeft <= 0) {
|
||||
if (onFinish) onFinish();
|
||||
return;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
const t = getTimeLeft(endTime);
|
||||
setTimeLeft(t);
|
||||
if (t <= 0 && onFinish) onFinish();
|
||||
}, 100);
|
||||
return () => clearInterval(timer);
|
||||
// eslint-disable-next-line
|
||||
}, [endTime, onFinish, timeLeft]);
|
||||
|
||||
const minutes = Math.floor(timeLeft / 60);
|
||||
const secs = timeLeft % 60;
|
||||
const pad = n => String(n).padStart(2, "0");
|
||||
|
||||
return (
|
||||
<div className="countdown-clock">
|
||||
<span>
|
||||
{minutes}:{pad(secs)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
frontend/src/apps/draft/common/DraftMoviePool.jsx
Normal file
23
frontend/src/apps/draft/common/DraftMoviePool.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { isEmptyObject } from "./utils";
|
||||
|
||||
export const DraftMoviePool = ({ isParticipant, draftDetails, draftState }) => {
|
||||
if(isEmptyObject(draftDetails)) {return}
|
||||
const {movies} = draftDetails
|
||||
const {current_movie} = draftState
|
||||
|
||||
return (
|
||||
<div className="movie-pool-container">
|
||||
<label>Movies</label>
|
||||
<ul>
|
||||
{movies.map(m => (
|
||||
<li key={m.id} className={`${current_movie == m.id ? "current-movie fw-bold" : null }`}>
|
||||
<a href={`/api/movie/${m.id}/detail`}>
|
||||
{m.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
frontend/src/apps/draft/common/ParticipantList.jsx
Normal file
32
frontend/src/apps/draft/common/ParticipantList.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import { fetchDraftDetails, isEmptyObject } from "../common/utils.js"
|
||||
|
||||
export const ParticipantList = ({ isAdmin, draftState, draftDetails, currentUser }) => {
|
||||
if (isEmptyObject(draftState) || isEmptyObject(draftDetails)) { console.warn('empty draft state', draftState); return }
|
||||
const { draft_order, draft_index, connected_participants } = draftState
|
||||
const { participants } = draftDetails
|
||||
|
||||
const ListTag = draft_order.length > 0 ? "ol" : "ul"
|
||||
const listItems = draft_order.length > 0 ? draft_order.map(d => participants.find(p => p.username == d)) : participants
|
||||
|
||||
|
||||
return (
|
||||
<div className="participant-list-container">
|
||||
<label>Particpants</label>
|
||||
<ListTag className="participant-list">
|
||||
{listItems.map((p, i) => (
|
||||
<li key={i} className={`${i == draft_index ? "fw-bold" : ""}`}>
|
||||
<span className={`${p.username == currentUser ? "current-user" : ""}`}>{p?.full_name}</span>
|
||||
{isAdmin ? (
|
||||
<div
|
||||
className={
|
||||
`ms-2 stop-light ${connected_participants.includes(p?.username) ? "success" : "danger"}`
|
||||
}
|
||||
></div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ListTag>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
frontend/src/apps/draft/common/WebSocketContext.jsx
Normal file
16
frontend/src/apps/draft/common/WebSocketContext.jsx
Normal 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);
|
||||
34
frontend/src/apps/draft/common/WebSocketStatus.jsx
Normal file
34
frontend/src/apps/draft/common/WebSocketStatus.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
export const WebSocketStatus = ({ socket }) => {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('socket changed', socket)
|
||||
if (!socket) return;
|
||||
|
||||
const handleOpen = () => { console.log('socket open'); setIsConnected(true) };
|
||||
const handleClose = () => { console.log('socket close'); setIsConnected(false) };
|
||||
const handleError = () => { console.log('socket error'); setIsConnected(false) };
|
||||
|
||||
socket.addEventListener("open", handleOpen);
|
||||
socket.addEventListener("close", handleClose);
|
||||
socket.addEventListener("error", handleError);
|
||||
|
||||
// 🧹 Cleanup to remove listeners when component unmounts or socket changes
|
||||
return () => {
|
||||
socket.removeEventListener("open", handleOpen);
|
||||
socket.removeEventListener("close", handleClose);
|
||||
socket.removeEventListener("error", handleError);
|
||||
};
|
||||
|
||||
}, [socket])
|
||||
return (
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<span
|
||||
className={`badge ${isConnected ? "text-bg-success" : "text-bg-danger"}`}
|
||||
>
|
||||
{isConnected ? "Connected" : "Disconnected"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
81
frontend/src/apps/draft/common/utils.js
Normal file
81
frontend/src/apps/draft/common/utils.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { DraftMessage } from "../constants";
|
||||
|
||||
export async function fetchDraftDetails(draftSessionId) {
|
||||
return fetch(`/api/draft/${draftSessionId}/`)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error fetching draft details", err);
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchMovieDetails(draftSessionId) {
|
||||
return fetch(`/api/draft/${draftSessionId}/movie/`)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error fetching draft details", err);
|
||||
});
|
||||
}
|
||||
|
||||
export function isEmptyObject(obj) {
|
||||
return (
|
||||
obj == null || (Object.keys(obj).length === 0 && obj.constructor === Object)
|
||||
);
|
||||
}
|
||||
|
||||
export const handleDraftStatusMessages = (event, setDraftState) => {
|
||||
const message = JSON.parse(event.data);
|
||||
const { type, payload } = message;
|
||||
console.log("Message: ", type, event?.data);
|
||||
|
||||
if (!payload) return;
|
||||
const {
|
||||
connected_participants,
|
||||
phase,
|
||||
draft_order,
|
||||
draft_index,
|
||||
current_movie,
|
||||
bidding_timer_end,
|
||||
bidding_timer_start,
|
||||
current_pick,
|
||||
next_picks
|
||||
} = payload;
|
||||
|
||||
if (type == DraftMessage.STATUS_SYNC_INFORM) {
|
||||
setDraftState(payload);
|
||||
}
|
||||
|
||||
setDraftState((prev) => ({
|
||||
...prev,
|
||||
...(connected_participants ? { connected_participants } : {}),
|
||||
...(draft_order ? { draft_order } : {}),
|
||||
...(draft_index ? { draft_index } : {}),
|
||||
...(phase ? { phase: Number(phase) } : {}),
|
||||
...(current_movie ? { current_movie } : {}),
|
||||
...(bidding_timer_end ? { bidding_timer_end: Number(bidding_timer_end) } : {}),
|
||||
...(current_pick ? { current_pick } : {}),
|
||||
...(next_picks ? { next_picks } : {}),
|
||||
}));
|
||||
};
|
||||
|
||||
export const handleUserIdentifyMessages = (event, setUser) => {
|
||||
const message = JSON.parse(event.data);
|
||||
const { type, payload } = message;
|
||||
|
||||
if (type == DraftMessage.USER_IDENTIFICATION_INFORM) {
|
||||
console.log("Message: ", type, event.data);
|
||||
const { user } = payload;
|
||||
setUser(user);
|
||||
}
|
||||
};
|
||||
49
frontend/src/apps/draft/constants.js
Normal file
49
frontend/src/apps/draft/constants.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// AUTO-GENERATED. Do not edit by hand.
|
||||
// Run: python scripts/generate_js_constants.py
|
||||
|
||||
|
||||
export const DraftMessage = {
|
||||
PARTICIPANT_JOIN_REQUEST: "participant.join.request",
|
||||
PARTICIPANT_JOIN_CONFIRM: "participant.join.confirm",
|
||||
PARTICIPANT_JOIN_REJECT: "participant.join.reject",
|
||||
PARTICIPANT_LEAVE_INFORM: "participant.leave.inform",
|
||||
USER_JOIN_INFORM: "user.join.inform",
|
||||
USER_LEAVE_INFORM: "user.leave.inform",
|
||||
USER_IDENTIFICATION_INFORM: "user.identification.inform",
|
||||
PHASE_CHANGE_INFORM: "phase.change.inform",
|
||||
PHASE_CHANGE_REQUEST: "phase.change.request",
|
||||
PHASE_CHANGE_CONFIRM: "phase.change.confirm",
|
||||
STATUS_SYNC_REQUEST: "status.sync.request",
|
||||
STATUS_SYNC_INFORM: "status.sync.inform",
|
||||
DRAFT_INDEX_ADVANCE_REQUEST: "draft.index.advance.request",
|
||||
DRAFT_INDEX_ADVANCE_CONFIRM: "draft.index.advance.confirm",
|
||||
ORDER_DETERMINE_REQUEST: "order.determine.request",
|
||||
ORDER_DETERMINE_CONFIRM: "order.determine.confirm",
|
||||
BID_START_INFORM: "bid.start.inform",
|
||||
BID_START_REQUEST: "bid.start.request",
|
||||
BID_PLACE_REQUEST: "bid.place.request",
|
||||
BID_UPDATE_INFORM: "bid.update.inform",
|
||||
BID_END_INFORM: "bid.end.inform",
|
||||
NOMINATION_SUBMIT_REQUEST: "nomination.submit.request",
|
||||
NOMINATION_CONFIRM: "nomination.submit.confirm",
|
||||
};
|
||||
|
||||
export const DraftPhase = {
|
||||
WAITING: 10,
|
||||
DETERMINE_ORDER: 20,
|
||||
NOMINATING: 30,
|
||||
BIDDING: 40,
|
||||
AWARDING: 50,
|
||||
FINALIZING: 60,
|
||||
};
|
||||
|
||||
export const DraftPhaseLabel = {
|
||||
[DraftPhase.WAITING]: "waiting",
|
||||
[DraftPhase.DETERMINE_ORDER]: "determine_order",
|
||||
[DraftPhase.NOMINATING]: "nominating",
|
||||
[DraftPhase.BIDDING]: "bidding",
|
||||
[DraftPhase.AWARDING]: "awarding",
|
||||
[DraftPhase.FINALIZING]: "finalizing",
|
||||
};
|
||||
|
||||
export const DraftPhasesOrdered = [DraftPhase.WAITING, DraftPhase.DETERMINE_ORDER, DraftPhase.NOMINATING, DraftPhase.BIDDING, DraftPhase.AWARDING, DraftPhase.FINALIZING];
|
||||
164
frontend/src/apps/draft/participant/DraftParticipant.jsx
Normal file
164
frontend/src/apps/draft/participant/DraftParticipant.jsx
Normal file
@@ -0,0 +1,164 @@
|
||||
// DraftAdmin.jsx
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { useWebSocket } from "../common/WebSocketContext.jsx";
|
||||
import { WebSocketStatus } from "../common/WebSocketStatus.jsx";
|
||||
import { DraftMessage, DraftPhaseLabel, DraftPhases } from '../constants.js';
|
||||
import { fetchDraftDetails, handleUserIdentifyMessages, isEmptyObject } from "../common/utils.js";
|
||||
import { DraftMoviePool } from "../common/DraftMoviePool.jsx";
|
||||
import { ParticipantList } from "../common/ParticipantList.jsx";
|
||||
import { DraftCountdownClock } from "../common/DraftCountdownClock.jsx"
|
||||
import { handleDraftStatusMessages } from '../common/utils.js'
|
||||
|
||||
const NominateMenu = ({ socket, draftState, draftDetails, currentUser }) => {
|
||||
if (!socket || isEmptyObject(draftDetails) || isEmptyObject(draftState)) return;
|
||||
const currentDrafter = draftState.draft_order[draftState.draft_index]
|
||||
if (currentUser != currentDrafter) return;
|
||||
const { movies } = draftDetails
|
||||
|
||||
const requestNomination = (event) => {
|
||||
event.preventDefault()
|
||||
const formData = new FormData(event.target)
|
||||
socket.send(JSON.stringify({
|
||||
type: DraftMessage.NOMINATION_SUBMIT_REQUEST,
|
||||
payload: {
|
||||
id: formData.get('movie'),
|
||||
user: currentUser
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label>Nominate</label>
|
||||
<div className="d-flex">
|
||||
<form onSubmit={requestNomination}>
|
||||
<select className="form-control" name="movie">
|
||||
{movies.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn-primary">Nominate</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const DraftParticipant = ({ draftSessionId }) => {
|
||||
const socket = useWebSocket();
|
||||
const [draftState, setDraftState] = useState({});
|
||||
const [draftDetails, setDraftDetails] = useState({});
|
||||
const [currentUser, setCurrentUser] = useState(null);
|
||||
|
||||
const [movies, setMovies] = useState([]);
|
||||
console.log(socket)
|
||||
|
||||
useEffect(() => {
|
||||
fetchDraftDetails(draftSessionId)
|
||||
.then((data) => {
|
||||
console.log("Fetched draft data", data)
|
||||
setMovies(data.movies)
|
||||
setDraftDetails(data)
|
||||
})
|
||||
}, [draftSessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
socket.onclose = (event) => {
|
||||
console.log('Websocket Closed')
|
||||
}
|
||||
}, [socket])
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const draftStatusMessageHandler = (event) => handleDraftStatusMessages(event, setDraftState)
|
||||
const userIdentifyMessageHandler = (event) => handleUserIdentifyMessages(event, setCurrentUser)
|
||||
socket.addEventListener('message', draftStatusMessageHandler);
|
||||
socket.addEventListener('message', userIdentifyMessageHandler);
|
||||
|
||||
return () => {
|
||||
socket.removeEventListener('message', draftStatusMessageHandler);
|
||||
socket.removeEventListener('message', userIdentifyMessageHandler);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
return (
|
||||
<div className="draft-participant">
|
||||
<section class="panel draft-live">
|
||||
<header class="panel-header d-flex justify-content-between align-items-center">
|
||||
<h2 class="panel-title">Draft Live</h2>
|
||||
<div class="d-flex gap-1">
|
||||
<div class="phase-indicator badge bg-primary">{DraftPhaseLabel[draftState.phase]}</div>
|
||||
<WebSocketStatus socket={socket} />
|
||||
</div>
|
||||
</header>
|
||||
<div class="panel-body">
|
||||
<div class="draft-live-state-container">
|
||||
<DraftCountdownClock endTime={draftState.bidding_timer_end}></DraftCountdownClock>
|
||||
<div class="pick-description">
|
||||
{console.log("draft_state", draftState)}
|
||||
<div>Round {draftState.current_pick?.round}</div>
|
||||
<div>Pick {draftState.current_pick?.pick_in_round}</div>
|
||||
<div>{draftState.current_pick?.overall+1} Overall</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="current-movie card"></div>
|
||||
<div class="bid-controls btn-group"></div>
|
||||
<ParticipantList
|
||||
currentUser={draftState.current_pick?.participant}
|
||||
draftState={draftState}
|
||||
draftDetails={draftDetails}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel draft-board">
|
||||
<header class="panel-header">
|
||||
<h2 class="panel-title">Draft Board</h2>
|
||||
</header>
|
||||
<div class="panel-body">
|
||||
<div class="current-movie-detail card"></div>
|
||||
<div class="movie-filters"></div>
|
||||
<DraftMoviePool isParticipant={true} draftDetails={draftDetails} draftState={draftState}></DraftMoviePool>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="panel my-team">
|
||||
<header class="panel-header">
|
||||
<h2 class="panel-title">My Team</h2>
|
||||
</header>
|
||||
<div class="panel-body">
|
||||
<ul class="team-movie-list list-group">
|
||||
<li class="team-movie-item list-group-item"></li>
|
||||
</ul>
|
||||
<div class="budget-status"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="panel teams">
|
||||
<header class="panel-header">
|
||||
<h2 class="panel-title">Teams</h2>
|
||||
</header>
|
||||
<div class="panel-body">
|
||||
<ul class="team-list list-group">
|
||||
<li class="team-item list-group-item">
|
||||
<div class="team-name fw-bold"></div>
|
||||
<ul class="team-movie-list list-group list-group-flush">
|
||||
<li class="team-movie-item list-group-item"></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<NominateMenu socket={socket} currentUser={currentUser} draftState={draftState} draftDetails={draftDetails}></NominateMenu>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +1,40 @@
|
||||
import './scss/styles.scss'
|
||||
console.log("Webpack HMR loaded!");
|
||||
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { WebSocketProvider } from "./apps/draft/common/WebSocketContext.jsx";
|
||||
import { DraftAdmin } from "./apps/draft/admin/DraftAdmin.jsx";
|
||||
import { DraftParticipant} from './apps/draft/participant/DraftParticipant.jsx'
|
||||
import { DraftDebug} from './apps/draft/DraftDebug.jsx'
|
||||
|
||||
|
||||
const draftAdminRoot = document.getElementById("draft-admin-root");
|
||||
const draftPartipantRoot = document.getElementById("draft-participant-root")
|
||||
const draftDebugRoot = document.getElementById("draft-debug-root")
|
||||
const {draftSessionId} = window; // from backend template
|
||||
|
||||
if (draftPartipantRoot) {
|
||||
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/participant`;
|
||||
createRoot(draftPartipantRoot).render(
|
||||
<WebSocketProvider url={wsUrl}>
|
||||
<DraftParticipant draftSessionId={draftSessionId} />
|
||||
</WebSocketProvider>
|
||||
);
|
||||
}
|
||||
if (draftAdminRoot) {
|
||||
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/admin`;
|
||||
createRoot(draftAdminRoot).render(
|
||||
<WebSocketProvider url={wsUrl}>
|
||||
<DraftAdmin draftSessionId={draftSessionId}/>
|
||||
</WebSocketProvider>
|
||||
);
|
||||
}
|
||||
if (draftDebugRoot) {
|
||||
console.log('draft-debug')
|
||||
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/admin`;
|
||||
createRoot(draftDebugRoot).render(
|
||||
<WebSocketProvider url={wsUrl}>
|
||||
<DraftDebug draftSessionId={draftSessionId}/>
|
||||
</WebSocketProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
@use '../../node_modules/bootstrap/scss/bootstrap.scss';
|
||||
@use './fonts/graphique.css';
|
||||
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Oswald:wght@200..700&display=swap');
|
||||
@use "../../node_modules/bootstrap/scss/bootstrap.scss";
|
||||
@use "./fonts/graphique.css";
|
||||
@import url("https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Oswald:wght@200..700&display=swap");
|
||||
|
||||
|
||||
|
||||
.navbar{
|
||||
.navbar {
|
||||
// background-color: #582f0e;
|
||||
@extend .border-bottom;
|
||||
// font-family: "Bebas Neue";
|
||||
@@ -15,4 +13,141 @@
|
||||
font-family: "Graphique";
|
||||
font-size: x-large;
|
||||
}
|
||||
}
|
||||
|
||||
.draft-panel {
|
||||
@extend .mt-4;
|
||||
@extend .border;
|
||||
@extend .rounded-2;
|
||||
@extend .p-2;
|
||||
@extend .pt-1;
|
||||
label {
|
||||
@extend .form-label;
|
||||
}
|
||||
input {
|
||||
@extend .form-control;
|
||||
}
|
||||
}
|
||||
|
||||
.message-log {
|
||||
max-height: 300px;
|
||||
overflow-y: scroll;
|
||||
font-family: monospace;
|
||||
background: #f8f9fa;
|
||||
padding: 1em;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.stop-light {
|
||||
@extend .me-2;
|
||||
// @extend .badge;
|
||||
// @extend .rounded-pill;
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.success {
|
||||
@extend .bg-success;
|
||||
}
|
||||
|
||||
.danger {
|
||||
@extend .bg-danger;
|
||||
}
|
||||
.draft-panel {
|
||||
}
|
||||
|
||||
.draft-phase-container {
|
||||
label {
|
||||
@extend .fs-3;
|
||||
}
|
||||
.change-phase {
|
||||
button {
|
||||
@extend .btn;
|
||||
@extend .btn-light;
|
||||
@extend .p-0;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
--bs-list-group-active-bg: var(--bs-primary-bg-subtle);
|
||||
--bs-list-group-active-color: $dark;
|
||||
@extend .list-group;
|
||||
@extend .list-group-horizontal;
|
||||
@extend .ms-1;
|
||||
@extend .me-1;
|
||||
li {
|
||||
@extend .list-group-item;
|
||||
@extend .p-1;
|
||||
@extend .ps-2;
|
||||
@extend .pe-2;
|
||||
|
||||
&.current-phase {
|
||||
@extend .active;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.participant-list-container,
|
||||
.movie-pool-container {
|
||||
max-width: 575.98px;
|
||||
label {
|
||||
@extend .fs-3;
|
||||
}
|
||||
@extend .list-group;
|
||||
ol,
|
||||
ul {
|
||||
@extend .p-0;
|
||||
}
|
||||
ol {
|
||||
@extend .list-group-numbered;
|
||||
}
|
||||
li {
|
||||
@extend .list-group-item;
|
||||
@extend .d-flex;
|
||||
@extend .justify-content-between;
|
||||
@extend .align-items-center;
|
||||
span {
|
||||
@extend .me-auto;
|
||||
@extend .ps-1;
|
||||
}
|
||||
}
|
||||
.current-user {
|
||||
&::after {
|
||||
content: " *";
|
||||
font-size: 1em; // adjust as needed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.draft-participant {
|
||||
display: flex;
|
||||
flex-wrap: wrap; /* allow panels to wrap */
|
||||
gap: 1rem; /* space between panels */
|
||||
justify-content: center; /* center the panels horizontally */
|
||||
|
||||
.panel {
|
||||
flex: 1 1 350px; /* grow/shrink, base width */
|
||||
max-width: 450px; /* never go beyond this */
|
||||
min-width: 300px; /* keeps them from getting too small */
|
||||
}
|
||||
.panel.draft-live {
|
||||
.draft-live-state-container {
|
||||
@extend .d-flex;
|
||||
.countdown-clock {
|
||||
@extend .fs-1;
|
||||
@extend .fw-bold;
|
||||
@extend .col;
|
||||
@extend .align-content-center;
|
||||
@extend .text-center;
|
||||
}
|
||||
.pick-description{
|
||||
@extend .col;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
103
scripts/generate_js_constants.py
Normal file
103
scripts/generate_js_constants.py
Normal 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 don’t 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 aren’t 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()
|
||||
Reference in New Issue
Block a user