From e1a78382c3ea055a480924c81db5635f18b08f07 Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Mon, 18 Nov 2024 16:15:27 +0530 Subject: [PATCH 1/4] feat: learning paths --- frontend/src/components/AppSidebar.vue | 12 ++ frontend/src/components/Modals/Settings.vue | 37 +++- frontend/src/components/SettingDetails.vue | 10 +- frontend/src/components/SettingFields.vue | 3 +- frontend/src/pages/ProgramForm.vue | 88 ++++++++++ frontend/src/pages/Programs.vue | 164 ++++++++++++++++++ frontend/src/router.js | 11 ++ lms/lms/doctype/lms_program/__init__.py | 0 lms/lms/doctype/lms_program/lms_program.js | 8 + lms/lms/doctype/lms_program/lms_program.json | 60 +++++++ lms/lms/doctype/lms_program/lms_program.py | 9 + .../doctype/lms_program/test_lms_program.py | 21 +++ .../doctype/lms_program_course/__init__.py | 0 .../lms_program_course.json | 42 +++++ .../lms_program_course/lms_program_course.py | 9 + .../doctype/lms_program_member/__init__.py | 0 .../lms_program_member.json | 42 +++++ .../lms_program_member/lms_program_member.py | 9 + .../doctype/lms_settings/lms_settings.json | 19 +- 19 files changed, 535 insertions(+), 9 deletions(-) create mode 100644 frontend/src/pages/ProgramForm.vue create mode 100644 frontend/src/pages/Programs.vue create mode 100644 lms/lms/doctype/lms_program/__init__.py create mode 100644 lms/lms/doctype/lms_program/lms_program.js create mode 100644 lms/lms/doctype/lms_program/lms_program.json create mode 100644 lms/lms/doctype/lms_program/lms_program.py create mode 100644 lms/lms/doctype/lms_program/test_lms_program.py create mode 100644 lms/lms/doctype/lms_program_course/__init__.py create mode 100644 lms/lms/doctype/lms_program_course/lms_program_course.json create mode 100644 lms/lms/doctype/lms_program_course/lms_program_course.py create mode 100644 lms/lms/doctype/lms_program_member/__init__.py create mode 100644 lms/lms/doctype/lms_program_member/lms_program_member.json create mode 100644 lms/lms/doctype/lms_program_member/lms_program_member.py diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index a0b94a831..2c0afea44 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -183,6 +183,17 @@ const addQuizzes = () => { } } +const addPrograms = () => { + if (isInstructor.value || isModerator.value) { + sidebarLinks.value.push({ + label: 'Programs', + icon: 'Route', + to: 'Programs', + activeFor: ['Programs', 'ProgramForm'], + }) + } +} + const openPageModal = (link) => { showPageModal.value = true pageToEdit.value = link @@ -215,6 +226,7 @@ watch(userResource, () => { isModerator.value = userResource.data.is_moderator isInstructor.value = userResource.data.is_instructor addQuizzes() + addPrograms() } }) diff --git a/frontend/src/components/Modals/Settings.vue b/frontend/src/components/Modals/Settings.vue index f8854df2b..bf6801d44 100644 --- a/frontend/src/components/Modals/Settings.vue +++ b/frontend/src/components/Modals/Settings.vue @@ -108,9 +108,31 @@ const tabsStructure = computed(() => { hideLabel: true, items: [ { - label: 'Members', - description: 'Manage the members of your learning system', - icon: 'UserRoundPlus', + label: 'General', + icon: 'Wrench', + fields: [ + { + label: 'Enable Learning Paths', + name: 'enable_learning_paths', + description: + 'This will change the default flow of the system and enforce students to go through programs assigned to them in the correct order.', + type: 'checkbox', + }, + { + label: 'Send calendar invite for evaluations', + name: 'send_calendar_invite_for_evaluations', + description: + 'If enabled and Google Calendar of the evaluator is set in the system, students will receive calendar invites to remind them of their evaluations.', + type: 'checkbox', + }, + { + label: 'Unsplash Access Key', + name: 'unsplash_access_key', + description: + 'Optional. If this is set, students can pick a cover image from the unsplash library for their profile page. Refer the docs to know more https://unsplash.com/documentation#getting-started.', + type: 'text', + }, + ], }, ], }, @@ -156,9 +178,14 @@ const tabsStructure = computed(() => { ], }, { - label: 'Settings', - hideLabel: true, + label: 'Lists', + hideLabel: false, items: [ + { + label: 'Members', + description: 'Manage the members of your learning system', + icon: 'UserRoundPlus', + }, { label: 'Categories', description: 'Manage the members of your learning system', diff --git a/frontend/src/components/SettingDetails.vue b/frontend/src/components/SettingDetails.vue index 87a80f263..d90b1c4c8 100644 --- a/frontend/src/components/SettingDetails.vue +++ b/frontend/src/components/SettingDetails.vue @@ -29,6 +29,7 @@ diff --git a/frontend/src/components/SettingFields.vue b/frontend/src/components/SettingFields.vue index 2cee55e99..cc74b3f72 100644 --- a/frontend/src/components/SettingFields.vue +++ b/frontend/src/components/SettingFields.vue @@ -90,6 +90,7 @@ :type="field.type" :rows="field.rows" :options="field.options" + :description="field.description" /> @@ -100,7 +101,7 @@ import { FormControl, FileUploader, Button, Switch } from 'frappe-ui' import { computed } from 'vue' import { getFileSize, validateFile } from '@/utils' -import { X, FileText } from 'lucide-vue-next' +import { X } from 'lucide-vue-next' import Link from '@/components/Controls/Link.vue' import CodeEditor from '@/components/Controls/CodeEditor.vue' diff --git a/frontend/src/pages/ProgramForm.vue b/frontend/src/pages/ProgramForm.vue new file mode 100644 index 000000000..e40a674b6 --- /dev/null +++ b/frontend/src/pages/ProgramForm.vue @@ -0,0 +1,88 @@ + + diff --git a/frontend/src/pages/Programs.vue b/frontend/src/pages/Programs.vue new file mode 100644 index 000000000..6ac897455 --- /dev/null +++ b/frontend/src/pages/Programs.vue @@ -0,0 +1,164 @@ + + diff --git a/frontend/src/router.js b/frontend/src/router.js index cff7e5f64..d69a1e461 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -182,6 +182,17 @@ const routes = [ component: () => import('@/pages/QuizSubmission.vue'), props: true, }, + { + path: '/programs', + name: 'Programs', + component: () => import('@/pages/Programs.vue'), + }, + { + path: '/programs/:programName', + name: 'ProgramForm', + component: () => import('@/pages/ProgramForm.vue'), + props: true, + }, ] let router = createRouter({ diff --git a/lms/lms/doctype/lms_program/__init__.py b/lms/lms/doctype/lms_program/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lms/lms/doctype/lms_program/lms_program.js b/lms/lms/doctype/lms_program/lms_program.js new file mode 100644 index 000000000..557104447 --- /dev/null +++ b/lms/lms/doctype/lms_program/lms_program.js @@ -0,0 +1,8 @@ +// Copyright (c) 2024, Frappe and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("LMS Program", { +// refresh(frm) { + +// }, +// }); diff --git a/lms/lms/doctype/lms_program/lms_program.json b/lms/lms/doctype/lms_program/lms_program.json new file mode 100644 index 000000000..f2806e725 --- /dev/null +++ b/lms/lms/doctype/lms_program/lms_program.json @@ -0,0 +1,60 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:title", + "creation": "2024-11-18 12:27:13.283169", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "title", + "program_courses", + "program_members" + ], + "fields": [ + { + "fieldname": "program_courses", + "fieldtype": "Table", + "label": "Program Courses", + "options": "LMS Program Course" + }, + { + "fieldname": "program_members", + "fieldtype": "Table", + "label": "Program Members", + "options": "LMS Program Member" + }, + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Title", + "reqd": 1, + "unique": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2024-11-18 14:08:26.958831", + "modified_by": "Administrator", + "module": "LMS", + "name": "LMS Program", + "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 + } + ], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/lms/lms/doctype/lms_program/lms_program.py b/lms/lms/doctype/lms_program/lms_program.py new file mode 100644 index 000000000..c07e3df5d --- /dev/null +++ b/lms/lms/doctype/lms_program/lms_program.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class LMSProgram(Document): + pass diff --git a/lms/lms/doctype/lms_program/test_lms_program.py b/lms/lms/doctype/lms_program/test_lms_program.py new file mode 100644 index 000000000..e1599d982 --- /dev/null +++ b/lms/lms/doctype/lms_program/test_lms_program.py @@ -0,0 +1,21 @@ +# Copyright (c) 2024, Frappe and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase + + +# On IntegrationTestCase, the doctype test records and all +# link-field test record depdendencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class TestLMSProgram(UnitTestCase): + """ + Unit tests for LMSProgram. + Use this class for testing individual functions and methods. + """ + + pass diff --git a/lms/lms/doctype/lms_program_course/__init__.py b/lms/lms/doctype/lms_program_course/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lms/lms/doctype/lms_program_course/lms_program_course.json b/lms/lms/doctype/lms_program_course/lms_program_course.json new file mode 100644 index 000000000..a7edc7ed4 --- /dev/null +++ b/lms/lms/doctype/lms_program_course/lms_program_course.json @@ -0,0 +1,42 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-11-18 12:27:37.030302", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "course", + "course_title" + ], + "fields": [ + { + "fieldname": "course", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Course", + "options": "LMS Course", + "reqd": 1 + }, + { + "fetch_from": "course.title", + "fieldname": "course_title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Course Title", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2024-11-18 12:43:46.800199", + "modified_by": "Administrator", + "module": "LMS", + "name": "LMS Program Course", + "owner": "Administrator", + "permissions": [], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/lms/lms/doctype/lms_program_course/lms_program_course.py b/lms/lms/doctype/lms_program_course/lms_program_course.py new file mode 100644 index 000000000..0d1269373 --- /dev/null +++ b/lms/lms/doctype/lms_program_course/lms_program_course.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class LMSProgramCourse(Document): + pass diff --git a/lms/lms/doctype/lms_program_member/__init__.py b/lms/lms/doctype/lms_program_member/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lms/lms/doctype/lms_program_member/lms_program_member.json b/lms/lms/doctype/lms_program_member/lms_program_member.json new file mode 100644 index 000000000..d8553936f --- /dev/null +++ b/lms/lms/doctype/lms_program_member/lms_program_member.json @@ -0,0 +1,42 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-11-18 12:29:13.615014", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "member", + "full_name" + ], + "fields": [ + { + "fieldname": "member", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Member", + "options": "User", + "reqd": 1 + }, + { + "fetch_from": "member.full_name", + "fieldname": "full_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Full Name", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2024-11-18 12:44:02.648786", + "modified_by": "Administrator", + "module": "LMS", + "name": "LMS Program Member", + "owner": "Administrator", + "permissions": [], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/lms/lms/doctype/lms_program_member/lms_program_member.py b/lms/lms/doctype/lms_program_member/lms_program_member.py new file mode 100644 index 000000000..473cd1f9b --- /dev/null +++ b/lms/lms/doctype/lms_program_member/lms_program_member.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class LMSProgramMember(Document): + pass diff --git a/lms/lms/doctype/lms_settings/lms_settings.json b/lms/lms/doctype/lms_settings/lms_settings.json index d8bae6868..87a595edc 100644 --- a/lms/lms/doctype/lms_settings/lms_settings.json +++ b/lms/lms/doctype/lms_settings/lms_settings.json @@ -5,13 +5,15 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "general_tab", "default_home", + "send_calendar_invite_for_evaluations", "is_onboarding_complete", "column_break_zdel", + "enable_learning_paths", "unsplash_access_key", "livecode_url", "section_break_szgq", - "send_calendar_invite_for_evaluations", "show_day_view", "column_break_2", "show_dashboard", @@ -80,6 +82,7 @@ { "fieldname": "mentor_request_section", "fieldtype": "Section Break", + "hidden": 1, "label": "Mentor Request" }, { @@ -127,6 +130,7 @@ { "fieldname": "section_break_szgq", "fieldtype": "Section Break", + "hidden": 1, "label": "Batch Settings" }, { @@ -336,12 +340,23 @@ "fieldname": "payments_app_is_not_installed", "fieldtype": "HTML", "label": "Payments app is not installed" + }, + { + "default": "0", + "fieldname": "enable_learning_paths", + "fieldtype": "Check", + "label": "Enable Learning Paths" + }, + { + "fieldname": "general_tab", + "fieldtype": "Tab Break", + "label": "General" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2024-10-01 12:15:49.800242", + "modified": "2024-11-18 12:52:41.236252", "modified_by": "Administrator", "module": "LMS", "name": "LMS Settings", From 582c7af12df26f4cf686bf14dad3802c4290a436 Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Wed, 20 Nov 2024 19:32:49 +0530 Subject: [PATCH 2/4] feat: reorder courses and students view for programs --- .../src/components/Controls/Autocomplete.vue | 2 +- frontend/src/components/Controls/Link.vue | 7 +- frontend/src/components/Modals/Settings.vue | 6 +- frontend/src/pages/Courses.vue | 21 +- frontend/src/pages/ProgramForm.vue | 301 ++++++++++++++++-- frontend/src/pages/Programs.vue | 197 ++++++------ frontend/src/router.js | 10 +- frontend/src/utils/index.js | 2 + lms/lms/doctype/lms_program/lms_program.json | 26 +- lms/lms/doctype/lms_program/lms_program.py | 103 +++++- .../doctype/lms_settings/lms_settings.json | 9 +- lms/lms/utils.py | 26 ++ 12 files changed, 574 insertions(+), 136 deletions(-) diff --git a/frontend/src/components/Controls/Autocomplete.vue b/frontend/src/components/Controls/Autocomplete.vue index 81f5358be..c4c18efac 100644 --- a/frontend/src/components/Controls/Autocomplete.vue +++ b/frontend/src/components/Controls/Autocomplete.vue @@ -92,7 +92,7 @@ {{ option.label }}
diff --git a/frontend/src/components/Controls/Link.vue b/frontend/src/components/Controls/Link.vue index b80484baf..6c2de9a58 100644 --- a/frontend/src/components/Controls/Link.vue +++ b/frontend/src/components/Controls/Link.vue @@ -44,6 +44,7 @@ +

{{ description }}

@@ -67,6 +68,10 @@ const props = defineProps({ type: String, default: '', }, + description: { + type: String, + default: '', + }, }) const emit = defineEmits(['update:modelValue', 'change']) @@ -118,7 +123,7 @@ const options = createResource({ transform: (data) => { return data.map((option) => { return { - label: option.value, + label: option.label || option.value, value: option.value, description: option.description, } diff --git a/frontend/src/components/Modals/Settings.vue b/frontend/src/components/Modals/Settings.vue index bf6801d44..7f49ac6fe 100644 --- a/frontend/src/components/Modals/Settings.vue +++ b/frontend/src/components/Modals/Settings.vue @@ -115,21 +115,21 @@ const tabsStructure = computed(() => { label: 'Enable Learning Paths', name: 'enable_learning_paths', description: - 'This will change the default flow of the system and enforce students to go through programs assigned to them in the correct order.', + 'This will enforce students to go through programs assigned to them in the correct order.', type: 'checkbox', }, { label: 'Send calendar invite for evaluations', name: 'send_calendar_invite_for_evaluations', description: - 'If enabled and Google Calendar of the evaluator is set in the system, students will receive calendar invites to remind them of their evaluations.', + 'If enabled, it sends google calendar invite to the student for evaluations.', type: 'checkbox', }, { label: 'Unsplash Access Key', name: 'unsplash_access_key', description: - 'Optional. If this is set, students can pick a cover image from the unsplash library for their profile page. Refer the docs to know more https://unsplash.com/documentation#getting-started.', + 'Optional. If this is set, students can pick a cover image from the unsplash library for their profile page. https://unsplash.com/documentation#getting-started.', type: 'text', }, ], diff --git a/frontend/src/pages/Courses.vue b/frontend/src/pages/Courses.vue index 114a3c4c0..8fc7b1e86 100644 --- a/frontend/src/pages/Courses.vue +++ b/frontend/src/pages/Courses.vue @@ -160,30 +160,45 @@ diff --git a/frontend/src/router.js b/frontend/src/router.js index d69a1e461..d459209c8 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -182,17 +182,17 @@ const routes = [ component: () => import('@/pages/QuizSubmission.vue'), props: true, }, - { - path: '/programs', - name: 'Programs', - component: () => import('@/pages/Programs.vue'), - }, { path: '/programs/:programName', name: 'ProgramForm', component: () => import('@/pages/ProgramForm.vue'), props: true, }, + { + path: '/programs', + name: 'Programs', + component: () => import('@/pages/Programs.vue'), + }, ] let router = createRouter({ diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index aaf79771d..1f3eaa111 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -438,6 +438,8 @@ export function getSidebarLinks() { 'Lesson', 'CourseForm', 'LessonForm', + 'Programs', + 'ProgramForm', ], }, { diff --git a/lms/lms/doctype/lms_program/lms_program.json b/lms/lms/doctype/lms_program/lms_program.json index f2806e725..5b4843fb1 100644 --- a/lms/lms/doctype/lms_program/lms_program.json +++ b/lms/lms/doctype/lms_program/lms_program.json @@ -34,7 +34,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-11-18 14:08:26.958831", + "modified": "2024-11-20 12:26:02.214628", "modified_by": "Administrator", "module": "LMS", "name": "LMS Program", @@ -52,6 +52,30 @@ "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": "creation", diff --git a/lms/lms/doctype/lms_program/lms_program.py b/lms/lms/doctype/lms_program/lms_program.py index c07e3df5d..0de0f5a72 100644 --- a/lms/lms/doctype/lms_program/lms_program.py +++ b/lms/lms/doctype/lms_program/lms_program.py @@ -1,9 +1,108 @@ # Copyright (c) 2024, Frappe and contributors # For license information, please see license.txt -# import frappe +import frappe +from frappe import _ from frappe.model.document import Document class LMSProgram(Document): - pass + def validate(self): + self.validate_program_courses() + self.validate_program_members() + + def on_update(self): + self.manage_acccess() + + def manage_acccess(self): + old_doc = self.get_doc_before_save() + + if not old_doc: + return + + previous_courses = [row.course for row in old_doc.program_courses] + current_courses = [row.course for row in self.program_courses] + + print("previous_courses", previous_courses) + print("current_courses", current_courses) + + previous_members = [row.member for row in old_doc.program_members] + current_members = [row.member for row in self.program_members] + + print("previous_members", previous_members) + print("current_members", current_members) + + courses_added = [ + course for course in current_courses if course not in previous_courses + ] + courses_removed = [ + course for course in previous_courses if course not in current_courses + ] + + members_added = [ + member for member in current_members if member not in previous_members + ] + members_removed = [ + member for member in previous_members if member not in current_members + ] + + print(courses_removed) + print(members_removed) + + if len(courses_added) > 0: + self.grant_program_access(current_members, courses_added) + + if len(courses_removed) > 0: + print(courses_removed) + self.revoke_program_access(current_members, courses_removed) + + if len(members_added) > 0: + self.grant_program_access(members_added, current_courses) + + if len(members_removed) > 0: + print(members_removed) + self.revoke_program_access(members_removed, current_courses) + + def grant_program_access(self, members, courses): + for course in courses: + for member in members: + enrollment = frappe.db.exists( + "LMS Enrollment", {"course": course, "member": member} + ) + if not enrollment: + enrollment = frappe.new_doc("LMS Enrollment") + enrollment.course = course + enrollment.member = member + enrollment.insert() + + def revoke_program_access(self, members, courses): + for course in courses: + print(course) + for member in members: + print(member) + enrollment = frappe.db.exists( + "LMS Enrollment", {"course": course, "member": member} + ) + print(enrollment) + if enrollment: + frappe.delete_doc("LMS Enrollment", enrollment) + + def validate_program_courses(self): + courses = [row.course for row in self.program_courses] + duplicates = {course for course in courses if courses.count(course) > 1} + if len(duplicates): + frappe.throw( + _("Course {0} has already been added to this batch.").format( + frappe.bold(next(iter(duplicates))) + ) + ) + + def validate_program_members(self): + members = [row.member for row in self.program_members] + duplicates = {member for member in members if members.count(member) > 1} + if len(duplicates): + frappe.throw( + _("Member {0} has already been added to this batch.").format( + frappe.bold(next(iter(duplicates))) + ) + ) diff --git a/lms/lms/doctype/lms_settings/lms_settings.json b/lms/lms/doctype/lms_settings/lms_settings.json index 87a595edc..27bba9ff6 100644 --- a/lms/lms/doctype/lms_settings/lms_settings.json +++ b/lms/lms/doctype/lms_settings/lms_settings.json @@ -356,7 +356,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2024-11-18 12:52:41.236252", + "modified": "2024-11-20 11:55:05.358421", "modified_by": "Administrator", "module": "LMS", "name": "LMS Settings", @@ -371,6 +371,13 @@ "role": "System Manager", "share": 1, "write": 1 + }, + { + "email": 1, + "print": 1, + "read": 1, + "role": "LMS Student", + "share": 1 } ], "sort_field": "modified", diff --git a/lms/lms/utils.py b/lms/lms/utils.py index a02d73abb..b94c0350b 100644 --- a/lms/lms/utils.py +++ b/lms/lms/utils.py @@ -1751,3 +1751,29 @@ def enroll_in_batch(batch, payment_name=None): ) student.save(ignore_permissions=True) + + +@frappe.whitelist() +def get_programs(): + if ( + has_course_moderator_role() + or has_course_instructor_role() + or has_course_evaluator_role() + ): + programs = frappe.get_all("LMS Program", fields=["name"]) + else: + programs = frappe.get_all( + "LMS Program Member", {"member": frappe.session.user}, ["parent as name"] + ) + + for program in programs: + program_courses = frappe.get_all( + "LMS Program Course", {"parent": program.name}, ["course"] + ) + program.courses = [] + for course in program_courses: + program.courses.append(get_course_details(course.course)) + + program.members = frappe.db.count("LMS Program Member", {"parent": program.name}) + + return programs From 64ed0b3e948e90a3d2c550f9ed69f7c9dedce194 Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Thu, 21 Nov 2024 17:10:24 +0530 Subject: [PATCH 3/4] feat: program restrictions --- frontend/src/components/AppSidebar.vue | 19 ++++- frontend/src/components/CourseOutline.vue | 2 +- frontend/src/components/Quiz.vue | 3 + frontend/src/pages/Courses.vue | 16 ++-- frontend/src/pages/ProgramForm.vue | 3 - frontend/src/pages/Programs.vue | 85 ++++++++++++------- frontend/src/stores/settings.js | 13 +++ frontend/src/utils/index.js | 2 - .../doctype/course_lesson/course_lesson.py | 8 +- .../doctype/lms_enrollment/lms_enrollment.py | 24 ++++++ lms/lms/doctype/lms_program/lms_program.py | 76 ----------------- .../lms_program_member.json | 12 ++- lms/lms/utils.py | 56 +++++++++++- lms/lms/workspace/lms/lms.json | 8 +- 14 files changed, 190 insertions(+), 137 deletions(-) diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index 2c0afea44..7dba321cb 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -99,6 +99,7 @@ import { getSidebarLinks } from '../utils' import { usersStore } from '@/stores/user' import { sessionStore } from '@/stores/session' import { useSidebar } from '@/stores/sidebar' +import { useSettings } from '@/stores/settings' import { ChevronRight, Plus } from 'lucide-vue-next' import { createResource, Button } from 'frappe-ui' import PageModal from '@/components/Modals/PageModal.vue' @@ -114,6 +115,7 @@ const isModerator = ref(false) const isInstructor = ref(false) const pageToEdit = ref(null) const showWebPages = ref(false) +const settingsStore = useSettings() onMounted(() => { socket.on('publish_lms_notifications', (data) => { @@ -184,12 +186,23 @@ const addQuizzes = () => { } const addPrograms = () => { - if (isInstructor.value || isModerator.value) { - sidebarLinks.value.push({ + if (settingsStore.learningPaths.data) { + let activeFor = ['Programs', 'ProgramForm'] + let index = 1 + if (!isInstructor.value && !isModerator.value) { + sidebarLinks.value = sidebarLinks.value.filter( + (link) => link.label !== 'Courses' + ) + activeFor.push('CourseDetail') + activeFor.push('Lesson') + index = 0 + } + + sidebarLinks.value.splice(index, 0, { label: 'Programs', icon: 'Route', to: 'Programs', - activeFor: ['Programs', 'ProgramForm'], + activeFor: activeFor, }) } } diff --git a/frontend/src/components/CourseOutline.vue b/frontend/src/components/CourseOutline.vue index d93dbc2d7..04f739375 100644 --- a/frontend/src/components/CourseOutline.vue +++ b/frontend/src/components/CourseOutline.vue @@ -303,9 +303,9 @@ const trashChapter = (chapterName) => { } const redirectToChapter = (chapter) => { + if (!chapter.is_scorm_package) return event.preventDefault() if (props.allowEdit) return - if (!chapter.is_scorm_package) return if (!user.data) { showToast( __('You are not enrolled'), diff --git a/frontend/src/components/Quiz.vue b/frontend/src/components/Quiz.vue index 79096e9e3..b4b3c42fc 100644 --- a/frontend/src/components/Quiz.vue +++ b/frontend/src/components/Quiz.vue @@ -397,6 +397,9 @@ const attempts = createResource({ watch( () => quiz.data, () => { + if (quiz.data) { + populateQuestions() + } if (quiz.data && quiz.data.max_attempts) { attempts.reload() resetQuiz() diff --git a/frontend/src/pages/Courses.vue b/frontend/src/pages/Courses.vue index 8fc7b1e86..16ae3ba08 100644 --- a/frontend/src/pages/Courses.vue +++ b/frontend/src/pages/Courses.vue @@ -173,12 +173,14 @@ import { BookOpen, Plus, Search } from 'lucide-vue-next' import { ref, computed, inject, onMounted, watch } from 'vue' import { updateDocumentTitle } from '@/utils' import { useRouter } from 'vue-router' +import { useSettings } from '@/stores/settings' const user = inject('$user') const searchQuery = ref('') const currentCategory = ref(null) const hasCourses = ref(false) const router = useRouter() +const settings = useSettings() onMounted(() => { checkLearningPath() @@ -189,14 +191,12 @@ onMounted(() => { }) const checkLearningPath = () => { - call('frappe.client.get_single_value', { - doctype: 'LMS Settings', - field: 'enable_learning_paths', - }).then((res) => { - if (res && !user.data?.is_moderator && !user.data?.is_instructor) { - router.push({ name: 'Programs' }) - } - }) + if ( + settings.learningPaths.data && + (!user.data?.is_moderator || !user.data?.is_instructor) + ) { + router.push({ name: 'Programs' }) + } } const courses = createResource({ diff --git a/frontend/src/pages/ProgramForm.vue b/frontend/src/pages/ProgramForm.vue index 13f15d3c9..092e41b80 100644 --- a/frontend/src/pages/ProgramForm.vue +++ b/frontend/src/pages/ProgramForm.vue @@ -211,8 +211,6 @@ const program = createDocumentResource({ cache: ['program', props.programName], }) -console.log(program) - const addProgramCourse = () => { program.setValue.submit( { @@ -288,7 +286,6 @@ const updateOrder = (e) => { course.idx = index + 1 }) - console.log(courses) program.setValue.submit( { program_courses: courses, diff --git a/frontend/src/pages/Programs.vue b/frontend/src/pages/Programs.vue index 29f5c09bf..860a12444 100644 --- a/frontend/src/pages/Programs.vue +++ b/frontend/src/pages/Programs.vue @@ -15,15 +15,27 @@
-
+
{{ program.name }}
- - {{ program.members }} {{ __('Members') }} + + {{ program.members }} + {{ + program.members == 1 ? __(singularize('members')) : __('members') + }} + + + {{ program.progress }}{{ __('% completed') }} + - - - + :course="course" + @click="enrollMember(program.name, course.name)" + class="cursor-pointer" + />
{{ __('No courses in this program') }} @@ -128,6 +117,7 @@ import { computed, inject, ref } from 'vue' import { BookOpen, Edit, Plus } from 'lucide-vue-next' import CourseCard from '@/components/CourseCard.vue' import { useRouter } from 'vue-router' +import { showToast, singularize } from '@/utils' const user = inject('$user') const showDialog = ref(false) @@ -140,8 +130,6 @@ const programs = createResource({ cache: 'programs', }) -console.log(programs) - const createProgram = (close) => { call('frappe.client.insert', { doc: { @@ -153,6 +141,37 @@ const createProgram = (close) => { }) } +const enrollMember = (program, course) => { + call('lms.lms.utils.enroll_in_program_course', { + program: program, + course: course, + }) + .then((data) => { + if (data.current_lesson) { + router.push({ + name: 'Lesson', + params: { + courseName: course, + chapterNumber: data.current_lesson.split('-')[0], + lessonNumber: data.current_lesson.split('-')[1], + }, + }) + } else if (data) { + router.push({ + name: 'Lesson', + params: { + courseName: course, + chapterNumber: 1, + lessonNumber: 1, + }, + }) + } + }) + .catch((err) => { + showToast('Error', err.messages?.[0] || err, 'x') + }) +} + const breadbrumbs = computed(() => [ { label: 'Programs', diff --git a/frontend/src/stores/settings.js b/frontend/src/stores/settings.js index 61d6bedcf..9844517fa 100644 --- a/frontend/src/stores/settings.js +++ b/frontend/src/stores/settings.js @@ -1,12 +1,25 @@ import { defineStore } from 'pinia' import { ref } from 'vue' +import { createResource } from 'frappe-ui' export const useSettings = defineStore('settings', () => { const isSettingsOpen = ref(false) const activeTab = ref(null) + const learningPaths = createResource({ + url: 'frappe.client.get_single_value', + makeParams(values) { + return { + doctype: 'LMS Settings', + field: 'enable_learning_paths', + } + }, + auto: true, + cache: ['learningPaths'], + }) return { isSettingsOpen, activeTab, + learningPaths, } }) diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index 1f3eaa111..aaf79771d 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -438,8 +438,6 @@ export function getSidebarLinks() { 'Lesson', 'CourseForm', 'LessonForm', - 'Programs', - 'ProgramForm', ], }, { diff --git a/lms/lms/doctype/course_lesson/course_lesson.py b/lms/lms/doctype/course_lesson/course_lesson.py index e1aac53c7..2955305b6 100644 --- a/lms/lms/doctype/course_lesson/course_lesson.py +++ b/lms/lms/doctype/course_lesson/course_lesson.py @@ -93,15 +93,15 @@ def save_progress(lesson, course): frappe.db.set_value("LMS Enrollment", membership, "current_lesson", lesson) - quiz_completed = get_quiz_progress(lesson) - if not quiz_completed: - return 0 - if frappe.db.exists( "LMS Course Progress", {"lesson": lesson, "member": frappe.session.user} ): return + quiz_completed = get_quiz_progress(lesson) + if not quiz_completed: + return 0 + frappe.get_doc( { "doctype": "LMS Course Progress", diff --git a/lms/lms/doctype/lms_enrollment/lms_enrollment.py b/lms/lms/doctype/lms_enrollment/lms_enrollment.py index 04f518754..389e1b2d9 100644 --- a/lms/lms/doctype/lms_enrollment/lms_enrollment.py +++ b/lms/lms/doctype/lms_enrollment/lms_enrollment.py @@ -4,6 +4,7 @@ import frappe from frappe import _ from frappe.model.document import Document +from frappe.utils import ceil class LMSEnrollment(Document): @@ -11,6 +12,9 @@ def validate(self): self.validate_membership_in_same_batch() self.validate_membership_in_different_batch_same_course() + def on_update(self): + self.update_program_progress() + def validate_membership_in_same_batch(self): filters = {"member": self.member, "course": self.course, "name": ["!=", self.name]} if self.batch_old: @@ -55,6 +59,26 @@ def validate_membership_in_different_batch_same_course(self): ) ) + def update_program_progress(self): + programs = frappe.get_all( + "LMS Program Member", {"member": self.member}, ["parent", "name"] + ) + + for program in programs: + total_progress = 0 + courses = frappe.get_all( + "LMS Program Course", {"parent": program.parent}, pluck="course" + ) + for course in courses: + progress = frappe.db.get_value( + "LMS Enrollment", {"course": course, "member": self.member}, "progress" + ) + progress = progress or 0 + total_progress += progress + + average_progress = ceil(total_progress / len(courses)) + frappe.db.set_value("LMS Program Member", program.name, "progress", average_progress) + @frappe.whitelist() def create_membership( diff --git a/lms/lms/doctype/lms_program/lms_program.py b/lms/lms/doctype/lms_program/lms_program.py index 0de0f5a72..d0d6fa139 100644 --- a/lms/lms/doctype/lms_program/lms_program.py +++ b/lms/lms/doctype/lms_program/lms_program.py @@ -11,82 +11,6 @@ def validate(self): self.validate_program_courses() self.validate_program_members() - def on_update(self): - self.manage_acccess() - - def manage_acccess(self): - old_doc = self.get_doc_before_save() - - if not old_doc: - return - - previous_courses = [row.course for row in old_doc.program_courses] - current_courses = [row.course for row in self.program_courses] - - print("previous_courses", previous_courses) - print("current_courses", current_courses) - - previous_members = [row.member for row in old_doc.program_members] - current_members = [row.member for row in self.program_members] - - print("previous_members", previous_members) - print("current_members", current_members) - - courses_added = [ - course for course in current_courses if course not in previous_courses - ] - courses_removed = [ - course for course in previous_courses if course not in current_courses - ] - - members_added = [ - member for member in current_members if member not in previous_members - ] - members_removed = [ - member for member in previous_members if member not in current_members - ] - - print(courses_removed) - print(members_removed) - - if len(courses_added) > 0: - self.grant_program_access(current_members, courses_added) - - if len(courses_removed) > 0: - print(courses_removed) - self.revoke_program_access(current_members, courses_removed) - - if len(members_added) > 0: - self.grant_program_access(members_added, current_courses) - - if len(members_removed) > 0: - print(members_removed) - self.revoke_program_access(members_removed, current_courses) - - def grant_program_access(self, members, courses): - for course in courses: - for member in members: - enrollment = frappe.db.exists( - "LMS Enrollment", {"course": course, "member": member} - ) - if not enrollment: - enrollment = frappe.new_doc("LMS Enrollment") - enrollment.course = course - enrollment.member = member - enrollment.insert() - - def revoke_program_access(self, members, courses): - for course in courses: - print(course) - for member in members: - print(member) - enrollment = frappe.db.exists( - "LMS Enrollment", {"course": course, "member": member} - ) - print(enrollment) - if enrollment: - frappe.delete_doc("LMS Enrollment", enrollment) - def validate_program_courses(self): courses = [row.course for row in self.program_courses] duplicates = {course for course in courses if courses.count(course) > 1} diff --git a/lms/lms/doctype/lms_program_member/lms_program_member.json b/lms/lms/doctype/lms_program_member/lms_program_member.json index d8553936f..f629e1f22 100644 --- a/lms/lms/doctype/lms_program_member/lms_program_member.json +++ b/lms/lms/doctype/lms_program_member/lms_program_member.json @@ -7,7 +7,8 @@ "engine": "InnoDB", "field_order": [ "member", - "full_name" + "full_name", + "progress" ], "fields": [ { @@ -25,12 +26,19 @@ "in_list_view": 1, "label": "Full Name", "read_only": 1 + }, + { + "default": "0", + "fieldname": "progress", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Progress" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-11-18 12:44:02.648786", + "modified": "2024-11-21 12:51:31.882576", "modified_by": "Administrator", "module": "LMS", "name": "LMS Program Member", diff --git a/lms/lms/utils.py b/lms/lms/utils.py index b94c0350b..3c0ba3c00 100644 --- a/lms/lms/utils.py +++ b/lms/lms/utils.py @@ -1763,12 +1763,12 @@ def get_programs(): programs = frappe.get_all("LMS Program", fields=["name"]) else: programs = frappe.get_all( - "LMS Program Member", {"member": frappe.session.user}, ["parent as name"] + "LMS Program Member", {"member": frappe.session.user}, ["parent as name", "progress"] ) for program in programs: program_courses = frappe.get_all( - "LMS Program Course", {"parent": program.name}, ["course"] + "LMS Program Course", {"parent": program.name}, ["course"], order_by="idx" ) program.courses = [] for course in program_courses: @@ -1777,3 +1777,55 @@ def get_programs(): program.members = frappe.db.count("LMS Program Member", {"parent": program.name}) return programs + + +@frappe.whitelist() +def enroll_in_program_course(program, course): + enrollment = frappe.db.exists( + "LMS Enrollment", {"member": frappe.session.user, "course": course} + ) + + if enrollment: + enrollment = frappe.db.get_value( + "LMS Enrollment", enrollment, ["name", "current_lesson"], as_dict=1 + ) + enrollment.current_lesson = get_lesson_index(enrollment.current_lesson) + return enrollment + + program_courses = frappe.get_all( + "LMS Program Course", {"parent": program}, ["course", "idx"], order_by="idx" + ) + current_course_idx = [ + program_course.idx + for program_course in program_courses + if program_course.course == course + ][0] + + for program_course in program_courses: + if program_course.idx < current_course_idx: + enrollment = frappe.db.get_value( + "LMS Enrollment", + {"member": frappe.session.user, "course": program_course.course}, + ["name", "progress"], + as_dict=1, + ) + if enrollment and enrollment.progress != 100: + frappe.throw( + _("Please complete the previous courses in the program to enroll in this course.") + ) + elif not enrollment: + frappe.throw( + _("Please complete the previous courses in the program to enroll in this course.") + ) + else: + continue + + enrollment = frappe.new_doc("LMS Enrollment") + enrollment.update( + { + "member": frappe.session.user, + "course": course, + } + ) + enrollment.save() + return enrollment diff --git a/lms/lms/workspace/lms/lms.json b/lms/lms/workspace/lms/lms.json index 2a52d245f..f92a845e6 100644 --- a/lms/lms/workspace/lms/lms.json +++ b/lms/lms/workspace/lms/lms.json @@ -1,4 +1,5 @@ { + "app": "lms", "charts": [ { "chart_name": "New Signups", @@ -145,7 +146,7 @@ "type": "Link" } ], - "modified": "2024-08-09 13:19:06.273056", + "modified": "2024-11-21 12:16:25.886431", "modified_by": "Administrator", "module": "LMS", "name": "LMS", @@ -212,5 +213,6 @@ "type": "DocType" } ], - "title": "LMS" -} + "title": "LMS", + "type": "Workspace" +} \ No newline at end of file From 68e87f20aaff5c2944a72b351148fbf505d38625 Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Fri, 22 Nov 2024 11:07:23 +0530 Subject: [PATCH 4/4] feat: added progress column in program members list --- frontend/src/pages/ProgramForm.vue | 6 ++++++ frontend/src/pages/Programs.vue | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/ProgramForm.vue b/frontend/src/pages/ProgramForm.vue index 092e41b80..9194e94ef 100644 --- a/frontend/src/pages/ProgramForm.vue +++ b/frontend/src/pages/ProgramForm.vue @@ -331,6 +331,12 @@ const memberColumns = computed(() => { width: 3, align: 'left', }, + { + label: 'Progress', + key: 'progress', + width: 3, + align: 'left', + }, ] }) diff --git a/frontend/src/pages/Programs.vue b/frontend/src/pages/Programs.vue index 860a12444..b68f0986c 100644 --- a/frontend/src/pages/Programs.vue +++ b/frontend/src/pages/Programs.vue @@ -32,7 +32,12 @@ program.members == 1 ? __(singularize('members')) : __('members') }} - + {{ program.progress }}{{ __('% completed') }}