diff --git a/kolibri/core/assets/src/exams/utils.js b/kolibri/core/assets/src/exams/utils.js index b7eb26d085..d1f3a8d732 100644 --- a/kolibri/core/assets/src/exams/utils.js +++ b/kolibri/core/assets/src/exams/utils.js @@ -1,5 +1,6 @@ import every from 'lodash/every'; import uniq from 'lodash/uniq'; +import { v4 as uuidv4 } from 'uuid'; import { assessmentMetaDataState } from 'kolibri.coreVue.vuex.mappers'; import { ExamResource, ContentNodeResource } from 'kolibri.resources'; @@ -82,6 +83,51 @@ function annotateQuestionsWithItem(questions) { }); } +/* Given a V2 question_sources, return V3 structure with those questions within one new section */ +/** + * @param {Array} questionSources - a V2 question_sources object + * @param {boolean} learners_see_fixed_order - whether the questions should be randomized or not + * - a V2 quiz will have this value on itself, but a V3 quiz will have it + * on each section, so it should be passed in here + * @returns V3 formatted question_sources + */ +export function convertV2toV3(questionSources, exam) { + questionSources = questionSources || []; // Default value while requiring all params + const questions = annotateQuestionsWithItem(questionSources); + return { + section_id: uuidv4(), + section_title: '', + description: '', + resource_pool: [], + questions, + learners_see_fixed_order: exam.learners_see_fixed_order, + question_count: exam.question_count, + }; +} + +export function revertV3toV2(questionSources) { + if (!questionSources.length) { + return []; + } + return questionSources[0].questions; +} + +/** + * @param {object} exam - an exam object of any question_sources version + * @returns V2 formatted question_sources + */ +export function convertExamQuestionSourcesToV3(exam, extraArgs = {}) { + if (exam.data_model_version !== 3) { + const V2_sources = convertExamQuestionSources(exam, extraArgs); + return [convertV2toV3(V2_sources, exam)]; + } + + return exam.question_sources; +} + +/** + * @returns V2 formatted question_sources + */ export function convertExamQuestionSources(exam, extraArgs = {}) { const { data_model_version } = exam; if (data_model_version === 0) { @@ -107,12 +153,23 @@ export function convertExamQuestionSources(exam, extraArgs = {}) { if (data_model_version === 1) { return annotateQuestionsWithItem(convertExamQuestionSourcesV1V2(exam.question_sources)); } + + // For backwards compatibility. If you are using V3, use the convertExamQuestionSourcesToV3 func + if (data_model_version === 3) { + return revertV3toV2(exam.question_sources); + } + return annotateQuestionsWithItem(exam.question_sources); } export function fetchNodeDataAndConvertExam(exam) { const { data_model_version } = exam; - if (data_model_version >= 2) { + if (data_model_version >= 3) { + /* For backwards compatibility, we need to convert V3 to V2 */ + exam.question_sources = revertV3toV2(exam.question_sources); + return Promise.resolve(exam); + } + if (data_model_version == 2) { exam.question_sources = annotateQuestionsWithItem(exam.question_sources); return Promise.resolve(exam); } diff --git a/kolibri/core/assets/test/exams/utils.spec.js b/kolibri/core/assets/test/exams/utils.spec.js index 8472fa44ed..58203cd58b 100644 --- a/kolibri/core/assets/test/exams/utils.spec.js +++ b/kolibri/core/assets/test/exams/utils.spec.js @@ -1,5 +1,5 @@ import map from 'lodash/map'; -import { convertExamQuestionSources } from '../../src/exams/utils'; +import { convertExamQuestionSources, convertExamQuestionSourcesToV3 } from '../../src/exams/utils'; // map of content IDs to lists of question IDs const QUESTION_IDS = { @@ -81,6 +81,91 @@ const contentNodes = map(QUESTION_IDS, (assessmentIds, nodeId) => { }); describe('exam utils', () => { + describe('convertExamQuestionSourcesToV3 converting from any previous version to V3', () => { + // Stolen from test below to ensure we're getting expected V2 values... as expected + const exam = { + data_model_version: 1, + learners_see_fixed_order: true, + question_count: 8, + question_sources: [ + { + exercise_id: 'E1', + question_id: 'Q1', + title: 'Question 1', + }, + { + exercise_id: 'E1', + question_id: 'Q2', + title: 'Question 2', + }, + { + exercise_id: 'E2', + question_id: 'Q1', + title: 'Question 1', + }, + ], + }; + const expectedSources = [ + { + exercise_id: 'E1', + question_id: 'Q1', + title: 'Question 1', + counter_in_exercise: 1, + item: 'E1:Q1', + }, + { + exercise_id: 'E1', + question_id: 'Q2', + title: 'Question 2', + counter_in_exercise: 2, + item: 'E1:Q2', + }, + { + exercise_id: 'E2', + question_id: 'Q1', + title: 'Question 1', + counter_in_exercise: 1, + item: 'E2:Q1', + }, + ]; + it('returns an array of newly structured objects with old question sources in questions', () => { + const converted = convertExamQuestionSourcesToV3(exam); + // The section id is randomly generated so just test that it is there and is set on the object + expect(converted[0].section_id).toBeTruthy(); + expect(converted).toEqual([ + { + section_id: converted[0].section_id, + section_title: '', + description: '', + resource_pool: [], + questions: expectedSources.sort(), + learners_see_fixed_order: true, + question_count: 8, + }, + ]); + }); + it('sets the fixed order property to what the exam property value is', () => { + exam.learners_see_fixed_order = false; + const converted = convertExamQuestionSourcesToV3(exam); + expect(converted[0].learners_see_fixed_order).toEqual(false); + }); + it('always sets the question_count and learners_see_fixed_order properties to the original exam values', () => { + exam.learners_see_fixed_order = false; + exam.question_count = 49; + const converted = convertExamQuestionSourcesToV3(exam); + expect(converted).toEqual([ + { + section_id: converted[0].section_id, + section_title: '', + description: '', + resource_pool: [], + questions: expectedSources.sort(), + learners_see_fixed_order: false, + question_count: 49, + }, + ]); + }); + }); describe('convertExamQuestionSources converting from V1 to V2', () => { it('returns a question_sources array with a counter_in_exercise field', () => { const exam = { diff --git a/kolibri/core/exams/migrations/0007_bump_data_model_version_to_3.py b/kolibri/core/exams/migrations/0007_bump_data_model_version_to_3.py new file mode 100644 index 0000000000..03ab436c7d --- /dev/null +++ b/kolibri/core/exams/migrations/0007_bump_data_model_version_to_3.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2023-07-27 20:52 +from __future__ import unicode_literals + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("exams", "0006_nullable_creator_assigned_by"), + ] + + operations = [ + migrations.AlterField( + model_name="exam", + name="data_model_version", + field=models.SmallIntegerField(default=3), + ), + ] diff --git a/kolibri/core/exams/models.py b/kolibri/core/exams/models.py index 76ef735e1b..5f4da888b9 100644 --- a/kolibri/core/exams/models.py +++ b/kolibri/core/exams/models.py @@ -52,6 +52,35 @@ class Exam(AbstractFacilityDataModel): """ The `question_sources` field contains different values depending on the 'data_model_version' field. + V3: + Represents a list of questions of V2 objects each of which are now a "Exam/Quiz Section" + and extends it with an additional description field. The `learners_see_fixed_order` field + will now be persisted within each section itself, rather than for the whole quiz. + + # Exam + [ + # Section 1 + { + "section_id": , + "section_title":
, + "description":
, + "resource_pool": [ ], + "question_count": , + "learners_see_fixed_order": , + "questions": [ + { + "exercise_id": , + "question_id": , + "title": , + "counter_in_exercise": <unique_count_for_question>, + }, + ] + }, + + # Section 2 + {...} + ] + V2: Similar to V1, but with a `counter_in_exercise` field [ @@ -176,7 +205,7 @@ def save(self, *args, **kwargs): Certain fields that are only relevant for older model versions get prefixed with their version numbers. """ - data_model_version = models.SmallIntegerField(default=2) + data_model_version = models.SmallIntegerField(default=3) def infer_dataset(self, *args, **kwargs): return self.cached_related_dataset_lookup("collection")