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

1
core/__init__.py Normal file
View File

@@ -0,0 +1 @@
default_app_config = 'core.apps.CoreConfig'

6
core/admin/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
from .sport_admin import SportAdmin
from .league_structure_admin import ConferenceAdmin, DivisionAdmin
from .team_admin import TeamAdmin
from .stadium_admin import StadiumAdmin
from .game_admin import GameAdmin
from .alias_admin import TeamAliasAdmin, StadiumAliasAdmin

84
core/admin/alias_admin.py Normal file
View File

@@ -0,0 +1,84 @@
from django.contrib import admin
from import_export.admin import ImportExportMixin
from simple_history.admin import SimpleHistoryAdmin
from core.models import TeamAlias, StadiumAlias
from core.resources import TeamAliasResource, StadiumAliasResource
@admin.register(TeamAlias)
class TeamAliasAdmin(ImportExportMixin, SimpleHistoryAdmin):
resource_class = TeamAliasResource
list_display = [
'alias',
'team',
'sport_display',
'alias_type',
'valid_from',
'valid_until',
'is_primary',
]
list_filter = ['team__sport', 'alias_type', 'is_primary']
search_fields = ['alias', 'team__full_name', 'team__abbreviation']
ordering = ['team__sport', 'team', '-valid_from']
readonly_fields = ['created_at', 'updated_at']
autocomplete_fields = ['team']
fieldsets = [
(None, {
'fields': ['team', 'alias', 'alias_type']
}),
('Validity Period', {
'fields': ['valid_from', 'valid_until']
}),
('Options', {
'fields': ['is_primary', 'source', 'notes']
}),
('Metadata', {
'fields': ['created_at', 'updated_at'],
'classes': ['collapse']
}),
]
def sport_display(self, obj):
return obj.team.sport.short_name
sport_display.short_description = 'Sport'
@admin.register(StadiumAlias)
class StadiumAliasAdmin(ImportExportMixin, SimpleHistoryAdmin):
resource_class = StadiumAliasResource
list_display = [
'alias',
'stadium',
'sport_display',
'alias_type',
'valid_from',
'valid_until',
'is_primary',
]
list_filter = ['stadium__sport', 'alias_type', 'is_primary']
search_fields = ['alias', 'stadium__name', 'stadium__city']
ordering = ['stadium__sport', 'stadium', '-valid_from']
readonly_fields = ['created_at', 'updated_at']
autocomplete_fields = ['stadium']
fieldsets = [
(None, {
'fields': ['stadium', 'alias', 'alias_type']
}),
('Validity Period', {
'fields': ['valid_from', 'valid_until']
}),
('Options', {
'fields': ['is_primary', 'source', 'notes']
}),
('Metadata', {
'fields': ['created_at', 'updated_at'],
'classes': ['collapse']
}),
]
def sport_display(self, obj):
return obj.stadium.sport.short_name
sport_display.short_description = 'Sport'

117
core/admin/game_admin.py Normal file
View File

@@ -0,0 +1,117 @@
from django.contrib import admin
from django.utils.html import format_html
from import_export.admin import ImportExportMixin
from simple_history.admin import SimpleHistoryAdmin
from core.models import Game
from core.resources import GameResource
@admin.register(Game)
class GameAdmin(ImportExportMixin, SimpleHistoryAdmin):
resource_class = GameResource
list_display = [
'game_display',
'sport',
'season',
'game_date',
'score_display',
'status',
'stadium_display',
'is_playoff',
]
list_filter = [
'sport',
'season',
'status',
'is_playoff',
'is_neutral_site',
('game_date', admin.DateFieldListFilter),
]
search_fields = [
'id',
'home_team__full_name',
'home_team__abbreviation',
'away_team__full_name',
'away_team__abbreviation',
'stadium__name',
]
date_hierarchy = 'game_date'
ordering = ['-game_date']
readonly_fields = ['id', 'created_at', 'updated_at', 'source_link']
autocomplete_fields = ['home_team', 'away_team', 'stadium']
fieldsets = [
(None, {
'fields': ['id', 'sport', 'season']
}),
('Teams', {
'fields': ['home_team', 'away_team']
}),
('Schedule', {
'fields': ['game_date', 'game_number', 'stadium', 'is_neutral_site']
}),
('Score', {
'fields': ['status', 'home_score', 'away_score']
}),
('Playoff', {
'fields': ['is_playoff', 'playoff_round'],
'classes': ['collapse']
}),
('Raw Data (Debug)', {
'fields': ['raw_home_team', 'raw_away_team', 'raw_stadium', 'source_url', 'source_link'],
'classes': ['collapse']
}),
('Metadata', {
'fields': ['created_at', 'updated_at'],
'classes': ['collapse']
}),
]
actions = ['mark_as_final', 'mark_as_postponed', 'mark_as_cancelled']
@admin.display(description='Game', ordering='home_team__abbreviation')
def game_display(self, obj):
return f"{obj.away_team.abbreviation} @ {obj.home_team.abbreviation}"
@admin.display(description='Score', ordering='home_score')
def score_display(self, obj):
if obj.home_score is not None and obj.away_score is not None:
winner_style = "font-weight: bold;"
away_style = winner_style if obj.away_score > obj.home_score else ""
home_style = winner_style if obj.home_score > obj.away_score else ""
return format_html(
'<span style="{}">{}</span> - <span style="{}">{}</span>',
away_style, obj.away_score, home_style, obj.home_score
)
return '-'
@admin.display(description='Stadium', ordering='stadium__name')
def stadium_display(self, obj):
if obj.stadium:
return obj.stadium.name[:30]
return '-'
def source_link(self, obj):
if obj.source_url:
return format_html(
'<a href="{}" target="_blank">View Source</a>',
obj.source_url
)
return '-'
source_link.short_description = 'Source'
@admin.action(description='Mark selected games as Final')
def mark_as_final(self, request, queryset):
updated = queryset.update(status='final')
self.message_user(request, f'{updated} games marked as final.')
@admin.action(description='Mark selected games as Postponed')
def mark_as_postponed(self, request, queryset):
updated = queryset.update(status='postponed')
self.message_user(request, f'{updated} games marked as postponed.')
@admin.action(description='Mark selected games as Cancelled')
def mark_as_cancelled(self, request, queryset):
updated = queryset.update(status='cancelled')
self.message_user(request, f'{updated} games marked as cancelled.')

View File

@@ -0,0 +1,70 @@
from django.contrib import admin
from import_export.admin import ImportExportMixin
from simple_history.admin import SimpleHistoryAdmin
from core.models import Conference, Division
from core.resources import ConferenceResource, DivisionResource
class DivisionInline(admin.TabularInline):
model = Division
extra = 0
fields = ['canonical_id', 'name', 'short_name', 'order']
ordering = ['order', 'name']
@admin.register(Conference)
class ConferenceAdmin(ImportExportMixin, SimpleHistoryAdmin):
resource_class = ConferenceResource
list_display = ['canonical_id', 'name', 'sport', 'short_name', 'division_count', 'team_count', 'order']
list_filter = ['sport']
search_fields = ['name', 'short_name', 'canonical_id']
ordering = ['sport', 'order', 'name']
readonly_fields = ['created_at', 'updated_at']
inlines = [DivisionInline]
fieldsets = [
(None, {
'fields': ['sport', 'canonical_id', 'name', 'short_name', 'order']
}),
('Metadata', {
'fields': ['created_at', 'updated_at'],
'classes': ['collapse']
}),
]
def division_count(self, obj):
return obj.divisions.count()
division_count.short_description = 'Divisions'
def team_count(self, obj):
return sum(d.teams.count() for d in obj.divisions.all())
team_count.short_description = 'Teams'
@admin.register(Division)
class DivisionAdmin(ImportExportMixin, SimpleHistoryAdmin):
resource_class = DivisionResource
list_display = ['canonical_id', 'name', 'conference', 'sport_display', 'short_name', 'team_count', 'order']
list_filter = ['conference__sport', 'conference']
search_fields = ['name', 'short_name', 'canonical_id', 'conference__name']
ordering = ['conference__sport', 'conference', 'order', 'name']
readonly_fields = ['created_at', 'updated_at']
fieldsets = [
(None, {
'fields': ['conference', 'canonical_id', 'name', 'short_name', 'order']
}),
('Metadata', {
'fields': ['created_at', 'updated_at'],
'classes': ['collapse']
}),
]
def sport_display(self, obj):
return obj.conference.sport.short_name
sport_display.short_description = 'Sport'
def team_count(self, obj):
return obj.teams.count()
team_count.short_description = 'Teams'

54
core/admin/sport_admin.py Normal file
View File

@@ -0,0 +1,54 @@
from django.contrib import admin
from import_export.admin import ImportExportMixin
from simple_history.admin import SimpleHistoryAdmin
from core.models import Sport
from core.resources import SportResource
@admin.register(Sport)
class SportAdmin(ImportExportMixin, SimpleHistoryAdmin):
resource_class = SportResource
list_display = [
'code',
'short_name',
'name',
'season_type',
'expected_game_count',
'is_active',
'team_count',
'game_count',
]
list_filter = ['is_active', 'season_type']
search_fields = ['code', 'name', 'short_name']
ordering = ['name']
readonly_fields = ['created_at', 'updated_at']
fieldsets = [
(None, {
'fields': ['code', 'name', 'short_name']
}),
('Season Configuration', {
'fields': [
'season_type',
'season_start_month',
'season_end_month',
'expected_game_count',
]
}),
('Status', {
'fields': ['is_active']
}),
('Metadata', {
'fields': ['created_at', 'updated_at'],
'classes': ['collapse']
}),
]
def team_count(self, obj):
return obj.teams.count()
team_count.short_description = 'Teams'
def game_count(self, obj):
return obj.games.count()
game_count.short_description = 'Games'

View File

@@ -0,0 +1,89 @@
from django.contrib import admin
from django.utils.html import format_html
from import_export.admin import ImportExportMixin
from simple_history.admin import SimpleHistoryAdmin
from core.models import Stadium, StadiumAlias
from core.resources import StadiumResource
class StadiumAliasInline(admin.TabularInline):
model = StadiumAlias
extra = 0
fields = ['alias', 'alias_type', 'valid_from', 'valid_until', 'is_primary']
ordering = ['-valid_from']
@admin.register(Stadium)
class StadiumAdmin(ImportExportMixin, SimpleHistoryAdmin):
resource_class = StadiumResource
list_display = [
'name',
'sport',
'location_display',
'capacity_display',
'surface',
'roof_type',
'opened_year',
'home_team_count',
'alias_count',
]
list_filter = ['sport', 'country', 'surface', 'roof_type']
search_fields = ['id', 'name', 'city', 'state']
ordering = ['sport', 'city', 'name']
readonly_fields = ['id', 'created_at', 'updated_at', 'map_link']
inlines = [StadiumAliasInline]
fieldsets = [
(None, {
'fields': ['id', 'sport', 'name']
}),
('Location', {
'fields': ['city', 'state', 'country', 'timezone']
}),
('Coordinates', {
'fields': ['latitude', 'longitude', 'map_link']
}),
('Venue Details', {
'fields': ['capacity', 'surface', 'roof_type', 'opened_year']
}),
('Media', {
'fields': ['image_url']
}),
('Metadata', {
'fields': ['created_at', 'updated_at'],
'classes': ['collapse']
}),
]
def location_display(self, obj):
return obj.location
location_display.short_description = 'Location'
def capacity_display(self, obj):
if obj.capacity:
return f"{obj.capacity:,}"
return '-'
capacity_display.short_description = 'Capacity'
def home_team_count(self, obj):
return obj.home_teams.count()
home_team_count.short_description = 'Teams'
def alias_count(self, obj):
return obj.aliases.count()
alias_count.short_description = 'Aliases'
def map_link(self, obj):
if obj.latitude and obj.longitude:
return format_html(
'<a href="https://www.google.com/maps?q={},{}" target="_blank">View on Google Maps</a>',
obj.latitude, obj.longitude
)
return '-'
map_link.short_description = 'Map'
def get_search_results(self, request, queryset, search_term):
"""Enable autocomplete search."""
queryset, use_distinct = super().get_search_results(request, queryset, search_term)
return queryset, use_distinct

