""" شکستنِ جزیره‌های بلند به جزیره‌های حداکثر ۶ درسی (۲۰۲۶-۱۰-۱۲؛ نخست ۱۰ بود، به خواستِ مالک: «جزیره‌ها زود تموم می‌شن، صدها جزیره»). چرا: `seed_curriculum_from_textbook` هر «درسِ فهرستِ کتاب» را یک جزیره می‌کند. حسابانِ دوازدهم فقط ۱۲ جزیره دارد با میانگینِ ۲۷ و حداکثرِ ۴۳ نوعِ مسئله، در حالی‌که پایه‌های دیگر ۲۸ تا ۵۳ جزیره‌ی ۸ تا ۱۰ درسی دارند. حجمِ سوال یکی بود، ولی دانش‌آموز «تعداد جزیره» را می‌بیند و هر جزیره‌ی بلند فقط یک آزمونِ چهارسوالی می‌دهد (سینیور درست می‌گفت: کم است). مهم‌ترین قیدها: - شناسه‌ی درس‌ها (Lesson.id) و سوال‌ها دست نمی‌خورد؛ فقط `Lesson.node` عوض می‌شود. پس UserLessonCompletion/UserAnswer همه‌جا درست می‌ماند. - جزیره‌ی اصلی بخشِ اول می‌ماند (همان id)، بخش‌های بعدی جزیره‌ی تازه‌اند. - کسی که جزیره‌ی اصلی را با آزمون تمام کرده بود، همان را برای بخش‌های تازه هم تمام‌شده دارد (ردیفِ UserNodeCompletion/NodeProgress با xp و سکه‌ی صفر و زمانِ اصلی؛ XP دوبار شمرده نشود و لیدربورد/گزارشِ هفتگی تغییر نکند). بدونِ این، پیشرفتِ کاربرانِ فعلی عقب می‌رفت. - ایدمپوتنت: جزیره‌ای که از قبل کوتاه است (≤ max_lessons) دست نمی‌خورد، پس اجرای دوباره چیزی را دوباره نمی‌شکند. نسنجیده: اجرا روی دیتابیسی که کاربرِ واقعی در وسطِ آزمونِ یک جزیره‌ی بلند دارد (آزمون ۴ سوالِ ثابت با seed `nodeId:userId` است؛ شناسه‌ی جزیره‌ی بخشِ اول همان است، پس آزمونِ نیمه‌کاره‌اش فقط ممکن است با درس‌های کمتر ساخته شود). """ import math _FA = str.maketrans('0123456789', '۰۱۲۳۴۵۶۷۸۹') def part_count(n_lessons: int, max_lessons: int, part_cap: int) -> int: """جزیره‌ی تا max_lessons درس دست نمی‌خورد؛ بلندتر به کمترین تعدادی تکه می‌شود که هیچ‌کدام از part_cap بزرگ‌تر نشود.""" if n_lessons <= max_lessons: return 1 return max(2, math.ceil(n_lessons / part_cap)) def balanced_chunks(items: list, parts: int) -> list[list]: """parts تکه‌ی پشتِ‌هم با اندازه‌های حداکثر یک واحد اختلاف (اولی‌ها بزرگ‌تر).""" size, extra = divmod(len(items), parts) out, start = [], 0 for i in range(parts): end = start + size + (1 if i < extra else 0) out.append(items[start:end]) start = end return out def part_title(base: str, index: int) -> str: return f'{base} ({str(index).translate(_FA)})' def split_long_nodes(*, Node, Lesson, UserNodeCompletion, NodeProgress, units, max_lessons=7, part_cap=6, dry_run=False) -> dict: """ units: queryset از Unit؛ مدل‌ها پارامتر می‌شوند تا هم migration (مدل‌های تاریخی) و هم دستورِ مدیریتی از همین کد استفاده کنند. """ counts = {'nodes_split': 0, 'nodes_created': 0, 'lessons_moved': 0, 'completions_cloned': 0} for unit in units: nodes = list(Node.objects.filter(unit=unit).order_by('order', 'id')) plan = [] for node in nodes: lessons = list(Lesson.objects.filter(node=node).order_by('id').values_list('id', flat=True)) parts = part_count(len(lessons), max_lessons, part_cap) plan.append((node, balanced_chunks(lessons, parts) if parts > 1 else None)) if not any(chunks for _node, chunks in plan): continue if dry_run: for node, chunks in plan: if chunks: counts['nodes_split'] += 1 counts['nodes_created'] += len(chunks) - 1 counts['lessons_moved'] += sum(len(c) for c in chunks[1:]) continue order = 0 for node, chunks in plan: if not chunks: order += 1 if node.order != order: Node.objects.filter(pk=node.pk).update(order=order) continue base = node.title counts['nodes_split'] += 1 completions = list(UserNodeCompletion.objects.filter(node=node)) progresses = list(NodeProgress.objects.filter(node=node, status='completed')) for index, lesson_ids in enumerate(chunks, start=1): order += 1 title = part_title(base, index) if index == 1: Node.objects.filter(pk=node.pk).update(title=title, order=order) continue new = Node.objects.create(unit=unit, title=title, order=order, icon=node.icon, color=node.color) counts['nodes_created'] += 1 counts['lessons_moved'] += Lesson.objects.filter(pk__in=lesson_ids).update(node=new) clones = UserNodeCompletion.objects.bulk_create([ UserNodeCompletion( user_id=c.user_id, node=new, correct_count=c.correct_count, total_questions=c.total_questions, stars=c.stars, xp_earned=0, coins_earned=0, idempotency_key=None, ) for c in completions ]) # created_at از auto_now_add است و در bulk_create نادیده گرفته نمی‌شود؛ بعداً به زمانِ اصلی برمی‌گردد. for clone, original in zip(clones, completions): UserNodeCompletion.objects.filter(pk=clone.pk).update(created_at=original.created_at) counts['completions_cloned'] += len(clones) NodeProgress.objects.bulk_create([ NodeProgress( user_id=p.user_id, node=new, status='completed', lessons_completed_count=len(lesson_ids), started_at=p.started_at, completed_at=p.completed_at, last_activity_at=p.last_activity_at, ) for p in progresses ]) # بخشِ اول فقط درس‌های خودش را نگه می‌دارد: درس‌های بخش‌های بعدی بالا جابه‌جا شدند. return counts