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

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

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

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

1
dashboard/__init__.py Normal file
View File

@@ -0,0 +1 @@
default_app_config = 'dashboard.apps.DashboardConfig'

7
dashboard/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class DashboardConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'dashboard'
verbose_name = 'Dashboard'

View File

@@ -0,0 +1,130 @@
{% extends "admin/base_site.html" %}
{% load static %}
{% block extrahead %}
{{ block.super }}
<style>
.dashboard-container {
padding: 20px;
}
.stat-card {
background: #fff;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.stat-card h3 {
margin: 0 0 15px 0;
color: #417690;
font-size: 16px;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.stat-item {
text-align: center;
padding: 15px;
background: #f8f9fa;
border-radius: 6px;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #417690;
}
.stat-label {
font-size: 12px;
color: #666;
text-transform: uppercase;
margin-top: 5px;
}
.table-responsive {
overflow-x: auto;
}
table.dashboard-table {
width: 100%;
border-collapse: collapse;
}
table.dashboard-table th,
table.dashboard-table td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #eee;
}
table.dashboard-table th {
background: #f8f9fa;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
color: #666;
}
.status-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: bold;
color: white;
}
.status-completed, .status-synced { background: #5cb85c; }
.status-running, .status-pending { background: #f0ad4e; }
.status-failed { background: #d9534f; }
.status-cancelled { background: #777; }
.btn-action {
display: inline-block;
padding: 8px 16px;
background: #417690;
color: white;
text-decoration: none;
border-radius: 4px;
font-size: 13px;
border: none;
cursor: pointer;
}
.btn-action:hover {
background: #205067;
color: white;
}
.btn-action.btn-secondary {
background: #6c757d;
}
.btn-action.btn-danger {
background: #d9534f;
}
.nav-tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #eee;
padding-bottom: 10px;
}
.nav-tabs a {
padding: 8px 16px;
text-decoration: none;
color: #666;
border-radius: 4px;
}
.nav-tabs a.active, .nav-tabs a:hover {
background: #417690;
color: white;
}
</style>
{% endblock %}
{% block content %}
<div class="dashboard-container">
<div class="nav-tabs">
<a href="{% url 'dashboard:index' %}" {% if request.resolver_match.url_name == 'index' %}class="active"{% endif %}>Overview</a>
<a href="{% url 'dashboard:stats' %}" {% if request.resolver_match.url_name == 'stats' %}class="active"{% endif %}>Statistics</a>
<a href="{% url 'dashboard:scraper_status' %}" {% if request.resolver_match.url_name == 'scraper_status' %}class="active"{% endif %}>Scrapers</a>
<a href="{% url 'dashboard:sync_status' %}" {% if request.resolver_match.url_name == 'sync_status' %}class="active"{% endif %}>CloudKit Sync</a>
<a href="{% url 'dashboard:review_queue' %}" {% if request.resolver_match.url_name == 'review_queue' %}class="active"{% endif %}>Review Queue{% if pending_reviews %} ({{ pending_reviews }}){% endif %}</a>
</div>
{% block dashboard_content %}{% endblock %}
</div>
{% endblock %}

View File

@@ -0,0 +1,125 @@
{% extends "dashboard/base.html" %}
{% block dashboard_content %}
<div class="stat-card">
<h3>Overview</h3>
<div class="stat-grid">
<div class="stat-item">
<div class="stat-value">{{ sports_count }}</div>
<div class="stat-label">Sports</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ teams_count }}</div>
<div class="stat-label">Teams</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ stadiums_count }}</div>
<div class="stat-label">Stadiums</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ games_count }}</div>
<div class="stat-label">Games</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ pending_reviews }}</div>
<div class="stat-label">Pending Reviews</div>
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div class="stat-card">
<h3>Recent Scraper Jobs</h3>
{% if recent_jobs %}
<div class="table-responsive">
<table class="dashboard-table">
<thead>
<tr>
<th>Sport</th>
<th>Status</th>
<th>Games</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for job in recent_jobs %}
<tr>
<td>{{ job.config.sport.short_name }} {{ job.config.season }}</td>
<td><span class="status-badge status-{{ job.status }}">{{ job.status|upper }}</span></td>
<td>{{ job.games_found }}</td>
<td>{{ job.created_at|timesince }} ago</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No recent scraper jobs.</p>
{% endif %}
<p style="margin-top: 15px;">
<a href="{% url 'dashboard:scraper_status' %}" class="btn-action">View All Jobs</a>
</p>
</div>
<div class="stat-card">
<h3>Recent CloudKit Syncs</h3>
{% if recent_syncs %}
<div class="table-responsive">
<table class="dashboard-table">
<thead>
<tr>
<th>Config</th>
<th>Status</th>
<th>Records</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for sync in recent_syncs %}
<tr>
<td>{{ sync.configuration.name }}</td>
<td><span class="status-badge status-{{ sync.status }}">{{ sync.status|upper }}</span></td>
<td>{{ sync.records_synced }}</td>
<td>{{ sync.created_at|timesince }} ago</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No recent sync jobs.</p>
{% endif %}
<p style="margin-top: 15px;">
<a href="{% url 'dashboard:sync_status' %}" class="btn-action">View Sync Status</a>
</p>
</div>
</div>
<div class="stat-card">
<h3>Sport Summary</h3>
<div class="table-responsive">
<table class="dashboard-table">
<thead>
<tr>
<th>Sport</th>
<th>Teams</th>
<th>Stadiums</th>
<th>Games</th>
<th>Pending Reviews</th>
</tr>
</thead>
<tbody>
{% for stat in sport_stats %}
<tr>
<td><strong>{{ stat.sport.short_name }}</strong> - {{ stat.sport.name }}</td>
<td>{{ stat.teams }}</td>
<td>{{ stat.stadiums }}</td>
<td>{{ stat.games }}</td>
<td>{% if stat.pending_reviews %}<span style="color: #f0ad4e; font-weight: bold;">{{ stat.pending_reviews }}</span>{% else %}0{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,74 @@
{% extends "dashboard/base.html" %}
{% block dashboard_content %}
<div class="stat-card">
<h3>Review Queue Summary</h3>
<div class="stat-grid">
<div class="stat-item">
<div class="stat-value">{{ total_pending }}</div>
<div class="stat-label">Total Pending</div>
</div>
{% for item in review_summary %}
<div class="stat-item">
<div class="stat-value">{{ item.count }}</div>
<div class="stat-label">{{ item.sport__short_name }} {{ item.item_type }}s</div>
</div>
{% endfor %}
</div>
</div>
<div class="stat-card">
<h3>Pending Review Items</h3>
{% if pending_items %}
<div class="table-responsive">
<table class="dashboard-table">
<thead>
<tr>
<th>Type</th>
<th>Sport</th>
<th>Raw Value</th>
<th>Suggested Match</th>
<th>Confidence</th>
<th>Reason</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for item in pending_items %}
<tr>
<td>{{ item.item_type }}</td>
<td>{{ item.sport.short_name }}</td>
<td><code>{{ item.raw_value }}</code></td>
<td>
{% if item.suggested_id %}
<code style="background: #e8f5e9;">{{ item.suggested_id }}</code>
{% else %}
<span style="color: #999;">None</span>
{% endif %}
</td>
<td>
{% if item.confidence > 0 %}
<span style="color: {% if item.confidence >= 0.85 %}#5cb85c{% elif item.confidence >= 0.7 %}#f0ad4e{% else %}#d9534f{% endif %}; font-weight: bold;">
{{ item.confidence_display }}
</span>
{% else %}-{% endif %}
</td>
<td>{{ item.get_reason_display }}</td>
<td>
<a href="{% url 'admin:scraper_manualreviewitem_change' item.id %}" class="btn-action">Review</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pending > 50 %}
<p style="margin-top: 15px; color: #666;">
Showing 50 of {{ total_pending }} items. <a href="{% url 'admin:scraper_manualreviewitem_changelist' %}?status__exact=pending">View all in admin</a>.
</p>
{% endif %}
{% else %}
<p style="color: #5cb85c; font-weight: bold;">No pending review items! 🎉</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,100 @@
{% extends "dashboard/base.html" %}
{% block dashboard_content %}
<div class="stat-card">
<h3>Scraper Status</h3>
<div class="stat-grid">
<div class="stat-item">
<div class="stat-value">{{ running_jobs }}</div>
<div class="stat-label">Running</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ pending_jobs }}</div>
<div class="stat-label">Pending</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ configs.count }}</div>
<div class="stat-label">Configurations</div>
</div>
</div>
</div>
<div class="stat-card">
<h3>Scraper Configurations</h3>
<div class="table-responsive">
<table class="dashboard-table">
<thead>
<tr>
<th>Sport</th>
<th>Season</th>
<th>Enabled</th>
<th>Last Run</th>
<th>Status</th>
<th>Games</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for config in configs %}
<tr>
<td><strong>{{ config.sport.short_name }}</strong></td>
<td>{{ config.sport.get_season_display }}</td>
<td>{% if config.is_enabled %}<span style="color: green;"></span>{% else %}<span style="color: #999;"></span>{% endif %}</td>
<td>{% if config.last_run %}{{ config.last_run|timesince }} ago{% else %}-{% endif %}</td>
<td>
{% if config.last_run_status %}
<span class="status-badge status-{{ config.last_run_status }}">{{ config.last_run_status|upper }}</span>
{% else %}-{% endif %}
</td>
<td>{{ config.last_run_games }}</td>
<td>
<form method="post" action="{% url 'dashboard:run_scraper' config.sport.code config.season %}" style="display: inline;">
{% csrf_token %}
<button type="submit" class="btn-action" {% if not config.is_enabled %}disabled{% endif %}>Run Now</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="stat-card">
<h3>Recent Jobs</h3>
<div class="table-responsive">
<table class="dashboard-table">
<thead>
<tr>
<th>ID</th>
<th>Sport</th>
<th>Status</th>
<th>Trigger</th>
<th>Started</th>
<th>Duration</th>
<th>Games</th>
<th>Reviews</th>
</tr>
</thead>
<tbody>
{% for job in recent_jobs %}
<tr>
<td><a href="{% url 'admin:scraper_scrapejob_change' job.id %}">{{ job.id }}</a></td>
<td>{{ job.config.sport.short_name }} {{ job.config.season }}</td>
<td><span class="status-badge status-{{ job.status }}">{{ job.status|upper }}</span></td>
<td>{{ job.triggered_by }}</td>
<td>{% if job.started_at %}{{ job.started_at|timesince }} ago{% else %}-{% endif %}</td>
<td>{{ job.duration_display }}</td>
<td>
{% if job.games_found %}
{{ job.games_found }} ({{ job.games_new }} new, {{ job.games_updated }} upd)
{% else %}-{% endif %}
</td>
<td>{% if job.review_items_created %}{{ job.review_items_created }}{% else %}-{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,85 @@
{% extends "dashboard/base.html" %}
{% block dashboard_content %}
<div class="stat-card">
<h3>Game Statistics</h3>
<div class="stat-grid">
<div class="stat-item">
<div class="stat-value">{{ game_stats.total }}</div>
<div class="stat-label">Total Games</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ game_stats.scheduled }}</div>
<div class="stat-label">Scheduled</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ game_stats.final }}</div>
<div class="stat-label">Final</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ game_stats.today }}</div>
<div class="stat-label">Today</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ game_stats.this_week }}</div>
<div class="stat-label">This Week</div>
</div>
</div>
</div>
<div class="stat-card">
<h3>CloudKit Sync Statistics</h3>
<div class="stat-grid">
<div class="stat-item">
<div class="stat-value">{{ sync_stats.total }}</div>
<div class="stat-label">Total Records</div>
</div>
<div class="stat-item">
<div class="stat-value" style="color: #5cb85c;">{{ sync_stats.synced }}</div>
<div class="stat-label">Synced</div>
</div>
<div class="stat-item">
<div class="stat-value" style="color: #f0ad4e;">{{ sync_stats.pending }}</div>
<div class="stat-label">Pending</div>
</div>
<div class="stat-item">
<div class="stat-value" style="color: #d9534f;">{{ sync_stats.failed }}</div>
<div class="stat-label">Failed</div>
</div>
</div>
</div>
<div class="stat-card">
<h3>Data by Sport</h3>
<div class="table-responsive">
<table class="dashboard-table">
<thead>
<tr>
<th>Sport</th>
<th>Teams</th>
<th>Stadiums</th>
<th>Games</th>
<th>Pending Reviews</th>
</tr>
</thead>
<tbody>
{% for stat in sport_stats %}
<tr>
<td><strong>{{ stat.sport.short_name }}</strong> - {{ stat.sport.name }}</td>
<td>{{ stat.teams }}</td>
<td>{{ stat.stadiums }}</td>
<td>{{ stat.games }}</td>
<td>
{% if stat.pending_reviews %}
<span style="color: #f0ad4e; font-weight: bold;">{{ stat.pending_reviews }}</span>
{% else %}
<span style="color: #5cb85c;">0</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,382 @@
{% extends 'base.html' %}
{% block content %}
<h1>CloudKit Sync</h1>
<!-- Status Overview -->
<div class="stat-grid mb-2">
<div class="stat-card {% if running_syncs > 0 %}primary{% endif %}">
<div class="stat-value">{{ running_syncs|default:0 }}</div>
<div class="stat-label">Running Syncs</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ total_records }}</div>
<div class="stat-label">Total Records</div>
</div>
</div>
<!-- CloudKit Configurations -->
<div class="card">
<div class="card-header">
<h3 style="margin: 0;">CloudKit Configurations</h3>
</div>
{% if all_configs %}
<table class="table">
<thead>
<tr>
<th>Config</th>
<th>Environment</th>
<th>Container</th>
<th>Status</th>
<th>Progress</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for c in all_configs %}
<tr id="config-row-{{ c.id }}">
<td><strong>{{ c.name }}</strong>{% if c.is_active %} ★{% endif %}</td>
<td>
{% if c.environment == 'production' %}
<span class="badge badge-success">Production</span>
{% else %}
<span class="badge badge-info">Development</span>
{% endif %}
</td>
<td>{{ c.container_id }}</td>
<td>
{% if c.is_active %}
<span class="badge badge-success">Active</span>
{% else %}
<span class="badge badge-secondary">Inactive</span>
{% endif %}
</td>
<td id="progress-{{ c.id }}" style="min-width: 200px;">
<span class="text-muted">-</span>
</td>
<td>
<button type="button" class="btn btn-primary sync-btn" data-config-id="{{ c.id }}" data-config-name="{{ c.name }}" data-environment="{{ c.environment }}" style="padding: 0.25rem 0.5rem; font-size: 0.85rem;">Sync Now</button>
<a href="{% url 'admin:cloudkit_cloudkitconfiguration_change' c.id %}" class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.85rem;">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="padding: 1rem;">
<p class="text-muted">No CloudKit configuration found. <a href="{% url 'admin:cloudkit_cloudkitconfiguration_add' %}">Create one</a>.</p>
</div>
{% endif %}
</div>
<!-- Confirmation Modal -->
<div id="sync-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000;">
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 2rem; border-radius: 8px; max-width: 440px; width: 90%;">
<h3 style="margin-top: 0;">Confirm Sync</h3>
<p>Sync to <strong id="modal-config-name"></strong> (<span id="modal-environment"></span>)</p>
<form id="sync-form" method="post" action="{% url 'dashboard:run_sync' %}">
{% csrf_token %}
<input type="hidden" name="config_id" id="modal-config-id">
<div style="margin-bottom: 1rem;">
<label style="font-weight: 600; display: block; margin-bottom: 0.5rem;">Record Types</label>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.35rem 1rem;">
<label style="font-weight: normal; cursor: pointer;">
<input type="checkbox" name="record_types" value="all" id="cb-all" checked onchange="toggleAll(this)"> <strong>Sync All</strong>
</label>
<div></div>
<label style="font-weight: normal; cursor: pointer;">
<input type="checkbox" name="record_types" value="Sport" class="cb-type" onchange="uncheckAll()"> Sport
</label>
<label style="font-weight: normal; cursor: pointer;">
<input type="checkbox" name="record_types" value="Conference" class="cb-type" onchange="uncheckAll()"> Conference
</label>
<label style="font-weight: normal; cursor: pointer;">
<input type="checkbox" name="record_types" value="Division" class="cb-type" onchange="uncheckAll()"> Division
</label>
<label style="font-weight: normal; cursor: pointer;">
<input type="checkbox" name="record_types" value="Team" class="cb-type" onchange="uncheckAll()"> Team
</label>
<label style="font-weight: normal; cursor: pointer;">
<input type="checkbox" name="record_types" value="Stadium" class="cb-type" onchange="uncheckAll()"> Stadium
</label>
<label style="font-weight: normal; cursor: pointer;">
<input type="checkbox" name="record_types" value="Game" class="cb-type" onchange="uncheckAll()"> Game
</label>
<label style="font-weight: normal; cursor: pointer;">
<input type="checkbox" name="record_types" value="TeamAlias" class="cb-type" onchange="uncheckAll()"> TeamAlias
</label>
<label style="font-weight: normal; cursor: pointer;">
<input type="checkbox" name="record_types" value="StadiumAlias" class="cb-type" onchange="uncheckAll()"> StadiumAlias
</label>
<label style="font-weight: normal; cursor: pointer;">
<input type="checkbox" name="record_types" value="LeagueStructure" class="cb-type" onchange="uncheckAll()"> LeagueStructure
</label>
</div>
</div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1.5rem;">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Sync Now</button>
</div>
</form>
</div>
</div>
<!-- Recent Sync Jobs -->
<div class="card">
<div class="card-header">
<h3 style="margin: 0;">Recent Sync Jobs</h3>
</div>
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Config</th>
<th>Status</th>
<th>Type</th>
<th>Trigger</th>
<th>Started</th>
<th>Duration</th>
<th>Records</th>
</tr>
</thead>
<tbody>
{% for sync in recent_syncs %}
<tr data-job-id="{{ sync.id }}">
<td><a href="{% url 'admin:cloudkit_cloudkitsyncjob_change' sync.id %}">{{ sync.id }}</a></td>
<td>
{{ sync.configuration.name }}
{% if sync.configuration.environment == 'production' %}
<span class="badge badge-success" style="font-size: 0.7rem;">Prod</span>
{% else %}
<span class="badge badge-info" style="font-size: 0.7rem;">Dev</span>
{% endif %}
</td>
<td>
{% if sync.status == 'completed' %}
<span class="badge badge-success">Completed</span>
{% elif sync.status == 'running' %}
<span class="badge badge-info">Running</span>
{% elif sync.status == 'failed' %}
<span class="badge badge-danger">Failed</span>
{% elif sync.status == 'cancelled' %}
<span class="badge badge-secondary">Cancelled</span>
{% else %}
<span class="badge badge-warning">{{ sync.status|title }}</span>
{% endif %}
</td>
<td>
{% if sync.record_type_filter %}
<span class="badge badge-info">{{ sync.record_type_filter }}</span>
{% else %}
All
{% endif %}
</td>
<td>{{ sync.triggered_by }}</td>
<td class="text-muted">{% if sync.started_at %}{{ sync.started_at|timesince }} ago{% else %}-{% endif %}</td>
<td>{{ sync.duration_display }}</td>
<td>
{% if sync.records_synced or sync.records_failed %}
{{ sync.records_synced }} synced{% if sync.records_failed %}, <span class="text-danger">{{ sync.records_failed }} failed</span>{% endif %}
<div style="font-size: 0.78rem; color: #666; margin-top: 0.25rem; line-height: 1.5;">
{% if sync.sports_synced or sync.sports_failed %}
<span>Sport: {{ sync.sports_synced }}{% if sync.sports_failed %}<span class="text-danger">/{{ sync.sports_failed }}f</span>{% endif %}</span><br>
{% endif %}
{% if sync.conferences_synced or sync.conferences_failed %}
<span>Conf: {{ sync.conferences_synced }}{% if sync.conferences_failed %}<span class="text-danger">/{{ sync.conferences_failed }}f</span>{% endif %}</span><br>
{% endif %}
{% if sync.divisions_synced or sync.divisions_failed %}
<span>Div: {{ sync.divisions_synced }}{% if sync.divisions_failed %}<span class="text-danger">/{{ sync.divisions_failed }}f</span>{% endif %}</span><br>
{% endif %}
{% if sync.teams_synced or sync.teams_failed %}
<span>Team: {{ sync.teams_synced }}{% if sync.teams_failed %}<span class="text-danger">/{{ sync.teams_failed }}f</span>{% endif %}</span><br>
{% endif %}
{% if sync.stadiums_synced or sync.stadiums_failed %}
<span>Stadium: {{ sync.stadiums_synced }}{% if sync.stadiums_failed %}<span class="text-danger">/{{ sync.stadiums_failed }}f</span>{% endif %}</span><br>
{% endif %}
{% if sync.games_synced or sync.games_failed %}
<span>Game: {{ sync.games_synced }}{% if sync.games_failed %}<span class="text-danger">/{{ sync.games_failed }}f</span>{% endif %}</span><br>
{% endif %}
{% if sync.team_aliases_synced or sync.team_aliases_failed %}
<span>TeamAlias: {{ sync.team_aliases_synced }}{% if sync.team_aliases_failed %}<span class="text-danger">/{{ sync.team_aliases_failed }}f</span>{% endif %}</span><br>
{% endif %}
{% if sync.stadium_aliases_synced or sync.stadium_aliases_failed %}
<span>StadiumAlias: {{ sync.stadium_aliases_synced }}{% if sync.stadium_aliases_failed %}<span class="text-danger">/{{ sync.stadium_aliases_failed }}f</span>{% endif %}</span><br>
{% endif %}
</div>
{% else %}-{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="8" class="text-muted">No sync jobs yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
let pollingIntervals = {};
let checkRunningInterval = null;
document.addEventListener('DOMContentLoaded', function() {
// Attach click handlers to sync buttons
document.querySelectorAll('.sync-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
const configId = this.dataset.configId;
const configName = this.dataset.configName;
const environment = this.dataset.environment;
showModal(configId, configName, environment);
});
});
// Check for running syncs and start polling
{% for sync in recent_syncs %}
{% if sync.status == 'running' %}
startPolling({{ sync.id }}, {{ sync.configuration.id }});
{% endif %}
{% endfor %}
// Also check for running syncs after a delay (catches jobs started just before page load)
setTimeout(checkForRunningSyncs, 1000);
setTimeout(checkForRunningSyncs, 3000);
});
function checkForRunningSyncs() {
fetch('/dashboard/api/running-syncs/')
.then(response => response.json())
.then(data => {
data.running.forEach(function(job) {
// Only start polling if not already polling this config
if (!pollingIntervals[job.configuration_id]) {
startPolling(job.id, job.configuration_id);
}
});
})
.catch(error => console.error('Error checking running syncs:', error));
}
function showModal(configId, configName, environment) {
document.getElementById('modal-config-id').value = configId;
document.getElementById('modal-config-name').textContent = configName;
document.getElementById('modal-environment').textContent = environment;
// Reset to "Sync All" checked, individual unchecked
document.getElementById('cb-all').checked = true;
document.querySelectorAll('.cb-type').forEach(function(cb) { cb.checked = false; });
document.getElementById('sync-modal').style.display = 'block';
}
function closeModal() {
document.getElementById('sync-modal').style.display = 'none';
}
function toggleAll(allCb) {
if (allCb.checked) {
document.querySelectorAll('.cb-type').forEach(function(cb) { cb.checked = false; });
}
}
function uncheckAll() {
var anyChecked = document.querySelectorAll('.cb-type:checked').length > 0;
document.getElementById('cb-all').checked = !anyChecked;
}
// Close modal on outside click
document.getElementById('sync-modal').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
function startPolling(jobId, configId) {
const btn = document.querySelector('.sync-btn[data-config-id="' + configId + '"]');
if (btn) {
btn.disabled = true;
btn.textContent = 'Syncing...';
}
pollingIntervals[configId] = setInterval(function() {
fetchProgress(jobId, configId);
}, 1000);
fetchProgress(jobId, configId);
}
function stopPolling(configId) {
if (pollingIntervals[configId]) {
clearInterval(pollingIntervals[configId]);
delete pollingIntervals[configId];
}
const btn = document.querySelector('.sync-btn[data-config-id="' + configId + '"]');
if (btn) {
btn.disabled = false;
btn.textContent = 'Sync Now';
}
}
function fetchProgress(jobId, configId) {
fetch('/dashboard/api/sync-progress/' + jobId + '/')
.then(response => response.json())
.then(data => {
updateProgressColumn(configId, data);
if (data.status === 'completed' || data.status === 'failed') {
stopPolling(configId);
setTimeout(function() { location.reload(); }, 2000);
}
})
.catch(error => console.error('Error fetching progress:', error));
}
function updateProgressColumn(configId, data) {
const progressEl = document.getElementById('progress-' + configId);
if (!progressEl) return;
if (data.status === 'running') {
let html = '<div style="font-size: 0.85rem; line-height: 1.6;">';
// Teams
if (data.teams.total > 0) {
const teamsDone = data.teams.synced + data.teams.failed;
const teamsClass = teamsDone >= data.teams.total ? 'text-success' : (data.current_type === 'Team' ? 'text-primary' : 'text-muted');
html += '<div class="' + teamsClass + '"><strong>Teams:</strong> ' + teamsDone + '/' + data.teams.total;
if (data.teams.failed > 0) html += ' <span class="text-danger">(' + data.teams.failed + ' failed)</span>';
html += '</div>';
} else if (data.current_type === 'Team') {
html += '<div class="text-primary"><strong>Teams:</strong> starting...</div>';
}
// Stadiums
if (data.stadiums.total > 0) {
const stadiumsDone = data.stadiums.synced + data.stadiums.failed;
const stadiumsClass = stadiumsDone >= data.stadiums.total ? 'text-success' : (data.current_type === 'Stadium' ? 'text-primary' : 'text-muted');
html += '<div class="' + stadiumsClass + '"><strong>Stadiums:</strong> ' + stadiumsDone + '/' + data.stadiums.total;
if (data.stadiums.failed > 0) html += ' <span class="text-danger">(' + data.stadiums.failed + ' failed)</span>';
html += '</div>';
} else if (data.current_type === 'Stadium') {
html += '<div class="text-primary"><strong>Stadiums:</strong> starting...</div>';
}
// Games
if (data.games.total > 0) {
const gamesDone = data.games.synced + data.games.failed;
const gamesClass = gamesDone >= data.games.total ? 'text-success' : (data.current_type === 'Game' ? 'text-primary' : 'text-muted');
html += '<div class="' + gamesClass + '"><strong>Games:</strong> ' + gamesDone + '/' + data.games.total;
if (data.games.failed > 0) html += ' <span class="text-danger">(' + data.games.failed + ' failed)</span>';
html += '</div>';
} else if (data.current_type === 'Game') {
html += '<div class="text-primary"><strong>Games:</strong> starting...</div>';
}
html += '</div>';
progressEl.innerHTML = html;
} else if (data.status === 'completed') {
progressEl.innerHTML = '<span class="badge badge-success">Complete!</span> ' + data.synced + ' synced';
} else if (data.status === 'failed') {
progressEl.innerHTML = '<span class="badge badge-danger">Failed</span>';
}
}
</script>
{% endblock %}

