diff --git a/apps/hadis/management/commands/diversify_topic_sources.py b/apps/hadis/management/commands/diversify_topic_sources.py new file mode 100644 index 0000000..9eff7e0 --- /dev/null +++ b/apps/hadis/management/commands/diversify_topic_sources.py @@ -0,0 +1,106 @@ +import time +from django.core.management.base import BaseCommand +from django.db import transaction, connection +from django.core.cache import cache +from apps.hadis.models import HadisCategory + + +class Command(BaseCommand): + help = ( + 'Diversify topic source_types across parent categories so that ' + 'sub-topics under each category have distinct, varied source types ' + '(hadith, quran, history, fatwa, quote).' + ) + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Simulate diversification without modifying the database.' + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + + if dry_run: + self.stdout.write(self.style.WARNING("=== RUNNING IN DRY-RUN MODE (No changes will be saved) ===")) + else: + self.stdout.write(self.style.NOTICE("=== STARTING TOPIC SOURCE DIVERSIFICATION ===")) + + # Ensure database connection with retry + for attempt in range(5): + try: + connection.ensure_connection() + break + except Exception as e: + self.stdout.write(f"Connection attempt {attempt+1} failed: {e}. Retrying in 2s...") + time.sleep(2) + + source_choices = [ + HadisCategory.SourceType.HADITH, + HadisCategory.SourceType.QURAN, + HadisCategory.SourceType.HISTORY, + HadisCategory.SourceType.FATWA, + HadisCategory.SourceType.QUOTE, + ] + num_choices = len(source_choices) + + parents = HadisCategory.objects.filter(is_topic=False).select_related('sect').order_by('sect_id', 'tree_id', 'lft', 'id') + parents_with_topics = [p for p in parents if p.children.filter(is_topic=True).exists()] + + self.stdout.write(f"Total parent categories with topics: {len(parents_with_topics)}") + + updated_topics_count = 0 + total_topics_count = 0 + global_rotation_idx = 0 + + with transaction.atomic(): + for parent in parents_with_topics: + topics = list(parent.children.filter(is_topic=True).order_by('order', 'id')) + topic_count = len(topics) + total_topics_count += topic_count + + sect_name = getattr(parent.sect, 'sect_type', 'unknown') + safe_slug = parent.slug.encode('ascii', 'replace').decode('ascii') + self.stdout.write( + f"\nParent [{sect_name}] ID={parent.id} ({safe_slug}) - {topic_count} topics:" + ) + + # For this parent's topics, pick distinct source types from the rotation + for i, topic in enumerate(topics): + new_source = source_choices[(global_rotation_idx + i) % num_choices] + old_source = topic.source_type + safe_topic_slug = topic.slug.encode('ascii', 'replace').decode('ascii') if topic.slug else 'no-slug' + + if old_source != new_source: + updated_topics_count += 1 + self.stdout.write( + f" -> Topic ID={topic.id:4d} | '{safe_topic_slug}' : {old_source} -> {new_source}" + ) + if not dry_run: + topic.source_type = new_source + topic.save(update_fields=['source_type']) + else: + self.stdout.write( + f" -> Topic ID={topic.id:4d} | '{safe_topic_slug}' : {new_source} (unchanged)" + ) + + # Advance global rotation index + global_rotation_idx = (global_rotation_idx + topic_count) % num_choices + + if dry_run: + transaction.set_rollback(True) + + self.stdout.write("") + self.stdout.write(self.style.SUCCESS("=== DIVERSIFICATION SUMMARY ===")) + self.stdout.write(f" Parent categories processed: {len(parents_with_topics)}") + self.stdout.write(f" Total topics inspected: {total_topics_count}") + self.stdout.write(f" Topics updated: {updated_topics_count}") + + if not dry_run: + # Clear cache so new source types are immediately served + cache.clear() + self.stdout.write(self.style.SUCCESS("Django cache cleared successfully!")) + self.stdout.write(self.style.SUCCESS("All topic source types updated successfully!")) + else: + self.stdout.write(self.style.WARNING("Dry run finished. Run without --dry-run to commit changes.")) diff --git a/apps/hadis/management/commands/migrate_leaf_categories_to_topics.py b/apps/hadis/management/commands/migrate_leaf_categories_to_topics.py new file mode 100644 index 0000000..1e94896 --- /dev/null +++ b/apps/hadis/management/commands/migrate_leaf_categories_to_topics.py @@ -0,0 +1,126 @@ +from django.core.management.base import BaseCommand +from django.db import transaction +from apps.hadis.models import HadisCategory + + +class Command(BaseCommand): + help = ( + 'Migrate legacy categories to topic architecture: ' + 'Leaf categories (and categories with hadiths) become topics (is_topic=True) ' + 'and parent categories become structural categories (is_topic=False, source_type=None).' + ) + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Simulate migration without modifying the database.' + ) + parser.add_argument( + '--default-source-type', + type=str, + default='hadith', + help='Default source_type to assign to topics if empty (default: hadith).' + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + default_source_type = options['default_source_type'] + + valid_sources = [choice[0] for choice in HadisCategory.SourceType.choices] + if default_source_type not in valid_sources: + self.stdout.write( + self.style.ERROR( + f"Invalid default-source-type: '{default_source_type}'. " + f"Must be one of: {valid_sources}" + ) + ) + return + + if dry_run: + self.stdout.write(self.style.WARNING("=== RUNNING IN DRY-RUN MODE (No database changes will be saved) ===")) + else: + self.stdout.write(self.style.NOTICE("=== STARTING CATEGORY-TO-TOPIC MIGRATION ===")) + + categories = HadisCategory.objects.all().order_by('tree_id', 'level', 'id') + total_count = categories.count() + + if total_count == 0: + self.stdout.write(self.style.WARNING("No categories found in database.")) + return + + to_topic_count = 0 + to_structural_count = 0 + already_correct_count = 0 + warnings = [] + + with transaction.atomic(): + for category in categories: + has_children = category.children.exists() + has_hadis = category.hadis_set.exists() or ( + hasattr(category, 'hadis_items') and category.hadis_items.exists() + ) + + if has_children and has_hadis: + warnings.append( + f"Category ID={category.id} ('{category.slug}') has both children and hadiths! " + f"Setting as structural category (is_topic=False) to preserve tree hierarchy." + ) + + # Determine target state + if not has_children: + # Leaf node -> Topic + target_is_topic = True + target_source_type = category.source_type if category.source_type in valid_sources else default_source_type + else: + # Parent node with children -> Structural Category + target_is_topic = False + target_source_type = None + + needs_update = ( + category.is_topic != target_is_topic or + category.source_type != target_source_type + ) + + if needs_update: + if target_is_topic: + to_topic_count += 1 + action_label = f"Convert to TOPIC (source={target_source_type})" + else: + to_structural_count += 1 + action_label = "Convert to CATEGORY (source=None)" + + self.stdout.write( + f" [UPDATE] ID={category.id:4d} | slug='{category.slug}' | {action_label}" + ) + + if not dry_run: + category.is_topic = target_is_topic + category.source_type = target_source_type + category.save(update_fields=['is_topic', 'source_type']) + else: + already_correct_count += 1 + + if dry_run: + # Rollback transaction in dry run mode + transaction.set_rollback(True) + + self.stdout.write("") + self.stdout.write(self.style.SUCCESS("=== MIGRATION SUMMARY ===")) + self.stdout.write(f" Total categories inspected: {total_count}") + self.stdout.write(f" Categories converted to TOPICS: {to_topic_count}") + self.stdout.write(f" Categories kept as STRUCTURAL: {to_structural_count}") + self.stdout.write(f" Already correct: {already_correct_count}") + + if warnings: + self.stdout.write("") + self.stdout.write(self.style.WARNING(f"=== WARNINGS ({len(warnings)}) ===")) + for w in warnings: + self.stdout.write(self.style.WARNING(f" ! {w}")) + + if dry_run: + self.stdout.write("") + self.stdout.write(self.style.WARNING("Dry run finished. Run without --dry-run to apply changes to database.")) + else: + self.stdout.write("") + self.stdout.write(self.style.SUCCESS("✓ All database changes committed successfully!"))