There will be no courses. What was there: one draft called "jk" with two empty lessons, and 4,000 lines of code around it — courses, modules, lessons, enrolments, per-lesson progress, SCORM, BigBlueButton, completion certificates, three React pages, a router, two models. Its real cost was everywhere else. Every query that measured practice had to remember `Quiz.course_id.is_(None)`, and forgetting it in one place would have silently mixed course attempts into a learner's analytics; the bank predicate carried a subquery to exclude a course's own questions from every search, recommendation and share; quiz access had a second, parallel rule about enrolment. All of that is gone, so the remaining rules say what they mean. `quizzes.allow_review` goes with it. It was only ever enforced for a course quiz, so it had become a promise nothing keeps — the public session page was still offering "no answer review" about sessions that review fine. The fixtures' question 5 lived in a course quiz and stood for "a question that exists but is not in your bank". There is no such thing now — a question is in the bank unless it is deleted — so the counts it kept out of the numbers are back in, and the tests that turned on it now turn on deletion or on the attempt that actually holds a question. Files the LMS uploaded stay on disk and stay protected: LEGACY_LMS_PREFIXES in app/utils/upload_access.py is what keeps them unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
88 lines
4.2 KiB
Python
88 lines
4.2 KiB
Python
"""Turning a chat turn into a session.
|
|
|
|
The chat is for finding out what you do not know; the point of finding out is
|
|
to go and practise it. What is under test is which questions that lands on,
|
|
and that a chat cannot reach a question the learner could not otherwise see.
|
|
"""
|
|
import unittest
|
|
from datetime import datetime
|
|
from unittest.mock import patch
|
|
|
|
import test_quiz_builder as fixtures
|
|
from app.models.conversation import Conversation, ConversationMessage
|
|
from app.routers import ai_mode
|
|
|
|
|
|
class PracticeFromChatTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.bank = fixtures.BuilderTests()
|
|
self.bank.setUp()
|
|
self.client = self.bank.client
|
|
self.db = self.bank.db
|
|
self.client.app.include_router(ai_mode.router, prefix='/ai')
|
|
|
|
def tearDown(self):
|
|
self.bank.tearDown()
|
|
|
|
def thread(self, citations, asked='What causes stridor?'):
|
|
conversation = Conversation(user_id=1, title='Stridor')
|
|
self.db.add(conversation)
|
|
self.db.flush()
|
|
self.db.add(ConversationMessage(conversation_id=conversation.id, role='user',
|
|
content=asked, citations=[]))
|
|
answer = ConversationMessage(conversation_id=conversation.id, role='assistant',
|
|
content='Croup, mostly.', citations=citations)
|
|
self.db.add(answer)
|
|
self.db.commit()
|
|
return conversation.id, answer.id
|
|
|
|
def practise(self, conversation_id, **body):
|
|
with patch('app.services.search_service.hybrid_ids', return_value=([], {})):
|
|
return self.client.post(f'/ai/conversations/{conversation_id}/practice', json=body)
|
|
|
|
def test_a_cited_question_is_the_session(self):
|
|
cid, _ = self.thread([{'marker': '[[question:1]]', 'kind': 'question', 'id': 1,
|
|
'title': 'Question #1'}])
|
|
response = self.practise(cid)
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
self.assertEqual(response.json()['count'], 1)
|
|
quiz = self.client.get(f"/quizzes/{response.json()['quiz_id']}").json()
|
|
self.assertEqual([q['id'] for q in quiz['questions']], [1])
|
|
# Study mode, never exam: this is reading followed by practice.
|
|
self.assertEqual(quiz['mode'], 'learning')
|
|
|
|
def test_a_cited_article_brings_the_questions_filed_under_its_topic(self):
|
|
from app.models.article import Article
|
|
# The fixture files questions 1 and 2 under categories 1 and 2.
|
|
self.db.add(Article(id=5, title='Croup', slug='croup', content='…',
|
|
status='published', user_id=3, category_id=2))
|
|
self.db.commit()
|
|
cid, _ = self.thread([{'marker': '[[article:5]]', 'kind': 'article', 'id': 5,
|
|
'title': 'Croup'}])
|
|
response = self.practise(cid)
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
quiz = self.client.get(f"/quizzes/{response.json()['quiz_id']}").json()
|
|
self.assertEqual(sorted(q['id'] for q in quiz['questions']), [2, 4, 5])
|
|
|
|
def test_a_chat_cannot_reach_a_question_the_learner_may_not_see(self):
|
|
# Question 4 has been removed from the bank. A citation could only name
|
|
# it by mistake; the bank's own rules answer anyway.
|
|
self.bank.db.get(fixtures.Question, 4).deleted_at = datetime(2026, 1, 1)
|
|
self.bank.db.commit()
|
|
cid, _ = self.thread([{'marker': '[[question:4]]', 'kind': 'question', 'id': 4,
|
|
'title': 'Question #4'}])
|
|
self.assertEqual(self.practise(cid).status_code, 404)
|
|
|
|
def test_nothing_to_practise_says_so(self):
|
|
cid, _ = self.thread([])
|
|
response = self.practise(cid)
|
|
self.assertEqual(response.status_code, 404)
|
|
self.assertIn('No questions', response.json()['detail'])
|
|
|
|
def test_one_turn_can_be_named_and_another_learner_cannot_ask(self):
|
|
cid, answer_id = self.thread([{'marker': '[[question:1]]', 'kind': 'question',
|
|
'id': 1, 'title': 'Question #1'}])
|
|
self.assertEqual(self.practise(cid, message_id=answer_id).status_code, 200)
|
|
self.assertEqual(self.practise(cid, message_id=99999).status_code, 404)
|
|
self.bank.user = self.bank.peer
|
|
self.assertEqual(self.practise(cid).status_code, 404)
|