21
dashboard/urls.py Normal file
View File

@@ -0,0 +1,21 @@
from django.urls import path
from . import views
app_name = 'dashboard'
urlpatterns = [
path('', views.index, name='index'),
path('stats/', views.stats, name='stats'),
path('scraper-status/', views.scraper_status, name='scraper_status'),
path('sync-status/', views.sync_status, name='sync_status'),
path('review-queue/', views.review_queue, name='review_queue'),
path('export/', views.export_data, name='export'),
# Actions
path('run-scraper/<str:sport_code>/<int:season>/', views.run_scraper, name='run_scraper'),
path('run-all-scrapers/', views.run_all_scrapers, name='run_all_scrapers'),
path('run-sync/', views.run_sync, name='run_sync'),
path('export/download/', views.export_download, name='export_download'),
# API
path('api/sync-progress/<int:job_id>/', views.sync_progress_api, name='sync_progress_api'),
path('api/running-syncs/', views.running_syncs_api, name='running_syncs_api'),
]

644
dashboard/views.py Normal file
View File

@@ -0,0 +1,644 @@
import io
import json
import zipfile
from datetime import timedelta, timezone as dt_timezone
from urllib.parse import urlparse
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib import messages
from django.db.models import Count, Q
from django.http import JsonResponse, HttpResponse
from django.utils import timezone
from core.models import Sport, Team, Stadium, Game, Conference, Division, TeamAlias, StadiumAlias
from scraper.models import ScraperConfig, ScrapeJob, ManualReviewItem
from cloudkit.models import CloudKitConfiguration, CloudKitSyncState, CloudKitSyncJob
@staff_member_required
def index(request):
"""Main dashboard overview."""
# Get counts
context = {
'title': 'Dashboard',
'sports_count': Sport.objects.filter(is_active=True).count(),
'teams_count': Team.objects.count(),
'stadiums_count': Stadium.objects.count(),
'games_count': Game.objects.count(),
# Recent activity
'recent_jobs': ScrapeJob.objects.select_related('config__sport')[:5],
'recent_syncs': CloudKitSyncJob.objects.select_related('configuration')[:5],
'pending_reviews': ManualReviewItem.objects.filter(status='pending').count(),
# Sport summaries
'sport_stats': get_sport_stats(),
}
return render(request, 'dashboard/index.html', context)
@staff_member_required
def stats(request):
"""Detailed statistics view."""
context = {
'title': 'Statistics',
'sport_stats': get_sport_stats(),
'game_stats': get_game_stats(),
'sync_stats': get_sync_stats(),
}
return render(request, 'dashboard/stats.html', context)
@staff_member_required
def scraper_status(request):
"""Scraper status and controls."""
configs = ScraperConfig.objects.select_related('sport').order_by('-season', 'sport')
recent_jobs = ScrapeJob.objects.select_related('config__sport').order_by('-created_at')[:20]
context = {
'title': 'Scraper Status',
'configs': configs,
'recent_jobs': recent_jobs,
'running_jobs': ScrapeJob.objects.filter(status='running').count(),
'pending_jobs': ScrapeJob.objects.filter(status='pending').count(),
}
return render(request, 'dashboard/scraper_status.html', context)
@staff_member_required
def sync_status(request):
"""CloudKit sync status."""
from core.models import Game, Team, Stadium
# Get all configs for the dropdown
all_configs = CloudKitConfiguration.objects.all()
# Get selected config from query param, or default to active
selected_config_id = request.GET.get('config')
if selected_config_id:
config = CloudKitConfiguration.objects.filter(id=selected_config_id).first()
else:
config = CloudKitConfiguration.objects.filter(is_active=True).first()
# Recent sync jobs (filtered by selected config if any)
recent_syncs = CloudKitSyncJob.objects.select_related('configuration').order_by('-created_at')
if config:
recent_syncs = recent_syncs.filter(configuration=config)
running_syncs = recent_syncs.filter(status='running').count()
recent_syncs = recent_syncs[:10]
# Record counts
teams_count = Team.objects.count()
stadiums_count = Stadium.objects.count()
games_count = Game.objects.count()
total_records = teams_count + stadiums_count + games_count
context = {
'title': 'Sync Status',
'config': config,
'all_configs': all_configs,
'recent_syncs': recent_syncs,
'running_syncs': running_syncs,
'total_records': total_records,
}
return render(request, 'dashboard/sync_status.html', context)
@staff_member_required
def review_queue(request):
"""Manual review queue."""
pending = ManualReviewItem.objects.filter(
status='pending'
).select_related('sport', 'job').order_by('-confidence', '-created_at')
# Group by sport and type
review_summary = ManualReviewItem.objects.filter(
status='pending'
).values('sport__short_name', 'item_type').annotate(count=Count('id'))
context = {
'title': 'Review Queue',
'pending_items': pending[:50],
'review_summary': review_summary,
'total_pending': pending.count(),
}
return render(request, 'dashboard/review_queue.html', context)
@staff_member_required
def run_scraper(request, sport_code, season):
"""Trigger a scraper job."""
if request.method == 'POST':
from scraper.tasks import run_scraper_task
config = get_object_or_404(ScraperConfig, sport__code=sport_code, season=season)
run_scraper_task.delay(config.id)
messages.success(request, f'Started scraper for {config}')
return redirect('dashboard:scraper_status')
@staff_member_required
def run_all_scrapers(request):
"""Trigger all enabled scraper jobs."""
if request.method == 'POST':
from scraper.tasks import run_scraper_task
configs = ScraperConfig.objects.filter(is_enabled=True)
count = 0
for config in configs:
run_scraper_task.delay(config.id)
count += 1
if count > 0:
messages.success(request, f'Started {count} scraper jobs')
else:
messages.warning(request, 'No enabled scraper configurations')
return redirect('dashboard:scraper_status')
@staff_member_required
def run_sync(request):
"""Trigger a CloudKit sync."""
if request.method == 'POST':
from cloudkit.tasks import run_cloudkit_sync
# Get config from form or fall back to active config
config_id = request.POST.get('config_id')
if config_id:
config = CloudKitConfiguration.objects.filter(id=config_id).first()
else:
config = CloudKitConfiguration.objects.filter(is_active=True).first()
if config:
# Get selected record types
record_types = request.POST.getlist('record_types')
if not record_types or 'all' in record_types:
# Sync all — no record_type filter
run_cloudkit_sync.delay(config.id)
messages.success(request, f'Started full CloudKit sync to {config.name} ({config.environment})')
else:
# Queue a sync job per selected record type
for rt in record_types:
run_cloudkit_sync.delay(config.id, record_type=rt)
type_list = ', '.join(record_types)
messages.success(request, f'Started CloudKit sync for {type_list} to {config.name} ({config.environment})')
return redirect(f"{request.path.replace('/run-sync/', '/sync-status/')}?config={config.id}")
else:
messages.error(request, 'No CloudKit configuration found')
return redirect('dashboard:sync_status')
@staff_member_required
def sync_progress_api(request, job_id):
"""API endpoint for sync job progress."""
try:
job = CloudKitSyncJob.objects.get(id=job_id)
return JsonResponse(job.get_progress())
except CloudKitSyncJob.DoesNotExist:
return JsonResponse({'error': 'Job not found'}, status=404)
@staff_member_required
def running_syncs_api(request):
"""API endpoint to check for running sync jobs."""
running_jobs = CloudKitSyncJob.objects.filter(status='running').values(
'id', 'configuration_id'
)
return JsonResponse({'running': list(running_jobs)})
def get_sport_stats():
"""Get stats per sport."""
stats = []
for sport in Sport.objects.filter(is_active=True):
stats.append({
'sport': sport,
'teams': sport.teams.count(),
'stadiums': sport.stadiums.count(),
'games': sport.games.count(),
'pending_reviews': sport.review_items.filter(status='pending').count(),
})
return stats
def get_game_stats():
"""Get game statistics."""
now = timezone.now()
return {
'total': Game.objects.count(),
'scheduled': Game.objects.filter(status='scheduled').count(),
'final': Game.objects.filter(status='final').count(),
'today': Game.objects.filter(
game_date__date=now.date()
).count(),
'this_week': Game.objects.filter(
game_date__gte=now,
game_date__lt=now + timedelta(days=7)
).count(),
}
def get_sync_stats():
"""Get CloudKit sync statistics."""
return {
'total': CloudKitSyncState.objects.count(),
'synced': CloudKitSyncState.objects.filter(sync_status='synced').count(),
'pending': CloudKitSyncState.objects.filter(sync_status='pending').count(),
'failed': CloudKitSyncState.objects.filter(sync_status='failed').count(),
}
# =============================================================================
# Export Views
# =============================================================================
@staff_member_required
def export_data(request):
"""Export data page with options."""
sports = Sport.objects.filter(is_active=True).order_by('code')
# Get available years from game dates
from django.db.models.functions import ExtractYear
years = Game.objects.annotate(
game_year=ExtractYear('game_date')
).values_list('game_year', flat=True).distinct().order_by('-game_year')
# Get record counts for display
context = {
'title': 'Export Data',
'sports': sports,
'years': list(years),
'counts': {
'sports': Sport.objects.filter(is_active=True).count(),
'teams': Team.objects.count(),
'stadiums': Stadium.objects.count(),
'games': Game.objects.count(),
'team_aliases': TeamAlias.objects.count(),
'stadium_aliases': StadiumAlias.objects.count(),
'conferences': Conference.objects.count(),
'divisions': Division.objects.count(),
},
}
return render(request, 'dashboard/export.html', context)
@staff_member_required
def export_download(request):
"""Generate and download export files."""
# Get export options from request
export_types = request.GET.getlist('type')
sport_filter = request.GET.get('sport', '')
year_filter = request.GET.get('year', '')
if not export_types:
export_types = ['sports', 'league_structure', 'teams', 'stadiums', 'games', 'team_aliases', 'stadium_aliases']
# Convert year to int if provided
year_int = int(year_filter) if year_filter else None
# Generate export data
files = {}
if 'sports' in export_types:
files['sports_canonical.json'] = export_sports(sport_filter)
if 'league_structure' in export_types:
files['league_structure.json'] = export_league_structure(sport_filter)
if 'teams' in export_types:
files['teams_canonical.json'] = export_teams(sport_filter)
if 'stadiums' in export_types:
files['stadiums_canonical.json'] = export_stadiums(sport_filter)
if 'games' in export_types:
files['games_canonical.json'] = export_games(sport_filter, year_int)
if 'team_aliases' in export_types:
files['team_aliases.json'] = export_team_aliases(sport_filter)
if 'stadium_aliases' in export_types:
files['stadium_aliases.json'] = export_stadium_aliases(sport_filter)
# If single file, return JSON directly
if len(files) == 1:
filename, data = list(files.items())[0]
response = HttpResponse(
json.dumps(data, indent=2),
content_type='application/json'
)
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
# Multiple files - return as ZIP
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
for filename, data in files.items():
zf.writestr(filename, json.dumps(data, indent=2))
zip_buffer.seek(0)
# Build filename
parts = ['sportstime_export']
if sport_filter:
parts.append(sport_filter)
if year_filter:
parts.append(str(year_filter))
zip_filename = '_'.join(parts) + '.zip'
response = HttpResponse(zip_buffer.read(), content_type='application/zip')
response['Content-Disposition'] = f'attachment; filename="{zip_filename}"'
return response
# =============================================================================
# Export Helper Functions
# =============================================================================
def _get_conference_id(conference):
"""Get conference canonical ID from DB field."""
return conference.canonical_id
def _get_division_id(division):
"""Get division canonical ID from DB field."""
return division.canonical_id
def _extract_domain(url):
"""Extract domain from URL."""
try:
parsed = urlparse(url)
domain = parsed.netloc
if domain.startswith('www.'):
domain = domain[4:]
return domain
except Exception:
return None
def export_sports(sport_filter=None):
"""Export sports data."""
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.upper(),
'abbreviation': sport.short_name.upper(),
'display_name': sport.name,
'icon_name': sport.icon_name or '',
'color_hex': sport.color_hex or '',
'season_start_month': sport.season_start_month,
'season_end_month': sport.season_end_month,
'is_active': sport.is_active,
})
return data
def export_league_structure(sport_filter=None):
"""Export league structure data."""
data = []
seen_ids = set() # Track IDs to prevent duplicates
display_order = 0
sports = Sport.objects.all()
if sport_filter:
sports = sports.filter(code=sport_filter.lower())
for sport in sports.order_by('code'):
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
conferences = Conference.objects.filter(sport=sport).order_by('order', 'name')
for conf in conferences:
conf_id = _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,
})
divisions = Division.objects.filter(conference=conf).order_by('order', 'name')
for div in divisions:
div_id = _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,
})
return data
def export_teams(sport_filter=None):
"""Export teams data."""
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'):
conference_id = None
division_id = None
if team.division:
division_id = _get_division_id(team.division)
conference_id = _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,
})
return data
def export_stadiums(sport_filter=None):
"""Export stadiums data."""
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,
})
return data
def export_games(sport_filter=None, year_filter=None):
"""Export games data."""
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=dt_timezone.utc)
utc_dt = game_dt.astimezone(dt_timezone.utc)
source = None
if game.source_url:
source = _extract_domain(game.source_url)
data.append({
'canonical_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_info': None,
})
return data
def export_team_aliases(sport_filter=None):
"""Export team aliases data."""
aliases = TeamAlias.objects.select_related('team', 'team__sport').all()
if sport_filter:
aliases = aliases.filter(team__sport__code=sport_filter.lower())
alias_type_map = {
'full_name': 'name',
'city_name': 'city',
'abbreviation': 'abbreviation',
'nickname': 'name',
'historical': 'name',
}
data = []
for alias in aliases.order_by('team__sport__code', 'team__id', 'id'):
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
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,
})
return data
def export_stadium_aliases(sport_filter=None):
"""Export stadium aliases data."""
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'):
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,
})
return data