From d3b3e1c0d4b223296f6b516f334b9be66e62825d Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sat, 17 Jan 2026 15:52:46 +0000 Subject: [PATCH] AI AUtomtaion, Schudelign and publishign fromt and backe end refoactr --- backend/igny8_core/api/unified_settings.py | 357 +++++ ...onconfig_max_approvals_per_run_and_more.py | 53 + .../igny8_core/business/automation/models.py | 13 + .../automation/services/automation_service.py | 158 +- .../igny8_core/business/automation/tasks.py | 76 +- .../igny8_core/business/automation/views.py | 57 + .../0010_add_aimodelconfig_is_testing.py | 24 + backend/igny8_core/business/billing/models.py | 152 +- ...publishingsettings_queue_limit_and_more.py | 53 + .../igny8_core/business/integration/models.py | 67 +- backend/igny8_core/modules/billing/admin.py | 49 +- .../0035_aimodelconfig_is_testing_and_more.py | 47 + backend/igny8_core/modules/billing/urls.py | 5 +- backend/igny8_core/modules/billing/views.py | 174 +++ .../igny8_core/modules/integration/urls.py | 10 + .../igny8_core/modules/integration/views.py | 5 + backend/igny8_core/settings.py | 75 +- .../igny8_core/tasks/publishing_scheduler.py | 202 ++- .../0014_automation_per_run_limits.py | 56 + .../0015_publishing_settings_overhaul.py | 60 + .../0016_site_ai_budget_allocation.py | 77 + docs/plans/AUTOMATION-ENHANCEMENT-PLAN.md | 765 ++++++++++ docs/plans/SETTINGS-CONSOLIDATION-PLAN.md | 1272 +++++++++++++++++ .../AUTOMATION_RUNS_DETAIL_VIEW_UX_PLAN.md | 0 .../AUTOMATION_RUNS_IMPLEMENTATION_LOG.md | 0 .../AUTOMATION_RUNS_IMPLEMENTATION_SUMMARY.md | 0 .../AUTOMATION_RUNS_QUICK_START.md | 0 frontend/src/App.tsx | 5 +- .../src/components/form/SelectDropdown.tsx | 19 +- frontend/src/layout/AppSidebar.tsx | 1 - .../src/pages/Sites/AIAutomationSettings.tsx | 807 +++++++++++ frontend/src/pages/Sites/Settings.tsx | 280 +--- frontend/src/services/automationService.ts | 9 + frontend/src/services/unifiedSettings.api.ts | 162 +++ 34 files changed, 4715 insertions(+), 375 deletions(-) create mode 100644 backend/igny8_core/api/unified_settings.py create mode 100644 backend/igny8_core/business/automation/migrations/0008_automationconfig_max_approvals_per_run_and_more.py create mode 100644 backend/igny8_core/business/billing/migrations/0010_add_aimodelconfig_is_testing.py create mode 100644 backend/igny8_core/business/integration/migrations/0004_publishingsettings_queue_limit_and_more.py create mode 100644 backend/igny8_core/modules/billing/migrations/0035_aimodelconfig_is_testing_and_more.py create mode 100644 backend/migrations/0014_automation_per_run_limits.py create mode 100644 backend/migrations/0015_publishing_settings_overhaul.py create mode 100644 backend/migrations/0016_site_ai_budget_allocation.py create mode 100644 docs/plans/AUTOMATION-ENHANCEMENT-PLAN.md create mode 100644 docs/plans/SETTINGS-CONSOLIDATION-PLAN.md rename docs/plans/{ => automation}/AUTOMATION_RUNS_DETAIL_VIEW_UX_PLAN.md (100%) rename docs/plans/{ => automation}/AUTOMATION_RUNS_IMPLEMENTATION_LOG.md (100%) rename docs/plans/{ => automation}/AUTOMATION_RUNS_IMPLEMENTATION_SUMMARY.md (100%) rename docs/plans/{ => automation}/AUTOMATION_RUNS_QUICK_START.md (100%) create mode 100644 frontend/src/pages/Sites/AIAutomationSettings.tsx create mode 100644 frontend/src/services/unifiedSettings.api.ts diff --git a/backend/igny8_core/api/unified_settings.py b/backend/igny8_core/api/unified_settings.py new file mode 100644 index 00000000..0698045a --- /dev/null +++ b/backend/igny8_core/api/unified_settings.py @@ -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'] diff --git a/backend/igny8_core/business/automation/migrations/0008_automationconfig_max_approvals_per_run_and_more.py b/backend/igny8_core/business/automation/migrations/0008_automationconfig_max_approvals_per_run_and_more.py new file mode 100644 index 00000000..42f68789 --- /dev/null +++ b/backend/igny8_core/business/automation/migrations/0008_automationconfig_max_approvals_per_run_and_more.py @@ -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)'), + ), + ] diff --git a/backend/igny8_core/business/automation/models.py b/backend/igny8_core/business/automation/models.py index 6aefb841..b7d57d3d 100644 --- a/backend/igny8_core/business/automation/models.py +++ b/backend/igny8_core/business/automation/models.py @@ -44,6 +44,19 @@ class AutomationConfig(models.Model): within_stage_delay = models.IntegerField(default=3, help_text="Delay between batches within a stage (seconds)") between_stage_delay = models.IntegerField(default=5, help_text="Delay between stage transitions (seconds)") + # Per-run item limits (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) next_run_at = models.DateTimeField(null=True, blank=True, help_text="Calculated based on frequency") diff --git a/backend/igny8_core/business/automation/services/automation_service.py b/backend/igny8_core/business/automation/services/automation_service.py index 309f0975..755e3bdf 100644 --- a/backend/igny8_core/business/automation/services/automation_service.py +++ b/backend/igny8_core/business/automation/services/automation_service.py @@ -63,7 +63,7 @@ class AutomationService: 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: (should_stop, reason) @@ -79,6 +79,83 @@ class AutomationService: elif self.run.status == '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, "" def start_automation(self, trigger_type: str = 'manual') -> str: @@ -170,6 +247,19 @@ class AutomationService: 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() # IMPORTANT: Group keywords by sector to avoid mixing sectors in clustering @@ -480,6 +570,17 @@ class AutomationService: 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() # Log stage start @@ -674,6 +775,17 @@ class AutomationService: 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() # Log stage start @@ -837,6 +949,17 @@ class AutomationService: 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() # Log stage start @@ -1078,6 +1201,17 @@ class AutomationService: 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() # ADDED: Enhanced logging @@ -1291,6 +1425,17 @@ class AutomationService: 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() # Log stage start @@ -1538,6 +1683,17 @@ class AutomationService: 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() # Log stage start diff --git a/backend/igny8_core/business/automation/tasks.py b/backend/igny8_core/business/automation/tasks.py index 1c4f0c48..97c5ee23 100644 --- a/backend/igny8_core/business/automation/tasks.py +++ b/backend/igny8_core/business/automation/tasks.py @@ -49,9 +49,9 @@ def check_scheduled_automations(): logger.info(f"[AutomationTask] Skipping site {config.site.id} - already ran today") continue - # 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") + # Check if already running OR paused (don't start new if existing in progress) + 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 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) 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}") 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) - run = service.run config = service.config # 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): if stage_enabled[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: 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: logger.error(f"[AutomationTask] Failed to resume automation run {run_id}: {e}") - # Mark as failed - run = AutomationRun.objects.get(run_id=run_id) - run.status = 'failed' - run.error_message = str(e) - run.completed_at = timezone.now() - run.save() + # Mark as failed and release lock + try: + run = AutomationRun.objects.get(run_id=run_id) + run.status = 'failed' + run.error_message = str(e) + 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) diff --git a/backend/igny8_core/business/automation/views.py b/backend/igny8_core/business/automation/views.py index 1165fddf..32d697fa 100644 --- a/backend/igny8_core/business/automation/views.py +++ b/backend/igny8_core/business/automation/views.py @@ -77,6 +77,15 @@ class AutomationViewSet(viewsets.ViewSet): 'stage_6_batch_size': config.stage_6_batch_size, 'within_stage_delay': config.within_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, 'next_run_at': config.next_run_at, }) @@ -153,6 +162,18 @@ class AutomationViewSet(viewsets.ViewSet): except (TypeError, ValueError): 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() return Response({ @@ -175,6 +196,15 @@ class AutomationViewSet(viewsets.ViewSet): 'stage_6_batch_size': config.stage_6_batch_size, 'within_stage_delay': config.within_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, 'next_run_at': config.next_run_at, }) @@ -267,6 +297,17 @@ class AutomationViewSet(viewsets.ViewSet): try: service = AutomationService.from_run_id(run_id) 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'}) except AutomationRun.DoesNotExist: return Response( @@ -1613,6 +1654,22 @@ class AutomationViewSet(viewsets.ViewSet): run.completed_at = timezone.now() 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({ 'message': 'Automation cancelled', 'status': run.status, diff --git a/backend/igny8_core/business/billing/migrations/0010_add_aimodelconfig_is_testing.py b/backend/igny8_core/business/billing/migrations/0010_add_aimodelconfig_is_testing.py new file mode 100644 index 00000000..05ded83b --- /dev/null +++ b/backend/igny8_core/business/billing/migrations/0010_add_aimodelconfig_is_testing.py @@ -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.', + ), + ), + ] diff --git a/backend/igny8_core/business/billing/models.py b/backend/igny8_core/business/billing/models.py index ebe75044..499468c8 100644 --- a/backend/igny8_core/business/billing/models.py +++ b/backend/igny8_core/business/billing/models.py @@ -828,6 +828,13 @@ class AIModelConfig(models.Model): 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) landscape_size = models.CharField( max_length=20, @@ -892,12 +899,18 @@ class AIModelConfig(models.Model): return self.display_name 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: AIModelConfig.objects.filter( model_type=self.model_type, is_default=True ).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) @classmethod @@ -910,6 +923,25 @@ class AIModelConfig(models.Model): """Get the default image generation model""" 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 def get_image_models_by_tier(cls): """Get all active image models grouped by quality tier""" @@ -1044,3 +1076,121 @@ class WebhookEvent(models.Model): self.error_message = error_message self.retry_count += 1 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) + diff --git a/backend/igny8_core/business/integration/migrations/0004_publishingsettings_queue_limit_and_more.py b/backend/igny8_core/business/integration/migrations/0004_publishingsettings_queue_limit_and_more.py new file mode 100644 index 00000000..5375f0b1 --- /dev/null +++ b/backend/igny8_core/business/integration/migrations/0004_publishingsettings_queue_limit_and_more.py @@ -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'), + ), + ] diff --git a/backend/igny8_core/business/integration/models.py b/backend/igny8_core/business/integration/models.py index 1b065076..b1aef0d4 100644 --- a/backend/igny8_core/business/integration/models.py +++ b/backend/igny8_core/business/integration/models.py @@ -247,8 +247,16 @@ class SyncEvent(AccountBaseModel): class PublishingSettings(AccountBaseModel): """ - Site-level publishing configuration settings. - Controls automatic approval, publishing limits, and scheduling. + Site-level publishing SCHEDULE configuration (SIMPLIFIED). + 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'] @@ -273,26 +281,7 @@ class PublishingSettings(AccountBaseModel): help_text="Automatically publish approved content to the external site" ) - # Publishing limits - 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 + # Publishing schedule - Days + Time Slots only (SIMPLIFIED) publish_days = models.JSONField( default=list, 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'])" ) + # 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) updated_at = models.DateTimeField(auto_now=True) @@ -323,6 +327,22 @@ class PublishingSettings(AccountBaseModel): self.publish_time_slots = self.DEFAULT_TIME_SLOTS 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 def get_or_create_for_site(cls, site): """Get or create publishing settings for a site with defaults""" @@ -332,9 +352,6 @@ class PublishingSettings(AccountBaseModel): 'account': site.account, 'auto_approval_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_time_slots': cls.DEFAULT_TIME_SLOTS, } diff --git a/backend/igny8_core/modules/billing/admin.py b/backend/igny8_core/modules/billing/admin.py index 8d65402f..83dc0e23 100644 --- a/backend/igny8_core/modules/billing/admin.py +++ b/backend/igny8_core/modules/billing/admin.py @@ -839,6 +839,7 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin): 'provider_badge', 'credit_display', 'quality_tier', + 'is_testing_icon', 'is_active_icon', 'is_default_icon', 'updated_at', @@ -848,6 +849,7 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin): 'model_type', 'provider', 'quality_tier', + 'is_testing', 'is_active', 'is_default', ] @@ -884,7 +886,8 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin): 'classes': ('collapse',) }), ('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', { 'fields': ('created_at', 'updated_at'), @@ -969,8 +972,19 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin): ) 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( + '' + ) + return format_html( + '' + ) + is_testing_icon.short_description = 'Testing/Live' + # 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): """Enable selected models""" @@ -1005,3 +1019,34 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin): messages.SUCCESS ) 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' diff --git a/backend/igny8_core/modules/billing/migrations/0035_aimodelconfig_is_testing_and_more.py b/backend/igny8_core/modules/billing/migrations/0035_aimodelconfig_is_testing_and_more.py new file mode 100644 index 00000000..ace4ed3f --- /dev/null +++ b/backend/igny8_core/modules/billing/migrations/0035_aimodelconfig_is_testing_and_more.py @@ -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')}, + }, + ), + ] diff --git a/backend/igny8_core/modules/billing/urls.py b/backend/igny8_core/modules/billing/urls.py index 3d24cd4e..31b7f5c0 100644 --- a/backend/igny8_core/modules/billing/urls.py +++ b/backend/igny8_core/modules/billing/urls.py @@ -8,7 +8,8 @@ from .views import ( CreditUsageViewSet, CreditTransactionViewSet, BillingOverviewViewSet, - AdminBillingViewSet + AdminBillingViewSet, + SiteAIBudgetAllocationViewSet ) router = DefaultRouter() @@ -31,5 +32,7 @@ urlpatterns = [ 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/credit-costs/', AdminBillingViewSet.as_view({'get': 'credit_costs'}), name='admin-credit-costs'), + # Site AI budget allocation + path('sites//ai-budget/', SiteAIBudgetAllocationViewSet.as_view({'get': 'list', 'post': 'create'}), name='site-ai-budget'), ] diff --git a/backend/igny8_core/modules/billing/views.py b/backend/igny8_core/modules/billing/views.py index c745092b..07cf8a02 100644 --- a/backend/igny8_core/modules/billing/views.py +++ b/backend/igny8_core/modules/billing/views.py @@ -840,3 +840,177 @@ class AIModelConfigViewSet(viewsets.ReadOnlyModelViewSet): 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' + ) + + diff --git a/backend/igny8_core/modules/integration/urls.py b/backend/igny8_core/modules/integration/urls.py index 820e12aa..062e9b08 100644 --- a/backend/igny8_core/modules/integration/urls.py +++ b/backend/igny8_core/modules/integration/urls.py @@ -10,6 +10,7 @@ from igny8_core.modules.integration.webhooks import ( wordpress_status_webhook, wordpress_metadata_webhook, ) +from igny8_core.api.unified_settings import UnifiedSiteSettingsViewSet router = DefaultRouter() router.register(r'integrations', IntegrationViewSet, basename='integration') @@ -21,12 +22,21 @@ publishing_settings_viewset = PublishingSettingsViewSet.as_view({ 'patch': 'partial_update', }) +# Create Unified Settings ViewSet instance +unified_settings_viewset = UnifiedSiteSettingsViewSet.as_view({ + 'get': 'retrieve', + 'put': 'update', +}) + urlpatterns = [ path('', include(router.urls)), # Site-level publishing settings path('sites//publishing-settings/', publishing_settings_viewset, name='publishing-settings'), + # Unified site settings (AI & Automation consolidated) + path('sites//unified-settings/', unified_settings_viewset, name='unified-settings'), + # Webhook endpoints path('webhooks/wordpress/status/', wordpress_status_webhook, name='wordpress-status-webhook'), path('webhooks/wordpress/metadata/', wordpress_metadata_webhook, name='wordpress-metadata-webhook'), diff --git a/backend/igny8_core/modules/integration/views.py b/backend/igny8_core/modules/integration/views.py index de5c530d..01e525b1 100644 --- a/backend/igny8_core/modules/integration/views.py +++ b/backend/igny8_core/modules/integration/views.py @@ -966,11 +966,16 @@ class PublishingSettingsSerializer(serializers.ModelSerializer): 'site', 'auto_approval_enabled', 'auto_publish_enabled', + 'scheduling_mode', 'daily_publish_limit', 'weekly_publish_limit', 'monthly_publish_limit', 'publish_days', 'publish_time_slots', + 'stagger_start_time', + 'stagger_end_time', + 'stagger_interval_minutes', + 'queue_limit', 'created_at', 'updated_at', ] diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py index 3d51aea8..2358c425 100644 --- a/backend/igny8_core/settings.py +++ b/backend/igny8_core/settings.py @@ -706,34 +706,27 @@ UNFOLD = { {"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", "collapsible": True, "items": [ - {"title": "Transactions", "icon": "swap_horiz", "link": lambda request: "/admin/billing/credittransaction/"}, - {"title": "Usage Log", "icon": "history", "link": lambda request: "/admin/billing/creditusagelog/"}, + {"title": "Credit Transactions", "icon": "swap_horiz", "link": lambda request: "/admin/billing/credittransaction/"}, + {"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/"}, ], }, - # Planning + # Content Pipeline (RENAMED from Planning + Writing) { - "title": "Planning", - "icon": "map", + "title": "Content Pipeline", + "icon": "edit_note", "collapsible": True, "items": [ {"title": "Keywords", "icon": "key", "link": lambda request: "/admin/planner/keywords/"}, {"title": "Clusters", "icon": "hub", "link": lambda request: "/admin/planner/clusters/"}, {"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": "Content", "icon": "description", "link": lambda request: "/admin/writer/content/"}, {"title": "Images", "icon": "image", "link": lambda request: "/admin/writer/images/"}, @@ -758,10 +751,31 @@ UNFOLD = { "icon": "publish", "collapsible": True, "items": [ - {"title": "Integrations", "icon": "extension", "link": lambda request: "/admin/integration/siteintegration/"}, {"title": "Publishing Records", "icon": "cloud_upload", "link": lambda request: "/admin/publishing/publishingrecord/"}, {"title": "Deployments", "icon": "rocket", "link": lambda request: "/admin/publishing/deploymentrecord/"}, {"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 @@ -776,20 +790,7 @@ UNFOLD = { {"title": "Downloads", "icon": "download", "link": lambda request: "/admin/plugins/plugindownload/"}, ], }, - # AI Configuration - { - "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) + # Email Settings { "title": "Email Settings", "icon": "email", @@ -798,33 +799,29 @@ UNFOLD = { {"title": "Email Configuration", "icon": "settings", "link": lambda request: "/admin/system/emailsettings/"}, {"title": "Email Templates", "icon": "article", "link": lambda request: "/admin/system/emailtemplate/"}, {"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", "icon": "settings", "collapsible": True, "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": "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": "Author Profiles", "icon": "person_outline", "link": lambda request: "/admin/system/globalauthorprofile/"}, {"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", "collapsible": True, "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": "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 diff --git a/backend/igny8_core/tasks/publishing_scheduler.py b/backend/igny8_core/tasks/publishing_scheduler.py index 2e7b541c..fffa6a5a 100644 --- a/backend/igny8_core/tasks/publishing_scheduler.py +++ b/backend/igny8_core/tasks/publishing_scheduler.py @@ -68,7 +68,23 @@ def schedule_approved_content() -> Dict[str, Any]: results['sites_processed'] += 1 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) # 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. + 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: settings: PublishingSettings instance site: Site instance @@ -120,13 +141,13 @@ def _calculate_available_slots(settings: 'PublishingSettings', site: 'Site') -> from igny8_core.business.content.models import Content 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_times = settings.publish_time_slots or ['09:00', '14:00', '18:00'] - - # Day name mapping day_map = { 'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3, '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 = [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 daily_limit = settings.daily_publish_limit weekly_limit = settings.weekly_publish_limit monthly_limit = settings.monthly_publish_limit + queue_limit = getattr(settings, 'queue_limit', 100) or 100 # Count existing scheduled/published content 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 ).count() - # Generate slots for next 30 days - current_date = now.date() - slots_per_day = {} # Track slots used per day + # Route to appropriate slot generator + if settings.scheduling_mode == 'stagger': + 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 if day is allowed if check_date.weekday() not in allowed_days: continue @@ -216,8 +265,111 @@ def _calculate_available_slots(settings: 'PublishingSettings', site: 'Site') -> slots.append(slot_time) slots_per_day[day_key] = slots_per_day.get(day_key, 0) + 1 - # Limit total slots to prevent memory issues - if len(slots) >= 100: + # Respect queue limit + 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 diff --git a/backend/migrations/0014_automation_per_run_limits.py b/backend/migrations/0014_automation_per_run_limits.py new file mode 100644 index 00000000..85da84ab --- /dev/null +++ b/backend/migrations/0014_automation_per_run_limits.py @@ -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)'), + ), + ] diff --git a/backend/migrations/0015_publishing_settings_overhaul.py b/backend/migrations/0015_publishing_settings_overhaul.py new file mode 100644 index 00000000..897e407e --- /dev/null +++ b/backend/migrations/0015_publishing_settings_overhaul.py @@ -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' + ), + ), + ] diff --git a/backend/migrations/0016_site_ai_budget_allocation.py b/backend/migrations/0016_site_ai_budget_allocation.py new file mode 100644 index 00000000..48c914e3 --- /dev/null +++ b/backend/migrations/0016_site_ai_budget_allocation.py @@ -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'), + ), + ] diff --git a/docs/plans/AUTOMATION-ENHANCEMENT-PLAN.md b/docs/plans/AUTOMATION-ENHANCEMENT-PLAN.md new file mode 100644 index 00000000..0826aaba --- /dev/null +++ b/docs/plans/AUTOMATION-ENHANCEMENT-PLAN.md @@ -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 diff --git a/docs/plans/SETTINGS-CONSOLIDATION-PLAN.md b/docs/plans/SETTINGS-CONSOLIDATION-PLAN.md new file mode 100644 index 00000000..17ca28a0 --- /dev/null +++ b/docs/plans/SETTINGS-CONSOLIDATION-PLAN.md @@ -0,0 +1,1272 @@ +# Settings & AI Configuration Consolidation Plan + +**Created:** January 17, 2026 +**Updated:** January 17, 2026 +**Status:** FINALIZED - READY FOR IMPLEMENTATION +**Priority:** 🟡 HIGH - Improve UX & Reduce Confusion +**Depends On:** None (standalone improvement) + +> **📋 SINGLE PLAN FILE** - This document covers both backend AND frontend implementation. + +--- + +## Executive Summary + +The IGNY8 codebase has **40+ scattered settings** across **17 different models** with significant redundancy. This plan consolidates them into a clean, hierarchical structure with a unified frontend experience. + +### Key Decisions (Finalized) +1. **AI Models:** Simple **Testing** vs **Live** toggle (no quality tiers) +2. **Credit Budget:** Enabled by default, uses all available credits +3. **Per-Run Limits:** Apply to INPUT items (keywords, clusters, etc.) +4. **SiteIntegration Model:** To be REMOVED (redundant with Site.wp_* fields) +5. **Terminology:** Use "Site" not "WordPress" throughout + +--- + +## Part 1: Current State Analysis + +### 1.1 Settings Model Inventory + +| Scope | Model | Location | Purpose | Status | +|-------|-------|----------|---------|--------| +| **GLOBAL** | `IntegrationProvider` | modules/system/models.py | API keys - ONE record per provider (openai, runware, stripe, paypal, resend) | ✅ CANONICAL (SINGLE) | +| | `SystemAISettings` | modules/system/ai_settings.py | AI parameter defaults (temp, tokens, image style) | ✅ Active | +| | `GlobalIntegrationSettings` | modules/system/models.py | Legacy API keys + AI defaults | ⚠️ **DEPRECATED** | +| | `AIModelConfig` | business/billing/models.py | Model definitions + credit pricing | ✅ Active | +| | `CreditCostConfig` | business/billing/models.py | Fixed credits per operation | ✅ Active | +| | `BillingConfiguration` | business/billing/models.py | Token-credit ratios | ✅ Active | +| | `GlobalModuleSettings` | modules/system/models.py | Platform module toggles | ✅ Active | +| | `CreditPackage` | business/billing/models.py | Credit purchase bundles | ✅ Active | +| | `PaymentMethodConfig` | business/billing/models.py | Payment methods per country | ✅ Active | +| **ACCOUNT** | `AccountSettings` | modules/system/settings_models.py | Key-value store - MULTIPLE records per account (ai.temperature, ai.max_tokens, etc.) | ✅ CANONICAL | +| | `ModuleEnableSettings` | modules/system/settings_models.py | Account module toggles | ✅ Active | +| | `AISettings` | modules/system/settings_models.py | Integration configs per account (overlaps AccountSettings) | ⚠️ **TO REMOVE** - use AccountSettings instead | +| | `IntegrationSettings` | business/integration/models.py | Account integration overrides | ✅ Active | +| | `AccountPaymentMethod` | business/billing/models.py | Saved payment methods | ✅ Active | +| **SITE** | `AutomationConfig` | business/automation/models.py | Automation stages, limits | ✅ Active | +| | `PublishingSettings` | business/integration/models.py | Publishing schedule | ✅ Active | +| | `SiteAIBudgetAllocation` | business/billing/models.py | Credit % per AI function | ✅ Active | +| | `SiteIntegration` | business/integration/models.py | Site platform credentials | ⚠️ **TO BE REMOVED** - Redundant with Site.wp_* fields | +| **USER** | `UserSettings` | modules/system/settings_models.py | User preferences | ✅ Active | + +### 1.2 Identified Problems + +#### Problem 1: API Keys in Multiple Places +``` +❌ GlobalIntegrationSettings.openai_api_key +❌ GlobalIntegrationSettings.anthropic_api_key +❌ GlobalIntegrationSettings.runware_api_key +✅ IntegrationProvider.encrypted_credentials (CANONICAL) +``` +**Fix:** Remove API key fields from GlobalIntegrationSettings + +#### Problem 2: AI Parameters Fragmented +``` +SystemAISettings.temperature ← Global default +GlobalIntegrationSettings.openai_temperature ← Duplicate +AccountSettings[ai.temperature] ← Account override +IntegrationSettings.config.temperature ← Another override +``` +**Fix:** Keep only SystemAISettings + AccountSettings override + +#### Problem 3: Model Selection Confusion +``` +AIModelConfig.is_default = True ← Should be canonical +GlobalIntegrationSettings.default_text_model ← Duplicate +GlobalIntegrationSettings.default_image_model ← Duplicate +``` +**Fix:** Use AIModelConfig.is_default only + +#### Problem 4: Frontend "Testing vs Live" Model Not Implemented +``` +Current: Model names exposed directly (GPT-4.1, Claude 3.5, etc.) +Needed: "Testing Model" and "Live Model" abstraction +``` +**Fix:** Add `model_tier` field to AIModelConfig + +#### Problem 5: Redundant AISettings Model +``` +AISettings.integration_type + config ← Overlaps with: +- AccountSettings (ai.* keys) +- IntegrationSettings.config +``` +**Fix:** Remove AISettings model, consolidate into AccountSettings + +--- + +## Part 2: Target Architecture + +### 2.0 Frontend Location: CONFIRMED + +**Location:** Site Settings > AI & Automation Tab + +This is the CORRECT location for the unified settings page: +- Near existing site configuration +- Familiar location for users +- Keeps all site-specific settings together +- Rename existing "AI Settings" tab to "AI & Automation" + +**NOT** in Setup menu - Setup is for initial configuration, not ongoing settings. + +--- + +### 2.1 Django Admin Sidebar - Before & After + +#### BEFORE (Current - From Screenshot) +``` +Accounts & Users ▼ +├── Accounts +├── Users +├── Sites +├── Sectors +└── Site Access + +Plans & Billing ▼ +├── Plans +├── Subscriptions +├── Invoices +├── Payments +├── Credit Packages +├── Payment Methods (Global) +└── Account Payment Methods + +Credits ▼ +├── Credit Transactions +├── Credit Usage Logs +├── Credit Cost Config ← Rarely used +└── Billing Configuration ← Rarely used + +(AI Models scattered elsewhere) +``` + +#### AFTER (Proposed - Reorganized) +``` +Dashboard & Reports ▼ +├── Dashboard +├── Revenue Report +├── Usage Report +├── Content Report +├── AI Cost Analysis ← Margin/pricing reports here +└── Token Usage + +Accounts & Users ▼ +├── Accounts +├── Users +├── Sites +├── Sectors +└── Site Access + +Plans & Billing ▼ +├── Plans +├── Subscriptions +├── Invoices +├── Payments +├── Credit Packages +├── Payment Methods (Global) +└── Account Payment Methods + +Credits & AI Usage ▼ ← CONSOLIDATED LOGS +├── Credit Transactions +├── Credit Usage Log +├── AI Task Logs ← Moved from AI Configuration +└── Plan Limits + +AI Configuration ▼ ← SIMPLIFIED +├── AI Models (Testing/Live) ← Main config with COST fields +├── System AI Settings ← Default parameters +└── Integration Providers ← API keys (OpenAI, Runware, Stripe, etc.) + +Content Pipeline ▼ ← RENAMED (was Planning + Writing) +├── Keywords +├── Clusters +├── Content Ideas +├── Tasks +├── Content +├── Images +└── Image Prompts + +Publishing ▼ +├── Publishing Records +├── Deployments +└── Sync Events + (Removed: Integrations - SiteIntegration removed) + +Automation ▼ ← NEW SECTION +├── Automation Configs +└── Automation Runs + +Global Settings ▼ ← SIMPLIFIED +├── Global AI Prompts +├── Module Settings +├── Author Profiles +└── Strategies + +Account & User Settings ▼ ← CONSOLIDATED +├── Account Settings ← All key-value settings +├── User Settings +└── Module Enable Settings + +Resources ▼ +├── Industries +├── Industry Sectors +└── Seed Keywords + +Plugin Management ▼ +├── Plugins +├── Plugin Versions +├── Installations +└── Downloads + +Email Settings ▼ +├── Email Configuration +├── Email Templates +└── Email Logs + +Logs & Monitoring ▼ +├── System Health +├── API Monitor +├── Debug Console +├── Celery Tasks +└── Admin Log + +Django Admin ▼ +├── Groups +├── Permissions +├── Content Types +└── Sessions + +Trash ▼ +├── ... (all trash items) +``` + +#### Key Reorganization Changes + +| Before | After | Reason | +|--------|-------|--------| +| AI Task Logs in "AI Configuration" | Moved to "Credits & AI Usage" | All logs together | +| Credit Cost Config, Billing Config | REMOVED | Costs in AIModelConfig | +| Planning + Writing (separate) | "Content Pipeline" (combined) | Logical flow | +| SiteIntegration in Publishing | REMOVED | Redundant with Site.wp_* | +| Global Settings (cluttered) | Simplified + separate Automation | Cleaner | +| System Configuration (scattered) | "Account & User Settings" | Consolidated | + +--- + +### 2.2 Model Hierarchy (After Consolidation) + +``` +GLOBAL (Admin-Only Configuration) +├── IntegrationProvider # API keys for all providers (OpenAI, Anthropic, Runware) +├── AIModelConfig # Model definitions + pricing + is_testing flag +├── SystemAISettings # Default AI parameters (temp, tokens, image style) +└── GlobalModuleSettings # Platform module toggles + +ACCOUNT (User Account Settings) +├── AccountSettings # Key-value store for all account config +├── ModuleEnableSettings # Which modules user can access +└── AccountPaymentMethod # Saved payment methods + +SITE (Per-Site Configuration) +├── SiteAIAutomationSettings # NEW: Unified automation+AI+publishing +└── Site.wp_url, Site.wp_api_key # Integration credentials (moved from SiteIntegration) + +USER +└── UserSettings # User preferences +``` + +**REMOVED Models:** +- `SiteIntegration` → credentials now in `Site` model +- `CreditCostConfig` → not actively used +- `BillingConfiguration` → hardcode values +- `GlobalIntegrationSettings` → replaced by `IntegrationProvider` +- `AISettings` (settings_models) → use `AccountSettings` + +--- + +### 2.3 AI Model Designation: Testing vs Live (Simplified) + +**REMOVED:** Standard, Premium, Quality tiers +**NEW:** Simple boolean `is_testing` field + +| Field Value | Frontend Label | Description | +|-------------|---------------|-------------| +| `is_testing = True` | **Testing** | Cheap model for testing (100x cheaper, low quality) | +| `is_testing = False` | **Live** | Production model | + +**Constraint:** Only ONE model can be `is_testing=True` per `model_type` (text/image). + +**Frontend Column Header:** "Testing/Live AI Model" + +**Dropdown Options:** +``` +[Testing ▼] ← Shows only the testing model +[Live ▼] ← Shows only the live model +``` + +--- + +### 2.3.1 Costing & Pricing - Single Source of Truth + +**IMPORTANT:** After removing CreditCostConfig, ALL costing/pricing lives in `AIModelConfig`. + +#### Cost vs Price Definitions + +| Term | Definition | Where Stored | +|------|------------|--------------| +| **Cost** | Our expense per token/image (what we pay to OpenAI, Runware, etc.) | `AIModelConfig.cost_per_1k_input`, `cost_per_1k_output` | +| **Price** | What we charge users in credits (revenue calculation) | `AIModelConfig.tokens_per_credit`, `credits_per_image` | +| **Margin** | Price - Cost = Revenue margin | Calculated in reports | + +#### AIModelConfig Fields for Costing + +```python +class AIModelConfig(models.Model): + # ═══════════════════════════════════════════════════════════ + # COST (Our expense - what we pay providers) + # ═══════════════════════════════════════════════════════════ + + # Text models: Cost per 1K tokens (USD) + cost_per_1k_input = models.DecimalField(...) # e.g., $0.01 for GPT-4.1-mini input + cost_per_1k_output = models.DecimalField(...) # e.g., $0.03 for GPT-4.1-mini output + + # Image models: Cost per image (USD) - stored in metadata or calculated + # e.g., DALL-E 3 = $0.04/image, Runware basic = $0.004/image + + # ═══════════════════════════════════════════════════════════ + # PRICE (What we charge users in credits) + # ═══════════════════════════════════════════════════════════ + + # Text models: How many tokens = 1 credit + tokens_per_credit = models.IntegerField(...) # e.g., 10000 tokens = 1 credit + + # Image models: How many credits per image + credits_per_image = models.IntegerField(...) # e.g., 1 credit, 5 credits, 15 credits +``` + +#### Usage in Reports + +```python +# AI Cost Analysis Report (Backend) +def calculate_margin(usage_log): + model = usage_log.model_config + + # Calculate our cost + if model.model_type == 'text': + cost = (usage_log.input_tokens / 1000 * model.cost_per_1k_input + + usage_log.output_tokens / 1000 * model.cost_per_1k_output) + else: # image + cost = usage_log.images_generated * model.cost_per_image # from metadata + + # Calculate what user paid (credits × credit value) + credits_used = usage_log.credits_used + credit_value = 0.01 # $0.01 per credit (configurable) + revenue = credits_used * credit_value + + # Margin + margin = revenue - cost + margin_pct = (margin / revenue * 100) if revenue > 0 else 0 + + return { + 'cost': cost, + 'revenue': revenue, + 'margin': margin, + 'margin_pct': margin_pct, + } +``` + +#### What Gets Removed + +| Model | Reason | +|-------|--------| +| `CreditCostConfig` | Redundant - costs now in AIModelConfig per model | +| `BillingConfiguration` | Rarely used - can hardcode credit value ($0.01) | + +--- + +### 2.3.2 Settings Hierarchy - Where Defaults & Overrides Live + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ SETTINGS HIERARCHY │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ GLOBAL DEFAULTS (Admin-only, one record each) │ +│ ├── IntegrationProvider (one per provider: openai, runware, stripe, etc.) │ +│ │ └── API keys, endpoints, sandbox mode │ +│ │ │ +│ ├── AIModelConfig (one per model: gpt-4.1, dall-e-3, etc.) │ +│ │ └── Model pricing, costs, capabilities, is_testing flag │ +│ │ │ +│ └── SystemAISettings (SINGLETON - one record total) │ +│ └── Default temperature, max_tokens, image_style, image_quality │ +│ │ +│ ════════════════════════════════════════════════════════════════════════ │ +│ │ +│ ACCOUNT OVERRIDES (Multiple key-value records per account) │ +│ └── AccountSettings (unique_together: account + key) │ +│ ├── ai.temperature = 0.8 # Override SystemAISettings default │ +│ ├── ai.max_tokens = 4000 # Override SystemAISettings default │ +│ ├── ai.image_style = "vivid" # Override SystemAISettings default │ +│ └── ... other account-specific settings │ +│ │ +│ ════════════════════════════════════════════════════════════════════════ │ +│ │ +│ SITE CONFIGURATION (One record per site) │ +│ └── SiteAIAutomationSettings (NEW unified model) │ +│ ├── Automation: enabled, frequency, time │ +│ ├── Stages: JSON config per stage (batch_size, per_run_limit, etc.) │ +│ └── Publishing: days, time_slots │ +│ │ +│ ════════════════════════════════════════════════════════════════════════ │ +│ │ +│ Resolution Order: Site → Account → Global Default │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +### 2.3.3 AISettings Model - REMOVAL PLAN + +**Current State:** `AISettings` in `settings_models.py` stores per-account integration configs. + +**Problem:** Overlaps with `AccountSettings` which already has `ai.*` keys. + +**Migration:** +1. Any data in `AISettings.config` → Move to `AccountSettings` keys +2. `AISettings.model_preferences` → `AccountSettings['ai.model_preferences']` +3. `AISettings.cost_limits` → Not needed (credit budget in SiteAIAutomationSettings) +4. Remove `AISettings` model and admin + +**After Removal:** +- Account AI settings use `AccountSettings` with keys like: + - `ai.temperature` + - `ai.max_tokens` + - `ai.image_style` + - `ai.image_quality` + - `ai.model_preferences` + +--- + +### 2.4 SiteIntegration Model - REMOVAL PLAN + +**Status:** CONFIRMED REDUNDANT - Safe to remove + +**Analysis:** +| What SiteIntegration Stores | Where It Actually Lives | +|----------------------------|------------------------| +| `credentials_json.api_key` | `Site.wp_api_key` (single source of truth) | +| `config_json.url` | `Site.wp_url` (single source of truth) | +| `platform` | Always 'wordpress' (hardcoded) | +| `sync_status`, `last_sync_at` | Can move to `Site` model | + +**Evidence from codebase:** +```python +# All services use Site model directly: +api_key = site.wp_api_key # NOT SiteIntegration.credentials_json +url = site.wp_url # NOT SiteIntegration.config_json +``` + +**Removal Steps:** +1. Add `sync_status`, `last_sync_at` fields to `Site` model +2. Update `SyncEvent.integration` FK → `SyncEvent.site` FK +3. Update `sync_health_service.py` to use Site directly +4. Remove `SiteIntegration` admin registration +5. Remove `SiteIntegration` model +6. Create migration to drop table + +--- + +### 2.5 New Unified Model: SiteAIAutomationSettings + +**Replaces:** AutomationConfig + PublishingSettings + SiteAIBudgetAllocation fields + +```python +class SiteAIAutomationSettings(AccountBaseModel): + """ + Unified AI & Automation settings for a site. + Consolidates AutomationConfig, PublishingSettings, and budget allocation. + """ + site = models.OneToOneField('Site', on_delete=models.CASCADE) + + # ═══════════════════════════════════════════════════════════ + # AUTOMATION SCHEDULING + # ═══════════════════════════════════════════════════════════ + automation_enabled = models.BooleanField(default=False) + automation_frequency = models.CharField(max_length=20, default='daily', + choices=[('hourly', 'Hourly'), ('daily', 'Daily'), ('weekly', 'Weekly')]) + automation_time = models.TimeField(default='02:00') + + # ═══════════════════════════════════════════════════════════ + # STAGE CONFIGURATION (JSON for flexibility) + # ═══════════════════════════════════════════════════════════ + # Structure: { + # "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}, + # ... + # } + # NOTE: per_run_limit applies to INPUT items: + # Stage 1: max keywords to cluster + # Stage 2: max clusters to generate ideas from + # Stage 4: max tasks to generate content for + # etc. + stage_config = models.JSONField(default=dict) + + # ═══════════════════════════════════════════════════════════ + # DELAYS + # ═══════════════════════════════════════════════════════════ + within_stage_delay = models.IntegerField(default=3, help_text="Seconds between items") + between_stage_delay = models.IntegerField(default=5, help_text="Seconds between stages") + + # ═══════════════════════════════════════════════════════════ + # PUBLISHING SCHEDULE (Simplified - Time Slots only) + # ═══════════════════════════════════════════════════════════ + auto_approval_enabled = models.BooleanField(default=True) + auto_publish_enabled = models.BooleanField(default=True) + + # Schedule: Days + Time Slots (NO stagger, NO limits) + publish_days = models.JSONField(default=list) # ['mon', 'tue', 'wed', 'thu', 'fri'] + publish_time_slots = models.JSONField(default=list) # ['09:00', '14:00', '18:00'] + + # REMOVED: scheduling_mode, daily/weekly/monthly limits, stagger_*, queue_limit + + class Meta: + db_table = 'igny8_site_ai_automation_settings' +``` + +--- + +### 2.6 Publishing Schedule - SIMPLIFIED (Corrected) + +**What PublishingSettings Controls:** SCHEDULING (when content gets published), NOT publishing itself. + +**REMOVED Features (Unnecessary Complexity):** +- ❌ Staggered mode - Not needed +- ❌ Daily/weekly/monthly limits - Derived from time slots × days +- ❌ Queue limit - No artificial limit needed + +**SIMPLIFIED Model:** + +```python +class PublishingSettings(AccountBaseModel): + """Site-level publishing SCHEDULE configuration.""" + + site = models.OneToOneField('Site', on_delete=models.CASCADE) + + # Auto-approval/publish toggles + auto_approval_enabled = models.BooleanField(default=True) + auto_publish_enabled = models.BooleanField(default=True) + + # Schedule Configuration (Time Slots mode ONLY) + publish_days = models.JSONField(default=list) # ['mon', 'tue', 'wed', 'thu', 'fri'] + publish_time_slots = models.JSONField(default=list) # ['09:00', '14:00', '18:00'] + + # REMOVED: + # - 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) +``` + +**How It Works:** + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ PUBLISHING SCHEDULE = Days × Time Slots │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Selected Days: [✓] Mon [✓] Tue [✓] Wed [✓] Thu [✓] Fri [ ] Sat [ ] Sun │ +│ └── 5 days selected │ +│ │ +│ Time Slots: [09:00] [14:00] [18:00] │ +│ └── 3 slots per day │ +│ │ +│ ════════════════════════════════════════════════════════════════════════ │ +│ │ +│ Calculated Capacity: │ +│ • Daily: 3 articles (one per time slot) │ +│ • Weekly: 15 articles (3 slots × 5 days) │ +│ • Monthly: ~65 articles (15 × 4.3 weeks) │ +│ │ +│ ════════════════════════════════════════════════════════════════════════ │ +│ │ +│ Content Flow: │ +│ [Approved] → [Scheduled for next available slot] → [Published] │ +│ │ +│ Example (Wednesday 10:30 AM): │ +│ Content approved → Scheduled for Wed 14:00 (next available slot) │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +**No Queue Limit Needed:** +- Content is scheduled to the next available time slot +- If all slots are full for today, schedule for tomorrow +- Natural flow, no artificial limits + +--- + +## Part 3: Backend Changes + +### 3.1 Add is_testing Flag to AIModelConfig + +```python +# In business/billing/models.py - AIModelConfig + +class AIModelConfig(models.Model): + # ... existing fields ... + + # REMOVE these fields: + # - quality_tier (was: basic/quality/premium for images) + # - model_tier (proposed but not needed) + + # ADD: Simple testing flag + is_testing = models.BooleanField( + default=False, + db_index=True, + help_text="Testing model (cheap, for testing only). One per model_type." + ) + + class Meta: + constraints = [ + # Only one testing model per type (text/image) + models.UniqueConstraint( + fields=['model_type'], + condition=models.Q(is_testing=True, is_active=True), + name='unique_testing_model_per_type' + ) + ] + + @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() + + # NEW: Only one model per (model_type, model_tier) can be active + @classmethod + def get_model_for_tier(cls, model_type: str, tier: str): + """Get the active model for a given type and tier""" + return cls.objects.filter( + model_type=model_type, + model_tier=tier, + is_active=True + ).first() +``` + +### 3.2 Unified Settings API Endpoint + +```python +# New file: backend/igny8_core/api/unified_settings.py + +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/ + """ + + def retrieve(self, request, site_id): + """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 the unified settings + settings, _ = SiteAIAutomationSettings.objects.get_or_create( + site=site, + defaults=self._get_defaults() + ) + + # Get available models (Testing vs Live) + text_models = { + 'testing': AIModelConfig.get_testing_model('text'), + 'live': AIModelConfig.get_live_model('text'), + } + image_models = { + 'testing': AIModelConfig.get_testing_model('image'), + 'live': AIModelConfig.get_live_model('image'), + } + + return Response({ + 'automation': { + 'enabled': settings.automation_enabled, + 'frequency': settings.automation_frequency, + 'time': settings.automation_time.strftime('%H:%M'), + }, + 'stages': self._build_stage_matrix(settings), + 'delays': { + 'within_stage': settings.within_stage_delay, + 'between_stage': settings.between_stage_delay, + }, + 'publishing': { + 'auto_approval_enabled': settings.auto_approval_enabled, + 'auto_publish_enabled': settings.auto_publish_enabled, + 'publish_days': settings.publish_days, + 'time_slots': settings.publish_time_slots, + # Calculated capacity (read-only, derived from days × slots) + 'daily_capacity': len(settings.publish_time_slots), + 'weekly_capacity': len(settings.publish_time_slots) * len(settings.publish_days), + }, + 'available_models': { + 'text': { + 'testing': text_models['testing'].display_name if text_models['testing'] else None, + 'live': text_models['live'].display_name if text_models['live'] else None, + }, + 'image': { + 'testing': image_models['testing'].display_name if image_models['testing'] else None, + 'live': image_models['live'].display_name if image_models['live'] else None, + }, + }, + }) + + def _build_stage_matrix(self, settings): + """Build stage configuration matrix""" + stage_config = settings.stage_config or {} + + stages = [ + {'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}, + ] + + result = [] + for stage in stages: + num = str(stage['number']) + config = stage_config.get(num, {}) + + result.append({ + 'number': stage['number'], + 'name': stage['name'], + 'has_ai': stage['has_ai'], + 'enabled': config.get('enabled', True), + 'batch_size': config.get('batch_size', self._default_batch_size(stage['number'])), + 'per_run_limit': config.get('per_run_limit', 0), + 'model_tier': config.get('model_tier', 'standard') if stage['has_ai'] else None, + 'credit_percentage': config.get('credit_percentage', 20) if stage['has_ai'] else None, + }) + + return result +``` + +### 3.3 Remove Deprecated Models/Fields + +**Phase 1 (This Release):** +- Add deprecation warnings to `GlobalIntegrationSettings` API key fields +- Add `model_tier` to `AIModelConfig` +- Create `SiteAIAutomationSettings` model + +**Phase 2 (Next Release):** +- Remove API key fields from `GlobalIntegrationSettings` +- Remove `AISettings` model (settings_models.py) +- Migrate data from old models to new unified model + +**Phase 3 (v2.0):** +- Remove `GlobalIntegrationSettings` model entirely +- Remove separate `AutomationConfig`, `PublishingSettings` models +- Full migration to unified settings + +--- + +## Part 4: Frontend Changes + +### 4.1 Unified Settings Page - Full UI Mockup + +**Location:** `frontend/src/pages/Sites/AIAutomationSettings.tsx` +**Access:** Site Settings > AI & Automation tab + +``` +┌─────────────────────────────────────────────────────────────────────────────────────────────┐ +│ AI & Automation Settings [Save Changes] │ +├─────────────────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─── Quick Settings ────────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ Automation [✓] Enable Scheduled Runs Frequency: [Daily ▼] Time: [02:00] │ │ +│ │ Publishing [✓] Auto-Approve Content [✓] Auto-Publish to Site │ │ +│ │ │ │ +│ └────────────────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─── Stage Configuration Matrix ────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ┌────────────────────────┬────────┬───────────┬───────────┬──────────────┬─────────┐ │ │ +│ │ │ Stage │ Enable │ Batch Size│ Per-Run │ Testing/Live │ Budget │ │ │ +│ │ │ │ │ │ Limit* │ AI Model │ % │ │ │ +│ │ ├────────────────────────┼────────┼───────────┼───────────┼──────────────┼─────────┤ │ │ +│ │ │ 1. Keywords→Clusters │ [✓] │ [50] │ [0] │ Live ▼ │ [15] │ │ │ +│ │ ├────────────────────────┼────────┼───────────┼───────────┼──────────────┼─────────┤ │ │ +│ │ │ 2. Clusters→Ideas │ [✓] │ [1] │ [10] │ Live ▼ │ [10] │ │ │ +│ │ ├────────────────────────┼────────┼───────────┼───────────┼──────────────┼─────────┤ │ │ +│ │ │ 3. Ideas→Tasks │ [✓] │ [20] │ [0] │ - │ - │ │ │ +│ │ │ (local operation) │ │ │ │ │ │ │ │ +│ │ ├────────────────────────┼────────┼───────────┼───────────┼──────────────┼─────────┤ │ │ +│ │ │ 4. Tasks→Content │ [✓] │ [1] │ [5] │ Live ▼ │ [40] │ │ │ +│ │ ├────────────────────────┼────────┼───────────┼───────────┼──────────────┼─────────┤ │ │ +│ │ │ 5. Content→Prompts │ [✓] │ [1] │ [5] │ Live ▼ │ [5] │ │ │ +│ │ ├────────────────────────┼────────┼───────────┼───────────┼──────────────┼─────────┤ │ │ +│ │ │ 6. Prompts→Images │ [✓] │ [1] │ [20] │ Testing ▼ │ [30] │ │ │ +│ │ ├────────────────────────┼────────┼───────────┼───────────┼──────────────┼─────────┤ │ │ +│ │ │ 7. Review→Approved │ [✓] │ - │ [10] │ - │ - │ │ │ +│ │ │ (local operation) │ │ │ │ │ │ │ │ +│ │ └────────────────────────┴────────┴───────────┴───────────┴──────────────┴─────────┘ │ │ +│ │ │ │ +│ │ * Per-Run Limit: Max INPUT items to process per run (0 = all pending) │ │ +│ │ Stage 1: max keywords to cluster │ │ +│ │ Stage 2: max clusters to generate ideas from │ │ +│ │ Stage 4: max tasks to generate content for │ │ +│ │ │ │ +│ │ Budget %: Credit allocation per AI stage (must total ≤100%) Total: 100% ✓ │ │ +│ │ │ │ +│ │ Delays: Between stages [5] seconds Within stage [3] seconds │ │ +│ │ │ │ +│ └────────────────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─── Publishing Schedule (Simplified) ──────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ [✓] Auto-Approve Content [✓] Auto-Publish to Site │ │ +│ │ │ │ +│ │ Publish Days: [✓] Mon [✓] Tue [✓] Wed [✓] Thu [✓] Fri [ ] Sat [ ] Sun │ │ +│ │ │ │ +│ │ Time Slots: [09:00] [14:00] [18:00] [+ Add Slot] │ │ +│ │ │ │ +│ │ ┌─ Calculated Capacity ──────────────────────────────────────────────────────────┐ │ │ +│ │ │ 📊 Daily: 3 articles Weekly: 15 articles Monthly: ~65 articles │ │ │ +│ │ └────────────────────────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ℹ️ Content is scheduled to the next available time slot automatically │ │ +│ │ │ │ +│ └────────────────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ [Reset to Defaults] [Save Changes] │ +└─────────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.1.1 Stage Configuration Matrix - Column Reference + +| Column | Description | Input Type | Applicable Stages | +|--------|-------------|------------|-------------------| +| **Enable** | Turn stage on/off | Toggle | All (1-7) | +| **Batch Size** | Items per batch | Number input | 1, 2, 3, 4, 5, 6 | +| **Per-Run Limit** | Max INPUT items per run (0=all) | Number input | All (1-7) | +| **Testing/Live AI Model** | Which model to use | Dropdown | 1, 2, 4, 5, 6 (AI stages) | +| **Budget %** | Credit allocation | Number (0-100) | 1, 2, 4, 5, 6 (AI stages) | + +Stages 3 and 7 show "-" for AI-related columns (they're local operations, no AI calls). + +### 4.2 Testing/Live AI Model Dropdown Component + +```tsx +// components/AIModelSelect.tsx + +interface AIModelSelectProps { + modelType: 'text' | 'image'; + useTesting: boolean; + onChange: (useTesting: boolean) => void; + disabled?: boolean; +} + +export function AIModelSelect({ modelType, useTesting, onChange, disabled }: AIModelSelectProps) { + return ( + + ); +} + +// Tooltip for Testing mode + + + ⚡ Testing + + +

