Files
igny8/backend/igny8_core/business/automation/admin.py
2026-01-18 15:03:01 +00:00

540 lines
21 KiB
Python

"""
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)'