""" 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)