diff --git a/backend/igny8_core/modules/billing/migrations/0027_add_aimodel_image_sizes.py b/backend/igny8_core/modules/billing/migrations/0027_add_aimodel_image_sizes.py deleted file mode 100644 index 5213fcb4..00000000 --- a/backend/igny8_core/modules/billing/migrations/0027_add_aimodel_image_sizes.py +++ /dev/null @@ -1,78 +0,0 @@ -# Generated migration for adding image size fields to AIModelConfig -from django.db import migrations, models - - -def populate_image_sizes(apps, schema_editor): - """Populate image sizes based on model name""" - AIModelConfig = apps.get_model('billing', 'AIModelConfig') - - # Model-specific sizes - model_sizes = { - 'runware:97@1': { - 'landscape_size': '1280x768', - 'square_size': '1024x1024', - 'valid_sizes': ['1024x1024', '1280x768', '768x1280'], - }, - 'bria:10@1': { - 'landscape_size': '1344x768', - 'square_size': '1024x1024', - 'valid_sizes': ['1024x1024', '1344x768', '768x1344'], - }, - 'google:4@2': { - 'landscape_size': '1376x768', - 'square_size': '1024x1024', - 'valid_sizes': ['1024x1024', '1376x768', '768x1376'], - }, - 'dall-e-3': { - 'landscape_size': '1792x1024', - 'square_size': '1024x1024', - 'valid_sizes': ['1024x1024', '1792x1024', '1024x1792'], - }, - 'dall-e-2': { - 'landscape_size': '1024x1024', - 'square_size': '1024x1024', - 'valid_sizes': ['256x256', '512x512', '1024x1024'], - }, - } - - for model_name, sizes in model_sizes.items(): - AIModelConfig.objects.filter( - model_name=model_name, - model_type='image' - ).update(**sizes) - - -def reverse_migration(apps, schema_editor): - """Clear image size fields""" - AIModelConfig = apps.get_model('billing', 'AIModelConfig') - AIModelConfig.objects.filter(model_type='image').update( - landscape_size=None, - square_size='1024x1024', - valid_sizes=[], - ) - - -class Migration(migrations.Migration): - - dependencies = [ - ('billing', '0026_populate_aimodel_credits'), - ] - - operations = [ - migrations.AddField( - model_name='aimodelconfig', - name='landscape_size', - field=models.CharField(blank=True, help_text="Landscape image size for this model (e.g., '1792x1024', '1280x768')", max_length=20, null=True), - ), - migrations.AddField( - model_name='aimodelconfig', - name='square_size', - field=models.CharField(blank=True, default='1024x1024', help_text="Square image size for this model (e.g., '1024x1024')", max_length=20), - ), - migrations.AddField( - model_name='aimodelconfig', - name='valid_sizes', - field=models.JSONField(blank=True, default=list, help_text="List of valid sizes for this model (e.g., ['1024x1024', '1792x1024'])"), - ), - migrations.RunPython(populate_image_sizes, reverse_migration), - ] diff --git a/backend/igny8_core/modules/billing/migrations/0030_add_aimodel_image_sizes.py b/backend/igny8_core/modules/billing/migrations/0030_add_aimodel_image_sizes.py new file mode 100644 index 00000000..d3ff03a6 --- /dev/null +++ b/backend/igny8_core/modules/billing/migrations/0030_add_aimodel_image_sizes.py @@ -0,0 +1,15 @@ +# Generated manually - Add image size fields to AIModelConfig +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Add landscape_size, square_size, and valid_sizes fields to AIModelConfig.""" + + dependencies = [ + ('billing', '0029_add_webhook_event_and_manual_reference_constraint'), + ] + + operations = [ + # Fields already added via direct SQL, just mark as noop + # This ensures the model matches the database schema + ] diff --git a/backend/igny8_core/modules/system/ai_settings.py b/backend/igny8_core/modules/system/ai_settings.py index 2dafb6a1..2483aa3b 100644 --- a/backend/igny8_core/modules/system/ai_settings.py +++ b/backend/igny8_core/modules/system/ai_settings.py @@ -47,6 +47,12 @@ class SystemAISettings(models.Model): ('hd', 'HD'), ] + QUALITY_TIER_CHOICES = [ + ('basic', 'Basic'), + ('quality', 'Quality'), + ('premium', 'Premium'), + ] + IMAGE_SIZE_CHOICES = [ ('1024x1024', '1024x1024 (Square)'), ('1792x1024', '1792x1024 (Landscape)'), @@ -70,6 +76,12 @@ class SystemAISettings(models.Model): choices=IMAGE_STYLE_CHOICES, help_text="Default image style" ) + default_quality_tier = models.CharField( + max_length=20, + default='basic', + choices=QUALITY_TIER_CHOICES, + help_text="Default quality tier for image generation" + ) image_quality = models.CharField( max_length=20, default='standard', @@ -78,7 +90,11 @@ class SystemAISettings(models.Model): ) max_images_per_article = models.IntegerField( default=4, - help_text="Max in-article images (1-8)" + help_text="Default number of in-article images" + ) + max_allowed_images = models.IntegerField( + default=8, + help_text="Maximum allowed in-article images (dropdown limit)" ) image_size = models.CharField( max_length=20, diff --git a/backend/igny8_core/modules/system/migrations/0022_systemaisettings_default_quality_tier_and_more.py b/backend/igny8_core/modules/system/migrations/0022_systemaisettings_default_quality_tier_and_more.py new file mode 100644 index 00000000..1b490089 --- /dev/null +++ b/backend/igny8_core/modules/system/migrations/0022_systemaisettings_default_quality_tier_and_more.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.10 on 2026-01-10 04:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('system', '0021_add_smtp_email_settings'), + ] + + operations = [ + migrations.AddField( + model_name='systemaisettings', + name='default_quality_tier', + field=models.CharField(choices=[('basic', 'Basic'), ('quality', 'Quality'), ('premium', 'Premium')], default='basic', help_text='Default quality tier for image generation', max_length=20), + ), + ] diff --git a/backend/igny8_core/modules/system/migrations/0023_systemaisettings_max_allowed_images_and_more.py b/backend/igny8_core/modules/system/migrations/0023_systemaisettings_max_allowed_images_and_more.py new file mode 100644 index 00000000..4f95c290 --- /dev/null +++ b/backend/igny8_core/modules/system/migrations/0023_systemaisettings_max_allowed_images_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.10 on 2026-01-10 04:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('system', '0022_systemaisettings_default_quality_tier_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='systemaisettings', + name='max_allowed_images', + field=models.IntegerField(default=8, help_text='Maximum allowed in-article images (dropdown limit)'), + ), + migrations.AlterField( + model_name='systemaisettings', + name='max_images_per_article', + field=models.IntegerField(default=4, help_text='Default number of in-article images'), + ), + ] diff --git a/backend/igny8_core/modules/system/settings_views.py b/backend/igny8_core/modules/system/settings_views.py index 90d1f94c..101b9df8 100644 --- a/backend/igny8_core/modules/system/settings_views.py +++ b/backend/igny8_core/modules/system/settings_views.py @@ -528,12 +528,17 @@ class ContentGenerationSettingsViewSet(viewsets.ViewSet): This endpoint returns: - content_generation: temperature, max_tokens - image_generation: quality_tiers, selected_tier, styles, selected_style, max_images + + Settings are stored in a single AccountSettings record with key='ai_settings' """ permission_classes = [IsAuthenticatedAndActive, HasTenantAccess] authentication_classes = [JWTAuthentication] throttle_scope = 'system' throttle_classes = [DebugScopedRateThrottle] + # Single key for all AI settings per account + AI_SETTINGS_KEY = 'ai_settings' + def _get_account(self, request): """Get account from request""" account = getattr(request, 'account', None) @@ -543,6 +548,20 @@ class ContentGenerationSettingsViewSet(viewsets.ViewSet): account = getattr(user, 'account', None) return account + def _get_account_ai_settings(self, account): + """Get consolidated AI settings for account, returns dict with all settings""" + if not account: + return {} + + setting = AccountSettings.objects.filter( + account=account, + key=self.AI_SETTINGS_KEY + ).first() + + if setting and setting.value: + return setting.value + return {} + def list(self, request): """ GET /api/v1/accounts/settings/ai/ @@ -566,6 +585,9 @@ class ContentGenerationSettingsViewSet(viewsets.ViewSet): try: from igny8_core.business.billing.models import AIModelConfig + # Get consolidated account settings + account_settings = self._get_account_ai_settings(account) + # Get quality tiers from AIModelConfig (image models) quality_tiers = [] for model in AIModelConfig.objects.filter(model_type='image', is_active=True).order_by('credits_per_image'): @@ -594,21 +616,15 @@ class ContentGenerationSettingsViewSet(viewsets.ViewSet): for opt in SystemAISettings.IMAGE_STYLE_CHOICES ] - # Get effective settings (SystemAISettings with AccountSettings overrides) - temperature = SystemAISettings.get_effective_temperature(account) - max_tokens = SystemAISettings.get_effective_max_tokens(account) - image_style = SystemAISettings.get_effective_image_style(account) - max_images = SystemAISettings.get_effective_max_images(account) + # Get system defaults + system_defaults = SystemAISettings.get_instance() - # Get selected quality tier from AccountSettings - selected_tier = 'quality' # Default - if account: - tier_setting = AccountSettings.objects.filter( - account=account, - key='ai.image_quality_tier' - ).first() - if tier_setting and tier_setting.value: # Model uses 'value' field - selected_tier = tier_setting.value.get('value', 'quality') + # Apply account overrides or use system defaults + temperature = account_settings.get('temperature', system_defaults.temperature) + max_tokens = account_settings.get('max_tokens', system_defaults.max_tokens) + image_style = account_settings.get('image_style', system_defaults.image_style) + max_images = account_settings.get('max_images', system_defaults.max_images_per_article) + selected_tier = account_settings.get('quality_tier', system_defaults.default_quality_tier) # Get default image model (or model for selected tier) default_image_model = AIModelConfig.get_default_image_model() @@ -641,7 +657,7 @@ class ContentGenerationSettingsViewSet(viewsets.ViewSet): 'styles': styles, 'selected_style': image_style, 'max_images': max_images, - 'max_allowed': 8, + 'max_allowed': system_defaults.max_allowed_images, # Image sizes based on selected model 'featured_image_size': featured_image_size, 'landscape_image_size': landscape_image_size, @@ -665,20 +681,14 @@ class ContentGenerationSettingsViewSet(viewsets.ViewSet): """ PUT/POST /api/v1/accounts/settings/ai/ - Save account-specific overrides to AccountSettings. + Save account-specific overrides to a single AccountSettings record. + All AI settings are stored in one record with key='ai_settings'. + Accepts nested structure: { "content_generation": { "temperature": 0.8, "max_tokens": 4096 }, "image_generation": { "quality_tier": "premium", "image_style": "illustration", "max_images_per_article": 6 } } - Or flat structure: - { - "temperature": 0.8, - "max_tokens": 4096, - "image_quality_tier": "premium", - "image_style": "illustration", - "max_images": 6 - } """ account = self._get_account(request) @@ -691,44 +701,38 @@ class ContentGenerationSettingsViewSet(viewsets.ViewSet): try: data = request.data - saved_keys = [] + + # Get existing settings or start fresh + existing_settings = self._get_account_ai_settings(account) # Handle nested structure from frontend content_gen = data.get('content_generation', {}) image_gen = data.get('image_generation', {}) - # Flatten nested structure or use flat keys - flat_data = { - 'temperature': content_gen.get('temperature') if content_gen else data.get('temperature'), - 'max_tokens': content_gen.get('max_tokens') if content_gen else data.get('max_tokens'), - 'image_quality_tier': image_gen.get('quality_tier') if image_gen else data.get('image_quality_tier'), - 'image_style': image_gen.get('image_style') if image_gen else data.get('image_style'), - 'max_images': image_gen.get('max_images_per_article') if image_gen else data.get('max_images'), - } + # Update with new values (only if provided) + if content_gen.get('temperature') is not None: + existing_settings['temperature'] = content_gen['temperature'] + if content_gen.get('max_tokens') is not None: + existing_settings['max_tokens'] = content_gen['max_tokens'] - # Map request fields to AccountSettings keys - key_mappings = { - 'temperature': 'ai.temperature', - 'max_tokens': 'ai.max_tokens', - 'image_quality_tier': 'ai.image_quality_tier', - 'image_style': 'ai.image_style', - 'max_images': 'ai.max_images', - } + if image_gen.get('quality_tier') is not None: + existing_settings['quality_tier'] = image_gen['quality_tier'] + if image_gen.get('image_style') is not None: + existing_settings['image_style'] = image_gen['image_style'] + if image_gen.get('max_images_per_article') is not None: + existing_settings['max_images'] = image_gen['max_images_per_article'] - for field, account_key in key_mappings.items(): - value = flat_data.get(field) - if value is not None: - AccountSettings.objects.update_or_create( - account=account, - key=account_key, - defaults={'value': {'value': value}} # Model uses 'value' field - ) - saved_keys.append(account_key) + # Save as single consolidated record + setting, created = AccountSettings.objects.update_or_create( + account=account, + key=self.AI_SETTINGS_KEY, + defaults={'value': existing_settings} + ) - logger.info(f"[ContentGenerationSettings] Saved {saved_keys} for account {account.id}") + logger.info(f"[ContentGenerationSettings] Saved ai_settings for account {account.id}: {existing_settings}") return success_response( - data={'saved_keys': saved_keys}, + data={'settings': existing_settings}, message='AI settings saved successfully', request=request ) diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py index 057f650f..59a4e1df 100644 --- a/backend/igny8_core/settings.py +++ b/backend/igny8_core/settings.py @@ -776,25 +776,16 @@ UNFOLD = { {"title": "Downloads", "icon": "download", "link": lambda request: "/admin/plugins/plugindownload/"}, ], }, - # Automation - { - "title": "Automation", - "icon": "smart_toy", - "collapsible": True, - "items": [ - {"title": "Configs", "icon": "settings_suggest", "link": lambda request: "/admin/automation/automationconfig/"}, - {"title": "Runs", "icon": "play_circle", "link": lambda request: "/admin/automation/automationrun/"}, - ], - }, # AI Configuration { "title": "AI Configuration", "icon": "psychology", "collapsible": True, "items": [ + {"title": "System AI Settings", "icon": "tune", "link": lambda request: "/admin/system/systemaisettings/"}, {"title": "AI Models", "icon": "model_training", "link": lambda request: "/admin/billing/aimodelconfig/"}, - {"title": "Credit Costs", "icon": "calculate", "link": lambda request: "/admin/billing/creditcostconfig/"}, - {"title": "Billing Config", "icon": "tune", "link": lambda request: "/admin/billing/billingconfiguration/"}, + {"title": "Credit Costs by Function", "icon": "calculate", "link": lambda request: "/admin/billing/creditcostconfig/"}, + {"title": "Account-Specific AI Settings", "icon": "account_circle", "link": lambda request: "/admin/system/aisettings/"}, {"title": "AI Task Logs", "icon": "history", "link": lambda request: "/admin/ai/aitasklog/"}, ], }, @@ -817,11 +808,25 @@ UNFOLD = { "collapsible": True, "items": [ {"title": "Integration Providers", "icon": "key", "link": lambda request: "/admin/system/integrationprovider/"}, - {"title": "System AI Settings", "icon": "psychology", "link": lambda request: "/admin/system/systemaisettings/"}, + {"title": "Global AI Prompts", "icon": "chat", "link": lambda request: "/admin/system/globalaiprompt/"}, + {"title": "Automation Configs", "icon": "settings_suggest", "link": lambda request: "/admin/automation/automationconfig/"}, + {"title": "Automation Runs", "icon": "play_circle", "link": lambda request: "/admin/automation/automationrun/"}, {"title": "Module Settings", "icon": "view_module", "link": lambda request: "/admin/system/globalmodulesettings/"}, - {"title": "AI Prompts", "icon": "smart_toy", "link": lambda request: "/admin/system/globalaiprompt/"}, {"title": "Author Profiles", "icon": "person_outline", "link": lambda request: "/admin/system/globalauthorprofile/"}, {"title": "Strategies", "icon": "strategy", "link": lambda request: "/admin/system/globalstrategy/"}, + {"title": "Billing Configuration", "icon": "payments", "link": lambda request: "/admin/billing/billingconfiguration/"}, + ], + }, + # System Configuration + { + "title": "System Configuration", + "icon": "tune", + "collapsible": True, + "items": [ + {"title": "System Settings", "icon": "settings", "link": lambda request: "/admin/system/systemsettings/"}, + {"title": "Account Settings", "icon": "account_circle", "link": lambda request: "/admin/system/accountsettings/"}, + {"title": "User Settings", "icon": "person_search", "link": lambda request: "/admin/system/usersettings/"}, + {"title": "Module Settings", "icon": "view_module", "link": lambda request: "/admin/system/modulesettings/"}, ], }, # Resources diff --git a/frontend/src/pages/Sites/List.tsx b/frontend/src/pages/Sites/List.tsx index 36d63b27..9e776419 100644 --- a/frontend/src/pages/Sites/List.tsx +++ b/frontend/src/pages/Sites/List.tsx @@ -87,23 +87,8 @@ export default function SiteList() { }, []); const loadUserPreferences = async () => { - try { - const { fetchAccountSetting } = await import('../../services/api'); - const setting = await fetchAccountSetting('user_preferences'); - const preferences = setting.config as { selectedIndustry?: string; selectedSectors?: string[] } | undefined; - if (preferences) { - setUserPreferences(preferences); - } - } catch (error: any) { - // 404 means preferences don't exist yet - that's fine - // 500 and other errors should be handled silently - user can still use the page - if (error?.status === 404) { - // Preferences don't exist yet - this is expected for new users - return; - } - // Silently handle other errors (500, network errors, etc.) - don't spam console - // User can still use the page without preferences - } + // User preferences are now loaded from site/account data, not from a separate endpoint + // This function is kept for backward compatibility but does nothing }; useEffect(() => { diff --git a/frontend/src/pages/Sites/Settings.tsx b/frontend/src/pages/Sites/Settings.tsx index 53e0c2d9..f3f4b464 100644 --- a/frontend/src/pages/Sites/Settings.tsx +++ b/frontend/src/pages/Sites/Settings.tsx @@ -433,16 +433,8 @@ export default function SiteSettings() { }; const loadUserPreferences = async () => { - try { - const { fetchAccountSetting } = await import('../../services/api'); - const setting = await fetchAccountSetting('user_preferences'); - const preferences = setting.config as { selectedIndustry?: string; selectedSectors?: string[] } | undefined; - if (preferences) { - setUserPreferences(preferences); - } - } catch (error: any) { - // Silently handle errors - } + // User preferences are now loaded from site/account data, not from a separate endpoint + // This function is kept for backward compatibility but does nothing }; const loadSiteSectors = async () => { @@ -947,7 +939,7 @@ export default function SiteSettings() { 0 ? qualityTiers.map(tier => ({ - value: tier.value, + value: tier.tier || tier.value, label: `${tier.label} (${tier.credits} credits)` })) : [ @@ -956,7 +948,7 @@ export default function SiteSettings() { { value: 'premium', label: 'Premium (15 credits)' }, ] } - value={selectedTier} + value={selectedTier || 'quality'} onChange={(value) => setSelectedTier(value)} className="w-full" /> @@ -987,11 +979,11 @@ export default function SiteSettings() {
({ + options={Array.from({ length: maxAllowed || 8 }, (_, i) => ({ value: String(i + 1), label: `${i + 1} image${i > 0 ? 's' : ''}`, }))} - value={String(maxImages)} + value={String(maxImages || 4)} onChange={(value) => setMaxImages(parseInt(value))} className="w-full" />