This commit is contained in:
alorig
2025-12-24 01:58:22 +05:00
60 changed files with 12275 additions and 1272 deletions

View File

@@ -8,8 +8,8 @@ from unfold.admin import ModelAdmin
from simple_history.admin import SimpleHistoryAdmin
from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin
from igny8_core.business.billing.models import (
AIModelConfig,
CreditCostConfig,
BillingConfiguration,
Invoice,
Payment,
CreditPackage,
@@ -17,7 +17,7 @@ from igny8_core.business.billing.models import (
PlanLimitUsage,
)
from .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod
from import_export.admin import ExportMixin, ImportExportMixin
from import_export.admin import ExportMixin
from import_export import resources
from rangefilter.filters import DateRangeFilter
@@ -50,21 +50,43 @@ class CreditTransactionAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
get_account_display.short_description = 'Account'
class CreditUsageLogResource(resources.ModelResource):
"""Resource class for exporting Credit Usage Logs"""
class Meta:
model = CreditUsageLog
fields = ('id', 'account__name', 'operation_type', 'credits_used', 'cost_usd',
'model_used', 'created_at')
export_order = fields
@admin.register(AIModelConfig)
class AIModelConfigAdmin(Igny8ModelAdmin):
list_display = ['display_name', 'model_name', 'provider', 'model_type', 'tokens_per_credit', 'cost_per_1k_input_tokens', 'cost_per_1k_output_tokens', 'is_active', 'is_default']
list_filter = ['provider', 'model_type', 'is_active', 'is_default']
search_fields = ['model_name', 'display_name', 'description']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Model Information', {
'fields': ('model_name', 'display_name', 'description', 'provider', 'model_type')
}),
('Pricing', {
'fields': ('cost_per_1k_input_tokens', 'cost_per_1k_output_tokens', 'tokens_per_credit')
}),
('Status', {
'fields': ('is_active', 'is_default')
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
def save_model(self, request, obj, form, change):
# If setting as default, unset other defaults of same type
if obj.is_default:
AIModelConfig.objects.filter(
model_type=obj.model_type,
is_default=True
).exclude(pk=obj.pk).update(is_default=False)
super().save_model(request, obj, form, change)
@admin.register(CreditUsageLog)
class CreditUsageLogAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = CreditUsageLogResource
list_display = ['id', 'account', 'operation_type', 'credits_used', 'cost_usd', 'model_used', 'created_at']
list_filter = ['operation_type', 'created_at', 'account', 'model_used']
search_fields = ['account__name', 'model_used']
class CreditUsageLogAdmin(AccountAdminMixin, Igny8ModelAdmin):
list_display = ['id', 'account', 'operation_type', 'credits_used', 'cost_usd', 'model_config', 'created_at']
list_filter = ['operation_type', 'created_at', 'account', 'model_config']
search_fields = ['account__name', 'model_name']
readonly_fields = ['created_at']
date_hierarchy = 'created_at'
@@ -78,18 +100,8 @@ class CreditUsageLogAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
get_account_display.short_description = 'Account'
class InvoiceResource(resources.ModelResource):
"""Resource class for exporting Invoices"""
class Meta:
model = Invoice
fields = ('id', 'invoice_number', 'account__name', 'status', 'total', 'currency',
'invoice_date', 'due_date', 'created_at', 'updated_at')
export_order = fields
@admin.register(Invoice)
class InvoiceAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = InvoiceResource
class InvoiceAdmin(AccountAdminMixin, Igny8ModelAdmin):
list_display = [
'invoice_number',
'account',
@@ -102,56 +114,6 @@ class InvoiceAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
list_filter = ['status', 'currency', 'invoice_date', 'account']
search_fields = ['invoice_number', 'account__name']
readonly_fields = ['created_at', 'updated_at']
actions = [
'bulk_set_status_draft',
'bulk_set_status_sent',
'bulk_set_status_paid',
'bulk_set_status_overdue',
'bulk_set_status_cancelled',
'bulk_send_reminders',
]
def bulk_set_status_draft(self, request, queryset):
"""Set selected invoices to draft status"""
updated = queryset.update(status='draft')
self.message_user(request, f'{updated} invoice(s) set to draft.', messages.SUCCESS)
bulk_set_status_draft.short_description = 'Set status to Draft'
def bulk_set_status_sent(self, request, queryset):
"""Set selected invoices to sent status"""
updated = queryset.update(status='sent')
self.message_user(request, f'{updated} invoice(s) set to sent.', messages.SUCCESS)
bulk_set_status_sent.short_description = 'Set status to Sent'
def bulk_set_status_paid(self, request, queryset):
"""Set selected invoices to paid status"""
updated = queryset.update(status='paid')
self.message_user(request, f'{updated} invoice(s) set to paid.', messages.SUCCESS)
bulk_set_status_paid.short_description = 'Set status to Paid'
def bulk_set_status_overdue(self, request, queryset):
"""Set selected invoices to overdue status"""
updated = queryset.update(status='overdue')
self.message_user(request, f'{updated} invoice(s) set to overdue.', messages.SUCCESS)
bulk_set_status_overdue.short_description = 'Set status to Overdue'
def bulk_set_status_cancelled(self, request, queryset):
"""Set selected invoices to cancelled status"""
updated = queryset.update(status='cancelled')
self.message_user(request, f'{updated} invoice(s) set to cancelled.', messages.SUCCESS)
bulk_set_status_cancelled.short_description = 'Set status to Cancelled'
def bulk_send_reminders(self, request, queryset):
"""Send reminder emails for selected invoices"""
# TODO: Implement email sending logic when email service is configured
unpaid = queryset.filter(status__in=['sent', 'overdue'])
count = unpaid.count()
self.message_user(
request,
f'{count} invoice reminder(s) queued for sending. (Email integration required)',
messages.INFO
)
bulk_send_reminders.short_description = 'Send payment reminders'
class PaymentResource(resources.ModelResource):
@@ -198,7 +160,7 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
'manual_notes'
]
readonly_fields = ['created_at', 'updated_at', 'approved_at', 'processed_at', 'failed_at', 'refunded_at']
actions = ['approve_payments', 'reject_payments', 'bulk_refund']
actions = ['approve_payments', 'reject_payments']
fieldsets = (
('Payment Info', {
@@ -444,71 +406,14 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
self.message_user(request, f'Rejected {count} payment(s)')
reject_payments.short_description = 'Reject selected manual payments'
def bulk_refund(self, request, queryset):
"""Refund selected payments"""
from django.utils import timezone
# Only refund succeeded payments
succeeded_payments = queryset.filter(status='succeeded')
count = 0
for payment in succeeded_payments:
# Mark as refunded
payment.status = 'refunded'
payment.refunded_at = timezone.now()
payment.admin_notes = f'{payment.admin_notes or ""}\nBulk refunded by {request.user.email} on {timezone.now()}'
payment.save()
# TODO: Process actual refund through payment gateway (Stripe/PayPal)
# For now, just marking as refunded in database
count += 1
self.message_user(
request,
f'{count} payment(s) marked as refunded. Note: Actual gateway refunds need to be processed separately.',
messages.WARNING
)
bulk_refund.short_description = 'Refund selected payments'
class CreditPackageResource(resources.ModelResource):
"""Resource class for importing/exporting Credit Packages"""
class Meta:
model = CreditPackage
fields = ('id', 'name', 'slug', 'credits', 'price', 'discount_percentage',
'is_active', 'is_featured', 'sort_order', 'created_at')
export_order = fields
import_id_fields = ('id',)
skip_unchanged = True
@admin.register(CreditPackage)
class CreditPackageAdmin(ImportExportMixin, Igny8ModelAdmin):
resource_class = CreditPackageResource
class CreditPackageAdmin(Igny8ModelAdmin):
list_display = ['name', 'slug', 'credits', 'price', 'discount_percentage', 'is_active', 'is_featured', 'sort_order']
list_filter = ['is_active', 'is_featured']
search_fields = ['name', 'slug']
readonly_fields = ['created_at', 'updated_at']
actions = [
'bulk_activate',
'bulk_deactivate',
]
actions = [
'bulk_activate',
'bulk_deactivate',
]
def bulk_activate(self, request, queryset):
updated = queryset.update(is_active=True)
self.message_user(request, f'{updated} credit package(s) activated.', messages.SUCCESS)
bulk_activate.short_description = 'Activate selected packages'
def bulk_deactivate(self, request, queryset):
updated = queryset.update(is_active=False)
self.message_user(request, f'{updated} credit package(s) deactivated.', messages.SUCCESS)
bulk_deactivate.short_description = 'Deactivate selected packages'
@admin.register(PaymentMethodConfig)
@@ -554,57 +459,55 @@ class CreditCostConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
list_display = [
'operation_type',
'display_name',
'tokens_per_credit_display',
'price_per_credit_usd',
'min_credits',
'credits_cost_display',
'unit',
'is_active',
'cost_change_indicator',
'updated_at',
'updated_by'
]
list_filter = ['is_active', 'updated_at']
list_filter = ['is_active', 'unit', 'updated_at']
search_fields = ['operation_type', 'display_name', 'description']
fieldsets = (
('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'
('Cost Configuration', {
'fields': ('credits_cost', 'unit', 'is_active')
}),
('Audit Trail', {
'fields': ('previous_tokens_per_credit', 'updated_by', 'created_at', 'updated_at'),
'fields': ('previous_cost', 'updated_by', 'created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ['created_at', 'updated_at', 'previous_tokens_per_credit']
readonly_fields = ['created_at', 'updated_at', 'previous_cost']
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:
def credits_cost_display(self, obj):
"""Show cost with color coding"""
if obj.credits_cost >= 20:
color = 'red'
elif obj.credits_cost >= 10:
color = 'orange'
else:
color = 'green' # Cheap (high tokens per credit)
color = 'green'
return format_html(
'<span style="color: {}; font-weight: bold;">{} tokens/credit</span>',
'<span style="color: {}; font-weight: bold;">{} credits</span>',
color,
obj.tokens_per_credit
obj.credits_cost
)
tokens_per_credit_display.short_description = 'Token Ratio'
credits_cost_display.short_description = 'Cost'
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)
"""Show if cost changed recently"""
if obj.previous_cost is not None:
if obj.credits_cost > obj.previous_cost:
icon = '📈' # Increased
color = 'red'
elif obj.tokens_per_credit > obj.previous_tokens_per_credit:
icon = '📉' # Cheaper (more tokens per credit)
elif obj.credits_cost < obj.previous_cost:
icon = '📉' # Decreased
color = 'green'
else:
icon = '➡️' # Same
@@ -614,8 +517,8 @@ class CreditCostConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
'{} <span style="color: {};">({}{})</span>',
icon,
color,
obj.previous_tokens_per_credit,
obj.tokens_per_credit
obj.previous_cost,
obj.credits_cost
)
return ''
cost_change_indicator.short_description = 'Recent Change'
@@ -626,18 +529,8 @@ class CreditCostConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
super().save_model(request, obj, form, change)
class PlanLimitUsageResource(resources.ModelResource):
"""Resource class for exporting Plan Limit Usage"""
class Meta:
model = PlanLimitUsage
fields = ('id', 'account__name', 'limit_type', 'amount_used',
'period_start', 'period_end', 'created_at')
export_order = fields
@admin.register(PlanLimitUsage)
class PlanLimitUsageAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = PlanLimitUsageResource
class PlanLimitUsageAdmin(AccountAdminMixin, Igny8ModelAdmin):
"""Admin for tracking plan limit usage across billing periods"""
list_display = [
'account',
@@ -655,10 +548,6 @@ class PlanLimitUsageAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
search_fields = ['account__name']
readonly_fields = ['created_at', 'updated_at']
date_hierarchy = 'period_start'
actions = [
'bulk_reset_usage',
'bulk_delete_old_records',
]
fieldsets = (
('Usage Info', {
@@ -681,66 +570,4 @@ class PlanLimitUsageAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
"""Display billing period range"""
return f"{obj.period_start} to {obj.period_end}"
period_display.short_description = 'Billing Period'
def bulk_reset_usage(self, request, queryset):
"""Reset usage counters to zero"""
updated = queryset.update(amount_used=0)
self.message_user(request, f'{updated} usage counter(s) reset to zero.', messages.SUCCESS)
bulk_reset_usage.short_description = 'Reset usage counters'
def bulk_delete_old_records(self, request, queryset):
"""Delete usage records older than 1 year"""
from django.utils import timezone
from datetime import timedelta
cutoff_date = timezone.now() - timedelta(days=365)
old_records = queryset.filter(period_end__lt=cutoff_date)
count = old_records.count()
old_records.delete()
self.message_user(request, f'{count} old usage record(s) deleted (older than 1 year).', messages.SUCCESS)
bulk_delete_old_records.short_description = 'Delete old records (>1 year)'
@admin.register(BillingConfiguration)
class BillingConfigurationAdmin(Igny8ModelAdmin):
"""Admin for global billing configuration (Singleton)"""
list_display = [
'id',
'default_tokens_per_credit',
'default_credit_price_usd',
'credit_rounding_mode',
'enable_token_based_reporting',
'updated_at',
'updated_by'
]
fieldsets = (
('Global Token-to-Credit Settings', {
'fields': ('default_tokens_per_credit', 'default_credit_price_usd', 'credit_rounding_mode'),
'description': 'These settings apply when no operation-specific config exists'
}),
('Reporting Settings', {
'fields': ('enable_token_based_reporting',),
'description': 'Control token-based reporting features'
}),
('Audit Trail', {
'fields': ('updated_by', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ['updated_at']
def has_add_permission(self, request):
"""Only allow one instance (singleton)"""
from igny8_core.business.billing.models import BillingConfiguration
return not BillingConfiguration.objects.exists()
def has_delete_permission(self, request, obj=None):
"""Prevent deletion of the singleton"""
return False
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)

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.9 on 2025-12-23 04:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0017_add_history_tracking'),
]
operations = [
migrations.AlterField(
model_name='creditcostconfig',
name='operation_type',
field=models.CharField(choices=[('clustering', 'Keyword Clustering'), ('idea_generation', 'Content Ideas Generation'), ('content_generation', 'Content Generation'), ('image_generation', 'Image Generation'), ('image_prompt_extraction', 'Image Prompt Extraction'), ('linking', 'Internal Linking'), ('optimization', 'Content Optimization'), ('reparse', 'Content Reparse'), ('site_structure_generation', 'Site Structure Generation'), ('site_page_generation', 'Site Page Generation')], help_text='AI operation type', max_length=50, unique=True),
),
migrations.AlterField(
model_name='creditusagelog',
name='operation_type',
field=models.CharField(choices=[('clustering', 'Keyword Clustering'), ('idea_generation', 'Content Ideas Generation'), ('content_generation', 'Content Generation'), ('image_generation', 'Image Generation'), ('image_prompt_extraction', 'Image Prompt Extraction'), ('linking', 'Internal Linking'), ('optimization', 'Content Optimization'), ('reparse', 'Content Reparse'), ('site_structure_generation', 'Site Structure Generation'), ('site_page_generation', 'Site Page Generation'), ('ideas', 'Content Ideas Generation (Legacy)'), ('content', 'Content Generation (Legacy)'), ('images', 'Image Generation (Legacy)')], db_index=True, max_length=50),
),
migrations.AlterField(
model_name='historicalcreditcostconfig',
name='operation_type',
field=models.CharField(choices=[('clustering', 'Keyword Clustering'), ('idea_generation', 'Content Ideas Generation'), ('content_generation', 'Content Generation'), ('image_generation', 'Image Generation'), ('image_prompt_extraction', 'Image Prompt Extraction'), ('linking', 'Internal Linking'), ('optimization', 'Content Optimization'), ('reparse', 'Content Reparse'), ('site_structure_generation', 'Site Structure Generation'), ('site_page_generation', 'Site Page Generation')], db_index=True, help_text='AI operation type', max_length=50),
),
]

View File

@@ -0,0 +1,90 @@
# Generated by Django 5.2.9 on 2025-12-23 05:31
import django.core.validators
import django.db.models.deletion
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0018_update_operation_choices'),
]
operations = [
migrations.CreateModel(
name='AIModelConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('model_name', models.CharField(help_text='Technical model name (e.g., gpt-4-turbo, gpt-3.5-turbo)', max_length=100, unique=True)),
('provider', models.CharField(choices=[('openai', 'OpenAI'), ('anthropic', 'Anthropic'), ('runware', 'Runware'), ('other', 'Other')], help_text='AI provider', max_length=50)),
('model_type', models.CharField(choices=[('text', 'Text Generation'), ('image', 'Image Generation'), ('embedding', 'Embeddings')], default='text', help_text='Type of AI model', max_length=20)),
('cost_per_1k_input_tokens', models.DecimalField(decimal_places=6, default=Decimal('0.001'), help_text='Cost in USD per 1,000 input tokens', max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0'))])),
('cost_per_1k_output_tokens', models.DecimalField(decimal_places=6, default=Decimal('0.002'), help_text='Cost in USD per 1,000 output tokens', max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0'))])),
('tokens_per_credit', models.IntegerField(default=100, help_text='How many tokens equal 1 credit (e.g., 100 tokens = 1 credit)', validators=[django.core.validators.MinValueValidator(1)])),
('display_name', models.CharField(help_text="Human-readable name (e.g., 'GPT-4 Turbo (Premium)')", max_length=150)),
('description', models.TextField(blank=True, help_text='Model description and use cases')),
('is_active', models.BooleanField(default=True, help_text='Enable/disable this model')),
('is_default', models.BooleanField(default=False, help_text='Use as system-wide default model')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'AI Model Configuration',
'verbose_name_plural': 'AI Model Configurations',
'db_table': 'igny8_ai_model_config',
'ordering': ['provider', 'model_name'],
},
),
migrations.AddField(
model_name='creditusagelog',
name='cost_usd_input',
field=models.DecimalField(blank=True, decimal_places=6, help_text='USD cost for input tokens', max_digits=10, null=True),
),
migrations.AddField(
model_name='creditusagelog',
name='cost_usd_output',
field=models.DecimalField(blank=True, decimal_places=6, help_text='USD cost for output tokens', max_digits=10, null=True),
),
migrations.AddField(
model_name='creditusagelog',
name='cost_usd_total',
field=models.DecimalField(blank=True, decimal_places=6, help_text='Total USD cost (input + output)', max_digits=10, null=True),
),
migrations.AddField(
model_name='creditusagelog',
name='model_name',
field=models.CharField(blank=True, help_text='Model name (deprecated, use model_used FK)', max_length=100),
),
migrations.AlterField(
model_name='creditcostconfig',
name='unit',
field=models.CharField(choices=[('per_request', 'Per Request'), ('per_100_words', 'Per 100 Words'), ('per_200_words', 'Per 200 Words'), ('per_item', 'Per Item'), ('per_image', 'Per Image'), ('per_100_tokens', 'Per 100 Tokens'), ('per_1000_tokens', 'Per 1000 Tokens')], default='per_request', help_text='What the cost applies to', max_length=50),
),
migrations.AlterField(
model_name='creditusagelog',
name='cost_usd',
field=models.DecimalField(blank=True, decimal_places=4, help_text='Deprecated, use cost_usd_total', max_digits=10, null=True),
),
migrations.AlterField(
model_name='historicalcreditcostconfig',
name='unit',
field=models.CharField(choices=[('per_request', 'Per Request'), ('per_100_words', 'Per 100 Words'), ('per_200_words', 'Per 200 Words'), ('per_item', 'Per Item'), ('per_image', 'Per Image'), ('per_100_tokens', 'Per 100 Tokens'), ('per_1000_tokens', 'Per 1000 Tokens')], default='per_request', help_text='What the cost applies to', max_length=50),
),
migrations.AddField(
model_name='creditcostconfig',
name='default_model',
field=models.ForeignKey(blank=True, help_text='Default AI model for this operation (optional)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='operation_configs', to='billing.aimodelconfig'),
),
migrations.AddField(
model_name='historicalcreditcostconfig',
name='default_model',
field=models.ForeignKey(blank=True, db_constraint=False, help_text='Default AI model for this operation (optional)', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='billing.aimodelconfig'),
),
migrations.AlterField(
model_name='creditusagelog',
name='model_used',
field=models.ForeignKey(blank=True, help_text='AI model used for this operation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='usage_logs', to='billing.aimodelconfig'),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.9 on 2025-12-23 14:24
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0019_add_ai_model_config'),
]
operations = [
migrations.RemoveField(
model_name='creditusagelog',
name='model_used',
),
migrations.AddField(
model_name='creditusagelog',
name='model_config',
field=models.ForeignKey(blank=True, db_column='model_config_id', help_text='AI model configuration used', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='usage_logs', to='billing.aimodelconfig'),
),
migrations.AlterField(
model_name='creditusagelog',
name='model_name',
field=models.CharField(blank=True, help_text='Model name (deprecated, use model_config FK)', max_length=100),
),
]

View File

@@ -6,11 +6,11 @@ from unfold.admin import ModelAdmin
from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin
from .models import AIPrompt, IntegrationSettings, AuthorProfile, Strategy
from .global_settings_models import (
GlobalModuleSettings,
GlobalIntegrationSettings,
GlobalAIPrompt,
GlobalAuthorProfile,
GlobalStrategy,
GlobalModuleSettings,
)
from django.contrib import messages
@@ -59,8 +59,8 @@ except ImportError:
@admin.register(AIPrompt)
class AIPromptAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = AIPromptResource
list_display = ['id', 'prompt_type', 'account', 'is_customized', 'is_active', 'updated_at']
list_filter = ['prompt_type', 'is_active', 'is_customized', 'account']
list_display = ['id', 'prompt_type', 'account', 'is_active', 'updated_at']
list_filter = ['prompt_type', 'is_active', 'account']
search_fields = ['prompt_type']
readonly_fields = ['created_at', 'updated_at', 'default_prompt']
actions = [
@@ -71,11 +71,10 @@ class AIPromptAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin):
fieldsets = (
('Basic Info', {
'fields': ('account', 'prompt_type', 'is_active', 'is_customized')
'fields': ('account', 'prompt_type', 'is_active')
}),
('Prompt Content', {
'fields': ('prompt_value', 'default_prompt'),
'description': 'Customize prompt_value or reset to default_prompt'
'fields': ('prompt_value', 'default_prompt')
}),
('Timestamps', {
'fields': ('created_at', 'updated_at')
@@ -102,14 +101,14 @@ class AIPromptAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin):
bulk_deactivate.short_description = 'Deactivate selected prompts'
def bulk_reset_to_default(self, request, queryset):
"""Reset selected prompts to their global defaults"""
count = 0
for prompt in queryset:
if prompt.default_prompt:
prompt.reset_to_default()
prompt.prompt_value = prompt.default_prompt
prompt.save()
count += 1
self.message_user(request, f'{count} prompt(s) reset to default.', messages.SUCCESS)
bulk_reset_to_default.short_description = 'Reset selected prompts to global default'
self.message_user(request, f'{count} AI prompt(s) reset to default values.', messages.SUCCESS)
bulk_reset_to_default.short_description = 'Reset to default values'
class IntegrationSettingsResource(resources.ModelResource):
@@ -122,42 +121,36 @@ class IntegrationSettingsResource(resources.ModelResource):
@admin.register(IntegrationSettings)
class IntegrationSettingsAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
"""
Admin for per-account integration setting overrides.
IMPORTANT: This stores ONLY model selection and parameter overrides.
API keys come from GlobalIntegrationSettings and cannot be overridden.
Free plan users cannot create these - they must use global defaults.
"""
resource_class = IntegrationSettingsResource
list_display = ['id', 'integration_type', 'account', 'is_active', 'updated_at']
list_filter = ['integration_type', 'is_active', 'account']
search_fields = ['integration_type', 'account__name']
search_fields = ['integration_type']
readonly_fields = ['created_at', 'updated_at']
actions = [
'bulk_activate',
'bulk_deactivate',
'bulk_test_connection',
]
fieldsets = (
('Basic Info', {
'fields': ('account', 'integration_type', 'is_active')
}),
('Configuration Overrides', {
('Configuration', {
'fields': ('config',),
'description': (
'JSON overrides for model/parameter selection. '
'Fields: model, temperature, max_tokens, image_size, image_quality, etc. '
'Leave null to use global defaults. '
'Example: {"model": "gpt-4", "temperature": 0.8, "max_tokens": 4000} '
'WARNING: NEVER store API keys here - they come from GlobalIntegrationSettings'
)
'description': 'JSON configuration containing API keys and settings. Example: {"apiKey": "sk-...", "model": "gpt-4.1", "enabled": true}'
}),
('Timestamps', {
'fields': ('created_at', 'updated_at')
}),
)
def get_readonly_fields(self, request, obj=None):
"""Make config readonly when viewing to prevent accidental exposure"""
if obj: # Editing existing object
return self.readonly_fields + ['config']
return self.readonly_fields
def get_account_display(self, obj):
"""Safely get account name"""
try:
@@ -329,9 +322,49 @@ class StrategyAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin):
bulk_clone.short_description = 'Clone selected strategies'
# =============================================================================
@admin.register(GlobalModuleSettings)
class GlobalModuleSettingsAdmin(ModelAdmin):
"""Admin for Global Module Settings (Singleton)"""
list_display = [
'id',
'planner_enabled',
'writer_enabled',
'thinker_enabled',
'automation_enabled',
'site_builder_enabled',
'linker_enabled',
'optimizer_enabled',
'publisher_enabled',
]
fieldsets = (
('Module Toggles', {
'fields': (
'planner_enabled',
'writer_enabled',
'thinker_enabled',
'automation_enabled',
'site_builder_enabled',
'linker_enabled',
'optimizer_enabled',
'publisher_enabled',
),
'description': 'Platform-wide module enable/disable controls. Changes affect all accounts immediately.'
}),
)
def has_add_permission(self, request):
"""Only allow one instance (singleton)"""
return not self.model.objects.exists()
def has_delete_permission(self, request, obj=None):
"""Prevent deletion of singleton"""
return False
# =====================================================================================
# GLOBAL SETTINGS ADMIN - Platform-wide defaults
# =============================================================================
# =====================================================================================
@admin.register(GlobalIntegrationSettings)
class GlobalIntegrationSettingsAdmin(Igny8ModelAdmin):
@@ -370,9 +403,10 @@ class GlobalIntegrationSettingsAdmin(Igny8ModelAdmin):
return not GlobalIntegrationSettings.objects.exists()
def has_delete_permission(self, request, obj=None):
"""Dont allow deletion of singleton"""
"""Don't allow deletion of singleton"""
return False
@admin.register(GlobalAIPrompt)
class GlobalAIPromptAdmin(ExportMixin, Igny8ModelAdmin):
"""Admin for global AI prompt templates"""
@@ -445,56 +479,3 @@ class GlobalStrategyAdmin(ImportExportMixin, Igny8ModelAdmin):
"fields": ("created_at", "updated_at")
}),
)
@admin.register(GlobalModuleSettings)
class GlobalModuleSettingsAdmin(Igny8ModelAdmin):
"""
Admin for global module enable/disable settings.
Singleton model - only one record exists.
Controls which modules are available platform-wide.
"""
def has_add_permission(self, request):
"""Only allow one instance"""
return not GlobalModuleSettings.objects.exists()
def has_delete_permission(self, request, obj=None):
"""Prevent deletion of singleton"""
return False
fieldsets = (
('Module Availability (Platform-Wide)', {
'fields': (
'planner_enabled',
'writer_enabled',
'thinker_enabled',
'automation_enabled',
'site_builder_enabled',
'linker_enabled',
'optimizer_enabled',
'publisher_enabled',
),
'description': 'Control which modules are available across the entire platform. Disabled modules will not load for ANY user.'
}),
('Metadata', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ['created_at', 'updated_at']
list_display = [
'id',
'planner_enabled',
'writer_enabled',
'thinker_enabled',
'automation_enabled',
'site_builder_enabled',
'linker_enabled',
'optimizer_enabled',
'publisher_enabled',
'updated_at',
]

View File

@@ -7,6 +7,78 @@ from django.db import models
from django.conf import settings
class GlobalModuleSettings(models.Model):
"""
Global module enable/disable settings (platform-wide).
Singleton model - only one record exists (pk=1).
Controls which modules are available across the entire platform.
No per-account overrides allowed - this is admin-only control.
"""
planner_enabled = models.BooleanField(
default=True,
help_text="Enable Planner module platform-wide"
)
writer_enabled = models.BooleanField(
default=True,
help_text="Enable Writer module platform-wide"
)
thinker_enabled = models.BooleanField(
default=True,
help_text="Enable Thinker module platform-wide"
)
automation_enabled = models.BooleanField(
default=True,
help_text="Enable Automation module platform-wide"
)
site_builder_enabled = models.BooleanField(
default=True,
help_text="Enable Site Builder module platform-wide"
)
linker_enabled = models.BooleanField(
default=True,
help_text="Enable Linker module platform-wide"
)
optimizer_enabled = models.BooleanField(
default=True,
help_text="Enable Optimizer module platform-wide"
)
publisher_enabled = models.BooleanField(
default=True,
help_text="Enable Publisher module platform-wide"
)
created_at = models.DateTimeField(auto_now_add=True, null=True)
updated_at = models.DateTimeField(auto_now=True, null=True)
class Meta:
verbose_name = "Global Module Settings"
verbose_name_plural = "Global Module Settings"
db_table = "igny8_global_module_settings"
def __str__(self):
return "Global Module Settings"
@classmethod
def get_instance(cls):
"""Get or create the singleton instance"""
obj, created = cls.objects.get_or_create(pk=1)
return obj
def is_module_enabled(self, module_name: str) -> bool:
"""Check if a module is enabled"""
field_name = f"{module_name}_enabled"
return getattr(self, field_name, False)
def save(self, *args, **kwargs):
"""Enforce singleton pattern"""
self.pk = 1
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""Prevent deletion"""
pass
class GlobalIntegrationSettings(models.Model):
"""
Platform-wide API keys and default integration settings.
@@ -14,26 +86,12 @@ class GlobalIntegrationSettings(models.Model):
IMPORTANT:
- API keys stored here are used by ALL accounts (no exceptions)
- Model selections and parameters are defaults
- Model selections and parameters are defaults (linked to AIModelConfig)
- Accounts can override model/params via IntegrationSettings model
- Free plan: Cannot override, must use these defaults
- Starter/Growth/Scale: Can override model, temperature, tokens, etc.
"""
OPENAI_MODEL_CHOICES = [
('gpt-4.1', 'GPT-4.1 - $2.00 / $8.00 per 1M tokens'),
('gpt-4o-mini', 'GPT-4o mini - $0.15 / $0.60 per 1M tokens'),
('gpt-4o', 'GPT-4o - $2.50 / $10.00 per 1M tokens'),
('gpt-4-turbo-preview', 'GPT-4 Turbo Preview - $10.00 / $30.00 per 1M tokens'),
('gpt-5.1', 'GPT-5.1 - $1.25 / $10.00 per 1M tokens (16K)'),
('gpt-5.2', 'GPT-5.2 - $1.75 / $14.00 per 1M tokens (16K)'),
]
DALLE_MODEL_CHOICES = [
('dall-e-3', 'DALL·E 3 - $0.040 per image'),
('dall-e-2', 'DALL·E 2 - $0.020 per image'),
]
DALLE_SIZE_CHOICES = [
('1024x1024', '1024x1024 (Square)'),
('1792x1024', '1792x1024 (Landscape)'),
@@ -41,22 +99,6 @@ class GlobalIntegrationSettings(models.Model):
('512x512', '512x512 (Small Square)'),
]
DALLE_QUALITY_CHOICES = [
('standard', 'Standard'),
('hd', 'HD'),
]
DALLE_STYLE_CHOICES = [
('vivid', 'Vivid'),
('natural', 'Natural'),
]
RUNWARE_MODEL_CHOICES = [
('runware:97@1', 'Runware 97@1 - Versatile Model'),
('runware:100@1', 'Runware 100@1 - High Quality'),
('runware:101@1', 'Runware 101@1 - Fast Generation'),
]
IMAGE_QUALITY_CHOICES = [
('standard', 'Standard'),
('hd', 'HD'),
@@ -81,10 +123,13 @@ class GlobalIntegrationSettings(models.Model):
blank=True,
help_text="Platform OpenAI API key - used by ALL accounts"
)
openai_model = models.CharField(
max_length=100,
default='gpt-4o-mini',
choices=OPENAI_MODEL_CHOICES,
openai_model = models.ForeignKey(
'billing.AIModelConfig',
on_delete=models.PROTECT,
related_name='global_openai_text_model',
limit_choices_to={'provider': 'openai', 'model_type': 'text', 'is_active': True},
null=True,
blank=True,
help_text="Default text generation model (accounts can override if plan allows)"
)
openai_temperature = models.FloatField(
@@ -102,10 +147,13 @@ class GlobalIntegrationSettings(models.Model):
blank=True,
help_text="Platform DALL-E API key - used by ALL accounts (can be same as OpenAI)"
)
dalle_model = models.CharField(
max_length=100,
default='dall-e-3',
choices=DALLE_MODEL_CHOICES,
dalle_model = models.ForeignKey(
'billing.AIModelConfig',
on_delete=models.PROTECT,
related_name='global_dalle_model',
limit_choices_to={'provider': 'openai', 'model_type': 'image', 'is_active': True},
null=True,
blank=True,
help_text="Default DALL-E model (accounts can override if plan allows)"
)
dalle_size = models.CharField(
@@ -121,10 +169,13 @@ class GlobalIntegrationSettings(models.Model):
blank=True,
help_text="Platform Runware API key - used by ALL accounts"
)
runware_model = models.CharField(
max_length=100,
default='runware:97@1',
choices=RUNWARE_MODEL_CHOICES,
runware_model = models.ForeignKey(
'billing.AIModelConfig',
on_delete=models.PROTECT,
related_name='global_runware_model',
limit_choices_to={'provider': 'runware', 'model_type': 'image', 'is_active': True},
null=True,
blank=True,
help_text="Default Runware model (accounts can override if plan allows)"
)
@@ -345,74 +396,3 @@ class GlobalStrategy(models.Model):
def __str__(self):
return f"{self.name} ({self.get_category_display()})"
class GlobalModuleSettings(models.Model):
"""
Global module enable/disable settings (platform-wide).
Singleton model - only one record exists (pk=1).
Controls which modules are available across the entire platform.
No per-account overrides allowed - this is admin-only control.
"""
planner_enabled = models.BooleanField(
default=True,
help_text="Enable Planner module platform-wide"
)
writer_enabled = models.BooleanField(
default=True,
help_text="Enable Writer module platform-wide"
)
thinker_enabled = models.BooleanField(
default=True,
help_text="Enable Thinker module platform-wide"
)
automation_enabled = models.BooleanField(
default=True,
help_text="Enable Automation module platform-wide"
)
site_builder_enabled = models.BooleanField(
default=True,
help_text="Enable Site Builder module platform-wide"
)
linker_enabled = models.BooleanField(
default=True,
help_text="Enable Linker module platform-wide"
)
optimizer_enabled = models.BooleanField(
default=True,
help_text="Enable Optimizer module platform-wide"
)
publisher_enabled = models.BooleanField(
default=True,
help_text="Enable Publisher module platform-wide"
)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'igny8_global_module_settings'
verbose_name = 'Global Module Settings'
verbose_name_plural = 'Global Module Settings'
def __str__(self):
return "Global Module Settings"
def save(self, *args, **kwargs):
"""Enforce singleton pattern"""
self.pk = 1
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""Prevent deletion"""
pass
@classmethod
def get_instance(cls):
"""Get or create the singleton instance"""
obj, created = cls.objects.get_or_create(pk=1)
return obj
def is_module_enabled(self, module_name: str) -> bool:
"""Check if a module is enabled"""
field_name = f"{module_name}_enabled"
return getattr(self, field_name, False)

View File

@@ -10,7 +10,7 @@ from drf_spectacular.utils import extend_schema, extend_schema_view
from igny8_core.api.base import AccountModelViewSet
from igny8_core.api.response import success_response, error_response
from igny8_core.api.throttles import DebugScopedRateThrottle
from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner
from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsSystemAccountOrDeveloper
from django.conf import settings
logger = logging.getLogger(__name__)
@@ -30,12 +30,12 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
Following reference plugin pattern: WordPress uses update_option() for igny8_api_settings
We store in IntegrationSettings model with account isolation
Integration settings configured through Django admin interface.
Normal users can view settings but only Admin/Owner roles can modify.
IMPORTANT:
- GlobalIntegrationSettings (platform-wide API keys): Configured by admins only in Django admin
- IntegrationSettings (per-account model preferences): Accessible to all authenticated users
- Users can select which models to use but cannot configure API keys (those are platform-wide)
NOTE: Class-level permissions are [IsAuthenticatedAndActive, HasTenantAccess] only.
Individual actions override with IsAdminOrOwner where needed (save, test).
task_progress and get_image_generation_settings accessible to all authenticated users.
NOTE: All authenticated users with tenant access can configure their integration settings.
"""
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
@@ -45,15 +45,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
def get_permissions(self):
"""
Override permissions based on action.
- list, retrieve: authenticated users with tenant access (read-only)
- update, save, test: Admin/Owner roles only (write operations)
- task_progress, get_image_generation_settings: all authenticated users
All authenticated users with tenant access can configure their integration settings.
Note: Users can only select models (not configure API keys which are platform-wide in GlobalIntegrationSettings).
"""
if self.action in ['update', 'save_post', 'test_connection']:
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner]
else:
permission_classes = self.permission_classes
return [permission() for permission in permission_classes]
# All actions use base permissions: IsAuthenticatedAndActive, HasTenantAccess
return [permission() for permission in self.permission_classes]
def list(self, request):
"""List all integrations - for debugging URL patterns"""
@@ -90,16 +86,73 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
pk = kwargs.get('pk')
return self.save_settings(request, pk)
@action(detail=False, methods=['get'], url_path='available-models', url_name='available_models')
def available_models(self, request):
"""
Get available AI models from AIModelConfig
Returns models grouped by provider and type
"""
try:
from igny8_core.business.billing.models import AIModelConfig
# Get all active models
models = AIModelConfig.objects.filter(is_active=True).order_by('provider', 'model_type', 'model_name')
# Group by provider and type
grouped_models = {
'openai_text': [],
'openai_image': [],
'runware_image': [],
}
for model in models:
# Format display name with pricing
if model.model_type == 'text':
display_name = f"{model.model_name} - ${model.cost_per_1k_input_tokens:.2f} / ${model.cost_per_1k_output_tokens:.2f} per 1M tokens"
else: # image
# Calculate cost per image based on tokens_per_credit
cost_per_image = (model.tokens_per_credit or 1) * (model.cost_per_1k_input_tokens or 0) / 1000
display_name = f"{model.model_name} - ${cost_per_image:.4f} per image"
model_data = {
'value': model.model_name,
'label': display_name,
'provider': model.provider,
'model_type': model.model_type,
}
# Add to appropriate group
if model.provider == 'openai' and model.model_type == 'text':
grouped_models['openai_text'].append(model_data)
elif model.provider == 'openai' and model.model_type == 'image':
grouped_models['openai_image'].append(model_data)
elif model.provider == 'runware' and model.model_type == 'image':
grouped_models['runware_image'].append(model_data)
return success_response(
data=grouped_models,
request=request
)
except Exception as e:
logger.error(f"Error getting available models: {e}", exc_info=True)
return error_response(
error=f'Failed to get available models: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
@action(detail=True, methods=['post'], url_path='test', url_name='test',
permission_classes=[IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner])
permission_classes=[IsAuthenticatedAndActive, HasTenantAccess])
def test_connection(self, request, pk=None):
"""
Test API connection using platform API keys.
Tests OpenAI or Runware with current model selection.
Test API connection for OpenAI or Runware
Supports two modes:
- with_response=false: Simple connection test (GET /v1/models)
- with_response=true: Full response test with ping message
"""
integration_type = pk # 'openai', 'runware'
logger.info(f"[test_connection] Called for integration_type={integration_type}")
logger.info(f"[test_connection] Called for integration_type={integration_type}, user={getattr(request, 'user', None)}, account={getattr(request, 'account', None)}")
if not integration_type:
return error_response(
@@ -108,43 +161,79 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
request=request
)
# Get API key and config from request or saved settings
config = request.data.get('config', {}) if isinstance(request.data.get('config'), dict) else {}
api_key = request.data.get('apiKey') or config.get('apiKey')
# Merge request.data with config if config is a dict
if not isinstance(config, dict):
config = {}
if not api_key:
# Try to get from saved settings (account-specific override)
# CRITICAL FIX: Always use user.account directly, never request.account or default account
user = getattr(request, 'user', None)
account = None
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
account = getattr(user, 'account', None)
logger.info(f"[test_connection] Account from user.account: {account.id if account else None}")
if account:
try:
from .models import IntegrationSettings
logger.info(f"[test_connection] Looking for saved settings for account {account.id}")
saved_settings = IntegrationSettings.objects.get(
integration_type=integration_type,
account=account
)
api_key = saved_settings.config.get('apiKey')
logger.info(f"[test_connection] Found saved settings, has_apiKey={bool(api_key)}")
except IntegrationSettings.DoesNotExist:
logger.info(f"[test_connection] No account settings found, will try global settings")
pass
# If still no API key, get from GlobalIntegrationSettings
if not api_key:
logger.info(f"[test_connection] No API key in request or account settings, checking GlobalIntegrationSettings")
try:
from .global_settings_models import GlobalIntegrationSettings
global_settings = GlobalIntegrationSettings.objects.first()
if global_settings:
if integration_type == 'openai':
api_key = global_settings.openai_api_key
elif integration_type == 'runware':
api_key = global_settings.runware_api_key
logger.info(f"[test_connection] Got API key from GlobalIntegrationSettings, has_key={bool(api_key)}")
else:
logger.warning(f"[test_connection] No GlobalIntegrationSettings found")
except Exception as e:
logger.error(f"[test_connection] Error getting global settings: {e}")
if not api_key:
logger.error(f"[test_connection] No API key found in request or saved settings")
return error_response(
error='API key is required',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
logger.info(f"[test_connection] Testing {integration_type} connection with API key (length={len(api_key) if api_key else 0})")
try:
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
# Get platform API keys
global_settings = GlobalIntegrationSettings.get_instance()
# 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.',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
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.',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
return self._test_runware(api_key, request)
else:
return error_response(
error=f'Testing not supported for {integration_type}',
error=f'Validation not supported for {integration_type}',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
except Exception as e:
logger.error(f"Error testing {integration_type} connection: {str(e)}", exc_info=True)
import traceback
error_trace = traceback.format_exc()
logger.error(f"Full traceback: {error_trace}")
return error_response(
error=str(e),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -323,19 +412,12 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
"""
from igny8_core.utils.ai_processor import AIProcessor
# 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)
# Fallback to default account
if not account:
from igny8_core.auth.models import Account
try:
account = Account.objects.first()
except Exception:
pass
# Get account from user directly
# CRITICAL FIX: Always use user.account, never request.account or default account
user = getattr(request, 'user', None)
account = None
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
account = getattr(user, 'account', None)
try:
# EXACT match to reference plugin: core/admin/ajax.php line 4946-5003
@@ -471,24 +553,14 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
request=request
)
# Get account
logger.info("[generate_image] Step 1: Getting account")
account = getattr(request, 'account', None)
if not account:
user = getattr(request, 'user', None)
logger.info(f"[generate_image] No account in request, checking user: {user}")
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
account = getattr(user, 'account', None)
logger.info(f"[generate_image] Got account from user: {account}")
if not account:
logger.info("[generate_image] No account found, trying to get first account from DB")
from igny8_core.auth.models import Account
try:
account = Account.objects.first()
logger.info(f"[generate_image] Got first account from DB: {account}")
except Exception as e:
logger.error(f"[generate_image] Error getting account from DB: {e}")
pass
# Get account from user directly
# CRITICAL FIX: Always use user.account, never request.account or default account
logger.info("[generate_image] Step 1: Getting account from user")
user = getattr(request, 'user', None)
account = None
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
account = getattr(user, 'account', None)
logger.info(f"[generate_image] Got account from user: {account}")
if not account:
logger.error("[generate_image] ERROR: No account found, returning error response")
@@ -633,15 +705,18 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
return self.save_settings(request, integration_type)
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)
- Free plan: Should be blocked at frontend level
"""
integration_type = pk
"""Save integration settings"""
integration_type = pk # 'openai', 'runware', 'gsc'
logger.info(f"[save_settings] Called for integration_type={integration_type}, user={getattr(request, 'user', None)}, account={getattr(request, 'account', None)}")
# DEBUG: Log everything about the request
logger.info(f"[save_settings] === START DEBUG ===")
logger.info(f"[save_settings] integration_type={integration_type}")
logger.info(f"[save_settings] request.user={getattr(request, 'user', None)}")
logger.info(f"[save_settings] request.user.id={getattr(getattr(request, 'user', None), 'id', None)}")
logger.info(f"[save_settings] request.account={getattr(request, 'account', None)}")
logger.info(f"[save_settings] request.account.id={getattr(getattr(request, 'account', None), 'id', None) if hasattr(request, 'account') and request.account else 'NO ACCOUNT'}")
logger.info(f"[save_settings] request.account.name={getattr(getattr(request, 'account', None), 'name', None) if hasattr(request, 'account') and request.account else 'NO ACCOUNT'}")
logger.info(f"[save_settings] === END DEBUG ===")
if not integration_type:
return error_response(
@@ -654,117 +729,258 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
config = dict(request.data) if hasattr(request.data, 'dict') else (request.data if isinstance(request.data, dict) else {})
logger.info(f"[save_settings] Config keys: {list(config.keys()) if isinstance(config, dict) else 'Not a dict'}")
# Remove any API keys from config (security - they shouldn't be sent but just in case)
config.pop('apiKey', None)
config.pop('api_key', None)
config.pop('openai_api_key', None)
config.pop('dalle_api_key', None)
config.pop('runware_api_key', None)
config.pop('anthropic_api_key', None)
try:
# Get account
account = getattr(request, 'account', None)
logger.info(f"[save_settings] Account from request: {account.id if account else None}")
if not account:
user = getattr(request, 'user', None)
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
try:
account = getattr(user, 'account', None)
except Exception as e:
logger.warning(f"Error getting account from user: {e}")
account = None
if not account:
logger.error(f"[save_settings] No account found")
# CRITICAL FIX: Always get account from authenticated user, not from request.account
# request.account can be manipulated or set incorrectly by middleware/auth
# The user's account relationship is the source of truth for their integration settings
user = getattr(request, 'user', None)
if not user or not hasattr(user, 'is_authenticated') or not user.is_authenticated:
logger.error(f"[save_settings] User not authenticated")
return error_response(
error='Account not found. Please ensure you are logged in.',
status_code=status.HTTP_400_BAD_REQUEST,
error='Authentication required',
status_code=status.HTTP_401_UNAUTHORIZED,
request=request
)
logger.info(f"[save_settings] Using account: {account.id} ({account.name})")
# Get account directly from user.account relationship
account = getattr(user, 'account', None)
# 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
# CRITICAL SECURITY CHECK: Prevent saving to system accounts
if account and account.slug in ['aws-admin', 'system']:
logger.error(f"[save_settings] BLOCKED: Attempt to save to system account {account.slug} by user {user.id}")
logger.error(f"[save_settings] This indicates the user's account field is incorrectly set to a system account")
return error_response(
error=f'Cannot save integration settings: Your user account is incorrectly linked to system account "{account.slug}". Please contact administrator.',
status_code=status.HTTP_403_FORBIDDEN,
request=request
)
logger.info(f"[save_settings] Account from user.account: {account.id if account else None}")
# CRITICAL: Require valid account - do NOT allow saving without proper account
if not account:
logger.error(f"[save_settings] No account found for user {user.id} ({user.email})")
return error_response(
error='Account not found. Please ensure your user has an account assigned.',
status_code=status.HTTP_401_UNAUTHORIZED,
request=request
)
logger.info(f"[save_settings] Using account: {account.id} ({account.name}, slug={account.slug}, status={account.status})")
# Store integration settings in a simple model or settings table
# For now, we'll use a simple approach - store in IntegrationSettings model
# or use Django settings/database
# Import IntegrationSettings model
from .models import IntegrationSettings
# Build clean config with only allowed overrides
clean_config = {}
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']
elif integration_type == 'image_generation':
# For image_generation, ensure provider is set correctly
if 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', 'mobile_enabled', 'featured_image_size', 'desktop_image_size']:
if key in config:
clean_config[key] = config[key]
if 'service' in config and 'provider' not in config:
config['provider'] = config['service']
# Ensure provider is set
if 'provider' not in config:
config['provider'] = config.get('service', 'openai')
# Set model based on provider
if config.get('provider') == 'openai' and 'model' not in config:
config['model'] = config.get('imageModel', 'dall-e-3')
elif config.get('provider') == 'runware' and 'model' not in config:
config['model'] = config.get('runwareModel', 'runware:97@1')
# Ensure all image settings have defaults (except max_in_article_images which must be explicitly set)
config.setdefault('image_type', 'realistic')
config.setdefault('image_format', 'webp')
config.setdefault('desktop_enabled', True)
config.setdefault('mobile_enabled', True)
# Set default image sizes based on provider/model
provider = config.get('provider', 'openai')
model = config.get('model', 'dall-e-3')
if not config.get('featured_image_size'):
if provider == 'runware':
config['featured_image_size'] = '1280x832'
else: # openai
config['featured_image_size'] = '1024x1024'
if not config.get('desktop_image_size'):
config['desktop_image_size'] = '1024x1024'
# 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}")
# Check if user is changing from global defaults
# Only save IntegrationSettings if config differs from global defaults
global_defaults = self._get_global_defaults(integration_type)
if not created:
integration_settings.config = clean_config
integration_settings.is_active = True
integration_settings.save()
logger.info(f"[save_settings] Updated existing settings")
# Compare config with global defaults (excluding 'enabled' and 'id' fields)
config_without_metadata = {k: v for k, v in config.items() if k not in ['enabled', 'id']}
defaults_without_keys = {k: v for k, v in global_defaults.items() if k not in ['apiKey', 'id']}
logger.info(f"[save_settings] Successfully saved overrides for {integration_type}")
# Check if user is actually changing model or other settings from defaults
is_custom_config = False
for key, value in config_without_metadata.items():
default_value = defaults_without_keys.get(key)
if default_value is not None and str(value) != str(default_value):
is_custom_config = True
logger.info(f"[save_settings] Custom value detected: {key}={value} (default={default_value})")
break
# Get global enabled status
from .global_settings_models import GlobalIntegrationSettings
global_settings_obj = GlobalIntegrationSettings.objects.first()
global_enabled = False
if global_settings_obj:
if integration_type == 'openai':
global_enabled = bool(global_settings_obj.openai_api_key)
elif integration_type == 'runware':
global_enabled = bool(global_settings_obj.runware_api_key)
elif integration_type == 'image_generation':
global_enabled = bool(global_settings_obj.openai_api_key or global_settings_obj.runware_api_key)
user_enabled = config.get('enabled', False)
# Save enable/disable state in IntegrationState model (single record per account)
from igny8_core.ai.models import IntegrationState
# Map integration_type to field name
field_map = {
'openai': 'is_openai_enabled',
'runware': 'is_runware_enabled',
'image_generation': 'is_image_generation_enabled',
}
field_name = field_map.get(integration_type)
if not field_name:
logger.error(f"[save_settings] Unknown integration_type: {integration_type}")
else:
logger.info(f"[save_settings] === CRITICAL DEBUG START ===")
logger.info(f"[save_settings] About to save IntegrationState for integration_type={integration_type}")
logger.info(f"[save_settings] Field name to update: {field_name}")
logger.info(f"[save_settings] Account being used: ID={account.id}, Name={account.name}, Slug={account.slug}")
logger.info(f"[save_settings] User enabled value: {user_enabled}")
logger.info(f"[save_settings] Request user: ID={request.user.id}, Email={request.user.email}")
logger.info(f"[save_settings] Request user account: ID={request.user.account.id if request.user.account else None}")
integration_state, created = IntegrationState.objects.get_or_create(
account=account,
defaults={
'is_openai_enabled': True,
'is_runware_enabled': True,
'is_image_generation_enabled': True,
}
)
logger.info(f"[save_settings] IntegrationState {'CREATED' if created else 'RETRIEVED'}")
logger.info(f"[save_settings] IntegrationState.account: ID={integration_state.account.id}, Name={integration_state.account.name}")
logger.info(f"[save_settings] Before update: {field_name}={getattr(integration_state, field_name)}")
# Update the specific field
setattr(integration_state, field_name, user_enabled)
integration_state.save()
logger.info(f"[save_settings] After update: {field_name}={getattr(integration_state, field_name)}")
logger.info(f"[save_settings] IntegrationState saved to database")
logger.info(f"[save_settings] === CRITICAL DEBUG END ===")
# Save custom config only if different from global defaults
if is_custom_config:
# User has custom settings (different model, etc.) - save override
logger.info(f"[save_settings] User has custom config, saving IntegrationSettings")
integration_settings, created = IntegrationSettings.objects.get_or_create(
integration_type=integration_type,
account=account,
defaults={'config': config_without_metadata, 'is_active': True}
)
if not created:
integration_settings.config = config_without_metadata
integration_settings.save()
logger.info(f"[save_settings] Updated IntegrationSettings config")
else:
logger.info(f"[save_settings] Created new IntegrationSettings for custom config")
else:
# Config matches global defaults - delete any existing override
logger.info(f"[save_settings] User settings match global defaults, removing any account override")
deleted_count, _ = IntegrationSettings.objects.filter(
integration_type=integration_type,
account=account
).delete()
if deleted_count > 0:
logger.info(f"[save_settings] Deleted {deleted_count} IntegrationSettings override(s)")
logger.info(f"[save_settings] Successfully saved settings for {integration_type}")
return success_response(
data={'config': clean_config},
data={'config': config},
message=f'{integration_type.upper()} settings saved successfully',
request=request
)
except Exception as e:
logger.error(f"Error saving integration settings for {integration_type}: {str(e)}", exc_info=True)
import traceback
error_trace = traceback.format_exc()
logger.error(f"Full traceback: {error_trace}")
return error_response(
error=f'Failed to save settings: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
def _get_global_defaults(self, integration_type):
"""Get global defaults from GlobalIntegrationSettings"""
try:
from .global_settings_models import GlobalIntegrationSettings
global_settings = GlobalIntegrationSettings.objects.first()
if not global_settings:
return {}
defaults = {}
# Map integration_type to GlobalIntegrationSettings fields
if integration_type == 'openai':
defaults = {
'apiKey': global_settings.openai_api_key or '',
'model': global_settings.openai_model.model_name if global_settings.openai_model else 'gpt-4o-mini',
'temperature': float(global_settings.openai_temperature or 0.7),
'maxTokens': int(global_settings.openai_max_tokens or 8192),
}
elif integration_type == 'runware':
defaults = {
'apiKey': global_settings.runware_api_key or '',
'model': global_settings.runware_model.model_name if global_settings.runware_model else 'runware:97@1',
}
elif integration_type == 'image_generation':
provider = global_settings.default_image_service or 'openai'
# Get model based on provider
if provider == 'openai':
model = global_settings.dalle_model.model_name if global_settings.dalle_model else 'dall-e-3'
else: # runware
model = global_settings.runware_model.model_name if global_settings.runware_model else 'runware:97@1'
defaults = {
'provider': provider,
'service': provider, # Alias
'model': model,
'imageModel': model if provider == 'openai' else None,
'runwareModel': model if provider == 'runware' else None,
'image_type': global_settings.image_style or 'vivid',
'image_quality': global_settings.image_quality or 'standard',
'max_in_article_images': global_settings.max_in_article_images or 5,
'desktop_image_size': global_settings.desktop_image_size or '1024x1024',
'mobile_image_size': global_settings.mobile_image_size or '512x512',
'featured_image_size': global_settings.desktop_image_size or '1024x1024',
'desktop_enabled': True,
'mobile_enabled': True,
}
logger.info(f"[_get_global_defaults] {integration_type} defaults: {defaults}")
return defaults
except Exception as e:
logger.error(f"Error getting global defaults for {integration_type}: {e}", exc_info=True)
return {}
def get_settings(self, request, pk=None):
"""
Get integration settings for frontend.
Returns:
- Global defaults (model, temperature, etc.)
- Account overrides if they exist
- NO API keys (platform-wide only)
"""
"""Get integration settings - merges global defaults with account-specific overrides"""
integration_type = pk
if not integration_type:
@@ -775,127 +991,128 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
)
try:
# Get account
account = getattr(request, 'account', None)
# CRITICAL FIX: Always get account from authenticated user, not from request.account
# Match the pattern used in save_settings() for consistency
user = getattr(request, 'user', None)
if not user or not hasattr(user, 'is_authenticated') or not user.is_authenticated:
logger.error(f"[get_settings] User not authenticated")
return error_response(
error='Authentication required',
status_code=status.HTTP_401_UNAUTHORIZED,
request=request
)
if not account:
user = getattr(request, 'user', None)
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
try:
account = getattr(user, 'account', None)
except Exception as e:
logger.warning(f"Error getting account from user: {e}")
account = None
# Get account directly from user.account relationship
account = getattr(user, 'account', None)
logger.info(f"[get_settings] Account from user.account: {account.id if account else None}")
from .models import IntegrationSettings
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
# Get global defaults
global_settings = GlobalIntegrationSettings.get_instance()
# Start with global defaults
global_defaults = self._get_global_defaults(integration_type)
# Build response with global defaults
if integration_type == 'openai':
response_data = {
'id': 'openai',
'enabled': True, # Always enabled (platform-wide)
'model': global_settings.openai_model,
'temperature': global_settings.openai_temperature,
'max_tokens': global_settings.openai_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',
'enabled': True, # Always enabled (platform-wide)
'using_global': True,
}
elif integration_type == 'image_generation':
# 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
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,
'image_format': 'webp',
'desktop_enabled': True,
'mobile_enabled': True,
'featured_image_size': global_settings.dalle_size,
'desktop_image_size': global_settings.desktop_image_size,
'mobile_image_size': global_settings.mobile_image_size,
'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', 'mobile_enabled', 'featured_image_size', 'desktop_image_size']:
if key in config:
response_data[key] = config[key]
except IntegrationSettings.DoesNotExist:
pass
# Get account-specific settings and merge
# Get account-specific enabled state from IntegrationState (single record)
from igny8_core.ai.models import IntegrationState
# Map integration_type to field name
field_map = {
'openai': 'is_openai_enabled',
'runware': 'is_runware_enabled',
'image_generation': 'is_image_generation_enabled',
}
account_enabled = None
if account:
try:
integration_state = IntegrationState.objects.get(account=account)
field_name = field_map.get(integration_type)
if field_name:
account_enabled = getattr(integration_state, field_name)
logger.info(f"[get_settings] Found IntegrationState.{field_name}={account_enabled}")
except IntegrationState.DoesNotExist:
logger.info(f"[get_settings] No IntegrationState found, will use global default")
# Try to get account-specific config overrides
if account:
try:
integration_settings = IntegrationSettings.objects.get(
integration_type=integration_type,
account=account
)
# Merge: global defaults + account overrides
merged_config = {**global_defaults, **integration_settings.config}
# Use account-specific enabled state if available, otherwise use global
if account_enabled is not None:
enabled_state = account_enabled
else:
# Fall back to global enabled logic
try:
from .global_settings_models import GlobalIntegrationSettings
global_settings = GlobalIntegrationSettings.objects.first()
if global_settings:
if integration_type == 'openai':
enabled_state = bool(global_settings.openai_api_key)
elif integration_type == 'runware':
enabled_state = bool(global_settings.runware_api_key)
elif integration_type == 'image_generation':
enabled_state = bool(global_settings.openai_api_key or global_settings.runware_api_key)
else:
enabled_state = False
else:
enabled_state = False
except Exception as e:
logger.error(f"Error checking global enabled status: {e}")
enabled_state = False
response_data = {
'id': integration_settings.integration_type,
'enabled': enabled_state,
**merged_config
}
logger.info(f"[get_settings] Merged settings for {integration_type}: enabled={enabled_state}")
return success_response(
data=response_data,
request=request
)
except IntegrationSettings.DoesNotExist:
logger.info(f"[get_settings] No account settings, returning global defaults for {integration_type}")
pass
except Exception as e:
logger.error(f"Error getting account-specific settings: {e}", exc_info=True)
# Return global defaults with account-specific enabled state if available
# Determine if integration is "enabled" based on IntegrationState or global configuration
if account_enabled is not None:
is_enabled = account_enabled
logger.info(f"[get_settings] Using account IntegrationState: enabled={is_enabled}")
else:
# Other integration types - return empty
response_data = {
'id': integration_type,
'enabled': False,
}
try:
from .global_settings_models import GlobalIntegrationSettings
global_settings = GlobalIntegrationSettings.objects.first()
# Check if global API keys are configured
is_enabled = False
if global_settings:
if integration_type == 'openai':
is_enabled = bool(global_settings.openai_api_key)
elif integration_type == 'runware':
is_enabled = bool(global_settings.runware_api_key)
elif integration_type == 'image_generation':
# Image generation is enabled if either OpenAI or Runware is configured
is_enabled = bool(global_settings.openai_api_key or global_settings.runware_api_key)
logger.info(f"[get_settings] Using global enabled status: enabled={is_enabled} (no account override)")
except Exception as e:
logger.error(f"Error checking global enabled status: {e}")
is_enabled = False
response_data = {
'id': integration_type,
'enabled': is_enabled,
**global_defaults
}
return success_response(
data=response_data,
request=request
@@ -910,23 +1127,12 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
@action(detail=False, methods=['get'], url_path='image_generation', url_name='image_generation_settings')
def get_image_generation_settings(self, request):
"""Get image generation settings for current account
Normal users fallback to system account (aws-admin) settings
"""
account = getattr(request, 'account', None)
if not account:
# Fallback to user's account
user = getattr(request, 'user', None)
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
account = getattr(user, 'account', None)
# Fallback to default account
if not account:
from igny8_core.auth.models import Account
try:
account = Account.objects.first()
except Exception:
pass
"""Get image generation settings for current account - merges global defaults with account overrides"""
# CRITICAL FIX: Always use user.account directly, never request.account or default account
user = getattr(request, 'user', None)
account = None
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
account = getattr(user, 'account', None)
if not account:
return error_response(
@@ -937,42 +1143,26 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
try:
from .models import IntegrationSettings
from igny8_core.auth.models import Account
# Try to get settings for user's account first
# Start with global defaults
global_defaults = self._get_global_defaults('image_generation')
# Try to get account-specific settings
try:
integration = IntegrationSettings.objects.get(
account=account,
integration_type='image_generation',
is_active=True
)
logger.info(f"[get_image_generation_settings] Found settings for account {account.id}")
config = {**global_defaults, **(integration.config or {})}
logger.info(f"[get_image_generation_settings] Found account settings, merged with globals")
except IntegrationSettings.DoesNotExist:
# Fallback to system account (aws-admin) settings - normal users use centralized settings
logger.info(f"[get_image_generation_settings] No settings for account {account.id}, falling back to system account")
try:
system_account = Account.objects.get(slug='aws-admin')
integration = IntegrationSettings.objects.get(
account=system_account,
integration_type='image_generation',
is_active=True
)
logger.info(f"[get_image_generation_settings] Using system account (aws-admin) settings")
except (Account.DoesNotExist, IntegrationSettings.DoesNotExist):
logger.error("[get_image_generation_settings] No image generation settings found in aws-admin account")
return error_response(
error='Image generation settings not configured in aws-admin account',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
config = integration.config or {}
# Use global defaults only
config = global_defaults
logger.info(f"[get_image_generation_settings] No account settings, using global defaults")
# Debug: Log what's actually in the config
logger.info(f"[get_image_generation_settings] Full config: {config}")
logger.info(f"[get_image_generation_settings] Config keys: {list(config.keys())}")
logger.info(f"[get_image_generation_settings] model field: {config.get('model')}")
logger.info(f"[get_image_generation_settings] imageModel field: {config.get('imageModel')}")
logger.info(f"[get_image_generation_settings] Final config: {config}")
# Get model - try 'model' first, then 'imageModel' as fallback
model = config.get('model') or config.get('imageModel') or 'dall-e-3'
@@ -997,12 +1187,6 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
},
request=request
)
except IntegrationSettings.DoesNotExist:
return error_response(
error='Image generation settings not configured',
status_code=status.HTTP_404_NOT_FOUND,
request=request
)
except Exception as e:
logger.error(f"[get_image_generation_settings] Error: {str(e)}", exc_info=True)
return error_response(

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.2.9 on 2025-12-23 05:32
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0019_add_ai_model_config'),
('system', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='integrationsettings',
name='default_image_model',
field=models.ForeignKey(blank=True, help_text='Default AI model for image generation operations', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='image_integrations', to='billing.aimodelconfig'),
),
migrations.AddField(
model_name='integrationsettings',
name='default_text_model',
field=models.ForeignKey(blank=True, help_text='Default AI model for text generation operations', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='text_integrations', to='billing.aimodelconfig'),
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django on 2025-12-23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('system', '0002_add_model_fk_to_integrations'),
]
operations = [
migrations.CreateModel(
name='GlobalModuleSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('planner_enabled', models.BooleanField(default=True, help_text='Enable Planner module platform-wide')),
('writer_enabled', models.BooleanField(default=True, help_text='Enable Writer module platform-wide')),
('thinker_enabled', models.BooleanField(default=True, help_text='Enable Thinker module platform-wide')),
('automation_enabled', models.BooleanField(default=True, help_text='Enable Automation module platform-wide')),
('site_builder_enabled', models.BooleanField(default=True, help_text='Enable Site Builder module platform-wide')),
('linker_enabled', models.BooleanField(default=True, help_text='Enable Linker module platform-wide')),
],
options={
'verbose_name': 'Global Module Settings',
'verbose_name_plural': 'Global Module Settings',
'db_table': 'igny8_global_module_settings',
},
),
]

View File

@@ -0,0 +1,106 @@
# Generated by Django 5.2.9 on 2025-12-23 08:40
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('system', '0003_globalmodulesettings'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='GlobalAIPrompt',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('prompt_type', models.CharField(choices=[('clustering', 'Clustering'), ('ideas', 'Ideas Generation'), ('content_generation', 'Content Generation'), ('image_prompt_extraction', 'Image Prompt Extraction'), ('image_prompt_template', 'Image Prompt Template'), ('negative_prompt', 'Negative Prompt'), ('site_structure_generation', 'Site Structure Generation'), ('product_generation', 'Product Content Generation'), ('service_generation', 'Service Page Generation'), ('taxonomy_generation', 'Taxonomy Generation')], help_text='Type of AI operation this prompt is for', max_length=50, unique=True)),
('prompt_value', models.TextField(help_text='Default prompt template')),
('description', models.TextField(blank=True, help_text='Description of what this prompt does')),
('variables', models.JSONField(blank=True, default=list, help_text='Optional: List of variables used in the prompt (e.g., {keyword}, {industry})')),
('is_active', models.BooleanField(db_index=True, default=True)),
('version', models.IntegerField(default=1, help_text='Prompt version for tracking changes')),
('last_updated', models.DateTimeField(auto_now=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'Global AI Prompt',
'verbose_name_plural': 'Global AI Prompts',
'db_table': 'igny8_global_ai_prompts',
'ordering': ['prompt_type'],
},
),
migrations.CreateModel(
name='GlobalAuthorProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text="Profile name (e.g., 'SaaS B2B Professional')", max_length=255, unique=True)),
('description', models.TextField(help_text='Description of the writing style')),
('tone', models.CharField(help_text="Writing tone (e.g., 'Professional', 'Casual', 'Technical')", max_length=100)),
('language', models.CharField(default='en', help_text='Language code', max_length=50)),
('structure_template', models.JSONField(default=dict, help_text='Structure template defining content sections')),
('category', models.CharField(choices=[('saas', 'SaaS/B2B'), ('ecommerce', 'E-commerce'), ('blog', 'Blog/Publishing'), ('technical', 'Technical'), ('creative', 'Creative'), ('news', 'News/Media'), ('academic', 'Academic')], help_text='Profile category', max_length=50)),
('is_active', models.BooleanField(db_index=True, default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Global Author Profile',
'verbose_name_plural': 'Global Author Profiles',
'db_table': 'igny8_global_author_profiles',
'ordering': ['category', 'name'],
},
),
migrations.CreateModel(
name='GlobalStrategy',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Strategy name', max_length=255, unique=True)),
('description', models.TextField(help_text='Description of the content strategy')),
('prompt_types', models.JSONField(default=list, help_text='List of prompt types to use')),
('section_logic', models.JSONField(default=dict, help_text='Section logic configuration')),
('category', models.CharField(choices=[('blog', 'Blog Content'), ('ecommerce', 'E-commerce'), ('saas', 'SaaS/B2B'), ('news', 'News/Media'), ('technical', 'Technical Documentation'), ('marketing', 'Marketing Content')], help_text='Strategy category', max_length=50)),
('is_active', models.BooleanField(db_index=True, default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Global Strategy',
'verbose_name_plural': 'Global Strategies',
'db_table': 'igny8_global_strategies',
'ordering': ['category', 'name'],
},
),
migrations.CreateModel(
name='GlobalIntegrationSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('openai_api_key', models.CharField(blank=True, help_text='Platform OpenAI API key - used by ALL accounts', max_length=500)),
('openai_model', models.CharField(choices=[('gpt-4.1', 'GPT-4.1 - $2.00 / $8.00 per 1M tokens'), ('gpt-4o-mini', 'GPT-4o mini - $0.15 / $0.60 per 1M tokens'), ('gpt-4o', 'GPT-4o - $2.50 / $10.00 per 1M tokens'), ('gpt-4-turbo-preview', 'GPT-4 Turbo Preview - $10.00 / $30.00 per 1M tokens'), ('gpt-5.1', 'GPT-5.1 - $1.25 / $10.00 per 1M tokens (16K)'), ('gpt-5.2', 'GPT-5.2 - $1.75 / $14.00 per 1M tokens (16K)')], default='gpt-4o-mini', help_text='Default text generation model (accounts can override if plan allows)', max_length=100)),
('openai_temperature', models.FloatField(default=0.7, help_text='Default temperature 0.0-2.0 (accounts can override if plan allows)')),
('openai_max_tokens', models.IntegerField(default=8192, help_text='Default max tokens for responses (accounts can override if plan allows)')),
('dalle_api_key', models.CharField(blank=True, help_text='Platform DALL-E API key - used by ALL accounts (can be same as OpenAI)', max_length=500)),
('dalle_model', models.CharField(choices=[('dall-e-3', 'DALL·E 3 - $0.040 per image'), ('dall-e-2', 'DALL·E 2 - $0.020 per image')], default='dall-e-3', help_text='Default DALL-E model (accounts can override if plan allows)', max_length=100)),
('dalle_size', models.CharField(choices=[('1024x1024', '1024x1024 (Square)'), ('1792x1024', '1792x1024 (Landscape)'), ('1024x1792', '1024x1792 (Portrait)'), ('512x512', '512x512 (Small Square)')], default='1024x1024', help_text='Default image size (accounts can override if plan allows)', max_length=20)),
('runware_api_key', models.CharField(blank=True, help_text='Platform Runware API key - used by ALL accounts', max_length=500)),
('runware_model', models.CharField(choices=[('runware:97@1', 'Runware 97@1 - Versatile Model'), ('runware:100@1', 'Runware 100@1 - High Quality'), ('runware:101@1', 'Runware 101@1 - Fast Generation')], default='runware:97@1', help_text='Default Runware model (accounts can override if plan allows)', max_length=100)),
('default_image_service', 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)', max_length=20)),
('image_quality', models.CharField(choices=[('standard', 'Standard'), ('hd', 'HD')], default='standard', help_text='Default image quality for all providers (accounts can override if plan allows)', max_length=20)),
('image_style', models.CharField(choices=[('vivid', 'Vivid'), ('natural', 'Natural'), ('realistic', 'Realistic'), ('artistic', 'Artistic'), ('cartoon', 'Cartoon')], default='realistic', help_text='Default image style for all providers (accounts can override if plan allows)', max_length=20)),
('max_in_article_images', models.IntegerField(default=2, help_text='Default maximum images to generate per article (1-5, accounts can override if plan allows)')),
('desktop_image_size', models.CharField(default='1024x1024', help_text='Default desktop image size (accounts can override if plan allows)', max_length=20)),
('mobile_image_size', models.CharField(default='512x512', help_text='Default mobile image size (accounts can override if plan allows)', max_length=20)),
('is_active', models.BooleanField(default=True)),
('last_updated', models.DateTimeField(auto_now=True)),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='global_settings_updates', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Global Integration Settings',
'verbose_name_plural': 'Global Integration Settings',
'db_table': 'igny8_global_integration_settings',
},
),
]

View File

@@ -0,0 +1,183 @@
# Generated by Django 5.2.9 on 2025-12-23 (custom data migration)
import django.db.models.deletion
from django.db import migrations, models
def migrate_model_strings_to_fks(apps, schema_editor):
"""Convert CharField model identifiers to ForeignKey references"""
GlobalIntegrationSettings = apps.get_model('system', 'GlobalIntegrationSettings')
AIModelConfig = apps.get_model('billing', 'AIModelConfig')
# Get the singleton GlobalIntegrationSettings instance
try:
settings = GlobalIntegrationSettings.objects.first()
if not settings:
print(" No GlobalIntegrationSettings found, skipping data migration")
return
# Map openai_model string to AIModelConfig FK
if settings.openai_model_old:
model_name = settings.openai_model_old
# Try to find matching model
openai_model = AIModelConfig.objects.filter(
model_name=model_name,
provider='openai',
model_type='text'
).first()
if openai_model:
settings.openai_model_new = openai_model
print(f" ✓ Mapped openai_model: {model_name}{openai_model.id}")
else:
# Try gpt-4o-mini as fallback
openai_model = AIModelConfig.objects.filter(
model_name='gpt-4o-mini',
provider='openai',
model_type='text'
).first()
if openai_model:
settings.openai_model_new = openai_model
print(f" ⚠ Could not find {model_name}, using fallback: gpt-4o-mini")
# Map dalle_model string to AIModelConfig FK
if settings.dalle_model_old:
model_name = settings.dalle_model_old
dalle_model = AIModelConfig.objects.filter(
model_name=model_name,
provider='openai',
model_type='image'
).first()
if dalle_model:
settings.dalle_model_new = dalle_model
print(f" ✓ Mapped dalle_model: {model_name}{dalle_model.id}")
else:
# Try dall-e-3 as fallback
dalle_model = AIModelConfig.objects.filter(
model_name='dall-e-3',
provider='openai',
model_type='image'
).first()
if dalle_model:
settings.dalle_model_new = dalle_model
print(f" ⚠ Could not find {model_name}, using fallback: dall-e-3")
# Map runware_model string to AIModelConfig FK
if settings.runware_model_old:
model_name = settings.runware_model_old
# Runware models might have different naming
runware_model = AIModelConfig.objects.filter(
provider='runware',
model_type='image'
).first() # Just get first active Runware model
if runware_model:
settings.runware_model_new = runware_model
print(f" ✓ Mapped runware_model: {model_name}{runware_model.id}")
settings.save()
print(" ✅ Data migration complete")
except Exception as e:
print(f" ⚠ Error during data migration: {e}")
# Don't fail the migration, let admin fix it manually
class Migration(migrations.Migration):
dependencies = [
('billing', '0019_add_ai_model_config'),
('system', '0004_add_global_integration_models'),
]
operations = [
# Step 1: Add new FK fields with temporary names
migrations.AddField(
model_name='globalintegrationsettings',
name='openai_model_new',
field=models.ForeignKey(
blank=True,
null=True,
help_text='Default text generation model (accounts can override if plan allows)',
limit_choices_to={'is_active': True, 'model_type': 'text', 'provider': 'openai'},
on_delete=django.db.models.deletion.PROTECT,
related_name='global_openai_text_model_new',
to='billing.aimodelconfig'
),
),
migrations.AddField(
model_name='globalintegrationsettings',
name='dalle_model_new',
field=models.ForeignKey(
blank=True,
null=True,
help_text='Default DALL-E model (accounts can override if plan allows)',
limit_choices_to={'is_active': True, 'model_type': 'image', 'provider': 'openai'},
on_delete=django.db.models.deletion.PROTECT,
related_name='global_dalle_model_new',
to='billing.aimodelconfig'
),
),
migrations.AddField(
model_name='globalintegrationsettings',
name='runware_model_new',
field=models.ForeignKey(
blank=True,
null=True,
help_text='Default Runware model (accounts can override if plan allows)',
limit_choices_to={'is_active': True, 'model_type': 'image', 'provider': 'runware'},
on_delete=django.db.models.deletion.PROTECT,
related_name='global_runware_model_new',
to='billing.aimodelconfig'
),
),
# Step 2: Rename old CharField fields
migrations.RenameField(
model_name='globalintegrationsettings',
old_name='openai_model',
new_name='openai_model_old',
),
migrations.RenameField(
model_name='globalintegrationsettings',
old_name='dalle_model',
new_name='dalle_model_old',
),
migrations.RenameField(
model_name='globalintegrationsettings',
old_name='runware_model',
new_name='runware_model_old',
),
# Step 3: Run data migration
migrations.RunPython(migrate_model_strings_to_fks, migrations.RunPython.noop),
# Step 4: Remove old CharField fields
migrations.RemoveField(
model_name='globalintegrationsettings',
name='openai_model_old',
),
migrations.RemoveField(
model_name='globalintegrationsettings',
name='dalle_model_old',
),
migrations.RemoveField(
model_name='globalintegrationsettings',
name='runware_model_old',
),
# Step 5: Rename new FK fields to final names
migrations.RenameField(
model_name='globalintegrationsettings',
old_name='openai_model_new',
new_name='openai_model',
),
migrations.RenameField(
model_name='globalintegrationsettings',
old_name='dalle_model_new',
new_name='dalle_model',
),
migrations.RenameField(
model_name='globalintegrationsettings',
old_name='runware_model_new',
new_name='runware_model',
),
]

View File

@@ -0,0 +1,50 @@
# Generated by Django 5.2.9 on 2025-12-23 14:24
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0020_add_optimizer_publisher_timestamps'),
('system', '0005_link_global_settings_to_aimodelconfig'),
]
operations = [
migrations.AddField(
model_name='globalmodulesettings',
name='created_at',
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='globalmodulesettings',
name='optimizer_enabled',
field=models.BooleanField(default=True, help_text='Enable Optimizer module platform-wide'),
),
migrations.AddField(
model_name='globalmodulesettings',
name='publisher_enabled',
field=models.BooleanField(default=True, help_text='Enable Publisher module platform-wide'),
),
migrations.AddField(
model_name='globalmodulesettings',
name='updated_at',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='globalintegrationsettings',
name='dalle_model',
field=models.ForeignKey(blank=True, help_text='Default DALL-E model (accounts can override if plan allows)', limit_choices_to={'is_active': True, 'model_type': 'image', 'provider': 'openai'}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='global_dalle_model', to='billing.aimodelconfig'),
),
migrations.AlterField(
model_name='globalintegrationsettings',
name='openai_model',
field=models.ForeignKey(blank=True, help_text='Default text generation model (accounts can override if plan allows)', limit_choices_to={'is_active': True, 'model_type': 'text', 'provider': 'openai'}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='global_openai_text_model', to='billing.aimodelconfig'),
),
migrations.AlterField(
model_name='globalintegrationsettings',
name='runware_model',
field=models.ForeignKey(blank=True, help_text='Default Runware model (accounts can override if plan allows)', limit_choices_to={'is_active': True, 'model_type': 'image', 'provider': 'runware'}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='global_runware_model', to='billing.aimodelconfig'),
),
]

View File

@@ -137,6 +137,25 @@ class IntegrationSettings(AccountBaseModel):
)
)
is_active = models.BooleanField(default=True)
# AI Model Selection (NEW)
default_text_model = models.ForeignKey(
'billing.AIModelConfig',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='text_integrations',
help_text="Default AI model for text generation operations"
)
default_image_model = models.ForeignKey(
'billing.AIModelConfig',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='image_integrations',
help_text="Default AI model for image generation operations"
)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.2.9 on 2025-12-20 20:39
# Generated by Django on 2025-12-23
from django.db import migrations, models