96
core/admin/team_admin.py Normal file
View File

@@ -0,0 +1,96 @@
from django.contrib import admin
from django.utils.html import format_html
from import_export.admin import ImportExportMixin
from simple_history.admin import SimpleHistoryAdmin
from core.models import Team, TeamAlias
from core.resources import TeamResource
class TeamAliasInline(admin.TabularInline):
model = TeamAlias
extra = 0
fields = ['alias', 'alias_type', 'valid_from', 'valid_until', 'is_primary']
ordering = ['-valid_from']
@admin.register(Team)
class TeamAdmin(ImportExportMixin, SimpleHistoryAdmin):
resource_class = TeamResource
list_display = [
'abbreviation',
'full_name',
'sport',
'division_display',
'home_stadium',
'color_preview',
'is_active',
'alias_count',
]
list_filter = ['sport', 'is_active', 'division__conference']
search_fields = ['id', 'city', 'name', 'full_name', 'abbreviation']
ordering = ['sport', 'city', 'name']
readonly_fields = ['id', 'created_at', 'updated_at', 'color_preview_large']
autocomplete_fields = ['home_stadium', 'division']
inlines = [TeamAliasInline]
fieldsets = [
(None, {
'fields': ['id', 'sport', 'division']
}),
('Team Info', {
'fields': ['city', 'name', 'full_name', 'abbreviation']
}),
('Venue', {
'fields': ['home_stadium']
}),
('Branding', {
'fields': ['primary_color', 'secondary_color', 'color_preview_large', 'logo_url']
}),
('Status', {
'fields': ['is_active']
}),
('Metadata', {
'fields': ['created_at', 'updated_at'],
'classes': ['collapse']
}),
]
def division_display(self, obj):
if obj.division:
return f"{obj.division.conference.short_name or obj.division.conference.name} - {obj.division.name}"
return '-'
division_display.short_description = 'Division'
def color_preview(self, obj):
if obj.primary_color:
return format_html(
'<span style="background-color: {}; padding: 2px 10px; border-radius: 3px;">&nbsp;</span>',
obj.primary_color
)
return '-'
color_preview.short_description = 'Color'
def color_preview_large(self, obj):
html = ''
if obj.primary_color:
html += format_html(
'<span style="background-color: {}; padding: 5px 20px; border-radius: 3px; margin-right: 10px;">&nbsp;&nbsp;&nbsp;</span>',
obj.primary_color
)
if obj.secondary_color:
html += format_html(
'<span style="background-color: {}; padding: 5px 20px; border-radius: 3px;">&nbsp;&nbsp;&nbsp;</span>',
obj.secondary_color
)
return format_html(html) if html else '-'
color_preview_large.short_description = 'Color Preview'
def alias_count(self, obj):
return obj.aliases.count()
alias_count.short_description = 'Aliases'
def get_search_results(self, request, queryset, search_term):
"""Enable autocomplete search."""
queryset, use_distinct = super().get_search_results(request, queryset, search_term)
return queryset, use_distinct

7
core/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'core'
verbose_name = 'Core Data'

View File

@@ -0,0 +1 @@
# Management commands package

View File

@@ -0,0 +1 @@
# Commands package

View File

@@ -0,0 +1,445 @@
"""
Management command to export Django database data to JSON bootstrap files for iOS app.
"""
import json
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
from django.core.management.base import BaseCommand
from core.models import Sport, Conference, Division, Team, Stadium, Game, TeamAlias, StadiumAlias
class Command(BaseCommand):
help = 'Export database data to JSON bootstrap files for iOS app'
def add_arguments(self, parser):
parser.add_argument(
'--output-dir',
type=str,
default='./bootstrap',
help='Directory to write JSON files to'
)
parser.add_argument(
'--sports',
action='store_true',
help='Export sports only'
)
parser.add_argument(
'--league-structure',
action='store_true',
help='Export league structure only'
)
parser.add_argument(
'--teams',
action='store_true',
help='Export teams only'
)
parser.add_argument(
'--stadiums',
action='store_true',
help='Export stadiums only'
)
parser.add_argument(
'--games',
action='store_true',
help='Export games only'
)
parser.add_argument(
'--team-aliases',
action='store_true',
help='Export team aliases only'
)
parser.add_argument(
'--stadium-aliases',
action='store_true',
help='Export stadium aliases only'
)
parser.add_argument(
'--sport',
type=str,
help='Filter by sport code (e.g., nba, mlb)'
)
parser.add_argument(
'--year',
type=int,
help='Filter games by calendar year (e.g., 2025 returns all games played in 2025)'
)
parser.add_argument(
'--pretty',
action='store_true',
default=True,
help='Pretty print JSON output (default: true)'
)
def handle(self, *args, **options):
output_dir = Path(options['output_dir'])
output_dir.mkdir(parents=True, exist_ok=True)
# If no specific flags, export everything
export_all = not any([
options['sports'],
options['league_structure'],
options['teams'],
options['stadiums'],
options['games'],
options['team_aliases'],
options['stadium_aliases'],
])
sport_filter = options.get('sport')
year_filter = options.get('year')
indent = 2 if options['pretty'] else None
if export_all or options['sports']:
self._export_sports(output_dir, sport_filter, indent)
if export_all or options['league_structure']:
self._export_league_structure(output_dir, sport_filter, indent)
if export_all or options['teams']:
self._export_teams(output_dir, sport_filter, indent)
if export_all or options['stadiums']:
self._export_stadiums(output_dir, sport_filter, indent)
if export_all or options['games']:
self._export_games(output_dir, sport_filter, year_filter, indent)
if export_all or options['team_aliases']:
self._export_team_aliases(output_dir, sport_filter, indent)
if export_all or options['stadium_aliases']:
self._export_stadium_aliases(output_dir, sport_filter, indent)
self.stdout.write(self.style.SUCCESS(f'Export completed to {output_dir}'))
def _get_conference_id(self, conference):
"""Get conference canonical ID from DB field."""
return conference.canonical_id
def _get_division_id(self, division):
"""Get division canonical ID from DB field."""
return division.canonical_id
def _export_sports(self, output_dir, sport_filter, indent):
"""Export sports to sports.json."""
self.stdout.write('Exporting sports...')
sports = Sport.objects.filter(is_active=True)
if sport_filter:
sports = sports.filter(code=sport_filter.lower())
data = []
for sport in sports.order_by('code'):
data.append({
'sport_id': sport.short_name,
'abbreviation': sport.short_name,
'display_name': sport.name,
'icon_name': sport.icon_name,
'color_hex': sport.color_hex,
'season_start_month': sport.season_start_month,
'season_end_month': sport.season_end_month,
'is_active': sport.is_active,
})
file_path = output_dir / 'sports.json'
with open(file_path, 'w') as f:
json.dump(data, f, indent=indent)
self.stdout.write(f' Wrote {len(data)} sports to {file_path}')
def _export_league_structure(self, output_dir, sport_filter, indent):
"""Export league structure (sports as leagues, conferences, divisions)."""
self.stdout.write('Exporting league structure...')
data = []
seen_ids = set() # Track IDs to prevent duplicates
display_order = 0
# Query sports
sports = Sport.objects.all()
if sport_filter:
sports = sports.filter(code=sport_filter.lower())
for sport in sports.order_by('code'):
# Create league entry from Sport
league_id = f"{sport.code}_league"
# Skip if we've already seen this ID
if league_id in seen_ids:
continue
seen_ids.add(league_id)
data.append({
'id': league_id,
'sport': sport.short_name,
'type': 'league',
'name': sport.name,
'abbreviation': sport.short_name,
'parent_id': None,
'display_order': display_order,
})
display_order += 1
# Get conferences for this sport
conferences = Conference.objects.filter(sport=sport).order_by('order', 'name')
for conf in conferences:
conf_id = self._get_conference_id(conf)
# Skip duplicate conference IDs
if conf_id in seen_ids:
continue
seen_ids.add(conf_id)
data.append({
'id': conf_id,
'sport': sport.short_name,
'type': 'conference',
'name': conf.name,
'abbreviation': conf.short_name or None,
'parent_id': league_id,
'display_order': conf.order,
})
# Get divisions for this conference
divisions = Division.objects.filter(conference=conf).order_by('order', 'name')
for div in divisions:
div_id = self._get_division_id(div)
# Skip duplicate division IDs
if div_id in seen_ids:
continue
seen_ids.add(div_id)
data.append({
'id': div_id,
'sport': sport.short_name,
'type': 'division',
'name': div.name,
'abbreviation': div.short_name or None,
'parent_id': conf_id,
'display_order': div.order,
})
file_path = output_dir / 'league_structure.json'
with open(file_path, 'w') as f:
json.dump(data, f, indent=indent)
self.stdout.write(f' Wrote {len(data)} entries to {file_path}')
def _export_teams(self, output_dir, sport_filter, indent):
"""Export teams to teams_canonical.json."""
self.stdout.write('Exporting teams...')
teams = Team.objects.select_related(
'sport', 'division', 'division__conference', 'home_stadium'
).all()
if sport_filter:
teams = teams.filter(sport__code=sport_filter.lower())
data = []
for team in teams.order_by('sport__code', 'city', 'name'):
# Get conference and division IDs
conference_id = None
division_id = None
if team.division:
division_id = self._get_division_id(team.division)
conference_id = self._get_conference_id(team.division.conference)
data.append({
'canonical_id': team.id,
'name': team.name,
'abbreviation': team.abbreviation,
'sport': team.sport.short_name,
'city': team.city,
'stadium_canonical_id': team.home_stadium_id,
'conference_id': conference_id,
'division_id': division_id,
'primary_color': team.primary_color or None,
'secondary_color': team.secondary_color or None,
})
file_path = output_dir / 'teams_canonical.json'
with open(file_path, 'w') as f:
json.dump(data, f, indent=indent)
self.stdout.write(f' Wrote {len(data)} teams to {file_path}')
def _export_stadiums(self, output_dir, sport_filter, indent):
"""Export stadiums to stadiums_canonical.json."""
self.stdout.write('Exporting stadiums...')
stadiums = Stadium.objects.select_related('sport').all()
if sport_filter:
stadiums = stadiums.filter(sport__code=sport_filter.lower())
# Build map of stadium -> team abbreviations
stadium_teams = {}
teams = Team.objects.filter(home_stadium__isnull=False).select_related('home_stadium')
if sport_filter:
teams = teams.filter(sport__code=sport_filter.lower())
for team in teams:
if team.home_stadium_id not in stadium_teams:
stadium_teams[team.home_stadium_id] = []
stadium_teams[team.home_stadium_id].append(team.abbreviation)
data = []
for stadium in stadiums.order_by('sport__code', 'city', 'name'):
data.append({
'canonical_id': stadium.id,
'name': stadium.name,
'city': stadium.city,
'state': stadium.state or None,
'latitude': float(stadium.latitude) if stadium.latitude else None,
'longitude': float(stadium.longitude) if stadium.longitude else None,
'capacity': stadium.capacity or 0,
'sport': stadium.sport.short_name,
'primary_team_abbrevs': stadium_teams.get(stadium.id, []),
'year_opened': stadium.opened_year,
'timezone_identifier': stadium.timezone or None,
'image_url': stadium.image_url or None,
})
file_path = output_dir / 'stadiums_canonical.json'
with open(file_path, 'w') as f:
json.dump(data, f, indent=indent)
self.stdout.write(f' Wrote {len(data)} stadiums to {file_path}')
def _export_games(self, output_dir, sport_filter, year_filter, indent):
"""Export games to games.json."""
self.stdout.write('Exporting games...')
games = Game.objects.select_related(
'sport', 'home_team', 'away_team', 'stadium'
).all()
if sport_filter:
games = games.filter(sport__code=sport_filter.lower())
if year_filter:
games = games.filter(game_date__year=year_filter)
data = []
for game in games.order_by('game_date', 'sport__code'):
# Ensure game_date is UTC-aware
game_dt = game.game_date
if game_dt.tzinfo is None:
game_dt = game_dt.replace(tzinfo=timezone.utc)
utc_dt = game_dt.astimezone(timezone.utc)
# Extract domain from source_url
source = None
if game.source_url:
source = self._extract_domain(game.source_url)
data.append({
'id': game.id,
'sport': game.sport.short_name,
'season': str(game.game_date.year),
'game_datetime_utc': utc_dt.strftime('%Y-%m-%dT%H:%M:%SZ'),
'home_team': game.home_team.full_name,
'away_team': game.away_team.full_name,
'home_team_abbrev': game.home_team.abbreviation,
'away_team_abbrev': game.away_team.abbreviation,
'home_team_canonical_id': game.home_team_id,
'away_team_canonical_id': game.away_team_id,
'venue': game.stadium.name if game.stadium else None,
'stadium_canonical_id': game.stadium_id,
'source': source,
'is_playoff': game.is_playoff,
'broadcast': None, # Not tracked in DB currently
})
file_path = output_dir / 'games.json'
with open(file_path, 'w') as f:
json.dump(data, f, indent=indent)
self.stdout.write(f' Wrote {len(data)} games to {file_path}')
def _extract_domain(self, url):
"""Extract domain from URL (e.g., 'espn.com' from 'https://www.espn.com/...')."""
try:
parsed = urlparse(url)
domain = parsed.netloc
# Remove 'www.' prefix if present
if domain.startswith('www.'):
domain = domain[4:]
return domain
except Exception:
return None
def _export_team_aliases(self, output_dir, sport_filter, indent):
"""Export team aliases to team_aliases.json."""
self.stdout.write('Exporting team aliases...')
aliases = TeamAlias.objects.select_related('team', 'team__sport').all()
if sport_filter:
aliases = aliases.filter(team__sport__code=sport_filter.lower())
# Map model alias types to export alias types
alias_type_map = {
'full_name': 'name',
'city_name': 'city',
'abbreviation': 'abbreviation',
'nickname': 'name', # Map nickname to name
'historical': 'name', # Map historical to name
}
data = []
for alias in aliases.order_by('team__sport__code', 'team__id', 'id'):
# Format dates
valid_from = alias.valid_from.strftime('%Y-%m-%d') if alias.valid_from else None
valid_until = alias.valid_until.strftime('%Y-%m-%d') if alias.valid_until else None
# Map alias type
export_type = alias_type_map.get(alias.alias_type, 'name')
data.append({
'id': f"alias_{alias.team.sport.code}_{alias.pk}",
'team_canonical_id': alias.team_id,
'alias_type': export_type,
'alias_value': alias.alias,
'valid_from': valid_from,
'valid_until': valid_until,
})
file_path = output_dir / 'team_aliases.json'
with open(file_path, 'w') as f:
json.dump(data, f, indent=indent)
self.stdout.write(f' Wrote {len(data)} team aliases to {file_path}')
def _export_stadium_aliases(self, output_dir, sport_filter, indent):
"""Export stadium aliases to stadium_aliases.json."""
self.stdout.write('Exporting stadium aliases...')
aliases = StadiumAlias.objects.select_related('stadium', 'stadium__sport').all()
if sport_filter:
aliases = aliases.filter(stadium__sport__code=sport_filter.lower())
data = []
for alias in aliases.order_by('stadium__sport__code', 'stadium__id', 'id'):
# Format dates
valid_from = alias.valid_from.strftime('%Y-%m-%d') if alias.valid_from else None
valid_until = alias.valid_until.strftime('%Y-%m-%d') if alias.valid_until else None
data.append({
'alias_name': alias.alias,
'stadium_canonical_id': alias.stadium_id,
'valid_from': valid_from,
'valid_until': valid_until,
})
file_path = output_dir / 'stadium_aliases.json'
with open(file_path, 'w') as f:
json.dump(data, f, indent=indent)
self.stdout.write(f' Wrote {len(data)} stadium aliases to {file_path}')

