diff --git a/backend/igny8_core/admin/trash_admin.py b/backend/igny8_core/admin/trash_admin.py index 0e8f8df8..176e750e 100644 --- a/backend/igny8_core/admin/trash_admin.py +++ b/backend/igny8_core/admin/trash_admin.py @@ -12,8 +12,9 @@ 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.content.models import Tasks, Content, Images, ContentTaxonomy from igny8_core.business.planning.models import Clusters, Keywords, ContentIdeas +from django.db import transaction class DeletedOnlyManager: @@ -157,9 +158,17 @@ class BaseTrashAdmin(ModelAdmin): expired = queryset.filter(restore_until__lt=now) restored_count = 0 + failed_count = 0 + failed_items = [] + for obj in restorable: - obj.restore() - restored_count += 1 + try: + with transaction.atomic(): + obj.restore() + restored_count += 1 + except Exception as e: + failed_count += 1 + failed_items.append(f"{obj} ({str(e)[:50]}...)") if restored_count > 0: self.message_user( @@ -168,6 +177,13 @@ class BaseTrashAdmin(ModelAdmin): messages.SUCCESS ) + if failed_count > 0: + self.message_user( + request, + f'{failed_count} record(s) could not be restored (constraint violation). Items: {", ".join(failed_items[:3])}', + messages.ERROR + ) + if expired.exists(): self.message_user( request, @@ -179,14 +195,71 @@ class BaseTrashAdmin(ModelAdmin): def permanently_delete_records(self, request, queryset): """Permanently delete selected records (irreversible).""" count = queryset.count() - for obj in queryset: - obj.hard_delete() + failed_count = 0 + failed_items = [] + deleted_count = 0 - self.message_user( - request, - f'Permanently deleted {count} record(s). This cannot be undone.', - messages.SUCCESS - ) + for obj in queryset: + try: + with transaction.atomic(): + # For Sector, cascade delete related soft-deleted records first + if isinstance(obj, Sector): + self._cascade_delete_sector_relations(obj) + # For Site, cascade delete related soft-deleted records first + elif isinstance(obj, Site): + self._cascade_delete_site_relations(obj) + obj.hard_delete() + deleted_count += 1 + except Exception as e: + failed_count += 1 + failed_items.append(f"{obj} ({str(e)[:100]}...)") + + if deleted_count > 0: + self.message_user( + request, + f'Permanently deleted {deleted_count} record(s). This cannot be undone.', + messages.SUCCESS + ) + + if failed_count > 0: + self.message_user( + request, + f'{failed_count} record(s) could not be deleted. Items: {", ".join(failed_items[:3])}', + messages.ERROR + ) + + def _cascade_delete_sector_relations(self, sector): + """Cascade delete all related soft-deleted records for a sector.""" + # Delete in order of dependencies + # 1. Images (depends on Content/Tasks) + Images.all_objects.filter(sector=sector).delete() + # 2. Content (depends on Tasks) + Content.all_objects.filter(sector=sector).delete() + # 3. Tasks (depends on Clusters) + Tasks.all_objects.filter(sector=sector).delete() + # 4. ContentIdeas (depends on Clusters) + ContentIdeas.all_objects.filter(sector=sector).delete() + # 5. Keywords (depends on Clusters) + Keywords.all_objects.filter(sector=sector).delete() + # 6. Clusters + Clusters.all_objects.filter(sector=sector).delete() + # 7. ContentTaxonomy (if exists) + ContentTaxonomy.objects.filter(sector=sector).delete() + + def _cascade_delete_site_relations(self, site): + """Cascade delete all related soft-deleted records for a site.""" + # Delete sectors first (which will cascade to their relations) + for sector in Sector.all_objects.filter(site=site): + self._cascade_delete_sector_relations(sector) + sector.hard_delete() + # Delete any site-level records + Images.all_objects.filter(site=site).delete() + Content.all_objects.filter(site=site).delete() + Tasks.all_objects.filter(site=site).delete() + ContentIdeas.all_objects.filter(site=site).delete() + Keywords.all_objects.filter(site=site).delete() + Clusters.all_objects.filter(site=site).delete() + ContentTaxonomy.objects.filter(site=site).delete() actions = ['restore_records', 'permanently_delete_records'] @@ -442,6 +515,31 @@ class ContentIdeasTrashAdmin(BaseTrashAdmin): site_name.short_description = 'Site' +class ContentTaxonomyTrashAdmin(BaseTrashAdmin): + """Trash view for deleted Content Taxonomies (Tags/Categories).""" + list_display = [ + 'name', 'taxonomy_type', 'site_name', 'deleted_at_display', + 'time_until_permanent_delete', 'can_restore_display' + ] + list_filter = [RestorableTimePeriodFilter, DeletedInLastFilter, 'taxonomy_type'] + search_fields = ['name', 'slug', 'site__name'] + ordering = ['-deleted_at'] + readonly_fields = [ + 'name', 'slug', 'taxonomy_type', 'description', 'account', 'site', 'sector', + 'external_id', 'external_taxonomy', 'count', + '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' + + # ============================================================================ # PROXY MODELS FOR TRASH VIEWS # ============================================================================ @@ -527,6 +625,15 @@ class ContentIdeasTrash(ContentIdeas): verbose_name_plural = 'Content Ideas (Trash)' +class ContentTaxonomyTrash(ContentTaxonomy): + """Proxy model for Content Taxonomy (Tags/Categories) trash view.""" + class Meta: + proxy = True + app_label = 'writer' + verbose_name = 'Taxonomy (Trash)' + verbose_name_plural = 'Taxonomies (Trash)' + + # ============================================================================ # REGISTER TRASH ADMINS # ============================================================================ @@ -540,3 +647,4 @@ admin.site.register(ImagesTrash, ImagesTrashAdmin) admin.site.register(ClustersTrash, ClustersTrashAdmin) admin.site.register(KeywordsTrash, KeywordsTrashAdmin) admin.site.register(ContentIdeasTrash, ContentIdeasTrashAdmin) +admin.site.register(ContentTaxonomyTrash, ContentTaxonomyTrashAdmin) diff --git a/backend/igny8_core/business/content/models.py b/backend/igny8_core/business/content/models.py index e49df013..e7821b00 100644 --- a/backend/igny8_core/business/content/models.py +++ b/backend/igny8_core/business/content/models.py @@ -440,10 +440,11 @@ class Content(SoftDeletableModel, SiteSectorBaseModel): return super().hard_delete(using=using, keep_parents=keep_parents) -class ContentTaxonomy(SiteSectorBaseModel): +class ContentTaxonomy(SoftDeletableModel, SiteSectorBaseModel): """ Simplified taxonomy model for AI-generated categories and tags. Directly linked to Content via many-to-many relationship. + Supports soft-delete for trash/restore functionality. """ TAXONOMY_TYPE_CHOICES = [ @@ -497,6 +498,9 @@ class ContentTaxonomy(SiteSectorBaseModel): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + objects = SoftDeleteManager() + all_objects = models.Manager() + class Meta: app_label = 'writer' db_table = 'igny8_content_taxonomy_terms' diff --git a/backend/igny8_core/business/planning/models.py b/backend/igny8_core/business/planning/models.py index de259eb4..2fbdc903 100644 --- a/backend/igny8_core/business/planning/models.py +++ b/backend/igny8_core/business/planning/models.py @@ -79,7 +79,7 @@ class Keywords(SoftDeletableModel, SiteSectorBaseModel): # Required: Link to global SeedKeyword seed_keyword = models.ForeignKey( SeedKeyword, - on_delete=models.PROTECT, # Prevent deletion if Keywords reference it + on_delete=models.CASCADE, # Allow deletion of SeedKeyword (cascades to Keywords) related_name='site_keywords', help_text="Reference to the global seed keyword" ) diff --git a/backend/igny8_core/modules/planner/migrations/0010_add_taxonomy_soft_delete_and_keyword_fk_cascade.py b/backend/igny8_core/modules/planner/migrations/0010_add_taxonomy_soft_delete_and_keyword_fk_cascade.py new file mode 100644 index 00000000..dcf263b9 --- /dev/null +++ b/backend/igny8_core/modules/planner/migrations/0010_add_taxonomy_soft_delete_and_keyword_fk_cascade.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.10 on 2026-01-12 16:50 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('igny8_core_auth', '0021_accounttrash_sectortrash_sitetrash'), + ('planner', '0009_clusterstrash_contentideastrash_keywordstrash'), + ] + + operations = [ + migrations.AlterField( + model_name='keywords', + name='seed_keyword', + field=models.ForeignKey(help_text='Reference to the global seed keyword', on_delete=django.db.models.deletion.CASCADE, related_name='site_keywords', to='igny8_core_auth.seedkeyword'), + ), + ] diff --git a/backend/igny8_core/modules/writer/migrations/0018_add_taxonomy_soft_delete_and_keyword_fk_cascade.py b/backend/igny8_core/modules/writer/migrations/0018_add_taxonomy_soft_delete_and_keyword_fk_cascade.py new file mode 100644 index 00000000..e21d4b76 --- /dev/null +++ b/backend/igny8_core/modules/writer/migrations/0018_add_taxonomy_soft_delete_and_keyword_fk_cascade.py @@ -0,0 +1,54 @@ +# Generated by Django 5.2.10 on 2026-01-12 16:50 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('writer', '0017_contenttrash_imagestrash_taskstrash'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ContentTaxonomyTrash', + fields=[ + ], + options={ + 'verbose_name': 'Taxonomy (Trash)', + 'verbose_name_plural': 'Taxonomies (Trash)', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('writer.contenttaxonomy',), + ), + migrations.AddField( + model_name='contenttaxonomy', + name='delete_reason', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='contenttaxonomy', + name='deleted_at', + field=models.DateTimeField(blank=True, db_index=True, null=True), + ), + migrations.AddField( + model_name='contenttaxonomy', + name='deleted_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='contenttaxonomy', + name='is_deleted', + field=models.BooleanField(db_index=True, default=False), + ), + migrations.AddField( + model_name='contenttaxonomy', + name='restore_until', + field=models.DateTimeField(blank=True, db_index=True, null=True), + ), + ]