From 28cb6985797735262657308251f37108f745cdf1 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Mon, 12 Jan 2026 12:02:57 +0000 Subject: [PATCH] django fixes restored defautl delte cofnruiiamtions and working accoutna dn sites deletion with cascading --- backend/igny8_core/auth/admin.py | 167 ++++-------------------------- backend/igny8_core/auth/models.py | 108 +++++++++++++++++++ 2 files changed, 126 insertions(+), 149 deletions(-) diff --git a/backend/igny8_core/auth/admin.py b/backend/igny8_core/auth/admin.py index 0f53ac09..70b0075a 100644 --- a/backend/igny8_core/auth/admin.py +++ b/backend/igny8_core/auth/admin.py @@ -2,15 +2,14 @@ Admin interface for auth models """ from django import forms -from django.contrib import admin, messages +from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from unfold.admin import ModelAdmin, TabularInline -from unfold.contrib.filters.admin import ChoicesDropdownFilter, RelatedDropdownFilter 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 +from import_export import resources class AccountAdminForm(forms.ModelForm): @@ -214,8 +213,6 @@ class AccountAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode 'bulk_set_status_cancelled', 'bulk_add_credits', 'bulk_subtract_credits', - 'bulk_soft_delete', - 'bulk_hard_delete', ] def get_queryset(self, request): @@ -454,41 +451,6 @@ class AccountAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode '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): @@ -631,7 +593,6 @@ class SiteAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin): 'bulk_set_status_active', 'bulk_set_status_inactive', 'bulk_set_status_maintenance', - 'bulk_soft_delete', ] fieldsets = ( @@ -677,36 +638,15 @@ class SiteAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin): 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).""" + """Generate API keys for selected sites""" 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.wp_api_key = f"igny8_{''.join(secrets.choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(40))}" 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).') + self.message_user(request, f'Generated API keys for {updated_count} site(s). Sites with existing keys were skipped.') generate_api_keys.short_description = 'Generate WordPress API Keys' def bulk_set_status_active(self, request, queryset): @@ -727,15 +667,6 @@ class SiteAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin): 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() @@ -899,10 +830,7 @@ class IndustrySectorResource(resources.ModelResource): class IndustrySectorAdmin(ImportExportMixin, Igny8ModelAdmin): resource_class = IndustrySectorResource list_display = ['name', 'slug', 'industry', 'is_active'] - list_filter = [ - ('is_active', ChoicesDropdownFilter), - ('industry', RelatedDropdownFilter), - ] + list_filter = ['is_active', 'industry'] search_fields = ['name', 'slug', 'description'] readonly_fields = ['created_at', 'updated_at'] actions = [ @@ -928,53 +856,13 @@ class IndustrySectorAdmin(ImportExportMixin, Igny8ModelAdmin): 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', + fields = ('id', 'keyword', 'industry__name', 'sector__name', 'volume', 'difficulty', 'country', 'is_active', 'created_at') export_order = fields - import_id_fields = ('keyword', 'industry', 'sector') # Use natural keys for import + import_id_fields = ('id',) 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) @@ -986,10 +874,11 @@ class SeedKeywordAdmin(ImportExportMixin, Igny8ModelAdmin): search_fields = ['keyword'] readonly_fields = ['created_at', 'updated_at'] actions = [ + 'delete_selected', 'bulk_activate', 'bulk_deactivate', 'bulk_update_country', - ] + ] # Enable bulk delete fieldsets = ( ('Keyword Info', { @@ -1003,38 +892,18 @@ class SeedKeywordAdmin(ImportExportMixin, Igny8ModelAdmin): }), ) + 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): - """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 - ) + updated = queryset.update(is_active=True) + self.message_user(request, f'{updated} seed keyword(s) activated.', messages.SUCCESS) 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 - ) + updated = queryset.update(is_active=False) + self.message_user(request, f'{updated} seed keyword(s) deactivated.', messages.SUCCESS) bulk_deactivate.short_description = 'Deactivate selected keywords' def bulk_update_country(self, request, queryset): diff --git a/backend/igny8_core/auth/models.py b/backend/igny8_core/auth/models.py index 53422d12..01f0b977 100644 --- a/backend/igny8_core/auth/models.py +++ b/backend/igny8_core/auth/models.py @@ -565,6 +565,114 @@ class Site(SoftDeletableModel, AccountBaseModel): def can_add_sector(self): """Check if site can add another sector based on plan limits.""" return self.get_active_sectors_count() < self.get_max_sectors_limit() + + def soft_delete(self, user=None, reason=None, retention_days=None, cascade=True): + """ + Soft delete site and optionally cascade to all related objects. + + Args: + user: User performing the deletion + reason: Reason for deletion + retention_days: Days to retain before permanent deletion + cascade: If True, cascade soft-delete to all related objects + """ + if cascade: + self._cascade_delete_related(user=user, reason=reason, retention_days=retention_days, hard_delete=False) + + return super().soft_delete(user=user, reason=reason, retention_days=retention_days) + + def _cascade_delete_related(self, user=None, reason=None, retention_days=None, hard_delete=False): + """ + Delete all related objects when site is deleted. + For soft delete: soft-deletes objects with SoftDeletableModel, hard-deletes others + For hard delete: hard-deletes everything + """ + from igny8_core.common.soft_delete import SoftDeletableModel + + # List of related objects to delete (in order to avoid FK constraint issues) + related_names = [ + # Content & Planning related (delete first due to dependencies) + 'contentclustermap_set', + 'contentattribute_set', + 'contenttaxonomy_set', + 'content_set', + 'images_set', + 'contentideas_set', + 'tasks_set', + 'keywords_set', + 'clusters_set', + # Automation + 'automation_runs', + 'automation_config', + # Publishing & Integration + 'sync_events', + 'publishing_settings', + 'publishingrecord_set', + 'deploymentrecord_set', + 'integrations', + # Notifications + 'notifications', + # Settings & AI + # Core + 'sectors', + 'user_access', + ] + + for related_name in related_names: + try: + related = getattr(self, related_name, None) + if related is None: + continue + + # Handle OneToOne fields + if hasattr(related, 'pk'): + # It's a single object (OneToOneField) + if hard_delete: + related.hard_delete() if hasattr(related, 'hard_delete') else related.delete() + elif isinstance(related, SoftDeletableModel): + related.soft_delete(user=user, reason=reason, retention_days=retention_days) + else: + # Non-soft-deletable single object - hard delete + related.delete() + else: + # It's a RelatedManager (ForeignKey) + queryset = related.all() + if queryset.exists(): + if hard_delete: + # Hard delete all + if hasattr(queryset, 'hard_delete'): + queryset.hard_delete() + else: + for obj in queryset: + if hasattr(obj, 'hard_delete'): + obj.hard_delete() + else: + obj.delete() + else: + # Soft delete if supported, otherwise hard delete + model = queryset.model + if issubclass(model, SoftDeletableModel): + for obj in queryset: + obj.soft_delete(user=user, reason=reason, retention_days=retention_days) + else: + queryset.delete() + except Exception as e: + # Log but don't fail - some relations may not exist + import logging + logger = logging.getLogger(__name__) + logger.warning(f"Failed to delete related {related_name} for site {self.pk}: {e}") + + def hard_delete_with_cascade(self, using=None, keep_parents=False): + """ + Permanently delete the site and ALL related objects. + This bypasses soft-delete and removes everything from the database. + USE WITH CAUTION - this cannot be undone! + """ + # Cascade hard-delete all related objects first + self._cascade_delete_related(hard_delete=True) + + # Finally hard-delete the site itself + return super().hard_delete(using=using, keep_parents=keep_parents) class Industry(models.Model):