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

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

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

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

View File

@@ -0,0 +1 @@
default_app_config = 'notifications.apps.NotificationsConfig'

119
notifications/admin.py Normal file
View File

@@ -0,0 +1,119 @@
from django.contrib import admin
from django.utils.html import format_html
from simple_history.admin import SimpleHistoryAdmin
from .models import EmailConfiguration, EmailLog
@admin.register(EmailConfiguration)
class EmailConfigurationAdmin(SimpleHistoryAdmin):
list_display = [
'name',
'is_enabled_badge',
'recipient_count',
'notify_on_scrape_complete',
'notify_on_scrape_failure',
'notify_on_sync_failure',
]
list_filter = ['is_enabled']
readonly_fields = ['created_at', 'updated_at']
fieldsets = [
(None, {
'fields': ['name', 'is_enabled']
}),
('Recipients', {
'fields': ['recipient_emails']
}),
('Scraper Notifications', {
'fields': [
'notify_on_scrape_complete',
'notify_on_scrape_failure',
'notify_on_new_reviews',
]
}),
('CloudKit Sync Notifications', {
'fields': [
'notify_on_sync_complete',
'notify_on_sync_failure',
]
}),
('Thresholds', {
'fields': ['min_games_for_notification']
}),
('Metadata', {
'fields': ['created_at', 'updated_at'],
'classes': ['collapse']
}),
]
actions = ['send_test_email']
def is_enabled_badge(self, obj):
if obj.is_enabled:
return format_html('<span style="color: green;">● Enabled</span>')
return format_html('<span style="color: gray;">○ Disabled</span>')
is_enabled_badge.short_description = 'Status'
def recipient_count(self, obj):
return len(obj.get_recipients())
recipient_count.short_description = 'Recipients'
@admin.action(description='Send test email')
def send_test_email(self, request, queryset):
from notifications.tasks import send_test_notification
for config in queryset:
send_test_notification.delay(config.id)
self.message_user(request, f'Test emails queued for {queryset.count()} configurations.')
@admin.register(EmailLog)
class EmailLogAdmin(admin.ModelAdmin):
list_display = [
'subject',
'status_badge',
'recipients_display',
'created_at',
]
list_filter = ['status', 'created_at']
search_fields = ['subject', 'recipients']
date_hierarchy = 'created_at'
ordering = ['-created_at']
readonly_fields = [
'configuration',
'subject',
'recipients',
'body_preview',
'status',
'error_message',
'scrape_job',
'sync_job',
'created_at',
]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def status_badge(self, obj):
colors = {
'sent': '#5cb85c',
'failed': '#d9534f',
}
color = colors.get(obj.status, '#999')
return format_html(
'<span style="background-color: {}; color: white; padding: 3px 8px; '
'border-radius: 3px; font-size: 11px;">{}</span>',
color,
obj.status.upper()
)
status_badge.short_description = 'Status'
def recipients_display(self, obj):
recipients = obj.recipients.split(',')
if len(recipients) > 2:
return f"{recipients[0]}, +{len(recipients)-1} more"
return obj.recipients
recipients_display.short_description = 'Recipients'

7
notifications/apps.py Normal file
View File

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

View File

