Merge pull request #646 from pateljannat/quiz-refactor

feat: Quiz Refactor
This commit is contained in:
Jannat Patel
2023-10-20 11:31:31 +05:30
committed by GitHub
29 changed files with 943 additions and 408 deletions

View File

@@ -98,7 +98,6 @@ override_doctype_class = {
doc_events = {
"Discussion Reply": {"after_insert": "lms.lms.utils.create_notification_log"},
"Course Lesson": {"on_update": "lms.lms.doctype.lms_quiz.lms_quiz.update_lesson_info"},
}
# Scheduled Tasks

View File

@@ -99,8 +99,14 @@ def save_progress(lesson, course, status):
quizzes = [value for name, value in macros if name == "Quiz"]
for quiz in quizzes:
passing_percentage = frappe.db.get_value("LMS Quiz", quiz, "passing_percentage")
if not frappe.db.exists(
"LMS Quiz Submission", {"quiz": quiz, "owner": frappe.session.user}
"LMS Quiz Submission",
{
"quiz": quiz,
"owner": frappe.session.user,
"percentage": [">=", passing_percentage],
},
):
return 0

View File

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2023, Frappe and contributors
// For license information, please see license.txt
// frappe.ui.form.on("LMS Question", {
// refresh(frm) {
// },
// });

View File

@@ -0,0 +1,245 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "format:QTS-{YYYY}-{#####}",
"creation": "2023-10-10 10:24:14.035772",
"default_view": "List",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"question",
"type",
"multiple",
"section_break_ytxi",
"option_1",
"is_correct_1",
"column_break_fpvl",
"explanation_1",
"section_break_eiaa",
"option_2",
"is_correct_2",
"column_break_akwy",
"explanation_2",
"section_break_cwqv",
"option_3",
"is_correct_3",
"column_break_atpl",
"explanation_3",
"section_break_yqel",
"option_4",
"is_correct_4",
"column_break_lknb",
"explanation_4",
"section_break_hkfe",
"possibility_1",
"possibility_3",
"column_break_wpjr",
"possibility_2",
"possibility_4"
],
"fields": [
{
"fieldname": "question",
"fieldtype": "Text Editor",
"in_list_view": 1,
"label": "Question"
},
{
"fieldname": "type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Type",
"options": "Choices\nUser Input"
},
{
"depends_on": "eval:doc.type == \"Choices\";",
"fieldname": "section_break_ytxi",
"fieldtype": "Section Break"
},
{
"fieldname": "option_1",
"fieldtype": "Small Text",
"label": "Option 1",
"mandatory_depends_on": "eval: doc.type == 'Choices'"
},
{
"default": "0",
"fieldname": "is_correct_1",
"fieldtype": "Check",
"label": "Is Correct"
},
{
"fieldname": "column_break_fpvl",
"fieldtype": "Column Break"
},
{
"fieldname": "explanation_1",
"fieldtype": "Small Text",
"label": "Explanation"
},
{
"depends_on": "eval:doc.type == \"Choices\";",
"fieldname": "section_break_eiaa",
"fieldtype": "Section Break"
},
{
"fieldname": "option_2",
"fieldtype": "Small Text",
"label": "Option 2",
"mandatory_depends_on": "eval: doc.type == 'Choices'"
},
{
"default": "0",
"fieldname": "is_correct_2",
"fieldtype": "Check",
"label": "Is Correct"
},
{
"fieldname": "column_break_akwy",
"fieldtype": "Column Break"
},
{
"fieldname": "explanation_2",
"fieldtype": "Small Text",
"label": "Explanation "
},
{
"depends_on": "eval: doc.type == 'Choices'",
"fieldname": "section_break_cwqv",
"fieldtype": "Section Break"
},
{
"fieldname": "option_3",
"fieldtype": "Small Text",
"label": "Option 3"
},
{
"default": "0",
"fieldname": "is_correct_3",
"fieldtype": "Check",
"label": "Is Correct"
},
{
"fieldname": "column_break_atpl",
"fieldtype": "Column Break"
},
{
"fieldname": "explanation_3",
"fieldtype": "Small Text",
"label": "Explanation"
},
{
"depends_on": "eval: doc.type == 'Choices'",
"fieldname": "section_break_yqel",
"fieldtype": "Section Break"
},
{
"fieldname": "option_4",
"fieldtype": "Small Text",
"label": "Option 4"
},
{
"default": "0",
"fieldname": "is_correct_4",
"fieldtype": "Check",
"label": "Is Correct"
},
{
"fieldname": "column_break_lknb",
"fieldtype": "Column Break"
},
{
"fieldname": "explanation_4",
"fieldtype": "Small Text",
"label": "Explanation"
},
{
"default": "0",
"fieldname": "multiple",
"fieldtype": "Check",
"hidden": 1,
"label": "Multiple Correct Answers"
},
{
"depends_on": "eval: doc.type == 'User Input'",
"fieldname": "section_break_hkfe",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_wpjr",
"fieldtype": "Column Break"
},
{
"fieldname": "possibility_1",
"fieldtype": "Small Text",
"label": "Possible Answer 1",
"mandatory_depends_on": "eval: doc.type == 'User Input'"
},
{
"fieldname": "possibility_3",
"fieldtype": "Small Text",
"label": "Possible Answer 3"
},
{
"fieldname": "possibility_2",
"fieldtype": "Small Text",
"label": "Possible Answer 2"
},
{
"fieldname": "possibility_4",
"fieldtype": "Small Text",
"label": "Possible Answer 4"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-10-18 21:58:42.653317",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Question",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Course Creator",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "question"
}

View File

@@ -0,0 +1,92 @@
# Copyright (c) 2023, Frappe and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.document import Document
from lms.lms.utils import has_course_instructor_role, has_course_moderator_role
class LMSQuestion(Document):
def validate(self):
validate_correct_answers(self)
def validate_correct_answers(question):
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."))
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."))
def validate_possible_answer(question):
possible_answers = []
possible_answers_fields = [
"possibility_1",
"possibility_2",
"possibility_3",
"possibility_4",
]
for field in possible_answers_fields:
if question.get(field):
possible_answers.append(field)
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(question):
correct_options = []
correct_option_fields = [
"is_correct_1",
"is_correct_2",
"is_correct_3",
"is_correct_4",
]
for field in correct_option_fields:
if question.get(field) == 1:
correct_options.append(field)
return correct_options
@frappe.whitelist()
def get_question_details(question):
if not has_course_instructor_role() or not has_course_moderator_role():
return
fields = ["question", "type", "name"]
for i in range(1, 5):
fields.append(f"option_{i}")
fields.append(f"is_correct_{i}")
fields.append(f"explanation_{i}")
fields.append(f"possibility_{i}")
return frappe.db.get_value("LMS Question", question, fields, as_dict=1)

View File

@@ -0,0 +1,9 @@
# Copyright (c) 2023, Frappe and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestLMSQuestion(FrappeTestCase):
pass

View File

@@ -5,3 +5,13 @@ frappe.ui.form.on("LMS Quiz", {
// refresh: function(frm) {
// }
});
frappe.ui.form.on("LMS Quiz Question", {
marks: function (frm) {
total_marks = 0;
frm.doc.questions.forEach((question) => {
total_marks += question.marks;
});
frm.doc.total_marks = total_marks;
},
});

View File

@@ -12,6 +12,10 @@
"column_break_gaac",
"max_attempts",
"show_submission_history",
"section_break_hsiv",
"passing_percentage",
"column_break_rocd",
"total_marks",
"section_break_sbjx",
"questions",
"section_break_3",
@@ -43,7 +47,7 @@
"read_only": 1
},
{
"default": "1",
"default": "0",
"fieldname": "max_attempts",
"fieldtype": "Int",
"label": "Max Attempts"
@@ -90,11 +94,34 @@
"fieldname": "show_submission_history",
"fieldtype": "Check",
"label": "Show Submission History"
},
{
"fieldname": "section_break_hsiv",
"fieldtype": "Section Break"
},
{
"fieldname": "passing_percentage",
"fieldtype": "Int",
"label": "Passing Percentage",
"non_negative": 1,
"reqd": 1
},
{
"fieldname": "column_break_rocd",
"fieldtype": "Column Break"
},
{
"fieldname": "total_marks",
"fieldtype": "Int",
"label": "Total Marks",
"non_negative": 1,
"read_only": 1,
"reqd": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-07-04 15:26:24.457745",
"modified": "2023-10-18 22:50:58.252350",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Quiz",
@@ -123,6 +150,18 @@
"role": "Moderator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Course Creator",
"share": 1,
"write": 1
}
],
"show_title_field_in_link": 1,

