From ebc4088ccb9fe9efb822ccecd8957fa3772a4061 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 18 Jan 2026 15:03:01 +0000 Subject: [PATCH] autmation final reaftocrs and setitgns dafautls --- backend/igny8_core/api/unified_settings.py | 101 ++++- .../igny8_core/business/automation/admin.py | 288 +++++++++++-- .../migrations/0010_add_test_mode_fields.py | 27 ++ .../0011_add_default_automation_config.py | 63 +++ .../0012_add_publishing_image_defaults.py | 73 ++++ .../igny8_core/business/automation/models.py | 146 +++++++ .../igny8_core/business/automation/tasks.py | 103 +++-- .../integration/services/defaults_service.py | 64 ++- backend/igny8_core/celery.py | 6 +- .../igny8_core/modules/integration/urls.py | 5 +- backend/igny8_core/settings.py | 1 + .../AUTOMATION-AND-PUBLISHING-SCHEDULING.md | 403 ++++++++++++++++++ .../src/pages/Sites/AIAutomationSettings.tsx | 112 ++++- frontend/src/services/unifiedSettings.api.ts | 65 ++- 14 files changed, 1367 insertions(+), 90 deletions(-) create mode 100644 backend/igny8_core/business/automation/migrations/0010_add_test_mode_fields.py create mode 100644 backend/igny8_core/business/automation/migrations/0011_add_default_automation_config.py create mode 100644 backend/igny8_core/business/automation/migrations/0012_add_publishing_image_defaults.py create mode 100644 docs/40-WORKFLOWS/AUTOMATION-AND-PUBLISHING-SCHEDULING.md diff --git a/backend/igny8_core/api/unified_settings.py b/backend/igny8_core/api/unified_settings.py index b9f7c0fe..2611108e 100644 --- a/backend/igny8_core/api/unified_settings.py +++ b/backend/igny8_core/api/unified_settings.py @@ -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) diff --git a/backend/igny8_core/business/automation/admin.py b/backend/igny8_core/business/automation/admin.py index 3306585d..7203f5c0 100644 --- a/backend/igny8_core/business/automation/admin.py +++ b/backend/igny8_core/business/automation/admin.py @@ -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): diff --git a/backend/igny8_core/business/automation/migrations/0010_add_test_mode_fields.py b/backend/igny8_core/business/automation/migrations/0010_add_test_mode_fields.py new file mode 100644 index 00000000..5bd75c34 --- /dev/null +++ b/backend/igny8_core/business/automation/migrations/0010_add_test_mode_fields.py @@ -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'), + ), + ] diff --git a/backend/igny8_core/business/automation/migrations/0011_add_default_automation_config.py b/backend/igny8_core/business/automation/migrations/0011_add_default_automation_config.py new file mode 100644 index 00000000..b527b2b8 --- /dev/null +++ b/backend/igny8_core/business/automation/migrations/0011_add_default_automation_config.py @@ -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', + }, + ), + ] diff --git a/backend/igny8_core/business/automation/migrations/0012_add_publishing_image_defaults.py b/backend/igny8_core/business/automation/migrations/0012_add_publishing_image_defaults.py new file mode 100644 index 00000000..8811f446 --- /dev/null +++ b/backend/igny8_core/business/automation/migrations/0012_add_publishing_image_defaults.py @@ -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), + ] diff --git a/backend/igny8_core/business/automation/models.py b/backend/igny8_core/business/automation/models.py index 49280ca3..51638e91 100644 --- a/backend/igny8_core/business/automation/models.py +++ b/backend/igny8_core/business/automation/models.py @@ -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,10 +199,40 @@ 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): @@ -96,6 +241,7 @@ class AutomationRun(models.Model): TRIGGER_TYPE_CHOICES = [ ('manual', 'Manual'), ('scheduled', 'Scheduled'), + ('test', 'Test'), ] STATUS_CHOICES = [ diff --git a/backend/igny8_core/business/automation/tasks.py b/backend/igny8_core/business/automation/tasks.py index 857f1268..1aa6e84e 100644 --- a/backend/igny8_core/business/automation/tasks.py +++ b/backend/igny8_core/business/automation/tasks.py @@ -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): """ diff --git a/backend/igny8_core/business/integration/services/defaults_service.py b/backend/igny8_core/business/integration/services/defaults_service.py index 3d807d7a..89371cca 100644 --- a/backend/igny8_core/business/integration/services/defaults_service.py +++ b/backend/igny8_core/business/integration/services/defaults_service.py @@ -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, diff --git a/backend/igny8_core/celery.py b/backend/igny8_core/celery.py index 24858d31..3ca7469f 100644 --- a/backend/igny8_core/celery.py +++ b/backend/igny8_core/celery.py @@ -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': { diff --git a/backend/igny8_core/modules/integration/urls.py b/backend/igny8_core/modules/integration/urls.py index 062e9b08..c9e69e32 100644 --- a/backend/igny8_core/modules/integration/urls.py +++ b/backend/igny8_core/modules/integration/urls.py @@ -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//publishing-settings/', publishing_settings_viewset, name='publishing-settings'), diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py index 2358c425..36c679b8 100644 --- a/backend/igny8_core/settings.py +++ b/backend/igny8_core/settings.py @@ -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/"}, ], diff --git a/docs/40-WORKFLOWS/AUTOMATION-AND-PUBLISHING-SCHEDULING.md b/docs/40-WORKFLOWS/AUTOMATION-AND-PUBLISHING-SCHEDULING.md new file mode 100644 index 00000000..1babfd7e --- /dev/null +++ b/docs/40-WORKFLOWS/AUTOMATION-AND-PUBLISHING-SCHEDULING.md @@ -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 = │ │ + │ └─────────────────────────────────────────┘ │ + └───────────────────────────────────────────────┘ + │ + ▼ + ┌────────────────────────┐ + │ 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" +``` diff --git a/frontend/src/pages/Sites/AIAutomationSettings.tsx b/frontend/src/pages/Sites/AIAutomationSettings.tsx index 75ab5eee..8665e67e 100644 --- a/frontend/src/pages/Sites/AIAutomationSettings.tsx +++ b/frontend/src/pages/Sites/AIAutomationSettings.tsx @@ -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 ({ 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" />
- updateAutomation({ time: e.target.value })} - disabled={!settings.automation.enabled} - /> +
+
+ { + const { ampm } = fromTime24(settings.automation.time); + updateAutomation({ time: toTime24(value, ampm) }); + }} + disabled={!settings.automation.enabled} + className="w-full" + /> +
+
+ { + const { hour } = fromTime24(settings.automation.time); + updateAutomation({ time: toTime24(hour, value) }); + }} + disabled={!settings.automation.enabled} + className="w-full" + /> +
+
@@ -352,7 +430,7 @@ export default function AIAutomationSettings({ siteId }: AIAutomationSettingsPro {settings.automation.enabled && settings.automation.next_run_at ? ( Next run: {new Date(settings.automation.next_run_at).toLocaleString()} ) : ( - Runs {settings.automation.frequency} at {settings.automation.time} + Runs {settings.automation.frequency} at {formatTime(settings.automation.time)} )} @@ -828,11 +906,11 @@ export default function AIAutomationSettings({ siteId }: AIAutomationSettingsPro
Check Frequency -

Every 15 minutes

+

Hourly

- Check Times -

:00, :15, :30, :45

+ Check Time +

5 minutes past each hour

Timezone @@ -840,7 +918,7 @@ export default function AIAutomationSettings({ siteId }: AIAutomationSettingsPro

- 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).

diff --git a/frontend/src/services/unifiedSettings.api.ts b/frontend/src/services/unifiedSettings.api.ts index 1be0ad17..af2b4dbb 100644 --- a/frontend/src/services/unifiedSettings.api.ts +++ b/frontend/src/services/unifiedSettings.api.ts @@ -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}`; } /**