manage questions from the admin panel, not the server terminal
تا الان تنها راهِ گذاشتنِ سوال روی یک سرور، زدنِ دستور در ترمینالِ همان سرور بود. یعنی هر تغییرِ محتوایی به کسی که SSH دارد وابسته میشد، تاریخچه نداشت و برگشتپذیر نبود. محتوا داده است نه کد؛ باید با رابطِ کاربری ویرایش شود. در /admin/content/question/ دکمهی «وارد کردنِ سوالها از فایل» اضافه شد، و اکشنِ «دریافتِ فایلِ سوالهای انتخابشده» خروجی میدهد. خروجی دقیقاً همان قالبی است که ورودی میپذیرد، پس چرخهی «بگیر → ویرایش کن → برگردان» بسته است و برای گرفتنِ نمونه هم لازم نیست کسی مستندات بخواند. قالب عمداً ساده است (lessonId، prompt، choices، correct) تا کسی که برنامهنویس نیست هم بتواند در ویرایشگرِ متن درستش کند. اعتبارسنجی قبل از هر نوشتنی کامل انجام میشود و کلِ فایل در یک تراکنش است: فایلِ نامعتبر هیچ چیزی نمینویسد. وارد کردنِ نیمهکاره بدترین حالت بود، چون کسی که فایل را داده فکر میکند کار شده ولی درس نصفه پر است. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
96
content/tests_question_io.py
Normal file
96
content/tests_question_io.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
تستهای وارد/خارج کردنِ سوالها.
|
||||
|
||||
نکتهی محوری: فایلِ بد نباید چیزی بنویسد. وارد کردنِ نیمهکاره بدترین حالت است
|
||||
چون کسی که فایل را داده فکر میکند کار شده، ولی درس نصفه پر است.
|
||||
"""
|
||||
import json
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from content.models import Choice, Grade, Lesson, Node, Question, Unit
|
||||
from content.question_io import ImportError_, export_questions, import_questions, parse
|
||||
|
||||
|
||||
class QuestionImportTests(TestCase):
|
||||
def setUp(self):
|
||||
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
|
||||
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
|
||||
node = Node.objects.create(unit=unit, title='درس ۱', order=1)
|
||||
self.lesson = Lesson.objects.create(node=node, title='نوع ۱')
|
||||
|
||||
def _payload(self, **over):
|
||||
row = {
|
||||
'lessonId': self.lesson.pk,
|
||||
'prompt': 'حاصل ۲+۳ چند است؟',
|
||||
'type': 'multiple_choice',
|
||||
'choices': ['۴', '۵', '۶', '۷'],
|
||||
'correct': 1,
|
||||
}
|
||||
row.update(over)
|
||||
return json.dumps([row], ensure_ascii=False).encode()
|
||||
|
||||
def test_a_valid_file_creates_the_question_and_marks_one_choice_correct(self):
|
||||
result = import_questions(parse(self._payload()))
|
||||
self.assertEqual(result['created'], 1)
|
||||
question = Question.objects.get()
|
||||
self.assertEqual(question.choices.count(), 4)
|
||||
self.assertEqual(question.choices.filter(is_correct=True).get().value, '۵')
|
||||
|
||||
def test_an_unknown_lesson_is_rejected_before_anything_is_written(self):
|
||||
with self.assertRaises(ImportError_):
|
||||
import_questions(parse(self._payload(lessonId=999999)))
|
||||
self.assertEqual(Question.objects.count(), 0)
|
||||
|
||||
def test_a_bad_row_in_the_middle_rolls_the_whole_file_back(self):
|
||||
good = json.loads(self._payload())[0]
|
||||
bad = dict(good, lessonId=999999)
|
||||
raw = json.dumps([good, bad], ensure_ascii=False).encode()
|
||||
with self.assertRaises(ImportError_):
|
||||
import_questions(parse(raw))
|
||||
self.assertEqual(Question.objects.count(), 0, 'نباید نیمهکاره بنویسد')
|
||||
|
||||
def test_correct_out_of_range_is_rejected(self):
|
||||
with self.assertRaises(ImportError_):
|
||||
parse(self._payload(correct=9))
|
||||
|
||||
def test_duplicate_choices_are_rejected(self):
|
||||
with self.assertRaises(ImportError_):
|
||||
parse(self._payload(choices=['۵', '۵', '۶', '۷']))
|
||||
|
||||
def test_broken_json_says_where_it_broke(self):
|
||||
with self.assertRaises(ImportError_) as ctx:
|
||||
parse(b'[{"lessonId": 1,,}]')
|
||||
self.assertIn('JSON', str(ctx.exception))
|
||||
|
||||
def test_replace_clears_the_previous_questions_of_that_lesson(self):
|
||||
import_questions(parse(self._payload()))
|
||||
import_questions(parse(self._payload(prompt='سوال تازه')), replace=True)
|
||||
self.assertEqual(Question.objects.count(), 1)
|
||||
self.assertEqual(Question.objects.get().prompt, 'سوال تازه')
|
||||
|
||||
def test_export_round_trips_back_through_import(self):
|
||||
import_questions(parse(self._payload()))
|
||||
exported = export_questions(Question.objects.all())
|
||||
Question.objects.all().delete()
|
||||
import_questions(parse(json.dumps(exported, ensure_ascii=False).encode()))
|
||||
self.assertEqual(Question.objects.get().choices.filter(is_correct=True).get().value, '۵')
|
||||
|
||||
|
||||
class HealthEndpointTests(TestCase):
|
||||
def test_reports_ok_and_counts_content(self):
|
||||
response = self.client.get('/api/health')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(body['status'], 'ok')
|
||||
self.assertTrue(body['checks']['database']['ok'])
|
||||
self.assertTrue(body['checks']['migrations']['ok'])
|
||||
self.assertIn('questions', body['checks']['content'])
|
||||
|
||||
def test_never_leaks_a_secret(self):
|
||||
raw = self.client.get('/api/health').content.decode()
|
||||
for needle in ('sk-', 'SECRET', 'API_KEY', 'password'):
|
||||
self.assertNotIn(needle, raw)
|
||||
|
||||
def test_needs_no_authentication(self):
|
||||
self.assertEqual(self.client.get('/api/health').status_code, 200)
|
||||
Reference in New Issue
Block a user