View File

@@ -5,7 +5,8 @@ import json
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import cstr
from frappe.utils import cstr, comma_and
from lms.lms.doctype.lms_question.lms_question import validate_correct_answers
from lms.lms.utils import (
generate_slug,
has_course_moderator_role,
@@ -14,13 +15,22 @@ from lms.lms.utils import (
class LMSQuiz(Document):
def validate(self):
self.validate_duplicate_questions()
self.total_marks = set_total_marks(self.name, self.questions)
def validate_duplicate_questions(self):
questions = [row.question for row in self.questions]
rows = [i + 1 for i, x in enumerate(questions) if questions.count(x) > 1]
if len(rows):
frappe.throw(
_("Rows {0} have the duplicate questions.").format(frappe.bold(comma_and(rows)))
)
def autoname(self):
if not self.name:
self.name = generate_slug(self.title, "LMS Quiz")
def validate(self):
validate_correct_answers(self.questions)
def get_last_submission_details(self):
"""Returns the latest submission for this user."""
user = frappe.session.user
@@ -39,76 +49,11 @@ 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):
def set_total_marks(quiz, questions):
marks = 0
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(
"LMS Quiz", doc.quiz_id, {"lesson": doc.name, "course": doc.course}
)
marks += question.get("marks")
return marks
@frappe.whitelist()
@@ -118,25 +63,42 @@ def quiz_summary(quiz, results):
for result in results:
correct = result["is_correct"][0]
result["question"] = frappe.db.get_value(
"LMS Quiz Question",
{"parent": quiz, "idx": result["question_index"] + 1},
["question"],
)
for point in result["is_correct"]:
correct = correct and point
result["is_correct"] = correct
score += correct
question_details = frappe.db.get_value(
"LMS Quiz Question",
{"parent": quiz, "idx": result["question_index"] + 1},
["question", "marks"],
as_dict=1,
)
result["question_name"] = question_details.question
result["question"] = frappe.db.get_value(
"LMS Question", question_details.question, "question"
)
marks = question_details.marks if correct else 0
result["marks"] = marks
score += marks
del result["question_index"]
quiz_details = frappe.db.get_value(
"LMS Quiz", quiz, ["total_marks", "passing_percentage"], as_dict=1
)
score_out_of = quiz_details.total_marks
percentage = (score / score_out_of) * 100
submission = frappe.get_doc(
{
"doctype": "LMS Quiz Submission",
"quiz": quiz,
"result": results,
"score": score,
"score_out_of": score_out_of,
"member": frappe.session.user,
}
)
@@ -144,19 +106,28 @@ def quiz_summary(quiz, results):
return {
"score": score,
"score_out_of": score_out_of,
"submission": submission.name,
"pass": percentage == quiz_details.passing_percentage,
}
@frappe.whitelist()
def save_quiz(
quiz_title, max_attempts=1, quiz=None, show_answers=1, show_submission_history=0
quiz_title,
passing_percentage,
questions,
max_attempts=0,
quiz=None,
show_answers=1,
show_submission_history=0,
):
if not has_course_moderator_role() or not has_course_instructor_role():
return
values = {
"title": quiz_title,
"passing_percentage": passing_percentage,
"max_attempts": max_attempts,
"show_answers": show_answers,
"show_submission_history": show_submission_history,
@@ -164,41 +135,77 @@ def save_quiz(
if quiz:
frappe.db.set_value("LMS Quiz", quiz, values)
update_questions(quiz, questions)
return quiz
else:
doc = frappe.new_doc("LMS Quiz")
doc.update(values)
doc.save(ignore_permissions=True)
doc.save()
update_questions(doc.name, questions)
return doc.name
def update_questions(quiz, questions):
questions = json.loads(questions)
delete_questions(quiz, questions)
add_questions(quiz, questions)
frappe.db.set_value("LMS Quiz", quiz, "total_marks", set_total_marks(quiz, questions))
def delete_questions(quiz, questions):
existing_questions = frappe.get_all(
"LMS Quiz Question",
{
"parent": quiz,
},
pluck="name",
)
current_questions = [question.get("question_name") for question in questions]
for question in existing_questions:
if question not in current_questions:
frappe.db.delete("LMS Quiz Question", question)
def add_questions(quiz, questions):
for index, question in enumerate(questions):
question = frappe._dict(question)
if question.question_name:
doc = frappe.get_doc("LMS Quiz Question", question.question_name)
else:
doc = frappe.new_doc("LMS Quiz Question")
doc.update(
{
"parent": quiz,
"parenttype": "LMS Quiz",
"parentfield": "questions",
"idx": index + 1,
}
)
doc.update({"question": question.question, "marks": question.marks})
doc.save()
@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"))
doc = frappe.get_doc("LMS Question", values.get("name"))
else:
doc = frappe.new_doc("LMS Quiz Question")
doc = frappe.new_doc("LMS Question")
doc.update(
{
"question": values["question"],
"question": values.question,
"type": values["type"],
}
)
if not values.get("name"):
doc.update(
{
"parent": quiz,
"parenttype": "LMS Quiz",
"parentfield": "questions",
"idx": index,
}
)
for num in range(1, 5):
if values.get(f"option_{num}"):
doc.update(
@@ -222,9 +229,8 @@ def save_question(quiz, values, index):
}
)
doc.save(ignore_permissions=True)
return quiz
doc.save()
return doc.name
@frappe.whitelist()
@@ -257,9 +263,7 @@ def check_choice_answers(question, answers):
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
)
question_details = frappe.db.get_value("LMS Question", question, fields, as_dict=1)
for num in range(1, 5):
if question_details[f"option_{num}"] in answers:
@@ -275,9 +279,7 @@ def check_input_answers(question, answer):
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
)
question_details = frappe.db.get_value("LMS 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():

View File

@@ -10,51 +10,36 @@ import frappe
class TestLMSQuiz(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
frappe.get_doc({"doctype": "LMS Quiz", "title": "Test Quiz"}).save(
ignore_permissions=True
)
frappe.get_doc(
{"doctype": "LMS Quiz", "title": "Test Quiz", "passing_percentage": 90}
).save(ignore_permissions=True)
def test_with_multiple_options(self):
quiz = frappe.get_doc("LMS Quiz", "test-quiz")
quiz.append(
"questions",
{
"question": "Question Multiple",
"type": "Choices",
"option_1": "Option 1",
"is_correct_1": 1,
"option_2": "Option 2",
"is_correct_2": 1,
},
)
quiz.save()
self.assertTrue(quiz.questions[0].multiple)
question = frappe.new_doc("LMS Question")
question.question = "Question Multiple"
question.type = "Choices"
question.option_1 = "Option 1"
question.is_correct_1 = 1
question.option_2 = "Option 2"
question.is_correct_2 = 1
question.save()
self.assertTrue(question.multiple)
def test_with_no_correct_option(self):
quiz = frappe.get_doc("LMS Quiz", "test-quiz")
quiz.append(
"questions",
{
"question": "Question no correct option",
"type": "Choices",
"option_1": "Option 1",
"option_2": "Option 2",
},
)
self.assertRaises(frappe.ValidationError, quiz.save)
question = frappe.new_doc("LMS Question")
question.question = "Question Multiple"
question.type = "Choices"
question.option_1 = "Option 1"
question.option_2 = "Option 2"
self.assertRaises(frappe.ValidationError, question.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)
question = frappe.new_doc("LMS Question")
question.question = "Question Multiple"
question.type = "User Input"
self.assertRaises(frappe.ValidationError, question.save)
@classmethod
def tearDownClass(cls) -> None:
frappe.db.delete("LMS Quiz", "test-quiz")
frappe.db.delete("LMS Quiz Question", {"parent": "test-quiz"})
frappe.db.delete("LMS Question")

View File

@@ -6,208 +6,31 @@
"engine": "InnoDB",
"field_order": [
"question",
"type",
"options_section",
"option_1",
"is_correct_1",
"column_break_5",
"explanation_1",
"section_break_5",
"option_2",
"is_correct_2",
"column_break_10",
"explanation_2",
"column_break_4",
"option_3",
"is_correct_3",
"column_break_15",
"explanation_3",
"section_break_11",
"option_4",
"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"
"marks"
],
"fields": [
{
"fieldname": "question",
"fieldtype": "Text Editor",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Question",
"options": "LMS Question",
"reqd": 1
},
{
"fieldname": "option_1",
"fieldtype": "Small Text",
"label": "Option 1",
"mandatory_depends_on": "eval: doc.type == 'Choices'"
},
{
"fieldname": "option_2",
"fieldtype": "Small Text",
"label": "Option 2",
"mandatory_depends_on": "eval: doc.type == 'Choices'"
},
{
"fieldname": "option_3",
"fieldtype": "Small Text",
"label": "Option 3"
},
{
"fieldname": "option_4",
"fieldtype": "Small Text",
"label": "Option 4"
},
{
"default": "0",
"depends_on": "option_1",
"fieldname": "is_correct_1",
"fieldtype": "Check",
"label": "Is Correct"
},
{
"default": "0",
"depends_on": "option_2",
"fieldname": "is_correct_2",
"fieldtype": "Check",
"label": "Is Correct"
},
{
"default": "0",
"depends_on": "option_3",
"fieldname": "is_correct_3",
"fieldtype": "Check",
"label": "Is Correct"
},
{
"default": "0",
"depends_on": "option_4",
"fieldname": "is_correct_4",
"fieldtype": "Check",
"label": "Is Correct"
},
{
"default": "0",
"fieldname": "multiple",
"fieldtype": "Check",
"hidden": 1,
"label": "Multiple Correct Answers",
"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"
},
{
"depends_on": "option_1",
"fieldname": "explanation_1",
"fieldtype": "Data",
"label": "Explanation"
},
{
"depends_on": "option_2",
"fieldname": "explanation_2",
"fieldtype": "Data",
"label": "Explanation"
},
{
"depends_on": "option_3",
"fieldname": "explanation_3",
"fieldtype": "Data",
"label": "Explanation"
},
{
"depends_on": "option_4",
"fieldname": "explanation_4",
"fieldtype": "Data",
"label": "Explanation"
},
{
"fieldname": "column_break_5",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_10",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_15",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_20",
"fieldtype": "Column Break"
},
{
"fieldname": "type",
"fieldtype": "Select",
"default": "1",
"fieldname": "marks",
"fieldtype": "Int",
"in_list_view": 1,
"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"
"label": "Marks",
"non_negative": 1,
"reqd": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2023-07-04 16:43:49.837134",
"modified": "2023-10-16 19:51:03.893144",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Quiz Question",

View File

@@ -6,7 +6,11 @@
"engine": "InnoDB",
"field_order": [
"question",
"section_break_fztv",
"question_name",
"answer",
"column_break_flus",
"marks",
"is_correct"
],
"fields": [
@@ -31,12 +35,33 @@
"in_list_view": 1,
"label": "Is Correct",
"read_only": 1
},
{
"fieldname": "section_break_fztv",
"fieldtype": "Section Break"
},
{
"fieldname": "question_name",
"fieldtype": "Link",
"label": "Question Name",
"options": "LMS Question"
},
{
"fieldname": "column_break_flus",
"fieldtype": "Column Break"
},
{
"fieldname": "marks",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Marks",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2022-11-24 11:15:45.931119",
"modified": "2023-10-17 11:55:25.641214",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Quiz Result",

View File

@@ -6,11 +6,15 @@
"engine": "InnoDB",
"field_order": [
"quiz",
"score",
"course",
"column_break_3",
"member",
"member_name",
"section_break_dkpn",
"score",
"score_out_of",
"column_break_gkip",
"percentage",
"section_break_6",
"result"
],
@@ -31,9 +35,11 @@
},
{
"fieldname": "score",
"fieldtype": "Data",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Score"
"label": "Score",
"read_only": 1,
"reqd": 1
},
{
"fieldname": "member",
@@ -65,12 +71,37 @@
"label": "Course",
"options": "LMS Course",
"read_only": 1
},
{
"fetch_from": "quiz.total_marks",
"fieldname": "score_out_of",
"fieldtype": "Int",
"label": "Score Out Of",
"non_negative": 1,
"read_only": 1,
"reqd": 1
},
{
"fieldname": "section_break_dkpn",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_gkip",
"fieldtype": "Column Break"
},
{
"fieldname": "percentage",
"fieldtype": "Int",
"label": "Percentage",
"non_negative": 1,
"read_only": 1,
"reqd": 1
}
],
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2022-11-15 15:27:07.770945",
"modified": "2023-10-17 13:07:27.979974",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Quiz Submission",

View File

@@ -6,4 +6,9 @@ from frappe.model.document import Document
class LMSQuizSubmission(Document):
pass
def before_insert(self):
self.set_percentage()
def set_percentage(self):
if self.score and self.score_out_of:
self.percentage = (self.score / self.score_out_of) * 100

View File

@@ -71,4 +71,7 @@ lms.patches.v1_0.publish_batches
lms.patches.v1_0.publish_certificates
lms.patches.v1_0.change_naming_for_batch_course #14-09-2023
execute:frappe.permissions.reset_perms("LMS Enrollment")
lms.patches.v1_0.create_student_role
lms.patches.v1_0.create_student_role
lms.patches.v1_0.mark_confirmation_for_batch_students
lms.patches.v1_0.create_quiz_questions
lms.patches.v1_0.add_default_marks #16-10-2023

View File

@@ -0,0 +1,18 @@
import frappe
def execute():
frappe.reload_doc("lms", "doctype", "lms_quiz_question")
frappe.reload_doc("lms", "doctype", "lms_quiz")
questions = frappe.get_all("LMS Quiz Question", pluck="name")
for question in questions:
frappe.db.set_value("LMS Quiz Question", question, "marks", 1)
quizzes = frappe.get_all("LMS Quiz", pluck="name")
for quiz in quizzes:
questions_count = frappe.db.count("LMS Quiz Question", {"parent": quiz})
frappe.db.set_value(
"LMS Quiz", quiz, {"total_marks": questions_count, "passing_percentage": 100}
)

View File

@@ -0,0 +1,43 @@
import frappe
def execute():
frappe.reload_doc("lms", "doctype", "lms_question")
fields = ["name", "question", "type", "multiple"]
for num in range(1, 5):
fields.append(f"option_{num}")
fields.append(f"is_correct_{num}")
fields.append(f"explanation_{num}")
fields.append(f"possibility_{num}")
questions = frappe.get_all(
"LMS Quiz Question",
fields=fields,
)
for question in questions:
print(question.name)
doc = frappe.new_doc("LMS Question")
doc.update(
{
"question": question.question,
"type": question.type,
"multiple": question.multiple,
}
)
for num in range(1, 5):
if question.get(f"option_{num}"):
doc.update(
{
f"option_{num}": question[f"option_{num}"],
f"is_correct_{num}": question[f"is_correct_{num}"],
f"explanation_{num}": question[f"explanation_{num}"],
f"possibility_{num}": question[f"possibility_{num}"],
}
)
doc.save()
print(doc.name)
frappe.db.set_value("LMS Quiz Question", question.name, "question", doc.name)

View File

@@ -0,0 +1,9 @@
import frappe
def execute():
frappe.reload_doc("lms", "doctype", "batch_student")
students = frappe.get_all("Batch Student", pluck="name")
for student in students:
frappe.db.set_value("Batch Student", student, "confirmation_email_sent", 1)

View File

@@ -109,7 +109,39 @@ def quiz_renderer(quiz_name):
)
+"</div>"
quiz = frappe.get_doc("LMS Quiz", quiz_name)
quiz = frappe.db.get_value(
"LMS Quiz",
quiz_name,
[
"name",
"title",
"max_attempts",
"show_answers",
"show_submission_history",
"passing_percentage",
],
as_dict=True,
)
quiz.questions = []
fields = ["name", "question", "type", "multiple"]
for num in range(1, 5):
fields.append(f"option_{num}")
fields.append(f"is_correct_{num}")
fields.append(f"explanation_{num}")
fields.append(f"possibility_{num}")
questions = frappe.get_all(
"LMS Quiz Question",
filters={"parent": quiz.name},
fields=["question", "marks"],
order_by="idx",
)
for question in questions:
details = frappe.db.get_value("LMS Question", question.question, fields, as_dict=1)
details["marks"] = question.marks
quiz.questions.append(details)
no_of_attempts = frappe.db.count(
"LMS Quiz Submission", {"owner": frappe.session.user, "quiz": quiz_name}
)

View File

@@ -785,12 +785,13 @@ input[type=checkbox] {
}
.breadcrumb {
display: flex;
align-items: center;
font-size: var(--text-base);
line-height: 20px;
color: var(--gray-900);
padding: 0;
display: flex;
align-items: center;
font-size: var(--text-base);
line-height: 20px;
color: var(--gray-900);
padding: 0;
border-radius: 0;
}
.course-details-outline {
@@ -2473,4 +2474,8 @@ select {
.modal-body .ql-container {
max-height: unset !important;
}
.questions-table .row-index {
display: none;
}

View File

@@ -6,6 +6,15 @@
{{ _("This quiz consists of {0} questions.").format(quiz.questions | length) }}
</li>
{% if quiz.passing_percentage %}
<li>
{{ _("You will have to get {0}% correct answers in order to pass the quiz.").format(quiz.passing_percentage) }}
</li>
<li>
{{ _("Without passing the quiz you won't be able to complete the lesson.") }}
</li>
{% endif %}
{% if quiz.max_attempts %}
{% set suffix = "times" if quiz.max_attempts > 1 else "time" %}
<li>
@@ -18,8 +27,7 @@
{{ _("The quiz has a time limit. For each question you will be given {0} seconds.").format(quiz.time) }}
</li>
{% endif %}
</ul>
</ul>
<div id="start-banner" class="common-card-style column-card align-items-center">
@@ -50,8 +58,12 @@
<div class="question hide" data-name="{{ question.name }}" data-type="{{ question.type }}"
data-multi="{{ question.multiple }}" data-qt-index="{{ loop.index }}">
<div>
<div class="pull-right font-weight-bold">
{{ question.marks }} {{ _("Marks") }}
</div>
<div class="question-number">
{{ _("Question ") }}{{ loop.index }}: {{ instruction }}</div>
{{ _("Question ") }}{{ loop.index }}: {{ instruction }}
</div>
<div class="question-text">
{{ question.question }}
</div>

View File

@@ -120,7 +120,6 @@ const enable_check = (e) => {
const quiz_summary = (e = undefined) => {
e && e.preventDefault();
let quiz_name = $("#quiz-title").data("name");
let total_questions = $(".question").length;
let self = this;
frappe.call({
@@ -136,13 +135,16 @@ const quiz_summary = (e = undefined) => {
$("#quiz-form").prepend(
`<div class="summary bold-heading text-center">
${__("Your score is")} ${data.message.score}
${__("out of")} ${total_questions}
${__("out of")} ${data.message.score_out_of}
</div>`
);
$("#try-again").attr("data-submission", data.message.submission);
$("#try-again").removeClass("hide");
self.quiz_submitted = true;
if (this.hasOwnProperty("marked_as_complete")) {
if (
this.hasOwnProperty("marked_as_complete") &&
data.message.pass
) {
mark_progress();
}
},

View File

@@ -7,7 +7,7 @@ def get_context(context):
context.no_cache = 1
if frappe.session.user == "Guest":
raise frappe.PermissionError(_("You don't have permission to access this page."))
raise frappe.PermissionError(_("Please login to submit the assignment."))
context.is_moderator = has_course_moderator_role()
submission = frappe.form_dict["submission"]

View File

@@ -429,9 +429,9 @@ class Quiz {
}
render_quiz(quiz) {
return `<div class="common-card-style p-2 my-2 bold-heading">
return `<a class="common-card-style p-20 my-2 justify-center bold-heading" target="_blank" href=/quizzes/${quiz}>
Quiz: ${quiz}
</div>`;
</a>`;
}
validate(savedData) {

View File

@@ -16,25 +16,9 @@
{% macro QuizForm(quiz) %}
<div id="quiz-form" {% if quiz.name %} data-name="{{ quiz.name }}" data-index="{{ quiz.questions | length }}" {% endif %}>
{{ QuizDetails(quiz) }}
{% if quiz.questions %}
<div class="field-group">
<div class="field-label mb-1">
{{ _("Questions") }}
</div>
<div class="common-card-style column-card px-3 py-0">
{% for question in quiz.questions %}
{{ Question(question, loop.index) }}
{% endfor %}
</div>
<button class="btn btn-secondary btn-sm btn-add-question mt-4">
{{ _("Add Question") }}
</button>
</div>
{% endif %}
{% if quiz.name and not quiz.questions | length %}
{{ EmptyState() }}
{% endif %}
<div class="field-group">
<div class="questions-table"></div>
</div>
</div>
{% endmacro %}
@@ -59,11 +43,6 @@
</div>
<div class="align-self-center">
{% if quiz.name %}
<button class="btn btn-secondary btn-sm btn-add-question mr-2">
{{ _("Add Question") }}
</button>
{% endif %}
<button class="btn btn-primary btn-sm btn-save-quiz">
{{ _("Save") }}
</button>
@@ -98,18 +77,30 @@
{{ _("Enter the maximum number of times a user can attempt this quiz") }}
</div>
<div>
{% set max_attempts = quiz.max_attempts if quiz.name else 1 %}
{% set max_attempts = quiz.max_attempts if quiz.name else 0 %}
<input type="number" class="field-input" id="max-attempts" value="{{ max_attempts }}">
</div>
</div>
<div class="field-group">
<div class="field-label reqd">
{{ _("Passing Percentage") }}
</div>
<div class="field-description">
{{ _("Minimum percentage required to pass this quiz.") }}
</div>
<div>
<input type="number" class="field-input" id="passing-percentage" value="{{ quiz.passing_percentage }}">
</div>
</div>
<div class="field-group vertically-center">
{% set show_answers = quiz.show_answers or not quiz.name %}
<label for="show-answers" class="vertically-center mb-0">
<input type="checkbox" id="show-answers" {% if show_answers %} checked {% endif %}>
{{ _("Show Answers") }}
</label>
<label for="upcoming" class="vertically-center mb-0 ml-20">
<label for="show-submission-history" class="vertically-center mb-0 ml-20">
<input type="checkbox" id="show-submission-history" {% if quiz.show_submission_history %} checked {% endif %}>
{{ _("Show Submission History") }}
</label>
@@ -151,5 +142,9 @@
{%- block script %}
{{ super() }}
{{ include_script('controls.bundle.js') }}
{% if has_course_instructor_role() or has_course_moderator_role() %}
<script>
const quiz_questions = {{ quiz.questions or [] }}
</script>
{% endif %}
{% endblock %}

View File

@@ -1,17 +1,21 @@
frappe.ready(() => {
$(".btn-save-quiz").click((e) => {
save_quiz({
quiz_title: $("#quiz-title").val(),
max_attempts: $("#max-attempts").val(),
if ($(".questions-table").length) {
frappe.require("controls.bundle.js", () => {
create_questions_table();
});
}
$(".btn-save-quiz").click((e) => {
save_quiz();
});
$(".question-row").click((e) => {
edit_question(e);
});
$(".btn-add-question").click((e) => {
show_question_modal();
$(document).on("click", ".questions-table .link-btn", (e) => {
e.preventDefault();
fetch_question_data(e);
});
});
@@ -31,6 +35,8 @@ const show_question_modal = (values = {}) => {
};
const get_question_fields = (values = {}) => {
if (!values.question) values = {};
let dialog_fields = [
{
fieldtype: "Text Editor",
@@ -66,6 +72,7 @@ const get_question_fields = (values = {}) => {
if (num <= 2) option.mandatory_depends_on = "eval:doc.type=='Choices'";
dialog_fields.push(option);
console.log(dialog_fields);
dialog_fields.push({
fieldtype: "Data",
@@ -120,12 +127,16 @@ const edit_question = (e) => {
const save_quiz = (values) => {
validate_mandatory();
validate_questions();
frappe.call({
method: "lms.lms.doctype.lms_quiz.lms_quiz.save_quiz",
args: {
quiz_title: values.quiz_title,
max_attempts: values.max_attempts,
quiz_title: $("#quiz-title").val(),
max_attempts: $("#max-attempts").val(),
passing_percentage: $("#passing-percentage").val(),
quiz: $("#quiz-form").data("name") || "",
questions: this.table.get_value("questions"),
show_answers: $("#show-answers").is(":checked") ? 1 : 0,
show_submission_history: $("#show-submission-history").is(
":checked"
@@ -146,13 +157,45 @@ const save_quiz = (values) => {
};
const validate_mandatory = () => {
if (!$("#quiz-title").val()) {
let error = $("p")
.addClass("error-message")
.text(__("Please enter a Quiz Title"));
$(error).insertAfter("#quiz-title");
$("#quiz-title").focus();
throw "Title is mandatory";
let fields = ["#quiz-title", "#passing-percentage"];
fields.forEach((field, idx) => {
if (!$(field).val()) {
let error = $("p")
.addClass("error-message")
.text(__("Please enter a value"));
$(error).insertAfter(field);
scroll_to_element($(field));
throw "This field is mandatory";
}
});
};
const validate_questions = () => {
let questions = this.table.get_value("questions");
if (!questions.length) {
frappe.throw(__("Please add a question."));
}
questions.forEach((question, index) => {
if (!question.question) {
frappe.throw(__("Please add question in row") + " " + (index + 1));
}
if (!question.marks) {
frappe.throw(__("Please add marks in row") + " " + (index + 1));
}
});
};
const scroll_to_element = (element) => {
if ($(element).length) {
$([document.documentElement, document.body]).animate(
{
scrollTop: $(element).offset().top - 100,
},
1000
);
}
};
@@ -167,13 +210,98 @@ const save_question = (values) => {
callback: (data) => {
if (data.message) this.question_dialog.hide();
frappe.show_alert({
message: __("Saved"),
indicator: "green",
});
setTimeout(() => {
window.location.reload();
}, 1000);
if (values.name) {
frappe.show_alert({
message: __("Saved"),
indicator: "green",
});
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
let details = {
question: data.message,
};
index = this.table.get_value("questions").length;
add_question_row(details, index);
}
},
});
};
const create_questions_table = () => {
this.table = new frappe.ui.FieldGroup({
fields: [
{
fieldname: "questions",
fieldtype: "Table",
in_place_edit: 1,
label: __("Questions"),
fields: [
{
fieldname: "question",
fieldtype: "Link",
label: __("Question"),
options: "LMS Question",
in_list_view: 1,
only_select: 1,
reqd: 1,
},
{
fieldname: "marks",
fieldtype: "Int",
label: __("Marks"),
in_list_view: 1,
reqd: 1,
},
{
fieldname: "question_name",
fieldname: "Link",
options: "LMS Quiz Question",
label: __("Question Name"),
},
],
},
],
body: $(".questions-table").get(0),
});
this.table.make();
$(".questions-table .form-section:last").removeClass("empty-section");
$(".questions-table .frappe-control").removeClass("hide-control");
$(".questions-table .form-column").addClass("p-0");
quiz_questions.forEach((question, idx) => {
add_question_row(question, idx);
});
this.table.fields_dict["questions"].grid.add_custom_button(
"New Question",
show_question_modal,
"bottom"
);
};
const add_question_row = (question, idx) => {
this.table.fields_dict["questions"].grid.add_new_row();
this.table.get_value("questions")[idx] = {
question: question.question,
marks: question.marks,
};
this.table.refresh();
};
const fetch_question_data = (e) => {
let question_name = $(e.currentTarget)
.find(".btn-open")
.attr("href")
.split("/")[3];
frappe.call({
method: "lms.lms.doctype.lms_question.lms_question.get_question_details",
args: {
question: question_name,
},
callback: (data) => {
show_question_modal(data.message);
},
});
};

View File

@@ -18,14 +18,22 @@ def get_context(context):
if quizname == "new-quiz":
context.quiz = frappe._dict()
else:
fields_arr = ["name", "question", "type"]
context.quiz = frappe.db.get_value(
"LMS Quiz",
quizname,
["title", "name", "max_attempts", "show_answers", "show_submission_history"],
[
"title",
"name",
"max_attempts",
"passing_percentage",
"show_answers",
"show_submission_history",
],
as_dict=1,
)
fields_arr = ["name", "question", "marks"]
context.quiz.questions = frappe.get_all(
"LMS Quiz Question", {"parent": quizname}, fields_arr, order_by="idx"
)

View File

@@ -653,6 +653,7 @@ const setup_calendar = (events) => {
const options = get_calendar_options(element, calendar_id);
const calendar = new Calendar(container, options);
this.calendar_ = calendar;
create_events(calendar, events);
add_links_to_events(calendar, events);
scroll_to_date(calendar, events);