View File

@@ -0,0 +1,98 @@
"""
Assign home_stadium to WNBA teams and backfill stadium on WNBA games.
Usage:
python manage.py fix_wnba_stadiums
python manage.py fix_wnba_stadiums --dry-run
"""
from django.core.management.base import BaseCommand
from core.models import Team, Stadium, Game
# WNBA team abbreviation → stadium canonical ID
WNBA_TEAM_STADIUMS = {
'ATL': 'stadium_wnba_gateway_center_arena',
'CHI': 'stadium_wnba_wintrust_arena',
'CON': 'stadium_wnba_mohegan_sun_arena',
'DAL': 'stadium_wnba_college_park_center',
'GSV': 'stadium_wnba_chase_center',
'IND': 'stadium_wnba_gainbridge_fieldhouse',
'LA': 'stadium_wnba_cryptocom_arena',
'LV': 'stadium_wnba_michelob_ultra_arena',
'MIN': 'stadium_wnba_target_center',
'NY': 'stadium_wnba_barclays_center',
'PHX': 'stadium_wnba_footprint_center',
'SEA': 'stadium_wnba_climate_pledge_arena',
'WAS': 'stadium_wnba_entertainment_sports_arena',
}
class Command(BaseCommand):
help = "Assign home_stadium to WNBA teams and backfill game stadiums."
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would change without saving',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
if dry_run:
self.stdout.write(self.style.WARNING("DRY RUN — no changes will be saved"))
# 1. Assign home_stadium to WNBA teams
self.stdout.write("\n=== Assigning WNBA team stadiums ===")
teams_updated = 0
for abbrev, stadium_id in WNBA_TEAM_STADIUMS.items():
try:
team = Team.objects.get(sport_id='wnba', abbreviation=abbrev)
except Team.DoesNotExist:
self.stderr.write(f" Team not found: WNBA {abbrev}")
continue
try:
stadium = Stadium.objects.get(id=stadium_id)
except Stadium.DoesNotExist:
self.stderr.write(f" Stadium not found: {stadium_id}")
continue
if team.home_stadium_id != stadium_id:
self.stdout.write(f" {abbrev:5} {team.city} {team.name}{stadium.name}")
if not dry_run:
team.home_stadium = stadium
team.save(update_fields=['home_stadium', 'updated_at'])
teams_updated += 1
self.stdout.write(f" Teams updated: {teams_updated}")
# 2. Backfill stadium on WNBA games missing it
self.stdout.write("\n=== Backfilling WNBA game stadiums ===")
games_missing = Game.objects.filter(
sport_id='wnba', stadium__isnull=True
).select_related('home_team')
games_updated = 0
for game in games_missing:
stadium_id = WNBA_TEAM_STADIUMS.get(game.home_team.abbreviation)
if not stadium_id:
self.stderr.write(f" No stadium mapping for {game.home_team.abbreviation}: {game.id}")
continue
self.stdout.write(f" {game.id} ({game.home_team.abbreviation} home) → {stadium_id}")
if not dry_run:
game.stadium_id = stadium_id
game.save(update_fields=['stadium', 'updated_at'])
games_updated += 1
self.stdout.write(f" Games updated: {games_updated}")
# 3. Summary
self.stdout.write(f"\n=== Done ===")
missing_stadium = Team.objects.filter(sport_id='wnba', home_stadium__isnull=True).count()
missing_game_stadium = Game.objects.filter(sport_id='wnba', stadium__isnull=True).count()
self.stdout.write(f" WNBA teams still missing stadium: {missing_stadium}")
self.stdout.write(f" WNBA games still missing stadium: {missing_game_stadium}")

View File

