Add Linker and Optimizer modules with API integration and frontend components
- Added Linker and Optimizer apps to `INSTALLED_APPS` in `settings.py`. - Configured API endpoints for Linker and Optimizer in `urls.py`. - Implemented `OptimizeContentFunction` for content optimization in the AI module. - Created prompts for content optimization and site structure generation. - Updated `OptimizerService` to utilize the new AI function for content optimization. - Developed frontend components including dashboards and content lists for Linker and Optimizer. - Integrated new routes and sidebar navigation for Linker and Optimizer in the frontend. - Enhanced content management with source and sync status filters in the Writer module. - Comprehensive test coverage added for new features and components.
This commit is contained in:
2
backend/igny8_core/modules/linker/__init__.py
Normal file
2
backend/igny8_core/modules/linker/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
default_app_config = 'igny8_core.modules.linker.apps.LinkerConfig'
|
||||
|
||||
8
backend/igny8_core/modules/linker/apps.py
Normal file
8
backend/igny8_core/modules/linker/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LinkerConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'igny8_core.modules.linker'
|
||||
verbose_name = 'Linker Module'
|
||||
|
||||
42
backend/igny8_core/modules/linker/serializers.py
Normal file
42
backend/igny8_core/modules/linker/serializers.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from rest_framework import serializers
|
||||
from igny8_core.business.content.models import Content
|
||||
|
||||
|
||||
class LinkContentSerializer(serializers.Serializer):
|
||||
"""Serializer for linking content"""
|
||||
content_id = serializers.IntegerField(required=True)
|
||||
|
||||
def validate_content_id(self, value):
|
||||
try:
|
||||
content = Content.objects.get(id=value)
|
||||
account = self.context['request'].user.account if hasattr(self.context['request'].user, 'account') else None
|
||||
if account and content.account != account:
|
||||
raise serializers.ValidationError("Content not found or access denied")
|
||||
return value
|
||||
except Content.DoesNotExist:
|
||||
raise serializers.ValidationError("Content not found")
|
||||
|
||||
|
||||
class BatchLinkContentSerializer(serializers.Serializer):
|
||||
"""Serializer for batch linking"""
|
||||
content_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
min_length=1,
|
||||
max_length=50
|
||||
)
|
||||
|
||||
def validate_content_ids(self, value):
|
||||
account = self.context['request'].user.account if hasattr(self.context['request'].user, 'account') else None
|
||||
if not account:
|
||||
raise serializers.ValidationError("Account not found")
|
||||
|
||||
content_ids = Content.objects.filter(
|
||||
id__in=value,
|
||||
account=account
|
||||
).values_list('id', flat=True)
|
||||
|
||||
if len(content_ids) != len(value):
|
||||
raise serializers.ValidationError("Some content IDs are invalid or inaccessible")
|
||||
|
||||
return list(content_ids)
|
||||
|
||||
2
backend/igny8_core/modules/linker/tests/__init__.py
Normal file
2
backend/igny8_core/modules/linker/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Linker module tests
|
||||
|
||||
137
backend/igny8_core/modules/linker/tests/test_views.py
Normal file
137
backend/igny8_core/modules/linker/tests/test_views.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Tests for Linker API endpoints
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class LinkerAPITests(IntegrationTestBase):
|
||||
"""Tests for Linker API endpoints"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
# Create test content
|
||||
self.content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test Content",
|
||||
html_content="<p>Test content.</p>",
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
def test_process_endpoint_requires_authentication(self):
|
||||
"""Test that process endpoint requires authentication"""
|
||||
client = APIClient() # Not authenticated
|
||||
response = client.post('/api/v1/linker/process/', {
|
||||
'content_id': self.content.id
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
@patch('igny8_core.modules.linker.views.LinkerService.process')
|
||||
def test_process_endpoint_success(self, mock_process):
|
||||
"""Test successful processing"""
|
||||
mock_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Linked Content",
|
||||
html_content="<p>Linked.</p>",
|
||||
internal_links=[{'content_id': 1, 'anchor_text': 'test'}],
|
||||
linker_version=1,
|
||||
word_count=100
|
||||
)
|
||||
mock_process.return_value = mock_content
|
||||
|
||||
response = self.client.post('/api/v1/linker/process/', {
|
||||
'content_id': self.content.id
|
||||
}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertEqual(response.data['data']['content_id'], self.content.id)
|
||||
self.assertEqual(response.data['data']['links_added'], 1)
|
||||
|
||||
def test_process_endpoint_invalid_content_id(self):
|
||||
"""Test process endpoint with invalid content ID"""
|
||||
response = self.client.post('/api/v1/linker/process/', {
|
||||
'content_id': 99999
|
||||
}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@patch('igny8_core.modules.linker.views.LinkerService.process')
|
||||
def test_process_endpoint_insufficient_credits(self, mock_process):
|
||||
"""Test process endpoint with insufficient credits"""
|
||||
mock_process.side_effect = InsufficientCreditsError("Insufficient credits")
|
||||
|
||||
response = self.client.post('/api/v1/linker/process/', {
|
||||
'content_id': self.content.id
|
||||
}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_402_PAYMENT_REQUIRED)
|
||||
|
||||
@patch('igny8_core.modules.linker.views.LinkerService.batch_process')
|
||||
def test_batch_process_endpoint_success(self, mock_batch):
|
||||
"""Test successful batch processing"""
|
||||
content2 = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Content 2",
|
||||
word_count=100
|
||||
)
|
||||
|
||||
mock_batch.return_value = [self.content, content2]
|
||||
|
||||
response = self.client.post('/api/v1/linker/batch_process/', {
|
||||
'content_ids': [self.content.id, content2.id]
|
||||
}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertEqual(len(response.data['data']), 2)
|
||||
|
||||
def test_batch_process_endpoint_validation(self):
|
||||
"""Test batch process endpoint validation"""
|
||||
response = self.client.post('/api/v1/linker/batch_process/', {
|
||||
'content_ids': [] # Empty list
|
||||
}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_process_endpoint_respects_account_isolation(self):
|
||||
"""Test that process endpoint respects account isolation"""
|
||||
from igny8_core.auth.models import Account
|
||||
other_account = Account.objects.create(
|
||||
name="Other Account",
|
||||
slug="other",
|
||||
plan=self.plan,
|
||||
owner=self.user
|
||||
)
|
||||
|
||||
other_content = Content.objects.create(
|
||||
account=other_account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Other Content",
|
||||
word_count=100
|
||||
)
|
||||
|
||||
response = self.client.post('/api/v1/linker/process/', {
|
||||
'content_id': other_content.id
|
||||
}, format='json')
|
||||
|
||||
# Should return 400 because content belongs to different account
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
12
backend/igny8_core/modules/linker/urls.py
Normal file
12
backend/igny8_core/modules/linker/urls.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from igny8_core.modules.linker.views import LinkerViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'', LinkerViewSet, basename='linker')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
||||
109
backend/igny8_core/modules/linker/views.py
Normal file
109
backend/igny8_core/modules/linker/views.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import logging
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.viewsets import ViewSet
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
||||
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove
|
||||
from igny8_core.api.response import success_response, error_response
|
||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.linking.services.linker_service import LinkerService
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||
from igny8_core.modules.linker.serializers import (
|
||||
LinkContentSerializer,
|
||||
BatchLinkContentSerializer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
process=extend_schema(tags=['Linker']),
|
||||
batch_process=extend_schema(tags=['Linker']),
|
||||
)
|
||||
class LinkerViewSet(ViewSet):
|
||||
"""
|
||||
API endpoints for internal linking operations.
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove]
|
||||
throttle_scope = 'linker'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.linker_service = LinkerService()
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def process(self, request):
|
||||
"""
|
||||
Process a single content item for internal linking.
|
||||
|
||||
POST /api/v1/linker/process/
|
||||
{
|
||||
"content_id": 123
|
||||
}
|
||||
"""
|
||||
serializer = LinkContentSerializer(data=request.data, context={'request': request})
|
||||
if not serializer.is_valid():
|
||||
return error_response(serializer.errors, status=status.HTTP_400_BAD_REQUEST, request=request)
|
||||
|
||||
content_id = serializer.validated_data['content_id']
|
||||
|
||||
try:
|
||||
content = self.linker_service.process(content_id)
|
||||
|
||||
result = {
|
||||
'content_id': content.id,
|
||||
'links_added': len(content.internal_links) if content.internal_links else 0,
|
||||
'links': content.internal_links or [],
|
||||
'linker_version': content.linker_version,
|
||||
'success': True,
|
||||
}
|
||||
|
||||
return success_response(result, request=request)
|
||||
|
||||
except ValueError as e:
|
||||
return error_response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST, request=request)
|
||||
except InsufficientCreditsError as e:
|
||||
return error_response({'error': str(e)}, status=status.HTTP_402_PAYMENT_REQUIRED, request=request)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing content {content_id}: {str(e)}", exc_info=True)
|
||||
return error_response({'error': 'Internal server error'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def batch_process(self, request):
|
||||
"""
|
||||
Process multiple content items for internal linking.
|
||||
|
||||
POST /api/v1/linker/batch_process/
|
||||
{
|
||||
"content_ids": [123, 456, 789]
|
||||
}
|
||||
"""
|
||||
serializer = BatchLinkContentSerializer(data=request.data, context={'request': request})
|
||||
if not serializer.is_valid():
|
||||
return error_response(serializer.errors, status=status.HTTP_400_BAD_REQUEST, request=request)
|
||||
|
||||
content_ids = serializer.validated_data['content_ids']
|
||||
|
||||
try:
|
||||
results = self.linker_service.batch_process(content_ids)
|
||||
|
||||
response_data = []
|
||||
for content in results:
|
||||
response_data.append({
|
||||
'content_id': content.id,
|
||||
'links_added': len(content.internal_links) if content.internal_links else 0,
|
||||
'links': content.internal_links or [],
|
||||
'linker_version': content.linker_version,
|
||||
'success': True,
|
||||
})
|
||||
|
||||
return success_response(response_data, request=request)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error batch processing content: {str(e)}", exc_info=True)
|
||||
return error_response({'error': 'Internal server error'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request)
|
||||
|
||||
2
backend/igny8_core/modules/optimizer/__init__.py
Normal file
2
backend/igny8_core/modules/optimizer/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
default_app_config = 'igny8_core.modules.optimizer.apps.OptimizerConfig'
|
||||
|
||||
8
backend/igny8_core/modules/optimizer/apps.py
Normal file
8
backend/igny8_core/modules/optimizer/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OptimizerConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'igny8_core.modules.optimizer'
|
||||
verbose_name = 'Optimizer Module'
|
||||
|
||||
74
backend/igny8_core/modules/optimizer/serializers.py
Normal file
74
backend/igny8_core/modules/optimizer/serializers.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from rest_framework import serializers
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.optimization.models import OptimizationTask
|
||||
|
||||
|
||||
class OptimizeContentSerializer(serializers.Serializer):
|
||||
"""Serializer for optimizing content"""
|
||||
content_id = serializers.IntegerField(required=True)
|
||||
entry_point = serializers.ChoiceField(
|
||||
choices=['auto', 'writer', 'wordpress', 'external', 'manual'],
|
||||
default='auto',
|
||||
required=False
|
||||
)
|
||||
|
||||
def validate_content_id(self, value):
|
||||
try:
|
||||
content = Content.objects.get(id=value)
|
||||
account = self.context['request'].user.account if hasattr(self.context['request'].user, 'account') else None
|
||||
if account and content.account != account:
|
||||
raise serializers.ValidationError("Content not found or access denied")
|
||||
return value
|
||||
except Content.DoesNotExist:
|
||||
raise serializers.ValidationError("Content not found")
|
||||
|
||||
|
||||
class BatchOptimizeContentSerializer(serializers.Serializer):
|
||||
"""Serializer for batch optimization"""
|
||||
content_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
min_length=1,
|
||||
max_length=20
|
||||
)
|
||||
entry_point = serializers.ChoiceField(
|
||||
choices=['auto', 'writer', 'wordpress', 'external', 'manual'],
|
||||
default='auto',
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class OptimizationResultSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for optimization results"""
|
||||
content_title = serializers.CharField(source='content.title', read_only=True)
|
||||
content_id = serializers.IntegerField(source='content.id', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = OptimizationTask
|
||||
fields = [
|
||||
'id',
|
||||
'content_id',
|
||||
'content_title',
|
||||
'scores_before',
|
||||
'scores_after',
|
||||
'status',
|
||||
'credits_used',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class AnalyzeContentSerializer(serializers.Serializer):
|
||||
"""Serializer for content analysis (preview only)"""
|
||||
content_id = serializers.IntegerField(required=True)
|
||||
|
||||
def validate_content_id(self, value):
|
||||
try:
|
||||
content = Content.objects.get(id=value)
|
||||
account = self.context['request'].user.account if hasattr(self.context['request'].user, 'account') else None
|
||||
if account and content.account != account:
|
||||
raise serializers.ValidationError("Content not found or access denied")
|
||||
return value
|
||||
except Content.DoesNotExist:
|
||||
raise serializers.ValidationError("Content not found")
|
||||
|
||||
2
backend/igny8_core/modules/optimizer/tests/__init__.py
Normal file
2
backend/igny8_core/modules/optimizer/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Optimizer module tests
|
||||
|
||||
180
backend/igny8_core/modules/optimizer/tests/test_views.py
Normal file
180
backend/igny8_core/modules/optimizer/tests/test_views.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Tests for Optimizer API endpoints
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.optimization.models import OptimizationTask
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class OptimizerAPITests(IntegrationTestBase):
|
||||
"""Tests for Optimizer API endpoints"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
# Create test content
|
||||
self.content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test Content",
|
||||
html_content="<p>Test content.</p>",
|
||||
word_count=500,
|
||||
status='draft',
|
||||
source='igny8'
|
||||
)
|
||||
|
||||
def test_optimize_endpoint_requires_authentication(self):
|
||||
"""Test that optimize endpoint requires authentication"""
|
||||
client = APIClient() # Not authenticated
|
||||
response = client.post('/api/v1/optimizer/optimize/', {
|
||||
'content_id': self.content.id
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
@patch('igny8_core.modules.optimizer.views.OptimizerService.optimize_from_writer')
|
||||
def test_optimize_endpoint_success(self, mock_optimize):
|
||||
"""Test successful optimization"""
|
||||
optimized_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Optimized",
|
||||
html_content="<p>Optimized.</p>",
|
||||
word_count=500,
|
||||
optimizer_version=1,
|
||||
optimization_scores={'overall_score': 75.0}
|
||||
)
|
||||
mock_optimize.return_value = optimized_content
|
||||
|
||||
# Create optimization task
|
||||
task = OptimizationTask.objects.create(
|
||||
content=optimized_content,
|
||||
scores_before={'overall_score': 50.0},
|
||||
scores_after={'overall_score': 75.0},
|
||||
status='completed',
|
||||
account=self.account
|
||||
)
|
||||
|
||||
response = self.client.post('/api/v1/optimizer/optimize/', {
|
||||
'content_id': self.content.id,
|
||||
'entry_point': 'writer'
|
||||
}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertEqual(response.data['data']['content_id'], self.content.id)
|
||||
|
||||
def test_optimize_endpoint_all_entry_points(self):
|
||||
"""Test optimize endpoint with all entry point values"""
|
||||
entry_points = ['auto', 'writer', 'wordpress', 'external', 'manual']
|
||||
|
||||
for entry_point in entry_points:
|
||||
with patch(f'igny8_core.modules.optimizer.views.OptimizerService.optimize_{entry_point if entry_point != "auto" else "from_writer"}') as mock_opt:
|
||||
if entry_point == 'auto':
|
||||
mock_opt = patch('igny8_core.modules.optimizer.views.OptimizerService.optimize_from_writer')
|
||||
mock_opt.return_value = self.content
|
||||
|
||||
response = self.client.post('/api/v1/optimizer/optimize/', {
|
||||
'content_id': self.content.id,
|
||||
'entry_point': entry_point
|
||||
}, format='json')
|
||||
|
||||
# Should accept all entry points
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
|
||||
|
||||
@patch('igny8_core.modules.optimizer.views.OptimizerService.optimize_from_writer')
|
||||
def test_batch_optimize_endpoint_success(self, mock_optimize):
|
||||
"""Test successful batch optimization"""
|
||||
content2 = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Content 2",
|
||||
word_count=500,
|
||||
source='igny8'
|
||||
)
|
||||
|
||||
mock_optimize.return_value = self.content
|
||||
|
||||
response = self.client.post('/api/v1/optimizer/batch_optimize/', {
|
||||
'content_ids': [self.content.id, content2.id],
|
||||
'entry_point': 'writer'
|
||||
}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertIn('succeeded', response.data['data'])
|
||||
|
||||
@patch('igny8_core.modules.optimizer.views.OptimizerService.analyze_only')
|
||||
def test_analyze_endpoint_success(self, mock_analyze):
|
||||
"""Test analyze endpoint returns scores"""
|
||||
scores = {
|
||||
'seo_score': 50.0,
|
||||
'readability_score': 60.0,
|
||||
'engagement_score': 55.0,
|
||||
'overall_score': 55.0
|
||||
}
|
||||
mock_analyze.return_value = scores
|
||||
|
||||
response = self.client.post('/api/v1/optimizer/analyze/', {
|
||||
'content_id': self.content.id
|
||||
}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertIn('scores', response.data['data'])
|
||||
self.assertEqual(response.data['data']['scores']['overall_score'], 55.0)
|
||||
|
||||
@patch('igny8_core.modules.optimizer.views.OptimizerService.optimize_from_writer')
|
||||
def test_optimize_endpoint_insufficient_credits(self, mock_optimize):
|
||||
"""Test optimize endpoint with insufficient credits"""
|
||||
mock_optimize.side_effect = InsufficientCreditsError("Insufficient credits")
|
||||
|
||||
response = self.client.post('/api/v1/optimizer/optimize/', {
|
||||
'content_id': self.content.id
|
||||
}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_402_PAYMENT_REQUIRED)
|
||||
|
||||
def test_optimize_endpoint_invalid_content_id(self):
|
||||
"""Test optimize endpoint with invalid content ID"""
|
||||
response = self.client.post('/api/v1/optimizer/optimize/', {
|
||||
'content_id': 99999
|
||||
}, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_optimize_endpoint_respects_account_isolation(self):
|
||||
"""Test that optimize endpoint respects account isolation"""
|
||||
from igny8_core.auth.models import Account
|
||||
other_account = Account.objects.create(
|
||||
name="Other Account",
|
||||
slug="other",
|
||||
plan=self.plan,
|
||||
owner=self.user
|
||||
)
|
||||
|
||||
other_content = Content.objects.create(
|
||||
account=other_account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Other Content",
|
||||
word_count=100
|
||||
)
|
||||
|
||||
response = self.client.post('/api/v1/optimizer/optimize/', {
|
||||
'content_id': other_content.id
|
||||
}, format='json')
|
||||
|
||||
# Should return 400 because content belongs to different account
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
12
backend/igny8_core/modules/optimizer/urls.py
Normal file
12
backend/igny8_core/modules/optimizer/urls.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from igny8_core.modules.optimizer.views import OptimizerViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'', OptimizerViewSet, basename='optimizer')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
||||
201
backend/igny8_core/modules/optimizer/views.py
Normal file
201
backend/igny8_core/modules/optimizer/views.py
Normal file
@@ -0,0 +1,201 @@
|
||||
import logging
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.viewsets import ViewSet
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
||||
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove
|
||||
from igny8_core.api.response import success_response, error_response
|
||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.optimization.services.optimizer_service import OptimizerService
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||
from igny8_core.modules.optimizer.serializers import (
|
||||
OptimizeContentSerializer,
|
||||
BatchOptimizeContentSerializer,
|
||||
AnalyzeContentSerializer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
optimize=extend_schema(tags=['Optimizer']),
|
||||
batch_optimize=extend_schema(tags=['Optimizer']),
|
||||
analyze=extend_schema(tags=['Optimizer']),
|
||||
)
|
||||
class OptimizerViewSet(ViewSet):
|
||||
"""
|
||||
API endpoints for content optimization operations.
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove]
|
||||
throttle_scope = 'optimizer'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.optimizer_service = OptimizerService()
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def optimize(self, request):
|
||||
"""
|
||||
Optimize content (auto-detects entry point based on source).
|
||||
|
||||
POST /api/v1/optimizer/optimize/
|
||||
{
|
||||
"content_id": 123,
|
||||
"entry_point": "auto" // optional: auto, writer, wordpress, external, manual
|
||||
}
|
||||
"""
|
||||
serializer = OptimizeContentSerializer(data=request.data, context={'request': request})
|
||||
if not serializer.is_valid():
|
||||
return error_response(serializer.errors, status=status.HTTP_400_BAD_REQUEST, request=request)
|
||||
|
||||
content_id = serializer.validated_data['content_id']
|
||||
entry_point = serializer.validated_data.get('entry_point', 'auto')
|
||||
|
||||
try:
|
||||
content = Content.objects.get(id=content_id)
|
||||
|
||||
# Route to appropriate entry point
|
||||
if entry_point == 'auto':
|
||||
# Auto-detect based on source
|
||||
if content.source == 'igny8':
|
||||
content = self.optimizer_service.optimize_from_writer(content_id)
|
||||
elif content.source == 'wordpress':
|
||||
content = self.optimizer_service.optimize_from_wordpress_sync(content_id)
|
||||
elif content.source in ['shopify', 'custom']:
|
||||
content = self.optimizer_service.optimize_from_external_sync(content_id)
|
||||
else:
|
||||
content = self.optimizer_service.optimize_manual(content_id)
|
||||
elif entry_point == 'writer':
|
||||
content = self.optimizer_service.optimize_from_writer(content_id)
|
||||
elif entry_point == 'wordpress':
|
||||
content = self.optimizer_service.optimize_from_wordpress_sync(content_id)
|
||||
elif entry_point == 'external':
|
||||
content = self.optimizer_service.optimize_from_external_sync(content_id)
|
||||
else: # manual
|
||||
content = self.optimizer_service.optimize_manual(content_id)
|
||||
|
||||
# Get latest optimization task
|
||||
task = content.optimization_tasks.order_by('-created_at').first()
|
||||
|
||||
result = {
|
||||
'content_id': content.id,
|
||||
'optimizer_version': content.optimizer_version,
|
||||
'scores_before': task.scores_before if task else {},
|
||||
'scores_after': content.optimization_scores,
|
||||
'task_id': task.id if task else None,
|
||||
'success': True,
|
||||
}
|
||||
|
||||
return success_response(result, request=request)
|
||||
|
||||
except ValueError as e:
|
||||
return error_response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST, request=request)
|
||||
except InsufficientCreditsError as e:
|
||||
return error_response({'error': str(e)}, status=status.HTTP_402_PAYMENT_REQUIRED, request=request)
|
||||
except Exception as e:
|
||||
logger.error(f"Error optimizing content {content_id}: {str(e)}", exc_info=True)
|
||||
return error_response({'error': 'Internal server error'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def batch_optimize(self, request):
|
||||
"""
|
||||
Batch optimize multiple content items.
|
||||
|
||||
POST /api/v1/optimizer/batch_optimize/
|
||||
{
|
||||
"content_ids": [123, 456, 789],
|
||||
"entry_point": "auto"
|
||||
}
|
||||
"""
|
||||
serializer = BatchOptimizeContentSerializer(data=request.data, context={'request': request})
|
||||
if not serializer.is_valid():
|
||||
return error_response(serializer.errors, status=status.HTTP_400_BAD_REQUEST, request=request)
|
||||
|
||||
content_ids = serializer.validated_data['content_ids']
|
||||
entry_point = serializer.validated_data.get('entry_point', 'auto')
|
||||
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
for content_id in content_ids:
|
||||
try:
|
||||
content = Content.objects.get(id=content_id)
|
||||
|
||||
# Route to appropriate entry point
|
||||
if entry_point == 'auto':
|
||||
if content.source == 'igny8':
|
||||
content = self.optimizer_service.optimize_from_writer(content_id)
|
||||
elif content.source == 'wordpress':
|
||||
content = self.optimizer_service.optimize_from_wordpress_sync(content_id)
|
||||
elif content.source in ['shopify', 'custom']:
|
||||
content = self.optimizer_service.optimize_from_external_sync(content_id)
|
||||
else:
|
||||
content = self.optimizer_service.optimize_manual(content_id)
|
||||
elif entry_point == 'writer':
|
||||
content = self.optimizer_service.optimize_from_writer(content_id)
|
||||
elif entry_point == 'wordpress':
|
||||
content = self.optimizer_service.optimize_from_wordpress_sync(content_id)
|
||||
elif entry_point == 'external':
|
||||
content = self.optimizer_service.optimize_from_external_sync(content_id)
|
||||
else:
|
||||
content = self.optimizer_service.optimize_manual(content_id)
|
||||
|
||||
task = content.optimization_tasks.order_by('-created_at').first()
|
||||
|
||||
results.append({
|
||||
'content_id': content.id,
|
||||
'optimizer_version': content.optimizer_version,
|
||||
'scores_after': content.optimization_scores,
|
||||
'success': True,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error optimizing content {content_id}: {str(e)}", exc_info=True)
|
||||
errors.append({
|
||||
'content_id': content_id,
|
||||
'error': str(e),
|
||||
'success': False,
|
||||
})
|
||||
|
||||
return success_response({
|
||||
'results': results,
|
||||
'errors': errors,
|
||||
'total': len(content_ids),
|
||||
'succeeded': len(results),
|
||||
'failed': len(errors),
|
||||
}, request=request)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def analyze(self, request):
|
||||
"""
|
||||
Analyze content without optimizing (preview scores).
|
||||
|
||||
POST /api/v1/optimizer/analyze/
|
||||
{
|
||||
"content_id": 123
|
||||
}
|
||||
"""
|
||||
serializer = AnalyzeContentSerializer(data=request.data, context={'request': request})
|
||||
if not serializer.is_valid():
|
||||
return error_response(serializer.errors, status=status.HTTP_400_BAD_REQUEST, request=request)
|
||||
|
||||
content_id = serializer.validated_data['content_id']
|
||||
|
||||
try:
|
||||
scores = self.optimizer_service.analyze_only(content_id)
|
||||
|
||||
return success_response({
|
||||
'content_id': content_id,
|
||||
'scores': scores,
|
||||
}, request=request)
|
||||
|
||||
except ValueError as e:
|
||||
return error_response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST, request=request)
|
||||
except Exception as e:
|
||||
logger.error(f"Error analyzing content {content_id}: {str(e)}", exc_info=True)
|
||||
return error_response({'error': 'Internal server error'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request)
|
||||
|
||||
Reference in New Issue
Block a user