""" 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 rest_framework.views import APIView 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, IsViewerOrAbove 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, DefaultAutomationConfig 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 get_permissions(self): """Viewers can read settings; writes require editor+.""" if self.action == 'retrieve': return [IsAuthenticatedAndActive(), IsViewerOrAbove()] return [IsAuthenticatedAndActive(), IsEditorOrAbove()] 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 logger.info(f"[UnifiedSettings] Received update for site {site_id}: {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'] schedule_changed = False if 'enabled' in auto: if automation_config.is_enabled != auto['enabled']: schedule_changed = True automation_config.is_enabled = auto['enabled'] if 'frequency' in auto: if automation_config.frequency != auto['frequency']: schedule_changed = True automation_config.frequency = auto['frequency'] if 'time' in auto: from datetime import datetime new_time = datetime.strptime(auto['time'], '%H:%M').time() if automation_config.scheduled_time != new_time: schedule_changed = True automation_config.scheduled_time = new_time # Reset last_run_at and recalculate next_run_at if any schedule setting changed if schedule_changed: automation_config.last_run_at = None # Recalculate next_run_at based on new schedule from django.utils import timezone from datetime import timedelta now = timezone.now() scheduled_time = automation_config.scheduled_time # Calculate next run at the scheduled time next_run = now.replace( hour=scheduled_time.hour, minute=scheduled_time.minute, second=0, microsecond=0 ) # If scheduled time has passed today, set to tomorrow (for daily) # or appropriate next occurrence for weekly/monthly if next_run <= now: if automation_config.frequency == 'daily': next_run = next_run + timedelta(days=1) elif automation_config.frequency == 'weekly': # Next Monday days_until_monday = (7 - now.weekday()) % 7 if days_until_monday == 0: days_until_monday = 7 next_run = now + timedelta(days=days_until_monday) next_run = next_run.replace( hour=scheduled_time.hour, minute=scheduled_time.minute, second=0, microsecond=0 ) elif automation_config.frequency == 'monthly': # Next 1st of month if now.month == 12: next_run = now.replace(year=now.year + 1, month=1, day=1) else: next_run = now.replace(month=now.month + 1, day=1) next_run = next_run.replace( hour=scheduled_time.hour, minute=scheduled_time.minute, second=0, microsecond=0 ) else: next_run = next_run + timedelta(days=1) automation_config.next_run_at = next_run logger.info(f"[UnifiedSettings] Schedule changed for site {site_id}, reset last_run_at=None, next_run_at={next_run}") # Update stage configuration if 'stages' in data: logger.info(f"[UnifiedSettings] Updating stages: {data['stages']}") 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() logger.info(f"[UnifiedSettings] AutomationConfig saved for site {site_id}") # 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': automation_config.stage_1_use_testing, 'budget_pct': automation_config.stage_1_budget_pct, }, '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': automation_config.stage_2_use_testing, 'budget_pct': automation_config.stage_2_budget_pct, }, '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': automation_config.stage_4_use_testing, 'budget_pct': automation_config.stage_4_budget_pct, }, '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': automation_config.stage_5_use_testing, 'budget_pct': automation_config.stage_5_budget_pct, }, '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': automation_config.stage_6_use_testing, 'budget_pct': automation_config.stage_6_budget_pct, }, '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: logger.warning(f"[UnifiedSettings] Stage missing 'number': {stage}") continue logger.info(f"[UnifiedSettings] Processing stage {num}: enabled={stage.get('enabled')}, batch_size={stage.get('batch_size')}, per_run_limit={stage.get('per_run_limit')}, use_testing={stage.get('use_testing')}, budget_pct={stage.get('budget_pct')}") 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'] if 'use_testing' in stage: automation_config.stage_1_use_testing = stage['use_testing'] if 'budget_pct' in stage: automation_config.stage_1_budget_pct = stage['budget_pct'] 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'] if 'use_testing' in stage: automation_config.stage_2_use_testing = stage['use_testing'] if 'budget_pct' in stage: automation_config.stage_2_budget_pct = stage['budget_pct'] 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'] if 'use_testing' in stage: automation_config.stage_4_use_testing = stage['use_testing'] if 'budget_pct' in stage: automation_config.stage_4_budget_pct = stage['budget_pct'] 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'] if 'use_testing' in stage: automation_config.stage_5_use_testing = stage['use_testing'] if 'budget_pct' in stage: automation_config.stage_5_budget_pct = stage['budget_pct'] 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'] if 'use_testing' in stage: automation_config.stage_6_use_testing = stage['use_testing'] if 'budget_pct' in stage: automation_config.stage_6_budget_pct = stage['budget_pct'] 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'] logger.info(f"[UnifiedSettings] After update - stage_1_batch_size={automation_config.stage_1_batch_size}, max_keywords_per_run={automation_config.max_keywords_per_run}") # ═══════════════════════════════════════════════════════════ # DEFAULT SETTINGS API # ═══════════════════════════════════════════════════════════ class DefaultSettingsAPIView(APIView): """ API endpoint to fetch default settings for reset functionality. Reads from DefaultAutomationConfig singleton in backend. GET /api/v1/settings/defaults/ """ permission_classes = [IsAuthenticatedAndActive] @extend_schema( tags=['Site Settings'], summary='Get default settings', description='Fetch default automation, publishing, and stage settings from backend configuration. Used by frontend reset functionality.', ) def get(self, request): """Return default settings from DefaultAutomationConfig""" try: defaults = DefaultAutomationConfig.get_instance() # Build stage defaults from the model stage_defaults = [] for i in range(1, 8): stage_config = { 'number': i, 'enabled': getattr(defaults, f'stage_{i}_enabled', True), } # Batch size (stages 1-6) if i <= 6: stage_config['batch_size'] = getattr(defaults, f'stage_{i}_batch_size', 1) # Per-run limits limit_map = { 1: 'max_keywords_per_run', 2: 'max_clusters_per_run', 3: 'max_ideas_per_run', 4: 'max_tasks_per_run', 5: 'max_content_per_run', 6: 'max_images_per_run', 7: 'max_approvals_per_run', } stage_config['per_run_limit'] = getattr(defaults, limit_map[i], 0) # Use testing (AI stages only: 1, 2, 4, 5, 6) if i in [1, 2, 4, 5, 6]: stage_config['use_testing'] = getattr(defaults, f'stage_{i}_use_testing', False) stage_config['budget_pct'] = getattr(defaults, f'stage_{i}_budget_pct', 0) stage_defaults.append(stage_config) # Build publish days - ensure it's a list publish_days = defaults.publish_days if not publish_days: publish_days = ['mon', 'tue', 'wed', 'thu', 'fri'] # Build time slots - ensure it's a list time_slots = defaults.publish_time_slots if not time_slots: time_slots = ['09:00', '14:00', '18:00'] # Format scheduled_time from hour scheduled_hour = defaults.next_scheduled_hour scheduled_time = f"{scheduled_hour:02d}:00" return success_response({ 'automation': { 'enabled': defaults.is_enabled, 'frequency': defaults.frequency, 'time': scheduled_time, }, 'stages': stage_defaults, 'delays': { 'within_stage': defaults.within_stage_delay, 'between_stage': defaults.between_stage_delay, }, 'publishing': { 'auto_approval_enabled': defaults.auto_approval_enabled, 'auto_publish_enabled': defaults.auto_publish_enabled, 'daily_publish_limit': defaults.daily_publish_limit, 'weekly_publish_limit': defaults.weekly_publish_limit, 'monthly_publish_limit': defaults.monthly_publish_limit, 'publish_days': publish_days, 'time_slots': time_slots, }, 'images': { 'style': defaults.image_style, 'max_images': defaults.max_images_per_article, }, }) except Exception as e: logger.error(f"[DefaultSettings] Error fetching defaults: {e}") return error_response(str(e), status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)