From a492eb356071fd4e6c319a1cf30c01d64fc2c4f9 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 10:24:46 +0000 Subject: [PATCH] Enhance ImagesViewSet and Images component with site and sector filtering - Added site_id and sector_id query parameter support in ImagesViewSet for filtering content and task-linked images. - Implemented event listeners in the Images component to refresh data on site and sector changes. - Updated image prompt handling to allow undefined values. --- .../igny8_core/api/tests/test_ai_framework.py | 232 ++++++++++++++++++ backend/igny8_core/modules/writer/views.py | 31 +++ frontend/src/pages/Writer/Images.tsx | 20 +- 3 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 backend/igny8_core/api/tests/test_ai_framework.py diff --git a/backend/igny8_core/api/tests/test_ai_framework.py b/backend/igny8_core/api/tests/test_ai_framework.py new file mode 100644 index 00000000..956f22be --- /dev/null +++ b/backend/igny8_core/api/tests/test_ai_framework.py @@ -0,0 +1,232 @@ +""" +Unit tests for AI framework +Tests get_model_config() and AICore.run_ai_request() functions +""" +from django.test import TestCase +from igny8_core.auth.models import Account, User, Plan +from igny8_core.modules.system.models import IntegrationSettings +from igny8_core.ai.settings import get_model_config +from igny8_core.ai.ai_core import AICore + + +class GetModelConfigTestCase(TestCase): + """Test cases for get_model_config() function""" + + def setUp(self): + """Set up test data""" + # Create plan first + self.plan = Plan.objects.create( + name="Test Plan", + slug="test-plan", + price=0, + credits_per_month=1000 + ) + + # Create user first (Account needs owner) + self.user = User.objects.create_user( + username='testuser', + email='test@test.com', + password='testpass123', + role='owner' + ) + + # Create account with owner + self.account = Account.objects.create( + name='Test Account', + slug='test-account', + plan=self.plan, + owner=self.user, + status='active' + ) + + # Update user to have account + self.user.account = self.account + self.user.save() + + def test_get_model_config_with_valid_settings(self): + """Test get_model_config() with valid IntegrationSettings""" + IntegrationSettings.objects.create( + integration_type='openai', + account=self.account, + is_active=True, + config={ + 'model': 'gpt-4o', + 'max_tokens': 4000, + 'temperature': 0.7, + 'apiKey': 'test-key' + } + ) + + config = get_model_config('auto_cluster', self.account) + + self.assertEqual(config['model'], 'gpt-4o') + self.assertEqual(config['max_tokens'], 4000) + self.assertEqual(config['temperature'], 0.7) + self.assertIn('response_format', config) + + def test_get_model_config_without_account(self): + """Test get_model_config() without account - should raise ValueError""" + with self.assertRaises(ValueError) as context: + get_model_config('auto_cluster', None) + + self.assertIn('Account is required', str(context.exception)) + + def test_get_model_config_without_integration_settings(self): + """Test get_model_config() without IntegrationSettings - should raise ValueError""" + with self.assertRaises(ValueError) as context: + get_model_config('auto_cluster', self.account) + + self.assertIn('OpenAI IntegrationSettings not configured', str(context.exception)) + self.assertIn(str(self.account.id), str(context.exception)) + + def test_get_model_config_without_model_in_config(self): + """Test get_model_config() without model in config - should raise ValueError""" + IntegrationSettings.objects.create( + integration_type='openai', + account=self.account, + is_active=True, + config={ + 'max_tokens': 4000, + 'temperature': 0.7, + 'apiKey': 'test-key' + # No 'model' key + } + ) + + with self.assertRaises(ValueError) as context: + get_model_config('auto_cluster', self.account) + + self.assertIn('Model not configured in IntegrationSettings', str(context.exception)) + self.assertIn(str(self.account.id), str(context.exception)) + + def test_get_model_config_with_inactive_settings(self): + """Test get_model_config() with inactive IntegrationSettings - should raise ValueError""" + IntegrationSettings.objects.create( + integration_type='openai', + account=self.account, + is_active=False, + config={ + 'model': 'gpt-4o', + 'max_tokens': 4000, + 'temperature': 0.7 + } + ) + + with self.assertRaises(ValueError) as context: + get_model_config('auto_cluster', self.account) + + self.assertIn('OpenAI IntegrationSettings not configured', str(context.exception)) + + def test_get_model_config_with_function_alias(self): + """Test get_model_config() with function alias""" + IntegrationSettings.objects.create( + integration_type='openai', + account=self.account, + is_active=True, + config={ + 'model': 'gpt-4o-mini', + 'max_tokens': 2000, + 'temperature': 0.5 + } + ) + + # Test with alias + config1 = get_model_config('cluster_keywords', self.account) + config2 = get_model_config('auto_cluster', self.account) + + # Both should return the same config + self.assertEqual(config1['model'], config2['model']) + self.assertEqual(config1['model'], 'gpt-4o-mini') + + def test_get_model_config_json_mode_models(self): + """Test get_model_config() sets response_format for JSON mode models""" + json_models = ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo-preview'] + + for model in json_models: + IntegrationSettings.objects.filter(account=self.account).delete() + IntegrationSettings.objects.create( + integration_type='openai', + account=self.account, + is_active=True, + config={ + 'model': model, + 'max_tokens': 4000, + 'temperature': 0.7 + } + ) + + config = get_model_config('auto_cluster', self.account) + self.assertIn('response_format', config) + self.assertEqual(config['response_format'], {'type': 'json_object'}) + + +class AICoreTestCase(TestCase): + """Test cases for AICore.run_ai_request() function""" + + def setUp(self): + """Set up test data""" + # Create plan first + self.plan = Plan.objects.create( + name="Test Plan", + slug="test-plan", + price=0, + credits_per_month=1000 + ) + + # Create user first (Account needs owner) + self.user = User.objects.create_user( + username='testuser', + email='test@test.com', + password='testpass123', + role='owner' + ) + + # Create account with owner + self.account = Account.objects.create( + name='Test Account', + slug='test-account', + plan=self.plan, + owner=self.user, + status='active' + ) + + # Update user to have account + self.user.account = self.account + self.user.save() + + self.ai_core = AICore(account=self.account) + + def test_run_ai_request_without_model(self): + """Test run_ai_request() without model - should return error dict""" + result = self.ai_core.run_ai_request( + prompt="Test prompt", + model=None, + function_name='test_function' + ) + + self.assertIn('error', result) + self.assertIn('Model is required', result['error']) + self.assertEqual(result['content'], None) + self.assertEqual(result['total_tokens'], 0) + + def test_run_ai_request_with_empty_model(self): + """Test run_ai_request() with empty model string - should return error dict""" + result = self.ai_core.run_ai_request( + prompt="Test prompt", + model="", + function_name='test_function' + ) + + self.assertIn('error', result) + self.assertIn('Model is required', result['error']) + self.assertEqual(result['content'], None) + self.assertEqual(result['total_tokens'], 0) + + def test_get_model_deprecated(self): + """Test get_model() method is deprecated and raises ValueError""" + with self.assertRaises(ValueError) as context: + self.ai_core.get_model('openai') + + self.assertIn('deprecated', str(context.exception).lower()) + self.assertIn('run_ai_request', str(context.exception)) + diff --git a/backend/igny8_core/modules/writer/views.py b/backend/igny8_core/modules/writer/views.py index 96cde1d8..82fe0713 100644 --- a/backend/igny8_core/modules/writer/views.py +++ b/backend/igny8_core/modules/writer/views.py @@ -663,17 +663,47 @@ class ImagesViewSet(SiteSectorModelViewSet): account = getattr(request, 'account', None) + # Get site_id and sector_id from query parameters + site_id = request.query_params.get('site_id') + sector_id = request.query_params.get('sector_id') + # Get all content that has images (either directly or via task) # First, get content with direct image links queryset = Content.objects.filter(images__isnull=False) if account: queryset = queryset.filter(account=account) + # Apply site/sector filtering if provided + if site_id: + try: + queryset = queryset.filter(site_id=int(site_id)) + except (ValueError, TypeError): + pass + + if sector_id: + try: + queryset = queryset.filter(sector_id=int(sector_id)) + except (ValueError, TypeError): + pass + # Also get content from images linked via task task_linked_images = Images.objects.filter(task__isnull=False, content__isnull=True) if account: task_linked_images = task_linked_images.filter(account=account) + # Apply site/sector filtering to task-linked images + if site_id: + try: + task_linked_images = task_linked_images.filter(site_id=int(site_id)) + except (ValueError, TypeError): + pass + + if sector_id: + try: + task_linked_images = task_linked_images.filter(sector_id=int(sector_id)) + except (ValueError, TypeError): + pass + # Get content IDs from task-linked images task_content_ids = set() for image in task_linked_images: @@ -694,6 +724,7 @@ class ImagesViewSet(SiteSectorModelViewSet): for content_id in content_ids: try: content = Content.objects.get(id=content_id) + # Get images linked directly to content OR via task content_images = Images.objects.filter( Q(content=content) | Q(task=content.task) diff --git a/frontend/src/pages/Writer/Images.tsx b/frontend/src/pages/Writer/Images.tsx index ce5dc000..963e9684 100644 --- a/frontend/src/pages/Writer/Images.tsx +++ b/frontend/src/pages/Writer/Images.tsx @@ -153,6 +153,24 @@ export default function Images() { loadImages(); }, [loadImages]); + // Listen for site and sector changes and refresh data + useEffect(() => { + const handleSiteChange = () => { + loadImages(); + }; + + const handleSectorChange = () => { + loadImages(); + }; + + window.addEventListener('siteChanged', handleSiteChange); + window.addEventListener('sectorChanged', handleSectorChange); + return () => { + window.removeEventListener('siteChanged', handleSiteChange); + window.removeEventListener('sectorChanged', handleSectorChange); + }; + }, [loadImages]); + // Debounced search useEffect(() => { const timer = setTimeout(() => { @@ -258,7 +276,7 @@ export default function Images() { type: 'in_article', position: img.position || idx + 1, contentTitle: contentImages.content_title || `Content #${contentId}`, - prompt: img.prompt, + prompt: img.prompt || undefined, status: 'pending', progress: 0, imageUrl: null,