diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index a0b94a83..7dba321c 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) => { @@ -183,6 +185,28 @@ const addQuizzes = () => { } } +const addPrograms = () => { + 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: activeFor, + }) + } +} + const openPageModal = (link) => { showPageModal.value = true pageToEdit.value = link @@ -215,6 +239,7 @@ watch(userResource, () => { isModerator.value = userResource.data.is_moderator isInstructor.value = userResource.data.is_instructor addQuizzes() + addPrograms() } }) diff --git a/frontend/src/components/Controls/Autocomplete.vue b/frontend/src/components/Controls/Autocomplete.vue index 81f5358b..c4c18efa 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 b80484ba..6c2de9a5 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/CourseOutline.vue b/frontend/src/components/CourseOutline.vue index d93dbc2d..04f73937 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/Modals/Settings.vue b/frontend/src/components/Modals/Settings.vue index f8854df2..7f49ac6f 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 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, 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. 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/Quiz.vue b/frontend/src/components/Quiz.vue index 79096e9e..b4b3c42f 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/components/SettingDetails.vue b/frontend/src/components/SettingDetails.vue index 87a80f26..d90b1c4c 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 2cee55e9..cc74b3f7 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/Courses.vue b/frontend/src/pages/Courses.vue index 114a3c4c..16ae3ba0 100644 --- a/frontend/src/pages/Courses.vue +++ b/frontend/src/pages/Courses.vue @@ -160,30 +160,45 @@ diff --git a/frontend/src/pages/Programs.vue b/frontend/src/pages/Programs.vue new file mode 100644 index 00000000..b68f0986 --- /dev/null +++ b/frontend/src/pages/Programs.vue @@ -0,0 +1,185 @@ + + diff --git a/frontend/src/router.js b/frontend/src/router.js index cff7e5f6..d459209c 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/: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/stores/settings.js b/frontend/src/stores/settings.js index 61d6bedc..9844517f 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/lms/lms/doctype/course_lesson/course_lesson.py b/lms/lms/doctype/course_lesson/course_lesson.py index e1aac53c..2955305b 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 04f51875..389e1b2d 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 @@ class LMSEnrollment(Document): 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 @@ class LMSEnrollment(Document): ) ) + 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/__init__.py b/lms/lms/doctype/lms_program/__init__.py new file mode 100644 index 00000000..e69de29b 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 00000000..55710444 --- /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 00000000..5b4843fb --- /dev/null +++ b/lms/lms/doctype/lms_program/lms_program.json @@ -0,0 +1,84 @@ +{ + "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-20 12:26:02.214628", + "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 + }, + { + "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", + "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 00000000..d0d6fa13 --- /dev/null +++ b/lms/lms/doctype/lms_program/lms_program.py @@ -0,0 +1,32 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document + + +class LMSProgram(Document): + def validate(self): + self.validate_program_courses() + self.validate_program_members() + + 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_program/test_lms_program.py b/lms/lms/doctype/lms_program/test_lms_program.py new file mode 100644 index 00000000..e1599d98 --- /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 00000000..e69de29b 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 00000000..a7edc7ed --- /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 00000000..0d126937 --- /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 00000000..e69de29b 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 00000000..f629e1f2 --- /dev/null +++ b/lms/lms/doctype/lms_program_member/lms_program_member.json @@ -0,0 +1,50 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-11-18 12:29:13.615014", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "member", + "full_name", + "progress" + ], + "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 + }, + { + "default": "0", + "fieldname": "progress", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Progress" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2024-11-21 12:51:31.882576", + "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 00000000..473cd1f9 --- /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 d8bae686..27bba9ff 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-20 11:55:05.358421", "modified_by": "Administrator", "module": "LMS", "name": "LMS Settings", @@ -356,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 a02d73ab..3c0ba3c0 100644 --- a/lms/lms/utils.py +++ b/lms/lms/utils.py @@ -1751,3 +1751,81 @@ 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", "progress"] + ) + + for program in programs: + program_courses = frappe.get_all( + "LMS Program Course", {"parent": program.name}, ["course"], order_by="idx" + ) + 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 + + +@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 2a52d245..f92a845e 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