AI AUtomtaion, Schudelign and publishign fromt and backe end refoactr

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-17 15:52:46 +00:00
parent 0435a5cf70
commit d3b3e1c0d4
34 changed files with 4715 additions and 375 deletions

View File

@@ -0,0 +1,357 @@
"""
Unified Site Settings API
Consolidates AI & Automation settings into a single endpoint.
Per SETTINGS-CONSOLIDATION-PLAN.md:
GET/PUT /api/v1/sites/{site_id}/unified-settings/
"""
import logging
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.decorators import action
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
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.integration.models import PublishingSettings
from igny8_core.business.billing.models import AIModelConfig
logger = logging.getLogger(__name__)
# Default stage configuration
DEFAULT_STAGE_CONFIG = {
'1': {'enabled': True, 'batch_size': 50, 'per_run_limit': 0, 'use_testing': False, 'budget_pct': 15},
'2': {'enabled': True, 'batch_size': 1, 'per_run_limit': 10, 'use_testing': False, 'budget_pct': 10},
'3': {'enabled': True, 'batch_size': 20, 'per_run_limit': 0}, # No AI
'4': {'enabled': True, 'batch_size': 1, 'per_run_limit': 5, 'use_testing': False, 'budget_pct': 40},
'5': {'enabled': True, 'batch_size': 1, 'per_run_limit': 5, 'use_testing': False, 'budget_pct': 5},
'6': {'enabled': True, 'batch_size': 1, 'per_run_limit': 20, 'use_testing': False, 'budget_pct': 30},
'7': {'enabled': True, 'per_run_limit': 10}, # No AI
}
STAGE_INFO = [
{'number': 1, 'name': 'Keywords → Clusters', 'has_ai': True},
{'number': 2, 'name': 'Clusters → Ideas', 'has_ai': True},
{'number': 3, 'name': 'Ideas → Tasks', 'has_ai': False},
{'number': 4, 'name': 'Tasks → Content', 'has_ai': True},
{'number': 5, 'name': 'Content → Prompts', 'has_ai': True},
{'number': 6, 'name': 'Prompts → Images', 'has_ai': True},
{'number': 7, 'name': 'Review → Approved', 'has_ai': False},
]
@extend_schema_view(
retrieve=extend_schema(
tags=['Site Settings'],
summary='Get unified site settings',
description='Get all AI & Automation settings for a site in one response',
parameters=[
OpenApiParameter(name='site_id', location='path', type=int, required=True),
]
),
update=extend_schema(
tags=['Site Settings'],
summary='Update unified site settings',
description='Update all AI & Automation settings for a site atomically',
parameters=[
OpenApiParameter(name='site_id', location='path', type=int, required=True),
]
),
)
class UnifiedSiteSettingsViewSet(viewsets.ViewSet):
"""
Unified API for all site AI & automation settings.
GET /api/v1/sites/{site_id}/unified-settings/
PUT /api/v1/sites/{site_id}/unified-settings/
"""
permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove]
throttle_scope = 'settings'
throttle_classes = [DebugScopedRateThrottle]
def retrieve(self, request, site_id=None):
"""Get all settings for a site in one response"""
site = get_object_or_404(Site, id=site_id, account=request.user.account)
# Get or create AutomationConfig
automation_config, _ = AutomationConfig.objects.get_or_create(
site=site,
defaults={
'account': site.account,
'is_enabled': False,
'frequency': 'daily',
'scheduled_time': '02:00',
}
)
# Get or create PublishingSettings
publishing_settings, _ = PublishingSettings.get_or_create_for_site(site)
# Get available models (Testing vs Live)
text_testing = AIModelConfig.get_testing_model('text')
text_live = AIModelConfig.get_live_model('text')
image_testing = AIModelConfig.get_testing_model('image')
image_live = AIModelConfig.get_live_model('image')
# Build stage configuration from AutomationConfig
stage_config = self._build_stage_config_from_automation(automation_config)
# Handle scheduled_time which might be a string or time object
scheduled_time = automation_config.scheduled_time
if scheduled_time:
if hasattr(scheduled_time, 'strftime'):
time_str = scheduled_time.strftime('%H:%M')
else:
time_str = str(scheduled_time)[:5] # Get HH:MM from string
else:
time_str = '02:00'
response_data = {
'site_id': site.id,
'site_name': site.name,
'automation': {
'enabled': automation_config.is_enabled,
'frequency': automation_config.frequency,
'time': time_str,
'last_run_at': automation_config.last_run_at.isoformat() if automation_config.last_run_at else None,
'next_run_at': automation_config.next_run_at.isoformat() if automation_config.next_run_at else None,
},
'stages': self._build_stage_matrix(stage_config),
'delays': {
'within_stage': automation_config.within_stage_delay,
'between_stage': automation_config.between_stage_delay,
},
'publishing': {
'auto_approval_enabled': publishing_settings.auto_approval_enabled,
'auto_publish_enabled': publishing_settings.auto_publish_enabled,
'publish_days': publishing_settings.publish_days,
'time_slots': publishing_settings.publish_time_slots,
# Calculated capacity (read-only)
'daily_capacity': publishing_settings.daily_capacity,
'weekly_capacity': publishing_settings.weekly_capacity,
'monthly_capacity': publishing_settings.monthly_capacity,
},
'available_models': {
'text': {
'testing': {
'id': text_testing.id if text_testing else None,
'name': text_testing.display_name if text_testing else None,
'model_name': text_testing.model_name if text_testing else None,
} if text_testing else None,
'live': {
'id': text_live.id if text_live else None,
'name': text_live.display_name if text_live else None,
'model_name': text_live.model_name if text_live else None,
} if text_live else None,
},
'image': {
'testing': {
'id': image_testing.id if image_testing else None,
'name': image_testing.display_name if image_testing else None,
'model_name': image_testing.model_name if image_testing else None,
} if image_testing else None,
'live': {
'id': image_live.id if image_live else None,
'name': image_live.display_name if image_live else None,
'model_name': image_live.model_name if image_live else None,
} if image_live else None,
},
},
}
return success_response(response_data, request=request)
def update(self, request, site_id=None):
"""Update all settings for a site atomically"""
site = get_object_or_404(Site, id=site_id, account=request.user.account)
data = request.data
try:
# Get or create AutomationConfig
automation_config, _ = AutomationConfig.objects.get_or_create(
site=site,
defaults={'account': site.account}
)
# Get or create PublishingSettings
publishing_settings, _ = PublishingSettings.get_or_create_for_site(site)
# Update automation settings
if 'automation' in data:
auto = data['automation']
if 'enabled' in auto:
automation_config.is_enabled = auto['enabled']
if 'frequency' in auto:
automation_config.frequency = auto['frequency']
if 'time' in auto:
from datetime import datetime
automation_config.scheduled_time = datetime.strptime(auto['time'], '%H:%M').time()
# Update stage configuration
if 'stages' in data:
self._update_stage_config(automation_config, data['stages'])
# Update delays
if 'delays' in data:
delays = data['delays']
if 'within_stage' in delays:
automation_config.within_stage_delay = delays['within_stage']
if 'between_stage' in delays:
automation_config.between_stage_delay = delays['between_stage']
automation_config.save()
# Update publishing settings
if 'publishing' in data:
pub = data['publishing']
if 'auto_approval_enabled' in pub:
publishing_settings.auto_approval_enabled = pub['auto_approval_enabled']
if 'auto_publish_enabled' in pub:
publishing_settings.auto_publish_enabled = pub['auto_publish_enabled']
if 'publish_days' in pub:
publishing_settings.publish_days = pub['publish_days']
if 'time_slots' in pub:
publishing_settings.publish_time_slots = pub['time_slots']
publishing_settings.save()
# Return the updated settings
return self.retrieve(request, site_id)
except Exception as e:
logger.exception(f"Error updating unified settings for site {site_id}")
return error_response(
f"Failed to update settings: {str(e)}",
None,
status.HTTP_400_BAD_REQUEST,
request
)
def _build_stage_config_from_automation(self, automation_config):
"""Build stage config dict from AutomationConfig model fields"""
return {
'1': {
'enabled': automation_config.stage_1_enabled,
'batch_size': automation_config.stage_1_batch_size,
'per_run_limit': automation_config.max_keywords_per_run,
'use_testing': False, # Default, can be stored in metadata later
},
'2': {
'enabled': automation_config.stage_2_enabled,
'batch_size': automation_config.stage_2_batch_size,
'per_run_limit': automation_config.max_clusters_per_run,
'use_testing': False,
},
'3': {
'enabled': automation_config.stage_3_enabled,
'batch_size': automation_config.stage_3_batch_size,
'per_run_limit': automation_config.max_ideas_per_run,
},
'4': {
'enabled': automation_config.stage_4_enabled,
'batch_size': automation_config.stage_4_batch_size,
'per_run_limit': automation_config.max_tasks_per_run,
'use_testing': False,
},
'5': {
'enabled': automation_config.stage_5_enabled,
'batch_size': automation_config.stage_5_batch_size,
'per_run_limit': automation_config.max_content_per_run,
'use_testing': False,
},
'6': {
'enabled': automation_config.stage_6_enabled,
'batch_size': automation_config.stage_6_batch_size,
'per_run_limit': automation_config.max_images_per_run,
'use_testing': False,
},
'7': {
'enabled': automation_config.stage_7_enabled,
'per_run_limit': automation_config.max_approvals_per_run,
},
}
def _build_stage_matrix(self, stage_config):
"""Build stage configuration matrix for frontend"""
result = []
for stage in STAGE_INFO:
num = str(stage['number'])
config = stage_config.get(num, DEFAULT_STAGE_CONFIG.get(num, {}))
stage_data = {
'number': stage['number'],
'name': stage['name'],
'has_ai': stage['has_ai'],
'enabled': config.get('enabled', True),
'batch_size': config.get('batch_size', 1),
'per_run_limit': config.get('per_run_limit', 0),
}
# Only include AI-related fields for stages that use AI
if stage['has_ai']:
stage_data['use_testing'] = config.get('use_testing', False)
stage_data['budget_pct'] = config.get('budget_pct', 20)
result.append(stage_data)
return result
def _update_stage_config(self, automation_config, stages):
"""Update AutomationConfig from stages array"""
for stage in stages:
num = stage.get('number')
if not num:
continue
if num == 1:
if 'enabled' in stage:
automation_config.stage_1_enabled = stage['enabled']
if 'batch_size' in stage:
automation_config.stage_1_batch_size = stage['batch_size']
if 'per_run_limit' in stage:
automation_config.max_keywords_per_run = stage['per_run_limit']
elif num == 2:
if 'enabled' in stage:
automation_config.stage_2_enabled = stage['enabled']
if 'batch_size' in stage:
automation_config.stage_2_batch_size = stage['batch_size']
if 'per_run_limit' in stage:
automation_config.max_clusters_per_run = stage['per_run_limit']
elif num == 3:
if 'enabled' in stage:
automation_config.stage_3_enabled = stage['enabled']
if 'batch_size' in stage:
automation_config.stage_3_batch_size = stage['batch_size']
if 'per_run_limit' in stage:
automation_config.max_ideas_per_run = stage['per_run_limit']
elif num == 4:
if 'enabled' in stage:
automation_config.stage_4_enabled = stage['enabled']
if 'batch_size' in stage:
automation_config.stage_4_batch_size = stage['batch_size']
if 'per_run_limit' in stage:
automation_config.max_tasks_per_run = stage['per_run_limit']
elif num == 5:
if 'enabled' in stage:
automation_config.stage_5_enabled = stage['enabled']
if 'batch_size' in stage:
automation_config.stage_5_batch_size = stage['batch_size']
if 'per_run_limit' in stage:
automation_config.max_content_per_run = stage['per_run_limit']
elif num == 6:
if 'enabled' in stage:
automation_config.stage_6_enabled = stage['enabled']
if 'batch_size' in stage:
automation_config.stage_6_batch_size = stage['batch_size']
if 'per_run_limit' in stage:
automation_config.max_images_per_run = stage['per_run_limit']
elif num == 7:
if 'enabled' in stage:
automation_config.stage_7_enabled = stage['enabled']
if 'per_run_limit' in stage:
automation_config.max_approvals_per_run = stage['per_run_limit']

View File

@@ -0,0 +1,53 @@
# Generated by Django 5.2.10 on 2026-01-17 14:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('automation', '0007_add_stage_enabled_toggles'),
]
operations = [
migrations.AddField(
model_name='automationconfig',
name='max_approvals_per_run',
field=models.IntegerField(default=0, help_text='Max content pieces to auto-approve in stage 7 (0=unlimited)'),
),
migrations.AddField(
model_name='automationconfig',
name='max_clusters_per_run',
field=models.IntegerField(default=0, help_text='Max clusters to process in stage 2 (0=unlimited)'),
),
migrations.AddField(
model_name='automationconfig',
name='max_content_per_run',
field=models.IntegerField(default=0, help_text='Max content pieces for image prompts in stage 5 (0=unlimited)'),
),
migrations.AddField(
model_name='automationconfig',
name='max_credits_per_run',
field=models.IntegerField(default=0, help_text='Max credits to use per run (0=unlimited)'),
),
migrations.AddField(
model_name='automationconfig',
name='max_ideas_per_run',
field=models.IntegerField(default=0, help_text='Max ideas to process in stage 3 (0=unlimited)'),
),
migrations.AddField(
model_name='automationconfig',
name='max_images_per_run',
field=models.IntegerField(default=0, help_text='Max images to generate in stage 6 (0=unlimited)'),
),
migrations.AddField(
model_name='automationconfig',
name='max_keywords_per_run',
field=models.IntegerField(default=0, help_text='Max keywords to process in stage 1 (0=unlimited)'),
),
migrations.AddField(
model_name='automationconfig',
name='max_tasks_per_run',
field=models.IntegerField(default=0, help_text='Max tasks to process in stage 4 (0=unlimited)'),
),
]

View File

@@ -44,6 +44,19 @@ class AutomationConfig(models.Model):
within_stage_delay = models.IntegerField(default=3, help_text="Delay between batches within a stage (seconds)") 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)") between_stage_delay = models.IntegerField(default=5, help_text="Delay between stage transitions (seconds)")
# Per-run item limits (0 = unlimited, processes all available)
# These prevent runaway automation and control resource usage
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)")
# Credit budget limit per run (0 = use site's full credit balance)
max_credits_per_run = models.IntegerField(default=0, help_text="Max credits to use per run (0=unlimited)")
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")

View File

@@ -63,7 +63,7 @@ class AutomationService:
def _check_should_stop(self) -> tuple[bool, str]: def _check_should_stop(self) -> tuple[bool, str]:
""" """
Check if automation should stop (paused or cancelled) Check if automation should stop (paused, cancelled, or credit budget exceeded)
Returns: Returns:
(should_stop, reason) (should_stop, reason)
@@ -79,6 +79,83 @@ class AutomationService:
elif self.run.status == 'cancelled': elif self.run.status == 'cancelled':
return True, "cancelled" return True, "cancelled"
# Check credit budget
budget_exceeded, budget_reason = self._check_credit_budget()
if budget_exceeded:
return True, f"credit_budget_exceeded: {budget_reason}"
return False, ""
def _get_per_run_limit(self, stage: int) -> int:
"""
Get the per-run item limit for a stage from config.
Args:
stage: Stage number (1-7)
Returns:
Max items to process (0 = unlimited)
"""
limit_map = {
1: self.config.max_keywords_per_run,
2: self.config.max_clusters_per_run,
3: self.config.max_ideas_per_run,
4: self.config.max_tasks_per_run,
5: self.config.max_content_per_run,
6: self.config.max_images_per_run,
7: self.config.max_approvals_per_run,
}
return limit_map.get(stage, 0)
def _apply_per_run_limit(self, queryset, stage: int, log_prefix: str = ""):
"""
Apply per-run limit to queryset if configured.
Args:
queryset: Django queryset to limit
stage: Stage number (1-7)
log_prefix: Prefix for log messages
Returns:
Limited queryset (or list if limit applied)
"""
limit = self._get_per_run_limit(stage)
if limit > 0:
total = queryset.count()
if total > limit:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage, f"{log_prefix}Applying per-run limit: {limit} of {total} items (limit set in automation config)"
)
return list(queryset[:limit])
return queryset
def _check_credit_budget(self) -> tuple[bool, str]:
"""
Check if credit budget for this run has been exceeded.
Returns:
(exceeded, reason) - If exceeded is True, automation should stop
"""
if not self.run or not self.config:
return False, ""
max_credits = self.config.max_credits_per_run
if max_credits <= 0: # 0 = unlimited
return False, ""
credits_used = self._get_credits_used()
if credits_used >= max_credits:
reason = f"Credit budget exhausted: {credits_used}/{max_credits} credits used"
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
self.run.current_stage, reason
)
return True, reason
return False, "" return False, ""
def start_automation(self, trigger_type: str = 'manual') -> str: def start_automation(self, trigger_type: str = 'manual') -> str:
@@ -170,6 +247,19 @@ class AutomationService:
disabled=False disabled=False
) )
# Apply per-run limit (0 = unlimited)
per_run_limit = self._get_per_run_limit(stage_number)
total_available = pending_keywords.count()
if per_run_limit > 0 and total_available > per_run_limit:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Per-run limit: Processing {per_run_limit} of {total_available} keywords"
)
# Get limited keyword IDs first, then filter queryset
limited_ids = list(pending_keywords.values_list('id', flat=True)[:per_run_limit])
pending_keywords = pending_keywords.filter(id__in=limited_ids)
total_count = pending_keywords.count() total_count = pending_keywords.count()
# IMPORTANT: Group keywords by sector to avoid mixing sectors in clustering # IMPORTANT: Group keywords by sector to avoid mixing sectors in clustering
@@ -480,6 +570,17 @@ class AutomationService:
disabled=False disabled=False
) )
# Apply per-run limit (0 = unlimited)
per_run_limit = self._get_per_run_limit(stage_number)
total_available = pending_clusters.count()
if per_run_limit > 0 and total_available > per_run_limit:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Per-run limit: Processing {per_run_limit} of {total_available} clusters"
)
pending_clusters = pending_clusters[:per_run_limit]
total_count = pending_clusters.count() total_count = pending_clusters.count()
# Log stage start # Log stage start
@@ -674,6 +775,17 @@ class AutomationService:
status='new' status='new'
) )
# Apply per-run limit (0 = unlimited)
per_run_limit = self._get_per_run_limit(stage_number)
total_available = pending_ideas.count()
if per_run_limit > 0 and total_available > per_run_limit:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Per-run limit: Processing {per_run_limit} of {total_available} ideas"
)
pending_ideas = pending_ideas[:per_run_limit]
total_count = pending_ideas.count() total_count = pending_ideas.count()
# Log stage start # Log stage start
@@ -837,6 +949,17 @@ class AutomationService:
status='queued' status='queued'
) )
# Apply per-run limit (0 = unlimited)
per_run_limit = self._get_per_run_limit(stage_number)
total_available = pending_tasks.count()
if per_run_limit > 0 and total_available > per_run_limit:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Per-run limit: Processing {per_run_limit} of {total_available} tasks"
)
pending_tasks = pending_tasks[:per_run_limit]
total_count = pending_tasks.count() total_count = pending_tasks.count()
# Log stage start # Log stage start
@@ -1078,6 +1201,17 @@ class AutomationService:
images_count=0 images_count=0
) )
# Apply per-run limit (0 = unlimited)
per_run_limit = self._get_per_run_limit(stage_number)
total_available = content_without_images.count()
if per_run_limit > 0 and total_available > per_run_limit:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Per-run limit: Processing {per_run_limit} of {total_available} content items"
)
content_without_images = content_without_images[:per_run_limit]
total_count = content_without_images.count() total_count = content_without_images.count()
# ADDED: Enhanced logging # ADDED: Enhanced logging
@@ -1291,6 +1425,17 @@ class AutomationService:
status='pending' status='pending'
) )
# Apply per-run limit (0 = unlimited)
per_run_limit = self._get_per_run_limit(stage_number)
total_available = pending_images.count()
if per_run_limit > 0 and total_available > per_run_limit:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Per-run limit: Processing {per_run_limit} of {total_available} images"
)
pending_images = pending_images[:per_run_limit]
total_count = pending_images.count() total_count = pending_images.count()
# Log stage start # Log stage start
@@ -1538,6 +1683,17 @@ class AutomationService:
status='review' status='review'
) )
# Apply per-run limit (0 = unlimited)
per_run_limit = self._get_per_run_limit(stage_number)
total_available = ready_for_review.count()
if per_run_limit > 0 and total_available > per_run_limit:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Per-run limit: Approving {per_run_limit} of {total_available} content items"
)
ready_for_review = ready_for_review[:per_run_limit]
total_count = ready_for_review.count() total_count = ready_for_review.count()
# Log stage start # Log stage start

