Repository: learningequality/kolibri

Issue:
Update progress tracking endpoint to remove dependence on backend read content metadata
## Observed behavior
https://github.com/learningequality/kolibri/pull/8525 consolidated all progress tracking into a single endpoint and reduced the amount of data that needed to be sent to the backend to establish context for a session by reading additional required metadata from the backend.

Unfortunately, the need for being able to log progress for non-locally available resources means that this mechanism is no longer suitable.

## Expected behavior
All contextual metadata will now need to be sent when the session starts and not read from the database.

Both the API endpoint and the companion `useProgressTracking` composable will have to be updated with this new interface.


Discussion:

Resolving patch:
diff --git a/kolibri/core/logger/api.py b/kolibri/core/logger/api.py
--- a/kolibri/core/logger/api.py
+++ b/kolibri/core/logger/api.py
@@ -6,9 +6,7 @@
 from django.core.exceptions import PermissionDenied
 from django.db import transaction
 from django.db.models import IntegerField
-from django.db.models import OuterRef
 from django.db.models import Q
-from django.db.models import Subquery
 from django.db.models import Sum
 from django.db.models import Value
 from django.db.models.functions import Coalesce
@@ -21,7 +19,6 @@
 from django_filters.rest_framework import UUIDFilter
 from le_utils.constants import content_kinds
 from le_utils.constants import exercises
-from le_utils.constants import modalities
 from rest_framework import serializers
 from rest_framework import viewsets
 from rest_framework.decorators import action
@@ -37,8 +34,6 @@
 from kolibri.core.auth.api import KolibriAuthPermissionsFilter
 from kolibri.core.auth.models import dataset_cache
 from kolibri.core.content.api import OptionalPageNumberPagination
-from kolibri.core.content.models import AssessmentMetaData
-from kolibri.core.content.models import ContentNode
 from kolibri.core.decorators import query_params_required
 from kolibri.core.exams.models import Exam
 from kolibri.core.lessons.models import Lesson
@@ -67,9 +62,19 @@ def to_internal_value(self, data):
         return super(HexStringUUIDField, self).to_internal_value(data).hex
 
 
+class MasteryModelSerializer(serializers.Serializer):
+    type = serializers.ChoiceField(choices=exercises.MASTERY_MODELS)
+    m = serializers.IntegerField(required=False)
+    n = serializers.IntegerField(required=False)
+
+
 class StartSessionSerializer(serializers.Serializer):
     lesson_id = HexStringUUIDField(required=False)
     node_id = HexStringUUIDField(required=False)
+    content_id = HexStringUUIDField(required=False)
+    channel_id = HexStringUUIDField(required=False)
+    kind = serializers.ChoiceField(choices=content_kinds.choices, required=False)
+    mastery_model = MasteryModelSerializer(required=False)
     # Do this as a special way of handling our coach generated quizzes
     quiz_id = HexStringUUIDField(required=False)
     # A flag to indicate whether to start the session over again
@@ -80,6 +85,32 @@ def validate(self, data):
             raise ValidationError("quiz_id must not be mixed with other context")
         if "node_id" not in data and "quiz_id" not in data:
             raise ValidationError("node_id is required if not a coach assigned quiz")
+        if "node_id" in data:
+            errors = {}
+            if "kind" not in data:
+                errors["kind"] = ValidationError("kind is required for any node_id")
+            else:
+                if (
+                    data["kind"] == content_kinds.EXERCISE
+                    and "mastery_model" not in data
+                ):
+                    errors["mastery_model"] = ValidationError(
+                        "mastery model must be specified for exercise kinds"
+                    )
+                elif data["kind"] != content_kinds.EXERCISE and "mastery_model" in data:
+                    errors["mastery_model"] = ValidationError(
+                        "mastery model must not be specified for non-exercise kinds"
+                    )
+            if "content_id" not in data:
+                errors["content_id"] = ValidationError(
+                    "content_id is required for any node_id"
+                )
+            if "channel_id" not in data:
+                errors["channel_id"] = ValidationError(
+                    "channel_id is required for any node_id"
+                )
+            if errors:
+                raise ValidationError(errors)
         return data
 
 
@@ -228,35 +259,14 @@ def _get_context(self, user, validated_data):
         context = LogContext()
 
         if node_id is not None:
