diff --git a/backend/igny8_core/api/base.py b/backend/igny8_core/api/base.py
index a4d2b0ed..223cb99a 100644
--- a/backend/igny8_core/api/base.py
+++ b/backend/igny8_core/api/base.py
@@ -181,7 +181,26 @@ class AccountModelViewSet(viewsets.ModelViewSet):
"""
try:
instance = self.get_object()
- self.perform_destroy(instance)
+ # Protect system account
+ if hasattr(instance, 'slug') and getattr(instance, 'slug', '') == 'aws-admin':
+ from django.core.exceptions import PermissionDenied
+ raise PermissionDenied("System account cannot be deleted.")
+
+ if hasattr(instance, 'soft_delete'):
+ user = getattr(request, 'user', None)
+ retention_days = None
+ account = getattr(instance, 'account', None)
+ if account and hasattr(account, 'deletion_retention_days'):
+ retention_days = account.deletion_retention_days
+ elif hasattr(instance, 'deletion_retention_days'):
+ retention_days = getattr(instance, 'deletion_retention_days', None)
+ instance.soft_delete(
+ user=user if getattr(user, 'is_authenticated', False) else None,
+ retention_days=retention_days,
+ reason='api_delete'
+ )
+ else:
+ self.perform_destroy(instance)
return success_response(
data=None,
message='Deleted successfully',
diff --git a/backend/igny8_core/auth/admin.py b/backend/igny8_core/auth/admin.py
index 58c4d5b1..af873f2a 100644
--- a/backend/igny8_core/auth/admin.py
+++ b/backend/igny8_core/auth/admin.py
@@ -56,6 +56,11 @@ class AccountAdmin(AccountAdminMixin, admin.ModelAdmin):
pass
return qs.none()
+ def has_delete_permission(self, request, obj=None):
+ if obj and getattr(obj, 'slug', '') == 'aws-admin':
+ return False
+ return super().has_delete_permission(request, obj)
+
@admin.register(Subscription)
class SubscriptionAdmin(AccountAdminMixin, admin.ModelAdmin):
diff --git a/backend/igny8_core/auth/management/commands/purge_soft_deleted.py b/backend/igny8_core/auth/management/commands/purge_soft_deleted.py
new file mode 100644
index 00000000..b7fea9be
--- /dev/null
+++ b/backend/igny8_core/auth/management/commands/purge_soft_deleted.py
@@ -0,0 +1,42 @@
+from django.core.management.base import BaseCommand
+from django.utils import timezone
+
+from igny8_core.auth.models import Account, Site, Sector
+from igny8_core.business.planning.models import Clusters, Keywords, ContentIdeas
+from igny8_core.business.content.models import Tasks, Content, Images
+
+
+class Command(BaseCommand):
+ help = "Permanently delete soft-deleted records whose retention window has expired."
+
+ def handle(self, *args, **options):
+ now = timezone.now()
+ total_deleted = 0
+
+ models = [
+ Account,
+ Site,
+ Sector,
+ Clusters,
+ Keywords,
+ ContentIdeas,
+ Tasks,
+ Content,
+ Images,
+ ]
+
+ for model in models:
+ qs = model.all_objects.filter(is_deleted=True, restore_until__lt=now)
+ if model is Account:
+ qs = qs.exclude(slug='aws-admin')
+ count = qs.count()
+ if count:
+ qs.delete()
+ total_deleted += count
+ self.stdout.write(self.style.SUCCESS(f"Purged {count} {model.__name__} record(s)."))
+
+ if total_deleted == 0:
+ self.stdout.write("No expired soft-deleted records to purge.")
+ else:
+ self.stdout.write(self.style.SUCCESS(f"Total purged: {total_deleted}"))
+
diff --git a/backend/igny8_core/auth/migrations/0006_soft_delete_and_retention.py b/backend/igny8_core/auth/migrations/0006_soft_delete_and_retention.py
new file mode 100644
index 00000000..7ce1bbdf
--- /dev/null
+++ b/backend/igny8_core/auth/migrations/0006_soft_delete_and_retention.py
@@ -0,0 +1,93 @@
+from django.db import migrations, models
+import django.db.models.deletion
+from django.core.validators import MinValueValidator, MaxValueValidator
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('igny8_core_auth', '0005_account_owner_nullable'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='account',
+ name='delete_reason',
+ field=models.CharField(blank=True, max_length=255, null=True),
+ ),
+ migrations.AddField(
+ model_name='account',
+ name='deleted_at',
+ field=models.DateTimeField(blank=True, db_index=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='account',
+ name='deleted_by',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
+ ),
+ migrations.AddField(
+ model_name='account',
+ name='deletion_retention_days',
+ field=models.PositiveIntegerField(default=14, help_text='Retention window (days) before soft-deleted items are purged', validators=[MinValueValidator(1), MaxValueValidator(365)]),
+ ),
+ migrations.AddField(
+ model_name='account',
+ name='is_deleted',
+ field=models.BooleanField(db_index=True, default=False),
+ ),
+ migrations.AddField(
+ model_name='account',
+ name='restore_until',
+ field=models.DateTimeField(blank=True, db_index=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='sector',
+ name='delete_reason',
+ field=models.CharField(blank=True, max_length=255, null=True),
+ ),
+ migrations.AddField(
+ model_name='sector',
+ name='deleted_at',
+ field=models.DateTimeField(blank=True, db_index=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='sector',
+ name='deleted_by',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
+ ),
+ migrations.AddField(
+ model_name='sector',
+ name='is_deleted',
+ field=models.BooleanField(db_index=True, default=False),
+ ),
+ migrations.AddField(
+ model_name='sector',
+ name='restore_until',
+ field=models.DateTimeField(blank=True, db_index=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='site',
+ name='delete_reason',
+ field=models.CharField(blank=True, max_length=255, null=True),
+ ),
+ migrations.AddField(
+ model_name='site',
+ name='deleted_at',
+ field=models.DateTimeField(blank=True, db_index=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='site',
+ name='deleted_by',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
+ ),
+ migrations.AddField(
+ model_name='site',
+ name='is_deleted',
+ field=models.BooleanField(db_index=True, default=False),
+ ),
+ migrations.AddField(
+ model_name='site',
+ name='restore_until',
+ field=models.DateTimeField(blank=True, db_index=True, null=True),
+ ),
+ ]
+
diff --git a/backend/igny8_core/auth/models.py b/backend/igny8_core/auth/models.py
index af7be4e0..50cc3274 100644
--- a/backend/igny8_core/auth/models.py
+++ b/backend/igny8_core/auth/models.py
@@ -5,6 +5,7 @@ from django.db import models
from django.contrib.auth.models import AbstractUser
from django.utils.translation import gettext_lazy as _
from django.core.validators import MinValueValidator, MaxValueValidator
+from igny8_core.common.soft_delete import SoftDeletableModel, SoftDeleteManager
class AccountBaseModel(models.Model):
@@ -52,7 +53,7 @@ class SiteSectorBaseModel(AccountBaseModel):
super().save(*args, **kwargs)
-class Account(models.Model):
+class Account(SoftDeletableModel):
"""
Account/Organization model for multi-account support.
"""
@@ -76,6 +77,11 @@ class Account(models.Model):
plan = models.ForeignKey('igny8_core_auth.Plan', on_delete=models.PROTECT, related_name='accounts')
credits = models.IntegerField(default=0, validators=[MinValueValidator(0)])
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='trial')
+ deletion_retention_days = models.PositiveIntegerField(
+ default=14,
+ validators=[MinValueValidator(1), MaxValueValidator(365)],
+ help_text="Retention window (days) before soft-deleted items are purged",
+ )
# Billing information
billing_email = models.EmailField(blank=True, null=True, help_text="Email for billing notifications")
@@ -99,6 +105,9 @@ class Account(models.Model):
models.Index(fields=['status']),
]
+ objects = SoftDeleteManager()
+ all_objects = models.Manager()
+
def __str__(self):
return self.name
@@ -107,6 +116,15 @@ class Account(models.Model):
# System accounts bypass all filtering restrictions
return self.slug in ['aws-admin', 'default-account', 'default']
+ def soft_delete(self, user=None, reason=None, retention_days=None):
+ if self.is_system_account():
+ from django.core.exceptions import PermissionDenied
+ raise PermissionDenied("System account cannot be deleted.")
+ return super().soft_delete(user=user, reason=reason, retention_days=retention_days)
+
+ def delete(self, using=None, keep_parents=False):
+ return self.soft_delete()
+
class Plan(models.Model):
"""
@@ -202,7 +220,7 @@ class Subscription(models.Model):
-class Site(AccountBaseModel):
+class Site(SoftDeletableModel, AccountBaseModel):
"""
Site model - Each account can have multiple sites based on their plan.
Each site belongs to ONE industry and can have 1-5 sectors from that industry.
@@ -274,6 +292,9 @@ class Site(AccountBaseModel):
blank=True,
help_text="SEO metadata: meta tags, Open Graph, Schema.org"
)
+
+ objects = SoftDeleteManager()
+ all_objects = models.Manager()
class Meta:
db_table = 'igny8_sites'
@@ -409,7 +430,7 @@ class SeedKeyword(models.Model):
return f"{self.keyword} ({self.industry.name} - {self.sector.name})"
-class Sector(AccountBaseModel):
+class Sector(SoftDeletableModel, AccountBaseModel):
"""
Sector model - Each site can have 1-5 sectors.
Sectors are site-specific instances that reference an IndustrySector template.
@@ -436,6 +457,9 @@ class Sector(AccountBaseModel):
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
+
+ objects = SoftDeleteManager()
+ all_objects = models.Manager()
class Meta:
db_table = 'igny8_sectors'
diff --git a/backend/igny8_core/business/content/models.py b/backend/igny8_core/business/content/models.py
index 73aae514..88be0379 100644
--- a/backend/igny8_core/business/content/models.py
+++ b/backend/igny8_core/business/content/models.py
@@ -1,9 +1,10 @@
from django.db import models
from django.core.validators import MinValueValidator
from igny8_core.auth.models import SiteSectorBaseModel
+from igny8_core.common.soft_delete import SoftDeletableModel, SoftDeleteManager
-class Tasks(SiteSectorBaseModel):
+class Tasks(SoftDeletableModel, SiteSectorBaseModel):
"""Tasks model for content generation queue"""
STATUS_CHOICES = [
@@ -116,6 +117,9 @@ class Tasks(SiteSectorBaseModel):
models.Index(fields=['site', 'sector']),
]
+ objects = SoftDeleteManager()
+ all_objects = models.Manager()
+
def __str__(self):
return self.title
@@ -133,7 +137,7 @@ class ContentTaxonomyRelation(models.Model):
unique_together = [['content', 'taxonomy']]
-class Content(SiteSectorBaseModel):
+class Content(SoftDeletableModel, SiteSectorBaseModel):
"""
Content model for AI-generated or WordPress-imported content.
Final architecture: simplified content management.
@@ -267,6 +271,9 @@ class Content(SiteSectorBaseModel):
models.Index(fields=['site', 'sector']),
]
+ objects = SoftDeleteManager()
+ all_objects = models.Manager()
+
def __str__(self):
return self.title or f"Content {self.id}"
@@ -350,7 +357,7 @@ class ContentTaxonomy(SiteSectorBaseModel):
return f"{self.name} ({self.get_taxonomy_type_display()})"
-class Images(SiteSectorBaseModel):
+class Images(SoftDeletableModel, SiteSectorBaseModel):
"""Images model for content-related images (featured, desktop, mobile, in-article)"""
IMAGE_TYPE_CHOICES = [
@@ -399,6 +406,9 @@ class Images(SiteSectorBaseModel):
models.Index(fields=['task', 'position']),
]
+ objects = SoftDeleteManager()
+ all_objects = models.Manager()
+
def save(self, *args, **kwargs):
"""Automatically set account, site, and sector from content or task"""
# Prefer content over task
diff --git a/backend/igny8_core/business/planning/models.py b/backend/igny8_core/business/planning/models.py
index 1ba4653f..63eb6bf8 100644
--- a/backend/igny8_core/business/planning/models.py
+++ b/backend/igny8_core/business/planning/models.py
@@ -1,8 +1,9 @@
from django.db import models
from igny8_core.auth.models import SiteSectorBaseModel, SeedKeyword
+from igny8_core.common.soft_delete import SoftDeletableModel, SoftDeleteManager
-class Clusters(SiteSectorBaseModel):
+class Clusters(SoftDeletableModel, SiteSectorBaseModel):
"""Clusters model for keyword grouping - pure topic clusters"""
STATUS_CHOICES = [
@@ -33,11 +34,14 @@ class Clusters(SiteSectorBaseModel):
models.Index(fields=['site', 'sector']),
]
+ objects = SoftDeleteManager()
+ all_objects = models.Manager()
+
def __str__(self):
return self.name
-class Keywords(SiteSectorBaseModel):
+class Keywords(SoftDeletableModel, SiteSectorBaseModel):
"""
Keywords model for SEO keyword management.
Site-specific instances that reference global SeedKeywords.
@@ -101,6 +105,9 @@ class Keywords(SiteSectorBaseModel):
models.Index(fields=['seed_keyword', 'site', 'sector']),
]
+ objects = SoftDeleteManager()
+ all_objects = models.Manager()
+
@property
def keyword(self):
"""Get keyword text from seed_keyword"""
@@ -144,7 +151,7 @@ class Keywords(SiteSectorBaseModel):
return self.keyword
-class ContentIdeas(SiteSectorBaseModel):
+class ContentIdeas(SoftDeletableModel, SiteSectorBaseModel):
"""Content Ideas model for planning content based on keyword clusters"""
STATUS_CHOICES = [
@@ -232,6 +239,9 @@ class ContentIdeas(SiteSectorBaseModel):
models.Index(fields=['site', 'sector']),
]
+ objects = SoftDeleteManager()
+ all_objects = models.Manager()
+
def __str__(self):
return self.idea_title
diff --git a/backend/igny8_core/celery.py b/backend/igny8_core/celery.py
index fcebb033..760dc8b1 100644
--- a/backend/igny8_core/celery.py
+++ b/backend/igny8_core/celery.py
@@ -43,6 +43,11 @@ app.conf.beat_schedule = {
'task': 'automation.check_scheduled_automations',
'schedule': crontab(minute=0), # Every hour at :00
},
+ # Maintenance: purge expired soft-deleted records daily at 3:15 AM
+ 'purge-soft-deleted-records': {
+ 'task': 'igny8_core.purge_soft_deleted',
+ 'schedule': crontab(hour=3, minute=15),
+ },
}
@app.task(bind=True, ignore_result=True)
diff --git a/backend/igny8_core/common/soft_delete.py b/backend/igny8_core/common/soft_delete.py
new file mode 100644
index 00000000..9afa900e
--- /dev/null
+++ b/backend/igny8_core/common/soft_delete.py
@@ -0,0 +1,95 @@
+from django.db import models
+from django.utils import timezone
+from django.conf import settings
+
+
+class SoftDeleteQuerySet(models.QuerySet):
+ """QuerySet that filters out soft-deleted rows by default."""
+
+ def delete(self):
+ # Prevent accidental hard deletes through queryset.delete()
+ for obj in self:
+ obj.delete()
+
+ def hard_delete(self):
+ return super().delete()
+
+ def with_deleted(self):
+ """Return all rows, including soft-deleted."""
+ return super().all()
+
+
+class SoftDeleteManager(models.Manager):
+ """Manager that hides soft-deleted rows by default."""
+
+ def get_queryset(self):
+ return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=False)
+
+ def with_deleted(self):
+ return SoftDeleteQuerySet(self.model, using=self._db)
+
+
+class SoftDeletableModel(models.Model):
+ """
+ Abstract mixin for soft-deletion with retention window.
+ Objects are hidden by default via SoftDeleteManager.
+ """
+
+ is_deleted = models.BooleanField(default=False, db_index=True)
+ deleted_at = models.DateTimeField(null=True, blank=True, db_index=True)
+ restore_until = models.DateTimeField(null=True, blank=True, db_index=True)
+ delete_reason = models.CharField(max_length=255, null=True, blank=True)
+ deleted_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='+',
+ )
+
+ objects = SoftDeleteManager()
+ all_objects = models.Manager()
+
+ DEFAULT_RETENTION_DAYS = 14
+
+ class Meta:
+ abstract = True
+
+ def soft_delete(self, user=None, reason=None, retention_days=None):
+ """Mark the instance as deleted with a retention window."""
+ if self.is_deleted:
+ return
+ now = timezone.now()
+ if retention_days is None:
+ retention_days = getattr(
+ getattr(self, 'account', None),
+ 'deletion_retention_days',
+ self.DEFAULT_RETENTION_DAYS,
+ )
+ self.is_deleted = True
+ self.deleted_at = now
+ self.restore_until = now + timezone.timedelta(days=retention_days)
+ self.deleted_by = user
+ if reason:
+ self.delete_reason = reason
+ self.save(update_fields=['is_deleted', 'deleted_at', 'restore_until', 'deleted_by', 'delete_reason'])
+
+ def restore(self):
+ """Restore a soft-deleted instance if within the retention window."""
+ if not self.is_deleted:
+ return
+ self.is_deleted = False
+ self.deleted_at = None
+ self.restore_until = None
+ self.delete_reason = None
+ self.deleted_by = None
+ self.save(update_fields=['is_deleted', 'deleted_at', 'restore_until', 'delete_reason', 'deleted_by'])
+
+ def delete(self, using=None, keep_parents=False):
+ """Override delete to perform a soft delete."""
+ self.soft_delete()
+
+ def hard_delete(self, using=None, keep_parents=False):
+ """Irrevocably delete the row."""
+ return super().delete(using=using, keep_parents=keep_parents)
+
diff --git a/backend/igny8_core/management/__init__.py b/backend/igny8_core/management/__init__.py
new file mode 100644
index 00000000..f4a855bc
--- /dev/null
+++ b/backend/igny8_core/management/__init__.py
@@ -0,0 +1,2 @@
+# Package marker for Django management commands
+
diff --git a/backend/igny8_core/management/commands/__init__.py b/backend/igny8_core/management/commands/__init__.py
new file mode 100644
index 00000000..f4a855bc
--- /dev/null
+++ b/backend/igny8_core/management/commands/__init__.py
@@ -0,0 +1,2 @@
+# Package marker for Django management commands
+
diff --git a/backend/igny8_core/management/commands/purge_soft_deleted.py b/backend/igny8_core/management/commands/purge_soft_deleted.py
new file mode 100644
index 00000000..b7fea9be
--- /dev/null
+++ b/backend/igny8_core/management/commands/purge_soft_deleted.py
@@ -0,0 +1,42 @@
+from django.core.management.base import BaseCommand
+from django.utils import timezone
+
+from igny8_core.auth.models import Account, Site, Sector
+from igny8_core.business.planning.models import Clusters, Keywords, ContentIdeas
+from igny8_core.business.content.models import Tasks, Content, Images
+
+
+class Command(BaseCommand):
+ help = "Permanently delete soft-deleted records whose retention window has expired."
+
+ def handle(self, *args, **options):
+ now = timezone.now()
+ total_deleted = 0
+
+ models = [
+ Account,
+ Site,
+ Sector,
+ Clusters,
+ Keywords,
+ ContentIdeas,
+ Tasks,
+ Content,
+ Images,
+ ]
+
+ for model in models:
+ qs = model.all_objects.filter(is_deleted=True, restore_until__lt=now)
+ if model is Account:
+ qs = qs.exclude(slug='aws-admin')
+ count = qs.count()
+ if count:
+ qs.delete()
+ total_deleted += count
+ self.stdout.write(self.style.SUCCESS(f"Purged {count} {model.__name__} record(s)."))
+
+ if total_deleted == 0:
+ self.stdout.write("No expired soft-deleted records to purge.")
+ else:
+ self.stdout.write(self.style.SUCCESS(f"Total purged: {total_deleted}"))
+
diff --git a/backend/igny8_core/modules/billing/admin.py b/backend/igny8_core/modules/billing/admin.py
index 44e75d68..110edd8a 100644
--- a/backend/igny8_core/modules/billing/admin.py
+++ b/backend/igny8_core/modules/billing/admin.py
@@ -2,7 +2,9 @@
Billing Module Admin
"""
from django.contrib import admin
+from django.utils.html import format_html
from igny8_core.admin.base import AccountAdminMixin
+from igny8_core.business.billing.models import CreditCostConfig
from .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod
@@ -70,3 +72,78 @@ class AccountPaymentMethodAdmin(AccountAdminMixin, admin.ModelAdmin):
}),
)
+
+@admin.register(CreditCostConfig)
+class CreditCostConfigAdmin(admin.ModelAdmin):
+ list_display = [
+ 'operation_type',
+ 'display_name',
+ 'credits_cost_display',
+ 'unit',
+ 'is_active',
+ 'cost_change_indicator',
+ 'updated_at',
+ 'updated_by'
+ ]
+
+ list_filter = ['is_active', 'unit', 'updated_at']
+ search_fields = ['operation_type', 'display_name', 'description']
+
+ fieldsets = (
+ ('Operation', {
+ 'fields': ('operation_type', 'display_name', 'description')
+ }),
+ ('Cost Configuration', {
+ 'fields': ('credits_cost', 'unit', 'is_active')
+ }),
+ ('Audit Trail', {
+ 'fields': ('previous_cost', 'updated_by', 'created_at', 'updated_at'),
+ 'classes': ('collapse',)
+ }),
+ )
+
+ readonly_fields = ['created_at', 'updated_at', 'previous_cost']
+
+ def credits_cost_display(self, obj):
+ """Show cost with color coding"""
+ if obj.credits_cost >= 20:
+ color = 'red'
+ elif obj.credits_cost >= 10:
+ color = 'orange'
+ else:
+ color = 'green'
+ return format_html(
+ '{} credits',
+ color,
+ obj.credits_cost
+ )
+ credits_cost_display.short_description = 'Cost'
+
+ def cost_change_indicator(self, obj):
+ """Show if cost changed recently"""
+ if obj.previous_cost is not None:
+ if obj.credits_cost > obj.previous_cost:
+ icon = '📈' # Increased
+ color = 'red'
+ elif obj.credits_cost < obj.previous_cost:
+ icon = '📉' # Decreased
+ color = 'green'
+ else:
+ icon = '➡️' # Same
+ color = 'gray'
+
+ return format_html(
+ '{} ({} → {})',
+ icon,
+ color,
+ obj.previous_cost,
+ obj.credits_cost
+ )
+ return '—'
+ cost_change_indicator.short_description = 'Recent Change'
+
+ def save_model(self, request, obj, form, change):
+ """Track who made the change"""
+ obj.updated_by = request.user
+ super().save_model(request, obj, form, change)
+
diff --git a/backend/igny8_core/modules/billing/apps.py b/backend/igny8_core/modules/billing/apps.py
index c4f86195..e8a80d2e 100644
--- a/backend/igny8_core/modules/billing/apps.py
+++ b/backend/igny8_core/modules/billing/apps.py
@@ -4,6 +4,6 @@ from django.apps import AppConfig
class BillingConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'igny8_core.modules.billing'
- verbose_name = 'Billing'
+ verbose_name = 'Billing & Tenancy'
diff --git a/backend/igny8_core/modules/planner/migrations/0008_soft_delete.py b/backend/igny8_core/modules/planner/migrations/0008_soft_delete.py
new file mode 100644
index 00000000..40264052
--- /dev/null
+++ b/backend/igny8_core/modules/planner/migrations/0008_soft_delete.py
@@ -0,0 +1,88 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('planner', '0007_fix_cluster_unique_constraint'),
+ ('igny8_core_auth', '0006_soft_delete_and_retention'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='clusters',
+ name='delete_reason',
+ field=models.CharField(blank=True, max_length=255, null=True),
+ ),
+ migrations.AddField(
+ model_name='clusters',
+ name='deleted_at',
+ field=models.DateTimeField(blank=True, db_index=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='clusters',
+ name='deleted_by',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
+ ),
+ migrations.AddField(
+ model_name='clusters',
+ name='is_deleted',
+ field=models.BooleanField(db_index=True, default=False),
+ ),
+ migrations.AddField(
+ model_name='clusters',
+ name='restore_until',
+ field=models.DateTimeField(blank=True, db_index=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='contentideas',
+ name='delete_reason',
+ field=models.CharField(blank=True, max_length=255, null=True),
+ ),
+ migrations.AddField(
+ model_name='contentideas',
+ name='deleted_at',
+ field=models.DateTimeField(blank=True, db_index=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='contentideas',
+ name='deleted_by',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
+ ),
+ migrations.AddField(
+ model_name='contentideas',
+ name='is_deleted',
+ field=models.BooleanField(db_index=True, default=False),
+ ),
+ migrations.AddField(
+ model_name='contentideas',
+ name='restore_until',
+ field=models.DateTimeField(blank=True, db_index=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='keywords',
+ name='delete_reason',
+ field=models.CharField(blank=True, max_length=255, null=True),
+ ),
+ migrations.AddField(
+ model_name='keywords',
+ name='deleted_at',
+ field=models.DateTimeField(blank=True, db_index=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='keywords',
+ name='deleted_by',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
+ ),
+ migrations.AddField(
+ model_name='keywords',
+ name='is_deleted',
+ field=models.BooleanField(db_index=True, default=False),
+ ),
+ migrations.AddField(
+ model_name='keywords',
+ name='restore_until',
+ field=models.DateTimeField(blank=True, db_index=True, null=True),
+ ),
+ ]
+
diff --git a/backend/igny8_core/modules/writer/migrations/0012_soft_delete.py b/backend/igny8_core/modules/writer/migrations/0012_soft_delete.py
new file mode 100644
index 00000000..5cb10076
--- /dev/null
+++ b/backend/igny8_core/modules/writer/migrations/0012_soft_delete.py
@@ -0,0 +1,88 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('writer', '0011_content_external_metadata_content_external_type_and_more'),
+ ('igny8_core_auth', '0006_soft_delete_and_retention'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='content',
+ name='delete_reason',
+ field=models.CharField(blank=True, max_length=255, null=True),
+ ),
+ migrations.AddField(
+ model_name='content',
+ name='deleted_at',
+ field=models.DateTimeField(blank=True, db_index=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='content',
+ name='deleted_by',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
+ ),
+ migrations.AddField(
+ model_name='content',
+ name='is_deleted',
+ field=models.BooleanField(db_index=True, default=False),
+ ),
+ migrations.AddField(
+ model_name='content',
+ name='restore_until',
+ field=models.DateTimeField(blank=True, db_index=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='images',
+ name='delete_reason',
+ field=models.CharField(blank=True, max_length=255, null=True),
+ ),
+ migrations.AddField(
+ model_name='images',
+ name='deleted_at',
+ field=models.DateTimeField(blank=True, db_index=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='images',
+ name='deleted_by',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
+ ),
+ migrations.AddField(
+ model_name='images',
+ name='is_deleted',
+ field=models.BooleanField(db_index=True, default=False),
+ ),
+ migrations.AddField(
+ model_name='images',
+ name='restore_until',
+ field=models.DateTimeField(blank=True, db_index=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='tasks',
+ name='delete_reason',
+ field=models.CharField(blank=True, max_length=255, null=True),
+ ),
+ migrations.AddField(
+ model_name='tasks',
+ name='deleted_at',
+ field=models.DateTimeField(blank=True, db_index=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='tasks',
+ name='deleted_by',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
+ ),
+ migrations.AddField(
+ model_name='tasks',
+ name='is_deleted',
+ field=models.BooleanField(db_index=True, default=False),
+ ),
+ migrations.AddField(
+ model_name='tasks',
+ name='restore_until',
+ field=models.DateTimeField(blank=True, db_index=True, null=True),
+ ),
+ ]
+
diff --git a/backend/igny8_core/tasks.py b/backend/igny8_core/tasks.py
new file mode 100644
index 00000000..d07b330a
--- /dev/null
+++ b/backend/igny8_core/tasks.py
@@ -0,0 +1,16 @@
+import logging
+from celery import shared_task
+from django.core.management import call_command
+
+logger = logging.getLogger(__name__)
+
+
+@shared_task(name='igny8_core.purge_soft_deleted')
+def purge_soft_deleted_task():
+ """Periodic task to purge expired soft-deleted records."""
+ try:
+ call_command('purge_soft_deleted')
+ except Exception as exc:
+ logger.exception("purge_soft_deleted task failed: %s", exc)
+ raise
+
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index cf052346..21de813a 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,5 +1,5 @@
import { Suspense, lazy, useEffect } from "react";
-import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom";
+import { Routes, Route, Navigate } from "react-router-dom";
import { HelmetProvider } from "react-helmet-async";
import AppLayout from "./layout/AppLayout";
import { ScrollToTop } from "./components/common/ScrollToTop";
@@ -176,10 +176,9 @@ export default function App() {
<>
-
-
-
-
+
+
+
{/* Auth Routes - Public */}
} />
} />
@@ -832,8 +831,7 @@ export default function App() {
{/* Fallback Route */}
} />
-
-
+
>
);
}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 7428fd8f..1f88ef3b 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -9,6 +9,7 @@ import { ThemeProvider } from "./context/ThemeContext";
import { ToastProvider } from "./components/ui/toast/ToastContainer";
import { HeaderMetricsProvider } from "./context/HeaderMetricsContext";
import { ErrorBoundary } from "./components/common/ErrorBoundary";
+import { BrowserRouter } from "react-router-dom";
createRoot(document.getElementById("root")!).render(
@@ -16,7 +17,9 @@ createRoot(document.getElementById("root")!).render(
-
+
+
+