100x cheaper. For testing only.

+

Not suitable for production content.

+
+
+``` + +### 4.3 Updated Sidebar Navigation + +```tsx +// In AppSidebar.tsx - Updated menu structure + +const menuSections: MenuSection[] = useMemo(() => { + return [ + // Dashboard standalone + { + label: "", + items: [{ icon: , name: "Dashboard", path: "/" }], + }, + + // SETUP - Site configuration + { + label: "SETUP", + items: [ + { icon: , name: "Setup Wizard", path: "/setup/wizard" }, + { icon: , name: "Sites", path: "/sites" }, + { icon: , name: "Keyword Library", path: "/setup/add-keywords" }, + // Thinker (admin only) + isModuleEnabled('thinker') && { + icon: , + name: "Thinker", + subItems: [ + { name: "Prompts", path: "/thinker/prompts" }, + { name: "Author Profiles", path: "/thinker/author-profiles" }, + ], + adminOnly: true, + }, + ].filter(Boolean), + }, + + // WORKFLOW - Content pipeline + { + label: "WORKFLOW", + items: [ + isModuleEnabled('planner') && { + icon: , + name: "Planner", + subItems: [ + { name: "Keywords", path: "/planner/keywords" }, + { name: "Clusters", path: "/planner/clusters" }, + { name: "Ideas", path: "/planner/ideas" }, + ], + }, + isModuleEnabled('writer') && { + icon: , + name: "Writer", + subItems: [ + { name: "Content Queue", path: "/writer/tasks" }, + { name: "Content Drafts", path: "/writer/content" }, + { name: "Content Images", path: "/writer/images" }, + ], + }, + { + icon: , + name: "Publisher", + subItems: [ + { name: "Content Review", path: "/writer/review" }, + { name: "Publish / Schedule", path: "/writer/approved" }, + { name: "Content Calendar", path: "/publisher/content-calendar" }, + ], + }, + isModuleEnabled('automation') && { + icon: , + name: "Automation", + subItems: [ + { name: "Overview", path: "/automation/overview" }, + { name: "Run Now", path: "/automation/run" }, + ], + }, + ].filter(Boolean), + }, + + // ACCOUNT - User account management + { + label: "ACCOUNT", + items: [ + { icon: , name: "Account Settings", path: "/account/settings" }, + { icon: , name: "Plans & Billing", path: "/account/plans" }, + { icon: , name: "Usage", path: "/account/usage" }, + { + icon: , + name: "AI Configuration", + path: "/settings/ai-models", + adminOnly: true, + }, + ], + }, + + // HELP + { + label: "HELP", + items: [ + { icon: , name: "Notifications", path: "/account/notifications" }, + { icon: , name: "Help & Docs", path: "/help" }, + ], + }, + ]; +}, [isModuleEnabled]); +``` + +### 4.4 Sidebar Dropdown Stay-Open Fix + +```tsx +// In AppSidebar.tsx - Fix dropdown to stay open when inner page is active + +useEffect(() => { + const currentPath = location.pathname; + let foundMatch = false; + + allSections.forEach((section, sectionIndex) => { + section.items.forEach((nav, itemIndex) => { + if (nav.subItems && !foundMatch) { + // Check if any subitem matches current path + const shouldOpen = nav.subItems.some((subItem) => { + // Exact match + if (currentPath === subItem.path) return true; + // Prefix match for nested routes (e.g., /sites/123/settings matches /sites) + if (currentPath.startsWith(subItem.path + '/')) return true; + return false; + }); + + if (shouldOpen) { + setOpenSubmenu({ sectionIndex, itemIndex }); + foundMatch = true; + } + } + }); + }); + + // IMPORTANT: Don't close if no match - user may have manually opened +}, [location.pathname, allSections]); +``` + +--- + +## Part 5: Django Admin Error Prevention + +### 5.1 Safe Admin Actions + +Add try/except wrappers to all admin actions to prevent 500 errors: + +```python +# In admin/base.py - Enhanced base admin class + +class Igny8ModelAdmin(ModelAdmin): + """Base admin class with error handling""" + + def save_model(self, request, obj, form, change): + """Safe save with error handling""" + try: + super().save_model(request, obj, form, change) + except IntegrityError as e: + messages.error(request, f"Database error: {str(e)}") + except ValidationError as e: + messages.error(request, f"Validation error: {str(e)}") + except Exception as e: + messages.error(request, f"Unexpected error: {str(e)}") + logger.exception(f"Error saving {obj.__class__.__name__}") + + def delete_model(self, request, obj): + """Safe delete with error handling""" + try: + super().delete_model(request, obj) + except ProtectedError as e: + messages.error(request, f"Cannot delete: {str(e)}") + except Exception as e: + messages.error(request, f"Delete error: {str(e)}") + logger.exception(f"Error deleting {obj.__class__.__name__}") + + def delete_queryset(self, request, queryset): + """Safe bulk delete""" + try: + super().delete_queryset(request, queryset) + except ProtectedError as e: + messages.error(request, f"Cannot delete some items: {str(e)}") + except Exception as e: + messages.error(request, f"Bulk delete error: {str(e)}") +``` + +### 5.2 Admin Error Logging + +```python +# Add to all admin classes that process data + +class CreditTransactionAdmin(Igny8ModelAdmin): + + @admin.action(description='Process selected transactions') + def process_transactions(self, request, queryset): + success_count = 0 + error_count = 0 + + for obj in queryset: + try: + obj.process() + success_count += 1 + except Exception as e: + error_count += 1 + logger.error(f"Failed to process transaction {obj.id}: {e}") + + if success_count: + messages.success(request, f'{success_count} transaction(s) processed.') + if error_count: + messages.warning(request, f'{error_count} transaction(s) failed. Check logs.') +``` + +--- + +## Part 6: Migration Plan + +### Phase 1: Preparation (Week 1) +1. Add `model_tier` field to `AIModelConfig` - Migration +2. Create `SiteAIAutomationSettings` model - Migration +3. Add deprecation warnings to `GlobalIntegrationSettings` +4. Create unified API endpoint (read-only initially) + +### Phase 2: Data Migration (Week 2) +1. Migrate existing `AutomationConfig` → `SiteAIAutomationSettings` +2. Migrate existing `PublishingSettings` → `SiteAIAutomationSettings` +3. Migrate existing `SiteAIBudgetAllocation` → `SiteAIAutomationSettings.budget_allocation` +4. Update automation service to use new model + +### Phase 3: Frontend Update (Week 3) +1. Create unified settings component +2. Update sidebar navigation +3. Update Site Settings to use new unified tab +4. Implement model tier dropdowns + +### Phase 4: Cleanup (Week 4) +1. Remove old separate settings pages +2. Mark old API endpoints as deprecated +3. Remove `AISettings` model +4. Update documentation + +### Phase 5: Final Migration (v2.0) +1. Remove `GlobalIntegrationSettings` model +2. Remove separate `AutomationConfig`, `PublishingSettings` models +3. Remove deprecated API endpoints + +--- + +## Part 7: Testing Checklist + +### Backend Tests +- [ ] `SiteAIAutomationSettings` CRUD works correctly +- [ ] Unified API returns complete settings +- [ ] Unified API saves all settings atomically +- [ ] `is_testing` flag works on AIModelConfig +- [ ] Migration scripts run without errors +- [ ] Old models still work during transition +- [ ] SiteIntegration removal doesn't break SyncEvent + +### Frontend Tests +- [ ] Site Settings > AI & Automation tab loads correctly +- [ ] Stage matrix displays all 7 stages +- [ ] Testing/Live AI Model dropdown works +- [ ] Budget % validation (must total ≤100%) +- [ ] Save button saves all settings atomically +- [ ] Sidebar stays open on inner pages +- [ ] No 500 errors in admin actions + +### Integration Tests +- [ ] Automation runs use new unified settings +- [ ] Publishing uses new settings +- [ ] Credit budget (stage %) works correctly +- [ ] Testing/Live model selection works in AI calls + +--- + +## Part 8: Files to Create/Modify + +### Backend Files + +| File | Action | Purpose | +|------|--------|---------| +| `business/billing/models.py` | MODIFY | Add `is_testing` field to AIModelConfig | +| `business/automation/models.py` | CREATE | Add `SiteAIAutomationSettings` model | +| `api/unified_settings.py` | CREATE | New unified API endpoint | +| `urls/api_v1.py` | MODIFY | Register new endpoint | +| `business/integration/models.py` | MODIFY | Remove SiteIntegration (Phase 2) | +| `admin/billing.py` | MODIFY | Update Django Admin grouping | +| `migrations/XXXX_*.py` | CREATE | Database migrations | + +### Frontend Files + +| File | Action | Purpose | +|------|--------|---------| +| `pages/Sites/AIAutomationSettings.tsx` | CREATE | Main unified settings page | +| `components/Settings/StageMatrix.tsx` | CREATE | Reusable stage matrix component | +| `components/Settings/AIModelSelect.tsx` | CREATE | Testing/Live dropdown component | +| `services/unifiedSettingsService.ts` | CREATE | API service for unified endpoint | +| `pages/Sites/SiteSettings.tsx` | MODIFY | Add AI & Automation tab | +| `layout/AppSidebar.tsx` | MODIFY | Keep dropdowns open on inner pages | + +### Files to Remove (Phase 2) + +| File | Reason | +|------|--------| +| Separate automation settings pages | Replaced by unified page | +| Old settings API endpoints | Deprecated, use unified | + +--- + +## Part 9: Quick Setup Presets (Future Enhancement) + +``` +┌─── Quick Setup Presets ─────────────────────────────────────────┐ +│ │ +│ [ ] Conservative - Low throughput, high quality │ +│ 5 tasks/run, 3 images/run, 30min stagger │ +│ │ +│ [●] Balanced - Moderate throughput (Recommended) │ +│ 10 tasks/run, 20 images/run, 15min stagger │ +│ │ +│ [ ] Aggressive - Maximum throughput │ +│ Unlimited, 5min stagger │ +│ │ +│ [ ] Custom - Configure manually │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Summary + +**Before:** 17 models, 40+ settings, 4+ pages to configure one workflow +**After:** ~10 models, unified page, matrix view, clear hierarchy + +| Metric | Before | After | +|--------|--------|-------| +| Settings Models | 17 | ~10 | +| Frontend Pages for Config | 4+ | 1 | +| API Calls to Load | 5+ | 1 | +| API Calls to Save | 5+ | 1 | +| AI Model Options | GPT-4.1, Claude, etc. (confusing) | Testing / Live (simple) | +| Credit Budget Config | Manual max + allocation | Auto (all credits) + stage % | +| User Confusion | High | Low | + +### Key Simplifications +1. **AI Models:** Just "Testing" vs "Live" - no quality tiers +2. **Credit Budget:** Always enabled, uses all available credits +3. **Publishing:** Only 3 modes + queue limit (removed daily/weekly targets) +4. **Per-Run Limit:** Clear - applies to INPUT items per stage +5. **SiteIntegration:** REMOVED - credentials in Site model diff --git a/docs/plans/AUTOMATION_RUNS_DETAIL_VIEW_UX_PLAN.md b/docs/plans/automation/AUTOMATION_RUNS_DETAIL_VIEW_UX_PLAN.md similarity index 100% rename from docs/plans/AUTOMATION_RUNS_DETAIL_VIEW_UX_PLAN.md rename to docs/plans/automation/AUTOMATION_RUNS_DETAIL_VIEW_UX_PLAN.md diff --git a/docs/plans/AUTOMATION_RUNS_IMPLEMENTATION_LOG.md b/docs/plans/automation/AUTOMATION_RUNS_IMPLEMENTATION_LOG.md similarity index 100% rename from docs/plans/AUTOMATION_RUNS_IMPLEMENTATION_LOG.md rename to docs/plans/automation/AUTOMATION_RUNS_IMPLEMENTATION_LOG.md diff --git a/docs/plans/AUTOMATION_RUNS_IMPLEMENTATION_SUMMARY.md b/docs/plans/automation/AUTOMATION_RUNS_IMPLEMENTATION_SUMMARY.md similarity index 100% rename from docs/plans/AUTOMATION_RUNS_IMPLEMENTATION_SUMMARY.md rename to docs/plans/automation/AUTOMATION_RUNS_IMPLEMENTATION_SUMMARY.md diff --git a/docs/plans/AUTOMATION_RUNS_QUICK_START.md b/docs/plans/automation/AUTOMATION_RUNS_QUICK_START.md similarity index 100% rename from docs/plans/AUTOMATION_RUNS_QUICK_START.md rename to docs/plans/automation/AUTOMATION_RUNS_QUICK_START.md diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 636fc00d..eb79ce09 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -113,7 +113,7 @@ const DeploymentPanel = lazy(() => import("./pages/Sites/DeploymentPanel")); // Publisher Module - Lazy loaded 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 const SetupWizard = lazy(() => import("./pages/Setup/SetupWizard")); @@ -203,10 +203,9 @@ export default function App() { } /> } /> - {/* Publisher Module - Content Calendar & Settings */} + {/* Publisher Module - Content Calendar */} } /> } /> - } /> {/* Linker Module - Redirect dashboard to content */} } /> diff --git a/frontend/src/components/form/SelectDropdown.tsx b/frontend/src/components/form/SelectDropdown.tsx index 2462373c..dc04b450 100644 --- a/frontend/src/components/form/SelectDropdown.tsx +++ b/frontend/src/components/form/SelectDropdown.tsx @@ -104,8 +104,11 @@ const SelectDropdown: React.FC = ({ } }; + // Check if w-full is specified to expand to container width + const isFullWidth = className.includes('w-full'); + return ( -
+
{/* Trigger Button - styled like igny8-select-styled */} diff --git a/frontend/src/layout/AppSidebar.tsx b/frontend/src/layout/AppSidebar.tsx index 65eef128..dfc1aaf2 100644 --- a/frontend/src/layout/AppSidebar.tsx +++ b/frontend/src/layout/AppSidebar.tsx @@ -159,7 +159,6 @@ const AppSidebar: React.FC = () => { subItems: [ { name: "Content Review", path: "/writer/review" }, { name: "Publish / Schedule", path: "/writer/approved" }, - { name: "Publish Settings", path: "/publisher/settings" }, { name: "Content Calendar", path: "/publisher/content-calendar" }, ], }); diff --git a/frontend/src/pages/Sites/AIAutomationSettings.tsx b/frontend/src/pages/Sites/AIAutomationSettings.tsx new file mode 100644 index 00000000..a7749a68 --- /dev/null +++ b/frontend/src/pages/Sites/AIAutomationSettings.tsx @@ -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 ( + + {children} + + {text} + + + ); +} + +// 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(null); + + // Image generation settings (from tenant-wide AI settings) + const [imageSettingsLoading, setImageSettingsLoading] = useState(true); + const [availableStyles, setAvailableStyles] = useState([ + { 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) => { + if (!settings) return; + setSettings({ + ...settings, + automation: { ...settings.automation, ...updates }, + }); + }; + + // Update stage configuration + const updateStage = (stageNumber: number, updates: Partial) => { + if (!settings) return; + setSettings({ + ...settings, + stages: settings.stages.map(s => + s.number === stageNumber ? { ...s, ...updates } : s + ), + }); + }; + + // Update delays + const updateDelays = (updates: Partial) => { + if (!settings) return; + setSettings({ + ...settings, + delays: { ...settings.delays, ...updates }, + }); + }; + + // Update publishing settings + const updatePublishing = (updates: Partial) => { + 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 ( +
+ +
+ ); + } + + if (!settings) { + return ( +
+

Failed to load settings. Please try again.

+ +
+ ); + } + + const totalBudget = calculateTotalBudget(settings.stages); + const hasTestingEnabled = settings.stages.some(s => s.has_ai && s.use_testing); + + return ( +
+ {/* Row 1: Three Cards - Automation Schedule, Content Publishing, Image Generation */} +
+ + {/* Card 1: Automation Schedule */} + +
+
+ +
+
+

Automation

+

Schedule runs

+
+
+ +
+
+ + updateAutomation({ enabled: checked })} + /> +
+ +
+
+ + ({ 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" + /> +
+
+ + updateAutomation({ time: e.target.value })} + disabled={!settings.automation.enabled} + /> +
+
+ +
+
+ + {settings.automation.enabled && settings.automation.next_run_at ? ( + Next run: {new Date(settings.automation.next_run_at).toLocaleString()} + ) : ( + Runs {settings.automation.frequency} at {settings.automation.time} + )} +
+
+
+
+ + {/* Card 2: Content Publishing */} + +
+
+ +
+
+

Publishing

+

Auto-publish options

+
+
+ +
+
+ + updatePublishing({ auto_approval_enabled: checked })} + /> +
+ +
+ + updatePublishing({ auto_publish_enabled: checked })} + /> +
+ + {/* AI Mode Status */} +
+
+ AI Mode: + + {hasTestingEnabled ? 'Testing' : 'Live'} + +
+

