import random import sys from django.core.management.base import BaseCommand from django.db import transaction from apps.course.models import Course, CourseChapter, CourseLesson class Command(BaseCommand): help = 'Redistributes course lessons into multiple chapters randomly (maintaining order) for courses with >= 2 lessons.' def add_arguments(self, parser): parser.add_argument( '--dry-run', action='store_true', help='Simulates the redistribution without modifying the database.', ) parser.add_argument( '--max-chapters', type=int, default=5, help='Maximum number of chapters to create for a course (default: 5).', ) def handle(self, *args, **options): # Reconfigure stdout to UTF-8 to prevent UnicodeEncodeError on Windows terminals try: sys.stdout.reconfigure(encoding='utf-8') except AttributeError: pass dry_run = options['dry_run'] max_chapters = options['max_chapters'] if dry_run: self.stdout.write(self.style.WARNING("⚠️ DRY RUN MODE: No database changes will be saved.")) self.stdout.write(self.style.WARNING("Starting lesson redistribution...")) courses = Course.objects.all() courses_updated = 0 total_chapters_created = 0 total_lessons_redistributed = 0 # Wrap everything in an atomic transaction to ensure safety with transaction.atomic(): for course in courses: lessons = list(course.lessons.all().order_by('priority')) n = len(lessons) if n < 2: self.stdout.write(self.style.NOTICE( f"Skipping course: '{course.title}' (ID: {course.id}) - Only has {n} lesson(s)." )) continue # Number of chapters C must be between 2 and min(N, max_chapters) c = random.randint(2, min(n, max_chapters)) self.stdout.write(self.style.MIGRATE_HEADING( f"\nReorganizing Course: '{course.title}' (ID: {course.id})" f"\n Total lessons: {n} | Splitting into {c} chapters..." )) # Partition the ordered lessons list into C slices # We randomly pick C-1 unique split boundaries between 1 and n-1 split_points = sorted(random.sample(range(1, n), c - 1)) partitioned_lessons = [] last_idx = 0 for sp in split_points: partitioned_lessons.append(lessons[last_idx:sp]) last_idx = sp partitioned_lessons.append(lessons[last_idx:]) if not dry_run: # 1. Unlink lessons from chapters first to prevent CASCADE deletion. # Using .update() bypasses the save() method and avoids AttributeErrors # caused by _adjust_priorities when chapter is None. course.lessons.all().update(chapter=None) # 2. Delete all existing chapters for this course course.chapters.all().delete() # 3. Create the new chapters and assign the sliced lessons for i, chapter_lessons in enumerate(partitioned_lessons): chapter_title = f"فصل {i+1}" priority = i + 1 self.stdout.write(f" 📂 Chapter '{chapter_title}' (priority {priority}) contains:") for idx, lesson in enumerate(chapter_lessons): self.stdout.write(f" - [{idx + 1}] {lesson.title} (Original ID: {lesson.id})") if not dry_run: # Create the new chapter chapter = CourseChapter.objects.create( course=course, title=chapter_title, priority=priority ) # Assign and save each lesson within this chapter for idx, lesson in enumerate(chapter_lessons): lesson.chapter = chapter lesson.priority = idx + 1 lesson.save() total_chapters_created += 1 total_lessons_redistributed += len(chapter_lessons) courses_updated += 1 if dry_run: self.stdout.write(self.style.WARNING("\n⚠️ Dry run complete. No database changes were made.")) else: self.stdout.write(self.style.SUCCESS( f"\n🎉 Successfully redistributed {total_lessons_redistributed} lessons " f"across {total_chapters_created} chapters in {courses_updated} courses!" ))