Repository: learningequality/kolibri

Issue:
Learner view: retry functionality for independent practice quiz is implemented
### User stories for this issue:

> As a learner, I want to be able to retake/retry an independent practice quiz from a channel OR from a lesson so that I can improve my score.

(Please note this issue is for both retrying a practice quiz from a channel and lesson)

|Frame|Explanation|
|--|--|
|![Screen Shot 2021-10-20 at 8 25 03 PM](https://user-images.githubusercontent.com/13563002/138210913-302a26eb-973d-4273-a97d-07ec17e74e41.png)|Learner navigates to independent practice quiz report from lesson or channel and clicks on "Practice again" button (see Figma for details)|
|![Screen Shot 2021-10-20 at 8 54 28 PM](https://user-images.githubusercontent.com/13563002/138210987-0313c99a-7c6d-4e69-853b-79ba91587911.png)|Learner is directed to practice quiz|
|![Screen Shot 2021-10-20 at 8 55 22 PM](https://user-images.githubusercontent.com/13563002/138211064-cf633ac1-bf90-44a1-8c7b-6f1e2a22f343.png)| **IF CHANNEL**: Learner is returned to channel and sees the completion data (score) for the most recent attempt on content card|
|![Screen Shot 2021-10-20 at 8 24 40 PM](https://user-images.githubusercontent.com/13563002/138211356-f3d255b2-3bf8-4a55-b86e-b46495b842e9.png)| **IF LESSON**: Learner is returned to lesson and sees the completion data (score) for the most recent attempt on card|
|![Screen Shot 2021-10-20 at 8 25 03 PM](https://user-images.githubusercontent.com/13563002/138211406-a73e03ef-9134-4bae-a5e5-9c33f7b92cab.png)| **IF FIRST ATTEMPT**: Learner clicks on the content card from frame above and sees this view of the report|
|![Screen Shot 2021-10-20 at 9 26 21 PM](https://user-images.githubusercontent.com/13563002/138211497-6ad87896-5975-4619-bf3e-216f76e4fdd1.png)| **IF AFTER FIRST ATTEMPT**: Learner clicks on content card from frame above and sees this view of the report|

### Notes and changes to current designs:
- [Figma for retry from a **lesson**](https://www.figma.com/file/TfaSTT9gs6fPKhGdNUjffU/Channel-quizzes?node-id=919%3A6694)
- [Figma for retry from a **channel**](https://www.figma.com/file/TfaSTT9gs6fPKhGdNUjffU/Channel-quizzes?node-id=911%3A6510)
- This is to address what happens after a learner clicks on the "Practice again" button in their report
- Behavior for the retry functionality is the same for when learner access this function either from a lesson or channel; however, routing is different
- Routing and navigation must be created to and from channel, and to and from lesson
- All strings required should already be in `commonCoreStrings`, `commonCoachStrings`, or `commonLearnStrings`
- Important to note that the `ReportsPracticeQuizLearnerPage` will differ depending on whether this was the learner's first attempt, or after multiple (see frames above)
- If a learner exits a practice quiz before they have submitted,
   - the timer resets
   - user will be returned to the page where they found the independent practice quiz (channel or lesson)
   - the "Completion status" at the bottom of the card should reset to "in progress"

### Criteria for success:
**Please copy these into your PR for "Steps for Testing"!**

_This issue is done when the checkbox and Gherkin story below works_

- [ ] Learners will see a different report depending whether learner it has been the first attempt on the practice quiz or it has been multiple
- [ ] Learners will see the most recent attempt's score on their lesson or channel content card after submitting quiz
- [ ] Learners can retry an independent practice quiz from a channel or lesson
- [ ] If a learner exits a practice quiz before they have submitted,
   - [ ] the timer resets
   - [ ] user will be returned to the page where they found the independent practice quiz (channel or lesson)
   - [ ] the "Completion status" in the content card should reset to "in progress"
```
Feature: Learners can retry an independent practice quiz
	Scenario: Learner can retake an independent practice quiz that was assigned in a lesson or found in a channel
			Given that I have finished and submitted the independent practice quiz
				And I am back on in the channels tab (or, for a lesson, back in my lesson)
				And I see my score on the same independent practice quiz content card
				And I click on the content card
				And I see my independent practice quiz report
			When I click on the button to retake the independent practice quiz
				Then I see that same independent practice quiz appear, ready to be taken and submitted
```


Discussion:
Hey team! Please [add your planning poker estimate](https://app.zenhub.com/workspaces/Kolibri-Ecosystem-6047e525b8b9d900182e2d6f/issues/learningequality/kolibri/8510?planning-poker) with ZenHub @jredrejo @marcellamaki @sairina @rtibbles @MisRob @nucleogenesis @jamalex

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
@@ -11,12 +11,14 @@
 from django.db.models import Sum
 from django.db.models import Value
 from django.http import Http404
+from django_filters.filters import NumberFilter
 from django_filters.rest_framework import CharFilter
 from django_filters.rest_framework import DjangoFilterBackend
 from django_filters.rest_framework import FilterSet
 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 filters
 from rest_framework import serializers
 from rest_framework import viewsets
@@ -237,7 +239,9 @@ def _get_context(self, user, validated_data):
                             ).values_list("mastery_model", flat=True)[:1]
                         )
                     )
-                    .values("content_id", "channel_id", "kind", "mastery_model")
+                    .values(
+                        "content_id", "channel_id", "kind", "mastery_model", "options"
+                    )
                     .get(id=node_id)
                 )
                 mastery_model = node["mastery_model"]
@@ -248,11 +252,16 @@ def _get_context(self, user, validated_data):
                 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")
         elif quiz_id is not None:
             self._check_quiz_permissions(user, quiz_id)
-            mastery_model = {"type": "quiz", "coach_assigned": True}
+            mastery_model = {"type": exercises.QUIZ, "coach_assigned": True}
             content_id = quiz_id
             channel_id = None
             kind = content_kinds.QUIZ
@@ -425,7 +434,7 @@ def _check_quiz_log_permissions(self, masterylog):
         if (
             masterylog
             and masterylog.complete
-            and masterylog.mastery_criterion.get("type") == "quiz"
+            and masterylog.mastery_criterion.get("type") == exercises.QUIZ
             and masterylog.mastery_criterion.get("coach_assigned")
         ):
             raise PermissionDenied("Cannot update a finished coach assigned quiz")
@@ -439,15 +448,22 @@ def _get_or_create_masterylog(
         start_timestamp,
         context,
     ):
-        masterylog = (
-            MasteryLog.objects.filter(
-                summarylog=summarylog,
-                user=user,
-            )
-            .order_by("-complete", "-end_timestamp")
-            .first()
+        is_quiz = mastery_model["type"] == exercises.QUIZ
+        masterylogs = MasteryLog.objects.filter(
+            summarylog=summarylog,
+            user=user,
         )
 
+        # Just in case there is an exercise that might have the same content_id
+        # and hence the same SummaryLog as a practice quiz, we filter masterylogs
+        # here by whether they have a negative mastery_level or not, depending
+        # on whether this is a quiz or an exercise.
+        if is_quiz:
+            masterylogs = masterylogs.filter(mastery_level__lt=0)
+        else:
+            masterylogs = masterylogs.filter(mastery_level__gt=0)
+        masterylog = masterylogs.order_by("-complete", "-end_timestamp").first()
+
         if masterylog is None or (masterylog.complete and repeat):
             # There is no previous masterylog, or the previous masterylog
             # is complete, and the request is requesting a new attempt.
@@ -457,9 +473,12 @@ def _get_or_create_masterylog(
             # identifier being created. So if the same user engages with the same assessment on different
             # devices, when the data synchronizes, if the mastery_level is the same, this data will be
             # unified under a single try.
-            if mastery_model.get("coach_assigned"):
+            if is_quiz:
                 # To prevent coach assigned quiz mastery logs from propagating to older
                 # Kolibri versions, we use negative mastery levels for these.
+                # Also, to prevent collisions between mastery logs for practice quizzes
+                # and mastery logs for exercises with the same content_id we also
+                # use negative mastery levels for practice quizzes.
                 # In older versions of Kolibri the mastery_level is validated to be
                 # between 1 and 10 - so these values will fail validation and hence will
                 # not be deserialized from the morango store.
@@ -516,7 +535,7 @@ def _start_assessment_session(
             attemptlogs = attemptlogs[: mastery_criterion["n"]]
         elif exercise_type in MAPPING:
             attemptlogs = attemptlogs[: MAPPING[exercise_type]]
-        elif exercise_type == "quiz":
+        elif exercise_type == exercises.QUIZ:
             attemptlogs = attemptlogs.order_by()
         else:
             attemptlogs = attemptlogs[:10]
@@ -526,6 +545,7 @@ def _start_assessment_session(
             "pastattempts": attemptlogs,
             "totalattempts": masterylog.attemptlogs.count(),
             "complete": masterylog.complete,
+            "time_spent": masterylog.time_spent or 0,
         }, masterylog.mastery_level
 
     def _generate_interaction_summary(self, validated_data):
@@ -551,7 +571,7 @@ def _process_masterylog_completed_notification(self, masterylog, context):
             )
 
     def _update_and_return_mastery_log_id(
-        self, user, complete, summarylog_id, end_timestamp, context
+        self, user, complete, time_spent_delta, summarylog_id, end_timestamp, context
     ):
         if not user.is_anonymous() and context["mastery_level"] is not None:
             try:
@@ -560,19 +580,26 @@ def _update_and_return_mastery_log_id(
                     mastery_level=context["mastery_level"],
                     summarylog_id=summarylog_id,
                 )
+                update_fields = tuple()
+                if time_spent_delta:
+                    masterylog.time_spent = (
+                        masterylog.time_spent or 0
+                    ) + time_spent_delta
+                    update_fields += ("time_spent",)
                 if complete and not masterylog.complete:
                     masterylog.complete = True
                     masterylog.completion_timestamp = end_timestamp
-                    masterylog.save(
-                        update_fields=(
-                            "complete",
-                            "completion_timestamp",
-                            "_morango_dirty_bit",
-                        )
+                    update_fields += (
+                        "complete",
+                        "completion_timestamp",
                     )
                     self._process_masterylog_completed_notification(masterylog, context)
                 else:
                     self._check_quiz_log_permissions(masterylog)
+                if update_fields:
+                    masterylog.save(
+                        update_fields=update_fields + ("_morango_dirty_bit",)
+                    )
                 return masterylog.id
             except MasteryLog.DoesNotExist:
                 raise ValidationError(
@@ -824,7 +851,12 @@ def update(self, request, pk=None):
                 pk, request.user, end_timestamp, validated_data
             )
             masterylog_id = self._update_and_return_mastery_log_id(
-                request.user, output["complete"], summarylog_id, end_timestamp, context
+                request.user,
+                output["complete"],
+                validated_data.get("time_spent_delta"),
+                summarylog_id,
+                end_timestamp,
+                context,
             )
             if "interactions" in validated_data:
                 attempt_output = self._update_or_create_attempts(
@@ -882,6 +914,7 @@ class MasteryLogViewSet(ReadOnlyValuesViewset):
         "completion_timestamp",
         "mastery_level",
         "complete",
+        "time_spent",
     )
     summary_values = (
         "id",
@@ -913,9 +946,7 @@ def summary(self, request):
 
         return Response(queryset)
 
-    @action(detail=True)
-    def diff(self, request, pk):
-        target_try = self.get_object()
+    def _get_diff_response(self, target_try):
         previous_try = get_previous_try(target_try)
 
         diff = try_diff(target_try, previous_try)
@@ -924,21 +955,48 @@ def diff(self, request, pk):
         if previous_try:
             attempt_logs = attempts_diff(attempt_logs, previous_try.attemptlogs.all())
 
-        data = self.serialize_object()
+        data = self.serialize_object(id=target_try.id)
         data["diff"] = diff
         data["attemptlogs"] = self.attempt_log_view_set.serialize(attempt_logs)
         return Response(data)
 
+    @action(detail=True)
+    def diff(self, request, pk):
+        target_try = self.get_object()
+        return self._get_diff_response(target_try)
+
+    @action(detail=False)
+    def most_recent_diff(self, request):
+        user_id = request.GET.get("user")
+        content_id = request.GET.get("content")
+
+        # required parameters
+        if not user_id:
+            return Response("Parameter `user` is required", status=412)
+        if not content_id:
+            return Response("Parameter `content` is required", status=412)
+
+        target_try = (
+            self.filter_queryset(find_tries(content_id, user_id))
+            .order_by(LOG_ORDER_BY)
+            .first()
+        )
+
+        # Note that for consistency with the `diff` detail view above, this list view
+        # returns a single object, not a list.
+        return self._get_diff_response(target_try)
+
 
 class AttemptFilter(FilterSet):
     content = CharFilter(method="filter_content")
+    mastery_level = NumberFilter(name="masterylog__mastery_level")
 
     def filter_content(self, queryset, name, value):
         return queryset.filter(masterylog__summarylog__content_id=value)
 
     class Meta:
         model = AttemptLog
-        fields = ["masterylog", "complete", "user", "content", "item"]
+        fields = ["masterylog", "complete", "user", "content", "item", "mastery_level"]
 
 
 def _attempts_diff(item):
diff --git a/kolibri/core/logger/migrations/0011_masterylog_time_spent.py b/kolibri/core/logger/migrations/0011_masterylog_time_spent.py
new file mode 100644
--- /dev/null
+++ b/kolibri/core/logger/migrations/0011_masterylog_time_spent.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.29 on 2021-11-18 00:38
+from __future__ import unicode_literals
+
+import django.core.validators
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("logger", "0010_min_length_validation"),
+    ]
+
+    operations = [
+        # We do the migration in two stages so that data recorded in older versions
+        # have the new field just set to null, but new records will have it set to 0 by default.
+        # This should also make sure that migrations on larger databases are quicker too.
+        migrations.AddField(
+            model_name="masterylog",
+            name="time_spent",
+            field=models.FloatField(blank=True, help_text="(in seconds)", null=True),
+        ),
+        migrations.AlterField(
+            model_name="masterylog",
+            name="time_spent",
+            field=models.FloatField(
+                blank=True,
+                default=0.0,
+                help_text="(in seconds)",
+                null=True,
+                validators=[django.core.validators.MinValueValidator(0)],
+            ),
+        ),
+    ]
diff --git a/kolibri/core/logger/models.py b/kolibri/core/logger/models.py
--- a/kolibri/core/logger/models.py
+++ b/kolibri/core/logger/models.py
@@ -242,15 +242,16 @@ class MasteryLog(BaseLogModel):
     mastery_level = models.IntegerField()
     # Has this mastery level been completed?
     complete = models.BooleanField(default=False)
-
-    @property
-    def time_spent(self):
-        """
-        :return: An integer of seconds between start and completion
-        """
-        if not self.completion_timestamp:
-            return None
-        return (self.completion_timestamp - self.start_timestamp).total_seconds()
+    # How long did the learner spend on this specific try of the exercise or quiz?
+    # Field is added as nullable so that we can distinguish values from older instances
+    # of Kolibri prior to this being added and zero values.
+    time_spent = models.FloatField(
+        help_text="(in seconds)",
+        null=True,
+        blank=True,
+        default=0.0,
+        validators=[MinValueValidator(0)],
+    )
 
     def infer_dataset(self, *args, **kwargs):
         return self.cached_related_dataset_lookup("user")


Test patch:
diff --git a/kolibri/core/assets/test/state/store.spec.js b/kolibri/core/assets/test/state/store.spec.js
--- a/kolibri/core/assets/test/state/store.spec.js
+++ b/kolibri/core/assets/test/state/store.spec.js
@@ -200,6 +200,31 @@ describe('Vuex store/actions for core module', () => {
       await store.dispatch('initContentSession', { nodeId: node_id, 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 store = makeStore();
+      const session_id = 'test_session_id';
+      const node_id = 'test_node_id';
+      const lesson_id = 'test_lesson_id';
+      const progress = 0.5;
+      const time_spent = 15;
+      const extra_fields = { extra: true };
+      client.__setPayload({
+        session_id,
+        context: { node_id, lesson_id },
+        progress,
+        time_spent,
+        extra_fields,
+        complete: false,
+      });
+      await store.dispatch('initContentSession', { nodeId: node_id, lessonId: lesson_id });
+      client.__reset();
+      await store.dispatch('initContentSession', {
+        nodeId: node_id,
+        lessonId: lesson_id,
+        repeat: true,
+      });
+      expect(client).toHaveBeenCalled();
+    });
     it('should not make a backend request when the session for quiz_id is already active', async () => {
       const store = makeStore();
       const session_id = 'test_session_id';
@@ -220,6 +245,26 @@ describe('Vuex store/actions for core module', () => {
       await store.dispatch('initContentSession', { quizId: quiz_id });
       expect(client).not.toHaveBeenCalled();
     });
+    it('should not make a backend request when the session for quiz_id is already active unless repeat is true', async () => {
+      const store = makeStore();
+      const session_id = 'test_session_id';
+      const quiz_id = 'test_quiz_id';
+      const progress = 0.5;
+      const time_spent = 15;
+      const extra_fields = { extra: true };
+      client.__setPayload({
+        session_id,
+        context: { quiz_id },
+        progress,
+        time_spent,
+        extra_fields,
+        complete: false,
+      });
+      await store.dispatch('initContentSession', { quizId: quiz_id });
+      client.__reset();
+      await store.dispatch('initContentSession', { quizId: quiz_id, repeat: true });
+      expect(client).toHaveBeenCalled();
+    });
     it('should set the logging state with the return data for an assessment from the client', async () => {
       const store = makeStore();
       const session_id = 'test_session_id';
diff --git a/kolibri/core/logger/test/helpers.py b/kolibri/core/logger/test/helpers.py
--- a/kolibri/core/logger/test/helpers.py
+++ b/kolibri/core/logger/test/helpers.py
@@ -133,6 +133,7 @@ def _create_try(cls, user_index, correct, end_timestamp, duration=30):
             completion_timestamp=end_timestamp,
             complete=True,
             mastery_level=randint(MIN_INTEGER, -1),
+            time_spent=duration * 60,
         )
 
         for i in range(3):
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
@@ -9,6 +9,7 @@
 from django.http.cookie import SimpleCookie
 from le_utils.constants import content_kinds
 from le_utils.constants import exercises
+from le_utils.constants import modalities
 from mock import patch
 from rest_framework.test import APITestCase
 from six import string_types
@@ -311,7 +312,7 @@ def test_start_assessment_session_logged_in_coach_assigned_succeeds(self):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(
             response.json()["mastery_criterion"],
-            {"type": "quiz", "coach_assigned": True},
+            {"type": exercises.QUIZ, "coach_assigned": True},
         )
         self.assertEqual(ContentSessionLog.objects.all().count(), 1)
         log = ContentSessionLog.objects.get()
@@ -326,7 +327,7 @@ def test_start_assessment_session_logged_in_coach_assigned_succeeds(self):
         self.assertEqual(ContentSummaryLog.objects.all().count(), 1)
         log = MasteryLog.objects.get()
         self.assertEqual(
-            log.mastery_criterion, {"type": "quiz", "coach_assigned": True}
+            log.mastery_criterion, {"type": exercises.QUIZ, "coach_assigned": True}
         )
         result = response.json()
         self.assertEqual(result["progress"], 0)
@@ -362,6 +363,47 @@ def test_start_assessment_session_logged_in_coach_assigned_quiz_not_active_fails
 
         self.assertEqual(response.status_code, 403)
 
+    def test_start_assessment_session_logged_in_practice_quiz_succeeds(self):
+        self.node.kind = content_kinds.EXERCISE
+        self.node.options = {"modality": modalities.QUIZ}
+        self.node.save()
+        mastery_model = {"type": exercises.M_OF_N, "m": 8, "n": 10}
+        AssessmentMetaData.objects.create(
+            mastery_model=mastery_model,
+            contentnode=self.node,
+            id=uuid.uuid4().hex,
+            number_of_assessments=20,
+        )
+        self.client.login(
+            username=self.user.username,
+            password=DUMMY_PASSWORD,
+            facility=self.facility,
+        )
+        response = self._make_request({})
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json()["mastery_criterion"], {"type": exercises.QUIZ})
+        self.assertEqual(ContentSessionLog.objects.all().count(), 1)
+        log = ContentSessionLog.objects.get()
+        self.assertEqual(log.content_id, self.content_id)
+        self.assertEqual(log.channel_id, self.channel_id)
+        self.assertEqual(ContentSummaryLog.objects.all().count(), 1)
+        log = ContentSummaryLog.objects.get()
+        self.assertEqual(log.content_id, self.content_id)
+        self.assertEqual(log.channel_id, self.channel_id)
+        self.assertEqual(ContentSummaryLog.objects.all().count(), 1)
+        log = MasteryLog.objects.get()
+        self.assertEqual(log.mastery_criterion, {"type": exercises.QUIZ})
+        self.assertLess(log.mastery_level, 0)
+        result = response.json()
+        self.assertEqual(result["progress"], 0)
+        self.assertEqual(result["time_spent"], 0)
+        self.assertEqual(result["complete"], False)
+        self.assertEqual(result["extra_fields"], {})
+        self.assertEqual(result["pastattempts"], [])
+        self.assertEqual(result["totalattempts"], 0)
+        self.assertEqual(result["context"]["node_id"], self.node.id)
+
     def tearDown(self):
         self.client.logout()
 
@@ -490,6 +532,7 @@ def setUp(self):
             start_timestamp=local_now(),
             end_timestamp=local_now(),
             kind="exercise",
+            time_spent=17,
         )
 
         self.mastery_log = MasteryLog.objects.create(
@@ -498,6 +541,7 @@ def setUp(self):
             start_timestamp=self.summary_log.start_timestamp,
             user=self.user,
             mastery_level=1,
+            time_spent=7,
         )
         self.client.login(
             username=self.user.username,
@@ -533,18 +577,54 @@ def test_start_assessment_session_logged_in_succeeds(self):
         self.assertEqual(log.channel_id, new_channel_id)
         self.assertEqual(ContentSummaryLog.objects.all().count(), 1)
         data = response.json()
-        self.assertEqual(log.time_spent, data["time_spent"])
         self.assertEqual(log.progress, data["progress"])
         self.assertEqual(log.extra_fields, data["extra_fields"])
         self.assertEqual(self.node.id, data["context"]["node_id"])
         self.assertEqual(session_id, data["session_id"])
         log = MasteryLog.objects.get()
+        self.assertEqual(log.time_spent, data["time_spent"])
         self.assertEqual(log.mastery_level, data["context"]["mastery_level"])
         self.assertEqual(log.mastery_level, data["context"]["mastery_level"])
         self.assertEqual(log.mastery_criterion, self.mastery_model)
         self.assertEqual(data["mastery_criterion"], self.mastery_model)
         self.assertEqual(log.complete, data["complete"])
 
+    def test_start_assessment_session_logged_in_practice_quiz_succeeds(self):
+        new_channel_id = uuid.uuid4().hex
+        self.node.channel_id = new_channel_id
+        self.node.options["modality"] = modalities.QUIZ
+        self.node.save()
+        self.mastery_log.delete()
+        self.mastery_log.mastery_level = -10
+        self.mastery_log.mastery_criterion = {"type": exercises.QUIZ}
+        self.mastery_log.save()
+        response = self._make_request({})
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json()["mastery_criterion"], {"type": exercises.QUIZ})
+        self.assertEqual(ContentSessionLog.objects.all().count(), 2)
+        log = ContentSessionLog.objects.filter(channel_id=new_channel_id).get()
+        self.assertEqual(log.content_id, self.content_id)
+        self.assertEqual(log.channel_id, new_channel_id)
+        self.assertEqual(ContentSummaryLog.objects.all().count(), 1)
+        session_id = log.id
+        log = ContentSummaryLog.objects.get()
+        self.assertEqual(log.content_id, self.content_id)
+        self.assertEqual(log.channel_id, new_channel_id)
+        self.assertEqual(ContentSummaryLog.objects.all().count(), 1)
+        data = response.json()
+        self.assertEqual(log.progress, data["progress"])
+        self.assertEqual(log.extra_fields, data["extra_fields"])
+        self.assertEqual(self.node.id, data["context"]["node_id"])
+        self.assertEqual(session_id, data["session_id"])
+        log = MasteryLog.objects.get(mastery_level=-10)
+        self.assertEqual(log.time_spent, data["time_spent"])
+        self.assertEqual(log.mastery_level, data["context"]["mastery_level"])
+        self.assertEqual(log.mastery_level, data["context"]["mastery_level"])
+        self.assertEqual(log.mastery_criterion, {"type": exercises.QUIZ})
+        self.assertEqual(data["mastery_criterion"], {"type": exercises.QUIZ})
+        self.assertEqual(log.complete, data["complete"])
+
     def test_start_assessment_session_logged_in_changed_mastery_model_succeeds(self):
         self.assessmentmetadata.mastery_model = {
             "type": exercises.M_OF_N,
@@ -567,6 +647,19 @@ def test_start_assessment_session_logged_in_completed_no_new(self):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(MasteryLog.objects.all().count(), 1)
 
+    def test_start_assessment_session_logged_in_completed_practice_quiz_no_new(self):
+        self.node.options["modality"] = modalities.QUIZ
+        self.node.save()
+        self.mastery_log.delete()
+        self.mastery_log.mastery_level = -10
+        self.mastery_log.mastery_criterion = {"type": exercises.QUIZ}
+        self.mastery_log.complete = True
+        self.mastery_log.save()
+        response = self._make_request({})
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(MasteryLog.objects.all().count(), 1)
+
     def test_start_assessment_session_logged_in_completed_repeat_new(self):
         self.mastery_log.complete = True
         self.mastery_log.save()
@@ -579,6 +672,25 @@ def test_start_assessment_session_logged_in_completed_repeat_new(self):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(MasteryLog.objects.all().count(), 2)
 
+    def test_start_assessment_session_logged_in_completed_practice_quiz_repeat_new(
+        self,
+    ):
+        self.node.options["modality"] = modalities.QUIZ
+        self.node.save()
+        self.mastery_log.delete()
+        self.mastery_log.mastery_level = -10
+        self.mastery_log.mastery_criterion = {"type": exercises.QUIZ}
+        self.mastery_log.complete = True
+        self.mastery_log.save()
+        response = self._make_request(
+            {
+                "repeat": True,
+            }
+        )
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(MasteryLog.objects.all().count(), 2)
+
     def test_start_assessment_session_logged_in_with_history(self):
         timestamp = local_now()
         interaction = {
@@ -658,14 +770,18 @@ def test_start_assessment_session_logged_in_with_history_in_a_row_type(self):
         self.assertEqual(response.json()["totalattempts"], 15)
         self.assertEqual(len(response.json()["pastattempts"]), 2)
 
-    def test_start_assessment_session_logged_in_with_history_quiz_type(self):
+    def test_start_assessment_session_logged_in_with_history_practice_quiz_type(self):
         timestamp = local_now()
         interaction = {
             "type": interaction_types.ANSWER,
             "answer": {"response": "hinty mchintyson"},
             "correct": 0,
         }
-        self.mastery_log.mastery_criterion = {"type": "quiz", "coach_assigned": True}
+        self.node.options["modality"] = modalities.QUIZ
+        self.node.save()
+        self.mastery_log.delete()
+        self.mastery_log.mastery_criterion = {"type": exercises.QUIZ}
+        self.mastery_log.mastery_level = -10
         self.mastery_log.save()
         for i in range(0, 15):
             AttemptLog.objects.create(
@@ -674,7 +790,59 @@ def test_start_assessment_session_logged_in_with_history_quiz_type(self):
                 start_timestamp=timestamp,
                 end_timestamp=timestamp,
                 correct=0,
-                item="{}:test_item_id".format(i),
+                item="{}test_item_id".format(i),
+                user=self.user,
+                answer=interaction["answer"],
+                interaction_history=[interaction],
+            )
+        response = self._make_request({})
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json()["totalattempts"], 15)
+        self.assertEqual(len(response.json()["pastattempts"]), 15)
+
+    def test_start_assessment_session_logged_in_with_history_practice_quiz_type_exercise_collision(
+        self,
+    ):
+        """
+        Test that if an exercise masterylog exists for this practice quiz that doesn't interfere with
+        the quiz.
+        """
+        timestamp = local_now()
+        interaction = {
+            "type": interaction_types.ANSWER,
+            "answer": {"response": "hinty mchintyson"},
+            "correct": 0,
+        }
+        for i in range(0, 15):
+            AttemptLog.objects.create(
+                masterylog=self.mastery_log,
+                sessionlog=self.session_log,
+                start_timestamp=timestamp,
+                end_timestamp=timestamp,
+                correct=0,
+                item="{}test_item_id".format(i),
+                user=self.user,
+                answer=interaction["answer"],
+                interaction_history=[interaction],
+            )
+        self.node.options["modality"] = modalities.QUIZ
+        self.node.save()
+        self.mastery_log = MasteryLog.objects.create(
+            mastery_criterion={"type": exercises.QUIZ},
+            summarylog=self.summary_log,
+            start_timestamp=self.summary_log.start_timestamp,
+            user=self.user,
+            mastery_level=-10,
+        )
+        for i in range(0, 15):
+            AttemptLog.objects.create(
+                masterylog=self.mastery_log,
+                sessionlog=self.session_log,
+                start_timestamp=timestamp,
+                end_timestamp=timestamp,
+                correct=0,
+                item="{}test_item_id".format(i),
                 user=self.user,
                 answer=interaction["answer"],
                 interaction_history=[interaction],
@@ -714,14 +882,16 @@ def setUp(self):
             start_timestamp=local_now(),
             end_timestamp=local_now(),
             kind="quiz",
+            time_spent=24,
         )
 
         self.mastery_log = MasteryLog.objects.create(
-            mastery_criterion={"type": "quiz", "coach_assigned": True},
+            mastery_criterion={"type": exercises.QUIZ, "coach_assigned": True},
             summarylog=self.summary_log,
             start_timestamp=self.summary_log.start_timestamp,
             user=self.user,
             mastery_level=-1,
+            time_spent=17,
         )
         self.client.login(
             username=self.user.username,
@@ -753,6 +923,7 @@ def test_start_assessment_session_logged_in_succeeds(self):
         self.assertEqual(log.content_id, self.content_id)
         self.assertIsNone(log.channel_id)
         self.assertEqual(ContentSummaryLog.objects.all().count(), 1)
+        self.assertEqual(self.mastery_log.time_spent, response.json()["time_spent"])
 
     def test_start_assessment_session_logged_in_quiz_not_active_fails(self):
         self.quiz.active = False
@@ -796,7 +967,10 @@ def test_start_assessment_session_logged_in_with_history_assigned_quiz(self):
             "answer": {"response": "hinty mchintyson"},
             "correct": 0,
         }
-        self.mastery_log.mastery_criterion = {"type": "quiz", "coach_assigned": True}
+        self.mastery_log.mastery_criterion = {
+            "type": exercises.QUIZ,
+            "coach_assigned": True,
+        }
         self.mastery_log.save()
         for i in range(0, 15):
             AttemptLog.objects.create(
@@ -1886,6 +2060,24 @@ def setUp(self):
     def mastery_level(self):
         return self.mastery_log.mastery_level
 
+    def test_update_assessment_session_update_time_delta_succeeds(self):
+        self.session_log.extra_fields = {
+            "context": {
+                "node_id": self.node.id,
+                "mastery_level": self.mastery_level,
+            }
+        }
+        self.session_log.save()
+        response = self._make_request(
+            {
+                "time_spent_delta": 5,
+            }
+        )
+
+        self.assertEqual(response.status_code, 200)
+        self.mastery_log.refresh_from_db()
+        self.assertEqual(self.mastery_log.time_spent, 5)
+
     def test_update_assessment_session_create_attempt_in_lesson_succeeds(self):
         lesson = create_assigned_lesson_for_user(self.user)
         lesson_id = lesson.id
@@ -2026,7 +2218,7 @@ def setUp(self):
         )
 
         self.mastery_log = MasteryLog.objects.create(
-            mastery_criterion={"type": "quiz", "coach_assigned": True},
+            mastery_criterion={"type": exercises.QUIZ, "coach_assigned": True},
             summarylog=self.summary_log,
             start_timestamp=self.summary_log.start_timestamp,
             user=self.user,
@@ -2054,6 +2246,18 @@ def _make_request(self, data):
             format="json",
         )
 
+    def test_update_assessment_session_update_time_delta_succeeds(self):
+        with patch("kolibri.core.logger.api.wrap_to_save_queue"):
+            response = self._make_request(
+                {
+                    "time_spent_delta": 5,
+                }
+            )
+
+        self.assertEqual(response.status_code, 200)
+        self.mastery_log.refresh_from_db()
+        self.assertEqual(self.mastery_log.time_spent, 5)
+
     def test_update_assessment_session_create_attempt_succeeds(self):
         with patch("kolibri.core.logger.api.wrap_to_save_queue") as save_queue_mock:
             super(
@@ -2196,3 +2400,193 @@ def test_update_assessment_session_update_attempt_closed_quiz_fails(self):
 
     def tearDown(self):
         self.client.logout()
+
+
+class ProgressTrackingViewSetLoggedInUpdateSessionAssessmentPracticeQuizTestCase(
+    ProgressTrackingViewSetUpdateSessionAssessmentBase, APITestCase
+):
+    def setUp(self):
+        self.facility = FacilityFactory.create()
+        # provision device to pass the setup_wizard middleware check
+        provision_device()
+        self.user = FacilityUserFactory.create(facility=self.facility)
+
+        self.channel_id = uuid.uuid4().hex
+        self.content_id = uuid.uuid4().hex
+
+        self.node = ContentNode.objects.create(
+            channel_id=self.channel_id,
+            content_id=self.content_id,
+            id=uuid.uuid4().hex,
+            options={"modality": modalities.QUIZ},
+        )
+        mastery_model = {"type": exercises.M_OF_N, "m": 8, "n": 10}
+        AssessmentMetaData.objects.create(
+            mastery_model=mastery_model,
+            contentnode=self.node,
+            id=uuid.uuid4().hex,
+            number_of_assessments=20,
+        )
+
+        self.session_log = ContentSessionLog.objects.create(
+            user=self.user,
+            content_id=self.content_id,
+            channel_id=self.channel_id,
+            start_timestamp=local_now(),
+            end_timestamp=local_now(),
+            kind="exercise",
+            extra_fields={"context": {"node_id": self.node.id, "mastery_level": -1}},
+        )
+        self.summary_log = ContentSummaryLog.objects.create(
+            user=self.user,
+            content_id=self.content_id,
+            channel_id=self.channel_id,
+            start_timestamp=local_now(),
+            end_timestamp=local_now(),
+            kind="exercise",
+        )
+
+        self.item = "test_item_id"
+
+        self.mastery_log = MasteryLog.objects.create(
+            mastery_criterion={"type": exercises.QUIZ},
+            summarylog=self.summary_log,
+            start_timestamp=self.summary_log.start_timestamp,
+            user=self.user,
+            mastery_level=-1,
+        )
+        self.client.login(
+            username=self.user.username,
+            password=DUMMY_PASSWORD,
+            facility=self.facility,
+        )
+
+    @property
+    def mastery_level(self):
+        return self.mastery_log.mastery_level
+
+    def test_update_assessment_session_update_time_delta_succeeds(self):
+        self.session_log.extra_fields = {
+            "context": {
+                "node_id": self.node.id,
+                "mastery_level": self.mastery_level,
+            }
+        }
+        self.session_log.save()
+        response = self._make_request(
+            {
+                "time_spent_delta": 5,
+            }
+        )
+
+        self.assertEqual(response.status_code, 200)
+        self.mastery_log.refresh_from_db()
+        self.assertEqual(self.mastery_log.time_spent, 5)
+
+    def test_update_assessment_session_create_attempt_in_lesson_succeeds(self):
+        lesson = create_assigned_lesson_for_user(self.user)
+        lesson_id = lesson.id
+        self.session_log.extra_fields = {
+            "context": {
+                "lesson_id": lesson_id,
+                "node_id": self.node.id,
+                "mastery_level": self.mastery_level,
+            }
+        }
+        self.session_log.save()
+        with patch("kolibri.core.logger.api.wrap_to_save_queue") as save_queue_mock:
+            response = self._make_request(
+                {
+                    "interactions": [
+                        {
+                            "item": self.item,
+                            "answer": {"response": "test"},
+                            "correct": 1.0,
+                            "time_spent": 10,
+                        }
+                    ],
+                }
+            )
+            save_queue_mock.assert_called()
+            self.assertEqual(save_queue_mock.mock_calls[0][1][0], parse_attemptslog)
+            self.assertTrue(isinstance(save_queue_mock.mock_calls[0][1][1], AttemptLog))
+
+        self.assertEqual(response.status_code, 200)
+        attempt_id = response.json().get("attempts", [{}])[0].get("id")
+        self.assertIsNotNone(attempt_id)
+        try:
+            attempt = AttemptLog.objects.get(id=attempt_id)
+        except AttemptLog.DoesNotExist:
+            self.fail("Attempt not created")
+        self.assertEqual(attempt.item, self.item)
+        self.assertEqual(attempt.correct, 1.0)
+        self.assertEqual(attempt.answer, {"response": "test"})
+        self.assertEqual(attempt.time_spent, 10)
+
+    def test_update_assessment_session_update_attempt_in_lesson_succeeds(self):
+        timestamp = local_now()
+        hinteraction = {
+            "type": interaction_types.HINT,
+            "answer": {"response": "hinty mchintyson"},
+        }
+        attemptlog = AttemptLog.objects.create(
+            masterylog=self.mastery_log,
+            sessionlog=self.session_log,
+            start_timestamp=timestamp,
+            end_timestamp=timestamp,
+            correct=0,
+            item="test_item_id",
+            user=self.user,
+            interaction_history=[hinteraction],
+        )
+        lesson = create_assigned_lesson_for_user(self.user)
+        lesson_id = lesson.id
+        self.session_log.extra_fields = {
+            "context": {
+                "lesson_id": lesson_id,
+                "node_id": self.node.id,
+                "mastery_level": self.mastery_level,
+            }
+        }
+        self.session_log.save()
+        with patch("kolibri.core.logger.api.wrap_to_save_queue") as save_queue_mock:
+            response = self._make_request(
+                {
+                    "interactions": [
+                        {
+                            "id": attemptlog.id,
+                            "item": self.item,
+                            "answer": {"response": "test"},
+                            "correct": 1,
+                            "time_spent": 10,
+                            "replace": True,
+                        }
+                    ],
+                }
+            )
+            save_queue_mock.assert_called()
+            self.assertEqual(save_queue_mock.mock_calls[0][1][0], parse_attemptslog)
+            self.assertTrue(isinstance(save_queue_mock.mock_calls[0][1][1], AttemptLog))
+
+        self.assertEqual(response.status_code, 200)
+        attempt_id = response.json().get("attempts", [{}])[0].get("id")
+        self.assertIsNotNone(attempt_id)
+        try:
+            attempt = AttemptLog.objects.get(id=attempt_id)
+        except AttemptLog.DoesNotExist:
+            self.fail("Nonexistent attempt_id returned")
+        self.assertEqual(attempt.correct, 1.0)
+        self.assertEqual(attempt.answer, {"response": "test"})
+        self.assertEqual(attempt.time_spent, 10)
+        self.assertEqual(attempt.interaction_history[0], hinteraction)
+        self.assertEqual(
+            attempt.interaction_history[1],
+            {
+                "type": interaction_types.ANSWER,
+                "answer": {"response": "test"},
+                "correct": 1.0,
+            },
+        )
+
+    def tearDown(self):
+        self.client.logout()