-            try:
-                node = (
-                    ContentNode.objects.annotate(
-                        mastery_model=Subquery(
-                            AssessmentMetaData.objects.filter(
-                                contentnode_id=OuterRef("id")
-                            ).values_list("mastery_model", flat=True)[:1]
-                        )
-                    )
-                    .values(
-                        "content_id", "channel_id", "kind", "mastery_model", "options"
-                    )
-                    .get(id=node_id)
-                )
-                mastery_model = node["mastery_model"]
-                content_id = node["content_id"]
-                channel_id = node["channel_id"]
-                kind = node["kind"]
-                context["node_id"] = node_id
-                if lesson_id:
-                    self._check_lesson_permissions(user, lesson_id)
-                    context["lesson_id"] = lesson_id
-                if (
-                    node["options"]
-                    and node["options"].get("modality") == modalities.QUIZ
-                ):
-                    mastery_model = {"type": exercises.QUIZ}
-            except ContentNode.DoesNotExist:
-                raise ValidationError("Invalid node_id")
+            mastery_model = validated_data.get("mastery_model")
+            content_id = validated_data.get("content_id")
+            channel_id = validated_data.get("channel_id")
+            kind = validated_data.get("kind")
+            context["node_id"] = node_id
+            if lesson_id:
+                self._check_lesson_permissions(user, lesson_id)
+                context["lesson_id"] = lesson_id
         elif quiz_id is not None:
             self._check_quiz_permissions(user, quiz_id)
             mastery_model = {"type": exercises.QUIZ, "coach_assigned": True}


Test patch:
diff --git a/kolibri/core/logger/test/test_integrated_api.py b/kolibri/core/logger/test/test_integrated_api.py
--- a/kolibri/core/logger/test/test_integrated_api.py
+++ b/kolibri/core/logger/test/test_integrated_api.py
@@ -81,10 +81,15 @@ def setUp(self):
             kind=content_kinds.VIDEO,
         )
 
-    def _make_request(self, data):
+    def _make_request(self, data, delete_keys=None):
         post_data = {
             "node_id": self.node.id,
+            "content_id": self.node.content_id,
+            "channel_id": self.node.channel_id,
+            "kind": self.node.kind,
         }
+        for key in delete_keys or []:
+            del post_data[key]
         post_data.update(data)
         return self.client.post(
             reverse("kolibri:core:trackprogress-list"),
@@ -218,7 +223,11 @@ def test_start_assessment_session_logged_in_succeeds(self):
             password=DUMMY_PASSWORD,
             facility=self.facility,
         )
-        response = self._make_request({})
+        response = self._make_request(
+            {
+                "mastery_model": mastery_model,
+            }
+        )
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json()["mastery_criterion"], mastery_model)
@@ -377,7 +386,11 @@ def test_start_assessment_session_logged_in_practice_quiz_succeeds(self):
             password=DUMMY_PASSWORD,
             facility=self.facility,
         )
-        response = self._make_request({})
+        response = self._make_request(
+            {
+                "mastery_model": {"type": exercises.QUIZ},
+            }
+        )
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json()["mastery_criterion"], {"type": exercises.QUIZ})
@@ -402,6 +415,48 @@ def test_start_assessment_session_logged_in_practice_quiz_succeeds(self):
         self.assertEqual(result["totalattempts"], 0)
         self.assertEqual(result["context"]["node_id"], self.node.id)
 
+    def test_start_session_node_id_no_channel_id_fails(self):
+        self.client.login(
+            username=self.user.username,
+            password=DUMMY_PASSWORD,
+            facility=self.facility,
+        )
+        response = self._make_request({}, delete_keys=["channel_id"])
+
+        self.assertEqual(response.status_code, 400)
+
+    def test_start_session_node_id_no_content_id_fails(self):
+        self.client.login(
+            username=self.user.username,
+            password=DUMMY_PASSWORD,
+            facility=self.facility,
+        )
+        response = self._make_request({}, delete_keys=["content_id"])
+
+        self.assertEqual(response.status_code, 400)
+
+    def test_start_session_node_id_has_mastery_model_not_exercise_fails(self):
+        self.client.login(
+            username=self.user.username,
+            password=DUMMY_PASSWORD,
+            facility=self.facility,
+        )
+        response = self._make_request({"mastery_model": {"type": "do_all"}})
+
+        self.assertEqual(response.status_code, 400)
+
+    def test_start_session_node_id_no_mastery_model_exercise_fails(self):
+        self.node.kind = content_kinds.EXERCISE
+        self.node.save()
+        self.client.login(
+            username=self.user.username,
+            password=DUMMY_PASSWORD,
+            facility=self.facility,
+        )
+        response = self._make_request({})
+
+        self.assertEqual(response.status_code, 400)
+
     def tearDown(self):
         self.client.logout()
 
@@ -449,7 +504,12 @@ def setUp(self):
         )
 
     def _make_request(self, data):
