From 70b2a11cb78c8d0dbd224b31306fb95948c80f5e Mon Sep 17 00:00:00 2001 From: FahidLatheef Date: Tue, 21 Jan 2025 16:23:23 +0530 Subject: [PATCH 1/8] feat: added is_scorm_chapter and scorm_details fields used for tracking SCORM Data Model feat: added is_scorm_chapter and scorm_content to track cmi.suspend_data for resuming scorm chapter --- .../lms_course_progress.json | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/lms/lms/doctype/lms_course_progress/lms_course_progress.json b/lms/lms/doctype/lms_course_progress/lms_course_progress.json index b05e1dd2..a0c89f76 100644 --- a/lms/lms/doctype/lms_course_progress/lms_course_progress.json +++ b/lms/lms/doctype/lms_course_progress/lms_course_progress.json @@ -11,7 +11,11 @@ "column_break_3", "lesson", "chapter", - "course" + "course", + "section_break_uoob", + "is_scorm_chapter", + "column_break_wskp", + "scorm_content" ], "fields": [ { @@ -68,11 +72,36 @@ "fieldtype": "Data", "label": "Member Name", "read_only": 1 + }, + { + "fieldname": "section_break_uoob", + "fieldtype": "Section Break" + }, + { + "default": "0", + "fetch_from": "chapter.is_scorm_package", + "fetch_if_empty": 1, + "fieldname": "is_scorm_chapter", + "fieldtype": "Check", + "label": "Is SCORM Chapter", + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "column_break_wskp", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval: doc.is_scorm_chapter == 1 && doc.status == 'Partially Complete'", + "fieldname": "scorm_content", + "fieldtype": "Long Text", + "label": "SCORM Content", + "read_only": 1 } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2025-01-17 15:54:34.040621", + "modified": "2025-01-20 22:25:36.829929", "modified_by": "Administrator", "module": "LMS", "name": "LMS Course Progress", @@ -106,4 +135,4 @@ "states": [], "title_field": "member_name", "track_changes": 1 -} \ No newline at end of file +} From 281e1554804582fc44515a96084803d6ec7e31a2 Mon Sep 17 00:00:00 2001 From: FahidLatheef Date: Tue, 21 Jan 2025 16:23:28 +0530 Subject: [PATCH 2/8] feat: set cmi.launch_data from scorm_content for resumability - Replaced already_completed with progress_already_exists variable - Added scorm_details parameter to save_progress whitelisted function - Added Separate logic for SCORM chapter progress in save_progress --- frontend/src/pages/SCORMChapter.vue | 28 +++++++---- .../doctype/course_lesson/course_lesson.py | 47 +++++++++++++++++-- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/frontend/src/pages/SCORMChapter.vue b/frontend/src/pages/SCORMChapter.vue index fed85dbe..ed3d51bb 100644 --- a/frontend/src/pages/SCORMChapter.vue +++ b/frontend/src/pages/SCORMChapter.vue @@ -86,25 +86,35 @@ const enrollment = createListResource({ }) const getDataFromLMS = (key) => { - if (key == 'cmi.core.lesson_status') { - if (progress.data?.status == 'Complete') { - return 'passed' - } - return 'incomplete' + if (key === 'cmi.core.lesson_status') { + return progress.data?.status === 'Complete' ? 'passed' : 'incomplete' + } else if (key === 'cmi.launch_data') { + return progress.data?.scorm_content || '' + } else if (key === 'cmi.suspend_data') { + return progress.data?.scorm_content || '' } return '' } const saveDataToLMS = (key, value) => { - if (key == 'cmi.core.lesson_status' && value == 'passed') { - saveProgress() + if (key === 'cmi.core.lesson_status' && value === 'passed') { + saveProgress({ + is_complete: true, + scorm_content: '', + }) + } else if (key === 'cmi.suspend_data') { + saveProgress({ + is_complete: false, + scorm_content: value, + }) } } -const saveProgress = () => { +const saveProgress = (scormDetails = null) => { call('lms.lms.doctype.course_lesson.course_lesson.save_progress', { lesson: chapter.doc.lessons[0].lesson, course: props.courseName, + scorm_details: scormDetails, }) } @@ -113,7 +123,7 @@ const progress = createResource({ makeParams(values) { return { doctype: 'LMS Course Progress', - fieldname: 'status', + fieldname: ['status', 'scorm_content'], filters: { member: user.data?.name, lesson: chapter.doc.lessons[0].lesson, diff --git a/lms/lms/doctype/course_lesson/course_lesson.py b/lms/lms/doctype/course_lesson/course_lesson.py index 4057a2ab..a35b16e7 100644 --- a/lms/lms/doctype/course_lesson/course_lesson.py +++ b/lms/lms/doctype/course_lesson/course_lesson.py @@ -8,6 +8,7 @@ from frappe.utils.telemetry import capture from lms.lms.utils import get_course_progress from ...md import find_macros import json +from pydantic import BaseModel class CourseLesson(Document): @@ -73,8 +74,16 @@ class CourseLesson(Document): return [frappe.get_doc("LMS Exercise", name) for name in exercises] +class SCORMDetails(BaseModel): + is_complete: bool + scorm_content: str | None = None + + @frappe.whitelist() -def save_progress(lesson, course): +def save_progress(lesson: str, course: str, scorm_details: SCORMDetails | None = None): + """ + Note: Pass the argument scorm_details only if it is SCORM related save_progress + """ membership = frappe.db.exists( "LMS Enrollment", {"course": course, "member": frappe.session.user} ) @@ -82,14 +91,23 @@ def save_progress(lesson, course): return 0 frappe.db.set_value("LMS Enrollment", membership, "current_lesson", lesson) - already_completed = frappe.db.exists( + progress_already_exists = frappe.db.exists( "LMS Course Progress", {"lesson": lesson, "member": frappe.session.user} ) + lesson_already_completed = frappe.db.exists( + "LMS Course Progress", + {"lesson": lesson, "member": frappe.session.user, "status": "Complete"}, + ) quiz_completed = get_quiz_progress(lesson) assignment_completed = get_assignment_progress(lesson) - if not already_completed and quiz_completed and assignment_completed: + if ( + not progress_already_exists + and quiz_completed + and assignment_completed + and not scorm_details + ): frappe.get_doc( { "doctype": "LMS Course Progress", @@ -98,6 +116,29 @@ def save_progress(lesson, course): "member": frappe.session.user, } ).save(ignore_permissions=True) + elif scorm_details and not lesson_already_completed and not progress_already_exists: + # Create new SCORM progress + frappe.get_doc( + { + "doctype": "LMS Course Progress", + "lesson": lesson, + "status": "Complete" if scorm_details.is_complete else "Partially Complete", + "member": frappe.session.user, + "scorm_content": "" if scorm_details.is_complete else scorm_details.scorm_content, + } + ).save(ignore_permissions=True) + elif scorm_details and not lesson_already_completed and progress_already_exists: + # Update Existing SCORM Progress + frappe.db.set_value( + "LMS Course Progress", + progress_already_exists, + { + "lesson": lesson, + "status": "Complete" if scorm_details.is_complete else "Partially Complete", + "member": frappe.session.user, + "scorm_content": "" if scorm_details.is_complete else scorm_details.scorm_content, + }, + ) progress = get_course_progress(course) capture_progress_for_analytics(progress, course) From 19e5136d64ebb4dee293271a3b42229184096bb1 Mon Sep 17 00:00:00 2001 From: FahidLatheef Date: Tue, 21 Jan 2025 16:23:33 +0530 Subject: [PATCH 3/8] feat: added logic to handle failure cases on SCORM courses failure (Whether to allow retake of final quiz or Reset the whole Course) --- frontend/src/pages/SCORMChapter.vue | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/SCORMChapter.vue b/frontend/src/pages/SCORMChapter.vue index ed3d51bb..7c8a68c8 100644 --- a/frontend/src/pages/SCORMChapter.vue +++ b/frontend/src/pages/SCORMChapter.vue @@ -47,6 +47,12 @@ import { updateDocumentTitle } from '@/utils' const sidebarStore = useSidebar() const user = inject('$user') const readyToRender = ref(false) +const isSuccessfullyCompleted = ref(false) + +// If courseRestartOnFailure is true, student has to restart the whole course if failed. +// Otherwise, student could retake the final quiz portion. +// Ideally, this should be configurable along with `Number of failures before course should restart`. +const courseRestartOnFailure = false const props = defineProps({ courseName: { @@ -97,12 +103,20 @@ const getDataFromLMS = (key) => { } const saveDataToLMS = (key, value) => { - if (key === 'cmi.core.lesson_status' && value === 'passed') { - saveProgress({ - is_complete: true, - scorm_content: '', - }) - } else if (key === 'cmi.suspend_data') { + if (key === 'cmi.core.lesson_status') { + if (value === 'passed') { + isSuccessfullyCompleted.value = true + saveProgress({ + is_complete: isSuccessfullyCompleted.value, + scorm_content: '', + }) + } else if (value === 'failed' && courseRestartOnFailure) { + saveProgress({ + is_complete: isSuccessfullyCompleted.value, + scorm_content: '', + }) + } + } else if (key === 'cmi.suspend_data' && !isSuccessfullyCompleted.value) { saveProgress({ is_complete: false, scorm_content: value, From 2ce2df6390ea3645131fd8a303627f60d2de622e Mon Sep 17 00:00:00 2001 From: FahidLatheef Date: Tue, 21 Jan 2025 16:23:38 +0530 Subject: [PATCH 4/8] style: change height of SCORM iframe so that user don't have to scroll --- frontend/src/pages/SCORMChapter.vue | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/SCORMChapter.vue b/frontend/src/pages/SCORMChapter.vue index 7c8a68c8..059eebe6 100644 --- a/frontend/src/pages/SCORMChapter.vue +++ b/frontend/src/pages/SCORMChapter.vue @@ -12,7 +12,10 @@ user.data?.is_instructor) " > -