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:
1
core/__init__.py
Normal file
1
core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
default_app_config = 'core.apps.CoreConfig'
|
||||
6
core/admin/__init__.py
Normal file
6
core/admin/__init__.py
Normal 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
84
core/admin/alias_admin.py
Normal 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
117
core/admin/game_admin.py
Normal 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.')
|
||||
70
core/admin/league_structure_admin.py
Normal file
70
core/admin/league_structure_admin.py
Normal 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
54
core/admin/sport_admin.py
Normal 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'
|
||||
89
core/admin/stadium_admin.py
Normal file
89
core/admin/stadium_admin.py
Normal 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
96
core/admin/team_admin.py
Normal 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;"> </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;"> </span>',
|
||||
obj.primary_color
|
||||
)
|
||||
if obj.secondary_color:
|
||||
html += format_html(
|
||||
'<span style="background-color: {}; padding: 5px 20px; border-radius: 3px;"> </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
7
core/apps.py
Normal 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'
|
||||
1
core/management/__init__.py
Normal file
1
core/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Management commands package
|
||||
1
core/management/commands/__init__.py
Normal file
1
core/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Commands package
|
||||
445
core/management/commands/export_data.py
Normal file
445
core/management/commands/export_data.py
Normal 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}')
|
||||
98
core/management/commands/fix_wnba_stadiums.py
Normal file
98
core/management/commands/fix_wnba_stadiums.py
Normal 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}")
|
||||
512
core/management/commands/import_data.py
Normal file
512
core/management/commands/import_data.py
Normal 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')
|
||||
351
core/management/commands/populate_stadium_details.py
Normal file
351
core/management/commands/populate_stadium_details.py
Normal 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}")
|
||||
147
core/management/commands/populate_stadium_images.py
Normal file
147
core/management/commands/populate_stadium_images.py
Normal 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}")
|
||||
268
core/management/commands/populate_team_metadata.py
Normal file
268
core/management/commands/populate_team_metadata.py
Normal 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
438
core/migrations/0001_initial.py
Executable 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
53
core/migrations/0002_conference_division_canonical_id.py
Executable file
53
core/migrations/0002_conference_division_canonical_id.py
Executable 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,
|
||||
),
|
||||
),
|
||||
]
|
||||
21
core/migrations/0003_sport_icon_name_color_hex.py
Normal file
21
core/migrations/0003_sport_icon_name_color_hex.py
Normal 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
0
core/migrations/__init__.py
Executable file
17
core/models/__init__.py
Normal file
17
core/models/__init__.py
Normal 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
169
core/models/alias.py
Normal 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
146
core/models/game.py
Normal 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"
|
||||
92
core/models/league_structure.py
Normal file
92
core/models/league_structure.py
Normal 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
78
core/models/sport.py
Normal 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
109
core/models/stadium.py
Normal 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
88
core/models/team.py
Normal 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
162
core/resources.py
Normal 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
|
||||
Reference in New Issue
Block a user