Repository: learningequality/kolibri

Issue:
'NaN' in titles of difficult questions

### Observed behavior

![image](https://user-images.githubusercontent.com/2367265/56761336-ed22bf00-6751-11e9-937a-1d8bd4d78e96.png)

![image](https://user-images.githubusercontent.com/2367265/56761322-e5631a80-6751-11e9-837b-32e2f885b74c.png)


### Expected behavior

When there is more than one question drawn from a single exercise, the questions should be numbered with incrementing integers

### User-facing consequences

Confusion

### Errors and logs

None

### Steps to reproduce

See it live on the demo server: http://kolibridemo.learningequality.org/coach/#/f6e7f562bb0ea654340c726e1f81bbe9/reports/quizzes/448067d8b82df0a03902ea2aeff939e2/questions

### Context

0.12.2


Discussion:
Yep, came up during the demo today... :confused: 

Resolving patch:
diff --git a/kolibri/core/exams/migrations/0003_auto_20190426_1015.py b/kolibri/core/exams/migrations/0003_auto_20190426_1015.py
new file mode 100644
--- /dev/null
+++ b/kolibri/core/exams/migrations/0003_auto_20190426_1015.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.20 on 2019-04-26 17:15
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('exams', '0002_update_exam_data_model'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='exam',
+            name='data_model_version',
+            field=models.SmallIntegerField(default=2),
+        ),
+    ]
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
@@ -32,7 +32,19 @@ class Exam(AbstractFacilityDataModel):
     question_count = models.IntegerField()
 
     """
-    This field contains different values depending on the 'data_model_version' field.
+    The `question_sources` field contains different values depending on the 'data_model_version' field.
+
+    V2:
+        Similar to V1, but with a `counter_in_exercise` field
+        [
+            {
+                "exercise_id": <exercise_pk>,
+                "question_id": <item_id_within_exercise>,
+                "title": <exercise_title>,
+                "counter_in_exercise": <unique_count_for_question>
+            },
+            ...
+        ]
 
     V1:
         JSON array describing the questions in this exam and the exercises they come from:
@@ -66,7 +78,7 @@ class Exam(AbstractFacilityDataModel):
 
     V0:
         Used to decide which questions are in an exam at runtime.
-        See convertExamQuestionSourcesV0V1 in exams/utils.js for details.
+        See convertExamQuestionSourcesV0V2 in exams/utils.js for details.
     """
     seed = models.IntegerField(default=1)
 
@@ -102,7 +114,7 @@ def delete(self, using=None, keep_parents=False):
     Certain fields that are only relevant for older model versions get prefixed
     with their version numbers.
     """
-    data_model_version = models.SmallIntegerField(default=0)
+    data_model_version = models.SmallIntegerField(default=2)
 
     def infer_dataset(self, *args, **kwargs):
         return self.creator.dataset_id
diff --git a/kolibri/core/exams/serializers.py b/kolibri/core/exams/serializers.py
--- a/kolibri/core/exams/serializers.py
+++ b/kolibri/core/exams/serializers.py
@@ -105,12 +105,17 @@ def validate(self, attrs):
 
     def validate_question_sources(self, value):
         for question in value:
-            if 'exercise_id' not in question:
-                raise serializers.ValidationError("Question missing 'exercise_id'")
-            if 'question_id' not in question:
-                raise serializers.ValidationError("Question missing 'question_id'")
-            if 'title' not in question:
-                raise serializers.ValidationError("Question missing 'title'")
+            required_fields = [
+                "exercise_id",
+                "question_id",
+                "title",
+                "counter_in_exercise",
+            ]
+            for field in required_fields:
+                if field not in question:
+                    raise serializers.ValidationError(
+                        "Question missing '{}'".format(field)
+                    )
         return value
 
     def to_internal_value(self, data):
@@ -126,7 +131,6 @@ def to_internal_value(self, data):
 
     def create(self, validated_data):
         assignees = validated_data.pop('assignments')
-        validated_data['data_model_version'] = 1
         new_exam = Exam.objects.create(**validated_data)
         # Create all of the new ExamAssignment
         for assignee in assignees:


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,4 +1,5 @@
-import { convertExamQuestionSourcesV0V1 } from '../../src/exams/utils';
+import map from 'lodash/map';
+import { convertExamQuestionSources } from '../../src/exams/utils';
 
 // map of content IDs to lists of question IDs
 const QUESTION_IDS = {
@@ -71,28 +72,131 @@ const QUESTION_IDS = {
   ],
 };
 
-describe('exam utils', function() {
-  describe('convertExamQuestionSourcesV0V1', function() {
-    it('should return 10 specific ordered questions from 3 exercises', function() {
-      const questionSources = [
+const contentNodes = map(QUESTION_IDS, (assessmentIds, nodeId) => {
+  // See core/mappers/assessmentMetaDataState to see why we mock ContentNodes this way
+  return {
+    id: nodeId,
+    assessmentmetadata: [{ assessment_item_ids: assessmentIds, mastery_model: { ultimate: true } }],
+  };
+});
+
+describe('exam utils', () => {
+  describe('convertExamQuestionSources converting from V1 to V2', () => {
+    it('returns a question_sources array with a counter_in_exercise field', () => {
+      const exam = {
+        data_model_version: 1,
+        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 converted = convertExamQuestionSources(exam);
+      const expectedOutput = [
         {
-          exercise_id: 'b9444e7d11395946b2e14edb5dc4670f',
-          number_of_questions: 3,
-          title: 'Count in order',
+          exercise_id: 'E1',
+          question_id: 'Q1',
+          title: 'Question 1',
+          counter_in_exercise: 1,
         },
         {
-          exercise_id: '69e5e6abf479581483d441b83d7d76f4',
-          number_of_questions: 4,
-          title: 'Count with small numbers',
+          exercise_id: 'E1',
+          question_id: 'Q2',
+          title: 'Question 2',
+          counter_in_exercise: 2,
         },
         {
-          exercise_id: 'b186a2a3ae8e51dd867614db03eb3783',
-          number_of_questions: 3,
-          title: 'Find 1 more or 1 less than a number',
+          exercise_id: 'E2',
+          question_id: 'Q1',
+          title: 'Question 1',
+          counter_in_exercise: 1,
         },
       ];
-      const seed = 423;
-      const converted = convertExamQuestionSourcesV0V1(questionSources, seed, QUESTION_IDS);
+      expect(converted).toEqual(expectedOutput);
+    });
+    it('renames counterInExercise field if it has it', () => {
+      const exam = {
+        data_model_version: 1,
+        question_sources: [
+          {
+            question_id: 'Q1',
+            exercise_id: 'E1',
+            title: 'Question 1',
+            counterInExercise: 4000,
+          },
+          {
+            question_id: 'Q1',
+            exercise_id: 'E2',
+            title: 'Question 2',
+            counterInExercise: 1,
+          },
+        ],
+      };
+      const converted = convertExamQuestionSources(exam);
+      expect(converted).toEqual([
+        {
+          question_id: 'Q1',
+          exercise_id: 'E1',
+          title: 'Question 1',
+          counter_in_exercise: 4000,
+        },
+        {
+          question_id: 'Q1',
+          exercise_id: 'E2',
+          title: 'Question 2',
+          counter_in_exercise: 1,
+        },
+      ]);
+    });
+  });
+
+  describe('convertExamQuestionSources converting from V0 to V2', () => {
+    it('throws an error if the required "contentNodes" data is not provided', () => {
+      const exam = {
+        data_model_version: 0,
+        seed: 1,
+        question_sources: [],
+      };
+      expect(() => {
+        convertExamQuestionSources(exam);
+      }).toThrow();
+    });
+
+    it('should return 10 specific ordered questions from 3 exercises', () => {
+      const exam = {
+        data_model_version: 0,
+        seed: 423,
+        question_sources: [
+          {
+            exercise_id: 'b9444e7d11395946b2e14edb5dc4670f',
+            number_of_questions: 3,
+            title: 'Count in order',
+          },
+          {
+            exercise_id: '69e5e6abf479581483d441b83d7d76f4',
+            number_of_questions: 4,
+            title: 'Count with small numbers',
+          },
+          {
+            exercise_id: 'b186a2a3ae8e51dd867614db03eb3783',
+            number_of_questions: 3,
+            title: 'Find 1 more or 1 less than a number',
+          },
+        ],
+      };
+      const converted = convertExamQuestionSources(exam, { contentNodes });
 
       /*
         The selected questions should be:
@@ -110,61 +214,61 @@ describe('exam utils', function() {
       */
       const expectedOutput = [
         {
-          counterInExercise: 16,
+          counter_in_exercise: 16,
           question_id: 'fc60ecb9f83f505fa31e734e517e6523',
           exercise_id: '69e5e6abf479581483d441b83d7d76f4',
           title: 'Count with small numbers',
         },
         {
-          counterInExercise: 13,
+          counter_in_exercise: 13,
           question_id: 'd4623921a2ef5ddaa39048c0f7a6fe06',
           exercise_id: 'b186a2a3ae8e51dd867614db03eb3783',
           title: 'Find 1 more or 1 less than a number',
         },
         {
-          counterInExercise: 6,
+          counter_in_exercise: 6,
           question_id: '2f5fdbc49ce35310abf49971867ac94e',
           exercise_id: 'b186a2a3ae8e51dd867614db03eb3783',
           title: 'Find 1 more or 1 less than a number',
         },
         {
-          counterInExercise: 15,
+          counter_in_exercise: 15,
           question_id: '19421254d90d520d981bd07fd6ede9b2',
           exercise_id: 'b9444e7d11395946b2e14edb5dc4670f',
           title: 'Count in order',
         },
         {
-          counterInExercise: 1,
+          counter_in_exercise: 1,
           question_id: '1e0ce47a58465b2cb298acd3b893dce5',
           exercise_id: 'b9444e7d11395946b2e14edb5dc4670f',
           title: 'Count in order',
         },
         {
-          counterInExercise: 10,
+          counter_in_exercise: 10,
           question_id: 'd3ac055fa4ad599bbd30a00eaeb93e5e',
           exercise_id: 'b186a2a3ae8e51dd867614db03eb3783',
           title: 'Find 1 more or 1 less than a number',
         },
         {
-          counterInExercise: 1,
+          counter_in_exercise: 1,
           question_id: 'a5f508eb2ba05d429812dc43b577ef03',
           exercise_id: '69e5e6abf479581483d441b83d7d76f4',
           title: 'Count with small numbers',
         },
         {
-          counterInExercise: 17,
+          counter_in_exercise: 17,
           question_id: '8358fbbd0a285e9d99da558094eabd4c',
           exercise_id: '69e5e6abf479581483d441b83d7d76f4',
           title: 'Count with small numbers',
         },
         {
-          counterInExercise: 2,
+          counter_in_exercise: 2,
           question_id: '3aeb023925e35001865091de1fb4d3ae',
           exercise_id: '69e5e6abf479581483d441b83d7d76f4',
           title: 'Count with small numbers',
         },
         {
-          counterInExercise: 3,
+          counter_in_exercise: 3,
           question_id: 'a654dec351af5bdf937566e46b7c2fc3',
           exercise_id: 'b9444e7d11395946b2e14edb5dc4670f',
           title: 'Count in order',
@@ -173,73 +277,76 @@ describe('exam utils', function() {
 
       expect(converted).toEqual(expectedOutput);
     });
-    it('should return 10 specific ordered questions from 1 exercise', function() {
-      const questionSources = [
-        {
-          exercise_id: '69e5e6abf479581483d441b83d7d76f4',
-          number_of_questions: 10,
-          title: 'Count with small numbers',
-        },
-      ];
-      const seed = 837;
-      const converted = convertExamQuestionSourcesV0V1(questionSources, seed, QUESTION_IDS);
+    it('should return 10 specific ordered questions from 1 exercise', () => {
+      const exam = {
+        data_model_version: 0,
+        question_sources: [
+          {
+            exercise_id: '69e5e6abf479581483d441b83d7d76f4',
+            number_of_questions: 10,
+            title: 'Count with small numbers',
+          },
+        ],
+        seed: 837,
+      };
+      const converted = convertExamQuestionSources(exam, { contentNodes });
       const expectedOutput = [
         {
-          counterInExercise: 20,
+          counter_in_exercise: 20,
           question_id: 'fc5958e2a67d5cd2bd48962e9e1c35c3',
           exercise_id: '69e5e6abf479581483d441b83d7d76f4',
           title: 'Count with small numbers',
         },
         {
-          counterInExercise: 3,
+          counter_in_exercise: 3,
           question_id: 'beb5eae9491c564fb6bc5b9c1421d085',
           exercise_id: '69e5e6abf479581483d441b83d7d76f4',
           title: 'Count with small numbers',
         },
         {
-          counterInExercise: 2,
+          counter_in_exercise: 2,
           question_id: '3aeb023925e35001865091de1fb4d3ae',
           exercise_id: '69e5e6abf479581483d441b83d7d76f4',
           title: 'Count with small numbers',
         },
         {
-          counterInExercise: 17,
+          counter_in_exercise: 17,
           question_id: '8358fbbd0a285e9d99da558094eabd4c',
           exercise_id: '69e5e6abf479581483d441b83d7d76f4',
           title: 'Count with small numbers',
         },
         {
-          counterInExercise: 6,
+          counter_in_exercise: 6,
           question_id: '4dd2526065ee572998a06b1fdae9cb4b',
           exercise_id: '69e5e6abf479581483d441b83d7d76f4',
           title: 'Count with small numbers',
         },
         {
-          counterInExercise: 5,
+          counter_in_exercise: 5,
           question_id: '10e6239f9cf35b75b0cf75ca7f7e6a14',
           exercise_id: '69e5e6abf479581483d441b83d7d76f4',
           title: 'Count with small numbers',
         },
         {
-          counterInExercise: 12,
+          counter_in_exercise: 12,
           question_id: 'e92a277052cf56e2aaae44338fa0bfec',
           exercise_id: '69e5e6abf479581483d441b83d7d76f4',
           title: 'Count with small numbers',
         },
         {
-          counterInExercise: 9,
+          counter_in_exercise: 9,
           question_id: '6ef7996754de54ad92b660d06436976c',
           exercise_id: '69e5e6abf479581483d441b83d7d76f4',
           title: 'Count with small numbers',
         },
         {
-          counterInExercise: 4,
+          counter_in_exercise: 4,
           question_id: 'b19341ebca9a5bdb817cdc31b8d62993',
           exercise_id: '69e5e6abf479581483d441b83d7d76f4',
           title: 'Count with small numbers',
         },
         {
-          counterInExercise: 8,
+          counter_in_exercise: 8,
           question_id: 'e226724765085214acfb807942918fed',
           exercise_id: '69e5e6abf479581483d441b83d7d76f4',
           title: 'Count with small numbers',
@@ -247,41 +354,44 @@ describe('exam utils', function() {
       ];
       expect(converted).toEqual(expectedOutput);
     });
-    it('should return 3 specific ordered questions from 3 exercises', function() {
-      const questionSources = [
-        {
-          exercise_id: 'b9444e7d11395946b2e14edb5dc4670f',
-          number_of_questions: 1,
-          title: 'Count in order',
-        },
-        {
-          exercise_id: '69e5e6abf479581483d441b83d7d76f4',
-          number_of_questions: 1,
-          title: 'Count with small numbers',
-        },
-        {
-          exercise_id: 'b186a2a3ae8e51dd867614db03eb3783',
-          number_of_questions: 1,
-          title: 'Find 1 more or 1 less than a number',
-        },
-      ];
-      const seed = 168;
-      const converted = convertExamQuestionSourcesV0V1(questionSources, seed, QUESTION_IDS);
+    it('should return 3 specific ordered questions from 3 exercises', () => {
+      const exam = {
+        data_model_version: 0,
+        question_sources: [
+          {
+            exercise_id: 'b9444e7d11395946b2e14edb5dc4670f',
+            number_of_questions: 1,
+            title: 'Count in order',
+          },
+          {
+            exercise_id: '69e5e6abf479581483d441b83d7d76f4',
+            number_of_questions: 1,
+            title: 'Count with small numbers',
+          },
+          {
+            exercise_id: 'b186a2a3ae8e51dd867614db03eb3783',
+            number_of_questions: 1,
+            title: 'Find 1 more or 1 less than a number',
+          },
+        ],
+        seed: 168,
+      };
+      const converted = convertExamQuestionSources(exam, { contentNodes });
       const expectedOutput = [
         {
-          counterInExercise: 9,
+          counter_in_exercise: 9,
           question_id: '6ef7996754de54ad92b660d06436976c',
           exercise_id: '69e5e6abf479581483d441b83d7d76f4',
           title: 'Count with small numbers',
         },
         {
-          counterInExercise: 2,
+          counter_in_exercise: 2,
           question_id: '952857e446b95c5da36226f59237ffcc',
           exercise_id: 'b9444e7d11395946b2e14edb5dc4670f',
           title: 'Count in order',
         },
         {
-          counterInExercise: 7,
+          counter_in_exercise: 7,
           question_id: '5a56a46b261d5ff7b3f870cf09c6952f',
           exercise_id: 'b186a2a3ae8e51dd867614db03eb3783',
           title: 'Find 1 more or 1 less than a number',
diff --git a/kolibri/core/exams/test/test_exam_api.py b/kolibri/core/exams/test/test_exam_api.py
--- a/kolibri/core/exams/test/test_exam_api.py
+++ b/kolibri/core/exams/test/test_exam_api.py
@@ -34,223 +34,159 @@ def setUp(self):
             creator=self.admin
         )
 
-    def test_logged_in_user_exam_no_delete(self):
+    def make_basic_exam(self):
+        return {
+            "title": "Exam",
+            "question_count": 1,
+            "active": True,
+            "collection": self.facility.id,
+            "learners_see_fixed_order": False,
+            "question_sources": [],
+            "assignments": [],
+        }
+
+    def post_new_exam(self, exam):
+        return self.client.post(reverse("kolibri:core:exam-list"), exam, format="json")
 
+    def put_updated_exam(self, exam_id, update):
+        return self.client.put(reverse("kolibri:core:exam-detail", kwargs={'pk': exam_id}), update, format="json")
+
+    def login_as_admin(self):
+        return self.client.login(username=self.admin.username, password=DUMMY_PASSWORD)
+
+    def login_as_learner(self):
         user = FacilityUser.objects.create(username="learner", facility=self.facility)
         user.set_password("pass")
         user.save()
+        return self.client.login(username="learner", password="pass")
 
-        self.client.login(username=user.username, password="pass")
-
+    def test_logged_in_user_exam_no_delete(self):
+        self.login_as_learner()
         response = self.client.delete(reverse("kolibri:core:exam-detail", kwargs={'pk': self.exam.id}))
         self.assertEqual(response.status_code, 403)
 
     def test_logged_in_admin_exam_delete(self):
-
-        self.client.login(username=self.admin.username, password=DUMMY_PASSWORD)
-
+        self.login_as_admin()
         response = self.client.delete(reverse("kolibri:core:exam-detail", kwargs={'pk': self.exam.id}))
         self.assertEqual(response.status_code, 204)
 
     def test_logged_in_admin_exam_create(self):
-
-        self.client.login(username=self.admin.username, password=DUMMY_PASSWORD)
-
-        response = self.client.post(reverse("kolibri:core:exam-list"), {
-            "title": "title next",
-            "question_count": 1,
-            "active": True,
-            "collection": self.facility.id,
-            "learners_see_fixed_order": False,
-            "question_sources": [],
-            "assignments": [],
-        }, format="json")
+        self.login_as_admin()
+        exam = self.make_basic_exam()
+        response = self.post_new_exam(exam)
         self.assertEqual(response.status_code, 201)
 
     def test_logged_in_admin_exam_create_with_assignments(self):
-
-        self.client.login(username=self.admin.username, password=DUMMY_PASSWORD)
-
-        response = self.client.post(reverse("kolibri:core:exam-list"), {
-            "title": "title next",
-            "question_count": 1,
-            "active": True,
-            "collection": self.facility.id,
-            "learners_see_fixed_order": False,
-            "question_sources": [],
-            "assignments": [{
-                "collection": self.facility.id,
-            }],
-        }, format="json")
+        self.login_as_admin()
+        exam = self.make_basic_exam()
+        exam["assignments"] = [{"collection": self.facility.id}]
+        response = self.post_new_exam(exam)
+        exam_id = response.data["id"]
         self.assertEqual(response.status_code, 201)
-        self.assertEqual(models.ExamAssignment.objects.get(collection=self.facility).exam, models.Exam.objects.get(title="title next"))
+        self.assertEqual(
+            models.ExamAssignment.objects.get(collection=self.facility).exam,
+            models.Exam.objects.get(id=exam_id)
+        )
 
     def test_logged_in_admin_exam_update_no_assignments(self):
-
-        self.client.login(username=self.admin.username, password=DUMMY_PASSWORD)
-
-        response = self.client.post(reverse("kolibri:core:exam-list"), {
-            "title": "title next",
-            "question_count": 1,
-            "active": True,
-            "collection": self.facility.id,
-            "learners_see_fixed_order": False,
-            "question_sources": [],
-            "assignments": [{
-                "collection": self.facility.id,
-            }],
-        }, format="json")
-        exam_id = models.Exam.objects.get(title="title next").id
-        response = self.client.put(reverse("kolibri:core:exam-detail", kwargs={'pk': exam_id}), {
-            "title": "title next",
-            "question_count": 1,
-            "active": True,
-            "collection": self.facility.id,
-            "learners_see_fixed_order": False,
-            "question_sources": [],
-            "assignments": [],
-            "creator": self.admin.id,
-        }, format="json")
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(models.Exam.objects.get(title="title next").assignments.count(), 0)
+        self.login_as_admin()
+        exam = self.make_basic_exam()
+        exam["assignments"] = [{"collection": self.facility.id}]
+        post_response = self.post_new_exam(exam)
+        exam_id = post_response.data["id"]
+        exam["assignments"] = []
+        put_response = self.put_updated_exam(exam_id, exam)
+        self.assertEqual(put_response.status_code, 200)
+        self.assertEqual(models.Exam.objects.get(id=exam_id).assignments.count(), 0)
 
     def test_logged_in_admin_exam_update_different_assignments(self):
-
-        self.client.login(username=self.admin.username, password=DUMMY_PASSWORD)
-
-        response = self.client.post(reverse("kolibri:core:exam-list"), {
-            "title": "title next",
-            "question_count": 1,
-            "active": True,
-            "collection": self.facility.id,
-            "learners_see_fixed_order": False,
-            "question_sources": [],
-            "assignments": [{
-                "collection": self.facility.id,
-            }],
-        }, format="json")
-        exam_id = models.Exam.objects.get(title="title next").id
+        self.login_as_admin()
+        exam = self.make_basic_exam()
+        exam["assignments"] = [{"collection": self.facility.id}]
+        post_response = self.post_new_exam(exam)
+        exam_id = post_response.data["id"]
         group = LearnerGroup.objects.create(name="test", parent=self.facility)
-        response = self.client.put(reverse("kolibri:core:exam-detail", kwargs={'pk': exam_id}), {
-            "title": "title next",
-            "question_count": 1,
-            "active": True,
-            "collection": self.facility.id,
-            "learners_see_fixed_order": False,
-            "question_sources": [],
-            "assignments": [{
-                "collection": group.id,
-            }],
-            "creator": self.admin.id,
-        }, format="json")
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(models.Exam.objects.get(title="title next").assignments.count(), 1)
-        self.assertEqual(models.Exam.objects.get(title="title next").assignments.first().collection, group)
+        exam["assignments"] = [{"collection": group.id}]
+        put_response = self.put_updated_exam(exam_id, exam)
+        self.assertEqual(put_response.status_code, 200)
+        assignments = models.Exam.objects.get(id=exam_id).assignments
+        self.assertEqual(assignments.count(), 1)
+        self.assertEqual(assignments.first().collection, group)
 
     def test_logged_in_admin_exam_update_additional_assignments(self):
-
-        self.client.login(username=self.admin.username, password=DUMMY_PASSWORD)
-
-        response = self.client.post(reverse("kolibri:core:exam-list"), {
-            "title": "title next",
-            "question_count": 1,
-            "active": True,
-            "collection": self.facility.id,
-            "learners_see_fixed_order": False,
-            "question_sources": [],
-            "assignments": [{
-                "collection": self.facility.id,
-            }],
-        }, format="json")
-        exam_id = models.Exam.objects.get(title="title next").id
+        self.login_as_admin()
+        exam = self.make_basic_exam()
+        exam["assignments"] = [{"collection": self.facility.id}]
+        post_response = self.post_new_exam(exam)
+        exam_id = post_response.data["id"]
         group = LearnerGroup.objects.create(name="test", parent=self.facility)
-        response = self.client.put(reverse("kolibri:core:exam-detail", kwargs={'pk': exam_id}), {
-            "title": "title next",
-            "question_count": 1,
-            "active": True,
-            "collection": self.facility.id,
-            "learners_see_fixed_order": False,
-            "question_sources": [],
-            "assignments": [
-                {
-                    "collection": group.id,
-                },
-                {
-                    "collection": self.facility.id,
-                }
-            ],
-            "creator": self.admin.id,
-        }, format="json")
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(models.Exam.objects.get(title="title next").assignments.count(), 2)
-        self.assertIn(models.Exam.objects.get(title="title next").assignments.first().collection, [group, self.facility])
-        self.assertIn(models.Exam.objects.get(title="title next").assignments.last().collection, [group, self.facility])
+        exam["assignments"].append({"collection": group.id})
+        put_response = self.put_updated_exam(exam_id, exam)
+        self.assertEqual(put_response.status_code, 200)
+        assignments = models.Exam.objects.get(id=exam_id).assignments
+        self.assertEqual(assignments.count(), 2)
+        self.assertIn(assignments.first().collection, [group, self.facility])
+        self.assertIn(assignments.last().collection, [group, self.facility])
 
     def test_logged_in_user_exam_no_create(self):
-
-        user = FacilityUser.objects.create(username="learner", facility=self.facility)
-        user.set_password("pass")
-        user.save()
-
-        self.client.login(username=user.username, password="pass")
-
-        response = self.client.post(reverse("kolibri:core:exam-list"), {
-            "title": "title next",
-            "question_count": 1,
-            "active": True,
-            "collection": self.facility.id,
-            "learners_see_fixed_order": False,
-            "question_sources": [],
-            "assignments": [],
-        }, format="json")
+        self.login_as_learner()
+        response = self.post_new_exam(self.make_basic_exam())
         self.assertEqual(response.status_code, 403)
 
     def test_logged_in_admin_exam_update(self):
-
-        self.client.login(username=self.admin.username, password=DUMMY_PASSWORD)
-
-        response = self.client.put(reverse("kolibri:core:exam-detail", kwargs={'pk': self.exam.id}), {
-            "title": "title",
-            "question_count": 2,
+        self.login_as_admin()
+        response = self.put_updated_exam(self.exam.id, {
+            "title": "updated title",
             "active": True,
-            "collection": self.facility.id,
         })
         self.assertEqual(response.status_code, 200)
+        self.assertEqual(models.Exam.objects.get(id=self.exam.id).title, "updated title")
 
     def test_logged_in_user_exam_no_update(self):
-
-        user = FacilityUser.objects.create(username="learner", facility=self.facility)
-        user.set_password("pass")
-        user.save()
-
-        self.client.login(username=user.username, password="pass")
-
-        response = self.client.put(reverse("kolibri:core:exam-detail", kwargs={'pk': self.exam.id}), {
-            "title": "title",
-            "question_count": 2,
+        self.login_as_learner()
+        response = self.put_updated_exam(self.exam.id, {
+            "title": "updated title",
             "active": True,
-            "collection": self.facility.id,
         })
         self.assertEqual(response.status_code, 403)
+        self.assertEqual(models.Exam.objects.get(id=self.exam.id).title, "title")
 
     def test_cannot_create_exam_same_title_case_insensitive(self):
-        self.client.login(username=self.admin.username, password=DUMMY_PASSWORD)
-
-        response = self.client.post(reverse("kolibri:core:exam-list"), {
-            "title": "TiTlE",
-            "question_count": 1,
-            "active": True,
-            "collection": self.facility.id,
-            "learners_see_fixed_order": False,
-            "question_sources": [],
-            "assignments": [{
-                "collection": self.facility.id,
-            }],
-        }, format="json")
-
+        self.login_as_admin()
+        exam = self.make_basic_exam()
+        self.post_new_exam(exam)
+        exam["title"] = "EXAM"
+        response = self.post_new_exam(exam)
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
         self.assertEqual(response.data[0]['id'], error_constants.UNIQUE)
 
+    def test_exam_with_invalid_question_sources_fails(self):
+        self.login_as_admin()
+        exam = self.make_basic_exam()
+        title = "invalid_question_sources"
+        exam["title"] = title
+        exam["question_sources"].append({
+            "exercise_id": "e1",
+            "question_id": "q1",
+            "title": "Title",
+            # missing 'counter_in_exercise'
+        })
+        response = self.post_new_exam(exam)
+        self.assertEqual(response.status_code, 400)
+        self.assertFalse(models.Exam.objects.filter(title=title).exists())
+
+    def test_exam_with_valid_question_sources_succeeds(self):
+        self.login_as_admin()
+        exam = self.make_basic_exam()
+        exam["question_sources"].append({
+            "exercise_id": "e1",
+            "question_id": "q1",
+            "title": "Title",
+            "counter_in_exercise": 1,
+        })
+        response = self.post_new_exam(exam)
+        self.assertEqual(response.status_code, 201)
+
 
 class ExamAssignmentAPITestCase(APITestCase):
 
diff --git a/kolibri/plugins/coach/assets/test/selectRandomExamQuestions.spec.js b/kolibri/plugins/coach/assets/test/selectRandomExamQuestions.spec.js
--- a/kolibri/plugins/coach/assets/test/selectRandomExamQuestions.spec.js
+++ b/kolibri/plugins/coach/assets/test/selectRandomExamQuestions.spec.js
@@ -1,5 +1,7 @@
 import selectQuestions from '../src/modules/examCreation/selectQuestions';
 
+jest.mock('kolibri.lib.logging');
+
 const EXERCISES_IDS = ['A', 'B', 'C'];
 const EXERCISES_TITLES = ['title_x', 'title_y', 'title_z'];
 const QUESTION_IDS = [