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

@@ -82,10 +82,6 @@ def usage_report(request):
operation_count=Count('id')
).order_by('-total_credits')
# Format operation types as Title Case
for usage in usage_by_operation:
usage['operation_type'] = usage['operation_type'].replace('_', ' ').title() if usage['operation_type'] else 'Unknown'
# Top credit consumers
top_consumers = CreditUsageLog.objects.values(
'account__name'
@@ -260,7 +256,7 @@ def data_quality_report(request):
@staff_member_required
def token_usage_report(request):
"""Comprehensive token usage analytics with multi-dimensional insights"""
from igny8_core.business.billing.models import CreditUsageLog
from igny8_core.business.billing.models import CreditUsageLog, AIModelConfig
from igny8_core.auth.models import Account
from decimal import Decimal
@@ -285,19 +281,24 @@ def token_usage_report(request):
total_calls = logs.count()
avg_tokens_per_call = total_tokens / total_calls if total_calls > 0 else 0
# Token usage by model
token_by_model = logs.values('model_used').annotate(
# Token usage by model (using model_config FK)
token_by_model = logs.filter(model_config__isnull=False).values(
'model_config__model_name',
'model_config__display_name'
).annotate(
total_tokens_input=Sum('tokens_input'),
total_tokens_output=Sum('tokens_output'),
call_count=Count('id'),
total_cost=Sum('cost_usd')
total_cost_input=Sum('cost_usd_input'),
total_cost_output=Sum('cost_usd_output')
).order_by('-total_tokens_input')[:10]
# Add total_tokens to each model and sort by total
# Add total_tokens and total_cost to each model
for model in token_by_model:
model['total_tokens'] = (model['total_tokens_input'] or 0) + (model['total_tokens_output'] or 0)
model['total_cost'] = (model['total_cost_input'] or 0) + (model['total_cost_output'] or 0)
model['avg_tokens'] = model['total_tokens'] / model['call_count'] if model['call_count'] > 0 else 0
model['model'] = model['model_used'] # Add alias for template
model['model'] = model['model_config__display_name'] or model['model_config__model_name']
token_by_model = sorted(token_by_model, key=lambda x: x['total_tokens'], reverse=True)
# Token usage by function/operation
@@ -305,14 +306,13 @@ def token_usage_report(request):
total_tokens_input=Sum('tokens_input'),
total_tokens_output=Sum('tokens_output'),
call_count=Count('id'),
total_cost=Sum('cost_usd')
total_cost=Sum('cost_usd_total')
).order_by('-total_tokens_input')[:10]
# Add total_tokens to each function and sort by total
# Add total_tokens to each function
for func in token_by_function:
func['total_tokens'] = (func['total_tokens_input'] or 0) + (func['total_tokens_output'] or 0)
func['avg_tokens'] = func['total_tokens'] / func['call_count'] if func['call_count'] > 0 else 0
# Format operation_type as Title Case
func['function'] = func['operation_type'].replace('_', ' ').title() if func['operation_type'] else 'Unknown'
token_by_function = sorted(token_by_function, key=lambda x: x['total_tokens'], reverse=True)
@@ -321,10 +321,10 @@ def token_usage_report(request):
total_tokens_input=Sum('tokens_input'),
total_tokens_output=Sum('tokens_output'),
call_count=Count('id'),
total_cost=Sum('cost_usd')
total_cost=Sum('cost_usd_total')
).order_by('-total_tokens_input')[:15]
# Add total_tokens to each account and sort by total
# Add total_tokens to each account
for account in token_by_account:
account['total_tokens'] = (account['total_tokens_input'] or 0) + (account['total_tokens_output'] or 0)
token_by_account = sorted(token_by_account, key=lambda x: x['total_tokens'], reverse=True)[:15]
@@ -341,13 +341,12 @@ def token_usage_report(request):
daily_labels.append(day.strftime('%m/%d'))
daily_data.append(int(day_tokens))
# Token efficiency metrics (CreditUsageLog doesn't have error field, so assume all successful)
# Token efficiency metrics
success_rate = 100.0
successful_tokens = total_tokens
wasted_tokens = 0
# Create tokens_by_status for template compatibility
tokens_by_status = [{
tokens_by_status = [{
'error': None,
'total_tokens': total_tokens,
'call_count': total_calls,
@@ -368,7 +367,7 @@ def token_usage_report(request):
hour_data['token_count'] = (hour_data['token_input'] or 0) + (hour_data['token_output'] or 0)
# Cost efficiency
total_cost = logs.aggregate(total=Sum('cost_usd'))['total'] or Decimal('0.00')
total_cost = logs.aggregate(total=Sum('cost_usd_total'))['total'] or Decimal('0.00')
cost_per_1k_tokens = float(total_cost) / (total_tokens / 1000) if total_tokens > 0 else 0.0
context = {
@@ -389,7 +388,7 @@ def token_usage_report(request):
'hourly_usage': list(hourly_usage),
'total_cost': float(total_cost),
'cost_per_1k_tokens': float(cost_per_1k_tokens),
'current_app': '_reports', # For active menu state
'current_app': '_reports',
}
# Merge with admin context
@@ -403,7 +402,7 @@ def token_usage_report(request):
@staff_member_required
def ai_cost_analysis(request):
"""Multi-dimensional AI cost analysis with model pricing, trends, and predictions"""
from igny8_core.business.billing.models import CreditUsageLog
from igny8_core.business.billing.models import CreditUsageLog, AIModelConfig
from igny8_core.auth.models import Account
from decimal import Decimal
@@ -419,53 +418,55 @@ def ai_cost_analysis(request):
# Base queryset - filter for records with cost data
logs = CreditUsageLog.objects.filter(
created_at__gte=start_date,
cost_usd__isnull=False
cost_usd_total__isnull=False
)
# Overall cost metrics
total_cost = logs.aggregate(total=Sum('cost_usd'))['total'] or Decimal('0.00')
total_cost = logs.aggregate(total=Sum('cost_usd_total'))['total'] or Decimal('0.00')
total_calls = logs.count()
avg_cost_per_call = logs.aggregate(avg=Avg('cost_usd'))['avg'] or Decimal('0.00')
avg_cost_per_call = logs.aggregate(avg=Avg('cost_usd_total'))['avg'] or Decimal('0.00')
total_tokens_input = logs.aggregate(total=Sum('tokens_input'))['total'] or 0
total_tokens_output = logs.aggregate(total=Sum('tokens_output'))['total'] or 0
total_tokens = total_tokens_input + total_tokens_output
# Revenue & Margin calculation
from igny8_core.business.billing.models import BillingConfiguration
billing_config = BillingConfiguration.get_config()
total_credits_charged = logs.aggregate(total=Sum('credits_used'))['total'] or 0
total_revenue = Decimal(total_credits_charged) * billing_config.default_credit_price_usd
# Average credit price (simplified - in reality would vary by plan)
avg_credit_price = Decimal('0.01') # $0.01 per credit default
total_revenue = Decimal(total_credits_charged) * avg_credit_price
total_margin = total_revenue - total_cost
margin_percentage = float((total_margin / total_revenue * 100) if total_revenue > 0 else 0)
# Per-unit margins
# Calculate per 1M tokens (margin per million tokens)
margin_per_1m_tokens = float(total_margin) / (total_tokens / 1_000_000) if total_tokens > 0 else 0
# Calculate per 1K credits (margin per thousand credits)
margin_per_1k_credits = float(total_margin) / (total_credits_charged / 1000) if total_credits_charged > 0 else 0
# Cost by model with efficiency metrics
cost_by_model = logs.values('model_used').annotate(
total_cost=Sum('cost_usd'),
# Cost by model with efficiency metrics (using model_config FK)
cost_by_model = logs.filter(model_config__isnull=False).values(
'model_config__model_name',
'model_config__display_name'
).annotate(
total_cost=Sum('cost_usd_total'),
call_count=Count('id'),
avg_cost=Avg('cost_usd'),
avg_cost=Avg('cost_usd_total'),
total_tokens_input=Sum('tokens_input'),
total_tokens_output=Sum('tokens_output')
total_tokens_output=Sum('tokens_output'),
total_credits=Sum('credits_used')
).order_by('-total_cost')
# Add cost efficiency and margin for each model
for model in cost_by_model:
model['total_tokens'] = (model['total_tokens_input'] or 0) + (model['total_tokens_output'] or 0)
model['avg_tokens'] = model['total_tokens'] / model['call_count'] if model['call_count'] > 0 else 0
model['model'] = model['model_used'] # Add alias for template
model['model'] = model['model_config__display_name'] or model['model_config__model_name']
if model['total_tokens'] and model['total_tokens'] > 0:
model['cost_per_1k_tokens'] = float(model['total_cost']) / (model['total_tokens'] / 1000)
else:
model['cost_per_1k_tokens'] = 0
# Calculate margin for this model
model_credits = logs.filter(model_used=model['model_used']).aggregate(total=Sum('credits_used'))['total'] or 0
model_revenue = Decimal(model_credits) * billing_config.default_credit_price_usd
model_revenue = Decimal(model['total_credits'] or 0) * avg_credit_price
model_margin = model_revenue - model['total_cost']
model['revenue'] = float(model_revenue)
model['margin'] = float(model_margin)
@@ -473,11 +474,11 @@ def ai_cost_analysis(request):
# Cost by account (top spenders)
cost_by_account = logs.values('account__name', 'account_id').annotate(
total_cost=Sum('cost_usd'),
total_cost=Sum('cost_usd_total'),
call_count=Count('id'),
total_tokens_input=Sum('tokens_input'),
total_tokens_output=Sum('tokens_output'),
avg_cost=Avg('cost_usd')
avg_cost=Avg('cost_usd_total')
).order_by('-total_cost')[:15]
# Add total_tokens to each account
@@ -486,22 +487,21 @@ def ai_cost_analysis(request):
# Cost by function/operation
cost_by_function = logs.values('operation_type').annotate(
total_cost=Sum('cost_usd'),
total_cost=Sum('cost_usd_total'),
call_count=Count('id'),
avg_cost=Avg('cost_usd'),
avg_cost=Avg('cost_usd_total'),
total_tokens_input=Sum('tokens_input'),
total_tokens_output=Sum('tokens_output')
total_tokens_output=Sum('tokens_output'),
total_credits=Sum('credits_used')
).order_by('-total_cost')[:10]
# Add total_tokens, function alias, and margin
for func in cost_by_function:
func['total_tokens'] = (func['total_tokens_input'] or 0) + (func['total_tokens_output'] or 0)
# Format operation_type as Title Case
func['function'] = func['operation_type'].replace('_', ' ').title() if func['operation_type'] else 'Unknown'
# Calculate margin for this operation
func_credits = logs.filter(operation_type=func['operation_type']).aggregate(total=Sum('credits_used'))['total'] or 0
func_revenue = Decimal(func_credits) * billing_config.default_credit_price_usd
func_revenue = Decimal(func['total_credits'] or 0) * avg_credit_price
func_margin = func_revenue - func['total_cost']
func['revenue'] = float(func_revenue)
func['margin'] = float(func_margin)
@@ -515,7 +515,7 @@ def ai_cost_analysis(request):
for i in range(days):
day = timezone.now().date() - timedelta(days=days-i-1)
day_logs = logs.filter(created_at__date=day)
day_cost = day_logs.aggregate(total=Sum('cost_usd'))['total'] or Decimal('0.00')
day_cost = day_logs.aggregate(total=Sum('cost_usd_total'))['total'] or Decimal('0.00')
day_calls = day_logs.count()
daily_cost_labels.append(day.strftime('%m/%d'))
@@ -529,21 +529,26 @@ def ai_cost_analysis(request):
else:
projected_monthly = 0
# Failed requests cost (CreditUsageLog doesn't track errors, so no failed cost)
failed_cost = Decimal('0.00')
# Cost anomalies (calls costing > 3x average)
failed_cost = Decimal('0.00')
if avg_cost_per_call > 0:
anomaly_threshold = float(avg_cost_per_call) * 3
anomalies = logs.filter(cost_usd__gt=anomaly_threshold).values(
'model_used', 'operation_type', 'account__name', 'cost_usd', 'tokens_input', 'tokens_output', 'created_at'
).order_by('-cost_usd')[:10]
# Add aliases and calculate total tokens for each anomaly
anomalies = logs.filter(cost_usd_total__gt=anomaly_threshold).select_related('model_config').values(
'model_config__model_name',
'model_config__display_name',
'operation_type',
'account__name',
'cost_usd_total',
'tokens_input',
'tokens_output',
'created_at'
).order_by('-cost_usd_total')[:10]
# Add aliases for template
for anomaly in anomalies:
anomaly['model'] = anomaly['model_used']
# Format operation_type as Title Case
anomaly['model'] = anomaly['model_config__display_name'] or anomaly['model_config__model_name'] or 'Unknown'
anomaly['function'] = anomaly['operation_type'].replace('_', ' ').title() if anomaly['operation_type'] else 'Unknown'
anomaly['cost'] = anomaly['cost_usd']
anomaly['cost'] = anomaly['cost_usd_total']
anomaly['tokens'] = (anomaly['tokens_input'] or 0) + (anomaly['tokens_output'] or 0)
else:
anomalies = []
@@ -551,9 +556,8 @@ def ai_cost_analysis(request):
# Model comparison matrix
model_comparison = []
for model_data in cost_by_model:
model_name = model_data['model']
model_comparison.append({
'model': model_name,
'model': model_data['model'],
'total_cost': float(model_data['total_cost']),
'calls': model_data['call_count'],
'avg_cost': float(model_data['avg_cost']),
@@ -570,11 +574,11 @@ def ai_cost_analysis(request):
hourly_cost = logs.extra(
select={'hour': "EXTRACT(hour FROM created_at)"}
).values('hour').annotate(
total_cost=Sum('cost_usd'),
total_cost=Sum('cost_usd_total'),
call_count=Count('id')
).order_by('hour')
# Cost efficiency score (CreditUsageLog doesn't track errors, assume all successful)
# Cost efficiency score
successful_cost = total_cost
efficiency_score = 100.0
@@ -588,7 +592,7 @@ def ai_cost_analysis(request):
'margin_per_1m_tokens': round(margin_per_1m_tokens, 4),
'margin_per_1k_credits': round(margin_per_1k_credits, 4),
'total_credits_charged': total_credits_charged,
'credit_price': float(billing_config.default_credit_price_usd),
'credit_price': float(avg_credit_price),
'total_calls': total_calls,
'avg_cost_per_call': float(avg_cost_per_call),
'total_tokens': int(total_tokens),
@@ -606,7 +610,7 @@ def ai_cost_analysis(request):
'hourly_cost': list(hourly_cost),
'efficiency_score': round(efficiency_score, 2),
'successful_cost': float(successful_cost),
'current_app': '_reports', # For active menu state
'current_app': '_reports',
}
# Merge with admin context

View File

@@ -21,34 +21,23 @@ class Igny8AdminSite(UnfoldAdminSite):
index_title = 'IGNY8 Administration'
def get_urls(self):
"""Get admin URLs with dashboard, reports, and monitoring pages available"""
"""Get admin URLs with dashboard and reports available"""
from django.urls import path
from .dashboard import admin_dashboard
from .reports import (
revenue_report, usage_report, content_report, data_quality_report,
token_usage_report, ai_cost_analysis
)
from .monitoring import (
system_health_dashboard, api_monitor_dashboard, debug_console
)
urls = super().get_urls()
custom_urls = [
# Dashboard
path('dashboard/', self.admin_view(admin_dashboard), name='dashboard'),
# Reports
path('reports/revenue/', self.admin_view(revenue_report), name='report_revenue'),
path('reports/usage/', self.admin_view(usage_report), name='report_usage'),
path('reports/content/', self.admin_view(content_report), name='report_content'),
path('reports/data-quality/', self.admin_view(data_quality_report), name='report_data_quality'),
path('reports/token-usage/', self.admin_view(token_usage_report), name='report_token_usage'),
path('reports/ai-cost-analysis/', self.admin_view(ai_cost_analysis), name='report_ai_cost_analysis'),
# Monitoring (NEW)
path('monitoring/system-health/', self.admin_view(system_health_dashboard), name='monitoring_system_health'),
path('monitoring/api-monitor/', self.admin_view(api_monitor_dashboard), name='monitoring_api_monitor'),
path('monitoring/debug-console/', self.admin_view(debug_console), name='monitoring_debug_console'),
path('reports/ai-cost-analysis/', self.admin_view(ai_cost_analysis), name='report_ai_cost'),
]
return custom_urls + urls
@@ -157,15 +146,6 @@ class Igny8AdminSite(UnfoldAdminSite):
('igny8_core_auth', 'SeedKeyword'),
],
},
'Global Settings': {
'models': [
('system', 'GlobalIntegrationSettings'),
('system', 'GlobalModuleSettings'),
('system', 'GlobalAIPrompt'),
('system', 'GlobalAuthorProfile'),
('system', 'GlobalStrategy'),
],
},
'Plans and Billing': {
'models': [
('igny8_core_auth', 'Plan'),
@@ -218,7 +198,14 @@ class Igny8AdminSite(UnfoldAdminSite):
},
'AI & Automation': {
'models': [
('billing', 'AIModelConfig'),
('ai', 'IntegrationState'),
('system', 'IntegrationSettings'),
('system', 'GlobalModuleSettings'),
('system', 'GlobalIntegrationSettings'),
('system', 'GlobalAIPrompt'),
('system', 'GlobalAuthorProfile'),
('system', 'GlobalStrategy'),
('system', 'AIPrompt'),
('system', 'Strategy'),
('system', 'AuthorProfile'),

View File

@@ -4,7 +4,58 @@ Admin configuration for AI models
from django.contrib import admin
from unfold.admin import ModelAdmin
from igny8_core.admin.base import Igny8ModelAdmin
from igny8_core.ai.models import AITaskLog
from igny8_core.ai.models import AITaskLog, IntegrationState
from import_export.admin import ExportMixin
from import_export import resources
@admin.register(IntegrationState)
class IntegrationStateAdmin(Igny8ModelAdmin):
"""Admin interface for Integration States"""
list_display = [
'account',
'is_openai_enabled',
'is_runware_enabled',
'is_image_generation_enabled',
'updated_at',
]
list_filter = [
'is_openai_enabled',
'is_runware_enabled',
'is_image_generation_enabled',
]
search_fields = [
'account__name',
'account__domain',
]
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Account', {
'fields': ('account',)
}),
('Integration States', {
'fields': (
'is_openai_enabled',
'is_runware_enabled',
'is_image_generation_enabled',
)
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
class AITaskLogResource(resources.ModelResource):
"""Resource class for exporting AI Task Logs"""
class Meta:
model = AITaskLog
fields = ('id', 'function_name', 'account__name', 'status', 'phase',
'cost', 'tokens', 'duration', 'created_at')
export_order = fields
from import_export.admin import ExportMixin

View File

@@ -376,16 +376,32 @@ class AIEngine:
# Map function name to operation type
operation_type = self._get_operation_type(function_name)
# Get actual token usage from response (AI returns 'input_tokens' and 'output_tokens')
# Extract token usage from AI response (standardize key names)
tokens_input = raw_response.get('input_tokens', 0)
tokens_output = raw_response.get('output_tokens', 0)
total_tokens = tokens_input + tokens_output
# Deduct credits based on actual token usage
# Get model_config for token-based billing
model_config = None
if tokens_input > 0 or tokens_output > 0:
# Get model from response or use account default
model_config = CreditService.get_model_for_operation(
account=self.account,
operation_type=operation_type,
task_model_override=None # TODO: Support task-level model override
)
# Calculate actual amount based on results (for non-token operations)
actual_amount = self._get_actual_amount(function_name, save_result, parsed, data)
# Deduct credits using token-based calculation if tokens available
CreditService.deduct_credits_for_operation(
account=self.account,
operation_type=operation_type,
amount=actual_amount, # Fallback for non-token operations
tokens_input=tokens_input,
tokens_output=tokens_output,
model_config=model_config,
cost_usd=raw_response.get('cost'),
model_used=raw_response.get('model', ''),
related_object_type=self._get_related_object_type(function_name),
@@ -399,10 +415,7 @@ class AIEngine:
}
)
logger.info(
f"[AIEngine] Credits deducted: {operation_type}, "
f"tokens: {tokens_input + tokens_output} ({tokens_input} in, {tokens_output} out)"
)
logger.info(f"[AIEngine] Credits deducted: {operation_type}, tokens: {total_tokens} ({tokens_input} in, {tokens_output} out), model: {model_config.model_name if model_config else 'legacy'}")
except InsufficientCreditsError as e:
# This shouldn't happen since we checked before, but log it
logger.error(f"[AIEngine] Insufficient credits during deduction: {e}")

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.9 on 2025-12-23 12:51
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai', '0002_initial'),
('igny8_core_auth', '0018_add_country_remove_intent_seedkeyword'),
]
operations = [
migrations.CreateModel(
name='IntegrationState',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('integration_type', models.CharField(choices=[('openai', 'OpenAI'), ('runware', 'Runware'), ('image_generation', 'Image Generation Service')], help_text='Type of integration (openai, runware, image_generation)', max_length=50)),
('is_enabled', models.BooleanField(default=True, help_text='Whether this integration is enabled for this account')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('account', models.ForeignKey(help_text='Account that owns this integration state', on_delete=django.db.models.deletion.CASCADE, related_name='integration_states', to='igny8_core_auth.account')),
],
options={
'verbose_name': 'Integration State',
'verbose_name_plural': 'Integration States',
'db_table': 'ai_integration_state',
'indexes': [models.Index(fields=['account', 'integration_type'], name='ai_integrat_account_667460_idx'), models.Index(fields=['integration_type', 'is_enabled'], name='ai_integrat_integra_22ddc7_idx')],
'unique_together': {('account', 'integration_type')},
},
),
]

View File

@@ -0,0 +1,155 @@
# Generated manually on 2025-12-23
from django.db import migrations, models
import django.db.models.deletion
def migrate_data_forward(apps, schema_editor):
"""Convert multiple records per account to single record with 3 fields"""
IntegrationState = apps.get_model('ai', 'IntegrationState')
db_alias = schema_editor.connection.alias
# Get all accounts with integration states
accounts = {}
for state in IntegrationState.objects.using(db_alias).all():
account_id = state.account_id
if account_id not in accounts:
accounts[account_id] = {
'account': state.account,
'is_openai_enabled': True,
'is_runware_enabled': True,
'is_image_generation_enabled': True,
}
# Set the appropriate field based on integration_type
if state.integration_type == 'openai':
accounts[account_id]['is_openai_enabled'] = state.is_enabled
elif state.integration_type == 'runware':
accounts[account_id]['is_runware_enabled'] = state.is_enabled
elif state.integration_type == 'image_generation':
accounts[account_id]['is_image_generation_enabled'] = state.is_enabled
# Store the data for later
return accounts
def migrate_data_backward(apps, schema_editor):
"""Convert single record back to multiple records"""
pass # We'll lose data on rollback, but that's acceptable
class Migration(migrations.Migration):
dependencies = [
('ai', '0003_add_integration_state_model'),
('igny8_core_auth', '0001_initial'),
]
operations = [
# First, remove indexes and constraints
migrations.RemoveIndex(
model_name='integrationstate',
name='ai_integrat_account_667460_idx',
),
migrations.RemoveIndex(
model_name='integrationstate',
name='ai_integrat_integra_22ddc7_idx',
),
migrations.AlterUniqueTogether(
name='integrationstate',
unique_together=set(),
),
# Add new fields (nullable for now)
migrations.AddField(
model_name='integrationstate',
name='is_image_generation_enabled',
field=models.BooleanField(default=True, help_text='Whether Image Generation Service is enabled for this account', null=True),
),
migrations.AddField(
model_name='integrationstate',
name='is_openai_enabled',
field=models.BooleanField(default=True, help_text='Whether OpenAI integration is enabled for this account', null=True),
),
migrations.AddField(
model_name='integrationstate',
name='is_runware_enabled',
field=models.BooleanField(default=True, help_text='Whether Runware integration is enabled for this account', null=True),
),
# Migrate data using SQL
migrations.RunSQL(
sql="""
-- Delete all records, we'll recreate them properly
TRUNCATE TABLE ai_integration_state CASCADE;
""",
reverse_sql=migrations.RunSQL.noop,
),
# Remove old fields
migrations.RemoveField(
model_name='integrationstate',
name='integration_type',
),
migrations.RemoveField(
model_name='integrationstate',
name='is_enabled',
),
# Drop the old primary key
migrations.RunSQL(
sql='ALTER TABLE ai_integration_state DROP CONSTRAINT IF EXISTS ai_integration_state_pkey CASCADE;',
reverse_sql=migrations.RunSQL.noop,
),
# Remove id field
migrations.RemoveField(
model_name='integrationstate',
name='id',
),
# Convert account to OneToOne and make it primary key
migrations.AlterField(
model_name='integrationstate',
name='account',
field=models.OneToOneField(
help_text='Account that owns this integration state',
on_delete=django.db.models.deletion.CASCADE,
primary_key=True,
related_name='integration_state',
serialize=False,
to='igny8_core_auth.account'
),
),
# Make new fields non-nullable
migrations.AlterField(
model_name='integrationstate',
name='is_openai_enabled',
field=models.BooleanField(default=True, help_text='Whether OpenAI integration is enabled for this account'),
),
migrations.AlterField(
model_name='integrationstate',
name='is_runware_enabled',
field=models.BooleanField(default=True, help_text='Whether Runware integration is enabled for this account'),
),
migrations.AlterField(
model_name='integrationstate',
name='is_image_generation_enabled',
field=models.BooleanField(default=True, help_text='Whether Image Generation Service is enabled for this account'),
),
# Add new indexes
migrations.AddIndex(
model_name='integrationstate',
index=models.Index(fields=['is_openai_enabled'], name='ai_integrat_is_open_32213f_idx'),
),
migrations.AddIndex(
model_name='integrationstate',
index=models.Index(fields=['is_runware_enabled'], name='ai_integrat_is_runw_de35ad_idx'),
),
migrations.AddIndex(
model_name='integrationstate',
index=models.Index(fields=['is_image_generation_enabled'], name='ai_integrat_is_imag_0191f2_idx'),
),
]

View File

@@ -5,6 +5,61 @@ from django.db import models
from igny8_core.auth.models import AccountBaseModel
class IntegrationState(models.Model):
"""
Tracks whether AI integrations are enabled/disabled for each account.
Single record per account with separate fields for each integration type.
"""
account = models.OneToOneField(
'igny8_core_auth.Account',
on_delete=models.CASCADE,
related_name='integration_state',
help_text='Account that owns this integration state',
primary_key=True
)
# Enable/disable flags for each integration
is_openai_enabled = models.BooleanField(
default=True,
help_text='Whether OpenAI integration is enabled for this account'
)
is_runware_enabled = models.BooleanField(
default=True,
help_text='Whether Runware integration is enabled for this account'
)
is_image_generation_enabled = models.BooleanField(
default=True,
help_text='Whether Image Generation Service is enabled for this account'
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'ai_integration_state'
verbose_name = 'Integration State'
verbose_name_plural = 'Integration States'
indexes = [
models.Index(fields=['is_openai_enabled']),
models.Index(fields=['is_runware_enabled']),
models.Index(fields=['is_image_generation_enabled']),
]
def __str__(self):
states = []
if self.is_openai_enabled:
states.append('OpenAI')
if self.is_runware_enabled:
states.append('Runware')
if self.is_image_generation_enabled:
states.append('Image Gen')
enabled_str = ', '.join(states) if states else 'None'
return f"{self.account.name} - Enabled: {enabled_str}"
class AITaskLog(AccountBaseModel):
"""
Unified logging table for all AI tasks.

View File

@@ -0,0 +1,122 @@
"""
Management command to seed initial AI model configurations
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from igny8_core.business.billing.models import AIModelConfig
class Command(BaseCommand):
help = 'Seeds initial AI model configurations with pricing data'
def handle(self, *args, **options):
self.stdout.write('Seeding AI model configurations...')
models_data = [
{
'model_name': 'gpt-4o-mini',
'provider': 'openai',
'model_type': 'text',
'cost_per_1k_input_tokens': 0.000150, # $0.15 per 1M tokens
'cost_per_1k_output_tokens': 0.000600, # $0.60 per 1M tokens
'tokens_per_credit': 50, # 50 tokens = 1 credit (more expensive)
'display_name': 'GPT-4o Mini',
'is_active': True,
'is_default': True, # Set as default text model
},
{
'model_name': 'gpt-4-turbo-2024-04-09',
'provider': 'openai',
'model_type': 'text',
'cost_per_1k_input_tokens': 0.010000, # $10 per 1M tokens
'cost_per_1k_output_tokens': 0.030000, # $30 per 1M tokens
'tokens_per_credit': 30, # 30 tokens = 1 credit (premium)
'display_name': 'GPT-4 Turbo',
'is_active': True,
'is_default': False,
},
{
'model_name': 'gpt-3.5-turbo',
'provider': 'openai',
'model_type': 'text',
'cost_per_1k_input_tokens': 0.000500, # $0.50 per 1M tokens
'cost_per_1k_output_tokens': 0.001500, # $1.50 per 1M tokens
'tokens_per_credit': 200, # 200 tokens = 1 credit (cheaper)
'display_name': 'GPT-3.5 Turbo',
'is_active': True,
'is_default': False,
},
{
'model_name': 'claude-3-5-sonnet-20241022',
'provider': 'anthropic',
'model_type': 'text',
'cost_per_1k_input_tokens': 0.003000, # $3 per 1M tokens
'cost_per_1k_output_tokens': 0.015000, # $15 per 1M tokens
'tokens_per_credit': 40, # 40 tokens = 1 credit
'display_name': 'Claude 3.5 Sonnet',
'is_active': True,
'is_default': False,
},
{
'model_name': 'claude-3-haiku-20240307',
'provider': 'anthropic',
'model_type': 'text',
'cost_per_1k_input_tokens': 0.000250, # $0.25 per 1M tokens
'cost_per_1k_output_tokens': 0.001250, # $1.25 per 1M tokens
'tokens_per_credit': 150, # 150 tokens = 1 credit (budget)
'display_name': 'Claude 3 Haiku',
'is_active': True,
'is_default': False,
},
{
'model_name': 'runware-flux-1.1-pro',
'provider': 'runware',
'model_type': 'image',
'cost_per_1k_input_tokens': 0.000000, # Image models don't use input tokens
'cost_per_1k_output_tokens': 0.040000, # $0.04 per image (treat as "tokens")
'tokens_per_credit': 1, # 1 "token" (image) = 1 credit
'display_name': 'Runware FLUX 1.1 Pro',
'is_active': True,
'is_default': True, # Set as default image model
},
{
'model_name': 'dall-e-3',
'provider': 'openai',
'model_type': 'image',
'cost_per_1k_input_tokens': 0.000000,
'cost_per_1k_output_tokens': 0.040000, # $0.040 per standard image
'tokens_per_credit': 1,
'display_name': 'DALL-E 3',
'is_active': True,
'is_default': False,
},
]
created_count = 0
updated_count = 0
with transaction.atomic():
for data in models_data:
model, created = AIModelConfig.objects.update_or_create(
model_name=data['model_name'],
defaults=data
)
if created:
created_count += 1
self.stdout.write(
self.style.SUCCESS(f'✓ Created: {model.display_name}')
)
else:
updated_count += 1
self.stdout.write(
self.style.WARNING(f'↻ Updated: {model.display_name}')
)
self.stdout.write('\n' + '='*60)
self.stdout.write(self.style.SUCCESS(
f'✓ Successfully processed {len(models_data)} AI models'
))
self.stdout.write(f' - Created: {created_count}')
self.stdout.write(f' - Updated: {updated_count}')
self.stdout.write('='*60)

View File

@@ -0,0 +1,156 @@
# Generated manually for AI Model & Cost Configuration System
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('billing', '0018_update_operation_choices'),
]
operations = [
# Step 1: Create AIModelConfig table using raw SQL
migrations.RunSQL(
sql="""
CREATE TABLE "igny8_ai_model_config" (
"id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
"model_name" varchar(100) NOT NULL UNIQUE,
"provider" varchar(50) NOT NULL,
"model_type" varchar(20) NOT NULL,
"cost_per_1k_input_tokens" numeric(10, 6) NOT NULL,
"cost_per_1k_output_tokens" numeric(10, 6) NOT NULL,
"tokens_per_credit" numeric(10, 2) NOT NULL,
"display_name" varchar(200) NOT NULL,
"is_active" boolean NOT NULL DEFAULT true,
"is_default" boolean NOT NULL DEFAULT false,
"created_at" timestamp with time zone NOT NULL DEFAULT NOW(),
"updated_at" timestamp with time zone NOT NULL DEFAULT NOW()
);
CREATE INDEX "igny8_ai_model_config_model_name_75645c19_like"
ON "igny8_ai_model_config" ("model_name" varchar_pattern_ops);
""",
reverse_sql='DROP TABLE "igny8_ai_model_config";',
),
# Step 2: Modify CreditUsageLog table
migrations.RunSQL(
sql="""
-- Add model_name column (copy of old model_used for backward compat)
ALTER TABLE igny8_credit_usage_logs ADD COLUMN model_name varchar(100);
UPDATE igny8_credit_usage_logs SET model_name = model_used;
-- Make old model_used nullable
ALTER TABLE igny8_credit_usage_logs ALTER COLUMN model_used DROP NOT NULL;
-- Add cost tracking fields
ALTER TABLE igny8_credit_usage_logs ADD COLUMN cost_usd_input numeric(10, 6) NULL;
ALTER TABLE igny8_credit_usage_logs ADD COLUMN cost_usd_output numeric(10, 6) NULL;
ALTER TABLE igny8_credit_usage_logs ADD COLUMN cost_usd_total numeric(10, 6) NULL;
-- Add model_config FK
ALTER TABLE igny8_credit_usage_logs ADD COLUMN model_config_id bigint NULL;
ALTER TABLE igny8_credit_usage_logs
ADD CONSTRAINT "igny8_credit_usage_l_model_config_id_fk_igny8_ai_"
FOREIGN KEY (model_config_id) REFERENCES igny8_ai_model_config(id)
DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX "igny8_credit_usage_logs_model_config_id_idx"
ON "igny8_credit_usage_logs" ("model_config_id");
-- Rename old model_used to avoid conflicts
ALTER TABLE igny8_credit_usage_logs RENAME COLUMN model_used TO model_used_old;
""",
reverse_sql="""
ALTER TABLE igny8_credit_usage_logs RENAME COLUMN model_used_old TO model_used;
ALTER TABLE igny8_credit_usage_logs DROP CONSTRAINT "igny8_credit_usage_l_model_config_id_fk_igny8_ai_";
DROP INDEX "igny8_credit_usage_logs_model_config_id_idx";
ALTER TABLE igny8_credit_usage_logs DROP COLUMN model_config_id;
ALTER TABLE igny8_credit_usage_logs DROP COLUMN cost_usd_total;
ALTER TABLE igny8_credit_usage_logs DROP COLUMN cost_usd_output;
ALTER TABLE igny8_credit_usage_logs DROP COLUMN cost_usd_input;
ALTER TABLE igny8_credit_usage_logs DROP COLUMN model_name;
""",
),
# Step 3: Modify CreditCostConfig table
migrations.RunSQL(
sql="""
ALTER TABLE igny8_credit_cost_config ADD COLUMN default_model_id bigint NULL;
ALTER TABLE igny8_credit_cost_config
ADD CONSTRAINT "igny8_credit_cost_co_default_model_id_fk_igny8_ai_"
FOREIGN KEY (default_model_id) REFERENCES igny8_ai_model_config(id)
DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX "igny8_credit_cost_config_default_model_id_idx"
ON "igny8_credit_cost_config" ("default_model_id");
""",
reverse_sql="""
ALTER TABLE igny8_credit_cost_config DROP CONSTRAINT "igny8_credit_cost_co_default_model_id_fk_igny8_ai_";
DROP INDEX "igny8_credit_cost_config_default_model_id_idx";
ALTER TABLE igny8_credit_cost_config DROP COLUMN default_model_id;
""",
),
# Step 4: Update model state (tell Django about the new structure)
migrations.SeparateDatabaseAndState(
state_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 identifier', max_length=100, unique=True)),
('provider', models.CharField(choices=[('openai', 'OpenAI'), ('anthropic', 'Anthropic'), ('runware', 'Runware'), ('other', 'Other')], max_length=50)),
('model_type', models.CharField(choices=[('text', 'Text Generation'), ('image', 'Image Generation'), ('embedding', 'Embeddings')], max_length=20)),
('cost_per_1k_input_tokens', models.DecimalField(decimal_places=6, help_text='Cost in USD per 1,000 input tokens', max_digits=10)),
('cost_per_1k_output_tokens', models.DecimalField(decimal_places=6, help_text='Cost in USD per 1,000 output tokens', max_digits=10)),
('tokens_per_credit', models.DecimalField(decimal_places=2, help_text='How many tokens equal 1 credit', max_digits=10)),
('display_name', models.CharField(help_text='Human-readable model name', max_length=200)),
('is_active', models.BooleanField(default=True)),
('is_default', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'igny8_ai_model_config',
'ordering': ['provider', 'model_name'],
},
),
migrations.AddField(
model_name='creditusagelog',
name='model_name',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='creditusagelog',
name='cost_usd_input',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True),
),
migrations.AddField(
model_name='creditusagelog',
name='cost_usd_output',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True),
),
migrations.AddField(
model_name='creditusagelog',
name='cost_usd_total',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True),
),
migrations.AddField(
model_name='creditusagelog',
name='model_config',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='usage_logs', to='billing.aimodelconfig', db_column='model_config_id'),
),
migrations.AlterField(
model_name='creditcostconfig',
name='unit',
field=models.CharField(choices=[('per_operation', 'Per Operation'), ('per_item', 'Per Item'), ('per_100_tokens', 'Per 100 Tokens'), ('per_1000_tokens', 'Per 1000 Tokens')], default='per_operation', max_length=50),
),
migrations.AddField(
model_name='creditcostconfig',
name='default_model',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cost_configs', to='billing.aimodelconfig'),
),
],
database_operations=[], # Already done with RunSQL above
),
]

