Repository: oppia/oppia

Issue:
Update mastery levels after every test/practice. Add mastery level information on every result modal/page.
Once tests are complete, we need to update skill mastery for the user and display the results as well to the user

Milestone 3.1 for @sophiewu6 's GSoC project


Discussion:

Resolving patch:
diff --git a/core/controllers/skill_mastery.py b/core/controllers/skill_mastery.py
--- a/core/controllers/skill_mastery.py
+++ b/core/controllers/skill_mastery.py
@@ -18,6 +18,7 @@
 from core.controllers import base
 from core.domain import skill_domain
 from core.domain import skill_services
+import feconf
 import utils
 
 
@@ -26,18 +27,57 @@ class SkillMasteryDataHandler(base.BaseHandler):
     skill mastery.
     """
 
+    GET_HANDLER_ERROR_RETURN_TYPE = feconf.HANDLER_TYPE_JSON
+
+    @acl_decorators.can_access_learner_dashboard
+    def get(self):
+        """Handles GET requests."""
+        comma_separated_skill_ids = (
+            self.request.get('comma_separated_skill_ids'))
+        if not comma_separated_skill_ids:
+            raise self.InvalidInputException(
+                'Expected request to contain parameter '
+                'comma_separated_skill_ids.')
+
+        skill_ids = comma_separated_skill_ids.split(',')
+
+        try:
+            for skill_id in skill_ids:
+                skill_domain.Skill.require_valid_skill_id(skill_id)
+        except Exception:
+            raise self.InvalidInputException('Invalid skill ID %s' % skill_id)
+
+        try:
+            skill_services.get_multi_skills(skill_ids)
+        except Exception as e:
+            raise self.PageNotFoundException(e)
+
+        degrees_of_mastery = skill_services.get_multi_user_skill_mastery(
+            self.user_id, skill_ids)
+
+        self.values.update({
+            'degrees_of_mastery': degrees_of_mastery
+        })
+        self.render_json(self.values)
+
+
     @acl_decorators.can_access_learner_dashboard
     def put(self):
         """Handles PUT requests."""
-        degree_of_mastery_per_skill = (
-            self.payload.get('degree_of_mastery_per_skill'))
-        if (not degree_of_mastery_per_skill or
-                not isinstance(degree_of_mastery_per_skill, dict)):
+        mastery_change_per_skill = (
+            self.payload.get('mastery_change_per_skill'))
+        if (not mastery_change_per_skill or
+                not isinstance(mastery_change_per_skill, dict)):
             raise self.InvalidInputException(
-                'Expected payload to contain degree_of_mastery_per_skill '
+                'Expected payload to contain mastery_change_per_skill '
                 'as a dict.')
 
-        skill_ids = degree_of_mastery_per_skill.keys()
+        skill_ids = mastery_change_per_skill.keys()
+
+        current_degrees_of_mastery = (
+            skill_services.get_multi_user_skill_mastery(self.user_id, skill_ids)
+        )
+        new_degrees_of_mastery = {}
 
         for skill_id in skill_ids:
             try:
@@ -47,27 +87,31 @@ def put(self):
                     'Invalid skill ID %s' % skill_id)
 
             # float(bool) will not raise an error.
-            if isinstance(degree_of_mastery_per_skill[skill_id], bool):
+            if isinstance(mastery_change_per_skill[skill_id], bool):
                 raise self.InvalidInputException(
                     'Expected degree of mastery of skill %s to be a number, '
                     'received %s.'
-                    % (skill_id, degree_of_mastery_per_skill[skill_id]))
+                    % (skill_id, mastery_change_per_skill[skill_id]))
 
             try:
-                degree_of_mastery_per_skill[skill_id] = (
-                    float(degree_of_mastery_per_skill[skill_id]))
+                mastery_change_per_skill[skill_id] = (
+                    float(mastery_change_per_skill[skill_id]))
             except (TypeError, ValueError):
                 raise self.InvalidInputException(
                     'Expected degree of mastery of skill %s to be a number, '
                     'received %s.'
-                    % (skill_id, degree_of_mastery_per_skill[skill_id]))
+                    % (skill_id, mastery_change_per_skill[skill_id]))
 
-            if (degree_of_mastery_per_skill[skill_id] < 0.0 or
-                    degree_of_mastery_per_skill[skill_id] > 1.0):
-                raise self.InvalidInputException(
-                    'Expected degree of mastery of skill %s to be a float '
-                    'between 0.0 and 1.0, received %s.'
-                    % (skill_id, degree_of_mastery_per_skill[skill_id]))
+            if current_degrees_of_mastery[skill_id] is None:
+                current_degrees_of_mastery[skill_id] = 0.0
+            new_degrees_of_mastery[skill_id] = (
+                current_degrees_of_mastery[skill_id] +
+                mastery_change_per_skill[skill_id])
+
+            if new_degrees_of_mastery[skill_id] < 0.0:
+                new_degrees_of_mastery[skill_id] = 0.0
+            elif new_degrees_of_mastery[skill_id] > 1.0:
+                new_degrees_of_mastery[skill_id] = 1.0
 
         try:
             skill_services.get_multi_skills(skill_ids)
@@ -75,6 +119,6 @@ def put(self):
             raise self.PageNotFoundException(e)
 
         skill_services.create_multi_user_skill_mastery(
-            self.user_id, degree_of_mastery_per_skill)
+            self.user_id, new_degrees_of_mastery)
 
         self.render_json({})


Test patch:
diff --git a/core/controllers/skill_mastery_test.py b/core/controllers/skill_mastery_test.py
--- a/core/controllers/skill_mastery_test.py
+++ b/core/controllers/skill_mastery_test.py
@@ -35,13 +35,106 @@ def setUp(self):
         self.save_new_skill(
             self.skill_id_2, self.user_id, 'Skill Description 2')
 
+        self.degree_of_mastery_1 = 0.3
+        self.degree_of_mastery_2 = 0.5
+
+    def test_get_with_valid_skill_ids_list(self):
+        skill_services.create_user_skill_mastery(
+            self.user_id, self.skill_id_1, self.degree_of_mastery_1)
+        skill_services.create_user_skill_mastery(
+            self.user_id, self.skill_id_2, self.degree_of_mastery_2)
+
+        skill_ids = [self.skill_id_1, self.skill_id_2]
+
+        self.login(self.NEW_USER_EMAIL)
+        response_json = self.get_json(
+            '%s' % feconf.SKILL_MASTERY_DATA_URL,
+            params={
+                'comma_separated_skill_ids': ','.join(skill_ids)
+            })
+        degrees_of_mastery = {
+            self.skill_id_1: self.degree_of_mastery_1,
+            self.skill_id_2: self.degree_of_mastery_2
+        }
+        self.assertEqual(
+            response_json['degrees_of_mastery'], degrees_of_mastery)
+
+        self.logout()
+
+    def test_get_with_skill_without_skill_mastery(self):
+        skill_services.create_user_skill_mastery(
+            self.user_id, self.skill_id_1, self.degree_of_mastery_1)
+
+        skill_ids = [self.skill_id_1, self.skill_id_2]
+
+        self.login(self.NEW_USER_EMAIL)
+        response_json = self.get_json(
+            '%s' % feconf.SKILL_MASTERY_DATA_URL,
+            params={
+                'comma_separated_skill_ids': ','.join(skill_ids)
+            })
+        degrees_of_mastery = {
+            self.skill_id_1: self.degree_of_mastery_1,
+            self.skill_id_2: None
+        }
+        self.assertEqual(
+            response_json['degrees_of_mastery'], degrees_of_mastery)
+
+        self.logout()
+
+    def test_get_with_no_skill_ids_returns_400(self):
+        self.login(self.NEW_USER_EMAIL)
+        json_response = self.get_json(
+            '%s' % feconf.SKILL_MASTERY_DATA_URL,
+            expected_status_int=400)
+
+        self.assertEqual(
+            json_response['error'],
+            'Expected request to contain parameter comma_separated_skill_ids.')
+
+        self.logout()
+
+    def test_get_with_invalid_skill_ids_returns_400(self):
+        skill_ids = ['invalid_skill_id']
+
+        self.login(self.NEW_USER_EMAIL)
+        json_response = self.get_json(
+            '%s' % feconf.SKILL_MASTERY_DATA_URL,
+            params={
+                'comma_separated_skill_ids': ','.join(skill_ids)
+            }, expected_status_int=400)
+
+        self.assertEqual(
+            json_response['error'],
+            'Invalid skill ID invalid_skill_id')
+
+        self.logout()
+
+    def test_get_with_nonexistent_skill_ids_returns_404(self):
+        skill_id_3 = skill_services.get_new_skill_id()
+        skill_ids = [self.skill_id_1, skill_id_3]
+
+        self.login(self.NEW_USER_EMAIL)
+        self.get_json(
+            '%s' % feconf.SKILL_MASTERY_DATA_URL,
+            params={
+                'comma_separated_skill_ids': ','.join(skill_ids)
+            }, expected_status_int=404)
+
+        self.logout()
+
     def test_put_with_valid_skill_mastery_dict(self):
+        skill_services.create_user_skill_mastery(
+            self.user_id, self.skill_id_1, self.degree_of_mastery_1)
+        skill_services.create_user_skill_mastery(
+            self.user_id, self.skill_id_2, self.degree_of_mastery_2)
+
         payload = {}
-        degree_of_mastery_per_skill = {
+        mastery_change_per_skill = {
             self.skill_id_1: 0.3,
-            self.skill_id_2: 0.5
+            self.skill_id_2: -0.3
         }
-        payload['degree_of_mastery_per_skill'] = degree_of_mastery_per_skill
+        payload['mastery_change_per_skill'] = mastery_change_per_skill
 
         self.login(self.NEW_USER_EMAIL)
         csrf_token = self.get_new_csrf_token()
@@ -49,12 +142,113 @@ def test_put_with_valid_skill_mastery_dict(self):
             '%s' % feconf.SKILL_MASTERY_DATA_URL,
             payload, csrf_token=csrf_token)
 
+        degrees_of_mastery = {
+            self.skill_id_1: 0.6,
+            self.skill_id_2: 0.2
+        }
+
+        self.assertEqual(
+            skill_services.get_multi_user_skill_mastery(
+                self.user_id, [self.skill_id_1, self.skill_id_2]),
+            degrees_of_mastery)
+
+        self.logout()
+
+    def test_put_with_skill_with_no_skill_mastery(self):
+        skill_services.create_user_skill_mastery(
+            self.user_id, self.skill_id_1, self.degree_of_mastery_1)
+
+        payload = {}
+        mastery_change_per_skill = {
+            self.skill_id_1: 0.3,
+            self.skill_id_2: 0.3
+        }
+        payload['mastery_change_per_skill'] = mastery_change_per_skill
+
+        self.login(self.NEW_USER_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+        self.put_json(
+            '%s' % feconf.SKILL_MASTERY_DATA_URL,
+            payload, csrf_token=csrf_token)
+
+        degrees_of_mastery = {
+            self.skill_id_1: 0.6,
+            self.skill_id_2: 0.3
+        }
+
+        self.assertEqual(
+            skill_services.get_multi_user_skill_mastery(
+                self.user_id, [self.skill_id_1, self.skill_id_2]),
+            degrees_of_mastery)
+
+        self.logout()
+
+    def test_put_with_skill_mastery_lower_than_zero(self):
+        skill_services.create_user_skill_mastery(
+            self.user_id, self.skill_id_1, self.degree_of_mastery_1)
+        skill_services.create_user_skill_mastery(
+            self.user_id, self.skill_id_2, self.degree_of_mastery_2)
+
+        payload = {}
+        mastery_change_per_skill = {
+            self.skill_id_1: -0.5,
+            self.skill_id_2: 0.3
+        }
+        payload['mastery_change_per_skill'] = mastery_change_per_skill
+
+        self.login(self.NEW_USER_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+        self.put_json(
+            '%s' % feconf.SKILL_MASTERY_DATA_URL,
+            payload, csrf_token=csrf_token)
+
+        degrees_of_mastery = {
+            self.skill_id_1: 0.0,
+            self.skill_id_2: 0.8
+        }
+
+        self.assertEqual(
+            skill_services.get_multi_user_skill_mastery(
+                self.user_id, [self.skill_id_1, self.skill_id_2]),
+            degrees_of_mastery)
+
+        self.logout()
+
+    def test_put_with_skill_mastery_higher_than_one(self):
+        skill_services.create_user_skill_mastery(
+            self.user_id, self.skill_id_1, self.degree_of_mastery_1)
+        skill_services.create_user_skill_mastery(
+            self.user_id, self.skill_id_2, self.degree_of_mastery_2)
+
+        payload = {}
+        mastery_change_per_skill = {
+            self.skill_id_1: 0.9,
+            self.skill_id_2: 0.3
+        }
+        payload['mastery_change_per_skill'] = mastery_change_per_skill
+
+        self.login(self.NEW_USER_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+        self.put_json(
+            '%s' % feconf.SKILL_MASTERY_DATA_URL,
+            payload, csrf_token=csrf_token)
+
+        degrees_of_mastery = {
+            self.skill_id_1: 1.0,
+            self.skill_id_2: 0.8
+        }
+
+        self.assertEqual(
+            skill_services.get_multi_user_skill_mastery(
+                self.user_id, [self.skill_id_1, self.skill_id_2]),
+            degrees_of_mastery)
+
         self.logout()
 
     def test_put_with_invalid_type_returns_400(self):
         payload = {}
-        degree_of_mastery_per_skill = [self.skill_id_1, self.skill_id_2]
-        payload['degree_of_mastery_per_skill'] = degree_of_mastery_per_skill
+        mastery_change_per_skill = [self.skill_id_1, self.skill_id_2]
+        payload['mastery_change_per_skill'] = mastery_change_per_skill
 
         self.login(self.NEW_USER_EMAIL)
         csrf_token = self.get_new_csrf_token()
@@ -64,12 +258,12 @@ def test_put_with_invalid_type_returns_400(self):
 
         self.assertEqual(
             json_response['error'],
-            'Expected payload to contain degree_of_mastery_per_skill as a dict.'
+            'Expected payload to contain mastery_change_per_skill as a dict.'
         )
 
         self.logout()
 
-    def test_put_with_no_degree_of_mastery_per_skill_returns_400(self):
+    def test_put_with_no_mastery_change_per_skill_returns_400(self):
         payload = {}
 
         self.login(self.NEW_USER_EMAIL)
@@ -80,17 +274,17 @@ def test_put_with_no_degree_of_mastery_per_skill_returns_400(self):
 
         self.assertEqual(
             json_response['error'],
-            'Expected payload to contain degree_of_mastery_per_skill as a dict.'
+            'Expected payload to contain mastery_change_per_skill as a dict.'
         )
 
         self.logout()
 
     def test_put_with_invalid_skill_ids_returns_400(self):
         payload = {}
-        degree_of_mastery_per_skill = {
+        mastery_change_per_skill = {
             'invalid_skill_id': 0.3
         }
-        payload['degree_of_mastery_per_skill'] = degree_of_mastery_per_skill
+        payload['mastery_change_per_skill'] = mastery_change_per_skill
 
         self.login(self.NEW_USER_EMAIL)
         csrf_token = self.get_new_csrf_token()
@@ -106,12 +300,12 @@ def test_put_with_invalid_skill_ids_returns_400(self):
     def test_put_with_nonexistent_skill_ids_returns_404(self):
         skill_id_3 = skill_services.get_new_skill_id()
         payload = {}
-        degree_of_mastery_per_skill = {
+        mastery_change_per_skill = {
             self.skill_id_1: 0.3,
             self.skill_id_2: 0.5,
             skill_id_3: 0.6
         }
-        payload['degree_of_mastery_per_skill'] = degree_of_mastery_per_skill
+        payload['mastery_change_per_skill'] = mastery_change_per_skill
 
         self.login(self.NEW_USER_EMAIL)
         csrf_token = self.get_new_csrf_token()
@@ -123,11 +317,11 @@ def test_put_with_nonexistent_skill_ids_returns_404(self):
 
     def test_put_with_invalid_type_of_degree_of_mastery_returns_400(self):
         payload = {}
-        degree_of_mastery_per_skill = {
+        mastery_change_per_skill = {
             self.skill_id_1: 0.1,
             self.skill_id_2: {}
         }
-        payload['degree_of_mastery_per_skill'] = degree_of_mastery_per_skill
+        payload['mastery_change_per_skill'] = mastery_change_per_skill
 
         self.login(self.NEW_USER_EMAIL)
         csrf_token = self.get_new_csrf_token()
@@ -140,11 +334,11 @@ def test_put_with_invalid_type_of_degree_of_mastery_returns_400(self):
             'Expected degree of mastery of skill %s to be a number, '
             'received %s.' % (self.skill_id_2, '{}'))
 
-        degree_of_mastery_per_skill = {
+        mastery_change_per_skill = {
             self.skill_id_1: 0.1,
             self.skill_id_2: True
         }
-        payload['degree_of_mastery_per_skill'] = degree_of_mastery_per_skill
+        payload['mastery_change_per_skill'] = mastery_change_per_skill
 
         json_response = self.put_json(
             '%s' % feconf.SKILL_MASTERY_DATA_URL,
@@ -157,35 +351,13 @@ def test_put_with_invalid_type_of_degree_of_mastery_returns_400(self):
 
         self.logout()
 
-    def test_put_with_invalid_value_of_degree_of_mastery_returns_400(self):
-        payload = {}
-        degree_of_mastery_per_skill = {
-            self.skill_id_1: -0.4,
-            self.skill_id_2: 0.5
-        }
-        payload['degree_of_mastery_per_skill'] = degree_of_mastery_per_skill
-
-        self.login(self.NEW_USER_EMAIL)
-        csrf_token = self.get_new_csrf_token()
-        json_response = self.put_json(
-            '%s' % feconf.SKILL_MASTERY_DATA_URL,
-            payload, csrf_token=csrf_token, expected_status_int=400)
-
-        self.assertEqual(
-            json_response['error'],
-            'Expected degree of mastery of skill %s to be a float '
-            'between 0.0 and 1.0, received %s.'
-            % (self.skill_id_1, '-0.4'))
-
-        self.logout()
-
     def test_put_with_no_logged_in_user_returns_401(self):
         payload = {}
-        degree_of_mastery_per_skill = {
+        mastery_change_per_skill = {
             self.skill_id_1: 0.3,
             self.skill_id_2: 0.5
         }
-        payload['degree_of_mastery_per_skill'] = degree_of_mastery_per_skill
+        payload['mastery_change_per_skill'] = mastery_change_per_skill
 
         csrf_token = self.get_new_csrf_token()
         json_response = self.put_json(