@@ -0,0 +1,512 @@
"""
Management command to import existing JSON data into Django models.
"""
import json
from datetime import datetime
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from core.models import Sport, Conference, Division, Team, Stadium, Game, TeamAlias, StadiumAlias
class Command(BaseCommand):
help = 'Import existing JSON data files into Django database'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Lookup maps for JSON ID -> Django object
self.divisions_by_json_id = {}
self.conferences_by_json_id = {}
def add_arguments(self, parser):
parser.add_argument(
'--data-dir',
type=str,
default='.',
help='Directory containing the JSON data files'
)
parser.add_argument(
'--output-dir',
type=str,
default='./output',
help='Directory containing scraped output files (teams, stadiums, games)'
)
parser.add_argument(
'--league-structure',
action='store_true',
help='Import league structure only'
)
parser.add_argument(
'--team-aliases',
action='store_true',
help='Import team aliases only'
)
parser.add_argument(
'--stadium-aliases',
action='store_true',
help='Import stadium aliases only'
)
parser.add_argument(
'--scraped-data',
action='store_true',
help='Import scraped teams, stadiums, and games from output directory'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be imported without making changes'
)
def handle(self, *args, **options):
data_dir = Path(options['data_dir'])
output_dir = Path(options['output_dir'])
dry_run = options['dry_run']
# If no specific flags, import everything
import_all = not any([
options['league_structure'],
options['team_aliases'],
options['stadium_aliases'],
options['scraped_data'],
])
if dry_run:
self.stdout.write(self.style.WARNING('DRY RUN - No changes will be made'))
try:
with transaction.atomic():
# Always ensure sports exist first
self._ensure_sports()
if import_all or options['league_structure']:
self._import_league_structure(data_dir, dry_run)
if import_all or options['scraped_data']:
self._import_scraped_data(output_dir, dry_run)
if import_all or options['team_aliases']:
self._import_team_aliases(data_dir, dry_run)
if import_all or options['stadium_aliases']:
self._import_stadium_aliases(data_dir, dry_run)
if dry_run:
raise CommandError('Dry run complete - rolling back')
except CommandError as e:
if 'Dry run' in str(e):
self.stdout.write(self.style.SUCCESS('Dry run completed successfully'))
else:
raise
self.stdout.write(self.style.SUCCESS('Data import completed successfully'))
def _ensure_sports(self):
"""Ensure all sports exist in the database."""
sports = [
{'code': 'mlb', 'name': 'Major League Baseball', 'short_name': 'MLB'},
{'code': 'nba', 'name': 'National Basketball Association', 'short_name': 'NBA'},
{'code': 'nfl', 'name': 'National Football League', 'short_name': 'NFL'},
{'code': 'nhl', 'name': 'National Hockey League', 'short_name': 'NHL'},
{'code': 'mls', 'name': 'Major League Soccer', 'short_name': 'MLS'},
{'code': 'wnba', 'name': "Women's National Basketball Association", 'short_name': 'WNBA'},
{'code': 'nwsl', 'name': "National Women's Soccer League", 'short_name': 'NWSL'},
]
for sport_data in sports:
sport, created = Sport.objects.update_or_create(
code=sport_data['code'],
defaults={
'name': sport_data['name'],
'short_name': sport_data['short_name'],
}
)
if created:
self.stdout.write(f' Created sport: {sport.short_name}')
def _import_league_structure(self, data_dir, dry_run):
"""Import league structure from JSON."""
self.stdout.write(self.style.HTTP_INFO('Importing league structure...'))
file_path = data_dir / 'league_structure.json'
if not file_path.exists():
self.stdout.write(self.style.WARNING(f' File not found: {file_path}'))
return
with open(file_path) as f:
data = json.load(f)
# First pass: conferences
conference_count = 0
for item in data:
if item['type'] != 'conference':
continue
sport_code = item['sport'].lower()
try:
sport = Sport.objects.get(code=sport_code)
except Sport.DoesNotExist:
self.stdout.write(self.style.WARNING(f' Sport not found: {sport_code}'))
continue
if not dry_run:
conference, created = Conference.objects.update_or_create(
sport=sport,
name=item['name'],
defaults={
'canonical_id': item['id'],
'short_name': item.get('abbreviation') or '',
'order': item.get('display_order', 0),
}
)
self.conferences_by_json_id[item['id']] = conference
if created:
conference_count += 1
else:
self.conferences_by_json_id[item['id']] = item['id']
conference_count += 1
self.stdout.write(f' Conferences: {conference_count} created/updated')
# Second pass: divisions
division_count = 0
for item in data:
if item['type'] != 'division':
continue
parent_id = item.get('parent_id')
if not parent_id or parent_id not in self.conferences_by_json_id:
self.stdout.write(self.style.WARNING(f' Parent conference not found for division: {item["name"]}'))
continue
if not dry_run:
conference = self.conferences_by_json_id[parent_id]
division, created = Division.objects.update_or_create(
conference=conference,
name=item['name'],
defaults={
'canonical_id': item['id'],
'short_name': item.get('abbreviation') or '',
'order': item.get('display_order', 0),
}
)
self.divisions_by_json_id[item['id']] = division
if created:
division_count += 1
else:
division_count += 1
self.stdout.write(f' Divisions: {division_count} created/updated')
def _import_team_aliases(self, data_dir, dry_run):
"""Import team aliases from JSON."""
self.stdout.write(self.style.HTTP_INFO('Importing team aliases...'))
file_path = data_dir / 'team_aliases.json'
if not file_path.exists():
self.stdout.write(self.style.WARNING(f' File not found: {file_path}'))
return
with open(file_path) as f:
data = json.load(f)
# Map JSON alias types to model alias types
alias_type_map = {
'name': 'full_name',
'city': 'city_name',
'abbreviation': 'abbreviation',
'nickname': 'nickname',
'historical': 'historical',
}
alias_count = 0
skipped_count = 0
for item in data:
team_id = item['team_canonical_id']
# Check if team exists
try:
team = Team.objects.get(id=team_id)
except Team.DoesNotExist:
skipped_count += 1
continue
valid_from = None
valid_until = None
if item.get('valid_from'):
try:
valid_from = datetime.strptime(item['valid_from'], '%Y-%m-%d').date()
except ValueError:
pass
if item.get('valid_until'):
try:
valid_until = datetime.strptime(item['valid_until'], '%Y-%m-%d').date()
except ValueError:
pass
# Map alias type
json_alias_type = item.get('alias_type', 'full_name')
model_alias_type = alias_type_map.get(json_alias_type, 'full_name')
if not dry_run:
# Use team + alias + alias_type as unique key (no explicit ID)
alias, created = TeamAlias.objects.update_or_create(
team=team,
alias=item['alias_value'],
alias_type=model_alias_type,
defaults={
'valid_from': valid_from,
'valid_until': valid_until,
}
)
if created:
alias_count += 1
else:
alias_count += 1
self.stdout.write(f' Team aliases: {alias_count} created/updated, {skipped_count} skipped (team not found)')
def _import_stadium_aliases(self, data_dir, dry_run):
"""Import stadium aliases from JSON."""
self.stdout.write(self.style.HTTP_INFO('Importing stadium aliases...'))
file_path = data_dir / 'stadium_aliases.json'
if not file_path.exists():
self.stdout.write(self.style.WARNING(f' File not found: {file_path}'))
return
with open(file_path) as f:
data = json.load(f)
alias_count = 0
skipped_count = 0
for item in data:
stadium_id = item['stadium_canonical_id']
# Check if stadium exists
try:
stadium = Stadium.objects.get(id=stadium_id)
except Stadium.DoesNotExist:
skipped_count += 1
continue
valid_from = None
valid_until = None
if item.get('valid_from'):
try:
valid_from = datetime.strptime(item['valid_from'], '%Y-%m-%d').date()
except ValueError:
pass
if item.get('valid_until'):
try:
valid_until = datetime.strptime(item['valid_until'], '%Y-%m-%d').date()
except ValueError:
pass
if not dry_run:
# Use stadium + alias as unique key (no explicit ID)
alias, created = StadiumAlias.objects.update_or_create(
stadium=stadium,
alias=item['alias_name'],
defaults={
'alias_type': 'official',
'valid_from': valid_from,
'valid_until': valid_until,
}
)
if created:
alias_count += 1
else:
alias_count += 1
self.stdout.write(f' Stadium aliases: {alias_count} created/updated, {skipped_count} skipped (stadium not found)')
def _import_scraped_data(self, output_dir, dry_run):
"""Import scraped teams, stadiums, and games from output directory."""
if not output_dir.exists():
self.stdout.write(self.style.WARNING(f' Output directory not found: {output_dir}'))
return
# Import stadiums first (teams reference them)
self._import_stadiums(output_dir, dry_run)
# Import teams (games reference them)
self._import_teams(output_dir, dry_run)
# Import games
self._import_games(output_dir, dry_run)
def _import_stadiums(self, output_dir, dry_run):
"""Import stadiums from output files."""
self.stdout.write(self.style.HTTP_INFO('Importing stadiums...'))
total_count = 0
sports = ['mlb', 'nba', 'nfl', 'nhl', 'mls', 'wnba', 'nwsl']
for sport_code in sports:
file_path = output_dir / f'stadiums_{sport_code}.json'
if not file_path.exists():
continue
try:
sport = Sport.objects.get(code=sport_code)
except Sport.DoesNotExist:
continue
with open(file_path) as f:
data = json.load(f)
for item in data:
if not dry_run:
Stadium.objects.update_or_create(
id=item['canonical_id'],
defaults={
'sport': sport,
'name': item['name'],
'city': item.get('city', ''),
'state': item.get('state', ''),
'country': 'USA',
'latitude': item.get('latitude'),
'longitude': item.get('longitude'),
'capacity': item.get('capacity') or None,
'timezone': item.get('timezone_identifier', ''),
'opened_year': item.get('year_opened'),
'image_url': item.get('image_url', '') or '',
}
)
total_count += 1
self.stdout.write(f' Stadiums: {total_count} created/updated')
def _import_teams(self, output_dir, dry_run):
"""Import teams from output files."""
self.stdout.write(self.style.HTTP_INFO('Importing teams...'))
total_count = 0
sports = ['mlb', 'nba', 'nfl', 'nhl', 'mls', 'wnba', 'nwsl']
for sport_code in sports:
file_path = output_dir / f'teams_{sport_code}.json'
if not file_path.exists():
continue
try:
sport = Sport.objects.get(code=sport_code)
except Sport.DoesNotExist:
continue
with open(file_path) as f:
data = json.load(f)
for item in data:
# Try to find division using JSON ID lookup
division = None
if item.get('division_id'):
division = self.divisions_by_json_id.get(item['division_id'])
# Try to find home stadium
home_stadium = None
if item.get('stadium_canonical_id'):
try:
home_stadium = Stadium.objects.get(id=item['stadium_canonical_id'])
except Stadium.DoesNotExist:
pass
if not dry_run:
Team.objects.update_or_create(
id=item['canonical_id'],
defaults={
'sport': sport,
'division': division,
'city': item.get('city', ''),
'name': item['name'],
'full_name': f"{item.get('city', '')} {item['name']}".strip(),
'abbreviation': item.get('abbreviation', ''),
'home_stadium': home_stadium,
'primary_color': item.get('primary_color', '') or '',
'secondary_color': item.get('secondary_color', '') or '',
}
)
total_count += 1
self.stdout.write(f' Teams: {total_count} created/updated')
def _import_games(self, output_dir, dry_run):
"""Import games from output files."""
self.stdout.write(self.style.HTTP_INFO('Importing games...'))
total_count = 0
error_count = 0
# Find all games files
game_files = list(output_dir.glob('games_*.json'))
for file_path in game_files:
# Parse sport code from filename (e.g., games_mlb_2026.json)
parts = file_path.stem.split('_')
if len(parts) < 2:
continue
sport_code = parts[1]
try:
sport = Sport.objects.get(code=sport_code)
except Sport.DoesNotExist:
continue
with open(file_path) as f:
data = json.load(f)
for item in data:
try:
# Get teams
home_team = Team.objects.get(id=item['home_team_canonical_id'])
away_team = Team.objects.get(id=item['away_team_canonical_id'])
# Get stadium (optional)
stadium = None
if item.get('stadium_canonical_id'):
try:
stadium = Stadium.objects.get(id=item['stadium_canonical_id'])
except Stadium.DoesNotExist:
pass
# Parse datetime
game_date = datetime.fromisoformat(
item['game_datetime_utc'].replace('Z', '+00:00')
)
# Parse season (may be "2025" or "2025-26")
season_str = str(item.get('season', game_date.year))
season = int(season_str.split('-')[0])
if not dry_run:
Game.objects.update_or_create(
id=item['canonical_id'],
defaults={
'sport': sport,
'season': season,
'home_team': home_team,
'away_team': away_team,
'stadium': stadium,
'game_date': game_date,
'status': 'scheduled',
'is_playoff': item.get('is_playoff', False),
}
)
total_count += 1
except (Team.DoesNotExist, KeyError) as e:
error_count += 1
if error_count <= 5:
self.stdout.write(self.style.WARNING(f' Error importing game: {e}'))
self.stdout.write(f' Games: {total_count} created/updated, {error_count} errors')

View File

