From 8a2bf26b161b17d2bb9dde1d36f385dfe6a7064a Mon Sep 17 00:00:00 2001 From: Zareian Date: Thu, 15 Oct 2026 13:53:30 +0330 Subject: [PATCH] Lesson.order from the book (seed sets it, also re-syncs unit/node order); matrix lesson rows as one multi-line step Co-Authored-By: Claude Sonnet 5.5 --- content/migrations/0025_lesson_order.py | 22 ++++++++++++++++++++++ content/models.py | 8 ++++++-- content/seed/curriculum.json | 5 +---- content/seed_sync.py | 22 ++++++++++++++++++---- content/tests_ordering.py | 2 +- content/tests_seed_content.py | 17 +++++++++++++++++ 6 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 content/migrations/0025_lesson_order.py diff --git a/content/migrations/0025_lesson_order.py b/content/migrations/0025_lesson_order.py new file mode 100644 index 0000000..4b562c8 --- /dev/null +++ b/content/migrations/0025_lesson_order.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.17 on 2026-10-15 10:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('content', '0024_explicit_ordering'), + ] + + operations = [ + migrations.AlterModelOptions( + name='lesson', + options={'ordering': ['order', 'id']}, + ), + migrations.AddField( + model_name='lesson', + name='order', + field=models.PositiveIntegerField(default=10000), + ), + ] diff --git a/content/models.py b/content/models.py index 3824fcb..dd9e303 100644 --- a/content/models.py +++ b/content/models.py @@ -156,11 +156,15 @@ class Lesson(models.Model): mascot_animation = models.CharField(max_length=50, default='explain') + # جایگاهِ درس در جزیره (از ۱). ۱۰۰۰۰ برای درسی که ادمین ساخته و در فایلِ کتاب نیست: ته‌ِ جزیره می‌نشیند. + order = models.PositiveIntegerField(default=10000) + class Meta: # بدونِ ORDER BY، Postgres سطرها را به ترتیبِ فیزیکی برمی‌گرداند و هر UPDATE (ویرایشِ ادمین، # seed_content) آن را جابه‌جا می‌کند: درسِ اول یک جزیره عوض می‌شد (گزارشِ مالک ۲۰۲۶-۱۰-۱۵). - # seed درس‌ها را به ترتیبِ فایل می‌سازد، پس ترتیبِ id همان ترتیبِ کتاب است. - ordering = ['id'] + # `order` جایگاهِ درس در کتاب است و seed_content آن را از فایل می‌گذارد؛ درسی که بعداً در وسطِ + # جزیره اضافه شده id بزرگ‌تر دارد ولی جایش وسط است. `id` فقط برای برابری است. + ordering = ['order', 'id'] def __str__(self): return self.title diff --git a/content/seed/curriculum.json b/content/seed/curriculum.json index e9184d4..c8ad9f2 100644 --- a/content/seed/curriculum.json +++ b/content/seed/curriculum.json @@ -173252,10 +173252,7 @@ "example": "ماتریس B به ابعاد m×n با درایه‌های bᵢⱼ که i=1 تا m و j=1 تا n تغییر می‌کند، نمایش داده شده است. ماتریس B را به صورت درایه‌ای نمایش دهید.", "steps": [ "ماتریس B به صورت زیر نمایش داده می‌شود:", - "B(m×n) = b₁₁ b₁₂ ⋯ b(1j) ⋯ b(1n)", - "b₂₁ b₂₂ ⋯ b(2j) ⋯ b(2n)", - "⋮ ⋮ ⋱ ⋮ ⋱ ⋮", - "b(i1) b(i2) ⋯ b(ij) ⋯ b(in)", + "B(m×n) = b₁₁ b₁₂ ⋯ b(1j) ⋯ b(1n)\nb₂₁ b₂₂ ⋯ b(2j) ⋯ b(2n)\n⋮ ⋮ ⋱ ⋮ ⋱ ⋮\nb(i1) b(i2) ⋯ b(ij) ⋯ b(in)", "✅ جواب: نمایش درایه‌ای ماتریس B با b(ij) برای سطر i و ستون j صحیح است." ] }, diff --git a/content/seed_sync.py b/content/seed_sync.py index 12cbc2c..644d0fc 100644 --- a/content/seed_sync.py +++ b/content/seed_sync.py @@ -71,7 +71,7 @@ class _Ledger: def sync(grades: list[dict], question_rows: list[dict]) -> dict: """grades: خروجیِ curriculum_io.parse؛ question_rows: ردیف‌های مسیردارِ questions.json.""" ledger = _Ledger(active=Grade.objects.exists()) - counts = {'grades': 0, 'units': 0, 'nodes': 0, 'lessons': 0, 'updated': 0, + counts = {'grades': 0, 'units': 0, 'nodes': 0, 'lessons': 0, 'updated': 0, 'reordered': 0, 'kept_admin_text': 0, 'retired': 0, 'created': 0, 'skipped': 0} for g in grades: @@ -98,6 +98,11 @@ def sync(grades: list[dict], question_rows: list[dict]) -> dict: ) counts['units'] += 1 ledger.record(_key('unit', *upath), 'unit') + if unit.order != u['order']: + # ترتیبِ فصل‌ها ترتیبِ کتاب است، نه سلیقه‌ی ادمین؛ دوباره از فایل گذاشته می‌شود. + unit.order = u['order'] + unit.save(update_fields=['order']) + counts['reordered'] += 1 if u.get('blurb') and not unit.blurb: unit.blurb = u['blurb'] unit.save(update_fields=['blurb']) @@ -112,16 +117,20 @@ def sync(grades: list[dict], question_rows: list[dict]) -> dict: node = Node.objects.create(unit=unit, title=n['title'], order=n['order']) counts['nodes'] += 1 ledger.record(_key('node', *npath), 'node') + if node.order != n['order']: + node.order = n['order'] + node.save(update_fields=['order']) + counts['reordered'] += 1 - for row in n['lessons']: - _sync_lesson(node, npath + (row['title'],), row, ledger, counts) + for position, row in enumerate(n['lessons'], start=1): + _sync_lesson(node, npath + (row['title'],), row, ledger, counts, position) _sync_questions(question_rows, ledger, counts) ledger.save() return counts -def _sync_lesson(node, path, row, ledger, counts): +def _sync_lesson(node, path, row, ledger, counts, position): intro_key = _key('intro', *path) lesson = Lesson.objects.filter(node=node, title=row['title']).first() if lesson is None: @@ -131,6 +140,7 @@ def _sync_lesson(node, path, row, ledger, counts): lesson = Lesson.objects.create( node=node, title=row['title'], intro_text=row['intro_text'], example=row['example'], steps=row['steps'], figures=row.get('figures') or [], + order=position, ) counts['lessons'] += 1 ledger.record(_key('lesson', *path), 'lesson') @@ -139,6 +149,10 @@ def _sync_lesson(node, path, row, ledger, counts): ledger.record(_key('lesson', *path), 'lesson') changed = [] + if lesson.order != position: + lesson.order = position + changed.append('order') + counts['reordered'] += 1 file_text, db_text = row['intro_text'], lesson.intro_text last_seeded = ledger.value(intro_key) if file_text and db_text != file_text: diff --git a/content/tests_ordering.py b/content/tests_ordering.py index 32b76eb..585821f 100644 --- a/content/tests_ordering.py +++ b/content/tests_ordering.py @@ -11,7 +11,7 @@ class StableOrderTests(TestCase): def test_content_querysets_have_explicit_order(self): for qs in (Lesson.objects.all(), Question.objects.all(), Choice.objects.all()): self.assertIn('ORDER BY', str(qs.query), qs.model.__name__) - self.assertEqual(qs.query.order_by or qs.model._meta.ordering, ['id']) + self.assertEqual(qs.model._meta.ordering[-1], 'id') def test_lessons_come_back_in_creation_order_after_updates(self): grade = Grade.objects.create(title='پایه', number=99) diff --git a/content/tests_seed_content.py b/content/tests_seed_content.py index 1286314..8f26f37 100644 --- a/content/tests_seed_content.py +++ b/content/tests_seed_content.py @@ -76,6 +76,23 @@ class SeedContentTests(TestCase): self.assertEqual(question.choices.count(), 3) self.assertEqual(question.choices.get(is_correct=True).value, '۴') + def test_book_order_wins_over_creation_order(self): + """درسی که بعداً در وسطِ جزیره اضافه می‌شود id بزرگ‌تر دارد؛ ترتیبِ نمایش باید ترتیبِ کتاب باشد، + و ترتیبِ جابه‌جاشده‌ی فصل و جزیره هم با اجرای بعدیِ seed به کتاب برمی‌گردد.""" + call_command('seed_content') + lessons = CURRICULUM['grades'][0]['units'][0]['nodes'][0]['lessons'] + lessons.insert(1, {'title': 'درسِ تازه در وسط', 'introText': 'x'}) + Unit.objects.update(order=99) + Node.objects.update(order=99) + self._write(CURRICULUM, QUESTIONS) + call_command('seed_content') + + node = Node.objects.get() + self.assertEqual([l.title for l in node.lessons.all()], + ['ضرب توان‌ها', 'درسِ تازه در وسط', 'تقسیم توان‌ها']) + self.assertEqual((Unit.objects.get().order, node.order), (4, 1)) + lessons.pop(1) # فایلِ مشترکِ تست را برای تست‌های بعدی برمی‌گرداند + def test_second_run_changes_nothing(self): """دیپلویِ دوم نباید چیزی را تکراری کند — این کلِ ایمنیِ خودکار بودنش است.""" call_command('seed_content')