commit fb35c70ef02fde5687f2acd09847c1f4b7c3baba
Author: Anthony Correa
Date: Mon Jul 21 21:31:13 2025 -0500
initial commit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..07bbe35
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+__pycache__
+*.sqlite3
+media/
+*.csv
+.env
diff --git a/boxofficefantasy.code-workspace b/boxofficefantasy.code-workspace
new file mode 100644
index 0000000..a7b9f89
--- /dev/null
+++ b/boxofficefantasy.code-workspace
@@ -0,0 +1,124 @@
+{
+ "folders": [
+ {
+ "path": "."
+ }
+ ],
+ "launch": {
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Run Django Server",
+ "type": "debugpy",
+ "request": "launch",
+ "program": "${workspaceFolder}/manage.py",
+ "args": ["runserver"],
+ "django": true,
+ "console": "integratedTerminal",
+ "envFile": "${workspaceFolder}/.env"
+ },
+ {
+ "name": "Launch Chrome",
+ "type": "chrome",
+ "request": "launch",
+ "url": "http://127.0.0.1:8000", // adjust based on your local server
+ "webRoot": "${workspaceFolder}",
+ "sourceMaps": true,
+ "trace": true
+ },
+ {
+ "name": "Debug: Import 2014-2019.csv",
+ "type": "debugpy",
+ "request": "launch",
+ "program": "${workspaceFolder}/manage.py",
+ "args": ["import_legacy", "./data/2014-2019.csv"],
+ "django": true,
+ "console": "integratedTerminal"
+ }
+ ],
+ "compounds":[{
+ "name": "Django + Chrome",
+ "configurations": ["Run Django Server", "Launch Chrome"],
+ "type": "compound"
+ }]
+ },
+ "tasks": {
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "🗑️ Delete all Movies",
+ "type": "shell",
+ "command": "${config:python.defaultInterpreterPath}",
+ "args": [
+ "manage.py",
+ "shell",
+ "-c",
+ "from boxofficefantasy.models import Movie; Movie.objects.all().delete()"
+ ],
+ "group": "build",
+ "problemMatcher": []
+ },
+ {
+ "label": "Import 2014-2019.csv",
+ "type": "shell",
+ "command": "${config:python.defaultInterpreterPath}",
+ "args": ["manage.py", "import_legacy", "./data/2014-2019.csv"],
+ "problemMatcher": []
+ },
+ {
+ "label": "📦 Make Migrations",
+ "type": "shell",
+ "command": "${config:python.defaultInterpreterPath}",
+ "args": ["manage.py", "makemigrations"],
+ "group": "build",
+ "problemMatcher": []
+ },
+ {
+ "label": "🔄 Apply Migrations",
+ "type": "shell",
+ "command": "${config:python.defaultInterpreterPath}",
+ "args": ["manage.py", "migrate"],
+ "group": "build",
+ "problemMatcher": []
+ },
+ {
+ "label": "Import ",
+ "type": "shell",
+ "command": "${config:python.defaultInterpreterPath}",
+ "args": ["manage.py", "migrate"],
+ "group": "build",
+ "problemMatcher": []
+ },
+ {
+ "label": "📦 🔄 Make & Apply Migratations",
+ "dependsOn": ["📦 Make Migrations", "🔄 Apply Migrations"],
+ "dependsOrder": "sequence",
+ "problemMatcher": [],
+ "group": {
+ "kind": "build",
+ "isDefault": true
+ }
+ }
+ ]
+ },
+ "settings": {
+ "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
+ "[python]": {
+ "editor.defaultFormatter": "ms-python.black-formatter"
+ },
+ "[django-html]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.quickSuggestions": {
+ "other": true,
+ "comments": true,
+ "strings": true
+ }
+ },
+ "files.associations": {
+ "*.dj.html": "django-html"
+ },
+ "emmet.includeLanguages": {
+ "django-html": "html"
+ }
+ }
+}
diff --git a/boxofficefantasy/__init__.py b/boxofficefantasy/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/boxofficefantasy/admin.py b/boxofficefantasy/admin.py
new file mode 100644
index 0000000..910cf32
--- /dev/null
+++ b/boxofficefantasy/admin.py
@@ -0,0 +1,11 @@
+from django.contrib import admin
+from .models import League, Season, UserSeasonEntry, Movie, MovieMetric, ScoringRule, Pick
+
+# Register your models here.
+admin.site.register(League)
+admin.site.register(Season)
+admin.site.register(UserSeasonEntry)
+admin.site.register(Movie)
+admin.site.register(MovieMetric)
+admin.site.register(ScoringRule)
+admin.site.register(Pick)
\ No newline at end of file
diff --git a/boxofficefantasy/apps.py b/boxofficefantasy/apps.py
new file mode 100644
index 0000000..aa62cc5
--- /dev/null
+++ b/boxofficefantasy/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class BoxofficefantasyConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'boxofficefantasy'
diff --git a/boxofficefantasy/integrations/tmdb.py b/boxofficefantasy/integrations/tmdb.py
new file mode 100644
index 0000000..8e8cec6
--- /dev/null
+++ b/boxofficefantasy/integrations/tmdb.py
@@ -0,0 +1,51 @@
+import requests
+from django.core.cache import cache
+from django.conf import settings
+from django.core.files.base import ContentFile
+from django.core.files.storage import default_storage
+from tmdbv3api import TMDb, Movie
+
+# Configure TMDb API client
+tmdb = TMDb()
+tmdb.api_key = settings.TMDB_API_KEY
+tmdb.language = "en"
+
+TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w500"
+
+def get_tmdb_movie_by_imdb(imdb_id):
+ """
+ Fetch TMDb metadata by IMDb ID, using cache to avoid redundant API calls.
+ """
+ cache_key = f"tmdb:movie:{imdb_id}"
+ cached = cache.get(cache_key)
+ if cached:
+ return cached
+
+ results = Movie().external(external_id=imdb_id, external_source="imdb_id")
+ if not results:
+ return None
+
+ movie_data = results.movie_results[0]
+ cache.set(cache_key, movie_data, timeout=60 * 60 * 24) # 1 day
+ return movie_data
+
+
+def cache_tmdb_poster(poster_path):
+ """
+ Download and cache the TMDb poster locally, returning a local URL.
+ """
+ if not poster_path:
+ return None
+
+ filename = f"tmdb_posters/{poster_path.strip('/')}"
+ if default_storage.exists(filename):
+ return default_storage.url(filename)
+
+ url = f"{TMDB_IMAGE_BASE}{poster_path}"
+ response = requests.get(url)
+ if response.status_code == 200:
+ content = ContentFile(response.content)
+ default_storage.save(filename, content)
+ return default_storage.url(filename)
+
+ return None
\ No newline at end of file
diff --git a/boxofficefantasy/management/__init__.py b/boxofficefantasy/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/boxofficefantasy/management/commands/__init__.py b/boxofficefantasy/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/boxofficefantasy/management/commands/import_legacy.py b/boxofficefantasy/management/commands/import_legacy.py
new file mode 100644
index 0000000..c4c24fb
--- /dev/null
+++ b/boxofficefantasy/management/commands/import_legacy.py
@@ -0,0 +1,64 @@
+import csv
+from django.core.management.base import BaseCommand
+from boxofficefantasy.models import Movie, MovieMetric, UserSeasonEntry, Pick, Season
+from django.contrib.auth.models import User
+
+
+class Command(BaseCommand):
+ help = "Import legacy draft/score CSV into boxofficefantasy app"
+
+ def add_arguments(self, parser):
+ parser.add_argument("csv_path", type=str)
+
+ def handle(self, *args, **options):
+ path = options["csv_path"]
+ with open(path, newline="", encoding="utf-8") as f:
+ reader = csv.DictReader(f)
+ for row in reader:
+ # 1. Get or create the Movie
+ movie, _ = Movie.objects.get_or_create(
+ title=row["Title"] if row["Title"] != 'NULL' else row["draftFilmTitle"],
+ defaults={
+ "imdb_id": row["imdbID"] if row["imdbID"] != 'NULL' else None,
+ "bom_legacy_id": row["mojoID"] if row["mojoID"] != 'NULL' else None,
+ "bom_id": None,
+ },
+ )
+
+ # 2. Add the MovieStat for domestic gross (if present)
+ if row.get("domesticgross"):
+ MovieMetric.objects.get_or_create(
+ movie=movie,
+ key="domestic_gross",
+ value=float(row["domesticgross"]) if row["domesticgross"] != 'NULL' else 0,
+ )
+
+ # 3. Create the Season (if needed)
+ season, _ = Season.objects.get_or_create(
+ year=row["year"],
+ defaults={"league_id": 1}, # Change if you have multiple leagues
+ )
+
+ # 3.5 Create the User (if needed)
+ user, did_create_new_user = User.objects.get_or_create(
+ username=row["ownerId"], email=f"{row['ownerId']}@not_an_email.com"
+ )
+ if did_create_new_user:
+ user.set_unusable_password()
+
+ # 4. Create the UserSeasonEntry
+ season_entry, _ = UserSeasonEntry.objects.get_or_create(
+ user=user, # Assumes user with that ID exists
+ season=season,
+ defaults={"team_name": row["teamname"]},
+ )
+
+ # # 5. Create the Pick
+ Pick.objects.get_or_create(
+ season_entry=season_entry,
+ season=season,
+ movie=movie,
+ defaults={"bid_amount": float(row["purchaseprice"])},
+ )
+
+ self.stdout.write(self.style.SUCCESS("Import completed."))
diff --git a/boxofficefantasy/migrations/0001_initial.py b/boxofficefantasy/migrations/0001_initial.py
new file mode 100644
index 0000000..130dfb9
--- /dev/null
+++ b/boxofficefantasy/migrations/0001_initial.py
@@ -0,0 +1,99 @@
+# Generated by Django 5.2.4 on 2025-07-18 20:01
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Movie',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', models.CharField(max_length=255)),
+ ('imdb_id', models.CharField(max_length=20, unique=True)),
+ ('bom_id', models.CharField(blank=True, max_length=50, null=True)),
+ ('bom_legacy_id', models.CharField(blank=True, max_length=50, null=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='League',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=100)),
+ ('commissioner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Season',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('year', models.IntegerField()),
+ ('start_date', models.DateField()),
+ ('end_date', models.DateField()),
+ ('league', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='boxofficefantasy.league')),
+ ],
+ options={
+ 'unique_together': {('league', 'year')},
+ },
+ ),
+ migrations.CreateModel(
+ name='ScoringRule',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('formula', models.TextField(help_text="Python expression using keys like 'domestic_gross', 'oscars', 'multiplier'.")),
+ ('season', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='boxofficefantasy.season')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='UserSeasonEntry',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('team_name', models.CharField(max_length=100)),
+ ('season', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='boxofficefantasy.season')),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Pick',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('bid_amount', models.DecimalField(decimal_places=2, max_digits=10)),
+ ('movie', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='boxofficefantasy.movie')),
+ ('entry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='boxofficefantasy.userseasonentry')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='MovieMetric',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('key', models.CharField(max_length=100)),
+ ('value', models.FloatField()),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('movie', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='boxofficefantasy.movie')),
+ ],
+ options={
+ 'unique_together': {('movie', 'key')},
+ },
+ ),
+ migrations.AddConstraint(
+ model_name='userseasonentry',
+ constraint=models.UniqueConstraint(fields=('season', 'team_name'), name='unique_team_name_per_season'),
+ ),
+ migrations.AlterUniqueTogether(
+ name='userseasonentry',
+ unique_together={('user', 'season')},
+ ),
+ migrations.AlterUniqueTogether(
+ name='pick',
+ unique_together={('entry', 'movie')},
+ ),
+ ]
diff --git a/boxofficefantasy/migrations/0002_alter_userseasonentry_options.py b/boxofficefantasy/migrations/0002_alter_userseasonentry_options.py
new file mode 100644
index 0000000..4fa286a
--- /dev/null
+++ b/boxofficefantasy/migrations/0002_alter_userseasonentry_options.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.2.4 on 2025-07-18 20:11
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('boxofficefantasy', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='userseasonentry',
+ options={'verbose_name': 'User Season Entry', 'verbose_name_plural': 'User Season Entries'},
+ ),
+ ]
diff --git a/boxofficefantasy/migrations/0003_alter_season_end_date_alter_season_start_date.py b/boxofficefantasy/migrations/0003_alter_season_end_date_alter_season_start_date.py
new file mode 100644
index 0000000..787d1bf
--- /dev/null
+++ b/boxofficefantasy/migrations/0003_alter_season_end_date_alter_season_start_date.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.2.4 on 2025-07-19 00:14
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('boxofficefantasy', '0002_alter_userseasonentry_options'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='season',
+ name='end_date',
+ field=models.DateField(null=True),
+ ),
+ migrations.AlterField(
+ model_name='season',
+ name='start_date',
+ field=models.DateField(null=True),
+ ),
+ ]
diff --git a/boxofficefantasy/migrations/0004_league_slug.py b/boxofficefantasy/migrations/0004_league_slug.py
new file mode 100644
index 0000000..a765e0a
--- /dev/null
+++ b/boxofficefantasy/migrations/0004_league_slug.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.4 on 2025-07-19 14:03
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('boxofficefantasy', '0003_alter_season_end_date_alter_season_start_date'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='league',
+ name='slug',
+ field=models.SlugField(blank=True, unique=True),
+ ),
+ ]
diff --git a/boxofficefantasy/migrations/0005_season_label.py b/boxofficefantasy/migrations/0005_season_label.py
new file mode 100644
index 0000000..e8421a7
--- /dev/null
+++ b/boxofficefantasy/migrations/0005_season_label.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.2.4 on 2025-07-19 14:36
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('boxofficefantasy', '0004_league_slug'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='season',
+ name='label',
+ field=models.CharField(default='Summer', max_length=50),
+ preserve_default=False,
+ ),
+ ]
diff --git a/boxofficefantasy/migrations/0006_rename_entry_pick_season_entry_and_more.py b/boxofficefantasy/migrations/0006_rename_entry_pick_season_entry_and_more.py
new file mode 100644
index 0000000..672c007
--- /dev/null
+++ b/boxofficefantasy/migrations/0006_rename_entry_pick_season_entry_and_more.py
@@ -0,0 +1,52 @@
+# Generated by Django 5.2.4 on 2025-07-19 15:59
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+def set_season_from_season_entry(apps, schema_editor):
+ Pick = apps.get_model("boxofficefantasy", "Pick")
+
+ for pick in Pick.objects.all():
+ pick.season = pick.season_entry.season
+ pick.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("boxofficefantasy", "0005_season_label"),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name="pick",
+ old_name="entry",
+ new_name="season_entry",
+ ),
+ migrations.AlterUniqueTogether(
+ name="pick",
+ unique_together={("season_entry", "movie")},
+ ),
+ migrations.AddField(
+ model_name="pick",
+ name="season",
+ field=models.ForeignKey(
+ default=None,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="boxofficefantasy.season",
+ null=True,
+ ),
+ preserve_default=False,
+ ),
+ migrations.RunPython(set_season_from_season_entry),
+ migrations.AlterField(
+ model_name="pick",
+ name="season",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="boxofficefantasy.season",
+ null=True,
+ ),
+ ),
+ ]
diff --git a/boxofficefantasy/migrations/0007_alter_pick_season.py b/boxofficefantasy/migrations/0007_alter_pick_season.py
new file mode 100644
index 0000000..1cfbc8d
--- /dev/null
+++ b/boxofficefantasy/migrations/0007_alter_pick_season.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.2.4 on 2025-07-19 16:07
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('boxofficefantasy', '0006_rename_entry_pick_season_entry_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='pick',
+ name='season',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='boxofficefantasy.season'),
+ ),
+ ]
diff --git a/boxofficefantasy/migrations/0008_alter_movie_imdb_id.py b/boxofficefantasy/migrations/0008_alter_movie_imdb_id.py
new file mode 100644
index 0000000..459377c
--- /dev/null
+++ b/boxofficefantasy/migrations/0008_alter_movie_imdb_id.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.4 on 2025-07-19 16:31
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('boxofficefantasy', '0007_alter_pick_season'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='movie',
+ name='imdb_id',
+ field=models.CharField(blank=True, max_length=20, null=True),
+ ),
+ ]
diff --git a/boxofficefantasy/migrations/__init__.py b/boxofficefantasy/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/boxofficefantasy/models.py b/boxofficefantasy/models.py
new file mode 100644
index 0000000..2399373
--- /dev/null
+++ b/boxofficefantasy/models.py
@@ -0,0 +1,106 @@
+from django.db import models
+from django.contrib.auth.models import User
+from django.utils.text import slugify
+from django.core.exceptions import ValidationError
+
+# 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)
+
+ 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 Meta:
+ 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 Meta:
+ unique_together = ('user', 'season')
+ constraints = [
+ models.UniqueConstraint(fields=['season', 'team_name'], name='unique_team_name_per_season')
+ ]
+ verbose_name = "User Season Entry"
+ verbose_name_plural = "User Season Entries"
+
+ def __str__(self):
+ 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)
+
+ 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.'})
+
+
+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 Meta:
+ 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 Meta:
+ 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}"
\ No newline at end of file
diff --git a/boxofficefantasy/templates/base.dj.html b/boxofficefantasy/templates/base.dj.html
new file mode 100644
index 0000000..4ab1047
--- /dev/null
+++ b/boxofficefantasy/templates/base.dj.html
@@ -0,0 +1,34 @@
+
+
+
+ {% block title %}My Site{% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+
+ {% block content %}
+
+ {% endblock %}
+
+
+
+
+
diff --git a/boxofficefantasy/templates/movie.dj.html b/boxofficefantasy/templates/movie.dj.html
new file mode 100644
index 0000000..3e4b104
--- /dev/null
+++ b/boxofficefantasy/templates/movie.dj.html
@@ -0,0 +1,15 @@
+{% extends "base.dj.html" %} {% load humanize %} {% block title %}Scoreboard –
+{{ season.label }} {{season.year }}{% endblock %} {% block content %}
+{{scoreboard|json_script:"scoreboard-data" }}
+
+ {{tmdb_data.title}}
+
+
+
+
+
+{{tmdb_data.overview}}
+
+{%endblock%}
diff --git a/boxofficefantasy/templates/movies.dj.html b/boxofficefantasy/templates/movies.dj.html
new file mode 100644
index 0000000..10e88a6
--- /dev/null
+++ b/boxofficefantasy/templates/movies.dj.html
@@ -0,0 +1,34 @@
+{% extends "base.dj.html" %} {% load humanize %} {% block title %}Scoreboard –
+{{ season.label }} {{season.year }}{% endblock %} {% block content %}
+{{movies|json_script:"movie_data" }}
+
+ {{ season.league.name }} {{ season.label }} {{ season.year }} Movies
+
+
+
+{%endblock%}
diff --git a/boxofficefantasy/templates/scoreboard.dj.html b/boxofficefantasy/templates/scoreboard.dj.html
new file mode 100644
index 0000000..3e12483
--- /dev/null
+++ b/boxofficefantasy/templates/scoreboard.dj.html
@@ -0,0 +1,52 @@
+{% extends "base.dj.html" %} {% load humanize %} {% block title %}Scoreboard –
+{{ season.label }} {{season.year }}{% endblock %} {% block content %}
+{{scoreboard|json_script:"scoreboard-data" }}
+
+ {{ season.league.name }} {{ season.label }} {{ season.year }} Scoreboard
+
+
+
+{% for entry in scoreboard %}
+{{ entry.team }} ({{ entry.user }})
+
+ {% for pick in entry.picks %}
+ -
+ {{ pick.movie.title }} – ${{ pick.score|floatformat:0|intcomma }} (Bid:
+ ${{pick.bid | floatformat:0}}M)
+
+
+ {% endfor %}
+
+{% endfor %}
+
+{%endblock%}
diff --git a/boxofficefantasy/templates/seasons.dj.html b/boxofficefantasy/templates/seasons.dj.html
new file mode 100644
index 0000000..a713b3a
--- /dev/null
+++ b/boxofficefantasy/templates/seasons.dj.html
@@ -0,0 +1,14 @@
+{% extends "base.dj.html" %} {% load humanize %} {% block content%}
+
+
+{%endblock%}
diff --git a/boxofficefantasy/templates/teams.dj.html b/boxofficefantasy/templates/teams.dj.html
new file mode 100644
index 0000000..f570689
--- /dev/null
+++ b/boxofficefantasy/templates/teams.dj.html
@@ -0,0 +1,39 @@
+{% extends "base.dj.html" %} {% load humanize %} {% block title %}Scoreboard –
+{{ season.label }} {{season.year }}{% endblock %} {% block content %}
+{{movies|json_script:"movie_data" }}
+
+ Teams
+
+
+
+ {%for entry in user_season_entries%}
+ - {{entry.username}} - {{entry.team_name}}
+ {%endfor%}
+
+
+{%endblock%}
diff --git a/boxofficefantasy/tests.py b/boxofficefantasy/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/boxofficefantasy/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/boxofficefantasy/urls.py b/boxofficefantasy/urls.py
new file mode 100644
index 0000000..3984fa8
--- /dev/null
+++ b/boxofficefantasy/urls.py
@@ -0,0 +1,29 @@
+from django.urls import path, include
+from . import views
+
+league_patterns = [
+ # 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("scoreboard/", views.scoreboard_view, name="scoreboard"),
+ path("team//", views.team_view, name="team"),
+ path("team/", views.team_view, name="teams"),
+ path("movie//", views.movie_view, name="movie"),
+ path("movie/", views.movie_view, name="movies")
+]
+
+urlpatterns = [
+ path(
+ "league//season//",
+ include((season_patterns, "boxofficefantasy"), namespace="season")
+ ),
+ path(
+ "league//",
+ include((league_patterns, "boxofficefantasy"), namespace="league")
+ ),
+]
\ No newline at end of file
diff --git a/boxofficefantasy/views.py b/boxofficefantasy/views.py
new file mode 100644
index 0000000..584f100
--- /dev/null
+++ b/boxofficefantasy/views.py
@@ -0,0 +1,221 @@
+from django.shortcuts import render, get_object_or_404
+from django.contrib.auth import get_user_model
+from django.http.response import Http404, HttpResponse
+from boxofficefantasy.models import League, Season, UserSeasonEntry, Movie, Pick
+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)
+ year = int(year)
+ return (label, year)
+ except ValueError:
+ raise Http404("Invalid season format.")
+
+
+# Create your views here.
+def scoreboard_view(request, league_slug, season_slug):
+ # season_slug is something like "summer-2025"
+
+ 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
+ )
+ entries = UserSeasonEntry.objects.filter(season=season).select_related("user")
+ scoreboard = []
+
+ for season_entry in entries:
+ picks = Pick.objects.filter(season_entry=season_entry).select_related("movie")
+ total_score = 0
+ pick_data = []
+
+ for pick in picks:
+ movie = pick.movie
+ metrics = {m.key: m.value for m in movie.moviemetric_set.all()}
+ score = metrics.get("domestic_gross", 0)
+ total_score += score
+ pick_data.append(
+ {
+ "imdb_id": movie.imdb_id,
+ "movie": movie.title,
+ "score": score,
+ "bid": pick.bid_amount,
+ }
+ )
+ pick_data.sort(key=lambda e: e["score"], reverse=True)
+ scoreboard.append(
+ {
+ "team": season_entry.team_name,
+ "user": season_entry.user.username,
+ "total": total_score,
+ "picks": pick_data,
+ }
+ )
+
+ scoreboard.sort(key=lambda e: e["total"], reverse=True)
+
+ return render(
+ request,
+ "scoreboard.dj.html",
+ {
+ "league": league,
+ "season": season,
+ "scoreboard": scoreboard,
+ },
+ )
+
+
+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")
+
+ 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,
+ })
+
+ # 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)
+
+ 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},
+ })
+
+ # 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")
+ 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)
+ })
+
+ 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):
+ if not league_slug:
+ return HttpResponse("Movie View: No league provided", content_type="text/plain")
+
+ # 1️⃣ League only — all movies across seasons
+ if league_slug and not season_slug and not imdb_id:
+ league = get_object_or_404(League, slug=league_slug)
+ picks = Pick.objects.filter(season__league=league)
+ movie_data = [
+ {
+ "title": pick.movie.title,
+ "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})
+
+ # 2️⃣ League + Season — all movies in that season
+ if league_slug and season_slug and not 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
+ )
+ 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,
+ "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})
+
+ # 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)
+ movie = get_object_or_404(Movie, imdb_id=imdb_id)
+
+ 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 = {
+ "movie": movie,
+ "tmdb_data": tmdb_data,
+ "poster_path": cache_tmdb_poster(tmdb_data.poster_path),
+ "metrics": metrics,
+ "picks": picks,
+ "season": season,
+ "league": league,
+ }
+ return render(request, "movie.dj.html", data)
+
+ return HttpResponse("Invalid parameter combination.", content_type="text/plain")
+
+
+def season_view(request, league_slug, season_slug=None):
+ if not league_slug:
+ return HttpResponse("League not specified", content_type="text/plain")
+
+ league = get_object_or_404(League, slug=league_slug)
+
+ # 1️⃣ League only – list all seasons in the league
+ if not season_slug:
+ seasons = [
+ {
+ "label": season.label,
+ "year": season.year,
+ "slug": season.slug,
+ "league": {"name": season.league.name, "slug": season.league.slug},
+ }
+ for season in league.season_set.all()
+ ]
+ 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)
+ season = get_object_or_404(
+ Season, league=league, year=season_year, label__iexact=season_label
+ )
+
+ return render(request, "scoreboard.dj.html", {
+ "season": season,
+ "league": league,
+ })
diff --git a/boxofficefantasy_project/__init__.py b/boxofficefantasy_project/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/boxofficefantasy_project/asgi.py b/boxofficefantasy_project/asgi.py
new file mode 100644
index 0000000..bf41fd4
--- /dev/null
+++ b/boxofficefantasy_project/asgi.py
@@ -0,0 +1,16 @@
+"""
+ASGI config for boxofficefantasy_project project.
+
+It exposes the ASGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
+"""
+
+import os
+
+from django.core.asgi import get_asgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'boxofficefantasy_project.settings')
+
+application = get_asgi_application()
diff --git a/boxofficefantasy_project/settings.py b/boxofficefantasy_project/settings.py
new file mode 100644
index 0000000..62a4b26
--- /dev/null
+++ b/boxofficefantasy_project/settings.py
@@ -0,0 +1,131 @@
+"""
+Django settings for boxofficefantasy_project project.
+
+Generated by 'django-admin startproject' using Django 5.1.4.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.1/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/5.1/ref/settings/
+"""
+
+from pathlib import Path
+import os
+
+# Build paths inside the project like this: BASE_DIR / 'subdir'.
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+
+# Quick-start development settings - unsuitable for production
+# 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'
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+ALLOWED_HOSTS = []
+
+# TMDB API KEY
+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'
+]
+
+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',
+]
+
+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',
+ ],
+ },
+ },
+]
+
+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',
+ }
+}
+
+
+# Password validation
+# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+ },
+]
+
+
+# Internationalization
+# https://docs.djangoproject.com/en/5.1/topics/i18n/
+
+LANGUAGE_CODE = 'en-us'
+
+TIME_ZONE = 'UTC'
+
+USE_I18N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/5.1/howto/static-files/
+
+STATIC_URL = 'static/'
+
+MEDIA_URL = "/media/"
+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'
diff --git a/boxofficefantasy_project/urls.py b/boxofficefantasy_project/urls.py
new file mode 100644
index 0000000..812266b
--- /dev/null
+++ b/boxofficefantasy_project/urls.py
@@ -0,0 +1,29 @@
+"""
+URL configuration for boxofficefantasy_project project.
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+ https://docs.djangoproject.com/en/5.1/topics/http/urls/
+Examples:
+Function views
+ 1. Add an import: from my_app import views
+ 2. Add a URL to urlpatterns: path('', views.home, name='home')
+Class-based views
+ 1. Add an import: from other_app.views import Home
+ 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
+Including another URLconf
+ 1. Import the include() function: from django.urls import include, path
+ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
+"""
+from django.contrib import admin
+from django.urls import path, include
+from django.conf import settings
+from django.conf.urls.static import static
+
+
+urlpatterns = [
+ path('admin/', admin.site.urls),
+ path("", include("boxofficefantasy.urls"))
+]
+
+if settings.DEBUG:
+ urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
\ No newline at end of file
diff --git a/boxofficefantasy_project/wsgi.py b/boxofficefantasy_project/wsgi.py
new file mode 100644
index 0000000..8172b1b
--- /dev/null
+++ b/boxofficefantasy_project/wsgi.py
@@ -0,0 +1,16 @@
+"""
+WSGI config for boxofficefantasy_project project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'boxofficefantasy_project.settings')
+
+application = get_wsgi_application()
diff --git a/manage.py b/manage.py
new file mode 100755
index 0000000..1695ae6
--- /dev/null
+++ b/manage.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+import os
+import sys
+
+
+def main():
+ """Run administrative tasks."""
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'boxofficefantasy_project.settings')
+ try:
+ from django.core.management import execute_from_command_line
+ except ImportError as exc:
+ raise ImportError(
+ "Couldn't import Django. Are you sure it's installed and "
+ "available on your PYTHONPATH environment variable? Did you "
+ "forget to activate a virtual environment?"
+ ) from exc
+ execute_from_command_line(sys.argv)
+
+
+if __name__ == '__main__':
+ main()