""" Admin registration for Automation models """ from django.contrib import admin from django.contrib import messages from unfold.admin import ModelAdmin from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin from .models import AutomationConfig, AutomationRun, DefaultAutomationConfig from import_export.admin import ExportMixin from import_export import resources # ═══════════════════════════════════════════════════════════ # DEFAULT AUTOMATION CONFIG (Singleton) # ═══════════════════════════════════════════════════════════ @admin.register(DefaultAutomationConfig) class DefaultAutomationConfigAdmin(Igny8ModelAdmin): """Admin for the singleton Default Automation Config""" list_display = ('__str__', 'is_enabled', 'frequency', 'next_scheduled_hour', 'updated_at') fieldsets = ( ('Default Schedule Settings', { 'fields': ('is_enabled', 'frequency'), 'description': 'These defaults are applied when creating new sites.' }), ('Auto-Increment Time Slot', { 'fields': ('base_scheduled_hour', 'next_scheduled_hour'), 'description': 'Each new site gets next_scheduled_hour as its run time, then it increments. Reset next_scheduled_hour to base_scheduled_hour to start over.' }), ('Publishing Defaults', { 'fields': ( 'auto_approval_enabled', 'auto_publish_enabled', 'daily_publish_limit', 'weekly_publish_limit', 'monthly_publish_limit', 'publish_days', 'publish_time_slots', ), 'description': 'Default publishing settings for new sites.', }), ('Image Generation Defaults', { 'fields': ( 'image_style', 'max_images_per_article', ), 'description': 'Default image generation settings for new sites.', }), ('Stage Enabled/Disabled', { 'fields': ( 'stage_1_enabled', 'stage_2_enabled', 'stage_3_enabled', 'stage_4_enabled', 'stage_5_enabled', 'stage_6_enabled', 'stage_7_enabled', ), 'classes': ('collapse',), }), ('Batch Sizes', { 'fields': ( 'stage_1_batch_size', 'stage_2_batch_size', 'stage_3_batch_size', 'stage_4_batch_size', 'stage_5_batch_size', 'stage_6_batch_size', ), 'classes': ('collapse',), }), ('Use Testing Model', { 'fields': ( 'stage_1_use_testing', 'stage_2_use_testing', 'stage_4_use_testing', 'stage_5_use_testing', 'stage_6_use_testing', ), 'classes': ('collapse',), }), ('Budget Allocation (%)', { 'fields': ( 'stage_1_budget_pct', 'stage_2_budget_pct', 'stage_4_budget_pct', 'stage_5_budget_pct', 'stage_6_budget_pct', ), 'classes': ('collapse',), }), ('Per-Run Limits', { 'fields': ( 'max_keywords_per_run', 'max_clusters_per_run', 'max_ideas_per_run', 'max_tasks_per_run', 'max_content_per_run', 'max_images_per_run', 'max_approvals_per_run', 'max_credits_per_run', ), 'classes': ('collapse',), }), ('Delays', { 'fields': ('within_stage_delay', 'between_stage_delay'), 'classes': ('collapse',), }), ) def has_add_permission(self, request): # Only allow one instance (singleton) return not DefaultAutomationConfig.objects.exists() def has_delete_permission(self, request, obj=None): # Don't allow deleting the singleton return False def changelist_view(self, request, extra_context=None): # Redirect to edit view if instance exists, otherwise show add form obj = DefaultAutomationConfig.objects.first() if obj: from django.shortcuts import redirect return redirect(f'admin:automation_defaultautomationconfig_change', obj.pk) return super().changelist_view(request, extra_context) # ═══════════════════════════════════════════════════════════ # AUTOMATION CONFIG (Per-Site) # ═══════════════════════════════════════════════════════════ class AutomationConfigResource(resources.ModelResource): """Resource class for exporting Automation Configs""" class Meta: model = AutomationConfig fields = ('id', 'site__domain', 'is_enabled', 'frequency', 'scheduled_time', 'last_run_at', 'next_run_at', 'created_at') export_order = fields @admin.register(AutomationConfig) class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin): resource_class = AutomationConfigResource list_display = ('site', 'is_enabled', 'frequency', 'scheduled_time', 'next_scheduled_run', 'last_run_at', 'test_mode_status') list_filter = ('is_enabled', 'frequency', 'test_mode_enabled') search_fields = ('site__domain',) readonly_fields = ('test_trigger_at_display',) actions = [ 'bulk_enable', 'bulk_disable', 'bulk_update_frequency', 'bulk_update_delays', 'trigger_test_run', 'clear_test_mode', ] fieldsets = ( (None, { 'fields': ('site', 'is_enabled', 'frequency', 'scheduled_time') }), ('Timing', { 'fields': ('last_run_at', 'next_run_at') }), ('Stage Enabled/Disabled', { 'fields': ( 'stage_1_enabled', 'stage_2_enabled', 'stage_3_enabled', 'stage_4_enabled', 'stage_5_enabled', 'stage_6_enabled', 'stage_7_enabled', ), }), ('Batch Sizes', { 'fields': ( 'stage_1_batch_size', 'stage_2_batch_size', 'stage_3_batch_size', 'stage_4_batch_size', 'stage_5_batch_size', 'stage_6_batch_size', ), 'classes': ('collapse',), }), ('Use Testing Model', { 'fields': ( 'stage_1_use_testing', 'stage_2_use_testing', 'stage_4_use_testing', 'stage_5_use_testing', 'stage_6_use_testing', ), 'classes': ('collapse',), 'description': 'Use testing AI model instead of live model for these stages.', }), ('Budget Allocation (%)', { 'fields': ( 'stage_1_budget_pct', 'stage_2_budget_pct', 'stage_4_budget_pct', 'stage_5_budget_pct', 'stage_6_budget_pct', ), 'classes': ('collapse',), 'description': 'Percentage of AI budget allocated to each stage (should total 100%).', }), ('Per-Run Limits', { 'fields': ( 'max_keywords_per_run', 'max_clusters_per_run', 'max_ideas_per_run', 'max_tasks_per_run', 'max_content_per_run', 'max_images_per_run', 'max_approvals_per_run', 'max_credits_per_run', ), 'classes': ('collapse',), 'description': 'Maximum items to process per automation run (0 = unlimited).', }), ('Delays', { 'fields': ('within_stage_delay', 'between_stage_delay'), 'classes': ('collapse',), }), ('Test Mode (Admin Only)', { 'fields': ('test_mode_enabled', 'test_trigger_at', 'test_trigger_at_display'), 'classes': ('collapse',), 'description': 'Enable test mode to trigger automation without waiting for schedule. Set trigger time and automation will run at next minute check.' }), ) def test_mode_status(self, obj): """Show test mode status in list view""" if not obj.test_mode_enabled: return '-' if obj.test_trigger_at: return f'⏳ Pending: {obj.test_trigger_at.strftime("%H:%M")}' return '✅ Enabled (no trigger set)' test_mode_status.short_description = 'Test Mode' def test_trigger_at_display(self, obj): """Display-only field for test trigger time""" if obj.test_trigger_at: return obj.test_trigger_at.strftime('%Y-%m-%d %H:%M:%S') return 'Not set' test_trigger_at_display.short_description = 'Trigger At (Display)' def next_scheduled_run(self, obj): """ Calculate the next scheduled run based on: - Celery Beat schedule (every hour at :05) - Frequency (daily, weekly, monthly) - Scheduled hour (user selects hour only, stored as HH:00) - 23-hour block after last_run_at SIMPLIFIED: Celery runs at :05 of every hour and checks if scheduled_hour == current_hour """ from django.utils import timezone from datetime import timedelta if not obj.is_enabled: return 'Disabled' now = timezone.now() scheduled_hour = obj.scheduled_time.hour # Calculate next occurrence based on frequency # Celery runs at :05, so we show the next :05 when the hour matches def get_next_celery_pickup(): # Start with today at the scheduled hour :05 candidate = now.replace( hour=scheduled_hour, minute=5, second=0, microsecond=0 ) if obj.frequency == 'daily': # If time has passed today, next is tomorrow if candidate <= now: candidate += timedelta(days=1) elif obj.frequency == 'weekly': # Run on Mondays days_until_monday = (7 - now.weekday()) % 7 if days_until_monday == 0: # Today is Monday - check if time passed candidate = now.replace( hour=scheduled_hour, minute=5, second=0, microsecond=0 ) if candidate <= now: days_until_monday = 7 candidate += timedelta(days=7) else: candidate = now + timedelta(days=days_until_monday) candidate = candidate.replace( hour=scheduled_hour, minute=5, second=0, microsecond=0 ) elif obj.frequency == 'monthly': # Run on 1st of month candidate = now.replace( day=1, hour=scheduled_hour, minute=5, second=0, microsecond=0 ) if candidate <= now: # Next month if now.month == 12: candidate = candidate.replace(year=now.year + 1, month=1) else: candidate = candidate.replace(month=now.month + 1) return candidate next_celery_pickup = get_next_celery_pickup() # Check 23-hour block if obj.last_run_at: earliest_eligible = obj.last_run_at + timedelta(hours=23) if next_celery_pickup < earliest_eligible: # Blocked - need to skip to next cycle if obj.frequency == 'daily': # Move to next day next_celery_pickup = earliest_eligible.replace( hour=scheduled_hour, minute=5, second=0, microsecond=0 ) if next_celery_pickup < earliest_eligible: next_celery_pickup += timedelta(days=1) elif obj.frequency == 'weekly': # Find next Monday after earliest_eligible days_until_monday = (7 - earliest_eligible.weekday()) % 7 if days_until_monday == 0: test_candidate = earliest_eligible.replace( hour=scheduled_hour, minute=5, second=0, microsecond=0 ) if test_candidate <= earliest_eligible: days_until_monday = 7 next_celery_pickup = earliest_eligible + timedelta(days=days_until_monday) next_celery_pickup = next_celery_pickup.replace( hour=scheduled_hour, minute=5, second=0, microsecond=0 ) elif obj.frequency == 'monthly': # Find next 1st of month after earliest_eligible next_celery_pickup = earliest_eligible.replace( day=1, hour=scheduled_hour, minute=5, second=0, microsecond=0 ) if next_celery_pickup < earliest_eligible: if earliest_eligible.month == 12: next_celery_pickup = next_celery_pickup.replace(year=earliest_eligible.year + 1, month=1) else: next_celery_pickup = next_celery_pickup.replace(month=earliest_eligible.month + 1) # Format nicely return next_celery_pickup.strftime('%b %d, %Y, %-I:%M %p') next_scheduled_run.short_description = 'Next Scheduled Run' def trigger_test_run(self, request, queryset): """Set test trigger for immediate execution""" from django.utils import timezone count = queryset.update( test_mode_enabled=True, test_trigger_at=timezone.now() ) self.message_user( request, f'{count} automation(s) set to trigger at next minute check. Test mode enabled.', messages.SUCCESS ) trigger_test_run.short_description = '🧪 Trigger test run (immediate)' def clear_test_mode(self, request, queryset): """Clear test mode and trigger time""" count = queryset.update( test_mode_enabled=False, test_trigger_at=None ) self.message_user( request, f'{count} automation(s) test mode cleared.', messages.SUCCESS ) clear_test_mode.short_description = '🧹 Clear test mode' def bulk_enable(self, request, queryset): """Enable selected automation configs""" updated = queryset.update(is_enabled=True) self.message_user(request, f'{updated} automation config(s) enabled.', messages.SUCCESS) bulk_enable.short_description = 'Enable selected automations' def bulk_disable(self, request, queryset): """Disable selected automation configs""" updated = queryset.update(is_enabled=False) self.message_user(request, f'{updated} automation config(s) disabled.', messages.SUCCESS) bulk_disable.short_description = 'Disable selected automations' def bulk_update_frequency(self, request, queryset): """Update frequency for selected automation configs""" from django import forms if 'apply' in request.POST: frequency = request.POST.get('frequency') if frequency: updated = queryset.update(frequency=frequency) self.message_user(request, f'{updated} automation config(s) updated to frequency: {frequency}', messages.SUCCESS) return FREQUENCY_CHOICES = [ ('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly'), ] class FrequencyForm(forms.Form): frequency = forms.ChoiceField( choices=FREQUENCY_CHOICES, label="Select Frequency", help_text=f"Update frequency for {queryset.count()} automation config(s)" ) from django.shortcuts import render return render(request, 'admin/bulk_action_form.html', { 'title': 'Update Automation Frequency', 'queryset': queryset, 'form': FrequencyForm(), 'action': 'bulk_update_frequency', }) bulk_update_frequency.short_description = 'Update frequency' def bulk_update_delays(self, request, queryset): """Update delay settings for selected automation configs""" from django import forms if 'apply' in request.POST: within_delay = int(request.POST.get('within_stage_delay', 0)) between_delay = int(request.POST.get('between_stage_delay', 0)) updated = queryset.update( within_stage_delay=within_delay, between_stage_delay=between_delay ) self.message_user(request, f'{updated} automation config(s) delay settings updated.', messages.SUCCESS) return class DelayForm(forms.Form): within_stage_delay = forms.IntegerField( min_value=0, initial=10, label="Within Stage Delay (minutes)", help_text="Delay between operations within the same stage" ) between_stage_delay = forms.IntegerField( min_value=0, initial=60, label="Between Stage Delay (minutes)", help_text="Delay between different stages" ) from django.shortcuts import render return render(request, 'admin/bulk_action_form.html', { 'title': 'Update Automation Delays', 'queryset': queryset, 'form': DelayForm(), 'action': 'bulk_update_delays', }) bulk_update_delays.short_description = 'Update delay settings' class AutomationRunResource(resources.ModelResource): """Resource class for exporting Automation Runs""" class Meta: model = AutomationRun fields = ('id', 'run_id', 'site__domain', 'status', 'current_stage', 'started_at', 'completed_at', 'created_at') export_order = fields @admin.register(AutomationRun) class AutomationRunAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin): resource_class = AutomationRunResource list_display = ('run_id', 'site', 'status', 'current_stage', 'started_at', 'completed_at') list_filter = ('status', 'current_stage') search_fields = ('run_id', 'site__domain') actions = [ 'bulk_retry_failed', 'bulk_cancel_running', 'bulk_delete_old_runs', ] def bulk_retry_failed(self, request, queryset): """Retry failed automation runs""" failed_runs = queryset.filter(status='failed') count = failed_runs.update(status='pending', current_stage='keyword_research') self.message_user(request, f'{count} failed run(s) marked for retry.', messages.SUCCESS) bulk_retry_failed.short_description = 'Retry failed runs' def bulk_cancel_running(self, request, queryset): """Cancel running automation runs""" running = queryset.filter(status__in=['pending', 'running']) count = running.update(status='failed') self.message_user(request, f'{count} running automation(s) cancelled.', messages.SUCCESS) bulk_cancel_running.short_description = 'Cancel running automations' def bulk_delete_old_runs(self, request, queryset): """Delete automation runs older than 30 days""" from django.utils import timezone from datetime import timedelta cutoff_date = timezone.now() - timedelta(days=30) old_runs = queryset.filter(created_at__lt=cutoff_date) count = old_runs.count() old_runs.delete() self.message_user(request, f'{count} old automation run(s) deleted (older than 30 days).', messages.SUCCESS) bulk_delete_old_runs.short_description = 'Delete old runs (>30 days)'