-        post_data = {"node_id": self.node.id}
+        post_data = {
+            "node_id": self.node.id,
+            "content_id": self.node.content_id,
+            "channel_id": self.node.channel_id,
+            "kind": self.node.kind,
+        }
         post_data.update(data)
         return self.client.post(
             reverse("kolibri:core:trackprogress-list"),
@@ -548,7 +608,13 @@ def setUp(self):
         )
 
     def _make_request(self, data):
-        post_data = {"node_id": self.node.id}
+        post_data = {
+            "node_id": self.node.id,
+            "content_id": self.node.content_id,
+            "channel_id": self.node.channel_id,
+            "kind": self.node.kind,
+            "mastery_model": self.assessmentmetadata.mastery_model,
+        }
         post_data.update(data)
         return self.client.post(
             reverse("kolibri:core:trackprogress-list"),
@@ -596,7 +662,11 @@ def test_start_assessment_session_logged_in_practice_quiz_succeeds(self):
         self.mastery_log.mastery_level = -10
         self.mastery_log.mastery_criterion = {"type": exercises.QUIZ}
         self.mastery_log.save()
-        response = self._make_request({})
+        response = self._make_request(
+            {
+                "mastery_model": {"type": exercises.QUIZ},
+            }
+        )
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json()["mastery_criterion"], {"type": exercises.QUIZ})
@@ -653,7 +723,7 @@ def test_start_assessment_session_logged_in_completed_practice_quiz_no_new(self)
         self.mastery_log.mastery_criterion = {"type": exercises.QUIZ}
         self.mastery_log.complete = True
         self.mastery_log.save()
-        response = self._make_request({})
+        response = self._make_request({"mastery_model": {"type": exercises.QUIZ}})
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(MasteryLog.objects.all().count(), 1)
@@ -698,6 +768,7 @@ def test_start_assessment_session_logged_in_completed_practice_quiz_repeat_new(
         self.mastery_log.save()
         response = self._make_request(
             {
+                "mastery_model": {"type": exercises.QUIZ},
                 "repeat": True,
             }
         )
@@ -809,7 +880,7 @@ def test_start_assessment_session_logged_in_with_history_practice_quiz_type(self
                 answer=interaction["answer"],
                 interaction_history=[interaction],
             )
-        response = self._make_request({})
+        response = self._make_request({"mastery_model": {"type": exercises.QUIZ}})
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json()["totalattempts"], 15)
@@ -861,7 +932,7 @@ def test_start_assessment_session_logged_in_with_history_practice_quiz_type_exer
                 answer=interaction["answer"],
                 interaction_history=[interaction],
             )
-        response = self._make_request({})
+        response = self._make_request({"mastery_model": {"type": exercises.QUIZ}})
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json()["totalattempts"], 15)
diff --git a/kolibri/plugins/learn/assets/src/composables/__tests__/useProgressTracking.spec.js b/kolibri/plugins/learn/assets/src/composables/__tests__/useProgressTracking.spec.js
--- a/kolibri/plugins/learn/assets/src/composables/__tests__/useProgressTracking.spec.js
+++ b/kolibri/plugins/learn/assets/src/composables/__tests__/useProgressTracking.spec.js
@@ -1,4 +1,5 @@
 import { get, set } from '@vueuse/core';
+import omit from 'lodash/omit';
 import client from 'kolibri.client';
 import { coreStoreFactory as makeStore } from 'kolibri.coreVue.vuex.store';
 import useProgressTracking from '../useProgressTracking';
@@ -11,6 +12,13 @@ function setUp() {
   return { store, ...useProgressTracking(store) };
 }
 
