diff --git a/apps/bookmark/apps.py b/apps/bookmark/apps.py index 419b8fc..7236513 100644 --- a/apps/bookmark/apps.py +++ b/apps/bookmark/apps.py @@ -4,3 +4,7 @@ from django.apps import AppConfig class BookmarkConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'apps.bookmark' + + def ready(self): + import apps.bookmark.signals # noqa: F401 + diff --git a/apps/bookmark/management/commands/cleanup_orphan_bookmarks.py b/apps/bookmark/management/commands/cleanup_orphan_bookmarks.py new file mode 100644 index 0000000..cb80f97 --- /dev/null +++ b/apps/bookmark/management/commands/cleanup_orphan_bookmarks.py @@ -0,0 +1,84 @@ +import logging +from django.core.management.base import BaseCommand +from apps.bookmark.models import Bookmark + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Find and delete orphan bookmarks whose target content was deleted from the database." + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Scan and report orphan bookmarks without actually deleting them.', + ) + + def handle(self, *args, **options): + dry_run = options.get('dry_run', False) + + from apps.library.models import Book + from apps.podcast.models import Podcast, PodcastPlaylist + from apps.video.models import Video, VideoPlaylist + from apps.hadis.models import Hadis, HadisCorrection + from apps.article.models import Article + + service_model_map = { + Bookmark.ServiceChoices.LIBRARY: (Book, 'Library Book'), + Bookmark.ServiceChoices.PODCAST: (Podcast, 'Podcast'), + Bookmark.ServiceChoices.PODCAST_PLAYLIST: (PodcastPlaylist, 'Podcast Playlist'), + Bookmark.ServiceChoices.HADITH: (Hadis, 'Hadith'), + Bookmark.ServiceChoices.HADITH_CORRECTION: (HadisCorrection, 'Hadith Correction'), + Bookmark.ServiceChoices.VIDEO: (Video, 'Video'), + Bookmark.ServiceChoices.VIDEO_PLAYLIST: (VideoPlaylist, 'Video Playlist'), + Bookmark.ServiceChoices.ARTICLE: (Article, 'Article'), + } + + total_checked = Bookmark.objects.count() + self.stdout.write(f"Scanning {total_checked} total bookmark(s)...") + + total_orphans = 0 + orphan_ids_to_delete = [] + + for service_choice, (model_cls, label) in service_model_map.items(): + bms = Bookmark.objects.filter(service=service_choice) + content_ids = set(bms.values_list('content_id', flat=True)) + if not content_ids: + continue + + existing_ids = set(model_cls.objects.filter(id__in=content_ids).values_list('id', flat=True)) + missing_ids = content_ids - existing_ids + + if missing_ids: + orphan_bms = bms.filter(content_id__in=missing_ids) + count = orphan_bms.count() + total_orphans += count + ids = list(orphan_bms.values_list('id', flat=True)) + orphan_ids_to_delete.extend(ids) + self.stdout.write( + self.style.WARNING(f" - [{label} ({service_choice})]: found {count} orphan bookmark(s) (missing IDs: {sorted(list(missing_ids))[:10]}{'...' if len(missing_ids) > 10 else ''})") + ) + + # Check any bookmarks with unknown service choices + known_services = set(service_model_map.keys()) + unknown_bms = Bookmark.objects.exclude(service__in=known_services) + unknown_count = unknown_bms.count() + if unknown_count > 0: + total_orphans += unknown_count + orphan_ids_to_delete.extend(list(unknown_bms.values_list('id', flat=True))) + self.stdout.write( + self.style.WARNING(f" - [Unknown Service]: found {unknown_count} bookmark(s) with invalid service name.") + ) + + if total_orphans == 0: + self.stdout.write(self.style.SUCCESS("No orphan bookmarks found! Everything is clean.")) + return + + self.stdout.write(f"\nTotal orphan bookmark(s) found: {total_orphans}") + + if dry_run: + self.stdout.write(self.style.NOTICE("Dry run enabled. No bookmarks were deleted.")) + else: + deleted_count, _ = Bookmark.objects.filter(id__in=orphan_ids_to_delete).delete() + self.stdout.write(self.style.SUCCESS(f"Successfully deleted {deleted_count} orphan bookmark(s)!")) diff --git a/apps/bookmark/signals.py b/apps/bookmark/signals.py new file mode 100644 index 0000000..be88d44 --- /dev/null +++ b/apps/bookmark/signals.py @@ -0,0 +1,81 @@ +import logging +from django.db.models.signals import post_delete +from django.dispatch import receiver + +from apps.bookmark.models import Bookmark + +logger = logging.getLogger(__name__) + + +def _delete_bookmarks_for(service: str, content_id: int): + if not content_id: + return + try: + deleted_count, _ = Bookmark.objects.filter(service=service, content_id=content_id).delete() + if deleted_count > 0: + logger.info("[Bookmark] Auto-deleted %d bookmark(s) for %s ID %s upon content deletion.", deleted_count, service, content_id) + except Exception as exc: + logger.error("[Bookmark] Error deleting bookmarks for %s ID %s: %s", service, content_id, exc) + + +# 1. Library Books +try: + from apps.library.models import Book + + @receiver(post_delete, sender=Book) + def on_book_deleted(sender, instance, **kwargs): + _delete_bookmarks_for(Bookmark.ServiceChoices.LIBRARY, instance.id) +except ImportError: + pass + +# 2. Hadith & Hadith Corrections +try: + from apps.hadis.models import Hadis, HadisCorrection + + @receiver(post_delete, sender=Hadis) + def on_hadis_deleted(sender, instance, **kwargs): + _delete_bookmarks_for(Bookmark.ServiceChoices.HADITH, instance.id) + + @receiver(post_delete, sender=HadisCorrection) + def on_hadis_correction_deleted(sender, instance, **kwargs): + _delete_bookmarks_for(Bookmark.ServiceChoices.HADITH_CORRECTION, instance.id) +except ImportError: + pass + +# 3. Videos & Video Playlists +try: + from apps.video.models import Video, VideoPlaylist + + @receiver(post_delete, sender=Video) + def on_video_deleted(sender, instance, **kwargs): + _delete_bookmarks_for(Bookmark.ServiceChoices.VIDEO, instance.id) + + @receiver(post_delete, sender=VideoPlaylist) + def on_video_playlist_deleted(sender, instance, **kwargs): + _delete_bookmarks_for(Bookmark.ServiceChoices.VIDEO_PLAYLIST, instance.id) +except ImportError: + pass + +# 4. Podcasts & Podcast Playlists +try: + from apps.podcast.models import Podcast, PodcastPlaylist + + @receiver(post_delete, sender=Podcast) + def on_podcast_deleted(sender, instance, **kwargs): + _delete_bookmarks_for(Bookmark.ServiceChoices.PODCAST, instance.id) + + @receiver(post_delete, sender=PodcastPlaylist) + def on_podcast_playlist_deleted(sender, instance, **kwargs): + _delete_bookmarks_for(Bookmark.ServiceChoices.PODCAST_PLAYLIST, instance.id) +except ImportError: + pass + +# 5. Articles +try: + from apps.article.models import Article + + @receiver(post_delete, sender=Article) + def on_article_deleted(sender, instance, **kwargs): + _delete_bookmarks_for(Bookmark.ServiceChoices.ARTICLE, instance.id) +except ImportError: + pass diff --git a/apps/bookmark/views/bookmark.py b/apps/bookmark/views/bookmark.py index 8b51c2b..9b30cbe 100644 --- a/apps/bookmark/views/bookmark.py +++ b/apps/bookmark/views/bookmark.py @@ -287,12 +287,16 @@ def _get_valid_content_ids(service, content_ids): elif service in ('article', 'blog'): from apps.article.models import Article return set(Article.objects.filter(id__in=content_ids, status=True).values_list('id', flat=True)) - elif service in ('hadith', 'hadith_correction'): - from apps.hadith.models import Hadith - return set(Hadith.objects.filter(id__in=content_ids, status=True).values_list('id', flat=True)) - except Exception: - pass - return set(content_ids) + elif service == 'hadith': + from apps.hadis.models import Hadis + return set(Hadis.objects.filter(id__in=content_ids, status=True).values_list('id', flat=True)) + elif service == 'hadith_correction': + from apps.hadis.models import HadisCorrection + return set(HadisCorrection.objects.filter(id__in=content_ids).values_list('id', flat=True)) + except Exception as exc: + import logging + logging.getLogger(__name__).warning("[Bookmark] Error validating content ids for service %s: %s", service, exc) + return set() class BookmarkStatusView(APIView): @@ -386,8 +390,7 @@ class BookmarkListView(APIView): if b.content_id in valid_ids: valid_bookmarks.append(b) else: - b.status = False - b.save(update_fields=['status']) + b.delete() serializer = BookmarkSerializer(valid_bookmarks, many=True) return Response(serializer.data, status=status.HTTP_200_OK)