diff --git a/lms/lms/doctype/lms_quiz/lms_quiz.py b/lms/lms/doctype/lms_quiz/lms_quiz.py index 26da9246..f9b4326a 100644 --- a/lms/lms/doctype/lms_quiz/lms_quiz.py +++ b/lms/lms/doctype/lms_quiz/lms_quiz.py @@ -21,17 +21,39 @@ class LMSQuiz(Document): def validate_correct_answers(self): for question in self.questions: - correct_options = self.get_correct_options(question) + if question.type == "Choices": + self.validate_correct_options(question) + else: + self.validate_possible_answer(question) - if len(correct_options) > 1: - question.multiple = 1 + def validate_correct_options(self, question): + correct_options = self.get_correct_options(question) - if not len(correct_options): - frappe.throw( - _("At least one option must be correct for this question: {0}").format( - frappe.bold(question.question) - ) + if len(correct_options) > 1: + question.multiple = 1 + + if not len(correct_options): + frappe.throw( + _("At least one option must be correct for this question: {0}").format( + frappe.bold(question.question) ) + ) + + def validate_possible_answer(self, question): + possible_answers_fields = [ + "possibility_1", + "possibility_2", + "possibility_3", + "possibility_4", + ] + possible_answers = list(filter(lambda x: question.get(x), possible_answers_fields)) + + if not len(possible_answers): + frappe.throw( + _("Add at least one possible answer for this question: {0}").format( + frappe.bold(question.question) + ) + ) def get_correct_options(self, question): correct_option_fields = [ @@ -126,17 +148,47 @@ def save_quiz(quiz_title, questions, quiz): } ) - question_doc.update({"question": row["question"], "multiple": row["multiple"]}) - - for num in range(1, 5): - question_doc.update( - { - "option_" + cstr(num): row["option_" + cstr(num)], - "explanation_" + cstr(num): row["explanation_" + cstr(num)], - "is_correct_" + cstr(num): row["is_correct_" + cstr(num)], - } - ) - + question_doc.update(row) question_doc.save(ignore_permissions=True) return doc.name + + +@frappe.whitelist() +def check_answer(question, type, answer): + if type == "Choices": + return check_choice_answers(question, answer) + else: + return check_input_answers(question, answer) + + +def check_choice_answers(question, answer): + fields = [] + for num in range(1, 5): + fields.append(f"option_{cstr(num)}") + fields.append(f"is_correct_{cstr(num)}") + + question_details = frappe.db.get_value( + "LMS Quiz Question", question, fields, as_dict=1 + ) + + for num in range(1, 5): + if question_details[f"option_{num}"] == answer: + return question_details[f"is_correct_{num}"] + return 0 + + +def check_input_answers(question, answer): + fields = [] + for num in range(1, 5): + fields.append(f"possibility_{cstr(num)}") + + question_details = frappe.db.get_value( + "LMS Quiz Question", question, fields, as_dict=1 + ) + for num in range(1, 5): + current_possibility = question_details[f"possibility_{num}"] + if current_possibility and current_possibility.lower() == answer.lower(): + return 1 + + return 0 diff --git a/lms/lms/doctype/lms_quiz/test_lms_quiz.py b/lms/lms/doctype/lms_quiz/test_lms_quiz.py index f51a8cd4..ee79b89a 100644 --- a/lms/lms/doctype/lms_quiz/test_lms_quiz.py +++ b/lms/lms/doctype/lms_quiz/test_lms_quiz.py @@ -19,7 +19,8 @@ class TestLMSQuiz(unittest.TestCase): quiz.append( "questions", { - "question": "Question multiple", + "question": "Question Multiple", + "type": "Choices", "option_1": "Option 1", "is_correct_1": 1, "option_2": "Option 2", @@ -35,12 +36,24 @@ class TestLMSQuiz(unittest.TestCase): "questions", { "question": "Question no correct option", + "type": "Choices", "option_1": "Option 1", "option_2": "Option 2", }, ) self.assertRaises(frappe.ValidationError, quiz.save) + def test_with_no_possible_answers(self): + quiz = frappe.get_doc("LMS Quiz", "test-quiz") + quiz.append( + "questions", + { + "question": "Question Possible Answers", + "type": "User Input", + }, + ) + self.assertRaises(frappe.ValidationError, quiz.save) + @classmethod def tearDownClass(cls) -> None: frappe.db.delete("LMS Quiz", "test-quiz") diff --git a/lms/lms/doctype/lms_quiz_question/lms_quiz_question.json b/lms/lms/doctype/lms_quiz_question/lms_quiz_question.json index 924e696a..09856df7 100644 --- a/lms/lms/doctype/lms_quiz_question/lms_quiz_question.json +++ b/lms/lms/doctype/lms_quiz_question/lms_quiz_question.json @@ -6,6 +6,7 @@ "engine": "InnoDB", "field_order": [ "question", + "type", "options_section", "option_1", "is_correct_1", @@ -26,6 +27,13 @@ "is_correct_4", "column_break_20", "explanation_4", + "section_break_mnhr", + "possibility_1", + "possibility_3", + "column_break_vnaj", + "possibility_2", + "possibility_4", + "section_break_c1lf", "multiple" ], "fields": [ @@ -40,13 +48,13 @@ "fieldname": "option_1", "fieldtype": "Data", "label": "Option 1", - "reqd": 1 + "mandatory_depends_on": "eval: doc.type == 'Choices'" }, { "fieldname": "option_2", "fieldtype": "Data", "label": "Option 2", - "reqd": 1 + "mandatory_depends_on": "eval: doc.type == 'Choices'" }, { "fieldname": "option_3", @@ -95,18 +103,22 @@ "read_only": 1 }, { + "depends_on": "eval: doc.type == 'Choices'", "fieldname": "options_section", "fieldtype": "Section Break" }, { + "depends_on": "eval: doc.type == 'Choices'", "fieldname": "column_break_4", "fieldtype": "Section Break" }, { + "depends_on": "eval: doc.type == 'Choices'", "fieldname": "section_break_5", "fieldtype": "Section Break" }, { + "depends_on": "eval: doc.type == 'Choices'", "fieldname": "section_break_11", "fieldtype": "Section Break" }, @@ -149,12 +161,52 @@ { "fieldname": "column_break_20", "fieldtype": "Column Break" + }, + { + "fieldname": "type", + "fieldtype": "Select", + "label": "Type", + "options": "Choices\nUser Input" + }, + { + "depends_on": "eval: doc.type == 'User Input'", + "fieldname": "section_break_mnhr", + "fieldtype": "Section Break" + }, + { + "fieldname": "possibility_1", + "fieldtype": "Small Text", + "label": "Possible Answer 1", + "mandatory_depends_on": "eval: doc.type == 'User Input'" + }, + { + "fieldname": "possibility_2", + "fieldtype": "Small Text", + "label": "Possible Answer 2" + }, + { + "fieldname": "possibility_3", + "fieldtype": "Small Text", + "label": "Possible Answer 3" + }, + { + "fieldname": "possibility_4", + "fieldtype": "Small Text", + "label": "Possible Answer 4" + }, + { + "fieldname": "section_break_c1lf", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_vnaj", + "fieldtype": "Column Break" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-07-19 19:35:28.446236", + "modified": "2023-03-17 18:22:20.324536", "modified_by": "Administrator", "module": "LMS", "name": "LMS Quiz Question", @@ -162,5 +214,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/lms/patches.txt b/lms/patches.txt index f67b6d87..03c7594c 100644 --- a/lms/patches.txt +++ b/lms/patches.txt @@ -48,3 +48,4 @@ lms.patches.v0_0.user_singles_issue #23-11-2022 lms.patches.v0_0.rename_community_to_users #06-01-2023 lms.patches.v0_0.video_embed_link lms.patches.v0_0.rename_exercise_doctype +lms.patches.v0_0.add_question_type \ No newline at end of file diff --git a/lms/patches/v0_0/add_question_type.py b/lms/patches/v0_0/add_question_type.py new file mode 100644 index 00000000..a4411a0a --- /dev/null +++ b/lms/patches/v0_0/add_question_type.py @@ -0,0 +1,8 @@ +import frappe + + +def execute(): + questions = frappe.get_all("LMS Quiz Question", pluck="name") + + for question in questions: + frappe.db.set_value("LMS Quiz Question", question, "type", "Choices") diff --git a/lms/public/css/style.css b/lms/public/css/style.css index d8e6b3aa..26f3cbd2 100644 --- a/lms/public/css/style.css +++ b/lms/public/css/style.css @@ -90,6 +90,7 @@ input[type=checkbox] { padding: 2rem 0 5rem; padding-top: 3rem; background-color: var(--bg-color); + font-size: var(--text-base); } .common-card-style { @@ -425,7 +426,6 @@ input[type=checkbox] { .lesson-links { display: flex; - align-items: center; padding: 0.5rem; color: var(--gray-900); font-size: var(--text-base); @@ -544,10 +544,10 @@ input[type=checkbox] { } .quiz-footer { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 5rem; + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 2rem; } .question { @@ -1366,7 +1366,7 @@ pre { .question-header { display: flex; align-items: center; - margin-bottom: 2rem; + margin-bottom: 1rem; } .question-number { @@ -2027,3 +2027,18 @@ select { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); grid-gap: 1.5rem; } + +.answer-indicator { + border-radius: var(--border-radius-md); + padding: 0.2rem 0.5rem; + width: fit-content; + margin-top: 0.5rem; +} + +.answer-indicator.success { + background-color: var(--dark-green-50); +} + +.answer-indicator.failure { + background-color: var(--red-50); +} \ No newline at end of file diff --git a/lms/templates/quiz.html b/lms/templates/quiz.html index 8629a2c9..c9752953 100644 --- a/lms/templates/quiz.html +++ b/lms/templates/quiz.html @@ -46,9 +46,9 @@
{% for question in quiz.questions %} - {% set instruction = _("Choose all answers that apply") if question.multiple else _("Choose 1 answer") %} + {% set instruction = _("Choose all answers that apply") if question.type == "Choices" and question.multiple else _("Choose 1 answer") if question.type == "Choices" else _("Enter the correct answer") %} -
{{ loop.index }}.
@@ -58,6 +58,7 @@
{{ instruction }}
+ {% if question.type == "Choices" %} {% set options = [question.option_1, question.option_2, question.option_3, question.option_4] %} {% for option in options %} {% if option %} @@ -66,8 +67,7 @@
@@ -80,6 +80,14 @@ {% endif %} {% endfor %} + {% else %} +
+
+ +
+
+ {% endif %} +
{% endfor %} @@ -96,19 +104,19 @@ {% endif %} - - `; $(question_template).insertAfter(add_after); get_question_template(); $(".btn-save-question").removeClass("hide"); @@ -40,11 +54,31 @@ const add_question = () => { const get_question_template = () => { Array.from({ length: 4 }, (x, num) => { let option_template = get_option_template(num + 1); + let add_after = $(".quiz-card:last .option-group").length ? $(".quiz-card:last .option-group").last() - : $(".question:last"); + : $(".type:last"); question_template = $(option_template).insertAfter(add_after); }); + + Array.from({ length: 4 }, (x, num) => { + let possibility_template = get_possibility_template(num + 1); + let add_after = $(".quiz-card:last .possibility-group").length + ? $(".quiz-card:last .possibility-group").last() + : $(".quiz-card:last .option-group:last"); + question_template = $(possibility_template).insertAfter(add_after); + }); +}; + +const get_possibility_template = (num) => { + return `
+ +
+
+
+
+
+
`; }; const get_option_template = (num) => { @@ -93,36 +127,42 @@ const get_questions = () => { let details = {}; let correct_options = 0; + let possibilities = 0; + + details["element"] = el; details["question"] = $(el).find(".question").text(); details["question_name"] = $(el).find(".question").data("question") || ""; + details["type"] = $(el).find(".type").val(); Array.from({ length: 4 }, (x, i) => { let num = i + 1; - details[`option_${num}`] = $(el) - .find(`.option-${num} .option-input:first`) - .text(); - details[`explanation_${num}`] = $(el) - .find(`.option-${num} .option-input:last`) - .text(); + if (details.type == "Choices") { + details[`option_${num}`] = $(el) + .find(`.option-${num} .option-input:first`) + .text(); + details[`explanation_${num}`] = $(el) + .find(`.option-${num} .option-input:last`) + .text(); - let is_correct = $(el) - .find(`.option-${num} .option-checkbox`) - .find("input") - .prop("checked"); - if (is_correct) correct_options += 1; + let is_correct = $(el) + .find(`.option-${num} .option-checkbox`) + .find("input") + .prop("checked"); + if (is_correct) correct_options += 1; - details[`is_correct_${num}`] = is_correct; + details[`is_correct_${num}`] = is_correct; + } else { + let possible_answer = $(el) + .find(`.possibility-${num}`) + .text() + .trim(); + if (possible_answer) possibilities += 1; + details[`possibility_${num}`] = possible_answer; + } }); - - if (!details["option_1"] || !details["option_2"]) - frappe.throw(__("Each question must have at least two options.")); - - if (!correct_options) - frappe.throw( - __("Each question must have at least one correct option.") - ); + validate_mandatory(details, correct_options, possibilities); details["multiple"] = correct_options > 1 ? 1 : 0; questions.push(details); @@ -131,12 +171,42 @@ const get_questions = () => { return questions; }; +const validate_mandatory = (details, correct_options, possibilities) => { + if (details["type"] == "Choices") { + if (!details["option_1"] || !details["option_2"]) { + scroll_to_element(details["element"]); + frappe.throw(__("Each question must have at least two options.")); + } + + if (!correct_options) { + scroll_to_element(details["element"]); + frappe.throw( + __( + "Question with choices must have at least one correct option." + ) + ); + } + } else if (!possibilities) { + scroll_to_element(details["element"]); + frappe.throw( + __( + "Question with user input must have at least one possible answer." + ) + ); + } +}; + const scroll_to_question_container = () => { - $([document.documentElement, document.body]).animate( - { - scrollTop: $(".new-quiz-card").offset().top, - }, - 1000 - ); + scroll_to_element(".new-quiz-card:last"); $(".new-quiz-card").find(".question").focus(); }; + +const scroll_to_element = (element) => { + if ($(element).length) + $([document.documentElement, document.body]).animate( + { + scrollTop: $(element).offset().top, + }, + 1000 + ); +}; diff --git a/lms/www/batch/quiz.py b/lms/www/batch/quiz.py index 7db15054..5d687fea 100644 --- a/lms/www/batch/quiz.py +++ b/lms/www/batch/quiz.py @@ -9,11 +9,12 @@ def get_context(context): context.quiz = frappe._dict() context.quiz.edit_mode = 1 else: - fields_arr = ["name", "question"] + fields_arr = ["name", "question", "type"] for num in range(1, 5): fields_arr.append("option_" + cstr(num)) fields_arr.append("is_correct_" + cstr(num)) fields_arr.append("explanation_" + cstr(num)) + fields_arr.append("possibility_" + cstr(num)) context.quiz = frappe.db.get_value("LMS Quiz", quizname, ["title", "name"], as_dict=1) context.quiz.questions = frappe.get_all(