From 0b3425307e0aab029c838423374d205ff398aa5d Mon Sep 17 00:00:00 2001 From: purwaren Date: Sat, 4 Apr 2026 05:32:19 +0700 Subject: [PATCH 01/10] Add course final exam module --- frontend/components.d.ts | 2 + .../src/components/CertificationLinks.vue | 14 +- frontend/src/components/CourseCardOverlay.vue | 3 +- frontend/src/components/CourseFinalExam.vue | 67 ++ frontend/src/components/CourseOutline.vue | 56 +- frontend/src/components/Exam.vue | 600 ++++++++++++++++++ frontend/src/components/MobileLayout.vue | 10 + frontend/src/components/Modals/Question.vue | 9 +- frontend/src/pages/Courses/CourseOverview.vue | 2 + frontend/src/pages/Exam/ExamActivities.vue | 193 ++++++ frontend/src/pages/Exam/ExamForm.vue | 359 +++++++++++ frontend/src/pages/Exam/ExamPage.vue | 49 ++ frontend/src/pages/Exam/ExamSubmission.vue | 92 +++ .../src/pages/Exam/ExamSubmissionList.vue | 61 ++ frontend/src/router.js | 29 + frontend/src/utils/index.js | 15 + lms/lms/api.py | 5 + .../lms_certificate/lms_certificate.py | 12 + lms/lms/doctype/lms_exam/__init__.py | 1 + lms/lms/doctype/lms_exam/lms_exam.json | 251 ++++++++ lms/lms/doctype/lms_exam/lms_exam.py | 246 +++++++ lms/lms/doctype/lms_exam/test_lms_exam.py | 11 + .../doctype/lms_exam_prerequisite/__init__.py | 1 + .../lms_exam_prerequisite.json | 63 ++ .../lms_exam_prerequisite.py | 8 + lms/lms/doctype/lms_exam_question/__init__.py | 1 + .../lms_exam_question/lms_exam_question.json | 72 +++ .../lms_exam_question/lms_exam_question.py | 8 + lms/lms/doctype/lms_exam_result/__init__.py | 1 + .../lms_exam_result/lms_exam_result.json | 80 +++ .../lms_exam_result/lms_exam_result.py | 8 + .../doctype/lms_exam_submission/__init__.py | 1 + .../lms_exam_submission.json | 164 +++++ .../lms_exam_submission.py | 48 ++ .../test_lms_exam_submission.py | 9 + lms/lms/utils.py | 185 +++++- memory-bank/activeContext.md | 17 +- memory-bank/productContext.md | 3 + memory-bank/progress.md | 10 +- memory-bank/projectbrief.md | 1 + memory-bank/systemPatterns.md | 4 + memory-bank/techContext.md | 4 + 42 files changed, 2748 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/CourseFinalExam.vue create mode 100644 frontend/src/components/Exam.vue create mode 100644 frontend/src/pages/Exam/ExamActivities.vue create mode 100644 frontend/src/pages/Exam/ExamForm.vue create mode 100644 frontend/src/pages/Exam/ExamPage.vue create mode 100644 frontend/src/pages/Exam/ExamSubmission.vue create mode 100644 frontend/src/pages/Exam/ExamSubmissionList.vue create mode 100644 lms/lms/doctype/lms_exam/__init__.py create mode 100644 lms/lms/doctype/lms_exam/lms_exam.json create mode 100644 lms/lms/doctype/lms_exam/lms_exam.py create mode 100644 lms/lms/doctype/lms_exam/test_lms_exam.py create mode 100644 lms/lms/doctype/lms_exam_prerequisite/__init__.py create mode 100644 lms/lms/doctype/lms_exam_prerequisite/lms_exam_prerequisite.json create mode 100644 lms/lms/doctype/lms_exam_prerequisite/lms_exam_prerequisite.py create mode 100644 lms/lms/doctype/lms_exam_question/__init__.py create mode 100644 lms/lms/doctype/lms_exam_question/lms_exam_question.json create mode 100644 lms/lms/doctype/lms_exam_question/lms_exam_question.py create mode 100644 lms/lms/doctype/lms_exam_result/__init__.py create mode 100644 lms/lms/doctype/lms_exam_result/lms_exam_result.json create mode 100644 lms/lms/doctype/lms_exam_result/lms_exam_result.py create mode 100644 lms/lms/doctype/lms_exam_submission/__init__.py create mode 100644 lms/lms/doctype/lms_exam_submission/lms_exam_submission.json create mode 100644 lms/lms/doctype/lms_exam_submission/lms_exam_submission.py create mode 100644 lms/lms/doctype/lms_exam_submission/test_lms_exam_submission.py diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 727e4ac56d..deb9785d71 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -41,6 +41,7 @@ declare module 'vue' { Coupons: typeof import('./src/components/Settings/Coupons/Coupons.vue')['default'] CourseCard: typeof import('./src/components/CourseCard.vue')['default'] CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default'] + CourseFinalExam: typeof import('./src/components/CourseFinalExam.vue')['default'] CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default'] CourseOutline: typeof import('./src/components/CourseOutline.vue')['default'] CourseReviews: typeof import('./src/components/CourseReviews.vue')['default'] @@ -60,6 +61,7 @@ declare module 'vue' { EvaluationModal: typeof import('./src/components/Modals/EvaluationModal.vue')['default'] Evaluators: typeof import('./src/components/Settings/Evaluators.vue')['default'] Event: typeof import('./src/components/Modals/Event.vue')['default'] + Exam: typeof import('./src/components/Exam.vue')['default'] ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default'] FeedbackModal: typeof import('./src/components/Modals/FeedbackModal.vue')['default'] FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default'] diff --git a/frontend/src/components/CertificationLinks.vue b/frontend/src/components/CertificationLinks.vue index 1f371def6b..4571c9ed4f 100644 --- a/frontend/src/components/CertificationLinks.vue +++ b/frontend/src/components/CertificationLinks.vue @@ -14,7 +14,8 @@ certification.data && certification.data.membership && certification.data.paid_certificate && - user.data?.is_student + user.data?.is_student && + (!certification.data.final_exam || certification.data.final_exam.passed) " > +
+ {{ __('Pass the final exam to unlock certification.') }} +
diff --git a/frontend/src/components/CourseOutline.vue b/frontend/src/components/CourseOutline.vue index 9ca22b66c1..54c3ccc523 100644 --- a/frontend/src/components/CourseOutline.vue +++ b/frontend/src/components/CourseOutline.vue @@ -97,18 +97,11 @@
{{ lesson.title }} + + {{ __('Exam') }} + + + {{ __('Locked') }} + const outline = createResource({ url: 'lms.lms.utils.get_course_outline', - cache: ['course_outline', props.courseName], + cache: ['course_outline', props.courseName, !props.allowEdit], makeParams() { return { course: props.courseName, progress: props.getProgress, + include_exams: !props.allowEdit, } }, auto: true, @@ -447,10 +453,32 @@ const isScormChapterComplete = (chapter) => { return chapter.lessons?.length && chapter.lessons.every((l) => l.is_complete) } -const isActiveLesson = (lessonNumber) => { +const getLessonRoute = (lesson) => { + if (lesson.is_exam) { + return { + name: props.allowEdit ? 'ExamForm' : 'ExamPage', + params: { + examID: lesson.name, + }, + } + } + return { + name: props.allowEdit ? 'LessonForm' : 'Lesson', + params: { + courseName: props.courseName, + chapterNumber: lesson.number.split('-')[0], + lessonNumber: lesson.number.split('-')[1], + }, + } +} + +const isActiveLesson = (lesson) => { + if (lesson.is_exam) { + return route.name === 'ExamPage' && route.params.examID === lesson.name + } return ( - route.params.chapterNumber == lessonNumber.split('-')[0] && - route.params.lessonNumber == lessonNumber.split('-')[1] + route.params.chapterNumber == lesson.number.split('-')[0] && + route.params.lessonNumber == lesson.number.split('-')[1] ) } diff --git a/frontend/src/components/Exam.vue b/frontend/src/components/Exam.vue new file mode 100644 index 0000000000..0654919d96 --- /dev/null +++ b/frontend/src/components/Exam.vue @@ -0,0 +1,600 @@ + + + diff --git a/frontend/src/components/MobileLayout.vue b/frontend/src/components/MobileLayout.vue index d067560339..ca50a17566 100644 --- a/frontend/src/components/MobileLayout.vue +++ b/frontend/src/components/MobileLayout.vue @@ -155,6 +155,7 @@ watch(userResource, () => { if (isModerator.value || isInstructor.value) { addProgrammingExercises() addQuizzes() + addExams() addDragDropActivities() addWordHuntActivities() addAssignments() @@ -185,6 +186,15 @@ const addDragDropActivities = () => { }) } +const addExams = () => { + otherLinks.value.push({ + label: 'Exams', + icon: 'NotebookPen', + to: 'Exams', + activeFor: ['Exams', 'ExamForm', 'ExamPage', 'ExamSubmissionList', 'ExamSubmission'], + }) +} + const addWordHuntActivities = () => { otherLinks.value.push({ label: 'Word Hunt Activities', diff --git a/frontend/src/components/Modals/Question.vue b/frontend/src/components/Modals/Question.vue index 581995e2d3..e42c7e2cdf 100644 --- a/frontend/src/components/Modals/Question.vue +++ b/frontend/src/components/Modals/Question.vue @@ -213,15 +213,18 @@ watch(show, () => { } }) +const childQuestionDoctype = () => + quiz.value?.doc?.doctype === 'LMS Exam' ? 'LMS Exam Question' : 'LMS Quiz Question' + const questionRow = createResource({ url: 'frappe.client.insert', makeParams(values) { return { doc: { - doctype: 'LMS Quiz Question', + doctype: childQuestionDoctype(), parent: quiz.value.doc.name, parentfield: 'questions', - parenttype: 'LMS Quiz', + parenttype: quiz.value.doc.doctype, ...values, }, } @@ -311,7 +314,7 @@ const marksUpdate = createResource({ auto: false, makeParams(values) { return { - doctype: 'LMS Quiz Question', + doctype: childQuestionDoctype(), name: props.questionDetail.name, fieldname: { marks: question.marks, diff --git a/frontend/src/pages/Courses/CourseOverview.vue b/frontend/src/pages/Courses/CourseOverview.vue index c3335169c4..e3f410fe8e 100644 --- a/frontend/src/pages/Courses/CourseOverview.vue +++ b/frontend/src/pages/Courses/CourseOverview.vue @@ -74,6 +74,7 @@ :getProgress="course.data.membership ? true : false" />
+ +
+ + +
+
+
+
+ {{ __('{0} Exams').format(exams.data?.length || 0) }} +
+ + + +
+ + + + + + + + + + + +
+ +
+
{{ exams.data?.length || 0 }} {{ __('of') }} {{ totalExams.data || 0 }}
+
+
+ + + + + + diff --git a/frontend/src/pages/Exam/ExamForm.vue b/frontend/src/pages/Exam/ExamForm.vue new file mode 100644 index 0000000000..d46ac3eba2 --- /dev/null +++ b/frontend/src/pages/Exam/ExamForm.vue @@ -0,0 +1,359 @@ + + + diff --git a/frontend/src/pages/Exam/ExamPage.vue b/frontend/src/pages/Exam/ExamPage.vue new file mode 100644 index 0000000000..0ea078ebf5 --- /dev/null +++ b/frontend/src/pages/Exam/ExamPage.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/src/pages/Exam/ExamSubmission.vue b/frontend/src/pages/Exam/ExamSubmission.vue new file mode 100644 index 0000000000..e1895dceef --- /dev/null +++ b/frontend/src/pages/Exam/ExamSubmission.vue @@ -0,0 +1,92 @@ + + + diff --git a/frontend/src/pages/Exam/ExamSubmissionList.vue b/frontend/src/pages/Exam/ExamSubmissionList.vue new file mode 100644 index 0000000000..320019a810 --- /dev/null +++ b/frontend/src/pages/Exam/ExamSubmissionList.vue @@ -0,0 +1,61 @@ + + + diff --git a/frontend/src/router.js b/frontend/src/router.js index 3b7eeb8086..4db13f331f 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -144,6 +144,17 @@ const routes = [ name: 'Quizzes', component: () => import('@/pages/Quizzes.vue'), }, + { + path: '/exams', + name: 'Exams', + component: () => import('@/pages/Exam/ExamActivities.vue'), + }, + { + path: '/exams/:examID', + name: 'ExamForm', + component: () => import('@/pages/Exam/ExamForm.vue'), + props: true, + }, { path: '/quizzes/:quizID', name: 'QuizForm', @@ -178,6 +189,12 @@ const routes = [ component: () => import('@/pages/QuizPage.vue'), props: true, }, + { + path: '/exam/:examID', + name: 'ExamPage', + component: () => import('@/pages/Exam/ExamPage.vue'), + props: true, + }, { path: '/drag-drop/:activityID', name: 'DragDropPage', @@ -196,6 +213,12 @@ const routes = [ component: () => import('@/pages/QuizSubmissionList.vue'), props: true, }, + { + path: '/exam-submissions/:examID', + name: 'ExamSubmissionList', + component: () => import('@/pages/Exam/ExamSubmissionList.vue'), + props: true, + }, { path: '/drag-drop-submissions/:activityID', name: 'DragDropSubmissionList', @@ -214,6 +237,12 @@ const routes = [ component: () => import('@/pages/QuizSubmission.vue'), props: true, }, + { + path: '/exam-submission/:submission', + name: 'ExamSubmission', + component: () => import('@/pages/Exam/ExamSubmission.vue'), + props: true, + }, { path: '/drag-drop-submission/:submission', name: 'DragDropSubmission', diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index e5858d3469..a9a3f82f4b 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -545,6 +545,21 @@ const getSidebarItems = () => { 'QuizSubmission', ], }, + { + label: 'Exams', + icon: 'NotebookPen', + to: 'Exams', + condition: () => { + return isAdmin() + }, + activeFor: [ + 'Exams', + 'ExamForm', + 'ExamPage', + 'ExamSubmissionList', + 'ExamSubmission', + ], + }, { label: 'Drag & Drop Activities', icon: 'Hand', diff --git a/lms/lms/api.py b/lms/lms/api.py index eaa70e3ddb..1657de7ad3 100644 --- a/lms/lms/api.py +++ b/lms/lms/api.py @@ -36,6 +36,7 @@ can_modify_course, get_average_rating, get_batch_details, + get_course_exam, get_course_details, get_field_meta, get_instructors, @@ -1427,11 +1428,13 @@ def get_certification_details(course: str): ["name", "template"], as_dict=1, ) + exam = get_course_exam(course, frappe.session.user) return { "membership": membership, "paid_certificate": paid_certificate, "certificate": certificate, + "final_exam": exam, } @@ -2175,11 +2178,13 @@ def get_course_assessment_progress(course: str, member: str): quizzes = get_course_quiz_progress(course, member) assignments = get_course_assignment_progress(course, member) programming_exercises = get_course_programming_exercise_progress(course, member) + exam = get_course_exam(course, member) return { "quizzes": quizzes, "assignments": assignments, "exercises": programming_exercises, + "exam": exam, } diff --git a/lms/lms/doctype/lms_certificate/lms_certificate.py b/lms/lms/doctype/lms_certificate/lms_certificate.py index 76f70fd3f8..c4b5b9d60c 100644 --- a/lms/lms/doctype/lms_certificate/lms_certificate.py +++ b/lms/lms/doctype/lms_certificate/lms_certificate.py @@ -9,6 +9,8 @@ from frappe.utils import nowdate from frappe.utils.telemetry import capture +from lms.lms.utils import get_course_exam, has_passed_exam + class LMSCertificate(Document): def validate(self): @@ -94,6 +96,12 @@ def validate_course_enrollment(self): _("Certification cannot be issued as the member has not completed the course.") ) + exam_status = get_course_exam(self.course, self.member) + if exam_status and not has_passed_exam(exam_status["name"], self.member): + frappe.throw( + _("Certification cannot be issued as the member has not passed the final exam.") + ) + def validate_duplicate_certificate(self): self.validate_course_duplicates() self.validate_batch_duplicates() @@ -219,6 +227,10 @@ def validate_certification_eligibility(course): if progress < 100: frappe.throw(_("You have not completed the course yet.")) + exam_status = get_course_exam(course, frappe.session.user) + if exam_status and not has_passed_exam(exam_status["name"], frappe.session.user): + frappe.throw(_("You must pass the final exam before getting certified.")) + def has_permission(doc, ptype="read", user=None): user = user or frappe.session.user diff --git a/lms/lms/doctype/lms_exam/__init__.py b/lms/lms/doctype/lms_exam/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/lms/lms/doctype/lms_exam/__init__.py @@ -0,0 +1 @@ + diff --git a/lms/lms/doctype/lms_exam/lms_exam.json b/lms/lms/doctype/lms_exam/lms_exam.json new file mode 100644 index 0000000000..9776a65488 --- /dev/null +++ b/lms/lms/doctype/lms_exam/lms_exam.json @@ -0,0 +1,251 @@ +{ + "actions": [], + "allow_import": 1, + "allow_rename": 1, + "creation": "2026-04-03 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "title", + "course", + "display_chapter", + "is_final_exam", + "column_break_core", + "max_attempts", + "show_answers", + "show_submission_history", + "duration", + "section_break_scoring", + "total_marks", + "passing_percentage", + "shuffle_questions", + "limit_questions_to", + "column_break_scoring", + "enable_negative_marking", + "marks_to_cut", + "available_from", + "available_until", + "section_break_instructions", + "instructions", + "section_break_prerequisites", + "prerequisites", + "section_break_questions", + "questions" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Title", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "course", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Course", + "options": "LMS Course", + "reqd": 1 + }, + { + "fieldname": "display_chapter", + "fieldtype": "Link", + "label": "Display Chapter", + "options": "Course Chapter" + }, + { + "default": "1", + "fieldname": "is_final_exam", + "fieldtype": "Check", + "label": "Final Exam" + }, + { + "fieldname": "column_break_core", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "max_attempts", + "fieldtype": "Int", + "label": "Max Attempts" + }, + { + "default": "1", + "fieldname": "show_answers", + "fieldtype": "Check", + "label": "Show Answers" + }, + { + "default": "0", + "fieldname": "show_submission_history", + "fieldtype": "Check", + "label": "Show Submission History" + }, + { + "fieldname": "duration", + "fieldtype": "Data", + "label": "Duration (in minutes)" + }, + { + "fieldname": "section_break_scoring", + "fieldtype": "Section Break" + }, + { + "default": "0", + "fieldname": "total_marks", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Total Marks", + "non_negative": 1, + "read_only": 1, + "reqd": 1 + }, + { + "default": "100", + "fieldname": "passing_percentage", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Passing Percentage", + "non_negative": 1, + "reqd": 1 + }, + { + "default": "0", + "fieldname": "shuffle_questions", + "fieldtype": "Check", + "label": "Shuffle Questions" + }, + { + "depends_on": "shuffle_questions", + "fieldname": "limit_questions_to", + "fieldtype": "Int", + "label": "Limit Questions To" + }, + { + "fieldname": "column_break_scoring", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "enable_negative_marking", + "fieldtype": "Check", + "label": "Enable Negative Marking" + }, + { + "default": "1", + "depends_on": "enable_negative_marking", + "fieldname": "marks_to_cut", + "fieldtype": "Int", + "label": "Marks To Cut" + }, + { + "fieldname": "available_from", + "fieldtype": "Datetime", + "label": "Available From" + }, + { + "fieldname": "available_until", + "fieldtype": "Datetime", + "label": "Available Until" + }, + { + "fieldname": "section_break_instructions", + "fieldtype": "Section Break" + }, + { + "fieldname": "instructions", + "fieldtype": "Text Editor", + "label": "Instructions" + }, + { + "fieldname": "section_break_prerequisites", + "fieldtype": "Section Break" + }, + { + "fieldname": "prerequisites", + "fieldtype": "Table", + "label": "Prerequisites", + "options": "LMS Exam Prerequisite" + }, + { + "fieldname": "section_break_questions", + "fieldtype": "Section Break" + }, + { + "fieldname": "questions", + "fieldtype": "Table", + "label": "Questions", + "options": "LMS Exam Question" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [ + { + "link_doctype": "LMS Exam Submission", + "link_fieldname": "exam" + } + ], + "modified": "2026-04-03 00:00:00.000000", + "modified_by": "Administrator", + "module": "LMS", + "name": "LMS Exam", + "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 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "LMS Student", + "share": 1 + } + ], + "row_format": "Dynamic", + "show_title_field_in_link": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "title", + "track_changes": 1 +} diff --git a/lms/lms/doctype/lms_exam/lms_exam.py b/lms/lms/doctype/lms_exam/lms_exam.py new file mode 100644 index 0000000000..6c9e465aed --- /dev/null +++ b/lms/lms/doctype/lms_exam/lms_exam.py @@ -0,0 +1,246 @@ +# Copyright (c) 2026, Frappe and contributors +# For license information, please see license.txt + +import json +import re + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import cint, comma_and, cstr, get_datetime, now_datetime + +from lms.lms.doctype.lms_quiz.lms_quiz import _save_file, get_corrupted_image_msg, verify_answer +from lms.lms.utils import ( + generate_slug, + get_exam_attempt_count, + get_exam_status, +) + + +class LMSExam(Document): + def validate(self): + self.validate_course_and_display_chapter() + self.validate_one_final_exam_per_course() + self.validate_duplicate_questions() + self.validate_limit() + self.calculate_total_marks() + self.validate_open_ended_questions() + self.validate_prerequisites() + self.validate_availability_window() + + def validate_course_and_display_chapter(self): + if not self.display_chapter: + return + + chapter_course = frappe.db.get_value("Course Chapter", self.display_chapter, "course") + if not chapter_course: + frappe.throw(_("Invalid display chapter.")) + if chapter_course != self.course: + frappe.throw(_("Display chapter must belong to the selected course.")) + + def validate_one_final_exam_per_course(self): + if not cint(self.is_final_exam): + return + + filters = { + "course": self.course, + "is_final_exam": 1, + "name": ["!=", self.name or ""], + } + if frappe.db.exists("LMS Exam", filters): + frappe.throw(_("Only one final exam is allowed per course.")) + + def validate_duplicate_questions(self): + questions = [row.question for row in self.questions] + rows = [i + 1 for i, x in enumerate(questions) if questions.count(x) > 1] + if rows: + frappe.throw(_("Rows {0} have duplicate questions.").format(frappe.bold(comma_and(rows)))) + + def validate_limit(self): + if not self.shuffle_questions and self.limit_questions_to: + self.limit_questions_to = 0 + + if self.limit_questions_to and cint(self.limit_questions_to) >= len(self.questions): + frappe.throw(_("Limit cannot be greater than or equal to the number of questions in the exam.")) + + if self.limit_questions_to and cint(self.limit_questions_to) < len(self.questions): + marks = [question.marks for question in self.questions] + if len(set(marks)) > 1: + frappe.throw(_("All questions should have the same marks if the limit is set.")) + + def calculate_total_marks(self): + if not self.questions: + self.total_marks = 0 + self.passing_percentage = 100 + return + + if self.limit_questions_to: + self.total_marks = sum( + question.marks for question in self.questions[: cint(self.limit_questions_to)] + ) + return + + self.total_marks = sum(cint(question.marks) for question in self.questions) + + def validate_open_ended_questions(self): + types = {question.type for question in self.questions} + + if "Open Ended" in types: + if len(types) > 1: + frappe.throw( + _( + "If you want open ended questions then make sure each question in the exam is of open ended type." + ) + ) + self.show_answers = 0 + + def validate_prerequisites(self): + for row in self.prerequisites: + if row.requirement_type == "Course Progress": + if row.minimum_percentage is None: + frappe.throw(_("Row {0}: Minimum percentage is required.").format(row.idx)) + continue + + if row.requirement_type == "Quiz": + if not row.quiz: + frappe.throw(_("Row {0}: Quiz is required.").format(row.idx)) + if frappe.db.get_value("LMS Quiz", row.quiz, "course") != self.course: + frappe.throw(_("Row {0}: Quiz must belong to the selected course.").format(row.idx)) + continue + + if row.requirement_type == "Drag Drop": + if not row.drag_drop_activity: + frappe.throw(_("Row {0}: Drag and drop activity is required.").format(row.idx)) + if ( + frappe.db.get_value("LMS Drag Drop Activity", row.drag_drop_activity, "course") + != self.course + ): + frappe.throw( + _("Row {0}: Drag and drop activity must belong to the selected course.").format( + row.idx + ) + ) + + def validate_availability_window(self): + if self.available_from and self.available_until: + if get_datetime(self.available_from) >= get_datetime(self.available_until): + frappe.throw(_("Available Until must be after Available From.")) + + def autoname(self): + if not self.name: + self.name = generate_slug(self.title, "LMS Exam") + + def get_last_submission_details(self): + user = frappe.session.user + if not user or user == "Guest": + return None + + result = frappe.get_all( + "LMS Exam Submission", + fields=["*"], + filters={"member": user, "exam": self.name}, + order_by="creation desc", + page_length=1, + ) + return result[0] if result else None + + +@frappe.whitelist() +def get_exam_context(exam: str): + doc = frappe.get_doc("LMS Exam", exam) + status = get_exam_status(exam) + return { + "exam": doc.as_dict(), + "status": status, + } + + +@frappe.whitelist() +def submit_exam(exam: str, results: str): + results = results and json.loads(results) + exam_doc = frappe.get_doc("LMS Exam", exam) + status = get_exam_status(exam) + + if not status.get("can_attempt"): + frappe.throw(status.get("locked_reason") or _("You cannot attempt this exam yet.")) + + data = process_results(results, exam_doc) + results = data["results"] + score = data["score"] + is_open_ended = data["is_open_ended"] + score_out_of = exam_doc.total_marks + percentage = (score / score_out_of) * 100 if score_out_of else 0 + + submission = create_submission(exam_doc, results, score_out_of, exam_doc.passing_percentage) + return { + "score": score, + "score_out_of": score_out_of, + "submission": submission.name, + "pass": percentage >= exam_doc.passing_percentage, + "percentage": percentage, + "is_open_ended": is_open_ended, + "status": get_exam_status(exam), + } + + +def process_results(results: list, exam_doc: LMSExam): + score = 0 + is_open_ended = False + + for result in results: + question_details = frappe.db.get_value( + "LMS Exam Question", + {"parent": exam_doc.name, "question": result["question_name"]}, + ["question", "marks", "question_detail", "type"], + as_dict=1, + ) + result["question_name"] = question_details.question + result["question"] = question_details.question_detail + result["marks_out_of"] = question_details.marks + + if question_details.type != "Open Ended": + correct = verify_answer(question_details.question, result["answer"]) + result["answer"] = ", ".join(result["answer"]) + if correct: + result["marks"] = question_details.marks + else: + result["marks"] = -exam_doc.marks_to_cut if exam_doc.enable_negative_marking else 0 + + score += result["marks"] + result["is_correct"] = 1 if correct else 0 + else: + is_open_ended = True + result["is_correct"] = 0 + result["answer"] = re.sub( + r']*src\s*=\s*["\'](?=data:)(.*?)["\']', + _save_file, + result["answer"][0], + ) + if frappe.flags.has_dataurl: + frappe.msgprint(get_corrupted_image_msg(), alert=True) + + return { + "results": results, + "score": score, + "is_open_ended": is_open_ended, + } + + +def create_submission(exam_doc: LMSExam, results: list, score_out_of: int, passing_percentage: float): + submission = frappe.new_doc("LMS Exam Submission") + attempt_number = get_exam_attempt_count(exam_doc.name) + 1 + submission.update( + { + "doctype": "LMS Exam Submission", + "exam": exam_doc.name, + "result": results, + "score": 0, + "score_out_of": score_out_of, + "member": frappe.session.user, + "percentage": 0, + "passing_percentage": passing_percentage, + "attempt_number": attempt_number, + } + ) + submission.save(ignore_permissions=True) + return submission diff --git a/lms/lms/doctype/lms_exam/test_lms_exam.py b/lms/lms/doctype/lms_exam/test_lms_exam.py new file mode 100644 index 0000000000..e28501295a --- /dev/null +++ b/lms/lms/doctype/lms_exam/test_lms_exam.py @@ -0,0 +1,11 @@ +# Copyright (c) 2026, Frappe and Contributors +# See license.txt + +import unittest + +import frappe + + +class TestLMSExam(unittest.TestCase): + def test_placeholder(self): + self.assertTrue(True) diff --git a/lms/lms/doctype/lms_exam_prerequisite/__init__.py b/lms/lms/doctype/lms_exam_prerequisite/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/lms/lms/doctype/lms_exam_prerequisite/__init__.py @@ -0,0 +1 @@ + diff --git a/lms/lms/doctype/lms_exam_prerequisite/lms_exam_prerequisite.json b/lms/lms/doctype/lms_exam_prerequisite/lms_exam_prerequisite.json new file mode 100644 index 0000000000..2238944b47 --- /dev/null +++ b/lms/lms/doctype/lms_exam_prerequisite/lms_exam_prerequisite.json @@ -0,0 +1,63 @@ +{ + "actions": [], + "creation": "2026-04-03 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "requirement_type", + "submission_requirement", + "minimum_percentage", + "quiz", + "drag_drop_activity" + ], + "fields": [ + { + "fieldname": "requirement_type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Requirement Type", + "options": "Course Progress\nQuiz\nDrag Drop", + "reqd": 1 + }, + { + "default": "Attempted", + "fieldname": "submission_requirement", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Requirement", + "options": "Attempted\nPassed" + }, + { + "fieldname": "minimum_percentage", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Minimum Percentage" + }, + { + "fieldname": "quiz", + "fieldtype": "Link", + "label": "Quiz", + "options": "LMS Quiz" + }, + { + "fieldname": "drag_drop_activity", + "fieldtype": "Link", + "label": "Drag & Drop Activity", + "options": "LMS Drag Drop Activity" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-04-03 00:00:00.000000", + "modified_by": "Administrator", + "module": "LMS", + "name": "LMS Exam Prerequisite", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/lms/lms/doctype/lms_exam_prerequisite/lms_exam_prerequisite.py b/lms/lms/doctype/lms_exam_prerequisite/lms_exam_prerequisite.py new file mode 100644 index 0000000000..eb9946e436 --- /dev/null +++ b/lms/lms/doctype/lms_exam_prerequisite/lms_exam_prerequisite.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, Frappe and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class LMSExamPrerequisite(Document): + pass diff --git a/lms/lms/doctype/lms_exam_question/__init__.py b/lms/lms/doctype/lms_exam_question/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/lms/lms/doctype/lms_exam_question/__init__.py @@ -0,0 +1 @@ + diff --git a/lms/lms/doctype/lms_exam_question/lms_exam_question.json b/lms/lms/doctype/lms_exam_question/lms_exam_question.json new file mode 100644 index 0000000000..05e32d8898 --- /dev/null +++ b/lms/lms/doctype/lms_exam_question/lms_exam_question.json @@ -0,0 +1,72 @@ +{ + "actions": [], + "creation": "2026-04-03 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "question", + "column_break_qcpo", + "marks", + "section_break_huup", + "question_detail", + "type" + ], + "fields": [ + { + "fieldname": "question", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Question", + "options": "LMS Question", + "reqd": 1 + }, + { + "default": "1", + "fieldname": "marks", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Marks", + "non_negative": 1, + "reqd": 1 + }, + { + "fetch_from": "question.question", + "fieldname": "question_detail", + "fieldtype": "Text", + "in_list_view": 1, + "label": "Question Detail", + "read_only": 1 + }, + { + "fieldname": "column_break_qcpo", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_huup", + "fieldtype": "Section Break" + }, + { + "fetch_from": "question.type", + "fieldname": "type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Type", + "options": "Choices\nUser Input\nOpen Ended", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-04-03 00:00:00.000000", + "modified_by": "Administrator", + "module": "LMS", + "name": "LMS Exam Question", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/lms/lms/doctype/lms_exam_question/lms_exam_question.py b/lms/lms/doctype/lms_exam_question/lms_exam_question.py new file mode 100644 index 0000000000..2a6ed18da4 --- /dev/null +++ b/lms/lms/doctype/lms_exam_question/lms_exam_question.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, Frappe and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class LMSExamQuestion(Document): + pass diff --git a/lms/lms/doctype/lms_exam_result/__init__.py b/lms/lms/doctype/lms_exam_result/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/lms/lms/doctype/lms_exam_result/__init__.py @@ -0,0 +1 @@ + diff --git a/lms/lms/doctype/lms_exam_result/lms_exam_result.json b/lms/lms/doctype/lms_exam_result/lms_exam_result.json new file mode 100644 index 0000000000..74cf6e0858 --- /dev/null +++ b/lms/lms/doctype/lms_exam_result/lms_exam_result.json @@ -0,0 +1,80 @@ +{ + "actions": [], + "creation": "2026-04-03 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "question", + "section_break_fztv", + "question_name", + "answer", + "column_break_flus", + "marks", + "marks_out_of", + "is_correct" + ], + "fields": [ + { + "fieldname": "question", + "fieldtype": "Text", + "in_list_view": 1, + "label": "Question", + "read_only": 1 + }, + { + "fieldname": "answer", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Users Response", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_correct", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Is Correct" + }, + { + "fieldname": "section_break_fztv", + "fieldtype": "Section Break" + }, + { + "fieldname": "question_name", + "fieldtype": "Link", + "label": "Question Name", + "options": "LMS Question" + }, + { + "fieldname": "column_break_flus", + "fieldtype": "Column Break" + }, + { + "fieldname": "marks", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Marks" + }, + { + "fieldname": "marks_out_of", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Marks out of", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-04-03 00:00:00.000000", + "modified_by": "Administrator", + "module": "LMS", + "name": "LMS Exam Result", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/lms/lms/doctype/lms_exam_result/lms_exam_result.py b/lms/lms/doctype/lms_exam_result/lms_exam_result.py new file mode 100644 index 0000000000..57eb262481 --- /dev/null +++ b/lms/lms/doctype/lms_exam_result/lms_exam_result.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, Frappe and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class LMSExamResult(Document): + pass diff --git a/lms/lms/doctype/lms_exam_submission/__init__.py b/lms/lms/doctype/lms_exam_submission/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/lms/lms/doctype/lms_exam_submission/__init__.py @@ -0,0 +1 @@ + diff --git a/lms/lms/doctype/lms_exam_submission/lms_exam_submission.json b/lms/lms/doctype/lms_exam_submission/lms_exam_submission.json new file mode 100644 index 0000000000..1a7bc393e0 --- /dev/null +++ b/lms/lms/doctype/lms_exam_submission/lms_exam_submission.json @@ -0,0 +1,164 @@ +{ + "actions": [], + "creation": "2026-04-03 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "exam", + "exam_title", + "course", + "column_break_3", + "member", + "member_name", + "attempt_number", + "section_break_dkpn", + "score", + "score_out_of", + "column_break_gkip", + "percentage", + "passing_percentage", + "section_break_6", + "result" + ], + "fields": [ + { + "fieldname": "exam", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Exam", + "options": "LMS Exam" + }, + { + "fieldname": "result", + "fieldtype": "Table", + "label": "Result", + "options": "LMS Exam Result" + }, + { + "fieldname": "score", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Score", + "reqd": 1 + }, + { + "fieldname": "member", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Member", + "options": "User" + }, + { + "fetch_from": "member.full_name", + "fieldname": "member_name", + "fieldtype": "Data", + "label": "Member Name", + "read_only": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fetch_from": "exam.course", + "fieldname": "course", + "fieldtype": "Link", + "label": "Course", + "options": "LMS Course", + "read_only": 1 + }, + { + "fetch_from": "exam.total_marks", + "fieldname": "score_out_of", + "fieldtype": "Int", + "label": "Score Out Of", + "non_negative": 1, + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "section_break_dkpn", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_gkip", + "fieldtype": "Column Break" + }, + { + "fieldname": "percentage", + "fieldtype": "Float", + "label": "Percentage", + "non_negative": 1, + "reqd": 1 + }, + { + "fieldname": "passing_percentage", + "fieldtype": "Int", + "label": "Passing Percentage", + "non_negative": 1, + "read_only": 1, + "reqd": 1 + }, + { + "fetch_from": "exam.title", + "fieldname": "exam_title", + "fieldtype": "Data", + "label": "Exam Title", + "read_only": 1 + }, + { + "fieldname": "attempt_number", + "fieldtype": "Int", + "label": "Attempt Number", + "read_only": 1 + }, + { + "fieldname": "section_break_6", + "fieldtype": "Section Break" + } + ], + "grid_page_length": 50, + "in_create": 1, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-04-03 00:00:00.000000", + "modified_by": "Administrator", + "module": "LMS", + "name": "LMS Exam Submission", + "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, + "email": 1, + "export": 1, + "if_owner": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "LMS Student", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "member_name", + "track_changes": 1 +} diff --git a/lms/lms/doctype/lms_exam_submission/lms_exam_submission.py b/lms/lms/doctype/lms_exam_submission/lms_exam_submission.py new file mode 100644 index 0000000000..a6717fc0d2 --- /dev/null +++ b/lms/lms/doctype/lms_exam_submission/lms_exam_submission.py @@ -0,0 +1,48 @@ +# Copyright (c) 2026, Frappe and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import cint + +from lms.lms.utils import get_exam_attempt_count, get_exam_status + + +class LMSExamSubmission(Document): + def validate(self): + self.validate_attempt_access() + self.validate_marks() + self.set_percentage() + self.set_attempt_number() + + def validate_attempt_access(self): + if not self.is_new(): + return + status = get_exam_status(self.exam, self.member or frappe.session.user) + if not status.get("can_attempt"): + frappe.throw(status.get("locked_reason") or _("You cannot attempt this exam yet.")) + + def validate_marks(self): + self.score = 0 + for row in self.result: + if cint(row.marks) > cint(row.marks_out_of): + frappe.throw( + _( + "Marks for question number {0} cannot be greater than the marks allotted for that question." + ).format(row.idx) + ) + self.score += cint(row.marks) + + def set_percentage(self): + if self.score_out_of: + self.percentage = (self.score / self.score_out_of) * 100 + + def set_attempt_number(self): + if self.attempt_number: + return + self.attempt_number = get_exam_attempt_count(self.exam) + 1 + + +class MaximumExamAttemptsExceededError(frappe.DuplicateEntryError): + pass diff --git a/lms/lms/doctype/lms_exam_submission/test_lms_exam_submission.py b/lms/lms/doctype/lms_exam_submission/test_lms_exam_submission.py new file mode 100644 index 0000000000..8123ba600e --- /dev/null +++ b/lms/lms/doctype/lms_exam_submission/test_lms_exam_submission.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, Frappe and Contributors +# See license.txt + +import unittest + + +class TestLMSExamSubmission(unittest.TestCase): + def test_placeholder(self): + self.assertTrue(True) diff --git a/lms/lms/utils.py b/lms/lms/utils.py index 5902270b96..5d775da751 100644 --- a/lms/lms/utils.py +++ b/lms/lms/utils.py @@ -20,6 +20,7 @@ get_frappe_version, get_fullname, getdate, + now_datetime, nowtime, pretty_date, rounded, @@ -327,6 +328,166 @@ def get_course_progress(course: str, member: str = None): return flt(((completed_lessons / lesson_count) * 100), precision) +def get_course_exam(course: str, member: str = None): + filters = {"course": course} + exam_name = frappe.db.get_value("LMS Exam", filters, "name") + if not exam_name: + return None + return get_exam_status(exam_name, member) + + +def get_exam_attempt_count(exam: str, member: str = None): + return frappe.db.count( + "LMS Exam Submission", + {"exam": exam, "member": member or frappe.session.user}, + ) + + +def has_passed_exam(exam: str, member: str = None): + member = member or frappe.session.user + passing_percentage = frappe.db.get_value("LMS Exam", exam, "passing_percentage") + return frappe.db.exists( + "LMS Exam Submission", + { + "exam": exam, + "member": member, + "percentage": [">=", passing_percentage], + }, + ) + + +def get_exam_prerequisite_status(exam: str, member: str = None): + member = member or frappe.session.user + exam_doc = frappe.get_doc("LMS Exam", exam) + items = [] + eligible = True + + for row in exam_doc.prerequisites or []: + item = { + "requirement_type": row.requirement_type, + "submission_requirement": row.submission_requirement, + "satisfied": False, + } + if row.requirement_type == "Course Progress": + progress = get_course_progress(exam_doc.course, member) + item.update( + { + "label": _("Complete {0}% of the course").format(row.minimum_percentage), + "current_value": progress, + "minimum_percentage": row.minimum_percentage, + "satisfied": flt(progress) >= flt(row.minimum_percentage), + } + ) + elif row.requirement_type == "Quiz": + title = frappe.db.get_value("LMS Quiz", row.quiz, "title") + item["reference"] = row.quiz + item["label"] = _("{0}: {1}").format(row.submission_requirement, title) + if row.submission_requirement == "Passed": + passing_percentage = frappe.db.get_value("LMS Quiz", row.quiz, "passing_percentage") + item["satisfied"] = bool( + frappe.db.exists( + "LMS Quiz Submission", + { + "quiz": row.quiz, + "member": member, + "percentage": [">=", passing_percentage], + }, + ) + ) + else: + item["satisfied"] = bool( + frappe.db.exists("LMS Quiz Submission", {"quiz": row.quiz, "member": member}) + ) + elif row.requirement_type == "Drag Drop": + title = frappe.db.get_value("LMS Drag Drop Activity", row.drag_drop_activity, "title") + item["reference"] = row.drag_drop_activity + item["label"] = _("{0}: {1}").format(row.submission_requirement, title) + if row.submission_requirement == "Passed": + passing_percentage = frappe.db.get_value( + "LMS Drag Drop Activity", row.drag_drop_activity, "passing_percentage" + ) + item["satisfied"] = bool( + frappe.db.exists( + "LMS Drag Drop Submission", + { + "activity": row.drag_drop_activity, + "member": member, + "percentage": [">=", passing_percentage], + }, + ) + ) + else: + item["satisfied"] = bool( + frappe.db.exists( + "LMS Drag Drop Submission", + {"activity": row.drag_drop_activity, "member": member}, + ) + ) + + if not item["satisfied"]: + eligible = False + items.append(item) + + return { + "eligible": eligible, + "items": items, + } + + +def get_exam_status(exam: str, member: str = None): + member = member or frappe.session.user + exam_doc = frappe.get_doc("LMS Exam", exam) + membership = get_membership(exam_doc.course, member) + prerequisites = get_exam_prerequisite_status(exam, member) + attempt_count = get_exam_attempt_count(exam, member) + max_attempts = cint(exam_doc.max_attempts) + now = now_datetime() + is_available = True + locked_reason = None + + if exam_doc.available_from and get_datetime(exam_doc.available_from) > now: + is_available = False + locked_reason = _("This exam is not available yet.") + elif exam_doc.available_until and get_datetime(exam_doc.available_until) < now: + is_available = False + locked_reason = _("This exam is no longer available.") + elif not membership and not can_modify_course(exam_doc.course): + locked_reason = _("You must be enrolled in this course to take the exam.") + elif not prerequisites["eligible"]: + locked_reason = _("Complete the prerequisites before attempting this exam.") + elif max_attempts and attempt_count >= max_attempts: + locked_reason = _("You have already used all available attempts for this exam.") + + can_attempt = not locked_reason and is_available + last_submission = frappe.get_all( + "LMS Exam Submission", + fields=["name", "creation", "score", "score_out_of", "percentage"], + filters={"member": member, "exam": exam}, + order_by="creation desc", + page_length=1, + ) + + return { + "name": exam_doc.name, + "title": exam_doc.title, + "course": exam_doc.course, + "display_chapter": exam_doc.display_chapter, + "is_final_exam": exam_doc.is_final_exam, + "attempts_used": attempt_count, + "attempts_remaining": max(max_attempts - attempt_count, 0) if max_attempts else None, + "max_attempts": max_attempts, + "eligible": prerequisites["eligible"], + "can_attempt": can_attempt, + "locked_reason": locked_reason, + "prerequisites": prerequisites["items"], + "passed": bool(has_passed_exam(exam, member)), + "has_submission": bool(last_submission), + "latest_submission": last_submission[0] if last_submission else None, + "url": get_lms_route(f"exam/{exam}"), + "edit_url": get_lms_route(f"exams/{exam}"), + } + + def is_instructor(course: str) -> bool: instructors = get_instructors("LMS Course", course) for instructor in instructors: @@ -917,6 +1078,11 @@ def get_course_details(course: str): if course_details.membership and course_details.membership.current_lesson: course_details.current_lesson = get_lesson_index(course_details.membership.current_lesson) + exam_status = get_course_exam(course, frappe.session.user if frappe.session.user != "Guest" else None) + course_details.final_exam = exam_status + course_details.final_exam_required = bool(exam_status) + course_details.final_exam_passed = exam_status.get("passed") if exam_status else False + return course_details @@ -956,7 +1122,7 @@ def get_categorized_courses(courses: list) -> dict: @frappe.whitelist(allow_guest=True) -def get_course_outline(course: str, progress: bool = False) -> list: +def get_course_outline(course: str, progress: bool = False, include_exams: bool = True) -> list: """Returns the course outline.""" if not guest_access_allowed(): @@ -964,6 +1130,7 @@ def get_course_outline(course: str, progress: bool = False) -> list: outline = [] chapters = frappe.get_all("Chapter Reference", {"parent": course}, ["chapter", "idx"], order_by="idx") + exam_status = get_course_exam(course, frappe.session.user if frappe.session.user != "Guest" else None) for chapter in chapters: chapter_details = frappe.db.get_value( "Course Chapter", @@ -974,6 +1141,22 @@ def get_course_outline(course: str, progress: bool = False) -> list: chapter_details["idx"] = chapter.idx chapter_details.lessons = get_lessons(course, chapter_details, progress=progress) + if include_exams and exam_status and exam_status.get("display_chapter") == chapter_details.name: + chapter_details.lessons.append( + frappe._dict( + { + "name": exam_status["name"], + "title": exam_status["title"], + "number": f"{chapter.idx}-exam", + "icon": "icon-quiz", + "is_exam": 1, + "is_complete": exam_status["passed"], + "is_locked": not exam_status["can_attempt"], + "locked_reason": exam_status["locked_reason"], + } + ) + ) + if chapter_details.is_scorm_package: chapter_details.scorm_package = frappe.db.get_value( "File", diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index 767f510423..01c246f782 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -1,9 +1,9 @@ # Active Context ## Current Focus -- 2026-03-29: Keep the memory bank aligned with recent drag and drop UX changes and container-build workflow hardening. -- 2026-03-29: Validate the remaining drag and drop learner flow end-to-end inside the running LMS app, especially mixed text/image prompts and stepped navigation. -- 2026-03-29: Monitor GitHub Actions release builds after the `FRAPPE_REF` / `PAYMENTS_REF` split and action-version upgrades. +- 2026-04-04: Commit and checkpoint the new exam module after backend/runtime verification. +- 2026-04-04: Keep the memory bank aligned with the new final-exam architecture, certification rule, and Docker-based verification workflow. +- 2026-04-04: Preserve the remaining manual browser QA gap for the exam UI and course integration views. ## Recent Changes - 2026-03-18: Reviewed repository docs, packaging files, hooks, auth layer, SPA router, frontend bootstrap, and test/CI surface. @@ -19,16 +19,21 @@ - 2026-03-29: Merged `origin/develop` into `devel`, resolved the recurring `ui-tests.yml` conflict by keeping the secret-based Cypress key plus the newer parallel screenshot behavior, and pushed the merge to `origin/devel`. - 2026-03-29: Hardened `.github/workflows/build.yml` by separating `FRAPPE_REF` and `PAYMENTS_REF`, validating upstream refs before Docker build, and upgrading workflow action versions where repo-controlled updates were available. - 2026-03-29: Confirmed release builds now succeed after fixing the invalid `version-16` framework ref assumption; the remaining Node 20 deprecation warning comes from Docker-maintained actions still on their current major lines. +- 2026-04-04: Added a dedicated exam module with new DocTypes for exams, exam questions, prerequisites, submissions, and results, plus standalone exam routes and authoring/learner pages under `frontend/src/pages/Exam`. +- 2026-04-04: Integrated final exams into course detail payloads, course outline rendering, certification checks, and LMS navigation. Exams are course-level, can display under a selected chapter, and are required for certification when present. +- 2026-04-04: Added prerequisite evaluation for course progress, selected quizzes, and selected drag-and-drop activities with AND-only semantics and `Attempted` / `Passed` requirement modes. +- 2026-04-04: Live-verified the exam module inside the running `frappe` container with `bench migrate`, course detail/outline payload checks, a successful smoke exam submission path, member-specific prerequisite status checks, and certification gating checks. +- 2026-04-04: Fixed stale exam output during live verification by replacing cached exam loads with uncached `frappe.get_doc` calls in exam status helpers. ## Next Actions +- Run a manual browser pass for the new exam UI on `/lms/exams`, `/lms/exams/:examID`, `/lms/exam/:examID`, and the course overview/outline exam surfaces. +- Decide whether to add editor-side chapter actions for attaching exams or keep chapter placement solely on the exam form for now. - Continue frontend verification from the parent compose project using `docker compose exec frappe ...` instead of host-shell builds. -- Manually verify drag and drop activities end-to-end in the LMS UI, including authoring, mixed text/image items, mobile tap placement, sidebar access, stepped navigation, submission, and retry flows. - Monitor GitHub Actions release runs to confirm the new `FRAPPE_REF` / `PAYMENTS_REF` validation produces clear failures when refs are wrong and that successful builds keep pushing to GHCR. - Revisit the remaining Node 20 deprecation warning only when Docker publishes Node 24-ready action runtimes or when a test branch is ready to opt into forced Node 24 execution. -- Use the memory bank as the starting context for the next implementation or review task in this repo. -- Expand system notes when future work touches under-documented areas like payments, search indexing, or SCORM delivery. ## Blockers - Host-shell frontend validation is misleading in this workspace because the app is meant to run inside Docker. - Container-side frontend validation currently hangs in `vite build` when run as `docker compose exec frappe bash -lc 'cd /home/frappe/frappe-bench/apps/lms/frontend && yarn build'`. - Remaining Node 20 deprecation warnings cannot be fully removed from the build workflow until Docker updates the affected GitHub Actions runtimes upstream. +- Playwright/browser automation is not available in this session, so exam verification is currently limited to Docker + Bench runtime checks plus any manual browser QA the user performs. diff --git a/memory-bank/productContext.md b/memory-bank/productContext.md index 0382f2a088..f920444eb8 100644 --- a/memory-bank/productContext.md +++ b/memory-bank/productContext.md @@ -19,6 +19,8 @@ - The product should work well as a browser-based experience and remain mobile-friendly through the SPA and PWA setup. - 2026-03-29: Drag and drop activities should feel usable on touch devices by supporting tap-to-select and tap-to-place, not only desktop drag gestures. - 2026-03-29: Drag and drop assessment flows should avoid overwhelming learners; the current UX direction is one prompt at a time with Previous/Next navigation. +- 2026-04-04: Final exams should feel like a first-class assessment type with a standalone authoring/library flow, a learner-facing locked/unlocked state, and prerequisite explanations that tell learners exactly what is missing. +- 2026-04-04: If a course has a final exam, learners should still be able to reach normal 100% course progress while clearly seeing that certification remains blocked until the final exam is passed. ## Acceptance Signals - Public course browsing, enrollment, and lesson consumption are available from website routes under the LMS base path. @@ -26,3 +28,4 @@ - Scheduled reminders and operational jobs keep recurring workflows moving without manual intervention. - The repo contains both Python tests around DocTypes and Cypress coverage for major UI flows. - 2026-03-29: Drag and drop activities are accessible from LMS navigation under Quizzes for instructor-facing flows. +- 2026-04-04: Exams are accessible from LMS navigation like other assessment libraries, can be attached to courses, appear in the course outline and overview, and show locked prerequisite messaging for enrolled learners. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 8f8e6aa32c..6e8aee1e0a 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -16,15 +16,22 @@ - 2026-03-29: Hardened `.github/workflows/build.yml` by separating `FRAPPE_REF` from `PAYMENTS_REF`, validating upstream refs before build, and keeping LMS sourced from the executing repository/ref. - 2026-03-29: Upgraded repo-controlled GitHub Action versions where available, including moving remaining `actions/checkout@v4` usages to `@v5` and `docker/login-action@v2` to `@v3`. - 2026-03-29: Confirmed container image builds now succeed after correcting the invalid framework-ref assumption. +- 2026-04-04: Added a new course-level exam module with dedicated DocTypes for exams, questions, prerequisites, submissions, and results. +- 2026-04-04: Added standalone exam list/form/submission routes and learner-facing exam UI, plus course overview and outline integration for final exams. +- 2026-04-04: Added prerequisite evaluation for course progress, selected quizzes, and selected drag-and-drop activities, with configurable `Attempted` or `Passed` checks. +- 2026-04-04: Added certification gating so courses with a final exam require an exam pass before certificate issuance. +- 2026-04-04: Live-verified exam migrations, course detail/outline payloads, smoke submission, learner-specific prerequisite status, and final-exam certification blocking inside the running Dockerized Frappe site. +- 2026-04-04: Fixed stale exam status rendering by removing cached exam document reads from the live exam aggregation path. ## In Progress +- Manual browser verification for the exam module is still pending because only Docker + Bench runtime verification was completed in this session. - Frontend verification for the drag and drop refresh is still in progress because the containerized `yarn build` does not complete after entering the Vite transform/build phase. -- Drag and drop verification still needs a live app pass to confirm uploader behavior, stepped navigation, and mixed-mode rendering work correctly inside Frappe. - Workflow observation is still in progress for the remaining Node 20 deprecation warning emitted by Docker-maintained GitHub Actions. ## Todo - Keep the memory bank current as future feature work, fixes, and design decisions land. - Add deeper historical notes when specific subsystems are modified or reviewed in detail. +- Run a browser-level exam QA pass covering authoring, learner lock states, unlocked attempts, course outline placement, and certificate button behavior. - Diagnose why `docker compose exec frappe bash -lc 'cd /home/frappe/frappe-bench/apps/lms/frontend && yarn build'` hangs during `vite build`. - Review whether the remaining PR semantic action should also be pinned or replaced with a more stable dependency reference. - Run targeted app-level tests or manual QA for drag and drop sidebar access, mobile/touch placement, and stepped item navigation once the local/container runtime is ready. @@ -38,3 +45,4 @@ - Long-running or stuck container builds can leave background `vite build` processes behind if verification attempts are not cleaned up. - Some release jobs may still reveal hidden permission gaps once they run against GitHub, especially where branch pushes, PR creation, or release mutation depend on repository settings beyond workflow YAML. - The remaining Docker-action Node 20 warnings can create alert fatigue even though the build currently succeeds, and they depend on upstream action runtime updates rather than repo-local YAML alone. +- The new exam feature crosses assessments, course aggregation, and certification logic, so regressions can appear in user flows that are not obviously “exam” screens. diff --git a/memory-bank/projectbrief.md b/memory-bank/projectbrief.md index e8bd46196e..491196d20d 100644 --- a/memory-bank/projectbrief.md +++ b/memory-bank/projectbrief.md @@ -19,5 +19,6 @@ ## Scope Notes - In scope: courses, lessons, batches, quizzes, assignments, programming exercises, certificates, payments, profiles, badges, jobs, programs, search, and statistics. - 2026-03-29: Drag and drop activities are now an actively extended assessment surface, including colorful answer banks, mixed text/image prompts, mobile-friendly tap placement, and one-item-at-a-time navigation. +- 2026-04-04: Final exams are now an in-scope assessment surface. They are modeled separately from quizzes, belong to a course, can be displayed under a chosen chapter, and can gate certification without changing normal course progress. - In scope: website delivery under a configurable LMS base path and a Vue SPA mounted inside Frappe. - Out of scope: native mobile clients and multi-service deployment orchestration in this repo. diff --git a/memory-bank/systemPatterns.md b/memory-bank/systemPatterns.md index 5cc58dc0d2..5bd5a4b916 100644 --- a/memory-bank/systemPatterns.md +++ b/memory-bank/systemPatterns.md @@ -20,6 +20,7 @@ - Scheduled automation pattern: reminders, attendance updates, statistics refresh, and evaluation scheduling run through Frappe scheduler hooks. - Content extensibility pattern: lessons support embedded exercises, quizzes, videos, assignments, audio, PDF, and SCORM-related flows. - Assessment rendering pattern: drag and drop activities now support multiple item presentation modes while keeping one scoring payload shape (`submitted_answer` per row). +- Assessment specialization pattern: final exams reuse quiz-style question/submission mechanics where practical but live in dedicated DocTypes and routes so they can evolve separate metadata, prerequisite rules, and certification behavior. - Container release pattern: the image build sources LMS from the executing repository/ref, while framework dependencies are controlled by explicit upstream refs validated before Docker build. ## Decisions @@ -31,3 +32,6 @@ - 2026-03-21: Treat tap-to-place as the primary mobile interaction model for drag and drop. Rationale: native HTML drag events are unreliable on touch browsers. Impact: mobile UX should emphasize selection state and tappable drop targets rather than relying on drag-only affordances. - 2026-03-21: Present drag and drop items one at a time with Previous/Next navigation. Rationale: the stepped flow is easier to use on mobile and less visually overwhelming than rendering every item at once. Impact: placement state must persist independently of the currently visible item. - 2026-03-29: Separate LMS release refs from framework dependency refs in the container build pipeline. Rationale: assuming one shared branch name caused failed builds when `version-16` did not exist upstream. Impact: Frappe and Payments refs are now independently configurable and validated before build. +- 2026-04-04: Model the final exam as a course-level DocType instead of overloading lesson resources or quizzes. Rationale: the exam needs independent prerequisite logic, certification gating, attempt handling, and future exam-only metadata. Impact: exam state now spans dedicated DocTypes, course detail/outline aggregation, and certificate eligibility rules. +- 2026-04-04: Keep final exams outside normal course progress while making them mandatory for certification when present. Rationale: product wants progress to reach 100% before the exam while still treating the exam as a final gate. Impact: prerequisite checks use normal course progress, and certificate validation now performs an additional exam-pass check. +- 2026-04-04: Use `frappe.get_doc` instead of `frappe.get_cached_doc` when assembling live exam status for course payloads. Rationale: cached exam docs caused stale `display_chapter` and status output during live verification. Impact: course outline/detail responses now reflect exam edits immediately. diff --git a/memory-bank/techContext.md b/memory-bank/techContext.md index 74481a13e2..9b7e29a4f9 100644 --- a/memory-bank/techContext.md +++ b/memory-bank/techContext.md @@ -13,6 +13,7 @@ - Root `package.json` delegates frontend development and build commands into the `frontend/` workspace-like folder. - Frontend dev server uses Vite with a local `frappe-ui` checkout when available, otherwise the npm package fallback. - 2026-03-29: Container image builds are driven by `.github/workflows/build.yml` and now use `FRAPPE_REF` and `PAYMENTS_REF` repository variables, with backward compatibility for the older `FRAPPE_BRANCH` variable. +- 2026-04-04: Live app verification for new DocTypes is currently most reliable through `docker compose exec frappe ...` with Bench commands against `lms.localhost`; direct host-shell Frappe/Python checks are less trustworthy in this workspace. ## Constraints - The repo mixes Python, Frappe metadata, Vue, Cypress, and generated assets, so changes often need cross-layer validation. @@ -21,6 +22,7 @@ - The backend surface is large, with many whitelisted methods in [`lms/lms/api.py`](/Users/purwaren/Projects/frappe/lms/lms/lms/api.py) and [`lms/lms/utils.py`](/Users/purwaren/Projects/frappe/lms/lms/lms/utils.py), so behavioral changes can have wide reach. - Docker-backed verification may be more reliable than host-shell verification because node/yarn availability and dependency state differ between the host and the running `frappe` container. - 2026-03-29: GitHub Actions build warnings about Node 20 deprecation still remain for the Docker-maintained actions (`docker/build-push-action`, `docker/login-action`, `docker/setup-buildx-action`, `docker/setup-qemu-action`) even after local workflow upgrades; this is currently an upstream action-runtime issue, not a repo-specific misconfiguration. +- 2026-04-04: Ad hoc Python inside the `frappe` container may need explicit `sites_path` and writable log directories (`/home/frappe/logs`, `/home/frappe/frappe-bench/sites//logs`) if run outside normal Bench helpers during deep verification. ## Key Commands - `bench start` @@ -30,5 +32,7 @@ - `yarn test-local` - `ruff check .` - `docker compose exec frappe bash -lc 'cd /home/frappe/frappe-bench/apps/lms/frontend && yarn build'` +- `docker compose exec frappe bash -lc 'cd /home/frappe/frappe-bench && bench --site lms.localhost migrate'` +- `docker compose exec frappe bash -lc 'cd /home/frappe/frappe-bench && bench --site lms.localhost execute --kwargs \"{...}\"'` - `git ls-remote --heads https://github.com/frappe/frappe ` - `git ls-remote --tags https://github.com/frappe/payments ` From b6af0aaf4b1bb00de8c1d794502b2abaa7a7f5e9 Mon Sep 17 00:00:00 2001 From: purwaren Date: Sat, 4 Apr 2026 05:52:53 +0700 Subject: [PATCH 02/10] Fix exam frontend progress bar import --- frontend/src/components/Exam.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/Exam.vue b/frontend/src/components/Exam.vue index 0654919d96..c1266c837b 100644 --- a/frontend/src/components/Exam.vue +++ b/frontend/src/components/Exam.vue @@ -248,7 +248,6 @@ import { Checkbox, createResource, FormControl, - ProgressBar, TextEditor, toast, } from 'frappe-ui' @@ -261,6 +260,7 @@ import { } from 'lucide-vue-next' import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { timeAgo } from '@/utils' +import ProgressBar from '@/components/ProgressBar.vue' const props = defineProps({ examName: { From fb63d4d91986ec320ecd63a69c8a10240172f50e Mon Sep 17 00:00:00 2001 From: purwaren Date: Sat, 4 Apr 2026 06:48:37 +0700 Subject: [PATCH 03/10] Normalize formatting for pre-commit --- frontend/src/components/AssessmentPlugin.vue | 8 +- frontend/src/components/CourseCardOverlay.vue | 23 +- frontend/src/components/CourseFinalExam.vue | 6 +- frontend/src/components/CourseOutline.vue | 10 +- frontend/src/components/DragDrop.vue | 229 +++++++++++++---- frontend/src/components/Exam.vue | 205 ++++++++++++--- frontend/src/components/LessonContent.vue | 2 +- frontend/src/components/MobileLayout.vue | 8 +- frontend/src/components/Modals/Question.vue | 4 +- frontend/src/components/NoSidebarLayout.vue | 23 +- frontend/src/components/Quiz.vue | 7 +- frontend/src/components/WordHunt.vue | 196 +++++++++++---- .../src/pages/DragDrop/DragDropActivities.vue | 72 +++++- frontend/src/pages/DragDrop/DragDropForm.vue | 141 +++++++++-- frontend/src/pages/DragDrop/DragDropPage.vue | 10 +- .../src/pages/DragDrop/DragDropSubmission.vue | 68 ++++- .../pages/DragDrop/DragDropSubmissionList.vue | 15 +- frontend/src/pages/Exam/ExamActivities.vue | 67 ++++- frontend/src/pages/Exam/ExamForm.vue | 235 ++++++++++++++---- frontend/src/pages/Exam/ExamPage.vue | 9 +- frontend/src/pages/Exam/ExamSubmission.vue | 89 +++++-- .../src/pages/Exam/ExamSubmissionList.vue | 54 +++- frontend/src/pages/Lesson.vue | 81 ++++-- .../src/pages/WordHunt/WordHuntActivities.vue | 107 ++++++-- frontend/src/pages/WordHunt/WordHuntForm.vue | 140 +++++++++-- frontend/src/pages/WordHunt/WordHuntPage.vue | 10 +- .../src/pages/WordHunt/WordHuntSubmission.vue | 68 ++++- .../pages/WordHunt/WordHuntSubmissionList.vue | 20 +- frontend/src/utils/dragDrop.js | 8 +- frontend/src/utils/quiz.js | 13 +- frontend/src/utils/wordHunt.js | 17 +- lms/lms/api.py | 2 +- .../doctype/course_lesson/course_lesson.py | 8 +- lms/lms/doctype/lms_batch/lms_batch.js | 23 +- .../lms_word_hunt_activity.py | 9 +- lms/lms/utils.py | 19 +- lms/plugins.py | 16 +- 37 files changed, 1600 insertions(+), 422 deletions(-) diff --git a/frontend/src/components/AssessmentPlugin.vue b/frontend/src/components/AssessmentPlugin.vue index a4256a1ca9..43e76eeff1 100644 --- a/frontend/src/components/AssessmentPlugin.vue +++ b/frontend/src/components/AssessmentPlugin.vue @@ -123,10 +123,10 @@ const addAssessment = () => { props.type == 'quiz' ? quiz.value : props.type == 'dragDrop' - ? dragDrop.value - : props.type == 'wordHunt' - ? wordHunt.value - : assignment.value + ? dragDrop.value + : props.type == 'wordHunt' + ? wordHunt.value + : assignment.value ) show.value = false } diff --git a/frontend/src/components/CourseCardOverlay.vue b/frontend/src/components/CourseCardOverlay.vue index 6fc5748db5..11e97579dc 100644 --- a/frontend/src/components/CourseCardOverlay.vue +++ b/frontend/src/components/CourseCardOverlay.vue @@ -94,11 +94,17 @@ class="space-y-3 rounded-xl border px-4 py-4" :style="courseHighlightsStyle" > -
+
{{ __('This course has:') }}
- + {{ course.data.lessons }} {{ course.data.lessons > 1 ? __('lessons') : __('lesson') }} @@ -128,7 +134,10 @@ v-if="course.data.enable_certification" class="flex items-center font-semibold text-ink-gray-9" > - + {{ __('Certificate of Completion') }} @@ -137,7 +146,10 @@ v-if="course.data.paid_certificate" class="flex items-center font-semibold text-ink-gray-9" > - + {{ __('Paid Certificate after Evaluation') }} @@ -272,7 +284,8 @@ const canGetCertificate = computed(() => { if ( props.course.data?.enable_certification && props.course.data?.membership?.progress == 100 && - (!props.course.data?.final_exam_required || props.course.data?.final_exam_passed) + (!props.course.data?.final_exam_required || + props.course.data?.final_exam_passed) ) { return true } diff --git a/frontend/src/components/CourseFinalExam.vue b/frontend/src/components/CourseFinalExam.vue index 9672988ab2..59cc77ad4f 100644 --- a/frontend/src/components/CourseFinalExam.vue +++ b/frontend/src/components/CourseFinalExam.vue @@ -47,7 +47,11 @@ }" >
diff --git a/frontend/src/components/CourseOutline.vue b/frontend/src/components/CourseOutline.vue index 54c3ccc523..33bc1dafcc 100644 --- a/frontend/src/components/CourseOutline.vue +++ b/frontend/src/components/CourseOutline.vue @@ -100,9 +100,7 @@ isActiveLesson(lesson) ? 'bg-surface-white/70' : '' " > - +
(props.accentColor || 'blue').toLowerCase()) +const accentColorName = computed(() => + (props.accentColor || 'blue').toLowerCase() +) const hexToRgb = (hex) => { if (!hex) return '37, 99, 235' @@ -271,7 +271,7 @@ const outlineContainerStyle = computed(() => ? { backgroundColor: alphaColor(0.08), borderColor: alphaColor(0.22), - } + } : {} ) diff --git a/frontend/src/components/DragDrop.vue b/frontend/src/components/DragDrop.vue index a508ee1c0c..6a7957a7e5 100644 --- a/frontend/src/components/DragDrop.vue +++ b/frontend/src/components/DragDrop.vue @@ -1,6 +1,8 @@