from rest_framework import viewsets, filters, status from rest_framework.decorators import action from rest_framework.response import Response from django_filters.rest_framework import DjangoFilterBackend from django.db import transaction from django.db.models import Max, Count, Sum, Q from django.http import HttpResponse import csv import json import time from igny8_core.api.base import SiteSectorModelViewSet from igny8_core.api.pagination import CustomPageNumberPagination from .models import Keywords, Clusters, ContentIdeas from .serializers import KeywordSerializer, ContentIdeasSerializer from .cluster_serializers import ClusterSerializer class KeywordViewSet(SiteSectorModelViewSet): """ ViewSet for managing keywords with CRUD operations Provides list, create, retrieve, update, and destroy actions """ queryset = Keywords.objects.all() serializer_class = KeywordSerializer permission_classes = [] # Allow any for now pagination_class = CustomPageNumberPagination # Explicitly use custom pagination # DRF filtering configuration filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] # Search configuration - search by seed_keyword's keyword field search_fields = ['seed_keyword__keyword'] # Ordering configuration - allow ordering by created_at, volume, difficulty (from seed_keyword) ordering_fields = ['created_at', 'seed_keyword__volume', 'seed_keyword__difficulty'] ordering = ['-created_at'] # Default ordering (newest first) # Filter configuration - filter by status, cluster_id, and seed_keyword fields filterset_fields = ['status', 'cluster_id', 'seed_keyword__intent', 'seed_keyword_id'] def get_queryset(self): """ Override to support custom difficulty range filtering Uses parent's get_queryset() which properly handles developer role and site/sector filtering """ import logging logger = logging.getLogger(__name__) try: # Use parent's get_queryset() which handles developer role and site filtering correctly queryset = super().get_queryset() # Safely access query_params (DRF wraps request with Request class) try: query_params = getattr(self.request, 'query_params', None) if query_params is None: # Fallback for non-DRF requests query_params = getattr(self.request, 'GET', {}) except AttributeError: query_params = {} # Custom difficulty range filtering (check override first, then seed_keyword) difficulty_min = query_params.get('difficulty_min') difficulty_max = query_params.get('difficulty_max') if difficulty_min is not None: try: # Filter by seed_keyword difficulty (override logic handled in property) queryset = queryset.filter( Q(difficulty_override__gte=int(difficulty_min)) | Q(difficulty_override__isnull=True, seed_keyword__difficulty__gte=int(difficulty_min)) ) except (ValueError, TypeError): pass if difficulty_max is not None: try: queryset = queryset.filter( Q(difficulty_override__lte=int(difficulty_max)) | Q(difficulty_override__isnull=True, seed_keyword__difficulty__lte=int(difficulty_max)) ) except (ValueError, TypeError): pass # Custom volume range filtering (check override first, then seed_keyword) volume_min = query_params.get('volume_min') volume_max = query_params.get('volume_max') if volume_min is not None: try: queryset = queryset.filter( Q(volume_override__gte=int(volume_min)) | Q(volume_override__isnull=True, seed_keyword__volume__gte=int(volume_min)) ) except (ValueError, TypeError): pass if volume_max is not None: try: queryset = queryset.filter( Q(volume_override__lte=int(volume_max)) | Q(volume_override__isnull=True, seed_keyword__volume__lte=int(volume_max)) ) except (ValueError, TypeError): pass return queryset except Exception as e: logger.error(f"Error in KeywordViewSet.get_queryset(): {type(e).__name__}: {str(e)}", exc_info=True) # Return empty queryset instead of raising exception return Keywords.objects.none() def list(self, request, *args, **kwargs): """ Override list method to add error handling """ import logging logger = logging.getLogger(__name__) try: queryset = self.filter_queryset(self.get_queryset()) page = self.paginate_queryset(queryset) if page is not None: serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) except Exception as e: logger.error(f"Error in KeywordViewSet.list(): {type(e).__name__}: {str(e)}", exc_info=True) return Response({ 'error': f'Error loading keywords: {str(e)}', 'type': type(e).__name__ }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) def perform_create(self, serializer): """Require explicit site_id and sector_id - no defaults.""" user = getattr(self.request, 'user', None) # Get site_id and sector_id from validated_data or query params # Safely access query_params try: query_params = getattr(self.request, 'query_params', None) if query_params is None: query_params = getattr(self.request, 'GET', {}) except AttributeError: query_params = {} site_id = serializer.validated_data.get('site_id') or query_params.get('site_id') sector_id = serializer.validated_data.get('sector_id') or query_params.get('sector_id') # Import here to avoid circular imports from igny8_core.auth.models import Site, Sector from rest_framework.exceptions import ValidationError # Site ID is REQUIRED if not site_id: raise ValidationError("site_id is required. Please select a site.") try: site = Site.objects.get(id=site_id) except Site.DoesNotExist: raise ValidationError(f"Site with id {site_id} does not exist") # Sector ID is REQUIRED if not sector_id: raise ValidationError("sector_id is required. Please select a sector.") try: sector = Sector.objects.get(id=sector_id) # Verify sector belongs to the site if sector.site_id != site_id: raise ValidationError(f"Sector '{sector.name}' does not belong to the selected site") except Sector.DoesNotExist: raise ValidationError(f"Sector with id {sector_id} does not exist") # Remove site_id and sector_id from validated_data as they're not model fields serializer.validated_data.pop('site_id', None) serializer.validated_data.pop('sector_id', None) # Get account from site or user account = getattr(self.request, 'account', None) if not account and user and user.is_authenticated: account = getattr(user, 'account', None) if not account: account = getattr(site, 'account', None) # Save with all required fields explicitly serializer.save(account=account, site=site, sector=sector) @action(detail=False, methods=['post'], url_path='bulk_delete', url_name='bulk_delete') def bulk_delete(self, request): """Bulk delete keywords""" ids = request.data.get('ids', []) if not ids: return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST) queryset = self.get_queryset() deleted_count, _ = queryset.filter(id__in=ids).delete() return Response({'deleted_count': deleted_count}, status=status.HTTP_200_OK) @action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update') def bulk_update(self, request): """Bulk update keyword status""" ids = request.data.get('ids', []) status_value = request.data.get('status') if not ids: return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST) if not status_value: return Response({'error': 'No status provided'}, status=status.HTTP_400_BAD_REQUEST) queryset = self.get_queryset() updated_count = queryset.filter(id__in=ids).update(status=status_value) return Response({'updated_count': updated_count}, status=status.HTTP_200_OK) @action(detail=False, methods=['post'], url_path='bulk_add_from_seed', url_name='bulk_add_from_seed') def bulk_add_from_seed(self, request): """Bulk add SeedKeywords to workflow (create Keywords records)""" from igny8_core.auth.models import SeedKeyword, Site, Sector seed_keyword_ids = request.data.get('seed_keyword_ids', []) site_id = request.data.get('site_id') sector_id = request.data.get('sector_id') if not seed_keyword_ids: return Response({'error': 'No seed keyword IDs provided'}, status=status.HTTP_400_BAD_REQUEST) if not site_id: return Response({'error': 'site_id is required'}, status=status.HTTP_400_BAD_REQUEST) if not sector_id: return Response({'error': 'sector_id is required'}, status=status.HTTP_400_BAD_REQUEST) try: site = Site.objects.get(id=site_id) sector = Sector.objects.get(id=sector_id) except (Site.DoesNotExist, Sector.DoesNotExist) as e: return Response({'error': f'Invalid site or sector: {str(e)}'}, status=status.HTTP_400_BAD_REQUEST) # Validate sector belongs to site if sector.site != site: return Response({'error': 'Sector does not belong to the specified site'}, status=status.HTTP_400_BAD_REQUEST) # Get account from site account = site.account if not account: return Response({'error': 'Site has no account assigned'}, status=status.HTTP_400_BAD_REQUEST) # Get SeedKeywords seed_keywords = SeedKeyword.objects.filter(id__in=seed_keyword_ids, is_active=True) if not seed_keywords.exists(): return Response({'error': 'No valid seed keywords found'}, status=status.HTTP_400_BAD_REQUEST) created_count = 0 skipped_count = 0 errors = [] with transaction.atomic(): for seed_keyword in seed_keywords: try: # Validate industry/sector match if site.industry != seed_keyword.industry: errors.append(f"SeedKeyword '{seed_keyword.keyword}' industry mismatch") skipped_count += 1 continue if sector.industry_sector != seed_keyword.sector: errors.append(f"SeedKeyword '{seed_keyword.keyword}' sector mismatch") skipped_count += 1 continue # Create Keyword if it doesn't exist keyword, created = Keywords.objects.get_or_create( seed_keyword=seed_keyword, site=site, sector=sector, defaults={ 'status': 'pending', 'account': account } ) if created: created_count += 1 else: skipped_count += 1 except Exception as e: errors.append(f"Error adding '{seed_keyword.keyword}': {str(e)}") skipped_count += 1 return Response({ 'success': True, 'created': created_count, 'skipped': skipped_count, 'errors': errors[:10] if errors else [] # Limit errors to first 10 }, status=status.HTTP_200_OK) @action(detail=False, methods=['get'], url_path='export', url_name='export') def export(self, request): """ Export keywords to CSV Query params: search, status, cluster_id, ids (comma-separated) Note: Always exports as CSV. The 'format' parameter is ignored to avoid DRF format suffix conflicts. If 'ids' parameter is provided, ONLY those IDs will be exported (other filters are ignored). """ # Get base queryset with site/sector/account filtering queryset = self.get_queryset() # Handle IDs filter for bulk export of selected records # If IDs are provided, ONLY export those IDs and ignore all other filters ids_param = request.query_params.get('ids', '') if ids_param: try: ids_list = [int(id_str.strip()) for id_str in ids_param.split(',') if id_str.strip()] if ids_list: print(f"Backend parses IDs: {ids_list}") # Filter ONLY by IDs when IDs parameter is present queryset = queryset.filter(id__in=ids_list) print(f"Backend filters queryset: queryset.filter(id__in={ids_list})") except (ValueError, TypeError): # If IDs parsing fails, fall through to regular filtering queryset = self.filter_queryset(queryset) else: # Apply all filters from query params (search, status, cluster_id) when no IDs specified queryset = self.filter_queryset(queryset) # Export all matching records keywords = queryset.all() record_count = keywords.count() print(f"Backend generates CSV with only those {record_count} records") # Generate CSV response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = 'attachment; filename="keywords.csv"' writer = csv.writer(response) # Header row writer.writerow(['ID', 'Keyword', 'Volume', 'Difficulty', 'Intent', 'Status', 'Cluster ID', 'Created At']) # Data rows for keyword in keywords: writer.writerow([ keyword.id, keyword.keyword, keyword.volume, keyword.difficulty, keyword.intent, keyword.status, keyword.cluster_id or '', keyword.created_at.isoformat() if keyword.created_at else '', ]) # Print raw CSV content for debugging csv_content = response.content.decode('utf-8') print("=== RAW CSV CONTENT ===") print(csv_content) print("=== END CSV CONTENT ===") print("Backend returns CSV as HTTP response") return response @action(detail=False, methods=['post'], url_path='import_keywords', url_name='import_keywords') def import_keywords(self, request): """ Import keywords from CSV file. Automatically links keywords to current active site/sector. """ if 'file' not in request.FILES: return Response({'error': 'No file provided'}, status=status.HTTP_400_BAD_REQUEST) file = request.FILES['file'] if not file.name.endswith('.csv'): return Response({'error': 'File must be a CSV'}, status=status.HTTP_400_BAD_REQUEST) user = getattr(request, 'user', None) # Get site_id and sector_id from query params or use active site try: query_params = getattr(request, 'query_params', None) if query_params is None: query_params = getattr(request, 'GET', {}) except AttributeError: query_params = {} site_id = query_params.get('site_id') sector_id = query_params.get('sector_id') # Import here to avoid circular imports from igny8_core.auth.models import Site, Sector from rest_framework.exceptions import ValidationError # Site ID is REQUIRED if not site_id: return Response({'error': 'site_id is required'}, status=status.HTTP_400_BAD_REQUEST) try: site = Site.objects.get(id=site_id) except Site.DoesNotExist: return Response({'error': f'Site with id {site_id} does not exist'}, status=status.HTTP_400_BAD_REQUEST) # Sector ID is REQUIRED if not sector_id: return Response({'error': 'sector_id is required'}, status=status.HTTP_400_BAD_REQUEST) try: sector = Sector.objects.get(id=sector_id) if sector.site_id != site_id: return Response({'error': 'Sector does not belong to the selected site'}, status=status.HTTP_400_BAD_REQUEST) except Sector.DoesNotExist: return Response({'error': f'Sector with id {sector_id} does not exist'}, status=status.HTTP_400_BAD_REQUEST) # Get account account = getattr(request, 'account', None) if not account and user and user.is_authenticated: account = getattr(user, 'account', None) if not account: account = getattr(site, 'account', None) # Parse CSV try: decoded_file = file.read().decode('utf-8') csv_reader = csv.DictReader(decoded_file.splitlines()) imported_count = 0 skipped_count = 0 errors = [] with transaction.atomic(): for row_num, row in enumerate(csv_reader, start=2): # Start at 2 (header is row 1) try: keyword_text = row.get('keyword', '').strip() if not keyword_text: skipped_count += 1 continue # Check if keyword already exists for this site/sector existing = Keywords.objects.filter( keyword=keyword_text, site=site, sector=sector, account=account ).first() if existing: skipped_count += 1 continue # Create keyword Keywords.objects.create( keyword=keyword_text, volume=int(row.get('volume', 0) or 0), difficulty=int(row.get('difficulty', 0) or 0), intent=row.get('intent', 'informational') or 'informational', status=row.get('status', 'pending') or 'pending', site=site, sector=sector, account=account ) imported_count += 1 except Exception as e: errors.append(f"Row {row_num}: {str(e)}") continue return Response({ 'success': True, 'imported': imported_count, 'skipped': skipped_count, 'errors': errors[:10] if errors else [] # Limit errors to first 10 }, status=status.HTTP_200_OK) except Exception as e: return Response({ 'error': f'Failed to parse CSV: {str(e)}' }, status=status.HTTP_400_BAD_REQUEST) @action(detail=False, methods=['post'], url_path='auto_cluster', url_name='auto_cluster') def auto_cluster(self, request): """Auto-cluster keywords using AI - New unified framework""" import logging from igny8_core.ai.tasks import run_ai_task from kombu.exceptions import OperationalError as KombuOperationalError logger = logging.getLogger(__name__) try: # Get account account = getattr(request, 'account', None) account_id = account.id if account else None # Prepare payload payload = { 'ids': request.data.get('ids', []), 'sector_id': request.data.get('sector_id') } logger.info(f"auto_cluster called with ids={payload['ids']}, sector_id={payload.get('sector_id')}") # Validate basic input if not payload['ids']: return Response({ 'success': False, 'error': 'No IDs provided' }, status=status.HTTP_400_BAD_REQUEST) if len(payload['ids']) > 20: return Response({ 'success': False, 'error': 'Maximum 20 keywords allowed for clustering' }, status=status.HTTP_400_BAD_REQUEST) # Try to queue Celery task try: if hasattr(run_ai_task, 'delay'): task = run_ai_task.delay( function_name='auto_cluster', payload=payload, account_id=account_id ) logger.info(f"Task queued: {task.id}") return Response({ 'success': True, 'task_id': str(task.id), 'message': 'Clustering started' }, status=status.HTTP_200_OK) else: # Celery not available - execute synchronously logger.warning("Celery not available, executing synchronously") result = run_ai_task( function_name='auto_cluster', payload=payload, account_id=account_id ) if result.get('success'): return Response({ 'success': True, **result }, status=status.HTTP_200_OK) else: return Response({ 'success': False, 'error': result.get('error', 'Clustering failed') }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) except (KombuOperationalError, ConnectionError) as e: # Broker connection failed - fall back to synchronous execution logger.warning(f"Celery broker unavailable, falling back to synchronous execution: {str(e)}") result = run_ai_task( function_name='auto_cluster', payload=payload, account_id=account_id ) if result.get('success'): return Response({ 'success': True, **result }, status=status.HTTP_200_OK) else: return Response({ 'success': False, 'error': result.get('error', 'Clustering failed') }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) except Exception as e: logger.error(f"Error in auto_cluster: {str(e)}", exc_info=True) return Response({ 'success': False, 'error': str(e) }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) except Exception as e: logger.error(f"Unexpected error in auto_cluster: {str(e)}", exc_info=True) return Response({ 'success': False, 'error': f'Unexpected error: {str(e)}' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) class ClusterViewSet(SiteSectorModelViewSet): """ ViewSet for managing clusters with CRUD operations """ queryset = Clusters.objects.all() serializer_class = ClusterSerializer pagination_class = CustomPageNumberPagination # Explicitly use custom pagination # DRF filtering configuration filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] # Search configuration - search by name search_fields = ['name'] # Ordering configuration ordering_fields = ['name', 'created_at', 'keywords_count', 'volume', 'difficulty'] ordering = ['name'] # Default ordering # Filter configuration filterset_fields = ['status'] def get_queryset(self): """ Get all clusters - keywords_count, volume, and difficulty are calculated in the serializer since there's no ForeignKey relationship between Clusters and Keywords Uses parent's get_queryset for filtering Annotates queryset with volume and difficulty for filtering """ queryset = super().get_queryset() # Annotate queryset with aggregated volume and difficulty for filtering from django.db.models import Sum, Avg, Case, When, F, IntegerField # Since volume and difficulty are properties (not DB fields), we need to use # COALESCE to check volume_override/difficulty_override first, then fallback to seed_keyword # Volume: COALESCE(volume_override, seed_keyword__volume) # Difficulty: COALESCE(difficulty_override, seed_keyword__difficulty) queryset = queryset.annotate( _annotated_volume=Sum( Case( When(keywords__volume_override__isnull=False, then=F('keywords__volume_override')), default=F('keywords__seed_keyword__volume'), output_field=IntegerField() ) ), _annotated_difficulty=Avg( Case( When(keywords__difficulty_override__isnull=False, then=F('keywords__difficulty_override')), default=F('keywords__seed_keyword__difficulty'), output_field=IntegerField() ) ) ) # Apply volume range filtering query_params = getattr(self.request, 'query_params', {}) volume_min = query_params.get('volume_min') volume_max = query_params.get('volume_max') if volume_min is not None: try: queryset = queryset.filter(_annotated_volume__gte=int(volume_min)) except (ValueError, TypeError): pass if volume_max is not None: try: queryset = queryset.filter(_annotated_volume__lte=int(volume_max)) except (ValueError, TypeError): pass # Apply difficulty range filtering difficulty_min = query_params.get('difficulty_min') difficulty_max = query_params.get('difficulty_max') if difficulty_min is not None: try: queryset = queryset.filter(_annotated_difficulty__gte=float(difficulty_min)) except (ValueError, TypeError): pass if difficulty_max is not None: try: queryset = queryset.filter(_annotated_difficulty__lte=float(difficulty_max)) except (ValueError, TypeError): pass return queryset def perform_create(self, serializer): """Require explicit site_id and sector_id - no defaults.""" user = getattr(self.request, 'user', None) # Get site_id and sector_id from validated_data or query params # Safely access query_params try: query_params = getattr(self.request, 'query_params', None) if query_params is None: # Fallback for non-DRF requests query_params = getattr(self.request, 'GET', {}) except AttributeError: query_params = {} site_id = serializer.validated_data.get('site_id') or query_params.get('site_id') sector_id = serializer.validated_data.get('sector_id') or query_params.get('sector_id') # Import here to avoid circular imports from igny8_core.auth.models import Site, Sector from rest_framework.exceptions import ValidationError # Site ID is REQUIRED if not site_id: raise ValidationError("site_id is required. Please select a site.") try: site = Site.objects.get(id=site_id) except Site.DoesNotExist: raise ValidationError(f"Site with id {site_id} does not exist") # Sector ID is REQUIRED if not sector_id: raise ValidationError("sector_id is required. Please select a sector.") try: sector = Sector.objects.get(id=sector_id) # Verify sector belongs to the site if sector.site_id != site_id: raise ValidationError(f"Sector '{sector.name}' does not belong to the selected site") except Sector.DoesNotExist: raise ValidationError(f"Sector with id {sector_id} does not exist") # Remove site_id and sector_id from validated_data as they're not model fields serializer.validated_data.pop('site_id', None) serializer.validated_data.pop('sector_id', None) # Get account from site or user account = getattr(self.request, 'account', None) if not account and user and user.is_authenticated: account = getattr(user, 'account', None) if not account: account = getattr(site, 'account', None) # Save with all required fields explicitly serializer.save(account=account, site=site, sector=sector) @action(detail=False, methods=['post'], url_path='auto_generate_ideas', url_name='auto_generate_ideas') def auto_generate_ideas(self, request): """Generate content ideas for clusters using AI""" ids = request.data.get('ids', []) if not ids: return Response({'error': 'No cluster IDs provided'}, status=status.HTTP_400_BAD_REQUEST) if len(ids) > 5: return Response({'error': 'Maximum 5 clusters allowed for idea generation'}, status=status.HTTP_400_BAD_REQUEST) # Get account - handle RelatedObjectDoesNotExist account = None account_id = None try: account = getattr(request, 'account', None) if account: # Access pk directly instead of id to avoid potential relationship access account_id = getattr(account, 'pk', None) or getattr(account, 'id', None) except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f"Error getting account: {type(e).__name__}: {e}", exc_info=True) account_id = None # Try to queue Celery task, fall back to synchronous if Celery not available try: import logging logger = logging.getLogger(__name__) logger.info(f"auto_generate_ideas called with ids={ids}, account_id={account_id}") from .tasks import auto_generate_ideas_task from kombu.exceptions import OperationalError as KombuOperationalError if hasattr(auto_generate_ideas_task, 'delay'): try: # Celery is available - queue async task logger.info("Queuing Celery task...") task = auto_generate_ideas_task.delay(ids, account_id=account_id) logger.info(f"Task queued successfully: {task.id}") return Response({ 'success': True, 'task_id': str(task.id), 'message': 'Idea generation started' }, status=status.HTTP_200_OK) except (KombuOperationalError, ConnectionError) as e: # Celery connection failed - execute synchronously logger.warning(f"Celery connection failed, executing synchronously: {e}") result = auto_generate_ideas_task(ids, account_id=account_id) if result.get('success'): return Response({ 'success': True, 'ideas_created': result.get('ideas_created', 0), 'message': 'Ideas generated successfully' }, status=status.HTTP_200_OK) else: return Response({ 'success': False, 'error': result.get('error', 'Idea generation failed') }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) else: # Celery not available - execute synchronously logger.info("Celery not available, executing synchronously") result = auto_generate_ideas_task(ids, account_id=account_id) if result.get('success'): return Response({ 'success': True, 'ideas_created': result.get('ideas_created', 0), 'message': 'Ideas generated successfully' }, status=status.HTTP_200_OK) else: return Response({ 'success': False, 'error': result.get('error', 'Idea generation failed') }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) except ImportError as e: import logging logger = logging.getLogger(__name__) logger.error(f"ImportError in auto_generate_ideas: {e}", exc_info=True) return Response({ 'success': False, 'error': 'AI tasks module not available' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) except Exception as e: import logging logger = logging.getLogger(__name__) error_type = type(e).__name__ error_msg = str(e) logger.error(f"Error in auto_generate_ideas: {error_type}: {error_msg}", exc_info=True) return Response({ 'success': False, 'error': f'Unexpected error: {error_msg}' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) def list(self, request, *args, **kwargs): """ Override list to optimize keyword stats calculation using bulk aggregation """ queryset = self.filter_queryset(self.get_queryset()) # Handle pagination first page = self.paginate_queryset(queryset) if page is not None: # Optimize keyword stats for the paginated clusters cluster_list = list(page) ClusterSerializer.prefetch_keyword_stats(cluster_list) serializer = self.get_serializer(cluster_list, many=True) return self.get_paginated_response(serializer.data) # No pagination - optimize all clusters cluster_list = list(queryset) ClusterSerializer.prefetch_keyword_stats(cluster_list) serializer = self.get_serializer(cluster_list, many=True) return Response(serializer.data) class ContentIdeasViewSet(SiteSectorModelViewSet): """ ViewSet for managing content ideas with CRUD operations """ queryset = ContentIdeas.objects.all() serializer_class = ContentIdeasSerializer pagination_class = CustomPageNumberPagination # Explicitly use custom pagination # DRF filtering configuration filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] # Search configuration - search by idea_title search_fields = ['idea_title'] # Ordering configuration ordering_fields = ['idea_title', 'created_at', 'estimated_word_count'] ordering = ['-created_at'] # Default ordering (newest first) # Filter configuration filterset_fields = ['status', 'keyword_cluster_id', 'content_structure', 'content_type'] def perform_create(self, serializer): """Require explicit site_id and sector_id - no defaults.""" user = getattr(self.request, 'user', None) try: query_params = getattr(self.request, 'query_params', None) if query_params is None: query_params = getattr(self.request, 'GET', {}) except AttributeError: query_params = {} site_id = serializer.validated_data.get('site_id') or query_params.get('site_id') sector_id = serializer.validated_data.get('sector_id') or query_params.get('sector_id') from igny8_core.auth.models import Site, Sector from rest_framework.exceptions import ValidationError # Site ID is REQUIRED if not site_id: raise ValidationError("site_id is required. Please select a site.") try: site = Site.objects.get(id=site_id) except Site.DoesNotExist: raise ValidationError(f"Site with id {site_id} does not exist") # Sector ID is REQUIRED if not sector_id: raise ValidationError("sector_id is required. Please select a sector.") try: sector = Sector.objects.get(id=sector_id) if sector.site_id != site_id: raise ValidationError(f"Sector does not belong to the selected site") except Sector.DoesNotExist: raise ValidationError(f"Sector with id {sector_id} does not exist") serializer.validated_data.pop('site_id', None) serializer.validated_data.pop('sector_id', None) account = getattr(self.request, 'account', None) if not account and user and user.is_authenticated: account = getattr(user, 'account', None) if not account: account = getattr(site, 'account', None) serializer.save(account=account, site=site, sector=sector) @action(detail=False, methods=['post'], url_path='bulk_queue_to_writer', url_name='bulk_queue_to_writer') def bulk_queue_to_writer(self, request): """Queue ideas to writer by creating Tasks""" ids = request.data.get('ids', []) if not ids: return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST) queryset = self.get_queryset() ideas = queryset.filter(id__in=ids, status='new') # Only queue 'new' ideas from igny8_core.modules.writer.models import Tasks created_tasks = [] for idea in ideas: task = Tasks.objects.create( title=idea.idea_title, description=idea.description or '', keywords=idea.target_keywords or '', cluster=idea.keyword_cluster, idea=idea, content_structure=idea.content_structure, content_type=idea.content_type, status='queued', account=idea.account, site=idea.site, sector=idea.sector, ) created_tasks.append(task.id) # Update idea status idea.status = 'scheduled' idea.save() return Response({ 'success': True, 'created_count': len(created_tasks), 'task_ids': created_tasks, 'message': f'Successfully queued {len(created_tasks)} ideas to writer' }, status=status.HTTP_200_OK) @action(detail=True, methods=['post'], url_path='generate_idea', url_name='generate_idea') def generate_idea(self, request, pk=None): """Generate a single content idea for a cluster using AI""" import logging logger = logging.getLogger(__name__) try: cluster_id = request.data.get('cluster_id') if not cluster_id: return Response({'error': 'cluster_id is required'}, status=status.HTTP_400_BAD_REQUEST) # Get account - handle RelatedObjectDoesNotExist account = None account_id = None try: account = getattr(request, 'account', None) if account: account_id = getattr(account, 'pk', None) or getattr(account, 'id', None) except Exception as e: logger.error(f"Error getting account: {type(e).__name__}: {e}", exc_info=True) account_id = None # Try to queue Celery task, fall back to synchronous if Celery not available try: from .tasks import generate_single_idea_task from kombu.exceptions import OperationalError as KombuOperationalError if hasattr(generate_single_idea_task, 'delay'): try: # Celery is available - queue async task task = generate_single_idea_task.delay(cluster_id, account_id=account_id) return Response({ 'success': True, 'task_id': str(task.id), 'message': 'Idea generation started' }, status=status.HTTP_200_OK) except (KombuOperationalError, ConnectionError) as e: # Celery connection failed - execute synchronously logger.warning(f"Celery connection failed, executing synchronously: {e}") from igny8_core.ai.functions.generate_ideas import generate_ideas_core result = generate_ideas_core(cluster_id, account_id=account_id, progress_callback=None) if result.get('success'): return Response({ 'success': True, 'idea_created': result.get('idea_created', 0), 'message': 'Idea generated successfully' }, status=status.HTTP_200_OK) else: return Response({ 'success': False, 'error': result.get('error', 'Idea generation failed') }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) else: # Celery not available - execute synchronously logger.info("Celery not available, executing synchronously") from .tasks import _generate_single_idea_core result = _generate_single_idea_core(cluster_id, account_id=account_id, progress_callback=None) if result.get('success'): return Response({ 'success': True, 'idea_created': result.get('idea_created', 0), 'message': 'Idea generated successfully' }, status=status.HTTP_200_OK) else: return Response({ 'success': False, 'error': result.get('error', 'Idea generation failed') }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) except ImportError as e: error_type = type(e).__name__ error_msg = str(e) logger.error(f"Error importing tasks module: {error_type}: {error_msg}", exc_info=True) return Response({ 'success': False, 'error': 'AI tasks module not available' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) except Exception as e: error_type = type(e).__name__ error_msg = str(e) logger.error(f"Error in generate_idea: {error_type}: {error_msg}", exc_info=True) return Response({ 'success': False, 'error': f'Unexpected error: {error_msg}' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) except Exception as e: error_type = type(e).__name__ error_msg = str(e) logger.error(f"Unexpected error in generate_idea: {error_type}: {error_msg}", exc_info=True) return Response({ 'success': False, 'error': f'Unexpected error: {error_msg}' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)