View File

@@ -49,9 +49,9 @@ def check_scheduled_automations():
logger.info(f"[AutomationTask] Skipping site {config.site.id} - already ran today") logger.info(f"[AutomationTask] Skipping site {config.site.id} - already ran today")
continue continue
# Check if already running # Check if already running OR paused (don't start new if existing in progress)
if AutomationRun.objects.filter(site=config.site, status='running').exists(): if AutomationRun.objects.filter(site=config.site, status__in=['running', 'paused']).exists():
logger.info(f"[AutomationTask] Skipping site {config.site.id} - already running") logger.info(f"[AutomationTask] Skipping site {config.site.id} - automation in progress (running/paused)")
continue continue
logger.info(f"[AutomationTask] Starting scheduled automation for site {config.site.id}") logger.info(f"[AutomationTask] Starting scheduled automation for site {config.site.id}")
@@ -162,13 +162,50 @@ def run_automation_task(self, run_id: str):
@shared_task(name='automation.resume_automation_task', bind=True, max_retries=0) @shared_task(name='automation.resume_automation_task', bind=True, max_retries=0)
def resume_automation_task(self, run_id: str): def resume_automation_task(self, run_id: str):
""" """
Resume paused automation run from current stage Resume paused automation run from current stage.
CRITICAL FIXES:
- Verifies run status is 'running' before processing
- Reacquires lock in case it expired during long pause
- Checks pause/cancel status after each stage
- Releases lock on failure
""" """
logger.info(f"[AutomationTask] Resuming automation run: {run_id}") logger.info(f"[AutomationTask] Resuming automation run: {run_id}")
try: try:
from django.core.cache import cache
# Load run and verify status
run = AutomationRun.objects.get(run_id=run_id)
# CRITICAL FIX: Verify run is actually in 'running' status
# (status is set to 'running' by views.resume before calling this task)
if run.status != 'running':
logger.warning(f"[AutomationTask] Run {run_id} status is '{run.status}', not 'running'. Aborting resume.")
return
# CRITICAL FIX: Reacquire lock in case it expired during long pause (6hr timeout)
lock_key = f'automation_lock_{run.site.id}'
lock_acquired = cache.add(lock_key, run_id, timeout=21600) # 6 hours
if not lock_acquired:
# Lock exists - check if it's ours (from original run start)
existing_lock = cache.get(lock_key)
# If lock exists but isn't our run_id, another run may have started
if existing_lock and existing_lock != run_id and existing_lock != 'locked':
logger.warning(f"[AutomationTask] Lock held by different run ({existing_lock}). Aborting resume for {run_id}")
run.status = 'failed'
run.error_message = f'Lock acquired by another run ({existing_lock}) during pause'
run.completed_at = timezone.now()
run.save()
return
# Lock exists and is either 'locked' (our old format) or our run_id - proceed
logger.info(f"[AutomationTask] Existing lock found, proceeding with resume")
else:
# We acquired a new lock (old one expired)
logger.info(f"[AutomationTask] Reacquired lock after expiry for run {run_id}")
service = AutomationService.from_run_id(run_id) service = AutomationService.from_run_id(run_id)
run = service.run
config = service.config config = service.config
# Continue from current stage # Continue from current stage
@@ -196,20 +233,35 @@ def resume_automation_task(self, run_id: str):
for stage in range(run.current_stage - 1, 7): for stage in range(run.current_stage - 1, 7):
if stage_enabled[stage]: if stage_enabled[stage]:
stage_methods[stage]() stage_methods[stage]()
# CRITICAL FIX: Check for pause/cancel AFTER each stage (same as run_automation_task)
service.run.refresh_from_db()
if service.run.status in ['paused', 'cancelled']:
logger.info(f"[AutomationTask] Resumed automation {service.run.status} after stage {stage + 1}")
return
else: else:
logger.info(f"[AutomationTask] Stage {stage + 1} is disabled, skipping") logger.info(f"[AutomationTask] Stage {stage + 1} is disabled, skipping")
logger.info(f"[AutomationTask] Resumed automation run: {run_id}") logger.info(f"[AutomationTask] Resumed automation completed: {run_id}")
except Exception as e: except Exception as e:
logger.error(f"[AutomationTask] Failed to resume automation run {run_id}: {e}") logger.error(f"[AutomationTask] Failed to resume automation run {run_id}: {e}")
# Mark as failed # Mark as failed and release lock
run = AutomationRun.objects.get(run_id=run_id) try:
run.status = 'failed' run = AutomationRun.objects.get(run_id=run_id)
run.error_message = str(e) run.status = 'failed'
run.completed_at = timezone.now() run.error_message = str(e)
run.save() run.completed_at = timezone.now()
run.save()
# Release lock on failure
from django.core.cache import cache
cache.delete(f'automation_lock_{run.site.id}')
except Exception as cleanup_err:
logger.error(f"[AutomationTask] Failed to cleanup after resume failure: {cleanup_err}")
raise
# Alias for continue_automation_task (same as resume) # Alias for continue_automation_task (same as resume)

View File

