feat(migrations): Rename indexes and update global integration settings fields for improved clarity and functionality
feat(admin): Add API monitoring, debug console, and system health templates for enhanced admin interface docs: Add AI system cleanup summary and audit report detailing architecture, token management, and recommendations docs: Introduce credits and tokens system guide outlining configuration, data flow, and monitoring strategies
This commit is contained in:
406
backend/igny8_core/admin/monitoring.py
Normal file
406
backend/igny8_core/admin/monitoring.py
Normal file
@@ -0,0 +1,406 @@
|
||||
"""
|
||||
Admin Monitoring Module - System Health, API Monitor, Debug Console
|
||||
Provides read-only monitoring and debugging tools for Django Admin
|
||||
"""
|
||||
from django.shortcuts import render
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.utils import timezone
|
||||
from django.db import connection
|
||||
from django.conf import settings
|
||||
import time
|
||||
import os
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def system_health_dashboard(request):
|
||||
"""
|
||||
System infrastructure health monitoring
|
||||
Checks: Database, Redis, Celery, File System
|
||||
"""
|
||||
context = {
|
||||
'page_title': 'System Health Monitor',
|
||||
'checked_at': timezone.now(),
|
||||
'checks': []
|
||||
}
|
||||
|
||||
# Database Check
|
||||
db_check = {
|
||||
'name': 'PostgreSQL Database',
|
||||
'status': 'unknown',
|
||||
'message': '',
|
||||
'details': {}
|
||||
}
|
||||
try:
|
||||
start = time.time()
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT version()")
|
||||
version = cursor.fetchone()[0]
|
||||
cursor.execute("SELECT COUNT(*) FROM django_session")
|
||||
session_count = cursor.fetchone()[0]
|
||||
|
||||
elapsed = (time.time() - start) * 1000
|
||||
db_check.update({
|
||||
'status': 'healthy',
|
||||
'message': f'Connected ({elapsed:.2f}ms)',
|
||||
'details': {
|
||||
'version': version.split('\n')[0],
|
||||
'response_time': f'{elapsed:.2f}ms',
|
||||
'active_sessions': session_count
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
db_check.update({
|
||||
'status': 'error',
|
||||
'message': f'Connection failed: {str(e)}'
|
||||
})
|
||||
context['checks'].append(db_check)
|
||||
|
||||
# Redis Check
|
||||
redis_check = {
|
||||
'name': 'Redis Cache',
|
||||
'status': 'unknown',
|
||||
'message': '',
|
||||
'details': {}
|
||||
}
|
||||
try:
|
||||
import redis
|
||||
r = redis.Redis(
|
||||
host=settings.CACHES['default']['LOCATION'].split(':')[0] if ':' in settings.CACHES['default'].get('LOCATION', '') else 'redis',
|
||||
port=6379,
|
||||
db=0,
|
||||
socket_connect_timeout=2
|
||||
)
|
||||
start = time.time()
|
||||
r.ping()
|
||||
elapsed = (time.time() - start) * 1000
|
||||
|
||||
info = r.info()
|
||||
redis_check.update({
|
||||
'status': 'healthy',
|
||||
'message': f'Connected ({elapsed:.2f}ms)',
|
||||
'details': {
|
||||
'version': info.get('redis_version', 'unknown'),
|
||||
'uptime': f"{info.get('uptime_in_seconds', 0) // 3600}h",
|
||||
'connected_clients': info.get('connected_clients', 0),
|
||||
'used_memory': f"{info.get('used_memory_human', 'unknown')}",
|
||||
'response_time': f'{elapsed:.2f}ms'
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
redis_check.update({
|
||||
'status': 'error',
|
||||
'message': f'Connection failed: {str(e)}'
|
||||
})
|
||||
context['checks'].append(redis_check)
|
||||
|
||||
# Celery Workers Check
|
||||
celery_check = {
|
||||
'name': 'Celery Workers',
|
||||
'status': 'unknown',
|
||||
'message': '',
|
||||
'details': {}
|
||||
}
|
||||
try:
|
||||
from igny8_core.celery import app
|
||||
inspect = app.control.inspect(timeout=2)
|
||||
stats = inspect.stats()
|
||||
active = inspect.active()
|
||||
|
||||
if stats:
|
||||
worker_count = len(stats)
|
||||
total_tasks = sum(len(tasks) for tasks in active.values()) if active else 0
|
||||
celery_check.update({
|
||||
'status': 'healthy',
|
||||
'message': f'{worker_count} worker(s) active',
|
||||
'details': {
|
||||
'workers': worker_count,
|
||||
'active_tasks': total_tasks,
|
||||
'worker_names': list(stats.keys())
|
||||
}
|
||||
})
|
||||
else:
|
||||
celery_check.update({
|
||||
'status': 'warning',
|
||||
'message': 'No workers responding'
|
||||
})
|
||||
except Exception as e:
|
||||
celery_check.update({
|
||||
'status': 'error',
|
||||
'message': f'Check failed: {str(e)}'
|
||||
})
|
||||
context['checks'].append(celery_check)
|
||||
|
||||
# File System Check
|
||||
fs_check = {
|
||||
'name': 'File System',
|
||||
'status': 'unknown',
|
||||
'message': '',
|
||||
'details': {}
|
||||
}
|
||||
try:
|
||||
import shutil
|
||||
media_root = settings.MEDIA_ROOT
|
||||
static_root = settings.STATIC_ROOT
|
||||
|
||||
media_stat = shutil.disk_usage(media_root) if os.path.exists(media_root) else None
|
||||
|
||||
if media_stat:
|
||||
free_gb = media_stat.free / (1024**3)
|
||||
total_gb = media_stat.total / (1024**3)
|
||||
used_percent = (media_stat.used / media_stat.total) * 100
|
||||
|
||||
fs_check.update({
|
||||
'status': 'healthy' if used_percent < 90 else 'warning',
|
||||
'message': f'{free_gb:.1f}GB free of {total_gb:.1f}GB',
|
||||
'details': {
|
||||
'media_root': media_root,
|
||||
'free_space': f'{free_gb:.1f}GB',
|
||||
'total_space': f'{total_gb:.1f}GB',
|
||||
'used_percent': f'{used_percent:.1f}%'
|
||||
}
|
||||
})
|
||||
else:
|
||||
fs_check.update({
|
||||
'status': 'warning',
|
||||
'message': 'Media directory not found'
|
||||
})
|
||||
except Exception as e:
|
||||
fs_check.update({
|
||||
'status': 'error',
|
||||
'message': f'Check failed: {str(e)}'
|
||||
})
|
||||
context['checks'].append(fs_check)
|
||||
|
||||
# Overall system status
|
||||
statuses = [check['status'] for check in context['checks']]
|
||||
if 'error' in statuses:
|
||||
context['overall_status'] = 'error'
|
||||
context['overall_message'] = 'System has errors'
|
||||
elif 'warning' in statuses:
|
||||
context['overall_status'] = 'warning'
|
||||
context['overall_message'] = 'System has warnings'
|
||||
else:
|
||||
context['overall_status'] = 'healthy'
|
||||
context['overall_message'] = 'All systems operational'
|
||||
|
||||
return render(request, 'admin/monitoring/system_health.html', context)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def api_monitor_dashboard(request):
|
||||
"""
|
||||
API endpoint health monitoring
|
||||
Tests key endpoints and displays response times
|
||||
"""
|
||||
from django.test.client import Client
|
||||
|
||||
context = {
|
||||
'page_title': 'API Monitor',
|
||||
'checked_at': timezone.now(),
|
||||
'endpoint_groups': []
|
||||
}
|
||||
|
||||
# Define endpoint groups to check
|
||||
endpoint_configs = [
|
||||
{
|
||||
'name': 'Authentication',
|
||||
'endpoints': [
|
||||
{'path': '/api/v1/auth/check/', 'method': 'GET', 'auth_required': False},
|
||||
]
|
||||
},
|
||||
{
|
||||
'name': 'System Settings',
|
||||
'endpoints': [
|
||||
{'path': '/api/v1/system/health/', 'method': 'GET', 'auth_required': False},
|
||||
]
|
||||
},
|
||||
{
|
||||
'name': 'Planner Module',
|
||||
'endpoints': [
|
||||
{'path': '/api/v1/planner/keywords/', 'method': 'GET', 'auth_required': True},
|
||||
]
|
||||
},
|
||||
{
|
||||
'name': 'Writer Module',
|
||||
'endpoints': [
|
||||
{'path': '/api/v1/writer/tasks/', 'method': 'GET', 'auth_required': True},
|
||||
]
|
||||
},
|
||||
{
|
||||
'name': 'Billing',
|
||||
'endpoints': [
|
||||
{'path': '/api/v1/billing/credits/balance/', 'method': 'GET', 'auth_required': True},
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
client = Client()
|
||||
|
||||
for group_config in endpoint_configs:
|
||||
group_results = {
|
||||
'name': group_config['name'],
|
||||
'endpoints': []
|
||||
}
|
||||
|
||||
for endpoint in group_config['endpoints']:
|
||||
result = {
|
||||
'path': endpoint['path'],
|
||||
'method': endpoint['method'],
|
||||
'status': 'unknown',
|
||||
'status_code': None,
|
||||
'response_time': None,
|
||||
'message': ''
|
||||
}
|
||||
|
||||
try:
|
||||
start = time.time()
|
||||
|
||||
if endpoint['method'] == 'GET':
|
||||
response = client.get(endpoint['path'])
|
||||
else:
|
||||
response = client.post(endpoint['path'])
|
||||
|
||||
elapsed = (time.time() - start) * 1000
|
||||
|
||||
result.update({
|
||||
'status_code': response.status_code,
|
||||
'response_time': f'{elapsed:.2f}ms',
|
||||
})
|
||||
|
||||
# Determine status
|
||||
if response.status_code < 300:
|
||||
result['status'] = 'healthy'
|
||||
result['message'] = 'OK'
|
||||
elif response.status_code == 401 and endpoint.get('auth_required'):
|
||||
result['status'] = 'healthy'
|
||||
result['message'] = 'Auth required (expected)'
|
||||
elif response.status_code < 500:
|
||||
result['status'] = 'warning'
|
||||
result['message'] = 'Client error'
|
||||
else:
|
||||
result['status'] = 'error'
|
||||
result['message'] = 'Server error'
|
||||
|
||||
except Exception as e:
|
||||
result.update({
|
||||
'status': 'error',
|
||||
'message': str(e)[:100]
|
||||
})
|
||||
|
||||
group_results['endpoints'].append(result)
|
||||
|
||||
context['endpoint_groups'].append(group_results)
|
||||
|
||||
# Calculate overall stats
|
||||
all_endpoints = [ep for group in context['endpoint_groups'] for ep in group['endpoints']]
|
||||
total = len(all_endpoints)
|
||||
healthy = len([ep for ep in all_endpoints if ep['status'] == 'healthy'])
|
||||
warnings = len([ep for ep in all_endpoints if ep['status'] == 'warning'])
|
||||
errors = len([ep for ep in all_endpoints if ep['status'] == 'error'])
|
||||
|
||||
context['stats'] = {
|
||||
'total': total,
|
||||
'healthy': healthy,
|
||||
'warnings': warnings,
|
||||
'errors': errors,
|
||||
'health_percentage': (healthy / total * 100) if total > 0 else 0
|
||||
}
|
||||
|
||||
return render(request, 'admin/monitoring/api_monitor.html', context)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def debug_console(request):
|
||||
"""
|
||||
System debug information (read-only)
|
||||
Shows environment, database config, cache config, etc.
|
||||
"""
|
||||
context = {
|
||||
'page_title': 'Debug Console',
|
||||
'checked_at': timezone.now(),
|
||||
'sections': []
|
||||
}
|
||||
|
||||
# Environment Variables Section
|
||||
env_section = {
|
||||
'title': 'Environment',
|
||||
'items': {
|
||||
'DEBUG': settings.DEBUG,
|
||||
'ENVIRONMENT': os.getenv('ENVIRONMENT', 'not set'),
|
||||
'DJANGO_SETTINGS_MODULE': os.getenv('DJANGO_SETTINGS_MODULE', 'not set'),
|
||||
'ALLOWED_HOSTS': settings.ALLOWED_HOSTS,
|
||||
'TIME_ZONE': settings.TIME_ZONE,
|
||||
'USE_TZ': settings.USE_TZ,
|
||||
}
|
||||
}
|
||||
context['sections'].append(env_section)
|
||||
|
||||
# Database Configuration
|
||||
db_config = settings.DATABASES.get('default', {})
|
||||
db_section = {
|
||||
'title': 'Database Configuration',
|
||||
'items': {
|
||||
'ENGINE': db_config.get('ENGINE', 'not set'),
|
||||
'NAME': db_config.get('NAME', 'not set'),
|
||||
'HOST': db_config.get('HOST', 'not set'),
|
||||
'PORT': db_config.get('PORT', 'not set'),
|
||||
'CONN_MAX_AGE': db_config.get('CONN_MAX_AGE', 'not set'),
|
||||
}
|
||||
}
|
||||
context['sections'].append(db_section)
|
||||
|
||||
# Cache Configuration
|
||||
cache_config = settings.CACHES.get('default', {})
|
||||
cache_section = {
|
||||
'title': 'Cache Configuration',
|
||||
'items': {
|
||||
'BACKEND': cache_config.get('BACKEND', 'not set'),
|
||||
'LOCATION': cache_config.get('LOCATION', 'not set'),
|
||||
'KEY_PREFIX': cache_config.get('KEY_PREFIX', 'not set'),
|
||||
}
|
||||
}
|
||||
context['sections'].append(cache_section)
|
||||
|
||||
# Celery Configuration
|
||||
celery_section = {
|
||||
'title': 'Celery Configuration',
|
||||
'items': {
|
||||
'BROKER_URL': getattr(settings, 'CELERY_BROKER_URL', 'not set'),
|
||||
'RESULT_BACKEND': getattr(settings, 'CELERY_RESULT_BACKEND', 'not set'),
|
||||
'TASK_ALWAYS_EAGER': getattr(settings, 'CELERY_TASK_ALWAYS_EAGER', False),
|
||||
}
|
||||
}
|
||||
context['sections'].append(celery_section)
|
||||
|
||||
# Media & Static Files
|
||||
files_section = {
|
||||
'title': 'Media & Static Files',
|
||||
'items': {
|
||||
'MEDIA_ROOT': settings.MEDIA_ROOT,
|
||||
'MEDIA_URL': settings.MEDIA_URL,
|
||||
'STATIC_ROOT': settings.STATIC_ROOT,
|
||||
'STATIC_URL': settings.STATIC_URL,
|
||||
}
|
||||
}
|
||||
context['sections'].append(files_section)
|
||||
|
||||
# Installed Apps (count)
|
||||
apps_section = {
|
||||
'title': 'Installed Applications',
|
||||
'items': {
|
||||
'Total Apps': len(settings.INSTALLED_APPS),
|
||||
'Custom Apps': len([app for app in settings.INSTALLED_APPS if app.startswith('igny8_')]),
|
||||
}
|
||||
}
|
||||
context['sections'].append(apps_section)
|
||||
|
||||
# Middleware (count)
|
||||
middleware_section = {
|
||||
'title': 'Middleware',
|
||||
'items': {
|
||||
'Total Middleware': len(settings.MIDDLEWARE),
|
||||
}
|
||||
}
|
||||
context['sections'].append(middleware_section)
|
||||
|
||||
return render(request, 'admin/monitoring/debug_console.html', context)
|
||||
@@ -21,23 +21,34 @@ class Igny8AdminSite(UnfoldAdminSite):
|
||||
index_title = 'IGNY8 Administration'
|
||||
|
||||
def get_urls(self):
|
||||
"""Get admin URLs with dashboard and reports available"""
|
||||
"""Get admin URLs with dashboard, reports, and monitoring pages 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'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
|
||||
@@ -43,18 +43,7 @@ class AICore:
|
||||
self._load_account_settings()
|
||||
|
||||
def _load_account_settings(self):
|
||||
"""Load API keys from IntegrationSettings with fallbacks (account -> system account -> Django settings)"""
|
||||
def get_system_account():
|
||||
try:
|
||||
from igny8_core.auth.models import Account
|
||||
for slug in ['aws-admin', 'default-account', 'default']:
|
||||
acct = Account.objects.filter(slug=slug).first()
|
||||
if acct:
|
||||
return acct
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
"""Load API keys from IntegrationSettings for account only - no fallbacks"""
|
||||
def get_integration_key(integration_type: str, account):
|
||||
if not account:
|
||||
return None
|
||||
@@ -71,20 +60,12 @@ class AICore:
|
||||
logger.warning(f"Could not load {integration_type} settings for account {getattr(account, 'id', None)}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
# 1) Account-specific keys
|
||||
# Load account-specific keys only - configure via Django admin
|
||||
if self.account:
|
||||
self._openai_api_key = get_integration_key('openai', self.account)
|
||||
self._runware_api_key = get_integration_key('runware', self.account)
|
||||
|
||||
# 2) Fallback to system account keys (shared across tenants)
|
||||
if not self._openai_api_key or not self._runware_api_key:
|
||||
system_account = get_system_account()
|
||||
if not self._openai_api_key:
|
||||
self._openai_api_key = get_integration_key('openai', system_account)
|
||||
if not self._runware_api_key:
|
||||
self._runware_api_key = get_integration_key('runware', system_account)
|
||||
|
||||
# 3) Fallback to Django settings
|
||||
# Fallback to Django settings as last resort
|
||||
if not self._openai_api_key:
|
||||
self._openai_api_key = getattr(settings, 'OPENAI_API_KEY', None)
|
||||
if not self._runware_api_key:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
AI Settings - Centralized model configurations and limits
|
||||
Uses IntegrationSettings only - no hardcoded defaults or fallbacks.
|
||||
Uses global settings with optional per-account overrides.
|
||||
"""
|
||||
from typing import Dict, Any
|
||||
import logging
|
||||
@@ -19,18 +19,23 @@ FUNCTION_ALIASES = {
|
||||
|
||||
def get_model_config(function_name: str, account) -> Dict[str, Any]:
|
||||
"""
|
||||
Get model configuration from IntegrationSettings.
|
||||
Falls back to system account (aws-admin) if user account doesn't have settings.
|
||||
Get model configuration for AI function.
|
||||
|
||||
Architecture:
|
||||
- API keys: ALWAYS from GlobalIntegrationSettings (platform-wide)
|
||||
- Model/params: From IntegrationSettings if account has override, else from global
|
||||
- Free plan: Cannot override, uses global defaults
|
||||
- Starter/Growth/Scale: Can override model, temperature, max_tokens, etc.
|
||||
|
||||
Args:
|
||||
function_name: Name of the AI function
|
||||
account: Account instance (required)
|
||||
|
||||
Returns:
|
||||
dict: Model configuration with 'model', 'max_tokens', 'temperature'
|
||||
dict: Model configuration with 'model', 'max_tokens', 'temperature', 'api_key'
|
||||
|
||||
Raises:
|
||||
ValueError: If account not provided or IntegrationSettings not configured
|
||||
ValueError: If account not provided or settings not configured
|
||||
"""
|
||||
if not account:
|
||||
raise ValueError("Account is required for model configuration")
|
||||
@@ -38,53 +43,57 @@ def get_model_config(function_name: str, account) -> Dict[str, Any]:
|
||||
# Resolve function alias
|
||||
actual_name = FUNCTION_ALIASES.get(function_name, function_name)
|
||||
|
||||
# Get IntegrationSettings for OpenAI - try user account first
|
||||
integration_settings = None
|
||||
try:
|
||||
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
|
||||
from igny8_core.modules.system.models import IntegrationSettings
|
||||
integration_settings = IntegrationSettings.objects.filter(
|
||||
integration_type='openai',
|
||||
account=account,
|
||||
is_active=True
|
||||
).first()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load OpenAI settings for account {account.id}: {e}")
|
||||
|
||||
# Fallback to system account (aws-admin, default-account, or default)
|
||||
if not integration_settings:
|
||||
logger.info(f"No OpenAI settings for account {account.id}, falling back to system account")
|
||||
|
||||
# Get global settings (for API keys and defaults)
|
||||
global_settings = GlobalIntegrationSettings.get_instance()
|
||||
|
||||
if not global_settings.openai_api_key:
|
||||
raise ValueError(
|
||||
"Platform OpenAI API key not configured. "
|
||||
"Please configure GlobalIntegrationSettings in Django admin."
|
||||
)
|
||||
|
||||
# Start with global defaults
|
||||
model = global_settings.openai_model
|
||||
temperature = global_settings.openai_temperature
|
||||
max_tokens = global_settings.openai_max_tokens
|
||||
api_key = global_settings.openai_api_key # ALWAYS from global
|
||||
|
||||
# Check if account has overrides (only for Starter/Growth/Scale plans)
|
||||
# Free plan users cannot create IntegrationSettings records
|
||||
try:
|
||||
from igny8_core.auth.models import Account
|
||||
from igny8_core.modules.system.models import IntegrationSettings
|
||||
for slug in ['aws-admin', 'default-account', 'default']:
|
||||
system_account = Account.objects.filter(slug=slug).first()
|
||||
if system_account:
|
||||
integration_settings = IntegrationSettings.objects.filter(
|
||||
integration_type='openai',
|
||||
account=system_account,
|
||||
is_active=True
|
||||
).first()
|
||||
if integration_settings:
|
||||
logger.info(f"Using OpenAI settings from system account: {slug}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load system account OpenAI settings: {e}")
|
||||
|
||||
# If still no settings found, raise error
|
||||
if not integration_settings:
|
||||
account_settings = IntegrationSettings.objects.get(
|
||||
account=account,
|
||||
integration_type='openai',
|
||||
is_active=True
|
||||
)
|
||||
|
||||
config = account_settings.config or {}
|
||||
|
||||
# Override model if specified (NULL = use global)
|
||||
if config.get('model'):
|
||||
model = config['model']
|
||||
|
||||
# Override temperature if specified
|
||||
if config.get('temperature') is not None:
|
||||
temperature = config['temperature']
|
||||
|
||||
# Override max_tokens if specified
|
||||
if config.get('max_tokens'):
|
||||
max_tokens = config['max_tokens']
|
||||
|
||||
except IntegrationSettings.DoesNotExist:
|
||||
# No account override, use global defaults (already set above)
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Could not load OpenAI settings for account {account.id}: {e}")
|
||||
raise ValueError(
|
||||
f"OpenAI IntegrationSettings not configured for account {account.id} or system account. "
|
||||
f"Please configure OpenAI settings in the integration page."
|
||||
)
|
||||
|
||||
config = integration_settings.config or {}
|
||||
|
||||
# Get model from config
|
||||
model = config.get('model')
|
||||
if not model:
|
||||
raise ValueError(
|
||||
f"Model not configured in IntegrationSettings for account {account.id}. "
|
||||
f"Please set 'model' in OpenAI integration settings."
|
||||
f"Could not load OpenAI configuration for account {account.id}. "
|
||||
f"Please configure GlobalIntegrationSettings."
|
||||
)
|
||||
|
||||
# Validate model is in our supported list (optional validation)
|
||||
@@ -96,15 +105,8 @@ def get_model_config(function_name: str, account) -> Dict[str, Any]:
|
||||
f"Supported models: {list(MODEL_RATES.keys())}"
|
||||
)
|
||||
except ImportError:
|
||||
# MODEL_RATES not available - skip validation
|
||||
pass
|
||||
|
||||
# Get max_tokens and temperature from config (standardized to 8192, 16384 for GPT-5.x)
|
||||
# GPT-5.1 and GPT-5.2 use 16384 max_tokens by default
|
||||
default_max_tokens = 16384 if model in ['gpt-5.1', 'gpt-5.2'] else 8192
|
||||
max_tokens = config.get('max_tokens', default_max_tokens)
|
||||
temperature = config.get('temperature', 0.7) # Reasonable default
|
||||
|
||||
# Build response format based on model (JSON mode for supported models)
|
||||
response_format = None
|
||||
try:
|
||||
@@ -112,7 +114,6 @@ def get_model_config(function_name: str, account) -> Dict[str, Any]:
|
||||
if model in JSON_MODE_MODELS:
|
||||
response_format = {"type": "json_object"}
|
||||
except ImportError:
|
||||
# JSON_MODE_MODELS not available - skip
|
||||
pass
|
||||
|
||||
return {
|
||||
|
||||
@@ -21,21 +21,6 @@ class AccountModelViewSet(viewsets.ModelViewSet):
|
||||
user = getattr(self.request, 'user', None)
|
||||
|
||||
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
|
||||
# Bypass filtering for superusers - they can see everything
|
||||
if getattr(user, 'is_superuser', False):
|
||||
return queryset
|
||||
|
||||
# Bypass filtering for developers
|
||||
if hasattr(user, 'role') and user.role == 'developer':
|
||||
return queryset
|
||||
|
||||
# Bypass filtering for system account users
|
||||
try:
|
||||
if hasattr(user, 'is_system_account_user') and user.is_system_account_user():
|
||||
return queryset
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
account = getattr(self.request, 'account', None)
|
||||
if not account and hasattr(self.request, 'user') and self.request.user and hasattr(self.request.user, 'is_authenticated') and self.request.user.is_authenticated:
|
||||
@@ -254,29 +239,6 @@ class SiteSectorModelViewSet(AccountModelViewSet):
|
||||
|
||||
# Check if user is authenticated and is a proper User instance (not AnonymousUser)
|
||||
if user and hasattr(user, 'is_authenticated') and user.is_authenticated and hasattr(user, 'get_accessible_sites'):
|
||||
# Bypass site filtering for superusers and developers
|
||||
# They already got unfiltered queryset from parent AccountModelViewSet
|
||||
if getattr(user, 'is_superuser', False) or (hasattr(user, 'role') and user.role == 'developer'):
|
||||
# No site filtering for superuser/developer
|
||||
# But still apply query param filters if provided
|
||||
try:
|
||||
query_params = getattr(self.request, 'query_params', None)
|
||||
if query_params is None:
|
||||
query_params = getattr(self.request, 'GET', {})
|
||||
site_id = query_params.get('site_id') or query_params.get('site')
|
||||
except AttributeError:
|
||||
site_id = None
|
||||
|
||||
if site_id:
|
||||
try:
|
||||
site_id_int = int(site_id) if site_id else None
|
||||
if site_id_int:
|
||||
queryset = queryset.filter(site_id=site_id_int)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return queryset
|
||||
|
||||
try:
|
||||
# Get user's accessible sites
|
||||
accessible_sites = user.get_accessible_sites()
|
||||
|
||||
@@ -50,24 +50,6 @@ class HasTenantAccess(permissions.BasePermission):
|
||||
logger.warning(f"[HasTenantAccess] DENIED: User not authenticated")
|
||||
return False
|
||||
|
||||
# Bypass for superusers
|
||||
if getattr(request.user, 'is_superuser', False):
|
||||
logger.info(f"[HasTenantAccess] ALLOWED: User {request.user.email} is superuser")
|
||||
return True
|
||||
|
||||
# Bypass for developers
|
||||
if hasattr(request.user, 'role') and request.user.role == 'developer':
|
||||
logger.info(f"[HasTenantAccess] ALLOWED: User {request.user.email} is developer")
|
||||
return True
|
||||
|
||||
# Bypass for system account users
|
||||
try:
|
||||
if hasattr(request.user, 'is_system_account_user') and request.user.is_system_account_user():
|
||||
logger.info(f"[HasTenantAccess] ALLOWED: User {request.user.email} is system account user")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# SIMPLIFIED LOGIC: Every authenticated user MUST have an account
|
||||
# Middleware already set request.account from request.user.account
|
||||
# Just verify it exists
|
||||
@@ -95,7 +77,6 @@ class IsViewerOrAbove(permissions.BasePermission):
|
||||
"""
|
||||
Permission class that requires viewer, editor, admin, or owner role
|
||||
For read-only operations
|
||||
Superusers and developers bypass this check.
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
import logging
|
||||
@@ -105,16 +86,6 @@ class IsViewerOrAbove(permissions.BasePermission):
|
||||
logger.warning(f"[IsViewerOrAbove] DENIED: User not authenticated")
|
||||
return False
|
||||
|
||||
# Bypass for superusers
|
||||
if getattr(request.user, 'is_superuser', False):
|
||||
logger.info(f"[IsViewerOrAbove] ALLOWED: User {request.user.email} is superuser")
|
||||
return True
|
||||
|
||||
# Bypass for developers
|
||||
if hasattr(request.user, 'role') and request.user.role == 'developer':
|
||||
logger.info(f"[IsViewerOrAbove] ALLOWED: User {request.user.email} is developer")
|
||||
return True
|
||||
|
||||
# Check user role
|
||||
if hasattr(request.user, 'role'):
|
||||
role = request.user.role
|
||||
@@ -135,20 +106,11 @@ class IsEditorOrAbove(permissions.BasePermission):
|
||||
"""
|
||||
Permission class that requires editor, admin, or owner role
|
||||
For content operations
|
||||
Superusers and developers bypass this check.
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Bypass for superusers
|
||||
if getattr(request.user, 'is_superuser', False):
|
||||
return True
|
||||
|
||||
# Bypass for developers
|
||||
if hasattr(request.user, 'role') and request.user.role == 'developer':
|
||||
return True
|
||||
|
||||
# Check user role
|
||||
if hasattr(request.user, 'role'):
|
||||
role = request.user.role
|
||||
@@ -163,20 +125,11 @@ class IsAdminOrOwner(permissions.BasePermission):
|
||||
"""
|
||||
Permission class that requires admin or owner role only
|
||||
For settings, keys, billing operations
|
||||
Superusers and developers bypass this check.
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Bypass for superusers
|
||||
if getattr(request.user, 'is_superuser', False):
|
||||
return True
|
||||
|
||||
# Bypass for developers
|
||||
if hasattr(request.user, 'role') and request.user.role == 'developer':
|
||||
return True
|
||||
|
||||
# Check user role
|
||||
if hasattr(request.user, 'role'):
|
||||
role = request.user.role
|
||||
@@ -185,23 +138,3 @@ class IsAdminOrOwner(permissions.BasePermission):
|
||||
|
||||
# If no role system, deny by default for security
|
||||
return False
|
||||
|
||||
|
||||
class IsSystemAccountOrDeveloper(permissions.BasePermission):
|
||||
"""
|
||||
Allow only system accounts (aws-admin/default-account/default) or developer role.
|
||||
Use for sensitive, globally-scoped settings like integration API keys.
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
user = getattr(request, "user", None)
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
account_slug = getattr(getattr(user, "account", None), "slug", None)
|
||||
if user.role == "developer":
|
||||
return True
|
||||
if account_slug in ["aws-admin", "default-account", "default"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -27,19 +27,6 @@ class DebugScopedRateThrottle(ScopedRateThrottle):
|
||||
return True
|
||||
|
||||
# OLD CODE BELOW (DISABLED)
|
||||
# Bypass for superusers and developers
|
||||
if request.user and hasattr(request.user, 'is_authenticated') and request.user.is_authenticated:
|
||||
if getattr(request.user, 'is_superuser', False):
|
||||
return True
|
||||
if hasattr(request.user, 'role') and request.user.role == 'developer':
|
||||
return True
|
||||
# Bypass for system account users
|
||||
try:
|
||||
if hasattr(request.user, 'is_system_account_user') and request.user.is_system_account_user():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check if throttling should be bypassed
|
||||
debug_bypass = getattr(settings, 'DEBUG', False)
|
||||
env_bypass = getattr(settings, 'IGNY8_DEBUG_THROTTLE', False)
|
||||
|
||||
@@ -140,23 +140,7 @@ class AccountContextMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
Ensure the authenticated user has an account and an active plan.
|
||||
Uses shared validation helper for consistency.
|
||||
Bypasses validation for superusers, developers, and system accounts.
|
||||
"""
|
||||
# Bypass validation for superusers
|
||||
if getattr(user, 'is_superuser', False):
|
||||
return None
|
||||
|
||||
# Bypass validation for developers
|
||||
if hasattr(user, 'role') and user.role == 'developer':
|
||||
return None
|
||||
|
||||
# Bypass validation for system account users
|
||||
try:
|
||||
if hasattr(user, 'is_system_account_user') and user.is_system_account_user():
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from .utils import validate_account_and_plan
|
||||
|
||||
is_valid, error_message, http_status = validate_account_and_plan(user)
|
||||
|
||||
@@ -150,22 +150,6 @@ def validate_account_and_plan(user_or_account):
|
||||
from rest_framework import status
|
||||
from .models import User, Account
|
||||
|
||||
# Bypass validation for superusers
|
||||
if isinstance(user_or_account, User):
|
||||
if getattr(user_or_account, 'is_superuser', False):
|
||||
return (True, None, None)
|
||||
|
||||
# Bypass validation for developers
|
||||
if hasattr(user_or_account, 'role') and user_or_account.role == 'developer':
|
||||
return (True, None, None)
|
||||
|
||||
# Bypass validation for system account users
|
||||
try:
|
||||
if hasattr(user_or_account, 'is_system_account_user') and user_or_account.is_system_account_user():
|
||||
return (True, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Extract account from user or use directly
|
||||
if isinstance(user_or_account, User):
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Management command to populate GlobalAIPrompt entries with default templates
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from igny8_core.modules.system.global_settings_models import GlobalAIPrompt
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Populate GlobalAIPrompt entries with default prompt templates'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
prompts_data = [
|
||||
{
|
||||
'prompt_type': 'clustering',
|
||||
'prompt_value': '''Analyze the following keywords and group them into clusters based on semantic similarity and topical relevance:
|
||||
|
||||
Keywords: {keywords}
|
||||
|
||||
Instructions:
|
||||
1. Group keywords that share similar intent or topic
|
||||
2. Each cluster should have 3-10 related keywords
|
||||
3. Create meaningful cluster names that capture the essence
|
||||
4. Prioritize high-value, commercially-relevant groupings
|
||||
|
||||
Return a JSON array of clusters with this structure:
|
||||
[
|
||||
{
|
||||
"cluster_name": "Descriptive name",
|
||||
"keywords": ["keyword1", "keyword2", ...],
|
||||
"primary_intent": "informational|commercial|transactional"
|
||||
}
|
||||
]'''
|
||||
},
|
||||
{
|
||||
'prompt_type': 'ideas',
|
||||
'prompt_value': '''Generate content ideas for the following topic cluster:
|
||||
|
||||
Topic: {topic}
|
||||
Keywords: {keywords}
|
||||
Target Audience: {audience}
|
||||
|
||||
Instructions:
|
||||
1. Create {count} unique content ideas
|
||||
2. Each idea should target different angles or subtopics
|
||||
3. Consider various content formats (how-to, comparison, list, guide)
|
||||
4. Focus on search intent and user value
|
||||
|
||||
Return a JSON array:
|
||||
[
|
||||
{
|
||||
"title": "Engaging title",
|
||||
"angle": "Unique perspective or approach",
|
||||
"target_keywords": ["keyword1", "keyword2"],
|
||||
"content_type": "how-to|comparison|list|guide|analysis"
|
||||
}
|
||||
]'''
|
||||
},
|
||||
{
|
||||
'prompt_type': 'content_generation',
|
||||
'prompt_value': '''Write comprehensive, SEO-optimized content for the following:
|
||||
|
||||
Title: {title}
|
||||
Target Keywords: {keywords}
|
||||
Word Count: {word_count}
|
||||
Tone: {tone}
|
||||
|
||||
Content Brief:
|
||||
{brief}
|
||||
|
||||
Instructions:
|
||||
1. Create engaging, informative content that fully addresses the topic
|
||||
2. Naturally incorporate target keywords (avoid keyword stuffing)
|
||||
3. Use clear headings (H2, H3) to structure the content
|
||||
4. Include actionable insights and examples where relevant
|
||||
5. Write for both readers and search engines
|
||||
6. Maintain the specified tone throughout
|
||||
|
||||
Return well-structured HTML content with proper heading tags.'''
|
||||
},
|
||||
{
|
||||
'prompt_type': 'image_prompt_extraction',
|
||||
'prompt_value': '''Analyze this article content and extract key visual concepts for image generation:
|
||||
|
||||
Content: {content}
|
||||
|
||||
Instructions:
|
||||
1. Identify {count} main concepts that would benefit from visual representation
|
||||
2. Focus on concrete, visualizable elements (not abstract concepts)
|
||||
3. Consider what would add value for readers
|
||||
4. Prioritize scenes, objects, or scenarios that can be depicted
|
||||
|
||||
Return a JSON array:
|
||||
[
|
||||
{
|
||||
"concept": "Brief description of what to visualize",
|
||||
"placement": "header|section1|section2|...",
|
||||
"priority": "high|medium|low"
|
||||
}
|
||||
]'''
|
||||
},
|
||||
{
|
||||
'prompt_type': 'image_prompt_template',
|
||||
'prompt_value': '''Create a detailed image generation prompt for:
|
||||
|
||||
Concept: {concept}
|
||||
Style: {style}
|
||||
Context: {context}
|
||||
|
||||
Generate a prompt that:
|
||||
1. Describes the scene or subject clearly
|
||||
2. Specifies composition, lighting, and perspective
|
||||
3. Matches the {style} aesthetic
|
||||
4. Is optimized for AI image generation
|
||||
|
||||
Return only the image prompt (no explanations).'''
|
||||
},
|
||||
{
|
||||
'prompt_type': 'negative_prompt',
|
||||
'prompt_value': '''text, watermark, logo, signature, username, artist name, blurry, low quality, pixelated, distorted, deformed, duplicate, cropped, out of frame, bad anatomy, bad proportions, extra limbs, missing limbs, floating limbs, disconnected limbs, mutation, mutated, ugly, disgusting, amputation, cartoon, anime'''
|
||||
},
|
||||
{
|
||||
'prompt_type': 'site_structure_generation',
|
||||
'prompt_value': '''Design a comprehensive site structure for:
|
||||
|
||||
Business Type: {business_type}
|
||||
Primary Keywords: {keywords}
|
||||
Target Audience: {audience}
|
||||
Goals: {goals}
|
||||
|
||||
Instructions:
|
||||
1. Create a logical, user-friendly navigation hierarchy
|
||||
2. Include essential pages (Home, About, Services/Products, Contact)
|
||||
3. Design category pages for primary keywords
|
||||
4. Plan supporting content pages
|
||||
5. Consider user journey and conversion paths
|
||||
|
||||
Return a JSON structure:
|
||||
{
|
||||
"navigation": [
|
||||
{
|
||||
"page": "Page name",
|
||||
"slug": "url-slug",
|
||||
"type": "home|category|product|service|content|utility",
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}'''
|
||||
},
|
||||
{
|
||||
'prompt_type': 'product_generation',
|
||||
'prompt_value': '''Create comprehensive product content for:
|
||||
|
||||
Product Name: {product_name}
|
||||
Category: {category}
|
||||
Features: {features}
|
||||
Target Audience: {audience}
|
||||
|
||||
Generate:
|
||||
1. Compelling product description (200-300 words)
|
||||
2. Key features and benefits (bullet points)
|
||||
3. Technical specifications
|
||||
4. Use cases or applications
|
||||
5. SEO-optimized meta description
|
||||
|
||||
Return structured JSON with all elements.'''
|
||||
},
|
||||
{
|
||||
'prompt_type': 'service_generation',
|
||||
'prompt_value': '''Create detailed service page content for:
|
||||
|
||||
Service Name: {service_name}
|
||||
Category: {category}
|
||||
Key Benefits: {benefits}
|
||||
Target Audience: {audience}
|
||||
|
||||
Generate:
|
||||
1. Overview section (150-200 words)
|
||||
2. Process or methodology (step-by-step)
|
||||
3. Benefits and outcomes
|
||||
4. Why choose us / differentiators
|
||||
5. FAQ section (5-7 questions)
|
||||
6. Call-to-action suggestions
|
||||
|
||||
Return structured HTML content.'''
|
||||
},
|
||||
{
|
||||
'prompt_type': 'taxonomy_generation',
|
||||
'prompt_value': '''Create a logical taxonomy structure for:
|
||||
|
||||
Content Type: {content_type}
|
||||
Domain: {domain}
|
||||
Existing Keywords: {keywords}
|
||||
|
||||
Instructions:
|
||||
1. Design parent categories that organize content logically
|
||||
2. Create subcategories for detailed organization
|
||||
3. Ensure balanced hierarchy (not too deep or flat)
|
||||
4. Use clear, descriptive category names
|
||||
5. Consider SEO and user navigation
|
||||
|
||||
Return a JSON structure:
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"name": "Category Name",
|
||||
"slug": "category-slug",
|
||||
"description": "Brief description",
|
||||
"subcategories": []
|
||||
}
|
||||
]
|
||||
}'''
|
||||
},
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
|
||||
for prompt_data in prompts_data:
|
||||
prompt, created = GlobalAIPrompt.objects.update_or_create(
|
||||
prompt_type=prompt_data['prompt_type'],
|
||||
defaults={'prompt_value': prompt_data['prompt_value']}
|
||||
)
|
||||
if created:
|
||||
created_count += 1
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'Created: {prompt.get_prompt_type_display()}')
|
||||
)
|
||||
else:
|
||||
updated_count += 1
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f'Updated: {prompt.get_prompt_type_display()}')
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'\nCompleted: {created_count} created, {updated_count} updated'
|
||||
)
|
||||
)
|
||||
@@ -1,3 +1,19 @@
|
||||
"""
|
||||
IGNY8 System Module
|
||||
"""
|
||||
# Avoid circular imports - don't import models at module level
|
||||
# Models are automatically discovered by Django
|
||||
|
||||
__all__ = [
|
||||
# Account-based models
|
||||
'AIPrompt',
|
||||
'IntegrationSettings',
|
||||
'AuthorProfile',
|
||||
'Strategy',
|
||||
# Global settings models
|
||||
'GlobalIntegrationSettings',
|
||||
'AccountIntegrationOverride',
|
||||
'GlobalAIPrompt',
|
||||
'GlobalAuthorProfile',
|
||||
'GlobalStrategy',
|
||||
]
|
||||
|
||||
@@ -5,6 +5,12 @@ from django.contrib import admin
|
||||
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 (
|
||||
GlobalIntegrationSettings,
|
||||
GlobalAIPrompt,
|
||||
GlobalAuthorProfile,
|
||||
GlobalStrategy,
|
||||
)
|
||||
|
||||
from django.contrib import messages
|
||||
from import_export.admin import ExportMixin, ImportExportMixin
|
||||
@@ -52,8 +58,8 @@ except ImportError:
|
||||
@admin.register(AIPrompt)
|
||||
class AIPromptAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
||||
resource_class = AIPromptResource
|
||||
list_display = ['id', 'prompt_type', 'account', 'is_active', 'updated_at']
|
||||
list_filter = ['prompt_type', 'is_active', 'account']
|
||||
list_display = ['id', 'prompt_type', 'account', 'is_customized', 'is_active', 'updated_at']
|
||||
list_filter = ['prompt_type', 'is_active', 'is_customized', 'account']
|
||||
search_fields = ['prompt_type']
|
||||
readonly_fields = ['created_at', 'updated_at', 'default_prompt']
|
||||
actions = [
|
||||
@@ -64,10 +70,11 @@ class AIPromptAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
||||
|
||||
fieldsets = (
|
||||
('Basic Info', {
|
||||
'fields': ('account', 'prompt_type', 'is_active')
|
||||
'fields': ('account', 'prompt_type', 'is_active', 'is_customized')
|
||||
}),
|
||||
('Prompt Content', {
|
||||
'fields': ('prompt_value', 'default_prompt')
|
||||
'fields': ('prompt_value', 'default_prompt'),
|
||||
'description': 'Customize prompt_value or reset to default_prompt'
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at')
|
||||
@@ -94,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.prompt_value = prompt.default_prompt
|
||||
prompt.save()
|
||||
prompt.reset_to_default()
|
||||
count += 1
|
||||
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'
|
||||
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'
|
||||
|
||||
|
||||
class IntegrationSettingsResource(resources.ModelResource):
|
||||
@@ -114,36 +121,42 @@ 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']
|
||||
search_fields = ['integration_type', 'account__name']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
actions = [
|
||||
'bulk_activate',
|
||||
'bulk_deactivate',
|
||||
'bulk_test_connection',
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
('Basic Info', {
|
||||
'fields': ('account', 'integration_type', 'is_active')
|
||||
}),
|
||||
('Configuration', {
|
||||
('Configuration Overrides', {
|
||||
'fields': ('config',),
|
||||
'description': 'JSON configuration containing API keys and settings. Example: {"apiKey": "sk-...", "model": "gpt-4.1", "enabled": true}'
|
||||
'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'
|
||||
)
|
||||
}),
|
||||
('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:
|
||||
@@ -312,4 +325,119 @@ class StrategyAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
||||
strategy_copy.save()
|
||||
count += 1
|
||||
self.message_user(request, f'{count} strategy/strategies cloned.', messages.SUCCESS)
|
||||
bulk_clone.short_description = 'Clone selected strategies'
|
||||
bulk_clone.short_description = 'Clone selected strategies'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GLOBAL SETTINGS ADMIN - Platform-wide defaults
|
||||
# =============================================================================
|
||||
|
||||
@admin.register(GlobalIntegrationSettings)
|
||||
class GlobalIntegrationSettingsAdmin(Igny8ModelAdmin):
|
||||
"""Admin for global integration settings (singleton)"""
|
||||
list_display = ["id", "is_active", "last_updated", "updated_by"]
|
||||
readonly_fields = ["last_updated"]
|
||||
|
||||
fieldsets = (
|
||||
("OpenAI Settings", {
|
||||
"fields": ("openai_api_key", "openai_model", "openai_temperature", "openai_max_tokens"),
|
||||
"description": "Global OpenAI configuration used by all accounts (unless overridden)"
|
||||
}),
|
||||
("DALL-E Settings", {
|
||||
"fields": ("dalle_api_key", "dalle_model", "dalle_size", "dalle_quality", "dalle_style"),
|
||||
"description": "Global DALL-E image generation configuration"
|
||||
}),
|
||||
("Anthropic Settings", {
|
||||
"fields": ("anthropic_api_key", "anthropic_model"),
|
||||
"description": "Global Anthropic Claude configuration"
|
||||
}),
|
||||
("Runware Settings", {
|
||||
"fields": ("runware_api_key",),
|
||||
"description": "Global Runware image generation configuration"
|
||||
}),
|
||||
("Status", {
|
||||
"fields": ("is_active", "last_updated", "updated_by")
|
||||
}),
|
||||
)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Only allow one instance (singleton pattern)"""
|
||||
return not GlobalIntegrationSettings.objects.exists()
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
"""Dont allow deletion of singleton"""
|
||||
return False
|
||||
|
||||
@admin.register(GlobalAIPrompt)
|
||||
class GlobalAIPromptAdmin(ExportMixin, Igny8ModelAdmin):
|
||||
"""Admin for global AI prompt templates"""
|
||||
list_display = ["prompt_type", "version", "is_active", "last_updated"]
|
||||
list_filter = ["is_active", "prompt_type", "version"]
|
||||
search_fields = ["prompt_type", "description"]
|
||||
readonly_fields = ["last_updated", "created_at"]
|
||||
|
||||
fieldsets = (
|
||||
("Basic Info", {
|
||||
"fields": ("prompt_type", "description", "is_active", "version")
|
||||
}),
|
||||
("Prompt Content", {
|
||||
"fields": ("prompt_value", "variables"),
|
||||
"description": "Variables should be a list of variable names used in the prompt"
|
||||
}),
|
||||
("Timestamps", {
|
||||
"fields": ("created_at", "last_updated")
|
||||
}),
|
||||
)
|
||||
|
||||
actions = ["increment_version"]
|
||||
|
||||
def increment_version(self, request, queryset):
|
||||
"""Increment version for selected prompts"""
|
||||
for prompt in queryset:
|
||||
prompt.version += 1
|
||||
prompt.save()
|
||||
self.message_user(request, f"{queryset.count()} prompt(s) version incremented.", messages.SUCCESS)
|
||||
increment_version.short_description = "Increment version"
|
||||
|
||||
|
||||
@admin.register(GlobalAuthorProfile)
|
||||
class GlobalAuthorProfileAdmin(ImportExportMixin, Igny8ModelAdmin):
|
||||
"""Admin for global author profile templates"""
|
||||
list_display = ["name", "category", "tone", "language", "is_active", "created_at"]
|
||||
list_filter = ["is_active", "category", "tone", "language"]
|
||||
search_fields = ["name", "description"]
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
|
||||
fieldsets = (
|
||||
("Basic Info", {
|
||||
"fields": ("name", "description", "category", "is_active")
|
||||
}),
|
||||
("Writing Style", {
|
||||
"fields": ("tone", "language", "structure_template")
|
||||
}),
|
||||
("Timestamps", {
|
||||
"fields": ("created_at", "updated_at")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(GlobalStrategy)
|
||||
class GlobalStrategyAdmin(ImportExportMixin, Igny8ModelAdmin):
|
||||
"""Admin for global strategy templates"""
|
||||
list_display = ["name", "category", "is_active", "created_at"]
|
||||
list_filter = ["is_active", "category"]
|
||||
search_fields = ["name", "description"]
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
|
||||
fieldsets = (
|
||||
("Basic Info", {
|
||||
"fields": ("name", "description", "category", "is_active")
|
||||
}),
|
||||
("Strategy Configuration", {
|
||||
"fields": ("prompt_types", "section_logic")
|
||||
}),
|
||||
("Timestamps", {
|
||||
"fields": ("created_at", "updated_at")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
270
backend/igny8_core/modules/system/global_settings_models.py
Normal file
270
backend/igny8_core/modules/system/global_settings_models.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
Global settings models - Platform-wide defaults
|
||||
These models store system-wide defaults that all accounts use.
|
||||
Accounts can override model selection and parameters (but NOT API keys).
|
||||
"""
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class GlobalIntegrationSettings(models.Model):
|
||||
"""
|
||||
Platform-wide API keys and default integration settings.
|
||||
Singleton pattern - only ONE instance exists (pk=1).
|
||||
|
||||
IMPORTANT:
|
||||
- API keys stored here are used by ALL accounts (no exceptions)
|
||||
- Model selections and parameters are defaults
|
||||
- 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 Settings (for text generation)
|
||||
openai_api_key = models.CharField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
help_text="Platform OpenAI API key - used by ALL accounts"
|
||||
)
|
||||
openai_model = models.CharField(
|
||||
max_length=100,
|
||||
default='gpt-4-turbo-preview',
|
||||
help_text="Default text generation model (accounts can override if plan allows)"
|
||||
)
|
||||
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)"
|
||||
)
|
||||
|
||||
# DALL-E Settings (for image generation)
|
||||
dalle_api_key = models.CharField(
|
||||
max_length=500,
|
||||
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',
|
||||
help_text="Default DALL-E model (accounts can override if plan allows)"
|
||||
)
|
||||
dalle_size = models.CharField(
|
||||
max_length=20,
|
||||
default='1024x1024',
|
||||
help_text="Default image size (accounts can override if plan allows)"
|
||||
)
|
||||
dalle_quality = models.CharField(
|
||||
max_length=20,
|
||||
default='standard',
|
||||
choices=[('standard', 'Standard'), ('hd', 'HD')],
|
||||
help_text="Default image quality (accounts can override if plan allows)"
|
||||
)
|
||||
dalle_style = models.CharField(
|
||||
max_length=20,
|
||||
default='vivid',
|
||||
choices=[('vivid', 'Vivid'), ('natural', 'Natural')],
|
||||
help_text="Default image style (accounts can override if plan allows)"
|
||||
)
|
||||
|
||||
# Anthropic Settings (for Claude)
|
||||
anthropic_api_key = models.CharField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
help_text="Platform Anthropic API key - used by ALL accounts"
|
||||
)
|
||||
anthropic_model = models.CharField(
|
||||
max_length=100,
|
||||
default='claude-3-sonnet-20240229',
|
||||
help_text="Default Anthropic model (accounts can override if plan allows)"
|
||||
)
|
||||
|
||||
# Runware Settings (alternative image generation)
|
||||
runware_api_key = models.CharField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
help_text="Platform Runware API key - used by ALL accounts"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
is_active = models.BooleanField(default=True)
|
||||
last_updated = models.DateTimeField(auto_now=True)
|
||||
updated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='global_settings_updates'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_global_integration_settings'
|
||||
verbose_name = "Global Integration Settings"
|
||||
verbose_name_plural = "Global Integration Settings"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Enforce singleton - always use pk=1
|
||||
self.pk = 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
"""Get or create the singleton instance"""
|
||||
obj, created = cls.objects.get_or_create(pk=1)
|
||||
return obj
|
||||
|
||||
def __str__(self):
|
||||
return "Global Integration Settings"
|
||||
|
||||
|
||||
|
||||
|
||||
class GlobalAIPrompt(models.Model):
|
||||
"""
|
||||
Platform-wide default AI prompt templates.
|
||||
All accounts use these by default. Accounts can save overrides which are stored
|
||||
in the AIPrompt model with the default_prompt field preserving this global value.
|
||||
"""
|
||||
|
||||
PROMPT_TYPE_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'),
|
||||
]
|
||||
|
||||
prompt_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=PROMPT_TYPE_CHOICES,
|
||||
unique=True,
|
||||
help_text="Type of AI operation this prompt is for"
|
||||
)
|
||||
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(
|
||||
default=list,
|
||||
help_text="List of variables used in the prompt (e.g., {keyword}, {industry})"
|
||||
)
|
||||
is_active = models.BooleanField(default=True, db_index=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)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_global_ai_prompts'
|
||||
verbose_name = "Global AI Prompt"
|
||||
verbose_name_plural = "Global AI Prompts"
|
||||
ordering = ['prompt_type']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_prompt_type_display()} (v{self.version})"
|
||||
|
||||
|
||||
class GlobalAuthorProfile(models.Model):
|
||||
"""
|
||||
Platform-wide author persona templates.
|
||||
All accounts can clone these profiles and customize them.
|
||||
"""
|
||||
|
||||
CATEGORY_CHOICES = [
|
||||
('saas', 'SaaS/B2B'),
|
||||
('ecommerce', 'E-commerce'),
|
||||
('blog', 'Blog/Publishing'),
|
||||
('technical', 'Technical'),
|
||||
('creative', 'Creative'),
|
||||
('news', 'News/Media'),
|
||||
('academic', 'Academic'),
|
||||
]
|
||||
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
help_text="Profile name (e.g., 'SaaS B2B Professional')"
|
||||
)
|
||||
description = models.TextField(help_text="Description of the writing style")
|
||||
tone = models.CharField(
|
||||
max_length=100,
|
||||
help_text="Writing tone (e.g., 'Professional', 'Casual', 'Technical')"
|
||||
)
|
||||
language = models.CharField(
|
||||
max_length=50,
|
||||
default='en',
|
||||
help_text="Language code"
|
||||
)
|
||||
structure_template = models.JSONField(
|
||||
default=dict,
|
||||
help_text="Structure template defining content sections"
|
||||
)
|
||||
category = models.CharField(
|
||||
max_length=50,
|
||||
choices=CATEGORY_CHOICES,
|
||||
help_text="Profile category"
|
||||
)
|
||||
is_active = models.BooleanField(default=True, db_index=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_global_author_profiles'
|
||||
verbose_name = "Global Author Profile"
|
||||
verbose_name_plural = "Global Author Profiles"
|
||||
ordering = ['category', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_category_display()})"
|
||||
|
||||
|
||||
class GlobalStrategy(models.Model):
|
||||
"""
|
||||
Platform-wide content strategy templates.
|
||||
All accounts can clone these strategies and customize them.
|
||||
"""
|
||||
|
||||
CATEGORY_CHOICES = [
|
||||
('blog', 'Blog Content'),
|
||||
('ecommerce', 'E-commerce'),
|
||||
('saas', 'SaaS/B2B'),
|
||||
('news', 'News/Media'),
|
||||
('technical', 'Technical Documentation'),
|
||||
('marketing', 'Marketing Content'),
|
||||
]
|
||||
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
help_text="Strategy name"
|
||||
)
|
||||
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(
|
||||
max_length=50,
|
||||
choices=CATEGORY_CHOICES,
|
||||
help_text="Strategy category"
|
||||
)
|
||||
is_active = models.BooleanField(default=True, db_index=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_global_strategies'
|
||||
verbose_name = "Global Strategy"
|
||||
verbose_name_plural = "Global Strategies"
|
||||
ordering = ['category', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_category_display()})"
|
||||
@@ -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, IsSystemAccountOrDeveloper
|
||||
from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner
|
||||
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
|
||||
|
||||
IMPORTANT: Integration settings are system-wide (configured by super users/developers)
|
||||
Normal users don't configure their own API keys - they use the system account settings via fallback
|
||||
Integration settings configured through Django admin interface.
|
||||
Normal users can view settings but only Admin/Owner roles can modify.
|
||||
|
||||
NOTE: Class-level permissions are [IsAuthenticatedAndActive, HasTenantAccess] only.
|
||||
Individual actions override with IsSystemAccountOrDeveloper where needed (save, test).
|
||||
task_progress and get_image_generation_settings need to be accessible to all authenticated users.
|
||||
Individual actions override with IsAdminOrOwner where needed (save, test).
|
||||
task_progress and get_image_generation_settings accessible to all authenticated users.
|
||||
"""
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
|
||||
|
||||
@@ -46,11 +46,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
"""
|
||||
Override permissions based on action.
|
||||
- list, retrieve: authenticated users with tenant access (read-only)
|
||||
- update, save, test: system accounts/developers only (write operations)
|
||||
- update, save, test: Admin/Owner roles only (write operations)
|
||||
- task_progress, get_image_generation_settings: all authenticated users
|
||||
"""
|
||||
if self.action in ['update', 'save_post', 'test_connection']:
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsSystemAccountOrDeveloper]
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner]
|
||||
else:
|
||||
permission_classes = self.permission_classes
|
||||
return [permission() for permission in permission_classes]
|
||||
@@ -91,7 +91,7 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
return self.save_settings(request, pk)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='test', url_name='test',
|
||||
permission_classes=[IsAuthenticatedAndActive, HasTenantAccess, IsSystemAccountOrDeveloper])
|
||||
permission_classes=[IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner])
|
||||
def test_connection(self, request, pk=None):
|
||||
"""
|
||||
Test API connection for OpenAI or Runware
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# Generated migration for global settings models
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('igny8_core_auth', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Create GlobalIntegrationSettings
|
||||
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='Global OpenAI API key used by all accounts (unless overridden)', max_length=500)),
|
||||
('openai_model', models.CharField(default='gpt-4-turbo-preview', help_text='Default OpenAI model for text generation', max_length=100)),
|
||||
('openai_temperature', models.FloatField(default=0.7, help_text='Temperature for OpenAI text generation (0.0 to 2.0)')),
|
||||
('openai_max_tokens', models.IntegerField(default=4000, help_text='Maximum tokens for OpenAI responses')),
|
||||
('dalle_api_key', models.CharField(blank=True, help_text='Global DALL-E API key (can be same as OpenAI key)', max_length=500)),
|
||||
('dalle_model', models.CharField(default='dall-e-3', help_text='DALL-E model version', max_length=100)),
|
||||
('dalle_size', models.CharField(default='1024x1024', help_text='Default image size for DALL-E', max_length=20)),
|
||||
('dalle_quality', models.CharField(choices=[('standard', 'Standard'), ('hd', 'HD')], default='standard', help_text='Image quality for DALL-E 3', max_length=20)),
|
||||
('dalle_style', models.CharField(choices=[('vivid', 'Vivid'), ('natural', 'Natural')], default='vivid', help_text='Image style for DALL-E 3', max_length=20)),
|
||||
('anthropic_api_key', models.CharField(blank=True, help_text='Global Anthropic Claude API key', max_length=500)),
|
||||
('anthropic_model', models.CharField(default='claude-3-sonnet-20240229', help_text='Default Anthropic Claude model', max_length=100)),
|
||||
('runware_api_key', models.CharField(blank=True, help_text='Global Runware API key for image generation', max_length=500)),
|
||||
('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',
|
||||
},
|
||||
),
|
||||
|
||||
# Create AccountIntegrationOverride
|
||||
migrations.CreateModel(
|
||||
name='AccountIntegrationOverride',
|
||||
fields=[
|
||||
('account', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='integration_override', serialize=False, to='igny8_core_auth.account')),
|
||||
('use_own_keys', models.BooleanField(default=False, help_text="Use account's own API keys instead of global settings")),
|
||||
('openai_api_key', models.CharField(blank=True, max_length=500, null=True)),
|
||||
('openai_model', models.CharField(blank=True, max_length=100, null=True)),
|
||||
('openai_temperature', models.FloatField(blank=True, null=True)),
|
||||
('openai_max_tokens', models.IntegerField(blank=True, null=True)),
|
||||
('dalle_api_key', models.CharField(blank=True, max_length=500, null=True)),
|
||||
('dalle_model', models.CharField(blank=True, max_length=100, null=True)),
|
||||
('dalle_size', models.CharField(blank=True, max_length=20, null=True)),
|
||||
('dalle_quality', models.CharField(blank=True, max_length=20, null=True)),
|
||||
('dalle_style', models.CharField(blank=True, max_length=20, null=True)),
|
||||
('anthropic_api_key', models.CharField(blank=True, max_length=500, null=True)),
|
||||
('anthropic_model', models.CharField(blank=True, max_length=100, null=True)),
|
||||
('runware_api_key', models.CharField(blank=True, max_length=500, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Account Integration Override',
|
||||
'verbose_name_plural': 'Account Integration Overrides',
|
||||
'db_table': 'igny8_account_integration_override',
|
||||
},
|
||||
),
|
||||
|
||||
# Create GlobalAIPrompt
|
||||
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(default=list, help_text='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'],
|
||||
},
|
||||
),
|
||||
|
||||
# Create GlobalAuthorProfile
|
||||
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'],
|
||||
},
|
||||
),
|
||||
|
||||
# Create GlobalStrategy
|
||||
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'],
|
||||
},
|
||||
),
|
||||
|
||||
# Update AIPrompt model - remove default_prompt, add is_customized
|
||||
migrations.RemoveField(
|
||||
model_name='aiprompt',
|
||||
name='default_prompt',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='aiprompt',
|
||||
name='is_customized',
|
||||
field=models.BooleanField(default=False, help_text='True if account customized the prompt, False if using global default'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='aiprompt',
|
||||
index=models.Index(fields=['is_customized'], name='igny8_ai_pr_is_cust_idx'),
|
||||
),
|
||||
|
||||
# Update AuthorProfile - add is_custom and cloned_from
|
||||
migrations.AddField(
|
||||
model_name='authorprofile',
|
||||
name='is_custom',
|
||||
field=models.BooleanField(default=False, help_text='True if created by account, False if cloned from global template'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='authorprofile',
|
||||
name='cloned_from',
|
||||
field=models.ForeignKey(blank=True, help_text='Reference to the global template this was cloned from', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cloned_instances', to='system.globalauthorprofile'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='authorprofile',
|
||||
index=models.Index(fields=['is_custom'], name='igny8_autho_is_cust_idx'),
|
||||
),
|
||||
|
||||
# Update Strategy - add is_custom and cloned_from
|
||||
migrations.AddField(
|
||||
model_name='strategy',
|
||||
name='is_custom',
|
||||
field=models.BooleanField(default=False, help_text='True if created by account, False if cloned from global template'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='strategy',
|
||||
name='cloned_from',
|
||||
field=models.ForeignKey(blank=True, help_text='Reference to the global template this was cloned from', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cloned_instances', to='system.globalstrategy'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='strategy',
|
||||
index=models.Index(fields=['is_custom'], name='igny8_strat_is_cust_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,108 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-20 12:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0003_fix_global_settings_architecture'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameIndex(
|
||||
model_name='aiprompt',
|
||||
new_name='igny8_ai_pr_is_cust_5d7a72_idx',
|
||||
old_name='igny8_ai_pr_is_cust_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='authorprofile',
|
||||
new_name='igny8_autho_is_cust_d163e6_idx',
|
||||
old_name='igny8_autho_is_cust_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='strategy',
|
||||
new_name='igny8_strat_is_cust_4b3c4b_idx',
|
||||
old_name='igny8_strat_is_cust_idx',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aiprompt',
|
||||
name='default_prompt',
|
||||
field=models.TextField(blank=True, help_text='Global default prompt - used for reset to default'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aiprompt',
|
||||
name='prompt_value',
|
||||
field=models.TextField(help_text='Current prompt text (customized or default)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='anthropic_api_key',
|
||||
field=models.CharField(blank=True, help_text='Platform Anthropic API key - used by ALL accounts', max_length=500),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='anthropic_model',
|
||||
field=models.CharField(default='claude-3-sonnet-20240229', help_text='Default Anthropic model (accounts can override if plan allows)', max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='dalle_api_key',
|
||||
field=models.CharField(blank=True, help_text='Platform DALL-E API key - used by ALL accounts (can be same as OpenAI)', max_length=500),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='dalle_model',
|
||||
field=models.CharField(default='dall-e-3', help_text='Default DALL-E model (accounts can override if plan allows)', max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='dalle_quality',
|
||||
field=models.CharField(choices=[('standard', 'Standard'), ('hd', 'HD')], default='standard', help_text='Default image quality (accounts can override if plan allows)', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='dalle_size',
|
||||
field=models.CharField(default='1024x1024', help_text='Default image size (accounts can override if plan allows)', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='dalle_style',
|
||||
field=models.CharField(choices=[('vivid', 'Vivid'), ('natural', 'Natural')], default='vivid', help_text='Default image style (accounts can override if plan allows)', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='openai_api_key',
|
||||
field=models.CharField(blank=True, help_text='Platform OpenAI API key - used by ALL accounts', max_length=500),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='openai_max_tokens',
|
||||
field=models.IntegerField(default=8192, help_text='Default max tokens for responses (accounts can override if plan allows)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='openai_model',
|
||||
field=models.CharField(default='gpt-4-turbo-preview', help_text='Default text generation model (accounts can override if plan allows)', max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='openai_temperature',
|
||||
field=models.FloatField(default=0.7, help_text='Default temperature 0.0-2.0 (accounts can override if plan allows)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='runware_api_key',
|
||||
field=models.CharField(blank=True, help_text='Platform Runware API key - used by ALL accounts', max_length=500),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationsettings',
|
||||
name='config',
|
||||
field=models.JSONField(default=dict, help_text='Model and parameter overrides only. Fields: model, temperature, max_tokens, image_size, image_quality, etc. NULL = use global default. NEVER store API keys here.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationsettings',
|
||||
name='integration_type',
|
||||
field=models.CharField(choices=[('openai', 'OpenAI'), ('dalle', 'DALL-E'), ('anthropic', 'Anthropic'), ('runware', 'Runware')], db_index=True, max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -11,7 +11,12 @@ from .settings_models import (
|
||||
|
||||
|
||||
class AIPrompt(AccountBaseModel):
|
||||
"""AI Prompt templates for various AI operations"""
|
||||
"""
|
||||
Account-specific AI Prompt templates.
|
||||
Stores global default in default_prompt, current value in prompt_value.
|
||||
When user saves an override, prompt_value changes but default_prompt stays.
|
||||
Reset copies default_prompt back to prompt_value.
|
||||
"""
|
||||
|
||||
PROMPT_TYPE_CHOICES = [
|
||||
('clustering', 'Clustering'),
|
||||
@@ -28,8 +33,15 @@ class AIPrompt(AccountBaseModel):
|
||||
]
|
||||
|
||||
prompt_type = models.CharField(max_length=50, choices=PROMPT_TYPE_CHOICES, db_index=True)
|
||||
prompt_value = models.TextField(help_text="The prompt template text")
|
||||
default_prompt = models.TextField(help_text="Default prompt value (for reset)")
|
||||
prompt_value = models.TextField(help_text="Current prompt text (customized or default)")
|
||||
default_prompt = models.TextField(
|
||||
blank=True,
|
||||
help_text="Global default prompt - used for reset to default"
|
||||
)
|
||||
is_customized = models.BooleanField(
|
||||
default=False,
|
||||
help_text="True if account customized the prompt, False if using global default"
|
||||
)
|
||||
is_active = models.BooleanField(default=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
@@ -41,24 +53,77 @@ class AIPrompt(AccountBaseModel):
|
||||
indexes = [
|
||||
models.Index(fields=['prompt_type']),
|
||||
models.Index(fields=['account', 'prompt_type']),
|
||||
models.Index(fields=['is_customized']),
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_effective_prompt(cls, account, prompt_type):
|
||||
"""
|
||||
Get the effective prompt for an account.
|
||||
Returns account-specific prompt if exists and customized.
|
||||
Otherwise returns global default.
|
||||
"""
|
||||
from .global_settings_models import GlobalAIPrompt
|
||||
|
||||
# Try to get account-specific prompt
|
||||
try:
|
||||
account_prompt = cls.objects.get(account=account, prompt_type=prompt_type, is_active=True)
|
||||
# If customized, use account's version
|
||||
if account_prompt.is_customized:
|
||||
return account_prompt.prompt_value
|
||||
# If not customized, use default_prompt from account record or global
|
||||
return account_prompt.default_prompt or account_prompt.prompt_value
|
||||
except cls.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Fallback to global prompt
|
||||
try:
|
||||
global_prompt = GlobalAIPrompt.objects.get(prompt_type=prompt_type, is_active=True)
|
||||
return global_prompt.prompt_value
|
||||
except GlobalAIPrompt.DoesNotExist:
|
||||
return None
|
||||
|
||||
def reset_to_default(self):
|
||||
"""Reset prompt to global default"""
|
||||
if self.default_prompt:
|
||||
self.prompt_value = self.default_prompt
|
||||
self.is_customized = False
|
||||
self.save()
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_prompt_type_display()}"
|
||||
status = "Custom" if self.is_customized else "Default"
|
||||
return f"{self.get_prompt_type_display()} ({status})"
|
||||
|
||||
|
||||
class IntegrationSettings(AccountBaseModel):
|
||||
"""Integration settings for OpenAI, Runware, GSC, etc."""
|
||||
"""
|
||||
Per-account integration settings overrides.
|
||||
|
||||
IMPORTANT: This model stores ONLY model selection and parameter overrides.
|
||||
API keys are NEVER stored here - they come from GlobalIntegrationSettings.
|
||||
|
||||
Free plan: Cannot create overrides, must use global defaults
|
||||
Starter/Growth/Scale plans: Can override model, temperature, tokens, image settings
|
||||
|
||||
NULL values in config mean "use global default"
|
||||
"""
|
||||
|
||||
INTEGRATION_TYPE_CHOICES = [
|
||||
('openai', 'OpenAI'),
|
||||
('dalle', 'DALL-E'),
|
||||
('anthropic', 'Anthropic'),
|
||||
('runware', 'Runware'),
|
||||
('gsc', 'Google Search Console'),
|
||||
('image_generation', 'Image Generation Service'),
|
||||
]
|
||||
|
||||
integration_type = models.CharField(max_length=50, choices=INTEGRATION_TYPE_CHOICES, db_index=True)
|
||||
config = models.JSONField(default=dict, help_text="Integration configuration (API keys, settings, etc.)")
|
||||
config = models.JSONField(
|
||||
default=dict,
|
||||
help_text=(
|
||||
"Model and parameter overrides only. Fields: model, temperature, max_tokens, "
|
||||
"image_size, image_quality, etc. NULL = use global default. "
|
||||
"NEVER store API keys here."
|
||||
)
|
||||
)
|
||||
is_active = models.BooleanField(default=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
@@ -80,6 +145,7 @@ class IntegrationSettings(AccountBaseModel):
|
||||
class AuthorProfile(AccountBaseModel):
|
||||
"""
|
||||
Writing style profiles - tone, language, structure templates.
|
||||
Can be cloned from global templates or created from scratch.
|
||||
Examples: "SaaS B2B Informative", "E-commerce Product Descriptions", etc.
|
||||
"""
|
||||
name = models.CharField(max_length=255, help_text="Profile name (e.g., 'SaaS B2B Informative')")
|
||||
@@ -93,6 +159,18 @@ class AuthorProfile(AccountBaseModel):
|
||||
default=dict,
|
||||
help_text="Structure template defining content sections and their order"
|
||||
)
|
||||
is_custom = models.BooleanField(
|
||||
default=False,
|
||||
help_text="True if created by account, False if cloned from global template"
|
||||
)
|
||||
cloned_from = models.ForeignKey(
|
||||
'system.GlobalAuthorProfile',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='cloned_instances',
|
||||
help_text="Reference to the global template this was cloned from"
|
||||
)
|
||||
is_active = models.BooleanField(default=True, db_index=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
@@ -105,16 +183,19 @@ class AuthorProfile(AccountBaseModel):
|
||||
indexes = [
|
||||
models.Index(fields=['account', 'is_active']),
|
||||
models.Index(fields=['name']),
|
||||
models.Index(fields=['is_custom']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
account = getattr(self, 'account', None)
|
||||
return f"{self.name} ({account.name if account else 'No Account'})"
|
||||
status = "Custom" if self.is_custom else "Template"
|
||||
return f"{self.name} ({status}) - {account.name if account else 'No Account'}"
|
||||
|
||||
|
||||
class Strategy(AccountBaseModel):
|
||||
"""
|
||||
Defined content strategies per sector, integrating prompt types, section logic, etc.
|
||||
Can be cloned from global templates or created from scratch.
|
||||
Links together prompts, author profiles, and sector-specific content strategies.
|
||||
"""
|
||||
name = models.CharField(max_length=255, help_text="Strategy name")
|
||||
@@ -135,6 +216,18 @@ class Strategy(AccountBaseModel):
|
||||
default=dict,
|
||||
help_text="Section logic configuration defining content structure and flow"
|
||||
)
|
||||
is_custom = models.BooleanField(
|
||||
default=False,
|
||||
help_text="True if created by account, False if cloned from global template"
|
||||
)
|
||||
cloned_from = models.ForeignKey(
|
||||
'system.GlobalStrategy',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='cloned_instances',
|
||||
help_text="Reference to the global template this was cloned from"
|
||||
)
|
||||
is_active = models.BooleanField(default=True, db_index=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
@@ -148,8 +241,10 @@ class Strategy(AccountBaseModel):
|
||||
models.Index(fields=['account', 'is_active']),
|
||||
models.Index(fields=['account', 'sector']),
|
||||
models.Index(fields=['name']),
|
||||
models.Index(fields=['is_custom']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
sector_name = self.sector.name if self.sector else 'Global'
|
||||
return f"{self.name} ({sector_name})"
|
||||
status = "Custom" if self.is_custom else "Template"
|
||||
return f"{self.name} ({status}) - {sector_name}"
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ page_title }} | Django Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="api-monitor">
|
||||
<h1>{{ page_title }}</h1>
|
||||
|
||||
<div class="api-summary" style="margin: 20px 0; padding: 20px; border-radius: 8px; background: #f8f9fa; border: 1px solid #dee2e6;">
|
||||
<h2 style="margin: 0 0 15px 0;">API Health Overview</h2>
|
||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px;">
|
||||
<div style="padding: 15px; background: white; border-radius: 8px; border: 1px solid #dee2e6; text-align: center;">
|
||||
<div style="font-size: 32px; font-weight: bold; color: #333;">{{ stats.total }}</div>
|
||||
<div style="color: #666; margin-top: 5px;">Total Endpoints</div>
|
||||
</div>
|
||||
<div style="padding: 15px; background: white; border-radius: 8px; border: 1px solid #28a745; text-align: center;">
|
||||
<div style="font-size: 32px; font-weight: bold; color: #28a745;">{{ stats.healthy }}</div>
|
||||
<div style="color: #666; margin-top: 5px;">Healthy</div>
|
||||
</div>
|
||||
<div style="padding: 15px; background: white; border-radius: 8px; border: 1px solid #ffc107; text-align: center;">
|
||||
<div style="font-size: 32px; font-weight: bold; color: #ffc107;">{{ stats.warnings }}</div>
|
||||
<div style="color: #666; margin-top: 5px;">Warnings</div>
|
||||
</div>
|
||||
<div style="padding: 15px; background: white; border-radius: 8px; border: 1px solid #dc3545; text-align: center;">
|
||||
<div style="font-size: 32px; font-weight: bold; color: #dc3545;">{{ stats.errors }}</div>
|
||||
<div style="color: #666; margin-top: 5px;">Errors</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin: 15px 0 0 0; color: #666;">Last checked: {{ checked_at|date:"Y-m-d H:i:s" }}</p>
|
||||
</div>
|
||||
|
||||
{% for group in endpoint_groups %}
|
||||
<div class="endpoint-group" style="margin: 20px 0; padding: 20px; border: 1px solid #ddd; border-radius: 8px; background: #fff;">
|
||||
<h3 style="margin: 0 0 15px 0; font-size: 18px; color: #333;">{{ group.name }}</h3>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="background: #f8f9fa; border-bottom: 2px solid #dee2e6;">
|
||||
<th style="padding: 10px; text-align: left; font-weight: 600;">Endpoint</th>
|
||||
<th style="padding: 10px; text-align: center; font-weight: 600; width: 100px;">Method</th>
|
||||
<th style="padding: 10px; text-align: center; font-weight: 600; width: 100px;">Status</th>
|
||||
<th style="padding: 10px; text-align: center; font-weight: 600; width: 120px;">Response Time</th>
|
||||
<th style="padding: 10px; text-align: left; font-weight: 600; width: 150px;">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for endpoint in group.endpoints %}
|
||||
<tr style="border-bottom: 1px solid #eee;">
|
||||
<td style="padding: 10px; font-family: monospace; font-size: 13px;">{{ endpoint.path }}</td>
|
||||
<td style="padding: 10px; text-align: center;">
|
||||
<span style="padding: 3px 8px; background: #6c757d; color: white; border-radius: 4px; font-size: 11px; font-weight: bold;">
|
||||
{{ endpoint.method }}
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding: 10px; text-align: center;">
|
||||
<span style="padding: 3px 12px; background: {% if endpoint.status == 'healthy' %}#28a745{% elif endpoint.status == 'warning' %}#ffc107{% else %}#dc3545{% endif %}; color: white; border-radius: 4px; font-size: 11px; font-weight: bold;">
|
||||
{% if endpoint.status_code %}{{ endpoint.status_code }}{% else %}ERR{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding: 10px; text-align: center; font-family: monospace; color: #666;">
|
||||
{{ endpoint.response_time|default:"—" }}
|
||||
</td>
|
||||
<td style="padding: 10px; color: #666; font-size: 13px;">
|
||||
{{ endpoint.message }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div style="margin: 20px 0; padding: 15px; background: #f8f9fa; border-radius: 8px;">
|
||||
<p style="margin: 0; color: #666; font-size: 14px;">
|
||||
<strong>Note:</strong> This page tests API endpoints from the server. Authentication-required endpoints may show 401 (expected).
|
||||
<a href="{% url 'admin:monitoring_api_monitor' %}" style="margin-left: 10px;">Refresh Now</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.api-monitor {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,61 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ page_title }} | Django Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="debug-console">
|
||||
<h1>{{ page_title }}</h1>
|
||||
|
||||
<div class="debug-header" style="margin: 20px 0; padding: 20px; border-radius: 8px; background: #fff3cd; border: 1px solid #ffeaa7;">
|
||||
<p style="margin: 0; color: #856404;">
|
||||
<strong>⚠ Read-Only Debug Information</strong><br>
|
||||
This page displays system configuration for debugging purposes. No sensitive data (passwords, API keys) is shown.
|
||||
</p>
|
||||
<p style="margin: 10px 0 0 0; color: #666; font-size: 14px;">
|
||||
Last checked: {{ checked_at|date:"Y-m-d H:i:s" }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% for section in sections %}
|
||||
<div class="debug-section" style="margin: 20px 0; padding: 20px; border: 1px solid #ddd; border-radius: 8px; background: #fff;">
|
||||
<h3 style="margin: 0 0 15px 0; font-size: 18px; color: #333; padding-bottom: 10px; border-bottom: 2px solid #007bff;">
|
||||
{{ section.title }}
|
||||
</h3>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
{% for key, value in section.items.items %}
|
||||
<tr style="border-bottom: 1px solid #f0f0f0;">
|
||||
<td style="padding: 12px 10px; color: #666; width: 250px; font-weight: 600;">{{ key }}:</td>
|
||||
<td style="padding: 12px 10px; font-family: monospace; color: #333; word-break: break-all;">
|
||||
{% if value is True %}
|
||||
<span style="color: #28a745; font-weight: bold;">✓ True</span>
|
||||
{% elif value is False %}
|
||||
<span style="color: #dc3545; font-weight: bold;">✗ False</span>
|
||||
{% elif value is None %}
|
||||
<span style="color: #6c757d;">None</span>
|
||||
{% else %}
|
||||
{{ value }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div style="margin: 20px 0; padding: 15px; background: #f8f9fa; border-radius: 8px;">
|
||||
<p style="margin: 0; color: #666; font-size: 14px;">
|
||||
<strong>Security Note:</strong> This console does not display sensitive information like API keys or passwords.
|
||||
For full configuration details, access the Django settings file directly on the server.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.debug-console {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,65 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ page_title }} | Django Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="system-health-monitor">
|
||||
<h1>{{ page_title }}</h1>
|
||||
|
||||
<div class="health-summary" style="margin: 20px 0; padding: 20px; border-radius: 8px; background: {% if overall_status == 'healthy' %}#d4edda{% elif overall_status == 'warning' %}#fff3cd{% else %}#f8d7da{% endif %}; border: 1px solid {% if overall_status == 'healthy' %}#c3e6cb{% elif overall_status == 'warning' %}#ffeaa7{% else %}#f5c6cb{% endif %};">
|
||||
<h2 style="margin: 0 0 10px 0; color: {% if overall_status == 'healthy' %}#155724{% elif overall_status == 'warning' %}#856404{% else %}#721c24{% endif %};">
|
||||
{% if overall_status == 'healthy' %}✓{% elif overall_status == 'warning' %}⚠{% else %}✗{% endif %} {{ overall_message }}
|
||||
</h2>
|
||||
<p style="margin: 0; color: #666;">Last checked: {{ checked_at|date:"Y-m-d H:i:s" }}</p>
|
||||
</div>
|
||||
|
||||
<div class="health-checks" style="margin: 20px 0;">
|
||||
{% for check in checks %}
|
||||
<div class="health-check" style="margin: 15px 0; padding: 20px; border: 1px solid #ddd; border-radius: 8px; background: #fff;">
|
||||
<div style="display: flex; align-items: center; margin-bottom: 10px;">
|
||||
<span style="font-size: 24px; margin-right: 10px;">
|
||||
{% if check.status == 'healthy' %}✓{% elif check.status == 'warning' %}⚠{% else %}✗{% endif %}
|
||||
</span>
|
||||
<div style="flex: 1;">
|
||||
<h3 style="margin: 0; font-size: 18px;">{{ check.name }}</h3>
|
||||
<p style="margin: 5px 0 0 0; color: {% if check.status == 'healthy' %}#28a745{% elif check.status == 'warning' %}#ffc107{% else %}#dc3545{% endif %};">
|
||||
{{ check.message }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge" style="padding: 5px 15px; border-radius: 12px; font-size: 12px; font-weight: bold; background: {% if check.status == 'healthy' %}#28a745{% elif check.status == 'warning' %}#ffc107{% else %}#dc3545{% endif %}; color: white;">
|
||||
{{ check.status|upper }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if check.details %}
|
||||
<div class="details" style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #eee;">
|
||||
<table style="width: 100%; font-size: 14px;">
|
||||
{% for key, value in check.details.items %}
|
||||
<tr>
|
||||
<td style="padding: 5px 10px; color: #666; width: 200px;">{{ key|title }}:</td>
|
||||
<td style="padding: 5px 10px; font-family: monospace;">{{ value }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div style="margin: 20px 0; padding: 15px; background: #f8f9fa; border-radius: 8px;">
|
||||
<p style="margin: 0; color: #666; font-size: 14px;">
|
||||
<strong>Note:</strong> This page shows real-time system health. Refresh to get latest status.
|
||||
<a href="{% url 'admin:monitoring_system_health' %}" style="margin-left: 10px;">Refresh Now</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.system-health-monitor {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user