Repository: oppia/oppia

Issue:
Add question difficulty (per skill) to the question editor
We have the backend ready to support question difficulty per skill. Now we would like to add the ability to do so in the question editor. 
While creating the question, editors should be able to assign a value (Easy, Medium, Hard) for each skill linked to that question.
At the backend it will translate to:
Easy: 0.3
Medium: 0.6
Hard: 0.9
Move skill_ids to the payload for QuestionCreationHandler in question_editor.py
Currently in PR #6929, question dicts are passed in through payload, but comma_seperated_skill_ids are passed in through URL. For this we will move both of them to payload later.


Discussion:
@sophiewu6 how is this going?


Resolving patch:
diff --git a/core/controllers/question_editor.py b/core/controllers/question_editor.py
--- a/core/controllers/question_editor.py
+++ b/core/controllers/question_editor.py
@@ -30,9 +30,14 @@ class QuestionCreationHandler(base.BaseHandler):
     """A handler that creates the question model given a question dict."""
 
     @acl_decorators.can_manage_question_skill_status
-    def post(self, comma_separated_skill_ids):
+    def post(self):
         """Handles POST requests."""
-        skill_ids = comma_separated_skill_ids.split(',')
+        skill_ids = self.payload.get('skill_ids')
+
+        if not skill_ids:
+            raise self.InvalidInputException(
+                'skill_ids parameter isn\'t present in the payload')
+
         if len(skill_ids) > constants.MAX_SKILLS_PER_QUESTION:
             raise self.InvalidInputException(
                 'More than %d QuestionSkillLinks for one question '
@@ -40,12 +45,12 @@ def post(self, comma_separated_skill_ids):
         try:
             for skill_id in skill_ids:
                 skill_domain.Skill.require_valid_skill_id(skill_id)
-        except Exception:
-            raise self.InvalidInputException
+        except Exception as e:
+            raise self.InvalidInputException('Skill ID(s) aren\'t valid: ', e)
 
         try:
             skill_services.get_multi_skills(skill_ids)
-        except Exception, e:
+        except Exception as e:
             raise self.PageNotFoundException(e)
 
         question_dict = self.payload.get('question_dict')
@@ -54,24 +59,49 @@ def post(self, comma_separated_skill_ids):
                 ('question_state_data' not in question_dict) or
                 ('language_code' not in question_dict) or
                 (question_dict['version'] != 1)):
-            raise self.InvalidInputException
+            raise self.InvalidInputException(
+                'Question Data should contain id, state data, language code, ' +
+                'and its version should be set as 1')
 
         question_dict['question_state_data_schema_version'] = (
             feconf.CURRENT_STATE_SCHEMA_VERSION)
         question_dict['id'] = question_services.get_new_question_id()
         question_dict['linked_skill_ids'] = skill_ids
+
         try:
             question = question_domain.Question.from_dict(question_dict)
-        except Exception:
-            raise self.InvalidInputException
+        except Exception as e:
+            raise self.InvalidInputException(
+                'Question structure is invalid:', e)
+
+        skill_difficulties = self.payload.get('skill_difficulties')
+
+        if not skill_difficulties:
+            raise self.InvalidInputException(
+                'skill_difficulties not present in the payload')
+        if len(skill_ids) != len(skill_difficulties):
+            raise self.InvalidInputException(
+                'Skill difficulties don\'t match up with skill IDs')
+
+        try:
+            skill_difficulties = [
+                float(difficulty) for difficulty in skill_difficulties]
+        except (ValueError, TypeError):
+            raise self.InvalidInputException(
+                'Skill difficulties must be a float value')
+
+        if any((
+                difficulty < 0 or difficulty > 1)
+               for difficulty in skill_difficulties):
+            raise self.InvalidInputException(
+                'Skill difficulties must be between 0 and 1')
+
         question_services.add_question(self.user_id, question)
