AI AUtomtaion, Schudelign and publishign fromt and backe end refoactr
This commit is contained in:
357
backend/igny8_core/api/unified_settings.py
Normal file
357
backend/igny8_core/api/unified_settings.py
Normal 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']
|
||||||
@@ -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)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
56
backend/migrations/0014_automation_per_run_limits.py
Normal file
56
backend/migrations/0014_automation_per_run_limits.py
Normal 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)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
60
backend/migrations/0015_publishing_settings_overhaul.py
Normal file
60
backend/migrations/0015_publishing_settings_overhaul.py
Normal 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'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
77
backend/migrations/0016_site_ai_budget_allocation.py
Normal file
77
backend/migrations/0016_site_ai_budget_allocation.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
765
docs/plans/AUTOMATION-ENHANCEMENT-PLAN.md
Normal file
765
docs/plans/AUTOMATION-ENHANCEMENT-PLAN.md
Normal 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
|
||||||
1272
docs/plans/SETTINGS-CONSOLIDATION-PLAN.md
Normal file
1272
docs/plans/SETTINGS-CONSOLIDATION-PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 />} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
807
frontend/src/pages/Sites/AIAutomationSettings.tsx
Normal file
807
frontend/src/pages/Sites/AIAutomationSettings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
162
frontend/src/services/unifiedSettings.api.ts
Normal file
162
frontend/src/services/unifiedSettings.api.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user