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
dashboard/__init__.py
Normal file
1
dashboard/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
default_app_config = 'dashboard.apps.DashboardConfig'
|
||||
7
dashboard/apps.py
Normal file
7
dashboard/apps.py
Normal 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'
|
||||
130
dashboard/templates/dashboard/base.html
Normal file
130
dashboard/templates/dashboard/base.html
Normal 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 %}
|
||||
125
dashboard/templates/dashboard/index.html
Normal file
125
dashboard/templates/dashboard/index.html
Normal 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 %}
|
||||
74
dashboard/templates/dashboard/review_queue.html
Normal file
74
dashboard/templates/dashboard/review_queue.html
Normal 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 %}
|
||||
100
dashboard/templates/dashboard/scraper_status.html
Normal file
100
dashboard/templates/dashboard/scraper_status.html
Normal 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 %}
|
||||
85
dashboard/templates/dashboard/stats.html
Normal file
85
dashboard/templates/dashboard/stats.html
Normal 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 %}
|
||||
382
dashboard/templates/dashboard/sync_status.html
Normal file
382
dashboard/templates/dashboard/sync_status.html
Normal 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
21
dashboard/urls.py
Normal 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
644
dashboard/views.py
Normal 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
|
||||
Reference in New Issue
Block a user