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