Enhance API response handling and implement unified API standard across multiple modules. Added feature flags for unified exception handling and debug throttling in settings. Updated pagination and response formats in various viewsets to align with the new standard. Improved error handling and response validation in frontend components for better user feedback.

This commit is contained in:
IGNY8 VPS (Salman)
2025-11-15 20:18:42 +00:00
parent 94f243f4a2
commit a75ebf2584
18 changed files with 1974 additions and 642 deletions

View File

@@ -10,6 +10,8 @@ import json
import time
from igny8_core.api.base import SiteSectorModelViewSet
from igny8_core.api.pagination import CustomPageNumberPagination
from igny8_core.api.response import success_response, error_response
from igny8_core.api.throttles import DebugScopedRateThrottle
from .models import Keywords, Clusters, ContentIdeas
from .serializers import KeywordSerializer, ContentIdeasSerializer
from .cluster_serializers import ClusterSerializer
@@ -19,11 +21,14 @@ class KeywordViewSet(SiteSectorModelViewSet):
"""
ViewSet for managing keywords with CRUD operations
Provides list, create, retrieve, update, and destroy actions
Unified API Standard v1.0 compliant
"""
queryset = Keywords.objects.all()
serializer_class = KeywordSerializer
permission_classes = [] # Allow any for now
pagination_class = CustomPageNumberPagination # Explicitly use custom pagination
throttle_scope = 'planner'
throttle_classes = [DebugScopedRateThrottle]
# DRF filtering configuration
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
@@ -121,13 +126,17 @@ class KeywordViewSet(SiteSectorModelViewSet):
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
return success_response(
data=serializer.data,
request=request
)
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)
return error_response(
error=f'Error loading keywords: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
def perform_create(self, serializer):
"""Require explicit site_id and sector_id - no defaults."""
@@ -190,12 +199,16 @@ class KeywordViewSet(SiteSectorModelViewSet):
"""Bulk delete keywords"""
ids = request.data.get('ids', [])
if not ids:
return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error='No IDs provided',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
queryset = self.get_queryset()
deleted_count, _ = queryset.filter(id__in=ids).delete()
return Response({'deleted_count': deleted_count}, status=status.HTTP_200_OK)
return success_response(data={'deleted_count': deleted_count}, request=request)
@action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update')
def bulk_update(self, request):
@@ -204,14 +217,22 @@ class KeywordViewSet(SiteSectorModelViewSet):
status_value = request.data.get('status')
if not ids:
return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error='No IDs provided',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
if not status_value:
return Response({'error': 'No status provided'}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error='No status provided',
status_code=status.HTTP_400_BAD_REQUEST,
request=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)
return success_response(data={'updated_count': updated_count}, request=request)
@action(detail=False, methods=['post'], url_path='bulk_add_from_seed', url_name='bulk_add_from_seed')
def bulk_add_from_seed(self, request):
@@ -223,32 +244,60 @@ class KeywordViewSet(SiteSectorModelViewSet):
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)
return error_response(
error='No seed keyword IDs provided',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
if not site_id:
return Response({'error': 'site_id is required'}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error='site_id is required',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
if not sector_id:
return Response({'error': 'sector_id is required'}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error='sector_id is required',
status_code=status.HTTP_400_BAD_REQUEST,
request=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)
return error_response(
error=f'Invalid site or sector: {str(e)}',
status_code=status.HTTP_400_BAD_REQUEST,
request=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)
return error_response(
error='Sector does not belong to the specified site',
status_code=status.HTTP_400_BAD_REQUEST,
request=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)
return error_response(
error='Site has no account assigned',
status_code=status.HTTP_400_BAD_REQUEST,
request=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)
return error_response(
error='No valid seed keywords found',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
created_count = 0
skipped_count = 0
@@ -288,12 +337,14 @@ class KeywordViewSet(SiteSectorModelViewSet):
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)
return success_response(
data={
'created': created_count,
'skipped': skipped_count,
'errors': errors[:10] if errors else [] # Limit errors to first 10
},
request=request
)
@action(detail=False, methods=['get'], url_path='export', url_name='export')
def export(self, request):
@@ -366,11 +417,19 @@ class KeywordViewSet(SiteSectorModelViewSet):
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)
return error_response(
error='No file provided',
status_code=status.HTTP_400_BAD_REQUEST,
request=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)
return error_response(
error='File must be a CSV',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
user = getattr(request, 'user', None)
@@ -391,23 +450,43 @@ class KeywordViewSet(SiteSectorModelViewSet):
# Site ID is REQUIRED
if not site_id:
return Response({'error': 'site_id is required'}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error='site_id is required',
status_code=status.HTTP_400_BAD_REQUEST,
request=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)
return error_response(
error=f'Site with id {site_id} does not exist',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Sector ID is REQUIRED
if not sector_id:
return Response({'error': 'sector_id is required'}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error='sector_id is required',
status_code=status.HTTP_400_BAD_REQUEST,
request=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)
return error_response(
error='Sector does not belong to the selected site',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
except Sector.DoesNotExist:
return Response({'error': f'Sector with id {sector_id} does not exist'}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error=f'Sector with id {sector_id} does not exist',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Get account
account = getattr(request, 'account', None)
@@ -461,17 +540,21 @@ class KeywordViewSet(SiteSectorModelViewSet):
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)
return success_response(
data={
'imported': imported_count,
'skipped': skipped_count,
'errors': errors[:10] if errors else [] # Limit errors to first 10
},
request=request
)
except Exception as e:
return Response({
'error': f'Failed to parse CSV: {str(e)}'
}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error=f'Failed to parse CSV: {str(e)}',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
@action(detail=False, methods=['post'], url_path='auto_cluster', url_name='auto_cluster')
def auto_cluster(self, request):
@@ -497,16 +580,18 @@ class KeywordViewSet(SiteSectorModelViewSet):
# Validate basic input
if not payload['ids']:
return Response({
'success': False,
'error': 'No IDs provided'
}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error='No IDs provided',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
if len(payload['ids']) > 20:
return Response({
'success': False,
'error': 'Maximum 20 keywords allowed for clustering'
}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error='Maximum 20 keywords allowed for clustering',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Try to queue Celery task
try:
@@ -517,11 +602,11 @@ class KeywordViewSet(SiteSectorModelViewSet):
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)
return success_response(
data={'task_id': str(task.id)},
message='Clustering started',
request=request
)
else:
# Celery not available - execute synchronously
logger.warning("Celery not available, executing synchronously")
@@ -531,15 +616,16 @@ class KeywordViewSet(SiteSectorModelViewSet):
account_id=account_id
)
if result.get('success'):
return Response({
'success': True,
**result
}, status=status.HTTP_200_OK)
return success_response(
data=result,
request=request
)
else:
return Response({
'success': False,
'error': result.get('error', 'Clustering failed')
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return error_response(
error=result.get('error', 'Clustering failed'),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
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)}")
@@ -549,36 +635,42 @@ class KeywordViewSet(SiteSectorModelViewSet):
account_id=account_id
)
if result.get('success'):
return Response({
'success': True,
**result
}, status=status.HTTP_200_OK)
return success_response(
data=result,
request=request
)
else:
return Response({
'success': False,
'error': result.get('error', 'Clustering failed')
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return error_response(
error=result.get('error', 'Clustering failed'),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
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)
return error_response(
error=str(e),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
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)
return error_response(
error=f'Unexpected error: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
class ClusterViewSet(SiteSectorModelViewSet):
"""
ViewSet for managing clusters with CRUD operations
Unified API Standard v1.0 compliant
"""
queryset = Clusters.objects.all()
serializer_class = ClusterSerializer
pagination_class = CustomPageNumberPagination # Explicitly use custom pagination
throttle_scope = 'planner'
throttle_classes = [DebugScopedRateThrottle]
# DRF filtering configuration
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
@@ -719,12 +811,16 @@ class ClusterViewSet(SiteSectorModelViewSet):
"""Bulk delete clusters"""
ids = request.data.get('ids', [])
if not ids:
return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error='No IDs provided',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
queryset = self.get_queryset()
deleted_count, _ = queryset.filter(id__in=ids).delete()
return Response({'deleted_count': deleted_count}, status=status.HTTP_200_OK)
return success_response(data={'deleted_count': deleted_count}, request=request)
@action(detail=False, methods=['post'], url_path='auto_generate_ideas', url_name='auto_generate_ideas')
def auto_generate_ideas(self, request):
@@ -749,16 +845,18 @@ class ClusterViewSet(SiteSectorModelViewSet):
# Validate basic input
if not payload['ids']:
return Response({
'success': False,
'error': 'No IDs provided'
}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error='No IDs provided',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
if len(payload['ids']) > 10:
return Response({
'success': False,
'error': 'Maximum 10 clusters allowed for idea generation'
}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error='Maximum 10 clusters allowed for idea generation',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Try to queue Celery task
try:
@@ -769,11 +867,11 @@ class ClusterViewSet(SiteSectorModelViewSet):
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)
return success_response(
data={'task_id': str(task.id)},
message='Idea generation started',
request=request
)
else:
# Celery not available - execute synchronously
logger.warning("Celery not available, executing synchronously")
@@ -783,15 +881,16 @@ class ClusterViewSet(SiteSectorModelViewSet):
account_id=account_id
)
if result.get('success'):
return Response({
'success': True,
**result
}, status=status.HTTP_200_OK)
return success_response(
data=result,
request=request
)
else:
return Response({
'success': False,
'error': result.get('error', 'Idea generation failed')
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return error_response(
error=result.get('error', 'Idea generation failed'),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
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)}")
@@ -801,27 +900,30 @@ class ClusterViewSet(SiteSectorModelViewSet):
account_id=account_id
)
if result.get('success'):
return Response({
'success': True,
**result
}, status=status.HTTP_200_OK)
return success_response(
data=result,
request=request
)
else:
return Response({
'success': False,
'error': result.get('error', 'Idea generation failed')
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return error_response(
error=result.get('error', 'Idea generation failed'),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
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)
return error_response(
error=str(e),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
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)
return error_response(
error=f'Unexpected error: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
def list(self, request, *args, **kwargs):
"""
@@ -842,16 +944,22 @@ class ClusterViewSet(SiteSectorModelViewSet):
cluster_list = list(queryset)
ClusterSerializer.prefetch_keyword_stats(cluster_list)
serializer = self.get_serializer(cluster_list, many=True)
return Response(serializer.data)
return success_response(
data=serializer.data,
request=request
)
class ContentIdeasViewSet(SiteSectorModelViewSet):
"""
ViewSet for managing content ideas with CRUD operations
Unified API Standard v1.0 compliant
"""
queryset = ContentIdeas.objects.all()
serializer_class = ContentIdeasSerializer
pagination_class = CustomPageNumberPagination # Explicitly use custom pagination
pagination_class = CustomPageNumberPagination
throttle_scope = 'planner'
throttle_classes = [DebugScopedRateThrottle] # Explicitly use custom pagination
# DRF filtering configuration
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
@@ -919,19 +1027,27 @@ class ContentIdeasViewSet(SiteSectorModelViewSet):
"""Bulk delete content ideas"""
ids = request.data.get('ids', [])
if not ids:
return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error='No IDs provided',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
queryset = self.get_queryset()
deleted_count, _ = queryset.filter(id__in=ids).delete()
return Response({'deleted_count': deleted_count}, status=status.HTTP_200_OK)
return success_response(data={'deleted_count': deleted_count}, request=request)
@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)
return error_response(
error='No IDs provided',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
queryset = self.get_queryset()
ideas = queryset.filter(id__in=ids, status='new') # Only queue 'new' ideas
@@ -958,11 +1074,13 @@ class ContentIdeasViewSet(SiteSectorModelViewSet):
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)
return success_response(
data={
'created_count': len(created_tasks),
'task_ids': created_tasks,
},
message=f'Successfully queued {len(created_tasks)} ideas to writer',
request=request
)
# REMOVED: generate_idea action - idea generation function removed