diff --git a/.github/helper/install.sh b/.github/helper/install.sh index b5661726..21bb9d9a 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -43,4 +43,4 @@ build_pid=$! bench --site lms.test reinstall --yes bench --site lms.test install-app lms -wait $build_pid +wait $build_pid \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aafbfa01..01ee036a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,5 +77,4 @@ jobs: run: bench --site frappe.local build - name: run tests working-directory: /home/runner/frappe-bench - run: bench --site frappe.local run-tests --app lms - + run: bench --site frappe.local run-tests --app lms \ No newline at end of file diff --git a/lms/hooks.py b/lms/hooks.py index 0c82242b..dbfd815f 100644 --- a/lms/hooks.py +++ b/lms/hooks.py @@ -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 diff --git a/lms/lms/doctype/course_lesson/course_lesson.py b/lms/lms/doctype/course_lesson/course_lesson.py index b2f2475f..d7b53fd4 100644 --- a/lms/lms/doctype/course_lesson/course_lesson.py +++ b/lms/lms/doctype/course_lesson/course_lesson.py @@ -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 diff --git a/lms/lms/doctype/lms_assignment/lms_assignment.py b/lms/lms/doctype/lms_assignment/lms_assignment.py index 647833de..b4aaedbf 100644 --- a/lms/lms/doctype/lms_assignment/lms_assignment.py +++ b/lms/lms/doctype/lms_assignment/lms_assignment.py @@ -3,7 +3,7 @@ import frappe from frappe.model.document import Document -from lms.lms.utils import can_create_courses +from lms.lms.utils import has_course_moderator_role, has_course_instructor_role class LMSAssignment(Document): @@ -12,7 +12,7 @@ class LMSAssignment(Document): @frappe.whitelist() def save_assignment(assignment, title, type, question): - if not can_create_courses(): + if not has_course_moderator_role() or not has_course_instructor_role(): return if assignment: diff --git a/lms/lms/doctype/lms_batch/lms_batch.js b/lms/lms/doctype/lms_batch/lms_batch.js index f7273fc1..1a95c493 100644 --- a/lms/lms/doctype/lms_batch/lms_batch.js +++ b/lms/lms/doctype/lms_batch/lms_batch.js @@ -12,12 +12,16 @@ frappe.ui.form.on("LMS Batch", { }); frm.set_query("reference_doctype", "timetable", function () { - let doctypes = [ - "Course Lesson", - "LMS Quiz", - "LMS Assignment", - "LMS Live Class", - ]; + let doctypes = ["Course Lesson", "LMS Quiz", "LMS Assignment"]; + return { + filters: { + name: ["in", doctypes], + }, + }; + }); + + frm.set_query("reference_doctype", "timetable_legends", function () { + let doctypes = ["Course Lesson", "LMS Quiz", "LMS Assignment"]; return { filters: { name: ["in", doctypes], @@ -27,36 +31,41 @@ frappe.ui.form.on("LMS Batch", { }, timetable_template: function (frm) { - if (frm.doc.timetable_template) { - frm.clear_table("timetable"); - frm.refresh_fields(); - - frappe.call({ - method: "frappe.client.get_list", - args: { - doctype: "LMS Batch Timetable", - parent: "LMS Timetable Template", - fields: [ - "reference_doctype", - "reference_docname", - "day", - "start_time", - "end_time", - "duration", - ], - filters: { - parent: frm.doc.timetable_template, - }, - order_by: "idx", - }, - callback: (data) => { - add_timetable_rows(frm, data.message); - }, - }); - } + set_timetable(frm); }, }); +const set_timetable = (frm) => { + if (frm.doc.timetable_template) { + frm.clear_table("timetable"); + frm.refresh_fields(); + + frappe.call({ + method: "frappe.client.get_list", + args: { + doctype: "LMS Batch Timetable", + parent: "LMS Timetable Template", + fields: [ + "reference_doctype", + "reference_docname", + "day", + "start_time", + "end_time", + "duration", + ], + filters: { + parent: frm.doc.timetable_template, + parenttype: "LMS Timetable Template", + }, + order_by: "idx", + }, + callback: (data) => { + add_timetable_rows(frm, data.message); + }, + }); + } +}; + const add_timetable_rows = (frm, timetable) => { timetable.forEach((row) => { let child = frm.add_child("timetable"); @@ -75,5 +84,40 @@ const add_timetable_rows = (frm, timetable) => { child.duration = row.duration; }); frm.refresh_field("timetable"); + + set_legends(frm); +}; + +const set_legends = (frm) => { + if (frm.doc.timetable_template) { + frm.clear_table("timetable_legends"); + frm.refresh_fields(); + frappe.call({ + method: "frappe.client.get_list", + args: { + doctype: "LMS Timetable Legend", + parent: "LMS Timetable Template", + fields: ["reference_doctype", "label", "color"], + filters: { + parent: frm.doc.timetable_template, + parenttype: "LMS Timetable Template", + }, + order_by: "idx", + }, + callback: (data) => { + add_legend_rows(frm, data.message); + }, + }); + } +}; + +const add_legend_rows = (frm, legends) => { + legends.forEach((row) => { + let child = frm.add_child("timetable_legends"); + child.reference_doctype = row.reference_doctype; + child.label = row.label; + child.color = row.color; + }); + frm.refresh_field("timetable_legends"); frm.save(); }; diff --git a/lms/lms/doctype/lms_batch/lms_batch.json b/lms/lms/doctype/lms_batch/lms_batch.json index 4cdb1e0a..f0838a4b 100644 --- a/lms/lms/doctype/lms_batch/lms_batch.json +++ b/lms/lms/doctype/lms_batch/lms_batch.json @@ -35,8 +35,10 @@ "timetable_template", "column_break_anya", "show_live_class", + "allow_future", "section_break_ontp", "timetable", + "timetable_legends", "pricing_tab", "section_break_gsac", "paid_batch", @@ -220,7 +222,7 @@ "default": "0", "fieldname": "show_live_class", "fieldtype": "Check", - "label": "Show Live Class" + "label": "Show live class" }, { "fieldname": "section_break_ontp", @@ -263,11 +265,23 @@ "fieldtype": "Code", "label": "Custom Script (JavaScript)", "options": "Javascript" + }, + { + "fieldname": "timetable_legends", + "fieldtype": "Table", + "label": "Timetable Legends", + "options": "LMS Timetable Legend" + }, + { + "default": "1", + "fieldname": "allow_future", + "fieldtype": "Check", + "label": "Allow accessing future dates" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-09-28 12:18:34.418812", + "modified": "2023-10-12 12:53:37.351989", "modified_by": "Administrator", "module": "LMS", "name": "LMS Batch", diff --git a/lms/lms/doctype/lms_course/lms_course.py b/lms/lms/doctype/lms_course/lms_course.py index 74ccc51a..9065b639 100644 --- a/lms/lms/doctype/lms_course/lms_course.py +++ b/lms/lms/doctype/lms_course/lms_course.py @@ -216,7 +216,7 @@ def save_course( course_price=None, currency=None, ): - if not can_create_courses(): + if not can_create_courses(course): return if course: diff --git a/lms/lms/doctype/lms_payment/lms_payment.json b/lms/lms/doctype/lms_payment/lms_payment.json index 696d5af9..c0019aef 100644 --- a/lms/lms/doctype/lms_payment/lms_payment.json +++ b/lms/lms/doctype/lms_payment/lms_payment.json @@ -8,8 +8,10 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "payment_for_document_type", "member", "column_break_rqkd", + "payment_for_document", "billing_name", "payment_received", "payment_details_section", @@ -115,11 +117,23 @@ "fieldname": "amount_with_gst", "fieldtype": "Currency", "label": "Amount with GST" + }, + { + "fieldname": "payment_for_document_type", + "fieldtype": "Select", + "label": "Payment for Document Type", + "options": "\nLMS Course\nLMS Batch" + }, + { + "fieldname": "payment_for_document", + "fieldtype": "Dynamic Link", + "label": "Payment for Document", + "options": "payment_for_document_type" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-09-12 10:40:22.721371", + "modified": "2023-10-17 23:17:50.334975", "modified_by": "Administrator", "module": "LMS", "name": "LMS Payment", diff --git a/lms/lms/doctype/lms_question/__init__.py b/lms/lms/doctype/lms_question/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lms/lms/doctype/lms_question/lms_question.js b/lms/lms/doctype/lms_question/lms_question.js new file mode 100644 index 00000000..74a28732 --- /dev/null +++ b/lms/lms/doctype/lms_question/lms_question.js @@ -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) { + +// }, +// }); diff --git a/lms/lms/doctype/lms_question/lms_question.json b/lms/lms/doctype/lms_question/lms_question.json new file mode 100644 index 00000000..90f820c7 --- /dev/null +++ b/lms/lms/doctype/lms_question/lms_question.json @@ -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" +} \ No newline at end of file diff --git a/lms/lms/doctype/lms_question/lms_question.py b/lms/lms/doctype/lms_question/lms_question.py new file mode 100644 index 00000000..0e6f87bd --- /dev/null +++ b/lms/lms/doctype/lms_question/lms_question.py @@ -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) diff --git a/lms/lms/doctype/lms_question/test_lms_question.py b/lms/lms/doctype/lms_question/test_lms_question.py new file mode 100644 index 00000000..0832daa9 --- /dev/null +++ b/lms/lms/doctype/lms_question/test_lms_question.py @@ -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 diff --git a/lms/lms/doctype/lms_quiz/lms_quiz.js b/lms/lms/doctype/lms_quiz/lms_quiz.js index 095938be..0f6f4729 100644 --- a/lms/lms/doctype/lms_quiz/lms_quiz.js +++ b/lms/lms/doctype/lms_quiz/lms_quiz.js @@ -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; + }, +}); diff --git a/lms/lms/doctype/lms_quiz/lms_quiz.json b/lms/lms/doctype/lms_quiz/lms_quiz.json index dbbea28b..04f16894 100644 --- a/lms/lms/doctype/lms_quiz/lms_quiz.json +++ b/lms/lms/doctype/lms_quiz/lms_quiz.json @@ -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, diff --git a/lms/lms/doctype/lms_quiz/lms_quiz.py b/lms/lms/doctype/lms_quiz/lms_quiz.py index 9cb5789c..13075e6f 100644 --- a/lms/lms/doctype/lms_quiz/lms_quiz.py +++ b/lms/lms/doctype/lms_quiz/lms_quiz.py @@ -5,18 +5,32 @@ import json import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cstr -from lms.lms.utils import generate_slug, has_course_moderator_role, can_create_courses +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, + has_course_instructor_role, +) 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 @@ -35,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() @@ -114,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, } ) @@ -140,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 can_create_courses(): + 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, @@ -160,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( @@ -218,9 +229,8 @@ def save_question(quiz, values, index): } ) - doc.save(ignore_permissions=True) - - return quiz + doc.save() + return doc.name @frappe.whitelist() @@ -253,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: @@ -271,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(): diff --git a/lms/lms/doctype/lms_quiz/test_lms_quiz.py b/lms/lms/doctype/lms_quiz/test_lms_quiz.py index ee79b89a..ca6a998c 100644 --- a/lms/lms/doctype/lms_quiz/test_lms_quiz.py +++ b/lms/lms/doctype/lms_quiz/test_lms_quiz.py @@ -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") 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 8e815ee3..e14a8d48 100644 --- a/lms/lms/doctype/lms_quiz_question/lms_quiz_question.json +++ b/lms/lms/doctype/lms_quiz_question/lms_quiz_question.json @@ -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", diff --git a/lms/lms/doctype/lms_quiz_result/lms_quiz_result.json b/lms/lms/doctype/lms_quiz_result/lms_quiz_result.json index 93487a38..7c8fcfac 100644 --- a/lms/lms/doctype/lms_quiz_result/lms_quiz_result.json +++ b/lms/lms/doctype/lms_quiz_result/lms_quiz_result.json @@ -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", diff --git a/lms/lms/doctype/lms_quiz_submission/lms_quiz_submission.json b/lms/lms/doctype/lms_quiz_submission/lms_quiz_submission.json index 80ca9ffd..735d87a0 100644 --- a/lms/lms/doctype/lms_quiz_submission/lms_quiz_submission.json +++ b/lms/lms/doctype/lms_quiz_submission/lms_quiz_submission.json @@ -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", diff --git a/lms/lms/doctype/lms_quiz_submission/lms_quiz_submission.py b/lms/lms/doctype/lms_quiz_submission/lms_quiz_submission.py index d8eeba65..e481d57e 100644 --- a/lms/lms/doctype/lms_quiz_submission/lms_quiz_submission.py +++ b/lms/lms/doctype/lms_quiz_submission/lms_quiz_submission.py @@ -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 diff --git a/lms/lms/doctype/lms_timetable_legend/__init__.py b/lms/lms/doctype/lms_timetable_legend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lms/lms/doctype/lms_timetable_legend/lms_timetable_legend.js b/lms/lms/doctype/lms_timetable_legend/lms_timetable_legend.js new file mode 100644 index 00000000..7a8a3684 --- /dev/null +++ b/lms/lms/doctype/lms_timetable_legend/lms_timetable_legend.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("LMS Timetable Legend", { +// refresh(frm) { + +// }, +// }); diff --git a/lms/lms/doctype/lms_timetable_legend/lms_timetable_legend.json b/lms/lms/doctype/lms_timetable_legend/lms_timetable_legend.json new file mode 100644 index 00000000..6ae0ffe4 --- /dev/null +++ b/lms/lms/doctype/lms_timetable_legend/lms_timetable_legend.json @@ -0,0 +1,52 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "hash", + "creation": "2023-10-11 16:36:45.079267", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "reference_doctype", + "label", + "color" + ], + "fields": [ + { + "fieldname": "reference_doctype", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Reference DocType", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "color", + "fieldtype": "Color", + "in_list_view": 1, + "label": "Color", + "reqd": 1 + }, + { + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2023-10-11 17:15:37.039139", + "modified_by": "Administrator", + "module": "LMS", + "name": "LMS Timetable Legend", + "naming_rule": "Random", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/lms/lms/doctype/lms_timetable_legend/lms_timetable_legend.py b/lms/lms/doctype/lms_timetable_legend/lms_timetable_legend.py new file mode 100644 index 00000000..c842e3a2 --- /dev/null +++ b/lms/lms/doctype/lms_timetable_legend/lms_timetable_legend.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class LMSTimetableLegend(Document): + pass diff --git a/lms/lms/doctype/lms_timetable_legend/test_lms_timetable_legend.py b/lms/lms/doctype/lms_timetable_legend/test_lms_timetable_legend.py new file mode 100644 index 00000000..816b1793 --- /dev/null +++ b/lms/lms/doctype/lms_timetable_legend/test_lms_timetable_legend.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestLMSTimetableLegend(FrappeTestCase): + pass diff --git a/lms/lms/doctype/lms_timetable_template/lms_timetable_template.js b/lms/lms/doctype/lms_timetable_template/lms_timetable_template.js index a9bff04e..b5ec2d9b 100644 --- a/lms/lms/doctype/lms_timetable_template/lms_timetable_template.js +++ b/lms/lms/doctype/lms_timetable_template/lms_timetable_template.js @@ -11,5 +11,19 @@ frappe.ui.form.on("LMS Timetable Template", { }, }; }); + + frm.set_query("reference_doctype", "timetable_legends", function () { + let doctypes = [ + "Course Lesson", + "LMS Quiz", + "LMS Assignment", + "LMS Live Class", + ]; + return { + filters: { + name: ["in", doctypes], + }, + }; + }); }, }); diff --git a/lms/lms/doctype/lms_timetable_template/lms_timetable_template.json b/lms/lms/doctype/lms_timetable_template/lms_timetable_template.json index 99c88628..3b016b7a 100644 --- a/lms/lms/doctype/lms_timetable_template/lms_timetable_template.json +++ b/lms/lms/doctype/lms_timetable_template/lms_timetable_template.json @@ -8,7 +8,8 @@ "engine": "InnoDB", "field_order": [ "title", - "timetable" + "timetable", + "timetable_legends" ], "fields": [ { @@ -21,11 +22,17 @@ "fieldtype": "Table", "label": "Timetable", "options": "LMS Batch Timetable" + }, + { + "fieldname": "timetable_legends", + "fieldtype": "Table", + "label": "Timetable Legends", + "options": "LMS Timetable Legend" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-09-18 17:57:15.819072", + "modified": "2023-10-11 17:09:05.096243", "modified_by": "Administrator", "module": "LMS", "name": "LMS Timetable Template", diff --git a/lms/lms/utils.py b/lms/lms/utils.py index b3d4e1b9..b41af0dd 100644 --- a/lms/lms/utils.py +++ b/lms/lms/utils.py @@ -4,7 +4,6 @@ import frappe import json import razorpay import requests -import base64 from frappe import _ from frappe.desk.doctype.dashboard_chart.dashboard_chart import get_result from frappe.desk.doctype.notification_log.notification_log import make_notification_logs @@ -521,21 +520,35 @@ def has_course_instructor_role(member=None): ) -def can_create_courses(member=None): +def can_create_courses(course, member=None): if not member: member = frappe.session.user + instructors = frappe.get_all( + "Course Instructor", + { + "parent": course, + }, + pluck="instructor", + ) + if frappe.session.user == "Guest": return False - if has_course_instructor_role(member) or has_course_moderator_role(member): + if has_course_moderator_role(member): + return True + + if has_course_instructor_role(member) and member in instructors: return True portal_course_creation = frappe.db.get_single_value( "LMS Settings", "portal_course_creation" ) - return portal_course_creation == "Anyone" + if portal_course_creation == "Anyone" and member in instructors: + return True + + return False def has_course_moderator_role(member=None): @@ -727,7 +740,7 @@ def get_chart_data(chart_name, timespan, timegrain, from_date, to_date): } -@frappe.whitelist() +@frappe.whitelist(allow_guest=True) def get_course_completion_data(): all_membership = frappe.db.count("LMS Enrollment") completed = frappe.db.count("LMS Enrollment", {"progress": ["like", "%100%"]}) @@ -1015,6 +1028,8 @@ def record_payment(address, response, client, doctype, docname): "amount_with_gst": payment_details["amount_with_gst"], "gstin": address.gstin, "pan": address.pan, + "payment_for_document_type": doctype, + "payment_for_document": docname, } ) payment_doc.save(ignore_permissions=True) diff --git a/lms/patches.txt b/lms/patches.txt index 86e4a840..5a28133a 100644 --- a/lms/patches.txt +++ b/lms/patches.txt @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/lms/patches/v1_0/add_default_marks.py b/lms/patches/v1_0/add_default_marks.py new file mode 100644 index 00000000..5560af5d --- /dev/null +++ b/lms/patches/v1_0/add_default_marks.py @@ -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} + ) diff --git a/lms/patches/v1_0/create_quiz_questions.py b/lms/patches/v1_0/create_quiz_questions.py new file mode 100644 index 00000000..12779cbd --- /dev/null +++ b/lms/patches/v1_0/create_quiz_questions.py @@ -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) diff --git a/lms/patches/v1_0/mark_confirmation_for_batch_students.py b/lms/patches/v1_0/mark_confirmation_for_batch_students.py new file mode 100644 index 00000000..f73e0a90 --- /dev/null +++ b/lms/patches/v1_0/mark_confirmation_for_batch_students.py @@ -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) diff --git a/lms/plugins.py b/lms/plugins.py index b0f40cd2..e3241244 100644 --- a/lms/plugins.py +++ b/lms/plugins.py @@ -109,7 +109,39 @@ def quiz_renderer(quiz_name): ) +"" - 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} ) diff --git a/lms/public/css/style.css b/lms/public/css/style.css index c62f3de4..acc05bee 100644 --- a/lms/public/css/style.css +++ b/lms/public/css/style.css @@ -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; } \ No newline at end of file diff --git a/lms/templates/quiz/quiz.html b/lms/templates/quiz/quiz.html index 41e9cf7f..84caaf50 100644 --- a/lms/templates/quiz/quiz.html +++ b/lms/templates/quiz/quiz.html @@ -6,6 +6,15 @@ {{ _("This quiz consists of {0} questions.").format(quiz.questions | length) }} + {% if quiz.passing_percentage %} +
  • + {{ _("You will have to get {0}% correct answers in order to pass the quiz.").format(quiz.passing_percentage) }} +
  • +
  • + {{ _("Without passing the quiz you won't be able to complete the lesson.") }} +
  • + {% endif %} + {% if quiz.max_attempts %} {% set suffix = "times" if quiz.max_attempts > 1 else "time" %}
  • @@ -18,8 +27,7 @@ {{ _("The quiz has a time limit. For each question you will be given {0} seconds.").format(quiz.time) }}
  • {% endif %} - - +
    @@ -50,8 +58,12 @@
    +
    + {{ question.marks }} {{ _("Marks") }} +
    - {{ _("Question ") }}{{ loop.index }}: {{ instruction }}
    + {{ _("Question ") }}{{ loop.index }}: {{ instruction }} +
    {{ question.question }}
    diff --git a/lms/templates/quiz/quiz.js b/lms/templates/quiz/quiz.js index 1de0b05a..ed37a030 100644 --- a/lms/templates/quiz/quiz.js +++ b/lms/templates/quiz/quiz.js @@ -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( `
    ${__("Your score is")} ${data.message.score} - ${__("out of")} ${total_questions} + ${__("out of")} ${data.message.score_out_of}
    ` ); $("#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(); } }, diff --git a/lms/www/assignment_submission/assignment_submission.py b/lms/www/assignment_submission/assignment_submission.py index 93ecb2f7..d1631a88 100644 --- a/lms/www/assignment_submission/assignment_submission.py +++ b/lms/www/assignment_submission/assignment_submission.py @@ -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"] diff --git a/lms/www/assignments/assignment.py b/lms/www/assignments/assignment.py index 9b1c299d..ec8cc543 100644 --- a/lms/www/assignments/assignment.py +++ b/lms/www/assignments/assignment.py @@ -1,12 +1,12 @@ import frappe from frappe import _ -from lms.lms.utils import can_create_courses +from lms.lms.utils import has_course_moderator_role, has_course_instructor_role def get_context(context): context.no_cache = 1 - if not can_create_courses(): + if not has_course_moderator_role() or not has_course_instructor_role(): message = "You do not have permission to access this page." if frappe.session.user == "Guest": message = "Please login to access this page." diff --git a/lms/www/batch/edit.html b/lms/www/batch/edit.html index 60b886fa..312e060f 100644 --- a/lms/www/batch/edit.html +++ b/lms/www/batch/edit.html @@ -127,7 +127,7 @@ {%- block script %} {{ super() }} - + {% endblock %} diff --git a/lms/www/batch/edit.js b/lms/www/batch/edit.js index 65318ccc..13010356 100644 --- a/lms/www/batch/edit.js +++ b/lms/www/batch/edit.js @@ -429,9 +429,9 @@ class Quiz { } render_quiz(quiz) { - return ``; + `; } validate(savedData) { diff --git a/lms/www/batch/quiz.html b/lms/www/batch/quiz.html index ad302f78..953e3496 100644 --- a/lms/www/batch/quiz.html +++ b/lms/www/batch/quiz.html @@ -16,25 +16,9 @@ {% macro QuizForm(quiz) %}
    {{ QuizDetails(quiz) }} - {% if quiz.questions %} -
    -
    - {{ _("Questions") }} -
    -
    - {% for question in quiz.questions %} - {{ Question(question, loop.index) }} - {% endfor %} -
    - -
    - {% endif %} - - {% if quiz.name and not quiz.questions | length %} - {{ EmptyState() }} - {% endif %} +
    +
    +
    {% endmacro %} @@ -59,11 +43,6 @@
    - {% if quiz.name %} - - {% endif %} @@ -98,18 +77,30 @@ {{ _("Enter the maximum number of times a user can attempt this quiz") }}
    - {% set max_attempts = quiz.max_attempts if quiz.name else 1 %} + {% set max_attempts = quiz.max_attempts if quiz.name else 0 %}
    +
    +
    + {{ _("Passing Percentage") }} +
    +
    + {{ _("Minimum percentage required to pass this quiz.") }} +
    +
    + +
    +
    +
    {% set show_answers = quiz.show_answers or not quiz.name %} -
    @@ -574,6 +574,8 @@ diff --git a/lms/www/batches/batch.js b/lms/www/batches/batch.js index f2f43f11..5a1407ef 100644 --- a/lms/www/batches/batch.js +++ b/lms/www/batches/batch.js @@ -653,7 +653,7 @@ const setup_calendar = (events) => { const options = get_calendar_options(element, calendar_id); const calendar = new Calendar(container, options); this.calendar_ = calendar; - console.log(options); + create_events(calendar, events); add_links_to_events(calendar, events); scroll_to_date(calendar, events); @@ -698,8 +698,8 @@ const get_calendar_options = (element, calendar_id) => { const create_events = (calendar, events, calendar_id) => { let calendar_events = []; - events.forEach((event, idx) => { + let clr = get_background_color(event.reference_doctype); calendar_events.push({ id: `event${idx}`, calendarId: calendar_id, @@ -707,7 +707,7 @@ const create_events = (calendar, events, calendar_id) => { start: `${event.date}T${event.start_time}`, end: `${event.date}T${event.end_time}`, isAllday: event.start_time ? false : true, - borderColor: get_background_color(event.reference_doctype), + borderColor: clr, backgroundColor: "var(--fg-color)", customStyle: { borderRadius: "var(--border-radius-md)", @@ -724,10 +724,15 @@ const create_events = (calendar, events, calendar_id) => { calendar.createEvents(calendar_events); }; -const add_links_to_events = (calendar, events) => { +const add_links_to_events = (calendar) => { calendar.on("clickEvent", ({ event }) => { - const el = document.getElementById("clicked-event"); - window.open(event.raw.url, "_blank"); + let event_date = event.start.d.d; + event_date = moment(event_date).format("YYYY-MM-DD"); + + let current_date = moment().format("YYYY-MM-DD"); + if (allow_future || moment(event_date).isSameOrBefore(current_date)) { + window.open(event.raw.url, "_blank"); + } }); }; @@ -764,10 +769,10 @@ const set_calendar_range = (calendar, events) => { }; const get_background_color = (doctype) => { - if (doctype == "Course Lesson") return "var(--blue-400)"; - if (doctype == "LMS Quiz") return "var(--green-400)"; - if (doctype == "LMS Assignment") return "var(--orange-400)"; - if (doctype == "LMS Live Class") return "var(--purple-400)"; + const match = legends.filter((legend) => { + return legend.reference_doctype == doctype; + }); + if (match.length) return match[0].color; }; const email_to_students = () => { @@ -780,6 +785,12 @@ const email_to_students = () => { label: __("Subject"), reqd: 1, }, + { + fieldtype: "Data", + fieldname: "reply_to", + label: __("Reply To"), + reqd: 0, + }, { fieldtype: "Text Editor", fieldname: "message", diff --git a/lms/www/batches/batch.py b/lms/www/batches/batch.py index 5e408c84..bb3ad307 100644 --- a/lms/www/batches/batch.py +++ b/lms/www/batches/batch.py @@ -42,6 +42,7 @@ def get_context(context): "currency", "batch_details", "published", + "allow_future", ], as_dict=True, ) @@ -96,7 +97,7 @@ def get_context(context): "parent": batch_name, }, ) - context.legends = get_legends() + context.legends = get_legends(batch_name) custom_tabs = frappe.get_hooks("lms_batch_tabs") @@ -261,22 +262,9 @@ def get_course_progress(batch_courses, student_details): student_details.courses[course.course] = 0 -def get_legends(): - return [ - { - "title": "Lesson", - "color": "var(--blue-400)", - }, - { - "title": "Quiz", - "color": "var(--green-400)", - }, - { - "title": "Assignment", - "color": "var(--orange-400)", - }, - { - "title": "Live Class", - "color": "var(--purple-400)", - }, - ] +def get_legends(batch): + return frappe.get_all( + "LMS Timetable Legend", + filters={"parenttype": "LMS Batch", "parent": batch}, + fields=["reference_doctype", "color", "label"], + ) diff --git a/lms/www/courses/course.py b/lms/www/courses/course.py index b4aa476c..b64395eb 100644 --- a/lms/www/courses/course.py +++ b/lms/www/courses/course.py @@ -23,7 +23,7 @@ def get_context(context): redirect_to_courses_list() if course_name == "new-course": - if not can_create_courses(): + if not can_create_courses(course_name): message = "You do not have permission to access this page." if frappe.session.user == "Guest": message = "Please login to access this page." diff --git a/lms/www/courses/create.py b/lms/www/courses/create.py index 901ce1f2..7b83f3f5 100644 --- a/lms/www/courses/create.py +++ b/lms/www/courses/create.py @@ -15,7 +15,7 @@ def get_context(context): except KeyError: redirect_to_courses_list() - if not can_create_courses(): + if not can_create_courses(course_name): message = "You do not have permission to access this page." if frappe.session.user == "Guest": message = "Please login to access this page." diff --git a/lms/www/courses/index.py b/lms/www/courses/index.py index d887e1ca..7df2a960 100644 --- a/lms/www/courses/index.py +++ b/lms/www/courses/index.py @@ -1,13 +1,13 @@ import frappe from frappe import _ from lms.lms.utils import ( - can_create_courses, check_profile_restriction, get_restriction_details, has_course_moderator_role, get_courses_under_review, get_average_rating, check_multicurrency, + has_course_instructor_role, ) from lms.overrides.user import get_enrolled_courses, get_authored_courses @@ -21,7 +21,17 @@ def get_context(context): context.created_courses = get_authored_courses(None, False) context.review_courses = get_courses_under_review() context.restriction = check_profile_restriction() - context.show_creators_section = can_create_courses() + + portal_course_creation = frappe.db.get_single_value( + "LMS Settings", "portal_course_creation" + ) + context.show_creators_section = ( + True + if portal_course_creation == "Anyone" + or has_course_moderator_role() + or has_course_instructor_role() + else False + ) context.show_review_section = ( has_course_moderator_role() and frappe.session.user != "Guest" ) diff --git a/lms/www/courses/outline.py b/lms/www/courses/outline.py index ad8f18d9..f01a71af 100644 --- a/lms/www/courses/outline.py +++ b/lms/www/courses/outline.py @@ -10,7 +10,7 @@ def get_context(context): if not frappe.db.exists("LMS Course", course_name): redirect_to_courses_list() - if not can_create_courses(): + if not can_create_courses(course_name): message = "You do not have permission to access this page." if frappe.session.user == "Guest": message = "Please login to access this page."