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:
alorig
2025-11-18 00:41:00 +05:00
parent 4b9e1a49a9
commit f7115190dc
60 changed files with 4932 additions and 80 deletions

View File

@@ -0,0 +1,2 @@
default_app_config = 'igny8_core.modules.linker.apps.LinkerConfig'

View 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'

View 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)

View File

@@ -0,0 +1,2 @@
# Linker module tests

View 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)

View 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)),
]

View 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)