""" System Module Admin """ from django.contrib import admin from django import forms 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, GlobalModuleSettings, ) from django.contrib import messages from import_export.admin import ExportMixin, ImportExportMixin from import_export import resources class AIPromptResource(resources.ModelResource): """Resource class for importing/exporting AI Prompts""" class Meta: model = AIPrompt fields = ('id', 'account__name', 'prompt_type', 'prompt_value', 'is_active', 'created_at') export_order = fields import_id_fields = ('id',) skip_unchanged = True # Import settings admin from .settings_admin import ( SystemSettingsAdmin, AccountSettingsAdmin, UserSettingsAdmin, ModuleSettingsAdmin ) try: from .models import SystemLog, SystemStatus @admin.register(SystemLog) class SystemLogAdmin(AccountAdminMixin, Igny8ModelAdmin): list_display = ['id', 'account', 'module', 'level', 'action', 'message', 'created_at'] list_filter = ['module', 'level', 'created_at', 'account'] search_fields = ['message', 'action'] readonly_fields = ['created_at', 'updated_at'] date_hierarchy = 'created_at' @admin.register(SystemStatus) class SystemStatusAdmin(AccountAdminMixin, Igny8ModelAdmin): list_display = ['component', 'account', 'status', 'message', 'last_check'] list_filter = ['status', 'component', 'account'] search_fields = ['component', 'message'] readonly_fields = ['last_check'] except ImportError: pass @admin.register(AIPrompt) class AIPromptAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin): resource_class = AIPromptResource list_display = ['id', 'prompt_type', 'account', 'is_customized', 'is_active', 'updated_at'] list_filter = ['prompt_type', 'is_active', 'is_customized', 'account'] search_fields = ['prompt_type'] readonly_fields = ['created_at', 'updated_at', 'default_prompt'] actions = [ 'bulk_activate', 'bulk_deactivate', 'bulk_reset_to_default', ] fieldsets = ( ('Basic Info', { 'fields': ('account', 'prompt_type', 'is_active', 'is_customized') }), ('Prompt Content', { 'fields': ('prompt_value', 'default_prompt'), 'description': 'Customize prompt_value or reset to default_prompt' }), ('Timestamps', { 'fields': ('created_at', 'updated_at') }), ) def get_account_display(self, obj): """Safely get account name""" try: account = getattr(obj, 'account', None) return account.name if account else '-' except: return '-' get_account_display.short_description = 'Account' def bulk_activate(self, request, queryset): updated = queryset.update(is_active=True) self.message_user(request, f'{updated} AI prompt(s) activated.', messages.SUCCESS) bulk_activate.short_description = 'Activate selected prompts' def bulk_deactivate(self, request, queryset): updated = queryset.update(is_active=False) self.message_user(request, f'{updated} AI prompt(s) deactivated.', messages.SUCCESS) bulk_deactivate.short_description = 'Deactivate selected prompts' def bulk_reset_to_default(self, request, queryset): """Reset selected prompts to their global defaults""" count = 0 for prompt in queryset: if prompt.default_prompt: prompt.reset_to_default() count += 1 self.message_user(request, f'{count} prompt(s) reset to default.', messages.SUCCESS) bulk_reset_to_default.short_description = 'Reset selected prompts to global default' class IntegrationSettingsResource(resources.ModelResource): """Resource class for exporting Integration Settings (config masked)""" class Meta: model = IntegrationSettings fields = ('id', 'account__name', 'integration_type', 'is_active', 'created_at') export_order = fields @admin.register(IntegrationSettings) class IntegrationSettingsAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin): """ Admin for per-account integration setting overrides. IMPORTANT: This stores ONLY model selection and parameter overrides. API keys come from GlobalIntegrationSettings and cannot be overridden. Free plan users cannot create these - they must use global defaults. """ resource_class = IntegrationSettingsResource list_display = ['id', 'integration_type', 'account', 'is_active', 'updated_at'] list_filter = ['integration_type', 'is_active', 'account'] search_fields = ['integration_type', 'account__name'] readonly_fields = ['created_at', 'updated_at'] actions = [ 'bulk_activate', 'bulk_deactivate', ] fieldsets = ( ('Basic Info', { 'fields': ('account', 'integration_type', 'is_active') }), ('Configuration Overrides', { 'fields': ('config',), 'description': ( 'JSON overrides for model/parameter selection. ' 'Fields: model, temperature, max_tokens, image_size, image_quality, etc. ' 'Leave null to use global defaults. ' 'Example: {"model": "gpt-4", "temperature": 0.8, "max_tokens": 4000} ' 'WARNING: NEVER store API keys here - they come from GlobalIntegrationSettings' ) }), ('Timestamps', { 'fields': ('created_at', 'updated_at') }), ) def get_account_display(self, obj): """Safely get account name""" try: account = getattr(obj, 'account', None) return account.name if account else '-' except: return '-' get_account_display.short_description = 'Account' def bulk_activate(self, request, queryset): updated = queryset.update(is_active=True) self.message_user(request, f'{updated} integration setting(s) activated.', messages.SUCCESS) bulk_activate.short_description = 'Activate selected integrations' def bulk_deactivate(self, request, queryset): updated = queryset.update(is_active=False) self.message_user(request, f'{updated} integration setting(s) deactivated.', messages.SUCCESS) bulk_deactivate.short_description = 'Deactivate selected integrations' def bulk_test_connection(self, request, queryset): """Test connection for selected integration settings""" count = queryset.filter(is_active=True).count() self.message_user( request, f'{count} integration(s) queued for connection test. (Test logic to be implemented)', messages.INFO ) bulk_test_connection.short_description = 'Test connections' class AuthorProfileResource(resources.ModelResource): """Resource class for importing/exporting Author Profiles""" class Meta: model = AuthorProfile fields = ('id', 'name', 'account__name', 'tone', 'language', 'is_active', 'created_at') export_order = fields import_id_fields = ('id',) skip_unchanged = True @admin.register(AuthorProfile) class AuthorProfileAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin): resource_class = AuthorProfileResource list_display = ['name', 'account', 'tone', 'language', 'is_active', 'created_at'] list_filter = ['is_active', 'tone', 'language', 'account'] search_fields = ['name', 'description', 'tone'] readonly_fields = ['created_at', 'updated_at'] actions = [ 'bulk_activate', 'bulk_deactivate', 'bulk_clone', ] fieldsets = ( ('Basic Info', { 'fields': ('account', 'name', 'description', 'is_active') }), ('Writing Style', { 'fields': ('tone', 'language', 'structure_template') }), ('Timestamps', { 'fields': ('created_at', 'updated_at') }), ) def get_account_display(self, obj): """Safely get account name""" try: account = getattr(obj, 'account', None) return account.name if account else '-' except: return '-' get_account_display.short_description = 'Account' def bulk_activate(self, request, queryset): updated = queryset.update(is_active=True) self.message_user(request, f'{updated} author profile(s) activated.', messages.SUCCESS) bulk_activate.short_description = 'Activate selected profiles' def bulk_deactivate(self, request, queryset): updated = queryset.update(is_active=False) self.message_user(request, f'{updated} author profile(s) deactivated.', messages.SUCCESS) bulk_deactivate.short_description = 'Deactivate selected profiles' def bulk_clone(self, request, queryset): count = 0 for profile in queryset: profile_copy = AuthorProfile.objects.get(pk=profile.pk) profile_copy.pk = None profile_copy.name = f"{profile.name} (Copy)" profile_copy.is_active = False profile_copy.save() count += 1 self.message_user(request, f'{count} author profile(s) cloned.', messages.SUCCESS) bulk_clone.short_description = 'Clone selected profiles' class StrategyResource(resources.ModelResource): """Resource class for importing/exporting Strategies""" class Meta: model = Strategy fields = ('id', 'name', 'account__name', 'sector__name', 'is_active', 'created_at') export_order = fields import_id_fields = ('id',) skip_unchanged = True @admin.register(Strategy) class StrategyAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin): resource_class = StrategyResource list_display = ['name', 'account', 'sector', 'is_active', 'created_at'] list_filter = ['is_active', 'account'] search_fields = ['name', 'description'] readonly_fields = ['created_at', 'updated_at'] actions = [ 'bulk_activate', 'bulk_deactivate', 'bulk_clone', ] fieldsets = ( ('Basic Info', { 'fields': ('account', 'name', 'description', 'sector', 'is_active') }), ('Strategy Configuration', { 'fields': ('prompt_types', 'section_logic') }), ('Timestamps', { 'fields': ('created_at', 'updated_at') }), ) def get_account_display(self, obj): """Safely get account name""" try: account = getattr(obj, 'account', None) return account.name if account else '-' except: return '-' get_account_display.short_description = 'Account' def get_sector_display(self, obj): """Safely get sector name""" try: return obj.sector.name if obj.sector else 'Global' except: return 'Global' get_sector_display.short_description = 'Sector' def bulk_activate(self, request, queryset): updated = queryset.update(is_active=True) self.message_user(request, f'{updated} strategy/strategies activated.', messages.SUCCESS) bulk_activate.short_description = 'Activate selected strategies' def bulk_deactivate(self, request, queryset): updated = queryset.update(is_active=False) self.message_user(request, f'{updated} strategy/strategies deactivated.', messages.SUCCESS) bulk_deactivate.short_description = 'Deactivate selected strategies' def bulk_clone(self, request, queryset): count = 0 for strategy in queryset: strategy_copy = Strategy.objects.get(pk=strategy.pk) strategy_copy.pk = None strategy_copy.name = f"{strategy.name} (Copy)" strategy_copy.is_active = False strategy_copy.save() count += 1 self.message_user(request, f'{count} strategy/strategies cloned.', messages.SUCCESS) bulk_clone.short_description = 'Clone selected strategies' # ============================================================================= # GLOBAL SETTINGS ADMIN - Platform-wide defaults # ============================================================================= class GlobalIntegrationSettingsForm(forms.ModelForm): """Custom form for GlobalIntegrationSettings with dynamic choices from AIModelConfig""" class Meta: model = GlobalIntegrationSettings fields = '__all__' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Load choices dynamically from AIModelConfig from igny8_core.modules.system.global_settings_models import ( get_text_model_choices, get_image_model_choices, get_provider_choices, ) # OpenAI text model choices openai_choices = get_text_model_choices() openai_text_choices = [(m, d) for m, d in openai_choices if 'gpt' in m.lower() or 'openai' in m.lower()] if openai_text_choices: self.fields['openai_model'].choices = openai_text_choices # DALL-E image model choices dalle_choices = get_image_model_choices(provider='openai') if dalle_choices: self.fields['dalle_model'].choices = dalle_choices # Runware image model choices runware_choices = get_image_model_choices(provider='runware') if runware_choices: self.fields['runware_model'].choices = runware_choices # Image service provider choices (only OpenAI and Runware for now) image_providers = get_provider_choices(model_type='image') # Filter to only OpenAI and Runware allowed_image_providers = [ (p, d) for p, d in image_providers if p in ('openai', 'runware') ] if allowed_image_providers: self.fields['default_image_service'].choices = allowed_image_providers @admin.register(GlobalIntegrationSettings) class GlobalIntegrationSettingsAdmin(Igny8ModelAdmin): """Admin for global integration settings (singleton)""" form = GlobalIntegrationSettingsForm list_display = ["id", "is_active", "last_updated", "updated_by"] readonly_fields = ["last_updated", "openai_max_tokens", "anthropic_max_tokens"] fieldsets = ( ("OpenAI Settings", { "fields": ("openai_api_key", "openai_model", "openai_temperature", "openai_max_tokens"), "description": "Global OpenAI configuration used by all accounts (unless overridden). Max tokens is loaded from AI Model Configuration." }), ("Image Generation - Default Service", { "fields": ("default_image_service",), "description": "Choose which image generation service is used by default for all accounts" }), ("Image Generation - DALL-E", { "fields": ("dalle_api_key", "dalle_model", "dalle_size"), "description": "Global DALL-E (OpenAI) image generation configuration" }), ("Image Generation - Runware", { "fields": ("runware_api_key", "runware_model"), "description": "Global Runware image generation configuration" }), ("Universal Image Settings", { "fields": ("image_quality", "image_style", "max_in_article_images", "desktop_image_size"), "description": "Image quality, style, and sizing settings that apply to ALL providers (DALL-E, Runware, etc.)" }), ("Status", { "fields": ("is_active", "last_updated", "updated_by") }), ) def get_readonly_fields(self, request, obj=None): """Make max_tokens fields readonly - they are populated from AI Model Configuration""" readonly = list(super().get_readonly_fields(request, obj)) if 'openai_max_tokens' not in readonly: readonly.append('openai_max_tokens') if 'anthropic_max_tokens' not in readonly: readonly.append('anthropic_max_tokens') return readonly def openai_max_tokens(self, obj): """Display max tokens from the selected OpenAI model's configuration""" from igny8_core.modules.system.global_settings_models import get_model_max_tokens max_tokens = get_model_max_tokens(obj.openai_model) if obj else None if max_tokens: return f"{max_tokens:,} (from AI Model Configuration)" return obj.openai_max_tokens if obj else "8192 (default)" openai_max_tokens.short_description = "Max Output Tokens" def anthropic_max_tokens(self, obj): """Display max tokens from the selected Anthropic model's configuration""" from igny8_core.modules.system.global_settings_models import get_model_max_tokens max_tokens = get_model_max_tokens(obj.anthropic_model) if obj else None if max_tokens: return f"{max_tokens:,} (from AI Model Configuration)" return obj.anthropic_max_tokens if obj else "8192 (default)" anthropic_max_tokens.short_description = "Max Output Tokens" def save_model(self, request, obj, form, change): """Update max_tokens from model config on save""" from igny8_core.modules.system.global_settings_models import get_model_max_tokens # Update OpenAI max tokens from model config openai_max = get_model_max_tokens(obj.openai_model) if openai_max: obj.openai_max_tokens = openai_max # Update Anthropic max tokens from model config anthropic_max = get_model_max_tokens(obj.anthropic_model) if anthropic_max: obj.anthropic_max_tokens = anthropic_max super().save_model(request, obj, form, change) 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") }), ) @admin.register(GlobalModuleSettings) class GlobalModuleSettingsAdmin(Igny8ModelAdmin): """ Admin for global module enable/disable settings. Singleton model - only one record exists. Controls which modules are available platform-wide. """ def has_add_permission(self, request): """Only allow one instance""" return not GlobalModuleSettings.objects.exists() def has_delete_permission(self, request, obj=None): """Prevent deletion of singleton""" return False fieldsets = ( ('Module Availability (Platform-Wide)', { 'fields': ( 'planner_enabled', 'writer_enabled', 'thinker_enabled', 'automation_enabled', 'site_builder_enabled', 'linker_enabled', 'optimizer_enabled', 'publisher_enabled', ), 'description': 'Control which modules are available across the entire platform. Disabled modules will not load for ANY user.' }), ('Metadata', { 'fields': ('created_at', 'updated_at'), 'classes': ('collapse',) }), ) readonly_fields = ['created_at', 'updated_at'] list_display = [ 'id', 'planner_enabled', 'writer_enabled', 'thinker_enabled', 'automation_enabled', 'site_builder_enabled', 'linker_enabled', 'optimizer_enabled', 'publisher_enabled', 'updated_at', ] # IntegrationProvider Admin (centralized API keys) from .models import IntegrationProvider @admin.register(IntegrationProvider) class IntegrationProviderAdmin(Igny8ModelAdmin): """ Admin for IntegrationProvider - Centralized API key management. Per final-model-schemas.md """ list_display = [ 'provider_id', 'display_name', 'provider_type', 'is_active', 'is_sandbox', 'has_api_key', 'updated_at', ] list_filter = ['provider_type', 'is_active', 'is_sandbox'] search_fields = ['provider_id', 'display_name'] readonly_fields = ['created_at', 'updated_at'] fieldsets = ( ('Provider Info', { 'fields': ('provider_id', 'display_name', 'provider_type') }), ('API Configuration', { 'fields': ('api_key', 'api_secret', 'webhook_secret', 'api_endpoint'), 'description': 'Enter API keys and endpoints. These are platform-wide.' }), ('Extra Config', { 'fields': ('config',), 'classes': ('collapse',), 'description': 'JSON config for provider-specific settings' }), ('Status', { 'fields': ('is_active', 'is_sandbox') }), ('Metadata', { 'fields': ('updated_by', 'created_at', 'updated_at'), 'classes': ('collapse',) }), ) def has_api_key(self, obj): """Show if API key is configured""" return bool(obj.api_key) has_api_key.boolean = True has_api_key.short_description = 'API Key Set' def save_model(self, request, obj, form, change): """Set updated_by to current user""" obj.updated_by = request.user super().save_model(request, obj, form, change) # SystemAISettings Admin (new simplified AI settings) from .ai_settings import SystemAISettings @admin.register(SystemAISettings) class SystemAISettingsAdmin(Igny8ModelAdmin): """ Admin for SystemAISettings - System-wide AI defaults (Singleton). Per final-model-schemas.md """ list_display = [ 'id', 'temperature', 'max_tokens', 'image_style', 'image_quality', 'max_images_per_article', 'updated_at', ] readonly_fields = ['updated_at'] fieldsets = ( ('AI Parameters', { 'fields': ('temperature', 'max_tokens'), 'description': 'System-wide defaults for AI text generation. Accounts can override via AccountSettings.' }), ('Image Generation', { 'fields': ('image_style', 'image_quality', 'max_images_per_article', 'image_size'), 'description': 'System-wide defaults for image generation. Accounts can override via AccountSettings.' }), ('Metadata', { 'fields': ('updated_by', 'updated_at'), 'classes': ('collapse',) }), ) def has_add_permission(self, request): """Only allow one instance (singleton)""" return not SystemAISettings.objects.exists() def has_delete_permission(self, request, obj=None): """Prevent deletion of singleton""" return False def save_model(self, request, obj, form, change): """Set updated_by to current user""" obj.updated_by = request.user super().save_model(request, obj, form, change) # Import Email Admin (EmailSettings, EmailTemplate, EmailLog) from .email_admin import EmailSettingsAdmin, EmailTemplateAdmin, EmailLogAdmin