From bfb07947ea9f5fb64e703c5d5bf92e2ed1348f75 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sat, 6 Dec 2025 16:41:35 +0000 Subject: [PATCH] many fixes of backeend and fronteend --- backend/igny8_core/api/base.py | 21 +++- backend/igny8_core/auth/admin.py | 5 + .../management/commands/purge_soft_deleted.py | 42 ++++++++ .../0006_soft_delete_and_retention.py | 93 ++++++++++++++++++ backend/igny8_core/auth/models.py | 30 +++++- backend/igny8_core/business/content/models.py | 16 +++- .../igny8_core/business/planning/models.py | 16 +++- backend/igny8_core/celery.py | 5 + backend/igny8_core/common/soft_delete.py | 95 +++++++++++++++++++ backend/igny8_core/management/__init__.py | 2 + .../management/commands/__init__.py | 2 + .../management/commands/purge_soft_deleted.py | 42 ++++++++ backend/igny8_core/modules/billing/admin.py | 77 +++++++++++++++ backend/igny8_core/modules/billing/apps.py | 2 +- .../planner/migrations/0008_soft_delete.py | 88 +++++++++++++++++ .../writer/migrations/0012_soft_delete.py | 88 +++++++++++++++++ backend/igny8_core/tasks.py | 16 ++++ frontend/src/App.tsx | 12 +-- frontend/src/main.tsx | 5 +- 19 files changed, 638 insertions(+), 19 deletions(-) create mode 100644 backend/igny8_core/auth/management/commands/purge_soft_deleted.py create mode 100644 backend/igny8_core/auth/migrations/0006_soft_delete_and_retention.py create mode 100644 backend/igny8_core/common/soft_delete.py create mode 100644 backend/igny8_core/management/__init__.py create mode 100644 backend/igny8_core/management/commands/__init__.py create mode 100644 backend/igny8_core/management/commands/purge_soft_deleted.py create mode 100644 backend/igny8_core/modules/planner/migrations/0008_soft_delete.py create mode 100644 backend/igny8_core/modules/writer/migrations/0012_soft_delete.py create mode 100644 backend/igny8_core/tasks.py 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( - + + +