-        # TODO(vinitamurthi): Replace DEFAULT_SKILL_DIFFICULTY
-        # with a value passed from the frontend.
         question_services.link_multiple_skills_for_question(
             self.user_id,
             question.id,
             skill_ids,
-            [constants.DEFAULT_SKILL_DIFFICULTY] * len(skill_ids))
+            skill_difficulties)
 
         self.values.update({
             'question_id': question.id
diff --git a/main.py b/main.py
--- a/main.py
+++ b/main.py
@@ -225,7 +225,7 @@ def ui_access_wrapper(self, *args, **kwargs):
         r'%s' % feconf.NEW_SKILL_URL,
         topics_and_skills_dashboard.NewSkillHandler),
     get_redirect_route(
-        r'%s/<comma_separated_skill_ids>' % feconf.NEW_QUESTION_URL,
+        r'%s' % feconf.NEW_QUESTION_URL,
         question_editor.QuestionCreationHandler),
     get_redirect_route(
         r'%s/<comma_separated_skill_ids>' % feconf.QUESTIONS_LIST_URL_PREFIX,


Test patch:
diff --git a/core/controllers/question_editor_test.py b/core/controllers/question_editor_test.py
--- a/core/controllers/question_editor_test.py
+++ b/core/controllers/question_editor_test.py
@@ -69,8 +69,9 @@ def test_post_with_non_admin_or_topic_manager_email_disallows_access(self):
         self.login(self.NEW_USER_EMAIL)
         csrf_token = self.get_new_csrf_token()
         self.post_json(
-            '%s/%s' % (feconf.NEW_QUESTION_URL, self.skill_id),
-            {}, csrf_token=csrf_token, expected_status_int=401)
+            feconf.NEW_QUESTION_URL, {
+                'skill_ids': [self.skill_id]
+            }, csrf_token=csrf_token, expected_status_int=401)
         self.logout()
 
     def test_post_with_editor_email_does_not_allow_question_creation(self):
@@ -79,8 +80,9 @@ def test_post_with_editor_email_does_not_allow_question_creation(self):
         question_dict = self.question.to_dict()
         question_dict['id'] = None
         self.post_json(
-            '%s/%s' % (feconf.NEW_QUESTION_URL, self.skill_id), {
-                'question_dict': question_dict
+            feconf.NEW_QUESTION_URL, {
+                'question_dict': question_dict,
+                'skill_ids': [self.skill_id]
             }, csrf_token=csrf_token, expected_status_int=401)
         self.logout()
 
@@ -89,17 +91,27 @@ def test_post_with_incorrect_skill_id_returns_404(self):
         csrf_token = self.get_new_csrf_token()
         incorrect_skill_id = 'abc123456789'
         self.post_json(
-            '%s/%s' % (feconf.NEW_QUESTION_URL, incorrect_skill_id),
-            {}, csrf_token=csrf_token, expected_status_int=404)
+            feconf.NEW_QUESTION_URL, {
+                'skill_ids': [incorrect_skill_id]
+            }, csrf_token=csrf_token, expected_status_int=404)
+        self.logout()
+
+    def test_post_with_no_skill_ids_returns_400(self):
+        self.login(self.ADMIN_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+        self.post_json(
+            feconf.NEW_QUESTION_URL, {},
+            csrf_token=csrf_token, expected_status_int=400)
         self.logout()
 
     def test_post_with_incorrect_list_of_skill_ids_returns_400(self):
         self.login(self.ADMIN_EMAIL)
         csrf_token = self.get_new_csrf_token()
-        incorrect_skill_id = [1, 2]
+        incorrect_skill_ids = [1, 2]
         self.post_json(
-            '%s/%s' % (feconf.NEW_QUESTION_URL, incorrect_skill_id),
-            {}, csrf_token=csrf_token, expected_status_int=400)
+            feconf.NEW_QUESTION_URL, {
+                'skill_ids': incorrect_skill_ids
+            }, csrf_token=csrf_token, expected_status_int=400)
         self.logout()
 
     def test_post_with_incorrect_type_of_skill_ids_returns_400(self):
@@ -107,8 +119,9 @@ def test_post_with_incorrect_type_of_skill_ids_returns_400(self):
         csrf_token = self.get_new_csrf_token()
         incorrect_skill_id = 1
         self.post_json(
-            '%s/%s' % (feconf.NEW_QUESTION_URL, incorrect_skill_id),
-            {}, csrf_token=csrf_token, expected_status_int=400)
+            feconf.NEW_QUESTION_URL, {
+                'skill_ids': [incorrect_skill_id],
+            }, csrf_token=csrf_token, expected_status_int=400)
         self.logout()
 
     def test_post_with_incorrect_question_id_returns_400(self):
