django fixes restored defautl delte cofnruiiamtions and working accoutna dn sites deletion with cascading

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-12 12:02:57 +00:00
parent a3e75e654e
commit 28cb698579
2 changed files with 126 additions and 149 deletions

View File

@@ -2,15 +2,14 @@
Admin interface for auth models Admin interface for auth models
""" """
from django import forms 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 django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from unfold.admin import ModelAdmin, TabularInline from unfold.admin import ModelAdmin, TabularInline
from unfold.contrib.filters.admin import ChoicesDropdownFilter, RelatedDropdownFilter
from simple_history.admin import SimpleHistoryAdmin from simple_history.admin import SimpleHistoryAdmin
from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin
from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword, PasswordResetToken from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword, PasswordResetToken
from import_export.admin import ExportMixin, ImportExportMixin from import_export.admin import ExportMixin, ImportExportMixin
from import_export import resources, fields, widgets from import_export import resources
class AccountAdminForm(forms.ModelForm): class AccountAdminForm(forms.ModelForm):
@@ -214,8 +213,6 @@ class AccountAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
'bulk_set_status_cancelled', 'bulk_set_status_cancelled',
'bulk_add_credits', 'bulk_add_credits',
'bulk_subtract_credits', 'bulk_subtract_credits',
'bulk_soft_delete',
'bulk_hard_delete',
] ]
def get_queryset(self, request): def get_queryset(self, request):
@@ -455,41 +452,6 @@ class AccountAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
}) })
bulk_subtract_credits.short_description = 'Subtract credits from accounts' 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): class SubscriptionResource(resources.ModelResource):
"""Resource class for exporting Subscriptions""" """Resource class for exporting Subscriptions"""
@@ -631,7 +593,6 @@ class SiteAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin):
'bulk_set_status_active', 'bulk_set_status_active',
'bulk_set_status_inactive', 'bulk_set_status_inactive',
'bulk_set_status_maintenance', 'bulk_set_status_maintenance',
'bulk_soft_delete',
] ]
fieldsets = ( fieldsets = (
@@ -677,36 +638,15 @@ class SiteAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin):
get_api_key_status.short_description = 'API Key' get_api_key_status.short_description = 'API Key'
def generate_api_keys(self, request, queryset): 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 import secrets
from igny8_core.business.integration.models import SiteIntegration
updated_count = 0 updated_count = 0
for site in queryset: for site in queryset:
if not site.wp_api_key: if not site.wp_api_key:
api_key = f"igny8_{''.join(secrets.choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(40))}" site.wp_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() 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 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' generate_api_keys.short_description = 'Generate WordPress API Keys'
def bulk_set_status_active(self, request, queryset): 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) self.message_user(request, f'{updated} site(s) set to maintenance mode.', messages.SUCCESS)
bulk_set_status_maintenance.short_description = 'Set status to Maintenance' 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): def get_sectors_count(self, obj):
try: try:
return obj.get_active_sectors_count() return obj.get_active_sectors_count()
@@ -899,10 +830,7 @@ class IndustrySectorResource(resources.ModelResource):
class IndustrySectorAdmin(ImportExportMixin, Igny8ModelAdmin): class IndustrySectorAdmin(ImportExportMixin, Igny8ModelAdmin):
resource_class = IndustrySectorResource resource_class = IndustrySectorResource
list_display = ['name', 'slug', 'industry', 'is_active'] list_display = ['name', 'slug', 'industry', 'is_active']
list_filter = [ list_filter = ['is_active', 'industry']
('is_active', ChoicesDropdownFilter),
('industry', RelatedDropdownFilter),
]
search_fields = ['name', 'slug', 'description'] search_fields = ['name', 'slug', 'description']
readonly_fields = ['created_at', 'updated_at'] readonly_fields = ['created_at', 'updated_at']
actions = [ actions = [
@@ -928,54 +856,14 @@ class IndustrySectorAdmin(ImportExportMixin, Igny8ModelAdmin):
class SeedKeywordResource(resources.ModelResource): class SeedKeywordResource(resources.ModelResource):
"""Resource class for importing/exporting Seed Keywords""" """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: class Meta:
model = SeedKeyword model = SeedKeyword
fields = ('id', 'keyword', 'industry', 'sector', 'volume', fields = ('id', 'keyword', 'industry__name', 'sector__name', 'volume',
'difficulty', 'country', 'is_active', 'created_at') 'difficulty', 'country', 'is_active', 'created_at')
export_order = fields export_order = fields
import_id_fields = ('keyword', 'industry', 'sector') # Use natural keys for import import_id_fields = ('id',)
skip_unchanged = True 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) @admin.register(SeedKeyword)
class SeedKeywordAdmin(ImportExportMixin, Igny8ModelAdmin): class SeedKeywordAdmin(ImportExportMixin, Igny8ModelAdmin):
@@ -986,10 +874,11 @@ class SeedKeywordAdmin(ImportExportMixin, Igny8ModelAdmin):
search_fields = ['keyword'] search_fields = ['keyword']
readonly_fields = ['created_at', 'updated_at'] readonly_fields = ['created_at', 'updated_at']
actions = [ actions = [
'delete_selected',
'bulk_activate', 'bulk_activate',
'bulk_deactivate', 'bulk_deactivate',
'bulk_update_country', 'bulk_update_country',
] ] # Enable bulk delete
fieldsets = ( fieldsets = (
('Keyword Info', { ('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): def bulk_activate(self, request, queryset):
"""Activate selected keywords""" updated = queryset.update(is_active=True)
try: self.message_user(request, f'{updated} seed keyword(s) activated.', messages.SUCCESS)
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' bulk_activate.short_description = 'Activate selected keywords'
def bulk_deactivate(self, request, queryset): def bulk_deactivate(self, request, queryset):
"""Deactivate selected keywords""" updated = queryset.update(is_active=False)
try: self.message_user(request, f'{updated} seed keyword(s) deactivated.', messages.SUCCESS)
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' bulk_deactivate.short_description = 'Deactivate selected keywords'
def bulk_update_country(self, request, queryset): def bulk_update_country(self, request, queryset):

View File

@@ -566,6 +566,114 @@ class Site(SoftDeletableModel, AccountBaseModel):
"""Check if site can add another sector based on plan limits.""" """Check if site can add another sector based on plan limits."""
return self.get_active_sectors_count() < self.get_max_sectors_limit() 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): class Industry(models.Model):
""" """