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/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..6784170b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Security Policy + +The Frappe team and community take security issues seriously. To report a security issue, please go through the information mentioned [here](https://frappe.io/security). + +We appreciate your efforts to responsibly disclose your findings. We'll endeavor to respond quickly and will keep you updated throughout the process. diff --git a/cypress.config.js b/cypress.config.js index ffbf5130..04ebc494 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -13,6 +13,6 @@ module.exports = defineConfig({ openMode: 0, }, e2e: { - baseUrl: "http://dd1:8000", + baseUrl: "http://pyp:8000", }, }); diff --git a/cypress/e2e/course_creation.cy.js b/cypress/e2e/course_creation.cy.js index 48f3c5dd..2be9a71c 100644 --- a/cypress/e2e/course_creation.cy.js +++ b/cypress/e2e/course_creation.cy.js @@ -33,19 +33,17 @@ describe("Course Creation", () => { cy.get("#lesson-title").type("Test Lesson"); // Content - cy.get(".ce-block").click().type("{enter}"); - cy.get(".ce-toolbar__plus").click(); - cy.get('[data-item-name="youtube"]').click(); + cy.get(".collapse-section.collapsed:first").click(); + cy.get("#lesson-content .ce-block") + .click() + .type( + "This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now. {enter}" + ); + cy.get("#lesson-content .ce-toolbar__plus").click(); + cy.get('#lesson-content [data-item-name="youtube"]').click(); cy.get('input[data-fieldname="youtube"]').type("GoDtyItReto"); cy.button("Insert").click(); cy.wait(1000); - - cy.get(".ce-block:last").click().type("{enter}"); - cy.get(".ce-block:last") - .click() - .type( - "This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now." - ); cy.button("Save").click(); // View Course diff --git a/lms/fixtures/custom_field.json b/lms/fixtures/custom_field.json index cdb50dba..1d8f17da 100644 --- a/lms/fixtures/custom_field.json +++ b/lms/fixtures/custom_field.json @@ -138,12 +138,12 @@ "label": "User Category", "length": 0, "mandatory_depends_on": null, - "modified": "2022-04-19 13:02:18.219508", + "modified": "2022-04-19 13:02:18.219510", "module": "LMS", "name": "User-user_category", "no_copy": 0, "non_negative": 0, - "options": "Business Owner\nManager (Sales/Marketing/Customer)\nEmployee\nStudent\nFreelancer/Just looking\nOthers", + "options": "\nBusiness Owner\nManager (Sales/Marketing/Customer)\nEmployee\nStudent\nFreelancer/Just looking\nOthers", "permlevel": 0, "precision": "", "print_hide": 0, diff --git a/lms/hooks.py b/lms/hooks.py index e0b1461c..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 @@ -304,6 +303,9 @@ lms_markdown_macro_renderers = { "YouTubeVideo": "lms.plugins.youtube_video_renderer", "Video": "lms.plugins.video_renderer", "Assignment": "lms.plugins.assignment_renderer", + "Embed": "lms.plugins.embed_renderer", + "Audio": "lms.plugins.audio_renderer", + "PDF": "lms.plugins.pdf_renderer", } # page_renderer to manage profile pages diff --git a/lms/install.py b/lms/install.py index 5856b839..cd74c28d 100644 --- a/lms/install.py +++ b/lms/install.py @@ -4,11 +4,11 @@ from frappe.desk.page.setup_wizard.setup_wizard import add_all_roles_to def after_install(): add_pages_to_nav() + create_batch_source() def after_sync(): create_lms_roles() - set_default_home() set_default_certificate_print_format() add_all_roles_to("Administrator") @@ -54,6 +54,7 @@ def create_lms_roles(): create_course_creator_role() create_moderator_role() create_evaluator_role() + create_lms_student_role() def delete_lms_roles(): @@ -63,10 +64,6 @@ def delete_lms_roles(): frappe.db.delete("Role", role) -def set_default_home(): - frappe.db.set_single_value("Portal Settings", "default_portal_home", "/courses") - - def create_course_creator_role(): if not frappe.db.exists("Role", "Course Creator"): role = frappe.get_doc( @@ -106,6 +103,19 @@ def create_evaluator_role(): role.save() +def create_lms_student_role(): + if not frappe.db.exists("Role", "LMS Student"): + role = frappe.new_doc("Role") + role.update( + { + "role_name": "LMS Student", + "home_page": "", + "desk_access": 0, + } + ) + role.save() + + def set_default_certificate_print_format(): filters = { "doc_type": "LMS Certificate", @@ -168,3 +178,20 @@ def delete_custom_fields(): for field in fields: frappe.db.delete("Custom Field", {"fieldname": field}) + + +def create_batch_source(): + sources = [ + "Newsletter", + "LinkedIn", + "Twitter", + "Website", + "Friend/Colleague/Connection", + "Google Search", + ] + + for source in sources: + if not frappe.db.exists("LMS Source", source): + doc = frappe.new_doc("LMS Source") + doc.source = source + doc.save() diff --git a/lms/job/doctype/job_opportunity/job_opportunity.json b/lms/job/doctype/job_opportunity/job_opportunity.json index 7ac25e65..15bf16b3 100644 --- a/lms/job/doctype/job_opportunity/job_opportunity.json +++ b/lms/job/doctype/job_opportunity/job_opportunity.json @@ -116,7 +116,7 @@ "index_web_pages_for_search": 1, "links": [], "make_attachments_public": 1, - "modified": "2022-09-15 17:22:21.662675", + "modified": "2023-09-29 17:03:30.825021", "modified_by": "Administrator", "module": "Job", "name": "Job Opportunity", @@ -144,7 +144,7 @@ "print": 1, "read": 1, "report": 1, - "role": "All", + "role": "LMS Student", "select": 1, "share": 1, "write": 1 diff --git a/lms/lms/api.py b/lms/lms/api.py index 480809bc..1e68205c 100644 --- a/lms/lms/api.py +++ b/lms/lms/api.py @@ -40,7 +40,7 @@ def save_current_lesson(course_name, lesson_name): return doc = frappe.get_doc("LMS Enrollment", name) doc.current_lesson = lesson_name - doc.save(ignore_permissions=True) + doc.save() return {"current_lesson": doc.current_lesson} @@ -66,7 +66,7 @@ def join_cohort(course, cohort, subgroup, invite_code): return {"ok": True, "status": "record found"} else: doc = frappe.get_doc(data) - doc.insert(ignore_permissions=True) + doc.insert() return {"ok": True, "status": "record created"} @@ -82,7 +82,7 @@ def approve_cohort_join_request(join_request): return {"ok": False, "error": "Permission Deined"} r.status = "Accepted" - r.save(ignore_permissions=True) + r.save() return {"ok": True} @@ -98,7 +98,7 @@ def reject_cohort_join_request(join_request): return {"ok": False, "error": "Permission Deined"} r.status = "Rejected" - r.save(ignore_permissions=True) + r.save() return {"ok": True} @@ -115,7 +115,7 @@ def undo_reject_cohort_join_request(join_request): return {"ok": False, "error": "Permission Deined"} r.status = "Pending" - r.save(ignore_permissions=True) + r.save() return {"ok": True} diff --git a/lms/lms/doctype/batch_student/batch_student.json b/lms/lms/doctype/batch_student/batch_student.json index b2a8816a..2fa00921 100644 --- a/lms/lms/doctype/batch_student/batch_student.json +++ b/lms/lms/doctype/batch_student/batch_student.json @@ -9,10 +9,12 @@ "field_order": [ "student_details_section", "student", - "payment", - "column_break_oduu", "student_name", - "username" + "username", + "column_break_oduu", + "payment", + "source", + "confirmation_email_sent" ], "fields": [ { @@ -52,12 +54,24 @@ "fieldtype": "Link", "label": "Payment", "options": "LMS Payment" + }, + { + "default": "0", + "fieldname": "confirmation_email_sent", + "fieldtype": "Check", + "label": "Confirmation Email Sent" + }, + { + "fieldname": "source", + "fieldtype": "Link", + "label": "Source", + "options": "LMS Source" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-08-24 17:48:53.045539", + "modified": "2023-10-26 16:52:04.266693", "modified_by": "Administrator", "module": "LMS", "name": "Batch Student", diff --git a/lms/lms/doctype/cohort_join_request/cohort_join_request.json b/lms/lms/doctype/cohort_join_request/cohort_join_request.json index 8ecd4be3..587d988c 100644 --- a/lms/lms/doctype/cohort_join_request/cohort_join_request.json +++ b/lms/lms/doctype/cohort_join_request/cohort_join_request.json @@ -51,7 +51,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-12-16 15:06:03.985221", + "modified": "2023-09-29 17:08:18.950560", "modified_by": "Administrator", "module": "LMS", "name": "Cohort Join Request", @@ -68,9 +68,21 @@ "role": "System Manager", "share": 1, "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "LMS Student", + "share": 1, + "write": 1 } ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/lms/lms/doctype/course_chapter/course_chapter.json b/lms/lms/doctype/course_chapter/course_chapter.json index ee8544bf..ba73237f 100644 --- a/lms/lms/doctype/course_chapter/course_chapter.json +++ b/lms/lms/doctype/course_chapter/course_chapter.json @@ -59,7 +59,7 @@ "link_fieldname": "chapter" } ], - "modified": "2022-03-14 17:57:00.707416", + "modified": "2023-09-29 17:03:58.013819", "modified_by": "Administrator", "module": "LMS", "name": "Course Chapter", @@ -86,7 +86,7 @@ "print": 1, "read": 1, "report": 1, - "role": "All", + "role": "LMS Student", "select": 1, "share": 1, "write": 1 diff --git a/lms/lms/doctype/course_lesson/course_lesson.json b/lms/lms/doctype/course_lesson/course_lesson.json index eaee75f2..d5879b00 100644 --- a/lms/lms/doctype/course_lesson/course_lesson.json +++ b/lms/lms/doctype/course_lesson/course_lesson.json @@ -24,6 +24,7 @@ "file_type", "section_break_11", "body", + "instructor_notes", "help_section", "help" ], @@ -131,11 +132,16 @@ { "fieldname": "column_break_15", "fieldtype": "Column Break" + }, + { + "fieldname": "instructor_notes", + "fieldtype": "Markdown Editor", + "label": "Instructor Notes" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-05-02 12:42:16.926753", + "modified": "2023-09-29 17:04:19.252897", "modified_by": "Administrator", "module": "LMS", "name": "Course Lesson", @@ -163,7 +169,7 @@ "print": 1, "read": 1, "report": 1, - "role": "All", + "role": "LMS Student", "select": 1, "share": 1, "write": 1 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/function/function.json b/lms/lms/doctype/function/function.json index 918ceb3e..40bf752e 100644 --- a/lms/lms/doctype/function/function.json +++ b/lms/lms/doctype/function/function.json @@ -19,7 +19,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-12-21 09:34:35.018280", + "modified": "2023-09-29 17:04:58.167481", "modified_by": "Administrator", "module": "LMS", "name": "Function", @@ -44,11 +44,12 @@ "print": 1, "read": 1, "report": 1, - "role": "All", + "role": "LMS Student", "select": 1, "share": 1 } ], "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/lms/lms/doctype/industry/industry.json b/lms/lms/doctype/industry/industry.json index 2ab23031..5bb770cb 100644 --- a/lms/lms/doctype/industry/industry.json +++ b/lms/lms/doctype/industry/industry.json @@ -19,7 +19,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-12-21 09:35:20.443192", + "modified": "2023-09-29 17:05:27.231982", "modified_by": "Administrator", "module": "LMS", "name": "Industry", @@ -44,11 +44,12 @@ "print": 1, "read": 1, "report": 1, - "role": "All", + "role": "LMS Student", "select": 1, "share": 1 } ], "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/lms/lms/doctype/invite_request/invite_request.py b/lms/lms/doctype/invite_request/invite_request.py index 7848529c..6a1d335b 100644 --- a/lms/lms/doctype/invite_request/invite_request.py +++ b/lms/lms/doctype/invite_request/invite_request.py @@ -11,7 +11,11 @@ from frappe.utils.password import get_decrypted_password class InviteRequest(Document): def on_update(self): - if self.has_value_changed("status") and self.status == "Approved": + if ( + self.has_value_changed("status") + and self.status == "Approved" + and not frappe.flags.in_test + ): self.send_email() def create_user(self, password): diff --git a/lms/lms/doctype/lms_assignment/lms_assignment.json b/lms/lms/doctype/lms_assignment/lms_assignment.json index 6cb778a1..654989d5 100644 --- a/lms/lms/doctype/lms_assignment/lms_assignment.json +++ b/lms/lms/doctype/lms_assignment/lms_assignment.json @@ -9,10 +9,12 @@ "engine": "InnoDB", "field_order": [ "title", + "grade_assignment", + "question", "column_break_hmwv", "type", - "section_break_lwvt", - "question" + "show_answer", + "answer" ], "fields": [ { @@ -26,7 +28,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Type", - "options": "Document\nPDF\nURL\nImage" + "options": "Document\nPDF\nURL\nImage\nText" }, { "fieldname": "title", @@ -40,13 +42,29 @@ "fieldtype": "Column Break" }, { - "fieldname": "section_break_lwvt", - "fieldtype": "Section Break" + "default": "0", + "depends_on": "eval:doc.type == \"Text\"", + "fieldname": "show_answer", + "fieldtype": "Check", + "label": "Show Answer" + }, + { + "depends_on": "show_answer", + "fieldname": "answer", + "fieldtype": "Text Editor", + "label": "Answer" + }, + { + "default": "1", + "depends_on": "eval:doc.type == \"Text\"", + "fieldname": "grade_assignment", + "fieldtype": "Check", + "label": "Grade Assignment" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-06-26 18:09:29.809564", + "modified": "2023-10-06 12:08:46.898950", "modified_by": "Administrator", "module": "LMS", "name": "LMS Assignment", 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_assignment_submission/lms_assignment_submission.js b/lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.js index d6aec59c..8a999c77 100644 --- a/lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.js +++ b/lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.js @@ -2,6 +2,13 @@ // For license information, please see license.txt frappe.ui.form.on("LMS Assignment Submission", { - // refresh: function(frm) { - // } + onload: function (frm) { + frm.set_query("member", function (doc) { + return { + filters: { + ignore_user_type: 1, + }, + }; + }); + }, }); diff --git a/lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.json b/lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.json index 1d68e33b..86c71c4d 100644 --- a/lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.json +++ b/lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.json @@ -9,29 +9,29 @@ "field_order": [ "assignment", "assignment_title", - "question", + "type", "column_break_3", "member", "member_name", - "type", + "section_break_dlzh", + "question", + "column_break_zvis", "assignment_attachment", "answer", "section_break_rqal", "status", + "evaluator", "column_break_esgd", "comments", "section_break_cwaw", "lesson", "course", - "column_break_ygdu", - "evaluator" + "column_break_ygdu" ], "fields": [ { "fieldname": "lesson", "fieldtype": "Link", - "in_list_view": 1, - "in_standard_filter": 1, "label": "Lesson", "options": "Course Lesson" }, @@ -78,7 +78,7 @@ "fieldname": "status", "fieldtype": "Select", "label": "Status", - "options": "Pass\nFail\nNot Graded" + "options": "Pass\nFail\nNot Graded\nNot Applicable" }, { "fieldname": "comments", @@ -94,7 +94,7 @@ "read_only": 1 }, { - "depends_on": "eval:doc.type != \"URL\";", + "depends_on": "eval:!([\"URL\", \"Text\"]).includes(doc.type);", "fieldname": "assignment_attachment", "fieldtype": "Attach", "label": "Assignment Attachment", @@ -104,8 +104,9 @@ "fetch_from": "assignment.type", "fieldname": "type", "fieldtype": "Select", + "in_list_view": 1, "label": "Type", - "options": "Document\nPDF\nURL\nImage" + "options": "Document\nPDF\nURL\nImage\nText" }, { "fetch_from": "assignment.question", @@ -137,17 +138,25 @@ "fieldtype": "Column Break" }, { - "depends_on": "eval:doc.type == \"URL\";", + "depends_on": "eval:([\"URL\", \"Text\"]).includes(doc.type);", "fieldname": "answer", - "fieldtype": "Long Text", + "fieldtype": "Text Editor", "label": "Answer", "mandatory_depends_on": "eval:doc.type == \"URL\";" + }, + { + "fieldname": "section_break_dlzh", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_zvis", + "fieldtype": "Column Break" } ], "index_web_pages_for_search": 1, "links": [], "make_attachments_public": 1, - "modified": "2023-08-30 12:09:03.332820", + "modified": "2023-10-06 15:14:55.984714", "modified_by": "Administrator", "module": "LMS", "name": "LMS Assignment Submission", @@ -181,6 +190,10 @@ { "color": "Red", "title": "Fail" + }, + { + "color": "Blue", + "title": "Not Applicable" } ], "title_field": "assignment_title" diff --git a/lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.py b/lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.py index 1989f87c..a58f2207 100644 --- a/lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.py +++ b/lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.py @@ -5,12 +5,17 @@ import frappe from frappe import _ from frappe.model.document import Document from frappe.utils import validate_url +from frappe.email.doctype.email_template.email_template import get_email_template class LMSAssignmentSubmission(Document): def validate(self): self.validate_duplicates() + def after_insert(self): + if not frappe.flags.in_test: + self.send_mail() + def validate_duplicates(self): if frappe.db.exists( "LMS Assignment Submission", @@ -23,6 +28,35 @@ class LMSAssignmentSubmission(Document): ) ) + def send_mail(self): + subject = _("New Assignment Submission") + template = "assignment_submission" + custom_template = frappe.db.get_single_value( + "LMS Settings", "assignment_submission_template" + ) + + args = { + "member_name": self.member_name, + "assignment_name": self.assignment, + "assignment_title": self.assignment_title, + "submission_name": self.name, + } + + moderators = frappe.get_all("Has Role", {"role": "Moderator"}, pluck="parent") + + if custom_template: + email_template = get_email_template(custom_template, args) + subject = email_template.get("subject") + content = email_template.get("message") + frappe.sendmail( + recipients=moderators, + subject=subject, + template=template if not custom_template else None, + content=content if custom_template else None, + args=args, + header=[subject, "green"], + ) + @frappe.whitelist() def upload_assignment( @@ -37,9 +71,12 @@ def upload_assignment( if frappe.session.user == "Guest": return - assignment_type = frappe.db.get_value("LMS Assignment", assignment, "type") + assignment_details = frappe.db.get_value( + "LMS Assignment", assignment, ["type", "grade_assignment"], as_dict=1 + ) + assignment_type = assignment_details.type - if assignment_type == "URL" and not answer: + if assignment_type in ["URL", "Text"] and not answer: frappe.throw(_("Please enter the URL for assignment submission.")) if assignment_type == "File" and not assignment_attachment: @@ -64,7 +101,9 @@ def upload_assignment( doc.update( { "assignment_attachment": assignment_attachment, - "status": status, + "status": "Not Applicable" + if assignment_type == "Text" and not assignment_details.grade_assignment + else status, "comments": comments, "answer": answer, } diff --git a/lms/lms/doctype/lms_batch/lms_batch.js b/lms/lms/doctype/lms_batch/lms_batch.js index 45f9d0b5..34f9d503 100644 --- a/lms/lms/doctype/lms_batch/lms_batch.js +++ b/lms/lms/doctype/lms_batch/lms_batch.js @@ -10,25 +10,116 @@ frappe.ui.form.on("LMS Batch", { }, }; }); - }, - fetch_lessons: (frm) => { - frm.clear_table("scheduled_flow"); - frappe.call({ - method: "lms.lms.doctype.lms_batch.lms_batch.fetch_lessons", - args: { - courses: frm.doc.courses, - }, - callback: (r) => { - if (r.message) { - r.message.forEach((lesson) => { - let row = frm.add_child("scheduled_flow"); - row.lesson = lesson.name; - row.lesson_title = lesson.title; - }); - frm.refresh_field("scheduled_flow"); - } - }, + frm.set_query("reference_doctype", "timetable", function () { + 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], + }, + }; }); }, + + timetable_template: function (frm) { + 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", + "milestone", + ], + 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"); + child.reference_doctype = row.reference_doctype; + child.reference_docname = row.reference_docname; + child.date = frappe.datetime.add_days(frm.doc.start_date, row.day - 1); + child.start_time = row.start_time; + child.end_time = row.end_time + ? row.end_time + : row.duration + ? moment + .utc(row.start_time, "HH:mm") + .add(row.duration, "hour") + .format("HH:mm") + : null; + child.duration = row.duration; + child.milestone = row.milestone; + }); + 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 26965356..f0838a4b 100644 --- a/lms/lms/doctype/lms_batch/lms_batch.json +++ b/lms/lms/doctype/lms_batch/lms_batch.json @@ -14,6 +14,7 @@ "column_break_4", "start_time", "end_time", + "published", "section_break_rgfj", "medium", "category", @@ -21,21 +22,34 @@ "seat_count", "section_break_6", "description", + "batch_details_raw", + "column_break_hlqw", "batch_details", + "meta_image", + "section_break_jgji", "students", "courses", + "assessment_tab", + "assessment", + "schedule_tab", + "timetable_template", + "column_break_anya", + "show_live_class", + "allow_future", + "section_break_ontp", + "timetable", + "timetable_legends", + "pricing_tab", "section_break_gsac", "paid_batch", "column_break_iens", "amount", "currency", + "customisations_tab", "section_break_ubxi", "custom_component", - "assessment_tab", - "assessment", - "schedule_tab", - "fetch_lessons", - "scheduled_flow" + "column_break_pxgb", + "custom_script" ], "fields": [ { @@ -86,10 +100,9 @@ "reqd": 1 }, { - "description": "The HTML code entered here will be displayed on the batch details page.", "fieldname": "custom_component", "fieldtype": "Code", - "label": "Custom Component", + "label": "Custom HTML", "options": "HTML" }, { @@ -142,33 +155,23 @@ }, { "fieldname": "category", - "fieldtype": "Autocomplete", - "label": "Category" - }, - { - "fieldname": "scheduled_flow", - "fieldtype": "Table", - "label": "Scheduled Flow", - "options": "Scheduled Flow" + "fieldtype": "Link", + "label": "Category", + "options": "LMS Category" }, { + "description": "These customisations will work on the main batch page.", "fieldname": "section_break_ubxi", "fieldtype": "Section Break" }, - { - "fieldname": "fetch_lessons", - "fieldtype": "Button", - "label": "Fetch Lessons" - }, { "fieldname": "schedule_tab", "fieldtype": "Tab Break", - "label": "Schedule" + "label": "Timetable" }, { "fieldname": "section_break_gsac", - "fieldtype": "Section Break", - "label": "Pricing" + "fieldtype": "Section Break" }, { "fieldname": "column_break_iens", @@ -192,11 +195,93 @@ "fieldtype": "Text Editor", "label": "Batch Details", "reqd": 1 + }, + { + "default": "0", + "fieldname": "published", + "fieldtype": "Check", + "label": "Published" + }, + { + "fieldname": "timetable", + "fieldtype": "Table", + "label": "Timetable", + "options": "LMS Batch Timetable" + }, + { + "fieldname": "timetable_template", + "fieldtype": "Link", + "label": "Timetable Template", + "options": "LMS Timetable Template" + }, + { + "fieldname": "column_break_anya", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "show_live_class", + "fieldtype": "Check", + "label": "Show live class" + }, + { + "fieldname": "section_break_ontp", + "fieldtype": "Section Break" + }, + { + "fieldname": "batch_details_raw", + "fieldtype": "HTML Editor", + "label": "Batch Details Raw" + }, + { + "fieldname": "column_break_hlqw", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_jgji", + "fieldtype": "Section Break" + }, + { + "fieldname": "meta_image", + "fieldtype": "Attach Image", + "label": "Meta Image" + }, + { + "fieldname": "column_break_pxgb", + "fieldtype": "Column Break" + }, + { + "fieldname": "customisations_tab", + "fieldtype": "Tab Break", + "label": "Customisations" + }, + { + "fieldname": "pricing_tab", + "fieldtype": "Tab Break", + "label": "Pricing" + }, + { + "fieldname": "custom_script", + "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-08-23 17:35:42.750754", + "modified": "2023-10-12 12:53:37.351989", "modified_by": "Administrator", "module": "LMS", "name": "LMS Batch", diff --git a/lms/lms/doctype/lms_batch/lms_batch.py b/lms/lms/doctype/lms_batch/lms_batch.py index 5a0b0467..cf371f55 100644 --- a/lms/lms/doctype/lms_batch/lms_batch.py +++ b/lms/lms/doctype/lms_batch/lms_batch.py @@ -6,9 +6,17 @@ import requests import base64 import json from frappe import _ +from datetime import timedelta from frappe.model.document import Document -from frappe.utils import cint, format_date, format_datetime -from lms.lms.utils import get_lessons +from frappe.utils import ( + cint, + format_date, + format_datetime, + get_time, +) +from lms.lms.utils import get_lessons, get_lesson_index, get_lesson_url +from lms.www.utils import get_quiz_details, get_assignment_details +from frappe.email.doctype.email_template.email_template import get_email_template class LMSBatch(Document): @@ -19,7 +27,8 @@ class LMSBatch(Document): self.validate_duplicate_students() self.validate_duplicate_assessments() self.validate_membership() - self.validate_schedule() + self.validate_timetable() + self.send_confirmation_mail() def validate_duplicate_students(self): students = [row.student for row in self.students] @@ -53,6 +62,43 @@ class LMSBatch(Document): ) ) + def send_confirmation_mail(self): + for student in self.students: + + if not student.confirmation_email_sent: + self.send_mail(student) + student.confirmation_email_sent = 1 + + def send_mail(self, student): + subject = _("Enrollment Confirmation for the Next Training Batch") + template = "batch_confirmation" + custom_template = frappe.db.get_single_value( + "LMS Settings", "batch_confirmation_template" + ) + + args = { + "student_name": student.student_name, + "start_time": self.start_time, + "start_date": self.start_date, + "medium": self.medium, + "name": self.name, + } + + if custom_template: + email_template = get_email_template(custom_template, args) + subject = email_template.get("subject") + content = email_template.get("message") + + frappe.sendmail( + recipients=student.student, + subject=subject, + template=template if not custom_template else None, + content=content if custom_template else None, + args=args, + header=[subject, "green"], + retry=3, + ) + def validate_membership(self): for course in self.courses: for student in self.students: @@ -68,26 +114,30 @@ class LMSBatch(Document): if cint(self.seat_count) < len(self.students): frappe.throw(_("There are no seats available in this batch.")) - def validate_schedule(self): - for schedule in self.scheduled_flow: + def validate_timetable(self): + for schedule in self.timetable: if schedule.start_time and schedule.end_time: - if ( - schedule.start_time > schedule.end_time or schedule.start_time == schedule.end_time - ): + if get_time(schedule.start_time) > get_time(schedule.end_time) or get_time( + schedule.start_time + ) == get_time(schedule.end_time): frappe.throw( _("Row #{0} Start time cannot be greater than or equal to end time.").format( schedule.idx ) ) - if schedule.start_time < self.start_time or schedule.start_time > self.end_time: + if get_time(schedule.start_time) < get_time(self.start_time) or get_time( + schedule.start_time + ) > get_time(self.end_time): frappe.throw( _("Row #{0} Start time cannot be outside the batch duration.").format( schedule.idx ) ) - if schedule.end_time < self.start_time or schedule.end_time > self.end_time: + if get_time(schedule.end_time) < get_time(self.start_time) or get_time( + schedule.end_time + ) > get_time(self.end_time): frappe.throw( _("Row #{0} End time cannot be outside the batch duration.").format(schedule.idx) ) @@ -192,6 +242,8 @@ def create_batch( end_date, description=None, batch_details=None, + batch_details_raw=None, + meta_image=None, seat_count=0, start_time=None, end_time=None, @@ -201,6 +253,7 @@ def create_batch( amount=0, currency=None, name=None, + published=0, ): frappe.only_for("Moderator") if name: @@ -215,6 +268,8 @@ def create_batch( "end_date": end_date, "description": description, "batch_details": batch_details, + "batch_details_raw": batch_details_raw, + "image": meta_image, "seat_count": seat_count, "start_time": start_time, "end_time": end_time, @@ -223,6 +278,7 @@ def create_batch( "paid_batch": paid_batch, "amount": amount, "currency": currency, + "published": published, } ) doc.save() @@ -243,6 +299,10 @@ def fetch_lessons(courses): @frappe.whitelist() def add_course(course, parent, name=None, evaluator=None): frappe.only_for("Moderator") + + if frappe.db.exists("Batch Course", {"course": course, "parent": parent}): + frappe.throw(_("Course already added to the batch.")) + if name: doc = frappe.get_doc("Batch Course", name) else: @@ -260,3 +320,119 @@ def add_course(course, parent, name=None, evaluator=None): doc.save() return doc.name + + +@frappe.whitelist() +def get_batch_timetable(batch): + timetable = frappe.get_all( + "LMS Batch Timetable", + filters={"parent": batch}, + fields=[ + "reference_doctype", + "reference_docname", + "date", + "start_time", + "end_time", + "milestone", + "name", + "idx", + "parent", + ], + order_by="date", + ) + + show_live_class = frappe.db.get_value("LMS Batch", batch, "show_live_class") + if show_live_class: + live_classes = get_live_classes(batch) + timetable.extend(live_classes) + + timetable = get_timetable_details(timetable) + return timetable + + +def get_live_classes(batch): + live_classes = frappe.get_all( + "LMS Live Class", + {"batch_name": batch}, + ["name", "title", "date", "time as start_time", "duration", "join_url as url"], + order_by="date", + ) + for class_ in live_classes: + class_.end_time = class_.start_time + timedelta(minutes=class_.duration) + class_.reference_doctype = "LMS Live Class" + class_.reference_docname = class_.name + class_.icon = "icon-call" + + return live_classes + + +def get_timetable_details(timetable): + for entry in timetable: + entry.title = frappe.db.get_value( + entry.reference_doctype, entry.reference_docname, "title" + ) + assessment = frappe._dict({"assessment_name": entry.reference_docname}) + + if entry.reference_doctype == "Course Lesson": + course = frappe.db.get_value( + entry.reference_doctype, entry.reference_docname, "course" + ) + entry.url = get_lesson_url(course, get_lesson_index(entry.reference_docname)) + + entry.completed = ( + True + if frappe.db.exists( + "LMS Course Progress", + {"lesson": entry.reference_docname, "member": frappe.session.user}, + ) + else False + ) + + elif entry.reference_doctype == "LMS Quiz": + entry.url = "/quizzes" + details = get_quiz_details(assessment, frappe.session.user) + entry.update(details) + + elif entry.reference_doctype == "LMS Assignment": + details = get_assignment_details(assessment, frappe.session.user) + entry.update(details) + + timetable = sorted(timetable, key=lambda k: k["date"]) + return timetable + + +@frappe.whitelist() +def is_milestone_complete(idx, batch): + previous_rows = frappe.get_all( + "LMS Batch Timetable", + filters={"parent": batch, "idx": ["<", cint(idx)]}, + fields=["reference_doctype", "reference_docname", "idx"], + order_by="idx", + ) + + for row in previous_rows: + if row.reference_doctype == "Course Lesson": + if not frappe.db.exists( + "LMS Course Progress", + {"member": frappe.session.user, "lesson": row.reference_docname}, + ): + return False + + if row.reference_doctype == "LMS Quiz": + passing_percentage = frappe.db.get_value( + row.reference_doctype, row.reference_docname, "passing_percentage" + ) + if not frappe.db.exists( + "LMS Quiz Submission", + {"quiz": row.reference_docname, "member": frappe.session.user}, + ): + return False + + if row.reference_doctype == "LMS Assignment": + if not frappe.db.exists( + "LMS Assignment Submission", + {"assignment": row.reference_docname, "member": frappe.session.user}, + ): + return False + + return True diff --git a/lms/lms/notification/assignment_submission_notification/__init__.py b/lms/lms/doctype/lms_batch_timetable/__init__.py similarity index 100% rename from lms/lms/notification/assignment_submission_notification/__init__.py rename to lms/lms/doctype/lms_batch_timetable/__init__.py diff --git a/lms/lms/doctype/lms_batch_timetable/lms_batch_timetable.js b/lms/lms/doctype/lms_batch_timetable/lms_batch_timetable.js new file mode 100644 index 00000000..4cecd9c3 --- /dev/null +++ b/lms/lms/doctype/lms_batch_timetable/lms_batch_timetable.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("LMS Batch Timetable", { +// refresh(frm) { + +// }, +// }); diff --git a/lms/lms/doctype/lms_batch_timetable/lms_batch_timetable.json b/lms/lms/doctype/lms_batch_timetable/lms_batch_timetable.json new file mode 100644 index 00000000..cbf51bd8 --- /dev/null +++ b/lms/lms/doctype/lms_batch_timetable/lms_batch_timetable.json @@ -0,0 +1,93 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "hash", + "creation": "2023-09-14 12:44:51.098956", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "column_break_htdc", + "reference_doctype", + "reference_docname", + "date", + "day", + "column_break_merq", + "start_time", + "end_time", + "duration", + "milestone" + ], + "fields": [ + { + "fieldname": "reference_doctype", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Reference DocType", + "options": "DocType" + }, + { + "fieldname": "reference_docname", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Reference DocName", + "options": "reference_doctype" + }, + { + "fieldname": "column_break_merq", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.parenttype == \"LMS Batch\";", + "fieldname": "date", + "fieldtype": "Date", + "label": "Date" + }, + { + "fieldname": "start_time", + "fieldtype": "Time", + "in_list_view": 1, + "label": "Start Time" + }, + { + "fieldname": "duration", + "fieldtype": "Data", + "label": "Duration" + }, + { + "fieldname": "column_break_htdc", + "fieldtype": "Column Break" + }, + { + "fieldname": "end_time", + "fieldtype": "Time", + "in_list_view": 1, + "label": "End Time" + }, + { + "depends_on": "eval: doc.parenttype == \"LMS Timetable Template\";", + "fieldname": "day", + "fieldtype": "Int", + "label": "Day" + }, + { + "fieldname": "milestone", + "fieldtype": "Check", + "label": "Milestone" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2023-10-20 11:58:01.782921", + "modified_by": "Administrator", + "module": "LMS", + "name": "LMS Batch Timetable", + "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_batch_timetable/lms_batch_timetable.py b/lms/lms/doctype/lms_batch_timetable/lms_batch_timetable.py new file mode 100644 index 00000000..257896a6 --- /dev/null +++ b/lms/lms/doctype/lms_batch_timetable/lms_batch_timetable.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 LMSBatchTimetable(Document): + pass diff --git a/lms/lms/doctype/lms_batch_timetable/test_lms_batch_timetable.py b/lms/lms/doctype/lms_batch_timetable/test_lms_batch_timetable.py new file mode 100644 index 00000000..68754a5f --- /dev/null +++ b/lms/lms/doctype/lms_batch_timetable/test_lms_batch_timetable.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestLMSBatchTimetable(FrappeTestCase): + pass diff --git a/lms/lms/doctype/lms_certificate/lms_certificate.js b/lms/lms/doctype/lms_certificate/lms_certificate.js index f68937c9..74cfeea8 100644 --- a/lms/lms/doctype/lms_certificate/lms_certificate.js +++ b/lms/lms/doctype/lms_certificate/lms_certificate.js @@ -10,6 +10,14 @@ frappe.ui.form.on("LMS Certificate", { }, }; }); + + frm.set_query("template", function (doc) { + return { + filters: { + doc_type: "LMS Certificate", + }, + }; + }); }, refresh: (frm) => { if (frm.doc.name) diff --git a/lms/lms/doctype/lms_certificate/lms_certificate.json b/lms/lms/doctype/lms_certificate/lms_certificate.json index 39206779..83f4ba52 100644 --- a/lms/lms/doctype/lms_certificate/lms_certificate.json +++ b/lms/lms/doctype/lms_certificate/lms_certificate.json @@ -8,10 +8,12 @@ "course", "member", "member_name", + "template", "column_break_3", "issue_date", "expiry_date", - "batch_name" + "batch_name", + "published" ], "fields": [ { @@ -60,11 +62,24 @@ "in_standard_filter": 1, "label": "Batch", "options": "LMS Batch" + }, + { + "default": "0", + "fieldname": "published", + "fieldtype": "Check", + "label": "Publish on Participant Page" + }, + { + "fieldname": "template", + "fieldtype": "Link", + "label": "Template", + "options": "Print Format", + "reqd": 1 } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-08-29 15:23:08.637215", + "modified": "2023-10-25 12:20:56.091979", "modified_by": "Administrator", "module": "LMS", "name": "LMS Certificate", diff --git a/lms/lms/doctype/lms_certificate/lms_certificate.py b/lms/lms/doctype/lms_certificate/lms_certificate.py index daa400e4..f7e1193e 100644 --- a/lms/lms/doctype/lms_certificate/lms_certificate.py +++ b/lms/lms/doctype/lms_certificate/lms_certificate.py @@ -6,12 +6,42 @@ from frappe import _ from frappe.model.document import Document from frappe.utils import add_years, nowdate from lms.lms.utils import is_certified +from frappe.email.doctype.email_template.email_template import get_email_template class LMSCertificate(Document): def validate(self): self.validate_duplicate_certificate() + def after_insert(self): + if not frappe.flags.in_test: + self.send_mail() + + def send_mail(self): + subject = _("Congratulations on getting certified!") + template = "certification" + custom_template = frappe.db.get_single_value("LMS Settings", "certification_template") + + args = { + "student_name": self.member_name, + "course_name": self.course, + "course_title": frappe.db.get_value("LMS Course", self.course, "title"), + "certificate_name": self.name, + } + + if custom_template: + email_template = get_email_template(custom_template, args) + subject = email_template.get("subject") + content = email_template.get("message") + frappe.sendmail( + recipients=self.member, + subject=subject, + template=template if not custom_template else None, + content=content if custom_template else None, + args=args, + header=[subject, "green"], + ) + def validate_duplicate_certificate(self): certificates = frappe.get_all( "LMS Certificate", @@ -48,6 +78,15 @@ def create_certificate(course): if expires_after_yrs: expiry_date = add_years(nowdate(), expires_after_yrs) + default_certificate_template = frappe.db.get_value( + "Property Setter", + { + "doc_type": "LMS Certificate", + "property": "default_print_format", + }, + "value", + ) + certificate = frappe.get_doc( { "doctype": "LMS Certificate", @@ -55,6 +94,7 @@ def create_certificate(course): "course": course, "issue_date": nowdate(), "expiry_date": expiry_date, + "template": default_certificate_template, } ) certificate.save(ignore_permissions=True) diff --git a/lms/lms/doctype/lms_certificate_evaluation/lms_certificate_evaluation.json b/lms/lms/doctype/lms_certificate_evaluation/lms_certificate_evaluation.json index eb66ee71..998ce8bc 100644 --- a/lms/lms/doctype/lms_certificate_evaluation/lms_certificate_evaluation.json +++ b/lms/lms/doctype/lms_certificate_evaluation/lms_certificate_evaluation.json @@ -84,7 +84,7 @@ "fieldname": "status", "fieldtype": "Select", "label": "Status", - "options": "Pass\nFail", + "options": "Pending\nIn Progress\nPass\nFail", "reqd": 1 }, { @@ -106,7 +106,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-08-23 14:51:21.947169", + "modified": "2023-09-26 19:44:43.594892", "modified_by": "Administrator", "module": "LMS", "name": "LMS Certificate Evaluation", @@ -139,6 +139,23 @@ ], "sort_field": "modified", "sort_order": "DESC", - "states": [], + "states": [ + { + "color": "Green", + "title": "Pass" + }, + { + "color": "Red", + "title": "Fail" + }, + { + "color": "Blue", + "title": "Pending" + }, + { + "color": "Orange", + "title": "In Progress" + } + ], "title_field": "member_name" } \ No newline at end of file diff --git a/lms/lms/doctype/lms_course/lms_course.py b/lms/lms/doctype/lms_course/lms_course.py index cd6f184f..9065b639 100644 --- a/lms/lms/doctype/lms_course/lms_course.py +++ b/lms/lms/doctype/lms_course/lms_course.py @@ -20,7 +20,6 @@ class LMSCourse(Document): self.image = validate_image(self.image) def validate_instructors(self): - print(self.is_new(), not self.instructors) if self.is_new() and not self.instructors: frappe.get_doc( { @@ -217,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: @@ -281,6 +280,7 @@ def save_lesson( preview, idx, lesson, + instructor_notes=None, youtube=None, quiz_id=None, question=None, @@ -296,6 +296,7 @@ def save_lesson( "chapter": chapter, "title": title, "body": body, + "instructor_notes": instructor_notes, "include_in_preview": preview, "youtube": youtube, "quiz_id": quiz_id, diff --git a/lms/lms/doctype/lms_enrollment/lms_enrollment.json b/lms/lms/doctype/lms_enrollment/lms_enrollment.json index 2ebf9161..c68465cb 100644 --- a/lms/lms/doctype/lms_enrollment/lms_enrollment.json +++ b/lms/lms/doctype/lms_enrollment/lms_enrollment.json @@ -123,7 +123,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-08-24 17:52:35.487141", + "modified": "2023-10-02 12:41:25.139734", "modified_by": "Administrator", "module": "LMS", "name": "LMS Enrollment", @@ -140,6 +140,31 @@ "role": "System Manager", "share": 1, "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "LMS Student", + "select": 1, + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Moderator", + "select": 1, + "share": 1, + "write": 1 } ], "quick_entry": 1, diff --git a/lms/lms/doctype/lms_live_class/lms_live_class.json b/lms/lms/doctype/lms_live_class/lms_live_class.json index 1e68dbfc..74e21479 100644 --- a/lms/lms/doctype/lms_live_class/lms_live_class.json +++ b/lms/lms/doctype/lms_live_class/lms_live_class.json @@ -10,16 +10,16 @@ "title", "host", "batch_name", - "password", - "auto_recording", "column_break_astv", - "description", - "section_break_glxh", "date", - "timezone", - "column_break_spvt", "time", "duration", + "section_break_glxh", + "description", + "column_break_spvt", + "timezone", + "password", + "auto_recording", "section_break_yrpq", "start_url", "column_break_yokr", @@ -126,7 +126,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-03-14 18:44:48.813103", + "modified": "2023-09-20 11:29:20.899897", "modified_by": "Administrator", "module": "LMS", "name": "LMS Live Class", @@ -157,8 +157,10 @@ "write": 1 } ], + "show_title_field_in_link": 1, "sort_field": "modified", "sort_order": "DESC", "states": [], + "title_field": "title", "track_changes": 1 } \ No newline at end of file diff --git a/lms/lms/doctype/lms_payment/lms_payment.json b/lms/lms/doctype/lms_payment/lms_payment.json index f89bc595..82fb9388 100644 --- a/lms/lms/doctype/lms_payment/lms_payment.json +++ b/lms/lms/doctype/lms_payment/lms_payment.json @@ -8,13 +8,17 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "payment_for_document_type", "member", + "source", "column_break_rqkd", + "payment_for_document", "billing_name", "payment_received", "payment_details_section", - "amount", "currency", + "amount", + "amount_with_gst", "column_break_yxpl", "order_id", "payment_id", @@ -39,7 +43,8 @@ "fieldname": "amount", "fieldtype": "Currency", "in_list_view": 1, - "label": "Amount" + "label": "Amount", + "options": "currency" }, { "fieldname": "currency", @@ -107,11 +112,35 @@ "label": "Member", "options": "User", "reqd": 1 + }, + { + "depends_on": "eval:doc.currency == \"INR\";", + "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" + }, + { + "fieldname": "source", + "fieldtype": "Link", + "label": "Source", + "options": "LMS Source" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-08-24 22:08:12.294960", + "modified": "2023-10-26 16:54:12.408274", "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..a00e1617 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,35 @@ "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" + }, + { + "default": "0", + "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-11-07 10:11:49.126789", "modified_by": "Administrator", "module": "LMS", "name": "LMS Quiz", @@ -123,6 +151,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..709cfe34 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,45 +63,72 @@ 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, + "percentage": percentage, + "passing_percentage": quiz_details.passing_percentage, } ) submission.save(ignore_permissions=True) 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 +136,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 +230,8 @@ def save_question(quiz, values, index): } ) - doc.save(ignore_permissions=True) - - return quiz + doc.save() + return doc.name @frappe.whitelist() @@ -253,9 +264,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 +280,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..84d63e77 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,16 @@ "engine": "InnoDB", "field_order": [ "quiz", - "score", "course", "column_break_3", "member", "member_name", + "section_break_dkpn", + "score", + "score_out_of", + "column_break_gkip", + "percentage", + "passing_percentage", "section_break_6", "result" ], @@ -31,9 +36,11 @@ }, { "fieldname": "score", - "fieldtype": "Data", + "fieldtype": "Int", "in_list_view": 1, - "label": "Score" + "label": "Score", + "read_only": 1, + "reqd": 1 }, { "fieldname": "member", @@ -65,12 +72,45 @@ "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 + }, + { + "fieldname": "passing_percentage", + "fieldtype": "Int", + "label": "Passing 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.979975", "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..9d7050d5 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,10 @@ from frappe.model.document import Document class LMSQuizSubmission(Document): - pass + def before_insert(self): + if not self.percentage: + 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_settings/lms_settings.json b/lms/lms/doctype/lms_settings/lms_settings.json index 4e1442c5..c69e4d8f 100644 --- a/lms/lms/doctype/lms_settings/lms_settings.json +++ b/lms/lms/doctype/lms_settings/lms_settings.json @@ -16,14 +16,15 @@ "portal_course_creation", "section_break_szgq", "send_calendar_invite_for_evaluations", - "column_break_2", "allow_student_progress", - "payment_section", - "razorpay_key", - "default_currency", - "column_break_cfcv", - "razorpay_secret", - "apply_gst", + "column_break_2", + "show_dashboard", + "show_courses", + "show_students", + "show_assessments", + "show_live_class", + "show_discussions", + "show_emails", "signup_settings_tab", "signup_settings_section", "terms_of_use", @@ -38,7 +39,22 @@ "mentor_request_tab", "mentor_request_section", "mentor_request_creation", - "mentor_request_status_update" + "mentor_request_status_update", + "payment_settings_tab", + "payment_section", + "razorpay_key", + "razorpay_secret", + "apply_gst", + "column_break_cfcv", + "default_currency", + "show_usd_equivalent", + "apply_rounding", + "exception_country", + "email_templates_tab", + "certification_template", + "batch_confirmation_template", + "column_break_uwsp", + "assignment_submission_template" ], "fields": [ { @@ -67,7 +83,8 @@ }, { "fieldname": "column_break_2", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "label": "Show Tab in Batch" }, { "fieldname": "search_placeholder", @@ -173,7 +190,7 @@ { "fieldname": "section_break_szgq", "fieldtype": "Section Break", - "label": "Class Settings" + "label": "Batch Settings" }, { "fieldname": "signup_settings_tab", @@ -183,6 +200,7 @@ { "fieldname": "mentor_request_tab", "fieldtype": "Tab Break", + "hidden": 1, "label": "Mentor Request" }, { @@ -194,8 +212,7 @@ }, { "fieldname": "payment_section", - "fieldtype": "Section Break", - "label": "Payment" + "fieldtype": "Section Break" }, { "fieldname": "default_currency", @@ -231,12 +248,105 @@ "fieldname": "apply_gst", "fieldtype": "Check", "label": "Apply GST for India" + }, + { + "default": "0", + "fieldname": "show_usd_equivalent", + "fieldtype": "Check", + "label": "Show USD Equivalent" + }, + { + "depends_on": "show_usd_equivalent", + "fieldname": "exception_country", + "fieldtype": "Table MultiSelect", + "label": "Maintain Original Currency", + "options": "Payment Country" + }, + { + "default": "0", + "fieldname": "apply_rounding", + "fieldtype": "Check", + "label": "Apply Rounding on Equivalent" + }, + { + "fieldname": "batch_confirmation_template", + "fieldtype": "Link", + "label": "Batch Confirmation Template", + "options": "Email Template" + }, + { + "default": "1", + "fieldname": "show_courses", + "fieldtype": "Check", + "label": "Courses" + }, + { + "default": "1", + "fieldname": "show_students", + "fieldtype": "Check", + "label": "Students" + }, + { + "default": "1", + "fieldname": "show_assessments", + "fieldtype": "Check", + "label": "Assessments" + }, + { + "default": "1", + "fieldname": "show_live_class", + "fieldtype": "Check", + "label": "Live Class" + }, + { + "default": "1", + "fieldname": "show_discussions", + "fieldtype": "Check", + "label": "Discussions" + }, + { + "default": "1", + "fieldname": "show_emails", + "fieldtype": "Check", + "label": "Emails" + }, + { + "fieldname": "payment_settings_tab", + "fieldtype": "Tab Break", + "label": "Payment Settings" + }, + { + "default": "1", + "fieldname": "show_dashboard", + "fieldtype": "Check", + "label": "Dashboard" + }, + { + "fieldname": "certification_template", + "fieldtype": "Link", + "label": "Certificate Email Template", + "options": "Email Template" + }, + { + "fieldname": "email_templates_tab", + "fieldtype": "Tab Break", + "label": "Email Templates" + }, + { + "fieldname": "assignment_submission_template", + "fieldtype": "Link", + "label": "Assignment Submission Template", + "options": "Email Template" + }, + { + "fieldname": "column_break_uwsp", + "fieldtype": "Column Break" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-08-29 09:54:48.030823", + "modified": "2023-11-07 11:23:14.257687", "modified_by": "Administrator", "module": "LMS", "name": "LMS Settings", diff --git a/lms/lms/doctype/lms_source/__init__.py b/lms/lms/doctype/lms_source/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lms/lms/doctype/lms_source/lms_source.js b/lms/lms/doctype/lms_source/lms_source.js new file mode 100644 index 00000000..e3c82001 --- /dev/null +++ b/lms/lms/doctype/lms_source/lms_source.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("LMS Source", { +// refresh(frm) { + +// }, +// }); diff --git a/lms/lms/doctype/lms_source/lms_source.json b/lms/lms/doctype/lms_source/lms_source.json new file mode 100644 index 00000000..a1361696 --- /dev/null +++ b/lms/lms/doctype/lms_source/lms_source.json @@ -0,0 +1,69 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:source", + "creation": "2023-10-26 16:28:53.932278", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "source" + ], + "fields": [ + { + "fieldname": "source", + "fieldtype": "Data", + "label": "Source", + "unique": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2023-10-26 17:25:09.144367", + "modified_by": "Administrator", + "module": "LMS", + "name": "LMS Source", + "naming_rule": "By fieldname", + "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 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "LMS Student", + "select": 1, + "share": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "source" +} \ No newline at end of file diff --git a/lms/lms/doctype/lms_source/lms_source.py b/lms/lms/doctype/lms_source/lms_source.py new file mode 100644 index 00000000..cf881d0d --- /dev/null +++ b/lms/lms/doctype/lms_source/lms_source.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 LMSSource(Document): + pass diff --git a/lms/lms/doctype/lms_source/test_lms_source.py b/lms/lms/doctype/lms_source/test_lms_source.py new file mode 100644 index 00000000..99b7e97f --- /dev/null +++ b/lms/lms/doctype/lms_source/test_lms_source.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestLMSSource(FrappeTestCase): + pass 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/__init__.py b/lms/lms/doctype/lms_timetable_template/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lms/lms/doctype/lms_timetable_template/lms_timetable_template.js b/lms/lms/doctype/lms_timetable_template/lms_timetable_template.js new file mode 100644 index 00000000..b5ec2d9b --- /dev/null +++ b/lms/lms/doctype/lms_timetable_template/lms_timetable_template.js @@ -0,0 +1,29 @@ +// Copyright (c) 2023, Frappe and contributors +// For license information, please see license.txt + +frappe.ui.form.on("LMS Timetable Template", { + refresh(frm) { + frm.set_query("reference_doctype", "timetable", function () { + 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", + "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 new file mode 100644 index 00000000..3b016b7a --- /dev/null +++ b/lms/lms/doctype/lms_timetable_template/lms_timetable_template.json @@ -0,0 +1,72 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "hash", + "creation": "2023-09-18 14:16:16.964077", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "title", + "timetable", + "timetable_legends" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "label": "Title" + }, + { + "fieldname": "timetable", + "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-10-11 17:09:05.096243", + "modified_by": "Administrator", + "module": "LMS", + "name": "LMS Timetable Template", + "naming_rule": "Random", + "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 + } + ], + "show_title_field_in_link": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "title" +} \ No newline at end of file diff --git a/lms/lms/doctype/lms_timetable_template/lms_timetable_template.py b/lms/lms/doctype/lms_timetable_template/lms_timetable_template.py new file mode 100644 index 00000000..21e7c70d --- /dev/null +++ b/lms/lms/doctype/lms_timetable_template/lms_timetable_template.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 LMSTimetableTemplate(Document): + pass diff --git a/lms/lms/doctype/lms_timetable_template/test_lms_timetable_template.py b/lms/lms/doctype/lms_timetable_template/test_lms_timetable_template.py new file mode 100644 index 00000000..090d1a58 --- /dev/null +++ b/lms/lms/doctype/lms_timetable_template/test_lms_timetable_template.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestLMSTimetableTemplate(FrappeTestCase): + pass diff --git a/lms/lms/doctype/payment_country/__init__.py b/lms/lms/doctype/payment_country/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lms/lms/doctype/payment_country/payment_country.js b/lms/lms/doctype/payment_country/payment_country.js new file mode 100644 index 00000000..3ad2f61b --- /dev/null +++ b/lms/lms/doctype/payment_country/payment_country.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Payment Country", { +// refresh(frm) { + +// }, +// }); diff --git a/lms/lms/doctype/payment_country/payment_country.json b/lms/lms/doctype/payment_country/payment_country.json new file mode 100644 index 00000000..cf493409 --- /dev/null +++ b/lms/lms/doctype/payment_country/payment_country.json @@ -0,0 +1,33 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-09-11 11:53:16.253740", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "country" + ], + "fields": [ + { + "fieldname": "country", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Country", + "options": "Country" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2023-09-11 12:04:56.048632", + "modified_by": "Administrator", + "module": "LMS", + "name": "Payment Country", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/lms/lms/doctype/payment_country/payment_country.py b/lms/lms/doctype/payment_country/payment_country.py new file mode 100644 index 00000000..9d834847 --- /dev/null +++ b/lms/lms/doctype/payment_country/payment_country.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 PaymentCountry(Document): + pass diff --git a/lms/lms/doctype/payment_country/test_payment_country.py b/lms/lms/doctype/payment_country/test_payment_country.py new file mode 100644 index 00000000..994c246d --- /dev/null +++ b/lms/lms/doctype/payment_country/test_payment_country.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestPaymentCountry(FrappeTestCase): + pass diff --git a/lms/lms/doctype/user_skill/user_skill.json b/lms/lms/doctype/user_skill/user_skill.json index ecc08a98..e047b414 100644 --- a/lms/lms/doctype/user_skill/user_skill.json +++ b/lms/lms/doctype/user_skill/user_skill.json @@ -19,7 +19,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-12-21 09:35:44.265910", + "modified": "2023-09-29 17:05:50.502696", "modified_by": "Administrator", "module": "LMS", "name": "User Skill", @@ -44,11 +44,12 @@ "print": 1, "read": 1, "report": 1, - "role": "All", + "role": "LMS Student", "select": 1, "share": 1 } ], "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/lms/lms/notification/assignment_submission_notification/assignment_submission_notification.json b/lms/lms/notification/assignment_submission_notification/assignment_submission_notification.json deleted file mode 100644 index 03a91be8..00000000 --- a/lms/lms/notification/assignment_submission_notification/assignment_submission_notification.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "attach_print": 0, - "channel": "Email", - "creation": "2023-03-27 16:34:03.505647", - "days_in_advance": 0, - "docstatus": 0, - "doctype": "Notification", - "document_type": "LMS Assignment Submission", - "enabled": 1, - "event": "New", - "idx": 0, - "is_standard": 1, - "message": "

{{ _(\"Assignment Submission\") }}\n\n{% set title = frappe.db.get_value(\"Course Lesson\", doc.lesson, \"title\") %}\n\n

{{ _(\"{0} has submitted their assignment for the lesson {1}\").format(doc.member_name, title) }}

\n\n

{{ _(\" Please evaluate and grade the assignment. \") }}

", - "modified": "2023-03-27 16:46:44.564007", - "modified_by": "Administrator", - "module": "LMS", - "name": "Assignment Submission Notification", - "owner": "Administrator", - "recipients": [ - { - "receiver_by_document_field": "evaluator" - } - ], - "send_system_notification": 0, - "send_to_all_assignees": 0, - "subject": "Assignment Submission" -} \ No newline at end of file diff --git a/lms/lms/notification/assignment_submission_notification/assignment_submission_notification.md b/lms/lms/notification/assignment_submission_notification/assignment_submission_notification.md deleted file mode 100644 index 02b2f997..00000000 --- a/lms/lms/notification/assignment_submission_notification/assignment_submission_notification.md +++ /dev/null @@ -1,11 +0,0 @@ -
-
-

{{ _("Assignment Submission") }}

- {% set title = frappe.db.get_value("Course Lesson", doc.lesson, "title") %} -
-

{{ _("{0} has submitted their assignment for the lesson {1}").format(frappe.bold(doc.member_name), frappe.bold(title)) }} -

-

{{ _(" Please evaluate and grade the assignment.") }}

-
-
- diff --git a/lms/lms/notification/assignment_submission_notification/assignment_submission_notification.py b/lms/lms/notification/assignment_submission_notification/assignment_submission_notification.py deleted file mode 100644 index 80b7b873..00000000 --- a/lms/lms/notification/assignment_submission_notification/assignment_submission_notification.py +++ /dev/null @@ -1,6 +0,0 @@ -import frappe - - -def get_context(context): - # do your magic here - pass diff --git a/lms/lms/print_format/certificate/certificate.json b/lms/lms/print_format/certificate/certificate.json index 47b27f51..a23e7908 100644 --- a/lms/lms/print_format/certificate/certificate.json +++ b/lms/lms/print_format/certificate/certificate.json @@ -2,7 +2,7 @@ "absolute_value": 0, "align_labels_right": 0, "creation": "2023-08-09 17:02:21.430320", - "css": ".outer-border {\n font-family: \"Inter\" sans-serif;\n font-size: 16px;\n border-radius: 0.5rem;\n border: 1px solid #E2E6E9;\n padding: 1rem;\n}\n\n.inner-border {\n border: 10px solid #0089FF;\n border-radius: 8px;\n text-align: center;\n padding: 6rem 4rem;\n background-color: #FFFFFF;\n}\n\n.certificate-logo {\n height: 1.5rem;\n margin-bottom: 4rem;\n}\n\n.certificate-name {\n font-size: 2rem;\n font-weight: 500;\n color: #192734;\n margin-bottom: 0.5rem;\n}\n\n.certificate-footer {\n margin: 4rem auto 0;\n width: 70%;\n text-align: center;\n}\n\n.certificate-footer-item {\n color: #192734;\n}\n\n.cursive-font {\n font-family: cursive;\n font-weight: 600;\n}\n\n.certificate-divider {\n margin: 0.5rem 0;\n}\n\n.certificate-expiry {\n margin-left: 2rem;\n}", + "css": ".outer-border {\n font-family: \"Inter\" sans-serif;\n font-size: 16px;\n border-radius: 0.5rem;\n border: 1px solid #E2E6E9;\n padding: 1rem;\n}\n\n.inner-border {\n border: 8px solid #0089FF;\n border-radius: 8px;\n text-align: center;\n padding: 6rem 4rem;\n background-color: #FFFFFF;\n}\n\n.certificate-logo {\n height: 1.5rem;\n margin-bottom: 4rem;\n}\n\n.certificate-name {\n font-size: 2rem;\n font-weight: 500;\n color: #192734;\n margin-bottom: 0.5rem;\n}\n\n.certificate-footer {\n margin: 4rem auto 0;\n width: 70%;\n text-align: center;\n}\n\n.certificate-footer-item {\n color: #192734;\n}\n\n.cursive-font {\n font-family: cursive;\n font-weight: 600;\n}\n\n.certificate-divider {\n margin: 0.5rem 0;\n}\n\n.certificate-expiry {\n margin-left: 2rem;\n}", "custom_format": 1, "disabled": 0, "doc_type": "LMS Certificate", @@ -10,19 +10,20 @@ "doctype": "Print Format", "font_size": 14, "format_data": "{\"header\":\"
\\n\\t

LMS Certificate

\\n\\t

{{ doc.name }}

\\n
\",\"sections\":[{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Course\",\"fieldname\":\"course\",\"fieldtype\":\"Link\",\"options\":\"LMS Course\"},{\"label\":\"Member\",\"fieldname\":\"member\",\"fieldtype\":\"Link\",\"options\":\"User\"},{\"label\":\"Member Name\",\"fieldname\":\"member_name\",\"fieldtype\":\"Data\"},{\"label\":\"Evaluator\",\"fieldname\":\"evaluator\",\"fieldtype\":\"Data\",\"options\":\"\"}]},{\"label\":\"\",\"fields\":[{\"label\":\"Issue Date\",\"fieldname\":\"issue_date\",\"fieldtype\":\"Date\"},{\"label\":\"Expiry Date\",\"fieldname\":\"expiry_date\",\"fieldtype\":\"Date\"},{\"label\":\"Version\",\"fieldname\":\"version\",\"fieldtype\":\"Select\",\"options\":\"V13\\nV14\"},{\"label\":\"Module Names for Certificate\",\"fieldname\":\"module_names_for_certificate\",\"fieldtype\":\"Data\"}]}],\"has_fields\":true}]}", - "html": "{% set certificate = frappe.db.get_value(\"LMS Certificate\", doc.name, [\"name\", \"member\", \"issue_date\", \"expiry_date\", \"course\"], as_dict=True) %}\n{% set member = frappe.db.get_value(\"User\", doc.member, [\"full_name\"], as_dict=True) %}\n{% set course = frappe.db.get_value(\"LMS Course\", doc.course, [\"title\", \"name\", \"image\"], as_dict=True) %}\n{% set logo = frappe.db.get_single_value(\"Website Settings\", \"banner_image\") %}\n{% set instructors = frappe.get_all(\"Course Instructor\", {\"parent\": doc.course}, pluck=\"instructor\", order_by=\"idx\") %}\n\n\n\n\n\n\n
\n
\n \n {% if logo %}\n \n {% endif %}\n
\n {{ _(\"This certifies that\") }}\n
\n \n
\n {{ member.full_name }}\n
\n
\n {{ _(\"has successfully completed the course on\") }}\n {{ course.title }} \n on {{ frappe.utils.format_date(certificate.issue_date, \"medium\") }}.\n
\n \n \n \n {% if instructors %}\n \n {% endif %}\n \n {% if certificate.expiry_date %}\n \n \n \n {% endif %}\n \n
\n \n
\n
{{ _(\"Course Instructor\") }}
\n
\n \n
\n
{{ _(\"Expiry Date\") }}
\n
\n
\n
", + "html": "{% set certificate = frappe.db.get_value(\"LMS Certificate\", doc.name, [\"name\", \"member\", \"issue_date\", \"expiry_date\", \"course\"], as_dict=True) %}\n{% set member = frappe.db.get_value(\"User\", doc.member, [\"full_name\"], as_dict=True) %}\n{% set course = frappe.db.get_value(\"LMS Course\", doc.course, [\"title\", \"name\", \"image\"], as_dict=True) %}\n{% set logo = frappe.db.get_single_value(\"Website Settings\", \"banner_image\") %}\n{% set instructors = frappe.get_all(\"Course Instructor\", {\"parent\": doc.course}, pluck=\"instructor\", order_by=\"idx\") %}\n\n\n\n\n\n\n
\n
\n \n {% if logo %}\n \n {% endif %}\n
\n {{ _(\"This certifies that\") }}\n
\n \n
\n {{ member.full_name }}\n
\n
\n {{ _(\"has successfully completed the course on\") }}\n {{ course.title }} \n on {{ frappe.utils.format_date(certificate.issue_date, \"medium\") }}.\n
\n \n \n \n {% if instructors %}\n \n {% endif %}\n \n {% if certificate.expiry_date %}\n \n \n \n {% endif %}\n \n
\n \n
\n
{{ _(\"Course Instructor\") }}
\n
\n \n
\n
{{ _(\"Expiry Date\") }}
\n
\n
\n
", "idx": 0, "line_breaks": 0, "margin_bottom": 0.0, "margin_left": 0.0, "margin_right": 0.0, "margin_top": 0.0, - "modified": "2023-08-09 17:02:21.430320", + "modified": "2023-11-01 18:22:56.715846", "modified_by": "Administrator", "module": "LMS", "name": "Certificate", "owner": "Administrator", "page_number": "Hide", + "print_designer": 0, "print_format_builder": 0, "print_format_builder_beta": 1, "print_format_type": "Jinja", diff --git a/lms/lms/test_utils.py b/lms/lms/test_utils.py index 3831c5a5..e2ed069d 100644 --- a/lms/lms/test_utils.py +++ b/lms/lms/test_utils.py @@ -72,4 +72,4 @@ def create_evaluation(user, course, date, rating, status): "status": status, } ) - evaluation.save(ignore_permissions=True) + evaluation.save() diff --git a/lms/lms/utils.py b/lms/lms/utils.py index a5bc80ce..3ed651e4 100644 --- a/lms/lms/utils.py +++ b/lms/lms/utils.py @@ -3,6 +3,7 @@ import string import frappe import json import razorpay +import requests 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 @@ -16,6 +17,7 @@ from frappe.utils import ( get_datetime, getdate, validate_phone_number, + ceil, ) from frappe.utils.dateutils import get_period from lms.lms.md import find_macros, markdown_to_html @@ -143,12 +145,12 @@ def get_lesson_details(chapter): "quiz_id", "question", "file_type", + "instructor_notes", ], as_dict=True, ) - lesson_details.number = flt(f"{chapter.idx}.{row.idx}") + lesson_details.number = f"{chapter.idx}.{row.idx}" lesson_details.icon = get_lesson_icon(lesson_details.body) - lessons.append(lesson_details) return lessons @@ -518,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): @@ -582,7 +598,7 @@ def validate_image(path): if path and "/private" in path: file = frappe.get_doc("File", {"file_url": path}) file.is_private = 0 - file.save(ignore_permissions=True) + file.save() return file.file_url return path @@ -724,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%"]}) @@ -830,19 +846,23 @@ def get_upcoming_evals(student, courses): @frappe.whitelist() -def get_payment_options(doctype, docname, phone): +def get_payment_options(doctype, docname, phone, country): if not frappe.db.exists(doctype, docname): frappe.throw(_("Invalid document provided.")) validate_phone_number(phone, True) details = get_details(doctype, docname) + details.amount, details.currency = check_multicurrency( + details.amount, details.currency, country + ) + if details.currency == "INR": + details.amount, details.gst_applied = apply_gst(details.amount, country) - razorpay_key = frappe.db.get_single_value("LMS Settings", "razorpay_key") client = get_client() order = create_order(client, details.amount, details.currency) options = { - "key_id": razorpay_key, + "key_id": frappe.db.get_single_value("LMS Settings", "razorpay_key"), "name": frappe.db.get_single_value("Website Settings", "app_name"), "description": _("Payment for {0} course").format(details["title"]), "order_id": order["id"], @@ -857,6 +877,46 @@ def get_payment_options(doctype, docname, phone): return options +def check_multicurrency(amount, currency, country=None): + show_usd_equivalent = frappe.db.get_single_value("LMS Settings", "show_usd_equivalent") + exception_country = frappe.get_all( + "Payment Country", filters={"parent": "LMS Settings"}, pluck="country" + ) + apply_rounding = frappe.db.get_single_value("LMS Settings", "apply_rounding") + country = country or frappe.db.get_value( + "Address", {"email_id": frappe.session.user}, "country" + ) + + if not show_usd_equivalent or currency == "USD": + return amount, currency + + if not country or (exception_country and country in exception_country): + return amount, currency + + exchange_rate = get_current_exchange_rate(currency, "USD") + amount = amount * exchange_rate + currency = "USD" + + if apply_rounding and amount % 100 != 0: + amount = amount + 100 - amount % 100 + + return amount, currency + + +def apply_gst(amount, country=None): + gst_applied = False + apply_gst = frappe.db.get_single_value("LMS Settings", "apply_gst") + + if not country: + country = frappe.db.get_value("User", frappe.session.user, "country") + + if apply_gst and country == "India": + gst_applied = True + amount = amount * 1.18 + + return amount, gst_applied + + def get_details(doctype, docname): if doctype == "LMS Course": details = frappe.db.get_value( @@ -881,7 +941,15 @@ def get_details(doctype, docname): def save_address(address): - address.update( + filters = {"email_id": frappe.session.user} + exists = frappe.db.exists("Address", filters) + if exists: + address_doc = frappe.get_last_doc("Address", filters=filters) + else: + address_doc = frappe.new_doc("Address") + + address_doc.update(address) + address_doc.update( { "address_title": frappe.db.get_value("User", frappe.session.user, "full_name"), "address_type": "Billing", @@ -889,15 +957,14 @@ def save_address(address): "email_id": frappe.session.user, } ) - doc = frappe.new_doc("Address") - doc.update(address) - doc.save(ignore_permissions=True) - return doc.name + address_doc.save(ignore_permissions=True) + return address_doc.name def get_client(): - razorpay_key = frappe.db.get_single_value("LMS Settings", "razorpay_key") - razorpay_secret = frappe.db.get_single_value("LMS Settings", "razorpay_secret") + settings = frappe.get_single("LMS Settings") + razorpay_key = settings.razorpay_key + razorpay_secret = settings.get_password("razorpay_secret", raise_exception=True) if not razorpay_key and not razorpay_secret: frappe.throw( @@ -946,7 +1013,7 @@ def record_payment(address, response, client, doctype, docname): address = frappe._dict(json.loads(address)) address_name = save_address(address) - payment_details = get_payment_details(doctype, docname) + payment_details = get_payment_details(doctype, docname, address) payment_doc = frappe.new_doc("LMS Payment") payment_doc.update( { @@ -958,29 +1025,39 @@ def record_payment(address, response, client, doctype, docname): "payment_id": response["razorpay_payment_id"], "amount": payment_details["amount"], "currency": payment_details["currency"], + "amount_with_gst": payment_details["amount_with_gst"], "gstin": address.gstin, "pan": address.pan, + "source": address.source, + "payment_for_document_type": doctype, + "payment_for_document": docname, } ) payment_doc.save(ignore_permissions=True) - return payment_doc.name + return payment_doc -def get_payment_details(doctype, docname): +def get_payment_details(doctype, docname, address): amount_field = "course_price" if doctype == "LMS Course" else "amount" amount = frappe.db.get_value(doctype, docname, amount_field) currency = frappe.db.get_value(doctype, docname, "currency") + amount_with_gst = 0 + + amount, currency = check_multicurrency(amount, currency) + if currency == "INR" and address.country == "India": + amount_with_gst, gst_applied = apply_gst(amount, address.country) return { "amount": amount, "currency": currency, + "amount_with_gst": amount_with_gst, } def create_membership(course, payment): membership = frappe.new_doc("LMS Enrollment") membership.update( - {"member": frappe.session.user, "course": course, "payment": payment} + {"member": frappe.session.user, "course": course, "payment": payment.name} ) membership.save(ignore_permissions=True) return f"/courses/{course}/learn/1.1" @@ -991,7 +1068,8 @@ def add_student_to_batch(batchname, payment): student.update( { "student": frappe.session.user, - "payment": payment, + "payment": payment.name, + "source": payment.source, "parent": batchname, "parenttype": "LMS Batch", "parentfield": "students", @@ -999,3 +1077,18 @@ def add_student_to_batch(batchname, payment): ) student.save(ignore_permissions=True) return f"/batches/{batchname}" + + +def get_current_exchange_rate(source, target="USD"): + url = f"https://api.frankfurter.app/latest?from={source}&to={target}" + + response = requests.request("GET", url) + details = response.json() + return details["rates"][target] + + +@frappe.whitelist() +def change_currency(amount, currency, country=None): + amount = cint(amount) + amount, currency = check_multicurrency(amount, currency, country) + return fmt_money(amount, 0, currency) diff --git a/lms/overrides/user.py b/lms/overrides/user.py index a02db99e..bfc1f49b 100644 --- a/lms/overrides/user.py +++ b/lms/overrides/user.py @@ -235,13 +235,15 @@ def sign_up(email, full_name, verify_terms, user_category): user.flags.ignore_permissions = True user.flags.ignore_password_policy = True user.insert() - set_country_from_ip(None, user.name) # set default signup role as per Portal Settings default_role = frappe.db.get_value("Portal Settings", None, "default_role") if default_role: user.add_roles(default_role) + user.add_roles("LMS Student") + set_country_from_ip(None, user.name) + if user.flags.email_sent: return 1, _("Please check your email for verification") else: diff --git a/lms/patches.txt b/lms/patches.txt index a0c423b3..4e9db712 100644 --- a/lms/patches.txt +++ b/lms/patches.txt @@ -1,3 +1,4 @@ +[pre_model_sync] community.patches.set_email_preferences community.patches.change_name_for_community_members community.patches.save_abbr_for_community_members @@ -66,4 +67,18 @@ lms.patches.v1_0.revert_class_registration #18-08-2023 lms.patches.v1_0.rename_lms_batch_doctype lms.patches.v1_0.rename_lms_batch_membership_doctype lms.patches.v1_0.rename_lms_class_to_lms_batch -lms.patches.v1_0.rename_classes_in_navbar \ No newline at end of file +lms.patches.v1_0.rename_classes_in_navbar +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.mark_confirmation_for_batch_students +lms.patches.v1_0.create_quiz_questions +lms.patches.v1_0.add_default_marks #16-10-2023 +lms.patches.v1_0.add_certificate_template #26-10-2023 +lms.patches.v1_0.create_batch_source + +[post_model_sync] +lms.patches.v1_0.batch_tabs_settings +execute:frappe.delete_doc("Notification", "Assignment Submission Notification") \ No newline at end of file diff --git a/lms/patches/v1_0/add_certificate_template.py b/lms/patches/v1_0/add_certificate_template.py new file mode 100644 index 00000000..b1eb7498 --- /dev/null +++ b/lms/patches/v1_0/add_certificate_template.py @@ -0,0 +1,20 @@ +import frappe + + +def execute(): + frappe.reload_doc("lms", "doctype", "lms_certificate") + default_certificate_template = frappe.db.get_value( + "Property Setter", + { + "doc_type": "LMS Certificate", + "property": "default_print_format", + }, + "value", + ) + + if frappe.db.exists("Print Format", default_certificate_template): + certificates = frappe.get_all("LMS Certificate", pluck="name") + for certificate in certificates: + frappe.db.set_value( + "LMS Certificate", certificate, "template", default_certificate_template + ) 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/batch_tabs_settings.py b/lms/patches/v1_0/batch_tabs_settings.py new file mode 100644 index 00000000..3fec8329 --- /dev/null +++ b/lms/patches/v1_0/batch_tabs_settings.py @@ -0,0 +1,16 @@ +import frappe + + +def execute(): + fields = [ + "show_dashboard", + "show_courses", + "show_students", + "show_emails", + "show_assessments", + "show_discussions", + "show_live_class", + ] + + for field in fields: + frappe.db.set_single_value("LMS Settings", field, 1) diff --git a/lms/patches/v1_0/change_naming_for_batch_course.py b/lms/patches/v1_0/change_naming_for_batch_course.py new file mode 100644 index 00000000..7a93720c --- /dev/null +++ b/lms/patches/v1_0/change_naming_for_batch_course.py @@ -0,0 +1,6 @@ +import frappe + + +def execute(): + frappe.db.create_sequence("Batch Course", check_not_exists=True) + frappe.db.set_next_sequence_val("Batch Course", 500, is_val_used=False) diff --git a/lms/patches/v1_0/create_batch_source.py b/lms/patches/v1_0/create_batch_source.py new file mode 100644 index 00000000..c6c8f54b --- /dev/null +++ b/lms/patches/v1_0/create_batch_source.py @@ -0,0 +1,7 @@ +import frappe +from lms.install import create_batch_source + + +def execute(): + frappe.reload_doc("lms", "doctype", "lms_source") + create_batch_source() 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/create_student_role.py b/lms/patches/v1_0/create_student_role.py new file mode 100644 index 00000000..ec0f4e0a --- /dev/null +++ b/lms/patches/v1_0/create_student_role.py @@ -0,0 +1,22 @@ +import frappe +from lms.install import create_lms_student_role + + +def execute(): + create_lms_student_role() + + users = frappe.get_all( + "User", filters={"user_type": "Website User", "enabled": 1}, pluck="name" + ) + + for user in users: + filters = { + "parent": user, + "parenttype": "User", + "parentfield": "roles", + "role": "LMS Student", + } + if not frappe.db.exists("Has Role", filters): + doc = frappe.new_doc("Has Role") + doc.update(filters) + doc.save() 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/patches/v1_0/publish_batches.py b/lms/patches/v1_0/publish_batches.py new file mode 100644 index 00000000..7b16c4f1 --- /dev/null +++ b/lms/patches/v1_0/publish_batches.py @@ -0,0 +1,9 @@ +import frappe + + +def execute(): + frappe.reload_doc("lms", "doctype", "lms_batch") + batches = frappe.get_all("LMS Batch", pluck="name") + + for batch in batches: + frappe.db.set_value("LMS Batch", batch, "Published", 1) diff --git a/lms/patches/v1_0/publish_certificates.py b/lms/patches/v1_0/publish_certificates.py new file mode 100644 index 00000000..608f0d54 --- /dev/null +++ b/lms/patches/v1_0/publish_certificates.py @@ -0,0 +1,9 @@ +import frappe + + +def execute(): + frappe.reload_doc("lms", "doctype", "lms_certificate") + certificates = frappe.get_all("LMS Certificate", pluck="name") + + for certificate in certificates: + frappe.db.set_value("LMS Certificate", certificate, "published", 1) diff --git a/lms/plugins.py b/lms/plugins.py index 69571476..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} ) @@ -155,10 +187,38 @@ def youtube_video_renderer(video_id): """ +def embed_renderer(details): + type = details.split("|||")[0] + src = details.split("|||")[1] + width = "100%" + height = "400" + + if type == "pdf": + width = "75%" + height = "600" + + return f""" + + """ + + def video_renderer(src): - return ( - f"" - ) + return f"" + + +def audio_renderer(src): + return f"" + + +def pdf_renderer(src): + return f"" def assignment_renderer(detail): diff --git a/lms/public/css/style.css b/lms/public/css/style.css index 77f435fb..c6c9e99d 100644 --- a/lms/public/css/style.css +++ b/lms/public/css/style.css @@ -160,6 +160,10 @@ textarea.field-input { position: unset; } +.codex-editor--narrow .ce-toolbar__actions { + right: 100%; +} + .lesson-editor { border: 1px solid var(--gray-300); border-radius: var(--border-radius-md); @@ -781,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 { @@ -2053,8 +2058,8 @@ select { } .onboarding-parent { - background-color: var(--primary-light); - padding: 2rem 0; + background-color: var(--gray-100); + padding: 1rem 0; } .onboarding-steps { @@ -2339,4 +2344,142 @@ select { .batch-course-list .cards-parent { row-gap: 3rem +} + +.embed-tool__caption { + display: none; +} + +.card-buttons { + display: flex; + position: relative; + top: 10%; + left: 80%; + z-index: 10; + width: fit-content; +} + +.toastui-calendar-milestone { + display: none; +} + +.toastui-calendar-task { + display: none; +} + +.toastui-calendar-panel-resizer { + display: none; +} + +.toastui-calendar-day-name__date { + font-size: var(--text-base) !important; +} + +.toastui-calendar-day-name__name { + font-size: var(--text-base) !important; +} + +.toastui-calendar-day-view-day-names, .toastui-calendar-week-view-day-names { + border-bottom: none !important; +} + +.toastui-calendar-layout { + border: 1px solid var(--gray-200) !important; + border-radius: var(--border-radius-md) !important; + background-color: var(--gray-100) !important; +} + +.toastui-calendar-panel .toastui-calendar-day-names.toastui-calendar-week { + border-top: none !important; +} + +.toastui-calendar-panel.toastui-calendar-time { + height: 80% !important; +} + +.toastui-calendar-panel.toastui-calendar-week-view-day-names { + background-color: var(--gray-50) !important; +} + +.toastui-calendar-allday { + border-bottom: 1px solid var(--gray-200) !important; +} + +.calendar-navigation { + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 1rem; +} + +.calendar-range { + margin: 0 2rem; + font-weight: 500; + color: var(--text-color); +} + +.calendar-event-title { + font-size: var(--text-md); + font-weight: 500; + margin-top: 0.2rem; +} + +.legend-color { + width: 50px; + height: 20px; + border-radius: var(--border-radius-sm); + margin-right: 0.25rem; +} + +.legend-item { + display: flex; + align-items: center; +} + +.legend-text { + color: var(--text-color); + font-weight: 500; +} + +.calendar-legends { + display: flex; + align-items: center; + justify-content: space-between; + width: 50%; + margin: 0 auto 1rem; +} + +.batch-details { + width: 50%; + margin: 2rem 0; +} + +@media (max-width: 1000px) { + .batch-details { + width: 100%; + } +} + +.collapse-section { + font-size: var(--text-lg); + cursor: pointer; +} + +.collapse-section.collapsed .icon { + transition: all 0.5s; + -webkit-transform: rotate(180deg); + -moz-transform: rotate(180deg); + transform: rotate(180deg); +} + +.modal-body .ql-container { + max-height: unset !important; +} + +.questions-table .row-index { + display: none; +} + +.text-color { + color: var(--text-color); } \ No newline at end of file diff --git a/lms/public/js/common_functions.js b/lms/public/js/common_functions.js index cdafb590..e4cf8433 100644 --- a/lms/public/js/common_functions.js +++ b/lms/public/js/common_functions.js @@ -261,6 +261,24 @@ const open_batch_dialog = () => { reqd: 1, default: batch_info && batch_info.title, }, + { + fieldtype: "Check", + label: __("Published"), + fieldname: "published", + default: batch_info && batch_info.published, + }, + { + fieldtype: "Column Break", + }, + { + fieldtype: "Int", + label: __("Seat Count"), + fieldname: "seat_count", + default: batch_info && batch_info.seat_count, + }, + { + fieldtype: "Section Break", + }, { fieldtype: "Date", label: __("Start Date"), @@ -297,17 +315,12 @@ const open_batch_dialog = () => { fieldname: "end_time", default: batch_info && batch_info.end_time, }, - { - fieldtype: "Int", - label: __("Seat Count"), - fieldname: "seat_count", - default: batch_info && batch_info.seat_count, - }, { fieldtype: "Link", label: __("Category"), fieldname: "category", options: "LMS Category", + only_select: 1, default: batch_info && batch_info.category, }, { @@ -327,6 +340,18 @@ const open_batch_dialog = () => { default: batch_info && batch_info.batch_details, reqd: 1, }, + { + fieldtype: "HTML Editor", + label: __("Batch Details Raw"), + fieldname: "batch_details_raw", + default: batch_info && batch_info.batch_details_raw, + }, + { + fieldtype: "Attach Image", + label: __("Meta Image"), + fieldname: "meta_image", + default: batch_info && batch_info.meta_image, + }, { fieldtype: "Section Break", label: __("Pricing"), diff --git a/lms/templates/certificates_section.html b/lms/templates/certificates_section.html index 311d3ca6..df789e8c 100644 --- a/lms/templates/certificates_section.html +++ b/lms/templates/certificates_section.html @@ -1,5 +1,3 @@ -{% set certificates = get_certificates(user) %} - {% if certificates | length %}
{% for certificate in certificates %} diff --git a/lms/templates/emails/assignment_submission.html b/lms/templates/emails/assignment_submission.html new file mode 100644 index 00000000..2622a852 --- /dev/null +++ b/lms/templates/emails/assignment_submission.html @@ -0,0 +1,10 @@ +

+ {{ _("{0} has submitted the assignment {1}").format(frappe.bold(member_name), frappe.bold(assignment_title)) }} +

+
+

{{ _(" Please evaluate and grade it.") }}

+
` + + {{ _("Open Assignment") }} + + diff --git a/lms/templates/emails/batch_confirmation.html b/lms/templates/emails/batch_confirmation.html new file mode 100644 index 00000000..4e9ad30a --- /dev/null +++ b/lms/templates/emails/batch_confirmation.html @@ -0,0 +1,38 @@ +

+ {{ _("Dear ") }} {{ student_name }}, +

+
+

+ {{ _("I am pleased to inform you that your enrollment for the upcoming training batch has been successfully processed. Congratulations!") }} +

+
+

+ + {{ _("Important Details:") }} + +

+ +

+ {{ _("Batch Start Date:") }} {{ frappe.utils.format_date(start_date, "medium") }} +

+ +

+ {{ _("Medium:") }} {{ medium }} +

+ +

+ {{ _("Timings:") }} {{ frappe.utils.format_time(start_time, "hh:mm a") }} +

+
+

+ {{ _("Visit the following link to view your ") }} + {{ _("Batch Details") }} +

+

+ {{ _("If you have any questions or require assistance, feel free to contact us.") }} +

+
+

+ {{ _("Best Regards") }} +

+ diff --git a/lms/templates/emails/certification.html b/lms/templates/emails/certification.html new file mode 100644 index 00000000..3d47b1b9 --- /dev/null +++ b/lms/templates/emails/certification.html @@ -0,0 +1,21 @@ +

+ {{ _("Dear ") }} {{ student_name }}, +

+
+

+ {{ _("I am delighted to inform you that you have successfully earned your certification for the {0} course. Congratulations!").format(frappe.bold(course_title)) }} +

+
+

+ {{ _("With this certification, you can now showcase your updated skills and share your achievement with your colleagues and on LinkedIn. To access your certificate, please click on the link provided below.") }} +

+
+{{ _("Certificate Link") }} +
+

+ {{ _("Once again, congratulations on this significant accomplishment.")}} +

+
+

+ {{ _("Best Regards") }} +

\ No newline at end of file diff --git a/lms/templates/onboarding_header.html b/lms/templates/onboarding_header.html index 204e36ee..fdcd1cdf 100644 --- a/lms/templates/onboarding_header.html +++ b/lms/templates/onboarding_header.html @@ -4,7 +4,7 @@
{{ _("Skip") }}
-
+
{{ _("Get Started") }}
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.html b/lms/www/assignment_submission/assignment_submission.html index 3a25ee2c..253a8004 100644 --- a/lms/www/assignment_submission/assignment_submission.html +++ b/lms/www/assignment_submission/assignment_submission.html @@ -21,7 +21,7 @@
    {{ assignment.title }}
    - {% if submission.status %} + {% if assignment.grade_assignment and submission.status %} {% set color = "green" if submission.status == "Pass" else "red" if submission.status == "Fail" else "orange" %}
    {{ submission.status }} @@ -37,7 +37,7 @@
    - + {% if not assignment.show_answer or (assignment.show_answer and not submission) %}
    + {% endif %}
    @@ -52,7 +53,7 @@ {% macro SubmissionForm(assignment) %}
    - {% if submission.name %} + {% if assignment.grade_assignment and submission.name %}
    {{ _("You've successfully submitted the assignment. Once the moderator grades your submission, you'll find the details here. Feel free to make edits to your submission if needed.") }}
    @@ -73,7 +74,7 @@ {{ assignment.question }}
    - {% if assignment.type != "URL" %} + {% if assignment.type not in ["URL", "Text"] %}
    {{ _("Submit")}} @@ -100,17 +101,41 @@
    - {{ _("Submit")}} + {{ _("Submission")}}
    - {{ _("Enter a URL") }} + {% if assignment.type == "URL" %} + {{ _("Enter a {0}").format(assignment.type) }} + {% else %} + {{ _("Enter your response") }} + {% endif %}
    - + {% if assignment.type == "URL" %} + + {% else %} +
    + {% if submission.answer %} +
    + {{ submission.answer }} +
    + {% endif %} + {% endif %}
    {% endif %} - {% if is_moderator %} + {% if assignment.show_answer and submission %} +
    +
    + {{ _("Response by Instructor:") }} +
    +
    + {{ assignment.answer }} +
    +
    + {% endif %} + + {% if assignment.grade_assignment and is_moderator %}
    {{ _("Status") }} diff --git a/lms/www/assignment_submission/assignment_submission.js b/lms/www/assignment_submission/assignment_submission.js index d5147554..f336abc3 100644 --- a/lms/www/assignment_submission/assignment_submission.js +++ b/lms/www/assignment_submission/assignment_submission.js @@ -1,4 +1,10 @@ frappe.ready(() => { + if ($(".assignment-text").length) { + frappe.require("controls.bundle.js", () => { + make_text_editor(); + }); + } + $(".btn-upload").click((e) => { upload_file(e); }); @@ -52,11 +58,19 @@ const save_assignment = (e) => { file = ""; if (data == "URL") { - answer = $("#assignment-url").val(); + answer = $(".assignment-answer").val(); if (!answer) { frappe.throw({ - title: __("No URL"), - message: __("Please enter a URL."), + title: __("No Submission"), + message: __("Please enter a response."), + }); + } + } else if (data == "Text") { + answer = this.text_editor.get_value("assignment_text"); + if (!answer) { + frappe.throw({ + title: __("No Submission"), + message: __("Please enter a response."), }); } } else { @@ -99,3 +113,20 @@ const clear_preview = (e) => { $("#assignment-preview a").attr("href", ""); $("#assignment-preview .btn-close").addClass("hide"); }; + +const make_text_editor = () => { + this.text_editor = new frappe.ui.FieldGroup({ + fields: [ + { + fieldname: "assignment_text", + fieldtype: "Text Editor", + default: $(".assignment-text-data").html(), + }, + ], + body: $(".assignment-text").get(0), + }); + this.text_editor.make(); + $(".assignment-text .form-section:last").removeClass("empty-section"); + $(".assignment-text .frappe-control").removeClass("hide-control"); + $(".assignment-text .form-column").addClass("p-0"); +}; diff --git a/lms/www/assignment_submission/assignment_submission.py b/lms/www/assignment_submission/assignment_submission.py index a026679e..d1631a88 100644 --- a/lms/www/assignment_submission/assignment_submission.py +++ b/lms/www/assignment_submission/assignment_submission.py @@ -7,14 +7,17 @@ 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"] assignment = frappe.form_dict["assignment"] context.assignment = frappe.db.get_value( - "LMS Assignment", assignment, ["title", "name", "type", "question"], as_dict=1 + "LMS Assignment", + assignment, + ["title", "name", "type", "question", "show_answer", "answer", "grade_assignment"], + as_dict=1, ) if submission == "new-submission": @@ -34,6 +37,10 @@ def get_context(context): ], as_dict=True, ) + + if not context.submission: + raise frappe.PermissionError(_("Invalid Submission URL")) + if not context.is_moderator and frappe.session.user != context.submission.member: raise frappe.PermissionError(_("You don't have permission to access this page.")) 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/assignments/index.html b/lms/www/assignments/index.html index e04bc3f3..52374455 100644 --- a/lms/www/assignments/index.html +++ b/lms/www/assignments/index.html @@ -5,7 +5,7 @@ {% block content %}
    -
    +
    {{ Header() }} {% if assignments | length %} {{ AssignmentList(assignments) }} @@ -32,7 +32,33 @@ {% macro AssignmentList(assignments) %}
    -
      +
      +
      +
      +
      +
      + {{ _("Title") }} +
      +
      + {{ _("Type") }} +
      +
      +
      +
      + {% for assignment in assignments %} +
      +
      + + {{ assignment.title }} + +
      + {{ assignment.type }} +
      +
      +
      + {% endfor %} +
      +
    {% endmacro %} diff --git a/lms/www/assignments/index.py b/lms/www/assignments/index.py index 528b92f8..f8d9e3a4 100644 --- a/lms/www/assignments/index.py +++ b/lms/www/assignments/index.py @@ -1,10 +1,17 @@ import frappe +from lms.lms.utils import has_course_moderator_role def get_context(context): context.no_cache = 1 + + filters = {"owner": frappe.session.user} + + if has_course_moderator_role(): + filters = {} + context.assignments = frappe.get_all( "LMS Assignment", - {"owner": frappe.session.user}, + filters, ["title", "name", "type", "question"], ) diff --git a/lms/www/batch/edit.html b/lms/www/batch/edit.html index 6fa88e22..312e060f 100644 --- a/lms/www/batch/edit.html +++ b/lms/www/batch/edit.html @@ -66,13 +66,8 @@ {% macro CreateLesson() %}
    -
    -
    - {{ _("Title") }} -
    -
    - {{ _("Something Short and Concise") }} -
    +
    + {{ _("Title") }}
    @@ -80,46 +75,60 @@
    -
    +
    + +
    +
    +
    + {% if lesson.instructor_notes %} +
    {{ lesson.instructor_notes }}
    + {% endif %} +
    +
    {{ _("Content") }}
    -
    +
    {{ _("Add your lesson content here") }}
    -
    + +
    +
    +
    + {% if lesson.body %} -
    {{ lesson.body }}
    +
    {{ lesson.body }}
    {% endif %}
    -
    {% endmacro %} {%- block script %} {{ super() }} - {% if is_moderator %} - - {% endif %} - {{ include_script('controls.bundle.js') }} - + + {% endblock %} diff --git a/lms/www/batch/edit.js b/lms/www/batch/edit.js index dd0ae722..13010356 100644 --- a/lms/www/batch/edit.js +++ b/lms/www/batch/edit.js @@ -1,92 +1,161 @@ frappe.ready(() => { - frappe.telemetry.capture("on_lesson_creation_page", "lms"); let self = this; - this.quiz_in_lesson = []; + frappe.require("controls.bundle.js"); + frappe.telemetry.capture("on_lesson_creation_page", "lms"); + if ($("#current-lesson-content").length) { - parse_string_to_lesson(); + parse_string_to_lesson("lesson"); } - setup_editor(); - fetch_quiz_list(); + if ($("#current-instructor-notes").length) { + parse_string_to_lesson("notes"); + } + + setup_editor_for_lesson_content(); + setup_editor_for_instructor_notes(); $("#save-lesson").click((e) => { save_lesson(e); }); }); -const setup_editor = () => { +const setup_editor_for_lesson_content = () => { self.editor = new EditorJS({ holder: "lesson-content", - tools: { - header: { - class: Header, - inlineToolbar: ["bold", "italic", "link"], - config: { - levels: [4, 5, 6], - defaultLevel: 5, - }, - icon: ` - - `, - }, - paragraph: { - class: Paragraph, - inlineToolbar: true, - config: { - preserveBlank: true, - }, - }, - youtube: YouTubeVideo, - quiz: Quiz, - upload: Upload, - }, + tools: get_tools(), data: { - blocks: self.blocks ? self.blocks : [], + blocks: self.lesson_blocks || [], }, }); }; -const parse_string_to_lesson = () => { - let lesson_content = $("#current-lesson-content").html(); - let lesson_blocks = []; +const setup_editor_for_instructor_notes = () => { + self.instructor_notes_editor = new EditorJS({ + holder: "instructor-notes", + tools: get_tools(), + data: { + blocks: self.notes_blocks || [], + }, + }); +}; - lesson_content.split("\n").forEach((block) => { +const get_tools = () => { + return { + embed: { + class: Embed, + config: { + services: { + youtube: true, + vimeo: true, + codepen: true, + slides: { + regex: /https:\/\/docs\.google\.com\/presentation\/d\/e\/([A-Za-z0-9_-]+)\/pub/, + embedUrl: + "https://docs.google.com/presentation/d/e/<%= remote_id %>/embed", + html: "", + }, + }, + }, + }, + header: { + class: Header, + inlineToolbar: ["bold", "italic", "link"], + config: { + levels: [4, 5, 6], + defaultLevel: 5, + }, + icon: ` + + `, + }, + paragraph: { + class: Paragraph, + inlineToolbar: true, + config: { + preserveBlank: true, + }, + }, + youtube: YouTubeVideo, + quiz: Quiz, + upload: Upload, + }; +}; + +const parse_string_to_lesson = (type) => { + let content; + let blocks = []; + + if (type == "lesson") { + content = $("#current-lesson-content").html(); + } else if (type == "notes") { + content = $("#current-instructor-notes").html(); + } + + content.split("\n").forEach((block) => { if (block.includes("{{ YouTubeVideo")) { - let youtube_id = block.match(/'([^']+)'/)[1]; - lesson_blocks.push({ + let youtube_id = block.match(/\(["']([^"']+?)["']\)/)[1]; + blocks.push({ type: "youtube", data: { youtube: youtube_id, }, }); } else if (block.includes("{{ Quiz")) { - let quiz = block.match(/'([^']+)'/)[1]; - this.quiz_in_lesson.push(quiz); - lesson_blocks.push({ + let quiz = block.match(/\(["']([^"']+?)["']\)/)[1]; + blocks.push({ type: "quiz", data: { - quiz: [quiz], + quiz: quiz, }, }); } else if (block.includes("{{ Video")) { - let video = block.match(/'([^']+)'/)[1]; - lesson_blocks.push({ + let video = block.match(/\(["']([^"']+?)["']\)/)[1]; + blocks.push({ type: "upload", data: { file_url: video, + file_type: "video", + }, + }); + } else if (block.includes("{{ Audio")) { + let audio = block.match(/\(["']([^"']+?)["']\)/)[1]; + blocks.push({ + type: "upload", + data: { + file_url: audio, + file_type: "audio", + }, + }); + } else if (block.includes("{{ PDF")) { + let pdf = block.match(/\(["']([^"']+?)["']\)/)[1]; + blocks.push({ + type: "upload", + data: { + file_url: pdf, + file_type: "pdf", + }, + }); + } else if (block.includes("{{ Embed")) { + let embed = block.match(/\(["']([^"']+?)["']\)/)[1]; + blocks.push({ + type: "embed", + data: { + service: embed.split("|||")[0], + embed: embed.split("|||")[1], }, }); } else if (block.includes("![]")) { let image = block.match(/\((.*?)\)/)[1]; - lesson_blocks.push({ + blocks.push({ type: "upload", data: { file_url: image, + file_type: "image", }, }); } else if (block.includes("#")) { let level = (block.match(/#/g) || []).length; - lesson_blocks.push({ + blocks.push({ type: "header", data: { text: block.replace(/#/g, "").trim(), @@ -94,7 +163,7 @@ const parse_string_to_lesson = () => { }, }); } else { - lesson_blocks.push({ + blocks.push({ type: "paragraph", data: { text: block, @@ -103,52 +172,73 @@ const parse_string_to_lesson = () => { } }); - this.blocks = lesson_blocks; + if (type == "lesson") { + this.lesson_blocks = blocks; + } else if (type == "notes") { + this.notes_blocks = blocks; + } }; const save_lesson = (e) => { self.editor.save().then((outputData) => { - parse_lesson_to_string(outputData); + parse_content_to_string(outputData, "lesson"); + + self.instructor_notes_editor.save().then((outputData) => { + parse_content_to_string(outputData, "notes"); + save(); + }); }); }; -const parse_lesson_to_string = (data) => { +const parse_content_to_string = (data, type) => { let lesson_content = ""; data.blocks.forEach((block) => { if (block.type == "youtube") { lesson_content += `{{ YouTubeVideo("${block.data.youtube}") }}\n`; } else if (block.type == "quiz") { - block.data.quiz.forEach((quiz) => { - lesson_content += `{{ Quiz("${quiz}") }}\n`; - }); + lesson_content += `{{ Quiz("${block.data.quiz}") }}\n`; } else if (block.type == "upload") { let url = block.data.file_url; - lesson_content += block.data.is_video - ? `{{ Video("${url}") }}\n` - : `![](${url})`; + if (block.data.file_type == "video") { + lesson_content += `{{ Video("${url}") }}\n`; + } else if (block.data.file_type == "audio") { + lesson_content += `{{ Audio("${url}") }}\n`; + } else if (block.data.file_type == "pdf") { + lesson_content += `{{ PDF("${url}") }}\n`; + } else { + lesson_content += `![](${url})`; + } } else if (block.type == "header") { lesson_content += "#".repeat(block.data.level) + ` ${block.data.text}\n`; } else if (block.type == "paragraph") { lesson_content += `${block.data.text}\n`; + } else if (block.type == "embed") { + lesson_content += `{{ Embed("${ + block.data.service + }|||${block.data.embed.replace(/&/g, "&")}") }}\n`; } }); - save(lesson_content); + if (type == "lesson") { + this.lesson_content_data = lesson_content; + } else if (type == "notes") { + this.instructor_notes_data = lesson_content; + } }; -const save = (lesson_content) => { - validate_mandatory(lesson_content); +const save = () => { + validate_mandatory(this.lesson_content_data); let lesson = $("#lesson-title").data("lesson"); - frappe.call({ method: "lms.lms.doctype.lms_course.lms_course.save_lesson", args: { title: $("#lesson-title").val(), - body: lesson_content, + body: this.lesson_content_data, chapter: $("#lesson-title").data("chapter"), preview: $("#preview").prop("checked") ? 1 : 0, idx: $("#lesson-title").data("index"), lesson: lesson ? lesson : "", + instructor_notes: this.instructor_notes_data, }, callback: (data) => { frappe.show_alert({ @@ -184,19 +274,26 @@ const validate_mandatory = (lesson_content) => { } }; -const fetch_quiz_list = () => { - frappe.call({ - method: "lms.lms.doctype.lms_quiz.lms_quiz.get_user_quizzes", - callback: (r) => { - self.quiz_list = r.message; - }, - }); -}; - -const is_video = (url) => { +const get_file_type = (url) => { let video_types = ["mov", "mp4", "mkv"]; let video_extension = url.split(".").pop(); - return video_types.indexOf(video_extension) >= 0; + + if (video_types.indexOf(video_extension) >= 0) { + return "video"; + } + + let audio_types = ["mp3", "wav", "ogg"]; + let audio_extension = url.split(".").pop(); + + if (audio_types.indexOf(audio_extension) >= 0) { + return "audio"; + } + + if (url.split(".").pop() == "pdf") { + return "pdf"; + } + + return "image"; }; class YouTubeVideo { @@ -290,57 +387,10 @@ class Quiz { this.data = data; } - get_fields() { - let fields = [ - { - fieldname: "start_section", - fieldtype: "Section Break", - label: __( - "To create a new quiz, click on the button below. Once you have created the new quiz you can come back to this lesson and add it from here." - ), - }, - { - fieldname: "create_quiz", - fieldtype: "Button", - label: __("Create Quiz"), - click: () => { - window.location.href = "/quizzes"; - }, - }, - { - fieldname: "quiz_information", - fieldtype: "HTML", - options: __("OR"), - }, - { - fieldname: "quiz_list_section", - fieldtype: "Section Break", - label: __("Select a exisitng quiz to add to this lesson."), - }, - ]; - let break_index = Math.ceil(self.quiz_list.length / 2) + 4; - - self.quiz_list.forEach((quiz) => { - fields.push({ - fieldname: quiz.name, - fieldtype: "Check", - label: quiz.title, - default: self.quiz_in_lesson.includes(quiz.name) ? 1 : 0, - read_only: self.quiz_in_lesson.includes(quiz.name) ? 1 : 0, - }); - }); - - fields.splice(break_index, 0, { - fieldname: "column_break", - fieldtype: "Column Break", - }); - return fields; - } - render() { this.wrapper = document.createElement("div"); if (this.data && this.data.quiz) { - $(this.wrapper).html(this.render_quiz()); + $(this.wrapper).html(this.render_quiz(this.data.quiz)); } else { this.render_quiz_dialog(); } @@ -349,16 +399,24 @@ class Quiz { render_quiz_dialog() { let me = this; - let fields = this.get_fields(); let quizdialog = new frappe.ui.Dialog({ title: __("Manage Quiz"), - fields: fields, + fields: [ + { + fieldname: "quiz", + fieldtype: "Link", + label: __("Quiz"), + options: "LMS Quiz", + only_select: 1, + }, + ], primary_action_label: __("Insert"), primary_action(values) { - me.analyze_quiz_list(values); + me.quiz = values.quiz; quizdialog.hide(); + $(me.wrapper).html(me.render_quiz(me.quiz)); }, - secondary_action_label: __("Create New Quiz"), + secondary_action_label: __("Create New"), secondary_action: () => { window.location.href = `/quizzes`; }, @@ -370,38 +428,19 @@ class Quiz { }, 1000); } - analyze_quiz_list(values) { - /* If quiz is selected and is not already in the lesson then render it.*/ - - this.quiz_to_render = []; - Object.keys(values).forEach((key) => { - if (values[key] === 1 && !self.quiz_in_lesson.includes(key)) { - self.quiz_in_lesson.push(key); - this.quiz_to_render.push(key); - } - }); - - $(this.wrapper).html(this.render_quiz()); - } - - render_quiz() { - let html = ``; - let quiz_list = this.data.quiz || this.quiz_to_render; - quiz_list.forEach((quiz) => { - html += `
    - Quiz: ${quiz} -
    `; - }); - return html; + render_quiz(quiz) { + return ` + Quiz: ${quiz} + `; } validate(savedData) { - return !savedData.quiz || !savedData.quiz.length ? false : true; + return !savedData.quiz || !savedData.quiz.trim() ? false : true; } save(block_content) { return { - quiz: this.data.quiz || this.quiz_to_render, + quiz: this.data.quiz || this.quiz, }; } } @@ -435,7 +474,7 @@ class Upload { folder: "Home/Attachments", make_attachments_public: true, restrictions: { - allowed_file_types: ["image/*", "video/*"], + allowed_file_types: ["image/*", "video/*", "audio/*", ".pdf"], }, on_success: (file_doc) => { self.file_url = file_doc.file_url; @@ -445,11 +484,19 @@ class Upload { } render_upload(url) { - this.is_video = is_video(url); - if (this.is_video) { - return `
    - {% if batch_info.custom_component %} - {{ batch_info.custom_component }} +
    + {{ batch_info.custom_component }} +
    {% endif %}
    {% endmacro %} @@ -87,8 +88,7 @@
    - {% if is_student %} + {% if settings.show_dashboard and is_student %}
    {{ Dashboard(batch_info, batch_courses, current_student) }}
    {% endif %} + {% if settings.show_courses %}
    {{ CoursesSection(batch_info, batch_courses) }}
    + {% endif %} - {% if flow | length %} -
    - {{ ScheduleSection(flow) }} + {% if show_timetable %} +
    + {{ Timetable() }}
    {% endif %} {% if is_moderator %} -
    - {{ StudentsSection(batch_info, batch_students) }} -
    + {% if settings.show_students %} +
    + {{ StudentsSection(batch_info, batch_students) }} +
    + {% endif %} -
    - {{ AssessmentsSection(batch_info) }} -
    + {% if settings.show_assessments %} +
    + {{ AssessmentsSection(batch_info) }} +
    + {% endif %} + + {% if settings.show_emails %} +
    + {{ EmailsSection() }} +
    + {% endif %} {% endif %} {% if batch_students | length and (is_moderator or is_student or is_evaluator) %} -
    - {{ Discussions(batch_info) }} -
    + {% if settings.show_discussions %} +
    + {{ Discussions(batch_info) }} +
    + {% endif %} -
    - {{ LiveClassSection(batch_info, live_classes) }} -
    + {% if settings.show_live_class %} +
    + {{ LiveClassSection(batch_info, live_classes) }} +
    + {% endif %} + {% endif %} + + {% if custom_tabs_content %} + {% include custom_tabs_content %} {% endif %}
    @@ -205,17 +246,19 @@ {% set assessments = current_student.assessments %} {% set student = current_student %} -{% if student.name == frappe.session.user %} - -{% endif %} +
    + {% if student.name == frappe.session.user %} + + {% endif %} -
    - {% include "lms/templates/upcoming_evals.html" %} -
    -
    - {% include "lms/templates/assessments.html" %} +
    + {% include "lms/templates/upcoming_evals.html" %} +
    +
    + {% include "lms/templates/assessments.html" %} +
    {% endmacro %} @@ -361,6 +404,41 @@ {% endmacro %} + +{% macro EmailsSection() %} +
    + +
    +
    + {% for email in batch_emails %} +
    +
    + + + {% set member = frappe.db.get_value("User", email.sender, ["full_name", "username", "name", "user_image"], as_dict=1) %} + {{ widgets.Avatar(member=member, avatar_class="avatar-small") }} + + + {{ member.full_name }} +
    + + {{ frappe.utils.pretty_date(email.communication_date) }} + +
    +
    +
    +
    +
    + {{ email.content }} +
    +
    + {% endfor %} +
    +{% endmacro %} + + {% macro AssessmentList(assessments) %} {% if assessments | length %}
    @@ -424,7 +502,7 @@ {{ CreateLiveClass(batch_info) }} {{ LiveClassList(batch_info, live_classes) }} -
    + {% endmacro %} @@ -513,86 +591,56 @@ {% endmacro %} -{% macro ScheduleSection(flow) %} +{% macro Timetable() %}
    - {{ _("Schedule") }} + {{ _("Timetable") }}
    - -
    - {% for chapter in flow %} -
    -
    - -
    - {{ chapter.chapter_title }} -
    -
    - -
    +
    + + + +
    +
    + {% for legend in legends %} +
    +
    +
    {{ legend.label }}
    +
    {% endfor %}
    +
    +
    +
    {% endmacro %} {%- block script %} {{ super() }} + {% if batch_info.custom_script %} + + {% endif %} + + + + {% endblock %} \ No newline at end of file diff --git a/lms/www/batches/batch.js b/lms/www/batches/batch.js index 1fdefaa3..111c1307 100644 --- a/lms/www/batches/batch.js +++ b/lms/www/batches/batch.js @@ -2,6 +2,24 @@ frappe.ready(() => { let self = this; frappe.require("controls.bundle.js"); + if ($("#calendar").length) { + setup_timetable(); + } + + if ($("#calendar").length) { + $(document).on("click", "#prev-week", (e) => { + this.calendar_ && this.calendar_.prev(); + set_calendar_range(this.calendar_, this.events); + }); + } + + if ($("#calendar").length) { + $(document).on("click", "#next-week", (e) => { + this.calendar_ && this.calendar_.next(); + set_calendar_range(this.calendar_, this.events); + }); + } + if ($("#live-class-form").length) { setTimeout(() => { make_live_class_form(); @@ -45,6 +63,10 @@ frappe.ready(() => { $(document).on("click", ".slot", (e) => { mark_active_slot(e); }); + + $(".btn-email").click((e) => { + email_to_students(); + }); }); const create_live_class = (e) => { @@ -495,6 +517,10 @@ const open_evaluation_form = (e) => { }, filter_description: " ", only_select: 1, + change: () => { + this.eval_form.set_value("date", ""); + $("[data-fieldname='slots']").html(""); + }, }, { fieldtype: "Date", @@ -505,7 +531,7 @@ const open_evaluation_form = (e) => { frappe.datetime.add_days(frappe.datetime.get_today(), 1) ), change: () => { - get_slots(); + if (this.eval_form.get_value("date")) get_slots(); }, }, { @@ -530,7 +556,7 @@ const get_slots = () => { args: { course: this.eval_form.get_value("course"), date: this.eval_form.get_value("date"), - batch_name: $(".class-details").data("batch"), + batch: $(".class-details").data("batch"), }, callback: (r) => { if (r.message) { @@ -606,3 +632,246 @@ const submit_evaluation_form = (values) => { }, }); }; + +const setup_timetable = () => { + let self = this; + frappe.call({ + method: "lms.lms.doctype.lms_batch.lms_batch.get_batch_timetable", + args: { + batch: $(".class-details").data("batch"), + }, + callback: (r) => { + if (r.message.length) { + setup_calendar(r.message); + self.events = r.message; + } + }, + }); +}; + +const setup_calendar = (events) => { + const element = $("#calendar"); + const Calendar = tui.Calendar; + const calendar_id = "calendar1"; + const container = element[0]; + 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); + set_calendar_range(calendar, events); +}; + +const get_calendar_options = (element, calendar_id) => { + const start_time = element.data("start"); + const end_time = element.data("end"); + + return { + defaultView: "week", + usageStatistics: false, + week: { + narrowWeekend: true, + hourStart: parseInt(start_time.split(":")[0]) - 1, + /* hourEnd: parseInt(end_time.split(":")[0]) + 1, */ + }, + month: { + narrowWeekend: true, + }, + taskView: false, + isReadOnly: true, + calendars: [ + { + id: calendar_id, + name: "Timetable", + backgroundColor: "var(--fg-color)", + }, + ], + template: { + time: function (event) { + let hide = event.raw.completed ? "" : "hide"; + return `
    + +
    ${frappe.datetime.get_time(event.start.d.d)} - + ${frappe.datetime.get_time(event.end.d.d)}
    +
    ${event.title}
    +
    `; + }, + }, + }; +}; + +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, + title: event.title, + start: `${event.date}T${event.start_time}`, + end: `${event.date}T${event.end_time}`, + isAllday: event.start_time ? false : true, + borderColor: clr, + backgroundColor: "var(--fg-color)", + customStyle: { + borderRadius: "var(--border-radius-md)", + boxShadow: "var(--shadow-base)", + borderWidth: "8px", + padding: "0.25rem 0.5rem 0.5rem", + }, + raw: { + url: event.url, + milestone: event.milestone, + name: event.name, + idx: event.idx, + parent: event.parent, + completed: event.completed, + }, + }); + }); + + calendar.createEvents(calendar_events); +}; + +const add_links_to_events = (calendar) => { + calendar.on("clickEvent", ({ event }) => { + 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 (!moment(event_date).isSameOrBefore(current_date) && !allow_future) + return; + + if (event.raw.milestone) { + frappe.call({ + method: "lms.lms.doctype.lms_batch.lms_batch.is_milestone_complete", + args: { + idx: event.raw.idx, + batch: event.raw.parent, + }, + callback: (data) => { + if (data.message) window.open(event.raw.url, "_blank"); + else + frappe.show_alert({ + message: + "Please complete all previous activities to proceed.", + indicator: "red", + }); + }, + }); + } else window.open(event.raw.url, "_blank"); + }); +}; + +const scroll_to_date = (calendar, events) => { + if ( + new Date() < new Date(events[0].date) || + new Date() > new Date(events.slice(-1).date) + ) { + calendar.setDate(new Date(events[0].date)); + } +}; + +const set_calendar_range = (calendar, events) => { + let week_start = moment(calendar.getDateRangeStart().d.d); + let week_end = moment(calendar.getDateRangeEnd().d.d); + + $(".calendar-range").text( + `${moment(week_start).format("DD MMMM YYYY")} - ${moment( + week_end + ).format("DD MMMM YYYY")}` + ); + + if (week_start.diff(moment(events[0].date), "days") <= 0) + $("#prev-week").hide(); + else $("#prev-week").show(); + + if (week_end.diff(moment(events.slice(-1)[0].date), "days") > 0) + $("#next-week").hide(); + else $("#next-week").show(); +}; + +const get_background_color = (doctype) => { + const match = legends.filter((legend) => { + return legend.reference_doctype == doctype; + }); + if (match.length) return match[0].color; +}; + +const email_to_students = () => { + this.email_dialog = new frappe.ui.Dialog({ + title: __("Email to Students"), + fields: [ + { + fieldtype: "Data", + fieldname: "subject", + label: __("Subject"), + reqd: 1, + }, + { + fieldtype: "Data", + fieldname: "reply_to", + label: __("Reply To"), + reqd: 0, + }, + { + fieldtype: "Text Editor", + fieldname: "message", + label: __("Message"), + reqd: 1, + max_height: 100, + min_lines: 5, + }, + ], + primary_action: (values) => { + send_email(values); + }, + }); + this.email_dialog.show(); +}; + +const send_email = (values) => { + frappe.call({ + method: "frappe.client.get_list", + args: { + doctype: "Batch Student", + parent: "LMS Batch", + fields: ["student"], + filters: { + parent: $(".class-details").data("batch"), + }, + }, + callback: (data) => { + send_email_to_students(data.message, values); + }, + }); +}; + +const send_email_to_students = (students, values) => { + students = students.map((row) => row.student); + frappe.call({ + method: "frappe.core.doctype.communication.email.make", + args: { + recipients: students.join(", "), + cc: values.reply_to, + subject: values.subject, + content: values.message, + doctype: "LMS Batch", + name: $(".class-details").data("batch"), + send_email: 1, + }, + callback: (r) => { + this.email_dialog.hide(); + frappe.show_alert({ + message: __("Email sent successfully"), + indicator: "green", + }); + setTimeout(() => { + window.location.reload(); + }, 2000); + }, + }); +}; diff --git a/lms/www/batches/batch.py b/lms/www/batches/batch.py index 83fac64a..ab339e15 100644 --- a/lms/www/batches/batch.py +++ b/lms/www/batches/batch.py @@ -1,6 +1,6 @@ from frappe import _ import frappe -from frappe.utils import getdate, cint +from frappe.utils import getdate from lms.www.utils import get_assessments, is_student from lms.lms.utils import ( has_course_moderator_role, @@ -17,13 +17,13 @@ from lms.lms.utils import ( def get_context(context): context.no_cache = 1 - class_name = frappe.form_dict["batchname"] + batch_name = frappe.form_dict["batchname"] context.is_moderator = has_course_moderator_role() context.is_evaluator = has_course_evaluator_role() context.batch_info = frappe.db.get_value( "LMS Batch", - class_name, + batch_name, [ "name", "title", @@ -32,6 +32,7 @@ def get_context(context): "description", "medium", "custom_component", + "custom_script", "seat_count", "start_time", "end_time", @@ -40,25 +41,27 @@ def get_context(context): "amount", "currency", "batch_details", + "published", + "allow_future", ], as_dict=True, ) context.reference_doctype = "LMS Batch" - context.reference_name = class_name + context.reference_name = batch_name batch_courses = frappe.get_all( "Batch Course", - {"parent": class_name}, + {"parent": batch_name}, ["name", "course", "title"], - order_by="creation desc", + order_by="idx", ) batch_students = frappe.get_all( "Batch Student", - {"parent": class_name}, + {"parent": batch_name}, ["name", "student", "student_name", "username"], - order_by="creation desc", + order_by="idx", ) context.batch_courses = get_class_course_details(batch_courses) @@ -67,31 +70,51 @@ def get_context(context): "LMS Course", fields=["name", "title"], limit_page_length=0 ) context.course_name_list = [course.course for course in context.batch_courses] - context.assessments = get_assessments(class_name) + context.assessments = get_assessments(batch_name) + context.batch_emails = frappe.get_all( + "Communication", + filters={"reference_doctype": "LMS Batch", "reference_name": batch_name}, + fields=["subject", "content", "recipients", "cc", "communication_date", "sender"], + order_by="communication_date desc", + ) + context.batch_students = get_class_student_details( batch_students, batch_courses, context.assessments ) - context.is_student = is_student(class_name) + context.is_student = is_student(batch_name) if not context.is_student and not context.is_moderator and not context.is_evaluator: raise frappe.PermissionError(_("You don't have permission to access this page.")) context.live_classes = frappe.get_all( "LMS Live Class", - {"class_name": class_name, "date": [">=", getdate()]}, + {"batch_name": batch_name, "date": [">=", getdate()]}, ["title", "description", "time", "date", "start_url", "join_url", "owner"], order_by="date", ) context.current_student = ( - get_current_student_details(batch_courses, class_name) if context.is_student else None + get_current_student_details(batch_courses, batch_name) if context.is_student else None ) - context.all_assignments = get_all_assignments(class_name) - context.all_quizzes = get_all_quizzes(class_name) - context.flow = get_scheduled_flow(class_name) + context.all_assignments = get_all_assignments(batch_name) + context.all_quizzes = get_all_quizzes(batch_name) + context.show_timetable = frappe.db.count( + "LMS Batch Timetable", + { + "parent": batch_name, + }, + ) + context.legends = get_legends(batch_name) + context.settings = frappe.get_single("LMS Settings") + + custom_tabs = frappe.get_hooks("lms_batch_tabs") + if custom_tabs: + context.custom_tabs_header = custom_tabs.get("header_html")[0] + context.custom_tabs_content = custom_tabs.get("content_html")[0] + context.update(frappe.get_attr(custom_tabs.get("context")[0])()) -def get_all_quizzes(class_name): +def get_all_quizzes(batch_name): filters = {} if has_course_moderator_role() else {"owner": frappe.session.user} all_quizzes = frappe.get_all("LMS Quiz", filters, ["name", "title"]) for quiz in all_quizzes: @@ -100,13 +123,13 @@ def get_all_quizzes(class_name): "doctype": "LMS Assessment", "assessment_type": "LMS Quiz", "assessment_name": quiz.name, - "parent": class_name, + "parent": batch_name, } ) return all_quizzes -def get_all_assignments(class_name): +def get_all_assignments(batch_name): filters = {} if has_course_moderator_role() else {"owner": frappe.session.user} all_assignments = frappe.get_all("LMS Assignment", filters, ["name", "title"]) for assignment in all_assignments: @@ -115,7 +138,7 @@ def get_all_assignments(class_name): "doctype": "LMS Assessment", "assessment_type": "LMS Assignment", "assessment_name": assignment.name, - "parent": class_name, + "parent": batch_name, } ) return all_assignments @@ -209,39 +232,7 @@ def sort_students(batch_students): return batch_students -def get_scheduled_flow(class_name): - chapters = [] - - lessons = frappe.get_all( - "Scheduled Flow", - {"parent": class_name}, - ["name", "lesson", "date", "start_time", "end_time"], - order_by="idx", - ) - - for lesson in lessons: - lesson = get_lesson_details(lesson, class_name) - chapter_exists = [ - chapter for chapter in chapters if chapter.chapter == lesson.chapter - ] - - if len(chapter_exists) == 0: - chapters.append( - frappe._dict( - { - "chapter": lesson.chapter, - "chapter_title": frappe.db.get_value("Course Chapter", lesson.chapter, "title"), - "lessons": [lesson], - } - ) - ) - else: - chapter_exists[0]["lessons"].append(lesson) - - return chapters - - -def get_lesson_details(lesson, class_name): +def get_lesson_details(lesson, batch_name): lesson.update( frappe.db.get_value( "Course Lesson", @@ -251,19 +242,19 @@ def get_lesson_details(lesson, class_name): ) ) lesson.index = get_lesson_index(lesson.lesson) - lesson.url = get_lesson_url(lesson.course, lesson.index) + "?class=" + class_name + lesson.url = get_lesson_url(lesson.course, lesson.index) + "?class=" + batch_name lesson.icon = get_lesson_icon(lesson.body) return lesson -def get_current_student_details(batch_courses, class_name): +def get_current_student_details(batch_courses, batch_name): student_details = frappe._dict() student_details.courses = frappe._dict() course_list = [course.course for course in batch_courses] get_course_progress(batch_courses, student_details) student_details.name = frappe.session.user - student_details.assessments = get_assessments(class_name, frappe.session.user) + student_details.assessments = get_assessments(batch_name, frappe.session.user) student_details.upcoming_evals = get_upcoming_evals(frappe.session.user, course_list) return student_details @@ -276,3 +267,11 @@ def get_course_progress(batch_courses, student_details): student_details.courses[course.course] = membership.progress else: student_details.courses[course.course] = 0 + + +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/batches/batch_details.html b/lms/www/batches/batch_details.html index 1787a2da..9fd5c93e 100644 --- a/lms/www/batches/batch_details.html +++ b/lms/www/batches/batch_details.html @@ -14,6 +14,7 @@ {{ CourseList(courses) }}
    + {{ BatchDetailsRaw() }}
    {% endblock %} @@ -136,9 +137,9 @@ {% endif %}
    - {% if is_moderator or is_evaluator or is_student %} + {% if is_moderator or is_evaluator %} - {{ _("Checkout Batch") }} + {{ _("Manage Batch") }} {% elif batch_info.paid_batch %} -
    - {{ batch_info.batch_details }} -
    +
    + {{ batch_info.batch_details }}
    {% endmacro %} {% macro CourseList(courses) %} +{% if courses | length or is_moderator %}
    - {% if is_moderator %} - - {% endif %} -
    - {{ _("Courses") }} + +
    +
    + {{ _("Courses") }} +
    + {% if is_moderator %} + + {% endif %}
    + {% if courses | length %}
    {% for course in courses %}
    - {{ widgets.CourseCard(course=course, read_only=False) }} {% if is_moderator %} -
    +
    {% endif %} + {{ widgets.CourseCard(course=course, read_only=False) }}
    {% endfor %}
    @@ -212,6 +216,16 @@
    {% endif %}
    +{% endif %} +{% endmacro %} + + +{% macro BatchDetailsRaw() %} + {% if batch_info.batch_details_raw %} +
    + {{ batch_info.batch_details_raw }} +
    + {% endif %} {% endmacro %} {%- block script %} diff --git a/lms/www/batches/batch_details.js b/lms/www/batches/batch_details.js index 4774dfe2..92070ece 100644 --- a/lms/www/batches/batch_details.js +++ b/lms/www/batches/batch_details.js @@ -31,6 +31,7 @@ const show_course_modal = (e) => { reqd: 1, only_select: 1, default: course || "", + read_only: course ? 1 : 0, }, { fieldtype: "Link", @@ -48,6 +49,9 @@ const show_course_modal = (e) => { }, }); course_modal.show(); + setTimeout(() => { + $(".modal-body").css("min-height", "300px"); + }, 1000); }; const add_course = (values, course_name) => { diff --git a/lms/www/batches/batch_details.py b/lms/www/batches/batch_details.py index d7d59b61..a4f4db4f 100644 --- a/lms/www/batches/batch_details.py +++ b/lms/www/batches/batch_details.py @@ -1,5 +1,10 @@ import frappe -from lms.lms.utils import has_course_moderator_role, has_course_evaluator_role +from frappe import _ +from lms.lms.utils import ( + has_course_moderator_role, + has_course_evaluator_role, + check_multicurrency, +) from lms.www.utils import is_student @@ -20,18 +25,41 @@ def get_context(context): "paid_batch", "amount", "currency", + "category", + "medium", "start_time", "end_time", "seat_count", + "published", + "meta_image", + "batch_details_raw", ], as_dict=1, ) + if context.batch_info.amount and context.batch_info.currency: + amount, currency = check_multicurrency( + context.batch_info.amount, context.batch_info.currency + ) + context.batch_info.amount = amount + context.batch_info.currency = currency + + context.is_moderator = has_course_moderator_role() + context.is_evaluator = has_course_evaluator_role() + context.is_student = is_student(batch_name) + + if not context.is_moderator and not context.batch_info.published: + raise frappe.PermissionError(_("You do not have permission to access this page.")) + + if context.is_student: + frappe.local.flags.redirect_location = f"/batches/{batch_name}" + raise frappe.Redirect + context.courses = frappe.get_all( "Batch Course", {"parent": batch_name}, ["name as batch_course", "course", "title", "evaluator"], - order_by="creation desc", + order_by="idx", ) for course in context.courses: @@ -44,6 +72,10 @@ def get_context(context): context.student_count = frappe.db.count("Batch Student", {"parent": batch_name}) context.seats_left = context.batch_info.seat_count - context.student_count - context.is_moderator = has_course_moderator_role() - context.is_evaluator = has_course_evaluator_role() - context.is_student = is_student(batch_name) + context.metatags = { + "title": context.batch_info.title, + "image": context.batch_info.meta_image, + "description": context.batch_info.description, + "keywords": context.batch_info.title, + "og:type": "website", + } diff --git a/lms/www/batches/index.html b/lms/www/batches/index.html index bf5f998f..d05bf6f5 100644 --- a/lms/www/batches/index.html +++ b/lms/www/batches/index.html @@ -7,8 +7,8 @@
    {{ Header() }} - {% if past_batches | length or upcoming_batches | length %} - {{ BatchTabs(past_batches, upcoming_batches, my_batches) }} + {% if past_batches | length or upcoming_batches | length or private_batches | length %} + {{ BatchTabs(past_batches, upcoming_batches, private_batches, my_batches) }} {% else %} {{ EmptyState() }} {% endif %} @@ -27,7 +27,7 @@ {% endmacro %} -{% macro BatchTabs(past_batches, upcoming_batches, my_batches) %} +{% macro BatchTabs(past_batches, upcoming_batches, private_batches, my_batches) %}
    {% endmacro %} diff --git a/lms/www/batches/index.py b/lms/www/batches/index.py index 206b87a7..6112fdc2 100644 --- a/lms/www/batches/index.py +++ b/lms/www/batches/index.py @@ -1,6 +1,10 @@ import frappe from frappe.utils import getdate -from lms.lms.utils import has_course_moderator_role, has_course_evaluator_role +from lms.lms.utils import ( + has_course_moderator_role, + has_course_evaluator_role, + check_multicurrency, +) def get_context(context): @@ -19,25 +23,34 @@ def get_context(context): "amount", "currency", "seat_count", + "published", ], order_by="start_date", ) - past_batches, upcoming_batches = [], [] + past_batches, upcoming_batches, private_batches = [], [], [] for batch in batches: batch.student_count = frappe.db.count("Batch Student", {"parent": batch.name}) batch.course_count = frappe.db.count("Batch Course", {"parent": batch.name}) + + if batch.amount and batch.currency: + amount, currency = check_multicurrency(batch.amount, batch.currency) + batch.amount = amount + batch.currency = currency + batch.seats_left = ( batch.seat_count - batch.student_count if batch.seat_count else None ) - print(batch.seat_count, batch.student_count, batch.seats_left) - if getdate(batch.start_date) < getdate(): + if not batch.published: + private_batches.append(batch) + elif getdate(batch.start_date) <= getdate(): past_batches.append(batch) else: upcoming_batches.append(batch) - context.past_batches = sorted(past_batches, key=lambda d: d.start_date) + context.past_batches = sorted(past_batches, key=lambda d: d.start_date, reverse=True) context.upcoming_batches = sorted(upcoming_batches, key=lambda d: d.start_date) + context.private_batches = sorted(private_batches, key=lambda d: d.start_date) if frappe.session.user != "Guest": my_batches_info = [] @@ -70,5 +83,6 @@ def get_context(context): batchinfo.seats_left = batchinfo.seat_count - batchinfo.student_count my_batches_info.append(batchinfo) + my_batches_info = sorted(my_batches_info, key=lambda d: d.start_date, reverse=True) context.my_batches = my_batches_info diff --git a/lms/www/billing/billing.html b/lms/www/billing/billing.html index 72643285..b97ca3b3 100644 --- a/lms/www/billing/billing.html +++ b/lms/www/billing/billing.html @@ -30,16 +30,22 @@
    - {% set label = "Course Name" if module == "course" else "Batch Name" %} + {% set label = "Course" if module == "course" else "Batch" %} {{ _(label) }} : {{ title }}
    - {{ _("Total Price: ") }} {{ frappe.utils.fmt_money(amount, 2, currency) }} + {{ _("Total Price: ") }} + {{ frappe.utils.fmt_money(amount, 2, currency) }}
    + {% if gst_applied %} + + {{ _("18% GST included") }} + + {% endif %}
    {% endmacro %} @@ -59,4 +65,11 @@ {%- block script %} {{ super() }} + {% endblock %} diff --git a/lms/www/billing/billing.js b/lms/www/billing/billing.js index 4f719057..8e99c32f 100644 --- a/lms/www/billing/billing.js +++ b/lms/www/billing/billing.js @@ -18,31 +18,36 @@ const setup_billing = () => { label: __("Billing Name"), fieldname: "billing_name", reqd: 1, + default: address && address.billing_name, }, { fieldtype: "Data", label: __("Address Line 1"), fieldname: "address_line1", reqd: 1, + default: address && address.address_line1, }, { fieldtype: "Data", label: __("Address Line 2"), fieldname: "address_line2", + default: address && address.address_line2, }, { fieldtype: "Data", label: __("City/Town"), fieldname: "city", reqd: 1, - }, - { - fieldtype: "Column Break", + default: address && address.city, }, { fieldtype: "Data", label: __("State/Province"), fieldname: "state", + default: address && address.state, + }, + { + fieldtype: "Column Break", }, { fieldtype: "Link", @@ -51,18 +56,32 @@ const setup_billing = () => { options: "Country", reqd: 1, only_select: 1, + default: address && address.country, + change: () => { + change_currency(); + }, }, { fieldtype: "Data", label: __("Postal Code"), fieldname: "pincode", reqd: 1, + default: address && address.pincode, }, { fieldtype: "Data", label: __("Phone Number"), fieldname: "phone", reqd: 1, + default: address && address.phone, + }, + { + fieldtype: "Link", + label: __("Where did you hear about this?"), + fieldname: "source", + options: "LMS Source", + only_select: 1, + reqd: 1, }, { fieldtype: "Section Break", @@ -94,7 +113,8 @@ const setup_billing = () => { }; const generate_payment_link = (e) => { - address = this.billing.get_values(); + let new_address = this.billing.get_values(); + validate_address(new_address); let doctype = $(e.currentTarget).attr("data-doctype"); let docname = decodeURIComponent($(e.currentTarget).attr("data-name")); @@ -103,7 +123,8 @@ const generate_payment_link = (e) => { args: { doctype: doctype, docname: docname, - phone: address.phone, + phone: new_address.phone, + country: new_address.country, }, callback: (data) => { data.message.handler = (response) => { @@ -111,7 +132,7 @@ const generate_payment_link = (e) => { response, doctype, docname, - address, + new_address, data.message.order_id ); }; @@ -142,3 +163,84 @@ const handle_success = (response, doctype, docname, address, order_id) => { }, }); }; + +const change_currency = () => { + $("#gst-message").removeClass("hide"); + let country = this.billing.get_value("country"); + if (exception_country.includes(country)) { + update_price(original_price_formatted); + return; + } + frappe.call({ + method: "lms.lms.utils.change_currency", + args: { + country: country, + amount: amount, + currency: currency, + }, + callback: (data) => { + let current_price = $(".total-price").text(); + if (current_price != data.message) { + update_price(data.message); + } + if (data.message.includes("INR")) { + $("#gst-message").removeClass("hide").addClass("show"); + } else { + $("#gst-message").removeClass("show").addClass("hide"); + } + }, + }); +}; + +const update_price = (price) => { + $(".total-price").text(price); + frappe.show_alert({ + message: "Total Price has been updated.", + indicator: "yellow", + }); +}; + +const validate_address = (billing_address) => { + if (billing_address.country == "India" && !billing_address.state) + frappe.throw(__("State is mandatory.")); + + const states = [ + "Andhra Pradesh", + "Arunachal Pradesh", + "Assam", + "Bihar", + "Chhattisgarh", + "Goa", + "Gujarat", + "Haryana", + "Himachal Pradesh", + "Jharkhand", + "Karnataka", + "Kerala", + "Madhya Pradesh", + "Maharashtra", + "Manipur", + "Meghalaya", + "Mizoram", + "Nagaland", + "Odisha", + "Punjab", + "Rajasthan", + "Sikkim", + "Tamil Nadu", + "Telangana", + "Tripura", + "Uttar Pradesh", + "Uttarakhand", + "West Bengal", + ]; + if ( + billing_address.country == "India" && + !states.includes(billing_address.state) + ) + frappe.throw( + __( + "Please enter a valid state with correct spelling and the first letter capitalized." + ) + ); +}; diff --git a/lms/www/billing/billing.py b/lms/www/billing/billing.py index 6cb9ec60..d91013de 100644 --- a/lms/www/billing/billing.py +++ b/lms/www/billing/billing.py @@ -1,22 +1,46 @@ import frappe from frappe import _ +from lms.lms.utils import check_multicurrency, apply_gst def get_context(context): module = frappe.form_dict.module docname = frappe.form_dict.modulename - - if frappe.session.user == "Guest": - raise frappe.PermissionError(_("You are not allowed to access this page.")) - - if module not in ["course", "batch"]: - raise ValueError(_("Module is incorrect.")) - doctype = "LMS Course" if module == "course" else "LMS Batch" + context.module = module context.docname = docname context.doctype = doctype - context.apply_gst = frappe.db.get_single_value("LMS Settings", "apply_gst") + + validate_access(doctype, docname, module) + get_billing_details(context) + + context.original_currency = context.currency + context.original_amount = ( + apply_gst(context.amount, None)[0] + if context.original_currency == "INR" + else context.amount + ) + + context.exception_country = frappe.get_all( + "Payment Country", filters={"parent": "LMS Settings"}, pluck="country" + ) + + context.amount, context.currency = check_multicurrency( + context.amount, context.currency + ) + + context.address = get_address() + if context.currency == "INR": + context.amount, context.gst_applied = apply_gst(context.amount, None) + + +def validate_access(doctype, docname, module): + if frappe.session.user == "Guest": + raise frappe.PermissionError(_("Please login to continue with payment.")) + + if module not in ["course", "batch"]: + raise ValueError(_("Module is incorrect.")) if not frappe.db.exists(doctype, docname): raise ValueError(_("Module Name is incorrect or does not exist.")) @@ -35,37 +59,64 @@ def get_context(context): if membership: raise frappe.PermissionError(_("You are already enrolled for this batch.")) - if doctype == "LMS Course": - course = frappe.db.get_value( + +def get_billing_details(context): + if context.doctype == "LMS Course": + details = frappe.db.get_value( "LMS Course", - docname, - ["title", "name", "paid_course", "course_price", "currency"], + context.docname, + ["title", "name", "paid_course", "course_price as amount", "currency"], as_dict=True, ) - if not course.paid_course: + if not details.paid_course: raise frappe.PermissionError(_("This course is free.")) - context.title = course.title - context.amount = course.course_price - context.currency = course.currency - else: - batch = frappe.db.get_value( + details = frappe.db.get_value( "LMS Batch", - docname, + context.docname, ["title", "name", "paid_batch", "amount", "currency"], as_dict=True, ) - if not batch.paid_batch: + if not details.paid_batch: raise frappe.PermissionError( _("To join this batch, please contact the Administrator.") ) - context.title = batch.title - context.amount = batch.amount - context.currency = batch.currency + context.title = details.title + context.amount = details.amount + context.currency = details.currency - if context.apply_gst: - context.gst_amount = context.amount * 1.18 + +def get_address(): + address = frappe.get_all( + "Address", + {"email_id": frappe.session.user}, + [ + "address_title as billing_name", + "address_line1", + "address_line2", + "city", + "state", + "country", + "pincode", + "phone", + ], + order_by="creation desc", + limit=1, + ) + + if not len(address): + return None + else: + address = address[0] + + if not address.address_line2: + address.address_line2 = "" + + if not address.state: + address.state = "" + + return address diff --git a/lms/www/certified_participants/__init__.py b/lms/www/certified_participants/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lms/www/certified_participants/certified_participants.html b/lms/www/certified_participants/certified_participants.html index 6435afb8..f4e49cb3 100644 --- a/lms/www/certified_participants/certified_participants.html +++ b/lms/www/certified_participants/certified_participants.html @@ -7,11 +7,28 @@
    -
    + {% if course_filter | length %} + + {% endif %} +
    {{ _("Certified Participants") }}
    - {{ ParticipantsList() }} + {% if participants | length %} + {{ ParticipantsList() }} + {% else %} + {{ EmptyState() }} + {% endif %} +
    {% endblock %} @@ -21,15 +38,26 @@ {% for participant in participants %}
    {{ widgets.Avatar(member=participant, avatar_class="avatar-large") }} -
    +
    {{ participant.full_name }}
    {% for course in participant.courses %} -
    +
    {{ course }}
    {% endfor %} +
    {% endfor %} +{% endmacro %} + +{% macro EmptyState() %} +
    + +
    +
    {{ _("No Certified Participants") }}
    +
    {{ _("Enroll in a batch to get certified.") }}
    +
    +
    {% endmacro %} \ No newline at end of file diff --git a/lms/www/certified_participants/certified_participants.js b/lms/www/certified_participants/certified_participants.js new file mode 100644 index 00000000..af5b1340 --- /dev/null +++ b/lms/www/certified_participants/certified_participants.js @@ -0,0 +1,18 @@ +frappe.ready(() => { + $("#certificate-filter").change((e) => { + filter_certified_participants(); + }); +}); + +const filter_certified_participants = () => { + const certificate = $("#certificate-filter").val(); + $(".common-card-style").removeClass("hide"); + + if (certificate) { + $(".common-card-style").addClass("hide"); + $(`[data-course='${certificate}']`) + .closest(".common-card-style") + .removeClass("hide"); + console.log(certificate); + } +}; diff --git a/lms/www/certified_participants/certified_participants.py b/lms/www/certified_participants/certified_participants.py index a0602219..2dc9b238 100644 --- a/lms/www/certified_participants/certified_participants.py +++ b/lms/www/certified_participants/certified_participants.py @@ -3,20 +3,40 @@ import frappe def get_context(context): context.no_cache = 1 - context.members = frappe.get_all( - "LMS Certificate", pluck="member", order_by="creation desc", distinct=1 + members = frappe.get_all( + "LMS Certificate", + filters={"published": 1}, + pluck="member", + order_by="issue_date desc", + distinct=1, ) participants = [] - for member in context.members: + course_filter = [] + for member in members: details = frappe.db.get_value( "User", member, ["name", "full_name", "user_image", "username", "enabled"], as_dict=1 ) - courses = frappe.get_all("LMS Certificate", {"member": member}, pluck="course") + courses = frappe.get_all( + "LMS Certificate", + filters={"member": member, "published": 1}, + fields=["course", "issue_date"], + ) details.courses = [] for course in courses: - details.courses.append(frappe.db.get_value("LMS Course", course, "title")) + + if not details.issue_date: + details.issue_date = course.issue_date + + title = frappe.db.get_value("LMS Course", course.course, "title") + details.courses.append(title) + + if title not in course_filter: + course_filter.append(title) + if details.enabled: participants.append(details) + participants = sorted(participants, key=lambda d: d.issue_date, reverse=True) context.participants = participants + context.course_filter = course_filter diff --git a/lms/www/classes/__init__.py b/lms/www/classes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lms/www/courses/certificate.html b/lms/www/courses/certificate.html index 15bea0cd..2a3af3d3 100644 --- a/lms/www/courses/certificate.html +++ b/lms/www/courses/certificate.html @@ -13,15 +13,14 @@
    -
    +
    {{ final_template }}
    - - {% if doc.member == frappe.session.user %} + {% if doc.member == frappe.session.user or is_moderator %}
    - + {{ _("Download") }} diff --git a/lms/www/courses/certificate.py b/lms/www/courses/certificate.py index 91e4de53..3b4839b4 100644 --- a/lms/www/courses/certificate.py +++ b/lms/www/courses/certificate.py @@ -2,6 +2,7 @@ import frappe from frappe import _ from frappe.utils.jinja import render_template from frappe.utils import get_url +from lms.lms.utils import has_course_moderator_role def get_context(context): @@ -16,7 +17,7 @@ def get_context(context): context.doc = frappe.db.get_value( "LMS Certificate", certificate_name, - ["name", "member", "issue_date", "expiry_date", "course"], + ["name", "member", "issue_date", "expiry_date", "course", "template"], as_dict=True, ) @@ -30,9 +31,14 @@ def get_context(context): "User", context.doc.member, ["full_name", "username"], as_dict=True ) context.url = f"{get_url()}/courses/{context.course.name}/{context.doc.name}" + context.is_moderator = has_course_moderator_role() - print_format = get_print_format() + if context.doc.template: + print_format = context.doc.template + else: + print_format = get_print_format() + context.print_format = print_format template = frappe.db.get_value( "Print Format", print_format, ["html", "css"], as_dict=True ) diff --git a/lms/www/courses/course.py b/lms/www/courses/course.py index 2666739a..60056698 100644 --- a/lms/www/courses/course.py +++ b/lms/www/courses/course.py @@ -10,6 +10,7 @@ from lms.lms.utils import ( is_instructor, redirect_to_courses_list, get_average_rating, + check_multicurrency, ) @@ -22,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." @@ -55,11 +56,17 @@ def set_course_context(context, course_name): "paid_course", "course_price", "currency", + "enable_certification", "grant_certificate_after", ], as_dict=True, ) + if course.course_price: + course.course_price, course.currency = check_multicurrency( + course.course_price, course.currency + ) + if frappe.form_dict.get("edit"): if not is_instructor(course.name) and not has_course_moderator_role(): raise frappe.PermissionError(_("You do not have permission to access this page.")) diff --git a/lms/www/courses/create.js b/lms/www/courses/create.js index fcbb2e27..64984e32 100644 --- a/lms/www/courses/create.js +++ b/lms/www/courses/create.js @@ -51,8 +51,13 @@ const create_tag = (e) => { if ($(e.target).val() == "") { return; } + + let tag_value = $(e.target) + .val() + .replace(//g, ">"); let tag = `