@@ -0,0 +1,351 @@
"""
Scrape stadium capacity and year-opened from Wikipedia and update local DB.
Wikipedia pages used:
- NBA: List_of_NBA_arenas
- NFL: List_of_current_NFL_stadiums
- MLB: List_of_current_Major_League_Baseball_stadiums
- NHL: List_of_NHL_arenas
- MLS: List_of_Major_League_Soccer_stadiums
- WNBA: Women's_National_Basketball_Association
- NWSL: List_of_National_Women's_Soccer_League_stadiums
Usage:
python manage.py populate_stadium_details
python manage.py populate_stadium_details --sport nba
python manage.py populate_stadium_details --dry-run
"""
import re
import requests
from bs4 import BeautifulSoup
from django.core.management.base import BaseCommand
from core.models import Stadium
WIKI_API = "https://en.wikipedia.org/w/api.php"
# (page_title, table_index, name_col, capacity_col, opened_col)
WIKI_SOURCES = {
"nba": ("List_of_NBA_arenas", 0, "Arena", "Capacity", "Opened"),
"nfl": ("List_of_current_NFL_stadiums", 0, "Name", "Capacity", "Opened"),
"mlb": ("List_of_current_Major_League_Baseball_stadiums", 0, "Name", "Capacity", "Opened"),
"nhl": ("List_of_NHL_arenas", 0, "Arena", "Capacity", "Opened"),
"mls": ("List_of_Major_League_Soccer_stadiums", 1, "Stadium", "Capacity", "Opened"),
"wnba": ("Women's_National_Basketball_Association", 1, "Arena", "Capacity", None),
"nwsl": ("List_of_National_Women's_Soccer_League_stadiums", 0, "Stadium", "Capacity", None),
}
# Wikipedia name → list of our possible stadium names (for fuzzy matching)
NAME_OVERRIDES = {
# NBA
"Rocket Arena": ["Rocket Mortgage FieldHouse"],
"Mortgage Matchup Center": [], # skip — not in our DB
"Xfinity Mobile Arena": ["Footprint Center"], # Phoenix — renamed
# NHL
"Lenovo Center": ["PNC Arena"], # Carolina — renamed
"Benchmark International Arena": ["Amalie Arena"], # Tampa — renamed
"Grand Casino Arena": ["Xcel Energy Center"], # Minnesota — renamed
# MLS
"Energizer Park": ["CITYPARK"], # St. Louis — renamed
"Saputo Stadium": ["Stade Saputo"], # Montreal — same stadium, French name
"ScottsMiracle-Gro Field": ["Lower.com Field"], # Columbus — renamed
"Sporting Park": ["Children's Mercy Park"], # KC — renamed
"Sports Illustrated Stadium": [], # skip — may not be in our DB yet
# NWSL
"CPKC Stadium": ["Children's Mercy Park"], # KC shared name
}
class Command(BaseCommand):
help = "Populate stadium capacity and opened_year from Wikipedia."
def add_arguments(self, parser):
parser.add_argument(
"--sport",
type=str,
choices=list(WIKI_SOURCES.keys()),
help="Only process a single sport",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would change without saving",
)
def handle(self, *args, **options):
sport_filter = options["sport"]
dry_run = options["dry_run"]
sports = [sport_filter] if sport_filter else list(WIKI_SOURCES.keys())
if dry_run:
self.stdout.write(self.style.WARNING("DRY RUN — no changes will be saved"))
for sport_code in sports:
self._process_sport(sport_code, dry_run)
self._print_summary()
def _process_sport(self, sport_code, dry_run):
page, table_idx, name_col, cap_col, opened_col = WIKI_SOURCES[sport_code]
self.stdout.write(f"\n{'='*60}")
self.stdout.write(self.style.HTTP_INFO(f"Processing {sport_code.upper()} — Wikipedia: {page}"))
self.stdout.write(f"{'='*60}")
# Fetch Wikipedia page
wiki_data = self._fetch_wiki_table(page, table_idx, name_col, cap_col, opened_col)
if not wiki_data:
self.stderr.write(self.style.ERROR(" Failed to parse Wikipedia table"))
return
self.stdout.write(f" Wikipedia returned {len(wiki_data)} venues")
# Get our stadiums for this sport
db_stadiums = Stadium.objects.filter(sport_id=sport_code)
# Build lookup: normalized name → stadium
stadium_lookup = {}
for s in db_stadiums:
stadium_lookup[self._normalize_name(s.name)] = s
matched = 0
updated = 0
unmatched_wiki = []
for wiki_name, info in wiki_data.items():
stadium = self._find_stadium(wiki_name, stadium_lookup)
if not stadium:
unmatched_wiki.append(wiki_name)
continue
matched += 1
changes = []
capacity = info.get("capacity")
opened = info.get("opened")
if capacity and (stadium.capacity is None or stadium.capacity != capacity):
changes.append(f"capacity: {stadium.capacity}{capacity}")
if not dry_run:
stadium.capacity = capacity
if opened and (stadium.opened_year is None or stadium.opened_year != opened):
changes.append(f"opened_year: {stadium.opened_year}{opened}")
if not dry_run:
stadium.opened_year = opened
if changes:
updated += 1
self.stdout.write(f" {stadium.name}")
for c in changes:
self.stdout.write(f" {c}")
if not dry_run:
update_fields = ["updated_at"]
if capacity:
update_fields.append("capacity")
if opened:
update_fields.append("opened_year")
stadium.save(update_fields=update_fields)
self.stdout.write(f"\n Matched: {matched} | Updated: {updated}")
if unmatched_wiki:
self.stdout.write(self.style.WARNING(
f" Wiki venues with no DB match ({len(unmatched_wiki)}):"
))
for name in sorted(unmatched_wiki):
self.stdout.write(f" - {name}")
# Check for DB stadiums that didn't match
matched_ids = set()
for wiki_name in wiki_data:
s = self._find_stadium(wiki_name, stadium_lookup)
if s:
matched_ids.add(s.id)
unmatched_db = [s for s in db_stadiums if s.id not in matched_ids]
if unmatched_db:
self.stdout.write(self.style.WARNING(
f" DB stadiums with no Wiki match ({len(unmatched_db)}):"
))
for s in sorted(unmatched_db, key=lambda x: x.name):
self.stdout.write(f" - {s.name} ({s.id})")
def _fetch_wiki_table(self, page, table_idx, name_col, cap_col, opened_col):
"""Fetch and parse a Wikipedia table. Returns {name: {capacity, opened}}."""
params = {
"action": "parse",
"page": page,
"prop": "text",
"format": "json",
"redirects": "true",
}
headers = {
"User-Agent": "SportsTimeBot/1.0 (stadium metadata; contact@example.com)",
}
try:
resp = requests.get(WIKI_API, params=params, headers=headers, timeout=15)
resp.raise_for_status()
data = resp.json()
except requests.RequestException as e:
self.stderr.write(f" Failed to fetch Wikipedia: {e}")
return None
if "error" in data:
self.stderr.write(f" Wikipedia error: {data['error']['info']}")
return None
html = data["parse"]["text"]["*"]
soup = BeautifulSoup(html, "lxml")
tables = soup.find_all("table", class_="wikitable")
if table_idx >= len(tables):
self.stderr.write(f" Table index {table_idx} out of range ({len(tables)} tables)")
return None
table = tables[table_idx]
return self._parse_table(table, name_col, cap_col, opened_col)
def _parse_table(self, table, name_col, cap_col, opened_col):
"""Parse an HTML table into {name: {capacity, opened}}.
Handles rowspan by detecting column count mismatches and adjusting indices.
"""
result = {}
# Get header indices from the actual <th> row
header_row = table.find("tr")
if not header_row:
return result
headers = [th.get_text(strip=True) for th in header_row.find_all("th")]
expected_cols = len(headers)
name_idx = self._find_col_idx(headers, name_col)
cap_idx = self._find_col_idx(headers, cap_col)
opened_idx = self._find_col_idx(headers, opened_col) if opened_col else None
if name_idx is None or cap_idx is None:
self.stderr.write(f" Could not find columns: name_col={name_col}({name_idx}), cap_col={cap_col}({cap_idx})")
self.stderr.write(f" Available headers: {headers}")
return result
rows = table.find_all("tr")[1:] # Skip header
for row in rows:
cells = row.find_all(["td", "th"])
actual_cols = len(cells)
# When a row has fewer cells than headers, a rowspan column is
# spanning from a previous row. Shift indices down by the difference.
offset = expected_cols - actual_cols
adj_name = name_idx - offset
adj_cap = cap_idx - offset
adj_opened = (opened_idx - offset) if opened_idx is not None else None
if adj_name < 0 or adj_cap < 0 or adj_name >= actual_cols or adj_cap >= actual_cols:
continue
name = cells[adj_name].get_text(strip=True)
# Clean up name — remove citation marks
name = re.sub(r"\[.*?\]", "", name).strip()
# Remove daggers and asterisks
name = re.sub(r"[†‡*♠§#]", "", name).strip()
if not name:
continue
# Parse capacity
cap_text = cells[adj_cap].get_text(strip=True)
capacity = self._parse_capacity(cap_text)
# Parse opened year
opened = None
if adj_opened is not None and 0 <= adj_opened < actual_cols:
opened_text = cells[adj_opened].get_text(strip=True)
opened = self._parse_year(opened_text)
result[name] = {"capacity": capacity, "opened": opened}
return result
def _find_col_idx(self, headers, col_name):
"""Find column index by name (fuzzy match)."""
if col_name is None:
return None
col_lower = col_name.lower()
for i, h in enumerate(headers):
if col_lower in h.lower():
return i
return None
def _parse_capacity(self, text):
"""Extract numeric capacity from text like '18,000' or '20,000[1]'."""
# Remove citations and parenthetical notes
text = re.sub(r"\[.*?\]", "", text)
text = re.sub(r"\(.*?\)", "", text)
# Find first number with commas
match = re.search(r"[\d,]+", text)
if match:
try:
return int(match.group().replace(",", ""))
except ValueError:
pass
return None
def _parse_year(self, text):
"""Extract a 4-digit year from text."""
text = re.sub(r"\[.*?\]", "", text)
match = re.search(r"\b((?:19|20)\d{2})\b", text)
if match:
return int(match.group(1))
return None
def _normalize_name(self, name):
"""Normalize stadium name for matching."""
name = name.lower()
name = re.sub(r"[''`.]", "", name)
name = re.sub(r"\s+", " ", name).strip()
return name
def _find_stadium(self, wiki_name, stadium_lookup):
"""Find a stadium in our DB by Wikipedia name."""
# Check overrides first (empty list = explicitly skip)
if wiki_name in NAME_OVERRIDES:
override_names = NAME_OVERRIDES[wiki_name]
if not override_names:
return None # Explicitly skip
for alt in override_names:
alt_norm = self._normalize_name(alt)
if alt_norm in stadium_lookup:
return stadium_lookup[alt_norm]
# Direct normalized match
normalized = self._normalize_name(wiki_name)
if normalized in stadium_lookup:
return stadium_lookup[normalized]
# Fuzzy: check if wiki name is a substring of any DB name or vice versa
for db_norm, stadium in stadium_lookup.items():
if normalized in db_norm or db_norm in normalized:
return stadium
return None
def _print_summary(self):
self.stdout.write(f"\n{'='*60}")
self.stdout.write(self.style.HTTP_INFO("Summary"))
self.stdout.write(f"{'='*60}")
total = Stadium.objects.count()
has_cap = Stadium.objects.exclude(capacity__isnull=True).count()
has_year = Stadium.objects.exclude(opened_year__isnull=True).count()
has_img = Stadium.objects.exclude(image_url="").count()
self.stdout.write(f" Total stadiums: {total}")
self.stdout.write(f" With capacity: {has_cap}")
self.stdout.write(f" With opened_year: {has_year}")
self.stdout.write(f" With image_url: {has_img}")

View File

