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 import viewsets, status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.views import APIView
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
|
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.response import success_response, error_response
|
||||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||||
from igny8_core.auth.models import Site
|
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.integration.models import PublishingSettings
|
||||||
from igny8_core.business.billing.models import AIModelConfig
|
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']
|
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}")
|
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 django.contrib import messages
|
||||||
from unfold.admin import ModelAdmin
|
from unfold.admin import ModelAdmin
|
||||||
from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin
|
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.admin import ExportMixin
|
||||||
from import_export import resources
|
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):
|
class AutomationConfigResource(resources.ModelResource):
|
||||||
"""Resource class for exporting Automation Configs"""
|
"""Resource class for exporting Automation Configs"""
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -24,26 +145,120 @@ class AutomationConfigResource(resources.ModelResource):
|
|||||||
@admin.register(AutomationConfig)
|
@admin.register(AutomationConfig)
|
||||||
class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
||||||
resource_class = AutomationConfigResource
|
resource_class = AutomationConfigResource
|
||||||
list_display = ('site', 'is_enabled', 'frequency', 'scheduled_time', 'next_scheduled_run', 'last_run_at')
|
list_display = ('site', 'is_enabled', 'frequency', 'scheduled_time', 'next_scheduled_run', 'last_run_at', 'test_mode_status')
|
||||||
list_filter = ('is_enabled', 'frequency')
|
list_filter = ('is_enabled', 'frequency', 'test_mode_enabled')
|
||||||
search_fields = ('site__domain',)
|
search_fields = ('site__domain',)
|
||||||
|
readonly_fields = ('test_trigger_at_display',)
|
||||||
actions = [
|
actions = [
|
||||||
'bulk_enable',
|
'bulk_enable',
|
||||||
'bulk_disable',
|
'bulk_disable',
|
||||||
'bulk_update_frequency',
|
'bulk_update_frequency',
|
||||||
'bulk_update_delays',
|
'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):
|
def next_scheduled_run(self, obj):
|
||||||
"""
|
"""
|
||||||
Calculate the next scheduled run based on:
|
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)
|
- Frequency (daily, weekly, monthly)
|
||||||
- Scheduled time
|
- Scheduled hour (user selects hour only, stored as HH:00)
|
||||||
- 23-hour block after last_run_at
|
- 23-hour block after last_run_at
|
||||||
|
|
||||||
Celery checks window at :00 for :00-:14, at :15 for :15-:29, etc.
|
SIMPLIFIED: Celery runs at :05 of every hour and checks if scheduled_hour == current_hour
|
||||||
So scheduled_time 12:12 will be picked up at the 12:00 check.
|
|
||||||
"""
|
"""
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -53,25 +268,20 @@ class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
|||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
scheduled_hour = obj.scheduled_time.hour
|
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
|
# 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():
|
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(
|
candidate = now.replace(
|
||||||
hour=scheduled_hour,
|
hour=scheduled_hour,
|
||||||
minute=window_start_minute,
|
minute=5,
|
||||||
second=0,
|
second=0,
|
||||||
microsecond=0
|
microsecond=0
|
||||||
)
|
)
|
||||||
|
|
||||||
if obj.frequency == 'daily':
|
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:
|
if candidate <= now:
|
||||||
candidate += timedelta(days=1)
|
candidate += timedelta(days=1)
|
||||||
elif obj.frequency == 'weekly':
|
elif obj.frequency == 'weekly':
|
||||||
@@ -81,7 +291,7 @@ class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
|||||||
# Today is Monday - check if time passed
|
# Today is Monday - check if time passed
|
||||||
candidate = now.replace(
|
candidate = now.replace(
|
||||||
hour=scheduled_hour,
|
hour=scheduled_hour,
|
||||||
minute=window_start_minute,
|
minute=5,
|
||||||
second=0,
|
second=0,
|
||||||
microsecond=0
|
microsecond=0
|
||||||
)
|
)
|
||||||
@@ -92,7 +302,7 @@ class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
|||||||
candidate = now + timedelta(days=days_until_monday)
|
candidate = now + timedelta(days=days_until_monday)
|
||||||
candidate = candidate.replace(
|
candidate = candidate.replace(
|
||||||
hour=scheduled_hour,
|
hour=scheduled_hour,
|
||||||
minute=window_start_minute,
|
minute=5,
|
||||||
second=0,
|
second=0,
|
||||||
microsecond=0
|
microsecond=0
|
||||||
)
|
)
|
||||||
@@ -101,7 +311,7 @@ class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
|||||||
candidate = now.replace(
|
candidate = now.replace(
|
||||||
day=1,
|
day=1,
|
||||||
hour=scheduled_hour,
|
hour=scheduled_hour,
|
||||||
minute=window_start_minute,
|
minute=5,
|
||||||
second=0,
|
second=0,
|
||||||
microsecond=0
|
microsecond=0
|
||||||
)
|
)
|
||||||
@@ -122,10 +332,10 @@ class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
|||||||
if next_celery_pickup < earliest_eligible:
|
if next_celery_pickup < earliest_eligible:
|
||||||
# Blocked - need to skip to next cycle
|
# Blocked - need to skip to next cycle
|
||||||
if obj.frequency == 'daily':
|
if obj.frequency == 'daily':
|
||||||
# Move to next day's window
|
# Move to next day
|
||||||
next_celery_pickup = earliest_eligible.replace(
|
next_celery_pickup = earliest_eligible.replace(
|
||||||
hour=scheduled_hour,
|
hour=scheduled_hour,
|
||||||
minute=window_start_minute,
|
minute=5,
|
||||||
second=0,
|
second=0,
|
||||||
microsecond=0
|
microsecond=0
|
||||||
)
|
)
|
||||||
@@ -137,7 +347,7 @@ class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
|||||||
if days_until_monday == 0:
|
if days_until_monday == 0:
|
||||||
test_candidate = earliest_eligible.replace(
|
test_candidate = earliest_eligible.replace(
|
||||||
hour=scheduled_hour,
|
hour=scheduled_hour,
|
||||||
minute=window_start_minute,
|
minute=5,
|
||||||
second=0,
|
second=0,
|
||||||
microsecond=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 = earliest_eligible + timedelta(days=days_until_monday)
|
||||||
next_celery_pickup = next_celery_pickup.replace(
|
next_celery_pickup = next_celery_pickup.replace(
|
||||||
hour=scheduled_hour,
|
hour=scheduled_hour,
|
||||||
minute=window_start_minute,
|
minute=5,
|
||||||
second=0,
|
second=0,
|
||||||
microsecond=0
|
microsecond=0
|
||||||
)
|
)
|
||||||
@@ -155,7 +365,7 @@ class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
|||||||
next_celery_pickup = earliest_eligible.replace(
|
next_celery_pickup = earliest_eligible.replace(
|
||||||
day=1,
|
day=1,
|
||||||
hour=scheduled_hour,
|
hour=scheduled_hour,
|
||||||
minute=window_start_minute,
|
minute=5,
|
||||||
second=0,
|
second=0,
|
||||||
microsecond=0
|
microsecond=0
|
||||||
)
|
)
|
||||||
@@ -170,6 +380,34 @@ class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
|||||||
|
|
||||||
next_scheduled_run.short_description = 'Next Scheduled Run'
|
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):
|
def bulk_enable(self, request, queryset):
|
||||||
"""Enable selected automation configs"""
|
"""Enable selected automation configs"""
|
||||||
updated = queryset.update(is_enabled=True)
|
updated = queryset.update(is_enabled=True)
|
||||||
@@ -194,9 +432,9 @@ class AutomationConfigAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
|||||||
return
|
return
|
||||||
|
|
||||||
FREQUENCY_CHOICES = [
|
FREQUENCY_CHOICES = [
|
||||||
('hourly', 'Hourly'),
|
|
||||||
('daily', 'Daily'),
|
('daily', 'Daily'),
|
||||||
('weekly', 'Weekly'),
|
('weekly', 'Weekly'),
|
||||||
|
('monthly', 'Monthly'),
|
||||||
]
|
]
|
||||||
|
|
||||||
class FrequencyForm(forms.Form):
|
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
|
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):
|
class AutomationConfig(models.Model):
|
||||||
"""Per-site automation configuration"""
|
"""Per-site automation configuration"""
|
||||||
|
|
||||||
@@ -74,6 +185,10 @@ class AutomationConfig(models.Model):
|
|||||||
last_run_at = models.DateTimeField(null=True, blank=True)
|
last_run_at = models.DateTimeField(null=True, blank=True)
|
||||||
next_run_at = models.DateTimeField(null=True, blank=True, help_text="Calculated based on frequency")
|
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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
@@ -84,10 +199,40 @@ class AutomationConfig(models.Model):
|
|||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['is_enabled', 'next_run_at']),
|
models.Index(fields=['is_enabled', 'next_run_at']),
|
||||||
models.Index(fields=['account', 'site']),
|
models.Index(fields=['account', 'site']),
|
||||||
|
models.Index(fields=['test_mode_enabled', 'test_trigger_at']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Automation Config: {self.site.domain} ({self.frequency})"
|
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):
|
class AutomationRun(models.Model):
|
||||||
@@ -96,6 +241,7 @@ class AutomationRun(models.Model):
|
|||||||
TRIGGER_TYPE_CHOICES = [
|
TRIGGER_TYPE_CHOICES = [
|
||||||
('manual', 'Manual'),
|
('manual', 'Manual'),
|
||||||
('scheduled', 'Scheduled'),
|
('scheduled', 'Scheduled'),
|
||||||
|
('test', 'Test'),
|
||||||
]
|
]
|
||||||
|
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
|
|||||||
@@ -16,48 +16,38 @@ logger = get_task_logger(__name__)
|
|||||||
@shared_task(name='automation.check_scheduled_automations')
|
@shared_task(name='automation.check_scheduled_automations')
|
||||||
def check_scheduled_automations():
|
def check_scheduled_automations():
|
||||||
"""
|
"""
|
||||||
Check for scheduled automation runs (runs every 15 minutes)
|
Check for scheduled automation runs (runs every hour at :05)
|
||||||
Matches automations scheduled within the current 15-minute window.
|
|
||||||
|
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()
|
now = timezone.now()
|
||||||
current_time = now.time()
|
current_hour = now.hour
|
||||||
|
|
||||||
# Calculate 15-minute window boundaries
|
logger.info(f"[AutomationTask] Current hour: {current_hour}:05, checking for configs scheduled at hour {current_hour}")
|
||||||
# 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 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 (matching hour)
|
||||||
|
|
||||||
# Find configs that should run now
|
|
||||||
for config in AutomationConfig.objects.filter(is_enabled=True):
|
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_hour = config.scheduled_time.hour
|
||||||
scheduled_minute = config.scheduled_time.minute
|
|
||||||
|
|
||||||
# Check if scheduled time falls within current 15-minute window
|
# Simple hour match
|
||||||
def is_in_window():
|
should_run = False
|
||||||
if current_time.hour != scheduled_hour:
|
|
||||||
return False
|
|
||||||
return window_start_minute <= scheduled_minute <= window_end_minute
|
|
||||||
|
|
||||||
if config.frequency == 'daily':
|
if config.frequency == 'daily':
|
||||||
# Run if scheduled_time falls within current 15-minute window
|
# Run every day if hour matches
|
||||||
if is_in_window():
|
should_run = (scheduled_hour == current_hour)
|
||||||
should_run = True
|
|
||||||
elif config.frequency == 'weekly':
|
elif config.frequency == 'weekly':
|
||||||
# Run on Mondays within scheduled window
|
# Run on Mondays if hour matches
|
||||||
if now.weekday() == 0 and is_in_window():
|
should_run = (now.weekday() == 0 and scheduled_hour == current_hour)
|
||||||
should_run = True
|
|
||||||
elif config.frequency == 'monthly':
|
elif config.frequency == 'monthly':
|
||||||
# Run on 1st of month within scheduled window
|
# Run on 1st of month if hour matches
|
||||||
if now.day == 1 and is_in_window():
|
should_run = (now.day == 1 and scheduled_hour == current_hour)
|
||||||
should_run = True
|
|
||||||
|
|
||||||
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:
|
if should_run:
|
||||||
# Check if already ran within the last 23 hours (prevents duplicate runs)
|
# 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}")
|
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)
|
@shared_task(name='automation.run_automation_task', bind=True, max_retries=0)
|
||||||
def run_automation_task(self, run_id: str):
|
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.auth.models import Account, Site
|
||||||
from igny8_core.business.integration.models import PublishingSettings
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -26,6 +26,7 @@ DEFAULT_PUBLISHING_SETTINGS = {
|
|||||||
'publish_time_slots': ['09:00', '14:00', '18:00'],
|
'publish_time_slots': ['09:00', '14:00', '18:00'],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Fallback defaults if no DefaultAutomationConfig exists
|
||||||
DEFAULT_AUTOMATION_SETTINGS = {
|
DEFAULT_AUTOMATION_SETTINGS = {
|
||||||
'is_enabled': True,
|
'is_enabled': True,
|
||||||
'frequency': 'daily',
|
'frequency': 'daily',
|
||||||
@@ -128,15 +129,66 @@ class DefaultsService:
|
|||||||
site: Site,
|
site: Site,
|
||||||
overrides: Optional[Dict[str, Any]] = None
|
overrides: Optional[Dict[str, Any]] = None
|
||||||
) -> AutomationConfig:
|
) -> 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:
|
if overrides:
|
||||||
config_data.update(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(
|
automation_config = AutomationConfig.objects.create(
|
||||||
account=self.account,
|
account=self.account,
|
||||||
site=site,
|
site=site,
|
||||||
|
|||||||
@@ -57,7 +57,11 @@ app.conf.beat_schedule = {
|
|||||||
# Automation Tasks
|
# Automation Tasks
|
||||||
'check-scheduled-automations': {
|
'check-scheduled-automations': {
|
||||||
'task': 'automation.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
|
# Publishing Scheduler Tasks
|
||||||
'schedule-approved-content': {
|
'schedule-approved-content': {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from igny8_core.modules.integration.webhooks import (
|
|||||||
wordpress_status_webhook,
|
wordpress_status_webhook,
|
||||||
wordpress_metadata_webhook,
|
wordpress_metadata_webhook,
|
||||||
)
|
)
|
||||||
from igny8_core.api.unified_settings import UnifiedSiteSettingsViewSet
|
from igny8_core.api.unified_settings import UnifiedSiteSettingsViewSet, DefaultSettingsAPIView
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r'integrations', IntegrationViewSet, basename='integration')
|
router.register(r'integrations', IntegrationViewSet, basename='integration')
|
||||||
@@ -31,6 +31,9 @@ unified_settings_viewset = UnifiedSiteSettingsViewSet.as_view({
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
|
|
||||||
|
# Default settings (for reset functionality)
|
||||||
|
path('settings/defaults/', DefaultSettingsAPIView.as_view(), name='settings-defaults'),
|
||||||
|
|
||||||
# Site-level publishing settings
|
# Site-level publishing settings
|
||||||
path('sites/<int:site_id>/publishing-settings/', publishing_settings_viewset, name='publishing-settings'),
|
path('sites/<int:site_id>/publishing-settings/', publishing_settings_viewset, name='publishing-settings'),
|
||||||
|
|
||||||
|
|||||||
@@ -763,6 +763,7 @@ UNFOLD = {
|
|||||||
"icon": "settings_suggest",
|
"icon": "settings_suggest",
|
||||||
"collapsible": True,
|
"collapsible": True,
|
||||||
"items": [
|
"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 Configs", "icon": "tune", "link": lambda request: "/admin/automation/automationconfig/"},
|
||||||
{"title": "Automation Runs", "icon": "play_circle", "link": lambda request: "/admin/automation/automationrun/"},
|
{"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,
|
StageConfig,
|
||||||
DAYS_OF_WEEK,
|
DAYS_OF_WEEK,
|
||||||
FREQUENCY_OPTIONS,
|
FREQUENCY_OPTIONS,
|
||||||
|
HOUR_OPTIONS,
|
||||||
|
AMPM_OPTIONS,
|
||||||
|
toTime24,
|
||||||
|
fromTime24,
|
||||||
|
formatTime,
|
||||||
calculateTotalBudget,
|
calculateTotalBudget,
|
||||||
} from '../../services/unifiedSettings.api';
|
} from '../../services/unifiedSettings.api';
|
||||||
|
|
||||||
@@ -185,11 +190,64 @@ export default function AIAutomationSettings({ siteId }: AIAutomationSettingsPro
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reset to defaults
|
// Reset to defaults - fetches from backend DefaultAutomationConfig
|
||||||
const handleReset = () => {
|
const handleReset = async () => {
|
||||||
loadSettings();
|
if (!settings) return;
|
||||||
loadImageSettings();
|
|
||||||
toastRef.current.info('Settings reset to last saved values');
|
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
|
// Update automation settings
|
||||||
@@ -330,19 +388,39 @@ export default function AIAutomationSettings({ siteId }: AIAutomationSettingsPro
|
|||||||
<SelectDropdown
|
<SelectDropdown
|
||||||
options={FREQUENCY_OPTIONS.map(f => ({ value: f.value, label: f.label }))}
|
options={FREQUENCY_OPTIONS.map(f => ({ value: f.value, label: f.label }))}
|
||||||
value={settings.automation.frequency}
|
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}
|
disabled={!settings.automation.enabled}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-2">Run Time</Label>
|
<Label className="mb-2">Run Time</Label>
|
||||||
<InputField
|
<div className="grid grid-cols-2 gap-2">
|
||||||
type="time"
|
<div>
|
||||||
value={settings.automation.time}
|
<SelectDropdown
|
||||||
onChange={(e) => updateAutomation({ time: e.target.value })}
|
options={HOUR_OPTIONS}
|
||||||
disabled={!settings.automation.enabled}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -352,7 +430,7 @@ export default function AIAutomationSettings({ siteId }: AIAutomationSettingsPro
|
|||||||
{settings.automation.enabled && settings.automation.next_run_at ? (
|
{settings.automation.enabled && settings.automation.next_run_at ? (
|
||||||
<span>Next run: {new Date(settings.automation.next_run_at).toLocaleString()}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -828,11 +906,11 @@ export default function AIAutomationSettings({ siteId }: AIAutomationSettingsPro
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Check Frequency</span>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Check Times</span>
|
<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">:00, :15, :30, :45</p>
|
<p className="text-sm font-semibold text-gray-900 dark:text-white">5 minutes past each hour</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Timezone</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export interface UnifiedSiteSettings {
|
|||||||
site_name: string;
|
site_name: string;
|
||||||
automation: {
|
automation: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
frequency: 'hourly' | 'daily' | 'weekly';
|
frequency: 'daily' | 'weekly' | 'monthly';
|
||||||
time: string; // HH:MM format
|
time: string; // HH:00 format (hour only)
|
||||||
last_run_at: string | null;
|
last_run_at: string | null;
|
||||||
next_run_at: string | null;
|
next_run_at: string | null;
|
||||||
};
|
};
|
||||||
@@ -66,8 +66,8 @@ export interface UnifiedSiteSettings {
|
|||||||
export interface UpdateUnifiedSettingsRequest {
|
export interface UpdateUnifiedSettingsRequest {
|
||||||
automation?: {
|
automation?: {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
frequency?: 'hourly' | 'daily' | 'weekly';
|
frequency?: 'daily' | 'weekly' | 'monthly';
|
||||||
time?: string;
|
time?: string; // HH:00 format (hour only)
|
||||||
};
|
};
|
||||||
stages?: Array<{
|
stages?: Array<{
|
||||||
number: number;
|
number: number;
|
||||||
@@ -136,20 +136,65 @@ export const DAYS_OF_WEEK = [
|
|||||||
* Frequency options for automation
|
* Frequency options for automation
|
||||||
*/
|
*/
|
||||||
export const FREQUENCY_OPTIONS = [
|
export const FREQUENCY_OPTIONS = [
|
||||||
{ value: 'hourly', label: 'Hourly' },
|
|
||||||
{ value: 'daily', label: 'Daily' },
|
{ value: 'daily', label: 'Daily' },
|
||||||
{ value: 'weekly', label: 'Weekly' },
|
{ 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
|
* Format time for display
|
||||||
*/
|
*/
|
||||||
export function formatTime(time: string): string {
|
export function formatTime(time: string): string {
|
||||||
const [hours, minutes] = time.split(':');
|
const { hour, ampm } = fromTime24(time);
|
||||||
const h = parseInt(hours);
|
return `${hour}:00 ${ampm}`;
|
||||||
const ampm = h >= 12 ? 'PM' : 'AM';
|
|
||||||
const displayHour = h > 12 ? h - 12 : h === 0 ? 12 : h;
|
|
||||||
return `${displayHour}:${minutes} ${ampm}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user