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:
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 %}
|
||||
Reference in New Issue
Block a user