django admin Groups reorg, Frontend udpates for site settings, #Migration runs
This commit is contained in:
@@ -12,8 +12,10 @@ __all__ = [
|
||||
'Strategy',
|
||||
# Global settings models
|
||||
'GlobalIntegrationSettings',
|
||||
'AccountIntegrationOverride',
|
||||
'GlobalAIPrompt',
|
||||
'GlobalAuthorProfile',
|
||||
'GlobalStrategy',
|
||||
# New centralized models
|
||||
'IntegrationProvider',
|
||||
'AISettings',
|
||||
]
|
||||
|
||||
@@ -32,7 +32,7 @@ class AIPromptResource(resources.ModelResource):
|
||||
# Import settings admin
|
||||
from .settings_admin import (
|
||||
SystemSettingsAdmin, AccountSettingsAdmin, UserSettingsAdmin,
|
||||
ModuleSettingsAdmin, AISettingsAdmin
|
||||
ModuleSettingsAdmin
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -587,3 +587,112 @@ class GlobalModuleSettingsAdmin(Igny8ModelAdmin):
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
|
||||
# IntegrationProvider Admin (centralized API keys)
|
||||
from .models import IntegrationProvider
|
||||
|
||||
|
||||
@admin.register(IntegrationProvider)
|
||||
class IntegrationProviderAdmin(Igny8ModelAdmin):
|
||||
"""
|
||||
Admin for IntegrationProvider - Centralized API key management.
|
||||
Per final-model-schemas.md
|
||||
"""
|
||||
|
||||
list_display = [
|
||||
'provider_id',
|
||||
'display_name',
|
||||
'provider_type',
|
||||
'is_active',
|
||||
'is_sandbox',
|
||||
'has_api_key',
|
||||
'updated_at',
|
||||
]
|
||||
list_filter = ['provider_type', 'is_active', 'is_sandbox']
|
||||
search_fields = ['provider_id', 'display_name']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
fieldsets = (
|
||||
('Provider Info', {
|
||||
'fields': ('provider_id', 'display_name', 'provider_type')
|
||||
}),
|
||||
('API Configuration', {
|
||||
'fields': ('api_key', 'api_secret', 'webhook_secret', 'api_endpoint'),
|
||||
'description': 'Enter API keys and endpoints. These are platform-wide.'
|
||||
}),
|
||||
('Extra Config', {
|
||||
'fields': ('config',),
|
||||
'classes': ('collapse',),
|
||||
'description': 'JSON config for provider-specific settings'
|
||||
}),
|
||||
('Status', {
|
||||
'fields': ('is_active', 'is_sandbox')
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': ('updated_by', 'created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
def has_api_key(self, obj):
|
||||
"""Show if API key is configured"""
|
||||
return bool(obj.api_key)
|
||||
has_api_key.boolean = True
|
||||
has_api_key.short_description = 'API Key Set'
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Set updated_by to current user"""
|
||||
obj.updated_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
# SystemAISettings Admin (new simplified AI settings)
|
||||
from .ai_settings import SystemAISettings
|
||||
|
||||
|
||||
@admin.register(SystemAISettings)
|
||||
class SystemAISettingsAdmin(Igny8ModelAdmin):
|
||||
"""
|
||||
Admin for SystemAISettings - System-wide AI defaults (Singleton).
|
||||
Per final-model-schemas.md
|
||||
"""
|
||||
|
||||
list_display = [
|
||||
'id',
|
||||
'temperature',
|
||||
'max_tokens',
|
||||
'image_style',
|
||||
'image_quality',
|
||||
'max_images_per_article',
|
||||
'updated_at',
|
||||
]
|
||||
readonly_fields = ['updated_at']
|
||||
|
||||
fieldsets = (
|
||||
('AI Parameters', {
|
||||
'fields': ('temperature', 'max_tokens'),
|
||||
'description': 'System-wide defaults for AI text generation. Accounts can override via AccountSettings.'
|
||||
}),
|
||||
('Image Generation', {
|
||||
'fields': ('image_style', 'image_quality', 'max_images_per_article', 'image_size'),
|
||||
'description': 'System-wide defaults for image generation. Accounts can override via AccountSettings.'
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': ('updated_by', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Only allow one instance (singleton)"""
|
||||
return not SystemAISettings.objects.exists()
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
"""Prevent deletion of singleton"""
|
||||
return False
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Set updated_by to current user"""
|
||||
obj.updated_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
195
backend/igny8_core/modules/system/ai_settings.py
Normal file
195
backend/igny8_core/modules/system/ai_settings.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
AI Settings - System-wide AI defaults (Singleton)
|
||||
|
||||
This is the clean, simplified model for AI configuration.
|
||||
Replaces the deprecated GlobalIntegrationSettings.
|
||||
|
||||
API keys are stored in IntegrationProvider.
|
||||
Model definitions are in AIModelConfig.
|
||||
This model only stores system-wide defaults for AI parameters.
|
||||
"""
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SystemAISettings(models.Model):
|
||||
"""
|
||||
System-wide AI defaults. Singleton (pk=1).
|
||||
|
||||
Removed fields (now elsewhere):
|
||||
- All *_api_key fields → IntegrationProvider
|
||||
- All *_model fields → AIModelConfig.is_default
|
||||
- default_text_provider → AIModelConfig.is_default where model_type='text'
|
||||
- default_image_service → AIModelConfig.is_default where model_type='image'
|
||||
|
||||
Accounts can override these via AccountSettings with keys like:
|
||||
- ai.temperature
|
||||
- ai.max_tokens
|
||||
- ai.image_style
|
||||
- ai.image_quality
|
||||
- ai.max_images
|
||||
"""
|
||||
|
||||
IMAGE_STYLE_CHOICES = [
|
||||
('photorealistic', 'Photorealistic'),
|
||||
('illustration', 'Illustration'),
|
||||
('3d_render', '3D Render'),
|
||||
('minimal_flat', 'Minimal / Flat Design'),
|
||||
('artistic', 'Artistic / Painterly'),
|
||||
('cartoon', 'Cartoon / Stylized'),
|
||||
]
|
||||
|
||||
IMAGE_QUALITY_CHOICES = [
|
||||
('standard', 'Standard'),
|
||||
('hd', 'HD'),
|
||||
]
|
||||
|
||||
IMAGE_SIZE_CHOICES = [
|
||||
('1024x1024', '1024x1024 (Square)'),
|
||||
('1792x1024', '1792x1024 (Landscape)'),
|
||||
('1024x1792', '1024x1792 (Portrait)'),
|
||||
]
|
||||
|
||||
# AI Parameters
|
||||
temperature = models.FloatField(
|
||||
default=0.7,
|
||||
help_text="AI temperature (0.0-2.0). Higher = more creative."
|
||||
)
|
||||
max_tokens = models.IntegerField(
|
||||
default=8192,
|
||||
help_text="Max response tokens"
|
||||
)
|
||||
|
||||
# Image Generation Settings
|
||||
image_style = models.CharField(
|
||||
max_length=30,
|
||||
default='photorealistic',
|
||||
choices=IMAGE_STYLE_CHOICES,
|
||||
help_text="Default image style"
|
||||
)
|
||||
image_quality = models.CharField(
|
||||
max_length=20,
|
||||
default='standard',
|
||||
choices=IMAGE_QUALITY_CHOICES,
|
||||
help_text="Default image quality (standard/hd)"
|
||||
)
|
||||
max_images_per_article = models.IntegerField(
|
||||
default=4,
|
||||
help_text="Max in-article images (1-8)"
|
||||
)
|
||||
image_size = models.CharField(
|
||||
max_length=20,
|
||||
default='1024x1024',
|
||||
choices=IMAGE_SIZE_CHOICES,
|
||||
help_text="Default image dimensions"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
updated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='system_ai_settings_updates'
|
||||
)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_system_ai_settings'
|
||||
verbose_name = 'System AI Settings'
|
||||
verbose_name_plural = 'System AI Settings'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Enforce singleton - always use pk=1"""
|
||||
self.pk = 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""Prevent deletion of singleton"""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
"""Get or create the singleton instance"""
|
||||
obj, created = cls.objects.get_or_create(pk=1)
|
||||
return obj
|
||||
|
||||
def __str__(self):
|
||||
return "System AI Settings"
|
||||
|
||||
# Helper methods for getting effective settings with account overrides
|
||||
@classmethod
|
||||
def get_effective_temperature(cls, account=None) -> float:
|
||||
"""Get temperature, checking account override first"""
|
||||
if account:
|
||||
override = cls._get_account_override(account, 'ai.temperature')
|
||||
if override is not None:
|
||||
return float(override)
|
||||
return cls.get_instance().temperature
|
||||
|
||||
@classmethod
|
||||
def get_effective_max_tokens(cls, account=None) -> int:
|
||||
"""Get max_tokens, checking account override first"""
|
||||
if account:
|
||||
override = cls._get_account_override(account, 'ai.max_tokens')
|
||||
if override is not None:
|
||||
return int(override)
|
||||
return cls.get_instance().max_tokens
|
||||
|
||||
@classmethod
|
||||
def get_effective_image_style(cls, account=None) -> str:
|
||||
"""Get image_style, checking account override first"""
|
||||
if account:
|
||||
override = cls._get_account_override(account, 'ai.image_style')
|
||||
if override is not None:
|
||||
return str(override)
|
||||
return cls.get_instance().image_style
|
||||
|
||||
@classmethod
|
||||
def get_effective_image_quality(cls, account=None) -> str:
|
||||
"""Get image_quality, checking account override first"""
|
||||
if account:
|
||||
override = cls._get_account_override(account, 'ai.image_quality')
|
||||
if override is not None:
|
||||
return str(override)
|
||||
return cls.get_instance().image_quality
|
||||
|
||||
@classmethod
|
||||
def get_effective_max_images(cls, account=None) -> int:
|
||||
"""Get max_images_per_article, checking account override first"""
|
||||
if account:
|
||||
override = cls._get_account_override(account, 'ai.max_images')
|
||||
if override is not None:
|
||||
return int(override)
|
||||
return cls.get_instance().max_images_per_article
|
||||
|
||||
@classmethod
|
||||
def get_effective_image_size(cls, account=None) -> str:
|
||||
"""Get image_size, checking account override first"""
|
||||
if account:
|
||||
override = cls._get_account_override(account, 'ai.image_size')
|
||||
if override is not None:
|
||||
return str(override)
|
||||
return cls.get_instance().image_size
|
||||
|
||||
@staticmethod
|
||||
def _get_account_override(account, key: str):
|
||||
"""Get account-specific override from AccountSettings"""
|
||||
try:
|
||||
from igny8_core.modules.system.settings_models import AccountSettings
|
||||
setting = AccountSettings.objects.filter(
|
||||
account=account,
|
||||
key=key
|
||||
).first()
|
||||
if setting and setting.config:
|
||||
return setting.config.get('value')
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get account override for {key}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Alias for backward compatibility and clearer naming
|
||||
AISettings = SystemAISettings
|
||||
@@ -21,7 +21,7 @@ def get_text_model_choices():
|
||||
models = AIModelConfig.objects.filter(
|
||||
model_type='text',
|
||||
is_active=True
|
||||
).order_by('sort_order', 'model_name')
|
||||
).order_by('model_name')
|
||||
|
||||
if models.exists():
|
||||
return [(m.model_name, m.display_name) for m in models]
|
||||
@@ -48,7 +48,7 @@ def get_image_model_choices(provider=None):
|
||||
)
|
||||
if provider:
|
||||
qs = qs.filter(provider=provider)
|
||||
qs = qs.order_by('sort_order', 'model_name')
|
||||
qs = qs.order_by('model_name')
|
||||
|
||||
if qs.exists():
|
||||
return [(m.model_name, m.display_name) for m in qs]
|
||||
|
||||
@@ -109,16 +109,15 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
)
|
||||
|
||||
try:
|
||||
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
|
||||
# Get platform API keys
|
||||
global_settings = GlobalIntegrationSettings.get_instance()
|
||||
# Get platform API keys from IntegrationProvider (centralized)
|
||||
api_key = ModelRegistry.get_api_key(integration_type)
|
||||
|
||||
# Get config from request (model selection)
|
||||
config = request.data.get('config', {}) if isinstance(request.data.get('config'), dict) else {}
|
||||
|
||||
if integration_type == 'openai':
|
||||
api_key = global_settings.openai_api_key
|
||||
if not api_key:
|
||||
return error_response(
|
||||
error='Platform OpenAI API key not configured. Please contact administrator.',
|
||||
@@ -128,7 +127,6 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
return self._test_openai(api_key, config, request)
|
||||
|
||||
elif integration_type == 'runware':
|
||||
api_key = global_settings.runware_api_key
|
||||
if not api_key:
|
||||
return error_response(
|
||||
error='Platform Runware API key not configured. Please contact administrator.',
|
||||
@@ -212,10 +210,13 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
output_tokens = usage.get('completion_tokens', 0)
|
||||
total_tokens = usage.get('total_tokens', 0)
|
||||
|
||||
# Calculate cost using model rates (reference plugin: line 274-275)
|
||||
from igny8_core.utils.ai_processor import MODEL_RATES
|
||||
rates = MODEL_RATES.get(model, {'input': 2.00, 'output': 8.00})
|
||||
cost = (input_tokens * rates['input'] + output_tokens * rates['output']) / 1000000
|
||||
# Calculate cost using ModelRegistry (database-driven)
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
cost = float(ModelRegistry.calculate_cost(
|
||||
model,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens
|
||||
))
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
@@ -521,31 +522,13 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get API key from saved settings for the specified provider only
|
||||
# Get API key from IntegrationProvider (centralized, platform-wide)
|
||||
logger.info(f"[generate_image] Step 3: Getting API key for provider: {provider}")
|
||||
from .models import IntegrationSettings
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
|
||||
# Only fetch settings for the specified provider
|
||||
api_key = None
|
||||
integration_enabled = False
|
||||
integration_type = provider # 'openai' or 'runware'
|
||||
|
||||
try:
|
||||
integration_settings = IntegrationSettings.objects.get(
|
||||
integration_type=integration_type,
|
||||
account=account
|
||||
)
|
||||
api_key = integration_settings.config.get('apiKey')
|
||||
integration_enabled = integration_settings.is_active
|
||||
logger.info(f"[generate_image] {integration_type.upper()} settings found: enabled={integration_enabled}, has_key={bool(api_key)}")
|
||||
except IntegrationSettings.DoesNotExist:
|
||||
logger.warning(f"[generate_image] {integration_type.upper()} settings not found in database")
|
||||
api_key = None
|
||||
integration_enabled = False
|
||||
except Exception as e:
|
||||
logger.error(f"[generate_image] Error getting {integration_type.upper()} settings: {e}")
|
||||
api_key = None
|
||||
integration_enabled = False
|
||||
api_key = ModelRegistry.get_api_key(provider)
|
||||
integration_enabled = api_key is not None
|
||||
logger.info(f"[generate_image] {provider.upper()} API key: enabled={integration_enabled}, has_key={bool(api_key)}")
|
||||
|
||||
# Validate provider and API key
|
||||
logger.info(f"[generate_image] Step 4: Validating {provider} provider and API key")
|
||||
@@ -635,8 +618,8 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
def save_settings(self, request, pk=None):
|
||||
"""
|
||||
Save integration settings (account overrides only).
|
||||
- Saves model/parameter overrides to IntegrationSettings
|
||||
- NEVER saves API keys (those are platform-wide)
|
||||
- Saves model/parameter overrides to AccountSettings (key-value store)
|
||||
- NEVER saves API keys (those are platform-wide via IntegrationProvider)
|
||||
- Free plan: Should be blocked at frontend level
|
||||
"""
|
||||
integration_type = pk
|
||||
@@ -689,62 +672,47 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
# TODO: Check if Free plan - they shouldn't be able to save overrides
|
||||
# This should be blocked at frontend level, but add backend check too
|
||||
|
||||
from .models import IntegrationSettings
|
||||
|
||||
# Build clean config with only allowed overrides
|
||||
clean_config = {}
|
||||
from .settings_models import AccountSettings
|
||||
|
||||
# Save account overrides to AccountSettings (key-value store)
|
||||
saved_keys = []
|
||||
|
||||
if integration_type == 'openai':
|
||||
# Only allow model, temperature, max_tokens overrides
|
||||
if 'model' in config:
|
||||
clean_config['model'] = config['model']
|
||||
if 'temperature' in config:
|
||||
clean_config['temperature'] = config['temperature']
|
||||
if 'max_tokens' in config:
|
||||
clean_config['max_tokens'] = config['max_tokens']
|
||||
# Save OpenAI-specific overrides to AccountSettings
|
||||
key_mappings = {
|
||||
'temperature': 'ai.temperature',
|
||||
'max_tokens': 'ai.max_tokens',
|
||||
}
|
||||
for config_key, account_key in key_mappings.items():
|
||||
if config_key in config:
|
||||
AccountSettings.objects.update_or_create(
|
||||
account=account,
|
||||
key=account_key,
|
||||
defaults={'config': {'value': config[config_key]}}
|
||||
)
|
||||
saved_keys.append(account_key)
|
||||
|
||||
elif integration_type == 'image_generation':
|
||||
# Map service to provider if service is provided
|
||||
if 'service' in config:
|
||||
clean_config['service'] = config['service']
|
||||
clean_config['provider'] = config['service']
|
||||
if 'provider' in config:
|
||||
clean_config['provider'] = config['provider']
|
||||
clean_config['service'] = config['provider']
|
||||
|
||||
# Model selection (service-specific)
|
||||
if 'model' in config:
|
||||
clean_config['model'] = config['model']
|
||||
if 'imageModel' in config:
|
||||
clean_config['imageModel'] = config['imageModel']
|
||||
clean_config['model'] = config['imageModel'] # Also store in 'model' for consistency
|
||||
if 'runwareModel' in config:
|
||||
clean_config['runwareModel'] = config['runwareModel']
|
||||
|
||||
# Universal image settings (applies to all providers)
|
||||
for key in ['image_type', 'image_quality', 'image_style', 'max_in_article_images', 'image_format',
|
||||
'desktop_enabled', 'featured_image_size', 'desktop_image_size']:
|
||||
if key in config:
|
||||
clean_config[key] = config[key]
|
||||
# Save image generation overrides to AccountSettings
|
||||
key_mappings = {
|
||||
'image_type': 'ai.image_style',
|
||||
'image_style': 'ai.image_style',
|
||||
'image_quality': 'ai.image_quality',
|
||||
'max_in_article_images': 'ai.max_images',
|
||||
'desktop_image_size': 'ai.image_size',
|
||||
}
|
||||
for config_key, account_key in key_mappings.items():
|
||||
if config_key in config:
|
||||
AccountSettings.objects.update_or_create(
|
||||
account=account,
|
||||
key=account_key,
|
||||
defaults={'config': {'value': config[config_key]}}
|
||||
)
|
||||
saved_keys.append(account_key)
|
||||
|
||||
# Get or create integration settings
|
||||
logger.info(f"[save_settings] Saving clean config: {clean_config}")
|
||||
integration_settings, created = IntegrationSettings.objects.get_or_create(
|
||||
integration_type=integration_type,
|
||||
account=account,
|
||||
defaults={'config': clean_config, 'is_active': True}
|
||||
)
|
||||
logger.info(f"[save_settings] Result: created={created}, id={integration_settings.id}")
|
||||
|
||||
if not created:
|
||||
integration_settings.config = clean_config
|
||||
integration_settings.is_active = True
|
||||
integration_settings.save()
|
||||
logger.info(f"[save_settings] Updated existing settings")
|
||||
|
||||
logger.info(f"[save_settings] Successfully saved overrides for {integration_type}")
|
||||
logger.info(f"[save_settings] Saved to AccountSettings: {saved_keys}")
|
||||
return success_response(
|
||||
data={'config': clean_config},
|
||||
data={'saved_keys': saved_keys},
|
||||
message=f'{integration_type.upper()} settings saved successfully',
|
||||
request=request
|
||||
)
|
||||
@@ -787,20 +755,20 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
logger.warning(f"Error getting account from user: {e}")
|
||||
account = None
|
||||
|
||||
from .models import IntegrationSettings
|
||||
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
|
||||
from igny8_core.modules.system.ai_settings import SystemAISettings
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
|
||||
# Get global defaults
|
||||
global_settings = GlobalIntegrationSettings.get_instance()
|
||||
|
||||
# Build response with global defaults
|
||||
# Build response using SystemAISettings (singleton) + AccountSettings overrides
|
||||
if integration_type == 'openai':
|
||||
# Get max_tokens from AIModelConfig for the selected model
|
||||
max_tokens = global_settings.openai_max_tokens # Fallback
|
||||
# Get default model from AIModelConfig
|
||||
default_model = ModelRegistry.get_default_model('text') or 'gpt-4o-mini'
|
||||
|
||||
# Get max_tokens from AIModelConfig for the model
|
||||
max_tokens = SystemAISettings.get_effective_max_tokens(account)
|
||||
try:
|
||||
from igny8_core.business.billing.models import AIModelConfig
|
||||
model_config = AIModelConfig.objects.filter(
|
||||
model_name=global_settings.openai_model,
|
||||
model_name=default_model,
|
||||
is_active=True
|
||||
).first()
|
||||
if model_config and model_config.max_output_tokens:
|
||||
@@ -811,31 +779,12 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
response_data = {
|
||||
'id': 'openai',
|
||||
'enabled': True, # Always enabled (platform-wide)
|
||||
'model': global_settings.openai_model,
|
||||
'temperature': global_settings.openai_temperature,
|
||||
'model': default_model,
|
||||
'temperature': SystemAISettings.get_effective_temperature(account),
|
||||
'max_tokens': max_tokens,
|
||||
'using_global': True, # Flag to show it's using global
|
||||
}
|
||||
|
||||
# Check for account overrides
|
||||
if account:
|
||||
try:
|
||||
integration_settings = IntegrationSettings.objects.get(
|
||||
integration_type=integration_type,
|
||||
account=account,
|
||||
is_active=True
|
||||
)
|
||||
config = integration_settings.config or {}
|
||||
if config.get('model'):
|
||||
response_data['model'] = config['model']
|
||||
response_data['using_global'] = False
|
||||
if config.get('temperature') is not None:
|
||||
response_data['temperature'] = config['temperature']
|
||||
if config.get('max_tokens'):
|
||||
response_data['max_tokens'] = config['max_tokens']
|
||||
except IntegrationSettings.DoesNotExist:
|
||||
pass
|
||||
|
||||
elif integration_type == 'runware':
|
||||
response_data = {
|
||||
'id': 'runware',
|
||||
@@ -851,63 +800,35 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
'google:4@2': '1376x768',
|
||||
}
|
||||
|
||||
# Get default service and model based on global settings
|
||||
default_service = global_settings.default_image_service
|
||||
default_model = global_settings.dalle_model if default_service == 'openai' else global_settings.runware_model
|
||||
# Get default image model from AIModelConfig
|
||||
default_model = ModelRegistry.get_default_model('image')
|
||||
if default_model:
|
||||
model_config = ModelRegistry.get_model(default_model)
|
||||
default_service = model_config.provider if model_config else 'openai'
|
||||
else:
|
||||
default_service = 'openai'
|
||||
default_model = 'dall-e-3'
|
||||
|
||||
model_landscape_size = MODEL_LANDSCAPE_SIZES.get(default_model, '1280x768')
|
||||
|
||||
response_data = {
|
||||
'id': 'image_generation',
|
||||
'enabled': True,
|
||||
'service': default_service, # From global settings
|
||||
'provider': default_service, # Alias for service
|
||||
'model': default_model, # Service-specific default model
|
||||
'imageModel': global_settings.dalle_model, # OpenAI model
|
||||
'runwareModel': global_settings.runware_model, # Runware model
|
||||
'image_type': global_settings.image_style, # Use image_style as default
|
||||
'image_quality': global_settings.image_quality, # Universal quality
|
||||
'image_style': global_settings.image_style, # Universal style
|
||||
'max_in_article_images': global_settings.max_in_article_images,
|
||||
'service': default_service,
|
||||
'provider': default_service,
|
||||
'model': default_model,
|
||||
'imageModel': default_model if default_service == 'openai' else 'dall-e-3',
|
||||
'runwareModel': default_model if default_service != 'openai' else None,
|
||||
'image_type': SystemAISettings.get_effective_image_style(account),
|
||||
'image_quality': SystemAISettings.get_effective_image_quality(account),
|
||||
'image_style': SystemAISettings.get_effective_image_style(account),
|
||||
'max_in_article_images': SystemAISettings.get_effective_max_images(account),
|
||||
'image_format': 'webp',
|
||||
'desktop_enabled': True,
|
||||
'featured_image_size': model_landscape_size, # Model-specific landscape
|
||||
'desktop_image_size': global_settings.desktop_image_size,
|
||||
'featured_image_size': model_landscape_size,
|
||||
'desktop_image_size': SystemAISettings.get_effective_image_size(account),
|
||||
'using_global': True,
|
||||
}
|
||||
|
||||
# Check for account overrides
|
||||
if account:
|
||||
try:
|
||||
integration_settings = IntegrationSettings.objects.get(
|
||||
integration_type=integration_type,
|
||||
account=account,
|
||||
is_active=True
|
||||
)
|
||||
config = integration_settings.config or {}
|
||||
# Override with account settings
|
||||
if config:
|
||||
response_data['using_global'] = False
|
||||
# Service/provider
|
||||
if 'service' in config:
|
||||
response_data['service'] = config['service']
|
||||
response_data['provider'] = config['service']
|
||||
if 'provider' in config:
|
||||
response_data['provider'] = config['provider']
|
||||
response_data['service'] = config['provider']
|
||||
# Models
|
||||
if 'model' in config:
|
||||
response_data['model'] = config['model']
|
||||
if 'imageModel' in config:
|
||||
response_data['imageModel'] = config['imageModel']
|
||||
if 'runwareModel' in config:
|
||||
response_data['runwareModel'] = config['runwareModel']
|
||||
# Universal image settings
|
||||
for key in ['image_type', 'image_quality', 'image_style', 'max_in_article_images', 'image_format',
|
||||
'desktop_enabled', 'featured_image_size', 'desktop_image_size']:
|
||||
if key in config:
|
||||
response_data[key] = config[key]
|
||||
except IntegrationSettings.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
# Other integration types - return empty
|
||||
response_data = {
|
||||
@@ -932,14 +853,12 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
"""Get image generation settings for current account.
|
||||
|
||||
Architecture:
|
||||
1. If account has IntegrationSettings override -> use it (with GlobalIntegrationSettings as fallback for missing fields)
|
||||
2. Otherwise -> use GlobalIntegrationSettings (platform-wide defaults)
|
||||
|
||||
Note: API keys are ALWAYS from GlobalIntegrationSettings (accounts cannot override API keys).
|
||||
Account IntegrationSettings only store model/parameter overrides.
|
||||
1. SystemAISettings (singleton) provides system-wide defaults
|
||||
2. AccountSettings (key-value) provides per-account overrides
|
||||
3. API keys come from IntegrationProvider (accounts cannot override API keys)
|
||||
"""
|
||||
from .models import IntegrationSettings
|
||||
from .global_settings_models import GlobalIntegrationSettings
|
||||
from igny8_core.modules.system.ai_settings import SystemAISettings
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
|
||||
account = getattr(request, 'account', None)
|
||||
|
||||
@@ -949,10 +868,7 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
|
||||
account = getattr(user, 'account', None)
|
||||
|
||||
# Get GlobalIntegrationSettings (platform defaults - always available)
|
||||
global_settings = GlobalIntegrationSettings.get_instance()
|
||||
|
||||
# Model-specific landscape sizes (from GlobalIntegrationSettings)
|
||||
# Model-specific landscape sizes
|
||||
MODEL_LANDSCAPE_SIZES = {
|
||||
'runware:97@1': '1280x768', # Hi Dream Full landscape
|
||||
'bria:10@1': '1344x768', # Bria 3.2 landscape (16:9)
|
||||
@@ -962,53 +878,38 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
}
|
||||
|
||||
try:
|
||||
# Check if account has specific overrides
|
||||
account_config = {}
|
||||
if account:
|
||||
try:
|
||||
integration = IntegrationSettings.objects.get(
|
||||
account=account,
|
||||
integration_type='image_generation',
|
||||
is_active=True
|
||||
)
|
||||
account_config = integration.config or {}
|
||||
logger.info(f"[get_image_generation_settings] Found account {account.id} override: {list(account_config.keys())}")
|
||||
except IntegrationSettings.DoesNotExist:
|
||||
logger.info(f"[get_image_generation_settings] No override for account {account.id if account else 'None'}, using GlobalIntegrationSettings")
|
||||
|
||||
# Build response using account overrides with global fallbacks
|
||||
provider = account_config.get('provider') or global_settings.default_image_service
|
||||
|
||||
# Get model based on provider
|
||||
if provider == 'runware':
|
||||
model = account_config.get('model') or account_config.get('imageModel') or global_settings.runware_model
|
||||
# Get default image model from AIModelConfig
|
||||
default_model = ModelRegistry.get_default_model('image')
|
||||
if default_model:
|
||||
model_config = ModelRegistry.get_model(default_model)
|
||||
provider = model_config.provider if model_config else 'openai'
|
||||
model = default_model
|
||||
else:
|
||||
model = account_config.get('model') or account_config.get('imageModel') or global_settings.dalle_model
|
||||
provider = 'openai'
|
||||
model = 'dall-e-3'
|
||||
|
||||
# Get model-specific landscape size
|
||||
model_landscape_size = MODEL_LANDSCAPE_SIZES.get(model, '1280x768')
|
||||
default_featured_size = model_landscape_size if provider == 'runware' else '1792x1024'
|
||||
|
||||
# Get image style with provider-specific defaults
|
||||
image_style = account_config.get('image_type') or global_settings.image_style
|
||||
# Get image style from SystemAISettings with AccountSettings overrides
|
||||
image_style = SystemAISettings.get_effective_image_style(account)
|
||||
|
||||
# Style options from GlobalIntegrationSettings model - loaded dynamically
|
||||
# Style options - loaded from SystemAISettings model choices
|
||||
# Runware: Uses all styles with prompt enhancement
|
||||
# OpenAI DALL-E: Only supports 'natural' or 'vivid'
|
||||
if provider == 'openai':
|
||||
# Get DALL-E styles from model definition
|
||||
available_styles = [
|
||||
{'value': opt[0], 'label': opt[1], 'description': opt[2]}
|
||||
for opt in GlobalIntegrationSettings.DALLE_STYLE_OPTIONS
|
||||
{'value': 'vivid', 'label': 'Vivid', 'description': 'Dramatic, hyper-realistic style'},
|
||||
{'value': 'natural', 'label': 'Natural', 'description': 'Natural, realistic style'},
|
||||
]
|
||||
# Map stored style to DALL-E compatible
|
||||
if image_style not in ['vivid', 'natural']:
|
||||
image_style = 'natural' # Default to natural for photorealistic
|
||||
else:
|
||||
# Get Runware styles from model definition
|
||||
available_styles = [
|
||||
{'value': opt[0], 'label': opt[1], 'description': opt[2]}
|
||||
for opt in GlobalIntegrationSettings.IMAGE_STYLE_OPTIONS
|
||||
{'value': opt[0], 'label': opt[1]}
|
||||
for opt in SystemAISettings.IMAGE_STYLE_CHOICES
|
||||
]
|
||||
# Default to photorealistic for Runware if not set
|
||||
if not image_style or image_style in ['natural', 'vivid']:
|
||||
@@ -1022,12 +923,12 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
'provider': provider,
|
||||
'model': model,
|
||||
'image_type': image_style,
|
||||
'available_styles': available_styles, # Loaded from GlobalIntegrationSettings model
|
||||
'max_in_article_images': account_config.get('max_in_article_images') or global_settings.max_in_article_images,
|
||||
'image_format': account_config.get('image_format', 'webp'),
|
||||
'desktop_enabled': account_config.get('desktop_enabled', True),
|
||||
'featured_image_size': account_config.get('featured_image_size') or default_featured_size,
|
||||
'desktop_image_size': account_config.get('desktop_image_size') or global_settings.desktop_image_size,
|
||||
'available_styles': available_styles,
|
||||
'max_in_article_images': SystemAISettings.get_effective_max_images(account),
|
||||
'image_format': 'webp',
|
||||
'desktop_enabled': True,
|
||||
'featured_image_size': default_featured_size,
|
||||
'desktop_image_size': SystemAISettings.get_effective_image_size(account),
|
||||
}
|
||||
},
|
||||
request=request
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-04 06:11
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0014_update_runware_models'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='anthropic_model',
|
||||
field=models.CharField(choices=[('claude-3-5-sonnet-20241022', 'Claude 3.5 Sonnet - $3.00 / $15.00 per 1M tokens'), ('claude-3-5-haiku-20241022', 'Claude 3.5 Haiku - $1.00 / $5.00 per 1M tokens'), ('claude-3-opus-20240229', 'Claude 3 Opus - $15.00 / $75.00 per 1M tokens'), ('claude-3-sonnet-20240229', 'Claude 3 Sonnet - $3.00 / $15.00 per 1M tokens'), ('claude-3-haiku-20240307', 'Claude 3 Haiku - $0.25 / $1.25 per 1M tokens')], default='claude-3-5-sonnet-20241022', help_text='Default Claude model (accounts can override if plan allows)', max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='default_image_service',
|
||||
field=models.CharField(choices=[('openai', 'OpenAI DALL-E'), ('runware', 'Runware')], default='openai', help_text='Default image generation service for all accounts (openai=DALL-E, runware=Runware, bria=Bria)', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='image_style',
|
||||
field=models.CharField(choices=[('photorealistic', 'Photorealistic'), ('illustration', 'Illustration'), ('3d_render', '3D Render'), ('minimal_flat', 'Minimal / Flat Design'), ('artistic', 'Artistic / Painterly'), ('cartoon', 'Cartoon / Stylized')], default='photorealistic', help_text='Default image style for all providers (accounts can override if plan allows)', max_length=30),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalmodulesettings',
|
||||
name='linker_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Enable Linker module platform-wide (Phase 2)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalmodulesettings',
|
||||
name='optimizer_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Enable Optimizer module platform-wide (Phase 2)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalmodulesettings',
|
||||
name='site_builder_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Enable Site Builder module platform-wide (DEPRECATED)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='moduleenablesettings',
|
||||
name='linker_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Enable Linker module (Phase 2)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='moduleenablesettings',
|
||||
name='optimizer_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Enable Optimizer module (Phase 2)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='moduleenablesettings',
|
||||
name='site_builder_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Enable Site Builder module (DEPRECATED)'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='IntegrationProvider',
|
||||
fields=[
|
||||
('provider_id', models.CharField(help_text="Unique identifier (e.g., 'openai', 'stripe', 'resend')", max_length=50, primary_key=True, serialize=False, unique=True)),
|
||||
('display_name', models.CharField(help_text='Human-readable name', max_length=100)),
|
||||
('provider_type', models.CharField(choices=[('ai', 'AI Provider'), ('email', 'Email Service'), ('payment', 'Payment Gateway'), ('storage', 'Storage Service'), ('analytics', 'Analytics'), ('other', 'Other')], db_index=True, default='ai', max_length=20)),
|
||||
('api_key', models.CharField(blank=True, help_text='Primary API key or token', max_length=500)),
|
||||
('api_secret', models.CharField(blank=True, help_text='Secondary secret (for OAuth, Stripe secret key, etc.)', max_length=500)),
|
||||
('webhook_secret', models.CharField(blank=True, help_text='Webhook signing secret (Stripe, PayPal)', max_length=500)),
|
||||
('api_endpoint', models.URLField(blank=True, help_text='Custom API endpoint (if not default)')),
|
||||
('webhook_url', models.URLField(blank=True, help_text='Webhook URL configured at provider')),
|
||||
('config', models.JSONField(blank=True, default=dict, help_text='Provider-specific config: rate limits, regions, modes, etc.')),
|
||||
('is_active', models.BooleanField(db_index=True, default=True)),
|
||||
('is_sandbox', models.BooleanField(default=False, help_text='True if using sandbox/test mode (Stripe test keys, PayPal sandbox)')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='integration_provider_updates', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Integration Provider',
|
||||
'verbose_name_plural': 'Integration Providers',
|
||||
'db_table': 'igny8_integration_providers',
|
||||
'ordering': ['provider_type', 'display_name'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,136 @@
|
||||
# Generated manually for data migration
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def populate_integration_providers(apps, schema_editor):
|
||||
"""
|
||||
Populate IntegrationProvider with all 3rd party integrations.
|
||||
API keys will need to be configured in Django admin after migration.
|
||||
"""
|
||||
IntegrationProvider = apps.get_model('system', 'IntegrationProvider')
|
||||
|
||||
providers = [
|
||||
# AI Providers
|
||||
{
|
||||
'provider_id': 'openai',
|
||||
'display_name': 'OpenAI',
|
||||
'provider_type': 'ai',
|
||||
'api_key': '', # To be configured in admin
|
||||
'config': {
|
||||
'default_model': 'gpt-5.1',
|
||||
'models': ['gpt-4o-mini', 'gpt-4o', 'gpt-5.1', 'dall-e-3'],
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'provider_id': 'runware',
|
||||
'display_name': 'Runware',
|
||||
'provider_type': 'ai',
|
||||
'api_key': '', # To be configured in admin
|
||||
'config': {
|
||||
'default_model': 'runware:97@1',
|
||||
'models': ['runware:97@1', 'google:4@2'],
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'provider_id': 'anthropic',
|
||||
'display_name': 'Anthropic (Claude)',
|
||||
'provider_type': 'ai',
|
||||
'api_key': '',
|
||||
'config': {
|
||||
'default_model': 'claude-3-5-sonnet-20241022',
|
||||
},
|
||||
'is_active': False, # Not currently used
|
||||
},
|
||||
{
|
||||
'provider_id': 'google',
|
||||
'display_name': 'Google Cloud',
|
||||
'provider_type': 'ai',
|
||||
'api_key': '',
|
||||
'config': {},
|
||||
'is_active': False, # Future: Gemini
|
||||
},
|
||||
|
||||
# Payment Providers
|
||||
{
|
||||
'provider_id': 'stripe',
|
||||
'display_name': 'Stripe',
|
||||
'provider_type': 'payment',
|
||||
'api_key': '', # Public key
|
||||
'api_secret': '', # Secret key
|
||||
'webhook_secret': '',
|
||||
'config': {
|
||||
'currency': 'usd',
|
||||
},
|
||||
'is_active': True,
|
||||
'is_sandbox': True, # Start in test mode
|
||||
},
|
||||
{
|
||||
'provider_id': 'paypal',
|
||||
'display_name': 'PayPal',
|
||||
'provider_type': 'payment',
|
||||
'api_key': '', # Client ID
|
||||
'api_secret': '', # Client Secret
|
||||
'webhook_secret': '',
|
||||
'api_endpoint': 'https://api-m.sandbox.paypal.com', # Sandbox endpoint
|
||||
'config': {
|
||||
'currency': 'usd',
|
||||
},
|
||||
'is_active': True,
|
||||
'is_sandbox': True,
|
||||
},
|
||||
|
||||
# Email Providers
|
||||
{
|
||||
'provider_id': 'resend',
|
||||
'display_name': 'Resend',
|
||||
'provider_type': 'email',
|
||||
'api_key': '',
|
||||
'config': {
|
||||
'from_email': 'noreply@igny8.com',
|
||||
'from_name': 'IGNY8',
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
|
||||
# Storage Providers (Future)
|
||||
{
|
||||
'provider_id': 'cloudflare_r2',
|
||||
'display_name': 'Cloudflare R2',
|
||||
'provider_type': 'storage',
|
||||
'api_key': '', # Access Key ID
|
||||
'api_secret': '', # Secret Access Key
|
||||
'config': {
|
||||
'bucket': '',
|
||||
'endpoint': '',
|
||||
},
|
||||
'is_active': False,
|
||||
},
|
||||
]
|
||||
|
||||
for provider_data in providers:
|
||||
IntegrationProvider.objects.update_or_create(
|
||||
provider_id=provider_data['provider_id'],
|
||||
defaults=provider_data
|
||||
)
|
||||
|
||||
|
||||
def reverse_migration(apps, schema_editor):
|
||||
"""Remove seeded providers"""
|
||||
IntegrationProvider = apps.get_model('system', 'IntegrationProvider')
|
||||
IntegrationProvider.objects.filter(
|
||||
provider_id__in=['openai', 'runware', 'anthropic', 'google', 'stripe', 'paypal', 'resend', 'cloudflare_r2']
|
||||
).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0015_add_integration_provider'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(populate_integration_providers, reverse_migration),
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-04 08:43
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0016_populate_integration_providers'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# AccountIntegrationOverride was already deleted in a previous migration
|
||||
# Keeping this migration empty for now
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-04 08:43
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0017_create_ai_settings'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SystemAISettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('temperature', models.FloatField(default=0.7, help_text='AI temperature (0.0-2.0). Higher = more creative.')),
|
||||
('max_tokens', models.IntegerField(default=8192, help_text='Max response tokens')),
|
||||
('image_style', models.CharField(choices=[('photorealistic', 'Photorealistic'), ('illustration', 'Illustration'), ('3d_render', '3D Render'), ('minimal_flat', 'Minimal / Flat Design'), ('artistic', 'Artistic / Painterly'), ('cartoon', 'Cartoon / Stylized')], default='photorealistic', help_text='Default image style', max_length=30)),
|
||||
('image_quality', models.CharField(choices=[('standard', 'Standard'), ('hd', 'HD')], default='standard', help_text='Default image quality (standard/hd)', max_length=20)),
|
||||
('max_images_per_article', models.IntegerField(default=4, help_text='Max in-article images (1-8)')),
|
||||
('image_size', models.CharField(choices=[('1024x1024', '1024x1024 (Square)'), ('1792x1024', '1792x1024 (Landscape)'), ('1024x1792', '1024x1792 (Portrait)')], default='1024x1024', help_text='Default image dimensions', max_length=20)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='system_ai_settings_updates', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'System AI Settings',
|
||||
'verbose_name_plural': 'System AI Settings',
|
||||
'db_table': 'igny8_system_ai_settings',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,91 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-04 10:40
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0018_create_ai_settings_table'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='accountsettings',
|
||||
name='config',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='accountsettings',
|
||||
name='is_active',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='integrationprovider',
|
||||
name='webhook_url',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='modulesettings',
|
||||
name='config',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accountsettings',
|
||||
name='value',
|
||||
field=models.JSONField(default=dict, help_text='Setting value'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='accountsettings',
|
||||
name='key',
|
||||
field=models.CharField(db_index=True, help_text='Setting key', max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationprovider',
|
||||
name='api_endpoint',
|
||||
field=models.URLField(blank=True, help_text='Custom endpoint (optional)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationprovider',
|
||||
name='api_key',
|
||||
field=models.CharField(blank=True, help_text='Primary API key', max_length=500),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationprovider',
|
||||
name='api_secret',
|
||||
field=models.CharField(blank=True, help_text='Secondary secret (Stripe, PayPal)', max_length=500),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationprovider',
|
||||
name='config',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='Provider-specific config'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationprovider',
|
||||
name='is_active',
|
||||
field=models.BooleanField(db_index=True, default=True, help_text='Enable/disable provider'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationprovider',
|
||||
name='is_sandbox',
|
||||
field=models.BooleanField(default=False, help_text='Test mode flag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationprovider',
|
||||
name='provider_type',
|
||||
field=models.CharField(choices=[('ai', 'AI Provider'), ('payment', 'Payment Gateway'), ('email', 'Email Service'), ('storage', 'Storage Service')], db_index=True, default='ai', help_text='ai / payment / email / storage', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationprovider',
|
||||
name='updated_by',
|
||||
field=models.ForeignKey(blank=True, help_text='Audit trail', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='integration_provider_updates', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationprovider',
|
||||
name='webhook_secret',
|
||||
field=models.CharField(blank=True, help_text='Webhook signing secret', max_length=500),
|
||||
),
|
||||
# AccountIntegrationOverride table doesn't exist in DB, so skip delete
|
||||
# migrations.DeleteModel(
|
||||
# name='AccountIntegrationOverride',
|
||||
# ),
|
||||
]
|
||||
@@ -2,6 +2,7 @@
|
||||
System module models - for global settings and prompts
|
||||
"""
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from igny8_core.auth.models import AccountBaseModel
|
||||
|
||||
# Import settings models
|
||||
@@ -10,6 +11,141 @@ from .settings_models import (
|
||||
)
|
||||
|
||||
|
||||
class IntegrationProvider(models.Model):
|
||||
"""
|
||||
Centralized storage for ALL external service API keys.
|
||||
|
||||
Per final-model-schemas.md:
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| provider_id | CharField(50) PK | Yes | openai, runware, stripe, paypal, resend |
|
||||
| display_name | CharField(100) | Yes | Human-readable name |
|
||||
| provider_type | CharField(20) | Yes | ai / payment / email / storage |
|
||||
| api_key | CharField(500) | No | Primary API key |
|
||||
| api_secret | CharField(500) | No | Secondary secret (Stripe, PayPal) |
|
||||
| webhook_secret | CharField(500) | No | Webhook signing secret |
|
||||
| api_endpoint | URLField | No | Custom endpoint (optional) |
|
||||
| config | JSONField | No | Provider-specific config |
|
||||
| is_active | BooleanField | Yes | Enable/disable provider |
|
||||
| is_sandbox | BooleanField | Yes | Test mode flag |
|
||||
| updated_by | FK(User) | No | Audit trail |
|
||||
| created_at | DateTime | Auto | |
|
||||
| updated_at | DateTime | Auto | |
|
||||
"""
|
||||
PROVIDER_TYPE_CHOICES = [
|
||||
('ai', 'AI Provider'),
|
||||
('payment', 'Payment Gateway'),
|
||||
('email', 'Email Service'),
|
||||
('storage', 'Storage Service'),
|
||||
]
|
||||
|
||||
# Primary Key
|
||||
provider_id = models.CharField(
|
||||
max_length=50,
|
||||
unique=True,
|
||||
primary_key=True,
|
||||
help_text="Unique identifier (e.g., 'openai', 'stripe', 'resend')"
|
||||
)
|
||||
|
||||
# Display name
|
||||
display_name = models.CharField(
|
||||
max_length=100,
|
||||
help_text="Human-readable name"
|
||||
)
|
||||
|
||||
# Provider type
|
||||
provider_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=PROVIDER_TYPE_CHOICES,
|
||||
default='ai',
|
||||
db_index=True,
|
||||
help_text="ai / payment / email / storage"
|
||||
)
|
||||
|
||||
# Authentication
|
||||
api_key = models.CharField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
help_text="Primary API key"
|
||||
)
|
||||
api_secret = models.CharField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
help_text="Secondary secret (Stripe, PayPal)"
|
||||
)
|
||||
webhook_secret = models.CharField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
help_text="Webhook signing secret"
|
||||
)
|
||||
|
||||
# Endpoints
|
||||
api_endpoint = models.URLField(
|
||||
blank=True,
|
||||
help_text="Custom endpoint (optional)"
|
||||
)
|
||||
|
||||
# Configuration
|
||||
config = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Provider-specific config"
|
||||
)
|
||||
|
||||
# Status
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
db_index=True,
|
||||
help_text="Enable/disable provider"
|
||||
)
|
||||
is_sandbox = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Test mode flag"
|
||||
)
|
||||
|
||||
# Audit
|
||||
updated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='integration_provider_updates',
|
||||
help_text="Audit trail"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_integration_providers'
|
||||
verbose_name = 'Integration Provider'
|
||||
verbose_name_plural = 'Integration Providers'
|
||||
ordering = ['provider_type', 'display_name']
|
||||
|
||||
def __str__(self):
|
||||
status = "Active" if self.is_active else "Inactive"
|
||||
mode = "(Sandbox)" if self.is_sandbox else ""
|
||||
return f"{self.display_name} - {status} {mode}".strip()
|
||||
|
||||
@classmethod
|
||||
def get_provider(cls, provider_id: str):
|
||||
"""Get provider by ID, returns None if not found or inactive"""
|
||||
try:
|
||||
return cls.objects.get(provider_id=provider_id, is_active=True)
|
||||
except cls.DoesNotExist:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_api_key(cls, provider_id: str) -> str:
|
||||
"""Get API key for a provider"""
|
||||
provider = cls.get_provider(provider_id)
|
||||
return provider.api_key if provider else ""
|
||||
|
||||
@classmethod
|
||||
def get_providers_by_type(cls, provider_type: str):
|
||||
"""Get all active providers of a type"""
|
||||
return cls.objects.filter(provider_type=provider_type, is_active=True)
|
||||
|
||||
|
||||
class AIPrompt(AccountBaseModel):
|
||||
"""
|
||||
Account-specific AI Prompt templates.
|
||||
|
||||
@@ -17,11 +17,29 @@ class SystemSettingsAdmin(ModelAdmin):
|
||||
|
||||
@admin.register(AccountSettings)
|
||||
class AccountSettingsAdmin(AccountAdminMixin, ModelAdmin):
|
||||
list_display = ['account', 'key', 'is_active', 'updated_at']
|
||||
list_filter = ['is_active', 'account']
|
||||
"""
|
||||
AccountSettings - Generic key-value store for account-specific settings.
|
||||
Per final-model-schemas.md
|
||||
"""
|
||||
list_display = ['account', 'key', 'updated_at']
|
||||
list_filter = ['account']
|
||||
search_fields = ['key', 'account__name']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
fieldsets = (
|
||||
('Account & Key', {
|
||||
'fields': ('account', 'key')
|
||||
}),
|
||||
('Value', {
|
||||
'fields': ('value',),
|
||||
'description': 'JSON value for this setting'
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
def get_account_display(self, obj):
|
||||
"""Safely get account name"""
|
||||
try:
|
||||
|
||||
@@ -7,7 +7,6 @@ from igny8_core.auth.models import AccountBaseModel
|
||||
|
||||
class BaseSettings(AccountBaseModel):
|
||||
"""Base class for all account-scoped settings models"""
|
||||
config = models.JSONField(default=dict, help_text="Settings configuration as JSON")
|
||||
is_active = models.BooleanField(default=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
@@ -35,9 +34,39 @@ class SystemSettings(models.Model):
|
||||
return f"SystemSetting: {self.key}"
|
||||
|
||||
|
||||
class AccountSettings(BaseSettings):
|
||||
"""Account-level settings"""
|
||||
key = models.CharField(max_length=255, db_index=True, help_text="Settings key identifier")
|
||||
class AccountSettings(AccountBaseModel):
|
||||
"""
|
||||
Generic key-value store for account-specific settings.
|
||||
|
||||
Per final-model-schemas.md:
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| id | AutoField PK | Auto | |
|
||||
| account | FK(Account) | Yes | |
|
||||
| key | CharField(100) | Yes | Setting key |
|
||||
| value | JSONField | Yes | Setting value |
|
||||
| created_at | DateTime | Auto | |
|
||||
| updated_at | DateTime | Auto | |
|
||||
|
||||
AI-Related Keys (override AISettings defaults):
|
||||
- ai.temperature
|
||||
- ai.max_tokens
|
||||
- ai.image_style
|
||||
- ai.image_quality
|
||||
- ai.max_images
|
||||
- ai.image_quality_tier
|
||||
"""
|
||||
key = models.CharField(
|
||||
max_length=100,
|
||||
db_index=True,
|
||||
help_text="Setting key"
|
||||
)
|
||||
value = models.JSONField(
|
||||
default=dict,
|
||||
help_text="Setting value"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_account_settings'
|
||||
|
||||
@@ -3,6 +3,7 @@ Serializers for Settings Models
|
||||
"""
|
||||
from rest_framework import serializers
|
||||
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings
|
||||
from .ai_settings import SystemAISettings
|
||||
from .validators import validate_settings_schema
|
||||
|
||||
|
||||
@@ -71,3 +72,21 @@ class AISettingsSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
read_only_fields = ['created_at', 'updated_at', 'account']
|
||||
|
||||
|
||||
class SystemAISettingsSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for SystemAISettings (singleton) with AccountSettings overrides.
|
||||
Per the plan: GET/PUT /api/v1/accounts/settings/ai/
|
||||
"""
|
||||
# Content Generation
|
||||
temperature = serializers.FloatField(min_value=0.0, max_value=2.0)
|
||||
max_tokens = serializers.IntegerField(min_value=100, max_value=32000)
|
||||
|
||||
# Image Generation
|
||||
image_quality_tier = serializers.CharField(max_length=20)
|
||||
image_style = serializers.CharField(max_length=30)
|
||||
max_images = serializers.IntegerField(min_value=1, max_value=8)
|
||||
|
||||
# Read-only metadata
|
||||
quality_tiers = serializers.ListField(read_only=True)
|
||||
styles = serializers.ListField(read_only=True)
|
||||
|
||||
@@ -15,10 +15,14 @@ from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner
|
||||
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings
|
||||
from .global_settings_models import GlobalModuleSettings
|
||||
from .ai_settings import SystemAISettings
|
||||
from .settings_serializers import (
|
||||
SystemSettingsSerializer, AccountSettingsSerializer, UserSettingsSerializer,
|
||||
ModuleSettingsSerializer, AISettingsSerializer
|
||||
ModuleSettingsSerializer, AISettingsSerializer, SystemAISettingsSerializer
|
||||
)
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
@@ -510,3 +514,184 @@ class AISettingsViewSet(AccountModelViewSet):
|
||||
|
||||
serializer.save(account=account)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=['AI Settings']),
|
||||
)
|
||||
class ContentGenerationSettingsViewSet(viewsets.ViewSet):
|
||||
"""
|
||||
ViewSet for Content Generation Settings per the plan.
|
||||
|
||||
GET /api/v1/accounts/settings/ai/ - Get merged SystemAISettings + AccountSettings
|
||||
PUT /api/v1/accounts/settings/ai/ - Save account overrides to AccountSettings
|
||||
|
||||
This endpoint returns:
|
||||
- content_generation: temperature, max_tokens
|
||||
- image_generation: quality_tiers, selected_tier, styles, selected_style, max_images
|
||||
"""
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
|
||||
authentication_classes = [JWTAuthentication]
|
||||
throttle_scope = 'system'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
def _get_account(self, request):
|
||||
"""Get account from request"""
|
||||
account = getattr(request, 'account', None)
|
||||
if not account:
|
||||
user = getattr(request, 'user', None)
|
||||
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
|
||||
account = getattr(user, 'account', None)
|
||||
return account
|
||||
|
||||
def list(self, request):
|
||||
"""
|
||||
GET /api/v1/accounts/settings/ai/
|
||||
|
||||
Returns merged AI settings (SystemAISettings + AccountSettings overrides)
|
||||
Response structure per the plan:
|
||||
{
|
||||
"content_generation": { "temperature": 0.7, "max_tokens": 8192 },
|
||||
"image_generation": {
|
||||
"quality_tiers": [...],
|
||||
"selected_tier": "quality",
|
||||
"styles": [...],
|
||||
"selected_style": "photorealistic",
|
||||
"max_images": 4,
|
||||
"max_allowed": 8
|
||||
}
|
||||
}
|
||||
"""
|
||||
account = self._get_account(request)
|
||||
|
||||
try:
|
||||
from igny8_core.business.billing.models import AIModelConfig
|
||||
|
||||
# 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'):
|
||||
tier = model.quality_tier or 'basic'
|
||||
# Avoid duplicates
|
||||
if not any(t['tier'] == tier for t in quality_tiers):
|
||||
quality_tiers.append({
|
||||
'tier': tier,
|
||||
'credits': model.credits_per_image or 1,
|
||||
'label': tier.title(),
|
||||
'description': f"{model.display_name} quality",
|
||||
'model': model.model_name,
|
||||
})
|
||||
|
||||
# Ensure we have at least basic tiers
|
||||
if not quality_tiers:
|
||||
quality_tiers = [
|
||||
{'tier': 'basic', 'credits': 1, 'label': 'Basic', 'description': 'Fast, simple images'},
|
||||
{'tier': 'quality', 'credits': 5, 'label': 'Quality', 'description': 'Balanced quality'},
|
||||
{'tier': 'premium', 'credits': 15, 'label': 'Premium', 'description': 'Best quality'},
|
||||
]
|
||||
|
||||
# Get styles from SystemAISettings model choices
|
||||
styles = [
|
||||
{'value': opt[0], 'label': opt[1]}
|
||||
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 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.config:
|
||||
selected_tier = tier_setting.config.get('value', 'quality')
|
||||
|
||||
response_data = {
|
||||
'content_generation': {
|
||||
'temperature': temperature,
|
||||
'max_tokens': max_tokens,
|
||||
},
|
||||
'image_generation': {
|
||||
'quality_tiers': quality_tiers,
|
||||
'selected_tier': selected_tier,
|
||||
'styles': styles,
|
||||
'selected_style': image_style,
|
||||
'max_images': max_images,
|
||||
'max_allowed': 8,
|
||||
}
|
||||
}
|
||||
|
||||
return success_response(data=response_data, request=request)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting AI settings: {e}", exc_info=True)
|
||||
return error_response(
|
||||
error=str(e),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
def create(self, request):
|
||||
"""
|
||||
PUT/POST /api/v1/accounts/settings/ai/
|
||||
|
||||
Save account-specific overrides to AccountSettings.
|
||||
Request body per the plan:
|
||||
{
|
||||
"temperature": 0.8,
|
||||
"max_tokens": 4096,
|
||||
"image_quality_tier": "premium",
|
||||
"image_style": "illustration",
|
||||
"max_images": 6
|
||||
}
|
||||
"""
|
||||
account = self._get_account(request)
|
||||
|
||||
if not account:
|
||||
return error_response(
|
||||
error='Account not found',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
try:
|
||||
data = request.data
|
||||
saved_keys = []
|
||||
|
||||
# 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',
|
||||
}
|
||||
|
||||
for field, account_key in key_mappings.items():
|
||||
if field in data:
|
||||
AccountSettings.objects.update_or_create(
|
||||
account=account,
|
||||
key=account_key,
|
||||
defaults={'config': {'value': data[field]}}
|
||||
)
|
||||
saved_keys.append(account_key)
|
||||
|
||||
logger.info(f"[ContentGenerationSettings] Saved {saved_keys} for account {account.id}")
|
||||
|
||||
return success_response(
|
||||
data={'saved_keys': saved_keys},
|
||||
message='AI settings saved successfully',
|
||||
request=request
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving AI settings: {e}", exc_info=True)
|
||||
return error_response(
|
||||
error=str(e),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user