feat: add Django web app, CloudKit sync, dashboard, and game_datetime_utc export

Adds the full Django application layer on top of sportstime_parser:
- core: Sport, Team, Stadium, Game models with aliases and league structure
- scraper: orchestration engine, adapter, job management, Celery tasks
- cloudkit: CloudKit sync client, sync state tracking, sync jobs
- dashboard: staff dashboard for monitoring scrapers, sync, review queue
- notifications: email reports for scrape/sync results
- Docker setup for deployment (Dockerfile, docker-compose, entrypoint)

Game exports now use game_datetime_utc (ISO 8601 UTC) instead of
venue-local date+time strings, matching the canonical format used
by the iOS app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-19 14:04:27 -06:00
parent 4353d5943c
commit 63acf7accb
114 changed files with 13070 additions and 887 deletions

438
core/migrations/0001_initial.py Executable file
View File

@@ -0,0 +1,438 @@
# Generated by Django 5.1.15 on 2026-01-26 08:59
import django.db.models.deletion
import simple_history.models
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='Conference',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50)),
('short_name', models.CharField(blank=True, help_text='Short name (e.g., East, West)', max_length=10)),
('order', models.PositiveSmallIntegerField(default=0, help_text='Display order')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Conference',
'verbose_name_plural': 'Conferences',
'ordering': ['sport', 'order', 'name'],
},
),
migrations.CreateModel(
name='Sport',
fields=[
('code', models.CharField(help_text='Sport code (e.g., nba, mlb, nfl)', max_length=10, primary_key=True, serialize=False)),
('name', models.CharField(help_text='Full name (e.g., National Basketball Association)', max_length=100)),
('short_name', models.CharField(help_text='Short name (e.g., NBA)', max_length=20)),
('season_type', models.CharField(choices=[('split', 'Split Year (e.g., 2024-25)'), ('single', 'Single Year (e.g., 2024)')], help_text='Whether season spans two years or one', max_length=10)),
('expected_game_count', models.PositiveIntegerField(default=0, help_text='Expected number of regular season games')),
('season_start_month', models.PositiveSmallIntegerField(default=1, help_text='Month when season typically starts (1-12)')),
('season_end_month', models.PositiveSmallIntegerField(default=12, help_text='Month when season typically ends (1-12)')),
('is_active', models.BooleanField(default=True, help_text='Whether this sport is actively being scraped')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Sport',
'verbose_name_plural': 'Sports',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Division',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50)),
('short_name', models.CharField(blank=True, help_text='Short name', max_length=10)),
('order', models.PositiveSmallIntegerField(default=0, help_text='Display order')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('conference', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='divisions', to='core.conference')),
],
options={
'verbose_name': 'Division',
'verbose_name_plural': 'Divisions',
'ordering': ['conference', 'order', 'name'],
'unique_together': {('conference', 'name')},
},
),
migrations.CreateModel(
name='HistoricalDivision',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('name', models.CharField(max_length=50)),
('short_name', models.CharField(blank=True, help_text='Short name', max_length=10)),
('order', models.PositiveSmallIntegerField(default=0, help_text='Display order')),
('created_at', models.DateTimeField(blank=True, editable=False)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('conference', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='core.conference')),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'historical Division',
'verbose_name_plural': 'historical Divisions',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalSport',
fields=[
('code', models.CharField(db_index=True, help_text='Sport code (e.g., nba, mlb, nfl)', max_length=10)),
('name', models.CharField(help_text='Full name (e.g., National Basketball Association)', max_length=100)),
('short_name', models.CharField(help_text='Short name (e.g., NBA)', max_length=20)),
('season_type', models.CharField(choices=[('split', 'Split Year (e.g., 2024-25)'), ('single', 'Single Year (e.g., 2024)')], help_text='Whether season spans two years or one', max_length=10)),
('expected_game_count', models.PositiveIntegerField(default=0, help_text='Expected number of regular season games')),
('season_start_month', models.PositiveSmallIntegerField(default=1, help_text='Month when season typically starts (1-12)')),
('season_end_month', models.PositiveSmallIntegerField(default=12, help_text='Month when season typically ends (1-12)')),
('is_active', models.BooleanField(default=True, help_text='Whether this sport is actively being scraped')),
('created_at', models.DateTimeField(blank=True, editable=False)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'historical Sport',
'verbose_name_plural': 'historical Sports',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalStadium',
fields=[
('id', models.CharField(db_index=True, help_text='Canonical ID (e.g., stadium_nba_los_angeles_lakers)', max_length=100)),
('name', models.CharField(help_text='Current stadium name', max_length=200)),
('city', models.CharField(max_length=100)),
('state', models.CharField(blank=True, help_text='State/Province (blank for international)', max_length=100)),
('country', models.CharField(default='USA', max_length=100)),
('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
('capacity', models.PositiveIntegerField(blank=True, help_text='Seating capacity', null=True)),
('surface', models.CharField(blank=True, choices=[('grass', 'Natural Grass'), ('turf', 'Artificial Turf'), ('ice', 'Ice'), ('hardwood', 'Hardwood'), ('other', 'Other')], max_length=20)),
('roof_type', models.CharField(blank=True, choices=[('dome', 'Dome (Closed)'), ('retractable', 'Retractable'), ('open', 'Open Air')], max_length=20)),
('opened_year', models.PositiveSmallIntegerField(blank=True, help_text='Year stadium opened', null=True)),
('timezone', models.CharField(blank=True, help_text='IANA timezone (e.g., America/Los_Angeles)', max_length=50)),
('image_url', models.URLField(blank=True, help_text='URL to stadium image')),
('created_at', models.DateTimeField(blank=True, editable=False)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('sport', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='core.sport')),
],
options={
'verbose_name': 'historical Stadium',
'verbose_name_plural': 'historical Stadiums',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalConference',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('name', models.CharField(max_length=50)),
('short_name', models.CharField(blank=True, help_text='Short name (e.g., East, West)', max_length=10)),
('order', models.PositiveSmallIntegerField(default=0, help_text='Display order')),
('created_at', models.DateTimeField(blank=True, editable=False)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('sport', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='core.sport')),
],
options={
'verbose_name': 'historical Conference',
'verbose_name_plural': 'historical Conferences',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.AddField(
model_name='conference',
name='sport',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conferences', to='core.sport'),
),
migrations.CreateModel(
name='Stadium',
fields=[
('id', models.CharField(help_text='Canonical ID (e.g., stadium_nba_los_angeles_lakers)', max_length=100, primary_key=True, serialize=False)),
('name', models.CharField(help_text='Current stadium name', max_length=200)),
('city', models.CharField(max_length=100)),
('state', models.CharField(blank=True, help_text='State/Province (blank for international)', max_length=100)),
('country', models.CharField(default='USA', max_length=100)),
('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
('capacity', models.PositiveIntegerField(blank=True, help_text='Seating capacity', null=True)),
('surface', models.CharField(blank=True, choices=[('grass', 'Natural Grass'), ('turf', 'Artificial Turf'), ('ice', 'Ice'), ('hardwood', 'Hardwood'), ('other', 'Other')], max_length=20)),
('roof_type', models.CharField(blank=True, choices=[('dome', 'Dome (Closed)'), ('retractable', 'Retractable'), ('open', 'Open Air')], max_length=20)),
('opened_year', models.PositiveSmallIntegerField(blank=True, help_text='Year stadium opened', null=True)),
('timezone', models.CharField(blank=True, help_text='IANA timezone (e.g., America/Los_Angeles)', max_length=50)),
('image_url', models.URLField(blank=True, help_text='URL to stadium image')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('sport', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stadiums', to='core.sport')),
],
options={
'verbose_name': 'Stadium',
'verbose_name_plural': 'Stadiums',
'ordering': ['sport', 'city', 'name'],
},
),
migrations.CreateModel(
name='HistoricalTeam',
fields=[
('id', models.CharField(db_index=True, help_text='Canonical ID (e.g., team_nba_lal)', max_length=50)),
('city', models.CharField(help_text='Team city (e.g., Los Angeles)', max_length=100)),
('name', models.CharField(help_text='Team name (e.g., Lakers)', max_length=100)),
('full_name', models.CharField(help_text='Full team name (e.g., Los Angeles Lakers)', max_length=200)),
('abbreviation', models.CharField(help_text='Team abbreviation (e.g., LAL)', max_length=10)),
('primary_color', models.CharField(blank=True, help_text='Primary color hex (e.g., #552583)', max_length=7)),
('secondary_color', models.CharField(blank=True, help_text='Secondary color hex (e.g., #FDB927)', max_length=7)),
('logo_url', models.URLField(blank=True, help_text='URL to team logo')),
('is_active', models.BooleanField(default=True, help_text='Whether team is currently active')),
('created_at', models.DateTimeField(blank=True, editable=False)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('division', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='core.division')),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('sport', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='core.sport')),
('home_stadium', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='core.stadium')),
],
options={
'verbose_name': 'historical Team',
'verbose_name_plural': 'historical Teams',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalStadiumAlias',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('alias', models.CharField(help_text='The alias text to match against', max_length=200)),
('alias_type', models.CharField(choices=[('official', 'Official Name'), ('former', 'Former Name'), ('nickname', 'Nickname'), ('abbreviation', 'Abbreviation')], default='official', max_length=20)),
('valid_from', models.DateField(blank=True, help_text='Date from which this alias is valid (inclusive)', null=True)),
('valid_until', models.DateField(blank=True, help_text='Date until which this alias is valid (inclusive)', null=True)),
('is_primary', models.BooleanField(default=False, help_text='Whether this is the current/primary name')),
('source', models.CharField(blank=True, help_text='Source of this alias', max_length=200)),
('notes', models.TextField(blank=True, help_text='Notes about this alias (e.g., naming rights deal)')),
('created_at', models.DateTimeField(blank=True, editable=False)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('stadium', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='core.stadium')),
],
options={
'verbose_name': 'historical Stadium Alias',
'verbose_name_plural': 'historical Stadium Aliases',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='Team',
fields=[
('id', models.CharField(help_text='Canonical ID (e.g., team_nba_lal)', max_length=50, primary_key=True, serialize=False)),
('city', models.CharField(help_text='Team city (e.g., Los Angeles)', max_length=100)),
('name', models.CharField(help_text='Team name (e.g., Lakers)', max_length=100)),
('full_name', models.CharField(help_text='Full team name (e.g., Los Angeles Lakers)', max_length=200)),
('abbreviation', models.CharField(help_text='Team abbreviation (e.g., LAL)', max_length=10)),
('primary_color', models.CharField(blank=True, help_text='Primary color hex (e.g., #552583)', max_length=7)),
('secondary_color', models.CharField(blank=True, help_text='Secondary color hex (e.g., #FDB927)', max_length=7)),
('logo_url', models.URLField(blank=True, help_text='URL to team logo')),
('is_active', models.BooleanField(default=True, help_text='Whether team is currently active')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('division', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teams', to='core.division')),
('home_stadium', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='home_teams', to='core.stadium')),
('sport', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teams', to='core.sport')),
],
options={
'verbose_name': 'Team',
'verbose_name_plural': 'Teams',
'ordering': ['sport', 'city', 'name'],
},
),
migrations.CreateModel(
name='HistoricalTeamAlias',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('alias', models.CharField(help_text='The alias text to match against', max_length=200)),
('alias_type', models.CharField(choices=[('full_name', 'Full Name'), ('city_name', 'City + Name'), ('abbreviation', 'Abbreviation'), ('nickname', 'Nickname'), ('historical', 'Historical Name')], default='full_name', max_length=20)),
('valid_from', models.DateField(blank=True, help_text='Date from which this alias is valid (inclusive)', null=True)),
('valid_until', models.DateField(blank=True, help_text='Date until which this alias is valid (inclusive)', null=True)),
('is_primary', models.BooleanField(default=False, help_text='Whether this is a primary/preferred alias')),
('source', models.CharField(blank=True, help_text='Source of this alias (e.g., ESPN, Basketball-Reference)', max_length=200)),
('notes', models.TextField(blank=True, help_text='Notes about this alias (e.g., relocation details)')),
('created_at', models.DateTimeField(blank=True, editable=False)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('team', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='core.team')),
],
options={
'verbose_name': 'historical Team Alias',
'verbose_name_plural': 'historical Team Aliases',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalGame',
fields=[
('id', models.CharField(db_index=True, help_text='Canonical ID (e.g., game_nba_2025_20251022_bos_lal)', max_length=100)),
('season', models.PositiveSmallIntegerField(help_text='Season start year (e.g., 2025 for 2025-26 season)')),
('game_date', models.DateTimeField(help_text='Game date and time (UTC)')),
('game_number', models.PositiveSmallIntegerField(blank=True, help_text='Game number for doubleheaders (1 or 2)', null=True)),
('home_score', models.PositiveSmallIntegerField(blank=True, null=True)),
('away_score', models.PositiveSmallIntegerField(blank=True, null=True)),
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('in_progress', 'In Progress'), ('final', 'Final'), ('postponed', 'Postponed'), ('cancelled', 'Cancelled'), ('suspended', 'Suspended')], default='scheduled', max_length=20)),
('is_neutral_site', models.BooleanField(default=False, help_text='Whether game is at neutral site')),
('is_playoff', models.BooleanField(default=False, help_text='Whether this is a playoff game')),
('playoff_round', models.CharField(blank=True, help_text='Playoff round (e.g., Finals, Conference Finals)', max_length=50)),
('raw_home_team', models.CharField(blank=True, help_text='Original scraped home team name', max_length=200)),
('raw_away_team', models.CharField(blank=True, help_text='Original scraped away team name', max_length=200)),
('raw_stadium', models.CharField(blank=True, help_text='Original scraped stadium name', max_length=200)),
('source_url', models.URLField(blank=True, help_text='URL where game was scraped from')),
('created_at', models.DateTimeField(blank=True, editable=False)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('sport', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='core.sport')),
('stadium', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='core.stadium')),
('away_team', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='core.team')),
('home_team', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='core.team')),
],
options={
'verbose_name': 'historical Game',
'verbose_name_plural': 'historical Games',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.AlterUniqueTogether(
name='conference',
unique_together={('sport', 'name')},
),
migrations.CreateModel(
name='StadiumAlias',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('alias', models.CharField(help_text='The alias text to match against', max_length=200)),
('alias_type', models.CharField(choices=[('official', 'Official Name'), ('former', 'Former Name'), ('nickname', 'Nickname'), ('abbreviation', 'Abbreviation')], default='official', max_length=20)),
('valid_from', models.DateField(blank=True, help_text='Date from which this alias is valid (inclusive)', null=True)),
('valid_until', models.DateField(blank=True, help_text='Date until which this alias is valid (inclusive)', null=True)),
('is_primary', models.BooleanField(default=False, help_text='Whether this is the current/primary name')),
('source', models.CharField(blank=True, help_text='Source of this alias', max_length=200)),
('notes', models.TextField(blank=True, help_text='Notes about this alias (e.g., naming rights deal)')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('stadium', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='aliases', to='core.stadium')),
],
options={
'verbose_name': 'Stadium Alias',
'verbose_name_plural': 'Stadium Aliases',
'ordering': ['stadium', '-valid_from'],
'indexes': [models.Index(fields=['alias'], name='core_stadiu_alias_7984d4_idx'), models.Index(fields=['stadium', 'valid_from', 'valid_until'], name='core_stadiu_stadium_d38e1b_idx')],
},
),
migrations.CreateModel(
name='Game',
fields=[
('id', models.CharField(help_text='Canonical ID (e.g., game_nba_2025_20251022_bos_lal)', max_length=100, primary_key=True, serialize=False)),
('season', models.PositiveSmallIntegerField(help_text='Season start year (e.g., 2025 for 2025-26 season)')),
('game_date', models.DateTimeField(help_text='Game date and time (UTC)')),
('game_number', models.PositiveSmallIntegerField(blank=True, help_text='Game number for doubleheaders (1 or 2)', null=True)),
('home_score', models.PositiveSmallIntegerField(blank=True, null=True)),
('away_score', models.PositiveSmallIntegerField(blank=True, null=True)),
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('in_progress', 'In Progress'), ('final', 'Final'), ('postponed', 'Postponed'), ('cancelled', 'Cancelled'), ('suspended', 'Suspended')], default='scheduled', max_length=20)),
('is_neutral_site', models.BooleanField(default=False, help_text='Whether game is at neutral site')),
('is_playoff', models.BooleanField(default=False, help_text='Whether this is a playoff game')),
('playoff_round', models.CharField(blank=True, help_text='Playoff round (e.g., Finals, Conference Finals)', max_length=50)),
('raw_home_team', models.CharField(blank=True, help_text='Original scraped home team name', max_length=200)),
('raw_away_team', models.CharField(blank=True, help_text='Original scraped away team name', max_length=200)),
('raw_stadium', models.CharField(blank=True, help_text='Original scraped stadium name', max_length=200)),
('source_url', models.URLField(blank=True, help_text='URL where game was scraped from')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('sport', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='games', to='core.sport')),
('stadium', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='games', to='core.stadium')),
('away_team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='away_games', to='core.team')),
('home_team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='home_games', to='core.team')),
],
options={
'verbose_name': 'Game',
'verbose_name_plural': 'Games',
'ordering': ['-game_date', 'sport'],
'indexes': [models.Index(fields=['sport', 'season'], name='core_game_sport_i_67c5c8_idx'), models.Index(fields=['sport', 'game_date'], name='core_game_sport_i_db4971_idx'), models.Index(fields=['home_team', 'season'], name='core_game_home_te_9b45c7_idx'), models.Index(fields=['away_team', 'season'], name='core_game_away_te_c8e42f_idx'), models.Index(fields=['status'], name='core_game_status_249a25_idx')],
},
),
migrations.CreateModel(
name='TeamAlias',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('alias', models.CharField(help_text='The alias text to match against', max_length=200)),
('alias_type', models.CharField(choices=[('full_name', 'Full Name'), ('city_name', 'City + Name'), ('abbreviation', 'Abbreviation'), ('nickname', 'Nickname'), ('historical', 'Historical Name')], default='full_name', max_length=20)),
('valid_from', models.DateField(blank=True, help_text='Date from which this alias is valid (inclusive)', null=True)),
('valid_until', models.DateField(blank=True, help_text='Date until which this alias is valid (inclusive)', null=True)),
('is_primary', models.BooleanField(default=False, help_text='Whether this is a primary/preferred alias')),
('source', models.CharField(blank=True, help_text='Source of this alias (e.g., ESPN, Basketball-Reference)', max_length=200)),
('notes', models.TextField(blank=True, help_text='Notes about this alias (e.g., relocation details)')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='aliases', to='core.team')),
],
options={
'verbose_name': 'Team Alias',
'verbose_name_plural': 'Team Aliases',
'ordering': ['team', '-valid_from'],
'indexes': [models.Index(fields=['alias'], name='core_teamal_alias_a89339_idx'), models.Index(fields=['team', 'valid_from', 'valid_until'], name='core_teamal_team_id_e29cea_idx')],
},
),
]