""" Billing Module Admin """ from django.contrib import admin from django.utils.html import format_html from django.contrib import messages from unfold.admin import ModelAdmin from simple_history.admin import SimpleHistoryAdmin from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin from igny8_core.business.billing.models import ( CreditCostConfig, BillingConfiguration, Invoice, Payment, CreditPackage, PaymentMethodConfig, PlanLimitUsage, AIModelConfig, ) from .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod from import_export.admin import ExportMixin, ImportExportMixin from import_export import resources class CreditTransactionResource(resources.ModelResource): """Resource class for exporting Credit Transactions""" class Meta: model = CreditTransaction fields = ('id', 'account__name', 'transaction_type', 'amount', 'balance_after', 'description', 'reference_id', 'created_at') export_order = fields @admin.register(CreditTransaction) class CreditTransactionAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin): resource_class = CreditTransactionResource list_display = ['id', 'account', 'transaction_type', 'amount', 'balance_after', 'description', 'created_at'] list_filter = ['transaction_type', 'created_at', 'account'] search_fields = ['description', 'account__name'] readonly_fields = ['created_at'] date_hierarchy = 'created_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' class CreditUsageLogResource(resources.ModelResource): """Resource class for exporting Credit Usage Logs""" class Meta: model = CreditUsageLog fields = ('id', 'account__name', 'operation_type', 'credits_used', 'cost_usd', 'model_used', 'created_at') export_order = fields @admin.register(CreditUsageLog) class CreditUsageLogAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin): resource_class = CreditUsageLogResource list_display = ['id', 'account', 'operation_type', 'credits_used', 'cost_usd', 'model_used', 'created_at'] list_filter = ['operation_type', 'created_at', 'account', 'model_used'] search_fields = ['account__name', 'model_used'] readonly_fields = ['created_at'] date_hierarchy = 'created_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' class InvoiceResource(resources.ModelResource): """Resource class for exporting Invoices""" class Meta: model = Invoice fields = ('id', 'invoice_number', 'account__name', 'status', 'total', 'currency', 'invoice_date', 'due_date', 'created_at', 'updated_at') export_order = fields @admin.register(Invoice) class InvoiceAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin): resource_class = InvoiceResource list_display = [ 'invoice_number', 'account', 'status', 'total', 'currency', 'invoice_date', 'due_date', ] list_filter = ['status', 'currency', 'invoice_date', 'account'] search_fields = ['invoice_number', 'account__name'] readonly_fields = ['created_at', 'updated_at'] actions = [ 'bulk_set_status_draft', 'bulk_set_status_sent', 'bulk_set_status_paid', 'bulk_set_status_overdue', 'bulk_set_status_cancelled', 'bulk_send_reminders', ] def bulk_set_status_draft(self, request, queryset): """Set selected invoices to draft status""" updated = queryset.update(status='draft') self.message_user(request, f'{updated} invoice(s) set to draft.', messages.SUCCESS) bulk_set_status_draft.short_description = 'Set status to Draft' def bulk_set_status_sent(self, request, queryset): """Set selected invoices to sent status""" updated = queryset.update(status='sent') self.message_user(request, f'{updated} invoice(s) set to sent.', messages.SUCCESS) bulk_set_status_sent.short_description = 'Set status to Sent' def bulk_set_status_paid(self, request, queryset): """Set selected invoices to paid status""" updated = queryset.update(status='paid') self.message_user(request, f'{updated} invoice(s) set to paid.', messages.SUCCESS) bulk_set_status_paid.short_description = 'Set status to Paid' def bulk_set_status_overdue(self, request, queryset): """Set selected invoices to overdue status""" updated = queryset.update(status='overdue') self.message_user(request, f'{updated} invoice(s) set to overdue.', messages.SUCCESS) bulk_set_status_overdue.short_description = 'Set status to Overdue' def bulk_set_status_cancelled(self, request, queryset): """Set selected invoices to cancelled status""" updated = queryset.update(status='cancelled') self.message_user(request, f'{updated} invoice(s) set to cancelled.', messages.SUCCESS) bulk_set_status_cancelled.short_description = 'Set status to Cancelled' def bulk_send_reminders(self, request, queryset): """Send reminder emails for selected invoices""" # TODO: Implement email sending logic when email service is configured unpaid = queryset.filter(status__in=['sent', 'overdue']) count = unpaid.count() self.message_user( request, f'{count} invoice reminder(s) queued for sending. (Email integration required)', messages.INFO ) bulk_send_reminders.short_description = 'Send payment reminders' class PaymentResource(resources.ModelResource): """Resource class for exporting Payments""" class Meta: model = Payment fields = ('id', 'invoice__invoice_number', 'account__name', 'payment_method', 'status', 'amount', 'currency', 'manual_reference', 'approved_by__email', 'processed_at', 'created_at') export_order = fields @admin.register(Payment) class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8ModelAdmin): """ Main Payment Admin with approval workflow. When you change status to 'succeeded', it automatically: - Updates invoice to 'paid' - Activates subscription - Activates account - Adds credits """ resource_class = PaymentResource list_display = [ 'id', 'invoice', 'account', 'payment_method', 'status', 'amount', 'currency', 'manual_reference', 'approved_by', 'processed_at', ] list_filter = ['status', 'payment_method', 'currency', 'created_at', 'processed_at'] search_fields = [ 'invoice__invoice_number', 'account__name', 'stripe_payment_intent_id', 'paypal_order_id', 'manual_reference', 'admin_notes', 'manual_notes' ] readonly_fields = ['created_at', 'updated_at', 'approved_at', 'processed_at', 'failed_at', 'refunded_at'] actions = ['approve_payments', 'reject_payments', 'bulk_refund'] fieldsets = ( ('Payment Info', { 'fields': ('invoice', 'account', 'amount', 'currency', 'payment_method', 'status') }), ('Manual Payment Details', { 'fields': ('manual_reference', 'manual_notes', 'admin_notes'), 'classes': ('collapse',), }), ('Stripe/PayPal', { 'fields': ('stripe_payment_intent_id', 'stripe_charge_id', 'paypal_order_id', 'paypal_capture_id'), 'classes': ('collapse',), }), ('Approval Info', { 'fields': ('approved_by', 'approved_at', 'processed_at', 'failed_at', 'refunded_at', 'failure_reason'), 'classes': ('collapse',), }), ('Timestamps', { 'fields': ('created_at', 'updated_at'), 'classes': ('collapse',), }), ) def save_model(self, request, obj, form, change): """ Override save_model to trigger approval workflow when status changes to succeeded. This ensures manual status changes in admin also activate accounts and add credits. """ from django.db import transaction from django.utils import timezone from igny8_core.business.billing.services.credit_service import CreditService from igny8_core.auth.models import Subscription # Check if status changed to 'succeeded' status_changed_to_succeeded = False if change and 'status' in form.changed_data: if obj.status == 'succeeded' and form.initial.get('status') != 'succeeded': status_changed_to_succeeded = True elif not change and obj.status == 'succeeded': status_changed_to_succeeded = True # Save the payment first if obj.status == 'succeeded' and not obj.approved_by: obj.approved_by = request.user if not obj.approved_at: obj.approved_at = timezone.now() if not obj.processed_at: obj.processed_at = timezone.now() super().save_model(request, obj, form, change) # If status changed to succeeded, trigger the full approval workflow if status_changed_to_succeeded: try: with transaction.atomic(): invoice = obj.invoice account = obj.account # Get subscription from invoice or account subscription = None if invoice and hasattr(invoice, 'subscription') and invoice.subscription: subscription = invoice.subscription elif account and hasattr(account, 'subscription'): try: subscription = account.subscription except Subscription.DoesNotExist: pass # Update Invoice if invoice and invoice.status != 'paid': invoice.status = 'paid' invoice.paid_at = timezone.now() invoice.save() # Update Subscription if subscription and subscription.status != 'active': subscription.status = 'active' subscription.external_payment_id = obj.manual_reference subscription.save() # Update Account if account.status != 'active': account.status = 'active' account.save() # Add or Reset Credits (check if not already added) from igny8_core.business.billing.models import CreditTransaction, Invoice existing_credit = CreditTransaction.objects.filter( account=account, metadata__payment_id=obj.id ).exists() if not existing_credit: credits_to_add = 0 plan_name = '' is_renewal = False if subscription and subscription.plan: credits_to_add = subscription.plan.included_credits plan_name = subscription.plan.name # Check if this is a renewal (previous paid invoices exist) previous_paid = Invoice.objects.filter( subscription=subscription, status='paid' ).exclude(id=invoice.id if invoice else None).exists() is_renewal = previous_paid elif account and account.plan: credits_to_add = account.plan.included_credits plan_name = account.plan.name # Check renewal by account history is_renewal = CreditTransaction.objects.filter( account=account, transaction_type='subscription' ).exists() if credits_to_add > 0: if is_renewal: # Renewal: Reset credits to full plan amount CreditService.reset_credits_for_renewal( account=account, new_amount=credits_to_add, description=f'{plan_name} Renewal - Invoice {invoice.invoice_number}', metadata={ 'subscription_id': subscription.id if subscription else None, 'invoice_id': invoice.id, 'payment_id': obj.id, 'approved_by': request.user.email, 'is_renewal': True } ) self.message_user( request, f'✓ Renewal approved: Account activated, credits reset to {credits_to_add}', level='SUCCESS' ) else: # Initial: Add credits CreditService.add_credits( account=account, amount=credits_to_add, transaction_type='subscription', description=f'{plan_name} - Invoice {invoice.invoice_number}', metadata={ 'subscription_id': subscription.id if subscription else None, 'invoice_id': invoice.id, 'payment_id': obj.id, 'approved_by': request.user.email } ) self.message_user( request, f'✓ Payment approved: Account activated, {credits_to_add} credits added', level='SUCCESS' ) except Exception as e: self.message_user( request, f'✗ Payment saved but workflow failed: {str(e)}', level='ERROR' ) def approve_payments(self, request, queryset): """Approve selected manual payments""" from django.db import transaction from django.utils import timezone from igny8_core.business.billing.services.credit_service import CreditService from igny8_core.auth.models import Subscription successful = [] errors = [] for payment in queryset.filter(status='pending_approval'): try: with transaction.atomic(): invoice = payment.invoice account = payment.account # Get subscription from invoice or account subscription = None if invoice and hasattr(invoice, 'subscription') and invoice.subscription: subscription = invoice.subscription elif account and hasattr(account, 'subscription'): try: subscription = account.subscription except Subscription.DoesNotExist: pass # Update Payment payment.status = 'succeeded' payment.approved_by = request.user payment.approved_at = timezone.now() payment.processed_at = timezone.now() payment.admin_notes = f'Bulk approved by {request.user.email}' payment.save() # Update Invoice invoice.status = 'paid' invoice.paid_at = timezone.now() invoice.save() # Update Subscription if subscription: subscription.status = 'active' subscription.external_payment_id = payment.manual_reference subscription.save() # Update Account account.status = 'active' account.save() # Add or Reset Credits based on whether this is a renewal # Check if there are previous paid invoices for this subscription (renewal) from igny8_core.business.billing.models import Invoice, CreditTransaction is_renewal = False if subscription: previous_paid_invoices = Invoice.objects.filter( subscription=subscription, status='paid' ).exclude(id=invoice.id).exists() is_renewal = previous_paid_invoices credits_added = 0 if subscription and subscription.plan and subscription.plan.included_credits > 0: credits_added = subscription.plan.included_credits if is_renewal: # Renewal: Reset credits to full plan amount CreditService.reset_credits_for_renewal( account=account, new_amount=credits_added, description=f'{subscription.plan.name} Renewal - Invoice {invoice.invoice_number}', metadata={ 'subscription_id': subscription.id, 'invoice_id': invoice.id, 'payment_id': payment.id, 'approved_by': request.user.email, 'is_renewal': True } ) else: # Initial subscription: Add credits CreditService.add_credits( account=account, amount=credits_added, transaction_type='subscription', description=f'{subscription.plan.name} - Invoice {invoice.invoice_number}', metadata={ 'subscription_id': subscription.id, 'invoice_id': invoice.id, 'payment_id': payment.id, 'approved_by': request.user.email } ) elif account and account.plan and account.plan.included_credits > 0: credits_added = account.plan.included_credits # Check renewal by looking at account credit transactions previous_subscriptions = CreditTransaction.objects.filter( account=account, transaction_type='subscription' ).exists() if previous_subscriptions: # Renewal: Reset credits CreditService.reset_credits_for_renewal( account=account, new_amount=credits_added, description=f'{account.plan.name} Renewal - Invoice {invoice.invoice_number}', metadata={ 'invoice_id': invoice.id, 'payment_id': payment.id, 'approved_by': request.user.email, 'is_renewal': True } ) else: CreditService.add_credits( account=account, amount=credits_added, transaction_type='subscription', description=f'{account.plan.name} - Invoice {invoice.invoice_number}', metadata={ 'invoice_id': invoice.id, 'payment_id': payment.id, 'approved_by': request.user.email } ) renewal_label = ' (renewal reset)' if is_renewal or (account.plan and CreditTransaction.objects.filter(account=account, transaction_type='subscription').count() > 0) else '' successful.append(f'Payment #{payment.id} - {account.name} - Invoice {invoice.invoice_number} - {credits_added} credits{renewal_label}') except Exception as e: errors.append(f'Payment #{payment.id}: {str(e)}') # Detailed success message if successful: self.message_user(request, f'✓ Successfully approved {len(successful)} payment(s):', level='SUCCESS') for msg in successful[:10]: # Show first 10 self.message_user(request, f' • {msg}', level='SUCCESS') if len(successful) > 10: self.message_user(request, f' ... and {len(successful) - 10} more', level='SUCCESS') # Detailed error messages if errors: self.message_user(request, f'✗ Failed to approve {len(errors)} payment(s):', level='ERROR') for error in errors: self.message_user(request, f' • {error}', level='ERROR') approve_payments.short_description = 'Approve selected manual payments' def reject_payments(self, request, queryset): """Reject selected manual payments""" from django.utils import timezone count = queryset.filter(status='pending_approval').update( status='failed', approved_by=request.user, approved_at=timezone.now(), failed_at=timezone.now(), admin_notes=f'Bulk rejected by {request.user.email}', failure_reason='Rejected by admin' ) self.message_user(request, f'Rejected {count} payment(s)') reject_payments.short_description = 'Reject selected manual payments' def bulk_refund(self, request, queryset): """Refund selected payments""" from django.utils import timezone # Only refund succeeded payments succeeded_payments = queryset.filter(status='succeeded') count = 0 for payment in succeeded_payments: # Mark as refunded payment.status = 'refunded' payment.refunded_at = timezone.now() payment.admin_notes = f'{payment.admin_notes or ""}\nBulk refunded by {request.user.email} on {timezone.now()}' payment.save() # TODO: Process actual refund through payment gateway (Stripe/PayPal) # For now, just marking as refunded in database count += 1 self.message_user( request, f'{count} payment(s) marked as refunded. Note: Actual gateway refunds need to be processed separately.', messages.WARNING ) bulk_refund.short_description = 'Refund selected payments' class CreditPackageResource(resources.ModelResource): """Resource class for importing/exporting Credit Packages""" class Meta: model = CreditPackage fields = ('id', 'name', 'slug', 'credits', 'price', 'discount_percentage', 'is_active', 'is_featured', 'sort_order', 'created_at') export_order = fields import_id_fields = ('id',) skip_unchanged = True @admin.register(CreditPackage) class CreditPackageAdmin(ImportExportMixin, Igny8ModelAdmin): resource_class = CreditPackageResource list_display = ['name', 'slug', 'credits', 'price', 'discount_percentage', 'is_active', 'is_featured', 'sort_order'] list_filter = ['is_active', 'is_featured'] search_fields = ['name', 'slug'] readonly_fields = ['created_at', 'updated_at'] actions = [ 'bulk_activate', 'bulk_deactivate', ] actions = [ 'bulk_activate', 'bulk_deactivate', ] def bulk_activate(self, request, queryset): updated = queryset.update(is_active=True) self.message_user(request, f'{updated} credit package(s) activated.', messages.SUCCESS) bulk_activate.short_description = 'Activate selected packages' def bulk_deactivate(self, request, queryset): updated = queryset.update(is_active=False) self.message_user(request, f'{updated} credit package(s) deactivated.', messages.SUCCESS) bulk_deactivate.short_description = 'Deactivate selected packages' @admin.register(PaymentMethodConfig) class PaymentMethodConfigAdmin(Igny8ModelAdmin): list_display = ['country_code', 'payment_method', 'display_name', 'is_enabled', 'sort_order', 'updated_at'] list_filter = ['payment_method', 'is_enabled', 'country_code'] search_fields = ['country_code', 'display_name', 'payment_method'] list_editable = ['is_enabled', 'sort_order'] readonly_fields = ['created_at', 'updated_at'] fieldsets = ( ('Payment Method', { 'fields': ('country_code', 'payment_method', 'display_name', 'is_enabled', 'sort_order') }), ('Instructions', { 'fields': ('instructions',), 'description': 'Instructions shown to users for this payment method' }), ('Bank Transfer Details', { 'fields': ('bank_name', 'account_title', 'account_number', 'routing_number', 'swift_code', 'iban'), 'classes': ('collapse',), 'description': 'Only for bank_transfer payment method' }), ('Local Wallet Details', { 'fields': ('wallet_type', 'wallet_id'), 'classes': ('collapse',), 'description': 'Only for local_wallet payment method (JazzCash, EasyPaisa, etc.)' }), ('Timestamps', { 'fields': ('created_at', 'updated_at'), 'classes': ('collapse',) }), ) @admin.register(AccountPaymentMethod) class AccountPaymentMethodAdmin(AccountAdminMixin, Igny8ModelAdmin): list_display = [ 'display_name', 'type', 'account', 'is_default', 'is_enabled', 'is_verified', 'country_code', 'updated_at', ] list_filter = ['type', 'is_default', 'is_enabled', 'is_verified', 'country_code'] search_fields = ['display_name', 'account__name', 'account__id'] readonly_fields = ['created_at', 'updated_at'] fieldsets = ( ('Payment Method', { 'fields': ('account', 'type', 'display_name', 'is_default', 'is_enabled', 'is_verified', 'country_code') }), ('Instructions / Metadata', { 'fields': ('instructions', 'metadata') }), ('Timestamps', { 'fields': ('created_at', 'updated_at'), 'classes': ('collapse',) }), ) @admin.register(CreditCostConfig) class CreditCostConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin): """ Admin for Credit Cost Configuration. Per final-model-schemas.md - Fixed credits per operation type. """ list_display = [ 'operation_type', 'display_name', 'base_credits_display', 'is_active_icon', ] list_filter = ['is_active'] search_fields = ['operation_type', 'display_name', 'description'] actions = ['bulk_activate', 'bulk_deactivate'] fieldsets = ( ('Operation', { 'fields': ('operation_type', 'display_name', 'description') }), ('Credits', { 'fields': ('base_credits', 'is_active'), 'description': 'Fixed credits charged per operation' }), ) def base_credits_display(self, obj): """Show base credits with formatting""" return format_html( '{} credits', obj.base_credits ) base_credits_display.short_description = 'Credits' def is_active_icon(self, obj): """Active status icon""" if obj.is_active: return format_html( '' ) return format_html( '' ) is_active_icon.short_description = 'Active' @admin.action(description='Activate selected configurations') def bulk_activate(self, request, queryset): """Bulk activate credit cost configurations""" updated = queryset.update(is_active=True) self.message_user(request, f'{updated} configuration(s) activated.', messages.SUCCESS) @admin.action(description='Deactivate selected configurations') def bulk_deactivate(self, request, queryset): """Bulk deactivate credit cost configurations""" updated = queryset.update(is_active=False) self.message_user(request, f'{updated} configuration(s) deactivated.', messages.WARNING) class PlanLimitUsageResource(resources.ModelResource): """Resource class for exporting Plan Limit Usage""" class Meta: model = PlanLimitUsage fields = ('id', 'account__name', 'limit_type', 'amount_used', 'period_start', 'period_end', 'created_at') export_order = fields @admin.register(PlanLimitUsage) class PlanLimitUsageAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin): resource_class = PlanLimitUsageResource """Admin for tracking plan limit usage across billing periods""" list_display = [ 'account', 'limit_type', 'amount_used', 'period_display', 'created_at', ] list_filter = ['limit_type', 'period_start', 'period_end', 'account'] search_fields = ['account__name'] readonly_fields = ['created_at', 'updated_at'] date_hierarchy = 'period_start' actions = [ 'bulk_reset_usage', 'bulk_delete_old_records', ] fieldsets = ( ('Usage Info', { 'fields': ('account', 'limit_type', 'amount_used') }), ('Billing Period', { 'fields': ('period_start', 'period_end') }), ('Metadata', { 'fields': ('metadata',), 'classes': ('collapse',) }), ('Timestamps', { 'fields': ('created_at', 'updated_at'), 'classes': ('collapse',) }), ) def period_display(self, obj): """Display billing period range""" return f"{obj.period_start} to {obj.period_end}" period_display.short_description = 'Billing Period' def bulk_reset_usage(self, request, queryset): """Reset usage counters to zero""" updated = queryset.update(amount_used=0) self.message_user(request, f'{updated} usage counter(s) reset to zero.', messages.SUCCESS) bulk_reset_usage.short_description = 'Reset usage counters' def bulk_delete_old_records(self, request, queryset): """Delete usage records older than 1 year""" from django.utils import timezone from datetime import timedelta cutoff_date = timezone.now() - timedelta(days=365) old_records = queryset.filter(period_end__lt=cutoff_date) count = old_records.count() old_records.delete() self.message_user(request, f'{count} old usage record(s) deleted (older than 1 year).', messages.SUCCESS) bulk_delete_old_records.short_description = 'Delete old records (>1 year)' @admin.register(BillingConfiguration) class BillingConfigurationAdmin(Igny8ModelAdmin): """Admin for global billing configuration (Singleton)""" list_display = [ 'id', 'default_tokens_per_credit', 'default_credit_price_usd', 'credit_rounding_mode', 'enable_token_based_reporting', 'updated_at', 'updated_by' ] fieldsets = ( ('Global Token-to-Credit Settings', { 'fields': ('default_tokens_per_credit', 'default_credit_price_usd', 'credit_rounding_mode'), 'description': 'These settings apply when no operation-specific config exists' }), ('Reporting Settings', { 'fields': ('enable_token_based_reporting',), 'description': 'Control token-based reporting features' }), ('Audit Trail', { 'fields': ('updated_by', 'updated_at'), 'classes': ('collapse',) }), ) readonly_fields = ['updated_at'] def has_add_permission(self, request): """Only allow one instance (singleton)""" from igny8_core.business.billing.models import BillingConfiguration return not BillingConfiguration.objects.exists() def has_delete_permission(self, request, obj=None): """Prevent deletion of the singleton""" return False def save_model(self, request, obj, form, change): """Track who made the change""" obj.updated_by = request.user super().save_model(request, obj, form, change) @admin.register(AIModelConfig) class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin): """ Admin for AI Model Configuration - Single Source of Truth for Models. Per final-model-schemas.md """ list_display = [ 'model_name', 'display_name_short', 'model_type_badge', 'provider_badge', 'credit_display', 'quality_tier', 'is_testing_icon', 'is_active_icon', 'is_default_icon', 'updated_at', ] list_filter = [ 'model_type', 'provider', 'quality_tier', 'is_testing', 'is_active', 'is_default', ] search_fields = ['model_name', 'display_name'] ordering = ['model_type', 'model_name'] readonly_fields = ['created_at', 'updated_at'] fieldsets = ( ('Basic Information', { 'fields': ('model_name', 'model_type', 'provider', 'display_name'), 'description': 'Core model identification' }), ('Text Model Pricing', { 'fields': ('cost_per_1k_input', 'cost_per_1k_output', 'tokens_per_credit', 'max_tokens', 'context_window'), 'description': 'For TEXT models only', 'classes': ('collapse',) }), ('Image Model Pricing', { 'fields': ('credits_per_image', 'quality_tier'), 'description': 'For IMAGE models only', 'classes': ('collapse',) }), ('Image Model Sizes', { 'fields': ('landscape_size', 'square_size', 'valid_sizes'), 'description': 'For IMAGE models: specify supported image dimensions', 'classes': ('collapse',) }), ('Capabilities', { 'fields': ('capabilities',), 'description': 'JSON: vision, function_calling, json_mode, etc.', 'classes': ('collapse',) }), ('Status', { 'fields': ('is_active', 'is_default', 'is_testing'), 'description': 'is_testing: Mark as cheap testing model (one per model_type)' }), ('Timestamps', { 'fields': ('created_at', 'updated_at'), 'classes': ('collapse',) }), ) # Custom display methods def display_name_short(self, obj): """Truncated display name for list view""" if len(obj.display_name) > 40: return obj.display_name[:37] + '...' return obj.display_name display_name_short.short_description = 'Display Name' def model_type_badge(self, obj): """Colored badge for model type""" colors = { 'text': '#3498db', # Blue 'image': '#e74c3c', # Red } color = colors.get(obj.model_type, '#95a5a6') return format_html( '{}', color, obj.get_model_type_display() ) model_type_badge.short_description = 'Type' def provider_badge(self, obj): """Colored badge for provider""" colors = { 'openai': '#10a37f', 'anthropic': '#d97757', 'runware': '#6366f1', 'google': '#4285f4', } color = colors.get(obj.provider, '#95a5a6') return format_html( '{}', color, obj.get_provider_display() ) provider_badge.short_description = 'Provider' def credit_display(self, obj): """Format credit info based on model type""" if obj.model_type == 'text' and obj.tokens_per_credit: return format_html( '{} tokens/credit', obj.tokens_per_credit ) elif obj.model_type == 'image' and obj.credits_per_image: return format_html( '{} credits/image', obj.credits_per_image ) return '-' credit_display.short_description = 'Credits' def is_active_icon(self, obj): """Active status icon""" if obj.is_active: return format_html( '' ) return format_html( '' ) is_active_icon.short_description = 'Active' def is_default_icon(self, obj): """Default status icon""" if obj.is_default: return format_html( '' ) return format_html( '' ) is_default_icon.short_description = 'Default' def is_testing_icon(self, obj): """Testing status icon - shows ⚡ for testing models""" if obj.is_testing: return format_html( '' ) return format_html( '' ) is_testing_icon.short_description = 'Testing/Live' # Admin actions actions = ['bulk_activate', 'bulk_deactivate', 'set_as_default', 'set_as_testing', 'unset_testing'] def bulk_activate(self, request, queryset): """Enable selected models""" count = queryset.update(is_active=True) self.message_user(request, f'{count} model(s) activated.', messages.SUCCESS) bulk_activate.short_description = 'Activate selected models' def bulk_deactivate(self, request, queryset): """Disable selected models""" count = queryset.update(is_active=False) self.message_user(request, f'{count} model(s) deactivated.', messages.WARNING) bulk_deactivate.short_description = 'Deactivate selected models' def set_as_default(self, request, queryset): """Set one model as default for its type""" if queryset.count() != 1: self.message_user(request, 'Select exactly one model.', messages.ERROR) return model = queryset.first() AIModelConfig.objects.filter( model_type=model.model_type, is_default=True ).exclude(pk=model.pk).update(is_default=False) model.is_default = True model.save() self.message_user( request, f'{model.model_name} is now the default {model.get_model_type_display()} model.', messages.SUCCESS ) set_as_default.short_description = 'Set as default model' def set_as_testing(self, request, queryset): """Set one model as testing model for its type""" if queryset.count() != 1: self.message_user(request, 'Select exactly one model.', messages.ERROR) return model = queryset.first() # Unset any existing testing model for this type AIModelConfig.objects.filter( model_type=model.model_type, is_testing=True, is_active=True ).exclude(pk=model.pk).update(is_testing=False) model.is_testing = True model.save() self.message_user( request, f'{model.model_name} is now the TESTING {model.get_model_type_display()} model.', messages.SUCCESS ) set_as_testing.short_description = 'Set as testing model (cheap, for testing)' def unset_testing(self, request, queryset): """Remove testing flag from selected models""" count = queryset.update(is_testing=False) self.message_user(request, f'{count} model(s) unmarked as testing.', messages.SUCCESS) unset_testing.short_description = 'Unset testing flag'