+ {hasTestingEnabled ? 'Using test models' : 'Production models'} +

+
+
+
+ + {/* Card 3: Image Generation (Style & Count only) */} + +
+
+ +
+
+

Images

+

Style & count

+
+
+ +
+
+ + ({ value: s.value, label: s.label }))} + value={selectedStyle} + onChange={(value) => setSelectedStyle(value)} + className="w-full" + /> +
+ +
+ + ({ + value: String(i + 1), + label: `${i + 1} image${i > 0 ? 's' : ''}`, + }))} + value={String(maxImages)} + onChange={(value) => setMaxImages(parseInt(value))} + className="w-full" + /> +
+ + {/* Image Sizes */} +
+
+

Featured

+

{featuredImageSize}

+
+
+

Landscape

+

{landscapeImageSize}

+
+
+

Square

+

{squareImageSize}

+
+
+
+
+
+ + {/* Row 2: Stage Configuration (2/3) + Schedule & Capacity (1/3) */} +
+ + {/* Left: Stage Configuration Matrix (2/3 width) */} + +
+
+
+ +
+
+

Stage Configuration

+

Configure each automation stage

+
+
+ + Budget: {totalBudget}% + +
+ + {/* Info Banner */} +
+
+ +

+ Limit: max items per run (0=all). + Budget: credit allocation across AI stages. +

