trash models added, first attempt for remainign issues

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-12 13:39:42 +00:00
parent 28cb698579
commit 7d4d309677
20 changed files with 1084 additions and 106 deletions

View File

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

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

View File

@@ -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'

View File

@@ -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',),
),
]

View File

@@ -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(

View File

@@ -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(

View File

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

View File

@@ -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)}')

View File

@@ -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',),
),
]

View File

@@ -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',
),
]

View File

@@ -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',),
),
]

View File

@@ -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",

View File

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

View File

@@ -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>

View File

@@ -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' },

View File

@@ -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({

View File

@@ -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}
/> />
))} ))}

View File

@@ -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

View File

@@ -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 })
); );

View File

@@ -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>