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:
139
scraper/admin.py
Normal file
139
scraper/admin.py
Normal 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.')
|
||||
Reference in New Issue
Block a user