@@ -0,0 +1,90 @@
# Generated by Django 5.1.15 on 2026-01-26 08:59
import django.db.models.deletion
import simple_history.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('cloudkit', '0001_initial'),
('scraper', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EmailConfiguration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='Default', help_text='Configuration name', max_length=100)),
('is_enabled', models.BooleanField(default=True, help_text='Whether email notifications are enabled')),
('recipient_emails', models.TextField(help_text='Comma-separated list of recipient email addresses')),
('notify_on_scrape_complete', models.BooleanField(default=True, help_text='Send email after each scraper job completes')),
('notify_on_scrape_failure', models.BooleanField(default=True, help_text='Send email when scraper job fails')),
('notify_on_sync_complete', models.BooleanField(default=False, help_text='Send email after CloudKit sync completes')),
('notify_on_sync_failure', models.BooleanField(default=True, help_text='Send email when CloudKit sync fails')),
('notify_on_new_reviews', models.BooleanField(default=True, help_text='Include review items in scrape notifications')),
('min_games_for_notification', models.PositiveIntegerField(default=0, help_text='Minimum games changed to trigger notification (0 = always)')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Email Configuration',
'verbose_name_plural': 'Email Configurations',
},
),
migrations.CreateModel(
name='EmailLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('subject', models.CharField(max_length=255)),
('recipients', models.TextField(help_text='Comma-separated list of recipients')),
('body_preview', models.TextField(blank=True, help_text='First 500 chars of email body')),
('status', models.CharField(choices=[('sent', 'Sent'), ('failed', 'Failed')], max_length=10)),
('error_message', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('configuration', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='logs', to='notifications.emailconfiguration')),
('scrape_job', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_logs', to='scraper.scrapejob')),
('sync_job', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_logs', to='cloudkit.cloudkitsyncjob')),
],
options={
'verbose_name': 'Email Log',
'verbose_name_plural': 'Email Logs',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='HistoricalEmailConfiguration',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('name', models.CharField(default='Default', help_text='Configuration name', max_length=100)),
('is_enabled', models.BooleanField(default=True, help_text='Whether email notifications are enabled')),
('recipient_emails', models.TextField(help_text='Comma-separated list of recipient email addresses')),
('notify_on_scrape_complete', models.BooleanField(default=True, help_text='Send email after each scraper job completes')),
('notify_on_scrape_failure', models.BooleanField(default=True, help_text='Send email when scraper job fails')),
('notify_on_sync_complete', models.BooleanField(default=False, help_text='Send email after CloudKit sync completes')),
('notify_on_sync_failure', models.BooleanField(default=True, help_text='Send email when CloudKit sync fails')),
('notify_on_new_reviews', models.BooleanField(default=True, help_text='Include review items in scrape notifications')),
('min_games_for_notification', models.PositiveIntegerField(default=0, help_text='Minimum games changed to trigger notification (0 = always)')),
('created_at', models.DateTimeField(blank=True, editable=False)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'historical Email Configuration',
'verbose_name_plural': 'historical Email Configurations',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
]

View File

131
notifications/models.py Normal file
View File

@@ -0,0 +1,131 @@
from django.db import models
from django.conf import settings
from simple_history.models import HistoricalRecords
class EmailConfiguration(models.Model):
"""
Email notification configuration.
"""
name = models.CharField(
max_length=100,
default='Default',
help_text='Configuration name'
)
is_enabled = models.BooleanField(
default=True,
help_text='Whether email notifications are enabled'
)
# Recipients
recipient_emails = models.TextField(
help_text='Comma-separated list of recipient email addresses'
)
# What to notify about
notify_on_scrape_complete = models.BooleanField(
default=True,
help_text='Send email after each scraper job completes'
)
notify_on_scrape_failure = models.BooleanField(
default=True,
help_text='Send email when scraper job fails'
)
notify_on_sync_complete = models.BooleanField(
default=False,
help_text='Send email after CloudKit sync completes'
)
notify_on_sync_failure = models.BooleanField(
default=True,
help_text='Send email when CloudKit sync fails'
)
notify_on_new_reviews = models.BooleanField(
default=True,
help_text='Include review items in scrape notifications'
)
# Thresholds
min_games_for_notification = models.PositiveIntegerField(
default=0,
help_text='Minimum games changed to trigger notification (0 = always)'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Audit trail
history = HistoricalRecords()
class Meta:
verbose_name = 'Email Configuration'
verbose_name_plural = 'Email Configurations'
def __str__(self):
return self.name
def get_recipients(self):
"""Return list of recipient emails."""
return [
email.strip()
for email in self.recipient_emails.split(',')
if email.strip()
]
class EmailLog(models.Model):
"""
Log of sent email notifications.
"""
STATUS_CHOICES = [
('sent', 'Sent'),
('failed', 'Failed'),
]
configuration = models.ForeignKey(
EmailConfiguration,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='logs'
)
subject = models.CharField(max_length=255)
recipients = models.TextField(
help_text='Comma-separated list of recipients'
)
body_preview = models.TextField(
blank=True,
help_text='First 500 chars of email body'
)
status = models.CharField(
max_length=10,
choices=STATUS_CHOICES
)
error_message = models.TextField(blank=True)
# Related objects
scrape_job = models.ForeignKey(
'scraper.ScrapeJob',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='email_logs'
)
sync_job = models.ForeignKey(
'cloudkit.CloudKitSyncJob',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='email_logs'
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
verbose_name = 'Email Log'
verbose_name_plural = 'Email Logs'
def __str__(self):
return f"{self.subject} ({self.status})"

240
notifications/tasks.py Normal file
View File

@@ -0,0 +1,240 @@
import logging
from celery import shared_task
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.conf import settings
logger = logging.getLogger('notifications')
@shared_task
def send_scrape_notification(job_id: int):
"""
Send email notification after scraper job.
"""
from scraper.models import ScrapeJob, ManualReviewItem
from notifications.models import EmailConfiguration, EmailLog
try:
job = ScrapeJob.objects.select_related('config__sport').get(id=job_id)
except ScrapeJob.DoesNotExist:
logger.error(f"ScrapeJob {job_id} not found")
return
# Get email configuration
config = EmailConfiguration.objects.filter(is_enabled=True).first()
if not config:
logger.info("No email configuration enabled")
return
# Check if we should send based on configuration
if job.status == 'completed' and not config.notify_on_scrape_complete:
return
if job.status == 'failed' and not config.notify_on_scrape_failure:
return
# Check minimum games threshold
total_changes = job.games_new + job.games_updated
if job.status == 'completed' and total_changes < config.min_games_for_notification:
logger.info(f"Skipping notification: {total_changes} changes below threshold {config.min_games_for_notification}")
return
# Get review items if configured
review_items = []
if config.notify_on_new_reviews and job.review_items_created > 0:
review_items = list(
ManualReviewItem.objects.filter(job=job)
.values('raw_value', 'item_type', 'suggested_id', 'confidence', 'reason')[:10]
)
# Build context
context = {
'job': job,
'sport': job.config.sport,
'season_display': job.config.sport.get_season_display(job.config.season),
'review_items': review_items,
'suggested_actions': get_suggested_actions(job),
}
# Render email
subject = f"[SportsTime] {job.config.sport.short_name} Scraper: {job.status.upper()}"
if job.status == 'completed':
subject = f"[SportsTime] {job.config.sport.short_name}: {job.games_new} new, {job.games_updated} updated"
html_body = render_to_string('notifications/emails/scrape_report.html', context)
text_body = render_to_string('notifications/emails/scrape_report.txt', context)
# Send email
recipients = config.get_recipients()
try:
send_mail(
subject=subject,
message=text_body,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=recipients,
html_message=html_body,
fail_silently=False,
)
# Log success
EmailLog.objects.create(
configuration=config,
subject=subject,
recipients=','.join(recipients),
body_preview=text_body[:500],
status='sent',
scrape_job=job,
)
logger.info(f"Sent scrape notification for job {job_id}")
except Exception as e:
# Log failure
EmailLog.objects.create(
configuration=config,
subject=subject,
recipients=','.join(recipients),
body_preview=text_body[:500],
status='failed',
error_message=str(e),
scrape_job=job,
)
logger.error(f"Failed to send scrape notification: {e}")
@shared_task
def send_sync_notification(job_id: int):
"""
Send email notification after CloudKit sync.
"""
from cloudkit.models import CloudKitSyncJob
from notifications.models import EmailConfiguration, EmailLog
try:
job = CloudKitSyncJob.objects.select_related('configuration').get(id=job_id)
except CloudKitSyncJob.DoesNotExist:
logger.error(f"CloudKitSyncJob {job_id} not found")
return
# Get email configuration
config = EmailConfiguration.objects.filter(is_enabled=True).first()
if not config:
return
# Check if we should send
if job.status == 'completed' and not config.notify_on_sync_complete:
return
if job.status == 'failed' and not config.notify_on_sync_failure:
return
# Build email
subject = f"[SportsTime] CloudKit Sync: {job.status.upper()}"
if job.status == 'completed':
subject = f"[SportsTime] CloudKit Sync: {job.records_synced} records"
context = {
'job': job,
}
html_body = render_to_string('notifications/emails/sync_report.html', context)
text_body = render_to_string('notifications/emails/sync_report.txt', context)
recipients = config.get_recipients()
try:
send_mail(
subject=subject,
message=text_body,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=recipients,
html_message=html_body,
fail_silently=False,
)
EmailLog.objects.create(
configuration=config,
subject=subject,
recipients=','.join(recipients),
body_preview=text_body[:500],
status='sent',
sync_job=job,
)
except Exception as e:
EmailLog.objects.create(
configuration=config,
subject=subject,
recipients=','.join(recipients),
body_preview=text_body[:500],
status='failed',
error_message=str(e),
sync_job=job,
)
logger.error(f"Failed to send sync notification: {e}")
@shared_task
def send_test_notification(config_id: int):
"""
Send a test notification email.
"""
from notifications.models import EmailConfiguration, EmailLog
try:
config = EmailConfiguration.objects.get(id=config_id)
except EmailConfiguration.DoesNotExist:
return
subject = "[SportsTime] Test Notification"
body = "This is a test notification from SportsTime.\n\nIf you received this, email notifications are working correctly."
recipients = config.get_recipients()
try:
send_mail(
subject=subject,
message=body,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=recipients,
fail_silently=False,
)
EmailLog.objects.create(
configuration=config,
subject=subject,
recipients=','.join(recipients),
body_preview=body,
status='sent',
)
logger.info(f"Sent test notification to {recipients}")
except Exception as e:
EmailLog.objects.create(
configuration=config,
subject=subject,
recipients=','.join(recipients),
body_preview=body,
status='failed',
error_message=str(e),
)
logger.error(f"Failed to send test notification: {e}")
def get_suggested_actions(job):
"""
Generate suggested actions based on job results.
"""
actions = []
if job.review_items_created > 0:
actions.append(f"Review {job.review_items_created} items in the review queue")
if job.games_errors > 0:
actions.append(f"Investigate {job.games_errors} game processing errors")
if job.status == 'failed':
actions.append("Check scraper logs for error details")
actions.append("Verify data source availability")
if job.games_found == 0 and job.status == 'completed':
actions.append("Verify scraper configuration and season dates")
return actions

View File

@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #417690; color: white; padding: 20px; border-radius: 8px 8px 0 0; }
.header h1 { margin: 0; font-size: 24px; }
.header .status { font-size: 14px; opacity: 0.9; margin-top: 5px; }
.content { background: #f8f9fa; padding: 20px; border-radius: 0 0 8px 8px; }
.stats { display: flex; flex-wrap: wrap; gap: 15px; margin: 20px 0; }
.stat { background: white; padding: 15px; border-radius: 6px; text-align: center; flex: 1; min-width: 100px; }
.stat-value { font-size: 28px; font-weight: bold; color: #417690; }
.stat-label { font-size: 12px; color: #666; text-transform: uppercase; }
.section { background: white; padding: 15px; border-radius: 6px; margin: 15px 0; }
.section h3 { margin: 0 0 10px 0; color: #417690; font-size: 16px; }
.success { color: #5cb85c; }
.warning { color: #f0ad4e; }
.error { color: #d9534f; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { padding: 8px; text-align: left; border-bottom: 1px solid #eee; }
th { font-weight: 600; color: #666; font-size: 12px; text-transform: uppercase; }
.action-item { padding: 8px 0; border-bottom: 1px solid #eee; }
.action-item:last-child { border-bottom: none; }
.footer { text-align: center; margin-top: 20px; font-size: 12px; color: #999; }
</style>
</head>
<body>
<div class="header">
<h1>{{ sport.short_name }} Scraper Report</h1>
<div class="status">
{{ season_display }} &bull;
{% if job.status == 'completed' %}<span class="success">Completed</span>
{% elif job.status == 'failed' %}<span class="error">Failed</span>
{% else %}{{ job.status|title }}{% endif %}
&bull; {{ job.duration_display }}
</div>
</div>
<div class="content">
{% if job.status == 'completed' %}
<div class="stats">
<div class="stat">
<div class="stat-value">{{ job.games_found }}</div>
<div class="stat-label">Games Found</div>
</div>
<div class="stat">
<div class="stat-value success">{{ job.games_new }}</div>
<div class="stat-label">New</div>
</div>
<div class="stat">
<div class="stat-value">{{ job.games_updated }}</div>
<div class="stat-label">Updated</div>
</div>
<div class="stat">
<div class="stat-value">{{ job.games_unchanged }}</div>
<div class="stat-label">Unchanged</div>
</div>
{% if job.games_errors %}
<div class="stat">
<div class="stat-value error">{{ job.games_errors }}</div>
<div class="stat-label">Errors</div>
</div>
{% endif %}
</div>
{% if job.review_items_created > 0 %}
<div class="section">
<h3>⚠️ Review Queue ({{ job.review_items_created }} items)</h3>
{% if review_items %}
<table>
<thead>
<tr>
<th>Type</th>
<th>Raw Value</th>
<th>Suggested</th>
<th>Confidence</th>
</tr>
</thead>
<tbody>
{% for item in review_items %}
<tr>
<td>{{ item.item_type }}</td>
<td><code>{{ item.raw_value }}</code></td>
<td>{% if item.suggested_id %}<code>{{ item.suggested_id }}</code>{% else %}-{% endif %}</td>
<td>{% if item.confidence %}{{ item.confidence|floatformat:0 }}%{% else %}-{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if job.review_items_created > 10 %}
<p style="color: #666; font-size: 12px; margin-top: 10px;">Showing 10 of {{ job.review_items_created }} items</p>
{% endif %}
{% endif %}
</div>
{% endif %}
{% else %}
<div class="section">
<h3 class="error">❌ Scraper Failed</h3>
<p><strong>Error:</strong> {{ job.error_message }}</p>
</div>
{% endif %}
{% if suggested_actions %}
<div class="section">
<h3>📋 Suggested Actions</h3>
{% for action in suggested_actions %}
<div class="action-item">• {{ action }}</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="footer">
<p>SportsTime Scraper &bull; Job #{{ job.id }} &bull; {{ job.finished_at|date:"Y-m-d H:i" }} UTC</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,43 @@
{{ sport.short_name }} SCRAPER REPORT
================================
Season: {{ season_display }}
Status: {{ job.status|upper }}
Duration: {{ job.duration_display }}
{% if job.status == 'completed' %}
SUMMARY
-------
Games Found: {{ job.games_found }}
New: {{ job.games_new }}
Updated: {{ job.games_updated }}
Unchanged: {{ job.games_unchanged }}
{% if job.games_errors %}Errors: {{ job.games_errors }}{% endif %}
{% if job.review_items_created > 0 %}
REVIEW QUEUE ({{ job.review_items_created }} items)
-------------------------------------------------
{% for item in review_items %}
- {{ item.item_type }}: "{{ item.raw_value }}" -> {{ item.suggested_id|default:"None" }} ({{ item.confidence|floatformat:0 }}%)
{% endfor %}
{% if job.review_items_created > 10 %}
... and {{ job.review_items_created|add:"-10" }} more items
{% endif %}
{% endif %}
{% else %}
ERROR
-----
{{ job.error_message }}
{% endif %}
{% if suggested_actions %}
SUGGESTED ACTIONS
-----------------
{% for action in suggested_actions %}
- {{ action }}
{% endfor %}
{% endif %}
---
SportsTime Scraper | Job #{{ job.id }} | {{ job.finished_at|date:"Y-m-d H:i" }} UTC

View File

@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #5bc0de; color: white; padding: 20px; border-radius: 8px 8px 0 0; }
.header h1 { margin: 0; font-size: 24px; }
.header .status { font-size: 14px; opacity: 0.9; margin-top: 5px; }
.content { background: #f8f9fa; padding: 20px; border-radius: 0 0 8px 8px; }
.stats { display: flex; flex-wrap: wrap; gap: 15px; margin: 20px 0; }
.stat { background: white; padding: 15px; border-radius: 6px; text-align: center; flex: 1; min-width: 100px; }
.stat-value { font-size: 28px; font-weight: bold; color: #5bc0de; }
.stat-label { font-size: 12px; color: #666; text-transform: uppercase; }
.section { background: white; padding: 15px; border-radius: 6px; margin: 15px 0; }
.section h3 { margin: 0 0 10px 0; color: #5bc0de; font-size: 16px; }
.success { color: #5cb85c; }
.error { color: #d9534f; }
.footer { text-align: center; margin-top: 20px; font-size: 12px; color: #999; }
</style>
</head>
<body>
<div class="header">
<h1>CloudKit Sync Report</h1>
<div class="status">
{{ job.configuration.name }} ({{ job.configuration.environment }}) &bull;
{% if job.status == 'completed' %}<span class="success">Completed</span>
{% elif job.status == 'failed' %}<span class="error">Failed</span>
{% else %}{{ job.status|title }}{% endif %}
&bull; {{ job.duration_display }}
</div>
</div>
<div class="content">
{% if job.status == 'completed' %}
<div class="stats">
<div class="stat">
<div class="stat-value">{{ job.records_synced }}</div>
<div class="stat-label">Records Synced</div>
</div>
<div class="stat">
<div class="stat-value success">{{ job.records_created }}</div>
<div class="stat-label">Created</div>
</div>
<div class="stat">
<div class="stat-value">{{ job.records_updated }}</div>
<div class="stat-label">Updated</div>
</div>
<div class="stat">
<div class="stat-value">{{ job.records_deleted }}</div>
<div class="stat-label">Deleted</div>
</div>
{% if job.records_failed %}
<div class="stat">
<div class="stat-value error">{{ job.records_failed }}</div>
<div class="stat-label">Failed</div>
</div>
{% endif %}
</div>
{% else %}
<div class="section">
<h3 class="error">❌ Sync Failed</h3>
<p><strong>Error:</strong> {{ job.error_message }}</p>
</div>
{% endif %}
</div>
<div class="footer">
<p>SportsTime CloudKit Sync &bull; Job #{{ job.id }} &bull; {{ job.finished_at|date:"Y-m-d H:i" }} UTC</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,23 @@
CLOUDKIT SYNC REPORT
====================
Configuration: {{ job.configuration.name }} ({{ job.configuration.environment }})
Status: {{ job.status|upper }}
Duration: {{ job.duration_display }}
{% if job.status == 'completed' %}
SUMMARY
-------
Records Synced: {{ job.records_synced }}
Created: {{ job.records_created }}
Updated: {{ job.records_updated }}
Deleted: {{ job.records_deleted }}
{% if job.records_failed %}Failed: {{ job.records_failed }}{% endif %}
{% else %}
ERROR
-----
{{ job.error_message }}
{% endif %}
---
SportsTime CloudKit Sync | Job #{{ job.id }} | {{ job.finished_at|date:"Y-m-d H:i" }} UTC