diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..4f6dfa59 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - "**/test_helper.py" \ No newline at end of file diff --git a/frappe-semgrep-rules b/frappe-semgrep-rules new file mode 160000 index 00000000..239029b7 --- /dev/null +++ b/frappe-semgrep-rules @@ -0,0 +1 @@ +Subproject commit 239029b7ebaf2ec0e83222ca8bc668c8c76668f9 diff --git a/frontend/src/components/Controls/Autocomplete.vue b/frontend/src/components/Controls/Autocomplete.vue index 4da8274e..dee1268f 100644 --- a/frontend/src/components/Controls/Autocomplete.vue +++ b/frontend/src/components/Controls/Autocomplete.vue @@ -99,18 +99,17 @@ name="item-label" v-bind="{ active, selected, option }" > -
-
- {{ option.label }} +
+
+ {{ + option.value == option.label + ? option.description + : option.label + }} +
+
+ {{ option.value }}
-
diff --git a/frontend/src/components/CourseOutline.vue b/frontend/src/components/CourseOutline.vue index ce4a07dc..ba295734 100644 --- a/frontend/src/components/CourseOutline.vue +++ b/frontend/src/components/CourseOutline.vue @@ -112,6 +112,14 @@ v-else-if="lesson.icon === 'icon-quiz'" class="h-4 w-4 stroke-1 mr-2" /> + + + diff --git a/frontend/src/components/NumberChartGraph.vue b/frontend/src/components/NumberChartGraph.vue index 51c771c3..67fdd508 100644 --- a/frontend/src/components/NumberChartGraph.vue +++ b/frontend/src/components/NumberChartGraph.vue @@ -5,7 +5,7 @@
-
+
{{ value }}
diff --git a/frontend/src/components/Settings/SettingFields.vue b/frontend/src/components/Settings/SettingFields.vue index efb9c071..eb622e36 100644 --- a/frontend/src/components/Settings/SettingFields.vue +++ b/frontend/src/components/Settings/SettingFields.vue @@ -65,7 +65,7 @@
diff --git a/frontend/src/pages/Courses/CourseDashboard.vue b/frontend/src/pages/Courses/CourseDashboard.vue index febc059b..186c29f1 100644 --- a/frontend/src/pages/Courses/CourseDashboard.vue +++ b/frontend/src/pages/Courses/CourseDashboard.vue @@ -22,7 +22,7 @@
-
+
{{ __('Students') }}
@@ -63,50 +63,52 @@ - - - +
+ {{ dayjs(row[column.key]).format('DD MMM YYYY') }} +
+
+ {{ Math.ceil(row[column.key]) }}% +
+
+ {{ row[column.key].toString() }} +
+ + +
-
- {{ - Math.round((row.value / course.data?.enrollments) * 100) - }}% -
+ +
+ {{ + Math.round((row.value / course.data?.enrollments) * 100) + }}% +
+
-
+
+ diff --git a/frontend/src/pages/Home/StudentHome.vue b/frontend/src/pages/Home/StudentHome.vue index 40f66849..1c45e331 100644 --- a/frontend/src/pages/Home/StudentHome.vue +++ b/frontend/src/pages/Home/StudentHome.vue @@ -72,7 +72,7 @@
-
+
{{ diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index e0a69d55..2261349c 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -513,7 +513,8 @@ const getSidebarItems = () => { : settings.data?.contact_us_email, condition: () => { return ( - settings?.data?.contact_us_email || + (settings?.data?.contact_us_email && + userResource?.data) || settings?.data?.contact_us_url ) }, diff --git a/lms/hooks.py b/lms/hooks.py index 26f1ce31..2a289ce1 100644 --- a/lms/hooks.py +++ b/lms/hooks.py @@ -3,7 +3,7 @@ import frappe from . import __version__ as app_version app_name = "frappe_lms" -app_title = "Frappe LMS" +app_title = "Learning" app_publisher = "Frappe" app_description = "Frappe LMS App" app_icon_url = "/assets/lms/images/lms-logo.png" diff --git a/lms/lms/api.py b/lms/lms/api.py index d7ac8173..931b23b7 100644 --- a/lms/lms/api.py +++ b/lms/lms/api.py @@ -1654,8 +1654,12 @@ def get_progress_distribution(progressList): "value": len([p for p in progressList if 30 <= p < 60]), }, { - "name": "Advanced (60-100%)", - "value": len([p for p in progressList if 60 <= p <= 100]), + "name": "Advanced (60-99%)", + "value": len([p for p in progressList if 60 <= p < 100]), + }, + { + "name": "Completed (100%)", + "value": len([p for p in progressList if p == 100]), }, ] @@ -2037,7 +2041,7 @@ def delete_programming_exercise(exercise): @frappe.whitelist() -def get_lesson_completion_stats(course): +def get_lesson_completion_stats(course: str): roles = frappe.get_roles() if "Course Creator" not in roles and "Moderator" not in roles: frappe.throw(_("You do not have permission to access lesson completion stats.")) @@ -2048,13 +2052,17 @@ def get_lesson_completion_stats(course): Lesson = frappe.qb.DocType("Course Lesson") rows = ( - frappe.qb.from_(CourseProgress) - .join(LessonReference) - .on(CourseProgress.lesson == LessonReference.lesson) + frappe.qb.from_(LessonReference) .join(ChapterReference) .on(LessonReference.parent == ChapterReference.chapter) .join(Lesson) - .on(CourseProgress.lesson == Lesson.name) + .on(LessonReference.lesson == Lesson.name) + .left_join(CourseProgress) + .on( + (CourseProgress.lesson == LessonReference.lesson) + & (CourseProgress.course == course) + & (CourseProgress.status == "Complete") + ) .select( LessonReference.idx, ChapterReference.idx.as_("chapter_idx"), @@ -2063,10 +2071,132 @@ def get_lesson_completion_stats(course): Lesson.name.as_("lesson_name"), fn.Count(CourseProgress.name).as_("completion_count"), ) - .where((CourseProgress.course == course) & (CourseProgress.status == "Complete")) - .groupby(CourseProgress.lesson) + .where(ChapterReference.parent == course) + .groupby(LessonReference.lesson) .orderby(ChapterReference.idx, LessonReference.idx) .run(as_dict=True) ) return rows + + +@frappe.whitelist() +def get_course_assessment_progress(course: str, member: str): + if not can_modify_course(course): + frappe.throw( + _("You do not have permission to access this course's assessment data."), frappe.PermissionError + ) + + quizzes = get_course_quiz_progress(course, member) + assignments = get_course_assignment_progress(course, member) + programming_exercises = get_course_programming_exercise_progress(course, member) + + return { + "quizzes": quizzes, + "assignments": assignments, + "exercises": programming_exercises, + } + + +def get_course_quiz_progress(course: str, member: str): + quizzes = get_assessment_from_lesson(course, "quiz") + attempts = [] + + for quiz in quizzes: + submissions = frappe.get_all( + "LMS Quiz Submission", + { + "quiz": quiz, + "member": member, + }, + ["name", "score", "percentage", "quiz", "quiz_title"], + order_by="creation desc", + limit=1, + ) + if len(submissions): + attempts.append(submissions[0]) + else: + attempts.append( + { + "quiz": quiz, + "quiz_title": frappe.db.get_value("LMS Quiz", quiz, "title"), + "score": 0, + "percentage": 0, + } + ) + + return attempts + + +def get_course_assignment_progress(course: str, member: str): + assignments = get_assessment_from_lesson(course, "assignment") + submissions = [] + + for assignment in assignments: + assignment_subs = frappe.get_all( + "LMS Assignment Submission", + { + "assignment": assignment, + "member": member, + }, + ["name", "status", "assignment", "assignment_title"], + order_by="creation desc", + limit=1, + ) + if len(assignment_subs): + submissions.append(assignment_subs[0]) + else: + submissions.append( + { + "assignment": assignment, + "assignment_title": frappe.db.get_value("LMS Assignment", assignment, "title"), + "status": "Not Submitted", + } + ) + + return submissions + + +def get_course_programming_exercise_progress(course: str, member: str): + exercises = get_assessment_from_lesson(course, "program") + submissions = [] + + for exercise in exercises: + exercise_subs = frappe.get_all( + "LMS Programming Exercise Submission", + { + "exercise": exercise, + "member": member, + }, + ["name", "status", "exercise", "exercise_title"], + order_by="creation desc", + limit=1, + ) + if len(exercise_subs): + submissions.append(exercise_subs[0]) + else: + submissions.append( + { + "exercise": exercise, + "exercise_title": frappe.db.get_value("LMS Programming Exercise", exercise, "title"), + "status": "Not Attempted", + } + ) + + return submissions + + +def get_assessment_from_lesson(course: str, assessmentType: str): + assessments = [] + lessons = frappe.get_all("Course Lesson", {"course": course}, ["name", "title", "content"]) + + for lesson in lessons: + if lesson.content: + content = json.loads(lesson.content) + for block in content.get("blocks", []): + if block.get("type") == assessmentType: + data_field = "exercise" if assessmentType == "program" else assessmentType + quiz_name = block.get("data", {}).get(data_field) + assessments.append(quiz_name) + + return assessments diff --git a/lms/lms/doctype/lms_assignment/lms_assignment.json b/lms/lms/doctype/lms_assignment/lms_assignment.json index 6dfabd71..f9abc409 100644 --- a/lms/lms/doctype/lms_assignment/lms_assignment.json +++ b/lms/lms/doctype/lms_assignment/lms_assignment.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_import": 1, "allow_rename": 1, "autoname": "format: ASG-{#####}", "creation": "2023-05-26 19:41:26.025081", @@ -79,8 +80,13 @@ ], "grid_page_length": 50, "index_web_pages_for_search": 1, - "links": [], - "modified": "2025-12-19 16:30:58.531722", + "links": [ + { + "link_doctype": "LMS Assignment Submission", + "link_fieldname": "assignment" + } + ], + "modified": "2026-02-05 11:37:36.492016", "modified_by": "sayali@frappe.io", "module": "LMS", "name": "LMS Assignment", @@ -104,6 +110,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, @@ -124,6 +131,7 @@ "create": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, @@ -135,6 +143,7 @@ "create": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 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 c03bbd5c..485cef55 100644 --- a/lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.json +++ b/lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.json @@ -42,7 +42,8 @@ "fieldname": "assignment", "fieldtype": "Link", "label": "Assignment", - "options": "LMS Assignment" + "options": "LMS Assignment", + "reqd": 1 }, { "fieldname": "member", @@ -150,7 +151,7 @@ "index_web_pages_for_search": 1, "links": [], "make_attachments_public": 1, - "modified": "2025-12-17 14:47:22.944223", + "modified": "2026-02-05 11:38:03.792865", "modified_by": "sayali@frappe.io", "module": "LMS", "name": "LMS Assignment Submission", diff --git a/lms/lms/doctype/lms_badge/lms_badge.json b/lms/lms/doctype/lms_badge/lms_badge.json index 2482804c..633106a0 100644 --- a/lms/lms/doctype/lms_badge/lms_badge.json +++ b/lms/lms/doctype/lms_badge/lms_badge.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_import": 1, "allow_rename": 1, "autoname": "field:title", "creation": "2024-04-30 11:29:53.548647", @@ -99,8 +100,8 @@ "link_fieldname": "badge" } ], - "modified": "2025-07-04 13:02:19.048994", - "modified_by": "Administrator", + "modified": "2026-02-03 10:52:37.122370", + "modified_by": "sayali@frappe.io", "module": "LMS", "name": "LMS Badge", "naming_rule": "By fieldname", @@ -118,13 +119,26 @@ "share": 1, "write": 1 }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "import": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Moderator", + "share": 1, + "write": 1 + }, { "email": 1, "export": 1, "print": 1, "read": 1, "report": 1, - "role": "All", + "role": "LMS Student", "share": 1 } ], diff --git a/lms/lms/doctype/lms_batch_enrollment/lms_batch_enrollment.json b/lms/lms/doctype/lms_batch_enrollment/lms_batch_enrollment.json index a2f05643..179867fb 100644 --- a/lms/lms/doctype/lms_batch_enrollment/lms_batch_enrollment.json +++ b/lms/lms/doctype/lms_batch_enrollment/lms_batch_enrollment.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_import": 1, "allow_rename": 1, "creation": "2025-02-10 11:17:12.462368", "doctype": "DocType", @@ -73,7 +74,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2026-01-14 08:53:16.672825", + "modified": "2026-02-03 10:51:28.475356", "modified_by": "sayali@frappe.io", "module": "LMS", "name": "LMS Batch Enrollment", @@ -96,6 +97,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, @@ -114,6 +116,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, diff --git a/lms/lms/doctype/lms_coupon/lms_coupon.json b/lms/lms/doctype/lms_coupon/lms_coupon.json index 26dcb5a9..5927a6c4 100644 --- a/lms/lms/doctype/lms_coupon/lms_coupon.json +++ b/lms/lms/doctype/lms_coupon/lms_coupon.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_import": 1, "allow_rename": 1, "autoname": "hash", "creation": "2025-10-11 21:39:11.456420", @@ -113,7 +114,7 @@ "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-10-27 19:52:11.835042", + "modified": "2026-02-03 10:50:23.387175", "modified_by": "sayali@frappe.io", "module": "LMS", "name": "LMS Coupon", @@ -149,6 +150,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, @@ -161,6 +163,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, @@ -173,6 +176,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 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 d71727ed..0aaab5ac 100644 --- a/lms/lms/doctype/lms_live_class/lms_live_class.json +++ b/lms/lms/doctype/lms_live_class/lms_live_class.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_import": 1, "allow_rename": 1, "creation": "2023-03-02 10:59:01.741349", "default_view": "List", @@ -177,7 +178,7 @@ "link_fieldname": "live_class" } ], - "modified": "2026-01-14 08:54:07.684781", + "modified": "2026-02-03 10:54:39.198916", "modified_by": "sayali@frappe.io", "module": "LMS", "name": "LMS Live Class", @@ -200,6 +201,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, @@ -221,6 +223,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, diff --git a/lms/lms/doctype/lms_payment/lms_payment.json b/lms/lms/doctype/lms_payment/lms_payment.json index 9688c3d4..fc62daa4 100644 --- a/lms/lms/doctype/lms_payment/lms_payment.json +++ b/lms/lms/doctype/lms_payment/lms_payment.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_import": 1, "allow_rename": 1, "creation": "2023-08-24 17:46:52.065763", "default_view": "List", @@ -201,7 +202,7 @@ "link_fieldname": "payment" } ], - "modified": "2025-12-19 17:55:25.968384", + "modified": "2026-02-03 10:54:12.361407", "modified_by": "sayali@frappe.io", "module": "LMS", "name": "LMS Payment", @@ -218,6 +219,19 @@ "role": "System Manager", "share": 1, "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "import": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Moderator", + "share": 1, + "write": 1 } ], "row_format": "Dynamic", diff --git a/lms/lms/doctype/lms_program/lms_program.json b/lms/lms/doctype/lms_program/lms_program.json index 29ad1551..b3213894 100644 --- a/lms/lms/doctype/lms_program/lms_program.json +++ b/lms/lms/doctype/lms_program/lms_program.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_import": 1, "allow_rename": 1, "autoname": "field:title", "creation": "2024-11-18 12:27:13.283169", @@ -92,7 +93,7 @@ "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-12-04 12:56:14.249363", + "modified": "2026-02-03 10:51:50.616781", "modified_by": "sayali@frappe.io", "module": "LMS", "name": "LMS Program", @@ -116,6 +117,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, @@ -128,6 +130,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, diff --git a/lms/lms/doctype/lms_programming_exercise/lms_programming_exercise.json b/lms/lms/doctype/lms_programming_exercise/lms_programming_exercise.json index c2fa9b8d..5a80c1d5 100644 --- a/lms/lms/doctype/lms_programming_exercise/lms_programming_exercise.json +++ b/lms/lms/doctype/lms_programming_exercise/lms_programming_exercise.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_import": 1, "allow_rename": 1, "creation": "2025-06-18 15:02:36.198855", "doctype": "DocType", @@ -33,7 +34,7 @@ "fieldname": "language", "fieldtype": "Select", "label": "Language", - "options": "Python\nJavaScript", + "options": "Python\nJavaScript\nRust\nGo", "reqd": 1 }, { @@ -63,7 +64,7 @@ "link_fieldname": "exercise" } ], - "modified": "2025-06-24 14:42:27.463492", + "modified": "2026-02-03 10:45:23.687185", "modified_by": "sayali@frappe.io", "module": "LMS", "name": "LMS Programming Exercise", @@ -74,6 +75,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, @@ -86,6 +88,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, @@ -98,6 +101,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, @@ -110,6 +114,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, diff --git a/lms/lms/doctype/lms_source/lms_source.json b/lms/lms/doctype/lms_source/lms_source.json index ea75d118..f77be20e 100644 --- a/lms/lms/doctype/lms_source/lms_source.json +++ b/lms/lms/doctype/lms_source/lms_source.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_import": 1, "allow_rename": 1, "autoname": "field:source", "creation": "2023-10-26 16:28:53.932278", @@ -21,7 +22,7 @@ "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-11-10 11:39:57.251861", + "modified": "2026-02-03 10:53:42.654881", "modified_by": "sayali@frappe.io", "module": "LMS", "name": "LMS Source", @@ -45,6 +46,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, diff --git a/lms/lms/doctype/lms_zoom_settings/lms_zoom_settings.json b/lms/lms/doctype/lms_zoom_settings/lms_zoom_settings.json index 53dbf8eb..26ca9b0e 100644 --- a/lms/lms/doctype/lms_zoom_settings/lms_zoom_settings.json +++ b/lms/lms/doctype/lms_zoom_settings/lms_zoom_settings.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_import": 1, "allow_rename": 1, "autoname": "field:account_name", "creation": "2025-05-26 13:04:18.285735", @@ -83,7 +84,7 @@ "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-11-10 11:39:13.146961", + "modified": "2026-02-03 10:50:59.906919", "modified_by": "sayali@frappe.io", "module": "LMS", "name": "LMS Zoom Settings", @@ -107,6 +108,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, @@ -120,6 +122,7 @@ "email": 1, "export": 1, "if_owner": 1, + "import": 1, "print": 1, "read": 1, "report": 1, diff --git a/lms/lms/test_api.py b/lms/lms/test_api.py new file mode 100644 index 00000000..28700217 --- /dev/null +++ b/lms/lms/test_api.py @@ -0,0 +1,63 @@ +import frappe + +from lms.lms.api import get_certified_participants, get_course_assessment_progress +from lms.lms.test_helpers import BaseTestUtils + + +class TestLMSAPI(BaseTestUtils): + def setUp(self): + super().setUp() + self._setup_course_flow() + + def test_certified_participants_with_category(self): + filters = {"category": "Utility Course"} + certified_participants = get_certified_participants(filters=filters) + self.assertEqual(len(certified_participants), 1) + self.assertEqual(certified_participants[0].member, self.student1.email) + + filters = {"category": "Nonexistent Category"} + certified_participants_no_match = get_certified_participants(filters=filters) + self.assertEqual(len(certified_participants_no_match), 0) + + def test_certified_participants_with_open_to_work(self): + filters = {"open_to_work": 1} + certified_participants_open_to_work = get_certified_participants(filters=filters) + self.assertEqual(len(certified_participants_open_to_work), 0) + + frappe.db.set_value("User", self.student1.email, "open_to", "Work") + certified_participants_open_to_work = get_certified_participants(filters=filters) + self.assertEqual(len(certified_participants_open_to_work), 1) + frappe.db.set_value("User", self.student1.email, "open_to", "") + + def test_certified_participants_with_open_to_hiring(self): + filters = {"hiring": 1} + certified_participants_hiring = get_certified_participants(filters=filters) + self.assertEqual(len(certified_participants_hiring), 0) + + frappe.db.set_value("User", self.student1.email, "open_to", "Hiring") + certified_participants_hiring = get_certified_participants(filters=filters) + self.assertEqual(len(certified_participants_hiring), 1) + frappe.db.set_value("User", self.student1.email, "open_to", "") + + def test_course_assessment_progress(self): + progress = get_course_assessment_progress(self.course.name, self.student1.name) + progress = frappe._dict(progress) + + self.assertEqual(len(progress.quizzes), 1) + for quiz in progress.quizzes: + self.assertEqual(quiz.quiz, self.quiz.name) + self.assertEqual(quiz.quiz_title, self.quiz.title) + self.assertEqual(quiz.score, 12) + self.assertEqual(quiz.percentage, 80) + + self.assertEqual(len(progress.assignments), 1) + for assignment in progress.assignments: + self.assertEqual(assignment.assignment, self.assignment.name) + self.assertEqual(assignment.assignment_title, self.assignment.title) + self.assertEqual(assignment.status, "Pass") + + self.assertEqual(len(progress.exercises), 1) + for exercise in progress.exercises: + self.assertEqual(exercise.exercise, self.programming_exercise.name) + self.assertEqual(exercise.exercise_title, self.programming_exercise.title) + self.assertEqual(exercise.status, "Passed") diff --git a/lms/lms/test_helpers.py b/lms/lms/test_helpers.py index 6d6e2008..e6a2362a 100644 --- a/lms/lms/test_helpers.py +++ b/lms/lms/test_helpers.py @@ -19,8 +19,8 @@ class BaseTestUtils(UnitTestCase): if frappe.db.exists(item_type, item_name): try: frappe.delete_doc(item_type, item_name, force=True) - except Exception: - pass + except Exception as e: + print(f"Error deleting {item_type} {item_name}: {e}") def _create_user(self, email, first_name, last_name, roles, user_type="Website User"): if frappe.db.exists("User", email): @@ -82,18 +82,21 @@ class BaseTestUtils(UnitTestCase): self.cleanup_items.append(("Course Chapter", chapter.name)) return chapter - def _create_lesson(self, title, chapter, course): + def _create_lesson(self, title, chapter, course, content=None): existing = frappe.db.exists("Course Lesson", {"course": course, "title": title}) if existing: return frappe.get_doc("Course Lesson", existing) + if not content: + content = '{"time":1765194986690,"blocks":[{"id":"dkLzbW14ds","type":"markdown","data":{"text":"This is a simple content for the current lesson."}},{"id":"KBwuWPc8rV","type":"markdown","data":{"text":""}}],"version":"2.29.0"}' + lesson = frappe.new_doc("Course Lesson") lesson.update( { "course": course, "chapter": chapter, "title": title, - "content": '{"time":1765194986690,"blocks":[{"id":"dkLzbW14ds","type":"markdown","data":{"text":"This is a simple content for the current lesson."}},{"id":"KBwuWPc8rV","type":"markdown","data":{"text":""}}],"version":"2.29.0"}', + "content": content, } ) lesson.save() @@ -248,3 +251,289 @@ class BaseTestUtils(UnitTestCase): certificate.save() self.cleanup_items.append(("LMS Certificate", certificate.name)) return certificate + + def _create_quiz_questions(self): + questions = [] + for index in range(1, 4): + question = frappe.new_doc("LMS Question") + question.update( + { + "question": f"Utility Question {index}?", + "type": "Choices", + "option_1": "Option 1", + "is_correct_1": 1, + "option_2": "Option 2", + "is_correct_2": 0, + } + ) + question.save() + self.cleanup_items.append(("LMS Quiz Question", question.name)) + questions.append(question) + return questions + + def _create_quiz(self, title="Utility Quiz"): + existing = frappe.db.exists("LMS Quiz", {"title": title}) + if existing: + return frappe.get_doc("LMS Quiz", existing) + + quiz = frappe.new_doc("LMS Quiz") + quiz.update( + { + "title": title, + "passing_percentage": 70, + "total_marks": 15, + } + ) + + for question in self.questions: + quiz.append( + "questions", + { + "question": question.name, + "marks": 5, + }, + ) + quiz.save() + self.cleanup_items.append(("LMS Quiz", quiz.name)) + return quiz + + def _create_assignment(self, title="Utility Assignment"): + existing = frappe.db.exists("LMS Assignment", {"title": title}) + if existing: + return frappe.get_doc("LMS Assignment", existing) + + assignment = frappe.new_doc("LMS Assignment") + assignment.update( + { + "title": title, + "question": "This is a utility assignment to test the assignment creation helper method.", + "type": "Text", + "grade_assignment": 1, + } + ) + assignment.save() + self.cleanup_items.append(("LMS Assignment", assignment.name)) + return assignment + + def _setup_course_flow(self): + self.student1 = self._create_user("student1@example.com", "Ashley", "Smith", ["LMS Student"]) + self.student2 = self._create_user("student2@example.com", "John", "Doe", ["LMS Student"]) + self.admin = self._create_user( + "frappe@example.com", "Frappe", "Admin", ["Moderator", "Course Creator", "Batch Evaluator"] + ) + self.course = self._create_course() + self._setup_quiz() + self._setup_assignment() + self._setup_programming_exercise() + self._setup_chapters() + + self._create_enrollment(self.student1.email, self.course.name) + self._add_student_progress(self.student1.email, self.course.name) + self._create_enrollment(self.student2.email, self.course.name) + self._add_student_progress(self.student2.email, self.course.name) + + self._add_rating(self.course.name, self.student1.email, 0.8, "Good course") + self._add_rating(self.course.name, self.student2.email, 1, "Excellent course") + + self._create_certificate(self.course.name, self.student1.email) + + def _setup_quiz(self): + self.questions = self._create_quiz_questions() + self.quiz = self._create_quiz() + + def _setup_assignment(self): + self.assignment = self._create_assignment() + + def _setup_programming_exercise(self): + self.programming_exercise = self._create_programming_exercise() + + def _setup_chapters(self): + chapters = [] + for i in range(1, 4): + chapter = self._create_chapter(f"Chapter {i}", self.course.name) + chapters.append(chapter) + self.course.reload() + for chapter in chapters: + if not any(c.chapter == chapter.name for c in self.course.chapters): + self.course.append("chapters", {"chapter": chapter.name}) + self.course.save() + self._setup_lessons() + + def _setup_lessons(self): + for index, chapter_ref in enumerate(self.course.chapters): + chapter_doc = frappe.get_doc("Course Chapter", chapter_ref.chapter) + for j in range(1, 5): + content = None + if j == 2 and index == 2: + content = self._get_quiz_lesson_content() + if j == 3 and index == 2: + content = self._get_assignment_lesson_content() + if j == 4 and index == 2: + content = self._get_exercise_lesson_content() + lesson_title = f"Lesson {j} of {chapter_ref.chapter}" + lesson = self._create_lesson(lesson_title, chapter_ref.chapter, self.course.name, content) + + if not any(l.lesson == lesson.name for l in chapter_doc.lessons): + chapter_doc.append("lessons", {"lesson": lesson.name}) + + chapter_doc.save() + + def _get_quiz_lesson_content(self): + return f"""{{ + "time": 1765194986690, + "blocks": [ + {{ + "id": "dkLzbW14ds", + "type": "quiz", + "data": {{ "quiz": "{self.quiz.name}" }} + }} + ], + "version": "2.29.0" + }}""" + + def _get_assignment_lesson_content(self): + return f"""{{ + "time": 1765194986690, + "blocks": [ + {{ + "id": "dkLzbW14ds", + "type": "assignment", + "data": {{ "assignment": "{self.assignment.name}" }} + }} + ], + "version": "2.29.0" + }}""" + + def _get_exercise_lesson_content(self): + return f"""{{ + "time": 1765194986690, + "blocks": [ + {{ + "id": "dkLzbW14ds", + "type": "program", + "data": {{ "exercise": "{self.programming_exercise.name}" }} + }} + ], + "version": "2.29.0" + }}""" + + def _setup_batch_flow(self): + self.evaluator = self._create_evaluator() + self.batch = self._create_batch(self.course.name) + self._create_batch_enrollment(self.student1.email, self.batch.name) + self._create_batch_enrollment(self.student2.email, self.batch.name) + + def _add_student_progress(self, member, course): + self._create_quiz_submission(member) + self._create_assignment_submission(member) + self._create_programming_exercise_submission(member) + lessons = frappe.db.get_all( + "Course Lesson", {"course": course}, pluck="name", limit=2, order_by="creation desc" + ) + for lesson in lessons: + self._create_lesson_progress(member, course, lesson) + + def _create_lesson_progress(self, member, course, lesson): + existing = frappe.db.exists( + "LMS Course Progress", {"member": member, "course": course, "lesson": lesson} + ) + if existing: + return frappe.get_doc("LMS Course Progress", existing) + + progress = frappe.new_doc("LMS Course Progress") + progress.update({"member": member, "course": course, "lesson": lesson, "status": "Complete"}) + progress.insert() + self.cleanup_items.append(("LMS Course Progress", progress.name)) + return progress + + def _create_quiz_submission(self, member): + existing = frappe.db.exists("LMS Quiz Submission", {"quiz": self.quiz.name, "member": member}) + if existing: + return frappe.get_doc("LMS Quiz Submission", existing) + submission = frappe.new_doc("LMS Quiz Submission") + submission.update( + { + "quiz": self.quiz.name, + "member": member, + "score_out_of": self.quiz.total_marks, + "passing_percentage": self.quiz.passing_percentage, + } + ) + + for question in self.questions: + submission.append( + "result", + { + "question": question.name, + "marks": 4, + "marks_out_of": 5, + }, + ) + + submission.insert() + self.cleanup_items.append(("LMS Quiz Submission", submission.name)) + return submission + + def _create_assignment_submission(self, member): + existing = frappe.db.exists( + "LMS Assignment Submission", {"assignment": self.assignment.name, "member": member} + ) + if existing: + return frappe.get_doc("LMS Assignment Submission", existing) + + submission = frappe.new_doc("LMS Assignment Submission") + submission.update( + { + "assignment": self.assignment.name, + "member": member, + "answer": "This is the submission content for the utility assignment.", + "status": "Pass", + } + ) + + submission.insert() + self.cleanup_items.append(("LMS Assignment Submission", submission.name)) + return submission + + def _create_programming_exercise(self, title="Utility Programming Exercise"): + existing = frappe.db.exists("LMS Programming Exercise", {"title": title}) + if existing: + return frappe.get_doc("LMS Programming Exercise", existing) + + programming_exercise = frappe.new_doc("LMS Programming Exercise") + programming_exercise.update( + { + "title": title, + "language": "Python", + "problem_statement": "Write a function to return the sum of two numbers.", + "test_cases": [ + {"input": "2", "expected_output": "3"}, + {"input": "11", "expected_output": "12"}, + ], + } + ) + programming_exercise.save() + self.cleanup_items.append(("LMS Programming Exercise", programming_exercise.name)) + return programming_exercise + + def _create_programming_exercise_submission(self, member): + existing = frappe.db.exists( + "LMS Programming Exercise Submission", + {"exercise": self.programming_exercise.name, "member": member}, + ) + if existing: + return frappe.get_doc("LMS Programming Exercise Submission", existing) + + submission = frappe.new_doc("LMS Programming Exercise Submission") + submission.update( + { + "exercise": self.programming_exercise.name, + "member": member, + "code": "print(inputs[0] + 1)", + "status": "Passed", + } + ) + + submission.insert() + self.cleanup_items.append(("LMS Programming Exercise Submission", submission.name)) + return submission diff --git a/lms/lms/test_utils.py b/lms/lms/test_utils.py index 0f59e454..827a7216 100644 --- a/lms/lms/test_utils.py +++ b/lms/lms/test_utils.py @@ -2,9 +2,8 @@ # See license.txt import frappe -from frappe.utils import get_time, getdate, to_timedelta +from frappe.utils import getdate, to_timedelta -from lms.lms.api import get_certified_participants from lms.lms.doctype.lms_certificate.lms_certificate import is_certified from lms.lms.test_helpers import BaseTestUtils from lms.lms.utils import ( @@ -33,48 +32,8 @@ class TestLMSUtils(BaseTestUtils): def setUp(self): super().setUp() - self.student1 = self._create_user("student1@example.com", "Ashley", "Smith", ["LMS Student"]) - self.student2 = self._create_user("student2@example.com", "John", "Doe", ["LMS Student"]) - self.admin = self._create_user( - "frappe@example.com", "Frappe", "Admin", ["Moderator", "Course Creator", "Batch Evaluator"] - ) - self.course = self._create_course() - self._setup_chapters_and_lessons() - - self._create_enrollment(self.student1.email, self.course.name) - self._create_enrollment(self.student2.email, self.course.name) - - self._add_rating(self.course.name, self.student1.email, 0.8, "Good course") - self._add_rating(self.course.name, self.student2.email, 1, "Excellent course") - - self._create_certificate(self.course.name, self.student1.email) - - self.evaluator = self._create_evaluator() - self.batch = self._create_batch(self.course.name) - self._create_batch_enrollment(self.student1.email, self.batch.name) - self._create_batch_enrollment(self.student2.email, self.batch.name) - - def _setup_chapters_and_lessons(self): - chapters = [] - for i in range(1, 4): - chapter = self._create_chapter(f"Chapter {i}", self.course.name) - chapters.append(chapter) - - self.course.reload() - for chapter in chapters: - if not any(c.chapter == chapter.name for c in self.course.chapters): - self.course.append("chapters", {"chapter": chapter.name}) - self.course.save() - - for chapter_ref in self.course.chapters: - chapter_doc = frappe.get_doc("Course Chapter", chapter_ref.chapter) - for j in range(1, 3): - lesson_title = f"Lesson {j} of {chapter_ref.chapter}" - lesson = self._create_lesson(lesson_title, chapter_ref.chapter, self.course.name) - - if not any(l.lesson == lesson.name for l in chapter_doc.lessons): - chapter_doc.append("lessons", {"lesson": lesson.name}) - chapter_doc.save() + self._setup_course_flow() + self._setup_batch_flow() def test_simple_slugs(self): self.assertEqual(slugify("hello-world"), "hello-world") @@ -156,36 +115,6 @@ class TestLMSUtils(BaseTestUtils): self.assertIsNone(is_certified(self.course.name)) frappe.session.user = "Administrator" - def test_certified_participants_with_category(self): - filters = {"category": "Utility Course"} - certified_participants = get_certified_participants(filters=filters) - self.assertEqual(len(certified_participants), 1) - self.assertEqual(certified_participants[0].member, self.student1.email) - - filters = {"category": "Nonexistent Category"} - certified_participants_no_match = get_certified_participants(filters=filters) - self.assertEqual(len(certified_participants_no_match), 0) - - def test_certified_participants_with_open_to_work(self): - filters = {"open_to_work": 1} - certified_participants_open_to_work = get_certified_participants(filters=filters) - self.assertEqual(len(certified_participants_open_to_work), 0) - - frappe.db.set_value("User", self.student1.email, "open_to", "Work") - certified_participants_open_to_work = get_certified_participants(filters=filters) - self.assertEqual(len(certified_participants_open_to_work), 1) - frappe.db.set_value("User", self.student1.email, "open_to", "") - - def test_certified_participants_with_open_to_hiring(self): - filters = {"hiring": 1} - certified_participants_hiring = get_certified_participants(filters=filters) - self.assertEqual(len(certified_participants_hiring), 0) - - frappe.db.set_value("User", self.student1.email, "open_to", "Hiring") - certified_participants_hiring = get_certified_participants(filters=filters) - self.assertEqual(len(certified_participants_hiring), 1) - frappe.db.set_value("User", self.student1.email, "open_to", "") - def test_rating_validation(self): student3 = self._create_user("student3@example.com", "Emily", "Cooper", ["LMS Student"]) with self.assertRaises(frappe.exceptions.ValidationError): diff --git a/lms/lms/utils.py b/lms/lms/utils.py index 4e4de890..f9c127c1 100644 --- a/lms/lms/utils.py +++ b/lms/lms/utils.py @@ -205,6 +205,10 @@ def get_lesson_icon(body, content): if block.get("type") == "quiz": return "icon-quiz" + if block.get("type") == "assignment": + return "icon-assignment" + if block.get("type") == "program": + return "icon-code" return "icon-list" diff --git a/lms/routing.py b/lms/routing.py deleted file mode 100644 index f5b0baf5..00000000 --- a/lms/routing.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Utilities for making custom routing.""" - -from werkzeug.datastructures import ImmutableDict -from werkzeug.routing import BaseConverter, Map - - -class RegexConverter(BaseConverter): - """werkzeug converter that supports custom regular expression. - - The `install_regex_converter` function must be called before using - regex converter in rules. - """ - - def __init__(self, map, regex): - super().__init__(map) - self.regex = regex - - -def install_regex_converter(): - """Installs the RegexConvetor to the default converters supported by werkzeug. - - This allows specifing rules using regex. For example: - - /profiles/ - """ - default_converters = dict(Map.default_converters, regex=RegexConverter) - Map.default_converters = ImmutableDict(default_converters) diff --git a/setup.py b/setup.py deleted file mode 100644 index 7f6e3f26..00000000 --- a/setup.py +++ /dev/null @@ -1,19 +0,0 @@ -from setuptools import find_packages, setup - -with open("requirements.txt") as f: - install_requires = f.read().strip().split("\n") - -# get version from __version__ variable in lms/__init__.py -from lms import __version__ as version - -setup( - name="lms", - version=version, - description="Learning Management System", - author="Jannat", - author_email="jannat@frappe.io", - packages=find_packages(), - zip_safe=False, - include_package_data=True, - install_requires=install_requires, -)