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
83 lines
4.9 KiB
Python
83 lines
4.9 KiB
Python
"""Additional question category assignments: counts, filters, validation and migration."""
|
|
import io
|
|
import os
|
|
import unittest
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import test_quiz_builder as fixtures
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
from alembic.script import ScriptDirectory
|
|
from app.models.question import Question
|
|
from app.models.question_category import QuestionCategoryLink
|
|
|
|
|
|
class MultiCategoryTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.bank = fixtures.BuilderTests()
|
|
self.bank.setUp()
|
|
self.client = self.bank.client
|
|
|
|
def tearDown(self):
|
|
self.bank.tearDown()
|
|
|
|
def test_additional_categories_update_counts_filters_and_bank_rows(self):
|
|
self.bank.user = self.bank.mod # Question management is moderator-only.
|
|
question = self.bank.db.get(Question, 3)
|
|
self.assertEqual(question.question_category_id, 3)
|
|
response = self.client.patch('/questions/3', json={'additional_category_ids': [4, 2]})
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
self.assertEqual(response.json()['question_category_id'], 3)
|
|
self.bank.user = self.bank.owner # Viewing counts stays open to learners.
|
|
cats = {cat['id']: cat['question_count'] for cat in self.client.get('/question-categories/').json()}
|
|
self.assertEqual(cats[4], 1) # Was an empty category; now includes the linked question.
|
|
self.assertEqual(cats[2], 4) # Questions 2, 4 and 5, plus the additional link on question 3.
|
|
self.assertEqual(cats[1], 5) # Question 3 was already counted through its primary (descendant of 1).
|
|
self.assertEqual(self.client.get('/questions/builder/count', params={'category_ids': [4]}).json()['count'], 1)
|
|
self.assertEqual(self.client.get('/questions/builder/count', params={'category_ids': [2]}).json()['count'], 4)
|
|
bank = self.client.get('/questions/bank', params={'category_ids': '4'}).json()
|
|
self.assertEqual([row['id'] for row in bank['questions']], [3])
|
|
self.assertEqual(set(bank['questions'][0]['category_ids']), {2, 3, 4})
|
|
self.assertEqual(self.client.get('/questions/bank/ids', params={'category_ids': '4'}).json(), [3])
|
|
# Category quiz creation counts extra-linked questions too.
|
|
created = self.client.post('/question-categories/4/create-quiz', params={'title': 'Linked quiz', 'mode': 'learning'})
|
|
self.assertEqual(created.status_code, 200, created.text)
|
|
# A removed question is not reachable through its category either.
|
|
self.bank.db.get(Question, 3).deleted_at = datetime(2026, 1, 1)
|
|
self.bank.db.commit()
|
|
self.assertEqual(self.client.get('/questions/builder/count', params={'category_ids': [4]}).json()['count'], 0)
|
|
|
|
def test_additional_categories_replace_validate_and_ignore_primary(self):
|
|
self.bank.user = self.bank.mod
|
|
self.assertEqual(self.client.patch('/questions/3', json={'additional_category_ids': [999]}).status_code, 400)
|
|
self.assertEqual(self.client.patch('/questions/3', json={'question_category_id': 999}).status_code, 400)
|
|
self.assertEqual(self.client.patch('/questions/3', json={'additional_category_ids': [3, 4, 4]}).status_code, 200)
|
|
links = {link.category_id for link in self.bank.db.query(QuestionCategoryLink).filter_by(question_id=3)}
|
|
self.assertEqual(links, {4}) # Primary is ignored; duplicates collapsed.
|
|
self.assertEqual(self.client.patch('/questions/3', json={'additional_category_ids': []}).status_code, 200)
|
|
self.assertEqual(self.bank.db.query(QuestionCategoryLink).filter_by(question_id=3).count(), 0)
|
|
self.bank.user = self.bank.peer
|
|
self.assertEqual(self.client.patch('/questions/3', json={'additional_category_ids': [4]}).status_code, 403) # Learners cannot edit.
|
|
|
|
def test_offline_migration_creates_link_table(self):
|
|
output = io.StringIO()
|
|
config = Config(output_buffer=output)
|
|
config.set_main_option('script_location', str(Path(__file__).resolve().parents[1] / 'alembic'))
|
|
scripts = ScriptDirectory.from_config(config)
|
|
self.assertEqual(len(scripts.get_heads()), 1)
|
|
self.assertEqual(scripts.get_revision('h1c2d3e4f506').down_revision, 'g4b7e2f5a903')
|
|
with patch.dict(os.environ, {'DATABASE_URL': 'postgresql://unused@127.0.0.1/offline_only'}):
|
|
command.upgrade(config, 'g4b7e2f5a903:h1c2d3e4f506', sql=True)
|
|
sql = output.getvalue()
|
|
self.assertIn('CREATE TABLE IF NOT EXISTS question_category_links', sql)
|
|
self.assertIn('UNIQUE (question_id, category_id)', sql)
|
|
output.seek(0)
|
|
output.truncate()
|
|
command.downgrade(config, 'h1c2d3e4f506:g4b7e2f5a903', sql=True)
|
|
self.assertIn('DROP TABLE IF EXISTS question_category_links', output.getvalue())
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|