13,689 new quiz questions in 160 practice islands (grades 3-12), Question.figure, replay rotates the quiz, LaTeX removed from lesson/question text
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
12
content/migrations/0019_question_figure.py
Normal file
12
content/migrations/0019_question_figure.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('content', '0018_split_long_calculus_nodes')]
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='figure',
|
||||
field=models.JSONField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
54
content/migrations/0020_plain_math_lesson_texts.py
Normal file
54
content/migrations/0020_plain_math_lesson_texts.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
متنِ درسهایی که LaTeX خام داشتند (`\( \vec{a} \)`، `^{n}`) به متنِ سادهی یونیکد برمیگردد.
|
||||
|
||||
فرانت LaTeX رندر نمیکند، پس دانشآموز بکاسلش و آکولاد میدید (درسهای بردارِ دوازدهم).
|
||||
فایلهای seed همین تبدیل را گرفتهاند، ولی `seed_content` متنِ مثال/قدمهای درسِ موجود را
|
||||
عوض نمیکند (فقط خانهی خالی را پر میکند)، پس سرور بیاین migration همان متنِ خراب را نگه میداشت.
|
||||
|
||||
تبدیل در `content/plain_math.py` است و idempotent: بار دوم هیچچیز عوض نمیشود. معکوس ندارد
|
||||
(متنِ قدیم بیفایده بود). نمودارها: فقط `FIGURE_TEXT_KEYS`، نه `expr`.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
from content.plain_math import convert, convert_figures
|
||||
|
||||
|
||||
def _h(text):
|
||||
return hashlib.sha1((text or '').encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _key(*parts):
|
||||
return hashlib.sha1('\x1f'.join(('intro', *map(str, parts))).encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
Lesson = apps.get_model('content', 'Lesson')
|
||||
SeedRecord = apps.get_model('content', 'SeedRecord')
|
||||
qs = Lesson.objects.select_related('node__unit__grade')
|
||||
for lesson in qs.iterator(chunk_size=500):
|
||||
new = {
|
||||
'intro_text': convert(lesson.intro_text),
|
||||
'example': convert(lesson.example),
|
||||
'steps': [convert(s) for s in (lesson.steps or [])],
|
||||
'figures': convert_figures(lesson.figures or []),
|
||||
}
|
||||
changed = [f for f, v in new.items() if v != getattr(lesson, f)]
|
||||
if not changed:
|
||||
continue
|
||||
old_intro = lesson.intro_text
|
||||
for f in changed:
|
||||
setattr(lesson, f, new[f])
|
||||
lesson.save(update_fields=changed)
|
||||
if 'intro_text' in changed:
|
||||
# دفترِ seed «آخرین متنی که خودمان نوشتیم» را با هش نگه میدارد؛ هشِ قدیم بماند
|
||||
# متنِ تازه «کارِ ادمین» حساب میشود و دیگر از فایل تازه نمیشود.
|
||||
n = lesson.node
|
||||
key = _key(n.unit.grade.title, n.unit.title, n.title, lesson.title)
|
||||
SeedRecord.objects.filter(key=key, value=_h(old_intro)).update(value=_h(new['intro_text']))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('content', '0019_question_figure')]
|
||||
operations = [migrations.RunPython(forwards, migrations.RunPython.noop)]
|
||||
@@ -167,6 +167,9 @@ class Question(models.Model):
|
||||
type = models.CharField(max_length=30, choices=TYPE_CHOICES)
|
||||
prompt = models.TextField()
|
||||
asset_group = models.CharField(max_length=100, blank=True)
|
||||
# شکلِ سوال (همان قالبِ `Lesson.figures[].diagram`، اعتبارسنجیشده با `rag.services.diagram.validate`).
|
||||
# null یعنی سوال شکل ندارد. هنگامِ نمایش هیچ فراخوانیِ مدل نیست؛ فرانت فقط میکشد.
|
||||
figure = models.JSONField(null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.prompt[:50]
|
||||
|
||||
96
content/plain_math.py
Normal file
96
content/plain_math.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
LaTeX → متنِ سادهی یونیکد، برای متنِ درسها و سوالها.
|
||||
|
||||
چرا: فرانت (`MathText`) هیچ LaTeXای رندر نمیکند؛ هر `\\( \\vec{a} \\)` که در متنِ درس بماند
|
||||
عیناً با بکاسلش و آکولاد دیده میشود (گزارشِ مالک از درسهای بردارِ دوازدهم، ۲۰۲۶-۱۰-۱۲).
|
||||
قراردادِ متنِ کتابها: بردارها بیفلش («بردار a»، «|a|»)، توان با نویسهی بالانویس (²، ⁿ).
|
||||
|
||||
فقط فرمانهایی که در seed دیده شد پشتیبانی میشود. فرمانِ ناشناس **تغییر نمیکند** و
|
||||
`leftover()` آن را برمیگرداند تا تست بشکند، نه اینکه بیصدا حدس زده شود.
|
||||
"""
|
||||
import re
|
||||
|
||||
_SUP = {**{str(i): c for i, c in enumerate('⁰¹²³⁴⁵⁶⁷⁸⁹')}, **{'۰۱۲۳۴۵۶۷۸۹'[i]: c for i, c in enumerate('⁰¹²³⁴⁵⁶⁷⁸⁹')}, '+': '⁺', '-': '⁻', '−': '⁻', '=': '⁼',
|
||||
'(': '⁽', ')': '⁾', 'n': 'ⁿ', 'i': 'ⁱ', 'x': 'ˣ', 'y': 'ʸ', 'k': 'ᵏ', 'm': 'ᵐ', 'p': 'ᵖ', 't': 'ᵗ'}
|
||||
_SUB = {**{str(i): c for i, c in enumerate('₀₁₂₃₄₅₆₇₈₉')}, **{'۰۱۲۳۴۵۶۷۸۹'[i]: c for i, c in enumerate('₀₁₂₃₄₅₆₇₈₉')}, '+': '₊', '-': '₋', '−': '₋', '=': '₌',
|
||||
'(': '₍', ')': '₎', 'n': 'ₙ', 'i': 'ᵢ', 'x': 'ₓ', 'k': 'ₖ', 'm': 'ₘ', 'p': 'ₚ', 't': 'ₜ'}
|
||||
|
||||
_SYMBOLS = {
|
||||
'times': '×', 'cdot': '·', 'div': '÷', 'pm': '±', 'theta': 'θ', 'pi': 'π', 'alpha': 'α', 'beta': 'β',
|
||||
'infty': '∞', 'neq': '≠', 'ne': '≠', 'leq': '≤', 'le': '≤', 'geq': '≥', 'ge': '≥', 'to': '→',
|
||||
'rightarrow': '→', 'Rightarrow': '⇒', 'perp': '⊥', 'parallel': '∥', 'circ': '°', 'ldots': '…', 'dots': '…',
|
||||
'lim': 'lim', 'sin': 'sin', 'cos': 'cos', 'tan': 'tan', 'log': 'log', 'ln': 'ln',
|
||||
}
|
||||
_BLACKBOARD = {'R': 'ℝ', 'Q': 'ℚ', 'N': 'ℕ', 'Z': 'ℤ', 'C': 'ℂ'}
|
||||
|
||||
_GROUP = r'\{([^{}]*)\}'
|
||||
|
||||
|
||||
def _script(body: str, table: dict, marker: str) -> str:
|
||||
body = body.strip()
|
||||
if body and all(ch in table for ch in body):
|
||||
return ''.join(table[ch] for ch in body)
|
||||
return f'{marker}({body})'
|
||||
|
||||
|
||||
def _try(body: str, table: dict):
|
||||
body = body.strip()
|
||||
return ''.join(table[ch] for ch in body) if body and all(ch in table for ch in body) else None
|
||||
|
||||
|
||||
def _atomic(s: str) -> bool:
|
||||
return bool(re.fullmatch(r'[\w۰-۹.]+', s.strip()))
|
||||
|
||||
|
||||
def _wrap(s: str) -> str:
|
||||
s = s.strip()
|
||||
return s if _atomic(s) else f'({s})'
|
||||
|
||||
|
||||
def convert(text: str) -> str:
|
||||
if not isinstance(text, str) or not re.search(r'[\\^]|[A-Za-z]_', text):
|
||||
return text
|
||||
s = text
|
||||
s = re.sub(r'\\[(\[]\s*|\s*\\[)\]]', '', s) # \( \) \[ \] با فاصلهی داخلشان
|
||||
for _ in range(4): # آکولادِ تودرتو، از داخل به بیرون
|
||||
s = re.sub(r'\\vec' + _GROUP, r'\1', s)
|
||||
s = re.sub(r'\\(?:overrightarrow|overline|bar|hat)' + _GROUP, r'\1', s)
|
||||
s = re.sub(r'\\mathbb' + _GROUP, lambda m: _BLACKBOARD.get(m[1], m[1]), s)
|
||||
s = re.sub(r'\\(?:text|mathrm|operatorname)' + _GROUP, r'\1', s)
|
||||
s = re.sub(r'\\frac' + _GROUP + _GROUP, lambda m: f'{_wrap(m[1])}/{_wrap(m[2])}', s)
|
||||
s = re.sub(r'\\sqrt' + _GROUP, lambda m: '√' + _wrap(m[1]), s)
|
||||
s = re.sub(r'\\lim\s*_' + _GROUP, r'lim(\1)', s) # lim_{x→0} پایینِ حد است، نه زیرنویس
|
||||
s = re.sub(r'\\(left|right)\s*', '', s)
|
||||
s = s.replace('\\|', '|').replace('\\{', '{').replace('\\}', '}').replace('\\,', ' ').replace('\\ ', ' ')
|
||||
s = re.sub(r'\\([A-Za-z]+)', lambda m: _SYMBOLS.get(m[1], m[0]), s)
|
||||
s = re.sub(r'\^' + _GROUP, lambda m: _script(m[1], _SUP, '^'), s)
|
||||
s = re.sub(r'_' + _GROUP, lambda m: _script(m[1], _SUB, '_'), s)
|
||||
# شکلهای بیآکولاد که کتابها و مدلها نوشتهاند: a^2، a^n، a^(m-n)، x_1. هر چه نویسهی
|
||||
# بالانویس/زیرنویس ندارد (a^b، D_f) همانطور میماند. «___» (جای خالی) دست نمیخورد.
|
||||
s = re.sub(r'\^\(([^()]*)\)', lambda m: _try(m[1], _SUP) or m[0], s)
|
||||
s = re.sub(r'\^([+\-−]?[0-9۰-۹]+|[nixykmpt])(?![A-Za-z0-9۰-۹])', lambda m: _try(m[1], _SUP) or m[0], s)
|
||||
s = re.sub(r'(?<=[A-Za-z\)])_([0-9۰-۹]+|[nikmpt])(?![A-Za-z0-9۰-۹])', lambda m: _try(m[1], _SUB) or m[0], s)
|
||||
return s
|
||||
|
||||
|
||||
_LEFTOVER = re.compile(r'\\[A-Za-z]+|\\[()\[\]]|\$|[_^]\{')
|
||||
|
||||
|
||||
def leftover(text: str) -> list[str]:
|
||||
return _LEFTOVER.findall(text) if isinstance(text, str) else []
|
||||
|
||||
|
||||
# کلیدهایی که در `Lesson.figures` متنِ نمایشیاند. `expr` (فرمولِ نمودار) و نامِ نقطهها
|
||||
# (`point`/`from`/`to`) هرگز نباید بازنویسی شوند: `x^2` در expr را به «x²» ببری نمودار میشکند.
|
||||
FIGURE_TEXT_KEYS = frozenset({'title', 'text', 'label', 'labels'})
|
||||
|
||||
|
||||
def convert_figures(obj, _key=None):
|
||||
"""فقط رشتههای زیرِ `FIGURE_TEXT_KEYS` را (در هر عمقی) تبدیل میکند."""
|
||||
if isinstance(obj, str):
|
||||
return convert(obj) if _key in FIGURE_TEXT_KEYS else obj
|
||||
if isinstance(obj, list):
|
||||
return [convert_figures(v, _key) for v in obj]
|
||||
if isinstance(obj, dict):
|
||||
return {k: convert_figures(v, k) for k, v in obj.items()}
|
||||
return obj
|
||||
@@ -24,6 +24,8 @@ import json
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from rag.services import diagram as diagram_spec
|
||||
|
||||
from .models import Choice, Lesson, Question
|
||||
|
||||
VALID_TYPES = {value for value, _ in Question.TYPE_CHOICES}
|
||||
@@ -63,12 +65,20 @@ def _validate_row(row, index: int) -> dict:
|
||||
if not isinstance(correct, int) or not (0 <= correct < len(choices)):
|
||||
raise ImportError_(f'{where}: correct باید شمارهی یکی از گزینهها باشد (از صفر).')
|
||||
|
||||
figure = row.get('figure')
|
||||
if figure is not None:
|
||||
# شکلِ نامعتبر بیصدا حذف نمیشود (برخلافِ دستیار): فایلِ محتوا باید خراببودنش را نشان دهد.
|
||||
figure = diagram_spec.validate(figure) if isinstance(figure, dict) else None
|
||||
if figure is None:
|
||||
raise ImportError_(f'{where}: «figure» شکلِ معتبر نیست (قالبِ rag.services.diagram).')
|
||||
|
||||
return {
|
||||
'lesson_id': lesson_id,
|
||||
'prompt': prompt,
|
||||
'type': qtype,
|
||||
'choices': choices,
|
||||
'correct': correct,
|
||||
'figure': figure,
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +145,7 @@ def import_questions(
|
||||
continue
|
||||
question = Question.objects.create(
|
||||
lesson_id=row['lesson_id'], type=row['type'], prompt=row['prompt'],
|
||||
figure=row.get('figure'),
|
||||
)
|
||||
Choice.objects.bulk_create([
|
||||
Choice(question=question, value=value, is_correct=(i == row['correct']))
|
||||
@@ -157,13 +168,16 @@ def export_questions(queryset) -> list[dict]:
|
||||
for question in queryset.prefetch_related('choices').select_related('lesson'):
|
||||
choices = list(question.choices.all())
|
||||
correct = next((i for i, c in enumerate(choices) if c.is_correct), 0)
|
||||
out.append({
|
||||
row = {
|
||||
'lessonId': question.lesson_id,
|
||||
'prompt': question.prompt,
|
||||
'type': question.type,
|
||||
'choices': [c.value for c in choices],
|
||||
'correct': correct,
|
||||
})
|
||||
}
|
||||
if question.figure:
|
||||
row['figure'] = question.figure
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
@@ -189,13 +203,16 @@ def export_questions_by_path(queryset) -> list[dict]:
|
||||
correct = next((i for i, c in enumerate(choices) if c.is_correct), 0)
|
||||
lesson = question.lesson
|
||||
node = lesson.node
|
||||
out.append({
|
||||
row = {
|
||||
'path': [node.unit.grade.title, node.unit.title, node.title, lesson.title],
|
||||
'prompt': question.prompt,
|
||||
'type': question.type,
|
||||
'choices': [c.value for c in choices],
|
||||
'correct': correct,
|
||||
})
|
||||
}
|
||||
if question.figure:
|
||||
row['figure'] = question.figure
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
254153
content/seed/questions.json
254153
content/seed/questions.json
File diff suppressed because it is too large
Load Diff
@@ -138,6 +138,8 @@ class QuestionSerializer(serializers.Serializer):
|
||||
type = serializers.CharField()
|
||||
prompt = serializers.CharField()
|
||||
options = ChoiceOptionSerializer(many=True)
|
||||
# شکلِ سوال (توصیفِ JSON مثلِ `LessonFigure.diagram`)؛ null یعنی شکل ندارد.
|
||||
figure = serializers.JSONField(allow_null=True, required=False)
|
||||
|
||||
|
||||
class QuizQuestionSerializer(serializers.Serializer):
|
||||
@@ -155,6 +157,7 @@ class QuizQuestionSerializer(serializers.Serializer):
|
||||
type = serializers.CharField()
|
||||
prompt = serializers.CharField()
|
||||
options = ChoiceOptionSerializer(many=True)
|
||||
figure = serializers.JSONField(allow_null=True, required=False)
|
||||
|
||||
|
||||
class NodeQuizResponseSerializer(serializers.Serializer):
|
||||
|
||||
@@ -231,3 +231,37 @@ class NodeQuizCompleteTests(TestCase):
|
||||
|
||||
self.assertEqual(body['stats']['progressPercent'], 10.0) # ۱ از ۱۰ درس
|
||||
self.assertTrue(UserLessonCompletion.objects.filter(user_id=self.user.id).exists())
|
||||
|
||||
|
||||
class NodeQuizRotationTests(TestCase):
|
||||
"""دوبارهبازیِ یک جزیره آزمونِ تازه میدهد؛ رفرش تا پایانِ آزمون همان را میدهد."""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
|
||||
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
|
||||
self.node = Node.objects.create(unit=unit, title='اول', order=1)
|
||||
for i in range(4):
|
||||
lesson = Lesson.objects.create(node=self.node, title=f'درس {i}')
|
||||
for j in range(12):
|
||||
question = Question.objects.create(
|
||||
lesson=lesson, type='multiple_choice', prompt=f'سوال {i}-{j}')
|
||||
Choice.objects.create(question=question, value='الف', is_correct=True)
|
||||
Choice.objects.create(question=question, value='ب', is_correct=False)
|
||||
self.user = User.objects.create(phone='09120000019', grade_id=grade.id)
|
||||
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.user.id)}'}
|
||||
|
||||
def _ids(self):
|
||||
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz', **self.auth).json()
|
||||
return [q['id'] for q in body['questions']]
|
||||
|
||||
def test_refresh_keeps_the_quiz_until_it_is_completed(self):
|
||||
self.assertEqual(self._ids(), self._ids())
|
||||
|
||||
def test_each_completed_round_draws_a_fresh_quiz(self):
|
||||
first = self._ids()
|
||||
UserNodeCompletion.objects.create(
|
||||
user_id=self.user.id, node=self.node, correct_count=4, total_questions=4, stars=3)
|
||||
second = self._ids()
|
||||
self.assertNotEqual(first, second)
|
||||
self.assertEqual(second, self._ids())
|
||||
|
||||
52
content/tests_plain_math.py
Normal file
52
content/tests_plain_math.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from content.plain_math import convert, convert_figures, leftover
|
||||
|
||||
SEED = Path(__file__).parent / 'seed' / 'curriculum.json'
|
||||
|
||||
|
||||
class ConvertTests(SimpleTestCase):
|
||||
def test_vector_and_sets(self):
|
||||
self.assertEqual(convert(r'بردار \( \vec{a} \) و \( \vec{AB} \)'), 'بردار a و AB')
|
||||
self.assertEqual(convert(r'از \( \mathbb{Q} \) و \( \mathbb{R} \)'), 'از ℚ و ℝ')
|
||||
|
||||
def test_norm_sqrt_powers(self):
|
||||
self.assertEqual(convert(r'\( \|\vec{a}\| = \sqrt{x^2 + y^2} \)'), '|a| = √(x² + y²)')
|
||||
self.assertEqual(convert('(−۱)^{۲+۲}'), '(−۱)²⁺²')
|
||||
|
||||
def test_unmappable_script_is_kept_readable_not_dropped(self):
|
||||
self.assertEqual(convert('D_{g}'), 'D_(g)')
|
||||
self.assertEqual(convert('a^b'), 'a^b')
|
||||
|
||||
def test_blank_placeholder_untouched(self):
|
||||
self.assertEqual(convert('3، 6، ___، 12'), '3، 6، ___، 12')
|
||||
|
||||
def test_idempotent(self):
|
||||
s = r'\( \frac{1}{2} \vec{a}^{n} \)'
|
||||
self.assertEqual(convert(convert(s)), convert(s))
|
||||
|
||||
def test_figure_expr_is_never_rewritten(self):
|
||||
fig = [{'diagram': {'items': [{'type': 'function', 'expr': 'x^2', 'label': 'f(x)=x^2'}]}}]
|
||||
out = convert_figures(fig)[0]['diagram']['items'][0]
|
||||
self.assertEqual(out['expr'], 'x^2')
|
||||
self.assertEqual(out['label'], 'f(x)=x²')
|
||||
|
||||
|
||||
class SeedHasNoLatexTests(SimpleTestCase):
|
||||
"""گزارشِ مالک ۲۰۲۶-۱۰-۱۲: LaTeX خام در درسهای بردار. فرانت LaTeX رندر نمیکند."""
|
||||
|
||||
def test_no_latex_in_lesson_text(self):
|
||||
data = json.loads(SEED.read_text(encoding='utf-8'))
|
||||
bad = []
|
||||
for g in data['grades']:
|
||||
for u in g['units']:
|
||||
for n in u['nodes']:
|
||||
for l in n['lessons']:
|
||||
texts = [l.get('introText'), l.get('example'), *(l.get('steps') or [])]
|
||||
for t in texts:
|
||||
if leftover(t):
|
||||
bad.append((l['title'], leftover(t)))
|
||||
self.assertEqual(bad, [])
|
||||
46
content/tests_question_figure.py
Normal file
46
content/tests_question_figure.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""شکلِ سوال: ورود از فایل، خروج، و پاسخِ API. شکلِ نامعتبر باید فایل را رد کند، نه بیصدا حذف شود."""
|
||||
import json
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
|
||||
from content import question_io
|
||||
from content.models import Grade, Lesson, Node, Question, Unit
|
||||
|
||||
FIGURE = {'kind': 'bar', 'title': 'میوهها', 'labels': ['سیب', 'پرتقال'], 'values': [5, 8]}
|
||||
|
||||
|
||||
class QuestionFigureTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
grade = Grade.objects.create(title='پایه سوم', icon='', color='')
|
||||
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
|
||||
self.node = Node.objects.create(unit=unit, title='آمار', order=1)
|
||||
self.lesson = Lesson.objects.create(node=self.node, title='نمودار')
|
||||
|
||||
def _row(self, **extra):
|
||||
return {'lessonId': self.lesson.pk, 'prompt': 'کدام میوه بیشتر است؟', 'type': 'multiple_choice',
|
||||
'choices': ['سیب', 'پرتقال'], 'correct': 1, **extra}
|
||||
|
||||
def test_valid_figure_is_stored_and_exported(self):
|
||||
rows = question_io.parse(json.dumps([self._row(figure=FIGURE)]).encode())
|
||||
question_io.import_questions(rows)
|
||||
q = Question.objects.get()
|
||||
self.assertEqual(q.figure['kind'], 'bar')
|
||||
exported = question_io.export_questions(Question.objects.all())
|
||||
self.assertEqual(exported[0]['figure']['kind'], 'bar')
|
||||
|
||||
def test_question_without_figure_exports_without_the_key(self):
|
||||
question_io.import_questions(question_io.parse(json.dumps([self._row()]).encode()))
|
||||
self.assertNotIn('figure', question_io.export_questions(Question.objects.all())[0])
|
||||
|
||||
def test_invalid_figure_rejects_the_file(self):
|
||||
with self.assertRaises(question_io.ImportError_):
|
||||
question_io.parse(json.dumps([self._row(figure={'kind': 'nope'})]).encode())
|
||||
|
||||
def test_quiz_api_returns_the_figure(self):
|
||||
question_io.import_questions(question_io.parse(json.dumps([self._row(figure=FIGURE)]).encode()))
|
||||
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=1').json()
|
||||
self.assertEqual(body['questions'][0]['figure']['kind'], 'bar')
|
||||
plain = self.client.get(f'/api/lessons/{self.lesson.pk}/questions').json()
|
||||
self.assertEqual(plain['questions'][0]['figure']['kind'], 'bar')
|
||||
@@ -589,6 +589,7 @@ class LessonQuestionsView(APIView):
|
||||
'type': q.type,
|
||||
'prompt': q.prompt,
|
||||
'options': [{'id': str(c.id), 'value': c.value} for c in q.choices.all()],
|
||||
'figure': q.figure,
|
||||
}
|
||||
for q in questions
|
||||
]}
|
||||
@@ -1035,7 +1036,15 @@ class NodeQuizView(APIView):
|
||||
# درهمریزیِ پایدار: همان کاربر روی همان مبحث همیشه همان ترتیب را
|
||||
# میبیند، ولی دو کاربرِ مختلف دو ترتیبِ مختلف.
|
||||
seed = request.query_params.get('seed') or str(getattr(request.user, 'id', '') or '')
|
||||
rng = random.Random(f'{node_id}:{seed}')
|
||||
# دوبارهبازیِ همان جزیره آزمونِ تازه میدهد: شمارِ آزمونهای تمامشدهی این کاربر روی این
|
||||
# جزیره در مبنای درهمریزی میآید. تا پایانِ آزمون عدد ثابت است، پس رفرش همان آزمون را
|
||||
# میدهد. با استخرِ یکسوالبهدرس عملاً چیزی عوض نمیشود؛ معنیاش با استخرِ بزرگ پیداست.
|
||||
# فقط برای کاربرِ واردشده و بی`?seed=`؛ نسنجیده: اثرش روی کشِ فرانت.
|
||||
rounds = 0
|
||||
if not request.query_params.get('seed') and getattr(request.user, 'id', None):
|
||||
rounds = UserNodeCompletion.objects.filter(
|
||||
user_id=request.user.id, node_id=node_id).count()
|
||||
rng = random.Random(f'{node_id}:{seed}:{rounds}' if rounds else f'{node_id}:{seed}')
|
||||
rng.shuffle(questions)
|
||||
|
||||
# حداکثر یک سوال از هر درس تا آزمون روی یک مهارت گیر نکند؛ اگر با این
|
||||
@@ -1069,6 +1078,7 @@ class NodeQuizView(APIView):
|
||||
'lessonTitle': q.lesson.title,
|
||||
'type': q.type,
|
||||
'prompt': q.prompt,
|
||||
'figure': q.figure,
|
||||
'options': [
|
||||
{'id': str(c.pk), 'value': c.value} for c in q.choices.all()
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user