"""
Trash Admin - View and manage soft-deleted records across the system.
Allows restoring or permanently deleting soft-deleted records.
"""
from django.contrib import admin, messages
from django.contrib.admin import SimpleListFilter
from django.utils import timezone
from django.utils.html import format_html
from django.db.models import Count
from unfold.admin import ModelAdmin
# Import all soft-deletable models
from igny8_core.auth.models import Account, Site, Sector
from igny8_core.business.content.models import Tasks, Content, Images, ContentTaxonomy
from igny8_core.business.planning.models import Clusters, Keywords, ContentIdeas
from django.db import transaction
class DeletedOnlyManager:
"""Helper to get only deleted records."""
@staticmethod
def get_deleted_queryset(model):
"""Get queryset of soft-deleted records."""
if hasattr(model, 'all_objects'):
return model.all_objects.filter(is_deleted=True)
return model.objects.none()
class RestorableTimePeriodFilter(SimpleListFilter):
"""Filter by whether records can still be restored."""
title = 'Restorable Status'
parameter_name = 'restorable'
def lookups(self, request, model_admin):
return (
('yes', 'Can Restore'),
('no', 'Expired (Cannot Restore)'),
)
def queryset(self, request, queryset):
now = timezone.now()
if self.value() == 'yes':
return queryset.filter(restore_until__gte=now)
if self.value() == 'no':
return queryset.filter(restore_until__lt=now)
return queryset
class DeletedInLastFilter(SimpleListFilter):
"""Filter by when records were deleted."""
title = 'Deleted In Last'
parameter_name = 'deleted_in'
def lookups(self, request, model_admin):
return (
('1', 'Last 24 hours'),
('7', 'Last 7 days'),
('14', 'Last 14 days'),
('30', 'Last 30 days'),
)
def queryset(self, request, queryset):
if self.value():
days = int(self.value())
cutoff = timezone.now() - timezone.timedelta(days=days)
return queryset.filter(deleted_at__gte=cutoff)
return queryset
# ============================================================================
# BASE TRASH ADMIN CLASS
# ============================================================================
class BaseTrashAdmin(ModelAdmin):
"""Base admin class for viewing soft-deleted records."""
def get_queryset(self, request):
"""Return only soft-deleted records."""
# Use all_objects to bypass SoftDeleteManager
qs = self.model.all_objects.filter(is_deleted=True)
ordering = self.get_ordering(request)
if ordering:
qs = qs.order_by(*ordering)
return qs
def has_add_permission(self, request):
"""Disable adding new records in trash view."""
return False
def has_change_permission(self, request, obj=None):
"""Allow viewing but not editing deleted records."""
return True
def has_delete_permission(self, request, obj=None):
"""Allow permanent deletion from trash."""
return request.user.is_superuser
def time_until_permanent_delete(self, obj):
"""Show time remaining until auto-permanent delete."""
if obj.restore_until:
now = timezone.now()
if obj.restore_until > now:
remaining = obj.restore_until - now
days = remaining.days
hours = remaining.seconds // 3600
if days > 0:
return format_html(
'{} days, {} hours',
days, hours
)
elif hours > 0:
return format_html(
'{} hours',
hours
)
else:
minutes = remaining.seconds // 60
return format_html(
'{} minutes',
minutes
)
else:
return format_html(
'Expired'
)
return '-'
time_until_permanent_delete.short_description = 'Time to Auto-Delete'
def deleted_at_display(self, obj):
"""Format deleted_at timestamp."""
if obj.deleted_at:
return obj.deleted_at.strftime('%Y-%m-%d %H:%M')
return '-'
deleted_at_display.short_description = 'Deleted At'
deleted_at_display.admin_order_field = 'deleted_at'
def can_restore_display(self, obj):
"""Show if record can be restored."""
if obj.restore_until:
if obj.restore_until > timezone.now():
return format_html('✓ Yes')
else:
return format_html('✗ No (Expired)')
return format_html('-')
can_restore_display.short_description = 'Can Restore'
# ========================================================================
# ACTIONS
# ========================================================================
@admin.action(description='🔄 Restore selected records')
def restore_records(self, request, queryset):
"""Restore selected soft-deleted records."""
now = timezone.now()
restorable = queryset.filter(restore_until__gte=now)
expired = queryset.filter(restore_until__lt=now)
restored_count = 0
failed_count = 0
failed_items = []
for obj in restorable:
try:
with transaction.atomic():
obj.restore()
restored_count += 1
except Exception as e:
failed_count += 1
failed_items.append(f"{obj} ({str(e)[:50]}...)")
if restored_count > 0:
self.message_user(
request,
f'Successfully restored {restored_count} record(s).',
messages.SUCCESS
)
if failed_count > 0:
self.message_user(
request,
f'{failed_count} record(s) could not be restored (constraint violation). Items: {", ".join(failed_items[:3])}',
messages.ERROR
)
if expired.exists():
self.message_user(
request,
f'{expired.count()} record(s) could not be restored (retention period expired).',
messages.WARNING
)
@admin.action(description='🗑️ Permanently delete selected records')
def permanently_delete_records(self, request, queryset):
"""Permanently delete selected records (irreversible)."""
count = queryset.count()
failed_count = 0
failed_items = []
deleted_count = 0
for obj in queryset:
try:
with transaction.atomic():
# For Sector, cascade delete related soft-deleted records first
if isinstance(obj, Sector):
self._cascade_delete_sector_relations(obj)
# For Site, cascade delete related soft-deleted records first
elif isinstance(obj, Site):
self._cascade_delete_site_relations(obj)
obj.hard_delete()
deleted_count += 1
except Exception as e:
failed_count += 1
failed_items.append(f"{obj} ({str(e)[:100]}...)")
if deleted_count > 0:
self.message_user(
request,
f'Permanently deleted {deleted_count} record(s). This cannot be undone.',
messages.SUCCESS
)
if failed_count > 0:
self.message_user(
request,
f'{failed_count} record(s) could not be deleted. Items: {", ".join(failed_items[:3])}',
messages.ERROR
)
def _cascade_delete_sector_relations(self, sector):
"""Cascade delete all related soft-deleted records for a sector."""
# Delete in order of dependencies
# 1. Images (depends on Content/Tasks)
Images.all_objects.filter(sector=sector).delete()
# 2. Content (depends on Tasks)
Content.all_objects.filter(sector=sector).delete()
# 3. Tasks (depends on Clusters)
Tasks.all_objects.filter(sector=sector).delete()
# 4. ContentIdeas (depends on Clusters)
ContentIdeas.all_objects.filter(sector=sector).delete()
# 5. Keywords (depends on Clusters)
Keywords.all_objects.filter(sector=sector).delete()
# 6. Clusters
Clusters.all_objects.filter(sector=sector).delete()
# 7. ContentTaxonomy (if exists)
ContentTaxonomy.objects.filter(sector=sector).delete()
def _cascade_delete_site_relations(self, site):
"""Cascade delete all related soft-deleted records for a site."""
# Delete sectors first (which will cascade to their relations)
for sector in Sector.all_objects.filter(site=site):
self._cascade_delete_sector_relations(sector)
sector.hard_delete()
# Delete any site-level records
Images.all_objects.filter(site=site).delete()
Content.all_objects.filter(site=site).delete()
Tasks.all_objects.filter(site=site).delete()
ContentIdeas.all_objects.filter(site=site).delete()
Keywords.all_objects.filter(site=site).delete()
Clusters.all_objects.filter(site=site).delete()
ContentTaxonomy.objects.filter(site=site).delete()
actions = ['restore_records', 'permanently_delete_records']
# ============================================================================
# TRASH ADMIN CLASSES FOR EACH MODEL
# ============================================================================
class AccountTrashAdmin(BaseTrashAdmin):
"""Trash view for deleted Accounts."""
list_display = [
'name', 'owner', 'deleted_at_display', 'delete_reason',
'time_until_permanent_delete', 'can_restore_display'
]
list_filter = [RestorableTimePeriodFilter, DeletedInLastFilter]
search_fields = ['name', 'owner__email', 'delete_reason']
ordering = ['-deleted_at']
readonly_fields = [
'name', 'owner', 'status', 'plan',
'is_deleted', 'deleted_at', 'restore_until', 'delete_reason', 'deleted_by'
]
class SiteTrashAdmin(BaseTrashAdmin):
"""Trash view for deleted Sites."""
list_display = [
'name', 'account_name', 'domain', 'deleted_at_display', 'delete_reason',
'time_until_permanent_delete', 'can_restore_display'
]
list_filter = [RestorableTimePeriodFilter, DeletedInLastFilter]
search_fields = ['name', 'domain', 'account__name', 'delete_reason']
ordering = ['-deleted_at']
readonly_fields = [
'name', 'domain', 'account', 'status',
'is_deleted', 'deleted_at', 'restore_until', 'delete_reason', 'deleted_by'
]
def account_name(self, obj):
"""Display account name safely."""
if obj.account:
# Account might be soft-deleted too, use all_objects
try:
account = Account.all_objects.get(pk=obj.account_id)
return account.name
except Account.DoesNotExist:
return f'[Deleted Account #{obj.account_id}]'
return '-'
account_name.short_description = 'Account'
class SectorTrashAdmin(BaseTrashAdmin):
"""Trash view for deleted Sectors."""
list_display = [
'name', 'account_name', 'site_name', 'deleted_at_display',
'time_until_permanent_delete', 'can_restore_display'
]
list_filter = [RestorableTimePeriodFilter, DeletedInLastFilter]
search_fields = ['name', 'account__name', 'site__name']
ordering = ['-deleted_at']
readonly_fields = [
'name', 'account', 'site', 'description',
'is_deleted', 'deleted_at', 'restore_until', 'delete_reason', 'deleted_by'
]
def account_name(self, obj):
if obj.account:
try:
return Account.all_objects.get(pk=obj.account_id).name
except Account.DoesNotExist:
return f'[Deleted #{obj.account_id}]'
return '-'
account_name.short_description = 'Account'
def site_name(self, obj):
if obj.site:
try:
return Site.all_objects.get(pk=obj.site_id).name
except Site.DoesNotExist:
return f'[Deleted #{obj.site_id}]'
return '-'
site_name.short_description = 'Site'
class ContentTrashAdmin(BaseTrashAdmin):
"""Trash view for deleted Content."""
list_display = [
'title_short', 'site_name', 'status', 'deleted_at_display',
'time_until_permanent_delete', 'can_restore_display'
]
list_filter = [RestorableTimePeriodFilter, DeletedInLastFilter, 'status']
search_fields = ['title', 'site__name', 'account__name']
ordering = ['-deleted_at']
readonly_fields = [
'title', 'account', 'site', 'sector', 'status', 'word_count',
'is_deleted', 'deleted_at', 'restore_until', 'delete_reason', 'deleted_by'
]
def title_short(self, obj):
"""Truncate long titles."""
if obj.title and len(obj.title) > 50:
return obj.title[:50] + '...'
return obj.title or '-'
title_short.short_description = 'Title'
def site_name(self, obj):
if obj.site:
try:
return Site.all_objects.get(pk=obj.site_id).name
except Site.DoesNotExist:
return f'[Deleted #{obj.site_id}]'
return '-'
site_name.short_description = 'Site'
class TasksTrashAdmin(BaseTrashAdmin):
"""Trash view for deleted Tasks."""
list_display = [
'id', 'content_type', 'site_name', 'status', 'deleted_at_display',
'time_until_permanent_delete', 'can_restore_display'
]
list_filter = [RestorableTimePeriodFilter, DeletedInLastFilter, 'status', 'content_type']
search_fields = ['site__name', 'account__name', 'title']
ordering = ['-deleted_at']
readonly_fields = [
'content_type', 'account', 'site', 'sector', 'status', 'title',
'is_deleted', 'deleted_at', 'restore_until', 'delete_reason', 'deleted_by'
]
def site_name(self, obj):
if obj.site:
try:
return Site.all_objects.get(pk=obj.site_id).name
except Site.DoesNotExist:
return f'[Deleted #{obj.site_id}]'
return '-'
site_name.short_description = 'Site'
class ImagesTrashAdmin(BaseTrashAdmin):
"""Trash view for deleted Images."""
list_display = [
'id', 'caption_short', 'site_name', 'deleted_at_display',
'time_until_permanent_delete', 'can_restore_display'
]
list_filter = [RestorableTimePeriodFilter, DeletedInLastFilter]
search_fields = ['caption', 'site__name']
ordering = ['-deleted_at']
readonly_fields = [
'caption', 'account', 'site', 'content',
'is_deleted', 'deleted_at', 'restore_until', 'delete_reason', 'deleted_by'
]
def caption_short(self, obj):
if obj.caption and len(obj.caption) > 40:
return obj.caption[:40] + '...'
return obj.caption or '-'
caption_short.short_description = 'Caption'
def site_name(self, obj):
if obj.site:
try:
return Site.all_objects.get(pk=obj.site_id).name
except Site.DoesNotExist:
return f'[Deleted #{obj.site_id}]'
return '-'
site_name.short_description = 'Site'
class ClustersTrashAdmin(BaseTrashAdmin):
"""Trash view for deleted Clusters."""
list_display = [
'name', 'site_name', 'deleted_at_display',
'time_until_permanent_delete', 'can_restore_display'
]
list_filter = [RestorableTimePeriodFilter, DeletedInLastFilter]
search_fields = ['name', 'site__name', 'account__name']
ordering = ['-deleted_at']
readonly_fields = [
'name', 'account', 'site', 'sector',
'is_deleted', 'deleted_at', 'restore_until', 'delete_reason', 'deleted_by'
]
def site_name(self, obj):
if obj.site:
try:
return Site.all_objects.get(pk=obj.site_id).name
except Site.DoesNotExist:
return f'[Deleted #{obj.site_id}]'
return '-'
site_name.short_description = 'Site'
class KeywordsTrashAdmin(BaseTrashAdmin):
"""Trash view for deleted Keywords."""
list_display = [
'seed_keyword_display', 'site_name', 'volume_override', 'deleted_at_display',
'time_until_permanent_delete', 'can_restore_display'
]
list_filter = [RestorableTimePeriodFilter, DeletedInLastFilter]
search_fields = ['seed_keyword__keyword', 'site__name']
ordering = ['-deleted_at']
readonly_fields = [
'seed_keyword', 'account', 'site', 'sector', 'cluster', 'volume_override',
'is_deleted', 'deleted_at', 'restore_until', 'delete_reason', 'deleted_by'
]
def seed_keyword_display(self, obj):
if obj.seed_keyword:
kw = str(obj.seed_keyword)
if len(kw) > 50:
return kw[:50] + '...'
return kw
return '-'
seed_keyword_display.short_description = 'Keyword'
def site_name(self, obj):
if obj.site:
try:
return Site.all_objects.get(pk=obj.site_id).name
except Site.DoesNotExist:
return f'[Deleted #{obj.site_id}]'
return '-'
site_name.short_description = 'Site'
class ContentIdeasTrashAdmin(BaseTrashAdmin):
"""Trash view for deleted Content Ideas."""
list_display = [
'title_short', 'site_name', 'deleted_at_display',
'time_until_permanent_delete', 'can_restore_display'
]
list_filter = [RestorableTimePeriodFilter, DeletedInLastFilter]
search_fields = ['idea_title', 'site__name']
ordering = ['-deleted_at']
readonly_fields = [
'idea_title', 'account', 'site', 'sector', 'keyword_cluster',
'is_deleted', 'deleted_at', 'restore_until', 'delete_reason', 'deleted_by'
]
def title_short(self, obj):
if obj.idea_title and len(obj.idea_title) > 50:
return obj.idea_title[:50] + '...'
return obj.idea_title or '-'
title_short.short_description = 'Title'
def site_name(self, obj):
if obj.site:
try:
return Site.all_objects.get(pk=obj.site_id).name
except Site.DoesNotExist:
return f'[Deleted #{obj.site_id}]'
return '-'
site_name.short_description = 'Site'
class ContentTaxonomyTrashAdmin(BaseTrashAdmin):
"""Trash view for deleted Content Taxonomies (Tags/Categories)."""
list_display = [
'name', 'taxonomy_type', 'site_name', 'deleted_at_display',
'time_until_permanent_delete', 'can_restore_display'
]
list_filter = [RestorableTimePeriodFilter, DeletedInLastFilter, 'taxonomy_type']
search_fields = ['name', 'slug', 'site__name']
ordering = ['-deleted_at']
readonly_fields = [
'name', 'slug', 'taxonomy_type', 'description', 'account', 'site', 'sector',
'external_id', 'external_taxonomy', 'count',
'is_deleted', 'deleted_at', 'restore_until', 'delete_reason', 'deleted_by'
]
def site_name(self, obj):
if obj.site:
try:
return Site.all_objects.get(pk=obj.site_id).name
except Site.DoesNotExist:
return f'[Deleted #{obj.site_id}]'
return '-'
site_name.short_description = 'Site'
# ============================================================================
# PROXY MODELS FOR TRASH VIEWS
# ============================================================================
class AccountTrash(Account):
"""Proxy model for Account trash view."""
class Meta:
proxy = True
app_label = 'igny8_core_auth'
verbose_name = 'Account (Trash)'
verbose_name_plural = 'Accounts (Trash)'
class SiteTrash(Site):
"""Proxy model for Site trash view."""
class Meta:
proxy = True
app_label = 'igny8_core_auth'
verbose_name = 'Site (Trash)'
verbose_name_plural = 'Sites (Trash)'
class SectorTrash(Sector):
"""Proxy model for Sector trash view."""
class Meta:
proxy = True
app_label = 'igny8_core_auth'
verbose_name = 'Sector (Trash)'
verbose_name_plural = 'Sectors (Trash)'
class ContentTrash(Content):
"""Proxy model for Content trash view."""
class Meta:
proxy = True
app_label = 'writer'
verbose_name = 'Content (Trash)'
verbose_name_plural = 'Content (Trash)'
class TasksTrash(Tasks):
"""Proxy model for Tasks trash view."""
class Meta:
proxy = True
app_label = 'writer'
verbose_name = 'Task (Trash)'
verbose_name_plural = 'Tasks (Trash)'
class ImagesTrash(Images):
"""Proxy model for Images trash view."""
class Meta:
proxy = True
app_label = 'writer'
verbose_name = 'Image (Trash)'
verbose_name_plural = 'Images (Trash)'
class ClustersTrash(Clusters):
"""Proxy model for Clusters trash view."""
class Meta:
proxy = True
app_label = 'planner'
verbose_name = 'Cluster (Trash)'
verbose_name_plural = 'Clusters (Trash)'
class KeywordsTrash(Keywords):
"""Proxy model for Keywords trash view."""
class Meta:
proxy = True
app_label = 'planner'
verbose_name = 'Keyword (Trash)'
verbose_name_plural = 'Keywords (Trash)'
class ContentIdeasTrash(ContentIdeas):
"""Proxy model for Content Ideas trash view."""
class Meta:
proxy = True
app_label = 'planner'
verbose_name = 'Content Idea (Trash)'
verbose_name_plural = 'Content Ideas (Trash)'
class ContentTaxonomyTrash(ContentTaxonomy):
"""Proxy model for Content Taxonomy (Tags/Categories) trash view."""
class Meta:
proxy = True
app_label = 'writer'
verbose_name = 'Taxonomy (Trash)'
verbose_name_plural = 'Taxonomies (Trash)'
# ============================================================================
# REGISTER TRASH ADMINS
# ============================================================================
admin.site.register(AccountTrash, AccountTrashAdmin)
admin.site.register(SiteTrash, SiteTrashAdmin)
admin.site.register(SectorTrash, SectorTrashAdmin)
admin.site.register(ContentTrash, ContentTrashAdmin)
admin.site.register(TasksTrash, TasksTrashAdmin)
admin.site.register(ImagesTrash, ImagesTrashAdmin)
admin.site.register(ClustersTrash, ClustersTrashAdmin)
admin.site.register(KeywordsTrash, KeywordsTrashAdmin)
admin.site.register(ContentIdeasTrash, ContentIdeasTrashAdmin)
admin.site.register(ContentTaxonomyTrash, ContentTaxonomyTrashAdmin)