Files
MathApp/content/seed_sync.py

293 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
وارد کردنِ محتوای ریپو در دیپلوی، بدونِ برگرداندنِ کارِ ادمین (`seed_content`).
دو منبع داریم: فایل‌های `content/seed/` که با هر دیپلوی می‌آیند، و پنلِ ادمین. تا ۲۰۲۶-۱۰-۰۳
سید هر چه در فایل بود و در دیتابیس پیدا نمی‌کرد دوباره می‌ساخت؛ با تستِ دورریختنی دیده شد که
سوالِ پاک‌شده برمی‌گشت، سوالِ اصلاح‌شده نسخه‌ی غلطش را کنارش می‌گرفت، تغییرِ نامِ یک فصل ۳۳
درس و ۴۴ سوالِ تکراری می‌ساخت، و متنِ توضیحِ هر درس با متنِ فایل بازنویسی می‌شد.
قاعده‌ی تازه، با دفترِ `SeedRecord`:
- چیزی که در دیتابیس هست: ثبت می‌شود و دست نمی‌خورد (جز متنِ توضیح، پایین).
- چیزی که نیست و قبلاً هم تحویل نشده: ساخته و ثبت می‌شود (محتوای تازه‌ی ریپو).
- چیزی که نیست ولی قبلاً تحویل شده: ادمین پاکش کرده یا نامش را عوض کرده؛ ساخته نمی‌شود و
فقط شمرده می‌شود (`retired`). زیرمجموعه‌اش هم ساخته نمی‌شود.
- متنِ توضیحِ درس: اگر متنِ دیتابیس همان است که سید آخرین بار نوشت، متنِ تازه‌ی فایل جایش
می‌نشیند؛ اگر ادمین عوضش کرده، متنِ ادمین می‌ماند.
دیتابیسِ بی‌محتوا (سرورِ تازه، یا بازگرداندن پس از پاک شدنِ همه‌چیز) دفتر را نادیده می‌گیرد و
همه‌چیز را از فایل می‌سازد.
آن‌چه آزموده نشده: اولین دیپلو روی سرورِ فعلی دفتری خالی دارد؛ هر چه ادمین تا آن لحظه پاک کرده
همان یک بارِ آخر دوباره ساخته می‌شود (رفتارِ قبلی)، و از آن به بعد نه. متنِ توضیحی که در آن لحظه
با فایل فرق دارد، کارِ ادمین فرض می‌شود و هرگز از فایل بازنویسی نمی‌شود.
"""
import hashlib
import json
from django.db import transaction
from . import question_io
from .curriculum_io import _subject_or_error
from .models import Grade, Lesson, Node, Question, SeedRecord, Unit
def _key(kind: str, *parts) -> str:
return hashlib.sha1('\x1f'.join((kind, *map(str, parts))).encode('utf-8')).hexdigest()
def _text_hash(text: str) -> str:
return hashlib.sha1((text or '').encode('utf-8')).hexdigest()
class _Ledger:
def __init__(self, active: bool):
self.known = dict(SeedRecord.objects.values_list('key', 'value')) if active else {}
self.write: dict[str, tuple[str, str]] = {}
def delivered(self, key: str) -> bool:
return key in self.known or key in self.write
def value(self, key: str):
if key in self.write:
return self.write[key][1]
return self.known.get(key)
def record(self, key: str, kind: str, value: str = '') -> None:
if self.known.get(key) != value or key not in self.known:
self.write[key] = (kind, value)
def save(self) -> None:
existing = set(SeedRecord.objects.filter(key__in=list(self.write)).values_list('key', flat=True))
SeedRecord.objects.bulk_create([
SeedRecord(key=k, kind=kind, value=value)
for k, (kind, value) in self.write.items() if k not in existing
])
for k, (_kind, value) in self.write.items():
if k in existing:
SeedRecord.objects.filter(key=k).update(value=value)
@transaction.atomic
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, 'reordered': 0,
'kept_admin_text': 0, 'retired': 0, 'created': 0, 'skipped': 0}
for g in grades:
gpath = (g['title'],)
grade = Grade.objects.filter(title=g['title']).first()
if grade is None:
if ledger.delivered(_key('grade', *gpath)):
counts['retired'] += 1
continue
grade = Grade.objects.create(title=g['title'], icon=g['icon'], color=g['color'])
counts['grades'] += 1
ledger.record(_key('grade', *gpath), 'grade')
for u in g['units']:
upath = gpath + (u['title'],)
unit = Unit.objects.filter(grade=grade, title=u['title']).first()
if unit is None:
if ledger.delivered(_key('unit', *upath)):
counts['retired'] += 1
continue
unit = Unit.objects.create(
grade=grade, title=u['title'], order=u['order'],
subject=_subject_or_error(u['subject']),
)
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'])
for n in u['nodes']:
npath = upath + (n['title'],)
node = Node.objects.filter(unit=unit, title=n['title']).first()
if node is None:
if ledger.delivered(_key('node', *npath)):
counts['retired'] += 1
continue
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 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, position):
intro_key = _key('intro', *path)
lesson = Lesson.objects.filter(node=node, title=row['title']).first()
if lesson is None:
if ledger.delivered(_key('lesson', *path)):
counts['retired'] += 1
return
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')
ledger.record(intro_key, 'intro', _text_hash(lesson.intro_text))
return
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:
untouched = (last_seeded is not None and _text_hash(db_text) == last_seeded) or not db_text
if untouched:
lesson.intro_text = file_text
changed.append('intro_text')
ledger.record(intro_key, 'intro', _text_hash(file_text))
else:
counts['kept_admin_text'] += 1
elif db_text == file_text:
ledger.record(intro_key, 'intro', _text_hash(db_text))
# مثال و قدم‌ها فقط در خانه‌ی خالی (همان قاعده‌ی curriculum_io).
if row.get('example') and not lesson.example:
lesson.example = row['example']
changed.append('example')
if row.get('steps') and not lesson.steps:
lesson.steps = row['steps']
changed.append('steps')
if row.get('figures') and not lesson.figures:
lesson.figures = row['figures']
changed.append('figures')
if changed:
lesson.save(update_fields=changed)
counts['updated'] += 1
def _sync_questions(rows, ledger, counts):
if not rows:
return
lessons = {
(l.node.unit.grade.title, l.node.unit.title, l.node.title, l.title): l.pk
for l in Lesson.objects.select_related('node__unit__grade')
}
existing = set(
Question.objects.filter(lesson_id__in=lessons.values()).values_list('lesson_id', 'prompt')
)
to_create, missing = [], []
for index, row in enumerate(rows):
path = row.get('path')
if not isinstance(path, list) or len(path) != 4:
raise question_io.ImportError_(
f'ردیف {index + 1}: «path» باید چهار عنوان باشد (پایه، فصل، مبحث، درس).')
path = tuple(str(p).strip() for p in path)
prompt = (row.get('prompt') or '').strip()
qkey = _key('question', *path, prompt)
lesson_id = lessons.get(path)
if lesson_id is None:
# درسی که سید قبلاً داده و حالا نیست (ادمین پاک یا نامش را عوض کرده): سوالش هم نمی‌آید.
if ledger.delivered(_key('lesson', *path)) or ledger.delivered(qkey):
counts['retired'] += 1
continue
missing.append(' › '.join(path))
continue
if (lesson_id, prompt) in existing:
ledger.record(qkey, 'question')
counts['skipped'] += 1
continue
if ledger.delivered(qkey):
counts['retired'] += 1
continue
to_create.append(({**{k: v for k, v in row.items() if k != 'path'}, 'lessonId': lesson_id}, qkey))
existing.add((lesson_id, prompt))
_refresh_practice(rows, lessons, ledger, counts)
if missing:
raise question_io.ImportError_(
'این درس‌ها در برنامه‌ی درسی پیدا نشدند (اول کوریکولوم را وارد کنید): '
+ '؛ '.join(missing[:5]) + ('…' if len(missing) > 5 else '')
)
if not to_create:
return
parsed = question_io.parse(json.dumps([r for r, _ in to_create]).encode('utf-8'))
result = question_io.import_questions(parsed)
counts['created'] += result['created']
for _row, qkey in to_create:
ledger.record(qkey, 'question')
PRACTICE_PREFIX = 'تمرین: '
def _refresh_practice(rows, lessons, ledger, counts):
"""
جزیره‌های «تمرین: …» را فایل می‌سازد و ادمین سوال‌هایش را کم‌وبیش دست نمی‌زند؛ پس اصلاحِ فایل
(گزینه‌ی هم‌ارزِ جواب، جوابِ غلط) باید به سوالِ موجود برسد، وگرنه سید فقط «سوالِ تازه» می‌سازد و نسخه‌ی
معیوب می‌ماند:
- همان صورت، گزینه‌ها/پاسخِ درست فرق دارد → گزینه‌ها جا به جا (همان idها) به‌روز می‌شود تا پاسخ‌های
ثبت‌شده نشکنند؛
- سوالی که خودِ سید قبلاً تحویل داده (دفتر) و حالا در فایل نیست و کسی جوابش نداده → پاک می‌شود.
سوالِ دستیِ ادمین (در دفتر نیست) هرگز دست نمی‌خورد. آزموده: content/tests_seed_content.py.
"""
practice_lessons = {
lid for path, lid in lessons.items() if path[2].startswith(PRACTICE_PREFIX)
}
if not practice_lessons:
return
want = {}
file_keys = set()
for row in rows:
path = tuple(str(p).strip() for p in row.get('path', []))
if len(path) != 4 or not path[2].startswith(PRACTICE_PREFIX):
continue
lid = lessons.get(path)
prompt = (row.get('prompt') or '').strip()
if lid is None:
continue
want[(lid, prompt)] = row
file_keys.add(_key('question', *path, prompt))
by_id = {}
for lid, path in ((v, k) for k, v in lessons.items() if v in practice_lessons):
by_id[lid] = path
qs = Question.objects.filter(lesson_id__in=practice_lessons).prefetch_related('choices')
stale = []
for q in qs.iterator(chunk_size=500):
row = want.get((q.lesson_id, q.prompt))
if row is None:
if _key('question', *by_id[q.lesson_id], q.prompt) in ledger.known:
stale.append(q.pk)
continue
new_choices = [str(c).strip() for c in row['choices']]
old = sorted(q.choices.all(), key=lambda c: c.pk)
if len(old) != len(new_choices):
continue
for index, choice in enumerate(old):
value, correct = new_choices[index], index == row['correct']
if choice.value != value or choice.is_correct != correct:
choice.value, choice.is_correct = value, correct
choice.save(update_fields=['value', 'is_correct'])
counts['updated'] += 1
if stale:
removable = list(Question.objects.filter(pk__in=stale, answers__isnull=True).values_list('pk', flat=True))
if removable:
Question.objects.filter(pk__in=removable).delete()
counts['retired'] += len(removable)