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

139
scraper/admin.py Normal file
View File

@@ -0,0 +1,139 @@
"""
Admin configuration for scraper models.
"""
from django.contrib import admin
from django.utils.html import format_html
from import_export.admin import ImportExportMixin, ImportExportModelAdmin
from simple_history.admin import SimpleHistoryAdmin
from .models import ScraperConfig, ScrapeJob, ManualReviewItem
from .resources import ScraperConfigResource, ScrapeJobResource, ManualReviewItemResource
@admin.register(ScraperConfig)
class ScraperConfigAdmin(ImportExportMixin, SimpleHistoryAdmin):
resource_class = ScraperConfigResource
list_display = [
'__str__',
'sport',
'season',
'is_active',
'last_scrape_at',
'next_scrape_at',
'scrape_interval_hours',
]
list_filter = ['sport', 'is_active', 'season']
search_fields = ['sport__name', 'sport__short_name']
ordering = ['-season', 'sport']
readonly_fields = ['created_at', 'updated_at']
@admin.register(ScrapeJob)
class ScrapeJobAdmin(ImportExportModelAdmin):
resource_class = ScrapeJobResource
list_display = [
'__str__',
'status_badge',
'games_found',
'games_created',
'games_updated',
'duration_display',
'created_at',
]
list_filter = ['status', 'config__sport', ('created_at', admin.DateFieldListFilter)]
search_fields = ['config__sport__name', 'errors']
ordering = ['-created_at']
readonly_fields = ['created_at', 'updated_at', 'duration_display']
@admin.display(description='Status')
def status_badge(self, obj):
colors = {
'pending': '#ffc107',
'running': '#17a2b8',
'completed': '#28a745',
'failed': '#dc3545',
'cancelled': '#6c757d',
}
color = colors.get(obj.status, '#6c757d')
return format_html(
'<span style="background-color: {}; color: white; padding: 3px 8px; '
'border-radius: 3px; font-size: 11px;">{}</span>',
color, obj.get_status_display()
)
@admin.display(description='Duration')
def duration_display(self, obj):
duration = obj.duration
if duration is not None:
if duration < 60:
return f"{duration:.1f}s"
elif duration < 3600:
return f"{duration/60:.1f}m"
else:
return f"{duration/3600:.1f}h"
return '-'
@admin.register(ManualReviewItem)
class ManualReviewItemAdmin(ImportExportModelAdmin):
resource_class = ManualReviewItemResource
list_display = [
'raw_value',
'item_type',
'sport',
'status_badge',
'confidence_bar',
'matched_value',
'created_at',
]
list_filter = ['status', 'item_type', 'sport']
search_fields = ['raw_value', 'matched_value']
ordering = ['-confidence', '-created_at']
readonly_fields = ['created_at', 'updated_at', 'resolved_at', 'resolved_by']
actions = ['approve_items', 'reject_items']
@admin.display(description='Status')
def status_badge(self, obj):
colors = {
'pending': '#ffc107',
'approved': '#28a745',
'rejected': '#dc3545',
'resolved': '#17a2b8',
}
color = colors.get(obj.status, '#6c757d')
return format_html(
'<span style="background-color: {}; color: white; padding: 3px 8px; '
'border-radius: 3px; font-size: 11px;">{}</span>',
color, obj.get_status_display()
)
@admin.display(description='Confidence')
def confidence_bar(self, obj):
color = '#28a745' if obj.confidence >= 85 else '#ffc107' if obj.confidence >= 70 else '#dc3545'
return format_html(
'<div style="width: 100px; background: #ddd; border-radius: 3px;">'
'<div style="width: {}%; background: {}; height: 16px; border-radius: 3px; '
'text-align: center; color: white; font-size: 11px; line-height: 16px;">'
'{}%</div></div>',
obj.confidence, color, obj.confidence
)
@admin.action(description='Approve selected items')
def approve_items(self, request, queryset):
from django.utils import timezone
updated = queryset.update(
status='approved',
resolved_at=timezone.now(),
resolved_by=request.user
)
self.message_user(request, f'{updated} items approved.')
@admin.action(description='Reject selected items')
def reject_items(self, request, queryset):
from django.utils import timezone
updated = queryset.update(
status='rejected',
resolved_at=timezone.now(),
resolved_by=request.user
)
self.message_user(request, f'{updated} items rejected.')