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:
213
cloudkit/admin.py
Normal file
213
cloudkit/admin.py
Normal file
@@ -0,0 +1,213 @@
|
||||
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 CloudKitConfiguration, CloudKitSyncState, CloudKitSyncJob
|
||||
from .resources import CloudKitConfigurationResource, CloudKitSyncStateResource, CloudKitSyncJobResource
|
||||
|
||||
|
||||
@admin.register(CloudKitConfiguration)
|
||||
class CloudKitConfigurationAdmin(ImportExportMixin, SimpleHistoryAdmin):
|
||||
resource_class = CloudKitConfigurationResource
|
||||
list_display = [
|
||||
'name',
|
||||
'environment',
|
||||
'container_id',
|
||||
'is_active_badge',
|
||||
'auto_sync_after_scrape',
|
||||
'batch_size',
|
||||
]
|
||||
list_filter = ['environment', 'is_active']
|
||||
search_fields = ['name', 'container_id']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
fieldsets = [
|
||||
(None, {
|
||||
'fields': ['name', 'environment', 'is_active']
|
||||
}),
|
||||
('CloudKit Credentials', {
|
||||
'fields': ['container_id', 'key_id', 'private_key', 'private_key_path'],
|
||||
'description': 'Enter your private key content directly OR provide a file path'
|
||||
}),
|
||||
('Sync Settings', {
|
||||
'fields': ['batch_size', 'auto_sync_after_scrape']
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': ['created_at', 'updated_at'],
|
||||
'classes': ['collapse']
|
||||
}),
|
||||
]
|
||||
|
||||
actions = ['run_sync', 'test_connection']
|
||||
|
||||
def is_active_badge(self, obj):
|
||||
if obj.is_active:
|
||||
return format_html(
|
||||
'<span style="color: green; font-weight: bold;">● ACTIVE</span>'
|
||||
)
|
||||
return format_html('<span style="color: gray;">○ Inactive</span>')
|
||||
is_active_badge.short_description = 'Status'
|
||||
|
||||
@admin.action(description='Run sync with selected configuration')
|
||||
def run_sync(self, request, queryset):
|
||||
from cloudkit.tasks import run_cloudkit_sync
|
||||
for config in queryset:
|
||||
run_cloudkit_sync.delay(config.id)
|
||||
self.message_user(request, f'Started {queryset.count()} sync jobs.')
|
||||
|
||||
@admin.action(description='Test CloudKit connection')
|
||||
def test_connection(self, request, queryset):
|
||||
from django.contrib import messages
|
||||
for config in queryset:
|
||||
try:
|
||||
client = config.get_client()
|
||||
if client.test_connection():
|
||||
self.message_user(
|
||||
request,
|
||||
f'✓ {config.name}: Connection successful!',
|
||||
messages.SUCCESS
|
||||
)
|
||||
else:
|
||||
self.message_user(
|
||||
request,
|
||||
f'✗ {config.name}: Connection failed',
|
||||
messages.ERROR
|
||||
)
|
||||
except Exception as e:
|
||||
self.message_user(
|
||||
request,
|
||||
f'✗ {config.name}: {str(e)}',
|
||||
messages.ERROR
|
||||
)
|
||||
|
||||
|
||||
@admin.register(CloudKitSyncState)
|
||||
class CloudKitSyncStateAdmin(ImportExportModelAdmin):
|
||||
resource_class = CloudKitSyncStateResource
|
||||
list_display = [
|
||||
'record_id',
|
||||
'record_type',
|
||||
'sync_status_badge',
|
||||
'last_synced',
|
||||
'retry_count',
|
||||
]
|
||||
list_filter = ['sync_status', 'record_type']
|
||||
search_fields = ['record_id', 'cloudkit_record_name']
|
||||
ordering = ['-updated_at']
|
||||
readonly_fields = [
|
||||
'record_type',
|
||||
'record_id',
|
||||
'cloudkit_record_name',
|
||||
'local_hash',
|
||||
'remote_change_tag',
|
||||
'last_synced',
|
||||
'last_error',
|
||||
'retry_count',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
actions = ['mark_pending', 'retry_failed']
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def sync_status_badge(self, obj):
|
||||
colors = {
|
||||
'pending': '#f0ad4e',
|
||||
'synced': '#5cb85c',
|
||||
'failed': '#d9534f',
|
||||
'deleted': '#999',
|
||||
}
|
||||
color = colors.get(obj.sync_status, '#999')
|
||||
return format_html(
|
||||
'<span style="background-color: {}; color: white; padding: 3px 8px; '
|
||||
'border-radius: 3px; font-size: 11px;">{}</span>',
|
||||
color,
|
||||
obj.sync_status.upper()
|
||||
)
|
||||
sync_status_badge.short_description = 'Status'
|
||||
|
||||
@admin.action(description='Mark selected as pending sync')
|
||||
def mark_pending(self, request, queryset):
|
||||
updated = queryset.update(sync_status='pending')
|
||||
self.message_user(request, f'{updated} records marked as pending.')
|
||||
|
||||
@admin.action(description='Retry failed syncs')
|
||||
def retry_failed(self, request, queryset):
|
||||
updated = queryset.filter(sync_status='failed').update(
|
||||
sync_status='pending',
|
||||
retry_count=0
|
||||
)
|
||||
self.message_user(request, f'{updated} failed records queued for retry.')
|
||||
|
||||
|
||||
@admin.register(CloudKitSyncJob)
|
||||
class CloudKitSyncJobAdmin(ImportExportModelAdmin):
|
||||
resource_class = CloudKitSyncJobResource
|
||||
list_display = [
|
||||
'id',
|
||||
'configuration',
|
||||
'status_badge',
|
||||
'triggered_by',
|
||||
'started_at',
|
||||
'duration_display',
|
||||
'records_summary',
|
||||
]
|
||||
list_filter = ['status', 'configuration', 'triggered_by']
|
||||
date_hierarchy = 'created_at'
|
||||
ordering = ['-created_at']
|
||||
readonly_fields = [
|
||||
'configuration',
|
||||
'status',
|
||||
'triggered_by',
|
||||
'started_at',
|
||||
'finished_at',
|
||||
'duration_display',
|
||||
'records_synced',
|
||||
'records_created',
|
||||
'records_updated',
|
||||
'records_deleted',
|
||||
'records_failed',
|
||||
'sport_filter',
|
||||
'record_type_filter',
|
||||
'error_message',
|
||||
'celery_task_id',
|
||||
'created_at',
|
||||
'updated_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 = {
|
||||
'pending': '#999',
|
||||
'running': '#f0ad4e',
|
||||
'completed': '#5cb85c',
|
||||
'failed': '#d9534f',
|
||||
'cancelled': '#777',
|
||||
}
|
||||
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 records_summary(self, obj):
|
||||
if obj.records_synced == 0 and obj.status != 'completed':
|
||||
return '-'
|
||||
return format_html(
|
||||
'<span title="Created: {}, Updated: {}, Deleted: {}, Failed: {}">'
|
||||
'{} synced ({} new)</span>',
|
||||
obj.records_created, obj.records_updated, obj.records_deleted, obj.records_failed,
|
||||
obj.records_synced, obj.records_created
|
||||
)
|
||||
records_summary.short_description = 'Records'
|
||||
Reference in New Issue
Block a user