Merge branch 'main' of https://git.igny8.com/salman/igny8
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
3
backend/igny8_core/business/system/__init__.py
Normal file
3
backend/igny8_core/business/system/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
System app package
|
||||
"""
|
||||
65
backend/igny8_core/business/system/admin.py
Normal file
65
backend/igny8_core/business/system/admin.py
Normal 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',)
|
||||
}
|
||||
11
backend/igny8_core/business/system/apps.py
Normal file
11
backend/igny8_core/business/system/apps.py
Normal 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'
|
||||
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
86
backend/igny8_core/business/system/models.py
Normal file
86
backend/igny8_core/business/system/models.py
Normal 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})"
|
||||
118
backend/igny8_core/management/commands/backfill_model_config.py
Normal file
118
backend/igny8_core/management/commands/backfill_model_config.py
Normal 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}'
|
||||
)
|
||||
41
backend/igny8_core/management/commands/test_debug.py
Normal file
41
backend/igny8_core/management/commands/test_debug.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
128
backend/igny8_core/utils/debug.py
Normal file
128
backend/igny8_core/utils/debug.py
Normal 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
119
backend/seed_ai_models.py
Normal 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)
|
||||
194
backend/seed_correct_ai_models.py
Normal file
194
backend/seed_correct_ai_models.py
Normal 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
109
backend/test_system.py
Normal 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)
|
||||
Reference in New Issue
Block a user