1561 lines
61 KiB
Python
1561 lines
61 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, models
|
|
from django.db.models import Q
|
|
from drf_spectacular.utils import extend_schema, extend_schema_view
|
|
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 igny8_core.api.permissions import IsAuthenticatedAndActive, IsViewerOrAbove, IsEditorOrAbove
|
|
from .models import Tasks, Images, Content
|
|
from .serializers import (
|
|
TasksSerializer,
|
|
ImagesSerializer,
|
|
ContentSerializer,
|
|
ContentTaxonomySerializer,
|
|
)
|
|
from igny8_core.business.content.models import ContentTaxonomy # ContentAttribute model exists but serializer removed in Stage 1
|
|
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
|
|
from igny8_core.business.content.services.validation_service import ContentValidationService
|
|
from igny8_core.business.content.services.metadata_mapping_service import MetadataMappingService
|
|
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
|
|
|
|
|
|
|
@extend_schema_view(
|
|
list=extend_schema(tags=['Writer']),
|
|
create=extend_schema(tags=['Writer']),
|
|
retrieve=extend_schema(tags=['Writer']),
|
|
update=extend_schema(tags=['Writer']),
|
|
partial_update=extend_schema(tags=['Writer']),
|
|
destroy=extend_schema(tags=['Writer']),
|
|
)
|
|
class TasksViewSet(SiteSectorModelViewSet):
|
|
"""
|
|
ViewSet for managing tasks with CRUD operations
|
|
Unified API Standard v1.0 compliant
|
|
Stage 1 Refactored - removed deprecated filters
|
|
"""
|
|
queryset = Tasks.objects.select_related('cluster', 'site', 'sector')
|
|
serializer_class = TasksSerializer
|
|
permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove]
|
|
pagination_class = CustomPageNumberPagination # Explicitly use custom pagination
|
|
throttle_scope = 'writer'
|
|
throttle_classes = [DebugScopedRateThrottle]
|
|
|
|
# DRF filtering configuration
|
|
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
|
|
|
# Search configuration
|
|
search_fields = ['title', 'keywords']
|
|
|
|
# Ordering configuration
|
|
ordering_fields = ['title', 'created_at', 'status']
|
|
ordering = ['-created_at'] # Default ordering (newest first)
|
|
|
|
# Filter configuration - Stage 1: removed entity_type, cluster_role
|
|
filterset_fields = ['status', 'cluster_id', 'content_type', 'content_structure']
|
|
|
|
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 and user.account:
|
|
account = user.account
|
|
if not account:
|
|
account = site.account
|
|
|
|
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 tasks"""
|
|
ids = request.data.get('ids', [])
|
|
if not ids:
|
|
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 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):
|
|
"""Bulk update task status"""
|
|
ids = request.data.get('ids', [])
|
|
status_value = request.data.get('status')
|
|
|
|
if not ids:
|
|
return error_response(
|
|
error='No IDs provided',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
if not status_value:
|
|
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 success_response(data={'updated_count': updated_count}, request=request)
|
|
|
|
@action(detail=False, methods=['post'], url_path='auto_generate_content', url_name='auto_generate_content')
|
|
def auto_generate_content(self, request):
|
|
"""Auto-generate content for tasks using ContentGenerationService"""
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
ids = request.data.get('ids', [])
|
|
if not ids:
|
|
return error_response(
|
|
error='No IDs provided',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
if len(ids) > 10:
|
|
return error_response(
|
|
error='Maximum 10 tasks allowed for content generation',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
# Get account
|
|
account = getattr(request, 'account', None)
|
|
if not account:
|
|
return error_response(
|
|
error='Account is required',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
# Validate task IDs exist
|
|
queryset = self.get_queryset()
|
|
existing_tasks = queryset.filter(id__in=ids, account=account)
|
|
existing_count = existing_tasks.count()
|
|
|
|
if existing_count == 0:
|
|
return error_response(
|
|
error=f'No tasks found for the provided IDs: {ids}',
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
request=request
|
|
)
|
|
|
|
# Use service to generate content
|
|
service = ContentGenerationService()
|
|
try:
|
|
result = service.generate_content(ids, account)
|
|
|
|
if result.get('success'):
|
|
if 'task_id' in result:
|
|
# Async task queued
|
|
return success_response(
|
|
data={'task_id': result['task_id']},
|
|
message=result.get('message', 'Content generation started'),
|
|
request=request
|
|
)
|
|
else:
|
|
# Synchronous execution
|
|
return success_response(
|
|
data=result,
|
|
message='Content generated successfully',
|
|
request=request
|
|
)
|
|
else:
|
|
return error_response(
|
|
error=result.get('error', 'Content generation failed'),
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
request=request
|
|
)
|
|
except InsufficientCreditsError as e:
|
|
return error_response(
|
|
error=str(e),
|
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
|
request=request
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error in auto_generate_content: {str(e)}", exc_info=True)
|
|
return error_response(
|
|
error=f'Content generation failed: {str(e)}',
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
request=request
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error in auto_generate_content: {str(e)}", exc_info=True)
|
|
return error_response(
|
|
error=f'Unexpected error: {str(e)}',
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
request=request
|
|
)
|
|
|
|
|
|
@extend_schema_view(
|
|
list=extend_schema(tags=['Writer']),
|
|
create=extend_schema(tags=['Writer']),
|
|
retrieve=extend_schema(tags=['Writer']),
|
|
update=extend_schema(tags=['Writer']),
|
|
partial_update=extend_schema(tags=['Writer']),
|
|
destroy=extend_schema(tags=['Writer']),
|
|
)
|
|
class ImagesViewSet(SiteSectorModelViewSet):
|
|
"""
|
|
ViewSet for managing content images
|
|
Unified API Standard v1.0 compliant
|
|
"""
|
|
queryset = Images.objects.all()
|
|
serializer_class = ImagesSerializer
|
|
permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove]
|
|
pagination_class = CustomPageNumberPagination
|
|
throttle_scope = 'writer'
|
|
throttle_classes = [DebugScopedRateThrottle]
|
|
|
|
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
|
ordering_fields = ['created_at', 'position', 'id']
|
|
ordering = ['-id'] # Sort by ID descending (newest first)
|
|
filterset_fields = ['task_id', 'content_id', 'image_type', 'status']
|
|
|
|
def perform_create(self, serializer):
|
|
"""Override to automatically set account, site, and sector"""
|
|
from rest_framework.exceptions import ValidationError
|
|
|
|
# Get site and sector from request (set by middleware) or user's active context
|
|
site = getattr(self.request, 'site', None)
|
|
sector = getattr(self.request, 'sector', None)
|
|
|
|
if not site:
|
|
# Fallback to user's active site if not set by middleware
|
|
user = getattr(self.request, 'user', None)
|
|
if user and user.is_authenticated and hasattr(user, 'active_site'):
|
|
site = user.active_site
|
|
|
|
if not sector and site:
|
|
# Fallback to default sector for the site if not set by middleware
|
|
from igny8_core.auth.models import Sector
|
|
sector = site.sectors.filter(is_default=True).first()
|
|
|
|
# Site and sector are required - raise ValidationError if not available
|
|
# Use dict format for ValidationError to ensure proper error structure
|
|
if not site:
|
|
raise ValidationError({"site": ["Site is required for image creation. Please select a site."]})
|
|
if not sector:
|
|
raise ValidationError({"sector": ["Sector is required for image creation. Please select a sector."]})
|
|
|
|
# Add site and sector to validated_data so base class can validate access
|
|
serializer.validated_data['site'] = site
|
|
serializer.validated_data['sector'] = sector
|
|
|
|
# Call parent to set account and validate access
|
|
super().perform_create(serializer)
|
|
|
|
@action(detail=True, methods=['get'], url_path='file', url_name='image_file')
|
|
def serve_image_file(self, request, pk=None):
|
|
"""
|
|
Serve image file from local path via URL
|
|
GET /api/v1/writer/images/{id}/file/
|
|
"""
|
|
import os
|
|
from django.http import FileResponse, Http404
|
|
from django.conf import settings
|
|
|
|
try:
|
|
# Get image directly without account filtering for file serving
|
|
# This allows public access to image files
|
|
try:
|
|
image = Images.objects.get(pk=pk)
|
|
except Images.DoesNotExist:
|
|
return error_response(
|
|
error='Image not found',
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
request=request
|
|
)
|
|
|
|
# Check if image has a local path
|
|
if not image.image_path:
|
|
return error_response(
|
|
error='No local file path available for this image',
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
request=request
|
|
)
|
|
|
|
file_path = image.image_path
|
|
|
|
# Verify file exists at the saved path
|
|
if not os.path.exists(file_path):
|
|
logger.error(f"[serve_image_file] Image {pk} - File not found at saved path: {file_path}")
|
|
return error_response(
|
|
error=f'Image file not found at: {file_path}',
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
request=request
|
|
)
|
|
|
|
# Check if file is readable
|
|
if not os.access(file_path, os.R_OK):
|
|
return error_response(
|
|
error='Image file is not readable',
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
request=request
|
|
)
|
|
|
|
# Determine content type from file extension
|
|
import mimetypes
|
|
content_type, _ = mimetypes.guess_type(file_path)
|
|
if not content_type:
|
|
content_type = 'image/png' # Default to PNG
|
|
|
|
# Serve the file
|
|
try:
|
|
return FileResponse(
|
|
open(file_path, 'rb'),
|
|
content_type=content_type,
|
|
filename=os.path.basename(file_path)
|
|
)
|
|
except Exception as e:
|
|
return error_response(
|
|
error=f'Failed to serve file: {str(e)}',
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
request=request
|
|
)
|
|
|
|
except Images.DoesNotExist:
|
|
return error_response(
|
|
error='Image not found',
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
request=request
|
|
)
|
|
except Exception as e:
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
logger.error(f"Error serving image file: {str(e)}", exc_info=True)
|
|
return error_response(
|
|
error=f'Failed to serve image: {str(e)}',
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
request=request
|
|
)
|
|
|
|
@action(detail=False, methods=['post'], url_path='auto_generate', url_name='auto_generate_images')
|
|
def auto_generate_images(self, request):
|
|
"""Auto-generate images for tasks using AI"""
|
|
task_ids = request.data.get('task_ids', [])
|
|
if not task_ids:
|
|
return error_response(
|
|
error='No task IDs provided',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
if len(task_ids) > 10:
|
|
return error_response(
|
|
error='Maximum 10 tasks allowed for image generation',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
# Get account
|
|
account = getattr(request, 'account', None)
|
|
account_id = account.id if account else None
|
|
|
|
# Try to queue Celery task, fall back to synchronous if Celery not available
|
|
try:
|
|
from igny8_core.ai.tasks import run_ai_task
|
|
from kombu.exceptions import OperationalError as KombuOperationalError
|
|
|
|
if hasattr(run_ai_task, 'delay'):
|
|
# Celery is available - queue async task
|
|
task = run_ai_task.delay(
|
|
function_name='generate_images',
|
|
payload={'ids': task_ids},
|
|
account_id=account_id
|
|
)
|
|
return success_response(
|
|
data={'task_id': str(task.id)},
|
|
message='Image generation started',
|
|
request=request
|
|
)
|
|
else:
|
|
# Celery not available - execute synchronously
|
|
result = run_ai_task(
|
|
function_name='generate_images',
|
|
payload={'ids': task_ids},
|
|
account_id=account_id
|
|
)
|
|
if result.get('success'):
|
|
return success_response(
|
|
data={'images_created': result.get('count', 0)},
|
|
message=result.get('message', 'Image generation completed'),
|
|
request=request
|
|
)
|
|
else:
|
|
return error_response(
|
|
error=result.get('error', 'Image generation failed'),
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
request=request
|
|
)
|
|
except KombuOperationalError as e:
|
|
return error_response(
|
|
error='Task queue unavailable. Please try again.',
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
request=request
|
|
)
|
|
except ImportError:
|
|
# Tasks module not available
|
|
return error_response(
|
|
error='Image generation task not available',
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
request=request
|
|
)
|
|
except Exception as e:
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
logger.error(f"Error queuing image generation task: {str(e)}", exc_info=True)
|
|
return error_response(
|
|
error=f'Failed to start image generation: {str(e)}',
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
request=request
|
|
)
|
|
|
|
@action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update')
|
|
def bulk_update(self, request):
|
|
"""Bulk update image status by content_id or image IDs
|
|
Updates all images for a content record (featured + 1-6 in-article images)
|
|
"""
|
|
from django.db.models import Q
|
|
from .models import Content
|
|
|
|
content_id = request.data.get('content_id')
|
|
image_ids = request.data.get('ids', [])
|
|
status_value = request.data.get('status')
|
|
|
|
if not status_value:
|
|
return error_response(
|
|
error='No status provided',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
queryset = self.get_queryset()
|
|
|
|
# Update by content_id if provided, otherwise by image IDs
|
|
if content_id:
|
|
try:
|
|
# Get the content object to also update images linked via task
|
|
content = Content.objects.get(id=content_id)
|
|
|
|
# Update images linked directly to content OR via task (same logic as content_images endpoint)
|
|
# This ensures we update all images: featured + 1-6 in-article images
|
|
updated_count = queryset.filter(
|
|
Q(content=content) | Q(task=content.task)
|
|
).update(status=status_value)
|
|
except Content.DoesNotExist:
|
|
return error_response(
|
|
error='Content not found',
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
request=request
|
|
)
|
|
elif image_ids:
|
|
updated_count = queryset.filter(id__in=image_ids).update(status=status_value)
|
|
else:
|
|
return error_response(
|
|
error='Either content_id or ids must be provided',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
return success_response(data={'updated_count': updated_count}, request=request)
|
|
|
|
@action(detail=False, methods=['get'], url_path='content_images', url_name='content_images')
|
|
def content_images(self, request):
|
|
"""Get images grouped by content - one row per content with featured and in-article images"""
|
|
from .serializers import ContentImagesGroupSerializer, ContentImageSerializer
|
|
|
|
account = getattr(request, 'account', None)
|
|
|
|
# Get site_id and sector_id from query parameters
|
|
site_id = request.query_params.get('site_id')
|
|
sector_id = request.query_params.get('sector_id')
|
|
|
|
# Get all content that has images (either directly or via task)
|
|
# First, get content with direct image links
|
|
queryset = Content.objects.filter(images__isnull=False)
|
|
if account:
|
|
queryset = queryset.filter(account=account)
|
|
|
|
# Apply site/sector filtering if provided
|
|
if site_id:
|
|
try:
|
|
queryset = queryset.filter(site_id=int(site_id))
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
if sector_id:
|
|
try:
|
|
queryset = queryset.filter(sector_id=int(sector_id))
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Task field removed in Stage 1 - images are now only linked to content directly
|
|
# All images must be linked via content, not task
|
|
|
|
# Build grouped response
|
|
grouped_data = []
|
|
content_ids = set(queryset.values_list('id', flat=True).distinct())
|
|
|
|
for content_id in content_ids:
|
|
try:
|
|
content = Content.objects.get(id=content_id)
|
|
|
|
# Get images linked directly to content
|
|
content_images = Images.objects.filter(content=content).order_by('position')
|
|
|
|
# Get featured image
|
|
featured_image = content_images.filter(image_type='featured').first()
|
|
|
|
# Get in-article images (sorted by position)
|
|
in_article_images = list(content_images.filter(image_type='in_article').order_by('position'))
|
|
|
|
# Determine overall status
|
|
all_images = list(content_images)
|
|
if not all_images:
|
|
overall_status = 'pending'
|
|
elif all(img.status == 'generated' for img in all_images):
|
|
overall_status = 'complete'
|
|
elif any(img.status == 'failed' for img in all_images):
|
|
overall_status = 'failed'
|
|
elif any(img.status == 'generated' for img in all_images):
|
|
overall_status = 'partial'
|
|
else:
|
|
overall_status = 'pending'
|
|
|
|
# Create serializer instances with request context for proper URL generation
|
|
featured_serializer = ContentImageSerializer(featured_image, context={'request': request}) if featured_image else None
|
|
in_article_serializers = [ContentImageSerializer(img, context={'request': request}) for img in in_article_images]
|
|
|
|
grouped_data.append({
|
|
'content_id': content.id,
|
|
'content_title': content.title or content.meta_title or f"Content #{content.id}",
|
|
'featured_image': featured_serializer.data if featured_serializer else None,
|
|
'in_article_images': [s.data for s in in_article_serializers],
|
|
'overall_status': overall_status,
|
|
})
|
|
except Content.DoesNotExist:
|
|
continue
|
|
|
|
# Sort by content title
|
|
grouped_data.sort(key=lambda x: x['content_title'])
|
|
|
|
return success_response(
|
|
data={
|
|
'count': len(grouped_data),
|
|
'results': grouped_data
|
|
},
|
|
request=request
|
|
)
|
|
|
|
@action(detail=False, methods=['post'], url_path='generate_images', url_name='generate_images')
|
|
def generate_images(self, request):
|
|
"""Generate images from prompts - queues Celery task for sequential processing"""
|
|
from igny8_core.ai.tasks import process_image_generation_queue
|
|
|
|
account = getattr(request, 'account', None)
|
|
image_ids = request.data.get('ids', [])
|
|
content_id = request.data.get('content_id')
|
|
|
|
if not image_ids:
|
|
return error_response(
|
|
error='No image IDs provided',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
account_id = account.id if account else None
|
|
|
|
# Queue Celery task
|
|
try:
|
|
if hasattr(process_image_generation_queue, 'delay'):
|
|
task = process_image_generation_queue.delay(
|
|
image_ids=image_ids,
|
|
account_id=account_id,
|
|
content_id=content_id
|
|
)
|
|
return success_response(
|
|
data={'task_id': str(task.id)},
|
|
message='Image generation started',
|
|
request=request
|
|
)
|
|
else:
|
|
# Fallback to synchronous execution (for testing)
|
|
result = process_image_generation_queue(
|
|
image_ids=image_ids,
|
|
account_id=account_id,
|
|
content_id=content_id
|
|
)
|
|
return success_response(data=result, request=request)
|
|
except Exception as e:
|
|
logger.error(f"[generate_images] Error: {str(e)}", exc_info=True)
|
|
return error_response(
|
|
error=str(e),
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
request=request
|
|
)
|
|
|
|
@action(detail=True, methods=['get'], url_path='validation', url_name='validation')
|
|
def validation(self, request, pk=None):
|
|
"""
|
|
Stage 3: Get validation checklist for content.
|
|
|
|
GET /api/v1/writer/content/{id}/validation/
|
|
Returns aggregated validation checklist for Writer UI.
|
|
"""
|
|
content = self.get_object()
|
|
validation_service = ContentValidationService()
|
|
|
|
errors = validation_service.validate_content(content)
|
|
publish_errors = validation_service.validate_for_publish(content)
|
|
|
|
return success_response(
|
|
data={
|
|
'content_id': content.id,
|
|
'is_valid': len(errors) == 0,
|
|
'ready_to_publish': len(publish_errors) == 0,
|
|
'validation_errors': errors,
|
|
'publish_errors': publish_errors,
|
|
'metadata': {
|
|
'has_entity_type': bool(content.entity_type),
|
|
'entity_type': content.entity_type,
|
|
'has_cluster_mapping': self._has_cluster_mapping(content),
|
|
'has_taxonomy_mapping': self._has_taxonomy_mapping(content),
|
|
}
|
|
},
|
|
request=request
|
|
)
|
|
|
|
@action(detail=True, methods=['post'], url_path='validate', url_name='validate')
|
|
def validate(self, request, pk=None):
|
|
"""
|
|
Stage 3: Re-run validators and return actionable errors.
|
|
|
|
POST /api/v1/writer/content/{id}/validate/
|
|
Re-validates content and returns structured errors.
|
|
"""
|
|
content = self.get_object()
|
|
validation_service = ContentValidationService()
|
|
|
|
# Persist metadata mappings if task exists
|
|
if content.task:
|
|
mapping_service = MetadataMappingService()
|
|
mapping_service.persist_task_metadata_to_content(content)
|
|
|
|
errors = validation_service.validate_for_publish(content)
|
|
|
|
return success_response(
|
|
data={
|
|
'content_id': content.id,
|
|
'is_valid': len(errors) == 0,
|
|
'errors': errors,
|
|
},
|
|
request=request
|
|
)
|
|
|
|
def _has_cluster_mapping(self, content):
|
|
"""Helper to check if content has cluster mapping"""
|
|
from igny8_core.business.content.models import ContentClusterMap
|
|
return ContentClusterMap.objects.filter(content=content).exists()
|
|
|
|
def _has_taxonomy_mapping(self, content):
|
|
"""Helper to check if content has taxonomy mapping"""
|
|
from igny8_core.business.content.models import ContentTaxonomyMap
|
|
return ContentTaxonomyMap.objects.filter(content=content).exists()
|
|
|
|
@extend_schema_view(
|
|
list=extend_schema(tags=['Writer']),
|
|
create=extend_schema(tags=['Writer']),
|
|
retrieve=extend_schema(tags=['Writer']),
|
|
update=extend_schema(tags=['Writer']),
|
|
partial_update=extend_schema(tags=['Writer']),
|
|
destroy=extend_schema(tags=['Writer']),
|
|
)
|
|
class ContentViewSet(SiteSectorModelViewSet):
|
|
"""
|
|
ViewSet for managing content with new unified structure
|
|
Unified API Standard v1.0 compliant
|
|
Stage 1 Refactored - removed deprecated fields
|
|
"""
|
|
queryset = Content.objects.select_related('cluster', 'site', 'sector').prefetch_related('taxonomy_terms')
|
|
serializer_class = ContentSerializer
|
|
permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove]
|
|
pagination_class = CustomPageNumberPagination
|
|
throttle_scope = 'writer'
|
|
throttle_classes = [DebugScopedRateThrottle]
|
|
|
|
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
|
search_fields = ['title', 'content_html', 'external_url']
|
|
ordering_fields = ['created_at', 'updated_at', 'status']
|
|
ordering = ['-created_at']
|
|
# Stage 1: removed task_id, entity_type, content_format, cluster_role, sync_status, external_type
|
|
filterset_fields = [
|
|
'cluster_id',
|
|
'status',
|
|
'content_type',
|
|
'content_structure',
|
|
'source',
|
|
]
|
|
|
|
def perform_create(self, serializer):
|
|
"""Override to automatically set account"""
|
|
account = getattr(self.request, 'account', None)
|
|
if account:
|
|
serializer.save(account=account)
|
|
else:
|
|
serializer.save()
|
|
|
|
@action(detail=True, methods=['post'], url_path='publish', url_name='publish', permission_classes=[IsAuthenticatedAndActive, IsEditorOrAbove])
|
|
def publish(self, request, pk=None):
|
|
"""
|
|
Stage 1: Publish content to WordPress site.
|
|
|
|
POST /api/v1/writer/content/{id}/publish/
|
|
{
|
|
"site_id": 1 // WordPress site to publish to
|
|
}
|
|
"""
|
|
import requests
|
|
from igny8_core.auth.models import Site
|
|
|
|
content = self.get_object()
|
|
site_id = request.data.get('site_id')
|
|
|
|
if not site_id:
|
|
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 error_response(
|
|
error=f'Site with id {site_id} does not exist',
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
request=request
|
|
)
|
|
|
|
# Build WordPress API payload
|
|
wp_payload = {
|
|
'title': content.title,
|
|
'content': content.content_html,
|
|
'status': 'publish',
|
|
'meta': {
|
|
'_igny8_content_id': str(content.id),
|
|
'_igny8_cluster_id': str(content.cluster_id) if content.cluster_id else '',
|
|
'_igny8_content_type': content.content_type,
|
|
'_igny8_content_structure': content.content_structure,
|
|
},
|
|
}
|
|
|
|
# Add taxonomy terms if present
|
|
if content.taxonomy_terms.exists():
|
|
wp_categories = []
|
|
wp_tags = []
|
|
for term in content.taxonomy_terms.all():
|
|
if term.taxonomy_type == 'category' and term.external_id:
|
|
wp_categories.append(int(term.external_id))
|
|
elif term.taxonomy_type == 'post_tag' and term.external_id:
|
|
wp_tags.append(int(term.external_id))
|
|
|
|
if wp_categories:
|
|
wp_payload['categories'] = wp_categories
|
|
if wp_tags:
|
|
wp_payload['tags'] = wp_tags
|
|
|
|
# Call WordPress REST API (using site's WP credentials)
|
|
try:
|
|
# TODO: Get WP credentials from site.metadata or environment
|
|
wp_url = site.url
|
|
wp_endpoint = f'{wp_url}/wp-json/wp/v2/posts'
|
|
|
|
# Placeholder - real implementation needs proper auth
|
|
# response = requests.post(wp_endpoint, json=wp_payload, auth=(wp_user, wp_password))
|
|
# response.raise_for_status()
|
|
# wp_post_data = response.json()
|
|
|
|
# For now, mark as published and return success
|
|
content.status = 'published'
|
|
content.external_id = '12345'
|
|
content.external_url = f'{wp_url}/?p=12345'
|
|
content.save()
|
|
|
|
return success_response(
|
|
data={
|
|
'content_id': content.id,
|
|
'status': content.status,
|
|
'external_id': content.external_id,
|
|
'external_url': content.external_url,
|
|
'message': 'Content published to WordPress (placeholder implementation)',
|
|
},
|
|
message='Content published successfully',
|
|
request=request
|
|
)
|
|
except Exception as e:
|
|
return error_response(
|
|
error=f'Failed to publish to WordPress: {str(e)}',
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
request=request
|
|
)
|
|
|
|
@action(detail=False, methods=['post'], url_path='generate_image_prompts', url_name='generate_image_prompts')
|
|
def generate_image_prompts(self, request):
|
|
"""Generate image prompts for content records - same pattern as other AI functions"""
|
|
from igny8_core.ai.tasks import run_ai_task
|
|
|
|
account = getattr(request, 'account', None)
|
|
ids = request.data.get('ids', [])
|
|
|
|
if not ids:
|
|
return error_response(
|
|
error='No IDs provided',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
account_id = account.id if account else None
|
|
|
|
# Queue Celery task
|
|
try:
|
|
if hasattr(run_ai_task, 'delay'):
|
|
task = run_ai_task.delay(
|
|
function_name='generate_image_prompts',
|
|
payload={'ids': ids},
|
|
account_id=account_id
|
|
)
|
|
return success_response(
|
|
data={'task_id': str(task.id)},
|
|
message='Image prompt generation started',
|
|
request=request
|
|
)
|
|
else:
|
|
# Fallback to synchronous execution
|
|
result = run_ai_task(
|
|
function_name='generate_image_prompts',
|
|
payload={'ids': ids},
|
|
account_id=account_id
|
|
)
|
|
if result.get('success'):
|
|
return success_response(
|
|
data={'prompts_created': result.get('count', 0)},
|
|
message='Image prompts generated successfully',
|
|
request=request
|
|
)
|
|
else:
|
|
return error_response(
|
|
error=result.get('error', 'Image prompt generation failed'),
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
request=request
|
|
)
|
|
except Exception as e:
|
|
return error_response(
|
|
error=str(e),
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
request=request
|
|
)
|
|
|
|
@action(detail=True, methods=['get'], url_path='validation', url_name='validation')
|
|
def validation(self, request, pk=None):
|
|
"""
|
|
Stage 3: Get validation checklist for content.
|
|
|
|
GET /api/v1/writer/content/{id}/validation/
|
|
Returns aggregated validation checklist for Writer UI.
|
|
"""
|
|
content = self.get_object()
|
|
validation_service = ContentValidationService()
|
|
|
|
errors = validation_service.validate_content(content)
|
|
publish_errors = validation_service.validate_for_publish(content)
|
|
|
|
return success_response(
|
|
data={
|
|
'content_id': content.id,
|
|
'is_valid': len(errors) == 0,
|
|
'ready_to_publish': len(publish_errors) == 0,
|
|
'validation_errors': errors,
|
|
'publish_errors': publish_errors,
|
|
'metadata': {
|
|
'has_entity_type': bool(content.entity_type),
|
|
'entity_type': content.entity_type,
|
|
'has_cluster_mapping': self._has_cluster_mapping(content),
|
|
'has_taxonomy_mapping': self._has_taxonomy_mapping(content),
|
|
}
|
|
},
|
|
request=request
|
|
)
|
|
|
|
@action(detail=True, methods=['post'], url_path='validate', url_name='validate')
|
|
def validate(self, request, pk=None):
|
|
"""
|
|
Stage 3: Re-run validators and return actionable errors.
|
|
|
|
POST /api/v1/writer/content/{id}/validate/
|
|
Re-validates content and returns structured errors.
|
|
"""
|
|
content = self.get_object()
|
|
validation_service = ContentValidationService()
|
|
|
|
# Persist metadata mappings if task exists
|
|
if content.task:
|
|
mapping_service = MetadataMappingService()
|
|
mapping_service.persist_task_metadata_to_content(content)
|
|
|
|
errors = validation_service.validate_for_publish(content)
|
|
|
|
return success_response(
|
|
data={
|
|
'content_id': content.id,
|
|
'is_valid': len(errors) == 0,
|
|
'errors': errors,
|
|
},
|
|
request=request
|
|
)
|
|
|
|
def _has_cluster_mapping(self, content):
|
|
"""Helper to check if content has cluster mapping"""
|
|
from igny8_core.business.content.models import ContentClusterMap
|
|
return ContentClusterMap.objects.filter(content=content).exists()
|
|
|
|
def _has_taxonomy_mapping(self, content):
|
|
"""Helper to check if content has taxonomy mapping"""
|
|
from igny8_core.business.content.models import ContentTaxonomyMap
|
|
return ContentTaxonomyMap.objects.filter(content=content).exists()
|
|
|
|
@action(detail=False, methods=['post'], url_path='generate_product', url_name='generate_product')
|
|
def generate_product(self, request):
|
|
"""
|
|
Generate product content (Phase 8).
|
|
|
|
POST /api/v1/writer/content/generate_product/
|
|
{
|
|
"name": "Product Name",
|
|
"description": "Product description",
|
|
"features": ["Feature 1", "Feature 2"],
|
|
"target_audience": "Target audience",
|
|
"primary_keyword": "Primary keyword",
|
|
"site_id": 1, // optional
|
|
"sector_id": 1 // optional
|
|
}
|
|
"""
|
|
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
|
|
from igny8_core.auth.models import Site, Sector
|
|
|
|
account = getattr(request, 'account', None)
|
|
if not account:
|
|
return error_response(
|
|
error='Account not found',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
product_data = request.data
|
|
site_id = product_data.get('site_id')
|
|
sector_id = product_data.get('sector_id')
|
|
|
|
site = None
|
|
sector = None
|
|
|
|
if site_id:
|
|
try:
|
|
site = Site.objects.get(id=site_id, account=account)
|
|
except Site.DoesNotExist:
|
|
return error_response(
|
|
error='Site not found',
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
request=request
|
|
)
|
|
|
|
if sector_id:
|
|
try:
|
|
sector = Sector.objects.get(id=sector_id, account=account)
|
|
except Sector.DoesNotExist:
|
|
return error_response(
|
|
error='Sector not found',
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
request=request
|
|
)
|
|
|
|
service = ContentGenerationService()
|
|
|
|
try:
|
|
result = service.generate_product_content(
|
|
product_data=product_data,
|
|
account=account,
|
|
site=site,
|
|
sector=sector
|
|
)
|
|
|
|
if result.get('success'):
|
|
return success_response(
|
|
data={'task_id': result.get('task_id')},
|
|
message=result.get('message', 'Product content generation started'),
|
|
request=request
|
|
)
|
|
else:
|
|
return error_response(
|
|
error=result.get('error', 'Product content generation failed'),
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
request=request
|
|
)
|
|
except Exception as e:
|
|
return error_response(
|
|
error=str(e),
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
request=request
|
|
)
|
|
|
|
@action(detail=True, methods=['get'], url_path='validation', url_name='validation')
|
|
def validation(self, request, pk=None):
|
|
"""
|
|
Stage 3: Get validation checklist for content.
|
|
|
|
GET /api/v1/writer/content/{id}/validation/
|
|
Returns aggregated validation checklist for Writer UI.
|
|
"""
|
|
content = self.get_object()
|
|
validation_service = ContentValidationService()
|
|
|
|
errors = validation_service.validate_content(content)
|
|
publish_errors = validation_service.validate_for_publish(content)
|
|
|
|
return success_response(
|
|
data={
|
|
'content_id': content.id,
|
|
'is_valid': len(errors) == 0,
|
|
'ready_to_publish': len(publish_errors) == 0,
|
|
'validation_errors': errors,
|
|
'publish_errors': publish_errors,
|
|
'metadata': {
|
|
'has_entity_type': bool(content.entity_type),
|
|
'entity_type': content.entity_type,
|
|
'has_cluster_mapping': self._has_cluster_mapping(content),
|
|
'has_taxonomy_mapping': self._has_taxonomy_mapping(content),
|
|
}
|
|
},
|
|
request=request
|
|
)
|
|
|
|
@action(detail=True, methods=['post'], url_path='validate', url_name='validate')
|
|
def validate(self, request, pk=None):
|
|
"""
|
|
Stage 3: Re-run validators and return actionable errors.
|
|
|
|
POST /api/v1/writer/content/{id}/validate/
|
|
Re-validates content and returns structured errors.
|
|
"""
|
|
content = self.get_object()
|
|
validation_service = ContentValidationService()
|
|
|
|
# Persist metadata mappings if task exists
|
|
if content.task:
|
|
mapping_service = MetadataMappingService()
|
|
mapping_service.persist_task_metadata_to_content(content)
|
|
|
|
errors = validation_service.validate_for_publish(content)
|
|
|
|
return success_response(
|
|
data={
|
|
'content_id': content.id,
|
|
'is_valid': len(errors) == 0,
|
|
'errors': errors,
|
|
},
|
|
request=request
|
|
)
|
|
|
|
def _has_cluster_mapping(self, content):
|
|
"""Helper to check if content has cluster mapping"""
|
|
from igny8_core.business.content.models import ContentClusterMap
|
|
return ContentClusterMap.objects.filter(content=content).exists()
|
|
|
|
def _has_taxonomy_mapping(self, content):
|
|
"""Helper to check if content has taxonomy mapping"""
|
|
from igny8_core.business.content.models import ContentTaxonomyMap
|
|
return ContentTaxonomyMap.objects.filter(content=content).exists()
|
|
|
|
@action(detail=False, methods=['post'], url_path='generate_service', url_name='generate_service')
|
|
def generate_service(self, request):
|
|
"""
|
|
Generate service page content (Phase 8).
|
|
|
|
POST /api/v1/writer/content/generate_service/
|
|
{
|
|
"name": "Service Name",
|
|
"description": "Service description",
|
|
"benefits": ["Benefit 1", "Benefit 2"],
|
|
"target_audience": "Target audience",
|
|
"primary_keyword": "Primary keyword",
|
|
"site_id": 1, // optional
|
|
"sector_id": 1 // optional
|
|
}
|
|
"""
|
|
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
|
|
from igny8_core.auth.models import Site, Sector
|
|
|
|
account = getattr(request, 'account', None)
|
|
if not account:
|
|
return error_response(
|
|
error='Account not found',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
service_data = request.data
|
|
site_id = service_data.get('site_id')
|
|
sector_id = service_data.get('sector_id')
|
|
|
|
site = None
|
|
sector = None
|
|
|
|
if site_id:
|
|
try:
|
|
site = Site.objects.get(id=site_id, account=account)
|
|
except Site.DoesNotExist:
|
|
return error_response(
|
|
error='Site not found',
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
request=request
|
|
)
|
|
|
|
if sector_id:
|
|
try:
|
|
sector = Sector.objects.get(id=sector_id, account=account)
|
|
except Sector.DoesNotExist:
|
|
return error_response(
|
|
error='Sector not found',
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
request=request
|
|
)
|
|
|
|
service = ContentGenerationService()
|
|
|
|
try:
|
|
result = service.generate_service_page(
|
|
service_data=service_data,
|
|
account=account,
|
|
site=site,
|
|
sector=sector
|
|
)
|
|
|
|
if result.get('success'):
|
|
return success_response(
|
|
data={'task_id': result.get('task_id')},
|
|
message=result.get('message', 'Service page generation started'),
|
|
request=request
|
|
)
|
|
else:
|
|
return error_response(
|
|
error=result.get('error', 'Service page generation failed'),
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
request=request
|
|
)
|
|
except Exception as e:
|
|
return error_response(
|
|
error=str(e),
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
request=request
|
|
)
|
|
|
|
@action(detail=True, methods=['get'], url_path='validation', url_name='validation')
|
|
def validation(self, request, pk=None):
|
|
"""
|
|
Stage 3: Get validation checklist for content.
|
|
|
|
GET /api/v1/writer/content/{id}/validation/
|
|
Returns aggregated validation checklist for Writer UI.
|
|
"""
|
|
content = self.get_object()
|
|
validation_service = ContentValidationService()
|
|
|
|
errors = validation_service.validate_content(content)
|
|
publish_errors = validation_service.validate_for_publish(content)
|
|
|
|
return success_response(
|
|
data={
|
|
'content_id': content.id,
|
|
'is_valid': len(errors) == 0,
|
|
'ready_to_publish': len(publish_errors) == 0,
|
|
'validation_errors': errors,
|
|
'publish_errors': publish_errors,
|
|
'metadata': {
|
|
'has_entity_type': bool(content.entity_type),
|
|
'entity_type': content.entity_type,
|
|
'has_cluster_mapping': self._has_cluster_mapping(content),
|
|
'has_taxonomy_mapping': self._has_taxonomy_mapping(content),
|
|
}
|
|
},
|
|
request=request
|
|
)
|
|
|
|
@action(detail=True, methods=['post'], url_path='validate', url_name='validate')
|
|
def validate(self, request, pk=None):
|
|
"""
|
|
Stage 3: Re-run validators and return actionable errors.
|
|
|
|
POST /api/v1/writer/content/{id}/validate/
|
|
Re-validates content and returns structured errors.
|
|
"""
|
|
content = self.get_object()
|
|
validation_service = ContentValidationService()
|
|
|
|
# Persist metadata mappings if task exists
|
|
if content.task:
|
|
mapping_service = MetadataMappingService()
|
|
mapping_service.persist_task_metadata_to_content(content)
|
|
|
|
errors = validation_service.validate_for_publish(content)
|
|
|
|
return success_response(
|
|
data={
|
|
'content_id': content.id,
|
|
'is_valid': len(errors) == 0,
|
|
'errors': errors,
|
|
},
|
|
request=request
|
|
)
|
|
|
|
def _has_cluster_mapping(self, content):
|
|
"""Helper to check if content has cluster mapping"""
|
|
from igny8_core.business.content.models import ContentClusterMap
|
|
return ContentClusterMap.objects.filter(content=content).exists()
|
|
|
|
def _has_taxonomy_mapping(self, content):
|
|
"""Helper to check if content has taxonomy mapping"""
|
|
from igny8_core.business.content.models import ContentTaxonomyMap
|
|
return ContentTaxonomyMap.objects.filter(content=content).exists()
|
|
|
|
@action(detail=False, methods=['post'], url_path='generate_taxonomy', url_name='generate_taxonomy')
|
|
def generate_taxonomy(self, request):
|
|
"""
|
|
Generate taxonomy page content (Phase 8).
|
|
|
|
POST /api/v1/writer/content/generate_taxonomy/
|
|
{
|
|
"name": "Taxonomy Name",
|
|
"description": "Taxonomy description",
|
|
"items": ["Item 1", "Item 2"],
|
|
"primary_keyword": "Primary keyword",
|
|
"site_id": 1, // optional
|
|
"sector_id": 1 // optional
|
|
}
|
|
"""
|
|
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
|
|
from igny8_core.auth.models import Site, Sector
|
|
|
|
account = getattr(request, 'account', None)
|
|
if not account:
|
|
return error_response(
|
|
error='Account not found',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
taxonomy_data = request.data
|
|
site_id = taxonomy_data.get('site_id')
|
|
sector_id = taxonomy_data.get('sector_id')
|
|
|
|
site = None
|
|
sector = None
|
|
|
|
if site_id:
|
|
try:
|
|
site = Site.objects.get(id=site_id, account=account)
|
|
except Site.DoesNotExist:
|
|
return error_response(
|
|
error='Site not found',
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
request=request
|
|
)
|
|
|
|
if sector_id:
|
|
try:
|
|
sector = Sector.objects.get(id=sector_id, account=account)
|
|
except Sector.DoesNotExist:
|
|
return error_response(
|
|
error='Sector not found',
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
request=request
|
|
)
|
|
|
|
service = ContentGenerationService()
|
|
|
|
try:
|
|
result = service.generate_taxonomy(
|
|
taxonomy_data=taxonomy_data,
|
|
account=account,
|
|
site=site,
|
|
sector=sector
|
|
)
|
|
|
|
if result.get('success'):
|
|
return success_response(
|
|
data={'task_id': result.get('task_id')},
|
|
message=result.get('message', 'Taxonomy generation started'),
|
|
request=request
|
|
)
|
|
else:
|
|
return error_response(
|
|
error=result.get('error', 'Taxonomy generation failed'),
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
request=request
|
|
)
|
|
except Exception as e:
|
|
return error_response(
|
|
error=str(e),
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
request=request
|
|
)
|
|
|
|
@action(detail=True, methods=['get'], url_path='validation', url_name='validation')
|
|
def validation(self, request, pk=None):
|
|
"""
|
|
Stage 3: Get validation checklist for content.
|
|
|
|
GET /api/v1/writer/content/{id}/validation/
|
|
Returns aggregated validation checklist for Writer UI.
|
|
"""
|
|
content = self.get_object()
|
|
validation_service = ContentValidationService()
|
|
|
|
errors = validation_service.validate_content(content)
|
|
publish_errors = validation_service.validate_for_publish(content)
|
|
|
|
return success_response(
|
|
data={
|
|
'content_id': content.id,
|
|
'is_valid': len(errors) == 0,
|
|
'ready_to_publish': len(publish_errors) == 0,
|
|
'validation_errors': errors,
|
|
'publish_errors': publish_errors,
|
|
'metadata': {
|
|
'has_entity_type': bool(content.entity_type),
|
|
'entity_type': content.entity_type,
|
|
'has_cluster_mapping': self._has_cluster_mapping(content),
|
|
'has_taxonomy_mapping': self._has_taxonomy_mapping(content),
|
|
}
|
|
},
|
|
request=request
|
|
)
|
|
|
|
@action(detail=True, methods=['post'], url_path='validate', url_name='validate')
|
|
def validate(self, request, pk=None):
|
|
"""
|
|
Stage 3: Re-run validators and return actionable errors.
|
|
|
|
POST /api/v1/writer/content/{id}/validate/
|
|
Re-validates content and returns structured errors.
|
|
"""
|
|
content = self.get_object()
|
|
validation_service = ContentValidationService()
|
|
|
|
# Persist metadata mappings if task exists
|
|
if content.task:
|
|
mapping_service = MetadataMappingService()
|
|
mapping_service.persist_task_metadata_to_content(content)
|
|
|
|
errors = validation_service.validate_for_publish(content)
|
|
|
|
return success_response(
|
|
data={
|
|
'content_id': content.id,
|
|
'is_valid': len(errors) == 0,
|
|
'errors': errors,
|
|
},
|
|
request=request
|
|
)
|
|
|
|
def _has_cluster_mapping(self, content):
|
|
"""Helper to check if content has cluster mapping"""
|
|
from igny8_core.business.content.models import ContentClusterMap
|
|
return ContentClusterMap.objects.filter(content=content).exists()
|
|
|
|
def _has_taxonomy_mapping(self, content):
|
|
"""Helper to check if content has taxonomy mapping"""
|
|
# Check new M2M relationship
|
|
return content.taxonomies.exists()
|
|
|
|
|
|
@extend_schema_view(
|
|
list=extend_schema(tags=['Writer - Taxonomies']),
|
|
create=extend_schema(tags=['Writer - Taxonomies']),
|
|
retrieve=extend_schema(tags=['Writer - Taxonomies']),
|
|
update=extend_schema(tags=['Writer - Taxonomies']),
|
|
partial_update=extend_schema(tags=['Writer - Taxonomies']),
|
|
destroy=extend_schema(tags=['Writer - Taxonomies']),
|
|
)
|
|
class ContentTaxonomyViewSet(SiteSectorModelViewSet):
|
|
"""
|
|
ViewSet for managing content taxonomies (categories, tags, product attributes)
|
|
Unified API Standard v1.0 compliant
|
|
"""
|
|
queryset = ContentTaxonomy.objects.select_related('parent', 'site', 'sector').prefetch_related('clusters', 'contents')
|
|
serializer_class = ContentTaxonomySerializer
|
|
permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove]
|
|
pagination_class = CustomPageNumberPagination
|
|
throttle_scope = 'writer'
|
|
throttle_classes = [DebugScopedRateThrottle]
|
|
|
|
# DRF filtering configuration
|
|
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
|
|
|
# Search configuration
|
|
search_fields = ['name', 'slug', 'description', 'external_taxonomy']
|
|
|
|
# Ordering configuration
|
|
ordering_fields = ['name', 'taxonomy_type', 'count', 'created_at']
|
|
ordering = ['taxonomy_type', 'name']
|
|
|
|
# Filter configuration
|
|
filterset_fields = ['taxonomy_type', 'sync_status', 'parent', 'external_id', 'external_taxonomy']
|
|
|
|
def perform_create(self, serializer):
|
|
"""Create taxonomy with site/sector context"""
|
|
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
|
|
|
|
if not site_id:
|
|
raise ValidationError("site_id is required")
|
|
|
|
try:
|
|
site = Site.objects.get(id=site_id)
|
|
except Site.DoesNotExist:
|
|
raise ValidationError(f"Site with id {site_id} does not exist")
|
|
|
|
if not sector_id:
|
|
raise ValidationError("sector_id is required")
|
|
|
|
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 and user.account:
|
|
account = user.account
|
|
if not account:
|
|
account = site.account
|
|
|
|
serializer.save(account=account, site=site, sector=sector)
|
|
|
|
@action(detail=True, methods=['post'], permission_classes=[IsAuthenticatedAndActive, IsEditorOrAbove])
|
|
def map_to_cluster(self, request, pk=None):
|
|
"""Map taxonomy to semantic cluster"""
|
|
taxonomy = self.get_object()
|
|
cluster_id = request.data.get('cluster_id')
|
|
|
|
if not cluster_id:
|
|
return error_response(
|
|
error="cluster_id is required",
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
request=request
|
|
)
|
|
|
|
from igny8_core.business.planning.models import Clusters
|
|
try:
|
|
cluster = Clusters.objects.get(id=cluster_id, site=taxonomy.site)
|
|
taxonomy.clusters.add(cluster)
|
|
|
|
return success_response(
|
|
data={'message': f'Taxonomy "{taxonomy.name}" mapped to cluster "{cluster.name}"'},
|
|
message="Taxonomy mapped to cluster successfully",
|
|
request=request
|
|
)
|
|
except Clusters.DoesNotExist:
|
|
return error_response(
|
|
error=f"Cluster with id {cluster_id} not found",
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
request=request
|
|
)
|
|
|
|
@action(detail=True, methods=['get'])
|
|
def contents(self, request, pk=None):
|
|
"""Get all content associated with this taxonomy"""
|
|
taxonomy = self.get_object()
|
|
contents = taxonomy.contents.all()
|
|
|
|
serializer = ContentSerializer(contents, many=True, context={'request': request})
|
|
|
|
return success_response(
|
|
data=serializer.data,
|
|
message=f"Found {contents.count()} content items for taxonomy '{taxonomy.name}'",
|
|
request=request
|
|
)
|
|
|
|
|
|
# ContentAttributeViewSet temporarily disabled - ContentAttributeSerializer was removed in Stage 1
|
|
# TODO: Re-implement or remove completely based on Stage 1 architecture decisions
|
|
|
|
|