@@ -0,0 +1,147 @@
"""
Fetch stadium image URLs from ESPN's per-team API.
ESPN provides venue images for NBA, NFL, MLB, NHL via each team's
franchise.venue.images field. MLS/WNBA/NWSL are not available.
Usage:
python manage.py populate_stadium_images
python manage.py populate_stadium_images --sport nba
python manage.py populate_stadium_images --dry-run
"""
import time
import requests
from django.core.management.base import BaseCommand
from core.models import Team, Stadium
# ESPN sport path segments (only sports with franchise.venue data)
ESPN_SPORT_PATHS = {
"nba": "basketball/nba",
"nfl": "football/nfl",
"mlb": "baseball/mlb",
"nhl": "hockey/nhl",
}
# ESPN abbreviation → slug overrides (where abbreviation != URL slug)
ESPN_SLUG_OVERRIDES = {
"nba": {"GS": "gs", "NO": "no", "NY": "ny", "SA": "sa", "UTAH": "utah", "WSH": "wsh"},
"nfl": {"WSH": "wsh"},
"mlb": {"WSH": "wsh", "ATH": "ath"},
"nhl": {"WSH": "wsh", "UTAH": "utah"},
}
# Our abbreviation → ESPN abbreviation (reverse of team metadata overrides)
OUR_TO_ESPN_ABBREV = {
"nba": {"GSW": "GS", "NOP": "NO", "NYK": "NY", "SAS": "SA", "UTA": "UTAH", "WAS": "WSH"},
"nfl": {"WAS": "WSH"},
"mlb": {"WSN": "WSH", "OAK": "ATH"},
"nhl": {"WAS": "WSH", "ARI": "UTAH"},
}
class Command(BaseCommand):
help = "Populate stadium image_url from ESPN venue data (NBA, NFL, MLB, NHL)."
def add_arguments(self, parser):
parser.add_argument(
"--sport",
type=str,
choices=list(ESPN_SPORT_PATHS.keys()),
help="Only process a single sport",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would change without saving",
)
def handle(self, *args, **options):
sport_filter = options["sport"]
dry_run = options["dry_run"]
sports = [sport_filter] if sport_filter else list(ESPN_SPORT_PATHS.keys())
if dry_run:
self.stdout.write(self.style.WARNING("DRY RUN — no changes will be saved"))
for sport_code in sports:
self._process_sport(sport_code, dry_run)
self._print_summary()
def _process_sport(self, sport_code, dry_run):
self.stdout.write(f"\n{'='*60}")
self.stdout.write(self.style.HTTP_INFO(f"Processing {sport_code.upper()} stadiums"))
self.stdout.write(f"{'='*60}")
sport_path = ESPN_SPORT_PATHS[sport_code]
abbrev_map = OUR_TO_ESPN_ABBREV.get(sport_code, {})
# Get teams with home stadiums
teams = Team.objects.filter(
sport_id=sport_code,
home_stadium__isnull=False,
).select_related("home_stadium")
updated_stadiums = set()
failed = 0
for team in teams:
stadium = team.home_stadium
# Skip if already has image or already updated this run
if stadium.id in updated_stadiums:
continue
if stadium.image_url and not dry_run:
updated_stadiums.add(stadium.id)
continue
# Build ESPN team slug (lowercase abbreviation)
espn_abbrev = abbrev_map.get(team.abbreviation, team.abbreviation)
slug = espn_abbrev.lower()
url = f"https://site.api.espn.com/apis/site/v2/sports/{sport_path}/teams/{slug}"
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
except requests.RequestException as e:
self.stderr.write(f" {team.abbreviation:6} FAILED: {e}")
failed += 1
time.sleep(0.3)
continue
# Extract venue image
venue = data.get("team", {}).get("franchise", {}).get("venue", {})
images = venue.get("images", [])
image_url = images[0]["href"] if images else ""
if image_url and stadium.image_url != image_url:
self.stdout.write(f" {team.abbreviation:6} {stadium.name}")
self.stdout.write(f" image_url → {image_url}")
if not dry_run:
stadium.image_url = image_url
stadium.save(update_fields=["image_url", "updated_at"])
elif not image_url:
self.stdout.write(self.style.WARNING(
f" {team.abbreviation:6} {stadium.name} — no image from ESPN"
))
updated_stadiums.add(stadium.id)
time.sleep(0.2) # Rate limiting
self.stdout.write(f"\n Stadiums updated: {len(updated_stadiums)} | Failed: {failed}")
def _print_summary(self):
self.stdout.write(f"\n{'='*60}")
self.stdout.write(self.style.HTTP_INFO("Summary"))
self.stdout.write(f"{'='*60}")
total = Stadium.objects.count()
has_image = Stadium.objects.exclude(image_url="").count()
self.stdout.write(f" Total stadiums: {total}")
self.stdout.write(f" With image_url: {has_image}")
self.stdout.write(f" Missing image_url: {total - has_image}")

View File

@@ -0,0 +1,268 @@
"""
Fetch team logos, colors, and MLS division assignments from ESPN's public API.
Usage:
python manage.py populate_team_metadata # all sports
python manage.py populate_team_metadata --sport nba
python manage.py populate_team_metadata --dry-run
"""
import requests
from django.core.management.base import BaseCommand
from core.models import Team, Sport, Conference, Division
ESPN_ENDPOINTS = {
"nba": "https://site.api.espn.com/apis/site/v2/sports/basketball/nba/teams",
"nfl": "https://site.api.espn.com/apis/site/v2/sports/football/nfl/teams",
"mlb": "https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/teams",
"nhl": "https://site.api.espn.com/apis/site/v2/sports/hockey/nhl/teams",
"mls": "https://site.api.espn.com/apis/site/v2/sports/soccer/usa.1/teams",
"wnba": "https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/teams",
"nwsl": "https://site.api.espn.com/apis/site/v2/sports/soccer/usa.nwsl/teams",
}
# ESPN abbreviation → our abbreviation (where they differ)
ABBREV_OVERRIDES = {
"nba": {"GS": "GSW", "NO": "NOP", "NY": "NYK", "SA": "SAS", "UTAH": "UTA", "WSH": "WAS"},
"nfl": {"WSH": "WAS"},
"mlb": {"WSH": "WSN", "ATH": "OAK"},
"nhl": {"WSH": "WAS", "UTAH": "ARI"},
"mls": {"ATX": "AUS", "NY": "RB", "RSL": "SLC", "LA": "LAG"},
"wnba": {"GS": "GSV", "WSH": "WAS"},
"nwsl": {
"LA": "ANG",
"GFC": "NJY",
"KC": "KCC",
"NC": "NCC",
"LOU": "RGN",
"SD": "SDW",
"WAS": "WSH",
},
}
# MLS conference assignments (from mls.py scrape_teams)
MLS_CONFERENCES = {
"Eastern": [
"ATL", "CLT", "CHI", "CIN", "CLB", "DC", "MIA", "MTL",
"NE", "NYC", "RB", "ORL", "PHI", "TOR",
],
"Western": [
"AUS", "COL", "DAL", "HOU", "LAG", "LAFC", "MIN", "NSH",
"POR", "SLC", "SD", "SJ", "SEA", "SKC", "STL", "VAN",
],
}
class Command(BaseCommand):
help = "Populate team logo_url, primary_color, secondary_color from ESPN, and assign MLS divisions."
def add_arguments(self, parser):
parser.add_argument(
"--sport",
type=str,
choices=list(ESPN_ENDPOINTS.keys()),
help="Only process a single sport",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would change without saving",
)
def handle(self, *args, **options):
sport_filter = options["sport"]
dry_run = options["dry_run"]
sports = [sport_filter] if sport_filter else list(ESPN_ENDPOINTS.keys())
if dry_run:
self.stdout.write(self.style.WARNING("DRY RUN — no changes will be saved"))
for sport_code in sports:
self._process_sport(sport_code, dry_run)
if "mls" in sports:
self._assign_mls_divisions(dry_run)
self._print_summary()
def _process_sport(self, sport_code, dry_run):
self.stdout.write(f"\n{'='*60}")
self.stdout.write(self.style.HTTP_INFO(f"Processing {sport_code.upper()}"))
self.stdout.write(f"{'='*60}")
url = ESPN_ENDPOINTS[sport_code]
try:
resp = requests.get(url, timeout=15)
resp.raise_for_status()
data = resp.json()
except requests.RequestException as e:
self.stderr.write(self.style.ERROR(f" Failed to fetch {url}: {e}"))
return
# Parse ESPN response
espn_teams = self._parse_espn_teams(data, sport_code)
if not espn_teams:
self.stderr.write(self.style.ERROR(f" No teams found in ESPN response"))
return
self.stdout.write(f" ESPN returned {len(espn_teams)} teams")
# Get our DB teams for this sport
db_teams = Team.objects.filter(sport_id=sport_code)
db_abbrevs = {t.abbreviation: t for t in db_teams}
overrides = ABBREV_OVERRIDES.get(sport_code, {})
matched = 0
updated = 0
unmatched_espn = []
for espn_abbrev, meta in espn_teams.items():
# Remap ESPN abbreviation to ours
our_abbrev = overrides.get(espn_abbrev, espn_abbrev)
team = db_abbrevs.pop(our_abbrev, None)
if not team:
unmatched_espn.append(f"{espn_abbrev} (mapped→{our_abbrev})" if espn_abbrev != our_abbrev else espn_abbrev)
continue
matched += 1
changes = []
if meta["logo_url"] and team.logo_url != meta["logo_url"]:
changes.append(f"logo_url → {meta['logo_url'][:60]}")
if not dry_run:
team.logo_url = meta["logo_url"]
if meta["primary_color"] and team.primary_color != meta["primary_color"]:
changes.append(f"primary_color → {meta['primary_color']}")
if not dry_run:
team.primary_color = meta["primary_color"]
if meta["secondary_color"] and team.secondary_color != meta["secondary_color"]:
changes.append(f"secondary_color → {meta['secondary_color']}")
if not dry_run:
team.secondary_color = meta["secondary_color"]
if changes:
updated += 1
self.stdout.write(f" {team.abbreviation:6} {team.full_name}")
for c in changes:
self.stdout.write(f" {c}")
if not dry_run:
team.save(update_fields=["logo_url", "primary_color", "secondary_color", "updated_at"])
# Report
self.stdout.write(f"\n Matched: {matched} | Updated: {updated}")
if unmatched_espn:
self.stdout.write(self.style.WARNING(f" ESPN teams with no DB match: {', '.join(sorted(unmatched_espn))}"))
if db_abbrevs:
missing = ", ".join(sorted(db_abbrevs.keys()))
self.stdout.write(self.style.WARNING(f" DB teams with no ESPN match: {missing}"))
def _parse_espn_teams(self, data, sport_code):
"""Extract abbreviation → {logo_url, primary_color, secondary_color} from ESPN response."""
result = {}
try:
teams_list = data["sports"][0]["leagues"][0]["teams"]
except (KeyError, IndexError):
return result
for entry in teams_list:
team = entry.get("team", {})
abbrev = team.get("abbreviation", "")
if not abbrev:
continue
color = team.get("color", "")
alt_color = team.get("alternateColor", "")
logos = team.get("logos", [])
logo_url = logos[0]["href"] if logos else ""
result[abbrev] = {
"logo_url": logo_url,
"primary_color": f"#{color}" if color else "",
"secondary_color": f"#{alt_color}" if alt_color else "",
}
return result
def _assign_mls_divisions(self, dry_run):
self.stdout.write(f"\n{'='*60}")
self.stdout.write(self.style.HTTP_INFO("Assigning MLS divisions"))
self.stdout.write(f"{'='*60}")
try:
mls_sport = Sport.objects.get(code="mls")
except Sport.DoesNotExist:
self.stderr.write(self.style.ERROR(" MLS sport not found in DB"))
return
# Build reverse lookup: abbreviation → conference name
abbrev_to_conf = {}
for conf_name, abbrevs in MLS_CONFERENCES.items():
for abbrev in abbrevs:
abbrev_to_conf[abbrev] = conf_name
# Pre-create conferences and divisions (skip in dry-run)
division_cache = {} # conf_name → Division
if not dry_run:
for conf_name in MLS_CONFERENCES:
conference, conf_created = Conference.objects.get_or_create(
sport=mls_sport,
name=f"{conf_name} Conference",
defaults={"short_name": conf_name[:4], "order": 0 if conf_name == "Eastern" else 1},
)
if conf_created:
self.stdout.write(f" Created conference: {conference}")
division, div_created = Division.objects.get_or_create(
conference=conference,
name=conf_name,
defaults={"short_name": conf_name[:4], "order": 0},
)
if div_created:
self.stdout.write(f" Created division: {division}")
division_cache[conf_name] = division
assigned = 0
for team in Team.objects.filter(sport=mls_sport):
conf_name = abbrev_to_conf.get(team.abbreviation)
if not conf_name:
self.stdout.write(self.style.WARNING(f" {team.abbreviation} not in conference map — skipping"))
continue
if dry_run:
if team.division is None:
self.stdout.write(f" {team.abbreviation:6}{conf_name}")
assigned += 1
else:
division = division_cache[conf_name]
if team.division != division:
self.stdout.write(f" {team.abbreviation:6}{division}")
assigned += 1
team.division = division
team.save(update_fields=["division", "updated_at"])
self.stdout.write(f"\n Divisions assigned: {assigned}")
def _print_summary(self):
self.stdout.write(f"\n{'='*60}")
self.stdout.write(self.style.HTTP_INFO("Summary"))
self.stdout.write(f"{'='*60}")
total = Team.objects.count()
missing_logo = Team.objects.filter(logo_url="").count()
missing_color = Team.objects.filter(primary_color="").count()
missing_div = Team.objects.filter(division__isnull=True).count()
self.stdout.write(f" Total teams: {total}")
self.stdout.write(f" Missing logo: {missing_logo}")
self.stdout.write(f" Missing color: {missing_color}")
self.stdout.write(f" Missing division: {missing_div}")

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')],
},
),
]

