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
notifications/__init__.py
Normal file
1
notifications/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
default_app_config = 'notifications.apps.NotificationsConfig'
|
||||
119
notifications/admin.py
Normal file
119
notifications/admin.py
Normal 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
7
notifications/apps.py
Normal 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'
|
||||
90
notifications/migrations/0001_initial.py
Normal file
90
notifications/migrations/0001_initial.py
Normal 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),
|
||||
),
|
||||
]
|
||||
0
notifications/migrations/__init__.py
Normal file
0
notifications/migrations/__init__.py
Normal file
131
notifications/models.py
Normal file
131
notifications/models.py
Normal 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
240
notifications/tasks.py
Normal 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
|
||||
119
notifications/templates/notifications/emails/scrape_report.html
Normal file
119
notifications/templates/notifications/emails/scrape_report.html
Normal 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 }} •
|
||||
{% if job.status == 'completed' %}<span class="success">Completed</span>
|
||||
{% elif job.status == 'failed' %}<span class="error">Failed</span>
|
||||
{% else %}{{ job.status|title }}{% endif %}
|
||||
• {{ 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 • Job #{{ job.id }} • {{ job.finished_at|date:"Y-m-d H:i" }} UTC</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
@@ -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 }}) •
|
||||
{% if job.status == 'completed' %}<span class="success">Completed</span>
|
||||
{% elif job.status == 'failed' %}<span class="error">Failed</span>
|
||||
{% else %}{{ job.status|title }}{% endif %}
|
||||
• {{ 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 • Job #{{ job.id }} • {{ job.finished_at|date:"Y-m-d H:i" }} UTC</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
23
notifications/templates/notifications/emails/sync_report.txt
Normal file
23
notifications/templates/notifications/emails/sync_report.txt
Normal 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
|
||||
Reference in New Issue
Block a user