diff --git a/backend/igny8_core/admin/apps.py b/backend/igny8_core/admin/apps.py index cb546d1c..911e6dcf 100644 --- a/backend/igny8_core/admin/apps.py +++ b/backend/igny8_core/admin/apps.py @@ -67,6 +67,19 @@ class Igny8AdminConfig(AdminConfig): # Import and setup enhanced Celery task monitoring self._setup_celery_admin() + + # Import trash admin to register soft-deleted record views + self._setup_trash_admin() + + def _setup_trash_admin(self): + """Setup Trash admin views for soft-deleted records.""" + try: + # Import registers the proxy models and their admin classes + from igny8_core.admin import trash_admin # noqa: F401 + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.warning(f"Could not setup Trash admin: {e}") def _setup_celery_admin(self): """Setup enhanced Celery admin with proper unregister/register""" diff --git a/backend/igny8_core/admin/trash_admin.py b/backend/igny8_core/admin/trash_admin.py new file mode 100644 index 00000000..0e8f8df8 --- /dev/null +++ b/backend/igny8_core/admin/trash_admin.py @@ -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( + '{} days, {} hours', + days, hours + ) + elif hours > 0: + return format_html( + '{} hours', + hours + ) + else: + minutes = remaining.seconds // 60 + return format_html( + '{} minutes', + minutes + ) + else: + return format_html( + 'Expired' + ) + return '-' + time_until_permanent_delete.short_description = 'Time to Auto-Delete' + + def deleted_at_display(self, obj): + """Format deleted_at timestamp.""" + if obj.deleted_at: + return obj.deleted_at.strftime('%Y-%m-%d %H:%M') + return '-' + deleted_at_display.short_description = 'Deleted At' + deleted_at_display.admin_order_field = 'deleted_at' + + def can_restore_display(self, obj): + """Show if record can be restored.""" + if obj.restore_until: + if obj.restore_until > timezone.now(): + return format_html('✓ Yes') + else: + return format_html('✗ No (Expired)') + return format_html('-') + can_restore_display.short_description = 'Can Restore' + + # ======================================================================== + # ACTIONS + # ======================================================================== + + @admin.action(description='🔄 Restore selected records') + def restore_records(self, request, queryset): + """Restore selected soft-deleted records.""" + now = timezone.now() + restorable = queryset.filter(restore_until__gte=now) + expired = queryset.filter(restore_until__lt=now) + + restored_count = 0 + 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) diff --git a/backend/igny8_core/auth/admin.py b/backend/igny8_core/auth/admin.py index 70b0075a..d66292fb 100644 --- a/backend/igny8_core/auth/admin.py +++ b/backend/igny8_core/auth/admin.py @@ -4,6 +4,7 @@ Admin interface for auth models from django import forms from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.db import models from unfold.admin import ModelAdmin, TabularInline from simple_history.admin import SimpleHistoryAdmin from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin @@ -896,6 +897,27 @@ class SeedKeywordAdmin(ImportExportMixin, Igny8ModelAdmin): """Allow deletion for superusers and developers""" 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): updated = queryset.update(is_active=True) self.message_user(request, f'{updated} seed keyword(s) activated.', messages.SUCCESS) @@ -1075,4 +1097,3 @@ class UserAdmin(ExportMixin, BaseUserAdmin, Igny8ModelAdmin): messages.INFO ) bulk_send_password_reset.short_description = 'Send password reset email' - diff --git a/backend/igny8_core/auth/migrations/0021_accounttrash_sectortrash_sitetrash.py b/backend/igny8_core/auth/migrations/0021_accounttrash_sectortrash_sitetrash.py new file mode 100644 index 00000000..67610161 --- /dev/null +++ b/backend/igny8_core/auth/migrations/0021_accounttrash_sectortrash_sitetrash.py @@ -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',), + ), + ] diff --git a/backend/igny8_core/business/automation/views.py b/backend/igny8_core/business/automation/views.py index 01f816a5..57cf3003 100644 --- a/backend/igny8_core/business/automation/views.py +++ b/backend/igny8_core/business/automation/views.py @@ -590,15 +590,25 @@ class AutomationViewSet(viewsets.ViewSet): # Get the run run = AutomationRun.objects.get(run_id=run_id, site=site) - # If not running, return None - if run.status != 'running': + # If not running or paused, return minimal state with updated credits + if run.status not in ('running', 'paused'): return Response({'data': None}) # Get current processing state service = AutomationService.from_run_id(run_id) 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: return Response( diff --git a/backend/igny8_core/business/billing/services/credit_service.py b/backend/igny8_core/business/billing/services/credit_service.py index b6878604..8f5b7c6d 100644 --- a/backend/igny8_core/business/billing/services/credit_service.py +++ b/backend/igny8_core/business/billing/services/credit_service.py @@ -508,6 +508,53 @@ class CreditService: 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 @transaction.atomic def deduct_credits_for_image( diff --git a/backend/igny8_core/business/billing/views/stripe_views.py b/backend/igny8_core/business/billing/views/stripe_views.py index cc06a847..1c66dd63 100644 --- a/backend/igny8_core/business/billing/views/stripe_views.py +++ b/backend/igny8_core/business/billing/views/stripe_views.py @@ -720,12 +720,11 @@ def _handle_invoice_paid(invoice: dict): logger.info(f"Skipping initial invoice for subscription {subscription_id}") 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: - CreditService.add_credits( + CreditService.reset_credits_for_renewal( account=account, - amount=plan.included_credits, - transaction_type='subscription', + new_amount=plan.included_credits, description=f'Monthly renewal: {plan.name}', metadata={ 'plan_id': str(plan.id), @@ -734,7 +733,7 @@ def _handle_invoice_paid(invoice: dict): ) 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}" ) diff --git a/backend/igny8_core/modules/billing/admin.py b/backend/igny8_core/modules/billing/admin.py index 566c0b1d..8d65402f 100644 --- a/backend/igny8_core/modules/billing/admin.py +++ b/backend/igny8_core/modules/billing/admin.py @@ -284,8 +284,8 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode account.status = 'active' account.save() - # Add Credits (check if not already added) - from igny8_core.business.billing.models import CreditTransaction + # Add or Reset Credits (check if not already added) + from igny8_core.business.billing.models import CreditTransaction, Invoice existing_credit = CreditTransaction.objects.filter( account=account, metadata__payment_id=obj.id @@ -294,32 +294,65 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode if not existing_credit: credits_to_add = 0 plan_name = '' + is_renewal = False if subscription and subscription.plan: credits_to_add = subscription.plan.included_credits 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: credits_to_add = account.plan.included_credits 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: - 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' - ) + if is_renewal: + # Renewal: Reset credits to full plan amount + CreditService.reset_credits_for_renewal( + account=account, + new_amount=credits_to_add, + description=f'{plan_name} Renewal - 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, + 'is_renewal': True + } + ) + self.message_user( + 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: self.message_user( @@ -377,37 +410,83 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode account.status = 'active' 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 if subscription and subscription.plan and subscription.plan.included_credits > 0: credits_added = subscription.plan.included_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 - } - ) + if is_renewal: + # Renewal: Reset credits to full plan amount + CreditService.reset_credits_for_renewal( + account=account, + new_amount=credits_added, + description=f'{subscription.plan.name} Renewal - Invoice {invoice.invoice_number}', + metadata={ + 'subscription_id': subscription.id, + 'invoice_id': invoice.id, + '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: credits_added = account.plan.included_credits - CreditService.add_credits( + # Check renewal by looking at account credit transactions + previous_subscriptions = CreditTransaction.objects.filter( 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 - } - ) + transaction_type='subscription' + ).exists() + if previous_subscriptions: + # Renewal: Reset credits + CreditService.reset_credits_for_renewal( + account=account, + 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: errors.append(f'Payment #{payment.id}: {str(e)}') diff --git a/backend/igny8_core/modules/planner/migrations/0009_clusterstrash_contentideastrash_keywordstrash.py b/backend/igny8_core/modules/planner/migrations/0009_clusterstrash_contentideastrash_keywordstrash.py new file mode 100644 index 00000000..e94622bf --- /dev/null +++ b/backend/igny8_core/modules/planner/migrations/0009_clusterstrash_contentideastrash_keywordstrash.py @@ -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',), + ), + ] diff --git a/backend/igny8_core/modules/system/migrations/0025_delete_accountintegrationoverride.py b/backend/igny8_core/modules/system/migrations/0025_delete_accountintegrationoverride.py new file mode 100644 index 00000000..ba61d1a3 --- /dev/null +++ b/backend/igny8_core/modules/system/migrations/0025_delete_accountintegrationoverride.py @@ -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', + ), + ] diff --git a/backend/igny8_core/modules/writer/migrations/0017_contenttrash_imagestrash_taskstrash.py b/backend/igny8_core/modules/writer/migrations/0017_contenttrash_imagestrash_taskstrash.py new file mode 100644 index 00000000..f651d266 --- /dev/null +++ b/backend/igny8_core/modules/writer/migrations/0017_contenttrash_imagestrash_taskstrash.py @@ -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',), + ), + ] diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py index e774cab0..3d51aea8 100644 --- a/backend/igny8_core/settings.py +++ b/backend/igny8_core/settings.py @@ -838,6 +838,23 @@ UNFOLD = { {"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 { "title": "Logs & Monitoring", diff --git a/frontend/src/components/Automation/CurrentProcessingCardV2.tsx b/frontend/src/components/Automation/CurrentProcessingCardV2.tsx index d6614eed..423efab0 100644 --- a/frontend/src/components/Automation/CurrentProcessingCardV2.tsx +++ b/frontend/src/components/Automation/CurrentProcessingCardV2.tsx @@ -4,7 +4,7 @@ * Clean UI without cluttered "Currently Processing" and "Up Next" sections */ 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 Button from '../ui/button/Button'; import IconButton from '../ui/button/IconButton'; @@ -98,6 +98,7 @@ const CurrentProcessingCard: React.FC = ({ pipelineOverview, }) => { const [processingState, setProcessingState] = useState(null); + const [totalCreditsUsed, setTotalCreditsUsed] = useState(currentRun.total_credits_used); const [isPausing, setIsPausing] = useState(false); const [isResuming, setIsResuming] = useState(false); const [isCancelling, setIsCancelling] = useState(false); @@ -110,12 +111,21 @@ const CurrentProcessingCard: React.FC = ({ const fetchState = async () => { try { - const state = await automationService.getCurrentProcessing(siteId, runId); + const response = await automationService.getCurrentProcessing(siteId, runId); 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 (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(); } } catch (err) { @@ -323,7 +333,7 @@ const CurrentProcessingCard: React.FC = ({ Credits - {currentRun.total_credits_used} + {totalCreditsUsed}
diff --git a/frontend/src/components/common/SiteCard.tsx b/frontend/src/components/common/SiteCard.tsx index 2c0f8071..3c6311d9 100644 --- a/frontend/src/components/common/SiteCard.tsx +++ b/frontend/src/components/common/SiteCard.tsx @@ -5,7 +5,7 @@ import Badge from '../ui/badge/Badge'; import SiteSetupChecklist from '../sites/SiteSetupChecklist'; import SiteTypeBadge from '../sites/SiteTypeBadge'; import { Site } from '../../services/api'; -import { BoxCubeIcon as SettingsIcon, EyeIcon, FileIcon } from '../../icons'; +import { BoxCubeIcon as SettingsIcon, EyeIcon, FileIcon, TrashBinIcon } from '../../icons'; interface SiteCardProps { site: Site; @@ -13,6 +13,7 @@ interface SiteCardProps { onToggle: (siteId: number, enabled: boolean) => void; onSettings: (site: Site) => void; onDetails: (site: Site) => void; + onDelete?: (site: Site) => void; isToggling?: boolean; } @@ -22,6 +23,7 @@ export default function SiteCard({ onToggle, onSettings, onDetails, + onDelete, isToggling = false, }: SiteCardProps) { const handleToggle = (enabled: boolean) => { @@ -126,6 +128,16 @@ export default function SiteCard({ > Settings + {onDelete && ( +
diff --git a/frontend/src/components/dashboard/AIOperationsWidget.tsx b/frontend/src/components/dashboard/AIOperationsWidget.tsx index 4ce16b20..a4a06cd6 100644 --- a/frontend/src/components/dashboard/AIOperationsWidget.tsx +++ b/frontend/src/components/dashboard/AIOperationsWidget.tsx @@ -20,7 +20,7 @@ export interface AIOperation { } export interface AIOperationsData { - period: '7d' | '30d' | '90d'; + period: 'today' | '7d' | '30d' | '90d'; operations: AIOperation[]; totals: { count: number; @@ -32,7 +32,7 @@ export interface AIOperationsData { interface AIOperationsWidgetProps { data: AIOperationsData; - onPeriodChange?: (period: '7d' | '30d' | '90d') => void; + onPeriodChange?: (period: 'today' | '7d' | '30d' | '90d') => void; loading?: boolean; } @@ -54,6 +54,7 @@ const operationConfig: Record([]); const [sitesLoading, setSitesLoading] = useState(true); 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 [loading, setLoading] = useState(true); const [subscription, setSubscription] = useState(null); @@ -171,7 +171,7 @@ export default function Home() { setLoading(true); 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 const stats = await getDashboardStats({ diff --git a/frontend/src/pages/Settings/Sites.tsx b/frontend/src/pages/Settings/Sites.tsx index 59c54181..286a0bef 100644 --- a/frontend/src/pages/Settings/Sites.tsx +++ b/frontend/src/pages/Settings/Sites.tsx @@ -423,6 +423,7 @@ export default function Sites() { onToggle={handleToggle} onSettings={handleSettings} onDetails={handleDetails} + onDelete={handleDeleteSite} isToggling={togglingSiteId === site.id} /> ))} diff --git a/frontend/src/pages/Sites/Dashboard.tsx b/frontend/src/pages/Sites/Dashboard.tsx index c1bea801..d0afd4f6 100644 --- a/frontend/src/pages/Sites/Dashboard.tsx +++ b/frontend/src/pages/Sites/Dashboard.tsx @@ -85,9 +85,9 @@ export default function SiteDashboard() { }); const [operations, setOperations] = useState([]); 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); }; @@ -180,7 +180,7 @@ export default function SiteDashboard() { // Load operation stats from real API data 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 }); // Map operation types from API to display types diff --git a/frontend/src/services/automationService.ts b/frontend/src/services/automationService.ts index cd23c246..508bc655 100644 --- a/frontend/src/services/automationService.ts +++ b/frontend/src/services/automationService.ts @@ -78,6 +78,12 @@ export interface ProcessingState { remaining_count: number; } +export interface CurrentProcessingResponse { + state: ProcessingState | null; + total_credits_used: number; + current_stage: number; +} + // NEW: Types for unified run_progress endpoint export interface StageProgress { number: number; @@ -257,11 +263,12 @@ export const automationService = { /** * Get current processing state for active automation run + * Returns state with total_credits_used for real-time credits tracking */ getCurrentProcessing: async ( siteId: number, runId: string - ): Promise => { + ): Promise => { const response = await fetchAPI( buildUrl('/current_processing/', { site_id: siteId, run_id: runId }) ); diff --git a/frontend/src/templates/ContentViewTemplate.tsx b/frontend/src/templates/ContentViewTemplate.tsx index 01d12e1b..c576f8d3 100644 --- a/frontend/src/templates/ContentViewTemplate.tsx +++ b/frontend/src/templates/ContentViewTemplate.tsx @@ -1068,47 +1068,94 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten {/* Action Buttons - Conditional based on status */} {content.status && (
-
- {/* Draft status: Show Edit Content + Generate Images */} - {content.status.toLowerCase() === 'draft' && ( - <> - - - - )} +
+
+ {/* Draft status: Show Edit Content + Generate Images */} + {content.status.toLowerCase() === 'draft' && ( + <> + + + + )} + + {/* Review status: Show Edit Content + Publish */} + {content.status.toLowerCase() === 'review' && ( + <> + + + + )} +
- {/* Review status: Show Edit Content + Publish */} - {content.status.toLowerCase() === 'review' && ( - <> - - - + {/* Publishing Status Display */} + {content.site_status && ( +
+
+ {content.site_status === 'published' && ( + + )} + {content.site_status === 'scheduled' && ( + + )} + {content.site_status === 'publishing' && ( + + )} + {content.site_status === 'failed' && ( + + )} + {content.site_status === 'not_published' && ( + + )} + + {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'} + +
+ {content.scheduled_publish_at && content.site_status === 'scheduled' && ( + + {formatDate(content.scheduled_publish_at)} + + )} + {content.external_url && content.site_status === 'published' && ( + + View on site → + + )} +
)}