autmation final reaftocrs and setitgns dafautls
This commit is contained in:
@@ -9,6 +9,7 @@ import logging
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.views import APIView
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
|
||||
|
||||
@@ -16,7 +17,7 @@ from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove
|
||||
from igny8_core.api.response import success_response, error_response
|
||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from igny8_core.auth.models import Site
|
||||
from igny8_core.business.automation.models import AutomationConfig
|
||||
from igny8_core.business.automation.models import AutomationConfig, DefaultAutomationConfig
|
||||
from igny8_core.business.integration.models import PublishingSettings
|
||||
from igny8_core.business.billing.models import AIModelConfig
|
||||
|
||||
@@ -452,3 +453,101 @@ class UnifiedSiteSettingsViewSet(viewsets.ViewSet):
|
||||
automation_config.max_approvals_per_run = stage['per_run_limit']
|
||||
|
||||
logger.info(f"[UnifiedSettings] After update - stage_1_batch_size={automation_config.stage_1_batch_size}, max_keywords_per_run={automation_config.max_keywords_per_run}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# DEFAULT SETTINGS API
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
class DefaultSettingsAPIView(APIView):
|
||||
"""
|
||||
API endpoint to fetch default settings for reset functionality.
|
||||
Reads from DefaultAutomationConfig singleton in backend.
|
||||
|
||||
GET /api/v1/settings/defaults/
|
||||
"""
|
||||
permission_classes = [IsAuthenticatedAndActive]
|
||||
|
||||
@extend_schema(
|
||||
tags=['Site Settings'],
|
||||
summary='Get default settings',
|
||||
description='Fetch default automation, publishing, and stage settings from backend configuration. Used by frontend reset functionality.',
|
||||
)
|
||||
def get(self, request):
|
||||
"""Return default settings from DefaultAutomationConfig"""
|
||||
try:
|
||||
defaults = DefaultAutomationConfig.get_instance()
|
||||
|
||||
# Build stage defaults from the model
|
||||
stage_defaults = []
|
||||
for i in range(1, 8):
|
||||
stage_config = {
|
||||
'number': i,
|
||||
'enabled': getattr(defaults, f'stage_{i}_enabled', True),
|
||||
}
|
||||
|
||||
# Batch size (stages 1-6)
|
||||
if i <= 6:
|
||||
stage_config['batch_size'] = getattr(defaults, f'stage_{i}_batch_size', 1)
|
||||
|
||||
# Per-run limits
|
||||
limit_map = {
|
||||
1: 'max_keywords_per_run',
|
||||
2: 'max_clusters_per_run',
|
||||
3: 'max_ideas_per_run',
|
||||
4: 'max_tasks_per_run',
|
||||
5: 'max_content_per_run',
|
||||
6: 'max_images_per_run',
|
||||
7: 'max_approvals_per_run',
|
||||
}
|
||||
stage_config['per_run_limit'] = getattr(defaults, limit_map[i], 0)
|
||||
|
||||
# Use testing (AI stages only: 1, 2, 4, 5, 6)
|
||||
if i in [1, 2, 4, 5, 6]:
|
||||
stage_config['use_testing'] = getattr(defaults, f'stage_{i}_use_testing', False)
|
||||
stage_config['budget_pct'] = getattr(defaults, f'stage_{i}_budget_pct', 0)
|
||||
|
||||
stage_defaults.append(stage_config)
|
||||
|
||||
# Build publish days - ensure it's a list
|
||||
publish_days = defaults.publish_days
|
||||
if not publish_days:
|
||||
publish_days = ['mon', 'tue', 'wed', 'thu', 'fri']
|
||||
|
||||
# Build time slots - ensure it's a list
|
||||
time_slots = defaults.publish_time_slots
|
||||
if not time_slots:
|
||||
time_slots = ['09:00', '14:00', '18:00']
|
||||
|
||||
# Format scheduled_time from hour
|
||||
scheduled_hour = defaults.next_scheduled_hour
|
||||
scheduled_time = f"{scheduled_hour:02d}:00"
|
||||
|
||||
return success_response({
|
||||
'automation': {
|
||||
'enabled': defaults.is_enabled,
|
||||
'frequency': defaults.frequency,
|
||||
'time': scheduled_time,
|
||||
},
|
||||
'stages': stage_defaults,
|
||||
'delays': {
|
||||
'within_stage': defaults.within_stage_delay,
|
||||
'between_stage': defaults.between_stage_delay,
|
||||
},
|
||||
'publishing': {
|
||||
'auto_approval_enabled': defaults.auto_approval_enabled,
|
||||
'auto_publish_enabled': defaults.auto_publish_enabled,
|
||||
'daily_publish_limit': defaults.daily_publish_limit,
|
||||
'weekly_publish_limit': defaults.weekly_publish_limit,
|
||||
'monthly_publish_limit': defaults.monthly_publish_limit,
|
||||
'publish_days': publish_days,
|
||||
'time_slots': time_slots,
|
||||
},
|
||||
'images': {
|
||||
'style': defaults.image_style,
|
||||
'max_images': defaults.max_images_per_article,
|
||||
},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"[DefaultSettings] Error fetching defaults: {e}")
|
||||
return error_response(str(e), status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@@ -5,13 +5,134 @@ 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
|
||||
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:
|
||||
@@ -24,26 +145,120 @@ class AutomationConfigResource(resources.ModelResource):
|
||||
@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')
|
||||
list_filter = ('is_enabled', 'frequency')
|
||||
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 15 minutes at :00, :15, :30, :45)
|
||||
- Celery Beat schedule (every hour at :05)
|
||||
- Frequency (daily, weekly, monthly)
|
||||
- Scheduled time
|
||||
- Scheduled hour (user selects hour only, stored as HH:00)
|
||||
- 23-hour block after last_run_at
|
||||
|
||||
Celery checks window at :00 for :00-:14, at :15 for :15-:29, etc.
|
||||
So scheduled_time 12:12 will be picked up at the 12:00 check.
|
||||
SIMPLIFIED: Celery runs at :05 of every hour and checks if scheduled_hour == current_hour
|
||||
"""
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
@@ -53,25 +268,20 @@ class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
||||
|
||||
now = timezone.now()
|
||||
scheduled_hour = obj.scheduled_time.hour
|
||||
scheduled_minute = obj.scheduled_time.minute
|
||||
|
||||
# Calculate the Celery window start time for this scheduled_time
|
||||
# If scheduled at :12, Celery checks at :00 (window :00-:14)
|
||||
# If scheduled at :35, Celery checks at :30 (window :30-:44)
|
||||
window_start_minute = (scheduled_minute // 15) * 15
|
||||
|
||||
# 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 Celery window start time
|
||||
# Start with today at the scheduled hour :05
|
||||
candidate = now.replace(
|
||||
hour=scheduled_hour,
|
||||
minute=window_start_minute,
|
||||
minute=5,
|
||||
second=0,
|
||||
microsecond=0
|
||||
)
|
||||
|
||||
if obj.frequency == 'daily':
|
||||
# If time has passed today (Celery already checked this window), next is tomorrow
|
||||
# If time has passed today, next is tomorrow
|
||||
if candidate <= now:
|
||||
candidate += timedelta(days=1)
|
||||
elif obj.frequency == 'weekly':
|
||||
@@ -81,7 +291,7 @@ class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
||||
# Today is Monday - check if time passed
|
||||
candidate = now.replace(
|
||||
hour=scheduled_hour,
|
||||
minute=window_start_minute,
|
||||
minute=5,
|
||||
second=0,
|
||||
microsecond=0
|
||||
)
|
||||
@@ -92,7 +302,7 @@ class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
||||
candidate = now + timedelta(days=days_until_monday)
|
||||
candidate = candidate.replace(
|
||||
hour=scheduled_hour,
|
||||
minute=window_start_minute,
|
||||
minute=5,
|
||||
second=0,
|
||||
microsecond=0
|
||||
)
|
||||
@@ -101,7 +311,7 @@ class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
||||
candidate = now.replace(
|
||||
day=1,
|
||||
hour=scheduled_hour,
|
||||
minute=window_start_minute,
|
||||
minute=5,
|
||||
second=0,
|
||||
microsecond=0
|
||||
)
|
||||
@@ -122,10 +332,10 @@ class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
||||
if next_celery_pickup < earliest_eligible:
|
||||
# Blocked - need to skip to next cycle
|
||||
if obj.frequency == 'daily':
|
||||
# Move to next day's window
|
||||
# Move to next day
|
||||
next_celery_pickup = earliest_eligible.replace(
|
||||
hour=scheduled_hour,
|
||||
minute=window_start_minute,
|
||||
minute=5,
|
||||
second=0,
|
||||
microsecond=0
|
||||
)
|
||||
@@ -137,7 +347,7 @@ class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
||||
if days_until_monday == 0:
|
||||
test_candidate = earliest_eligible.replace(
|
||||
hour=scheduled_hour,
|
||||
minute=window_start_minute,
|
||||
minute=5,
|
||||
second=0,
|
||||
microsecond=0
|
||||
)
|
||||
@@ -146,7 +356,7 @@ class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
||||
next_celery_pickup = earliest_eligible + timedelta(days=days_until_monday)
|
||||
next_celery_pickup = next_celery_pickup.replace(
|
||||
hour=scheduled_hour,
|
||||
minute=window_start_minute,
|
||||
minute=5,
|
||||
second=0,
|
||||
microsecond=0
|
||||
)
|
||||
@@ -155,7 +365,7 @@ class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
||||
next_celery_pickup = earliest_eligible.replace(
|
||||
day=1,
|
||||
hour=scheduled_hour,
|
||||
minute=window_start_minute,
|
||||
minute=5,
|
||||
second=0,
|
||||
microsecond=0
|
||||
)
|
||||
@@ -170,6 +380,34 @@ class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
||||
|
||||
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)
|
||||
@@ -194,9 +432,9 @@ class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
||||
return
|
||||
|
||||
FREQUENCY_CHOICES = [
|
||||
('hourly', 'Hourly'),
|
||||
('daily', 'Daily'),
|
||||
('weekly', 'Weekly'),
|
||||
('monthly', 'Monthly'),
|
||||
]
|
||||
|
||||
class FrequencyForm(forms.Form):
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated manually for test mode fields
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('automation', '0009_add_stage_use_testing_and_budget_pct'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='automationconfig',
|
||||
name='test_mode_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Enable test mode - allows test triggers'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='automationconfig',
|
||||
name='test_trigger_at',
|
||||
field=models.DateTimeField(blank=True, null=True, help_text='Set datetime to trigger a test run (auto-clears after trigger)'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='automationconfig',
|
||||
index=models.Index(fields=['test_mode_enabled', 'test_trigger_at'], name='automation__test_mo_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
# Generated manually for DefaultAutomationConfig model
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('automation', '0010_add_test_mode_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DefaultAutomationConfig',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('singleton_key', models.CharField(default='X', editable=False, max_length=1, unique=True)),
|
||||
('is_enabled', models.BooleanField(default=False, help_text='Default: Enable scheduled automation for new sites')),
|
||||
('frequency', models.CharField(choices=[('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly')], default='daily', max_length=20)),
|
||||
('base_scheduled_hour', models.IntegerField(default=2, help_text='Starting hour (0-23) for auto-assignment. Each new site gets next hour.')),
|
||||
('next_scheduled_hour', models.IntegerField(default=2, help_text='Next hour to assign (auto-increments, wraps at 24)')),
|
||||
('stage_1_enabled', models.BooleanField(default=True, help_text='Process Stage 1: Keywords → Clusters')),
|
||||
('stage_2_enabled', models.BooleanField(default=True, help_text='Process Stage 2: Clusters → Ideas')),
|
||||
('stage_3_enabled', models.BooleanField(default=True, help_text='Process Stage 3: Ideas → Tasks')),
|
||||
('stage_4_enabled', models.BooleanField(default=True, help_text='Process Stage 4: Tasks → Content')),
|
||||
('stage_5_enabled', models.BooleanField(default=True, help_text='Process Stage 5: Content → Image Prompts')),
|
||||
('stage_6_enabled', models.BooleanField(default=True, help_text='Process Stage 6: Image Prompts → Images')),
|
||||
('stage_7_enabled', models.BooleanField(default=True, help_text='Process Stage 7: Review → Published')),
|
||||
('stage_1_batch_size', models.IntegerField(default=50, help_text='Keywords per batch')),
|
||||
('stage_2_batch_size', models.IntegerField(default=1, help_text='Clusters at a time')),
|
||||
('stage_3_batch_size', models.IntegerField(default=20, help_text='Ideas per batch')),
|
||||
('stage_4_batch_size', models.IntegerField(default=1, help_text='Tasks - sequential')),
|
||||
('stage_5_batch_size', models.IntegerField(default=1, help_text='Content at a time')),
|
||||
('stage_6_batch_size', models.IntegerField(default=1, help_text='Images - sequential')),
|
||||
('stage_1_use_testing', models.BooleanField(default=False, help_text='Use testing model for Stage 1')),
|
||||
('stage_2_use_testing', models.BooleanField(default=False, help_text='Use testing model for Stage 2')),
|
||||
('stage_4_use_testing', models.BooleanField(default=False, help_text='Use testing model for Stage 4')),
|
||||
('stage_5_use_testing', models.BooleanField(default=False, help_text='Use testing model for Stage 5')),
|
||||
('stage_6_use_testing', models.BooleanField(default=False, help_text='Use testing model for Stage 6')),
|
||||
('stage_1_budget_pct', models.IntegerField(default=15, help_text='Budget percentage for Stage 1')),
|
||||
('stage_2_budget_pct', models.IntegerField(default=10, help_text='Budget percentage for Stage 2')),
|
||||
('stage_4_budget_pct', models.IntegerField(default=40, help_text='Budget percentage for Stage 4')),
|
||||
('stage_5_budget_pct', models.IntegerField(default=5, help_text='Budget percentage for Stage 5')),
|
||||
('stage_6_budget_pct', models.IntegerField(default=30, help_text='Budget percentage for Stage 6')),
|
||||
('within_stage_delay', models.IntegerField(default=3, help_text='Delay between batches within a stage (seconds)')),
|
||||
('between_stage_delay', models.IntegerField(default=5, help_text='Delay between stage transitions (seconds)')),
|
||||
('max_keywords_per_run', models.IntegerField(default=0, help_text='Max keywords to process in stage 1 (0=unlimited)')),
|
||||
('max_clusters_per_run', models.IntegerField(default=0, help_text='Max clusters to process in stage 2 (0=unlimited)')),
|
||||
('max_ideas_per_run', models.IntegerField(default=0, help_text='Max ideas to process in stage 3 (0=unlimited)')),
|
||||
('max_tasks_per_run', models.IntegerField(default=0, help_text='Max tasks to process in stage 4 (0=unlimited)')),
|
||||
('max_content_per_run', models.IntegerField(default=0, help_text='Max content pieces for image prompts in stage 5 (0=unlimited)')),
|
||||
('max_images_per_run', models.IntegerField(default=0, help_text='Max images to generate in stage 6 (0=unlimited)')),
|
||||
('max_approvals_per_run', models.IntegerField(default=0, help_text='Max content pieces to auto-approve in stage 7 (0=unlimited)')),
|
||||
('max_credits_per_run', models.IntegerField(default=0, help_text='Max credits to use per run (0=unlimited)')),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Default Automation Config',
|
||||
'verbose_name_plural': 'Default Automation Config',
|
||||
'db_table': 'igny8_default_automation_config',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
# Generated manually for publishing and image default fields
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def set_default_values(apps, schema_editor):
|
||||
"""Set default values for publish_days and publish_time_slots"""
|
||||
DefaultAutomationConfig = apps.get_model('automation', 'DefaultAutomationConfig')
|
||||
for config in DefaultAutomationConfig.objects.all():
|
||||
if not config.publish_days:
|
||||
config.publish_days = ['mon', 'tue', 'wed', 'thu', 'fri']
|
||||
if not config.publish_time_slots:
|
||||
config.publish_time_slots = ['09:00', '14:00', '18:00']
|
||||
config.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('automation', '0011_add_default_automation_config'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Publishing defaults
|
||||
migrations.AddField(
|
||||
model_name='defaultautomationconfig',
|
||||
name='auto_approval_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Auto-approve content after review'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='defaultautomationconfig',
|
||||
name='auto_publish_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Auto-publish approved content to site'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='defaultautomationconfig',
|
||||
name='daily_publish_limit',
|
||||
field=models.IntegerField(default=3, help_text='Max posts per day'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='defaultautomationconfig',
|
||||
name='weekly_publish_limit',
|
||||
field=models.IntegerField(default=15, help_text='Max posts per week'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='defaultautomationconfig',
|
||||
name='monthly_publish_limit',
|
||||
field=models.IntegerField(default=50, help_text='Max posts per month'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='defaultautomationconfig',
|
||||
name='publish_days',
|
||||
field=models.JSONField(default=list, help_text="Days to publish (e.g., ['mon', 'tue', 'wed', 'thu', 'fri'])"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='defaultautomationconfig',
|
||||
name='publish_time_slots',
|
||||
field=models.JSONField(default=list, help_text="Time slots to publish (e.g., ['09:00', '14:00', '18:00'])"),
|
||||
),
|
||||
# Image defaults
|
||||
migrations.AddField(
|
||||
model_name='defaultautomationconfig',
|
||||
name='image_style',
|
||||
field=models.CharField(default='photorealistic', help_text='Default image style', max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='defaultautomationconfig',
|
||||
name='max_images_per_article',
|
||||
field=models.IntegerField(default=4, help_text='Images per article (1-8)'),
|
||||
),
|
||||
# Set default JSON values
|
||||
migrations.RunPython(set_default_values, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -7,6 +7,117 @@ from django.utils import timezone
|
||||
from igny8_core.auth.models import Account, Site
|
||||
|
||||
|
||||
class DefaultAutomationConfig(models.Model):
|
||||
"""
|
||||
Singleton model for default automation settings.
|
||||
Used when creating new sites - copies these defaults to the new AutomationConfig.
|
||||
Also tracks the next scheduled hour to auto-increment for each new site.
|
||||
"""
|
||||
|
||||
FREQUENCY_CHOICES = [
|
||||
('daily', 'Daily'),
|
||||
('weekly', 'Weekly'),
|
||||
('monthly', 'Monthly'),
|
||||
]
|
||||
|
||||
# Singleton - only one row allowed
|
||||
singleton_key = models.CharField(max_length=1, default='X', unique=True, editable=False)
|
||||
|
||||
# Default scheduling settings
|
||||
is_enabled = models.BooleanField(default=False, help_text="Default: Enable scheduled automation for new sites")
|
||||
frequency = models.CharField(max_length=20, choices=FREQUENCY_CHOICES, default='daily')
|
||||
base_scheduled_hour = models.IntegerField(default=2, help_text="Starting hour (0-23) for auto-assignment. Each new site gets next hour.")
|
||||
next_scheduled_hour = models.IntegerField(default=2, help_text="Next hour to assign (auto-increments, wraps at 24)")
|
||||
|
||||
# Stage processing toggles
|
||||
stage_1_enabled = models.BooleanField(default=True, help_text="Process Stage 1: Keywords → Clusters")
|
||||
stage_2_enabled = models.BooleanField(default=True, help_text="Process Stage 2: Clusters → Ideas")
|
||||
stage_3_enabled = models.BooleanField(default=True, help_text="Process Stage 3: Ideas → Tasks")
|
||||
stage_4_enabled = models.BooleanField(default=True, help_text="Process Stage 4: Tasks → Content")
|
||||
stage_5_enabled = models.BooleanField(default=True, help_text="Process Stage 5: Content → Image Prompts")
|
||||
stage_6_enabled = models.BooleanField(default=True, help_text="Process Stage 6: Image Prompts → Images")
|
||||
stage_7_enabled = models.BooleanField(default=True, help_text="Process Stage 7: Review → Published")
|
||||
|
||||
# Batch sizes per stage
|
||||
stage_1_batch_size = models.IntegerField(default=50, help_text="Keywords per batch")
|
||||
stage_2_batch_size = models.IntegerField(default=1, help_text="Clusters at a time")
|
||||
stage_3_batch_size = models.IntegerField(default=20, help_text="Ideas per batch")
|
||||
stage_4_batch_size = models.IntegerField(default=1, help_text="Tasks - sequential")
|
||||
stage_5_batch_size = models.IntegerField(default=1, help_text="Content at a time")
|
||||
stage_6_batch_size = models.IntegerField(default=1, help_text="Images - sequential")
|
||||
|
||||
# Use testing model per stage
|
||||
stage_1_use_testing = models.BooleanField(default=False, help_text="Use testing model for Stage 1")
|
||||
stage_2_use_testing = models.BooleanField(default=False, help_text="Use testing model for Stage 2")
|
||||
stage_4_use_testing = models.BooleanField(default=False, help_text="Use testing model for Stage 4")
|
||||
stage_5_use_testing = models.BooleanField(default=False, help_text="Use testing model for Stage 5")
|
||||
stage_6_use_testing = models.BooleanField(default=False, help_text="Use testing model for Stage 6")
|
||||
|
||||
# Budget percentage per stage
|
||||
stage_1_budget_pct = models.IntegerField(default=15, help_text="Budget percentage for Stage 1")
|
||||
stage_2_budget_pct = models.IntegerField(default=10, help_text="Budget percentage for Stage 2")
|
||||
stage_4_budget_pct = models.IntegerField(default=40, help_text="Budget percentage for Stage 4")
|
||||
stage_5_budget_pct = models.IntegerField(default=5, help_text="Budget percentage for Stage 5")
|
||||
stage_6_budget_pct = models.IntegerField(default=30, help_text="Budget percentage for Stage 6")
|
||||
|
||||
# Delay configuration
|
||||
within_stage_delay = models.IntegerField(default=3, help_text="Delay between batches within a stage (seconds)")
|
||||
between_stage_delay = models.IntegerField(default=5, help_text="Delay between stage transitions (seconds)")
|
||||
|
||||
# Per-run item limits
|
||||
max_keywords_per_run = models.IntegerField(default=0, help_text="Max keywords to process in stage 1 (0=unlimited)")
|
||||
max_clusters_per_run = models.IntegerField(default=0, help_text="Max clusters to process in stage 2 (0=unlimited)")
|
||||
max_ideas_per_run = models.IntegerField(default=0, help_text="Max ideas to process in stage 3 (0=unlimited)")
|
||||
max_tasks_per_run = models.IntegerField(default=0, help_text="Max tasks to process in stage 4 (0=unlimited)")
|
||||
max_content_per_run = models.IntegerField(default=0, help_text="Max content pieces for image prompts in stage 5 (0=unlimited)")
|
||||
max_images_per_run = models.IntegerField(default=0, help_text="Max images to generate in stage 6 (0=unlimited)")
|
||||
max_approvals_per_run = models.IntegerField(default=0, help_text="Max content pieces to auto-approve in stage 7 (0=unlimited)")
|
||||
max_credits_per_run = models.IntegerField(default=0, help_text="Max credits to use per run (0=unlimited)")
|
||||
|
||||
# ===== PUBLISHING DEFAULTS =====
|
||||
# Content publishing settings for new sites
|
||||
auto_approval_enabled = models.BooleanField(default=False, help_text="Auto-approve content after review")
|
||||
auto_publish_enabled = models.BooleanField(default=False, help_text="Auto-publish approved content to site")
|
||||
daily_publish_limit = models.IntegerField(default=3, help_text="Max posts per day")
|
||||
weekly_publish_limit = models.IntegerField(default=15, help_text="Max posts per week")
|
||||
monthly_publish_limit = models.IntegerField(default=50, help_text="Max posts per month")
|
||||
publish_days = models.JSONField(default=list, help_text="Days to publish (e.g., ['mon', 'tue', 'wed', 'thu', 'fri'])")
|
||||
publish_time_slots = models.JSONField(default=list, help_text="Time slots to publish (e.g., ['09:00', '14:00', '18:00'])")
|
||||
|
||||
# ===== IMAGE DEFAULTS =====
|
||||
# Image generation settings for new sites
|
||||
image_style = models.CharField(max_length=50, default='photorealistic', help_text="Default image style")
|
||||
max_images_per_article = models.IntegerField(default=4, help_text="Images per article (1-8)")
|
||||
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_default_automation_config'
|
||||
verbose_name = 'Default Automation Config'
|
||||
verbose_name_plural = 'Default Automation Config'
|
||||
|
||||
def __str__(self):
|
||||
return f"Default Automation Config (next hour: {self.next_scheduled_hour}:00)"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Ensure singleton
|
||||
self.singleton_key = 'X'
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
"""Get or create the singleton instance"""
|
||||
obj, created = cls.objects.get_or_create(singleton_key='X')
|
||||
return obj
|
||||
|
||||
def get_next_hour_and_increment(self):
|
||||
"""Get the next scheduled hour and increment for the next site"""
|
||||
current_hour = self.next_scheduled_hour
|
||||
self.next_scheduled_hour = (self.next_scheduled_hour + 1) % 24
|
||||
self.save(update_fields=['next_scheduled_hour'])
|
||||
return current_hour
|
||||
|
||||
|
||||
class AutomationConfig(models.Model):
|
||||
"""Per-site automation configuration"""
|
||||
|
||||
@@ -74,6 +185,10 @@ class AutomationConfig(models.Model):
|
||||
last_run_at = models.DateTimeField(null=True, blank=True)
|
||||
next_run_at = models.DateTimeField(null=True, blank=True, help_text="Calculated based on frequency")
|
||||
|
||||
# Test mode fields (for admin testing without waiting for hourly schedule)
|
||||
test_mode_enabled = models.BooleanField(default=False, help_text="Enable test mode - allows test triggers")
|
||||
test_trigger_at = models.DateTimeField(null=True, blank=True, help_text="Set datetime to trigger a test run (auto-clears after trigger)")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -84,11 +199,41 @@ class AutomationConfig(models.Model):
|
||||
indexes = [
|
||||
models.Index(fields=['is_enabled', 'next_run_at']),
|
||||
models.Index(fields=['account', 'site']),
|
||||
models.Index(fields=['test_mode_enabled', 'test_trigger_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Automation Config: {self.site.domain} ({self.frequency})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
On first save (new config), auto-assign scheduled_time from DefaultAutomationConfig.
|
||||
- Gets next_scheduled_hour from DefaultAutomationConfig
|
||||
- Assigns it to this config
|
||||
- Increments next_scheduled_hour in DefaultAutomationConfig for future configs
|
||||
"""
|
||||
is_new = self.pk is None
|
||||
|
||||
if is_new:
|
||||
# Check if scheduled_time is still the default (not explicitly set by user)
|
||||
default_time_str = '02:00'
|
||||
current_time_str = self.scheduled_time.strftime('%H:%M') if hasattr(self.scheduled_time, 'strftime') else str(self.scheduled_time)
|
||||
|
||||
# Only auto-assign if using default time (user didn't explicitly set it)
|
||||
if current_time_str == default_time_str:
|
||||
try:
|
||||
# Get the tracked next hour from DefaultAutomationConfig and increment it
|
||||
default_config = DefaultAutomationConfig.get_instance()
|
||||
next_hour = default_config.get_next_hour_and_increment()
|
||||
|
||||
# Set the scheduled time to next_hour:00
|
||||
from datetime import time
|
||||
self.scheduled_time = time(hour=next_hour, minute=0)
|
||||
except Exception:
|
||||
pass # Keep default if something fails
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class AutomationRun(models.Model):
|
||||
"""Tracks each automation execution"""
|
||||
@@ -96,6 +241,7 @@ class AutomationRun(models.Model):
|
||||
TRIGGER_TYPE_CHOICES = [
|
||||
('manual', 'Manual'),
|
||||
('scheduled', 'Scheduled'),
|
||||
('test', 'Test'),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
|
||||
@@ -16,48 +16,38 @@ logger = get_task_logger(__name__)
|
||||
@shared_task(name='automation.check_scheduled_automations')
|
||||
def check_scheduled_automations():
|
||||
"""
|
||||
Check for scheduled automation runs (runs every 15 minutes)
|
||||
Matches automations scheduled within the current 15-minute window.
|
||||
Check for scheduled automation runs (runs every hour at :05)
|
||||
|
||||
SIMPLIFIED LOGIC:
|
||||
- User selects hour only (0-23), stored as HH:00
|
||||
- Celery runs at :05 of every hour
|
||||
- Match: scheduled_hour == current_hour
|
||||
"""
|
||||
logger.info("[AutomationTask] Checking scheduled automations")
|
||||
logger.info("[AutomationTask] Checking scheduled automations (hourly at :05)")
|
||||
|
||||
now = timezone.now()
|
||||
current_time = now.time()
|
||||
current_hour = now.hour
|
||||
|
||||
# Calculate 15-minute window boundaries
|
||||
# Window starts at current quarter hour (0, 15, 30, 45)
|
||||
window_start_minute = (current_time.minute // 15) * 15
|
||||
window_end_minute = window_start_minute + 14
|
||||
logger.info(f"[AutomationTask] Current hour: {current_hour}:05, checking for configs scheduled at hour {current_hour}")
|
||||
|
||||
logger.info(f"[AutomationTask] Current time: {current_time}, checking window {current_time.hour}:{window_start_minute:02d}-{current_time.hour}:{window_end_minute:02d}")
|
||||
|
||||
# Find configs that should run now
|
||||
# Find configs that should run now (matching hour)
|
||||
for config in AutomationConfig.objects.filter(is_enabled=True):
|
||||
# Check if it's time to run
|
||||
should_run = False
|
||||
scheduled_hour = config.scheduled_time.hour
|
||||
scheduled_minute = config.scheduled_time.minute
|
||||
|
||||
# Check if scheduled time falls within current 15-minute window
|
||||
def is_in_window():
|
||||
if current_time.hour != scheduled_hour:
|
||||
return False
|
||||
return window_start_minute <= scheduled_minute <= window_end_minute
|
||||
# Simple hour match
|
||||
should_run = False
|
||||
|
||||
if config.frequency == 'daily':
|
||||
# Run if scheduled_time falls within current 15-minute window
|
||||
if is_in_window():
|
||||
should_run = True
|
||||
# Run every day if hour matches
|
||||
should_run = (scheduled_hour == current_hour)
|
||||
elif config.frequency == 'weekly':
|
||||
# Run on Mondays within scheduled window
|
||||
if now.weekday() == 0 and is_in_window():
|
||||
should_run = True
|
||||
# Run on Mondays if hour matches
|
||||
should_run = (now.weekday() == 0 and scheduled_hour == current_hour)
|
||||
elif config.frequency == 'monthly':
|
||||
# Run on 1st of month within scheduled window
|
||||
if now.day == 1 and is_in_window():
|
||||
should_run = True
|
||||
# Run on 1st of month if hour matches
|
||||
should_run = (now.day == 1 and scheduled_hour == current_hour)
|
||||
|
||||
logger.debug(f"[AutomationTask] Site {config.site_id}: freq={config.frequency}, scheduled={config.scheduled_time}, should_run={should_run}")
|
||||
logger.debug(f"[AutomationTask] Site {config.site_id}: freq={config.frequency}, scheduled_hour={scheduled_hour}, current_hour={current_hour}, should_run={should_run}")
|
||||
|
||||
if should_run:
|
||||
# Check if already ran within the last 23 hours (prevents duplicate runs)
|
||||
@@ -90,6 +80,61 @@ def check_scheduled_automations():
|
||||
logger.error(f"[AutomationTask] Failed to start automation for site {config.site.id}: {e}")
|
||||
|
||||
|
||||
@shared_task(name='automation.check_test_triggers')
|
||||
def check_test_triggers():
|
||||
"""
|
||||
Check for test triggers (runs every minute, but only processes if test_mode_enabled)
|
||||
|
||||
This allows admins to test automation without waiting for hourly schedule.
|
||||
- Set test_mode_enabled=True and test_trigger_at=datetime on AutomationConfig
|
||||
- When test_trigger_at <= now, automation triggers immediately
|
||||
- Bypasses: is_enabled check, 23hr block, frequency rules
|
||||
- Run is marked as trigger_type='test'
|
||||
- test_trigger_at is cleared after trigger
|
||||
"""
|
||||
# Quick check - if no configs have test mode enabled, exit immediately
|
||||
if not AutomationConfig.objects.filter(test_mode_enabled=True).exists():
|
||||
return # No logging to avoid spam
|
||||
|
||||
logger.info("[AutomationTask] Checking test triggers")
|
||||
|
||||
now = timezone.now()
|
||||
|
||||
# Find configs with test mode enabled and trigger time passed
|
||||
test_configs = AutomationConfig.objects.filter(
|
||||
test_mode_enabled=True,
|
||||
test_trigger_at__isnull=False,
|
||||
test_trigger_at__lte=now
|
||||
)
|
||||
|
||||
for config in test_configs:
|
||||
logger.info(f"[AutomationTask] Test trigger found for site {config.site_id}, trigger_at={config.test_trigger_at}")
|
||||
|
||||
# Check if already running (still respect this to avoid conflicts)
|
||||
if AutomationRun.objects.filter(site=config.site, status__in=['running', 'paused']).exists():
|
||||
logger.info(f"[AutomationTask] Skipping test trigger for site {config.site_id} - automation in progress")
|
||||
continue
|
||||
|
||||
try:
|
||||
service = AutomationService(config.account, config.site)
|
||||
run_id = service.start_automation(trigger_type='test')
|
||||
|
||||
# Clear test_trigger_at (don't update last_run_at - test runs don't affect production schedule)
|
||||
config.test_trigger_at = None
|
||||
config.save(update_fields=['test_trigger_at'])
|
||||
|
||||
logger.info(f"[AutomationTask] Started test automation for site {config.site_id}, run_id={run_id}")
|
||||
|
||||
# Start async processing
|
||||
run_automation_task.delay(run_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[AutomationTask] Failed to start test automation for site {config.site_id}: {e}")
|
||||
# Clear trigger to prevent infinite retry
|
||||
config.test_trigger_at = None
|
||||
config.save(update_fields=['test_trigger_at'])
|
||||
|
||||
|
||||
@shared_task(name='automation.run_automation_task', bind=True, max_retries=0)
|
||||
def run_automation_task(self, run_id: str):
|
||||
"""
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.utils import timezone
|
||||
|
||||
from igny8_core.auth.models import Account, Site
|
||||
from igny8_core.business.integration.models import PublishingSettings
|
||||
from igny8_core.business.automation.models import AutomationConfig
|
||||
from igny8_core.business.automation.models import AutomationConfig, DefaultAutomationConfig
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,6 +26,7 @@ DEFAULT_PUBLISHING_SETTINGS = {
|
||||
'publish_time_slots': ['09:00', '14:00', '18:00'],
|
||||
}
|
||||
|
||||
# Fallback defaults if no DefaultAutomationConfig exists
|
||||
DEFAULT_AUTOMATION_SETTINGS = {
|
||||
'is_enabled': True,
|
||||
'frequency': 'daily',
|
||||
@@ -128,15 +129,66 @@ class DefaultsService:
|
||||
site: Site,
|
||||
overrides: Optional[Dict[str, Any]] = None
|
||||
) -> AutomationConfig:
|
||||
"""Create automation config with defaults, applying any overrides."""
|
||||
config_data = {**DEFAULT_AUTOMATION_SETTINGS}
|
||||
"""
|
||||
Create automation config using DefaultAutomationConfig settings.
|
||||
Each new site gets an auto-incremented scheduled hour.
|
||||
"""
|
||||
# Get default config (singleton) - creates if not exists
|
||||
default_config = DefaultAutomationConfig.get_instance()
|
||||
|
||||
# Get the next scheduled hour and increment it for future sites
|
||||
scheduled_hour = default_config.get_next_hour_and_increment()
|
||||
scheduled_time = f"{scheduled_hour:02d}:00"
|
||||
|
||||
# Build config from defaults
|
||||
config_data = {
|
||||
'is_enabled': default_config.is_enabled,
|
||||
'frequency': default_config.frequency,
|
||||
# Stage enabled toggles
|
||||
'stage_1_enabled': default_config.stage_1_enabled,
|
||||
'stage_2_enabled': default_config.stage_2_enabled,
|
||||
'stage_3_enabled': default_config.stage_3_enabled,
|
||||
'stage_4_enabled': default_config.stage_4_enabled,
|
||||
'stage_5_enabled': default_config.stage_5_enabled,
|
||||
'stage_6_enabled': default_config.stage_6_enabled,
|
||||
'stage_7_enabled': default_config.stage_7_enabled,
|
||||
# Batch sizes
|
||||
'stage_1_batch_size': default_config.stage_1_batch_size,
|
||||
'stage_2_batch_size': default_config.stage_2_batch_size,
|
||||
'stage_3_batch_size': default_config.stage_3_batch_size,
|
||||
'stage_4_batch_size': default_config.stage_4_batch_size,
|
||||
'stage_5_batch_size': default_config.stage_5_batch_size,
|
||||
'stage_6_batch_size': default_config.stage_6_batch_size,
|
||||
# Use testing model
|
||||
'stage_1_use_testing': default_config.stage_1_use_testing,
|
||||
'stage_2_use_testing': default_config.stage_2_use_testing,
|
||||
'stage_4_use_testing': default_config.stage_4_use_testing,
|
||||
'stage_5_use_testing': default_config.stage_5_use_testing,
|
||||
'stage_6_use_testing': default_config.stage_6_use_testing,
|
||||
# Budget percentages
|
||||
'stage_1_budget_pct': default_config.stage_1_budget_pct,
|
||||
'stage_2_budget_pct': default_config.stage_2_budget_pct,
|
||||
'stage_4_budget_pct': default_config.stage_4_budget_pct,
|
||||
'stage_5_budget_pct': default_config.stage_5_budget_pct,
|
||||
'stage_6_budget_pct': default_config.stage_6_budget_pct,
|
||||
# Delays
|
||||
'within_stage_delay': default_config.within_stage_delay,
|
||||
'between_stage_delay': default_config.between_stage_delay,
|
||||
# Per-run limits
|
||||
'max_keywords_per_run': default_config.max_keywords_per_run,
|
||||
'max_clusters_per_run': default_config.max_clusters_per_run,
|
||||
'max_ideas_per_run': default_config.max_ideas_per_run,
|
||||
'max_tasks_per_run': default_config.max_tasks_per_run,
|
||||
'max_content_per_run': default_config.max_content_per_run,
|
||||
'max_images_per_run': default_config.max_images_per_run,
|
||||
'max_approvals_per_run': default_config.max_approvals_per_run,
|
||||
'max_credits_per_run': default_config.max_credits_per_run,
|
||||
}
|
||||
|
||||
# Apply any overrides
|
||||
if overrides:
|
||||
config_data.update(overrides)
|
||||
|
||||
# Calculate next run time (tomorrow at scheduled time)
|
||||
scheduled_time = config_data.pop('scheduled_time', '02:00')
|
||||
|
||||
automation_config = AutomationConfig.objects.create(
|
||||
account=self.account,
|
||||
site=site,
|
||||
|
||||
@@ -57,7 +57,11 @@ app.conf.beat_schedule = {
|
||||
# Automation Tasks
|
||||
'check-scheduled-automations': {
|
||||
'task': 'automation.check_scheduled_automations',
|
||||
'schedule': crontab(minute='0,15,30,45'), # Every 15 minutes
|
||||
'schedule': crontab(minute=5), # Every hour at :05
|
||||
},
|
||||
'check-test-triggers': {
|
||||
'task': 'automation.check_test_triggers',
|
||||
'schedule': crontab(minute='*'), # Every minute (task self-checks if any test mode enabled)
|
||||
},
|
||||
# Publishing Scheduler Tasks
|
||||
'schedule-approved-content': {
|
||||
|
||||
@@ -10,7 +10,7 @@ from igny8_core.modules.integration.webhooks import (
|
||||
wordpress_status_webhook,
|
||||
wordpress_metadata_webhook,
|
||||
)
|
||||
from igny8_core.api.unified_settings import UnifiedSiteSettingsViewSet
|
||||
from igny8_core.api.unified_settings import UnifiedSiteSettingsViewSet, DefaultSettingsAPIView
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'integrations', IntegrationViewSet, basename='integration')
|
||||
@@ -31,6 +31,9 @@ unified_settings_viewset = UnifiedSiteSettingsViewSet.as_view({
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
|
||||
# Default settings (for reset functionality)
|
||||
path('settings/defaults/', DefaultSettingsAPIView.as_view(), name='settings-defaults'),
|
||||
|
||||
# Site-level publishing settings
|
||||
path('sites/<int:site_id>/publishing-settings/', publishing_settings_viewset, name='publishing-settings'),
|
||||
|
||||
|
||||
@@ -763,6 +763,7 @@ UNFOLD = {
|
||||
"icon": "settings_suggest",
|
||||
"collapsible": True,
|
||||
"items": [
|
||||
{"title": "Default Config", "icon": "settings", "link": lambda request: "/admin/automation/defaultautomationconfig/"},
|
||||
{"title": "Automation Configs", "icon": "tune", "link": lambda request: "/admin/automation/automationconfig/"},
|
||||
{"title": "Automation Runs", "icon": "play_circle", "link": lambda request: "/admin/automation/automationrun/"},
|
||||
],
|
||||
|
||||
403
docs/40-WORKFLOWS/AUTOMATION-AND-PUBLISHING-SCHEDULING.md
Normal file
403
docs/40-WORKFLOWS/AUTOMATION-AND-PUBLISHING-SCHEDULING.md
Normal file
@@ -0,0 +1,403 @@
|
||||
# Automation & Publishing Scheduling System
|
||||
|
||||
> **Last Updated:** January 18, 2026
|
||||
> **System:** IGNY8 Celery Task Scheduling
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
IGNY8 uses **Celery Beat** to schedule two distinct but related systems:
|
||||
|
||||
1. **Automation Pipeline** - Processes content through 7 stages (Keywords → Published)
|
||||
2. **Publishing Scheduler** - Schedules and publishes approved content to WordPress
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ CELERY BEAT SCHEDULER │
|
||||
│ (Persistent Schedule Store) │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────┼─────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Every Hour │ │ Every Hour │ │ Every 5 min │
|
||||
│ at :05 │ │ at :00 │ │ │
|
||||
└───────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ check_ │ │schedule_approved│ │process_scheduled│
|
||||
│ scheduled_ │ │ _content │ │ _publications │
|
||||
│ automations │ │ │ │ │
|
||||
└───────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Automation │ │ Content gets │ │ Publishes to │
|
||||
│ Pipeline │ │ scheduled_ │ │ WordPress via │
|
||||
│ (7 stages) │ │ publish_at set │ │ API │
|
||||
└───────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Automation Scheduling
|
||||
|
||||
### Celery Task Configuration
|
||||
|
||||
| Task Name | Schedule | Purpose |
|
||||
|-----------|----------|---------|
|
||||
| `automation.check_scheduled_automations` | Every hour at `:05` | Check if any automation configs should run |
|
||||
| `automation.check_test_triggers` | Every minute | Check for admin test triggers (exits early if none) |
|
||||
|
||||
### How It Works
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ AUTOMATION SCHEDULING FLOW │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Celery Beat AutomationConfig Result
|
||||
│ │ │
|
||||
│ (Every hour at :05) │ │
|
||||
▼ │ │
|
||||
┌─────────────┐ │ │
|
||||
│ 02:05 check │ ─── Hour ────► scheduled_hour == 2? │
|
||||
│ 03:05 check │ ─── Hour ────► scheduled_hour == 3? │
|
||||
│ 04:05 check │ ─── Hour ────► scheduled_hour == 4? │
|
||||
│ ... │ │ │
|
||||
└─────────────┘ │ │
|
||||
▼ │
|
||||
┌────────────────┐ │
|
||||
│ Match Found? │ │
|
||||
└────────────────┘ │
|
||||
│ │ │
|
||||
Yes No │
|
||||
│ │ │
|
||||
▼ └─► Skip │
|
||||
┌────────────────┐ │
|
||||
│ Check Blocks: │ │
|
||||
│ • 23hr block │ │
|
||||
│ • Already │ │
|
||||
│ running? │ │
|
||||
└────────────────┘ │
|
||||
│ │
|
||||
Pass │
|
||||
│ │
|
||||
▼ │
|
||||
┌────────────────┐ │
|
||||
│ Start Run │ ────────────────────────► Run Started
|
||||
│ • Set last_run │
|
||||
│ • Queue task │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
### Hourly Matching Logic
|
||||
|
||||
The scheduler runs at `:05` of every hour and checks if `scheduled_hour == current_hour`:
|
||||
|
||||
| Celery Runs At | Checks Hour | Example Config |
|
||||
|----------------|-------------|----------------|
|
||||
| `02:05` | `2` | `scheduled_time = 02:00` → Matches |
|
||||
| `03:05` | `3` | `scheduled_time = 03:00` → Matches |
|
||||
| `14:05` | `14` | `scheduled_time = 14:00` (2 PM) → Matches |
|
||||
|
||||
**Note:** Users select hour only (1-12 with AM/PM in UI), stored as `HH:00` format. The `:05` offset ensures the check happens after the configured hour begins.
|
||||
|
||||
### Test Mode (Admin Feature)
|
||||
|
||||
Admins can trigger automations without waiting for the hourly schedule:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ TEST TRIGGER FLOW │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Admin Action Celery (Every Minute) Result
|
||||
│ │ │
|
||||
▼ │ │
|
||||
┌─────────────────┐ │ │
|
||||
│ Enable test mode│ │ │
|
||||
│ Set trigger time│ │ │
|
||||
│ (or "now") │ │ │
|
||||
└─────────────────┘ │ │
|
||||
│ │ │
|
||||
└───────────────────────────────────▼ │
|
||||
┌─────────────────┐ │
|
||||
│check_test_ │ │
|
||||
│triggers task │ │
|
||||
└─────────────────┘ │
|
||||
│ │
|
||||
▼ │
|
||||
┌─────────────────┐ │
|
||||
│test_trigger_at │ │
|
||||
│<= now? │ │
|
||||
└─────────────────┘ │
|
||||
│ │ │
|
||||
Yes No │
|
||||
│ └─► Wait │
|
||||
▼ │
|
||||
┌─────────────────┐ │
|
||||
│Start test run │──────────────► Run Started
|
||||
│trigger_type= │ (type='test')
|
||||
│'test' │
|
||||
│Clear trigger_at │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
**Test mode fields:**
|
||||
- `test_mode_enabled` - Enable test functionality for this config
|
||||
- `test_trigger_at` - When to trigger (set to `now` for immediate)
|
||||
|
||||
**Admin actions:**
|
||||
- 🧪 **Trigger test run** - Sets both fields and triggers at next minute
|
||||
- 🧹 **Clear test mode** - Disables test mode and clears trigger
|
||||
|
||||
### Frequency Rules
|
||||
|
||||
| Frequency | When It Runs | Additional Condition |
|
||||
|-----------|--------------|----------------------|
|
||||
| `daily` | Every day | `scheduled_hour == current_hour` |
|
||||
| `weekly` | Mondays only | `weekday() == 0` + hour match |
|
||||
| `monthly` | 1st of month only | `day == 1` + hour match |
|
||||
|
||||
### Duplicate Prevention (23-Hour Block)
|
||||
|
||||
After a successful run, the config won't run again for **23 hours**:
|
||||
|
||||
```python
|
||||
if config.last_run_at:
|
||||
time_since_last_run = now - config.last_run_at
|
||||
if time_since_last_run < timedelta(hours=23):
|
||||
# SKIP - already ran recently
|
||||
```
|
||||
|
||||
### Schedule Change Behavior
|
||||
|
||||
When `scheduled_time`, `frequency`, or `is_enabled` changes:
|
||||
- `last_run_at` is reset to `None`
|
||||
- `next_run_at` is recalculated
|
||||
- Automation becomes eligible immediately at the next hourly check
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Current time: 12:30
|
||||
You change scheduled_time from 02:00 → 12:00 (12 PM)
|
||||
Hour 12 was already checked at 12:05
|
||||
→ Automation will run tomorrow at 12:05 (daily) or next valid day (weekly/monthly)
|
||||
```
|
||||
|
||||
**Pro tip:** Use test mode to trigger immediately after schedule changes.
|
||||
|
||||
---
|
||||
|
||||
## 2. Publishing Scheduling
|
||||
|
||||
### Celery Task Configuration
|
||||
|
||||
| Task Name | Schedule | Purpose |
|
||||
|-----------|----------|---------|
|
||||
| `publishing.schedule_approved_content` | Every hour at `:00` | Schedule approved content for future publishing |
|
||||
| `publishing.process_scheduled_publications` | Every 5 min | Publish content where `scheduled_publish_at <= now` |
|
||||
|
||||
### Publishing Flow
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ PUBLISHING SCHEDULING FLOW │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
EVERY HOUR (:00) EVERY 5 MINUTES
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────────────┐ ┌────────────────────────┐
|
||||
│schedule_approved_content│ │process_scheduled_ │
|
||||
│ │ │publications │
|
||||
└────────────────────────┘ └────────────────────────┘
|
||||
│ │
|
||||
▼ │
|
||||
┌────────────────────────┐ │
|
||||
│ For each Site with │ │
|
||||
│ auto_publish_enabled: │ │
|
||||
│ │ │
|
||||
│ 1. Find approved │ │
|
||||
│ content │ │
|
||||
│ 2. Calculate slots │ │
|
||||
│ based on settings │ │
|
||||
│ 3. Set scheduled_ │ │
|
||||
│ publish_at │ │
|
||||
│ 4. Set site_status= │ │
|
||||
│ 'scheduled' │ │
|
||||
└────────────────────────┘ │
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────────────────────────────────────┐
|
||||
│ DATABASE │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ Content Table │ │
|
||||
│ │ • status = 'approved' │ │
|
||||
│ │ • site_status = 'scheduled' │ │
|
||||
│ │ • scheduled_publish_at = <datetime> │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
└───────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────┐
|
||||
│ process_scheduled_ │
|
||||
│ publications │
|
||||
│ │
|
||||
│ WHERE: │
|
||||
│ • site_status = │
|
||||
│ 'scheduled' │
|
||||
│ • scheduled_publish_at │
|
||||
│ <= NOW │
|
||||
└────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────┐
|
||||
│ Publish to WordPress │
|
||||
│ via PublisherService │
|
||||
│ │
|
||||
│ On Success: │
|
||||
│ • site_status = │
|
||||
│ 'published' │
|
||||
│ • Set wp_post_id │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
### Scheduling Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `immediate` | Content scheduled for `now`, picked up within 5 minutes |
|
||||
| `time_slots` | Content scheduled at specific times (e.g., 9am, 2pm, 6pm) |
|
||||
| `stagger` | Content spread evenly across publish hours |
|
||||
|
||||
### Publishing Limits
|
||||
|
||||
| Limit | Description |
|
||||
|-------|-------------|
|
||||
| `daily_publish_limit` | Max posts per day |
|
||||
| `weekly_publish_limit` | Max posts per week |
|
||||
| `monthly_publish_limit` | Max posts per month |
|
||||
| `queue_limit` | Max items to schedule at once (default: 100) |
|
||||
|
||||
---
|
||||
|
||||
## 3. System Relationship
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ COMPLETE CONTENT LIFECYCLE │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
AUTOMATION PIPELINE PUBLISHING PIPELINE
|
||||
(Every 15 min check) (Hourly + Every 5 min)
|
||||
│ │
|
||||
▼ │
|
||||
┌─────────────────┐ │
|
||||
│ Stage 1-6 │ │
|
||||
│ Keywords → │ │
|
||||
│ Content Created │ │
|
||||
└─────────────────┘ │
|
||||
│ │
|
||||
▼ │
|
||||
┌─────────────────┐ │
|
||||
│ Stage 7 │ │
|
||||
│ Auto-Approval │ status='approved' │
|
||||
│ (if enabled) │ ─────────────────────────────────► │
|
||||
└─────────────────┘ │
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│schedule_approved_ │
|
||||
│content (hourly) │
|
||||
│ │
|
||||
│ Sets: │
|
||||
│ • scheduled_publish │
|
||||
│ _at │
|
||||
│ • site_status = │
|
||||
│ 'scheduled' │
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│process_scheduled_ │
|
||||
│publications (5 min) │
|
||||
│ │
|
||||
│ Publishes to WP │
|
||||
│ • site_status = │
|
||||
│ 'published' │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Celery Beat Schedule Summary
|
||||
|
||||
| Task | Schedule | Crontab |
|
||||
|------|----------|---------|
|
||||
| `automation.check_scheduled_automations` | Every 15 min | `minute='0,15,30,45'` |
|
||||
| `publishing.schedule_approved_content` | Hourly | `minute=0` |
|
||||
| `publishing.process_scheduled_publications` | Every 5 min | `minute='*/5'` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Key Configuration Tables
|
||||
|
||||
### AutomationConfig
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| `is_enabled` | Boolean | Enable/disable scheduling |
|
||||
| `frequency` | Choice | `daily`, `weekly`, `monthly` |
|
||||
| `scheduled_time` | Time | When to run (e.g., `02:00`) |
|
||||
| `last_run_at` | DateTime | Last successful run (23hr block) |
|
||||
| `next_run_at` | DateTime | Calculated next run time |
|
||||
|
||||
### PublishingSettings
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| `auto_publish_enabled` | Boolean | Enable auto-scheduling |
|
||||
| `scheduling_mode` | Choice | `immediate`, `time_slots`, `stagger` |
|
||||
| `publish_days` | Array | `['mon', 'tue', ...]` |
|
||||
| `publish_time_slots` | Array | `['09:00', '14:00', '18:00']` |
|
||||
| `daily_publish_limit` | Integer | Max posts/day |
|
||||
|
||||
---
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
### Automation Didn't Run
|
||||
|
||||
| Check | Solution |
|
||||
|-------|----------|
|
||||
| `is_enabled = False` | Enable the automation |
|
||||
| Time not in current window | Wait for next window or change time |
|
||||
| `last_run_at` within 23hrs | Wait for 23hr block to expire |
|
||||
| Another run in progress | Wait for current run to complete |
|
||||
| Config updated after window | Schedule moved to next occurrence |
|
||||
|
||||
### Content Not Publishing
|
||||
|
||||
| Check | Solution |
|
||||
|-------|----------|
|
||||
| `auto_publish_enabled = False` | Enable in PublishingSettings |
|
||||
| Content `status != 'approved'` | Approve content first |
|
||||
| No `wp_api_key` on Site | Configure WordPress integration |
|
||||
| Daily/weekly limit reached | Wait for limit reset or increase |
|
||||
|
||||
---
|
||||
|
||||
## 7. Logs
|
||||
|
||||
Monitor these logs for scheduling issues:
|
||||
|
||||
```bash
|
||||
# Automation scheduling
|
||||
docker logs igny8_celery_worker 2>&1 | grep "AutomationTask"
|
||||
|
||||
# Publishing scheduling
|
||||
docker logs igny8_celery_worker 2>&1 | grep "schedule_approved_content\|process_scheduled"
|
||||
```
|
||||
@@ -36,6 +36,11 @@ import {
|
||||
StageConfig,
|
||||
DAYS_OF_WEEK,
|
||||
FREQUENCY_OPTIONS,
|
||||
HOUR_OPTIONS,
|
||||
AMPM_OPTIONS,
|
||||
toTime24,
|
||||
fromTime24,
|
||||
formatTime,
|
||||
calculateTotalBudget,
|
||||
} from '../../services/unifiedSettings.api';
|
||||
|
||||
@@ -185,11 +190,64 @@ export default function AIAutomationSettings({ siteId }: AIAutomationSettingsPro
|
||||
}
|
||||
};
|
||||
|
||||
// Reset to defaults
|
||||
const handleReset = () => {
|
||||
loadSettings();
|
||||
loadImageSettings();
|
||||
toastRef.current.info('Settings reset to last saved values');
|
||||
// Reset to defaults - fetches from backend DefaultAutomationConfig
|
||||
const handleReset = async () => {
|
||||
if (!settings) return;
|
||||
|
||||
try {
|
||||
// Fetch defaults from backend
|
||||
// fetchAPI automatically extracts .data from the unified response format
|
||||
const defaults = await fetchAPI('/v1/integration/settings/defaults/');
|
||||
|
||||
if (!defaults || !defaults.automation) {
|
||||
throw new Error('Invalid defaults response format');
|
||||
}
|
||||
|
||||
// Reset automation settings
|
||||
const defaultSettings: UnifiedSiteSettings = {
|
||||
...settings,
|
||||
automation: {
|
||||
enabled: defaults.automation.enabled,
|
||||
frequency: defaults.automation.frequency,
|
||||
time: defaults.automation.time,
|
||||
last_run_at: null,
|
||||
next_run_at: null,
|
||||
},
|
||||
stages: settings.stages.map(s => {
|
||||
const stageDefault = defaults.stages?.find((d: { number: number }) => d.number === s.number);
|
||||
return {
|
||||
...s,
|
||||
enabled: stageDefault?.enabled ?? true,
|
||||
batch_size: stageDefault?.batch_size ?? (s.number === 1 ? 50 : s.number === 3 ? 20 : 1),
|
||||
per_run_limit: stageDefault?.per_run_limit ?? 0,
|
||||
use_testing: stageDefault?.use_testing ?? false,
|
||||
budget_pct: s.has_ai ? (stageDefault?.budget_pct ?? 0) : undefined,
|
||||
};
|
||||
}),
|
||||
delays: {
|
||||
within_stage: defaults.delays?.within_stage ?? 3,
|
||||
between_stage: defaults.delays?.between_stage ?? 5,
|
||||
},
|
||||
publishing: {
|
||||
...settings.publishing,
|
||||
auto_approval_enabled: defaults.publishing?.auto_approval_enabled ?? false,
|
||||
auto_publish_enabled: defaults.publishing?.auto_publish_enabled ?? false,
|
||||
publish_days: defaults.publishing?.publish_days ?? ['mon', 'tue', 'wed', 'thu', 'fri'],
|
||||
time_slots: defaults.publishing?.time_slots ?? ['09:00', '14:00', '18:00'],
|
||||
},
|
||||
};
|
||||
|
||||
setSettings(defaultSettings);
|
||||
|
||||
// NOTE: Image settings (style, max_images) are NOT reset here.
|
||||
// They are loaded from /v1/account/settings/ai/ (SystemAISettings/AccountSettings)
|
||||
// which is a separate global setting managed in the backend admin.
|
||||
|
||||
toastRef.current.info('Settings reset to defaults. Click Save to apply. (Image settings unchanged)');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch defaults:', error);
|
||||
toastRef.current.error('Failed to fetch default settings from server.');
|
||||
}
|
||||
};
|
||||
|
||||
// Update automation settings
|
||||
@@ -330,19 +388,39 @@ export default function AIAutomationSettings({ siteId }: AIAutomationSettingsPro
|
||||
<SelectDropdown
|
||||
options={FREQUENCY_OPTIONS.map(f => ({ value: f.value, label: f.label }))}
|
||||
value={settings.automation.frequency}
|
||||
onChange={(value) => updateAutomation({ frequency: value as 'hourly' | 'daily' | 'weekly' })}
|
||||
onChange={(value) => updateAutomation({ frequency: value as 'daily' | 'weekly' | 'monthly' })}
|
||||
disabled={!settings.automation.enabled}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2">Run Time</Label>
|
||||
<InputField
|
||||
type="time"
|
||||
value={settings.automation.time}
|
||||
onChange={(e) => updateAutomation({ time: e.target.value })}
|
||||
disabled={!settings.automation.enabled}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<SelectDropdown
|
||||
options={HOUR_OPTIONS}
|
||||
value={fromTime24(settings.automation.time).hour}
|
||||
onChange={(value) => {
|
||||
const { ampm } = fromTime24(settings.automation.time);
|
||||
updateAutomation({ time: toTime24(value, ampm) });
|
||||
}}
|
||||
disabled={!settings.automation.enabled}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<SelectDropdown
|
||||
options={AMPM_OPTIONS}
|
||||
value={fromTime24(settings.automation.time).ampm}
|
||||
onChange={(value) => {
|
||||
const { hour } = fromTime24(settings.automation.time);
|
||||
updateAutomation({ time: toTime24(hour, value) });
|
||||
}}
|
||||
disabled={!settings.automation.enabled}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -352,7 +430,7 @@ export default function AIAutomationSettings({ siteId }: AIAutomationSettingsPro
|
||||
{settings.automation.enabled && settings.automation.next_run_at ? (
|
||||
<span>Next run: {new Date(settings.automation.next_run_at).toLocaleString()}</span>
|
||||
) : (
|
||||
<span>Runs {settings.automation.frequency} at {settings.automation.time}</span>
|
||||
<span>Runs {settings.automation.frequency} at {formatTime(settings.automation.time)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -828,11 +906,11 @@ export default function AIAutomationSettings({ siteId }: AIAutomationSettingsPro
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Check Frequency</span>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">Every 15 minutes</p>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">Hourly</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Check Times</span>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">:00, :15, :30, :45</p>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Check Time</span>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">5 minutes past each hour</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Timezone</span>
|
||||
@@ -840,7 +918,7 @@ export default function AIAutomationSettings({ siteId }: AIAutomationSettingsPro
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
The scheduler checks for due automations every 15 minutes. Your scheduled time will trigger within its 15-minute window (e.g., 14:35 triggers at the 14:30 check). Automations only run once per day — if already run, the next run is tomorrow. All times are UTC.
|
||||
The scheduler runs at 5 minutes past each hour. Select the hour you want your automation to run — for example, selecting 2 PM means it will run at 2:05 PM UTC. Automations only run once per day. If it has already run today, the next run will be tomorrow (or the next scheduled day for weekly/monthly).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,8 +32,8 @@ export interface UnifiedSiteSettings {
|
||||
site_name: string;
|
||||
automation: {
|
||||
enabled: boolean;
|
||||
frequency: 'hourly' | 'daily' | 'weekly';
|
||||
time: string; // HH:MM format
|
||||
frequency: 'daily' | 'weekly' | 'monthly';
|
||||
time: string; // HH:00 format (hour only)
|
||||
last_run_at: string | null;
|
||||
next_run_at: string | null;
|
||||
};
|
||||
@@ -66,8 +66,8 @@ export interface UnifiedSiteSettings {
|
||||
export interface UpdateUnifiedSettingsRequest {
|
||||
automation?: {
|
||||
enabled?: boolean;
|
||||
frequency?: 'hourly' | 'daily' | 'weekly';
|
||||
time?: string;
|
||||
frequency?: 'daily' | 'weekly' | 'monthly';
|
||||
time?: string; // HH:00 format (hour only)
|
||||
};
|
||||
stages?: Array<{
|
||||
number: number;
|
||||
@@ -136,20 +136,65 @@ export const DAYS_OF_WEEK = [
|
||||
* Frequency options for automation
|
||||
*/
|
||||
export const FREQUENCY_OPTIONS = [
|
||||
{ value: 'hourly', label: 'Hourly' },
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Hour options (1-12) for time selection
|
||||
*/
|
||||
export const HOUR_OPTIONS = [
|
||||
{ value: '12', label: '12' },
|
||||
{ value: '1', label: '1' },
|
||||
{ value: '2', label: '2' },
|
||||
{ value: '3', label: '3' },
|
||||
{ value: '4', label: '4' },
|
||||
{ value: '5', label: '5' },
|
||||
{ value: '6', label: '6' },
|
||||
{ value: '7', label: '7' },
|
||||
{ value: '8', label: '8' },
|
||||
{ value: '9', label: '9' },
|
||||
{ value: '10', label: '10' },
|
||||
{ value: '11', label: '11' },
|
||||
];
|
||||
|
||||
/**
|
||||
* AM/PM options
|
||||
*/
|
||||
export const AMPM_OPTIONS = [
|
||||
{ value: 'AM', label: 'AM' },
|
||||
{ value: 'PM', label: 'PM' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Convert hour (1-12) and AM/PM to 24-hour time string (HH:00)
|
||||
*/
|
||||
export function toTime24(hour: string, ampm: string): string {
|
||||
let h = parseInt(hour);
|
||||
if (ampm === 'PM' && h !== 12) h += 12;
|
||||
if (ampm === 'AM' && h === 12) h = 0;
|
||||
return `${h.toString().padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert 24-hour time string to hour (1-12) and AM/PM
|
||||
*/
|
||||
export function fromTime24(time: string): { hour: string; ampm: string } {
|
||||
const [hours] = time.split(':');
|
||||
let h = parseInt(hours);
|
||||
const ampm = h >= 12 ? 'PM' : 'AM';
|
||||
if (h > 12) h -= 12;
|
||||
if (h === 0) h = 12;
|
||||
return { hour: h.toString(), ampm };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time for display
|
||||
*/
|
||||
export function formatTime(time: string): string {
|
||||
const [hours, minutes] = time.split(':');
|
||||
const h = parseInt(hours);
|
||||
const ampm = h >= 12 ? 'PM' : 'AM';
|
||||
const displayHour = h > 12 ? h - 12 : h === 0 ? 12 : h;
|
||||
return `${displayHour}:${minutes} ${ampm}`;
|
||||
const { hour, ampm } = fromTime24(time);
|
||||
return `${hour}:00 ${ampm}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user