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>
241 lines
7.4 KiB
Python
241 lines
7.4 KiB
Python
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
|