+
+
+ + {/* Stage Table */} + + + + + + + + + + + + + {settings.stages.map((stage) => ( + + + + + + + + + ))} + +
Stage + On + + Batch + + Limit + + Model + + Budget +
+
+ {stage.number}. + + {stage.name} + + {!stage.has_ai && (local)} +
+
+ updateStage(stage.number, { enabled: checked })} + /> + + updateStage(stage.number, { batch_size: parseInt(e.target.value) || 1 })} + min="1" + max="100" + disabled={!stage.enabled} + className="w-16 text-center" + /> + + updateStage(stage.number, { per_run_limit: parseInt(e.target.value) || 0 })} + min="0" + max="1000" + disabled={!stage.enabled} + className="w-16 text-center" + /> + + {stage.has_ai ? ( + updateStage(stage.number, { use_testing: value === 'true' })} + disabled={!stage.enabled} + className="w-full" + /> + ) : ( + - + )} + + {stage.has_ai ? ( + updateStage(stage.number, { budget_pct: parseInt(e.target.value) || 0 })} + min="0" + max="100" + disabled={!stage.enabled} + className="w-16 text-center" + /> + ) : ( + - + )} +
+ + {/* Delays Row */} +
+
+
+ + updateDelays({ between_stage: parseInt(e.target.value) || 0 })} + min="0" + max="60" + className="w-16 text-center" + /> + sec +
+
+ + updateDelays({ within_stage: parseInt(e.target.value) || 0 })} + min="0" + max="60" + className="w-16 text-center" + /> + sec +
+
+
+
+ + {/* Right: Schedule + Capacity stacked */} +
+ {/* Schedule Card */} + +
+
+ +
+
+

