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
|
# Import and setup enhanced Celery task monitoring
|
||||||
self._setup_celery_admin()
|
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):
|
def _setup_celery_admin(self):
|
||||||
"""Setup enhanced Celery admin with proper unregister/register"""
|
"""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)
|
||||||
@@ -4,6 +4,7 @@ Admin interface for auth models
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||||
|
from django.db import models
|
||||||
from unfold.admin import ModelAdmin, TabularInline
|
from unfold.admin import ModelAdmin, TabularInline
|
||||||
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
|
||||||
@@ -896,6 +897,27 @@ class SeedKeywordAdmin(ImportExportMixin, Igny8ModelAdmin):
|
|||||||
"""Allow deletion for superusers and developers"""
|
"""Allow deletion for superusers and developers"""
|
||||||
return request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer())
|
return request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer())
|
||||||
|
|
||||||
|
def delete_model(self, request, obj):
|
||||||
|
"""Override delete to handle PROTECT relationship with Keywords"""
|
||||||
|
from igny8_core.business.planning.models import Keywords
|
||||||
|
# Soft-delete all Keywords referencing this SeedKeyword first
|
||||||
|
site_keywords = Keywords.objects.filter(seed_keyword=obj)
|
||||||
|
for kw in site_keywords:
|
||||||
|
kw.soft_delete(user=request.user, reason=f"Parent seed keyword '{obj.keyword}' deleted")
|
||||||
|
# Now we can safely delete the SeedKeyword
|
||||||
|
super().delete_model(request, obj)
|
||||||
|
|
||||||
|
def delete_queryset(self, request, queryset):
|
||||||
|
"""Override bulk delete to handle PROTECT relationship with Keywords"""
|
||||||
|
from igny8_core.business.planning.models import Keywords
|
||||||
|
for seed_keyword in queryset:
|
||||||
|
# Soft-delete all Keywords referencing this SeedKeyword first
|
||||||
|
site_keywords = Keywords.objects.filter(seed_keyword=seed_keyword)
|
||||||
|
for kw in site_keywords:
|
||||||
|
kw.soft_delete(user=request.user, reason=f"Parent seed keyword '{seed_keyword.keyword}' deleted")
|
||||||
|
# Now we can safely delete the SeedKeywords
|
||||||
|
queryset.delete()
|
||||||
|
|
||||||
def bulk_activate(self, request, queryset):
|
def bulk_activate(self, request, queryset):
|
||||||
updated = queryset.update(is_active=True)
|
updated = queryset.update(is_active=True)
|
||||||
self.message_user(request, f'{updated} seed keyword(s) activated.', messages.SUCCESS)
|
self.message_user(request, f'{updated} seed keyword(s) activated.', messages.SUCCESS)
|
||||||
@@ -1075,4 +1097,3 @@ class UserAdmin(ExportMixin, BaseUserAdmin, Igny8ModelAdmin):
|
|||||||
messages.INFO
|
messages.INFO
|
||||||
)
|
)
|
||||||
bulk_send_password_reset.short_description = 'Send password reset email'
|
bulk_send_password_reset.short_description = 'Send password reset email'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 5.2.10 on 2026-01-12 13:36
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0020_fix_historical_account'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AccountTrash',
|
||||||
|
fields=[
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Account (Trash)',
|
||||||
|
'verbose_name_plural': 'Accounts (Trash)',
|
||||||
|
'proxy': True,
|
||||||
|
'indexes': [],
|
||||||
|
'constraints': [],
|
||||||
|
},
|
||||||
|
bases=('igny8_core_auth.account',),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SectorTrash',
|
||||||
|
fields=[
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Sector (Trash)',
|
||||||
|
'verbose_name_plural': 'Sectors (Trash)',
|
||||||
|
'proxy': True,
|
||||||
|
'indexes': [],
|
||||||
|
'constraints': [],
|
||||||
|
},
|
||||||
|
bases=('igny8_core_auth.sector',),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SiteTrash',
|
||||||
|
fields=[
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Site (Trash)',
|
||||||
|
'verbose_name_plural': 'Sites (Trash)',
|
||||||
|
'proxy': True,
|
||||||
|
'indexes': [],
|
||||||
|
'constraints': [],
|
||||||
|
},
|
||||||
|
bases=('igny8_core_auth.site',),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -590,15 +590,25 @@ class AutomationViewSet(viewsets.ViewSet):
|
|||||||
# Get the run
|
# Get the run
|
||||||
run = AutomationRun.objects.get(run_id=run_id, site=site)
|
run = AutomationRun.objects.get(run_id=run_id, site=site)
|
||||||
|
|
||||||
# If not running, return None
|
# If not running or paused, return minimal state with updated credits
|
||||||
if run.status != 'running':
|
if run.status not in ('running', 'paused'):
|
||||||
return Response({'data': None})
|
return Response({'data': None})
|
||||||
|
|
||||||
# Get current processing state
|
# Get current processing state
|
||||||
service = AutomationService.from_run_id(run_id)
|
service = AutomationService.from_run_id(run_id)
|
||||||
state = service.get_current_processing_state()
|
state = service.get_current_processing_state()
|
||||||
|
|
||||||
return Response({'data': state})
|
# Refresh run to get latest total_credits_used
|
||||||
|
run.refresh_from_db()
|
||||||
|
|
||||||
|
# Add updated credits info to response
|
||||||
|
response_data = {
|
||||||
|
'state': state,
|
||||||
|
'total_credits_used': run.total_credits_used,
|
||||||
|
'current_stage': run.current_stage,
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response({'data': response_data})
|
||||||
|
|
||||||
except AutomationRun.DoesNotExist:
|
except AutomationRun.DoesNotExist:
|
||||||
return Response(
|
return Response(
|
||||||
|
|||||||
@@ -508,6 +508,53 @@ class CreditService:
|
|||||||
|
|
||||||
return account.credits
|
return account.credits
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@transaction.atomic
|
||||||
|
def reset_credits_for_renewal(account, new_amount, description, metadata=None):
|
||||||
|
"""
|
||||||
|
Reset credits for subscription renewal (sets credits to new_amount instead of adding).
|
||||||
|
|
||||||
|
This is used when a subscription renews - the credits are reset to the full
|
||||||
|
plan amount, not added to existing balance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account: Account instance
|
||||||
|
new_amount: Number of credits to set (plan's included_credits)
|
||||||
|
description: Description of the transaction
|
||||||
|
metadata: Optional metadata dict
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: New credit balance
|
||||||
|
"""
|
||||||
|
old_balance = account.credits
|
||||||
|
account.credits = new_amount
|
||||||
|
account.save(update_fields=['credits'])
|
||||||
|
|
||||||
|
# Calculate the change for the transaction record
|
||||||
|
change_amount = new_amount - old_balance
|
||||||
|
|
||||||
|
# Create CreditTransaction - use 'subscription' type for renewal
|
||||||
|
CreditTransaction.objects.create(
|
||||||
|
account=account,
|
||||||
|
transaction_type='subscription', # Uses 'Subscription Renewal' display
|
||||||
|
amount=change_amount, # Can be positive or negative depending on usage
|
||||||
|
balance_after=account.credits,
|
||||||
|
description=description,
|
||||||
|
metadata={
|
||||||
|
**(metadata or {}),
|
||||||
|
'reset_from': old_balance,
|
||||||
|
'reset_to': new_amount,
|
||||||
|
'is_renewal_reset': True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Credits reset for renewal: Account {account.id} - "
|
||||||
|
f"from {old_balance} to {new_amount} (change: {change_amount})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return account.credits
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def deduct_credits_for_image(
|
def deduct_credits_for_image(
|
||||||
|
|||||||
@@ -720,12 +720,11 @@ def _handle_invoice_paid(invoice: dict):
|
|||||||
logger.info(f"Skipping initial invoice for subscription {subscription_id}")
|
logger.info(f"Skipping initial invoice for subscription {subscription_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Add monthly credits for renewal
|
# Reset credits for renewal (set to full plan amount, not add)
|
||||||
if plan.included_credits and plan.included_credits > 0:
|
if plan.included_credits and plan.included_credits > 0:
|
||||||
CreditService.add_credits(
|
CreditService.reset_credits_for_renewal(
|
||||||
account=account,
|
account=account,
|
||||||
amount=plan.included_credits,
|
new_amount=plan.included_credits,
|
||||||
transaction_type='subscription',
|
|
||||||
description=f'Monthly renewal: {plan.name}',
|
description=f'Monthly renewal: {plan.name}',
|
||||||
metadata={
|
metadata={
|
||||||
'plan_id': str(plan.id),
|
'plan_id': str(plan.id),
|
||||||
@@ -734,7 +733,7 @@ def _handle_invoice_paid(invoice: dict):
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Renewal credits added for account {account.id}: "
|
f"Renewal credits reset for account {account.id}: "
|
||||||
f"plan={plan.name}, credits={plan.included_credits}"
|
f"plan={plan.name}, credits={plan.included_credits}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -284,8 +284,8 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
|
|||||||
account.status = 'active'
|
account.status = 'active'
|
||||||
account.save()
|
account.save()
|
||||||
|
|
||||||
# Add Credits (check if not already added)
|
# Add or Reset Credits (check if not already added)
|
||||||
from igny8_core.business.billing.models import CreditTransaction
|
from igny8_core.business.billing.models import CreditTransaction, Invoice
|
||||||
existing_credit = CreditTransaction.objects.filter(
|
existing_credit = CreditTransaction.objects.filter(
|
||||||
account=account,
|
account=account,
|
||||||
metadata__payment_id=obj.id
|
metadata__payment_id=obj.id
|
||||||
@@ -294,32 +294,65 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
|
|||||||
if not existing_credit:
|
if not existing_credit:
|
||||||
credits_to_add = 0
|
credits_to_add = 0
|
||||||
plan_name = ''
|
plan_name = ''
|
||||||
|
is_renewal = False
|
||||||
|
|
||||||
if subscription and subscription.plan:
|
if subscription and subscription.plan:
|
||||||
credits_to_add = subscription.plan.included_credits
|
credits_to_add = subscription.plan.included_credits
|
||||||
plan_name = subscription.plan.name
|
plan_name = subscription.plan.name
|
||||||
|
# Check if this is a renewal (previous paid invoices exist)
|
||||||
|
previous_paid = Invoice.objects.filter(
|
||||||
|
subscription=subscription,
|
||||||
|
status='paid'
|
||||||
|
).exclude(id=invoice.id if invoice else None).exists()
|
||||||
|
is_renewal = previous_paid
|
||||||
elif account and account.plan:
|
elif account and account.plan:
|
||||||
credits_to_add = account.plan.included_credits
|
credits_to_add = account.plan.included_credits
|
||||||
plan_name = account.plan.name
|
plan_name = account.plan.name
|
||||||
|
# Check renewal by account history
|
||||||
|
is_renewal = CreditTransaction.objects.filter(
|
||||||
|
account=account,
|
||||||
|
transaction_type='subscription'
|
||||||
|
).exists()
|
||||||
|
|
||||||
if credits_to_add > 0:
|
if credits_to_add > 0:
|
||||||
CreditService.add_credits(
|
if is_renewal:
|
||||||
account=account,
|
# Renewal: Reset credits to full plan amount
|
||||||
amount=credits_to_add,
|
CreditService.reset_credits_for_renewal(
|
||||||
transaction_type='subscription',
|
account=account,
|
||||||
description=f'{plan_name} - Invoice {invoice.invoice_number}',
|
new_amount=credits_to_add,
|
||||||
metadata={
|
description=f'{plan_name} Renewal - Invoice {invoice.invoice_number}',
|
||||||
'subscription_id': subscription.id if subscription else None,
|
metadata={
|
||||||
'invoice_id': invoice.id,
|
'subscription_id': subscription.id if subscription else None,
|
||||||
'payment_id': obj.id,
|
'invoice_id': invoice.id,
|
||||||
'approved_by': request.user.email
|
'payment_id': obj.id,
|
||||||
}
|
'approved_by': request.user.email,
|
||||||
)
|
'is_renewal': True
|
||||||
self.message_user(
|
}
|
||||||
request,
|
)
|
||||||
f'✓ Payment approved: Account activated, {credits_to_add} credits added',
|
self.message_user(
|
||||||
level='SUCCESS'
|
request,
|
||||||
)
|
f'✓ Renewal approved: Account activated, credits reset to {credits_to_add}',
|
||||||
|
level='SUCCESS'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Initial: Add credits
|
||||||
|
CreditService.add_credits(
|
||||||
|
account=account,
|
||||||
|
amount=credits_to_add,
|
||||||
|
transaction_type='subscription',
|
||||||
|
description=f'{plan_name} - Invoice {invoice.invoice_number}',
|
||||||
|
metadata={
|
||||||
|
'subscription_id': subscription.id if subscription else None,
|
||||||
|
'invoice_id': invoice.id,
|
||||||
|
'payment_id': obj.id,
|
||||||
|
'approved_by': request.user.email
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f'✓ Payment approved: Account activated, {credits_to_add} credits added',
|
||||||
|
level='SUCCESS'
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.message_user(
|
self.message_user(
|
||||||
@@ -377,37 +410,83 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
|
|||||||
account.status = 'active'
|
account.status = 'active'
|
||||||
account.save()
|
account.save()
|
||||||
|
|
||||||
# Add Credits
|
# Add or Reset Credits based on whether this is a renewal
|
||||||
|
# Check if there are previous paid invoices for this subscription (renewal)
|
||||||
|
from igny8_core.business.billing.models import Invoice, CreditTransaction
|
||||||
|
is_renewal = False
|
||||||
|
if subscription:
|
||||||
|
previous_paid_invoices = Invoice.objects.filter(
|
||||||
|
subscription=subscription,
|
||||||
|
status='paid'
|
||||||
|
).exclude(id=invoice.id).exists()
|
||||||
|
is_renewal = previous_paid_invoices
|
||||||
|
|
||||||
credits_added = 0
|
credits_added = 0
|
||||||
if subscription and subscription.plan and subscription.plan.included_credits > 0:
|
if subscription and subscription.plan and subscription.plan.included_credits > 0:
|
||||||
credits_added = subscription.plan.included_credits
|
credits_added = subscription.plan.included_credits
|
||||||
CreditService.add_credits(
|
if is_renewal:
|
||||||
account=account,
|
# Renewal: Reset credits to full plan amount
|
||||||
amount=credits_added,
|
CreditService.reset_credits_for_renewal(
|
||||||
transaction_type='subscription',
|
account=account,
|
||||||
description=f'{subscription.plan.name} - Invoice {invoice.invoice_number}',
|
new_amount=credits_added,
|
||||||
metadata={
|
description=f'{subscription.plan.name} Renewal - Invoice {invoice.invoice_number}',
|
||||||
'subscription_id': subscription.id,
|
metadata={
|
||||||
'invoice_id': invoice.id,
|
'subscription_id': subscription.id,
|
||||||
'payment_id': payment.id,
|
'invoice_id': invoice.id,
|
||||||
'approved_by': request.user.email
|
'payment_id': payment.id,
|
||||||
}
|
'approved_by': request.user.email,
|
||||||
)
|
'is_renewal': True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Initial subscription: Add credits
|
||||||
|
CreditService.add_credits(
|
||||||
|
account=account,
|
||||||
|
amount=credits_added,
|
||||||
|
transaction_type='subscription',
|
||||||
|
description=f'{subscription.plan.name} - Invoice {invoice.invoice_number}',
|
||||||
|
metadata={
|
||||||
|
'subscription_id': subscription.id,
|
||||||
|
'invoice_id': invoice.id,
|
||||||
|
'payment_id': payment.id,
|
||||||
|
'approved_by': request.user.email
|
||||||
|
}
|
||||||
|
)
|
||||||
elif account and account.plan and account.plan.included_credits > 0:
|
elif account and account.plan and account.plan.included_credits > 0:
|
||||||
credits_added = account.plan.included_credits
|
credits_added = account.plan.included_credits
|
||||||
CreditService.add_credits(
|
# Check renewal by looking at account credit transactions
|
||||||
|
previous_subscriptions = CreditTransaction.objects.filter(
|
||||||
account=account,
|
account=account,
|
||||||
amount=credits_added,
|
transaction_type='subscription'
|
||||||
transaction_type='subscription',
|
).exists()
|
||||||
description=f'{account.plan.name} - Invoice {invoice.invoice_number}',
|
if previous_subscriptions:
|
||||||
metadata={
|
# Renewal: Reset credits
|
||||||
'invoice_id': invoice.id,
|
CreditService.reset_credits_for_renewal(
|
||||||
'payment_id': payment.id,
|
account=account,
|
||||||
'approved_by': request.user.email
|
new_amount=credits_added,
|
||||||
}
|
description=f'{account.plan.name} Renewal - Invoice {invoice.invoice_number}',
|
||||||
)
|
metadata={
|
||||||
|
'invoice_id': invoice.id,
|
||||||
|
'payment_id': payment.id,
|
||||||
|
'approved_by': request.user.email,
|
||||||
|
'is_renewal': True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
CreditService.add_credits(
|
||||||
|
account=account,
|
||||||
|
amount=credits_added,
|
||||||
|
transaction_type='subscription',
|
||||||
|
description=f'{account.plan.name} - Invoice {invoice.invoice_number}',
|
||||||
|
metadata={
|
||||||
|
'invoice_id': invoice.id,
|
||||||
|
'payment_id': payment.id,
|
||||||
|
'approved_by': request.user.email
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
successful.append(f'Payment #{payment.id} - {account.name} - Invoice {invoice.invoice_number} - {credits_added} credits')
|
renewal_label = ' (renewal reset)' if is_renewal or (account.plan and CreditTransaction.objects.filter(account=account, transaction_type='subscription').count() > 0) else ''
|
||||||
|
successful.append(f'Payment #{payment.id} - {account.name} - Invoice {invoice.invoice_number} - {credits_added} credits{renewal_label}')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f'Payment #{payment.id}: {str(e)}')
|
errors.append(f'Payment #{payment.id}: {str(e)}')
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 5.2.10 on 2026-01-12 13:36
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('planner', '0008_soft_delete'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ClustersTrash',
|
||||||
|
fields=[
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Cluster (Trash)',
|
||||||
|
'verbose_name_plural': 'Clusters (Trash)',
|
||||||
|
'proxy': True,
|
||||||
|
'indexes': [],
|
||||||
|
'constraints': [],
|
||||||
|
},
|
||||||
|
bases=('planner.clusters',),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ContentIdeasTrash',
|
||||||
|
fields=[
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Content Idea (Trash)',
|
||||||
|
'verbose_name_plural': 'Content Ideas (Trash)',
|
||||||
|
'proxy': True,
|
||||||
|
'indexes': [],
|
||||||
|
'constraints': [],
|
||||||
|
},
|
||||||
|
bases=('planner.contentideas',),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='KeywordsTrash',
|
||||||
|
fields=[
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Keyword (Trash)',
|
||||||
|
'verbose_name_plural': 'Keywords (Trash)',
|
||||||
|
'proxy': True,
|
||||||
|
'indexes': [],
|
||||||
|
'constraints': [],
|
||||||
|
},
|
||||||
|
bases=('planner.keywords',),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Generated by Django 5.2.10 on 2026-01-12 13:36
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('system', '0024_alter_systemaisettings_default_quality_tier_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='AccountIntegrationOverride',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 5.2.10 on 2026-01-12 13:36
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('writer', '0016_images_unique_position_constraint'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ContentTrash',
|
||||||
|
fields=[
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Content (Trash)',
|
||||||
|
'verbose_name_plural': 'Content (Trash)',
|
||||||
|
'proxy': True,
|
||||||
|
'indexes': [],
|
||||||
|
'constraints': [],
|
||||||
|
},
|
||||||
|
bases=('writer.content',),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ImagesTrash',
|
||||||
|
fields=[
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Image (Trash)',
|
||||||
|
'verbose_name_plural': 'Images (Trash)',
|
||||||
|
'proxy': True,
|
||||||
|
'indexes': [],
|
||||||
|
'constraints': [],
|
||||||
|
},
|
||||||
|
bases=('writer.images',),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TasksTrash',
|
||||||
|
fields=[
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Task (Trash)',
|
||||||
|
'verbose_name_plural': 'Tasks (Trash)',
|
||||||
|
'proxy': True,
|
||||||
|
'indexes': [],
|
||||||
|
'constraints': [],
|
||||||
|
},
|
||||||
|
bases=('writer.tasks',),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -838,6 +838,23 @@ UNFOLD = {
|
|||||||
{"title": "Seed Keywords", "icon": "eco", "link": lambda request: "/admin/igny8_core_auth/seedkeyword/"},
|
{"title": "Seed Keywords", "icon": "eco", "link": lambda request: "/admin/igny8_core_auth/seedkeyword/"},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
# Trash (Soft-Deleted Records)
|
||||||
|
{
|
||||||
|
"title": "Trash",
|
||||||
|
"icon": "delete",
|
||||||
|
"collapsible": True,
|
||||||
|
"items": [
|
||||||
|
{"title": "Accounts (Trash)", "icon": "business", "link": lambda request: "/admin/igny8_core_auth/accounttrash/"},
|
||||||
|
{"title": "Sites (Trash)", "icon": "language", "link": lambda request: "/admin/igny8_core_auth/sitetrash/"},
|
||||||
|
{"title": "Sectors (Trash)", "icon": "category", "link": lambda request: "/admin/igny8_core_auth/sectortrash/"},
|
||||||
|
{"title": "Content (Trash)", "icon": "description", "link": lambda request: "/admin/writer/contenttrash/"},
|
||||||
|
{"title": "Tasks (Trash)", "icon": "task_alt", "link": lambda request: "/admin/writer/taskstrash/"},
|
||||||
|
{"title": "Keywords (Trash)", "icon": "key", "link": lambda request: "/admin/planner/keywordstrash/"},
|
||||||
|
{"title": "Clusters (Trash)", "icon": "hub", "link": lambda request: "/admin/planner/clusterstrash/"},
|
||||||
|
{"title": "Images (Trash)", "icon": "image", "link": lambda request: "/admin/writer/imagestrash/"},
|
||||||
|
{"title": "Content Ideas (Trash)", "icon": "lightbulb", "link": lambda request: "/admin/planner/contentideastrash/"},
|
||||||
|
],
|
||||||
|
},
|
||||||
# Logs & Monitoring
|
# Logs & Monitoring
|
||||||
{
|
{
|
||||||
"title": "Logs & Monitoring",
|
"title": "Logs & Monitoring",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Clean UI without cluttered "Currently Processing" and "Up Next" sections
|
* Clean UI without cluttered "Currently Processing" and "Up Next" sections
|
||||||
*/
|
*/
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { automationService, ProcessingState, AutomationRun, PipelineStage } from '../../services/automationService';
|
import { automationService, ProcessingState, AutomationRun, PipelineStage, CurrentProcessingResponse } from '../../services/automationService';
|
||||||
import { useToast } from '../ui/toast/ToastContainer';
|
import { useToast } from '../ui/toast/ToastContainer';
|
||||||
import Button from '../ui/button/Button';
|
import Button from '../ui/button/Button';
|
||||||
import IconButton from '../ui/button/IconButton';
|
import IconButton from '../ui/button/IconButton';
|
||||||
@@ -98,6 +98,7 @@ const CurrentProcessingCard: React.FC<CurrentProcessingCardProps> = ({
|
|||||||
pipelineOverview,
|
pipelineOverview,
|
||||||
}) => {
|
}) => {
|
||||||
const [processingState, setProcessingState] = useState<ProcessingState | null>(null);
|
const [processingState, setProcessingState] = useState<ProcessingState | null>(null);
|
||||||
|
const [totalCreditsUsed, setTotalCreditsUsed] = useState<number>(currentRun.total_credits_used);
|
||||||
const [isPausing, setIsPausing] = useState(false);
|
const [isPausing, setIsPausing] = useState(false);
|
||||||
const [isResuming, setIsResuming] = useState(false);
|
const [isResuming, setIsResuming] = useState(false);
|
||||||
const [isCancelling, setIsCancelling] = useState(false);
|
const [isCancelling, setIsCancelling] = useState(false);
|
||||||
@@ -110,12 +111,21 @@ const CurrentProcessingCard: React.FC<CurrentProcessingCardProps> = ({
|
|||||||
|
|
||||||
const fetchState = async () => {
|
const fetchState = async () => {
|
||||||
try {
|
try {
|
||||||
const state = await automationService.getCurrentProcessing(siteId, runId);
|
const response = await automationService.getCurrentProcessing(siteId, runId);
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
setProcessingState(state);
|
|
||||||
|
if (response) {
|
||||||
|
// Update processing state from nested state object
|
||||||
|
setProcessingState(response.state);
|
||||||
|
|
||||||
|
// Update credits from the response
|
||||||
|
if (response.total_credits_used !== undefined) {
|
||||||
|
setTotalCreditsUsed(response.total_credits_used);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If stage completed, trigger update
|
// If stage completed, trigger update
|
||||||
if (state && state.processed_items >= state.total_items && state.total_items > 0) {
|
if (response?.state && response.state.processed_items >= response.state.total_items && response.state.total_items > 0) {
|
||||||
onUpdate();
|
onUpdate();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -323,7 +333,7 @@ const CurrentProcessingCard: React.FC<CurrentProcessingCardProps> = ({
|
|||||||
<BoltIcon className="w-4 h-4 text-warning-500" />
|
<BoltIcon className="w-4 h-4 text-warning-500" />
|
||||||
<span className="text-xs font-medium text-gray-500 uppercase">Credits</span>
|
<span className="text-xs font-medium text-gray-500 uppercase">Credits</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-base font-bold text-warning-600">{currentRun.total_credits_used}</span>
|
<span className="text-base font-bold text-warning-600">{totalCreditsUsed}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl px-3 py-2.5 border border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
<div className="bg-white dark:bg-gray-800 rounded-xl px-3 py-2.5 border border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Badge from '../ui/badge/Badge';
|
|||||||
import SiteSetupChecklist from '../sites/SiteSetupChecklist';
|
import SiteSetupChecklist from '../sites/SiteSetupChecklist';
|
||||||
import SiteTypeBadge from '../sites/SiteTypeBadge';
|
import SiteTypeBadge from '../sites/SiteTypeBadge';
|
||||||
import { Site } from '../../services/api';
|
import { Site } from '../../services/api';
|
||||||
import { BoxCubeIcon as SettingsIcon, EyeIcon, FileIcon } from '../../icons';
|
import { BoxCubeIcon as SettingsIcon, EyeIcon, FileIcon, TrashBinIcon } from '../../icons';
|
||||||
|
|
||||||
interface SiteCardProps {
|
interface SiteCardProps {
|
||||||
site: Site;
|
site: Site;
|
||||||
@@ -13,6 +13,7 @@ interface SiteCardProps {
|
|||||||
onToggle: (siteId: number, enabled: boolean) => void;
|
onToggle: (siteId: number, enabled: boolean) => void;
|
||||||
onSettings: (site: Site) => void;
|
onSettings: (site: Site) => void;
|
||||||
onDetails: (site: Site) => void;
|
onDetails: (site: Site) => void;
|
||||||
|
onDelete?: (site: Site) => void;
|
||||||
isToggling?: boolean;
|
isToggling?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export default function SiteCard({
|
|||||||
onToggle,
|
onToggle,
|
||||||
onSettings,
|
onSettings,
|
||||||
onDetails,
|
onDetails,
|
||||||
|
onDelete,
|
||||||
isToggling = false,
|
isToggling = false,
|
||||||
}: SiteCardProps) {
|
}: SiteCardProps) {
|
||||||
const handleToggle = (enabled: boolean) => {
|
const handleToggle = (enabled: boolean) => {
|
||||||
@@ -126,6 +128,16 @@ export default function SiteCard({
|
|||||||
>
|
>
|
||||||
Settings
|
Settings
|
||||||
</Button>
|
</Button>
|
||||||
|
{onDelete && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
tone="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(site)}
|
||||||
|
startIcon={<TrashBinIcon className="w-4 h-4" />}
|
||||||
|
title="Delete site"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface AIOperation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AIOperationsData {
|
export interface AIOperationsData {
|
||||||
period: '7d' | '30d' | '90d';
|
period: 'today' | '7d' | '30d' | '90d';
|
||||||
operations: AIOperation[];
|
operations: AIOperation[];
|
||||||
totals: {
|
totals: {
|
||||||
count: number;
|
count: number;
|
||||||
@@ -32,7 +32,7 @@ export interface AIOperationsData {
|
|||||||
|
|
||||||
interface AIOperationsWidgetProps {
|
interface AIOperationsWidgetProps {
|
||||||
data: AIOperationsData;
|
data: AIOperationsData;
|
||||||
onPeriodChange?: (period: '7d' | '30d' | '90d') => void;
|
onPeriodChange?: (period: 'today' | '7d' | '30d' | '90d') => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +54,7 @@ const operationConfig: Record<string, { label: string; icon: typeof GroupIcon; g
|
|||||||
const defaultConfig = { label: 'Other', icon: BoltIcon, gradient: 'from-gray-500 to-gray-600' };
|
const defaultConfig = { label: 'Other', icon: BoltIcon, gradient: 'from-gray-500 to-gray-600' };
|
||||||
|
|
||||||
const periods = [
|
const periods = [
|
||||||
|
{ value: 'today', label: 'Today' },
|
||||||
{ value: '7d', label: '7 days' },
|
{ value: '7d', label: '7 days' },
|
||||||
{ value: '30d', label: '30 days' },
|
{ value: '30d', label: '30 days' },
|
||||||
{ value: '90d', label: '90 days' },
|
{ value: '90d', label: '90 days' },
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function Home() {
|
|||||||
const [sites, setSites] = useState<Site[]>([]);
|
const [sites, setSites] = useState<Site[]>([]);
|
||||||
const [sitesLoading, setSitesLoading] = useState(true);
|
const [sitesLoading, setSitesLoading] = useState(true);
|
||||||
const [siteFilter, setSiteFilter] = useState<'all' | number>('all');
|
const [siteFilter, setSiteFilter] = useState<'all' | number>('all');
|
||||||
const [aiPeriod, setAIPeriod] = useState<'7d' | '30d' | '90d'>('7d');
|
const [aiPeriod, setAIPeriod] = useState<'today' | '7d' | '30d' | '90d'>('7d');
|
||||||
const [showAddSite, setShowAddSite] = useState(false);
|
const [showAddSite, setShowAddSite] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [subscription, setSubscription] = useState<Subscription | null>(null);
|
const [subscription, setSubscription] = useState<Subscription | null>(null);
|
||||||
@@ -171,7 +171,7 @@ export default function Home() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const siteId = siteFilter === 'all' ? undefined : siteFilter;
|
const siteId = siteFilter === 'all' ? undefined : siteFilter;
|
||||||
const periodDays = aiPeriod === '7d' ? 7 : aiPeriod === '30d' ? 30 : 90;
|
const periodDays = aiPeriod === 'today' ? 1 : aiPeriod === '7d' ? 7 : aiPeriod === '30d' ? 30 : 90;
|
||||||
|
|
||||||
// Fetch real dashboard stats from API
|
// Fetch real dashboard stats from API
|
||||||
const stats = await getDashboardStats({
|
const stats = await getDashboardStats({
|
||||||
|
|||||||
@@ -423,6 +423,7 @@ export default function Sites() {
|
|||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
onSettings={handleSettings}
|
onSettings={handleSettings}
|
||||||
onDetails={handleDetails}
|
onDetails={handleDetails}
|
||||||
|
onDelete={handleDeleteSite}
|
||||||
isToggling={togglingSiteId === site.id}
|
isToggling={togglingSiteId === site.id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -85,9 +85,9 @@ export default function SiteDashboard() {
|
|||||||
});
|
});
|
||||||
const [operations, setOperations] = useState<OperationStat[]>([]);
|
const [operations, setOperations] = useState<OperationStat[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [aiPeriod, setAiPeriod] = useState<'7d' | '30d' | '90d'>('7d');
|
const [aiPeriod, setAiPeriod] = useState<'today' | '7d' | '30d' | '90d'>('7d');
|
||||||
|
|
||||||
const handlePeriodChange = (period: '7d' | '30d' | '90d') => {
|
const handlePeriodChange = (period: 'today' | '7d' | '30d' | '90d') => {
|
||||||
setAiPeriod(period);
|
setAiPeriod(period);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ export default function SiteDashboard() {
|
|||||||
|
|
||||||
// Load operation stats from real API data
|
// Load operation stats from real API data
|
||||||
try {
|
try {
|
||||||
const periodDays = aiPeriod === '7d' ? 7 : aiPeriod === '30d' ? 30 : 90;
|
const periodDays = aiPeriod === 'today' ? 1 : aiPeriod === '7d' ? 7 : aiPeriod === '30d' ? 30 : 90;
|
||||||
const stats = await getDashboardStats({ site_id: Number(currentSiteId), days: periodDays });
|
const stats = await getDashboardStats({ site_id: Number(currentSiteId), days: periodDays });
|
||||||
|
|
||||||
// Map operation types from API to display types
|
// Map operation types from API to display types
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ export interface ProcessingState {
|
|||||||
remaining_count: number;
|
remaining_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CurrentProcessingResponse {
|
||||||
|
state: ProcessingState | null;
|
||||||
|
total_credits_used: number;
|
||||||
|
current_stage: number;
|
||||||
|
}
|
||||||
|
|
||||||
// NEW: Types for unified run_progress endpoint
|
// NEW: Types for unified run_progress endpoint
|
||||||
export interface StageProgress {
|
export interface StageProgress {
|
||||||
number: number;
|
number: number;
|
||||||
@@ -257,11 +263,12 @@ export const automationService = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current processing state for active automation run
|
* Get current processing state for active automation run
|
||||||
|
* Returns state with total_credits_used for real-time credits tracking
|
||||||
*/
|
*/
|
||||||
getCurrentProcessing: async (
|
getCurrentProcessing: async (
|
||||||
siteId: number,
|
siteId: number,
|
||||||
runId: string
|
runId: string
|
||||||
): Promise<ProcessingState | null> => {
|
): Promise<CurrentProcessingResponse | null> => {
|
||||||
const response = await fetchAPI(
|
const response = await fetchAPI(
|
||||||
buildUrl('/current_processing/', { site_id: siteId, run_id: runId })
|
buildUrl('/current_processing/', { site_id: siteId, run_id: runId })
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1068,47 +1068,94 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
|||||||
{/* Action Buttons - Conditional based on status */}
|
{/* Action Buttons - Conditional based on status */}
|
||||||
{content.status && (
|
{content.status && (
|
||||||
<div className="px-8 py-6 bg-gray-50 dark:bg-gray-900/30 border-b border-gray-200 dark:border-gray-700">
|
<div className="px-8 py-6 bg-gray-50 dark:bg-gray-900/30 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
{/* Draft status: Show Edit Content + Generate Images */}
|
<div className="flex items-center gap-4">
|
||||||
{content.status.toLowerCase() === 'draft' && (
|
{/* Draft status: Show Edit Content + Generate Images */}
|
||||||
<>
|
{content.status.toLowerCase() === 'draft' && (
|
||||||
<Button
|
<>
|
||||||
variant="primary"
|
<Button
|
||||||
onClick={() => navigate(`/sites/${content.site_id}/posts/${content.id}/edit`)}
|
variant="primary"
|
||||||
startIcon={<PencilIcon className="w-4 h-4" />}
|
onClick={() => navigate(`/sites/${content.site_id}/posts/${content.id}/edit`)}
|
||||||
>
|
startIcon={<PencilIcon className="w-4 h-4" />}
|
||||||
Edit Content
|
>
|
||||||
</Button>
|
Edit Content
|
||||||
<Button
|
</Button>
|
||||||
variant="primary"
|
<Button
|
||||||
tone="brand"
|
variant="primary"
|
||||||
onClick={() => navigate(`/writer/images?contentId=${content.id}`)}
|
tone="brand"
|
||||||
startIcon={<ImageIcon className="w-4 h-4" />}
|
onClick={() => navigate(`/writer/images?contentId=${content.id}`)}
|
||||||
>
|
startIcon={<ImageIcon className="w-4 h-4" />}
|
||||||
Generate Images
|
>
|
||||||
</Button>
|
Generate Images
|
||||||
</>
|
</Button>
|
||||||
)}
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Review status: Show Edit Content + Publish */}
|
||||||
|
{content.status.toLowerCase() === 'review' && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => navigate(`/sites/${content.site_id}/posts/${content.id}/edit`)}
|
||||||
|
startIcon={<PencilIcon className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Edit Content
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
tone="brand"
|
||||||
|
onClick={() => navigate(`/writer/published?contentId=${content.id}&action=publish`)}
|
||||||
|
startIcon={<BoltIcon className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Publish
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Review status: Show Edit Content + Publish */}
|
{/* Publishing Status Display */}
|
||||||
{content.status.toLowerCase() === 'review' && (
|
{content.site_status && (
|
||||||
<>
|
<div className="flex items-center gap-3 px-4 py-2 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
variant="primary"
|
{content.site_status === 'published' && (
|
||||||
onClick={() => navigate(`/sites/${content.site_id}/posts/${content.id}/edit`)}
|
<CheckCircleIcon className="w-5 h-5 text-success-500" />
|
||||||
startIcon={<PencilIcon className="w-4 h-4" />}
|
)}
|
||||||
>
|
{content.site_status === 'scheduled' && (
|
||||||
Edit Content
|
<ClockIcon className="w-5 h-5 text-brand-500" />
|
||||||
</Button>
|
)}
|
||||||
<Button
|
{content.site_status === 'publishing' && (
|
||||||
variant="primary"
|
<ClockIcon className="w-5 h-5 text-warning-500 animate-pulse" />
|
||||||
tone="brand"
|
)}
|
||||||
onClick={() => navigate(`/writer/published?contentId=${content.id}&action=publish`)}
|
{content.site_status === 'failed' && (
|
||||||
startIcon={<BoltIcon className="w-4 h-4" />}
|
<XCircleIcon className="w-5 h-5 text-error-500" />
|
||||||
>
|
)}
|
||||||
Publish
|
{content.site_status === 'not_published' && (
|
||||||
</Button>
|
<FileTextIcon className="w-5 h-5 text-gray-400" />
|
||||||
</>
|
)}
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{content.site_status === 'not_published' && 'Not Published'}
|
||||||
|
{content.site_status === 'scheduled' && 'Scheduled'}
|
||||||
|
{content.site_status === 'publishing' && 'Publishing...'}
|
||||||
|
{content.site_status === 'published' && 'Published'}
|
||||||
|
{content.site_status === 'failed' && 'Failed'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{content.scheduled_publish_at && content.site_status === 'scheduled' && (
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{formatDate(content.scheduled_publish_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{content.external_url && content.site_status === 'published' && (
|
||||||
|
<a
|
||||||
|
href={content.external_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||||
|
>
|
||||||
|
View on site →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user