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:
167
backend/igny8_core/ai/functions/optimize_content.py
Normal file
167
backend/igny8_core/ai/functions/optimize_content.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Optimize Content AI Function
|
||||
Phase 4 – Linker & Optimizer
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from igny8_core.ai.base import BaseAIFunction
|
||||
from igny8_core.ai.prompts import PromptRegistry
|
||||
from igny8_core.business.content.models import Content
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OptimizeContentFunction(BaseAIFunction):
|
||||
"""AI function that optimizes content for SEO, readability, and engagement."""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return 'optimize_content'
|
||||
|
||||
def get_metadata(self) -> Dict:
|
||||
metadata = super().get_metadata()
|
||||
metadata.update({
|
||||
'display_name': 'Optimize Content',
|
||||
'description': 'Optimize content for SEO, readability, and engagement.',
|
||||
'phases': {
|
||||
'INIT': 'Validating content data…',
|
||||
'PREP': 'Preparing content context…',
|
||||
'AI_CALL': 'Optimizing content with AI…',
|
||||
'PARSE': 'Parsing optimized content…',
|
||||
'SAVE': 'Saving optimized content…',
|
||||
'DONE': 'Content optimized!'
|
||||
}
|
||||
})
|
||||
return metadata
|
||||
|
||||
def validate(self, payload: dict, account=None) -> Dict[str, Any]:
|
||||
if not payload.get('ids'):
|
||||
return {'valid': False, 'error': 'Content ID is required'}
|
||||
return {'valid': True}
|
||||
|
||||
def prepare(self, payload: dict, account=None) -> Dict[str, Any]:
|
||||
content_ids = payload.get('ids', [])
|
||||
queryset = Content.objects.filter(id__in=content_ids)
|
||||
if account:
|
||||
queryset = queryset.filter(account=account)
|
||||
content = queryset.select_related('account', 'site', 'sector').first()
|
||||
if not content:
|
||||
raise ValueError("Content not found")
|
||||
|
||||
# Get current scores from analyzer
|
||||
from igny8_core.business.optimization.services.analyzer import ContentAnalyzer
|
||||
analyzer = ContentAnalyzer()
|
||||
scores_before = analyzer.analyze(content)
|
||||
|
||||
return {
|
||||
'content': content,
|
||||
'scores_before': scores_before,
|
||||
'html_content': content.html_content or '',
|
||||
'meta_title': content.meta_title or '',
|
||||
'meta_description': content.meta_description or '',
|
||||
'primary_keyword': content.primary_keyword or '',
|
||||
}
|
||||
|
||||
def build_prompt(self, data: Dict[str, Any], account=None) -> str:
|
||||
content: Content = data['content']
|
||||
scores_before = data.get('scores_before', {})
|
||||
|
||||
context = {
|
||||
'CONTENT_TITLE': content.title or 'Untitled',
|
||||
'HTML_CONTENT': data.get('html_content', ''),
|
||||
'META_TITLE': data.get('meta_title', ''),
|
||||
'META_DESCRIPTION': data.get('meta_description', ''),
|
||||
'PRIMARY_KEYWORD': data.get('primary_keyword', ''),
|
||||
'WORD_COUNT': str(content.word_count or 0),
|
||||
'CURRENT_SCORES': json.dumps(scores_before, indent=2),
|
||||
'SOURCE': content.source,
|
||||
'INTERNAL_LINKS_COUNT': str(len(content.internal_links) if content.internal_links else 0),
|
||||
}
|
||||
|
||||
return PromptRegistry.get_prompt(
|
||||
'optimize_content',
|
||||
account=account or content.account,
|
||||
context=context
|
||||
)
|
||||
|
||||
def parse_response(self, response: str, step_tracker=None) -> Dict[str, Any]:
|
||||
if not response:
|
||||
raise ValueError("AI response is empty")
|
||||
|
||||
response = response.strip()
|
||||
try:
|
||||
return self._ensure_dict(json.loads(response))
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Response not valid JSON, attempting to extract JSON object")
|
||||
cleaned = self._extract_json_object(response)
|
||||
if cleaned:
|
||||
return self._ensure_dict(json.loads(cleaned))
|
||||
raise ValueError("Unable to parse AI response into JSON")
|
||||
|
||||
def save_output(
|
||||
self,
|
||||
parsed: Dict[str, Any],
|
||||
original_data: Dict[str, Any],
|
||||
account=None,
|
||||
progress_tracker=None,
|
||||
step_tracker=None
|
||||
) -> Dict[str, Any]:
|
||||
content: Content = original_data['content']
|
||||
|
||||
# Extract optimized content
|
||||
optimized_html = parsed.get('html_content') or parsed.get('content') or content.html_content
|
||||
optimized_meta_title = parsed.get('meta_title') or content.meta_title
|
||||
optimized_meta_description = parsed.get('meta_description') or content.meta_description
|
||||
|
||||
# Update content
|
||||
content.html_content = optimized_html
|
||||
if optimized_meta_title:
|
||||
content.meta_title = optimized_meta_title
|
||||
if optimized_meta_description:
|
||||
content.meta_description = optimized_meta_description
|
||||
|
||||
# Recalculate word count
|
||||
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
|
||||
content_service = ContentGenerationService()
|
||||
content.word_count = content_service._count_words(optimized_html)
|
||||
|
||||
# Increment optimizer version
|
||||
content.optimizer_version += 1
|
||||
|
||||
# Get scores after optimization
|
||||
from igny8_core.business.optimization.services.analyzer import ContentAnalyzer
|
||||
analyzer = ContentAnalyzer()
|
||||
scores_after = analyzer.analyze(content)
|
||||
content.optimization_scores = scores_after
|
||||
|
||||
content.save(update_fields=[
|
||||
'html_content', 'meta_title', 'meta_description',
|
||||
'word_count', 'optimizer_version', 'optimization_scores', 'updated_at'
|
||||
])
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'content_id': content.id,
|
||||
'scores_before': original_data.get('scores_before', {}),
|
||||
'scores_after': scores_after,
|
||||
'word_count_before': original_data.get('word_count', 0),
|
||||
'word_count_after': content.word_count,
|
||||
'html_content': optimized_html,
|
||||
'meta_title': optimized_meta_title,
|
||||
'meta_description': optimized_meta_description,
|
||||
}
|
||||
|
||||
# Helper methods
|
||||
def _ensure_dict(self, data: Any) -> Dict[str, Any]:
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
raise ValueError("AI response must be a JSON object")
|
||||
|
||||
def _extract_json_object(self, text: str) -> str:
|
||||
start = text.find('{')
|
||||
end = text.rfind('}')
|
||||
if start != -1 and end != -1 and end > start:
|
||||
return text[start:end + 1]
|
||||
return ''
|
||||
|
||||
2
backend/igny8_core/ai/functions/tests/__init__.py
Normal file
2
backend/igny8_core/ai/functions/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# AI functions tests
|
||||
|
||||
179
backend/igny8_core/ai/functions/tests/test_optimize_content.py
Normal file
179
backend/igny8_core/ai/functions/tests/test_optimize_content.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Tests for OptimizeContentFunction
|
||||
"""
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.ai.functions.optimize_content import OptimizeContentFunction
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class OptimizeContentFunctionTests(IntegrationTestBase):
|
||||
"""Tests for OptimizeContentFunction"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.function = OptimizeContentFunction()
|
||||
|
||||
# Create test content
|
||||
self.content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test Content",
|
||||
html_content="<p>This is test content.</p>",
|
||||
meta_title="Test Title",
|
||||
meta_description="Test description",
|
||||
primary_keyword="test keyword",
|
||||
word_count=500,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
def test_function_validation_phase(self):
|
||||
"""Test validation phase"""
|
||||
# Valid payload
|
||||
result = self.function.validate({'ids': [self.content.id]}, self.account)
|
||||
self.assertTrue(result['valid'])
|
||||
|
||||
# Invalid payload - missing ids
|
||||
result = self.function.validate({}, self.account)
|
||||
self.assertFalse(result['valid'])
|
||||
self.assertIn('error', result)
|
||||
|
||||
def test_function_prep_phase(self):
|
||||
"""Test prep phase"""
|
||||
payload = {'ids': [self.content.id]}
|
||||
|
||||
data = self.function.prepare(payload, self.account)
|
||||
|
||||
self.assertIn('content', data)
|
||||
self.assertIn('scores_before', data)
|
||||
self.assertIn('html_content', data)
|
||||
self.assertEqual(data['content'].id, self.content.id)
|
||||
|
||||
def test_function_prep_phase_content_not_found(self):
|
||||
"""Test prep phase with non-existent content"""
|
||||
payload = {'ids': [99999]}
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.function.prepare(payload, self.account)
|
||||
|
||||
@patch('igny8_core.ai.functions.optimize_content.PromptRegistry.get_prompt')
|
||||
def test_function_build_prompt(self, mock_get_prompt):
|
||||
"""Test prompt building"""
|
||||
mock_get_prompt.return_value = "Test prompt"
|
||||
|
||||
data = {
|
||||
'content': self.content,
|
||||
'html_content': '<p>Test</p>',
|
||||
'meta_title': 'Title',
|
||||
'meta_description': 'Description',
|
||||
'primary_keyword': 'keyword',
|
||||
'scores_before': {'overall_score': 50.0}
|
||||
}
|
||||
|
||||
prompt = self.function.build_prompt(data, self.account)
|
||||
|
||||
self.assertEqual(prompt, "Test prompt")
|
||||
mock_get_prompt.assert_called_once()
|
||||
# Check that context was passed
|
||||
call_args = mock_get_prompt.call_args
|
||||
self.assertIn('context', call_args.kwargs)
|
||||
|
||||
def test_function_parse_response_valid_json(self):
|
||||
"""Test parsing valid JSON response"""
|
||||
response = '{"html_content": "<p>Optimized</p>", "meta_title": "New Title"}'
|
||||
|
||||
parsed = self.function.parse_response(response)
|
||||
|
||||
self.assertIn('html_content', parsed)
|
||||
self.assertEqual(parsed['html_content'], "<p>Optimized</p>")
|
||||
self.assertEqual(parsed['meta_title'], "New Title")
|
||||
|
||||
def test_function_parse_response_invalid_json(self):
|
||||
"""Test parsing invalid JSON response"""
|
||||
response = "This is not JSON"
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.function.parse_response(response)
|
||||
|
||||
def test_function_parse_response_extracts_json_object(self):
|
||||
"""Test that JSON object is extracted from text"""
|
||||
response = 'Some text {"html_content": "<p>Optimized</p>"} more text'
|
||||
|
||||
parsed = self.function.parse_response(response)
|
||||
|
||||
self.assertIn('html_content', parsed)
|
||||
self.assertEqual(parsed['html_content'], "<p>Optimized</p>")
|
||||
|
||||
@patch('igny8_core.business.optimization.services.analyzer.ContentAnalyzer.analyze')
|
||||
@patch('igny8_core.business.content.services.content_generation_service.ContentGenerationService._count_words')
|
||||
def test_function_save_phase(self, mock_count_words, mock_analyze):
|
||||
"""Test save phase updates content"""
|
||||
mock_count_words.return_value = 600
|
||||
mock_analyze.return_value = {
|
||||
'seo_score': 75.0,
|
||||
'readability_score': 80.0,
|
||||
'engagement_score': 70.0,
|
||||
'overall_score': 75.0
|
||||
}
|
||||
|
||||
parsed = {
|
||||
'html_content': '<p>Optimized content.</p>',
|
||||
'meta_title': 'Optimized Title',
|
||||
'meta_description': 'Optimized Description'
|
||||
}
|
||||
|
||||
original_data = {
|
||||
'content': self.content,
|
||||
'scores_before': {'overall_score': 50.0},
|
||||
'word_count': 500
|
||||
}
|
||||
|
||||
result = self.function.save_output(parsed, original_data, self.account)
|
||||
|
||||
self.assertTrue(result['success'])
|
||||
self.assertEqual(result['content_id'], self.content.id)
|
||||
|
||||
# Refresh content from DB
|
||||
self.content.refresh_from_db()
|
||||
self.assertEqual(self.content.html_content, '<p>Optimized content.</p>')
|
||||
self.assertEqual(self.content.optimizer_version, 1)
|
||||
self.assertIsNotNone(self.content.optimization_scores)
|
||||
|
||||
def test_function_handles_invalid_content_id(self):
|
||||
"""Test that function handles invalid content ID"""
|
||||
payload = {'ids': [99999]}
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.function.prepare(payload, self.account)
|
||||
|
||||
def test_function_respects_account_isolation(self):
|
||||
"""Test that function 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
|
||||
)
|
||||
|
||||
payload = {'ids': [self.content.id]}
|
||||
|
||||
# Should not find content from different account
|
||||
with self.assertRaises(ValueError):
|
||||
self.function.prepare(payload, other_account)
|
||||
|
||||
def test_get_name(self):
|
||||
"""Test get_name method"""
|
||||
self.assertEqual(self.function.get_name(), 'optimize_content')
|
||||
|
||||
def test_get_metadata(self):
|
||||
"""Test get_metadata method"""
|
||||
metadata = self.function.get_metadata()
|
||||
|
||||
self.assertIn('display_name', metadata)
|
||||
self.assertIn('description', metadata)
|
||||
self.assertIn('phases', metadata)
|
||||
self.assertEqual(metadata['display_name'], 'Optimize Content')
|
||||
|
||||
@@ -332,6 +332,62 @@ Make sure each prompt is detailed enough for image generation, describing the vi
|
||||
'image_prompt_template': 'Create a high-quality {image_type} image to use as a featured photo for a blog post titled "{post_title}". The image should visually represent the theme, mood, and subject implied by the image prompt: {image_prompt}. Focus on a realistic, well-composed scene that naturally communicates the topic without text or logos. Use balanced lighting, pleasing composition, and photographic detail suitable for lifestyle or editorial web content. Avoid adding any visible or readable text, brand names, or illustrative effects. **And make sure image is not blurry.**',
|
||||
|
||||
'negative_prompt': 'text, watermark, logo, overlay, title, caption, writing on walls, writing on objects, UI, infographic elements, post title',
|
||||
|
||||
'optimize_content': """You are an expert content optimizer specializing in SEO, readability, and engagement.
|
||||
|
||||
Your task is to optimize the provided content to improve its SEO score, readability, and engagement metrics.
|
||||
|
||||
CURRENT CONTENT:
|
||||
Title: {CONTENT_TITLE}
|
||||
Word Count: {WORD_COUNT}
|
||||
Source: {SOURCE}
|
||||
Primary Keyword: {PRIMARY_KEYWORD}
|
||||
Internal Links: {INTERNAL_LINKS_COUNT}
|
||||
|
||||
CURRENT META DATA:
|
||||
Meta Title: {META_TITLE}
|
||||
Meta Description: {META_DESCRIPTION}
|
||||
|
||||
CURRENT SCORES:
|
||||
{CURRENT_SCORES}
|
||||
|
||||
HTML CONTENT:
|
||||
{HTML_CONTENT}
|
||||
|
||||
OPTIMIZATION REQUIREMENTS:
|
||||
|
||||
1. SEO Optimization:
|
||||
- Ensure meta title is 30-60 characters (if provided)
|
||||
- Ensure meta description is 120-160 characters (if provided)
|
||||
- Optimize primary keyword usage (natural, not keyword stuffing)
|
||||
- Improve heading structure (H1, H2, H3 hierarchy)
|
||||
- Add internal links where relevant (maintain existing links)
|
||||
|
||||
2. Readability:
|
||||
- Average sentence length: 15-20 words
|
||||
- Use clear, concise language
|
||||
- Break up long paragraphs
|
||||
- Use bullet points and lists where appropriate
|
||||
- Ensure proper paragraph structure
|
||||
|
||||
3. Engagement:
|
||||
- Add compelling headings
|
||||
- Include relevant images placeholders (alt text)
|
||||
- Use engaging language
|
||||
- Create clear call-to-action sections
|
||||
- Improve content flow and structure
|
||||
|
||||
OUTPUT FORMAT:
|
||||
Return ONLY a JSON object in this format:
|
||||
{{
|
||||
"html_content": "[Optimized HTML content]",
|
||||
"meta_title": "[Optimized meta title, 30-60 chars]",
|
||||
"meta_description": "[Optimized meta description, 120-160 chars]",
|
||||
"optimization_notes": "[Brief notes on what was optimized]"
|
||||
}}
|
||||
|
||||
Do not include any explanations, text, or commentary outside the JSON output.
|
||||
""",
|
||||
}
|
||||
|
||||
# Mapping from function names to prompt types
|
||||
@@ -343,6 +399,7 @@ Make sure each prompt is detailed enough for image generation, describing the vi
|
||||
'extract_image_prompts': 'image_prompt_extraction',
|
||||
'generate_image_prompts': 'image_prompt_extraction',
|
||||
'generate_site_structure': 'site_structure_generation',
|
||||
'optimize_content': 'optimize_content',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -99,10 +99,16 @@ def _load_generate_site_structure():
|
||||
from igny8_core.ai.functions.generate_site_structure import GenerateSiteStructureFunction
|
||||
return GenerateSiteStructureFunction
|
||||
|
||||
def _load_optimize_content():
|
||||
"""Lazy loader for optimize_content function"""
|
||||
from igny8_core.ai.functions.optimize_content import OptimizeContentFunction
|
||||
return OptimizeContentFunction
|
||||
|
||||
register_lazy_function('auto_cluster', _load_auto_cluster)
|
||||
register_lazy_function('generate_ideas', _load_generate_ideas)
|
||||
register_lazy_function('generate_content', _load_generate_content)
|
||||
register_lazy_function('generate_images', _load_generate_images)
|
||||
register_lazy_function('generate_image_prompts', _load_generate_image_prompts)
|
||||
register_lazy_function('generate_site_structure', _load_generate_site_structure)
|
||||
register_lazy_function('optimize_content', _load_optimize_content)
|
||||
|
||||
|
||||
2
backend/igny8_core/business/billing/tests/__init__.py
Normal file
2
backend/igny8_core/business/billing/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Billing tests
|
||||
|
||||
133
backend/igny8_core/business/billing/tests/test_phase4_credits.py
Normal file
133
backend/igny8_core/business/billing/tests/test_phase4_credits.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Tests for Phase 4 credit deduction
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.billing.services.credit_service import CreditService
|
||||
from igny8_core.business.billing.constants import CREDIT_COSTS
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class Phase4CreditTests(IntegrationTestBase):
|
||||
"""Tests for Phase 4 credit deduction"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Set initial credits
|
||||
self.account.credits = 1000
|
||||
self.account.save()
|
||||
|
||||
def test_linking_deducts_correct_credits(self):
|
||||
"""Test that linking deducts correct credits"""
|
||||
cost = CreditService.get_credit_cost('linking')
|
||||
expected_cost = CREDIT_COSTS.get('linking', 0)
|
||||
|
||||
self.assertEqual(cost, expected_cost)
|
||||
self.assertEqual(cost, 8) # From constants
|
||||
|
||||
def test_optimization_deducts_correct_credits(self):
|
||||
"""Test that optimization deducts correct credits based on word count"""
|
||||
word_count = 500
|
||||
cost = CreditService.get_credit_cost('optimization', word_count)
|
||||
|
||||
# Should be 1 credit per 200 words, so 500 words = 3 credits (max(1, 1 * 500/200) = 3)
|
||||
expected = max(1, int(CREDIT_COSTS.get('optimization', 1) * (word_count / 200)))
|
||||
self.assertEqual(cost, expected)
|
||||
|
||||
def test_optimization_credits_per_entry_point(self):
|
||||
"""Test that optimization credits are same regardless of entry point"""
|
||||
word_count = 400
|
||||
|
||||
# All entry points should use same credit calculation
|
||||
cost = CreditService.get_credit_cost('optimization', word_count)
|
||||
|
||||
# 400 words = 2 credits (1 * 400/200)
|
||||
self.assertEqual(cost, 2)
|
||||
|
||||
@patch('igny8_core.business.billing.services.credit_service.CreditService.deduct_credits')
|
||||
def test_pipeline_deducts_credits_at_each_stage(self, mock_deduct):
|
||||
"""Test that pipeline deducts credits at each stage"""
|
||||
from igny8_core.business.content.services.content_pipeline_service import ContentPipelineService
|
||||
from igny8_core.business.linking.services.linker_service import LinkerService
|
||||
from igny8_core.business.optimization.services.optimizer_service import OptimizerService
|
||||
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
word_count=400,
|
||||
source='igny8'
|
||||
)
|
||||
|
||||
# Mock the services
|
||||
with patch.object(LinkerService, 'process') as mock_link, \
|
||||
patch.object(OptimizerService, 'optimize_from_writer') as mock_optimize:
|
||||
|
||||
mock_link.return_value = content
|
||||
mock_optimize.return_value = content
|
||||
|
||||
service = ContentPipelineService()
|
||||
service.process_writer_content(content.id)
|
||||
|
||||
# Should deduct credits for both linking and optimization
|
||||
self.assertGreater(mock_deduct.call_count, 0)
|
||||
|
||||
def test_insufficient_credits_blocks_linking(self):
|
||||
"""Test that insufficient credits blocks linking"""
|
||||
self.account.credits = 5 # Less than linking cost (8)
|
||||
self.account.save()
|
||||
|
||||
with self.assertRaises(InsufficientCreditsError):
|
||||
CreditService.check_credits(self.account, 'linking')
|
||||
|
||||
def test_insufficient_credits_blocks_optimization(self):
|
||||
"""Test that insufficient credits blocks optimization"""
|
||||
self.account.credits = 1 # Less than optimization cost for 500 words
|
||||
self.account.save()
|
||||
|
||||
with self.assertRaises(InsufficientCreditsError):
|
||||
CreditService.check_credits(self.account, 'optimization', 500)
|
||||
|
||||
def test_credit_deduction_logged(self):
|
||||
"""Test that credit deduction is logged"""
|
||||
from igny8_core.business.billing.models import CreditUsageLog
|
||||
|
||||
initial_credits = self.account.credits
|
||||
cost = CreditService.get_credit_cost('linking')
|
||||
|
||||
CreditService.deduct_credits_for_operation(
|
||||
account=self.account,
|
||||
operation_type='linking',
|
||||
description="Test linking"
|
||||
)
|
||||
|
||||
self.account.refresh_from_db()
|
||||
self.assertEqual(self.account.credits, initial_credits - cost)
|
||||
|
||||
# Check that usage log was created
|
||||
log = CreditUsageLog.objects.filter(
|
||||
account=self.account,
|
||||
operation_type='linking'
|
||||
).first()
|
||||
self.assertIsNotNone(log)
|
||||
|
||||
def test_batch_operations_deduct_multiple_credits(self):
|
||||
"""Test that batch operations deduct multiple credits"""
|
||||
initial_credits = self.account.credits
|
||||
linking_cost = CreditService.get_credit_cost('linking')
|
||||
|
||||
# Deduct for 3 linking operations
|
||||
for i in range(3):
|
||||
CreditService.deduct_credits_for_operation(
|
||||
account=self.account,
|
||||
operation_type='linking',
|
||||
description=f"Linking {i}"
|
||||
)
|
||||
|
||||
self.account.refresh_from_db()
|
||||
expected_credits = initial_credits - (linking_cost * 3)
|
||||
self.assertEqual(self.account.credits, expected_credits)
|
||||
|
||||
2
backend/igny8_core/business/content/tests/__init__.py
Normal file
2
backend/igny8_core/business/content/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Content tests
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Tests for ContentPipelineService
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.content.services.content_pipeline_service import ContentPipelineService
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class ContentPipelineServiceTests(IntegrationTestBase):
|
||||
"""Tests for ContentPipelineService"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.service = ContentPipelineService()
|
||||
|
||||
# Create writer content
|
||||
self.writer_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Writer Content",
|
||||
html_content="<p>Writer content.</p>",
|
||||
word_count=500,
|
||||
status='draft',
|
||||
source='igny8'
|
||||
)
|
||||
|
||||
# Create synced content
|
||||
self.synced_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="WordPress Content",
|
||||
html_content="<p>WordPress content.</p>",
|
||||
word_count=500,
|
||||
status='draft',
|
||||
source='wordpress'
|
||||
)
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.LinkerService.process')
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_from_writer')
|
||||
def test_process_writer_content_full_pipeline(self, mock_optimize, mock_link):
|
||||
"""Test full pipeline for writer content (linking + optimization)"""
|
||||
mock_link.return_value = self.writer_content
|
||||
mock_optimize.return_value = self.writer_content
|
||||
|
||||
result = self.service.process_writer_content(self.writer_content.id)
|
||||
|
||||
self.assertEqual(result.id, self.writer_content.id)
|
||||
mock_link.assert_called_once()
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_from_writer')
|
||||
def test_process_writer_content_optimization_only(self, mock_optimize):
|
||||
"""Test writer content with optimization only"""
|
||||
mock_optimize.return_value = self.writer_content
|
||||
|
||||
result = self.service.process_writer_content(
|
||||
self.writer_content.id,
|
||||
stages=['optimization']
|
||||
)
|
||||
|
||||
self.assertEqual(result.id, self.writer_content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.LinkerService.process')
|
||||
def test_process_writer_content_linking_only(self, mock_link):
|
||||
"""Test writer content with linking only"""
|
||||
mock_link.return_value = self.writer_content
|
||||
|
||||
result = self.service.process_writer_content(
|
||||
self.writer_content.id,
|
||||
stages=['linking']
|
||||
)
|
||||
|
||||
self.assertEqual(result.id, self.writer_content.id)
|
||||
mock_link.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.LinkerService.process')
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_from_writer')
|
||||
def test_process_writer_content_handles_linker_failure(self, mock_optimize, mock_link):
|
||||
"""Test that pipeline continues when linking fails"""
|
||||
mock_link.side_effect = Exception("Linking failed")
|
||||
mock_optimize.return_value = self.writer_content
|
||||
|
||||
# Should not raise exception, should continue to optimization
|
||||
result = self.service.process_writer_content(self.writer_content.id)
|
||||
|
||||
self.assertEqual(result.id, self.writer_content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_from_wordpress_sync')
|
||||
def test_process_synced_content_wordpress(self, mock_optimize):
|
||||
"""Test synced content pipeline for WordPress"""
|
||||
mock_optimize.return_value = self.synced_content
|
||||
|
||||
result = self.service.process_synced_content(self.synced_content.id)
|
||||
|
||||
self.assertEqual(result.id, self.synced_content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_from_external_sync')
|
||||
def test_process_synced_content_shopify(self, mock_optimize):
|
||||
"""Test synced content pipeline for Shopify"""
|
||||
shopify_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Shopify Content",
|
||||
word_count=100,
|
||||
source='shopify'
|
||||
)
|
||||
mock_optimize.return_value = shopify_content
|
||||
|
||||
result = self.service.process_synced_content(shopify_content.id)
|
||||
|
||||
self.assertEqual(result.id, shopify_content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_manual')
|
||||
def test_process_synced_content_custom(self, mock_optimize):
|
||||
"""Test synced content pipeline for custom source"""
|
||||
custom_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Custom Content",
|
||||
word_count=100,
|
||||
source='custom'
|
||||
)
|
||||
mock_optimize.return_value = custom_content
|
||||
|
||||
result = self.service.process_synced_content(custom_content.id)
|
||||
|
||||
self.assertEqual(result.id, custom_content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.ContentPipelineService.process_writer_content')
|
||||
def test_batch_process_writer_content(self, mock_process):
|
||||
"""Test batch processing writer content"""
|
||||
content2 = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Content 2",
|
||||
word_count=100,
|
||||
source='igny8'
|
||||
)
|
||||
|
||||
mock_process.side_effect = [self.writer_content, content2]
|
||||
|
||||
results = self.service.batch_process_writer_content([
|
||||
self.writer_content.id,
|
||||
content2.id
|
||||
])
|
||||
|
||||
self.assertEqual(len(results), 2)
|
||||
self.assertEqual(mock_process.call_count, 2)
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.ContentPipelineService.process_writer_content')
|
||||
def test_batch_process_handles_partial_failure(self, mock_process):
|
||||
"""Test batch processing handles partial failures"""
|
||||
mock_process.side_effect = [self.writer_content, Exception("Failed")]
|
||||
|
||||
results = self.service.batch_process_writer_content([
|
||||
self.writer_content.id,
|
||||
99999
|
||||
])
|
||||
|
||||
# Should continue processing and return successful results
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0].id, self.writer_content.id)
|
||||
|
||||
def test_process_writer_content_invalid_content(self):
|
||||
"""Test that ValueError is raised for invalid content"""
|
||||
with self.assertRaises(ValueError):
|
||||
self.service.process_writer_content(99999)
|
||||
|
||||
def test_process_synced_content_invalid_content(self):
|
||||
"""Test that ValueError is raised for invalid synced content"""
|
||||
with self.assertRaises(ValueError):
|
||||
self.service.process_synced_content(99999)
|
||||
|
||||
2
backend/igny8_core/business/linking/tests/__init__.py
Normal file
2
backend/igny8_core/business/linking/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Linking tests
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Tests for CandidateEngine
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.linking.services.candidate_engine import CandidateEngine
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class CandidateEngineTests(IntegrationTestBase):
|
||||
"""Tests for CandidateEngine"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.engine = CandidateEngine()
|
||||
|
||||
# Create source content
|
||||
self.source_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Source Content",
|
||||
html_content="<p>Source content about test keyword.</p>",
|
||||
primary_keyword="test keyword",
|
||||
secondary_keywords=["keyword1", "keyword2"],
|
||||
categories=["category1"],
|
||||
tags=["tag1", "tag2"],
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
# Create relevant content (same keyword)
|
||||
self.relevant_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Relevant Content",
|
||||
html_content="<p>Relevant content about test keyword.</p>",
|
||||
primary_keyword="test keyword",
|
||||
secondary_keywords=["keyword1"],
|
||||
categories=["category1"],
|
||||
tags=["tag1"],
|
||||
word_count=150,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
# Create less relevant content (different keyword)
|
||||
self.less_relevant = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Less Relevant",
|
||||
html_content="<p>Different content.</p>",
|
||||
primary_keyword="different keyword",
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
def test_find_candidates_returns_relevant_content(self):
|
||||
"""Test that find_candidates returns relevant content"""
|
||||
candidates = self.engine.find_candidates(self.source_content, max_candidates=10)
|
||||
|
||||
# Should find relevant content
|
||||
candidate_ids = [c['content_id'] for c in candidates]
|
||||
self.assertIn(self.relevant_content.id, candidate_ids)
|
||||
|
||||
def test_find_candidates_scores_by_relevance(self):
|
||||
"""Test that candidates are scored by relevance"""
|
||||
candidates = self.engine.find_candidates(self.source_content, max_candidates=10)
|
||||
|
||||
# Relevant content should have higher score
|
||||
relevant_candidate = next((c for c in candidates if c['content_id'] == self.relevant_content.id), None)
|
||||
self.assertIsNotNone(relevant_candidate)
|
||||
self.assertGreater(relevant_candidate['relevance_score'], 0)
|
||||
|
||||
def test_find_candidates_excludes_self(self):
|
||||
"""Test that source content is excluded from candidates"""
|
||||
candidates = self.engine.find_candidates(self.source_content, max_candidates=10)
|
||||
|
||||
candidate_ids = [c['content_id'] for c in candidates]
|
||||
self.assertNotIn(self.source_content.id, candidate_ids)
|
||||
|
||||
def test_find_candidates_respects_account_isolation(self):
|
||||
"""Test that candidates are only from same account"""
|
||||
# Create content from different account
|
||||
from igny8_core.auth.models import Account
|
||||
other_account = Account.objects.create(
|
||||
name="Other Account",
|
||||
slug="other-account",
|
||||
plan=self.plan,
|
||||
owner=self.user
|
||||
)
|
||||
|
||||
other_content = Content.objects.create(
|
||||
account=other_account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Other Account Content",
|
||||
primary_keyword="test keyword",
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
candidates = self.engine.find_candidates(self.source_content, max_candidates=10)
|
||||
candidate_ids = [c['content_id'] for c in candidates]
|
||||
self.assertNotIn(other_content.id, candidate_ids)
|
||||
|
||||
def test_find_candidates_returns_empty_for_no_content(self):
|
||||
"""Test that empty list is returned when no content"""
|
||||
empty_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Empty",
|
||||
html_content="",
|
||||
word_count=0,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
candidates = self.engine.find_candidates(empty_content, max_candidates=10)
|
||||
self.assertEqual(len(candidates), 0)
|
||||
|
||||
def test_find_candidates_respects_max_candidates(self):
|
||||
"""Test that max_candidates limit is respected"""
|
||||
# Create multiple relevant content items
|
||||
for i in range(15):
|
||||
Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title=f"Content {i}",
|
||||
primary_keyword="test keyword",
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
candidates = self.engine.find_candidates(self.source_content, max_candidates=5)
|
||||
self.assertLessEqual(len(candidates), 5)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Tests for InjectionEngine
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.linking.services.injection_engine import InjectionEngine
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class InjectionEngineTests(IntegrationTestBase):
|
||||
"""Tests for InjectionEngine"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.engine = InjectionEngine()
|
||||
|
||||
# Create content with HTML
|
||||
self.content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test Content",
|
||||
html_content="<p>This is test content with some keywords and text.</p>",
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
def test_inject_links_adds_links_to_html(self):
|
||||
"""Test that links are injected into HTML content"""
|
||||
candidates = [{
|
||||
'content_id': 1,
|
||||
'title': 'Target Content',
|
||||
'url': '/content/1/',
|
||||
'relevance_score': 50,
|
||||
'anchor_text': 'keywords'
|
||||
}]
|
||||
|
||||
result = self.engine.inject_links(self.content, candidates, max_links=5)
|
||||
|
||||
# Check that link was added
|
||||
self.assertIn('<a href="/content/1/" class="internal-link">keywords</a>', result['html_content'])
|
||||
self.assertEqual(result['links_added'], 1)
|
||||
self.assertEqual(len(result['links']), 1)
|
||||
|
||||
def test_inject_links_respects_max_links(self):
|
||||
"""Test that max_links limit is respected"""
|
||||
candidates = [
|
||||
{'content_id': i, 'title': f'Content {i}', 'url': f'/content/{i}/',
|
||||
'relevance_score': 50, 'anchor_text': f'keyword{i}'}
|
||||
for i in range(10)
|
||||
]
|
||||
|
||||
# Update HTML to include all anchor texts
|
||||
self.content.html_content = "<p>" + " ".join([f'keyword{i}' for i in range(10)]) + "</p>"
|
||||
self.content.save()
|
||||
|
||||
result = self.engine.inject_links(self.content, candidates, max_links=3)
|
||||
|
||||
self.assertLessEqual(result['links_added'], 3)
|
||||
self.assertLessEqual(len(result['links']), 3)
|
||||
|
||||
def test_inject_links_returns_unchanged_when_no_candidates(self):
|
||||
"""Test that content is unchanged when no candidates"""
|
||||
original_html = self.content.html_content
|
||||
|
||||
result = self.engine.inject_links(self.content, [], max_links=5)
|
||||
|
||||
self.assertEqual(result['html_content'], original_html)
|
||||
self.assertEqual(result['links_added'], 0)
|
||||
self.assertEqual(len(result['links']), 0)
|
||||
|
||||
def test_inject_links_returns_unchanged_when_no_html(self):
|
||||
"""Test that empty HTML returns unchanged"""
|
||||
self.content.html_content = ""
|
||||
self.content.save()
|
||||
|
||||
candidates = [{
|
||||
'content_id': 1,
|
||||
'title': 'Target',
|
||||
'url': '/content/1/',
|
||||
'relevance_score': 50,
|
||||
'anchor_text': 'test'
|
||||
}]
|
||||
|
||||
result = self.engine.inject_links(self.content, candidates, max_links=5)
|
||||
|
||||
self.assertEqual(result['html_content'], "")
|
||||
self.assertEqual(result['links_added'], 0)
|
||||
|
||||
def test_inject_links_case_insensitive_matching(self):
|
||||
"""Test that anchor text matching is case-insensitive"""
|
||||
self.content.html_content = "<p>This is TEST content.</p>"
|
||||
self.content.save()
|
||||
|
||||
candidates = [{
|
||||
'content_id': 1,
|
||||
'title': 'Target',
|
||||
'url': '/content/1/',
|
||||
'relevance_score': 50,
|
||||
'anchor_text': 'test'
|
||||
}]
|
||||
|
||||
result = self.engine.inject_links(self.content, candidates, max_links=5)
|
||||
|
||||
# Should find and replace despite case difference
|
||||
self.assertIn('internal-link', result['html_content'])
|
||||
self.assertEqual(result['links_added'], 1)
|
||||
|
||||
def test_inject_links_prevents_duplicate_links(self):
|
||||
"""Test that same candidate is not linked twice"""
|
||||
candidates = [
|
||||
{
|
||||
'content_id': 1,
|
||||
'title': 'Target',
|
||||
'url': '/content/1/',
|
||||
'relevance_score': 50,
|
||||
'anchor_text': 'test'
|
||||
},
|
||||
{
|
||||
'content_id': 1, # Same content_id
|
||||
'title': 'Target',
|
||||
'url': '/content/1/',
|
||||
'relevance_score': 40,
|
||||
'anchor_text': 'test'
|
||||
}
|
||||
]
|
||||
|
||||
self.content.html_content = "<p>This is test content with test keywords.</p>"
|
||||
self.content.save()
|
||||
|
||||
result = self.engine.inject_links(self.content, candidates, max_links=5)
|
||||
|
||||
# Should only add one link despite two candidates
|
||||
self.assertEqual(result['links_added'], 1)
|
||||
self.assertEqual(result['html_content'].count('internal-link'), 1)
|
||||
|
||||
141
backend/igny8_core/business/linking/tests/test_linker_service.py
Normal file
141
backend/igny8_core/business/linking/tests/test_linker_service.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Tests for LinkerService
|
||||
"""
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from django.test import TestCase
|
||||
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.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class LinkerServiceTests(IntegrationTestBase):
|
||||
"""Tests for LinkerService"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.service = LinkerService()
|
||||
|
||||
# Create test content
|
||||
self.content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test Content",
|
||||
html_content="<p>This is test content with some keywords.</p>",
|
||||
primary_keyword="test keyword",
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
# Create another content for linking
|
||||
self.target_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Target Content",
|
||||
html_content="<p>Target content for linking.</p>",
|
||||
primary_keyword="test keyword",
|
||||
word_count=150,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
@patch('igny8_core.business.linking.services.linker_service.CreditService.check_credits')
|
||||
@patch('igny8_core.business.linking.services.linker_service.CandidateEngine.find_candidates')
|
||||
@patch('igny8_core.business.linking.services.linker_service.InjectionEngine.inject_links')
|
||||
@patch('igny8_core.business.linking.services.linker_service.CreditService.deduct_credits_for_operation')
|
||||
def test_process_single_content(self, mock_deduct, mock_inject, mock_find, mock_check):
|
||||
"""Test processing single content for linking"""
|
||||
# Setup mocks
|
||||
mock_check.return_value = True
|
||||
mock_find.return_value = [{
|
||||
'content_id': self.target_content.id,
|
||||
'title': 'Target Content',
|
||||
'url': '/content/2/',
|
||||
'relevance_score': 50,
|
||||
'anchor_text': 'test keyword'
|
||||
}]
|
||||
mock_inject.return_value = {
|
||||
'html_content': '<p>This is test content with <a href="/content/2/">test keyword</a>.</p>',
|
||||
'links': [{
|
||||
'content_id': self.target_content.id,
|
||||
'anchor_text': 'test keyword',
|
||||
'url': '/content/2/'
|
||||
}],
|
||||
'links_added': 1
|
||||
}
|
||||
|
||||
# Execute
|
||||
result = self.service.process(self.content.id)
|
||||
|
||||
# Assertions
|
||||
self.assertEqual(result.id, self.content.id)
|
||||
self.assertEqual(result.linker_version, 1)
|
||||
self.assertEqual(len(result.internal_links), 1)
|
||||
mock_check.assert_called_once_with(self.account, 'linking')
|
||||
mock_deduct.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.linking.services.linker_service.CreditService.check_credits')
|
||||
def test_process_insufficient_credits(self, mock_check):
|
||||
"""Test that InsufficientCreditsError is raised when credits are insufficient"""
|
||||
mock_check.side_effect = InsufficientCreditsError("Insufficient credits")
|
||||
|
||||
with self.assertRaises(InsufficientCreditsError):
|
||||
self.service.process(self.content.id)
|
||||
|
||||
def test_process_content_not_found(self):
|
||||
"""Test that ValueError is raised when content doesn't exist"""
|
||||
with self.assertRaises(ValueError):
|
||||
self.service.process(99999)
|
||||
|
||||
@patch('igny8_core.business.linking.services.linker_service.LinkerService.process')
|
||||
def test_batch_process_multiple_content(self, mock_process):
|
||||
"""Test batch processing multiple content items"""
|
||||
# Create additional content
|
||||
content2 = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Content 2",
|
||||
html_content="<p>Content 2</p>",
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
# Setup mock
|
||||
mock_process.side_effect = [self.content, content2]
|
||||
|
||||
# Execute
|
||||
results = self.service.batch_process([self.content.id, content2.id])
|
||||
|
||||
# Assertions
|
||||
self.assertEqual(len(results), 2)
|
||||
self.assertEqual(mock_process.call_count, 2)
|
||||
|
||||
@patch('igny8_core.business.linking.services.linker_service.LinkerService.process')
|
||||
def test_batch_process_handles_partial_failure(self, mock_process):
|
||||
"""Test batch processing handles partial failures"""
|
||||
# Setup mock to fail on second item
|
||||
mock_process.side_effect = [self.content, Exception("Processing failed")]
|
||||
|
||||
# Execute
|
||||
results = self.service.batch_process([self.content.id, 99999])
|
||||
|
||||
# Assertions - should continue processing other items
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0].id, self.content.id)
|
||||
|
||||
@patch('igny8_core.business.linking.services.linker_service.CreditService.check_credits')
|
||||
@patch('igny8_core.business.linking.services.linker_service.CandidateEngine.find_candidates')
|
||||
def test_process_no_candidates_found(self, mock_find, mock_check):
|
||||
"""Test processing when no candidates are found"""
|
||||
mock_check.return_value = True
|
||||
mock_find.return_value = []
|
||||
|
||||
# Execute
|
||||
result = self.service.process(self.content.id)
|
||||
|
||||
# Assertions - should return content unchanged
|
||||
self.assertEqual(result.id, self.content.id)
|
||||
self.assertEqual(result.linker_version, 0) # Not incremented
|
||||
|
||||
@@ -176,8 +176,7 @@ class OptimizerService:
|
||||
|
||||
def _optimize_content(self, content: Content, scores_before: dict) -> Content:
|
||||
"""
|
||||
Internal method to optimize content.
|
||||
This is a placeholder - in production, this would call the AI function.
|
||||
Internal method to optimize content using AI function.
|
||||
|
||||
Args:
|
||||
content: Content to optimize
|
||||
@@ -186,14 +185,30 @@ class OptimizerService:
|
||||
Returns:
|
||||
Optimized Content instance
|
||||
"""
|
||||
# For now, return content as-is
|
||||
# In production, this would:
|
||||
# 1. Call OptimizeContentFunction AI function
|
||||
# 2. Get optimized HTML
|
||||
# 3. Update content
|
||||
from igny8_core.ai.engine import AIEngine
|
||||
from igny8_core.ai.registry import get_function_instance
|
||||
|
||||
# Prepare payload for AI function
|
||||
payload = {
|
||||
'ids': [content.id],
|
||||
}
|
||||
|
||||
# Get function from registry
|
||||
fn = get_function_instance('optimize_content')
|
||||
if not fn:
|
||||
raise ValueError("OptimizeContentFunction not found in registry")
|
||||
|
||||
# Execute AI function
|
||||
ai_engine = AIEngine(account=content.account)
|
||||
result = ai_engine.execute(fn, payload)
|
||||
|
||||
if not result.get('success'):
|
||||
raise ValueError(f"Optimization failed: {result.get('error', 'Unknown error')}")
|
||||
|
||||
# The AI function's save_output method already updates the content
|
||||
# We just need to refresh from database to get the updated content
|
||||
content.refresh_from_db()
|
||||
|
||||
# Placeholder: We'll implement AI function call later
|
||||
# For now, just return the content
|
||||
return content
|
||||
|
||||
def analyze_only(self, content_id: int) -> dict:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Optimization tests
|
||||
|
||||
177
backend/igny8_core/business/optimization/tests/test_analyzer.py
Normal file
177
backend/igny8_core/business/optimization/tests/test_analyzer.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Tests for ContentAnalyzer
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.optimization.services.analyzer import ContentAnalyzer
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class ContentAnalyzerTests(IntegrationTestBase):
|
||||
"""Tests for ContentAnalyzer"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.analyzer = ContentAnalyzer()
|
||||
|
||||
def test_analyze_returns_all_scores(self):
|
||||
"""Test that analyze returns all required scores"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test Content",
|
||||
html_content="<p>This is test content.</p>",
|
||||
meta_title="Test Title",
|
||||
meta_description="Test description",
|
||||
primary_keyword="test keyword",
|
||||
word_count=1500,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertIn('seo_score', scores)
|
||||
self.assertIn('readability_score', scores)
|
||||
self.assertIn('engagement_score', scores)
|
||||
self.assertIn('overall_score', scores)
|
||||
self.assertIn('word_count', scores)
|
||||
self.assertIn('has_meta_title', scores)
|
||||
self.assertIn('has_meta_description', scores)
|
||||
self.assertIn('has_primary_keyword', scores)
|
||||
self.assertIn('internal_links_count', scores)
|
||||
|
||||
def test_analyze_returns_zero_scores_for_empty_content(self):
|
||||
"""Test that empty content returns zero scores"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Empty",
|
||||
html_content="",
|
||||
word_count=0,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertEqual(scores['seo_score'], 0)
|
||||
self.assertEqual(scores['readability_score'], 0)
|
||||
self.assertEqual(scores['engagement_score'], 0)
|
||||
self.assertEqual(scores['overall_score'], 0)
|
||||
|
||||
def test_calculate_seo_score_with_meta_title(self):
|
||||
"""Test SEO score calculation with meta title"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
meta_title="Test Title" * 5, # 50 chars - optimal length
|
||||
word_count=1500,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertGreater(scores['seo_score'], 0)
|
||||
|
||||
def test_calculate_seo_score_with_primary_keyword(self):
|
||||
"""Test SEO score calculation with primary keyword"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
primary_keyword="test keyword",
|
||||
word_count=1500,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertGreater(scores['seo_score'], 0)
|
||||
|
||||
def test_calculate_readability_score(self):
|
||||
"""Test readability score calculation"""
|
||||
# Create content with good readability (short sentences, paragraphs)
|
||||
html = "<p>This is a sentence.</p><p>This is another sentence.</p><p>And one more.</p>"
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
html_content=html,
|
||||
word_count=20,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertGreater(scores['readability_score'], 0)
|
||||
|
||||
def test_calculate_engagement_score_with_headings(self):
|
||||
"""Test engagement score calculation with headings"""
|
||||
html = "<h1>Main Heading</h1><h2>Subheading 1</h2><h2>Subheading 2</h2>"
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
html_content=html,
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertGreater(scores['engagement_score'], 0)
|
||||
|
||||
def test_calculate_engagement_score_with_internal_links(self):
|
||||
"""Test engagement score calculation with internal links"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
html_content="<p>Test content.</p>",
|
||||
internal_links=[
|
||||
{'content_id': 1, 'anchor_text': 'link1'},
|
||||
{'content_id': 2, 'anchor_text': 'link2'},
|
||||
{'content_id': 3, 'anchor_text': 'link3'}
|
||||
],
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertGreater(scores['engagement_score'], 0)
|
||||
self.assertEqual(scores['internal_links_count'], 3)
|
||||
|
||||
def test_overall_score_is_weighted_average(self):
|
||||
"""Test that overall score is weighted average"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
html_content="<p>Test content.</p>",
|
||||
meta_title="Test Title",
|
||||
meta_description="Test description",
|
||||
primary_keyword="test",
|
||||
word_count=1500,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
# Overall should be weighted: SEO (40%) + Readability (30%) + Engagement (30%)
|
||||
expected = (
|
||||
scores['seo_score'] * 0.4 +
|
||||
scores['readability_score'] * 0.3 +
|
||||
scores['engagement_score'] * 0.3
|
||||
)
|
||||
|
||||
self.assertAlmostEqual(scores['overall_score'], expected, places=1)
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Tests for OptimizerService
|
||||
"""
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.optimization.models import OptimizationTask
|
||||
from igny8_core.business.optimization.services.optimizer_service import OptimizerService
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class OptimizerServiceTests(IntegrationTestBase):
|
||||
"""Tests for OptimizerService"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.service = OptimizerService()
|
||||
|
||||
# Create test content
|
||||
self.content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test Content",
|
||||
html_content="<p>This is test content.</p>",
|
||||
meta_title="Test Title",
|
||||
meta_description="Test description",
|
||||
primary_keyword="test keyword",
|
||||
word_count=500,
|
||||
status='draft',
|
||||
source='igny8'
|
||||
)
|
||||
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.check_credits')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.ContentAnalyzer.analyze')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.OptimizerService._optimize_content')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.deduct_credits_for_operation')
|
||||
def test_optimize_from_writer(self, mock_deduct, mock_optimize, mock_analyze, mock_check):
|
||||
"""Test optimize_from_writer entry point"""
|
||||
mock_check.return_value = True
|
||||
mock_analyze.return_value = {
|
||||
'seo_score': 50.0,
|
||||
'readability_score': 60.0,
|
||||
'engagement_score': 55.0,
|
||||
'overall_score': 55.0
|
||||
}
|
||||
|
||||
optimized_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Optimized Content",
|
||||
html_content="<p>Optimized content.</p>",
|
||||
word_count=500,
|
||||
status='draft',
|
||||
source='igny8'
|
||||
)
|
||||
mock_optimize.return_value = optimized_content
|
||||
|
||||
result = self.service.optimize_from_writer(self.content.id)
|
||||
|
||||
self.assertEqual(result.id, self.content.id)
|
||||
mock_check.assert_called_once()
|
||||
mock_deduct.assert_called_once()
|
||||
|
||||
def test_optimize_from_writer_invalid_content(self):
|
||||
"""Test that ValueError is raised for invalid content"""
|
||||
with self.assertRaises(ValueError):
|
||||
self.service.optimize_from_writer(99999)
|
||||
|
||||
def test_optimize_from_writer_wrong_source(self):
|
||||
"""Test that ValueError is raised for wrong source"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="WordPress Content",
|
||||
word_count=100,
|
||||
source='wordpress'
|
||||
)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.service.optimize_from_writer(content.id)
|
||||
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.check_credits')
|
||||
def test_optimize_insufficient_credits(self, mock_check):
|
||||
"""Test that InsufficientCreditsError is raised when credits are insufficient"""
|
||||
mock_check.side_effect = InsufficientCreditsError("Insufficient credits")
|
||||
|
||||
with self.assertRaises(InsufficientCreditsError):
|
||||
self.service.optimize(self.content)
|
||||
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.check_credits')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.ContentAnalyzer.analyze')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.OptimizerService._optimize_content')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.deduct_credits_for_operation')
|
||||
def test_optimize_creates_optimization_task(self, mock_deduct, mock_optimize, mock_analyze, mock_check):
|
||||
"""Test that optimization creates OptimizationTask"""
|
||||
mock_check.return_value = True
|
||||
scores = {
|
||||
'seo_score': 50.0,
|
||||
'readability_score': 60.0,
|
||||
'engagement_score': 55.0,
|
||||
'overall_score': 55.0
|
||||
}
|
||||
mock_analyze.return_value = scores
|
||||
|
||||
optimized_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Optimized",
|
||||
html_content="<p>Optimized.</p>",
|
||||
word_count=500,
|
||||
status='draft'
|
||||
)
|
||||
mock_optimize.return_value = optimized_content
|
||||
|
||||
result = self.service.optimize(self.content)
|
||||
|
||||
# Check that task was created
|
||||
task = OptimizationTask.objects.filter(content=self.content).first()
|
||||
self.assertIsNotNone(task)
|
||||
self.assertEqual(task.status, 'completed')
|
||||
self.assertEqual(task.scores_before, scores)
|
||||
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.check_credits')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.ContentAnalyzer.analyze')
|
||||
def test_analyze_only_returns_scores(self, mock_analyze, mock_check):
|
||||
"""Test analyze_only method returns scores without optimizing"""
|
||||
scores = {
|
||||
'seo_score': 50.0,
|
||||
'readability_score': 60.0,
|
||||
'engagement_score': 55.0,
|
||||
'overall_score': 55.0
|
||||
}
|
||||
mock_analyze.return_value = scores
|
||||
|
||||
result = self.service.analyze_only(self.content.id)
|
||||
|
||||
self.assertEqual(result, scores)
|
||||
mock_analyze.assert_called_once()
|
||||
|
||||
def test_optimize_from_wordpress_sync(self):
|
||||
"""Test optimize_from_wordpress_sync entry point"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="WordPress Content",
|
||||
word_count=100,
|
||||
source='wordpress'
|
||||
)
|
||||
|
||||
with patch.object(self.service, 'optimize') as mock_optimize:
|
||||
mock_optimize.return_value = content
|
||||
result = self.service.optimize_from_wordpress_sync(content.id)
|
||||
|
||||
self.assertEqual(result.id, content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
def test_optimize_from_external_sync(self):
|
||||
"""Test optimize_from_external_sync entry point"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Shopify Content",
|
||||
word_count=100,
|
||||
source='shopify'
|
||||
)
|
||||
|
||||
with patch.object(self.service, 'optimize') as mock_optimize:
|
||||
mock_optimize.return_value = content
|
||||
result = self.service.optimize_from_external_sync(content.id)
|
||||
|
||||
self.assertEqual(result.id, content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
def test_optimize_manual(self):
|
||||
"""Test optimize_manual entry point"""
|
||||
with patch.object(self.service, 'optimize') as mock_optimize:
|
||||
mock_optimize.return_value = self.content
|
||||
result = self.service.optimize_manual(self.content.id)
|
||||
|
||||
self.assertEqual(result.id, self.content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
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)
|
||||
|
||||
@@ -54,6 +54,8 @@ INSTALLED_APPS = [
|
||||
'igny8_core.modules.automation.apps.AutomationConfig',
|
||||
'igny8_core.business.site_building.apps.SiteBuildingConfig',
|
||||
'igny8_core.modules.site_builder.apps.SiteBuilderConfig',
|
||||
'igny8_core.modules.linker.apps.LinkerConfig',
|
||||
'igny8_core.modules.optimizer.apps.OptimizerConfig',
|
||||
]
|
||||
|
||||
# System module needs explicit registration for admin
|
||||
@@ -246,6 +248,8 @@ REST_FRAMEWORK = {
|
||||
# Billing Operations
|
||||
'billing': '30/min', # Credit queries, usage logs
|
||||
'billing_admin': '10/min', # Credit management (admin)
|
||||
'linker': '30/min', # Content linking operations
|
||||
'optimizer': '10/min', # AI-powered optimization
|
||||
# Default fallback
|
||||
'default': '100/min', # Default for endpoints without scope
|
||||
},
|
||||
|
||||
@@ -31,6 +31,8 @@ urlpatterns = [
|
||||
path('api/v1/system/', include('igny8_core.modules.system.urls')),
|
||||
path('api/v1/billing/', include('igny8_core.modules.billing.urls')), # Billing endpoints
|
||||
path('api/v1/automation/', include('igny8_core.modules.automation.urls')), # Automation endpoints
|
||||
path('api/v1/linker/', include('igny8_core.modules.linker.urls')), # Linker endpoints
|
||||
path('api/v1/optimizer/', include('igny8_core.modules.optimizer.urls')), # Optimizer endpoints
|
||||
# OpenAPI Schema and Documentation
|
||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
||||
|
||||
Reference in New Issue
Block a user