Schedule

+

Days and time slots

+
+
+ + {/* Days Selection */} +
+ +
+ {DAYS_OF_WEEK.map((day) => ( + + ))} +
+
+ + {/* Time Slots */} +
+
+ + {settings.publishing.time_slots.length > 0 && ( + + )} +
+ +
+ {settings.publishing.time_slots.length === 0 ? ( +
+ No time slots. Add at least one. +
+ ) : ( + settings.publishing.time_slots.map((slot, index) => ( +
+ #{index + 1} + updateTimeSlot(index, e.target.value)} + className="w-32" + /> + } + variant="ghost" + tone="danger" + size="sm" + title="Remove" + onClick={() => removeTimeSlot(index)} + /> +
+ )) + )} +
+ +
+
+ + {/* Calculated Capacity Card */} + +
+
+ +
+
+

Capacity

+

Calculated output

+
+
+ +
+
+
+ {settings.publishing.daily_capacity} +
+
Daily
+
+
+
+ {settings.publishing.weekly_capacity} +
+
Weekly
+
+
+
+ ~{settings.publishing.monthly_capacity} +
+
Monthly
+
+
+ +
+ + Based on {settings.publishing.publish_days.length} days × {settings.publishing.time_slots.length} slots +
+
+
+
+ + {/* Action Buttons */} +
+ + +
+ + {/* Help Cards - 3 cards explaining different sections */} +
+ {/* How Publishing Works */} + +
+ +
+

