diff --git a/lms/lms/doctype/lms_quiz/lms_quiz.py b/lms/lms/doctype/lms_quiz/lms_quiz.py index fa49b0a9..41da49a6 100644 --- a/lms/lms/doctype/lms_quiz/lms_quiz.py +++ b/lms/lms/doctype/lms_quiz/lms_quiz.py @@ -17,52 +17,7 @@ class LMSQuiz(Document): self.name = generate_slug(self.title, "LMS Quiz") def validate(self): - self.validate_correct_answers() - - def validate_correct_answers(self): - for question in self.questions: - if question.type == "Choices": - self.validate_correct_options(question) - else: - self.validate_possible_answer(question) - - def validate_correct_options(self, question): - correct_options = self.get_correct_options(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 = [ - "is_correct_1", - "is_correct_2", - "is_correct_3", - "is_correct_4", - ] - return list(filter(lambda x: question.get(x) == 1, correct_option_fields)) + validate_correct_answers(self.questions) def get_last_submission_details(self): """Returns the latest submission for this user.""" @@ -82,6 +37,71 @@ class LMSQuiz(Document): return result[0] +def get_correct_options(question): + correct_option_fields = [ + "is_correct_1", + "is_correct_2", + "is_correct_3", + "is_correct_4", + ] + return list(filter(lambda x: question.get(x) == 1, correct_option_fields)) + + +def validate_correct_answers(questions): + for question in questions: + if question.type == "Choices": + validate_duplicate_options(question) + validate_correct_options(question) + else: + validate_possible_answer(question) + + +def validate_duplicate_options(question): + options = [] + + for num in range(1, 5): + if question.get(f"option_{num}"): + options.append(question.get(f"option_{num}")) + + if len(set(options)) != len(options): + frappe.throw( + _("Duplicate options found for this question: {0}").format( + frappe.bold(question.question) + ) + ) + + +def validate_correct_options(question): + correct_options = get_correct_options(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(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 update_lesson_info(doc, method): if doc.quiz_id: frappe.db.set_value( @@ -121,37 +141,84 @@ def quiz_summary(quiz, results): @frappe.whitelist() -def save_quiz(quiz_title, questions, quiz): +def save_quiz(quiz_title, quiz): if quiz: - doc = frappe.get_doc("LMS Quiz", quiz) + frappe.db.set_value("LMS Quiz", quiz, "title", quiz_title) + return quiz else: - doc = frappe.get_doc( + doc = frappe.new_doc("LMS Quiz") + doc.update({"title": quiz_title}) + doc.save(ignore_permissions=True) + return doc.name + + +@frappe.whitelist() +def save_question(quiz, values, index): + values = frappe._dict(json.loads(values)) + validate_correct_answers([values]) + + if values.get("name"): + doc = frappe.get_doc("LMS Quiz Question", values.get("name")) + else: + doc = frappe.new_doc("LMS Quiz Question") + + doc.update( + { + "question": values["question"], + "type": values["type"], + } + ) + + if not values.get("name"): + doc.update( { - "doctype": "LMS Quiz", + "parent": quiz, + "parenttype": "LMS Quiz", + "parentfield": "questions", + "idx": index, } ) - doc.update({"title": quiz_title}) - doc.save(ignore_permissions=True) - - for index, row in enumerate(json.loads(questions)): - if row["question_name"]: - question_doc = frappe.get_doc("LMS Quiz Question", row["question_name"]) - else: - question_doc = frappe.get_doc( + for num in range(1, 5): + if values.get(f"option_{num}"): + doc.update( { - "doctype": "LMS Quiz Question", - "parent": doc.name, - "parenttype": "LMS Quiz", - "parentfield": "questions", - "idx": index + 1, + f"option_{num}": values[f"option_{num}"], + f"is_correct_{num}": values[f"is_correct_{num}"], } ) - question_doc.update(row) - question_doc.save(ignore_permissions=True) + if values.get(f"explanation_{num}"): + doc.update( + { + f"explanation_{num}": values[f"explanation_{num}"], + } + ) - return doc.name + if values.get(f"possibility_{num}"): + doc.update( + { + f"possibility_{num}": values[f"possibility_{num}"], + } + ) + + doc.save(ignore_permissions=True) + + return quiz + + +@frappe.whitelist() +def get_question_details(question): + if frappe.db.exists("LMS Quiz Question", question): + fields = ["name", "question", "type"] + for num in range(1, 5): + fields.append(f"option_{cstr(num)}") + fields.append(f"is_correct_{cstr(num)}") + fields.append(f"explanation_{cstr(num)}") + fields.append(f"possibility_{cstr(num)}") + + return frappe.db.get_value("LMS Quiz Question", question, fields, as_dict=1) + return @frappe.whitelist() diff --git a/lms/public/css/style.css b/lms/public/css/style.css index 23b64b1a..8489722c 100644 --- a/lms/public/css/style.css +++ b/lms/public/css/style.css @@ -185,12 +185,16 @@ textarea.field-input { .clickable { color: var(--gray-900); font-weight: 500; - cursor: pointer; } .clickable:hover { color: var(--gray-900); text-decoration: none; + cursor: pointer; +} + +.question-row .ql-editor.read-mode p:hover { + cursor: pointer; } .codex-editor path { @@ -471,10 +475,6 @@ input[type=checkbox] { color: var(--gray-700); } -.custom-checkbox>label>input { - visibility: hidden; -} - .custom-checkbox>label>.empty-checkbox { height: 1.5rem; width: 1.5rem; @@ -493,16 +493,30 @@ input[type=checkbox] { } .quiz-label { - display: flex; - align-items: center; - margin-bottom: 0; - cursor: pointer; + } .quiz-label p { display: inline; } +.option-row { + display: flex; + align-items: center; + flex: 1; + margin-bottom: 0; + padding: 0.75rem; + border: 1px solid var(--gray-200); + border-radius: var(--border-radius-lg); + cursor:pointer; + background-color: var(--gray-100); +} + +.active-option .option-row { + background-color: var(--blue-50); + border: 1px solid var(--blue-500); +} + .course-card-wide { width: 50%; margin-bottom: 2rem; @@ -1056,7 +1070,7 @@ pre { .column-card { flex-direction: column; - padding: 1.25rem; + padding: 1rem; height: 100%; } @@ -1502,41 +1516,21 @@ pre { } .reviews-parent .progress-bar { - background-color: var(--primary-color); + background-color: var(--primary-color); } .course-home-top-container { position: relative; } -.question-header { - display: flex; - align-items: center; - margin-bottom: 1rem; -} - -.question-number { - padding-right: 0.25rem; -} - -.option-text { - padding: 0.75rem; - border: 1px solid var(--gray-200); - border-radius: var(--border-radius-md); - flex: 1; -} - -.active-option .option-text { - background-color: var(--blue-50); - border: 1px solid var(--blue-500); -} - .question-text { - font-size: var(--text-lg); - color: var(--gray-900); - font-weight: 600; - flex: 1; - margin: 0 1rem; + margin: 0.5rem 0 1rem; + font-weight: 600; +} + +.question-text .ql-editor.read-mode { + white-space: inherit; + font-weight: 600; } .profile-column-grid { diff --git a/lms/templates/quiz.html b/lms/templates/quiz.html index c9752953..884c836e 100644 --- a/lms/templates/quiz.html +++ b/lms/templates/quiz.html @@ -9,20 +9,16 @@ - {% else %} -
-
- +
-

- {{ quiz.title }} -

+
+
+ {{ quiz.title }} +
-
+
{{ _("This quiz consists of {0} questions.").format(quiz.questions | length) }}
@@ -40,22 +36,27 @@
{% endif %} + +
+
-
+
{% for question in quiz.questions %} - {% 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") %} + {% set instruction = _("Choose all answers that apply") if question.type == "Choices" and question.multiple else _("Choose one answer") if question.type == "Choices" else _("Enter the correct answer") %}
-
-
{{ loop.index }}.
+ data-multi="{{ question.multiple }}" data-qt-index="{{ loop.index }}"> +
+
+ {{ _("Question ") }}{{ loop.index }}: {{ instruction }}
- {{ frappe.utils.md_to_html(question.question) }} + {{ question.question }}
-
{{ instruction }}
{% if question.type == "Choices" %} @@ -64,8 +65,7 @@ {% if option %}
-
@@ -66,11 +79,11 @@ {{ _("Title") }}
- {{ _("Give your quiz a title") }} + {{ _("Add a title for the quiz") }}
- +
@@ -78,67 +91,39 @@ {% macro Question(question, index) %} {% set type = question.type if question.type else "Choices" %} -
-
-
-
- {{ _("Question") }} {{ index }} -
-
-
- -
+
+
+ + {{ index }}. + + {{ question.question.split("\n")[0] }}
-
-
-
- {{ _("Question Type") }} -
-
- - -
-
-
-
- - {% for i in range(1,5) %} - {% set num = frappe.utils.cstr(i) %} - - {% set option = question["option_" + num] %} - {% set explanation = question["explanation_" + num] %} - {% set possible_answer = question["possibility_" + num] %} - -
- -
- - - -
- -
-
- {{ _("Possible Answers") }} {{ num }} -
- -
-
- - - {% endfor %} -
-{% endmacro %} \ No newline at end of file +{% endmacro %} + +{% macro EmptyState() %} +
+
+
+ {{ _("You have not added any question yet") }} +
+
+ {{ _("Create and manage questions from here.") }} +
+
+ +
+
+
+{% endmacro %} + +{%- block script %} + {{ super() }} + {{ include_script('controls.bundle.js') }} +{% endblock %} \ No newline at end of file diff --git a/lms/www/batch/quiz.js b/lms/www/batch/quiz.js index 47afe84a..142f5f4b 100644 --- a/lms/www/batch/quiz.js +++ b/lms/www/batch/quiz.js @@ -1,80 +1,153 @@ frappe.ready(() => { - if ($(".question-card").length <= 1) { - add_question(); - } + /* if (!$("#quiz-form").data("name")) { + show_quiz_modal(); + } */ + + $("#quiz-title").focusout((e) => { + if ($("#quiz-title").val() != $("#quiz-title").data("title")) { + save_quiz({ quiz_title: $("#quiz-title").val() }); + } + }); + + $(".question-row").click((e) => { + edit_question(e); + }); $(".btn-add-question").click((e) => { - add_question(true); + show_question_modal(); }); - - $(".btn-save-question").click((e) => { - save_question(e); - }); - - $(".copy-quiz-id").click((e) => { - frappe.utils.copy_to_clipboard($(e.currentTarget).data("name")); - }); - - $(document).on("click", ".question-type", (e) => { - toggle_form($(e.currentTarget)); - }); - - get_questions(); }); -const toggle_form = (el) => { - if ($(el).hasClass("active")) { - let type = $(el).find("input").data("type"); - if (type == "Choices") { - $(el) - .closest(".field-parent") - .find(".options-group") - .removeClass("hide"); - $(el) - .closest(".field-parent") - .find(".answers-group") - .addClass("hide"); - } else { - $(el) - .closest(".field-parent") - .find(".options-group") - .addClass("hide"); - $(el) - .closest(".field-parent") - .find(".answers-group") - .removeClass("hide"); - } - } +const show_quiz_modal = () => { + let quiz_dialog = new frappe.ui.Dialog({ + title: __("Create Quiz"), + fields: [ + { + fieldtype: "Data", + label: __("Quiz Title"), + fieldname: "quiz_title", + reqd: 1, + }, + ], + primary_action: (values) => { + quiz_dialog.hide(); + save_quiz(values); + }, + }); + + quiz_dialog.show(); }; -const add_question = (scroll = false) => { - let template = $("#question-template").html(); - let index = $(".question-card:nth-last-child(2)").data("index") + 1 || 1; - template = update_index(template, index); +const show_question_modal = (values = {}) => { + let fields = get_question_fields(values); - $(template).insertBefore($("#question-template")); - scroll && scroll_to_question_container(); + this.question_dialog = new frappe.ui.Dialog({ + title: __("Add Question"), + fields: fields, + primary_action: (data) => { + if (values) data.name = values.name; + save_question(data); + }, + }); + + question_dialog.show(); }; -const update_index = (template, index) => { - const $template = $(template); - $template.attr("data-index", index); - $template.find(".question-label").text("Question " + index); - $template.find(".question-type input").attr("name", "type-" + index); - return $template.prop("outerHTML"); +const get_question_fields = (values = {}) => { + let dialog_fields = [ + { + fieldtype: "Text Editor", + fieldname: "question", + label: __("Question"), + reqd: 1, + default: values.question || "", + }, + { + fieldtype: "Select", + fieldname: "type", + label: __("Type"), + options: ["Choices", "User Input"], + default: values.type || "Choices", + }, + ]; + Array.from({ length: 4 }, (x, i) => { + num = i + 1; + + dialog_fields.push({ + fieldtype: "Section Break", + fieldname: `section_break_${num}`, + label: __(""), + }); + + let option = { + fieldtype: "Small Text", + fieldname: `option_${num}`, + label: __("Option ") + num, + depends_on: "eval:doc.type=='Choices'", + default: values[`option_${num}`] || "", + }; + + if (num <= 2) option.mandatory_depends_on = "eval:doc.type=='Choices'"; + + dialog_fields.push(option); + + dialog_fields.push({ + fieldtype: "Data", + fieldname: `explanaion_${num}`, + label: __("Explanation"), + depends_on: "eval:doc.type=='Choices'", + default: values[`explanaion_${num}`] || "", + }); + + let is_correct = { + fieldtype: "Check", + fieldname: `is_correct_${num}`, + label: __("Is Correct"), + depends_on: "eval:doc.type=='Choices'", + default: values[`is_correct_${num}`] || 0, + }; + + if (num <= 2) + is_correct.mandatory_depends_on = "eval:doc.type=='Choices'"; + + dialog_fields.push(is_correct); + + possibility = { + fieldtype: "Small Text", + fieldname: `possibility_${num}`, + label: __("Possible Answer ") + num, + depends_on: "eval:doc.type=='User Input'", + default: values[`possibility_${num}`] || "", + }; + + if (num == 1) + possibility.mandatory_depends_on = "eval:doc.type=='User Input'"; + + dialog_fields.push(possibility); + }); + + return dialog_fields; }; -const save_question = (e) => { - if (!$("#quiz-title").val()) { - frappe.throw(__("Quiz Title is mandatory.")); - } +const edit_question = (e) => { + let question = $(e.currentTarget).data("question"); + frappe.call({ + method: "lms.lms.doctype.lms_quiz.lms_quiz.get_question_details", + args: { + question: question, + }, + callback: (data) => { + if (data.message) show_question_modal(data.message); + }, + }); +}; +const save_quiz = (values) => { frappe.call({ method: "lms.lms.doctype.lms_quiz.lms_quiz.save_quiz", args: { - quiz_title: $("#quiz-title").val(), - questions: get_questions(), - quiz: $("#quiz-title").data("name") || "", + quiz_title: values.quiz_title, + quiz: $("#quiz-form").data("name") || "", }, callback: (data) => { window.location.href = `/quizzes/${data.message}`; @@ -82,90 +155,24 @@ const save_question = (e) => { }); }; -const get_questions = () => { - let questions = []; +const save_question = (values) => { + frappe.call({ + method: "lms.lms.doctype.lms_quiz.lms_quiz.save_question", + args: { + quiz: $("#quiz-form").data("name") || "", + values: values, + index: $("#quiz-form").data("index") + 1, + }, + callback: (data) => { + if (data.message) this.question_dialog.hide(); - $(".field-parent").each((i, el) => { - if (!$(el).find(".question").val()) return; - let details = {}; - let correct_options = 0; - let possibilities = 0; - - details["element"] = el; - details["question"] = $(el).find(".question").val(); - details["question_name"] = - $(el).find(".question").data("question") || ""; - details["type"] = $(el).find("label.active").find("input").data("type"); - - Array.from({ length: 4 }, (x, i) => { - let num = i + 1; - - if (details.type == "Choices") { - details[`option_${num}`] = $(el).find(`.option-${num}`).val(); - - details[`explanation_${num}`] = $(el) - .find(`.explanation-${num}`) - .val(); - - let is_correct = $(el).find(`.correct-${num}`).prop("checked"); - - if (is_correct) correct_options += 1; - - details[`is_correct_${num}`] = is_correct; - } else { - let possible_answer = $(el) - .find(`.possibility-${num}`) - .val() - .trim(); - if (possible_answer) possibilities += 1; - details[`possibility_${num}`] = possible_answer; - } - }); - validate_mandatory(details, correct_options, possibilities); - - details["multiple"] = correct_options > 1 ? 1 : 0; - questions.push(details); + frappe.show_alert({ + message: __("Saved"), + indicator: "green", + }); + setTimeout(() => { + window.location.reload(); + }, 1000); + }, }); - - 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 = () => { - scroll_to_element(".question-card:nth-last-child(2)"); - $(".question-card:nth-last-child(2)").find(".question").focus(); -}; - -const scroll_to_element = (element) => { - if ($(element).length) - $([document.documentElement, document.body]).animate( - { - scrollTop: $(element).offset().top - 100, - }, - 1000 - ); }; diff --git a/lms/www/batch/quiz.py b/lms/www/batch/quiz.py index b24c60ed..2d83c588 100644 --- a/lms/www/batch/quiz.py +++ b/lms/www/batch/quiz.py @@ -19,11 +19,6 @@ def get_context(context): context.quiz = frappe._dict() else: 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( diff --git a/lms/www/batch/quiz_list.py b/lms/www/batch/quiz_list.py index ade57b69..ae8df7fc 100644 --- a/lms/www/batch/quiz_list.py +++ b/lms/www/batch/quiz_list.py @@ -1,5 +1,5 @@ import frappe -from lms.lms.utils import can_create_courses +from lms.lms.utils import can_create_courses, has_course_moderator_role from frappe import _ @@ -13,6 +13,5 @@ def get_context(context): raise frappe.PermissionError(_(message)) - context.quiz_list = frappe.get_all( - "LMS Quiz", {"owner": frappe.session.user}, ["name", "title"] - ) + filters = {} if has_course_moderator_role() else {"owner": frappe.session.user} + context.quiz_list = frappe.get_all("LMS Quiz", filters, ["name", "title"]) diff --git a/lms/www/courses/outline.js b/lms/www/courses/outline.js index eaf9fc6c..9df5fa58 100644 --- a/lms/www/courses/outline.js +++ b/lms/www/courses/outline.js @@ -31,7 +31,7 @@ const show_chapter_modal = (e) => { $.trim(parent.find(".chapter-description").text()) ); $("#chapter-modal").data("chapter", parent.data("chapter")); - $("#chapter-modal").data("idx", parent.data("idx")); + $("#ch apter-modal").data("idx", parent.data("idx")); } };