+const node = {
+  id: 'test_node',
+  channel_id: 'test_channel_id',
+  content_id: 'test_content_id',
+  kind: 'video',
+};
+
 describe('useProgressTracking composable', () => {
   describe('initContentSession', () => {
     it('should throw an error if no context provided', async () => {
@@ -18,7 +26,7 @@ describe('useProgressTracking composable', () => {
       try {
         await initContentSession({});
       } catch (error) {
-        expect(error).toEqual(new TypeError('Must define either nodeId or quizId'));
+        expect(error).toEqual(new TypeError('Must define either node or quizId'));
       }
     });
     it('should throw an error if only lessonId provided', async () => {
@@ -26,13 +34,13 @@ describe('useProgressTracking composable', () => {
       try {
         await initContentSession({ lessonId: 'test_lesson' });
       } catch (error) {
-        expect(error).toEqual(new TypeError('Must define either nodeId or quizId'));
+        expect(error).toEqual(new TypeError('Must define either node or quizId'));
       }
     });
-    it('should throw an error if quizId and nodeId provided', async () => {
+    it('should throw an error if quizId and node provided', async () => {
       const { initContentSession } = setUp();
       try {
-        await initContentSession({ quizId: 'test_quiz', nodeId: 'test_node' });
+        await initContentSession({ quizId: 'test_quiz', node });
       } catch (error) {
         expect(error).toEqual(
           new TypeError('quizId must be the only defined parameter if defined')
@@ -52,24 +60,111 @@ describe('useProgressTracking composable', () => {
         );
       }
     });
+    it.each(['id', 'content_id', 'channel_id', 'kind'])(
+      'should throw an error if %s is missing from node',
+      async property => {
+        const { initContentSession } = setUp();
+        try {
+          await initContentSession({
+            node: omit(node, property),
+          });
+        } catch (error) {
+          expect(error).toEqual(new TypeError(`node must have ${property} property`));
+        }
+      }
+    );
+    it('should throw an error if assessmentmetadata is missing from an exercise node', async () => {
+      const { initContentSession } = setUp();
+      try {
+        await initContentSession({
+          node: {
+            ...node,
+            kind: 'exercise',
+          },
+        });
+      } catch (error) {
+        expect(error).toEqual(new TypeError(`node must have assessmentmetadata property`));
+      }
+    });
+    it('should throw an error if mastery_model is missing from assessmentmetadata on an exercise node', async () => {
+      const { initContentSession } = setUp();
+      try {
+        await initContentSession({
+          node: {
+            ...node,
+            kind: 'exercise',
+            assessmentmetadata: {},
+          },
+        });
+      } catch (error) {
+        expect(error).toEqual(
+          new TypeError(`node must have assessmentmetadata property with mastery_model property`)
+        );
+      }
+    });
+    it('should throw an error if mastery_model is not an object on assessmentmetadata on an exercise node', async () => {
+      const { initContentSession } = setUp();
+      try {
+        await initContentSession({
+          node: {
+            ...node,
+            kind: 'exercise',
+            assessmentmetadata: {
+              mastery_model: [],
+            },
+          },
+        });
+      } catch (error) {
+        expect(error).toEqual(
+          new TypeError(
+            `node must have assessmentmetadata property with plain object mastery_model property`
+          )
+        );
+      }
+    });
+    it('should throw an error if there is no type property on mastery_model on assessmentmetadata on an exercise node', async () => {
+      const { initContentSession } = setUp();
+      try {
+        await initContentSession({
+          node: {
+            ...node,
+            kind: 'exercise',
+            assessmentmetadata: {
+              mastery_model: {},
+            },
+          },
+        });
+      } catch (error) {
+        expect(error).toEqual(
+          new TypeError(
+            `node must have assessmentmetadata property with mastery_model property with type property`
+          )
+        );
+      }
+    });
     it('should not set a lessonId if the lessonId is a falsey value', async () => {
       const { initContentSession } = setUp();
-      const node_id = 'test_node_id';
+      const node_id = node.id;
       const lesson_id = null;
       client.__setPayload({
         context: { node_id, lesson_id },
       });
-      await initContentSession({ nodeId: node_id, lessonId: lesson_id });
-      expect(client.mock.calls[0][0].data).toEqual({ node_id: node_id });
+      await initContentSession({ node, lessonId: lesson_id });
+      expect(client.mock.calls[0][0].data).toEqual({
+        node_id: node.id,
+        kind: node.kind,
+        content_id: node.content_id,
+        channel_id: node.channel_id,
+      });
     });
-    it('should not set a nodeId if the nodeId is a falsey value', async () => {
+    it('should not set a nodeId if the node is a falsey value', async () => {
       const { initContentSession } = setUp();
       const node_id = null;
       const quiz_id = 'test-quiz-id';
       client.__setPayload({
         context: { node_id, quiz_id },
       });
-      await initContentSession({ nodeId: node_id, quizId: quiz_id });
+      await initContentSession({ node: null, quizId: quiz_id });
       expect(client.mock.calls[0][0].data).toEqual({ quiz_id: quiz_id });
     });
     it('should set the logging state with the return data from the client', async () => {
@@ -84,14 +179,14 @@ describe('useProgressTracking composable', () => {
       } = setUp();
       const payload = {
         session_id: 'test_session_id',
-        context: { node_id: 'test_node_id' },
+        context: { node_id: node.id },
         progress: 0.5,
         time_spent: 15,
         extra_fields: { extra: true },
         complete: false,
       };
       client.__setPayload(payload);
-      await initContentSession({ nodeId: payload.context.node_id });
+      await initContentSession({ node });
       expect(get(session_id)).toEqual(payload.session_id);
       expect(get(context).node_id).toEqual(payload.context.node_id);
       expect(get(progress)).toEqual(payload.progress);
@@ -102,7 +197,7 @@ describe('useProgressTracking composable', () => {
     it('should not make a backend request when the session for node_id is already active', async () => {
       const { initContentSession } = setUp();
       const session_id = 'test_session_id';
-      const node_id = 'test_node_id';
+      const node_id = node.id;
       const progress = 0.5;
       const time_spent = 15;
       const extra_fields = { extra: true };
@@ -114,15 +209,15 @@ describe('useProgressTracking composable', () => {
         extra_fields,
         complete: false,
       });
-      await initContentSession({ nodeId: node_id });
+      await initContentSession({ node });
       client.__reset();
-      await initContentSession({ nodeId: node_id });
+      await initContentSession({ node });
       expect(client).not.toHaveBeenCalled();
     });
     it('should not make a backend request when the session for lesson_id and node_id is already active', async () => {
       const { initContentSession } = setUp();
       const session_id = 'test_session_id';
-      const node_id = 'test_node_id';
+      const node_id = node.id;
       const lesson_id = 'test_lesson_id';
       const progress = 0.5;
       const time_spent = 15;
@@ -135,15 +230,15 @@ describe('useProgressTracking composable', () => {
         extra_fields,
         complete: false,
       });
-      await initContentSession({ nodeId: node_id, lessonId: lesson_id });
+      await initContentSession({ node, lessonId: lesson_id });
       client.__reset();
-      await initContentSession({ nodeId: node_id, lessonId: lesson_id });
+      await initContentSession({ node, lessonId: lesson_id });
       expect(client).not.toHaveBeenCalled();
     });
     it('should not make a backend request when the session for lesson_id and node_id is already active unless repeat is true', async () => {
       const { initContentSession } = setUp();
       const session_id = 'test_session_id';
-      const node_id = 'test_node_id';
+      const node_id = node.id;
       const lesson_id = 'test_lesson_id';
       const progress = 0.5;
       const time_spent = 15;
@@ -156,10 +251,10 @@ describe('useProgressTracking composable', () => {
         extra_fields,
         complete: false,
       });
-      await initContentSession({ nodeId: node_id, lessonId: lesson_id });
+      await initContentSession({ node, lessonId: lesson_id });
       client.__reset();
       await initContentSession({
-        nodeId: node_id,
+        node,
         lessonId: lesson_id,
         repeat: true,
       });
@@ -220,7 +315,7 @@ describe('useProgressTracking composable', () => {
       } = setUp();
       const payload = {
         session_id: 'test_session_id',
-        context: { node_id: 'test_node_id' },
+        context: { node_id: node.id },
         progress: 0.5,
         time_spent: 15,
         extra_fields: { extra: true },
@@ -241,7 +336,15 @@ describe('useProgressTracking composable', () => {
         complete: false,
       };
       client.__setPayload(payload);
-      await initContentSession({ nodeId: payload.context.node_id });
+      await initContentSession({
+        node: {
+          ...node,
+          kind: 'exercise',
+          assessmentmetadata: {
+            mastery_model: payload.mastery_criterion,
+          },
+        },
+      });
       expect(get(session_id)).toEqual(payload.session_id);
       expect(get(context).node_id).toEqual(payload.context.node_id);
       expect(get(progress)).toEqual(payload.progress);
@@ -266,7 +369,7 @@ describe('useProgressTracking composable', () => {
       client.mockImplementation(() => {
         return Promise.reject(error);
       });
-      await expect(initContentSession({ nodeId: 'test' })).rejects.toMatchObject(error);
+      await expect(initContentSession({ node })).rejects.toMatchObject(error);
       expect(client).toHaveBeenCalledTimes(5);
     });
   });
@@ -274,7 +377,7 @@ describe('useProgressTracking composable', () => {
     async function initStore(data = {}) {
       const all = setUp();
       const session_id = 'test_session_id';
-      const node_id = 'test_node_id';
+      const node_id = node.id;
       const progress = 0.5;
       const time_spent = 15;
       const extra_fields = {};
@@ -291,7 +394,7 @@ describe('useProgressTracking composable', () => {
       };
       Object.assign(payload, data);
       client.__setPayload(payload);
-      await all.initContentSession({ nodeId: node_id });
+      await all.initContentSession({ node });
       client.__reset();
       return all;
     }