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 <noreply@anthropic.com>
This commit is contained in:
22
content/migrations/0025_lesson_order.py
Normal file
22
content/migrations/0025_lesson_order.py
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -156,11 +156,15 @@ class Lesson(models.Model):
|
|||||||
|
|
||||||
mascot_animation = models.CharField(max_length=50, default='explain')
|
mascot_animation = models.CharField(max_length=50, default='explain')
|
||||||
|
|
||||||
|
# جایگاهِ درس در جزیره (از ۱). ۱۰۰۰۰ برای درسی که ادمین ساخته و در فایلِ کتاب نیست: تهِ جزیره مینشیند.
|
||||||
|
order = models.PositiveIntegerField(default=10000)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
# بدونِ ORDER BY، Postgres سطرها را به ترتیبِ فیزیکی برمیگرداند و هر UPDATE (ویرایشِ ادمین،
|
# بدونِ ORDER BY، Postgres سطرها را به ترتیبِ فیزیکی برمیگرداند و هر UPDATE (ویرایشِ ادمین،
|
||||||
# seed_content) آن را جابهجا میکند: درسِ اول یک جزیره عوض میشد (گزارشِ مالک ۲۰۲۶-۱۰-۱۵).
|
# seed_content) آن را جابهجا میکند: درسِ اول یک جزیره عوض میشد (گزارشِ مالک ۲۰۲۶-۱۰-۱۵).
|
||||||
# seed درسها را به ترتیبِ فایل میسازد، پس ترتیبِ id همان ترتیبِ کتاب است.
|
# `order` جایگاهِ درس در کتاب است و seed_content آن را از فایل میگذارد؛ درسی که بعداً در وسطِ
|
||||||
ordering = ['id']
|
# جزیره اضافه شده id بزرگتر دارد ولی جایش وسط است. `id` فقط برای برابری است.
|
||||||
|
ordering = ['order', 'id']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|||||||
@@ -173252,10 +173252,7 @@
|
|||||||
"example": "ماتریس B به ابعاد m×n با درایههای bᵢⱼ که i=1 تا m و j=1 تا n تغییر میکند، نمایش داده شده است. ماتریس B را به صورت درایهای نمایش دهید.",
|
"example": "ماتریس B به ابعاد m×n با درایههای bᵢⱼ که i=1 تا m و j=1 تا n تغییر میکند، نمایش داده شده است. ماتریس B را به صورت درایهای نمایش دهید.",
|
||||||
"steps": [
|
"steps": [
|
||||||
"ماتریس B به صورت زیر نمایش داده میشود:",
|
"ماتریس B به صورت زیر نمایش داده میشود:",
|
||||||
"B(m×n) = b₁₁ b₁₂ ⋯ b(1j) ⋯ b(1n)",
|
"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₂₂ ⋯ b(2j) ⋯ b(2n)",
|
|
||||||
"⋮ ⋮ ⋱ ⋮ ⋱ ⋮",
|
|
||||||
"b(i1) b(i2) ⋯ b(ij) ⋯ b(in)",
|
|
||||||
"✅ جواب: نمایش درایهای ماتریس B با b(ij) برای سطر i و ستون j صحیح است."
|
"✅ جواب: نمایش درایهای ماتریس B با b(ij) برای سطر i و ستون j صحیح است."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class _Ledger:
|
|||||||
def sync(grades: list[dict], question_rows: list[dict]) -> dict:
|
def sync(grades: list[dict], question_rows: list[dict]) -> dict:
|
||||||
"""grades: خروجیِ curriculum_io.parse؛ question_rows: ردیفهای مسیردارِ questions.json."""
|
"""grades: خروجیِ curriculum_io.parse؛ question_rows: ردیفهای مسیردارِ questions.json."""
|
||||||
ledger = _Ledger(active=Grade.objects.exists())
|
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}
|
'kept_admin_text': 0, 'retired': 0, 'created': 0, 'skipped': 0}
|
||||||
|
|
||||||
for g in grades:
|
for g in grades:
|
||||||
@@ -98,6 +98,11 @@ def sync(grades: list[dict], question_rows: list[dict]) -> dict:
|
|||||||
)
|
)
|
||||||
counts['units'] += 1
|
counts['units'] += 1
|
||||||
ledger.record(_key('unit', *upath), 'unit')
|
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:
|
if u.get('blurb') and not unit.blurb:
|
||||||
unit.blurb = u['blurb']
|
unit.blurb = u['blurb']
|
||||||
unit.save(update_fields=['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'])
|
node = Node.objects.create(unit=unit, title=n['title'], order=n['order'])
|
||||||
counts['nodes'] += 1
|
counts['nodes'] += 1
|
||||||
ledger.record(_key('node', *npath), 'node')
|
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']:
|
for position, row in enumerate(n['lessons'], start=1):
|
||||||
_sync_lesson(node, npath + (row['title'],), row, ledger, counts)
|
_sync_lesson(node, npath + (row['title'],), row, ledger, counts, position)
|
||||||
|
|
||||||
_sync_questions(question_rows, ledger, counts)
|
_sync_questions(question_rows, ledger, counts)
|
||||||
ledger.save()
|
ledger.save()
|
||||||
return counts
|
return counts
|
||||||
|
|
||||||
|
|
||||||
def _sync_lesson(node, path, row, ledger, counts):
|
def _sync_lesson(node, path, row, ledger, counts, position):
|
||||||
intro_key = _key('intro', *path)
|
intro_key = _key('intro', *path)
|
||||||
lesson = Lesson.objects.filter(node=node, title=row['title']).first()
|
lesson = Lesson.objects.filter(node=node, title=row['title']).first()
|
||||||
if lesson is None:
|
if lesson is None:
|
||||||
@@ -131,6 +140,7 @@ def _sync_lesson(node, path, row, ledger, counts):
|
|||||||
lesson = Lesson.objects.create(
|
lesson = Lesson.objects.create(
|
||||||
node=node, title=row['title'], intro_text=row['intro_text'],
|
node=node, title=row['title'], intro_text=row['intro_text'],
|
||||||
example=row['example'], steps=row['steps'], figures=row.get('figures') or [],
|
example=row['example'], steps=row['steps'], figures=row.get('figures') or [],
|
||||||
|
order=position,
|
||||||
)
|
)
|
||||||
counts['lessons'] += 1
|
counts['lessons'] += 1
|
||||||
ledger.record(_key('lesson', *path), 'lesson')
|
ledger.record(_key('lesson', *path), 'lesson')
|
||||||
@@ -139,6 +149,10 @@ def _sync_lesson(node, path, row, ledger, counts):
|
|||||||
ledger.record(_key('lesson', *path), 'lesson')
|
ledger.record(_key('lesson', *path), 'lesson')
|
||||||
|
|
||||||
changed = []
|
changed = []
|
||||||
|
if lesson.order != position:
|
||||||
|
lesson.order = position
|
||||||
|
changed.append('order')
|
||||||
|
counts['reordered'] += 1
|
||||||
file_text, db_text = row['intro_text'], lesson.intro_text
|
file_text, db_text = row['intro_text'], lesson.intro_text
|
||||||
last_seeded = ledger.value(intro_key)
|
last_seeded = ledger.value(intro_key)
|
||||||
if file_text and db_text != file_text:
|
if file_text and db_text != file_text:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class StableOrderTests(TestCase):
|
|||||||
def test_content_querysets_have_explicit_order(self):
|
def test_content_querysets_have_explicit_order(self):
|
||||||
for qs in (Lesson.objects.all(), Question.objects.all(), Choice.objects.all()):
|
for qs in (Lesson.objects.all(), Question.objects.all(), Choice.objects.all()):
|
||||||
self.assertIn('ORDER BY', str(qs.query), qs.model.__name__)
|
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):
|
def test_lessons_come_back_in_creation_order_after_updates(self):
|
||||||
grade = Grade.objects.create(title='پایه', number=99)
|
grade = Grade.objects.create(title='پایه', number=99)
|
||||||
|
|||||||
@@ -76,6 +76,23 @@ class SeedContentTests(TestCase):
|
|||||||
self.assertEqual(question.choices.count(), 3)
|
self.assertEqual(question.choices.count(), 3)
|
||||||
self.assertEqual(question.choices.get(is_correct=True).value, '۴')
|
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):
|
def test_second_run_changes_nothing(self):
|
||||||
"""دیپلویِ دوم نباید چیزی را تکراری کند — این کلِ ایمنیِ خودکار بودنش است."""
|
"""دیپلویِ دوم نباید چیزی را تکراری کند — این کلِ ایمنیِ خودکار بودنش است."""
|
||||||
call_command('seed_content')
|
call_command('seed_content')
|
||||||
|
|||||||
Reference in New Issue
Block a user