@@ -77,6 +77,15 @@ class AutomationViewSet(viewsets.ViewSet):
'stage_6_batch_size': config.stage_6_batch_size, 'stage_6_batch_size': config.stage_6_batch_size,
'within_stage_delay': config.within_stage_delay, 'within_stage_delay': config.within_stage_delay,
'between_stage_delay': config.between_stage_delay, 'between_stage_delay': config.between_stage_delay,
# Per-run limits (0 = unlimited)
'max_keywords_per_run': config.max_keywords_per_run,
'max_clusters_per_run': config.max_clusters_per_run,
'max_ideas_per_run': config.max_ideas_per_run,
'max_tasks_per_run': config.max_tasks_per_run,
'max_content_per_run': config.max_content_per_run,
'max_images_per_run': config.max_images_per_run,
'max_approvals_per_run': config.max_approvals_per_run,
'max_credits_per_run': config.max_credits_per_run,
'last_run_at': config.last_run_at, 'last_run_at': config.last_run_at,
'next_run_at': config.next_run_at, 'next_run_at': config.next_run_at,
}) })
@@ -153,6 +162,18 @@ class AutomationViewSet(viewsets.ViewSet):
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
# Per-run limits (0 = unlimited)
for field in ['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']:
if field in request.data:
try:
value = int(request.data[field])
if value >= 0: # Allow 0 (unlimited) or positive numbers
setattr(config, field, value)
except (TypeError, ValueError):
pass
config.save() config.save()
return Response({ return Response({
@@ -175,6 +196,15 @@ class AutomationViewSet(viewsets.ViewSet):
'stage_6_batch_size': config.stage_6_batch_size, 'stage_6_batch_size': config.stage_6_batch_size,
'within_stage_delay': config.within_stage_delay, 'within_stage_delay': config.within_stage_delay,
'between_stage_delay': config.between_stage_delay, 'between_stage_delay': config.between_stage_delay,
# Per-run limits (0 = unlimited)
'max_keywords_per_run': config.max_keywords_per_run,
'max_clusters_per_run': config.max_clusters_per_run,
'max_ideas_per_run': config.max_ideas_per_run,
'max_tasks_per_run': config.max_tasks_per_run,
'max_content_per_run': config.max_content_per_run,
'max_images_per_run': config.max_images_per_run,
'max_approvals_per_run': config.max_approvals_per_run,
'max_credits_per_run': config.max_credits_per_run,
'last_run_at': config.last_run_at, 'last_run_at': config.last_run_at,
'next_run_at': config.next_run_at, 'next_run_at': config.next_run_at,
}) })
@@ -267,6 +297,17 @@ class AutomationViewSet(viewsets.ViewSet):
try: try:
service = AutomationService.from_run_id(run_id) service = AutomationService.from_run_id(run_id)
service.pause_automation() service.pause_automation()
# CRITICAL FIX: Log pause to automation log files
try:
service.logger.log_stage_progress(
service.run.run_id, service.account.id, service.site.id,
service.run.current_stage, f"Automation paused by user at stage {service.run.current_stage}"
)
except Exception as log_err:
# Don't fail the pause if logging fails
pass
return Response({'message': 'Automation paused'}) return Response({'message': 'Automation paused'})
except AutomationRun.DoesNotExist: except AutomationRun.DoesNotExist:
return Response( return Response(
@@ -1613,6 +1654,22 @@ class AutomationViewSet(viewsets.ViewSet):
run.completed_at = timezone.now() run.completed_at = timezone.now()
run.save(update_fields=['status', 'cancelled_at', 'completed_at']) run.save(update_fields=['status', 'cancelled_at', 'completed_at'])
# CRITICAL FIX: Release the lock so user can start new automation
from django.core.cache import cache
cache.delete(f'automation_lock_{run.site.id}')
# Log the cancellation to automation log files
try:
from igny8_core.business.automation.services.automation_logger import AutomationLogger
logger = AutomationLogger()
logger.log_stage_progress(
run.run_id, run.account.id, run.site.id, run.current_stage,
f"Automation cancelled by user at stage {run.current_stage}"
)
except Exception as log_err:
# Don't fail the cancellation if logging fails
pass
return Response({ return Response({
'message': 'Automation cancelled', 'message': 'Automation cancelled',
'status': run.status, 'status': run.status,

View File

@@ -0,0 +1,24 @@
# Generated migration for settings consolidation
# Add is_testing field to AIModelConfig
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0009_seed_ai_model_configs'),
]
operations = [
# Add is_testing field to AIModelConfig
migrations.AddField(
model_name='aimodelconfig',
name='is_testing',
field=models.BooleanField(
default=False,
db_index=True,
help_text='Testing model (cheap, for testing only). Only one per model_type can be is_testing=True.',
),
),
]

View File

@@ -828,6 +828,13 @@ class AIModelConfig(models.Model):
help_text="basic / quality / premium - for image models" help_text="basic / quality / premium - for image models"
) )
# Testing vs Live model designation
is_testing = models.BooleanField(
default=False,
db_index=True,
help_text="Testing model (cheap, for testing only). Only one per model_type can be is_testing=True."
)
# Image Size Configuration (for image models) # Image Size Configuration (for image models)
landscape_size = models.CharField( landscape_size = models.CharField(
max_length=20, max_length=20,
@@ -892,12 +899,18 @@ class AIModelConfig(models.Model):
return self.display_name return self.display_name
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Ensure only one is_default per model_type""" """Ensure only one is_default and one is_testing per model_type"""
if self.is_default: if self.is_default:
AIModelConfig.objects.filter( AIModelConfig.objects.filter(
model_type=self.model_type, model_type=self.model_type,
is_default=True is_default=True
).exclude(pk=self.pk).update(is_default=False) ).exclude(pk=self.pk).update(is_default=False)
if self.is_testing:
AIModelConfig.objects.filter(
model_type=self.model_type,
is_testing=True,
is_active=True
).exclude(pk=self.pk).update(is_testing=False)
super().save(*args, **kwargs) super().save(*args, **kwargs)
@classmethod @classmethod
@@ -910,6 +923,25 @@ class AIModelConfig(models.Model):
"""Get the default image generation model""" """Get the default image generation model"""
return cls.objects.filter(model_type='image', is_default=True, is_active=True).first() return cls.objects.filter(model_type='image', is_default=True, is_active=True).first()
@classmethod
def get_testing_model(cls, model_type: str):
"""Get the testing model for text or image"""
return cls.objects.filter(
model_type=model_type,
is_testing=True,
is_active=True
).first()
@classmethod
def get_live_model(cls, model_type: str):
"""Get the live (default production) model for text or image"""
return cls.objects.filter(
model_type=model_type,
is_testing=False,
is_default=True,
is_active=True
).first()
@classmethod @classmethod
def get_image_models_by_tier(cls): def get_image_models_by_tier(cls):
"""Get all active image models grouped by quality tier""" """Get all active image models grouped by quality tier"""
@@ -1044,3 +1076,121 @@ class WebhookEvent(models.Model):
self.error_message = error_message self.error_message = error_message
self.retry_count += 1 self.retry_count += 1
self.save(update_fields=['error_message', 'retry_count']) self.save(update_fields=['error_message', 'retry_count'])
class SiteAIBudgetAllocation(AccountBaseModel):
"""
Site-level AI budget allocation by function.
Allows configuring what percentage of the site's credit budget
can be used for each AI function. This provides fine-grained
control over credit consumption during automation runs.
Example: 40% content, 30% images, 20% clustering, 10% ideas
When max_credits_per_run is set in AutomationConfig:
- Each function can only use up to its allocated % of that budget
- Prevents any single function from consuming all credits
"""
AI_FUNCTION_CHOICES = [
('clustering', 'Keyword Clustering (Stage 1)'),
('idea_generation', 'Ideas Generation (Stage 2)'),
('content_generation', 'Content Generation (Stage 4)'),
('image_prompt', 'Image Prompt Extraction (Stage 5)'),
('image_generation', 'Image Generation (Stage 6)'),
]
site = models.ForeignKey(
'igny8_core_auth.Site',
on_delete=models.CASCADE,
related_name='ai_budget_allocations',
help_text="Site this allocation belongs to"
)
ai_function = models.CharField(
max_length=50,
choices=AI_FUNCTION_CHOICES,
help_text="AI function to allocate budget for"
)
allocation_percentage = models.PositiveIntegerField(
default=20,
validators=[MinValueValidator(0)],
help_text="Percentage of credit budget allocated to this function (0-100)"
)
is_enabled = models.BooleanField(
default=True,
help_text="Whether this function is enabled for automation"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'billing'
db_table = 'igny8_site_ai_budget_allocations'
verbose_name = 'Site AI Budget Allocation'
verbose_name_plural = 'Site AI Budget Allocations'
unique_together = [['site', 'ai_function']]
ordering = ['site', 'ai_function']
indexes = [
models.Index(fields=['site', 'is_enabled']),
models.Index(fields=['account', 'site']),
]
def __str__(self):
return f"{self.site.name} - {self.get_ai_function_display()}: {self.allocation_percentage}%"
@classmethod
def get_or_create_defaults_for_site(cls, site, account):
"""
Get or create default allocations for a site.
Default: Equal distribution across all functions (20% each = 100%)
"""
defaults = [
('clustering', 15),
('idea_generation', 10),
('content_generation', 40),
('image_prompt', 5),
('image_generation', 30),
]
allocations = []
for ai_function, percentage in defaults:
allocation, _ = cls.objects.get_or_create(
account=account,
site=site,
ai_function=ai_function,
defaults={
'allocation_percentage': percentage,
'is_enabled': True,
}
)
allocations.append(allocation)
return allocations
@classmethod
def get_allocation_for_function(cls, site, ai_function) -> int:
"""
Get allocation percentage for a specific AI function.
Returns 0 if not found or disabled.
"""
try:
allocation = cls.objects.get(site=site, ai_function=ai_function)
if allocation.is_enabled:
return allocation.allocation_percentage
return 0
except cls.DoesNotExist:
# Return default percentage if no allocation exists
default_map = {
'clustering': 15,
'idea_generation': 10,
'content_generation': 40,
'image_prompt': 5,
'image_generation': 30,
}
return default_map.get(ai_function, 20)

View File

@@ -0,0 +1,53 @@
# Generated by Django 5.2.10 on 2026-01-17 14:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('integration', '0003_add_publishing_settings'),
]
operations = [
migrations.AddField(
model_name='publishingsettings',
name='queue_limit',
field=models.PositiveIntegerField(default=100, help_text='DEPRECATED - not used'),
),
migrations.AddField(
model_name='publishingsettings',
name='scheduling_mode',
field=models.CharField(default='time_slots', help_text='DEPRECATED - always uses time_slots mode', max_length=20),
),
migrations.AddField(
model_name='publishingsettings',
name='stagger_end_time',
field=models.TimeField(default='18:00', help_text='DEPRECATED - not used'),
),
migrations.AddField(
model_name='publishingsettings',
name='stagger_interval_minutes',
field=models.PositiveIntegerField(default=30, help_text='DEPRECATED - not used'),
),
migrations.AddField(
model_name='publishingsettings',
name='stagger_start_time',
field=models.TimeField(default='09:00', help_text='DEPRECATED - not used'),
),
migrations.AlterField(
model_name='publishingsettings',
name='daily_publish_limit',
field=models.PositiveIntegerField(default=3, help_text='DEPRECATED - derived from time_slots'),
),
migrations.AlterField(
model_name='publishingsettings',
name='monthly_publish_limit',
field=models.PositiveIntegerField(default=50, help_text='DEPRECATED - not used'),
),
migrations.AlterField(
model_name='publishingsettings',
name='weekly_publish_limit',
field=models.PositiveIntegerField(default=15, help_text='DEPRECATED - derived from days × slots'),
),
]

View File

@@ -247,8 +247,16 @@ class SyncEvent(AccountBaseModel):
class PublishingSettings(AccountBaseModel): class PublishingSettings(AccountBaseModel):
""" """
Site-level publishing configuration settings. Site-level publishing SCHEDULE configuration (SIMPLIFIED).
Controls automatic approval, publishing limits, and scheduling. Controls automatic approval, publishing, and time-slot based scheduling.
REMOVED (per settings consolidation plan):
- scheduling_mode (only time_slots needed)
- daily_publish_limit (derived: len(time_slots))
- weekly_publish_limit (derived: len(time_slots) × len(publish_days))
- monthly_publish_limit (not needed)
- stagger_* fields (not needed)
- queue_limit (not needed)
""" """
DEFAULT_PUBLISH_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri'] DEFAULT_PUBLISH_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri']
@@ -273,26 +281,7 @@ class PublishingSettings(AccountBaseModel):
help_text="Automatically publish approved content to the external site" help_text="Automatically publish approved content to the external site"
) )
# Publishing limits # Publishing schedule - Days + Time Slots only (SIMPLIFIED)
daily_publish_limit = models.PositiveIntegerField(
default=3,
validators=[MinValueValidator(1)],
help_text="Maximum number of articles to publish per day"
)
weekly_publish_limit = models.PositiveIntegerField(
default=15,
validators=[MinValueValidator(1)],
help_text="Maximum number of articles to publish per week"
)
monthly_publish_limit = models.PositiveIntegerField(
default=50,
validators=[MinValueValidator(1)],
help_text="Maximum number of articles to publish per month"
)
# Publishing schedule
publish_days = models.JSONField( publish_days = models.JSONField(
default=list, default=list,
help_text="Days of the week to publish (mon, tue, wed, thu, fri, sat, sun)" help_text="Days of the week to publish (mon, tue, wed, thu, fri, sat, sun)"
@@ -303,6 +292,21 @@ class PublishingSettings(AccountBaseModel):
help_text="Times of day to publish (HH:MM format, e.g., ['09:00', '14:00', '18:00'])" help_text="Times of day to publish (HH:MM format, e.g., ['09:00', '14:00', '18:00'])"
) )
# DEPRECATED FIELDS - kept for backwards compatibility during migration
# These will be removed in a future migration
scheduling_mode = models.CharField(
max_length=20,
default='time_slots',
help_text="DEPRECATED - always uses time_slots mode"
)
daily_publish_limit = models.PositiveIntegerField(default=3, help_text="DEPRECATED - derived from time_slots")
weekly_publish_limit = models.PositiveIntegerField(default=15, help_text="DEPRECATED - derived from days × slots")
monthly_publish_limit = models.PositiveIntegerField(default=50, help_text="DEPRECATED - not used")
stagger_start_time = models.TimeField(default='09:00', help_text="DEPRECATED - not used")
stagger_end_time = models.TimeField(default='18:00', help_text="DEPRECATED - not used")
stagger_interval_minutes = models.PositiveIntegerField(default=30, help_text="DEPRECATED - not used")
queue_limit = models.PositiveIntegerField(default=100, help_text="DEPRECATED - not used")
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)
@@ -323,6 +327,22 @@ class PublishingSettings(AccountBaseModel):
self.publish_time_slots = self.DEFAULT_TIME_SLOTS self.publish_time_slots = self.DEFAULT_TIME_SLOTS
super().save(*args, **kwargs) super().save(*args, **kwargs)
# Calculated capacity properties (read-only, derived from days × slots)
@property
def daily_capacity(self) -> int:
"""Daily publishing capacity = number of time slots"""
return len(self.publish_time_slots) if self.publish_time_slots else 0
@property
def weekly_capacity(self) -> int:
"""Weekly publishing capacity = time slots × publish days"""
return self.daily_capacity * len(self.publish_days) if self.publish_days else 0
@property
def monthly_capacity(self) -> int:
"""Monthly publishing capacity (approximate: weekly × 4.3)"""
return int(self.weekly_capacity * 4.3)
@classmethod @classmethod
def get_or_create_for_site(cls, site): def get_or_create_for_site(cls, site):
"""Get or create publishing settings for a site with defaults""" """Get or create publishing settings for a site with defaults"""
@@ -332,9 +352,6 @@ class PublishingSettings(AccountBaseModel):
'account': site.account, 'account': site.account,
'auto_approval_enabled': True, 'auto_approval_enabled': True,
'auto_publish_enabled': True, 'auto_publish_enabled': True,
'daily_publish_limit': 3,
'weekly_publish_limit': 15,
'monthly_publish_limit': 50,
'publish_days': cls.DEFAULT_PUBLISH_DAYS, 'publish_days': cls.DEFAULT_PUBLISH_DAYS,
'publish_time_slots': cls.DEFAULT_TIME_SLOTS, 'publish_time_slots': cls.DEFAULT_TIME_SLOTS,
} }

View File

@@ -839,6 +839,7 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
'provider_badge', 'provider_badge',
'credit_display', 'credit_display',
'quality_tier', 'quality_tier',
'is_testing_icon',
'is_active_icon', 'is_active_icon',
'is_default_icon', 'is_default_icon',
'updated_at', 'updated_at',
@@ -848,6 +849,7 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
'model_type', 'model_type',
'provider', 'provider',
'quality_tier', 'quality_tier',
'is_testing',
'is_active', 'is_active',
'is_default', 'is_default',
] ]
@@ -884,7 +886,8 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
'classes': ('collapse',) 'classes': ('collapse',)
}), }),
('Status', { ('Status', {
'fields': ('is_active', 'is_default'), 'fields': ('is_active', 'is_default', 'is_testing'),
'description': 'is_testing: Mark as cheap testing model (one per model_type)'
}), }),
('Timestamps', { ('Timestamps', {
'fields': ('created_at', 'updated_at'), 'fields': ('created_at', 'updated_at'),
@@ -969,8 +972,19 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
) )
is_default_icon.short_description = 'Default' is_default_icon.short_description = 'Default'
def is_testing_icon(self, obj):
"""Testing status icon - shows ⚡ for testing models"""
if obj.is_testing:
return format_html(
'<span style="color: #f39c12; font-size: 18px;" title="Testing Model (cheap, for testing)">⚡</span>'
)
return format_html(
'<span style="color: #2ecc71; font-size: 14px;" title="Live Model">●</span>'
)
is_testing_icon.short_description = 'Testing/Live'
# Admin actions # Admin actions
actions = ['bulk_activate', 'bulk_deactivate', 'set_as_default'] actions = ['bulk_activate', 'bulk_deactivate', 'set_as_default', 'set_as_testing', 'unset_testing']
def bulk_activate(self, request, queryset): def bulk_activate(self, request, queryset):
"""Enable selected models""" """Enable selected models"""
@@ -1005,3 +1019,34 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
messages.SUCCESS messages.SUCCESS
) )
set_as_default.short_description = 'Set as default model' set_as_default.short_description = 'Set as default model'
def set_as_testing(self, request, queryset):
"""Set one model as testing model for its type"""
if queryset.count() != 1:
self.message_user(request, 'Select exactly one model.', messages.ERROR)
return
model = queryset.first()
# Unset any existing testing model for this type
AIModelConfig.objects.filter(
model_type=model.model_type,
is_testing=True,
is_active=True
).exclude(pk=model.pk).update(is_testing=False)
model.is_testing = True
model.save()
self.message_user(
request,
f'{model.model_name} is now the TESTING {model.get_model_type_display()} model.',
messages.SUCCESS
)
set_as_testing.short_description = 'Set as testing model (cheap, for testing)'
def unset_testing(self, request, queryset):
"""Remove testing flag from selected models"""
count = queryset.update(is_testing=False)
self.message_user(request, f'{count} model(s) unmarked as testing.', messages.SUCCESS)
unset_testing.short_description = 'Unset testing flag'

View File

@@ -0,0 +1,47 @@
# Generated by Django 5.2.10 on 2026-01-17 14:37
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0034_backfill_credit_usage_log_site'),
('igny8_core_auth', '0031_drop_all_blueprint_tables'),
]
operations = [
migrations.AddField(
model_name='aimodelconfig',
name='is_testing',
field=models.BooleanField(db_index=True, default=False, help_text='Testing model (cheap, for testing only). Only one per model_type can be is_testing=True.'),
),
migrations.AddField(
model_name='historicalaimodelconfig',
name='is_testing',
field=models.BooleanField(db_index=True, default=False, help_text='Testing model (cheap, for testing only). Only one per model_type can be is_testing=True.'),
),
migrations.CreateModel(
name='SiteAIBudgetAllocation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ai_function', models.CharField(choices=[('clustering', 'Keyword Clustering (Stage 1)'), ('idea_generation', 'Ideas Generation (Stage 2)'), ('content_generation', 'Content Generation (Stage 4)'), ('image_prompt', 'Image Prompt Extraction (Stage 5)'), ('image_generation', 'Image Generation (Stage 6)')], help_text='AI function to allocate budget for', max_length=50)),
('allocation_percentage', models.PositiveIntegerField(default=20, help_text='Percentage of credit budget allocated to this function (0-100)', validators=[django.core.validators.MinValueValidator(0)])),
('is_enabled', models.BooleanField(default=True, help_text='Whether this function is enabled for automation')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')),
('site', models.ForeignKey(help_text='Site this allocation belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='ai_budget_allocations', to='igny8_core_auth.site')),
],
options={
'verbose_name': 'Site AI Budget Allocation',
'verbose_name_plural': 'Site AI Budget Allocations',
'db_table': 'igny8_site_ai_budget_allocations',
'ordering': ['site', 'ai_function'],
'indexes': [models.Index(fields=['site', 'is_enabled'], name='igny8_site__site_id_36b0d0_idx'), models.Index(fields=['account', 'site'], name='igny8_site__tenant__853b16_idx')],
'unique_together': {('site', 'ai_function')},
},
),
]

View File

@@ -8,7 +8,8 @@ from .views import (
CreditUsageViewSet, CreditUsageViewSet,
CreditTransactionViewSet, CreditTransactionViewSet,
BillingOverviewViewSet, BillingOverviewViewSet,
AdminBillingViewSet AdminBillingViewSet,
SiteAIBudgetAllocationViewSet
) )
router = DefaultRouter() router = DefaultRouter()
@@ -31,5 +32,7 @@ urlpatterns = [
path('admin/billing/stats/', AdminBillingViewSet.as_view({'get': 'stats'}), name='admin-billing-stats'), path('admin/billing/stats/', AdminBillingViewSet.as_view({'get': 'stats'}), name='admin-billing-stats'),
path('admin/users/', AdminBillingViewSet.as_view({'get': 'list_users'}), name='admin-users-list'), path('admin/users/', AdminBillingViewSet.as_view({'get': 'list_users'}), name='admin-users-list'),
path('admin/credit-costs/', AdminBillingViewSet.as_view({'get': 'credit_costs'}), name='admin-credit-costs'), path('admin/credit-costs/', AdminBillingViewSet.as_view({'get': 'credit_costs'}), name='admin-credit-costs'),
# Site AI budget allocation
path('sites/<int:site_id>/ai-budget/', SiteAIBudgetAllocationViewSet.as_view({'get': 'list', 'post': 'create'}), name='site-ai-budget'),
] ]

View File

@@ -840,3 +840,177 @@ class AIModelConfigViewSet(viewsets.ReadOnlyModelViewSet):
status_code=status.HTTP_404_NOT_FOUND status_code=status.HTTP_404_NOT_FOUND
) )
# ==============================================================================
# Site AI Budget Allocation ViewSet
# ==============================================================================
from rest_framework import serializers as drf_serializers
class SiteAIBudgetAllocationSerializer(drf_serializers.Serializer):
"""Serializer for SiteAIBudgetAllocation model"""
id = drf_serializers.IntegerField(read_only=True)
ai_function = drf_serializers.CharField()
ai_function_display = drf_serializers.SerializerMethodField()
allocation_percentage = drf_serializers.IntegerField(min_value=0, max_value=100)
is_enabled = drf_serializers.BooleanField()
def get_ai_function_display(self, obj):
display_map = {
'clustering': 'Keyword Clustering (Stage 1)',
'idea_generation': 'Ideas Generation (Stage 2)',
'content_generation': 'Content Generation (Stage 4)',
'image_prompt': 'Image Prompt Extraction (Stage 5)',
'image_generation': 'Image Generation (Stage 6)',
}
if hasattr(obj, 'ai_function'):
return display_map.get(obj.ai_function, obj.ai_function)
return display_map.get(obj.get('ai_function', ''), '')
@extend_schema_view(
list=extend_schema(tags=['Billing'], summary='Get AI budget allocations for a site'),
create=extend_schema(tags=['Billing'], summary='Update AI budget allocations for a site'),
)
class SiteAIBudgetAllocationViewSet(viewsets.ViewSet):
"""
ViewSet for managing Site AI Budget Allocations.
GET /api/v1/billing/sites/{site_id}/ai-budget/
POST /api/v1/billing/sites/{site_id}/ai-budget/
Allows configuring what percentage of the site's credit budget
can be used for each AI function during automation runs.
"""
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
authentication_classes = [JWTAuthentication]
throttle_scope = 'billing'
throttle_classes = [DebugScopedRateThrottle]
def _get_site(self, site_id, request):
"""Get site and verify user has access"""
from igny8_core.auth.models import Site
try:
site = Site.objects.get(id=int(site_id))
account = getattr(request, 'account', None)
if account and site.account != account:
return None
return site
except (Site.DoesNotExist, ValueError, TypeError):
return None
def list(self, request, site_id=None):
"""
Get AI budget allocations for a site.
Creates default allocations if they don't exist.
"""
from igny8_core.business.billing.models import SiteAIBudgetAllocation
site = self._get_site(site_id, request)
if not site:
return error_response(
message='Site not found or access denied',
errors=None,
status_code=status.HTTP_404_NOT_FOUND
)
account = getattr(request, 'account', None) or site.account
# Get or create default allocations
allocations = SiteAIBudgetAllocation.get_or_create_defaults_for_site(site, account)
# Calculate total allocation
total_percentage = sum(a.allocation_percentage for a in allocations if a.is_enabled)
serializer = SiteAIBudgetAllocationSerializer(allocations, many=True)
return success_response(
data={
'allocations': serializer.data,
'total_percentage': total_percentage,
'is_valid': total_percentage <= 100,
},
message='AI budget allocations retrieved'
)
def create(self, request, site_id=None):
"""
Update AI budget allocations for a site.
Body:
{
"allocations": [
{"ai_function": "clustering", "allocation_percentage": 15, "is_enabled": true},
{"ai_function": "idea_generation", "allocation_percentage": 10, "is_enabled": true},
{"ai_function": "content_generation", "allocation_percentage": 40, "is_enabled": true},
{"ai_function": "image_prompt", "allocation_percentage": 5, "is_enabled": true},
{"ai_function": "image_generation", "allocation_percentage": 30, "is_enabled": true}
]
}
"""
from igny8_core.business.billing.models import SiteAIBudgetAllocation
site = self._get_site(site_id, request)
if not site:
return error_response(
message='Site not found or access denied',
errors=None,
status_code=status.HTTP_404_NOT_FOUND
)
account = getattr(request, 'account', None) or site.account
allocations_data = request.data.get('allocations', [])
if not allocations_data:
return error_response(
message='No allocations provided',
errors={'allocations': ['This field is required']},
status_code=status.HTTP_400_BAD_REQUEST
)
# Validate total percentage
total_percentage = sum(
a.get('allocation_percentage', 0)
for a in allocations_data
if a.get('is_enabled', True)
)
if total_percentage > 100:
return error_response(
message='Total allocation exceeds 100%',
errors={'total_percentage': [f'Total is {total_percentage}%, must be <= 100%']},
status_code=status.HTTP_400_BAD_REQUEST
)
# Update or create allocations
valid_functions = ['clustering', 'idea_generation', 'content_generation', 'image_prompt', 'image_generation']
updated = []
for alloc_data in allocations_data:
ai_function = alloc_data.get('ai_function')
if ai_function not in valid_functions:
continue
allocation, _ = SiteAIBudgetAllocation.objects.update_or_create(
account=account,
site=site,
ai_function=ai_function,
defaults={
'allocation_percentage': alloc_data.get('allocation_percentage', 20),
'is_enabled': alloc_data.get('is_enabled', True),
}
)
updated.append(allocation)
serializer = SiteAIBudgetAllocationSerializer(updated, many=True)
return success_response(
data={
'allocations': serializer.data,
'total_percentage': total_percentage,
},
message='AI budget allocations updated successfully'
)

View File

@@ -10,6 +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
router = DefaultRouter() router = DefaultRouter()
router.register(r'integrations', IntegrationViewSet, basename='integration') router.register(r'integrations', IntegrationViewSet, basename='integration')
@@ -21,12 +22,21 @@ publishing_settings_viewset = PublishingSettingsViewSet.as_view({
'patch': 'partial_update', 'patch': 'partial_update',
}) })
# Create Unified Settings ViewSet instance
unified_settings_viewset = UnifiedSiteSettingsViewSet.as_view({
'get': 'retrieve',
'put': 'update',
})
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),
# 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'),
# Unified site settings (AI & Automation consolidated)
path('sites/<int:site_id>/unified-settings/', unified_settings_viewset, name='unified-settings'),
# Webhook endpoints # Webhook endpoints
path('webhooks/wordpress/status/', wordpress_status_webhook, name='wordpress-status-webhook'), path('webhooks/wordpress/status/', wordpress_status_webhook, name='wordpress-status-webhook'),
path('webhooks/wordpress/metadata/', wordpress_metadata_webhook, name='wordpress-metadata-webhook'), path('webhooks/wordpress/metadata/', wordpress_metadata_webhook, name='wordpress-metadata-webhook'),

View File

@@ -966,11 +966,16 @@ class PublishingSettingsSerializer(serializers.ModelSerializer):
'site', 'site',
'auto_approval_enabled', 'auto_approval_enabled',
'auto_publish_enabled', 'auto_publish_enabled',
'scheduling_mode',
'daily_publish_limit', 'daily_publish_limit',
'weekly_publish_limit', 'weekly_publish_limit',
'monthly_publish_limit', 'monthly_publish_limit',
'publish_days', 'publish_days',
'publish_time_slots', 'publish_time_slots',
'stagger_start_time',
'stagger_end_time',
'stagger_interval_minutes',
'queue_limit',
'created_at', 'created_at',
'updated_at', 'updated_at',
] ]

View File

@@ -706,34 +706,27 @@ UNFOLD = {
{"title": "Account Payment Methods", "icon": "account_balance_wallet", "link": lambda request: "/admin/billing/accountpaymentmethod/"}, {"title": "Account Payment Methods", "icon": "account_balance_wallet", "link": lambda request: "/admin/billing/accountpaymentmethod/"},
], ],
}, },
# Credits # Credits & AI Usage (CONSOLIDATED)
{ {
"title": "Credits", "title": "Credits & AI Usage",
"icon": "toll", "icon": "toll",
"collapsible": True, "collapsible": True,
"items": [ "items": [
{"title": "Transactions", "icon": "swap_horiz", "link": lambda request: "/admin/billing/credittransaction/"}, {"title": "Credit Transactions", "icon": "swap_horiz", "link": lambda request: "/admin/billing/credittransaction/"},
{"title": "Usage Log", "icon": "history", "link": lambda request: "/admin/billing/creditusagelog/"}, {"title": "Credit Usage Log", "icon": "history", "link": lambda request: "/admin/billing/creditusagelog/"},
{"title": "AI Task Logs", "icon": "smart_toy", "link": lambda request: "/admin/ai/aitasklog/"},
{"title": "Plan Limits", "icon": "speed", "link": lambda request: "/admin/billing/planlimitusage/"}, {"title": "Plan Limits", "icon": "speed", "link": lambda request: "/admin/billing/planlimitusage/"},
], ],
}, },
# Planning # Content Pipeline (RENAMED from Planning + Writing)
{ {
"title": "Planning", "title": "Content Pipeline",
"icon": "map", "icon": "edit_note",
"collapsible": True, "collapsible": True,
"items": [ "items": [
{"title": "Keywords", "icon": "key", "link": lambda request: "/admin/planner/keywords/"}, {"title": "Keywords", "icon": "key", "link": lambda request: "/admin/planner/keywords/"},
{"title": "Clusters", "icon": "hub", "link": lambda request: "/admin/planner/clusters/"}, {"title": "Clusters", "icon": "hub", "link": lambda request: "/admin/planner/clusters/"},
{"title": "Content Ideas", "icon": "lightbulb", "link": lambda request: "/admin/planner/contentideas/"}, {"title": "Content Ideas", "icon": "lightbulb", "link": lambda request: "/admin/planner/contentideas/"},
],
},
# Writing
{
"title": "Writing",
"icon": "edit_note",
"collapsible": True,
"items": [
{"title": "Tasks", "icon": "task_alt", "link": lambda request: "/admin/writer/tasks/"}, {"title": "Tasks", "icon": "task_alt", "link": lambda request: "/admin/writer/tasks/"},
{"title": "Content", "icon": "description", "link": lambda request: "/admin/writer/content/"}, {"title": "Content", "icon": "description", "link": lambda request: "/admin/writer/content/"},
{"title": "Images", "icon": "image", "link": lambda request: "/admin/writer/images/"}, {"title": "Images", "icon": "image", "link": lambda request: "/admin/writer/images/"},
@@ -758,10 +751,31 @@ UNFOLD = {
"icon": "publish", "icon": "publish",
"collapsible": True, "collapsible": True,
"items": [ "items": [
{"title": "Integrations", "icon": "extension", "link": lambda request: "/admin/integration/siteintegration/"},
{"title": "Publishing Records", "icon": "cloud_upload", "link": lambda request: "/admin/publishing/publishingrecord/"}, {"title": "Publishing Records", "icon": "cloud_upload", "link": lambda request: "/admin/publishing/publishingrecord/"},
{"title": "Deployments", "icon": "rocket", "link": lambda request: "/admin/publishing/deploymentrecord/"}, {"title": "Deployments", "icon": "rocket", "link": lambda request: "/admin/publishing/deploymentrecord/"},
{"title": "Sync Events", "icon": "sync", "link": lambda request: "/admin/integration/syncevent/"}, {"title": "Sync Events", "icon": "sync", "link": lambda request: "/admin/integration/syncevent/"},
{"title": "Publishing Settings", "icon": "schedule", "link": lambda request: "/admin/integration/publishingsettings/"},
],
},
# Automation (NEW SECTION)
{
"title": "Automation",
"icon": "settings_suggest",
"collapsible": True,
"items": [
{"title": "Automation Configs", "icon": "tune", "link": lambda request: "/admin/automation/automationconfig/"},
{"title": "Automation Runs", "icon": "play_circle", "link": lambda request: "/admin/automation/automationrun/"},
],
},
# AI Configuration (SIMPLIFIED)
{
"title": "AI Configuration",
"icon": "psychology",
"collapsible": True,
"items": [
{"title": "AI Models (Testing/Live)", "icon": "model_training", "link": lambda request: "/admin/billing/aimodelconfig/"},
{"title": "System AI Settings", "icon": "tune", "link": lambda request: "/admin/system/systemaisettings/"},
{"title": "Integration Providers", "icon": "key", "link": lambda request: "/admin/system/integrationprovider/"},
], ],
}, },
# Plugin Management # Plugin Management
@@ -776,20 +790,7 @@ UNFOLD = {
{"title": "Downloads", "icon": "download", "link": lambda request: "/admin/plugins/plugindownload/"}, {"title": "Downloads", "icon": "download", "link": lambda request: "/admin/plugins/plugindownload/"},
], ],
}, },
# AI Configuration # Email Settings
{
"title": "AI Configuration",
"icon": "psychology",
"collapsible": True,
"items": [
{"title": "System AI Settings", "icon": "tune", "link": lambda request: "/admin/system/systemaisettings/"},
{"title": "AI Models", "icon": "model_training", "link": lambda request: "/admin/billing/aimodelconfig/"},
{"title": "Credit Costs by Function", "icon": "calculate", "link": lambda request: "/admin/billing/creditcostconfig/"},
{"title": "Billing Configuration", "icon": "payments", "link": lambda request: "/admin/billing/billingconfiguration/"},
{"title": "AI Task Logs", "icon": "history", "link": lambda request: "/admin/ai/aitasklog/"},
],
},
# Email Settings (NEW)
{ {
"title": "Email Settings", "title": "Email Settings",
"icon": "email", "icon": "email",
@@ -798,33 +799,29 @@ UNFOLD = {
{"title": "Email Configuration", "icon": "settings", "link": lambda request: "/admin/system/emailsettings/"}, {"title": "Email Configuration", "icon": "settings", "link": lambda request: "/admin/system/emailsettings/"},
{"title": "Email Templates", "icon": "article", "link": lambda request: "/admin/system/emailtemplate/"}, {"title": "Email Templates", "icon": "article", "link": lambda request: "/admin/system/emailtemplate/"},
{"title": "Email Logs", "icon": "history", "link": lambda request: "/admin/system/emaillog/"}, {"title": "Email Logs", "icon": "history", "link": lambda request: "/admin/system/emaillog/"},
{"title": "Resend Provider", "icon": "key", "link": lambda request: "/admin/system/integrationprovider/resend/change/"},
], ],
}, },
# Global Settings # Global Settings (SIMPLIFIED)
{ {
"title": "Global Settings", "title": "Global Settings",
"icon": "settings", "icon": "settings",
"collapsible": True, "collapsible": True,
"items": [ "items": [
{"title": "Integration Providers", "icon": "key", "link": lambda request: "/admin/system/integrationprovider/"},
{"title": "Global AI Prompts", "icon": "chat", "link": lambda request: "/admin/system/globalaiprompt/"}, {"title": "Global AI Prompts", "icon": "chat", "link": lambda request: "/admin/system/globalaiprompt/"},
{"title": "Automation Configs", "icon": "settings_suggest", "link": lambda request: "/admin/automation/automationconfig/"},
{"title": "Automation Runs", "icon": "play_circle", "link": lambda request: "/admin/automation/automationrun/"},
{"title": "Module Settings", "icon": "view_module", "link": lambda request: "/admin/system/globalmodulesettings/"}, {"title": "Module Settings", "icon": "view_module", "link": lambda request: "/admin/system/globalmodulesettings/"},
{"title": "Author Profiles", "icon": "person_outline", "link": lambda request: "/admin/system/globalauthorprofile/"}, {"title": "Author Profiles", "icon": "person_outline", "link": lambda request: "/admin/system/globalauthorprofile/"},
{"title": "Strategies", "icon": "strategy", "link": lambda request: "/admin/system/globalstrategy/"}, {"title": "Strategies", "icon": "strategy", "link": lambda request: "/admin/system/globalstrategy/"},
], ],
}, },
# System Configuration # Account & User Settings (CONSOLIDATED)
{ {
"title": "System Configuration", "title": "Account & User Settings",
"icon": "tune", "icon": "tune",
"collapsible": True, "collapsible": True,
"items": [ "items": [
{"title": "Account Settings (All Settings)", "icon": "account_circle", "link": lambda request: "/admin/system/accountsettings/"}, {"title": "Account Settings", "icon": "account_circle", "link": lambda request: "/admin/system/accountsettings/"},
{"title": "User Settings", "icon": "person_search", "link": lambda request: "/admin/system/usersettings/"}, {"title": "User Settings", "icon": "person_search", "link": lambda request: "/admin/system/usersettings/"},
{"title": "Module Settings", "icon": "view_module", "link": lambda request: "/admin/system/modulesettings/"}, {"title": "Module Enable Settings", "icon": "view_module", "link": lambda request: "/admin/system/modulesettings/"},
], ],
}, },
# Resources # Resources

View File

@@ -68,7 +68,23 @@ def schedule_approved_content() -> Dict[str, Any]:
results['sites_processed'] += 1 results['sites_processed'] += 1
continue continue
# Calculate available slots # Handle immediate mode - schedule for now (will be picked up by process_scheduled_publications)
if settings.scheduling_mode == 'immediate':
for content in pending_content:
content.scheduled_publish_at = timezone.now()
content.site_status = 'scheduled'
content.site_status_updated_at = timezone.now()
content.save(update_fields=['scheduled_publish_at', 'site_status', 'site_status_updated_at'])
site_result['scheduled_count'] += 1
results['content_scheduled'] += 1
logger.info(f"Scheduled content {content.id} for immediate publishing")
results['details'].append(site_result)
results['sites_processed'] += 1
continue
# Calculate available slots for time_slots and stagger modes
available_slots = _calculate_available_slots(settings, site) available_slots = _calculate_available_slots(settings, site)
# Assign slots to content # Assign slots to content
@@ -110,6 +126,11 @@ def _calculate_available_slots(settings: 'PublishingSettings', site: 'Site') ->
""" """
Calculate available publishing time slots based on settings and limits. Calculate available publishing time slots based on settings and limits.
Supports three scheduling modes:
- time_slots: Publish at specific configured times each day
- stagger: Spread evenly throughout publish hours
- immediate: No scheduling - return immediately (handled separately)
Args: Args:
settings: PublishingSettings instance settings: PublishingSettings instance
site: Site instance site: Site instance
@@ -120,13 +141,13 @@ def _calculate_available_slots(settings: 'PublishingSettings', site: 'Site') ->
from igny8_core.business.content.models import Content from igny8_core.business.content.models import Content
now = timezone.now() now = timezone.now()
slots = []
# Get configured days and times # Immediate mode - return empty list (content published immediately in process_scheduled_publications)
if settings.scheduling_mode == 'immediate':
return []
# Common setup
publish_days = settings.publish_days or ['mon', 'tue', 'wed', 'thu', 'fri'] publish_days = settings.publish_days or ['mon', 'tue', 'wed', 'thu', 'fri']
publish_times = settings.publish_time_slots or ['09:00', '14:00', '18:00']
# Day name mapping
day_map = { day_map = {
'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3, 'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3,
'fri': 4, 'sat': 5, 'sun': 6 'fri': 4, 'sat': 5, 'sun': 6
@@ -134,22 +155,11 @@ def _calculate_available_slots(settings: 'PublishingSettings', site: 'Site') ->
allowed_days = [day_map.get(d.lower(), -1) for d in publish_days] allowed_days = [day_map.get(d.lower(), -1) for d in publish_days]
allowed_days = [d for d in allowed_days if d >= 0] allowed_days = [d for d in allowed_days if d >= 0]
# Parse time slots
time_slots = []
for time_str in publish_times:
try:
hour, minute = map(int, time_str.split(':'))
time_slots.append((hour, minute))
except (ValueError, AttributeError):
continue
if not time_slots:
time_slots = [(9, 0), (14, 0), (18, 0)]
# Calculate limits # Calculate limits
daily_limit = settings.daily_publish_limit daily_limit = settings.daily_publish_limit
weekly_limit = settings.weekly_publish_limit weekly_limit = settings.weekly_publish_limit
monthly_limit = settings.monthly_publish_limit monthly_limit = settings.monthly_publish_limit
queue_limit = getattr(settings, 'queue_limit', 100) or 100
# Count existing scheduled/published content # Count existing scheduled/published content
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
@@ -174,14 +184,53 @@ def _calculate_available_slots(settings: 'PublishingSettings', site: 'Site') ->
scheduled_publish_at__gte=month_start scheduled_publish_at__gte=month_start
).count() ).count()
# Generate slots for next 30 days # Route to appropriate slot generator
current_date = now.date() if settings.scheduling_mode == 'stagger':
slots_per_day = {} # Track slots used per day return _generate_stagger_slots(
settings, site, now, allowed_days,
daily_limit, weekly_limit, monthly_limit, queue_limit,
daily_count, weekly_count, monthly_count
)
else:
# Default to time_slots mode
return _generate_time_slot_slots(
settings, site, now, allowed_days,
daily_limit, weekly_limit, monthly_limit, queue_limit,
daily_count, weekly_count, monthly_count
)
def _generate_time_slot_slots(
settings, site, now, allowed_days,
daily_limit, weekly_limit, monthly_limit, queue_limit,
daily_count, weekly_count, monthly_count
) -> list:
"""Generate slots based on specific time slots (original mode)."""
from igny8_core.business.content.models import Content
for day_offset in range(30): slots = []
publish_times = settings.publish_time_slots or ['09:00', '14:00', '18:00']
# Parse time slots
time_slots = []
for time_str in publish_times:
try:
hour, minute = map(int, time_str.split(':'))
time_slots.append((hour, minute))
except (ValueError, AttributeError):
continue
if not time_slots:
time_slots = [(9, 0), (14, 0), (18, 0)]
current_date = now.date()
slots_per_day = {}
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_start = today_start - timedelta(days=now.weekday())
for day_offset in range(90): # Look 90 days ahead
check_date = current_date + timedelta(days=day_offset) check_date = current_date + timedelta(days=day_offset)
# Check if day is allowed
if check_date.weekday() not in allowed_days: if check_date.weekday() not in allowed_days:
continue continue
@@ -216,8 +265,111 @@ def _calculate_available_slots(settings: 'PublishingSettings', site: 'Site') ->
slots.append(slot_time) slots.append(slot_time)
slots_per_day[day_key] = slots_per_day.get(day_key, 0) + 1 slots_per_day[day_key] = slots_per_day.get(day_key, 0) + 1
# Limit total slots to prevent memory issues # Respect queue limit
if len(slots) >= 100: if len(slots) >= queue_limit:
return slots
return slots
def _generate_stagger_slots(
settings, site, now, allowed_days,
daily_limit, weekly_limit, monthly_limit, queue_limit,
daily_count, weekly_count, monthly_count
) -> list:
"""
Generate slots spread evenly throughout the publishing window.
Distributes content throughout the day based on stagger_start_time,
stagger_end_time, and stagger_interval_minutes.
"""
from igny8_core.business.content.models import Content
slots = []
# Get stagger settings with defaults
start_hour, start_minute = 9, 0
end_hour, end_minute = 18, 0
if hasattr(settings, 'stagger_start_time') and settings.stagger_start_time:
start_hour = settings.stagger_start_time.hour
start_minute = settings.stagger_start_time.minute
if hasattr(settings, 'stagger_end_time') and settings.stagger_end_time:
end_hour = settings.stagger_end_time.hour
end_minute = settings.stagger_end_time.minute
interval_minutes = getattr(settings, 'stagger_interval_minutes', 30) or 30
interval = timedelta(minutes=interval_minutes)
current_date = now.date()
slots_per_day = {}
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_start = today_start - timedelta(days=now.weekday())
for day_offset in range(90): # Look 90 days ahead
check_date = current_date + timedelta(days=day_offset)
if check_date.weekday() not in allowed_days:
continue
# Day's publishing window
day_start = timezone.make_aware(
datetime.combine(check_date, datetime.min.time().replace(hour=start_hour, minute=start_minute))
)
day_end = timezone.make_aware(
datetime.combine(check_date, datetime.min.time().replace(hour=end_hour, minute=end_minute))
)
# Get existing scheduled times for this day to avoid conflicts
existing_times = set(
Content.objects.filter(
site=site,
site_status='scheduled',
scheduled_publish_at__date=check_date
).values_list('scheduled_publish_at', flat=True)
)
# Start slot calculation
current_slot = day_start
if check_date == current_date and now > day_start:
# Start from next interval after now
minutes_since_start = (now - day_start).total_seconds() / 60
intervals_passed = int(minutes_since_start / interval_minutes) + 1
current_slot = day_start + timedelta(minutes=intervals_passed * interval_minutes)
day_key = check_date.isoformat()
while current_slot <= day_end:
# Check daily limit
slots_this_day = slots_per_day.get(day_key, 0)
if daily_limit and (daily_count + slots_this_day) >= daily_limit:
break # Move to next day
# Check weekly limit
slot_week_start = current_slot - timedelta(days=current_slot.weekday())
if slot_week_start.date() == week_start.date():
scheduled_in_week = weekly_count + len([s for s in slots if s >= week_start])
if weekly_limit and scheduled_in_week >= weekly_limit:
current_slot += interval
continue
# Check monthly limit
if current_slot.month == now.month and current_slot.year == now.year:
scheduled_in_month = monthly_count + len([s for s in slots if s.month == now.month])
if monthly_limit and scheduled_in_month >= monthly_limit:
current_slot += interval
continue
# Avoid existing scheduled times
if current_slot not in existing_times:
slots.append(current_slot)
slots_per_day[day_key] = slots_per_day.get(day_key, 0) + 1
current_slot += interval
# Respect queue limit
if len(slots) >= queue_limit:
return slots return slots
return slots return slots

View File

@@ -0,0 +1,56 @@
# Generated manually for adding per-run limits to AutomationConfig
# Run: python manage.py migrate
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core', '0002_wordpress_sync_fields'), # Adjust based on your actual dependencies
]
operations = [
# Per-run item limits (0 = unlimited)
migrations.AddField(
model_name='automationconfig',
name='max_keywords_per_run',
field=models.IntegerField(default=0, help_text='Max keywords to process in stage 1 (0=unlimited)'),
),
migrations.AddField(
model_name='automationconfig',
name='max_clusters_per_run',
field=models.IntegerField(default=0, help_text='Max clusters to process in stage 2 (0=unlimited)'),
),
migrations.AddField(
model_name='automationconfig',
name='max_ideas_per_run',
field=models.IntegerField(default=0, help_text='Max ideas to process in stage 3 (0=unlimited)'),
),
migrations.AddField(
model_name='automationconfig',
name='max_tasks_per_run',
field=models.IntegerField(default=0, help_text='Max tasks to process in stage 4 (0=unlimited)'),
),
migrations.AddField(
model_name='automationconfig',
name='max_content_per_run',
field=models.IntegerField(default=0, help_text='Max content pieces for image prompts in stage 5 (0=unlimited)'),
),
migrations.AddField(
model_name='automationconfig',
name='max_images_per_run',
field=models.IntegerField(default=0, help_text='Max images to generate in stage 6 (0=unlimited)'),
),
migrations.AddField(
model_name='automationconfig',
name='max_approvals_per_run',
field=models.IntegerField(default=0, help_text='Max content pieces to auto-approve in stage 7 (0=unlimited)'),
),
# Credit budget limit per run
migrations.AddField(
model_name='automationconfig',
name='max_credits_per_run',
field=models.IntegerField(default=0, help_text='Max credits to use per run (0=unlimited)'),
),
]

View File

@@ -0,0 +1,60 @@
# Generated manually for adding new publishing settings fields
# Run: python manage.py migrate
from django.db import migrations, models
from django.core.validators import MinValueValidator
class Migration(migrations.Migration):
dependencies = [
('integration', '0001_initial'), # Adjust based on actual dependency
]
operations = [
# Scheduling mode
migrations.AddField(
model_name='publishingsettings',
name='scheduling_mode',
field=models.CharField(
max_length=20,
choices=[
('time_slots', 'Time Slots - Publish at specific times each day'),
('stagger', 'Staggered - Spread evenly throughout publish hours'),
('immediate', 'Immediate - Publish as soon as approved'),
],
default='time_slots',
help_text='How to schedule content for publishing'
),
),
# Stagger mode settings
migrations.AddField(
model_name='publishingsettings',
name='stagger_start_time',
field=models.TimeField(default='09:00', help_text='Start time for staggered publishing window'),
),
migrations.AddField(
model_name='publishingsettings',
name='stagger_end_time',
field=models.TimeField(default='18:00', help_text='End time for staggered publishing window'),
),
migrations.AddField(
model_name='publishingsettings',
name='stagger_interval_minutes',
field=models.PositiveIntegerField(
default=30,
validators=[MinValueValidator(5)],
help_text='Minimum minutes between staggered publications'
),
),
# Queue settings
migrations.AddField(
model_name='publishingsettings',
name='queue_limit',
field=models.PositiveIntegerField(
default=100,
validators=[MinValueValidator(1)],
help_text='Maximum items that can be scheduled at once'
),
),
]

View File

@@ -0,0 +1,77 @@
# Generated manually for adding SiteAIBudgetAllocation model
# Run: python manage.py migrate
from django.db import migrations, models
from django.core.validators import MinValueValidator
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('billing', '0001_initial'), # Adjust based on actual dependency
('igny8_core_auth', '0001_initial'), # Adjust based on actual dependency
]
operations = [
migrations.CreateModel(
name='SiteAIBudgetAllocation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ai_function', models.CharField(
max_length=50,
choices=[
('clustering', 'Keyword Clustering (Stage 1)'),
('idea_generation', 'Ideas Generation (Stage 2)'),
('content_generation', 'Content Generation (Stage 4)'),
('image_prompt', 'Image Prompt Extraction (Stage 5)'),
('image_generation', 'Image Generation (Stage 6)'),
],
help_text='AI function to allocate budget for'
)),
('allocation_percentage', models.PositiveIntegerField(
default=20,
validators=[MinValueValidator(0)],
help_text='Percentage of credit budget allocated to this function (0-100)'
)),
('is_enabled', models.BooleanField(
default=True,
help_text='Whether this function is enabled for automation'
)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('account', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='site_ai_budget_allocations',
to='igny8_core_auth.account'
)),
('site', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='ai_budget_allocations',
to='igny8_core_auth.site',
help_text='Site this allocation belongs to'
)),
],
options={
'verbose_name': 'Site AI Budget Allocation',
'verbose_name_plural': 'Site AI Budget Allocations',
'db_table': 'igny8_site_ai_budget_allocations',
'ordering': ['site', 'ai_function'],
},
),
migrations.AddConstraint(
model_name='siteaibudgetallocation',
constraint=models.UniqueConstraint(
fields=['site', 'ai_function'],
name='unique_site_ai_function'
),
),
migrations.AddIndex(
model_name='siteaibudgetallocation',
index=models.Index(fields=['site', 'is_enabled'], name='igny8_site_ai_site_enabled_idx'),
),
migrations.AddIndex(
model_name='siteaibudgetallocation',
index=models.Index(fields=['account', 'site'], name='igny8_site_ai_account_site_idx'),
),
]

View File

@@ -0,0 +1,765 @@
# Automation System Enhancement Plan
**Created:** January 17, 2026
**Updated:** January 17, 2026 (IMPLEMENTATION COMPLETE)
**Status:** ✅ ALL PHASES COMPLETE
**Priority:** 🔴 CRITICAL - Blocks Production Launch
---
## Implementation Progress
### ✅ PHASE 1: Bug Fixes (COMPLETE)
1. **Bug #1:** Cancel releases lock - [views.py](../../backend/igny8_core/business/automation/views.py)
2. **Bug #2:** Scheduled check includes 'paused' - [tasks.py](../../backend/igny8_core/business/automation/tasks.py)
3. **Bug #3:** Resume reacquires lock - [tasks.py](../../backend/igny8_core/business/automation/tasks.py)
4. **Bug #4:** Resume has pause/cancel checks - [tasks.py](../../backend/igny8_core/business/automation/tasks.py)
5. **Bug #5:** Pause logs to files - [views.py](../../backend/igny8_core/business/automation/views.py)
6. **Bug #6:** Resume exception releases lock - [tasks.py](../../backend/igny8_core/business/automation/tasks.py)
### ✅ PHASE 2: Per-Run Item Limits (COMPLETE)
- Added 8 new fields to `AutomationConfig` model:
- `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`
- Migration: [0014_automation_per_run_limits.py](../../backend/migrations/0014_automation_per_run_limits.py)
- Service: Updated `automation_service.py` with `_get_per_run_limit()`, `_apply_per_run_limit()`, `_check_credit_budget()`
- API: Updated config endpoints in views.py
### ✅ PHASE 3: Publishing Settings Overhaul (COMPLETE)
- Added scheduling modes: `time_slots`, `stagger`, `immediate`
- New fields: `scheduling_mode`, `stagger_start_time`, `stagger_end_time`, `stagger_interval_minutes`, `queue_limit`
- Migration: [0015_publishing_settings_overhaul.py](../../backend/migrations/0015_publishing_settings_overhaul.py)
- Scheduler: Updated `_calculate_available_slots()` with three mode handlers
### ✅ PHASE 4: Credit % Allocation per AI Function (COMPLETE)
- New model: `SiteAIBudgetAllocation` in billing/models.py
- Default allocations: 15% clustering, 10% ideas, 40% content, 5% prompts, 30% images
- Migration: [0016_site_ai_budget_allocation.py](../../backend/migrations/0016_site_ai_budget_allocation.py)
- API: New viewset at `/api/v1/billing/sites/{site_id}/ai-budget/`
### ✅ PHASE 5: UI Updates (COMPLETE)
- Updated `AutomationConfig` interface in `automationService.ts` with new per-run limit fields
- GlobalProgressBar already implements correct calculation using `initial_snapshot`
---
## Migrations To Run
```bash
cd /data/app/igny8/backend
python manage.py migrate
```
## Files Modified
### Backend
- `backend/igny8_core/business/automation/views.py` - Cancel releases lock, pause logs
- `backend/igny8_core/business/automation/tasks.py` - Resume fixes, scheduled check
- `backend/igny8_core/business/automation/models.py` - Per-run limit fields
- `backend/igny8_core/business/automation/services/automation_service.py` - Limit enforcement
- `backend/igny8_core/business/integration/models.py` - Publishing modes
- `backend/igny8_core/business/billing/models.py` - SiteAIBudgetAllocation
- `backend/igny8_core/modules/billing/views.py` - AI budget viewset
- `backend/igny8_core/modules/billing/urls.py` - AI budget route
- `backend/igny8_core/modules/integration/views.py` - Publishing serializer
- `backend/igny8_core/tasks/publishing_scheduler.py` - Scheduling modes
### Frontend
- `frontend/src/services/automationService.ts` - Config interface updated
### Migrations
- `backend/migrations/0014_automation_per_run_limits.py`
- `backend/migrations/0015_publishing_settings_overhaul.py`
- `backend/migrations/0016_site_ai_budget_allocation.py`
---
## Executive Summary
This plan addresses critical automation bugs and introduces 4 major enhancements:
1. **Fix Critical Automation Bugs** - Lock management, scheduled runs, logging
2. **Credit Budget Allocation** - Configurable % per AI function
3. **Publishing Schedule Overhaul** - Robust, predictable scheduling
4. **Per-Run Item Limits** - Control throughput per automation run
---
## Part 1: Critical Bug Fixes ✅ COMPLETE
### 🔴 BUG #1: Cancel Action Doesn't Release Lock
**Location:** `backend/igny8_core/business/automation/views.py` line ~1614
**Current Code:**
```python
def cancel_automation(self, request):
run.status = 'cancelled'
run.cancelled_at = timezone.now()
run.completed_at = timezone.now()
run.save(update_fields=['status', 'cancelled_at', 'completed_at'])
# ❌ MISSING: cache.delete(f'automation_lock_{run.site.id}')
```
**Fix:**
```python
def cancel_automation(self, request):
run.status = 'cancelled'
run.cancelled_at = timezone.now()
run.completed_at = timezone.now()
run.save(update_fields=['status', 'cancelled_at', 'completed_at'])
# Release the lock so user can start new automation
from django.core.cache import cache
cache.delete(f'automation_lock_{run.site.id}')
# Log the cancellation
from igny8_core.business.automation.services.automation_logger import AutomationLogger
logger = AutomationLogger()
logger.log_stage_progress(
run.run_id, run.account.id, run.site.id, run.current_stage,
f"Automation cancelled by user"
)
```
**Impact:** Users can immediately start new automation after cancelling
---
### 🔴 BUG #2: Scheduled Automation Doesn't Check 'paused' Status
**Location:** `backend/igny8_core/business/automation/tasks.py` line ~52
**Current Code:**
```python
# Check if already running
if AutomationRun.objects.filter(site=config.site, status='running').exists():
logger.info(f"[AutomationTask] Skipping site {config.site.id} - already running")
continue
```
**Fix:**
```python
# Check if already running OR paused
if AutomationRun.objects.filter(site=config.site, status__in=['running', 'paused']).exists():
logger.info(f"[AutomationTask] Skipping site {config.site.id} - automation in progress (running/paused)")
continue
```
**Impact:** Prevents duplicate runs when one is paused
---
### 🔴 BUG #3: Resume Doesn't Reacquire Lock
**Location:** `backend/igny8_core/business/automation/tasks.py` line ~164
**Current Code:**
```python
def resume_automation_task(self, run_id: str):
service = AutomationService.from_run_id(run_id)
# ❌ No lock check - could run unprotected after 6hr expiry
```
**Fix:**
```python
def resume_automation_task(self, run_id: str):
"""Resume paused automation run from current stage"""
logger.info(f"[AutomationTask] Resuming automation run: {run_id}")
try:
run = AutomationRun.objects.get(run_id=run_id)
# Verify run is actually in 'running' status (set by views.resume)
if run.status != 'running':
logger.warning(f"[AutomationTask] Run {run_id} status is {run.status}, not 'running'. Aborting resume.")
return
# Reacquire lock in case it expired during long pause
from django.core.cache import cache
lock_key = f'automation_lock_{run.site.id}'
# Try to acquire - if fails, another run may have started
if not cache.add(lock_key, 'locked', timeout=21600):
# Check if WE still own it (compare run_id if stored)
existing = cache.get(lock_key)
if existing and existing != 'locked':
logger.warning(f"[AutomationTask] Lock held by different run. Aborting resume for {run_id}")
run.status = 'failed'
run.error_message = 'Lock acquired by another run during pause'
run.save()
return
# Lock exists but may be ours - proceed cautiously
service = AutomationService.from_run_id(run_id)
# ... rest of processing with pause/cancel checks between stages
```
---
### 🔴 BUG #4: Resume Missing Pause/Cancel Checks Between Stages
**Location:** `backend/igny8_core/business/automation/tasks.py` line ~183
**Current Code:**
```python
for stage in range(run.current_stage - 1, 7):
if stage_enabled[stage]:
stage_methods[stage]()
# ❌ No pause/cancel check after each stage
```
**Fix:**
```python
for stage in range(run.current_stage - 1, 7):
if stage_enabled[stage]:
stage_methods[stage]()
# Check for pause/cancel AFTER each stage (same as run_automation_task)
service.run.refresh_from_db()
if service.run.status in ['paused', 'cancelled']:
logger.info(f"[AutomationTask] Resumed automation {service.run.status} after stage {stage + 1}")
return
else:
logger.info(f"[AutomationTask] Stage {stage + 1} is disabled, skipping")
```
---
### 🟡 BUG #5: Pause Missing File Log Entry
**Location:** `backend/igny8_core/business/automation/views.py` pause action
**Fix:** Add logging call:
```python
def pause(self, request):
# ... existing code ...
service.pause_automation()
# Log to automation files
service.logger.log_stage_progress(
service.run.run_id, service.account.id, service.site.id,
service.run.current_stage, f"Automation paused by user"
)
return Response({'message': 'Automation paused'})
```
---
## Part 2: Credit Budget Allocation System
### Overview
Add configurable credit % allocation per AI function. Users can:
- Use global defaults (configured by admin)
- Override with site-specific allocations
### Database Changes
**Extend `CreditCostConfig` model:**
```python
class CreditCostConfig(models.Model):
# ... existing fields ...
# NEW: Budget allocation percentage
budget_percentage = models.DecimalField(
max_digits=5,
decimal_places=2,
default=0,
validators=[MinValueValidator(0), MaxValueValidator(100)],
help_text="Default % of credits allocated to this operation (0-100)"
)
```
**New `SiteAIBudgetAllocation` model:**
```python
class SiteAIBudgetAllocation(AccountBaseModel):
"""Site-specific credit budget allocation overrides"""
site = models.OneToOneField(
'igny8_core_auth.Site',
on_delete=models.CASCADE,
related_name='ai_budget_allocation'
)
use_global_defaults = models.BooleanField(
default=True,
help_text="Use global CreditCostConfig percentages"
)
# Per-operation overrides (only used when use_global_defaults=False)
clustering_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=10)
idea_generation_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=10)
content_generation_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=40)
image_prompt_extraction_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=5)
image_generation_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=35)
class Meta:
db_table = 'igny8_site_ai_budget_allocations'
```
### Service Changes
**New `BudgetAllocationService`:**
```python
class BudgetAllocationService:
@staticmethod
def get_operation_budget(site, operation_type, total_credits):
"""
Get credits allocated for an operation based on site settings.
Args:
site: Site instance
operation_type: 'clustering', 'content_generation', etc.
total_credits: Total credits available
Returns:
int: Credits allocated for this operation
"""
allocation = SiteAIBudgetAllocation.objects.filter(site=site).first()
if not allocation or allocation.use_global_defaults:
# Use global CreditCostConfig percentages
config = CreditCostConfig.objects.filter(
operation_type=operation_type,
is_active=True
).first()
percentage = config.budget_percentage if config else 0
else:
# Use site-specific override
field_map = {
'clustering': 'clustering_percentage',
'idea_generation': 'idea_generation_percentage',
'content_generation': 'content_generation_percentage',
'image_prompt_extraction': 'image_prompt_extraction_percentage',
'image_generation': 'image_generation_percentage',
}
field = field_map.get(operation_type)
percentage = getattr(allocation, field, 0) if field else 0
return int(total_credits * (percentage / 100))
```
### Frontend Changes
**Site Settings > AI Settings Tab:**
- Add "Credit Budget Allocation" section
- Toggle: "Use Global Defaults" / "Custom Allocation"
- If custom: Show sliders for each operation (must sum to 100%)
- Visual pie chart showing allocation
---
## Part 3: Publishing Schedule Overhaul
### Current Issues
1. Limits are confusing - daily/weekly/monthly are treated as hard caps
2. Items not getting scheduled (30% missed in last run)
3. Time slot calculation doesn't account for stagger intervals
4. No visibility into WHY items weren't scheduled
### New Publishing Model
**Replace `PublishingSettings` with enhanced version:**
```python
class PublishingSettings(AccountBaseModel):
site = models.OneToOneField('igny8_core_auth.Site', on_delete=models.CASCADE)
# Auto-approval/publish toggles (keep existing)
auto_approval_enabled = models.BooleanField(default=True)
auto_publish_enabled = models.BooleanField(default=True)
# NEW: Scheduling configuration (replaces hard limits)
scheduling_mode = models.CharField(
max_length=20,
choices=[
('slots', 'Time Slots'), # Publish at specific times
('stagger', 'Staggered'), # Spread evenly throughout day
('immediate', 'Immediate'), # Publish as soon as approved
],
default='slots'
)
# Time slot configuration
publish_days = models.JSONField(
default=['mon', 'tue', 'wed', 'thu', 'fri'],
help_text="Days allowed for publishing"
)
publish_time_slots = models.JSONField(
default=['09:00', '14:00', '18:00'],
help_text="Specific times for slot mode"
)
# Stagger mode configuration
stagger_start_time = models.TimeField(default='09:00')
stagger_end_time = models.TimeField(default='18:00')
stagger_interval_minutes = models.IntegerField(
default=15,
help_text="Minutes between publications in stagger mode"
)
# Daily TARGET (soft limit - for estimation, not blocking)
daily_publish_target = models.IntegerField(
default=3,
help_text="Target articles per day (for scheduling spread)"
)
# Weekly/Monthly targets (informational only)
weekly_publish_target = models.IntegerField(default=15)
monthly_publish_target = models.IntegerField(default=50)
# NEW: Maximum queue depth (actual limit)
max_scheduled_queue = models.IntegerField(
default=100,
help_text="Maximum items that can be in 'scheduled' status at once"
)
```
### New Scheduling Algorithm
```python
def calculate_publishing_slots(settings, site, count_needed):
"""
Calculate publishing slots with NO arbitrary limits.
Returns:
List of (datetime, slot_info) tuples
"""
slots = []
now = timezone.now()
if settings.scheduling_mode == 'immediate':
# Return 'now' for all items
return [(now + timedelta(seconds=i*60), {'mode': 'immediate'}) for i in range(count_needed)]
elif settings.scheduling_mode == 'stagger':
# Spread throughout each day
return _calculate_stagger_slots(settings, site, count_needed, now)
else: # 'slots' mode
return _calculate_time_slot_slots(settings, site, count_needed, now)
def _calculate_stagger_slots(settings, site, count_needed, now):
"""
Stagger mode: Spread publications evenly throughout publish hours.
"""
slots = []
day_map = {'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3, 'fri': 4, 'sat': 5, 'sun': 6}
allowed_days = [day_map[d] for d in settings.publish_days if d in day_map]
current_date = now.date()
interval = timedelta(minutes=settings.stagger_interval_minutes)
for day_offset in range(90): # Look up to 90 days ahead
check_date = current_date + timedelta(days=day_offset)
if check_date.weekday() not in allowed_days:
continue
# Generate slots for this day
day_start = timezone.make_aware(
datetime.combine(check_date, settings.stagger_start_time)
)
day_end = timezone.make_aware(
datetime.combine(check_date, settings.stagger_end_time)
)
# Get existing scheduled for this day
existing = Content.objects.filter(
site=site,
site_status='scheduled',
scheduled_publish_at__date=check_date
).values_list('scheduled_publish_at', flat=True)
existing_times = set(existing)
current_slot = day_start
if check_date == current_date and now > day_start:
# Start from next interval after now
minutes_since_start = (now - day_start).total_seconds() / 60
intervals_passed = int(minutes_since_start / settings.stagger_interval_minutes) + 1
current_slot = day_start + timedelta(minutes=intervals_passed * settings.stagger_interval_minutes)
while current_slot <= day_end and len(slots) < count_needed:
if current_slot not in existing_times:
slots.append((current_slot, {'mode': 'stagger', 'date': str(check_date)}))
current_slot += interval
if len(slots) >= count_needed:
break
return slots
```
### Frontend Changes
**Site Settings > Publishing Tab - Redesign:**
```
┌─────────────────────────────────────────────────────────────────┐
│ Publishing Schedule │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Auto-Approval: [✓] Automatically approve content │
│ Auto-Publish: [✓] Automatically publish approved content │
│ │
│ ─── Scheduling Mode ─── │
│ ○ Time Slots - Publish at specific times each day │
│ ● Staggered - Spread evenly throughout publish hours │
│ ○ Immediate - Publish as soon as approved │
│ │
│ ─── Stagger Settings ─── │
│ Start Time: [09:00] End Time: [18:00] │
│ Interval: [15] minutes between publications │
│ │
│ ─── Publish Days ─── │
│ [✓] Mon [✓] Tue [✓] Wed [✓] Thu [✓] Fri [ ] Sat [ ] Sun │
│ │
│ ─── Targets (for estimation) ─── │
│ Daily: [3] Weekly: [15] Monthly: [50] │
│ │
│ ─── Current Queue ─── │
│ 📊 23 items scheduled │ Queue limit: 100 │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Part 4: Per-Run Item Limits
### Overview
Allow users to limit how many items are processed per automation run. This enables:
- Balancing content production with publishing capacity
- Predictable credit usage per run
- Gradual pipeline processing
### Database Changes
**Extend `AutomationConfig`:**
```python
class AutomationConfig(models.Model):
# ... existing fields ...
# NEW: Per-run limits (0 = unlimited)
max_keywords_per_run = models.IntegerField(
default=0,
help_text="Max keywords to cluster per run (0=unlimited)"
)
max_clusters_per_run = models.IntegerField(
default=0,
help_text="Max clusters to generate ideas for per run (0=unlimited)"
)
max_ideas_per_run = models.IntegerField(
default=0,
help_text="Max ideas to convert to tasks per run (0=unlimited)"
)
max_tasks_per_run = models.IntegerField(
default=0,
help_text="Max tasks to generate content for per run (0=unlimited)"
)
max_content_per_run = models.IntegerField(
default=0,
help_text="Max content to extract image prompts for per run (0=unlimited)"
)
max_images_per_run = models.IntegerField(
default=0,
help_text="Max images to generate per run (0=unlimited)"
)
max_approvals_per_run = models.IntegerField(
default=0,
help_text="Max content to auto-approve per run (0=unlimited)"
)
```
### Service Changes
**Modify stage methods to respect limits:**
```python
def run_stage_1(self):
"""Stage 1: Keywords → Clusters"""
# ... existing setup ...
# Apply per-run limit
max_per_run = self.config.max_keywords_per_run
if max_per_run > 0:
pending_keywords = pending_keywords[:max_per_run]
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
1, f"Per-run limit: Processing up to {max_per_run} keywords"
)
total_count = pending_keywords.count()
# ... rest of processing ...
```
### Frontend Changes
**Automation Settings Panel - Enhanced:**
```
┌─────────────────────────────────────────────────────────────────┐
│ Per-Run Limits │
│ Control how much is processed in each automation run │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Stage 1: Keywords → Clusters │
│ [ 50 ] keywords per run │ Current pending: 150 │
│ ⚡ Will take ~3 runs to process all │
│ │
│ Stage 2: Clusters → Ideas │
│ [ 10 ] clusters per run │ Current pending: 25 │
│ │
│ Stage 3: Ideas → Tasks │
│ [ 0 ] (unlimited) │ Current pending: 30 │
│ │
│ Stage 4: Tasks → Content │
│ [ 5 ] tasks per run │ Current pending: 30 │
│ 💡 Tip: Match with daily publish target for balanced flow │
│ │
│ Stage 5: Content → Image Prompts │
│ [ 5 ] content per run │ Current pending: 10 │
│ │
│ Stage 6: Image Prompts → Images │
│ [ 20 ] images per run │ Current pending: 50 │
│ │
│ Stage 7: Review → Approved │
│ [ 5 ] approvals per run│ Current in review: 15 │
│ ⚠️ Limited by publishing schedule capacity │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Part 5: UI/UX Fixes
### Automation Dashboard Issues
1. **Wrong metrics display** - Fix counts to show accurate pipeline state
2. **Confusing progress bars** - Use consistent calculation
3. **Missing explanations** - Add tooltips explaining each metric
### Run Detail Page Issues
1. **Stage results showing wrong data** - Fix JSON field mapping
2. **Missing "items remaining" after partial run** - Calculate from initial_snapshot
3. **No clear indication of WHY run stopped** - Show stopped_reason prominently
### Fixes
**GlobalProgressBar.tsx - Fix progress calculation:**
```typescript
// Use initial_snapshot as denominator, stage results as numerator
const calculateGlobalProgress = (run: AutomationRun): number => {
if (!run.initial_snapshot) return 0;
const total = run.initial_snapshot.total_initial_items || 0;
if (total === 0) return 0;
let processed = 0;
processed += run.stage_1_result?.keywords_processed || 0;
processed += run.stage_2_result?.clusters_processed || 0;
processed += run.stage_3_result?.ideas_processed || 0;
processed += run.stage_4_result?.tasks_processed || 0;
processed += run.stage_5_result?.content_processed || 0;
processed += run.stage_6_result?.images_processed || 0;
processed += run.stage_7_result?.approved_count || 0;
return Math.min(100, Math.round((processed / total) * 100));
};
```
---
## Implementation Order
### Phase 1: Critical Bug Fixes (Day 1)
1. ✅ Cancel releases lock
2. ✅ Scheduled check includes 'paused'
3. ✅ Resume reacquires lock
4. ✅ Resume has pause/cancel checks
5. ✅ Pause logs to files
### Phase 2: Per-Run Limits (Day 2)
1. Add model fields to AutomationConfig
2. Migration
3. Update automation_service.py stage methods
4. Frontend settings panel
5. Test with small limits
### Phase 3: Publishing Overhaul (Day 3)
1. Update PublishingSettings model
2. Migration
3. New scheduling algorithm
4. Frontend redesign
5. Test scheduling edge cases
### Phase 4: Credit Budget (Day 4)
1. Add model fields/new model
2. Migration
3. BudgetAllocationService
4. Frontend AI Settings section
5. Test budget calculations
### Phase 5: UI Fixes (Day 5)
1. Fix GlobalProgressBar
2. Fix AutomationPage metrics
3. Fix RunDetail display
4. Add helpful tooltips
5. End-to-end testing
---
## Testing Checklist
### Automation Flow
- [ ] Manual run starts, pauses, resumes, completes
- [ ] Manual run cancels, lock released, new run can start
- [ ] Scheduled run starts on time
- [ ] Scheduled run skips if manual run paused
- [ ] Resume after 7+ hour pause works
- [ ] Per-run limits respected
- [ ] Remaining items processed in next run
### Publishing
- [ ] Stagger mode spreads correctly
- [ ] Time slot mode uses exact times
- [ ] Immediate mode publishes right away
- [ ] No items missed due to limits
- [ ] Queue shows accurate count
### Credits
- [ ] Budget allocation calculates correctly
- [ ] Site override works
- [ ] Global defaults work
- [ ] Estimation uses budget
### UI
- [ ] Progress bar accurate during run
- [ ] Metrics match database counts
- [ ] Run detail shows correct stage results
- [ ] Stopped reason displayed clearly
---
## Rollback Plan
If issues arise:
1. All changes in separate migrations - can rollback individually
2. Feature flags for new behaviors (use_new_scheduling, use_budget_allocation)
3. Keep existing fields alongside new ones initially
4. Frontend changes are purely additive
---
## Success Criteria
1. **Zero lock issues** - Users never stuck unable to start automation
2. **100% scheduling** - All approved content gets scheduled
3. **Predictable runs** - Per-run limits produce consistent results
4. **Clear visibility** - UI shows exactly what's happening and why
5. **No regressions** - All existing functionality continues working

File diff suppressed because it is too large Load Diff

View File

@@ -113,7 +113,7 @@ const DeploymentPanel = lazy(() => import("./pages/Sites/DeploymentPanel"));
// Publisher Module - Lazy loaded // Publisher Module - Lazy loaded
const ContentCalendar = lazy(() => import("./pages/Publisher/ContentCalendar")); const ContentCalendar = lazy(() => import("./pages/Publisher/ContentCalendar"));
const PublishSettings = lazy(() => import("./pages/Publisher/PublishSettings")); // PublishSettings removed - now integrated into Site Settings > Automation tab
// Setup - Lazy loaded // Setup - Lazy loaded
const SetupWizard = lazy(() => import("./pages/Setup/SetupWizard")); const SetupWizard = lazy(() => import("./pages/Setup/SetupWizard"));
@@ -203,10 +203,9 @@ export default function App() {
<Route path="/automation/settings" element={<PipelineSettings />} /> <Route path="/automation/settings" element={<PipelineSettings />} />
<Route path="/automation/run" element={<AutomationPage />} /> <Route path="/automation/run" element={<AutomationPage />} />
{/* Publisher Module - Content Calendar & Settings */} {/* Publisher Module - Content Calendar */}
<Route path="/publisher" element={<Navigate to="/publisher/content-calendar" replace />} /> <Route path="/publisher" element={<Navigate to="/publisher/content-calendar" replace />} />
<Route path="/publisher/content-calendar" element={<ContentCalendar />} /> <Route path="/publisher/content-calendar" element={<ContentCalendar />} />
<Route path="/publisher/settings" element={<PublishSettings />} />
{/* Linker Module - Redirect dashboard to content */} {/* Linker Module - Redirect dashboard to content */}
<Route path="/linker" element={<Navigate to="/linker/content" replace />} /> <Route path="/linker" element={<Navigate to="/linker/content" replace />} />

View File

@@ -104,8 +104,11 @@ const SelectDropdown: React.FC<SelectDropdownProps> = ({
} }
}; };
// Check if w-full is specified to expand to container width
const isFullWidth = className.includes('w-full');
return ( return (
<div className={`relative flex-shrink-0 ${className}`}> <div className={`relative flex-shrink-0 ${isFullWidth ? 'w-full' : ''} ${className.replace('w-full', '').trim()}`}>
{/* Trigger Button - styled like igny8-select-styled */} {/* Trigger Button - styled like igny8-select-styled */}
<button <button
ref={buttonRef} ref={buttonRef}
@@ -113,9 +116,11 @@ const SelectDropdown: React.FC<SelectDropdownProps> = ({
onClick={() => !disabled && setIsOpen(!isOpen)} onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled} disabled={disabled}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
style={{ minWidth: `${estimatedMinWidth}px` }} style={isFullWidth ? undefined : { minWidth: `${estimatedMinWidth}px` }}
className={`igny8-select-styled w-auto max-w-[360px] appearance-none rounded-lg border border-gray-300 bg-transparent px-3 pr-10 shadow-theme-xs focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:focus:border-brand-800 ${ className={`igny8-select-styled relative appearance-none rounded-lg border border-gray-300 bg-transparent px-3 pr-8 shadow-theme-xs focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:focus:border-brand-800 ${
className.includes('text-base') ? 'h-11 py-2.5 text-base' : 'h-9 py-2 text-sm' isFullWidth ? 'w-full' : 'w-auto'
} ${
className.includes('text-base') ? 'h-11 py-2.5 text-base' : className.includes('text-xs') ? 'h-8 py-1.5 text-xs' : 'h-9 py-2 text-sm'
} ${ } ${
isPlaceholder isPlaceholder
? "text-gray-400 dark:text-gray-400" ? "text-gray-400 dark:text-gray-400"
@@ -126,9 +131,9 @@ const SelectDropdown: React.FC<SelectDropdownProps> = ({
: "" : ""
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`} } ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
> >
<span className="block text-left truncate">{displayText}</span> <span className="block text-left truncate pr-2">{displayText}</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"> <span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<ChevronDownIcon className={`h-4 w-4 text-gray-400 transition-transform ${isOpen ? 'transform rotate-180' : ''}`} /> <ChevronDownIcon className={`h-4 w-4 text-gray-400 transition-transform flex-shrink-0 ${isOpen ? 'transform rotate-180' : ''}`} />
</span> </span>
</button> </button>

View File

@@ -159,7 +159,6 @@ const AppSidebar: React.FC = () => {
subItems: [ subItems: [
{ name: "Content Review", path: "/writer/review" }, { name: "Content Review", path: "/writer/review" },
{ name: "Publish / Schedule", path: "/writer/approved" }, { name: "Publish / Schedule", path: "/writer/approved" },
{ name: "Publish Settings", path: "/publisher/settings" },
{ name: "Content Calendar", path: "/publisher/content-calendar" }, { name: "Content Calendar", path: "/publisher/content-calendar" },
], ],
}); });

View File

@@ -0,0 +1,807 @@
/**
* AI & Automation Settings Component
* Per SETTINGS-CONSOLIDATION-PLAN.md
*
* Unified settings page for site automation, stage configuration, and publishing schedule.
* Location: Site Settings > Automation tab
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Card } from '../../components/ui/card';
import Button from '../../components/ui/button/Button';
import IconButton from '../../components/ui/button/IconButton';
import Label from '../../components/form/Label';
import InputField from '../../components/form/input/InputField';
import SelectDropdown from '../../components/form/SelectDropdown';
import Switch from '../../components/form/switch/Switch';
import Checkbox from '../../components/form/input/Checkbox';
import { useToast } from '../../components/ui/toast/ToastContainer';
import Badge from '../../components/ui/badge/Badge';
import { fetchAPI } from '../../services/api';
import {
BoltIcon,
CalendarIcon,
Loader2Icon,
SaveIcon,
ClockIcon,
PlayIcon,
InfoIcon,
CloseIcon,
PlusIcon,
ImageIcon,
} from '../../icons';
import {
getUnifiedSiteSettings,
updateUnifiedSiteSettings,
UnifiedSiteSettings,
StageConfig,
DAYS_OF_WEEK,
FREQUENCY_OPTIONS,
calculateTotalBudget,
} from '../../services/unifiedSettings.api';
interface AIAutomationSettingsProps {
siteId: number;
}
// Tooltip component for inline help
function Tooltip({ text, children }: { text: string; children: React.ReactNode }) {
return (
<span className="relative group inline-flex">
{children}
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 text-xs text-white bg-gray-900 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10 pointer-events-none">
{text}
</span>
</span>
);
}
// Image settings types
interface ImageStyle {
value: string;
label: string;
}
export default function AIAutomationSettings({ siteId }: AIAutomationSettingsProps) {
const toast = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<UnifiedSiteSettings | null>(null);
// Image generation settings (from tenant-wide AI settings)
const [imageSettingsLoading, setImageSettingsLoading] = useState(true);
const [availableStyles, setAvailableStyles] = useState<ImageStyle[]>([
{ value: 'photorealistic', label: 'Photorealistic' },
{ value: 'illustration', label: 'Illustration' },
{ value: '3d_render', label: '3D Render' },
{ value: 'minimal_flat', label: 'Minimal / Flat' },
{ value: 'artistic', label: 'Artistic' },
{ value: 'cartoon', label: 'Cartoon' },
]);
const [selectedStyle, setSelectedStyle] = useState('photorealistic');
const [maxImages, setMaxImages] = useState(4);
const [maxAllowed, setMaxAllowed] = useState(8);
const [featuredImageSize, setFeaturedImageSize] = useState('2560x1440');
const [landscapeImageSize, setLandscapeImageSize] = useState('2560x1440');
const [squareImageSize, setSquareImageSize] = useState('2048x2048');
// Load unified settings
const loadSettings = useCallback(async () => {
try {
setLoading(true);
const data = await getUnifiedSiteSettings(siteId);
setSettings(data);
} catch (error) {
console.error('Failed to load unified settings:', error);
toast.error(`Failed to load settings: ${(error as Error).message}`);
} finally {
setLoading(false);
}
}, [siteId, toast]);
// Load image settings from tenant-wide AI settings API
const loadImageSettings = useCallback(async () => {
try {
setImageSettingsLoading(true);
const response = await fetchAPI('/v1/account/settings/ai/');
if (response?.image_generation) {
if (response.image_generation.styles) {
setAvailableStyles(response.image_generation.styles);
}
setSelectedStyle(response.image_generation.selected_style || 'photorealistic');
setMaxImages(response.image_generation.max_images ?? 4);
setMaxAllowed(response.image_generation.max_allowed ?? 8);
setFeaturedImageSize(response.image_generation.featured_image_size || '2560x1440');
setLandscapeImageSize(response.image_generation.landscape_image_size || '2560x1440');
setSquareImageSize(response.image_generation.square_image_size || '2048x2048');
}
} catch (error) {
console.error('Failed to load image settings:', error);
} finally {
setImageSettingsLoading(false);
}
}, []);
useEffect(() => {
loadSettings();
loadImageSettings();
}, [loadSettings, loadImageSettings]);
// Save settings
const handleSave = async () => {
if (!settings) return;
try {
setSaving(true);
// Save unified settings
const updated = await updateUnifiedSiteSettings(siteId, {
automation: settings.automation,
stages: settings.stages.map(s => ({
number: s.number,
enabled: s.enabled,
batch_size: s.batch_size,
per_run_limit: s.per_run_limit,
use_testing: s.use_testing,
budget_pct: s.budget_pct,
})),
delays: settings.delays,
publishing: {
auto_approval_enabled: settings.publishing.auto_approval_enabled,
auto_publish_enabled: settings.publishing.auto_publish_enabled,
publish_days: settings.publishing.publish_days,
time_slots: settings.publishing.time_slots,
},
});
setSettings(updated);
// Save image settings
await fetchAPI('/v1/account/settings/ai/', {
method: 'POST',
body: JSON.stringify({
image_generation: {
image_style: selectedStyle,
max_images_per_article: maxImages,
},
}),
});
toast.success('Settings saved successfully');
} catch (error) {
console.error('Failed to save settings:', error);
toast.error(`Failed to save settings: ${(error as Error).message}`);
} finally {
setSaving(false);
}
};
// Reset to defaults
const handleReset = () => {
loadSettings();
loadImageSettings();
toast.info('Settings reset to last saved values');
};
// Update automation settings
const updateAutomation = (updates: Partial<UnifiedSiteSettings['automation']>) => {
if (!settings) return;
setSettings({
...settings,
automation: { ...settings.automation, ...updates },
});
};
// Update stage configuration
const updateStage = (stageNumber: number, updates: Partial<StageConfig>) => {
if (!settings) return;
setSettings({
...settings,
stages: settings.stages.map(s =>
s.number === stageNumber ? { ...s, ...updates } : s
),
});
};
// Update delays
const updateDelays = (updates: Partial<UnifiedSiteSettings['delays']>) => {
if (!settings) return;
setSettings({
...settings,
delays: { ...settings.delays, ...updates },
});
};
// Update publishing settings
const updatePublishing = (updates: Partial<UnifiedSiteSettings['publishing']>) => {
if (!settings) return;
setSettings({
...settings,
publishing: { ...settings.publishing, ...updates },
});
};
// Toggle day in publish_days
const toggleDay = (day: string) => {
if (!settings) return;
const days = settings.publishing.publish_days;
const newDays = days.includes(day)
? days.filter(d => d !== day)
: [...days, day];
updatePublishing({ publish_days: newDays });
};
// Add time slot
const addTimeSlot = () => {
if (!settings) return;
const slots = settings.publishing.time_slots;
let newSlot = '09:00';
if (slots.length > 0) {
const lastSlot = slots[slots.length - 1];
const [hours, mins] = lastSlot.split(':').map(Number);
const newHours = (hours + 3) % 24;
newSlot = `${String(newHours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
updatePublishing({ time_slots: [...slots, newSlot] });
};
// Remove time slot
const removeTimeSlot = (index: number) => {
if (!settings) return;
const newSlots = settings.publishing.time_slots.filter((_, i) => i !== index);
updatePublishing({ time_slots: newSlots });
};
// Update time slot at index
const updateTimeSlot = (index: number, newSlot: string) => {
if (!settings) return;
const slots = [...settings.publishing.time_slots];
slots[index] = newSlot;
updatePublishing({ time_slots: slots });
};
// Clear all time slots
const clearAllSlots = () => {
if (!settings) return;
updatePublishing({ time_slots: [] });
};
if (loading || imageSettingsLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2Icon className="w-8 h-8 animate-spin text-brand-500" />
</div>
);
}
if (!settings) {
return (
<div className="text-center py-12">
<p className="text-gray-500">Failed to load settings. Please try again.</p>
<Button variant="outline" onClick={loadSettings} className="mt-4">
Retry
</Button>
</div>
);
}
const totalBudget = calculateTotalBudget(settings.stages);
const hasTestingEnabled = settings.stages.some(s => s.has_ai && s.use_testing);
return (
<div className="space-y-6">
{/* Row 1: Three Cards - Automation Schedule, Content Publishing, Image Generation */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Card 1: Automation Schedule */}
<Card className="p-5 border-l-4 border-l-brand-500">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
<BoltIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">Automation</h3>
<p className="text-sm text-gray-500">Schedule runs</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>Enable Scheduled Runs</Label>
<Switch
label=""
checked={settings.automation.enabled}
onChange={(checked) => updateAutomation({ enabled: checked })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-2">Frequency</Label>
<SelectDropdown
options={FREQUENCY_OPTIONS.map(f => ({ value: f.value, label: f.label }))}
value={settings.automation.frequency}
onChange={(value) => updateAutomation({ frequency: value as 'hourly' | 'daily' | 'weekly' })}
disabled={!settings.automation.enabled}
className="w-full"
/>
</div>
<div>
<Label className="mb-2">Run Time</Label>
<InputField
type="time"
value={settings.automation.time}
onChange={(e) => updateAutomation({ time: e.target.value })}
disabled={!settings.automation.enabled}
/>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3">
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<ClockIcon className="w-4 h-4" />
{settings.automation.enabled && settings.automation.next_run_at ? (
<span>Next run: {new Date(settings.automation.next_run_at).toLocaleString()}</span>
) : (
<span>Runs {settings.automation.frequency} at {settings.automation.time}</span>
)}
</div>
</div>
</div>
</Card>
{/* Card 2: Content Publishing */}
<Card className="p-5 border-l-4 border-l-success-500">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
<PlayIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">Publishing</h3>
<p className="text-sm text-gray-500">Auto-publish options</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>Auto-Approve Content</Label>
<Switch
label=""
checked={settings.publishing.auto_approval_enabled}
onChange={(checked) => updatePublishing({ auto_approval_enabled: checked })}
/>
</div>
<div className="flex items-center justify-between">
<Label>Auto-Publish to Site</Label>
<Switch
label=""
checked={settings.publishing.auto_publish_enabled}
onChange={(checked) => updatePublishing({ auto_publish_enabled: checked })}
/>
</div>
{/* AI Mode Status */}
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600 dark:text-gray-400">AI Mode:</span>
<Badge tone={hasTestingEnabled ? 'warning' : 'success'} size="sm">
{hasTestingEnabled ? 'Testing' : 'Live'}
</Badge>
</div>
<p className="text-sm text-gray-500 mt-1">
{hasTestingEnabled ? 'Using test models' : 'Production models'}
</p>
</div>
</div>
</Card>
{/* Card 3: Image Generation (Style & Count only) */}
<Card className="p-5 border-l-4 border-l-purple-500">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<ImageIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">Images</h3>
<p className="text-sm text-gray-500">Style & count</p>
</div>
</div>
<div className="space-y-4">
<div>
<Label className="mb-2">Style</Label>
<SelectDropdown
options={availableStyles.map(s => ({ value: s.value, label: s.label }))}
value={selectedStyle}
onChange={(value) => setSelectedStyle(value)}
className="w-full"
/>
</div>
<div>
<Label className="mb-2">Images per Article</Label>
<SelectDropdown
options={Array.from({ length: maxAllowed }, (_, i) => ({
value: String(i + 1),
label: `${i + 1} image${i > 0 ? 's' : ''}`,
}))}
value={String(maxImages)}
onChange={(value) => setMaxImages(parseInt(value))}
className="w-full"
/>
</div>
{/* Image Sizes */}
<div className="grid grid-cols-3 gap-2 pt-3 border-t border-gray-200 dark:border-gray-700">
<div className="text-center">
<p className="text-xs text-gray-500 mb-1">Featured</p>
<p className="text-sm font-medium">{featuredImageSize}</p>
</div>
<div className="text-center">
<p className="text-xs text-gray-500 mb-1">Landscape</p>
<p className="text-sm font-medium">{landscapeImageSize}</p>
</div>
<div className="text-center">
<p className="text-xs text-gray-500 mb-1">Square</p>
<p className="text-sm font-medium">{squareImageSize}</p>
</div>
</div>
</div>
</Card>
</div>
{/* Row 2: Stage Configuration (2/3) + Schedule & Capacity (1/3) */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left: Stage Configuration Matrix (2/3 width) */}
<Card className="p-5 lg:col-span-2">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<BoltIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">Stage Configuration</h3>
<p className="text-sm text-gray-500">Configure each automation stage</p>
</div>
</div>
<Badge tone={totalBudget <= 100 ? 'success' : 'danger'} size="sm">
Budget: {totalBudget}%
</Badge>
</div>
{/* Info Banner */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-4">
<div className="flex items-start gap-2">
<InfoIcon className="w-4 h-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-blue-800 dark:text-blue-200">
<span className="font-medium">Limit:</span> max items per run (0=all).
<span className="font-medium ml-2">Budget:</span> credit allocation across AI stages.
</p>
</div>
</div>
{/* Stage Table */}
<table className="w-full table-fixed">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-2 px-2 font-medium text-gray-600 dark:text-gray-400 text-sm w-[40%]">Stage</th>
<th className="text-center py-2 px-2 font-medium text-gray-600 dark:text-gray-400 text-sm w-[10%]">
<Tooltip text="Enable stage">On</Tooltip>
</th>
<th className="text-center py-2 px-2 font-medium text-gray-600 dark:text-gray-400 text-sm w-[12%]">
<Tooltip text="Batch size">Batch</Tooltip>
</th>
<th className="text-center py-2 px-2 font-medium text-gray-600 dark:text-gray-400 text-sm w-[12%]">
<Tooltip text="Per-run limit">Limit</Tooltip>
</th>
<th className="text-center py-2 px-2 font-medium text-gray-600 dark:text-gray-400 text-sm w-[14%]">
<Tooltip text="Test or Live AI">Model</Tooltip>
</th>
<th className="text-center py-2 px-2 font-medium text-gray-600 dark:text-gray-400 text-sm w-[12%]">
<Tooltip text="Budget %">Budget</Tooltip>
</th>
</tr>
</thead>
<tbody>
{settings.stages.map((stage) => (
<tr
key={stage.number}
className={`border-b border-gray-100 dark:border-gray-800 ${!stage.enabled ? 'opacity-50' : ''}`}
>
<td className="py-2 px-2">
<div className="flex items-center gap-1">
<span className="text-gray-400 text-sm">{stage.number}.</span>
<span className={`text-sm ${stage.has_ai ? 'text-gray-900 dark:text-white' : 'text-gray-500'}`}>
{stage.name}
</span>
{!stage.has_ai && <span className="text-xs text-gray-400">(local)</span>}
</div>
</td>
<td className="py-2 px-2 text-center">
<Checkbox
checked={stage.enabled}
onChange={(checked) => updateStage(stage.number, { enabled: checked })}
/>
</td>
<td className="py-2 px-2">
<InputField
type="number"
value={String(stage.batch_size)}
onChange={(e) => updateStage(stage.number, { batch_size: parseInt(e.target.value) || 1 })}
min="1"
max="100"
disabled={!stage.enabled}
className="w-16 text-center"
/>
</td>
<td className="py-2 px-2">
<InputField
type="number"
value={String(stage.per_run_limit)}
onChange={(e) => updateStage(stage.number, { per_run_limit: parseInt(e.target.value) || 0 })}
min="0"
max="1000"
disabled={!stage.enabled}
className="w-16 text-center"
/>
</td>
<td className="py-2 px-2 text-center">
{stage.has_ai ? (
<SelectDropdown
options={[
{ value: 'false', label: 'Live' },
{ value: 'true', label: 'Test' },
]}
value={stage.use_testing ? 'true' : 'false'}
onChange={(value) => updateStage(stage.number, { use_testing: value === 'true' })}
disabled={!stage.enabled}
className="w-full"
/>
) : (
<span className="text-gray-400">-</span>
)}
</td>
<td className="py-2 px-2">
{stage.has_ai ? (
<InputField
type="number"
value={String(stage.budget_pct || 0)}
onChange={(e) => updateStage(stage.number, { budget_pct: parseInt(e.target.value) || 0 })}
min="0"
max="100"
disabled={!stage.enabled}
className="w-16 text-center"
/>
) : (
<span className="text-gray-400 text-center block">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
{/* Delays Row */}
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-6 flex-wrap">
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Between stages:</Label>
<InputField
type="number"
value={String(settings.delays.between_stage)}
onChange={(e) => updateDelays({ between_stage: parseInt(e.target.value) || 0 })}
min="0"
max="60"
className="w-16 text-center"
/>
<span className="text-sm text-gray-500">sec</span>
</div>
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Within stage:</Label>
<InputField
type="number"
value={String(settings.delays.within_stage)}
onChange={(e) => updateDelays({ within_stage: parseInt(e.target.value) || 0 })}
min="0"
max="60"
className="w-16 text-center"
/>
<span className="text-sm text-gray-500">sec</span>
</div>
</div>
</div>
</Card>
{/* Right: Schedule + Capacity stacked */}
<div className="space-y-6">
{/* Schedule Card */}
<Card className="p-5 border-l-4 border-l-pink-500">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-pink-100 dark:bg-pink-900/30 rounded-lg">
<CalendarIcon className="w-5 h-5 text-pink-600 dark:text-pink-400" />
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">Schedule</h3>
<p className="text-sm text-gray-500">Days and time slots</p>
</div>
</div>
{/* Days Selection */}
<div className="mb-5">
<Label className="mb-2">Publishing Days</Label>
<div className="flex flex-wrap gap-2">
{DAYS_OF_WEEK.map((day) => (
<Button
key={day.value}
variant={settings.publishing.publish_days.includes(day.value) ? 'primary' : 'outline'}
tone="brand"
size="sm"
onClick={() => toggleDay(day.value)}
className="w-10 h-10 p-0"
>
{day.label.charAt(0)}
</Button>
))}
</div>
</div>
{/* Time Slots */}
<div>
<div className="flex items-center justify-between mb-2">
<Label>Time Slots</Label>
{settings.publishing.time_slots.length > 0 && (
<Button variant="ghost" tone="danger" size="sm" onClick={clearAllSlots}>
Clear
</Button>
)}
</div>
<div className="space-y-2">
{settings.publishing.time_slots.length === 0 ? (
<div className="text-center py-4 text-gray-500 border border-dashed border-gray-300 dark:border-gray-700 rounded-lg">
No time slots. Add at least one.
</div>
) : (
settings.publishing.time_slots.map((slot, index) => (
<div key={index} className="flex items-center gap-2">
<span className="text-sm text-gray-500 w-8">#{index + 1}</span>
<InputField
type="time"
value={slot}
onChange={(e) => updateTimeSlot(index, e.target.value)}
className="w-32"
/>
<IconButton
icon={<CloseIcon className="w-4 h-4" />}
variant="ghost"
tone="danger"
size="sm"
title="Remove"
onClick={() => removeTimeSlot(index)}
/>
</div>
))
)}
</div>
<Button
variant="ghost"
tone="brand"
size="sm"
startIcon={<PlusIcon className="w-4 h-4" />}
onClick={addTimeSlot}
className="mt-3"
>
Add Slot
</Button>
</div>
</Card>
{/* Calculated Capacity Card */}
<Card className="p-5 border-l-4 border-l-amber-500">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<CalendarIcon className="w-5 h-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">Capacity</h3>
<p className="text-sm text-gray-500">Calculated output</p>
</div>
</div>
<div className="grid grid-cols-3 gap-4 text-center py-4">
<div>
<div className="text-3xl font-bold text-gray-900 dark:text-white">
{settings.publishing.daily_capacity}
</div>
<div className="text-sm text-gray-500">Daily</div>
</div>
<div>
<div className="text-3xl font-bold text-gray-900 dark:text-white">
{settings.publishing.weekly_capacity}
</div>
<div className="text-sm text-gray-500">Weekly</div>
</div>
<div>
<div className="text-3xl font-bold text-gray-900 dark:text-white">
~{settings.publishing.monthly_capacity}
</div>
<div className="text-sm text-gray-500">Monthly</div>
</div>
</div>
<div className="flex items-center gap-2 text-sm text-gray-500 pt-4 border-t border-gray-200 dark:border-gray-700">
<InfoIcon className="w-4 h-4" />
<span>Based on {settings.publishing.publish_days.length} days × {settings.publishing.time_slots.length} slots</span>
</div>
</Card>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3 pt-2">
<Button variant="outline" onClick={handleReset} disabled={saving}>
Reset
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={saving}
startIcon={saving ? <Loader2Icon className="w-4 h-4 animate-spin" /> : <SaveIcon className="w-4 h-4" />}
>
{saving ? 'Saving...' : 'Save Settings'}
</Button>
</div>
{/* Help Cards - 3 cards explaining different sections */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 pt-4 border-t border-gray-200 dark:border-gray-700">
{/* How Publishing Works */}
<Card className="p-4 bg-brand-50 dark:bg-brand-900/20 border-brand-200 dark:border-brand-800">
<div className="flex items-start gap-3">
<InfoIcon className="w-5 h-5 text-brand-600 dark:text-brand-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-brand-800 dark:text-brand-200">
<p className="font-medium mb-2">How Publishing Works</p>
<ul className="list-disc list-inside space-y-1 text-brand-700 dark:text-brand-300">
<li>Content: Draft Review Approved Published</li>
<li>Auto-approval moves Review to Approved</li>
<li>Auto-publish sends to WordPress</li>
<li>Manual publish always available</li>
</ul>
</div>
</div>
</Card>
{/* Stage Configuration Explanation */}
<Card className="p-4 bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800">
<div className="flex items-start gap-3">
<BoltIcon className="w-5 h-5 text-purple-600 dark:text-purple-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-purple-800 dark:text-purple-200">
<p className="font-medium mb-2">Stage Configuration</p>
<ul className="list-disc list-inside space-y-1 text-purple-700 dark:text-purple-300">
<li><strong>Batch:</strong> Items processed together</li>
<li><strong>Limit:</strong> Max items per automation run</li>
<li><strong>Model:</strong> Live (production) or Test mode</li>
<li><strong>Budget:</strong> Credit allocation per stage</li>
</ul>
</div>
</div>
</Card>
{/* Schedule & Capacity Explanation */}
<Card className="p-4 bg-pink-50 dark:bg-pink-900/20 border-pink-200 dark:border-pink-800">
<div className="flex items-start gap-3">
<CalendarIcon className="w-5 h-5 text-pink-600 dark:text-pink-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-pink-800 dark:text-pink-200">
<p className="font-medium mb-2">Schedule & Capacity</p>
<ul className="list-disc list-inside space-y-1 text-pink-700 dark:text-pink-300">
<li>Select days content can be published</li>
<li>Add time slots for publishing</li>
<li>Capacity = Days × Time Slots</li>
<li>Content scheduled to next available slot</li>
</ul>
</div>
</div>
</Card>
</div>
</div>
);
}

View File

@@ -34,6 +34,7 @@ import Badge from '../../components/ui/badge/Badge';
import { Dropdown } from '../../components/ui/dropdown/Dropdown'; import { Dropdown } from '../../components/ui/dropdown/Dropdown';
import { DropdownItem } from '../../components/ui/dropdown/DropdownItem'; import { DropdownItem } from '../../components/ui/dropdown/DropdownItem';
import SiteInfoBar from '../../components/common/SiteInfoBar'; import SiteInfoBar from '../../components/common/SiteInfoBar';
import AIAutomationSettings from './AIAutomationSettings';
export default function SiteSettings() { export default function SiteSettings() {
const { id: siteId } = useParams<{ id: string }>(); const { id: siteId } = useParams<{ id: string }>();
@@ -51,9 +52,9 @@ export default function SiteSettings() {
const [isSiteSelectorOpen, setIsSiteSelectorOpen] = useState(false); const [isSiteSelectorOpen, setIsSiteSelectorOpen] = useState(false);
const siteSelectorRef = useRef<HTMLButtonElement>(null); const siteSelectorRef = useRef<HTMLButtonElement>(null);
// Check for tab parameter in URL - content-types removed, redirects to integrations // Check for tab parameter in URL - ai-settings removed (content in Automation tab)
const initialTab = (searchParams.get('tab') as 'general' | 'ai-settings' | 'integrations') || 'general'; const initialTab = (searchParams.get('tab') as 'general' | 'automation' | 'integrations') || 'general';
const [activeTab, setActiveTab] = useState<'general' | 'ai-settings' | 'integrations'>(initialTab); const [activeTab, setActiveTab] = useState<'general' | 'automation' | 'integrations'>(initialTab);
// Advanced Settings toggle // Advanced Settings toggle
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
@@ -137,7 +138,7 @@ export default function SiteSettings() {
useEffect(() => { useEffect(() => {
// Update tab if URL parameter changes // Update tab if URL parameter changes
const tab = searchParams.get('tab'); const tab = searchParams.get('tab');
if (tab && ['general', 'ai-settings', 'integrations'].includes(tab)) { if (tab && ['general', 'ai-settings', 'automation', 'integrations'].includes(tab)) {
setActiveTab(tab as typeof activeTab); setActiveTab(tab as typeof activeTab);
} }
// Handle legacy tab names - redirect content-types to integrations // Handle legacy tab names - redirect content-types to integrations
@@ -580,17 +581,17 @@ export default function SiteSettings() {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
setActiveTab('ai-settings'); setActiveTab('automation');
navigate(`/sites/${siteId}/settings?tab=ai-settings`, { replace: true }); navigate(`/sites/${siteId}/settings?tab=automation`, { replace: true });
}} }}
className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${ className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${
activeTab === 'ai-settings' activeTab === 'automation'
? 'border-success-500 text-success-600 dark:text-success-400' ? 'border-purple-500 text-purple-600 dark:text-purple-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
}`} }`}
startIcon={<BoltIcon className={`w-4 h-4 ${activeTab === 'ai-settings' ? 'text-success-500' : ''}`} />} startIcon={<CalendarIcon className={`w-4 h-4 ${activeTab === 'automation' ? 'text-purple-500' : ''}`} />}
> >
AI Settings Automation
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -611,256 +612,19 @@ export default function SiteSettings() {
</div> </div>
</div> </div>
{/* AI Settings Tab (merged content-generation + image-settings) */} {/* Automation Tab - Unified AI & Automation Settings */}
{activeTab === 'ai-settings' && ( {activeTab === 'automation' && siteId && (
<div className="space-y-6"> <AIAutomationSettings siteId={Number(siteId)} />
{/* 3 Cards in a Row */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Card 1: Content Settings */}
<Card className="p-6 border-l-4 border-l-brand-500">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
<FileTextIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Content Settings</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Customize article writing</p>
</div>
</div>
{contentGenerationLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2Icon className="w-8 h-8 animate-spin text-brand-500" />
</div>
) : (
<div className="space-y-4">
<div>
<Label className="mb-2">Append to Prompt</Label>
<TextArea
value={contentGenerationSettings.appendToPrompt}
onChange={(value) => setContentGenerationSettings({ ...contentGenerationSettings, appendToPrompt: value })}
placeholder="Custom instructions..."
rows={3}
/>
<p className="text-xs text-gray-500 mt-1">
Appended to every AI prompt
</p>
</div>
<div>
<Label className="mb-2">Tone</Label>
<SelectDropdown
options={[
{ value: 'professional', label: 'Professional' },
{ value: 'conversational', label: 'Conversational' },
{ value: 'formal', label: 'Formal' },
{ value: 'casual', label: 'Casual' },
{ value: 'friendly', label: 'Friendly' },
]}
value={contentGenerationSettings.defaultTone}
onChange={(value) => setContentGenerationSettings({ ...contentGenerationSettings, defaultTone: value })}
className="w-full"
/>
</div>
<div>
<Label className="mb-2">Article Length</Label>
<SelectDropdown
options={[
{ value: 'short', label: 'Short (500-800)' },
{ value: 'medium', label: 'Medium (1000-1500)' },
{ value: 'long', label: 'Long (2000-3000)' },
{ value: 'comprehensive', label: 'Comprehensive (3000+)' },
]}
value={contentGenerationSettings.defaultLength}
onChange={(value) => setContentGenerationSettings({ ...contentGenerationSettings, defaultLength: value })}
className="w-full"
/>
</div>
</div>
)}
</Card>
{/* Card 2: AI Parameters */}
<Card className="p-6 border-l-4 border-l-success-500">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
<BoltIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">AI Parameters</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Fine-tune content generation behavior</p>
</div>
</div>
{aiSettingsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2Icon className="w-8 h-8 animate-spin text-success-500" />
</div>
) : (
<div className="space-y-6">
{/* Temperature Slider */}
<div>
<Label className="mb-2">Temperature</Label>
<div className="flex items-center gap-4">
<div className="flex-1">
<input
type="range"
min="0"
max="2"
step="0.1"
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value))}
className="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-success-500"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>More focused</span>
<span>More creative</span>
</div>
</div>
<span className="w-12 text-center font-medium text-gray-700 dark:text-gray-300">{temperature.toFixed(1)}</span>
</div>
</div>
{/* Max Tokens Dropdown */}
<div className="max-w-xs">
<Label className="mb-2">Max Tokens</Label>
<SelectDropdown
options={[
{ value: '2048', label: '2,048 tokens' },
{ value: '4096', label: '4,096 tokens' },
{ value: '8192', label: '8,192 tokens' },
{ value: '16384', label: '16,384 tokens' },
]}
value={String(maxTokens)}
onChange={(value) => setMaxTokens(parseInt(value))}
className="w-full"
/>
<p className="text-xs text-gray-500 mt-1">
Maximum length of generated content. Higher values allow longer articles.
</p>
</div>
</div>
)}
</Card>
{/* Card 3: Image Generation */}
<Card className="p-6 border-l-4 border-l-purple-500">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<ImageIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Image Generation</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Quality & style</p>
</div>
</div>
{aiSettingsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2Icon className="w-8 h-8 animate-spin text-purple-500" />
</div>
) : (
<div className="space-y-4">
{/* Quality Tier Dropdown */}
<div>
<Label className="mb-2">Quality</Label>
<SelectDropdown
options={qualityTiers.length > 0
? qualityTiers.map(tier => ({
value: tier.tier || tier.value,
label: `${tier.label} (${tier.credits} credits)`
}))
: [
{ value: 'basic', label: 'Basic (1 credit)' },
{ value: 'quality', label: 'Quality (5 credits)' },
{ value: 'premium', label: 'Premium (15 credits)' },
]
}
value={selectedTier || 'quality'}
onChange={(value) => setSelectedTier(value)}
className="w-full"
/>
</div>
{/* Image Style Dropdown */}
<div>
<Label className="mb-2">Style</Label>
<SelectDropdown
options={availableStyles.length > 0
? availableStyles.map(style => ({ value: style.value, label: style.label }))
: [
{ value: 'photorealistic', label: 'Photorealistic' },
{ value: 'illustration', label: 'Illustration' },
{ value: '3d_render', label: '3D Render' },
{ value: 'minimal_flat', label: 'Minimal / Flat' },
{ value: 'artistic', label: 'Artistic' },
{ value: 'cartoon', label: 'Cartoon' },
]
}
value={selectedStyle}
onChange={(value) => setSelectedStyle(value)}
className="w-full"
/>
</div>
{/* Images Per Article Dropdown */}
<div>
<Label className="mb-2">Images per Article</Label>
<SelectDropdown
options={Array.from({ length: maxAllowed || 8 }, (_, i) => ({
value: String(i + 1),
label: `${i + 1} image${i > 0 ? 's' : ''}`,
}))}
value={String(maxImages || 4)}
onChange={(value) => setMaxImages(parseInt(value))}
className="w-full"
/>
</div>
{/* Image Sizes Display */}
<div className="grid grid-cols-3 gap-3 pt-2 border-t border-gray-200 dark:border-gray-700">
<div className="text-center">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Featured Image</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{featuredImageSize}</p>
</div>
<div className="text-center">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Landscape</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{landscapeImageSize}</p>
</div>
<div className="text-center">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Square</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{squareImageSize}</p>
</div>
</div>
</div>
)}
</Card>
</div>
{/* End of 3-card grid */}
{/* Save Button */}
<div className="flex justify-end gap-3">
<Button
variant="primary"
tone="brand"
onClick={async () => {
await Promise.all([
saveAISettings(),
saveContentGenerationSettings(),
]);
}}
disabled={aiSettingsSaving || contentGenerationSaving}
startIcon={(aiSettingsSaving || contentGenerationSaving) ? <Loader2Icon className="w-4 h-4 animate-spin" /> : <SaveIcon className="w-4 h-4" />}
>
{(aiSettingsSaving || contentGenerationSaving) ? 'Saving...' : 'Save Settings'}
</Button>
</div>
</div>
)} )}
{/*
AI Settings Tab - REMOVED (Jan 2026)
Reason: Text AI override options removed from user control.
Image settings moved to Automation tab.
Content generation settings (tone, length, append to prompt) to be removed in future.
State and functions kept for backward compatibility but tab hidden from UI.
*/}
{/* Tab content */} {/* Tab content */}
<div className="space-y-6"> <div className="space-y-6">
{/* General Tab */} {/* General Tab */}

View File

@@ -22,6 +22,15 @@ export interface AutomationConfig {
stage_6_batch_size: number; stage_6_batch_size: number;
within_stage_delay: number; within_stage_delay: number;
between_stage_delay: number; between_stage_delay: number;
// Per-run limits (0 = unlimited)
max_keywords_per_run: number;
max_clusters_per_run: number;
max_ideas_per_run: number;
max_tasks_per_run: number;
max_content_per_run: number;
max_images_per_run: number;
max_approvals_per_run: number;
max_credits_per_run: number;
last_run_at: string | null; last_run_at: string | null;
next_run_at: string | null; next_run_at: string | null;
} }

View File

@@ -0,0 +1,162 @@
/**
* Unified Settings API Service
* Per SETTINGS-CONSOLIDATION-PLAN.md
*
* Consolidates AI & Automation settings into a single API endpoint.
*/
import { fetchAPI } from './api';
// ═══════════════════════════════════════════════════════════
// TYPES
// ═══════════════════════════════════════════════════════════
export interface StageConfig {
number: number;
name: string;
has_ai: boolean;
enabled: boolean;
batch_size: number;
per_run_limit: number;
use_testing?: boolean;
budget_pct?: number;
}
export interface AvailableModel {
id: number | null;
name: string | null;
model_name: string | null;
}
export interface UnifiedSiteSettings {
site_id: number;
site_name: string;
automation: {
enabled: boolean;
frequency: 'hourly' | 'daily' | 'weekly';
time: string; // HH:MM format
last_run_at: string | null;
next_run_at: string | null;
};
stages: StageConfig[];
delays: {
within_stage: number;
between_stage: number;
};
publishing: {
auto_approval_enabled: boolean;
auto_publish_enabled: boolean;
publish_days: string[]; // ['mon', 'tue', ...]
time_slots: string[]; // ['09:00', '14:00', ...]
daily_capacity: number;
weekly_capacity: number;
monthly_capacity: number;
};
available_models: {
text: {
testing: AvailableModel | null;
live: AvailableModel | null;
};
image: {
testing: AvailableModel | null;
live: AvailableModel | null;
};
};
}
export interface UpdateUnifiedSettingsRequest {
automation?: {
enabled?: boolean;
frequency?: 'hourly' | 'daily' | 'weekly';
time?: string;
};
stages?: Array<{
number: number;
enabled?: boolean;
batch_size?: number;
per_run_limit?: number;
use_testing?: boolean;
budget_pct?: number;
}>;
delays?: {
within_stage?: number;
between_stage?: number;
};
publishing?: {
auto_approval_enabled?: boolean;
auto_publish_enabled?: boolean;
publish_days?: string[];
time_slots?: string[];
};
}
// ═══════════════════════════════════════════════════════════
// API FUNCTIONS
// ═══════════════════════════════════════════════════════════
/**
* Get unified site settings (AI & Automation consolidated)
*/
export async function getUnifiedSiteSettings(siteId: number): Promise<UnifiedSiteSettings> {
const response = await fetchAPI(`/v1/integration/sites/${siteId}/unified-settings/`);
return response.data || response;
}
/**
* Update unified site settings
*/
export async function updateUnifiedSiteSettings(
siteId: number,
data: UpdateUnifiedSettingsRequest
): Promise<UnifiedSiteSettings> {
const response = await fetchAPI(`/v1/integration/sites/${siteId}/unified-settings/`, {
method: 'PUT',
body: JSON.stringify(data),
});
return response.data || response;
}
// ═══════════════════════════════════════════════════════════
// HELPER FUNCTIONS
// ═══════════════════════════════════════════════════════════
/**
* Days of week for publishing schedule
*/
export const DAYS_OF_WEEK = [
{ value: 'mon', label: 'Mon' },
{ value: 'tue', label: 'Tue' },
{ value: 'wed', label: 'Wed' },
{ value: 'thu', label: 'Thu' },
{ value: 'fri', label: 'Fri' },
{ value: 'sat', label: 'Sat' },
{ value: 'sun', label: 'Sun' },
];
/**
* Frequency options for automation
*/
export const FREQUENCY_OPTIONS = [
{ value: 'hourly', label: 'Hourly' },
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
];
/**
* Format time for display
*/
export function formatTime(time: string): string {
const [hours, minutes] = time.split(':');
const h = parseInt(hours);
const ampm = h >= 12 ? 'PM' : 'AM';
const displayHour = h > 12 ? h - 12 : h === 0 ? 12 : h;
return `${displayHour}:${minutes} ${ampm}`;
}
/**
* Calculate total budget percentage from stages
*/
export function calculateTotalBudget(stages: StageConfig[]): number {
return stages
.filter(s => s.has_ai && s.budget_pct)
.reduce((sum, s) => sum + (s.budget_pct || 0), 0);
}