""" Admin interface for auth models """ from django import forms from django.contrib import admin, messages from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from unfold.admin import ModelAdmin, TabularInline from simple_history.admin import SimpleHistoryAdmin from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword, PasswordResetToken from import_export.admin import ExportMixin, ImportExportMixin from import_export import resources, fields, widgets class AccountAdminForm(forms.ModelForm): """Custom form for Account admin with dynamic payment method choices from PaymentMethodConfig""" class Meta: model = Account fields = '__all__' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) from igny8_core.business.billing.models import PaymentMethodConfig, AccountPaymentMethod if self.instance and self.instance.pk: # Get country from billing_country, fallback to wildcard '*' for global country = self.instance.billing_country or '*' # Get enabled payment methods for this country OR global (*) available_methods = PaymentMethodConfig.objects.filter( country_code__in=[country, '*'], is_enabled=True ).order_by('country_code', 'sort_order').values_list('payment_method', 'display_name') if available_methods: # Build choices from PaymentMethodConfig choices = [] seen = set() for method_type, display_name in available_methods: if method_type not in seen: choices.append((method_type, display_name or method_type.replace('_', ' ').title())) seen.add(method_type) else: # Fallback to model choices if no configs choices = Account.PAYMENT_METHOD_CHOICES self.fields['payment_method'].widget = forms.Select(choices=choices) # Get current default from AccountPaymentMethod default_method = AccountPaymentMethod.objects.filter( account=self.instance, is_default=True, is_enabled=True ).first() if default_method: self.fields['payment_method'].initial = default_method.type self.fields['payment_method'].help_text = f'✓ Current: {default_method.display_name} ({default_method.get_type_display()})' else: self.fields['payment_method'].help_text = 'Select from available payment methods based on country' def save(self, commit=True): """When payment_method changes, update/create AccountPaymentMethod""" from igny8_core.business.billing.models import AccountPaymentMethod, PaymentMethodConfig instance = super().save(commit=False) if commit: instance.save() # Get selected payment method selected_type = self.cleaned_data.get('payment_method') if selected_type: # Get config for display name and instructions country = instance.billing_country or '*' config = PaymentMethodConfig.objects.filter( country_code__in=[country, '*'], payment_method=selected_type, is_enabled=True ).first() # Create or update AccountPaymentMethod account_method, created = AccountPaymentMethod.objects.get_or_create( account=instance, type=selected_type, defaults={ 'display_name': config.display_name if config else selected_type.replace('_', ' ').title(), 'is_default': True, 'is_enabled': True, 'instructions': config.instructions if config else '', 'country_code': instance.billing_country or '', } ) if not created: # Update existing and set as default account_method.is_default = True account_method.is_enabled = True if config: account_method.display_name = config.display_name account_method.instructions = config.instructions account_method.save() # Unset other methods as default AccountPaymentMethod.objects.filter( account=instance ).exclude(id=account_method.id).update(is_default=False) return instance class PlanResource(resources.ModelResource): """Resource class for importing/exporting Plans""" class Meta: model = Plan fields = ('id', 'name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'max_keywords', 'max_ahrefs_queries', 'included_credits', 'is_active', 'is_featured') export_order = fields import_id_fields = ('id',) skip_unchanged = True @admin.register(Plan) class PlanAdmin(ImportExportMixin, Igny8ModelAdmin): resource_class = PlanResource """Plan admin - Global, no account filtering needed""" list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'max_keywords', 'max_ahrefs_queries', 'included_credits', 'is_active', 'is_featured'] list_filter = ['is_active', 'billing_cycle', 'is_internal', 'is_featured'] search_fields = ['name', 'slug'] readonly_fields = ['created_at'] actions = [ 'bulk_set_active', 'bulk_set_inactive', 'bulk_clone_plans', ] fieldsets = ( ('Plan Info', { 'fields': ('name', 'slug', 'price', 'original_price', 'annual_discount_percent', 'billing_cycle', 'features', 'is_active', 'is_featured', 'is_internal'), 'description': 'Price: Current price | Original Price: Crossed-out price (optional) | Annual Discount %: For annual billing | Is Featured: Show as popular/recommended plan' }), ('Account Management Limits', { 'fields': ('max_users', 'max_sites', 'max_industries', 'max_author_profiles'), 'description': 'Persistent limits for account-level resources' }), ('Hard Limits (Persistent)', { 'fields': ('max_keywords',), 'description': 'Total allowed - never reset' }), ('Monthly Limits (Reset on Billing Cycle)', { 'fields': ('max_ahrefs_queries',), 'description': 'Monthly Ahrefs keyword research queries (0 = disabled)' }), ('Billing & Credits', { 'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month') }), ('Stripe Integration', { 'fields': ('stripe_product_id', 'stripe_price_id') }), ) def bulk_set_active(self, request, queryset): """Set selected plans to active""" updated = queryset.update(is_active=True) self.message_user(request, f'{updated} plan(s) set to active.', messages.SUCCESS) bulk_set_active.short_description = 'Set plans to Active' def bulk_set_inactive(self, request, queryset): """Set selected plans to inactive""" updated = queryset.update(is_active=False) self.message_user(request, f'{updated} plan(s) set to inactive.', messages.SUCCESS) bulk_set_inactive.short_description = 'Set plans to Inactive' def bulk_clone_plans(self, request, queryset): """Clone selected plans""" count = 0 for plan in queryset: plan_copy = Plan.objects.get(pk=plan.pk) plan_copy.pk = None plan_copy.name = f"{plan.name} (Copy)" plan_copy.slug = f"{plan.slug}-copy" plan_copy.is_active = False plan_copy.save() count += 1 self.message_user(request, f'{count} plan(s) cloned.', messages.SUCCESS) bulk_clone_plans.short_description = 'Clone selected plans' class AccountResource(resources.ModelResource): """Resource class for exporting Accounts""" class Meta: model = Account fields = ('id', 'name', 'slug', 'owner__email', 'plan__name', 'status', 'credits', 'billing_country', 'created_at', 'updated_at') export_order = fields @admin.register(Account) class AccountAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8ModelAdmin): resource_class = AccountResource form = AccountAdminForm list_display = ['name', 'slug', 'owner', 'plan', 'status', 'health_indicator', 'credits', 'created_at'] list_filter = ['status', 'plan'] search_fields = ['name', 'slug'] readonly_fields = ['created_at', 'updated_at', 'health_indicator', 'health_details'] actions = [ 'bulk_set_status_active', 'bulk_set_status_suspended', 'bulk_set_status_trial', 'bulk_set_status_cancelled', 'bulk_add_credits', 'bulk_subtract_credits', 'bulk_soft_delete', 'bulk_hard_delete', ] def get_queryset(self, request): """Override to filter by account for non-superusers""" qs = super().get_queryset(request) if request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer()): return qs # Owners can see their own accounts if hasattr(request.user, 'role') and request.user.role == 'owner': return qs.filter(owner=request.user) # Admins can see their account try: user_account = getattr(request.user, 'account', None) if user_account: return qs.filter(id=user_account.id) except (AttributeError, Exception): # If account access fails (e.g., column mismatch), return empty pass return qs.none() def health_indicator(self, obj): """Display health status with visual indicator""" from django.utils.html import format_html from django.utils import timezone from datetime import timedelta # Check credits if obj.credits < 10: status = 'critical' message = 'Critical: Very low credits' elif obj.credits < 100: status = 'warning' message = 'Warning: Low credits' else: status = 'good' message = 'Good' # Check for recent failed automations try: from igny8_core.business.automation.models import AutomationRun week_ago = timezone.now() - timedelta(days=7) failed_runs = AutomationRun.objects.filter( account=obj, status='failed', created_at__gte=week_ago ).count() if failed_runs > 5: status = 'critical' message = f'Critical: {failed_runs} automation failures' elif failed_runs > 0: if status == 'good': status = 'warning' message = f'Warning: {failed_runs} automation failures' except: pass # Check account status if obj.status != 'active': status = 'critical' message = f'Critical: Account {obj.status}' colors = { 'good': '#0bbf87', 'warning': '#ff7a00', 'critical': '#ef4444' } return format_html( '{}', colors[status], message ) health_indicator.short_description = 'Health' def health_details(self, obj): """Detailed health information""" from django.utils.html import format_html from django.utils import timezone from datetime import timedelta details = [] # Credits status colors = { 'critical': '#ef4444', 'warning': '#ff7a00', 'good': '#0bbf87' } if obj.credits < 10: details.append(f'Critical: Only {obj.credits} credits remaining') elif obj.credits < 100: details.append(f'Warning: Only {obj.credits} credits remaining') else: details.append(f'Credits: {obj.credits} available') # Recent activity try: from igny8_core.modules.writer.models import Content week_ago = timezone.now() - timedelta(days=7) recent_content = Content.objects.filter( site__account=obj, created_at__gte=week_ago ).count() details.append(f'📚 Activity: {recent_content} content pieces created this week') except: pass # Failed automations try: from igny8_core.business.automation.models import AutomationRun week_ago = timezone.now() - timedelta(days=7) failed_runs = AutomationRun.objects.filter( account=obj, status='failed', created_at__gte=week_ago ).count() if failed_runs > 0: details.append(f'🔴 Automations: {failed_runs} failures this week') else: details.append(f'✅ Automations: No failures this week') except: pass # Failed syncs try: from igny8_core.business.integration.models import SyncEvent today = timezone.now().date() failed_syncs = SyncEvent.objects.filter( site__account=obj, success=False, created_at__date=today ).count() if failed_syncs > 0: details.append(f'⚠️ Syncs: {failed_syncs} failures today') else: details.append(f'✅ Syncs: No failures today') except: pass # Account status if obj.status == 'active': details.append(f'✅ Status: Active') else: details.append(f'🔴 Status: {obj.status.title()}') return format_html('
'.join(details)) health_details.short_description = 'Health Details' def has_delete_permission(self, request, obj=None): if obj and getattr(obj, 'slug', '') == 'aws-admin': return False return super().has_delete_permission(request, obj) # Bulk Actions def bulk_set_status_active(self, request, queryset): """Set selected accounts to active status""" updated = queryset.update(status='active') self.message_user(request, f'{updated} account(s) set to active.', messages.SUCCESS) bulk_set_status_active.short_description = 'Set status to Active' def bulk_set_status_suspended(self, request, queryset): """Set selected accounts to suspended status""" updated = queryset.update(status='suspended') self.message_user(request, f'{updated} account(s) set to suspended.', messages.SUCCESS) bulk_set_status_suspended.short_description = 'Set status to Suspended' def bulk_set_status_trial(self, request, queryset): """Set selected accounts to trial status""" updated = queryset.update(status='trial') self.message_user(request, f'{updated} account(s) set to trial.', messages.SUCCESS) bulk_set_status_trial.short_description = 'Set status to Trial' def bulk_set_status_cancelled(self, request, queryset): """Set selected accounts to cancelled status""" updated = queryset.update(status='cancelled') self.message_user(request, f'{updated} account(s) set to cancelled.', messages.SUCCESS) bulk_set_status_cancelled.short_description = 'Set status to Cancelled' def bulk_add_credits(self, request, queryset): """Add credits to selected accounts""" from django import forms if 'apply' in request.POST: amount = int(request.POST.get('credits', 0)) if amount > 0: for account in queryset: account.credits += amount account.save() self.message_user(request, f'Added {amount} credits to {queryset.count()} account(s).', messages.SUCCESS) return class CreditForm(forms.Form): credits = forms.IntegerField( min_value=1, label="Credits to Add", help_text=f"Add credits to {queryset.count()} selected account(s)" ) from django.shortcuts import render return render(request, 'admin/bulk_action_form.html', { 'title': 'Add Credits to Accounts', 'queryset': queryset, 'form': CreditForm(), 'action': 'bulk_add_credits', }) bulk_add_credits.short_description = 'Add credits to accounts' def bulk_subtract_credits(self, request, queryset): """Subtract credits from selected accounts""" from django import forms if 'apply' in request.POST: amount = int(request.POST.get('credits', 0)) if amount > 0: for account in queryset: account.credits = max(0, account.credits - amount) account.save() self.message_user(request, f'Subtracted {amount} credits from {queryset.count()} account(s).', messages.SUCCESS) return class CreditForm(forms.Form): credits = forms.IntegerField( min_value=1, label="Credits to Subtract", help_text=f"Subtract credits from {queryset.count()} selected account(s)" ) from django.shortcuts import render return render(request, 'admin/bulk_action_form.html', { 'title': 'Subtract Credits from Accounts', 'queryset': queryset, 'form': CreditForm(), 'action': 'bulk_subtract_credits', }) bulk_subtract_credits.short_description = 'Subtract credits from accounts' def bulk_soft_delete(self, request, queryset): """Soft delete selected accounts and all related data""" count = 0 for account in queryset: if account.slug != 'aws-admin': # Protect admin account account.delete() # Soft delete via SoftDeletableModel (now cascades) count += 1 self.message_user(request, f'{count} account(s) and all related data soft deleted.', messages.SUCCESS) bulk_soft_delete.short_description = 'Soft delete accounts (with cascade)' def bulk_hard_delete(self, request, queryset): """PERMANENTLY delete selected accounts and ALL related data - cannot be undone!""" import traceback count = 0 errors = [] for account in queryset: if account.slug == 'aws-admin': # Protect admin account errors.append(f'{account.name}: Protected system account') continue try: account.hard_delete_with_cascade() # Permanently delete everything count += 1 except Exception as e: # Log full traceback for debugging import logging logger = logging.getLogger(__name__) logger.error(f'Hard delete failed for account {account.pk} ({account.name}): {traceback.format_exc()}') errors.append(f'{account.name}: {str(e)}') if count > 0: self.message_user(request, f'{count} account(s) and ALL related data permanently deleted.', messages.SUCCESS) if errors: self.message_user(request, f'Errors: {"; ".join(errors)}', messages.ERROR) bulk_hard_delete.short_description = '⚠️ PERMANENTLY delete accounts (irreversible!)' class SubscriptionResource(resources.ModelResource): """Resource class for exporting Subscriptions""" class Meta: model = Subscription fields = ('id', 'account__name', 'status', 'current_period_start', 'current_period_end', 'stripe_subscription_id', 'created_at') export_order = fields @admin.register(Subscription) class SubscriptionAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin): resource_class = SubscriptionResource list_display = ['account', 'status', 'current_period_start', 'current_period_end'] list_filter = ['status'] search_fields = ['account__name', 'stripe_subscription_id'] readonly_fields = ['created_at', 'updated_at'] actions = [ 'bulk_set_status_active', 'bulk_set_status_cancelled', 'bulk_set_status_suspended', 'bulk_set_status_trialing', 'bulk_renew', ] actions = [ 'bulk_set_status_active', 'bulk_set_status_cancelled', 'bulk_set_status_suspended', 'bulk_set_status_trialing', 'bulk_renew', ] def bulk_set_status_active(self, request, queryset): """Set subscriptions to active""" updated = queryset.update(status='active') self.message_user(request, f'{updated} subscription(s) set to active.', messages.SUCCESS) bulk_set_status_active.short_description = 'Set status to Active' def bulk_set_status_cancelled(self, request, queryset): """Set subscriptions to cancelled""" updated = queryset.update(status='cancelled') self.message_user(request, f'{updated} subscription(s) set to cancelled.', messages.SUCCESS) bulk_set_status_cancelled.short_description = 'Set status to Cancelled' def bulk_set_status_suspended(self, request, queryset): """Set subscriptions to suspended""" updated = queryset.update(status='suspended') self.message_user(request, f'{updated} subscription(s) set to suspended.', messages.SUCCESS) bulk_set_status_suspended.short_description = 'Set status to Suspended' def bulk_set_status_trialing(self, request, queryset): """Set subscriptions to trialing""" updated = queryset.update(status='trialing') self.message_user(request, f'{updated} subscription(s) set to trialing.', messages.SUCCESS) bulk_set_status_trialing.short_description = 'Set status to Trialing' def bulk_renew(self, request, queryset): """Renew selected subscriptions""" from django.utils import timezone from datetime import timedelta count = 0 for subscription in queryset: # Extend subscription by one billing period if subscription.current_period_end: subscription.current_period_end = subscription.current_period_end + timedelta(days=30) subscription.status = 'active' subscription.save() count += 1 self.message_user(request, f'{count} subscription(s) renewed for 30 days.', messages.SUCCESS) bulk_renew.short_description = 'Renew subscriptions' @admin.register(PasswordResetToken) class PasswordResetTokenAdmin(Igny8ModelAdmin): list_display = ['user', 'token', 'used', 'expires_at', 'created_at'] list_filter = ['used', 'expires_at', 'created_at'] search_fields = ['user__email', 'token'] readonly_fields = ['created_at', 'token'] def get_queryset(self, request): """Filter by account for non-superusers""" qs = super().get_queryset(request) if request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer()): return qs user_account = getattr(request.user, 'account', None) if user_account: return qs.filter(user__account=user_account) return qs.none() class SectorInline(TabularInline): """Inline admin for sectors within Site admin.""" model = Sector extra = 0 fields = ['industry_sector', 'name', 'slug', 'status', 'is_active', 'get_keywords_count', 'get_clusters_count'] readonly_fields = ['get_keywords_count', 'get_clusters_count'] def get_keywords_count(self, obj): if obj.pk: try: return obj.keywords_set.count() except (AttributeError, Exception): return 0 return 0 get_keywords_count.short_description = 'Keywords' def get_clusters_count(self, obj): if obj.pk: try: return obj.clusters_set.count() except (AttributeError, Exception): return 0 return 0 get_clusters_count.short_description = 'Clusters' class SiteResource(resources.ModelResource): """Resource class for importing/exporting Sites""" class Meta: model = Site fields = ('id', 'name', 'slug', 'account__name', 'industry__name', 'domain', 'status', 'is_active', 'site_type', 'hosting_type', 'description', 'created_at') export_order = fields import_id_fields = ('id',) skip_unchanged = True @admin.register(Site) class SiteAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin): resource_class = SiteResource list_display = ['name', 'slug', 'account', 'industry', 'domain', 'status', 'is_active', 'get_api_key_status', 'get_sectors_count'] list_filter = ['status', 'is_active', 'account', 'industry', 'hosting_type'] search_fields = ['name', 'slug', 'domain', 'industry__name'] readonly_fields = ['created_at', 'updated_at', 'get_api_key_display'] inlines = [SectorInline] actions = [ 'generate_api_keys', 'bulk_set_status_active', 'bulk_set_status_inactive', 'bulk_set_status_maintenance', 'bulk_soft_delete', ] fieldsets = ( ('Site Info', { 'fields': ('name', 'slug', 'account', 'domain', 'description', 'industry', 'site_type', 'hosting_type', 'status', 'is_active') }), ('WordPress Integration', { 'fields': ('get_api_key_display',), 'description': 'WordPress integration API key. Use SiteIntegration model for full integration settings.' }), ('SEO Metadata', { 'fields': ('seo_metadata',), 'classes': ('collapse',) }), ('Timestamps', { 'fields': ('created_at', 'updated_at'), 'classes': ('collapse',) }), ) def get_api_key_display(self, obj): """Display API key with copy button""" from django.utils.html import format_html if obj.wp_api_key: return format_html( '
' '{}' '' '
', obj.wp_api_key, obj.wp_api_key ) return format_html('No API key generated') get_api_key_display.short_description = 'WordPress API Key' def get_api_key_status(self, obj): """Show API key status in list view""" from django.utils.html import format_html if obj.wp_api_key: return format_html(' Active') return format_html(' None') get_api_key_status.short_description = 'API Key' def generate_api_keys(self, request, queryset): """Generate API keys for selected sites. API key is stored ONLY in Site.wp_api_key (single source of truth).""" import secrets from igny8_core.business.integration.models import SiteIntegration updated_count = 0 for site in queryset: if not site.wp_api_key: api_key = f"igny8_{''.join(secrets.choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(40))}" # SINGLE SOURCE OF TRUTH: Store API key ONLY in Site.wp_api_key site.wp_api_key = api_key site.save() # Ensure SiteIntegration exists for status tracking (without API key) SiteIntegration.objects.get_or_create( site=site, platform='wordpress', defaults={ 'account': site.account, 'platform': 'wordpress', 'platform_type': 'cms', 'is_active': True, 'sync_enabled': True, 'credentials_json': {}, # Empty - API key is on Site model 'config_json': {} } ) updated_count += 1 self.message_user(request, f'Generated API keys for {updated_count} site(s). API keys stored in Site.wp_api_key (single source of truth).') generate_api_keys.short_description = 'Generate WordPress API Keys' def bulk_set_status_active(self, request, queryset): """Set selected sites to active status""" updated = queryset.update(status='active', is_active=True) self.message_user(request, f'{updated} site(s) set to active.', messages.SUCCESS) bulk_set_status_active.short_description = 'Set status to Active' def bulk_set_status_inactive(self, request, queryset): """Set selected sites to inactive status""" updated = queryset.update(status='inactive', is_active=False) self.message_user(request, f'{updated} site(s) set to inactive.', messages.SUCCESS) bulk_set_status_inactive.short_description = 'Set status to Inactive' def bulk_set_status_maintenance(self, request, queryset): """Set selected sites to maintenance status""" updated = queryset.update(status='maintenance') self.message_user(request, f'{updated} site(s) set to maintenance mode.', messages.SUCCESS) bulk_set_status_maintenance.short_description = 'Set status to Maintenance' def bulk_soft_delete(self, request, queryset): """Soft delete selected sites""" count = 0 for site in queryset: site.delete() # Soft delete via SoftDeletableModel count += 1 self.message_user(request, f'{count} site(s) soft deleted.', messages.SUCCESS) bulk_soft_delete.short_description = 'Soft delete selected sites' def get_sectors_count(self, obj): try: return obj.get_active_sectors_count() except: return 0 get_sectors_count.short_description = 'Active Sectors' def get_industry_display(self, obj): """Safely get industry name""" try: return obj.industry.name if obj.industry else '-' except: return '-' get_industry_display.short_description = 'Industry' class SectorResource(resources.ModelResource): """Resource class for exporting Sectors""" class Meta: model = Sector fields = ('id', 'name', 'slug', 'site__name', 'industry_sector__name', 'status', 'is_active', 'created_at') export_order = fields @admin.register(Sector) class SectorAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin): resource_class = SectorResource list_display = ['name', 'slug', 'site', 'industry_sector', 'get_industry', 'status', 'is_active', 'get_keywords_count', 'get_clusters_count'] list_filter = ['status', 'is_active', 'site', 'industry_sector__industry'] search_fields = ['name', 'slug', 'site__name', 'industry_sector__name'] readonly_fields = ['created_at', 'updated_at'] actions = [ 'bulk_set_status_active', 'bulk_set_status_inactive', 'bulk_soft_delete', ] def get_industry(self, obj): """Safely get industry name""" try: if obj.industry_sector and obj.industry_sector.industry: return obj.industry_sector.industry.name except: pass return '-' get_industry.short_description = 'Industry' def get_keywords_count(self, obj): """Safely get keywords count""" try: if obj.pk: return getattr(obj, 'keywords_set', obj.keywords_set).count() except: pass return 0 get_keywords_count.short_description = 'Keywords' def get_clusters_count(self, obj): """Safely get clusters count""" try: if obj.pk: return getattr(obj, 'clusters_set', obj.clusters_set).count() except: pass return 0 get_clusters_count.short_description = 'Clusters' def bulk_set_status_active(self, request, queryset): """Set selected sectors to active status""" updated = queryset.update(status='active', is_active=True) self.message_user(request, f'{updated} sector(s) set to active.', messages.SUCCESS) bulk_set_status_active.short_description = 'Set status to Active' def bulk_set_status_inactive(self, request, queryset): """Set selected sectors to inactive status""" updated = queryset.update(status='inactive', is_active=False) self.message_user(request, f'{updated} sector(s) set to inactive.', messages.SUCCESS) bulk_set_status_inactive.short_description = 'Set status to Inactive' def bulk_soft_delete(self, request, queryset): """Soft delete selected sectors""" count = 0 for sector in queryset: sector.delete() # Soft delete via SoftDeletableModel count += 1 self.message_user(request, f'{count} sector(s) soft deleted.', messages.SUCCESS) bulk_soft_delete.short_description = 'Soft delete selected sectors' @admin.register(SiteUserAccess) class SiteUserAccessAdmin(Igny8ModelAdmin): list_display = ['user', 'site', 'granted_at', 'granted_by'] list_filter = ['granted_at'] search_fields = ['user__email', 'site__name'] readonly_fields = ['granted_at'] class IndustrySectorInline(TabularInline): """Inline admin for industry sectors within Industry admin.""" model = IndustrySector extra = 0 fields = ['name', 'slug', 'description', 'is_active'] readonly_fields = [] class IndustryResource(resources.ModelResource): """Resource class for importing/exporting Industries""" class Meta: model = Industry fields = ('id', 'name', 'slug', 'description', 'is_active', 'created_at') export_order = fields import_id_fields = ('id',) skip_unchanged = True @admin.register(Industry) class IndustryAdmin(ImportExportMixin, Igny8ModelAdmin): resource_class = IndustryResource list_display = ['name', 'slug', 'is_active', 'get_sectors_count', 'created_at'] list_filter = ['is_active'] search_fields = ['name', 'slug', 'description'] readonly_fields = ['created_at', 'updated_at'] inlines = [IndustrySectorInline] actions = [ 'delete_selected', 'bulk_activate', 'bulk_deactivate', ] # Enable bulk delete def get_sectors_count(self, obj): return obj.sectors.filter(is_active=True).count() get_sectors_count.short_description = 'Active Sectors' def has_delete_permission(self, request, obj=None): """Allow deletion for superusers and developers""" return request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer()) def bulk_activate(self, request, queryset): updated = queryset.update(is_active=True) self.message_user(request, f'{updated} industry/industries activated.', messages.SUCCESS) bulk_activate.short_description = 'Activate selected industries' def bulk_deactivate(self, request, queryset): updated = queryset.update(is_active=False) self.message_user(request, f'{updated} industry/industries deactivated.', messages.SUCCESS) bulk_deactivate.short_description = 'Deactivate selected industries' class IndustrySectorResource(resources.ModelResource): """Resource class for importing/exporting Industry Sectors""" class Meta: model = IndustrySector fields = ('id', 'name', 'slug', 'industry__name', 'description', 'is_active', 'created_at') export_order = fields import_id_fields = ('id',) skip_unchanged = True @admin.register(IndustrySector) class IndustrySectorAdmin(ImportExportMixin, Igny8ModelAdmin): resource_class = IndustrySectorResource list_display = ['name', 'slug', 'industry', 'is_active'] list_filter = [ ('is_active', ChoicesDropdownFilter), ('industry', RelatedDropdownFilter), ] search_fields = ['name', 'slug', 'description'] readonly_fields = ['created_at', 'updated_at'] actions = [ 'delete_selected', 'bulk_activate', 'bulk_deactivate', ] # Enable bulk delete def has_delete_permission(self, request, obj=None): """Allow deletion for superusers and developers""" return request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer()) def bulk_activate(self, request, queryset): updated = queryset.update(is_active=True) self.message_user(request, f'{updated} sector(s) activated.', messages.SUCCESS) bulk_activate.short_description = 'Activate selected sectors' def bulk_deactivate(self, request, queryset): updated = queryset.update(is_active=False) self.message_user(request, f'{updated} sector(s) deactivated.', messages.SUCCESS) bulk_deactivate.short_description = 'Deactivate selected sectors' class SeedKeywordResource(resources.ModelResource): """Resource class for importing/exporting Seed Keywords""" industry = fields.Field( column_name='industry', attribute='industry', widget=widgets.ForeignKeyWidget(Industry, 'name') ) sector = fields.Field( column_name='sector', attribute='sector', widget=widgets.ForeignKeyWidget(IndustrySector, 'name') ) class Meta: model = SeedKeyword fields = ('id', 'keyword', 'industry', 'sector', 'volume', 'difficulty', 'country', 'is_active', 'created_at') export_order = fields import_id_fields = ('keyword', 'industry', 'sector') # Use natural keys for import skip_unchanged = True def before_import_row(self, row, **kwargs): """Clean and validate row data before import""" # Ensure volume is an integer if 'volume' in row: try: row['volume'] = int(row['volume']) if row['volume'] else 0 except (ValueError, TypeError): row['volume'] = 0 # Ensure difficulty is an integer between 0-100 if 'difficulty' in row: try: difficulty = int(row['difficulty']) if row['difficulty'] else 0 row['difficulty'] = max(0, min(100, difficulty)) # Clamp to 0-100 except (ValueError, TypeError): row['difficulty'] = 0 # Ensure country is valid if 'country' in row: valid_countries = [code for code, name in SeedKeyword.COUNTRY_CHOICES] if row['country'] not in valid_countries: row['country'] = 'US' # Default to US if invalid # Set defaults for optional fields if 'is_active' not in row or row['is_active'] == '': row['is_active'] = True return row @admin.register(SeedKeyword) class SeedKeywordAdmin(ImportExportMixin, Igny8ModelAdmin): resource_class = SeedKeywordResource """SeedKeyword admin - Global reference data, no account filtering""" list_display = ['keyword', 'industry', 'sector', 'volume', 'difficulty', 'country', 'is_active', 'created_at'] list_filter = ['is_active', 'industry', 'sector', 'country'] search_fields = ['keyword'] readonly_fields = ['created_at', 'updated_at'] actions = [ 'bulk_activate', 'bulk_deactivate', 'bulk_update_country', ] fieldsets = ( ('Keyword Info', { 'fields': ('keyword', 'industry', 'sector', 'country', 'is_active') }), ('SEO Metrics', { 'fields': ('volume', 'difficulty') }), ('Timestamps', { 'fields': ('created_at', 'updated_at') }), ) def bulk_activate(self, request, queryset): """Activate selected keywords""" try: updated = queryset.update(is_active=True) self.message_user( request, f'{updated} seed keyword(s) activated successfully.', messages.SUCCESS ) except Exception as e: self.message_user( request, f'Error activating keywords: {str(e)}', messages.ERROR ) bulk_activate.short_description = 'Activate selected keywords' def bulk_deactivate(self, request, queryset): """Deactivate selected keywords""" try: updated = queryset.update(is_active=False) self.message_user( request, f'{updated} seed keyword(s) deactivated successfully.', messages.SUCCESS ) except Exception as e: self.message_user( request, f'Error deactivating keywords: {str(e)}', messages.ERROR ) bulk_deactivate.short_description = 'Deactivate selected keywords' def bulk_update_country(self, request, queryset): from django import forms if 'apply' in request.POST: country = request.POST.get('country') if country: updated = queryset.update(country=country) self.message_user(request, f'{updated} seed keyword(s) country updated to: {country}', messages.SUCCESS) return COUNTRY_CHOICES = [ ('US', 'United States'), ('GB', 'United Kingdom'), ('CA', 'Canada'), ('AU', 'Australia'), ('IN', 'India'), ] class CountryForm(forms.Form): country = forms.ChoiceField( choices=COUNTRY_CHOICES, label="Select Country", help_text=f"Update country for {queryset.count()} seed keyword(s)" ) from django.shortcuts import render return render(request, 'admin/bulk_action_form.html', { 'title': 'Update Country', 'queryset': queryset, 'form': CountryForm(), 'action': 'bulk_update_country', }) bulk_update_country.short_description = 'Update country' class UserResource(resources.ModelResource): """Resource class for exporting Users""" class Meta: model = User fields = ('id', 'email', 'username', 'account__name', 'role', 'is_active', 'is_staff', 'created_at', 'last_login') export_order = fields @admin.register(User) class UserAdmin(ExportMixin, BaseUserAdmin, Igny8ModelAdmin): """ User admin using both Django's BaseUserAdmin (for user-specific functionality) and Unfold's ModelAdmin (for modern UI and styling including popups) """ resource_class = UserResource list_display = ['email', 'username', 'account', 'role', 'is_active', 'is_staff', 'created_at'] list_filter = ['role', 'account', 'is_active', 'is_staff'] search_fields = ['email', 'username'] readonly_fields = ['created_at', 'updated_at', 'password_display'] fieldsets = BaseUserAdmin.fieldsets + ( ('IGNY8 Info', {'fields': ('account', 'role')}), ('Timestamps', {'fields': ('created_at', 'updated_at')}), ) add_fieldsets = BaseUserAdmin.add_fieldsets + ( ('IGNY8 Info', {'fields': ('account', 'role')}), ) actions = [ 'bulk_set_role_owner', 'bulk_set_role_admin', 'bulk_set_role_editor', 'bulk_set_role_viewer', 'bulk_activate', 'bulk_deactivate', 'bulk_send_password_reset', 'bulk_set_temporary_password', ] def password_display(self, obj): """Show password hash with copy button (for debugging only)""" if obj.password: return f'Hash: {obj.password[:50]}...' return 'No password set' password_display.short_description = 'Password Hash' def bulk_set_temporary_password(self, request, queryset): """Set a temporary password for selected users and display it""" import secrets import string # Generate a secure random password alphabet = string.ascii_letters + string.digits temp_password = ''.join(secrets.choice(alphabet) for _ in range(12)) users_updated = [] for user in queryset: user.set_password(temp_password) user.save(update_fields=['password']) users_updated.append(user.email) if users_updated: # Display the password in the message (only visible to admin) self.message_user( request, f'Temporary password set for {len(users_updated)} user(s): "{temp_password}" (same password for all selected users)', messages.SUCCESS ) self.message_user( request, f'Users updated: {", ".join(users_updated)}', messages.INFO ) bulk_set_temporary_password.short_description = '🔑 Set temporary password (will display)' def get_queryset(self, request): """Filter users by account for non-superusers""" qs = super().get_queryset(request) if request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer()): return qs user_account = getattr(request.user, 'account', None) if user_account: return qs.filter(account=user_account) return qs.none() 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_set_role_owner(self, request, queryset): updated = queryset.update(role='owner') self.message_user(request, f'{updated} user(s) role set to Owner.', messages.SUCCESS) bulk_set_role_owner.short_description = 'Set role to Owner' def bulk_set_role_admin(self, request, queryset): updated = queryset.update(role='admin') self.message_user(request, f'{updated} user(s) role set to Admin.', messages.SUCCESS) bulk_set_role_admin.short_description = 'Set role to Admin' def bulk_set_role_editor(self, request, queryset): updated = queryset.update(role='editor') self.message_user(request, f'{updated} user(s) role set to Editor.', messages.SUCCESS) bulk_set_role_editor.short_description = 'Set role to Editor' def bulk_set_role_viewer(self, request, queryset): updated = queryset.update(role='viewer') self.message_user(request, f'{updated} user(s) role set to Viewer.', messages.SUCCESS) bulk_set_role_viewer.short_description = 'Set role to Viewer' def bulk_activate(self, request, queryset): updated = queryset.update(is_active=True) self.message_user(request, f'{updated} user(s) activated.', messages.SUCCESS) bulk_activate.short_description = 'Activate users' def bulk_deactivate(self, request, queryset): updated = queryset.update(is_active=False) self.message_user(request, f'{updated} user(s) deactivated.', messages.SUCCESS) bulk_deactivate.short_description = 'Deactivate users' def bulk_send_password_reset(self, request, queryset): # TODO: Implement password reset email sending count = queryset.count() self.message_user( request, f'{count} password reset email(s) queued for sending. (Email integration required)', messages.INFO ) bulk_send_password_reset.short_description = 'Send password reset email'