View File

@@ -0,0 +1,53 @@
# Generated manually
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='conference',
name='canonical_id',
field=models.CharField(
blank=True,
db_index=True,
help_text='Canonical ID from bootstrap JSON (e.g., nba_eastern)',
max_length=100,
),
),
migrations.AddField(
model_name='division',
name='canonical_id',
field=models.CharField(
blank=True,
db_index=True,
help_text='Canonical ID from bootstrap JSON (e.g., nba_southeast)',
max_length=100,
),
),
migrations.AddField(
model_name='historicalconference',
name='canonical_id',
field=models.CharField(
blank=True,
db_index=True,
help_text='Canonical ID from bootstrap JSON (e.g., nba_eastern)',
max_length=100,
),
),
migrations.AddField(
model_name='historicaldivision',
name='canonical_id',
field=models.CharField(
blank=True,
db_index=True,
help_text='Canonical ID from bootstrap JSON (e.g., nba_southeast)',
max_length=100,
),
),
]

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_conference_division_canonical_id'),
]
operations = [
migrations.AddField(
model_name='sport',
name='icon_name',
field=models.CharField(blank=True, help_text='SF Symbol name (e.g., baseball.fill, basketball.fill)', max_length=50),
),
migrations.AddField(
model_name='sport',
name='color_hex',
field=models.CharField(blank=True, help_text='Brand color hex (e.g., #CE1141)', max_length=10),
),
]

0
core/migrations/__init__.py Executable file
View File

17
core/models/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
from .sport import Sport
from .league_structure import Conference, Division
from .team import Team
from .stadium import Stadium
from .game import Game
from .alias import TeamAlias, StadiumAlias
__all__ = [
'Sport',
'Conference',
'Division',
'Team',
'Stadium',
'Game',
'TeamAlias',
'StadiumAlias',
]

169
core/models/alias.py Normal file
View File

