From fb35c70ef02fde5687f2acd09847c1f4b7c3baba Mon Sep 17 00:00:00 2001 From: Anthony Correa Date: Mon, 21 Jul 2025 21:31:13 -0500 Subject: [PATCH] initial commit --- .gitignore | 5 + boxofficefantasy.code-workspace | 124 ++++++++++ boxofficefantasy/__init__.py | 0 boxofficefantasy/admin.py | 11 + boxofficefantasy/apps.py | 6 + boxofficefantasy/integrations/tmdb.py | 51 ++++ boxofficefantasy/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/import_legacy.py | 64 +++++ boxofficefantasy/migrations/0001_initial.py | 99 ++++++++ .../0002_alter_userseasonentry_options.py | 17 ++ ...season_end_date_alter_season_start_date.py | 23 ++ .../migrations/0004_league_slug.py | 18 ++ .../migrations/0005_season_label.py | 19 ++ ...rename_entry_pick_season_entry_and_more.py | 52 +++++ .../migrations/0007_alter_pick_season.py | 19 ++ .../migrations/0008_alter_movie_imdb_id.py | 18 ++ boxofficefantasy/migrations/__init__.py | 0 boxofficefantasy/models.py | 106 +++++++++ boxofficefantasy/templates/base.dj.html | 34 +++ boxofficefantasy/templates/movie.dj.html | 15 ++ boxofficefantasy/templates/movies.dj.html | 34 +++ boxofficefantasy/templates/scoreboard.dj.html | 52 +++++ boxofficefantasy/templates/seasons.dj.html | 14 ++ boxofficefantasy/templates/teams.dj.html | 39 ++++ boxofficefantasy/tests.py | 3 + boxofficefantasy/urls.py | 29 +++ boxofficefantasy/views.py | 221 ++++++++++++++++++ boxofficefantasy_project/__init__.py | 0 boxofficefantasy_project/asgi.py | 16 ++ boxofficefantasy_project/settings.py | 131 +++++++++++ boxofficefantasy_project/urls.py | 29 +++ boxofficefantasy_project/wsgi.py | 16 ++ manage.py | 22 ++ 34 files changed, 1287 insertions(+) create mode 100644 .gitignore create mode 100644 boxofficefantasy.code-workspace create mode 100644 boxofficefantasy/__init__.py create mode 100644 boxofficefantasy/admin.py create mode 100644 boxofficefantasy/apps.py create mode 100644 boxofficefantasy/integrations/tmdb.py create mode 100644 boxofficefantasy/management/__init__.py create mode 100644 boxofficefantasy/management/commands/__init__.py create mode 100644 boxofficefantasy/management/commands/import_legacy.py create mode 100644 boxofficefantasy/migrations/0001_initial.py create mode 100644 boxofficefantasy/migrations/0002_alter_userseasonentry_options.py create mode 100644 boxofficefantasy/migrations/0003_alter_season_end_date_alter_season_start_date.py create mode 100644 boxofficefantasy/migrations/0004_league_slug.py create mode 100644 boxofficefantasy/migrations/0005_season_label.py create mode 100644 boxofficefantasy/migrations/0006_rename_entry_pick_season_entry_and_more.py create mode 100644 boxofficefantasy/migrations/0007_alter_pick_season.py create mode 100644 boxofficefantasy/migrations/0008_alter_movie_imdb_id.py create mode 100644 boxofficefantasy/migrations/__init__.py create mode 100644 boxofficefantasy/models.py create mode 100644 boxofficefantasy/templates/base.dj.html create mode 100644 boxofficefantasy/templates/movie.dj.html create mode 100644 boxofficefantasy/templates/movies.dj.html create mode 100644 boxofficefantasy/templates/scoreboard.dj.html create mode 100644 boxofficefantasy/templates/seasons.dj.html create mode 100644 boxofficefantasy/templates/teams.dj.html create mode 100644 boxofficefantasy/tests.py create mode 100644 boxofficefantasy/urls.py create mode 100644 boxofficefantasy/views.py create mode 100644 boxofficefantasy_project/__init__.py create mode 100644 boxofficefantasy_project/asgi.py create mode 100644 boxofficefantasy_project/settings.py create mode 100644 boxofficefantasy_project/urls.py create mode 100644 boxofficefantasy_project/wsgi.py create mode 100755 manage.py 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 %} + + + + + + + + + +
+
My Site Header
+
+ +
+ {% 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 }})

+ +{% 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 +

+
+ + +{%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()