django fixes restored defautl delte cofnruiiamtions and working accoutna dn sites deletion with cascading
This commit is contained in:
@@ -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):
|
||||||
@@ -454,41 +451,6 @@ class AccountAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
|
|||||||
'action': 'bulk_subtract_credits',
|
'action': 'bulk_subtract_credits',
|
||||||
})
|
})
|
||||||
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):
|
||||||
@@ -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,53 +856,13 @@ 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)
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -565,6 +565,114 @@ class Site(SoftDeletableModel, AccountBaseModel):
|
|||||||
def can_add_sector(self):
|
def can_add_sector(self):
|
||||||
"""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):
|
||||||
|
|||||||
Reference in New Issue
Block a user