autmation final reaftocrs and setitgns dafautls

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-18 15:03:01 +00:00
parent 879ef6ff06
commit ebc4088ccb
14 changed files with 1367 additions and 90 deletions

View File

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

View File

@@ -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):

View File

@@ -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'),
),
]

View File

@@ -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',
},
),
]

View File

@@ -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),
]

View File

@@ -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 = [

View File

@@ -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):
"""

View File

@@ -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,

View File

@@ -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': {

View File

@@ -10,7 +10,7 @@ from igny8_core.modules.integration.webhooks import (
wordpress_status_webhook,
wordpress_metadata_webhook,
)
from igny8_core.api.unified_settings import UnifiedSiteSettingsViewSet
from igny8_core.api.unified_settings import UnifiedSiteSettingsViewSet, DefaultSettingsAPIView
router = DefaultRouter()
router.register(r'integrations', IntegrationViewSet, basename='integration')
@@ -31,6 +31,9 @@ unified_settings_viewset = UnifiedSiteSettingsViewSet.as_view({
urlpatterns = [
path('', include(router.urls)),
# Default settings (for reset functionality)
path('settings/defaults/', DefaultSettingsAPIView.as_view(), name='settings-defaults'),
# Site-level publishing settings
path('sites/<int:site_id>/publishing-settings/', publishing_settings_viewset, name='publishing-settings'),

View File

@@ -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/"},
],