django admin Groups reorg, Frontend udpates for site settings, #Migration runs

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-05 01:21:52 +00:00
parent 6e30d2d4e8
commit dc7a459ebb
39 changed files with 3142 additions and 1589 deletions

View File

@@ -552,19 +552,18 @@ class AccountPaymentMethodAdmin(AccountAdminMixin, Igny8ModelAdmin):
@admin.register(CreditCostConfig)
class CreditCostConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
"""
Admin for Credit Cost Configuration.
Per final-model-schemas.md - Fixed credits per operation type.
"""
list_display = [
'operation_type',
'display_name',
'tokens_per_credit_display',
'price_per_credit_usd',
'min_credits',
'is_active',
'cost_change_indicator',
'updated_at',
'updated_by'
'base_credits_display',
'is_active_icon',
]
list_filter = ['is_active', 'updated_at']
list_filter = ['is_active']
search_fields = ['operation_type', 'display_name', 'description']
actions = ['bulk_activate', 'bulk_deactivate']
@@ -572,60 +571,30 @@ class CreditCostConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
('Operation', {
'fields': ('operation_type', 'display_name', 'description')
}),
('Token-to-Credit Configuration', {
'fields': ('tokens_per_credit', 'min_credits', 'price_per_credit_usd', 'is_active'),
'description': 'Configure how tokens are converted to credits for this operation'
}),
('Audit Trail', {
'fields': ('previous_tokens_per_credit', 'updated_by', 'created_at', 'updated_at'),
'classes': ('collapse',)
('Credits', {
'fields': ('base_credits', 'is_active'),
'description': 'Fixed credits charged per operation'
}),
)
readonly_fields = ['created_at', 'updated_at', 'previous_tokens_per_credit']
def tokens_per_credit_display(self, obj):
"""Show token ratio with color coding"""
if obj.tokens_per_credit <= 50:
color = 'red' # Expensive (low tokens per credit)
elif obj.tokens_per_credit <= 100:
color = 'orange'
else:
color = 'green' # Cheap (high tokens per credit)
def base_credits_display(self, obj):
"""Show base credits with formatting"""
return format_html(
'<span style="color: {}; font-weight: bold;">{} tokens/credit</span>',
color,
obj.tokens_per_credit
'<span style="font-weight: bold;">{} credits</span>',
obj.base_credits
)
tokens_per_credit_display.short_description = 'Token Ratio'
base_credits_display.short_description = 'Credits'
def cost_change_indicator(self, obj):
"""Show if token ratio changed recently"""
if obj.previous_tokens_per_credit is not None:
if obj.tokens_per_credit < obj.previous_tokens_per_credit:
icon = '📈' # More expensive (fewer tokens per credit)
color = 'red'
elif obj.tokens_per_credit > obj.previous_tokens_per_credit:
icon = '📉' # Cheaper (more tokens per credit)
color = 'green'
else:
icon = '➡️' # Same
color = 'gray'
def is_active_icon(self, obj):
"""Active status icon"""
if obj.is_active:
return format_html(
'{} <span style="color: {};">({}{})</span>',
icon,
color,
obj.previous_tokens_per_credit,
obj.tokens_per_credit
'<span style="color: green; font-size: 18px;" title="Active">●</span>'
)
return ''
cost_change_indicator.short_description = 'Recent Change'
def save_model(self, request, obj, form, change):
"""Track who made the change"""
obj.updated_by = request.user
super().save_model(request, obj, form, change)
return format_html(
'<span style="color: red; font-size: 18px;" title="Inactive">●</span>'
)
is_active_icon.short_description = 'Active'
@admin.action(description='Activate selected configurations')
def bulk_activate(self, request, queryset):
@@ -763,67 +732,60 @@ class BillingConfigurationAdmin(Igny8ModelAdmin):
@admin.register(AIModelConfig)
class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
"""
Admin for AI Model Configuration - Database-driven model pricing
Replaces hardcoded MODEL_RATES and IMAGE_MODEL_RATES
Admin for AI Model Configuration - Single Source of Truth for Models.
Per final-model-schemas.md
"""
list_display = [
'model_name',
'display_name_short',
'model_type_badge',
'provider_badge',
'pricing_display',
'credit_display',
'quality_tier',
'is_active_icon',
'is_default_icon',
'sort_order',
'updated_at',
]
list_filter = [
'model_type',
'provider',
'quality_tier',
'is_active',
'is_default',
'supports_json_mode',
'supports_vision',
'supports_function_calling',
]
search_fields = ['model_name', 'display_name', 'description']
search_fields = ['model_name', 'display_name']
ordering = ['model_type', 'sort_order', 'model_name']
ordering = ['model_type', 'model_name']
readonly_fields = ['created_at', 'updated_at', 'updated_by']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Basic Information', {
'fields': ('model_name', 'display_name', 'model_type', 'provider', 'description'),
'description': 'Core model identification and classification'
'fields': ('model_name', 'model_type', 'provider', 'display_name'),
'description': 'Core model identification'
}),
('Text Model Pricing', {
'fields': ('input_cost_per_1m', 'output_cost_per_1m', 'context_window', 'max_output_tokens'),
'description': 'Pricing and limits for TEXT models only (leave blank for image models)',
'fields': ('cost_per_1k_input', 'cost_per_1k_output', 'tokens_per_credit', 'max_tokens', 'context_window'),
'description': 'For TEXT models only',
'classes': ('collapse',)
}),
('Image Model Pricing', {
'fields': ('cost_per_image', 'valid_sizes'),
'description': 'Pricing and configuration for IMAGE models only (leave blank for text models)',
'fields': ('credits_per_image', 'quality_tier'),
'description': 'For IMAGE models only',
'classes': ('collapse',)
}),
('Capabilities', {
'fields': ('supports_json_mode', 'supports_vision', 'supports_function_calling'),
'description': 'Model features and capabilities'
}),
('Status & Display', {
'fields': ('is_active', 'is_default', 'sort_order'),
'description': 'Control model availability and ordering in dropdowns'
}),
('Lifecycle', {
'fields': ('release_date', 'deprecation_date'),
'description': 'Model release and deprecation dates',
'fields': ('capabilities',),
'description': 'JSON: vision, function_calling, json_mode, etc.',
'classes': ('collapse',)
}),
('Audit Trail', {
'fields': ('created_at', 'updated_at', 'updated_by'),
('Status', {
'fields': ('is_active', 'is_default'),
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
@@ -831,8 +793,8 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
# Custom display methods
def display_name_short(self, obj):
"""Truncated display name for list view"""
if len(obj.display_name) > 50:
return obj.display_name[:47] + '...'
if len(obj.display_name) > 40:
return obj.display_name[:37] + '...'
return obj.display_name
display_name_short.short_description = 'Display Name'
@@ -841,7 +803,6 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
colors = {
'text': '#3498db', # Blue
'image': '#e74c3c', # Red
'embedding': '#2ecc71', # Green
}
color = colors.get(obj.model_type, '#95a5a6')
return format_html(
@@ -855,10 +816,10 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
def provider_badge(self, obj):
"""Colored badge for provider"""
colors = {
'openai': '#10a37f', # OpenAI green
'anthropic': '#d97757', # Anthropic orange
'runware': '#6366f1', # Purple
'google': '#4285f4', # Google blue
'openai': '#10a37f',
'anthropic': '#d97757',
'runware': '#6366f1',
'google': '#4285f4',
}
color = colors.get(obj.provider, '#95a5a6')
return format_html(
@@ -869,23 +830,20 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
)
provider_badge.short_description = 'Provider'
def pricing_display(self, obj):
"""Format pricing based on model type"""
if obj.model_type == 'text':
def credit_display(self, obj):
"""Format credit info based on model type"""
if obj.model_type == 'text' and obj.tokens_per_credit:
return format_html(
'<span style="color: #2c3e50; font-family: monospace;">'
'${} / ${} per 1M</span>',
obj.input_cost_per_1m,
obj.output_cost_per_1m
'<span style="font-family: monospace;">{} tokens/credit</span>',
obj.tokens_per_credit
)
elif obj.model_type == 'image':
elif obj.model_type == 'image' and obj.credits_per_image:
return format_html(
'<span style="color: #2c3e50; font-family: monospace;">'
'${} per image</span>',
obj.cost_per_image
'<span style="font-family: monospace;">{} credits/image</span>',
obj.credits_per_image
)
return '-'
pricing_display.short_description = 'Pricing'
credit_display.short_description = 'Credits'
def is_active_icon(self, obj):
"""Active status icon"""
@@ -915,41 +873,27 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
def bulk_activate(self, request, queryset):
"""Enable selected models"""
count = queryset.update(is_active=True)
self.message_user(
request,
f'{count} model(s) activated successfully.',
messages.SUCCESS
)
self.message_user(request, f'{count} model(s) activated.', messages.SUCCESS)
bulk_activate.short_description = 'Activate selected models'
def bulk_deactivate(self, request, queryset):
"""Disable selected models"""
count = queryset.update(is_active=False)
self.message_user(
request,
f'{count} model(s) deactivated successfully.',
messages.WARNING
)
self.message_user(request, f'{count} model(s) deactivated.', messages.WARNING)
bulk_deactivate.short_description = 'Deactivate selected models'
def set_as_default(self, request, queryset):
"""Set one model as default for its type"""
if queryset.count() != 1:
self.message_user(
request,
'Please select exactly one model to set as default.',
messages.ERROR
)
self.message_user(request, 'Select exactly one model.', messages.ERROR)
return
model = queryset.first()
# Unset other defaults for same type
AIModelConfig.objects.filter(
model_type=model.model_type,
is_default=True
).exclude(pk=model.pk).update(is_default=False)
# Set this as default
model.is_default = True
model.save()
@@ -958,9 +902,4 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
f'{model.model_name} is now the default {model.get_model_type_display()} model.',
messages.SUCCESS
)
set_as_default.short_description = 'Set as default model (for its type)'
def save_model(self, request, obj, form, change):
"""Track who made the change"""
obj.updated_by = request.user
super().save_model(request, obj, form, change)
set_as_default.short_description = 'Set as default model'

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.9 on 2026-01-04 06:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0024_update_image_models_v2'),
]
operations = [
migrations.AddField(
model_name='aimodelconfig',
name='credits_per_image',
field=models.IntegerField(blank=True, help_text='Fixed credits per image generated. For image models only. (e.g., 1, 5, 15)', null=True),
),
migrations.AddField(
model_name='aimodelconfig',
name='quality_tier',
field=models.CharField(blank=True, choices=[('basic', 'Basic'), ('quality', 'Quality'), ('premium', 'Premium')], help_text='Quality tier for frontend UI display (Basic/Quality/Premium). For image models.', max_length=20, null=True),
),
migrations.AddField(
model_name='aimodelconfig',
name='tokens_per_credit',
field=models.IntegerField(blank=True, help_text='Number of tokens that equal 1 credit. For text models only. (e.g., 1000, 10000)', null=True),
),
migrations.AddField(
model_name='historicalaimodelconfig',
name='credits_per_image',
field=models.IntegerField(blank=True, help_text='Fixed credits per image generated. For image models only. (e.g., 1, 5, 15)', null=True),
),
migrations.AddField(
model_name='historicalaimodelconfig',
name='quality_tier',
field=models.CharField(blank=True, choices=[('basic', 'Basic'), ('quality', 'Quality'), ('premium', 'Premium')], help_text='Quality tier for frontend UI display (Basic/Quality/Premium). For image models.', max_length=20, null=True),
),
migrations.AddField(
model_name='historicalaimodelconfig',
name='tokens_per_credit',
field=models.IntegerField(blank=True, help_text='Number of tokens that equal 1 credit. For text models only. (e.g., 1000, 10000)', null=True),
),
]

View File

@@ -0,0 +1,63 @@
# Generated manually for data migration
from django.db import migrations
def populate_aimodel_credit_fields(apps, schema_editor):
"""
Populate credit calculation fields in AIModelConfig.
- Text models: tokens_per_credit (how many tokens = 1 credit)
- Image models: credits_per_image (fixed credits per image) + quality_tier
"""
AIModelConfig = apps.get_model('billing', 'AIModelConfig')
# Text models: tokens_per_credit
text_model_credits = {
'gpt-4o-mini': 10000, # Cheap model: 10k tokens = 1 credit
'gpt-4o': 1000, # Premium model: 1k tokens = 1 credit
'gpt-5.1': 1000, # Default model: 1k tokens = 1 credit
'gpt-5.2': 1000, # Future model
'gpt-4.1': 1000, # Legacy
'gpt-4-turbo-preview': 500, # Expensive
}
for model_name, tokens_per_credit in text_model_credits.items():
AIModelConfig.objects.filter(
model_name=model_name,
model_type='text'
).update(tokens_per_credit=tokens_per_credit)
# Image models: credits_per_image + quality_tier
image_model_credits = {
'runware:97@1': {'credits_per_image': 1, 'quality_tier': 'basic'}, # Basic - cheap
'dall-e-3': {'credits_per_image': 5, 'quality_tier': 'quality'}, # Quality - mid
'google:4@2': {'credits_per_image': 15, 'quality_tier': 'premium'}, # Premium - expensive
'dall-e-2': {'credits_per_image': 2, 'quality_tier': 'basic'}, # Legacy
}
for model_name, credits_data in image_model_credits.items():
AIModelConfig.objects.filter(
model_name=model_name,
model_type='image'
).update(**credits_data)
def reverse_migration(apps, schema_editor):
"""Clear credit fields"""
AIModelConfig = apps.get_model('billing', 'AIModelConfig')
AIModelConfig.objects.all().update(
tokens_per_credit=None,
credits_per_image=None,
quality_tier=None
)
class Migration(migrations.Migration):
dependencies = [
('billing', '0025_add_aimodel_credit_fields'),
]
operations = [
migrations.RunPython(populate_aimodel_credit_fields, reverse_migration),
]

View File

@@ -0,0 +1,356 @@
# Generated by Django 5.2.9 on 2026-01-04 10:40
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0026_populate_aimodel_credits'),
]
operations = [
migrations.AlterModelOptions(
name='aimodelconfig',
options={'ordering': ['model_type', 'model_name'], 'verbose_name': 'AI Model Configuration', 'verbose_name_plural': 'AI Model Configurations'},
),
migrations.RemoveField(
model_name='aimodelconfig',
name='cost_per_image',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='deprecation_date',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='description',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='input_cost_per_1m',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='max_output_tokens',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='output_cost_per_1m',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='release_date',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='sort_order',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='supports_function_calling',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='supports_json_mode',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='supports_vision',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='updated_by',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='valid_sizes',
),
migrations.RemoveField(
model_name='creditcostconfig',
name='created_at',
),
migrations.RemoveField(
model_name='creditcostconfig',
name='id',
),
migrations.RemoveField(
model_name='creditcostconfig',
name='min_credits',
),
migrations.RemoveField(
model_name='creditcostconfig',
name='previous_tokens_per_credit',
),
migrations.RemoveField(
model_name='creditcostconfig',
name='price_per_credit_usd',
),
migrations.RemoveField(
model_name='creditcostconfig',
name='tokens_per_credit',
),
migrations.RemoveField(
model_name='creditcostconfig',
name='updated_at',
),
migrations.RemoveField(
model_name='creditcostconfig',
name='updated_by',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='cost_per_image',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='deprecation_date',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='description',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='input_cost_per_1m',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='max_output_tokens',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='output_cost_per_1m',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='release_date',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='sort_order',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='supports_function_calling',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='supports_json_mode',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='supports_vision',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='updated_by',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='valid_sizes',
),
migrations.RemoveField(
model_name='historicalcreditcostconfig',
name='created_at',
),
migrations.RemoveField(
model_name='historicalcreditcostconfig',
name='id',
),
migrations.RemoveField(
model_name='historicalcreditcostconfig',
name='min_credits',
),
migrations.RemoveField(
model_name='historicalcreditcostconfig',
name='previous_tokens_per_credit',
),
migrations.RemoveField(
model_name='historicalcreditcostconfig',
name='price_per_credit_usd',
),
migrations.RemoveField(
model_name='historicalcreditcostconfig',
name='tokens_per_credit',
),
migrations.RemoveField(
model_name='historicalcreditcostconfig',
name='updated_at',
),
migrations.RemoveField(
model_name='historicalcreditcostconfig',
name='updated_by',
),
migrations.AddField(
model_name='aimodelconfig',
name='capabilities',
field=models.JSONField(blank=True, default=dict, help_text='Capabilities: vision, function_calling, json_mode, etc.'),
),
migrations.AddField(
model_name='aimodelconfig',
name='cost_per_1k_input',
field=models.DecimalField(blank=True, decimal_places=6, help_text='Provider cost per 1K input tokens (USD) - text models', max_digits=10, null=True),
),
migrations.AddField(
model_name='aimodelconfig',
name='cost_per_1k_output',
field=models.DecimalField(blank=True, decimal_places=6, help_text='Provider cost per 1K output tokens (USD) - text models', max_digits=10, null=True),
),
migrations.AddField(
model_name='aimodelconfig',
name='max_tokens',
field=models.IntegerField(blank=True, help_text='Model token limit', null=True),
),
migrations.AddField(
model_name='creditcostconfig',
name='base_credits',
field=models.IntegerField(default=1, help_text='Fixed credits per operation', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='historicalaimodelconfig',
name='capabilities',
field=models.JSONField(blank=True, default=dict, help_text='Capabilities: vision, function_calling, json_mode, etc.'),
),
migrations.AddField(
model_name='historicalaimodelconfig',
name='cost_per_1k_input',
field=models.DecimalField(blank=True, decimal_places=6, help_text='Provider cost per 1K input tokens (USD) - text models', max_digits=10, null=True),
),
migrations.AddField(
model_name='historicalaimodelconfig',
name='cost_per_1k_output',
field=models.DecimalField(blank=True, decimal_places=6, help_text='Provider cost per 1K output tokens (USD) - text models', max_digits=10, null=True),
),
migrations.AddField(
model_name='historicalaimodelconfig',
name='max_tokens',
field=models.IntegerField(blank=True, help_text='Model token limit', null=True),
),
migrations.AddField(
model_name='historicalcreditcostconfig',
name='base_credits',
field=models.IntegerField(default=1, help_text='Fixed credits per operation', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AlterField(
model_name='aimodelconfig',
name='context_window',
field=models.IntegerField(blank=True, help_text='Model context size', null=True),
),
migrations.AlterField(
model_name='aimodelconfig',
name='credits_per_image',
field=models.IntegerField(blank=True, help_text='Image: credits per image (e.g., 1, 5, 15)', null=True),
),
migrations.AlterField(
model_name='aimodelconfig',
name='display_name',
field=models.CharField(help_text='Human-readable name', max_length=200),
),
migrations.AlterField(
model_name='aimodelconfig',
name='is_active',
field=models.BooleanField(db_index=True, default=True, help_text='Enable/disable'),
),
migrations.AlterField(
model_name='aimodelconfig',
name='is_default',
field=models.BooleanField(db_index=True, default=False, help_text='One default per type'),
),
migrations.AlterField(
model_name='aimodelconfig',
name='model_name',
field=models.CharField(db_index=True, help_text="Model identifier (e.g., 'gpt-5.1', 'dall-e-3', 'runware:97@1')", max_length=100, unique=True),
),
migrations.AlterField(
model_name='aimodelconfig',
name='model_type',
field=models.CharField(choices=[('text', 'Text Generation'), ('image', 'Image Generation')], db_index=True, help_text='text / image', max_length=20),
),
migrations.AlterField(
model_name='aimodelconfig',
name='provider',
field=models.CharField(choices=[('openai', 'OpenAI'), ('anthropic', 'Anthropic'), ('runware', 'Runware'), ('google', 'Google')], db_index=True, help_text='Links to IntegrationProvider', max_length=50),
),
migrations.AlterField(
model_name='aimodelconfig',
name='quality_tier',
field=models.CharField(blank=True, choices=[('basic', 'Basic'), ('quality', 'Quality'), ('premium', 'Premium')], help_text='basic / quality / premium - for image models', max_length=20, null=True),
),
migrations.AlterField(
model_name='aimodelconfig',
name='tokens_per_credit',
field=models.IntegerField(blank=True, help_text='Text: tokens per 1 credit (e.g., 1000, 10000)', null=True),
),
migrations.AlterField(
model_name='creditcostconfig',
name='description',
field=models.TextField(blank=True, help_text='Admin notes about this operation'),
),
migrations.AlterField(
model_name='creditcostconfig',
name='operation_type',
field=models.CharField(help_text="Unique operation ID (e.g., 'article_generation', 'image_generation')", max_length=50, primary_key=True, serialize=False, unique=True),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='context_window',
field=models.IntegerField(blank=True, help_text='Model context size', null=True),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='credits_per_image',
field=models.IntegerField(blank=True, help_text='Image: credits per image (e.g., 1, 5, 15)', null=True),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='display_name',
field=models.CharField(help_text='Human-readable name', max_length=200),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='is_active',
field=models.BooleanField(db_index=True, default=True, help_text='Enable/disable'),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='is_default',
field=models.BooleanField(db_index=True, default=False, help_text='One default per type'),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='model_name',
field=models.CharField(db_index=True, help_text="Model identifier (e.g., 'gpt-5.1', 'dall-e-3', 'runware:97@1')", max_length=100),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='model_type',
field=models.CharField(choices=[('text', 'Text Generation'), ('image', 'Image Generation')], db_index=True, help_text='text / image', max_length=20),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='provider',
field=models.CharField(choices=[('openai', 'OpenAI'), ('anthropic', 'Anthropic'), ('runware', 'Runware'), ('google', 'Google')], db_index=True, help_text='Links to IntegrationProvider', max_length=50),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='quality_tier',
field=models.CharField(blank=True, choices=[('basic', 'Basic'), ('quality', 'Quality'), ('premium', 'Premium')], help_text='basic / quality / premium - for image models', max_length=20, null=True),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='tokens_per_credit',
field=models.IntegerField(blank=True, help_text='Text: tokens per 1 credit (e.g., 1000, 10000)', null=True),
),
migrations.AlterField(
model_name='historicalcreditcostconfig',
name='description',
field=models.TextField(blank=True, help_text='Admin notes about this operation'),
),
migrations.AlterField(
model_name='historicalcreditcostconfig',
name='operation_type',
field=models.CharField(db_index=True, help_text="Unique operation ID (e.g., 'article_generation', 'image_generation')", max_length=50),
),
]

View File

@@ -255,6 +255,23 @@ class AIModelConfigSerializer(serializers.Serializer):
)
valid_sizes = serializers.ListField(read_only=True, allow_null=True)
# Credit calculation fields (NEW)
credits_per_image = serializers.IntegerField(
read_only=True,
allow_null=True,
help_text="Credits charged per image generation"
)
tokens_per_credit = serializers.IntegerField(
read_only=True,
allow_null=True,
help_text="Tokens per credit for text models"
)
quality_tier = serializers.CharField(
read_only=True,
allow_null=True,
help_text="Quality tier: basic, quality, or premium"
)
# Capabilities
supports_json_mode = serializers.BooleanField(read_only=True)
supports_vision = serializers.BooleanField(read_only=True)

View File

@@ -789,7 +789,7 @@ class AIModelConfigViewSet(viewsets.ReadOnlyModelViewSet):
is_default_bool = is_default.lower() in ['true', '1', 'yes']
queryset = queryset.filter(is_default=is_default_bool)
return queryset.order_by('model_type', 'sort_order', 'model_name')
return queryset.order_by('model_type', 'model_name')
def get_serializer_class(self):
"""Return serializer class"""

View File

@@ -12,8 +12,10 @@ __all__ = [
'Strategy',
# Global settings models
'GlobalIntegrationSettings',
'AccountIntegrationOverride',
'GlobalAIPrompt',
'GlobalAuthorProfile',
'GlobalStrategy',
# New centralized models
'IntegrationProvider',
'AISettings',
]

View File

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

View 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

View File

@@ -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]

View File

@@ -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

View File

@@ -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'],
},
),
]

View File

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

View File

@@ -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
]

View File

@@ -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',
},
),
]

View File

@@ -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',
# ),
]

View File

@@ -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.

View File

@@ -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:

View File

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

View File

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

View File

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