945 lines
40 KiB
Python
945 lines
40 KiB
Python
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):
|
|
"""Auto-generate ideas for clusters 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', [])
|
|
}
|
|
|
|
logger.info(f"auto_generate_ideas called with ids={payload['ids']}")
|
|
|
|
# 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']) > 10:
|
|
return Response({
|
|
'success': False,
|
|
'error': 'Maximum 10 clusters allowed for idea generation'
|
|
}, 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_generate_ideas',
|
|
payload=payload,
|
|
account_id=account_id
|
|
)
|
|
logger.info(f"Task queued: {task.id}")
|
|
return Response({
|
|
'success': True,
|
|
'task_id': str(task.id),
|
|
'message': 'Idea generation 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_generate_ideas',
|
|
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', 'Idea generation 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_generate_ideas',
|
|
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', 'Idea generation failed')
|
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
except Exception as e:
|
|
logger.error(f"Error in auto_generate_ideas: {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_generate_ideas: {str(e)}", exc_info=True)
|
|
return Response({
|
|
'success': False,
|
|
'error': f'Unexpected error: {str(e)}'
|
|
}, 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)
|
|
|
|
# REMOVED: generate_idea action - idea generation function removed
|