Repository: learningequality/kolibri

Issue:
Add and handle V3 schema for `Exam#question_sources` JSONField
## Overview

The `kolibri.core.exams.models.Exam` class's `question_sources` property is a `JSONField` where we store the structure of a quiz and where it gets its exercises from.

The latest version is a list of objects as follows:

```
  # V2
  [
  	{
      	exercise_id: ...,
          question_id: ...,
          title: ...,
          counter_in_exercise: ...,
      },
  ]
```

We will update this data structure and its schema in the `Exam` model and then we will update the Javascript that handles converting older `question_sources`' data versions so that we can reliably retrieve the data we need in the shape we need it in.

## Acceptance criteria

- [x] Update the `kolibri.core.exams.models` `Exam` class's comment defining the schema for `question_sources` with something like the following, being sure to keep previous definitions.

```
  """
  V3:
  	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
      
      # Exam
        [
        	# Section 1
            {
              "title": <section title>,
              "description": <section description>,
              "questions": [
                {
                    "exercise_id": <exercise_pk>,
                    "question_id": <item_id_within_exercise>,
                    "title": <title of question>
                },
                ...
              ]
            },
            
            # Section 2
            {...}
        ]
  """
```

- [x] Update `kolibri/core/assets/src/exams/utils.js` so that there is a way to reliably get any Quiz's `question_sources` in the new `V3` format. Ultimately, this will result in taking `V2` data and creating a new object out of it to populate the `question_sources` list

- [ ] The **Practice Quiz** creation and **Coach > Quiz Reports** should be able to also still get any Exam using the `V2` schema so that we don't flat out break those features. For example - whenever any part of Coach **other than quiz creation/editing** wants the `question_sources` data, they should just be getting the first section of the quiz as it would just be the `V2` schema they are already using. This may result that `utils.js` having its exports extended to differentiate between V3 and previous versions.

- [x] Tests should be updated to accommodate the new data structure. See `kolibri/core/assets/test/exams/utils.spec.js` and `kolibri.core.exams.test.test_exam_api` as a start.



Discussion:

Resolving patch:
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
--- /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
--- 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": <a uuid unique to this section>,
+                  "section_title": <section title>,
+                  "description": <section description>,
+                  "resource_pool": [ <contentnode_ids of pool of resources> ],
+                  "question_count": <number of questions in section>,
+                  "learners_see_fixed_order": <bool>,
+                  "questions": [
+                    {
+                        "exercise_id": <exercise_pk>,
+                        "question_id": <item_id_within_exercise>,
+                        "title": <title of question>,
+                        "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")


Test patch:
diff --git a/kolibri/core/assets/test/exams/utils.spec.js b/kolibri/core/assets/test/exams/utils.spec.js
--- 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 = {