@@ -117,8 +130,9 @@ def test_post_with_incorrect_question_id_returns_400(self):
         question_dict = self.question.to_dict()
         question_dict['id'] = 'abc123456789'
         self.post_json(
-            '%s/%s' % (feconf.NEW_QUESTION_URL, self.skill_id), {
-                'question_dict': question_dict
+            feconf.NEW_QUESTION_URL, {
+                'question_dict': question_dict,
+                'skill_ids': [self.skill_id]
             }, csrf_token=csrf_token, expected_status_int=400)
         self.logout()
 
@@ -128,8 +142,60 @@ def test_post_with_incorrect_question_schema_returns_400(self):
         question_dict = self.question.to_dict()
         del question_dict['question_state_data']['content']
         self.post_json(
-            '%s/%s' % (feconf.NEW_QUESTION_URL, self.skill_id), {
-                'question_dict': question_dict
+            feconf.NEW_QUESTION_URL, {
+                'question_dict': question_dict,
+                'skill_ids': [self.skill_id],
+            }, csrf_token=csrf_token, expected_status_int=400)
+        self.logout()
+
+    def test_post_with_no_skill_difficulty_returns_400(self):
+        self.login(self.ADMIN_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+        question_dict = self.question.to_dict()
+        question_dict['id'] = None
+        self.post_json(
+            feconf.NEW_QUESTION_URL, {
+                'question_dict': question_dict,
+                'skill_ids': [self.skill_id]
+            }, csrf_token=csrf_token, expected_status_int=400)
+        self.logout()
+
+    def test_post_with_wrong_skill_difficulty_length_returns_400(self):
+        self.login(self.ADMIN_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+        question_dict = self.question.to_dict()
+        question_dict['id'] = None
+        self.post_json(
+            feconf.NEW_QUESTION_URL, {
+                'question_dict': question_dict,
+                'skill_ids': [self.skill_id],
+                'skill_difficulties': [0.6, 0.8]
+            }, csrf_token=csrf_token, expected_status_int=400)
+        self.logout()
+
+    def test_post_with_invalid_skill_difficulty_type_returns_400(self):
+        self.login(self.ADMIN_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+        question_dict = self.question.to_dict()
+        question_dict['id'] = None
+        self.post_json(
+            feconf.NEW_QUESTION_URL, {
+                'question_dict': question_dict,
+                'skill_ids': [self.skill_id],
+                'skill_difficulties': ['test']
+            }, csrf_token=csrf_token, expected_status_int=400)
+        self.logout()
+
+    def test_post_with_invalid_skill_difficulty_value_returns_400(self):
+        self.login(self.ADMIN_EMAIL)
+        csrf_token = self.get_new_csrf_token()
+        question_dict = self.question.to_dict()
+        question_dict['id'] = None
+        self.post_json(
+            feconf.NEW_QUESTION_URL, {
+                'question_dict': question_dict,
+                'skill_ids': [self.skill_id],
+                'skill_difficulties': [2.0]
             }, csrf_token=csrf_token, expected_status_int=400)
         self.logout()
 
@@ -139,8 +205,10 @@ def test_post_with_admin_email_allows_question_creation(self):
         question_dict = self.question.to_dict()
         question_dict['id'] = None
         self.post_json(
-            '%s/%s' % (feconf.NEW_QUESTION_URL, self.skill_id), {
-                'question_dict': question_dict
+            feconf.NEW_QUESTION_URL, {
+                'question_dict': question_dict,
+                'skill_ids': [self.skill_id],
+                'skill_difficulties': [0.6]
             }, csrf_token=csrf_token, expected_status_int=200)
         all_models = question_models.QuestionModel.get_all()
         questions = [
@@ -156,8 +224,10 @@ def test_post_with_topic_manager_email_allows_question_creation(self):
         question_dict = self.question.to_dict()
         question_dict['id'] = None
         self.post_json(
-            '%s/%s' % (feconf.NEW_QUESTION_URL, self.skill_id), {
-                'question_dict': question_dict
+            feconf.NEW_QUESTION_URL, {
+                'question_dict': question_dict,
+                'skill_ids': [self.skill_id],
+                'skill_difficulties': [0.6]
             }, csrf_token=csrf_token)
         all_models = question_models.QuestionModel.get_all()
         questions = [
@@ -174,8 +244,9 @@ def test_post_with_invalid_question_returns_400_status(self):
         question_dict['id'] = None
         question_dict['question_state_data'] = 'invalid_question_state_data'
         self.post_json(
-            '%s/%s' % (feconf.NEW_QUESTION_URL, self.skill_id), {
-                'question_dict': question_dict
+            feconf.NEW_QUESTION_URL, {
+                'question_dict': question_dict,
+                'skill_ids': [self.skill_id],
             }, csrf_token=csrf_token, expected_status_int=400)
         self.logout()
 
@@ -184,8 +255,9 @@ def test_post_with_too_many_skills_returns_400(self):
         csrf_token = self.get_new_csrf_token()
         skill_ids = [1, 2, 3, 4]
         self.post_json(
-            '%s/%s' % (feconf.NEW_QUESTION_URL, skill_ids),
-            {}, csrf_token=csrf_token, expected_status_int=400)
+            feconf.NEW_QUESTION_URL, {
+                'skill_ids': skill_ids
+            }, csrf_token=csrf_token, expected_status_int=400)
         self.logout()
 
 
diff --git a/core/tests/protractor_desktop/skillEditor.js b/core/tests/protractor_desktop/skillEditor.js
--- a/core/tests/protractor_desktop/skillEditor.js
+++ b/core/tests/protractor_desktop/skillEditor.js
@@ -93,6 +93,7 @@ describe('Skill Editor functionality', function() {
   it('should create a question for the skill', function() {
     skillEditorPage.moveToQuestionsTab();
     skillEditorPage.clickCreateQuestionButton();
+    skillEditorPage.confirmSkillDifficulty();
     explorationEditorMainTab.setContent(forms.toRichText('Question 1'));
     explorationEditorMainTab.setInteraction('TextInput', 'Placeholder', 5);
     explorationEditorMainTab.addResponse(
diff --git a/core/tests/protractor_desktop/topicsAndSkillsDashboard.js b/core/tests/protractor_desktop/topicsAndSkillsDashboard.js
--- a/core/tests/protractor_desktop/topicsAndSkillsDashboard.js
+++ b/core/tests/protractor_desktop/topicsAndSkillsDashboard.js
@@ -100,6 +100,7 @@ describe('Topics and skills dashboard functionality', function() {
     skillEditorPage.saveOrPublishSkill('Added review material.');
     skillEditorPage.moveToQuestionsTab();
     skillEditorPage.clickCreateQuestionButton();
+    skillEditorPage.confirmSkillDifficulty();
     explorationEditorMainTab.setContent(forms.toRichText('Question 1'));
     explorationEditorMainTab.setInteraction('TextInput', 'Placeholder', 5);
     explorationEditorMainTab.addResponse(
diff --git a/core/tests/protractor_utils/SkillEditorPage.js b/core/tests/protractor_utils/SkillEditorPage.js
--- a/core/tests/protractor_utils/SkillEditorPage.js
+++ b/core/tests/protractor_utils/SkillEditorPage.js
@@ -22,6 +22,8 @@ var waitFor = require('./waitFor.js');
 
 var SkillEditorPage = function() {
   var EDITOR_URL_PREFIX = '/skill_editor/';
+  var confirmSkillDifficultyButton = element(
+    by.css('.protractor-test-confirm-skill-difficulty-button'));
   var EditConceptCardExplanationButton = element(
     by.css('.protractor-test-edit-concept-card'));
   var saveConceptCardExplanationButton = element(
@@ -113,6 +115,10 @@ var SkillEditorPage = function() {
     createQuestionButton.click();
   };
 
+  this.confirmSkillDifficulty = function() {
+    confirmSkillDifficultyButton.click();
+  };
+
   this.changeSkillDescription = function(description) {
     skillDescriptionField.clear();
     skillDescriptionField.sendKeys(description);
diff --git a/core/tests/protractor_utils/TopicEditorPage.js b/core/tests/protractor_utils/TopicEditorPage.js
--- a/core/tests/protractor_utils/TopicEditorPage.js
+++ b/core/tests/protractor_utils/TopicEditorPage.js
@@ -71,6 +71,8 @@ var TopicEditorPage = function() {
   var skillItems = element.all(by.css('.protractor-test-skill-item'));
   var confirmSkillButton = element(
     by.css('.protractor-test-confirm-skill-button'));
+  var confirmSkillDifficultyButton = element(
+    by.css('.protractor-test-confirm-skill-difficulty-button'));
   var saveQuestionButton = element(
     by.css('.protractor-test-save-question-button'));
   var questionItems = element.all(
@@ -102,6 +104,7 @@ var TopicEditorPage = function() {
         confirmSkillButton,
         'Confirm Skill button takes too long to be clickable');
       confirmSkillButton.click();
+      confirmSkillDifficultyButton.click();
     });
   };