trash models added, first attempt for remainign issues
This commit is contained in:
@@ -67,6 +67,19 @@ class Igny8AdminConfig(AdminConfig):
|
||||
|
||||
# Import and setup enhanced Celery task monitoring
|
||||
self._setup_celery_admin()
|
||||
|
||||
# Import trash admin to register soft-deleted record views
|
||||
self._setup_trash_admin()
|
||||
|
||||
def _setup_trash_admin(self):
|
||||
"""Setup Trash admin views for soft-deleted records."""
|
||||
try:
|
||||
# Import registers the proxy models and their admin classes
|
||||
from igny8_core.admin import trash_admin # noqa: F401
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Could not setup Trash admin: {e}")
|
||||
|
||||
def _setup_celery_admin(self):
|
||||
"""Setup enhanced Celery admin with proper unregister/register"""
|
||||
|
||||
542
backend/igny8_core/admin/trash_admin.py
Normal file
542
backend/igny8_core/admin/trash_admin.py
Normal file
@@ -0,0 +1,542 @@
|
||||
"""
|
||||
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
|
||||
from igny8_core.business.planning.models import Clusters, Keywords, ContentIdeas
|
||||
|
||||
|
||||
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(
|
||||
'<span style="color: orange;">{} days, {} hours</span>',
|
||||
days, hours
|
||||
)
|
||||
elif hours > 0:
|
||||
return format_html(
|
||||
'<span style="color: red;">{} hours</span>',
|
||||
hours
|
||||
)
|
||||
else:
|
||||
minutes = remaining.seconds // 60
|
||||
return format_html(
|
||||
'<span style="color: red;">{} minutes</span>',
|
||||
minutes
|
||||
)
|
||||
else:
|
||||
return format_html(
|
||||
'<span style="color: gray;">Expired</span>'
|
||||
)
|
||||
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('<span style="color: green;">✓ Yes</span>')
|
||||
else:
|
||||
return format_html('<span style="color: red;">✗ No (Expired)</span>')
|
||||
return format_html('<span style="color: gray;">-</span>')
|
||||
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
|
||||
for obj in restorable:
|
||||
obj.restore()
|
||||
restored_count += 1
|
||||
|
||||
if restored_count > 0:
|
||||
self.message_user(
|
||||
request,
|
||||
f'Successfully restored {restored_count} record(s).',
|
||||
messages.SUCCESS
|
||||
)
|
||||
|
||||
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()
|
||||
for obj in queryset:
|
||||
obj.hard_delete()
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
f'Permanently deleted {count} record(s). This cannot be undone.',
|
||||
messages.SUCCESS
|
||||
)
|
||||
|
||||
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'
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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)'
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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)
|
||||
Reference in New Issue
Block a user