@@ -0,0 +1,169 @@
from django.db import models
from simple_history.models import HistoricalRecords
class TeamAlias(models.Model):
"""
Historical team name aliases for resolution.
Handles team renames, relocations, and alternate names.
"""
ALIAS_TYPE_CHOICES = [
('full_name', 'Full Name'),
('city_name', 'City + Name'),
('abbreviation', 'Abbreviation'),
('nickname', 'Nickname'),
('historical', 'Historical Name'),
]
team = models.ForeignKey(
'core.Team',
on_delete=models.CASCADE,
related_name='aliases'
)
alias = models.CharField(
max_length=200,
help_text='The alias text to match against'
)
alias_type = models.CharField(
max_length=20,
choices=ALIAS_TYPE_CHOICES,
default='full_name'
)
valid_from = models.DateField(
null=True,
blank=True,
help_text='Date from which this alias is valid (inclusive)'
)
valid_until = models.DateField(
null=True,
blank=True,
help_text='Date until which this alias is valid (inclusive)'
)
is_primary = models.BooleanField(
default=False,
help_text='Whether this is a primary/preferred alias'
)
source = models.CharField(
max_length=200,
blank=True,
help_text='Source of this alias (e.g., ESPN, Basketball-Reference)'
)
notes = models.TextField(
blank=True,
help_text='Notes about this alias (e.g., relocation details)'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Audit trail
history = HistoricalRecords()
class Meta:
ordering = ['team', '-valid_from']
verbose_name = 'Team Alias'
verbose_name_plural = 'Team Aliases'
indexes = [
models.Index(fields=['alias']),
models.Index(fields=['team', 'valid_from', 'valid_until']),
]
def __str__(self):
date_range = ""
if self.valid_from or self.valid_until:
start = self.valid_from.strftime('%Y') if self.valid_from else '...'
end = self.valid_until.strftime('%Y') if self.valid_until else 'present'
date_range = f" ({start}-{end})"
return f"{self.alias}{self.team.abbreviation}{date_range}"
def is_valid_for_date(self, check_date):
"""Check if this alias is valid for a given date."""
if self.valid_from and check_date < self.valid_from:
return False
if self.valid_until and check_date > self.valid_until:
return False
return True
class StadiumAlias(models.Model):
"""
Historical stadium name aliases for resolution.
Handles naming rights changes and alternate names.
"""
ALIAS_TYPE_CHOICES = [
('official', 'Official Name'),
('former', 'Former Name'),
('nickname', 'Nickname'),
('abbreviation', 'Abbreviation'),
]
stadium = models.ForeignKey(
'core.Stadium',
on_delete=models.CASCADE,
related_name='aliases'
)
alias = models.CharField(
max_length=200,
help_text='The alias text to match against'
)
alias_type = models.CharField(
max_length=20,
choices=ALIAS_TYPE_CHOICES,
default='official'
)
valid_from = models.DateField(
null=True,
blank=True,
help_text='Date from which this alias is valid (inclusive)'
)
valid_until = models.DateField(
null=True,
blank=True,
help_text='Date until which this alias is valid (inclusive)'
)
is_primary = models.BooleanField(
default=False,
help_text='Whether this is the current/primary name'
)
source = models.CharField(
max_length=200,
blank=True,
help_text='Source of this alias'
)
notes = models.TextField(
blank=True,
help_text='Notes about this alias (e.g., naming rights deal)'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Audit trail
history = HistoricalRecords()
class Meta:
ordering = ['stadium', '-valid_from']
verbose_name = 'Stadium Alias'
verbose_name_plural = 'Stadium Aliases'
indexes = [
models.Index(fields=['alias']),
models.Index(fields=['stadium', 'valid_from', 'valid_until']),
]
def __str__(self):
date_range = ""
if self.valid_from or self.valid_until:
start = self.valid_from.strftime('%Y') if self.valid_from else '...'
end = self.valid_until.strftime('%Y') if self.valid_until else 'present'
date_range = f" ({start}-{end})"
return f"{self.alias}{self.stadium.name}{date_range}"
def is_valid_for_date(self, check_date):
"""Check if this alias is valid for a given date."""
if self.valid_from and check_date < self.valid_from:
return False
if self.valid_until and check_date > self.valid_until:
return False
return True

146
core/models/game.py Normal file
View File

@@ -0,0 +1,146 @@
from django.db import models
from simple_history.models import HistoricalRecords
class Game(models.Model):
"""
Game model representing a single game between two teams.
"""
STATUS_CHOICES = [
('scheduled', 'Scheduled'),
('in_progress', 'In Progress'),
('final', 'Final'),
('postponed', 'Postponed'),
('cancelled', 'Cancelled'),
('suspended', 'Suspended'),
]
id = models.CharField(
max_length=100,
primary_key=True,
help_text='Canonical ID (e.g., game_nba_2025_20251022_bos_lal)'
)
sport = models.ForeignKey(
'core.Sport',
on_delete=models.CASCADE,
related_name='games'
)
season = models.PositiveSmallIntegerField(
help_text='Season start year (e.g., 2025 for 2025-26 season)'
)
home_team = models.ForeignKey(
'core.Team',
on_delete=models.CASCADE,
related_name='home_games'
)
away_team = models.ForeignKey(
'core.Team',
on_delete=models.CASCADE,
related_name='away_games'
)
stadium = models.ForeignKey(
'core.Stadium',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='games'
)
game_date = models.DateTimeField(
help_text='Game date and time (UTC)'
)
game_number = models.PositiveSmallIntegerField(
null=True,
blank=True,
help_text='Game number for doubleheaders (1 or 2)'
)
home_score = models.PositiveSmallIntegerField(
null=True,
blank=True
)
away_score = models.PositiveSmallIntegerField(
null=True,
blank=True
)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='scheduled'
)
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(
max_length=50,
blank=True,
help_text='Playoff round (e.g., Finals, Conference Finals)'
)
# Raw scraped values (for debugging/review)
raw_home_team = models.CharField(
max_length=200,
blank=True,
help_text='Original scraped home team name'
)
raw_away_team = models.CharField(
max_length=200,
blank=True,
help_text='Original scraped away team name'
)
raw_stadium = models.CharField(
max_length=200,
blank=True,
help_text='Original scraped stadium name'
)
source_url = models.URLField(
blank=True,
help_text='URL where game was scraped from'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Audit trail
history = HistoricalRecords()
class Meta:
ordering = ['-game_date', 'sport']
verbose_name = 'Game'
verbose_name_plural = 'Games'
indexes = [
models.Index(fields=['sport', 'season']),
models.Index(fields=['sport', 'game_date']),
models.Index(fields=['home_team', 'season']),
models.Index(fields=['away_team', 'season']),
models.Index(fields=['status']),
]
def __str__(self):
return f"{self.away_team.abbreviation} @ {self.home_team.abbreviation} - {self.game_date.strftime('%Y-%m-%d')}"
@property
def is_final(self):
return self.status == 'final'
@property
def winner(self):
"""Return winning team or None if not final."""
if not self.is_final or self.home_score is None or self.away_score is None:
return None
if self.home_score > self.away_score:
return self.home_team
elif self.away_score > self.home_score:
return self.away_team
return None # Tie
@property
def score_display(self):
"""Return score as 'away_score - home_score' or 'TBD'."""
if self.home_score is not None and self.away_score is not None:
return f"{self.away_score} - {self.home_score}"
return "TBD"

View File

@@ -0,0 +1,92 @@
from django.db import models
from simple_history.models import HistoricalRecords
class Conference(models.Model):
"""
Conference within a sport (e.g., Eastern, Western for NBA).
"""
sport = models.ForeignKey(
'core.Sport',
on_delete=models.CASCADE,
related_name='conferences'
)
canonical_id = models.CharField(
max_length=100,
blank=True,
db_index=True,
help_text='Canonical ID from bootstrap JSON (e.g., nba_eastern)'
)
name = models.CharField(max_length=50)
short_name = models.CharField(
max_length=10,
blank=True,
help_text='Short name (e.g., East, West)'
)
order = models.PositiveSmallIntegerField(
default=0,
help_text='Display order'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Audit trail
history = HistoricalRecords()
class Meta:
ordering = ['sport', 'order', 'name']
unique_together = ['sport', 'name']
verbose_name = 'Conference'
verbose_name_plural = 'Conferences'
def __str__(self):
return f"{self.sport.short_name} - {self.name}"
class Division(models.Model):
"""
Division within a conference (e.g., Atlantic, Central for NBA East).
"""
conference = models.ForeignKey(
Conference,
on_delete=models.CASCADE,
related_name='divisions'
)
canonical_id = models.CharField(
max_length=100,
blank=True,
db_index=True,
help_text='Canonical ID from bootstrap JSON (e.g., nba_southeast)'
)
name = models.CharField(max_length=50)
short_name = models.CharField(
max_length=10,
blank=True,
help_text='Short name'
)
order = models.PositiveSmallIntegerField(
default=0,
help_text='Display order'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Audit trail
history = HistoricalRecords()
class Meta:
ordering = ['conference', 'order', 'name']
unique_together = ['conference', 'name']
verbose_name = 'Division'
verbose_name_plural = 'Divisions'
def __str__(self):
return f"{self.conference.sport.short_name} - {self.conference.name} - {self.name}"
@property
def sport(self):
return self.conference.sport

78
core/models/sport.py Normal file
View File

@@ -0,0 +1,78 @@
from django.db import models
from simple_history.models import HistoricalRecords
class Sport(models.Model):
"""
Sport configuration model.
"""
SEASON_TYPE_CHOICES = [
('split', 'Split Year (e.g., 2024-25)'),
('single', 'Single Year (e.g., 2024)'),
]
code = models.CharField(
max_length=10,
primary_key=True,
help_text='Sport code (e.g., nba, mlb, nfl)'
)
name = models.CharField(
max_length=100,
help_text='Full name (e.g., National Basketball Association)'
)
short_name = models.CharField(
max_length=20,
help_text='Short name (e.g., NBA)'
)
season_type = models.CharField(
max_length=10,
choices=SEASON_TYPE_CHOICES,
help_text='Whether season spans two years or one'
)
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)'
)
icon_name = models.CharField(
max_length=50,
blank=True,
help_text='SF Symbol name (e.g., baseball.fill, basketball.fill)'
)
color_hex = models.CharField(
max_length=10,
blank=True,
help_text='Brand color hex (e.g., #CE1141)'
)
is_active = models.BooleanField(
default=True,
help_text='Whether this sport is actively being scraped'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Audit trail
history = HistoricalRecords()
class Meta:
ordering = ['name']
verbose_name = 'Sport'
verbose_name_plural = 'Sports'
def __str__(self):
return self.short_name
def get_season_display(self, year: int) -> str:
"""Return display string for a season (e.g., '2024-25' or '2024')."""
if self.season_type == 'split':
return f"{year}-{str(year + 1)[-2:]}"
return str(year)

109
core/models/stadium.py Normal file
View File

@@ -0,0 +1,109 @@
from django.db import models
from simple_history.models import HistoricalRecords
class Stadium(models.Model):
"""
Stadium/Arena/Venue model.
"""
SURFACE_CHOICES = [
('grass', 'Natural Grass'),
('turf', 'Artificial Turf'),
('ice', 'Ice'),
('hardwood', 'Hardwood'),
('other', 'Other'),
]
ROOF_TYPE_CHOICES = [
('dome', 'Dome (Closed)'),
('retractable', 'Retractable'),
('open', 'Open Air'),
]
id = models.CharField(
max_length=100,
primary_key=True,
help_text='Canonical ID (e.g., stadium_nba_los_angeles_lakers)'
)
sport = models.ForeignKey(
'core.Sport',
on_delete=models.CASCADE,
related_name='stadiums'
)
name = models.CharField(
max_length=200,
help_text='Current stadium name'
)
city = models.CharField(max_length=100)
state = models.CharField(
max_length=100,
blank=True,
help_text='State/Province (blank for international)'
)
country = models.CharField(
max_length=100,
default='USA'
)
latitude = models.DecimalField(
max_digits=9,
decimal_places=6,
null=True,
blank=True
)
longitude = models.DecimalField(
max_digits=9,
decimal_places=6,
null=True,
blank=True
)
capacity = models.PositiveIntegerField(
null=True,
blank=True,
help_text='Seating capacity'
)
surface = models.CharField(
max_length=20,
choices=SURFACE_CHOICES,
blank=True
)
roof_type = models.CharField(
max_length=20,
choices=ROOF_TYPE_CHOICES,
blank=True
)
opened_year = models.PositiveSmallIntegerField(
null=True,
blank=True,
help_text='Year stadium opened'
)
timezone = models.CharField(
max_length=50,
blank=True,
help_text='IANA timezone (e.g., America/Los_Angeles)'
)
image_url = models.URLField(
blank=True,
help_text='URL to stadium image'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Audit trail
history = HistoricalRecords()
class Meta:
ordering = ['sport', 'city', 'name']
verbose_name = 'Stadium'
verbose_name_plural = 'Stadiums'
def __str__(self):
return f"{self.name} ({self.city})"
@property
def location(self):
"""Return city, state/country string."""
if self.state:
return f"{self.city}, {self.state}"
return f"{self.city}, {self.country}"

88
core/models/team.py Normal file
View File

@@ -0,0 +1,88 @@
from django.db import models
from simple_history.models import HistoricalRecords
class Team(models.Model):
"""
Team model with canonical identifiers.
"""
id = models.CharField(
max_length=50,
primary_key=True,
help_text='Canonical ID (e.g., team_nba_lal)'
)
sport = models.ForeignKey(
'core.Sport',
on_delete=models.CASCADE,
related_name='teams'
)
division = models.ForeignKey(
'core.Division',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='teams'
)
city = models.CharField(
max_length=100,
help_text='Team city (e.g., Los Angeles)'
)
name = models.CharField(
max_length=100,
help_text='Team name (e.g., Lakers)'
)
full_name = models.CharField(
max_length=200,
help_text='Full team name (e.g., Los Angeles Lakers)'
)
abbreviation = models.CharField(
max_length=10,
help_text='Team abbreviation (e.g., LAL)'
)
home_stadium = models.ForeignKey(
'core.Stadium',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='home_teams'
)
primary_color = models.CharField(
max_length=7,
blank=True,
help_text='Primary color hex (e.g., #552583)'
)
secondary_color = models.CharField(
max_length=7,
blank=True,
help_text='Secondary color hex (e.g., #FDB927)'
)
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'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Audit trail
history = HistoricalRecords()
class Meta:
ordering = ['sport', 'city', 'name']
verbose_name = 'Team'
verbose_name_plural = 'Teams'
def __str__(self):
return self.full_name
@property
def conference(self):
"""Return team's conference via division."""
if self.division:
return self.division.conference
return None

162
core/resources.py Normal file
View File

@@ -0,0 +1,162 @@
"""Import/Export resources for core models."""
from import_export import resources, fields
from import_export.widgets import ForeignKeyWidget
from .models import Sport, Conference, Division, Team, Stadium, Game, TeamAlias, StadiumAlias
class SportResource(resources.ModelResource):
class Meta:
model = Sport
import_id_fields = ['code']
fields = [
'code', 'name', 'short_name', 'season_type',
'season_start_month', 'season_end_month',
'expected_game_count', 'is_active',
]
export_order = fields
class ConferenceResource(resources.ModelResource):
sport = fields.Field(
column_name='sport',
attribute='sport',
widget=ForeignKeyWidget(Sport, 'code')
)
class Meta:
model = Conference
import_id_fields = ['sport', 'name']
fields = ['sport', 'canonical_id', 'name', 'short_name', 'order']
export_order = fields
class DivisionResource(resources.ModelResource):
conference = fields.Field(
column_name='conference',
attribute='conference',
widget=ForeignKeyWidget(Conference, 'name')
)
sport = fields.Field(attribute='conference__sport__code', readonly=True)
class Meta:
model = Division
import_id_fields = ['conference', 'name']
fields = ['sport', 'conference', 'canonical_id', 'name', 'short_name', 'order']
export_order = fields
class TeamResource(resources.ModelResource):
sport = fields.Field(
column_name='sport',
attribute='sport',
widget=ForeignKeyWidget(Sport, 'code')
)
division = fields.Field(
column_name='division',
attribute='division',
widget=ForeignKeyWidget(Division, 'name')
)
home_stadium = fields.Field(
column_name='home_stadium',
attribute='home_stadium',
widget=ForeignKeyWidget(Stadium, 'name')
)
class Meta:
model = Team
import_id_fields = ['id']
fields = [
'id', 'sport', 'division', 'city', 'name', 'full_name',
'abbreviation', 'primary_color', 'secondary_color',
'logo_url', 'home_stadium', 'is_active',
]
export_order = fields
class StadiumResource(resources.ModelResource):
sport = fields.Field(
column_name='sport',
attribute='sport',
widget=ForeignKeyWidget(Sport, 'code')
)
class Meta:
model = Stadium
import_id_fields = ['id']
fields = [
'id', 'sport', 'name', 'city', 'state', 'country',
'latitude', 'longitude', 'timezone', 'capacity',
'surface', 'roof_type', 'opened_year', 'image_url',
]
export_order = fields
class GameResource(resources.ModelResource):
sport = fields.Field(
column_name='sport',
attribute='sport',
widget=ForeignKeyWidget(Sport, 'code')
)
home_team = fields.Field(
column_name='home_team',
attribute='home_team',
widget=ForeignKeyWidget(Team, 'abbreviation')
)
away_team = fields.Field(
column_name='away_team',
attribute='away_team',
widget=ForeignKeyWidget(Team, 'abbreviation')
)
stadium = fields.Field(
column_name='stadium',
attribute='stadium',
widget=ForeignKeyWidget(Stadium, 'name')
)
class Meta:
model = Game
import_id_fields = ['id']
fields = [
'id', 'sport', 'season', 'home_team', 'away_team',
'stadium', 'game_date', 'game_number', 'status',
'home_score', 'away_score', 'is_playoff', 'playoff_round',
'is_neutral_site', 'source_url',
]
export_order = fields
class TeamAliasResource(resources.ModelResource):
team = fields.Field(
column_name='team',
attribute='team',
widget=ForeignKeyWidget(Team, 'abbreviation')
)
sport = fields.Field(attribute='team__sport__code', readonly=True)
class Meta:
model = TeamAlias
import_id_fields = ['team', 'alias']
fields = [
'sport', 'team', 'alias', 'alias_type',
'valid_from', 'valid_until', 'is_primary', 'source', 'notes',
]
export_order = fields
class StadiumAliasResource(resources.ModelResource):
stadium = fields.Field(
column_name='stadium',
attribute='stadium',
widget=ForeignKeyWidget(Stadium, 'name')
)
sport = fields.Field(attribute='stadium__sport__code', readonly=True)
class Meta:
model = StadiumAlias
import_id_fields = ['stadium', 'alias']
fields = [
'sport', 'stadium', 'alias', 'alias_type',
'valid_from', 'valid_until', 'is_primary', 'source', 'notes',
]
export_order = fields