How Publishing Works

+
    +
  • Content: Draft → Review → Approved → Published
  • +
  • Auto-approval moves Review to Approved
  • +
  • Auto-publish sends to WordPress
  • +
  • Manual publish always available
  • +
+
+
+
+ + {/* Stage Configuration Explanation */} + +
+ +
+

Stage Configuration

+
    +
  • Batch: Items processed together
  • +
  • Limit: Max items per automation run
  • +
  • Model: Live (production) or Test mode
  • +
  • Budget: Credit allocation per stage
  • +
+
+
+
+ + {/* Schedule & Capacity Explanation */} + +
+ +
+

Schedule & Capacity

+
    +
  • Select days content can be published
  • +
  • Add time slots for publishing
  • +
  • Capacity = Days × Time Slots
  • +
  • Content scheduled to next available slot
  • +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/Sites/Settings.tsx b/frontend/src/pages/Sites/Settings.tsx index a693fed2..3e0a16cf 100644 --- a/frontend/src/pages/Sites/Settings.tsx +++ b/frontend/src/pages/Sites/Settings.tsx @@ -34,6 +34,7 @@ import Badge from '../../components/ui/badge/Badge'; import { Dropdown } from '../../components/ui/dropdown/Dropdown'; import { DropdownItem } from '../../components/ui/dropdown/DropdownItem'; import SiteInfoBar from '../../components/common/SiteInfoBar'; +import AIAutomationSettings from './AIAutomationSettings'; export default function SiteSettings() { const { id: siteId } = useParams<{ id: string }>(); @@ -51,9 +52,9 @@ export default function SiteSettings() { const [isSiteSelectorOpen, setIsSiteSelectorOpen] = useState(false); const siteSelectorRef = useRef(null); - // Check for tab parameter in URL - content-types removed, redirects to integrations - const initialTab = (searchParams.get('tab') as 'general' | 'ai-settings' | 'integrations') || 'general'; - const [activeTab, setActiveTab] = useState<'general' | 'ai-settings' | 'integrations'>(initialTab); + // Check for tab parameter in URL - ai-settings removed (content in Automation tab) + const initialTab = (searchParams.get('tab') as 'general' | 'automation' | 'integrations') || 'general'; + const [activeTab, setActiveTab] = useState<'general' | 'automation' | 'integrations'>(initialTab); // Advanced Settings toggle const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); @@ -137,7 +138,7 @@ export default function SiteSettings() { useEffect(() => { // Update tab if URL parameter changes 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); } // Handle legacy tab names - redirect content-types to integrations @@ -580,17 +581,17 @@ export default function SiteSettings() {
- {/* AI Settings Tab (merged content-generation + image-settings) */} - {activeTab === 'ai-settings' && ( -
- {/* 3 Cards in a Row */} -
- - {/* Card 1: Content Settings */} - -
-
- -
-
-

Content Settings

-

Customize article writing

-
-
- - {contentGenerationLoading ? ( -
- -
- ) : ( -
-
- -