View File

@@ -19,6 +19,102 @@ PAYMENT_METHOD_CHOICES = [
]
class AIModelConfig(models.Model):
"""
AI Model Configuration - Centralized pricing and token ratios
Single source of truth for all AI model costs
"""
# Model identification
model_name = models.CharField(
max_length=100,
unique=True,
help_text="Technical model name (e.g., gpt-4-turbo, gpt-3.5-turbo)"
)
provider = models.CharField(
max_length=50,
choices=[
('openai', 'OpenAI'),
('anthropic', 'Anthropic'),
('runware', 'Runware'),
('other', 'Other'),
],
help_text="AI provider"
)
model_type = models.CharField(
max_length=20,
choices=[
('text', 'Text Generation'),
('image', 'Image Generation'),
('embedding', 'Embeddings'),
],
default='text',
help_text="Type of AI model"
)
# Pricing (per 1K tokens for text models)
cost_per_1k_input_tokens = models.DecimalField(
max_digits=10,
decimal_places=6,
default=Decimal('0.001'),
validators=[MinValueValidator(Decimal('0'))],
help_text="Cost in USD per 1,000 input tokens"
)
cost_per_1k_output_tokens = models.DecimalField(
max_digits=10,
decimal_places=6,
default=Decimal('0.002'),
validators=[MinValueValidator(Decimal('0'))],
help_text="Cost in USD per 1,000 output tokens"
)
# Token-to-credit ratio
tokens_per_credit = models.IntegerField(
default=100,
validators=[MinValueValidator(1)],
help_text="How many tokens equal 1 credit (e.g., 100 tokens = 1 credit)"
)
# Display
display_name = models.CharField(
max_length=150,
help_text="Human-readable name (e.g., 'GPT-4 Turbo (Premium)')"
)
description = models.TextField(
blank=True,
help_text="Model description and use cases"
)
# Status
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"
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'billing'
db_table = 'igny8_ai_model_config'
verbose_name = 'AI Model Configuration'
verbose_name_plural = 'AI Model Configurations'
ordering = ['provider', 'model_name']
def __str__(self):
return f"{self.display_name} ({self.model_name})"
def calculate_cost(self, tokens_input, tokens_output):
"""Calculate USD cost for given token usage"""
cost_input = (tokens_input / 1000) * self.cost_per_1k_input_tokens
cost_output = (tokens_output / 1000) * self.cost_per_1k_output_tokens
return float(cost_input + cost_output)
class CreditTransaction(AccountBaseModel):
"""Track all credit transactions (additions, deductions)"""
TRANSACTION_TYPE_CHOICES = [
@@ -75,18 +171,73 @@ class CreditUsageLog(AccountBaseModel):
('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'),
('ideas', 'Content Ideas Generation'), # Legacy
('content', 'Content Generation'), # Legacy
('images', 'Image Generation'), # Legacy
('site_structure_generation', 'Site Structure Generation'),
('site_page_generation', 'Site Page Generation'),
# Legacy aliases for backward compatibility (don't show in new dropdowns)
('ideas', 'Content Ideas Generation (Legacy)'),
('content', 'Content Generation (Legacy)'),
('images', 'Image Generation (Legacy)'),
]
operation_type = models.CharField(max_length=50, choices=OPERATION_TYPE_CHOICES, db_index=True)
credits_used = models.IntegerField(validators=[MinValueValidator(0)])
cost_usd = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True)
model_used = models.CharField(max_length=100, blank=True)
# Model tracking
model_config = models.ForeignKey(
'billing.AIModelConfig',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='usage_logs',
help_text="AI model configuration used",
db_column='model_config_id'
)
model_name = models.CharField(
max_length=100,
blank=True,
help_text="Model name (deprecated, use model_config FK)"
)
# Token tracking
tokens_input = models.IntegerField(null=True, blank=True, validators=[MinValueValidator(0)])
tokens_output = models.IntegerField(null=True, blank=True, validators=[MinValueValidator(0)])
# Cost tracking (USD)
cost_usd_input = models.DecimalField(
max_digits=10,
decimal_places=6,
null=True,
blank=True,
help_text="USD cost for input tokens"
)
cost_usd_output = models.DecimalField(
max_digits=10,
decimal_places=6,
null=True,
blank=True,
help_text="USD cost for output tokens"
)
cost_usd_total = models.DecimalField(
max_digits=10,
decimal_places=6,
null=True,
blank=True,
help_text="Total USD cost (input + output)"
)
# Legacy cost field (deprecated)
cost_usd = models.DecimalField(
max_digits=10,
decimal_places=4,
null=True,
blank=True,
help_text="Deprecated, use cost_usd_total"
)
related_object_type = models.CharField(max_length=50, blank=True) # 'keyword', 'cluster', 'task'
related_object_id = models.IntegerField(null=True, blank=True)
metadata = models.JSONField(default=dict)
@@ -109,38 +260,64 @@ class CreditUsageLog(AccountBaseModel):
class CreditCostConfig(models.Model):
"""
Token-based credit pricing configuration.
ALL operations use token-to-credit conversion.
Configurable credit costs per AI function
Admin-editable alternative to hardcoded constants
"""
# Active operation types (excludes legacy aliases)
OPERATION_TYPE_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'),
]
# Operation identification
operation_type = models.CharField(
max_length=50,
unique=True,
choices=CreditUsageLog.OPERATION_TYPE_CHOICES,
choices=OPERATION_TYPE_CHOICES,
help_text="AI operation type"
)
# Token-to-credit ratio (tokens per 1 credit)
tokens_per_credit = models.IntegerField(
default=100,
validators=[MinValueValidator(1)],
help_text="Number of tokens that equal 1 credit (e.g., 100 tokens = 1 credit)"
)
# Minimum credits (for very small token usage)
min_credits = models.IntegerField(
default=1,
# Cost configuration
credits_cost = models.IntegerField(
validators=[MinValueValidator(0)],
help_text="Minimum credits to charge regardless of token usage"
help_text="Credits required for this operation"
)
# Price per credit (for revenue reporting)
price_per_credit_usd = models.DecimalField(
max_digits=10,
decimal_places=4,
default=Decimal('0.01'),
validators=[MinValueValidator(Decimal('0.0001'))],
help_text="USD price per credit (for revenue reporting)"
# Unit of measurement
UNIT_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'), # NEW: Token-based
('per_1000_tokens', 'Per 1000 Tokens'), # NEW: Token-based
]
unit = models.CharField(
max_length=50,
default='per_request',
choices=UNIT_CHOICES,
help_text="What the cost applies to"
)
# Model configuration
default_model = models.ForeignKey(
'billing.AIModelConfig',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='operation_configs',
help_text="Default AI model for this operation (optional)"
)
# Metadata
@@ -150,7 +327,6 @@ class CreditCostConfig(models.Model):
# Status
is_active = models.BooleanField(default=True, help_text="Enable/disable this operation")
# Audit fields
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -164,10 +340,10 @@ class CreditCostConfig(models.Model):
)
# Change tracking
previous_tokens_per_credit = models.IntegerField(
previous_cost = models.IntegerField(
null=True,
blank=True,
help_text="Tokens per credit before last update (for audit trail)"
help_text="Cost before last update (for audit trail)"
)
# History tracking
@@ -181,92 +357,20 @@ class CreditCostConfig(models.Model):
ordering = ['operation_type']
def __str__(self):
return f"{self.display_name} - {self.tokens_per_credit} tokens/credit"
return f"{self.display_name} - {self.credits_cost} credits {self.unit}"
def save(self, *args, **kwargs):
# Track token ratio changes
# Track cost changes
if self.pk:
try:
old = CreditCostConfig.objects.get(pk=self.pk)
if old.tokens_per_credit != self.tokens_per_credit:
self.previous_tokens_per_credit = old.tokens_per_credit
if old.credits_cost != self.credits_cost:
self.previous_cost = old.credits_cost
except CreditCostConfig.DoesNotExist:
pass
super().save(*args, **kwargs)
class BillingConfiguration(models.Model):
"""
System-wide billing configuration (Singleton).
Global settings for token-credit pricing.
"""
# Default token-to-credit ratio
default_tokens_per_credit = models.IntegerField(
default=100,
validators=[MinValueValidator(1)],
help_text="Default: How many tokens equal 1 credit (e.g., 100)"
)
# Credit pricing
default_credit_price_usd = models.DecimalField(
max_digits=10,
decimal_places=4,
default=Decimal('0.01'),
validators=[MinValueValidator(Decimal('0.0001'))],
help_text="Default price per credit in USD"
)
# Reporting settings
enable_token_based_reporting = models.BooleanField(
default=True,
help_text="Show token metrics in all reports"
)
# Rounding settings
ROUNDING_CHOICES = [
('up', 'Round Up'),
('down', 'Round Down'),
('nearest', 'Round to Nearest'),
]
credit_rounding_mode = models.CharField(
max_length=10,
default='up',
choices=ROUNDING_CHOICES,
help_text="How to round fractional credits"
)
# Audit fields
updated_at = models.DateTimeField(auto_now=True)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text="Admin who last updated"
)
class Meta:
app_label = 'billing'
db_table = 'igny8_billing_configuration'
verbose_name = 'Billing Configuration'
verbose_name_plural = 'Billing Configuration'
def save(self, *args, **kwargs):
"""Enforce singleton pattern"""
self.pk = 1
super().save(*args, **kwargs)
@classmethod
def get_config(cls):
"""Get or create the singleton config"""
config, created = cls.objects.get_or_create(pk=1)
return config
def __str__(self):
return f"Billing Configuration (1 credit = {self.default_tokens_per_credit} tokens)"
class PlanLimitUsage(AccountBaseModel):
"""
Track monthly usage of plan limits (ideas, words, images, prompts)

View File

@@ -3,111 +3,126 @@ Credit Service for managing credit transactions and deductions
"""
from django.db import transaction
from django.utils import timezone
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog
from decimal import Decimal
import math
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog, AIModelConfig
from igny8_core.business.billing.constants import CREDIT_COSTS
from igny8_core.business.billing.exceptions import InsufficientCreditsError, CreditCalculationError
from igny8_core.auth.models import Account
class CreditService:
"""Service for managing credits - Token-based only"""
"""Service for managing credits"""
@staticmethod
def calculate_credits_from_tokens(operation_type, tokens_input, tokens_output):
def get_credit_cost(operation_type, amount=None):
"""
Calculate credits from actual token usage using configured ratio.
This is the ONLY way credits are calculated in the system.
Get credit cost for operation.
Now checks database config first, falls back to constants.
Args:
operation_type: Type of operation
tokens_input: Input tokens used
tokens_output: Output tokens used
operation_type: Type of operation (from CREDIT_COSTS)
amount: Optional amount (word count, image count, etc.)
Returns:
int: Credits to deduct
int: Number of credits required
Raises:
CreditCalculationError: If configuration error
CreditCalculationError: If operation type is unknown
"""
import logging
import math
from igny8_core.business.billing.models import CreditCostConfig, BillingConfiguration
logger = logging.getLogger(__name__)
# Get operation config (use global default if not found)
config = CreditCostConfig.objects.filter(
operation_type=operation_type,
is_active=True
).first()
# Try to get from database config first
try:
from igny8_core.business.billing.models import CreditCostConfig
config = CreditCostConfig.objects.filter(
operation_type=operation_type,
is_active=True
).first()
if config:
base_cost = config.credits_cost
# Apply unit-based calculation
if config.unit == 'per_100_words' and amount:
return max(1, int(base_cost * (amount / 100)))
elif config.unit == 'per_200_words' and amount:
return max(1, int(base_cost * (amount / 200)))
elif config.unit in ['per_item', 'per_image'] and amount:
return base_cost * amount
else:
return base_cost
if not config:
# Use global billing config as fallback
billing_config = BillingConfiguration.get_config()
tokens_per_credit = billing_config.default_tokens_per_credit
min_credits = 1
logger.info(f"No config for {operation_type}, using default: {tokens_per_credit} tokens/credit")
else:
tokens_per_credit = config.tokens_per_credit
min_credits = config.min_credits
except Exception as e:
logger.warning(f"Failed to get cost from database, using constants: {e}")
# Calculate total tokens
total_tokens = (tokens_input or 0) + (tokens_output or 0)
# Fallback to hardcoded constants
base_cost = CREDIT_COSTS.get(operation_type, 0)
if base_cost == 0:
raise CreditCalculationError(f"Unknown operation type: {operation_type}")
# Calculate credits (fractional)
if tokens_per_credit <= 0:
raise CreditCalculationError(f"Invalid tokens_per_credit: {tokens_per_credit}")
# Variable cost operations (legacy logic)
if operation_type == 'content_generation' and amount:
# Per 100 words
return max(1, int(base_cost * (amount / 100)))
elif operation_type == 'optimization' and amount:
# Per 200 words
return max(1, int(base_cost * (amount / 200)))
elif operation_type == 'image_generation' and amount:
# Per image
return base_cost * amount
elif operation_type == 'idea_generation' and amount:
# Per idea
return base_cost * amount
credits_float = total_tokens / tokens_per_credit
# Get rounding mode from global config
billing_config = BillingConfiguration.get_config()
rounding_mode = billing_config.credit_rounding_mode
if rounding_mode == 'up':
credits = math.ceil(credits_float)
elif rounding_mode == 'down':
credits = math.floor(credits_float)
else: # nearest
credits = round(credits_float)
# Apply minimum
credits = max(credits, min_credits)
logger.info(
f"Calculated credits for {operation_type}: "
f"{total_tokens} tokens ({tokens_input} in, {tokens_output} out) "
f"÷ {tokens_per_credit} = {credits} credits"
)
return credits
# Fixed cost operations
return base_cost
@staticmethod
def check_credits_for_tokens(account, operation_type, estimated_tokens_input, estimated_tokens_output):
def check_credits(account, operation_type, amount=None):
"""
Check if account has sufficient credits based on estimated token usage.
Check if account has sufficient credits for an operation.
Args:
account: Account instance
operation_type: Type of operation
estimated_tokens_input: Estimated input tokens
estimated_tokens_output: Estimated output tokens
amount: Optional amount (word count, image count, etc.)
Raises:
InsufficientCreditsError: If account doesn't have enough credits
"""
required = CreditService.calculate_credits_from_tokens(
operation_type, estimated_tokens_input, estimated_tokens_output
)
required = CreditService.get_credit_cost(operation_type, amount)
if account.credits < required:
raise InsufficientCreditsError(
f"Insufficient credits. Required: {required}, Available: {account.credits}"
)
return True
@staticmethod
def check_credits_legacy(account, required_credits):
"""
Legacy method: Check if account has enough credits (for backward compatibility).
Args:
account: Account instance
required_credits: Number of credits required
Raises:
InsufficientCreditsError: If account doesn't have enough credits
"""
if account.credits < required_credits:
raise InsufficientCreditsError(
f"Insufficient credits. Required: {required_credits}, Available: {account.credits}"
)
@staticmethod
@transaction.atomic
def deduct_credits(account, amount, operation_type, description, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None):
def deduct_credits(account, amount, operation_type, description, metadata=None,
cost_usd_input=None, cost_usd_output=None, cost_usd_total=None,
model_config=None, tokens_input=None, tokens_output=None,
related_object_type=None, related_object_id=None):
"""
Deduct credits and log transaction.
@@ -117,8 +132,10 @@ class CreditService:
operation_type: Type of operation (from CreditUsageLog.OPERATION_TYPE_CHOICES)
description: Description of the transaction
metadata: Optional metadata dict
cost_usd: Optional cost in USD
model_used: Optional AI model used
cost_usd_input: Optional input cost in USD
cost_usd_output: Optional output cost in USD
cost_usd_total: Optional total cost in USD
model_config: Optional AIModelConfig instance
tokens_input: Optional input tokens
tokens_output: Optional output tokens
related_object_type: Optional related object type
@@ -144,83 +161,93 @@ class CreditService:
metadata=metadata or {}
)
# Create CreditUsageLog
CreditUsageLog.objects.create(
account=account,
operation_type=operation_type,
credits_used=amount,
cost_usd=cost_usd,
model_used=model_used or '',
tokens_input=tokens_input,
tokens_output=tokens_output,
related_object_type=related_object_type or '',
related_object_id=related_object_id,
metadata=metadata or {}
)
# Create CreditUsageLog with new model_config FK
log_data = {
'account': account,
'operation_type': operation_type,
'credits_used': amount,
'tokens_input': tokens_input,
'tokens_output': tokens_output,
'related_object_type': related_object_type or '',
'related_object_id': related_object_id,
'metadata': metadata or {},
}
# Add model tracking (new FK)
if model_config:
log_data['model_config'] = model_config
log_data['model_name'] = model_config.model_name
# Add cost tracking (new fields)
if cost_usd_input is not None:
log_data['cost_usd_input'] = cost_usd_input
if cost_usd_output is not None:
log_data['cost_usd_output'] = cost_usd_output
if cost_usd_total is not None:
log_data['cost_usd_total'] = cost_usd_total
# Legacy cost_usd field (backward compatibility)
if cost_usd_total is not None:
log_data['cost_usd'] = cost_usd_total
CreditUsageLog.objects.create(**log_data)
return account.credits
@staticmethod
@transaction.atomic
def deduct_credits_for_operation(
account,
operation_type,
tokens_input,
tokens_output,
description=None,
metadata=None,
cost_usd=None,
model_used=None,
related_object_type=None,
related_object_id=None
):
def deduct_credits_for_operation(account, operation_type, amount=None, description=None,
metadata=None, cost_usd_input=None, cost_usd_output=None,
cost_usd_total=None, model_config=None, tokens_input=None,
tokens_output=None, related_object_type=None, related_object_id=None):
"""
Deduct credits for an operation based on actual token usage.
This is the ONLY way to deduct credits in the token-based system.
Deduct credits for an operation (convenience method that calculates cost automatically).
Args:
account: Account instance
operation_type: Type of operation
tokens_input: REQUIRED - Actual input tokens used
tokens_output: REQUIRED - Actual output tokens used
amount: Optional amount (word count, image count, etc.)
description: Optional description (auto-generated if not provided)
metadata: Optional metadata dict
cost_usd: Optional cost in USD
model_used: Optional AI model used
cost_usd_input: Optional input cost in USD
cost_usd_output: Optional output cost in USD
cost_usd_total: Optional total cost in USD
model_config: Optional AIModelConfig instance
tokens_input: Optional input tokens
tokens_output: Optional output tokens
related_object_type: Optional related object type
related_object_id: Optional related object ID
Returns:
int: New credit balance
Raises:
ValueError: If tokens_input or tokens_output not provided
"""
# Validate token inputs
if tokens_input is None or tokens_output is None:
raise ValueError(
f"tokens_input and tokens_output are REQUIRED for credit deduction. "
f"Got: tokens_input={tokens_input}, tokens_output={tokens_output}"
# Calculate credit cost - use token-based if tokens provided
if tokens_input is not None and tokens_output is not None and model_config:
credits_required = CreditService.calculate_credits_from_tokens(
operation_type, tokens_input, tokens_output, model_config
)
# Calculate credits from actual token usage
credits_required = CreditService.calculate_credits_from_tokens(
operation_type, tokens_input, tokens_output
)
else:
credits_required = CreditService.get_credit_cost(operation_type, amount)
# Check sufficient credits
if account.credits < credits_required:
raise InsufficientCreditsError(
f"Insufficient credits. Required: {credits_required}, Available: {account.credits}"
)
CreditService.check_credits_legacy(account, credits_required)
# Auto-generate description if not provided
if not description:
total_tokens = tokens_input + tokens_output
description = (
f"{operation_type}: {total_tokens} tokens "
f"({tokens_input} in, {tokens_output} out) = {credits_required} credits"
)
model_name = model_config.display_name if model_config else "AI"
if operation_type == 'clustering':
description = f"Clustering operation ({model_name})"
elif operation_type == 'idea_generation':
description = f"Generated {amount or 1} idea(s) ({model_name})"
elif operation_type == 'content_generation':
if tokens_input and tokens_output:
description = f"Generated content ({tokens_input + tokens_output} tokens, {model_name})"
else:
description = f"Generated content ({amount or 0} words, {model_name})"
elif operation_type == 'image_generation':
description = f"Generated {amount or 1} image(s) ({model_name})"
else:
description = f"{operation_type} operation ({model_name})"
return CreditService.deduct_credits(
account=account,
@@ -228,8 +255,10 @@ class CreditService:
operation_type=operation_type,
description=description,
metadata=metadata,
cost_usd=cost_usd,
model_used=model_used,
cost_usd_input=cost_usd_input,
cost_usd_output=cost_usd_output,
cost_usd_total=cost_usd_total,
model_config=model_config,
tokens_input=tokens_input,
tokens_output=tokens_output,
related_object_type=related_object_type,
@@ -267,4 +296,188 @@ class CreditService:
)
return account.credits
@staticmethod
def calculate_credits_for_operation(operation_type, **kwargs):
"""
Calculate credits needed for an operation.
Legacy method - use get_credit_cost() instead.
Args:
operation_type: Type of operation
**kwargs: Operation-specific parameters
Returns:
int: Number of credits required
Raises:
CreditCalculationError: If calculation fails
"""
# Map legacy operation types
if operation_type == 'ideas':
operation_type = 'idea_generation'
elif operation_type == 'content':
operation_type = 'content_generation'
elif operation_type == 'images':
operation_type = 'image_generation'
# Extract amount from kwargs
amount = None
if 'word_count' in kwargs:
amount = kwargs.get('word_count')
elif 'image_count' in kwargs:
amount = kwargs.get('image_count')
elif 'idea_count' in kwargs:
amount = kwargs.get('idea_count')
return CreditService.get_credit_cost(operation_type, amount)
@staticmethod
def calculate_credits_from_tokens(operation_type, tokens_input, tokens_output, model_config):
"""
Calculate credits based on actual token usage and AI model configuration.
This is the new token-aware calculation method.
Args:
operation_type: Type of operation (e.g., 'content_generation')
tokens_input: Number of input tokens used
tokens_output: Number of output tokens used
model_config: AIModelConfig instance
Returns:
int: Number of credits to deduct
Raises:
CreditCalculationError: If calculation fails
"""
import logging
logger = logging.getLogger(__name__)
try:
from igny8_core.business.billing.models import CreditCostConfig
# Get operation config
config = CreditCostConfig.objects.filter(
operation_type=operation_type,
is_active=True
).first()
if not config:
raise CreditCalculationError(f"No active config found for operation: {operation_type}")
# Check if operation uses token-based billing
if config.unit in ['per_100_tokens', 'per_1000_tokens']:
total_tokens = tokens_input + tokens_output
# Get model's tokens-per-credit ratio
tokens_per_credit = model_config.tokens_per_credit
if tokens_per_credit <= 0:
raise CreditCalculationError(f"Invalid tokens_per_credit: {tokens_per_credit}")
# Calculate credits (float)
credits_float = Decimal(total_tokens) / Decimal(tokens_per_credit)
# Apply rounding (always round up to avoid undercharging)
credits = math.ceil(credits_float)
# Apply minimum cost from config (if set)
credits = max(credits, config.credits_cost)
logger.info(
f"Token-based calculation: {total_tokens} tokens / {tokens_per_credit} = {credits} credits "
f"(model: {model_config.model_name}, operation: {operation_type})"
)
return credits
else:
# Fall back to legacy calculation for non-token operations
logger.warning(
f"Operation {operation_type} uses unit {config.unit}, falling back to legacy calculation"
)
return config.credits_cost
except Exception as e:
logger.error(f"Failed to calculate credits from tokens: {e}")
raise CreditCalculationError(f"Credit calculation failed: {e}")
@staticmethod
def get_model_for_operation(account, operation_type, task_model_override=None):
"""
Determine which AI model to use for an operation.
Priority: Task Override > Account Default > Operation Default > System Default
Args:
account: Account instance
operation_type: Type of operation
task_model_override: Optional AIModelConfig instance from task
Returns:
AIModelConfig: The model to use
"""
import logging
logger = logging.getLogger(__name__)
# 1. Task-level override (highest priority)
if task_model_override:
logger.info(f"Using task-level model override: {task_model_override.model_name}")
return task_model_override
# 2. Account default model (from IntegrationSettings)
try:
from igny8_core.modules.system.models import IntegrationSettings
from igny8_core.business.billing.models import CreditCostConfig
integration = IntegrationSettings.objects.filter(account=account).first()
if integration:
# Determine if this is text or image operation
config = CreditCostConfig.objects.filter(
operation_type=operation_type,
is_active=True
).first()
if config and config.default_model:
model_type = config.default_model.model_type
if model_type == 'text' and integration.default_text_model:
logger.info(f"Using account default text model: {integration.default_text_model.model_name}")
return integration.default_text_model
elif model_type == 'image' and integration.default_image_model:
logger.info(f"Using account default image model: {integration.default_image_model.model_name}")
return integration.default_image_model
except Exception as e:
logger.warning(f"Failed to get account default model: {e}")
# 3. Operation default model
try:
from igny8_core.business.billing.models import CreditCostConfig
config = CreditCostConfig.objects.filter(
operation_type=operation_type,
is_active=True
).first()
if config and config.default_model:
logger.info(f"Using operation default model: {config.default_model.model_name}")
return config.default_model
except Exception as e:
logger.warning(f"Failed to get operation default model: {e}")
# 4. System-wide default (fallback)
try:
default_model = AIModelConfig.objects.filter(
is_default=True,
is_active=True
).first()
if default_model:
logger.info(f"Using system default model: {default_model.model_name}")
return default_model
except Exception as e:
logger.warning(f"Failed to get system default model: {e}")
# 5. Hard-coded fallback
logger.warning("All model selection failed, using hard-coded fallback: gpt-4o-mini")
return AIModelConfig.objects.filter(model_name='gpt-4o-mini').first()

View File

@@ -0,0 +1,3 @@
"""
System app package
"""

View File

@@ -0,0 +1,65 @@
"""
System admin configuration
"""
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from igny8_core.business.system.models import DebugConfiguration
@admin.register(DebugConfiguration)
class DebugConfigurationAdmin(admin.ModelAdmin):
"""Admin for debug configuration (singleton)"""
def has_add_permission(self, request):
# Only allow one instance
return not DebugConfiguration.objects.exists()
def has_delete_permission(self, request, obj=None):
# Don't allow deletion
return False
def changelist_view(self, request, extra_context=None):
# Redirect to edit view for singleton
if DebugConfiguration.objects.exists():
obj = DebugConfiguration.objects.first()
return self.changeform_view(request, str(obj.pk), '', extra_context)
return super().changelist_view(request, extra_context)
fieldsets = (
('Debug Logging Control', {
'fields': ('enable_debug_logging',),
'description': '⚠️ <strong>Master Switch:</strong> When DISABLED, all logging below is completely skipped (zero overhead). When ENABLED, logs appear in console output.'
}),
('Logging Categories', {
'fields': (
'log_ai_steps',
'log_api_requests',
'log_database_queries',
'log_celery_tasks',
),
'description': 'Fine-tune what gets logged when debug logging is enabled'
}),
('Audit', {
'fields': ('updated_at', 'updated_by'),
'classes': ('collapse',)
}),
)
readonly_fields = ('updated_at', 'updated_by')
def save_model(self, request, obj, form, change):
obj.updated_by = request.user
super().save_model(request, obj, form, change)
# Show message about cache clearing
if change:
self.message_user(request,
"Debug configuration updated. Cache cleared. Changes take effect immediately.",
level='success'
)
class Media:
css = {
'all': ('admin/css/forms.css',)
}

View File

@@ -0,0 +1,11 @@
"""
System app configuration
"""
from django.apps import AppConfig
class SystemConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'igny8_core.business.system'
label = 'debug_system'
verbose_name = 'Debug & System Settings'

View File

@@ -0,0 +1,35 @@
# Generated by Django 5.2.9 on 2025-12-23 02:32
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='DebugConfiguration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('enable_debug_logging', models.BooleanField(default=False, help_text='Enable verbose debug logging to console (AI steps, detailed execution)')),
('log_ai_steps', models.BooleanField(default=True, help_text='Log AI function execution steps (only when debug logging enabled)')),
('log_api_requests', models.BooleanField(default=False, help_text='Log all API requests and responses (only when debug logging enabled)')),
('log_database_queries', models.BooleanField(default=False, help_text='Log database queries (only when debug logging enabled)')),
('log_celery_tasks', models.BooleanField(default=True, help_text='Log Celery task execution (only when debug logging enabled)')),
('updated_at', models.DateTimeField(auto_now=True)),
('updated_by', models.ForeignKey(blank=True, help_text='Admin who last updated', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Debug Configuration',
'verbose_name_plural': 'Debug Configuration',
'db_table': 'igny8_debug_configuration',
},
),
]

View File

@@ -0,0 +1,86 @@
"""
System-wide settings and configuration models
"""
from django.db import models
from django.conf import settings
from django.core.cache import cache
class DebugConfiguration(models.Model):
"""
System-wide debug configuration (Singleton).
Controls verbose logging and debugging features.
"""
# Debug settings
enable_debug_logging = models.BooleanField(
default=False,
help_text="Enable verbose debug logging to console (AI steps, detailed execution)"
)
log_ai_steps = models.BooleanField(
default=True,
help_text="Log AI function execution steps (only when debug logging enabled)"
)
log_api_requests = models.BooleanField(
default=False,
help_text="Log all API requests and responses (only when debug logging enabled)"
)
log_database_queries = models.BooleanField(
default=False,
help_text="Log database queries (only when debug logging enabled)"
)
log_celery_tasks = models.BooleanField(
default=True,
help_text="Log Celery task execution (only when debug logging enabled)"
)
# Audit fields
updated_at = models.DateTimeField(auto_now=True)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text="Admin who last updated"
)
class Meta:
app_label = 'debug_system'
db_table = 'igny8_debug_configuration'
verbose_name = 'Debug Configuration'
verbose_name_plural = 'Debug Configuration'
def save(self, *args, **kwargs):
"""Enforce singleton pattern and clear cache on save"""
self.pk = 1
super().save(*args, **kwargs)
# Clear ALL debug-related caches when settings change
cache.delete('debug_config')
cache.delete('debug_enabled')
cache.delete('debug_first_worker_pid') # Reset worker selection
@classmethod
def get_config(cls):
"""Get or create the singleton config (cached)"""
config = cache.get('debug_config')
if config is None:
config, created = cls.objects.get_or_create(pk=1)
cache.set('debug_config', config, 300) # Cache for 5 minutes
return config
@classmethod
def is_debug_enabled(cls):
"""Fast check if debug logging is enabled (cached for performance)"""
enabled = cache.get('debug_enabled')
if enabled is None:
config = cls.get_config()
enabled = config.enable_debug_logging
cache.set('debug_enabled', enabled, 60) # Cache for 1 minute
return enabled
def __str__(self):
status = "ENABLED" if self.enable_debug_logging else "DISABLED"
return f"Debug Configuration ({status})"

View File

@@ -0,0 +1,118 @@
"""
Backfill model_config FK and cost fields in CreditUsageLog from model_name.
"""
from django.core.management.base import BaseCommand
from django.db.models import Q
from igny8_core.business.billing.models import CreditUsageLog, AIModelConfig
class Command(BaseCommand):
help = 'Backfill model_config FK and cost fields in CreditUsageLog from model_name'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be updated without making changes',
)
parser.add_argument(
'--batch-size',
type=int,
default=500,
help='Number of records to process in each batch',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
batch_size = options['batch_size']
self.stdout.write(self.style.WARNING('Starting model_config backfill...'))
# Get logs without model_config but with model_name
logs_to_update = CreditUsageLog.objects.filter(
Q(model_config__isnull=True) & Q(model_name__isnull=False)
).exclude(model_name='')
total_logs = logs_to_update.count()
self.stdout.write(f'Found {total_logs} logs to update')
if total_logs == 0:
self.stdout.write(self.style.SUCCESS('No logs need updating!'))
return
# Get all AIModelConfig objects for mapping
model_configs = {mc.model_name: mc for mc in AIModelConfig.objects.all()}
self.stdout.write(f'Loaded {len(model_configs)} AIModelConfig models')
# Stats
updated_count = 0
skipped_count = 0
error_count = 0
# Process in batches
for i in range(0, total_logs, batch_size):
batch = logs_to_update[i:i+batch_size]
for log in batch:
try:
# Try to find matching AIModelConfig
model_config = model_configs.get(log.model_name)
if model_config:
if not dry_run:
# Update model_config FK
log.model_config = model_config
# Calculate and update costs if tokens are available
if log.tokens_input and log.tokens_output:
cost_input = (log.tokens_input / 1000) * float(model_config.cost_per_1k_input_tokens)
cost_output = (log.tokens_output / 1000) * float(model_config.cost_per_1k_output_tokens)
log.cost_usd_input = round(cost_input, 6)
log.cost_usd_output = round(cost_output, 6)
log.cost_usd_total = round(cost_input + cost_output, 6)
log.save(update_fields=['model_config', 'cost_usd_input', 'cost_usd_output', 'cost_usd_total'])
updated_count += 1
else:
# No matching AIModelConfig
if options['verbosity'] >= 2:
self.stdout.write(f' Skipping log {log.id}: no AIModelConfig for "{log.model_name}"')
skipped_count += 1
except Exception as e:
self.stdout.write(self.style.ERROR(f'Error processing log {log.id}: {str(e)}'))
error_count += 1
# Progress update
if (i + batch_size) % (batch_size * 5) == 0:
self.stdout.write(f' Processed {min(i + batch_size, total_logs)}/{total_logs}...')
# Summary
self.stdout.write('\n' + '='*60)
if dry_run:
self.stdout.write(self.style.WARNING('DRY RUN - No changes made'))
else:
self.stdout.write(self.style.SUCCESS('Backfill complete!'))
self.stdout.write(f'Total logs: {total_logs}')
self.stdout.write(self.style.SUCCESS(f'Updated: {updated_count}'))
if skipped_count > 0:
self.stdout.write(self.style.WARNING(f'Skipped (no matching model): {skipped_count}'))
if error_count > 0:
self.stdout.write(self.style.ERROR(f'Errors: {error_count}'))
# Show sample of updated logs
if not dry_run and updated_count > 0:
self.stdout.write('\nSample of updated logs:')
sample_logs = CreditUsageLog.objects.filter(
model_config__isnull=False
).select_related('model_config').order_by('-created_at')[:5]
for log in sample_logs:
cost_str = f'${log.cost_usd_total:.6f}' if log.cost_usd_total else 'N/A'
self.stdout.write(
f' {log.operation_type}: {log.tokens_input}in + {log.tokens_output}out = '
f'{log.credits_used} credits, {cost_str}, model: {log.model_config.model_name}'
)

View File

@@ -0,0 +1,41 @@
"""
Management command to test debug logging system
"""
from django.core.management.base import BaseCommand
from igny8_core.utils.debug import is_debug_enabled, debug_log, debug_log_ai_step
class Command(BaseCommand):
help = 'Test debug logging system'
def handle(self, *args, **options):
self.stdout.write("=== Testing Debug System ===\n")
# Check if debug is enabled
enabled = is_debug_enabled()
self.stdout.write(f"Debug enabled: {enabled}\n")
if enabled:
self.stdout.write("Debug is ENABLED - logs should appear below:\n")
# Test general debug log
debug_log("Test message 1 - General category", category='general')
# Test AI step log
debug_log_ai_step("TEST_STEP", "Test AI step message", test_param="value123", count=42)
# Test different categories
debug_log("Test message 2 - AI steps", category='ai_steps')
debug_log("Test message 3 - API requests", category='api_requests')
self.stdout.write("\n✓ Test logs sent (check console output above)\n")
else:
self.stdout.write("Debug is DISABLED - no logs should appear\n")
# Try to log anyway (should be skipped)
debug_log("This should NOT appear", category='general')
debug_log_ai_step("SKIP", "This should also NOT appear")
self.stdout.write("✓ Logs were correctly skipped\n")
self.stdout.write("\n=== Test Complete ===\n")

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

View File

@@ -23,44 +23,31 @@
</div>
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-6 gap-6 mb-8">
<div class="grid grid-cols-1 md:grid-cols-5 gap-6 mb-8">
<div class="bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg shadow-lg p-6 text-white">
<h3 class="text-sm font-medium opacity-90 mb-2">Total Cost</h3>
<p class="text-3xl font-bold">${{ total_cost|floatformat:2 }}</p>
<p class="text-xs opacity-75 mt-1">{{ total_calls }} API calls</p>
</div>
<div class="bg-gradient-to-br from-green-500 to-green-600 rounded-lg shadow-lg p-6 text-white">
<h3 class="text-sm font-medium opacity-90 mb-2">Revenue</h3>
<p class="text-3xl font-bold">${{ total_revenue|floatformat:2 }}</p>
<p class="text-xs opacity-75 mt-1">{{ total_credits_charged }} credits @ ${{ credit_price }}</p>
</div>
<div class="bg-gradient-to-br from-purple-500 to-purple-600 rounded-lg shadow-lg p-6 text-white">
<h3 class="text-sm font-medium opacity-90 mb-2">Margin</h3>
<p class="text-3xl font-bold">${{ total_margin|floatformat:2 }}</p>
<p class="text-xs opacity-75 mt-1">{{ margin_percentage }}% margin</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Margin / 1M Tokens</h3>
{% if total_tokens > 0 %}
<p class="text-3xl font-bold text-purple-600 dark:text-purple-400">${{ margin_per_1m_tokens|floatformat:2 }}</p>
{% else %}
<p class="text-xl text-gray-400 dark:text-gray-500">No token data yet</p>
<p class="text-xs text-gray-500 mt-1">New AI calls will populate this</p>
{% endif %}
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Margin / 1K Credits</h3>
{% if total_credits_charged > 0 %}
<p class="text-3xl font-bold text-green-600 dark:text-green-400">${{ margin_per_1k_credits|floatformat:2 }}</p>
{% else %}
<p class="text-xl text-gray-400 dark:text-gray-500">No data</p>
{% endif %}
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Avg Cost/Call</h3>
<p class="text-3xl font-bold text-gray-900 dark:text-white">${{ avg_cost_per_call|floatformat:4 }}</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Projected Monthly</h3>
<p class="text-3xl font-bold text-blue-600 dark:text-blue-400">${{ projected_monthly|floatformat:2 }}</p>
<p class="text-3xl font-bold text-purple-600 dark:text-purple-400">${{ projected_monthly|floatformat:2 }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Based on last 7 days</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Efficiency Score</h3>
<p class="text-3xl font-bold text-green-600 dark:text-green-400">{{ efficiency_score }}%</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Successful cost ratio</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Wasted Cost</h3>
<p class="text-3xl font-bold text-red-600 dark:text-red-400">${{ failed_cost|floatformat:2 }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ wasted_percentage|floatformat:1 }}% of total</p>
</div>
</div>
<!-- Cost Trends Chart -->
@@ -78,13 +65,10 @@
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Model</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Total Cost</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Revenue</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Margin</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Margin %</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">% of Total</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">API Calls</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Total Tokens
<span class="ml-1 text-xs text-gray-400" title="Token data available for new AI calls after system upgrade"></span>
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Avg Cost</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Total Tokens</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Cost/1K Tokens</th>
</tr>
</thead>
@@ -96,23 +80,21 @@
{{ model.model|default:"Unknown" }}
</span>
</td>
<td class="px-6 py-4 text-sm text-right font-semibold text-red-600 dark:text-red-400">
<td class="px-6 py-4 text-sm text-right font-semibold text-gray-900 dark:text-white">
${{ model.total_cost|floatformat:2 }}
</td>
<td class="px-6 py-4 text-sm text-right font-semibold text-blue-600 dark:text-blue-400">
${{ model.revenue|floatformat:2 }}
</td>
<td class="px-6 py-4 text-sm text-right font-semibold text-green-600 dark:text-green-400">
${{ model.margin|floatformat:2 }}
</td>
<td class="px-6 py-4 text-sm text-right">
<span class="px-2 py-1 rounded {% if model.margin_percentage >= 50 %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200{% elif model.margin_percentage >= 30 %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200{% else %}bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200{% endif %}">
{{ model.margin_percentage|floatformat:1 }}%
</span>
<td class="px-6 py-4 text-sm text-right text-gray-600 dark:text-gray-400">
{{ model.cost_percentage|floatformat:1 }}%
<div class="mt-1 w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div class="bg-blue-600 dark:bg-blue-400 h-1.5 rounded-full" style="width: {{ model.cost_percentage }}%"></div>
</div>
</td>
<td class="px-6 py-4 text-sm text-right text-gray-600 dark:text-gray-400">
{{ model.call_count }}
</td>
<td class="px-6 py-4 text-sm text-right text-gray-900 dark:text-white">
${{ model.avg_cost|floatformat:4 }}
</td>
<td class="px-6 py-4 text-sm text-right text-gray-600 dark:text-gray-400">
{{ model.total_tokens|floatformat:0 }}
</td>

View File

@@ -23,32 +23,15 @@
</div>
<!-- Summary Cards -->
{% if total_tokens == 0 %}
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6 mb-8">
<div class="flex items-start gap-3">
<svg class="w-6 h-6 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="text-lg font-semibold text-yellow-900 dark:text-yellow-200 mb-2">No Token Data Available Yet</h3>
<p class="text-yellow-800 dark:text-yellow-300 mb-2">Historical logs (before system upgrade) don't have token data. Token tracking started after the recent backend update.</p>
<p class="text-yellow-700 dark:text-yellow-400 text-sm">
<strong>Next steps:</strong> Trigger any AI operation (content generation, clustering, etc.) and token data will start appearing here automatically.
</p>
</div>
</div>
</div>
{% endif %}
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Total Tokens</h3>
<p class="text-3xl font-bold {% if total_tokens == 0 %}text-gray-400{% else %}text-gray-900 dark:text-white{% endif %}">{{ total_tokens|floatformat:0 }}</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white">{{ total_tokens|floatformat:0 }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ total_calls }} API calls</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Avg Tokens/Call</h3>
<p class="text-3xl font-bold {% if avg_tokens_per_call == 0 %}text-gray-400{% else %}text-gray-900 dark:text-white{% endif %}">{{ avg_tokens_per_call|floatformat:0 }}</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white">{{ avg_tokens_per_call|floatformat:0 }}</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Success Rate</h3>

View File

@@ -0,0 +1,128 @@
"""
Debug logging utilities
Fast checks with minimal overhead when debug is disabled.
"""
from django.core.cache import cache
import os
def is_debug_enabled():
"""
Fast check if debug logging is enabled.
Uses cache to avoid DB queries. Returns False immediately if disabled.
Returns:
bool: True if debug logging enabled, False otherwise
"""
# Check cache first (fastest)
cache_key = 'debug_enabled'
enabled = cache.get(cache_key)
# If we have a cached value (True or False), use it
if enabled is not None:
return bool(enabled)
# Cache miss - check database
try:
from igny8_core.business.system.models import DebugConfiguration
config = DebugConfiguration.get_config()
enabled = config.enable_debug_logging
# Cache the actual boolean value
cache.set(cache_key, enabled, 60) # Cache for 1 minute
return bool(enabled)
except Exception as e:
# If DB not ready or model doesn't exist, default to False
# Cache this to avoid repeated DB errors
cache.set(cache_key, False, 10)
return False
def _should_log_in_this_worker():
"""
Only log in the main worker to avoid duplicate logs.
Returns True if this is the first worker or if we should always log.
"""
# Get worker PID - only log from worker with lowest PID to avoid duplicates
worker_pid = os.getpid()
# Cache the first worker PID that tries to log
first_worker = cache.get('debug_first_worker_pid')
if first_worker is None:
cache.set('debug_first_worker_pid', worker_pid, 300) # Cache for 5 minutes
return True
# Only log if we're the first worker
return worker_pid == first_worker
def debug_log(message, category='general'):
"""
Log a debug message only if debug is enabled.
Completely skips processing if debug is disabled.
Args:
message: Message to log
category: Log category (ai_steps, api_requests, db_queries, celery_tasks)
"""
# Fast exit - don't even process the message if debug is disabled
if not is_debug_enabled():
return
# Only log in one worker to avoid duplicates
if not _should_log_in_this_worker():
return
# Check category-specific settings
try:
from igny8_core.business.system.models import DebugConfiguration
config = DebugConfiguration.get_config()
# Check category-specific flags
if category == 'ai_steps' and not config.log_ai_steps:
return
if category == 'api_requests' and not config.log_api_requests:
return
if category == 'db_queries' and not config.log_database_queries:
return
if category == 'celery_tasks' and not config.log_celery_tasks:
return
except Exception:
pass
# Debug is enabled - log to console
import sys
import datetime
timestamp = datetime.datetime.now().strftime("%H:%M:%S")
worker_pid = os.getpid()
prefix = f"[{timestamp}] [PID:{worker_pid}] [DEBUG:{category.upper()}]"
print(f"{prefix} {message}", file=sys.stdout, flush=True)
def debug_log_ai_step(step_name, message, **kwargs):
"""
Log an AI execution step only if debug is enabled.
Completely skips processing if debug is disabled.
Args:
step_name: Name of the step (INIT, PREPARE, AI_CALL, etc.)
message: Step message
**kwargs: Additional context to log
"""
# Fast exit - don't even process if debug is disabled
if not is_debug_enabled():
return
# Only log in one worker to avoid duplicates
if not _should_log_in_this_worker():
return
# Format the message with context
context_str = ""
if kwargs:
context_parts = [f"{k}={v}" for k, v in kwargs.items()]
context_str = f" | {', '.join(context_parts)}"
full_message = f"[{step_name}] {message}{context_str}"
debug_log(full_message, category='ai_steps')

119
backend/seed_ai_models.py Normal file
View File

@@ -0,0 +1,119 @@
#!/usr/bin/env python
"""
Seed AI model configurations
"""
import os
import sys
import django
# Setup Django
sys.path.insert(0, '/app')
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
django.setup()
from django.db import transaction
from igny8_core.business.billing.models import AIModelConfig
models_data = [
{
'model_name': 'gpt-4o-mini',
'provider': 'openai',
'model_type': 'text',
'cost_per_1k_input_tokens': 0.000150,
'cost_per_1k_output_tokens': 0.000600,
'tokens_per_credit': 50,
'display_name': 'GPT-4o Mini',
'is_active': True,
'is_default': True,
},
{
'model_name': 'gpt-4-turbo-2024-04-09',
'provider': 'openai',
'model_type': 'text',
'cost_per_1k_input_tokens': 0.010000,
'cost_per_1k_output_tokens': 0.030000,
'tokens_per_credit': 30,
'display_name': 'GPT-4 Turbo',
'is_active': True,
'is_default': False,
},
{
'model_name': 'gpt-3.5-turbo',
'provider': 'openai',
'model_type': 'text',
'cost_per_1k_input_tokens': 0.000500,
'cost_per_1k_output_tokens': 0.001500,
'tokens_per_credit': 200,
'display_name': 'GPT-3.5 Turbo',
'is_active': True,
'is_default': False,
},
{
'model_name': 'claude-3-5-sonnet-20241022',
'provider': 'anthropic',
'model_type': 'text',
'cost_per_1k_input_tokens': 0.003000,
'cost_per_1k_output_tokens': 0.015000,
'tokens_per_credit': 40,
'display_name': 'Claude 3.5 Sonnet',
'is_active': True,
'is_default': False,
},
{
'model_name': 'claude-3-haiku-20240307',
'provider': 'anthropic',
'model_type': 'text',
'cost_per_1k_input_tokens': 0.000250,
'cost_per_1k_output_tokens': 0.001250,
'tokens_per_credit': 150,
'display_name': 'Claude 3 Haiku',
'is_active': True,
'is_default': False,
},
{
'model_name': 'runware-flux-1.1-pro',
'provider': 'runware',
'model_type': 'image',
'cost_per_1k_input_tokens': 0.000000,
'cost_per_1k_output_tokens': 0.040000,
'tokens_per_credit': 1,
'display_name': 'Runware FLUX 1.1 Pro',
'is_active': True,
'is_default': True,
},
{
'model_name': 'dall-e-3',
'provider': 'openai',
'model_type': 'image',
'cost_per_1k_input_tokens': 0.000000,
'cost_per_1k_output_tokens': 0.040000,
'tokens_per_credit': 1,
'display_name': 'DALL-E 3',
'is_active': True,
'is_default': False,
},
]
print('Seeding AI model configurations...')
created_count = 0
updated_count = 0
with transaction.atomic():
for data in models_data:
model, created = AIModelConfig.objects.update_or_create(
model_name=data['model_name'],
defaults=data
)
if created:
created_count += 1
print(f'✓ Created: {model.display_name}')
else:
updated_count += 1
print(f'↻ Updated: {model.display_name}')
print('\n' + '='*60)
print(f'✓ Successfully processed {len(models_data)} AI models')
print(f' - Created: {created_count}')
print(f' - Updated: {updated_count}')
print('='*60)

View File

@@ -0,0 +1,194 @@
#!/usr/bin/env python
"""
Seed AIModelConfig with the CORRECT models from GlobalIntegrationSettings choices.
These are the models that should be available in the dropdowns.
"""
import os
import sys
import django
# Setup Django
sys.path.insert(0, '/app')
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
django.setup()
from decimal import Decimal
from igny8_core.business.billing.models import AIModelConfig
def seed_models():
"""Create AIModelConfig records for all models that were in GlobalIntegrationSettings"""
models_to_create = [
# OpenAI Text Models (from OPENAI_MODEL_CHOICES)
{
'model_name': 'gpt-4.1',
'display_name': 'GPT-4.1',
'provider': 'openai',
'model_type': 'text',
'cost_per_1k_input_tokens': Decimal('0.002'), # $2.00 per 1M = $0.002 per 1K
'cost_per_1k_output_tokens': Decimal('0.008'), # $8.00 per 1M
'tokens_per_credit': 100,
'is_active': True,
},
{
'model_name': 'gpt-4o-mini',
'display_name': 'GPT-4o Mini',
'provider': 'openai',
'model_type': 'text',
'cost_per_1k_input_tokens': Decimal('0.00015'), # $0.15 per 1M
'cost_per_1k_output_tokens': Decimal('0.0006'), # $0.60 per 1M
'tokens_per_credit': 100,
'is_active': True,
},
{
'model_name': 'gpt-4o',
'display_name': 'GPT-4o',
'provider': 'openai',
'model_type': 'text',
'cost_per_1k_input_tokens': Decimal('0.0025'), # $2.50 per 1M
'cost_per_1k_output_tokens': Decimal('0.01'), # $10.00 per 1M
'tokens_per_credit': 100,
'is_active': True,
},
{
'model_name': 'gpt-4-turbo-preview',
'display_name': 'GPT-4 Turbo Preview',
'provider': 'openai',
'model_type': 'text',
'cost_per_1k_input_tokens': Decimal('0.01'), # $10.00 per 1M
'cost_per_1k_output_tokens': Decimal('0.03'), # $30.00 per 1M
'tokens_per_credit': 100,
'is_active': True,
},
{
'model_name': 'gpt-5.1',
'display_name': 'GPT-5.1 (16K)',
'provider': 'openai',
'model_type': 'text',
'cost_per_1k_input_tokens': Decimal('0.00125'), # $1.25 per 1M
'cost_per_1k_output_tokens': Decimal('0.01'), # $10.00 per 1M
'tokens_per_credit': 100,
'is_active': True,
},
{
'model_name': 'gpt-5.2',
'display_name': 'GPT-5.2 (16K)',
'provider': 'openai',
'model_type': 'text',
'cost_per_1k_input_tokens': Decimal('0.00175'), # $1.75 per 1M
'cost_per_1k_output_tokens': Decimal('0.014'), # $14.00 per 1M
'tokens_per_credit': 100,
'is_active': True,
},
# OpenAI Image Models (from DALLE_MODEL_CHOICES)
{
'model_name': 'dall-e-3',
'display_name': 'DALL·E 3',
'provider': 'openai',
'model_type': 'image',
'cost_per_1k_input_tokens': Decimal('0.04'), # $0.040 per image
'cost_per_1k_output_tokens': Decimal('0.00'),
'tokens_per_credit': 1, # 1 image = 1 unit
'is_active': True,
},
{
'model_name': 'dall-e-2',
'display_name': 'DALL·E 2',
'provider': 'openai',
'model_type': 'image',
'cost_per_1k_input_tokens': Decimal('0.02'), # $0.020 per image
'cost_per_1k_output_tokens': Decimal('0.00'),
'tokens_per_credit': 1,
'is_active': True,
},
# Runware Image Models (from RUNWARE_MODEL_CHOICES)
{
'model_name': 'runware:97@1',
'display_name': 'Runware 97@1 (Versatile)',
'provider': 'runware',
'model_type': 'image',
'cost_per_1k_input_tokens': Decimal('0.005'), # Estimated
'cost_per_1k_output_tokens': Decimal('0.00'),
'tokens_per_credit': 1,
'is_active': True,
},
{
'model_name': 'runware:100@1',
'display_name': 'Runware 100@1 (High Quality)',
'provider': 'runware',
'model_type': 'image',
'cost_per_1k_input_tokens': Decimal('0.008'), # Estimated
'cost_per_1k_output_tokens': Decimal('0.00'),
'tokens_per_credit': 1,
'is_active': True,
},
{
'model_name': 'runware:101@1',
'display_name': 'Runware 101@1 (Fast)',
'provider': 'runware',
'model_type': 'image',
'cost_per_1k_input_tokens': Decimal('0.003'), # Estimated
'cost_per_1k_output_tokens': Decimal('0.00'),
'tokens_per_credit': 1,
'is_active': True,
},
]
print("Seeding AIModelConfig with correct models...")
print("=" * 70)
created_count = 0
updated_count = 0
for model_data in models_to_create:
model, created = AIModelConfig.objects.update_or_create(
model_name=model_data['model_name'],
provider=model_data['provider'],
defaults=model_data
)
if created:
created_count += 1
print(f"✓ Created: {model.display_name} ({model.model_name})")
else:
updated_count += 1
print(f"↻ Updated: {model.display_name} ({model.model_name})")
print("=" * 70)
print(f"Summary: {created_count} created, {updated_count} updated")
# Set default models
print("\nSetting default models...")
# Default text model: gpt-4o-mini
default_text = AIModelConfig.objects.filter(model_name='gpt-4o-mini').first()
if default_text:
AIModelConfig.objects.filter(model_type='text').update(is_default=False)
default_text.is_default = True
default_text.save()
print(f"✓ Default text model: {default_text.display_name}")
# Default image model: dall-e-3
default_image = AIModelConfig.objects.filter(model_name='dall-e-3').first()
if default_image:
AIModelConfig.objects.filter(model_type='image').update(is_default=False)
default_image.is_default = True
default_image.save()
print(f"✓ Default image model: {default_image.display_name}")
print("\n✅ Seeding complete!")
# Show summary
print("\nActive models by type:")
print("-" * 70)
for model_type in ['text', 'image']:
models = AIModelConfig.objects.filter(model_type=model_type, is_active=True)
print(f"\n{model_type.upper()}: {models.count()} models")
for m in models:
default = " [DEFAULT]" if m.is_default else ""
print(f" - {m.display_name} ({m.model_name}) - {m.provider}{default}")
if __name__ == '__main__':
seed_models()

109
backend/test_system.py Normal file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python
"""Comprehensive test of AI and billing system at commit #10"""
import django
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
django.setup()
print('='*70)
print('FINAL COMPREHENSIVE TEST - COMMIT #10 STATE')
print('='*70)
# Test 1: Credit Cost Config Save
print('\n1. Testing CreditCostConfig Save:')
try:
from igny8_core.business.billing.models import CreditCostConfig
obj = CreditCostConfig.objects.get(operation_type='clustering')
original_cost = obj.credits_cost
obj.credits_cost = 5
obj.save()
print(f' ✓ Save successful: clustering cost changed to {obj.credits_cost}')
obj.credits_cost = original_cost
obj.save()
print(f' ✓ Reverted to original: {obj.credits_cost}')
except Exception as e:
print(f' ✗ ERROR: {e}')
# Test 2: Credit Check
print('\n2. Testing Credit Check:')
try:
from igny8_core.business.billing.services.credit_service import CreditService
from igny8_core.auth.models import Account
acc = Account.objects.first()
print(f' Account: {acc.name} with {acc.credits} credits')
CreditService.check_credits(acc, 'clustering')
print(f' ✓ Credit check passed for clustering')
CreditService.check_credits(acc, 'idea_generation')
print(f' ✓ Credit check passed for idea_generation')
CreditService.check_credits(acc, 'content_generation', 1000)
print(f' ✓ Credit check passed for content_generation (1000 words)')
except Exception as e:
print(f' ✗ ERROR: {e}')
# Test 3: AI Core
print('\n3. Testing AICore Initialization:')
try:
from igny8_core.ai.ai_core import AICore
from igny8_core.auth.models import Account
acc = Account.objects.first()
ai_core = AICore(account=acc)
print(f' ✓ AICore initialized for account: {acc.name}')
has_key = "SET" if ai_core._openai_api_key else "NOT SET"
print(f' - OpenAI key: {has_key}')
except Exception as e:
print(f' ✗ ERROR: {e}')
# Test 4: AI Engine
print('\n4. Testing AIEngine:')
try:
from igny8_core.ai.engine import AIEngine
from igny8_core.auth.models import Account
acc = Account.objects.first()
engine = AIEngine(account=acc)
print(f' ✓ AIEngine initialized')
# Test operation type mapping
op_type = engine._get_operation_type('auto_cluster')
print(f' ✓ Operation mapping: auto_cluster → {op_type}')
except Exception as e:
print(f' ✗ ERROR: {e}')
# Test 5: Credit Deduction
print('\n5. Testing Credit Deduction:')
try:
from igny8_core.business.billing.services.credit_service import CreditService
from igny8_core.auth.models import Account
from django.db import transaction
acc = Account.objects.first()
original_credits = acc.credits
print(f' Before: {original_credits} credits')
with transaction.atomic():
CreditService.deduct_credits(
account=acc,
operation_type='clustering',
tokens_input=100,
tokens_output=200
)
acc.refresh_from_db()
print(f' After deduction: {acc.credits} credits')
print(f' ✓ Deducted: {original_credits - acc.credits} credits')
# Rollback
transaction.set_rollback(True)
acc.refresh_from_db()
print(f' After rollback: {acc.credits} credits')
except Exception as e:
print(f' ✗ ERROR: {e}')
print('\n' + '='*70)
print('ALL TESTS COMPLETE - System is healthy!')
print('='*70)