From a24d17c7dcb2b19e2000cd59aaf2ae8807c28557 Mon Sep 17 00:00:00 2001 From: dannnn512 Date: Wed, 1 Apr 2026 09:21:48 +0700 Subject: [PATCH 1/3] feat:word hunt activity --- frontend/src/components/AssessmentPlugin.vue | 35 +- frontend/src/components/LessonContent.vue | 4 + frontend/src/components/MobileLayout.vue | 16 + .../src/components/Modals/AssessmentModal.vue | 8 + frontend/src/components/WordHunt.vue | 635 ++++++++++++++++++ frontend/src/components/WordHuntBlock.vue | 31 + .../pages/Batches/components/Assessments.vue | 9 + frontend/src/pages/Lesson.vue | 4 +- frontend/src/pages/LessonForm.vue | 8 + .../src/pages/WordHunt/WordHuntActivities.vue | 321 +++++++++ frontend/src/pages/WordHunt/WordHuntForm.vue | 170 +++++ frontend/src/pages/WordHunt/WordHuntPage.vue | 56 ++ .../src/pages/WordHunt/WordHuntSubmission.vue | 90 +++ .../pages/WordHunt/WordHuntSubmissionList.vue | 75 +++ frontend/src/utils/index.js | 17 + frontend/src/utils/wordHunt.js | 115 ++++ lms/hooks.py | 1 + .../doctype/course_lesson/course_lesson.py | 45 ++ lms/lms/doctype/lms_batch/lms_batch.js | 11 +- .../lms_word_hunt_activity/__init__.py | 0 .../lms_word_hunt_activity.json | 196 ++++++ .../lms_word_hunt_activity.py | 141 ++++ .../test_lms_word_hunt_activity.py | 91 +++ .../doctype/lms_word_hunt_item/__init__.py | 0 .../lms_word_hunt_item.json | 49 ++ .../lms_word_hunt_item/lms_word_hunt_item.py | 8 + .../doctype/lms_word_hunt_result/__init__.py | 0 .../lms_word_hunt_result.json | 80 +++ .../lms_word_hunt_result.py | 8 + .../lms_word_hunt_submission/__init__.py | 0 .../lms_word_hunt_submission.json | 163 +++++ .../lms_word_hunt_submission.py | 75 +++ lms/lms/utils.py | 81 ++- lms/plugins.py | 15 + 34 files changed, 2546 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/WordHunt.vue create mode 100644 frontend/src/components/WordHuntBlock.vue create mode 100644 frontend/src/pages/WordHunt/WordHuntActivities.vue create mode 100644 frontend/src/pages/WordHunt/WordHuntForm.vue create mode 100644 frontend/src/pages/WordHunt/WordHuntPage.vue create mode 100644 frontend/src/pages/WordHunt/WordHuntSubmission.vue create mode 100644 frontend/src/pages/WordHunt/WordHuntSubmissionList.vue create mode 100644 frontend/src/utils/wordHunt.js create mode 100644 lms/lms/doctype/lms_word_hunt_activity/__init__.py create mode 100644 lms/lms/doctype/lms_word_hunt_activity/lms_word_hunt_activity.json create mode 100644 lms/lms/doctype/lms_word_hunt_activity/lms_word_hunt_activity.py create mode 100644 lms/lms/doctype/lms_word_hunt_activity/test_lms_word_hunt_activity.py create mode 100644 lms/lms/doctype/lms_word_hunt_item/__init__.py create mode 100644 lms/lms/doctype/lms_word_hunt_item/lms_word_hunt_item.json create mode 100644 lms/lms/doctype/lms_word_hunt_item/lms_word_hunt_item.py create mode 100644 lms/lms/doctype/lms_word_hunt_result/__init__.py create mode 100644 lms/lms/doctype/lms_word_hunt_result/lms_word_hunt_result.json create mode 100644 lms/lms/doctype/lms_word_hunt_result/lms_word_hunt_result.py create mode 100644 lms/lms/doctype/lms_word_hunt_submission/__init__.py create mode 100644 lms/lms/doctype/lms_word_hunt_submission/lms_word_hunt_submission.json create mode 100644 lms/lms/doctype/lms_word_hunt_submission/lms_word_hunt_submission.py diff --git a/frontend/src/components/AssessmentPlugin.vue b/frontend/src/components/AssessmentPlugin.vue index 382fee2aa..a4256a1ca 100644 --- a/frontend/src/components/AssessmentPlugin.vue +++ b/frontend/src/components/AssessmentPlugin.vue @@ -2,12 +2,7 @@ +
diff --git a/frontend/src/components/WordHuntBlock.vue b/frontend/src/components/WordHuntBlock.vue new file mode 100644 index 000000000..36b94d845 --- /dev/null +++ b/frontend/src/components/WordHuntBlock.vue @@ -0,0 +1,31 @@ + + diff --git a/frontend/src/pages/Batches/components/Assessments.vue b/frontend/src/pages/Batches/components/Assessments.vue index 23ad356f5..3a3580b9b 100644 --- a/frontend/src/pages/Batches/components/Assessments.vue +++ b/frontend/src/pages/Batches/components/Assessments.vue @@ -195,6 +195,13 @@ const getRowRoute = (row) => { activityID: row.assessment_name, }, } + } else if (row.assessment_type == 'LMS Word Hunt Activity') { + return { + name: 'WordHuntPage', + params: { + activityID: row.assessment_name, + }, + } } else { return { name: 'QuizPage', @@ -251,6 +258,8 @@ const getAssessmentTypeLabel = (type) => { return __('Quiz') } else if (type == 'LMS Drag Drop Activity') { return __('Drag & Drop') + } else if (type == 'LMS Word Hunt Activity') { + return __('Word Hunt') } else if (type == 'LMS Programming Exercise') { return __('Programming Exercise') } diff --git a/frontend/src/pages/Lesson.vue b/frontend/src/pages/Lesson.vue index 56b3eaed6..8d5ed110d 100644 --- a/frontend/src/pages/Lesson.vue +++ b/frontend/src/pages/Lesson.vue @@ -585,7 +585,9 @@ const checkQuiz = () => { if (!editor.value && lesson.body) { const quizRegex = /\{\{ Quiz\(".*"\) \}\}/ const dragDropRegex = /\{\{ DragDrop\(".*"\) \}\}/ - hasQuiz.value = quizRegex.test(lesson.body) || dragDropRegex.test(lesson.body) + const wordHuntRegex = /\{\{ WordHunt\(".*"\) \}\}/ + hasQuiz.value = + quizRegex.test(lesson.body) || dragDropRegex.test(lesson.body) || wordHuntRegex.test(lesson.body) if (!hasQuiz.value && !zenModeEnabled) { allowDiscussions.value = true } else { diff --git a/frontend/src/pages/LessonForm.vue b/frontend/src/pages/LessonForm.vue index cf0c167be..6c1044388 100644 --- a/frontend/src/pages/LessonForm.vue +++ b/frontend/src/pages/LessonForm.vue @@ -314,6 +314,14 @@ const convertToJSON = (lessonData) => { activity: activity, }, }) + } else if (block.includes('{{ WordHunt')) { + let activity = block.match(/\(["']([^"']+?)["']\)/)[1] + blocks.push({ + type: 'wordHunt', + data: { + activity: activity, + }, + }) } else if (block.includes('{{ Video')) { let video = block.match(/\(["']([^"']+?)["']\)/)[1] blocks.push({ diff --git a/frontend/src/pages/WordHunt/WordHuntActivities.vue b/frontend/src/pages/WordHunt/WordHuntActivities.vue new file mode 100644 index 000000000..a9eda54ef --- /dev/null +++ b/frontend/src/pages/WordHunt/WordHuntActivities.vue @@ -0,0 +1,321 @@ + + diff --git a/frontend/src/pages/WordHunt/WordHuntForm.vue b/frontend/src/pages/WordHunt/WordHuntForm.vue new file mode 100644 index 000000000..9ff81c709 --- /dev/null +++ b/frontend/src/pages/WordHunt/WordHuntForm.vue @@ -0,0 +1,170 @@ + + diff --git a/frontend/src/pages/WordHunt/WordHuntPage.vue b/frontend/src/pages/WordHunt/WordHuntPage.vue new file mode 100644 index 000000000..5cf3e79b5 --- /dev/null +++ b/frontend/src/pages/WordHunt/WordHuntPage.vue @@ -0,0 +1,56 @@ + + diff --git a/frontend/src/pages/WordHunt/WordHuntSubmission.vue b/frontend/src/pages/WordHunt/WordHuntSubmission.vue new file mode 100644 index 000000000..f2c3aa9b2 --- /dev/null +++ b/frontend/src/pages/WordHunt/WordHuntSubmission.vue @@ -0,0 +1,90 @@ + + diff --git a/frontend/src/pages/WordHunt/WordHuntSubmissionList.vue b/frontend/src/pages/WordHunt/WordHuntSubmissionList.vue new file mode 100644 index 000000000..dab9bc94f --- /dev/null +++ b/frontend/src/pages/WordHunt/WordHuntSubmissionList.vue @@ -0,0 +1,75 @@ + + diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index 90101782e..e5858d346 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -3,6 +3,7 @@ import { useTimeAgo } from '@vueuse/core' import colorsJSON from '@/utils/frappe-ui-colors.json' import { Quiz } from '@/utils/quiz' import { DragDrop } from '@/utils/dragDrop' +import { WordHunt } from '@/utils/wordHunt' import { Program } from '@/utils/program' import { Assignment } from '@/utils/assignment' import { Upload } from '@/utils/upload' @@ -134,6 +135,7 @@ export function getEditorTools() { }, quiz: Quiz, dragDrop: DragDrop, + wordHunt: WordHunt, assignment: Assignment, program: Program, markdown: { @@ -558,6 +560,21 @@ const getSidebarItems = () => { 'DragDropSubmission', ], }, + { + label: 'Word Hunt Activities', + icon: 'Search', + to: 'WordHuntActivities', + condition: () => { + return isAdmin() + }, + activeFor: [ + 'WordHuntActivities', + 'WordHuntForm', + 'WordHuntPage', + 'WordHuntSubmissionList', + 'WordHuntSubmission', + ], + }, { label: 'Assignments', icon: 'Pencil', diff --git a/frontend/src/utils/wordHunt.js b/frontend/src/utils/wordHunt.js new file mode 100644 index 000000000..668182d59 --- /dev/null +++ b/frontend/src/utils/wordHunt.js @@ -0,0 +1,115 @@ +import AssessmentPlugin from '@/components/AssessmentPlugin.vue' +import { createApp, h } from 'vue' +import translationPlugin from '../translation' +import { Search } from 'lucide-vue-next' +import { getLmsRoute } from '@/utils/basePath' + +export class WordHunt { + constructor({ data, readOnly }) { + this.data = data + this.readOnly = readOnly + } + + static get toolbox() { + const app = createApp({ + render: () => h(Search, { size: 18, strokeWidth: 1.5 }), + }) + + const div = document.createElement('div') + app.mount(div) + + return { + title: __('Word Hunt'), + icon: div.innerHTML, + } + } + + static get isReadOnlySupported() { + return true + } + + render() { + this.wrapper = document.createElement('div') + if (Object.keys(this.data).length) { + this.renderActivity(this.data.activity) + } else { + this.renderActivityModal() + } + return this.wrapper + } + + renderActivity(activity) { + if (this.readOnly) { + const activityPath = getLmsRoute(`word-hunt/${activity}?fromLesson=1`) + + const iframe = document.createElement('iframe') + iframe.src = activityPath + iframe.className = 'w-full' + iframe.style.border = 'none' + iframe.style.height = '200px' + + iframe.addEventListener('load', () => { + let lastHeight = 0 + let attempts = 0 + const poll = setInterval(() => { + try { + const height = iframe.contentWindow.document.body.scrollHeight + if (height === lastHeight || attempts > 20) { + clearInterval(poll) + if (height > 0) iframe.style.height = height + 'px' + } + lastHeight = height + attempts++ + } catch (e) { + clearInterval(poll) + } + }, 100) + }) + + this.wrapper.appendChild(iframe) + + setTimeout(() => { + iframe.style.height = iframe.contentWindow.document.body.scrollHeight + 'px' + + let lastHeight = 0 + setInterval(() => { + try { + const height = iframe.contentWindow.document.body.scrollHeight + if (height !== lastHeight) { + iframe.style.height = height + 'px' + lastHeight = height + } + } catch (e) {} + }, 300) + }, 1500) + return + } + + this.wrapper.innerHTML = `
+ + Word Hunt Activity: ${activity} + +
` + } + + renderActivityModal() { + if (this.readOnly) return + + const app = createApp(AssessmentPlugin, { + type: 'wordHunt', + onAddition: (activity) => { + this.data.activity = activity + this.renderActivity(activity) + }, + }) + app.use(translationPlugin) + app.mount(this.wrapper) + } + + save() { + if (Object.keys(this.data).length === 0) return {} + return { + activity: this.data.activity, + } + } +} diff --git a/lms/hooks.py b/lms/hooks.py index f1743f978..5151ce760 100644 --- a/lms/hooks.py +++ b/lms/hooks.py @@ -254,6 +254,7 @@ def get_lms_path(): "Video": "lms.plugins.video_renderer", "Assignment": "lms.plugins.assignment_renderer", "DragDrop": "lms.plugins.drag_drop_renderer", + "WordHunt": "lms.plugins.word_hunt_renderer", "Embed": "lms.plugins.embed_renderer", "Audio": "lms.plugins.audio_renderer", "PDF": "lms.plugins.pdf_renderer", diff --git a/lms/lms/doctype/course_lesson/course_lesson.py b/lms/lms/doctype/course_lesson/course_lesson.py index 1b1d7d662..eae67eb34 100644 --- a/lms/lms/doctype/course_lesson/course_lesson.py +++ b/lms/lms/doctype/course_lesson/course_lesson.py @@ -79,6 +79,18 @@ def save_lesson_assessment_details(self, content): "lesson": self.name, }, ) + if block.get("type") == "wordHunt": + activity = block.get("data").get("activity") + if not frappe.db.exists("LMS Word Hunt Activity", activity): + frappe.throw(_("Invalid Word Hunt Activity ID in content")) + frappe.db.set_value( + "LMS Word Hunt Activity", + activity, + { + "course": self.course, + "lesson": self.name, + }, + ) @frappe.whitelist() @@ -101,6 +113,7 @@ def save_progress(lesson: str, course: str, scorm_details: dict = None): quiz_completed = get_quiz_progress(lesson) drag_drop_completed = get_drag_drop_progress(lesson) + word_hunt_completed = get_word_hunt_progress(lesson) assignment_completed = get_assignment_progress(lesson) if scorm_details: @@ -110,6 +123,7 @@ def save_progress(lesson: str, course: str, scorm_details: dict = None): not progress_already_exists and quiz_completed and drag_drop_completed + and word_hunt_completed and assignment_completed and not scorm_details ): @@ -231,6 +245,37 @@ def get_drag_drop_progress(lesson): return True +def get_word_hunt_progress(lesson): + lesson_details = frappe.db.get_value("Course Lesson", lesson, ["body", "content"], as_dict=1) + activities = [] + + if lesson_details.content: + content = json.loads(lesson_details.content) + + for block in content.get("blocks"): + if block.get("type") == "wordHunt": + activities.append(block.get("data").get("activity")) + + elif lesson_details.body: + macros = find_macros(lesson_details.body) + activities = [value for name, value in macros if name == "WordHunt"] + + for activity in activities: + passing_percentage = frappe.db.get_value( + "LMS Word Hunt Activity", activity, "passing_percentage" + ) + if not frappe.db.exists( + "LMS Word Hunt Submission", + { + "activity": activity, + "member": frappe.session.user, + "percentage": [">=", passing_percentage], + }, + ): + return False + return True + + def get_assignment_progress(lesson): lesson_details = frappe.db.get_value("Course Lesson", lesson, ["body", "content"], as_dict=1) assignments = [] diff --git a/lms/lms/doctype/lms_batch/lms_batch.js b/lms/lms/doctype/lms_batch/lms_batch.js index fb6fee9d6..7442c99b5 100644 --- a/lms/lms/doctype/lms_batch/lms_batch.js +++ b/lms/lms/doctype/lms_batch/lms_batch.js @@ -4,7 +4,7 @@ frappe.ui.form.on("LMS Batch", { onload: function (frm) { frm.set_query("reference_doctype", "timetable", function () { - let doctypes = ["Course Lesson", "LMS Quiz", "LMS Assignment", "LMS Drag Drop Activity"]; + let doctypes = ["Course Lesson", "LMS Quiz", "LMS Assignment", "LMS Drag Drop Activity", "LMS Word Hunt Activity"]; return { filters: { name: ["in", doctypes], @@ -21,7 +21,7 @@ frappe.ui.form.on("LMS Batch", { }); frm.set_query("assessment_type", "assessment", function () { - let doctypes = ["LMS Quiz", "LMS Assignment", "LMS Drag Drop Activity"]; + let doctypes = ["LMS Quiz", "LMS Assignment", "LMS Drag Drop Activity", "LMS Word Hunt Activity"]; return { filters: { name: ["in", doctypes], @@ -30,7 +30,7 @@ frappe.ui.form.on("LMS Batch", { }); frm.set_query("reference_doctype", "timetable_legends", function () { - let doctypes = ["Course Lesson", "LMS Quiz", "LMS Assignment", "LMS Drag Drop Activity"]; + let doctypes = ["Course Lesson", "LMS Quiz", "LMS Assignment", "LMS Drag Drop Activity", "LMS Word Hunt Activity"]; return { filters: { name: ["in", doctypes], @@ -167,6 +167,11 @@ const set_default_legends = (frm) => { label: "LMS Drag Drop Activity", color: "#F97316", }, + { + reference_doctype: "LMS Word Hunt Activity", + label: "LMS Word Hunt Activity", + color: "#14B8A6", + }, { reference_doctype: "LMS Live Class", label: "LMS Live Class", diff --git a/lms/lms/doctype/lms_word_hunt_activity/__init__.py b/lms/lms/doctype/lms_word_hunt_activity/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lms/lms/doctype/lms_word_hunt_activity/lms_word_hunt_activity.json b/lms/lms/doctype/lms_word_hunt_activity/lms_word_hunt_activity.json new file mode 100644 index 000000000..e687cd2fc --- /dev/null +++ b/lms/lms/doctype/lms_word_hunt_activity/lms_word_hunt_activity.json @@ -0,0 +1,196 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-03-31 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "title", + "passage", + "max_attempts", + "show_answers", + "show_submission_history", + "column_break_eosb", + "total_marks", + "passing_percentage", + "duration", + "section_break_kxhc", + "shuffle_answers", + "section_break_hgik", + "items", + "section_break_jlmr", + "lesson", + "column_break_hxby", + "course" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Title", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "passage", + "fieldtype": "Long Text", + "label": "Passage", + "reqd": 1 + }, + { + "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": "column_break_eosb", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "total_marks", + "fieldtype": "Int", + "label": "Total Marks", + "read_only": 1, + "reqd": 1 + }, + { + "default": "100", + "fieldname": "passing_percentage", + "fieldtype": "Int", + "label": "Passing Percentage", + "non_negative": 1, + "reqd": 1 + }, + { + "fieldname": "duration", + "fieldtype": "Data", + "label": "Duration (in minutes)" + }, + { + "fieldname": "section_break_kxhc", + "fieldtype": "Section Break" + }, + { + "default": "0", + "fieldname": "shuffle_answers", + "fieldtype": "Check", + "label": "Shuffle Answers" + }, + { + "fieldname": "section_break_hgik", + "fieldtype": "Section Break" + }, + { + "fieldname": "items", + "fieldtype": "Table", + "label": "Items", + "options": "LMS Word Hunt Item" + }, + { + "fieldname": "section_break_jlmr", + "fieldtype": "Section Break" + }, + { + "fieldname": "lesson", + "fieldtype": "Link", + "label": "Lesson", + "options": "Course Lesson", + "read_only": 1 + }, + { + "fetch_from": "lesson.course", + "fieldname": "course", + "fieldtype": "Link", + "label": "Course", + "options": "LMS Course", + "read_only": 1 + }, + { + "fieldname": "column_break_hxby", + "fieldtype": "Column Break" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [ + { + "link_doctype": "LMS Word Hunt Submission", + "link_fieldname": "activity" + } + ], + "modified": "2026-03-31 00:00:00.000000", + "modified_by": "Administrator", + "module": "LMS", + "name": "LMS Word Hunt Activity", + "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_word_hunt_activity/lms_word_hunt_activity.py b/lms/lms/doctype/lms_word_hunt_activity/lms_word_hunt_activity.py new file mode 100644 index 000000000..c98ceab58 --- /dev/null +++ b/lms/lms/doctype/lms_word_hunt_activity/lms_word_hunt_activity.py @@ -0,0 +1,141 @@ +# 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 + +from ..course_lesson.course_lesson import save_progress +from ...utils import generate_slug + +BLANK_PATTERN = re.compile(r"_+") + + +class LMSWordHuntActivity(Document): + def validate(self): + self.validate_passage() + self.validate_items() + self.validate_placeholders() + self.calculate_total_marks() + + def autoname(self): + if not self.name: + self.name = generate_slug(self.title, "LMS Word Hunt Activity") + + def validate_passage(self): + if not self.passage or not self.passage.strip(): + frappe.throw(_("The passage is required.")) + + def validate_items(self): + if not self.items: + frappe.throw(_("Add at least one blank to this activity.")) + + for index, row in enumerate(self.items, start=1): + row.placeholder = f"blank_{index}" + if not row.correct_answer: + frappe.throw(_("Each blank must have a correct answer.")) + + def validate_placeholders(self): + blank_count = count_blanks(self.passage) + + if not blank_count: + frappe.throw( + _("Add at least one blank in the passage using underscores like _ or _______.") + ) + + if blank_count != len(self.items): + frappe.throw( + _( + "The passage contains {0} blanks, but you added {1} answers. The counts must match." + ).format(blank_count, len(self.items)) + ) + + def calculate_total_marks(self): + self.total_marks = sum(cint(row.marks) for row in self.items) + if not self.items: + self.total_marks = 0 + self.passing_percentage = 100 + + +def normalize_answer(answer): + return " ".join((answer or "").strip().split()).casefold() + + +def count_blanks(passage): + return len(BLANK_PATTERN.findall(passage or "")) + + +@frappe.whitelist() +def submit_activity(activity: str, answers: str): + answers = json.loads(answers or "[]") + activity_doc = frappe.get_doc("LMS Word Hunt Activity", activity) + + results = [] + score = 0 + for item in activity_doc.items: + submitted_answer = next( + ( + row.get("submitted_answer") + for row in answers + if row.get("item") == item.name + or row.get("idx") == item.idx + ), + "", + ) + is_correct = normalize_answer(submitted_answer) == normalize_answer(item.correct_answer) + marks = cint(item.marks) if is_correct else 0 + score += marks + results.append( + { + "item": item.name, + "placeholder": item.placeholder, + "submitted_answer": submitted_answer, + "correct_answer": item.correct_answer, + "is_correct": 1 if is_correct else 0, + "marks": marks, + "marks_out_of": item.marks, + } + ) + + score_out_of = activity_doc.total_marks + percentage = (score / score_out_of) * 100 if score_out_of else 0 + submission = create_submission(activity_doc, results, score, score_out_of, percentage) + + save_progress_after_activity(activity_doc, percentage) + + return { + "score": score, + "score_out_of": score_out_of, + "submission": submission.name, + "pass": percentage >= activity_doc.passing_percentage, + "percentage": percentage, + } + + +def create_submission(activity_doc, results, score, score_out_of, percentage): + submission = frappe.new_doc("LMS Word Hunt Submission") + submission.update( + { + "activity": activity_doc.name, + "result": results, + "score": score, + "score_out_of": score_out_of, + "member": frappe.session.user, + "percentage": percentage, + "passing_percentage": activity_doc.passing_percentage, + } + ) + submission.save(ignore_permissions=True) + return submission + + +def save_progress_after_activity(activity_doc, percentage): + if not activity_doc.lesson or not activity_doc.course: + return + + if percentage >= activity_doc.passing_percentage or not activity_doc.passing_percentage: + save_progress(activity_doc.lesson, activity_doc.course) diff --git a/lms/lms/doctype/lms_word_hunt_activity/test_lms_word_hunt_activity.py b/lms/lms/doctype/lms_word_hunt_activity/test_lms_word_hunt_activity.py new file mode 100644 index 000000000..33065629f --- /dev/null +++ b/lms/lms/doctype/lms_word_hunt_activity/test_lms_word_hunt_activity.py @@ -0,0 +1,91 @@ +# Copyright (c) 2026, Frappe and Contributors +# See license.txt + +import unittest + +import frappe + + +class TestLMSWordHuntActivity(unittest.TestCase): + def test_total_marks_are_calculated(self): + activity = frappe.get_doc( + { + "doctype": "LMS Word Hunt Activity", + "title": "Word Hunt Marks Activity", + "passing_percentage": 100, + "passage": "I _______ to school and _______ lunch there.", + "items": [ + { + "doctype": "LMS Word Hunt Item", + "correct_answer": "go", + "marks": 2, + }, + { + "doctype": "LMS Word Hunt Item", + "correct_answer": "eat", + "marks": 3, + }, + ], + } + ) + activity.insert() + self.assertEqual(activity.total_marks, 5) + + def test_passage_blank_count_must_match_items(self): + activity = frappe.get_doc( + { + "doctype": "LMS Word Hunt Activity", + "title": "Mismatched Blank Count Activity", + "passing_percentage": 100, + "passage": "I _______ to school and _______ lunch there.", + "items": [ + { + "doctype": "LMS Word Hunt Item", + "correct_answer": "go", + "marks": 1, + } + ], + } + ) + + with self.assertRaises(frappe.ValidationError): + activity.insert() + + def test_requires_passage_blank(self): + activity = frappe.get_doc( + { + "doctype": "LMS Word Hunt Activity", + "title": "Invalid Passage Activity", + "passing_percentage": 100, + "passage": "This passage has no blanks.", + "items": [ + { + "doctype": "LMS Word Hunt Item", + "correct_answer": "go", + "marks": 1, + } + ], + } + ) + + with self.assertRaises(frappe.ValidationError): + activity.insert() + + def test_single_underscore_counts_as_blank(self): + activity = frappe.get_doc( + { + "doctype": "LMS Word Hunt Activity", + "title": "Single Underscore Activity", + "passing_percentage": 100, + "passage": "I _ to school.", + "items": [ + { + "doctype": "LMS Word Hunt Item", + "correct_answer": "go", + "marks": 1, + } + ], + } + ) + activity.insert() + self.assertEqual(activity.total_marks, 1) diff --git a/lms/lms/doctype/lms_word_hunt_item/__init__.py b/lms/lms/doctype/lms_word_hunt_item/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lms/lms/doctype/lms_word_hunt_item/lms_word_hunt_item.json b/lms/lms/doctype/lms_word_hunt_item/lms_word_hunt_item.json new file mode 100644 index 000000000..eb987c36b --- /dev/null +++ b/lms/lms/doctype/lms_word_hunt_item/lms_word_hunt_item.json @@ -0,0 +1,49 @@ +{ + "actions": [], + "creation": "2026-03-31 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "placeholder", + "correct_answer", + "marks" + ], + "fields": [ + { + "fieldname": "placeholder", + "fieldtype": "Data", + "label": "Placeholder", + "read_only": 1 + }, + { + "fieldname": "correct_answer", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Correct Answer", + "reqd": 1 + }, + { + "default": "1", + "fieldname": "marks", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Marks", + "non_negative": 1, + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-03-31 00:00:00.000000", + "modified_by": "Administrator", + "module": "LMS", + "name": "LMS Word Hunt Item", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/lms/lms/doctype/lms_word_hunt_item/lms_word_hunt_item.py b/lms/lms/doctype/lms_word_hunt_item/lms_word_hunt_item.py new file mode 100644 index 000000000..3e386e7f6 --- /dev/null +++ b/lms/lms/doctype/lms_word_hunt_item/lms_word_hunt_item.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 LMSWordHuntItem(Document): + pass diff --git a/lms/lms/doctype/lms_word_hunt_result/__init__.py b/lms/lms/doctype/lms_word_hunt_result/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lms/lms/doctype/lms_word_hunt_result/lms_word_hunt_result.json b/lms/lms/doctype/lms_word_hunt_result/lms_word_hunt_result.json new file mode 100644 index 000000000..669c14cb0 --- /dev/null +++ b/lms/lms/doctype/lms_word_hunt_result/lms_word_hunt_result.json @@ -0,0 +1,80 @@ +{ + "actions": [], + "creation": "2026-03-31 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item", + "placeholder", + "submitted_answer", + "column_break_ixwe", + "correct_answer", + "is_correct", + "marks", + "marks_out_of" + ], + "fields": [ + { + "fieldname": "item", + "fieldtype": "Data", + "label": "Item" + }, + { + "fieldname": "placeholder", + "fieldtype": "Data", + "label": "Placeholder", + "read_only": 1 + }, + { + "fieldname": "submitted_answer", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Submitted Answer", + "read_only": 1 + }, + { + "fieldname": "column_break_ixwe", + "fieldtype": "Column Break" + }, + { + "fieldname": "correct_answer", + "fieldtype": "Data", + "label": "Correct Answer", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_correct", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Is Correct" + }, + { + "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-03-31 00:00:00.000000", + "modified_by": "Administrator", + "module": "LMS", + "name": "LMS Word Hunt Result", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/lms/lms/doctype/lms_word_hunt_result/lms_word_hunt_result.py b/lms/lms/doctype/lms_word_hunt_result/lms_word_hunt_result.py new file mode 100644 index 000000000..70817dc21 --- /dev/null +++ b/lms/lms/doctype/lms_word_hunt_result/lms_word_hunt_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 LMSWordHuntResult(Document): + pass diff --git a/lms/lms/doctype/lms_word_hunt_submission/__init__.py b/lms/lms/doctype/lms_word_hunt_submission/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lms/lms/doctype/lms_word_hunt_submission/lms_word_hunt_submission.json b/lms/lms/doctype/lms_word_hunt_submission/lms_word_hunt_submission.json new file mode 100644 index 000000000..3f5c4f1ee --- /dev/null +++ b/lms/lms/doctype/lms_word_hunt_submission/lms_word_hunt_submission.json @@ -0,0 +1,163 @@ +{ + "actions": [], + "creation": "2026-03-31 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "activity", + "activity_title", + "column_break_mqku", + "member", + "member_name", + "section_break_iskf", + "score", + "score_out_of", + "column_break_qhbt", + "percentage", + "passing_percentage", + "section_break_uvmw", + "result" + ], + "fields": [ + { + "fieldname": "activity", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Activity", + "options": "LMS Word Hunt Activity" + }, + { + "fetch_from": "activity.title", + "fieldname": "activity_title", + "fieldtype": "Data", + "label": "Activity Title", + "read_only": 1 + }, + { + "fieldname": "column_break_mqku", + "fieldtype": "Column Break" + }, + { + "fieldname": "member", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Member", + "options": "User" + }, + { + "fetch_from": "member.full_name", + "fieldname": "member_name", + "fieldtype": "Data", + "label": "Member Name", + "read_only": 1 + }, + { + "fieldname": "section_break_iskf", + "fieldtype": "Section Break" + }, + { + "fieldname": "score", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Score", + "reqd": 1 + }, + { + "fieldname": "score_out_of", + "fieldtype": "Int", + "label": "Score Out Of", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "column_break_qhbt", + "fieldtype": "Column Break" + }, + { + "fieldname": "percentage", + "fieldtype": "Int", + "label": "Percentage", + "reqd": 1 + }, + { + "fieldname": "passing_percentage", + "fieldtype": "Int", + "label": "Passing Percentage", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "section_break_uvmw", + "fieldtype": "Section Break" + }, + { + "fieldname": "result", + "fieldtype": "Table", + "label": "Result", + "options": "LMS Word Hunt Result" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-03-31 00:00:00.000000", + "modified_by": "Administrator", + "module": "LMS", + "name": "LMS Word Hunt 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, + "print": 1, + "read": 1, + "report": 1, + "role": "LMS Student", + "share": 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 + } + ], + "show_title_field_in_link": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "member_name", + "track_changes": 1 +} diff --git a/lms/lms/doctype/lms_word_hunt_submission/lms_word_hunt_submission.py b/lms/lms/doctype/lms_word_hunt_submission/lms_word_hunt_submission.py new file mode 100644 index 000000000..e9f5b69de --- /dev/null +++ b/lms/lms/doctype/lms_word_hunt_submission/lms_word_hunt_submission.py @@ -0,0 +1,75 @@ +# Copyright (c) 2026, Frappe and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.desk.doctype.notification_log.notification_log import make_notification_logs +from frappe.model.document import Document +from frappe.utils import cint + + +class LMSWordHuntSubmission(Document): + def validate(self): + self.validate_if_max_attempts_exceeded() + self.validate_marks() + self.set_percentage() + + def on_update(self): + self.notify_member() + + def validate_if_max_attempts_exceeded(self): + max_attempts = frappe.db.get_value("LMS Word Hunt Activity", self.activity, "max_attempts") + if max_attempts == 0: + return + + current_user_submission_count = frappe.db.count( + self.doctype, filters={"activity": self.activity, "member": frappe.session.user} + ) + if current_user_submission_count >= max_attempts: + frappe.throw( + _("You have exceeded the maximum number of attempts ({0}) for this activity").format( + max_attempts + ), + MaximumAttemptsExceededError, + ) + + 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 row number {0} cannot be greater than the marks allotted for that row." + ).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 notify_member(self): + if not self.has_value_changed("score"): + return + + notification = frappe._dict( + { + "subject": _("Your score for {0} has been updated to {1}").format( + frappe.bold(self.activity_title), frappe.bold(self.score) + ), + "email_content": _( + "There has been an update on your word hunt submission. Your latest score is {0}." + ).format(frappe.bold(self.score)), + "document_type": self.doctype, + "document_name": self.name, + "for_user": self.member, + "from_user": frappe.session.user, + "type": "Alert", + "link": "", + } + ) + make_notification_logs(notification, [self.member]) + + +class MaximumAttemptsExceededError(frappe.DuplicateEntryError): + pass diff --git a/lms/lms/utils.py b/lms/lms/utils.py index 9cf199a8e..5902270b9 100644 --- a/lms/lms/utils.py +++ b/lms/lms/utils.py @@ -209,6 +209,8 @@ def get_lesson_icon(body: str, content: str): return "icon-quiz" if block.get("type") == "dragDrop": return "icon-quiz" + if block.get("type") == "wordHunt": + return "icon-quiz" if block.get("type") == "assignment": return "icon-assignment" if block.get("type") == "program": @@ -224,6 +226,8 @@ def get_lesson_icon(body: str, content: str): return "icon-quiz" elif macro[0] == "DragDrop": return "icon-quiz" + elif macro[0] == "WordHunt": + return "icon-quiz" return "icon-list" @@ -1271,6 +1275,9 @@ def get_assessments(batch: str) -> list: elif assessment.assessment_type == "LMS Drag Drop Activity": assessment = get_drag_drop_details(assessment, member) + elif assessment.assessment_type == "LMS Word Hunt Activity": + assessment = get_word_hunt_details(assessment, member) + elif assessment.assessment_type == "LMS Programming Exercise": assessment = get_exercise_details(assessment, member) @@ -1395,6 +1402,38 @@ def get_drag_drop_details(assessment, member): return assessment +def get_word_hunt_details(assessment, member): + assessment_details = frappe.db.get_value( + "LMS Word Hunt Activity", assessment.assessment_name, ["title", "passing_percentage"], as_dict=1 + ) + assessment.title = assessment_details.title + + existing_submission = frappe.get_all( + "LMS Word Hunt Submission", + { + "member": member, + "activity": assessment.assessment_name, + }, + ["name", "score", "percentage"], + order_by="percentage desc", + ) + + if len(existing_submission): + assessment.submission = existing_submission[0] + assessment.completed = True + assessment.status = assessment.submission.percentage or assessment.submission.score + else: + assessment.status = "Not Attempted" + assessment.color = "red" + assessment.completed = False + + assessment.edit_url = f"/word-hunt-activities/{assessment.assessment_name}" + submission_name = existing_submission[0].name if len(existing_submission) else "new-submission" + assessment.url = f"/word-hunt-submission/{submission_name}" + + return assessment + + @frappe.whitelist() def get_batch_student_progress(member: str, batch: str) -> dict: if not can_modify_batch(batch): @@ -1512,6 +1551,33 @@ def get_drag_drop_pass_stats(batch): return [{"task": row.title, "value": row.passed or 0} for row in rows] +def get_word_hunt_pass_stats(batch): + Assessment = frappe.qb.DocType("LMS Assessment") + Activity = frappe.qb.DocType("LMS Word Hunt Activity") + BatchEnrollment = frappe.qb.DocType("LMS Batch Enrollment") + Submission = frappe.qb.DocType("LMS Word Hunt Submission") + + rows = ( + frappe.qb.from_(Assessment) + .join(Activity) + .on(Activity.name == Assessment.assessment_name) + .left_join(BatchEnrollment) + .on(BatchEnrollment.batch == Assessment.parent) + .left_join(Submission) + .on((Submission.activity == Assessment.assessment_name) & (Submission.member == BatchEnrollment.member)) + .where((Assessment.parent == batch) & (Assessment.assessment_type == "LMS Word Hunt Activity")) + .groupby(Assessment.assessment_name, Activity.title) + .select( + Activity.title, + fn.Count(Case().when(Submission.percentage >= Submission.passing_percentage, Submission.member)) + .distinct() + .as_("passed"), + ) + ).run(as_dict=True) + + return [{"task": row.title, "value": row.passed or 0} for row in rows] + + @frappe.whitelist() def get_batch_chart_data(batch: str) -> list: """Get completion counts per course and assessment""" @@ -1525,6 +1591,7 @@ def get_batch_chart_data(batch: str) -> list: + get_assignment_pass_stats(batch) + get_quiz_pass_stats(batch) + get_drag_drop_pass_stats(batch) + + get_word_hunt_pass_stats(batch) ) @@ -1638,6 +1705,11 @@ def get_assessment_meta(assessment_type: str): docfield = "activity" fields = ["percentage"] not_attempted = 0 + elif assessment_type == "LMS Word Hunt Activity": + doctype = "LMS Word Hunt Submission" + docfield = "activity" + fields = ["percentage"] + not_attempted = 0 elif assessment_type == "LMS Programming Exercise": doctype = "LMS Programming Exercise Submission" docfield = "exercise" @@ -1663,12 +1735,19 @@ def get_assessment_attempt_details( ) if attempt_details.percentage >= passing_percentage: result = "Pass" + elif assessment_type == "LMS Word Hunt Activity": + result = "Failed" + passing_percentage = frappe.db.get_value( + "LMS Word Hunt Activity", assessment, "passing_percentage" + ) + if attempt_details.percentage >= passing_percentage: + result = "Pass" else: result = attempt_details.status return frappe._dict( { "status": attempt_details.percentage - if assessment_type in ["LMS Quiz", "LMS Drag Drop Activity"] + if assessment_type in ["LMS Quiz", "LMS Drag Drop Activity", "LMS Word Hunt Activity"] else attempt_details.status, "result": result, "assessment": assessment, diff --git a/lms/plugins.py b/lms/plugins.py index fa26d98d8..a54822634 100644 --- a/lms/plugins.py +++ b/lms/plugins.py @@ -240,6 +240,21 @@ def drag_drop_renderer(activity_name): return f"
{frappe.bold(activity.title)}
" +def word_hunt_renderer(activity_name): + if frappe.session.user == "Guest": + return "
" + _( + "Word hunt activity is not available to Guest users. Please login to continue." + ) + "
" + + activity = frappe.db.get_value( + "LMS Word Hunt Activity", + activity_name, + ["name", "title", "max_attempts", "show_answers", "show_submission_history", "passing_percentage"], + as_dict=True, + ) + return f"
{frappe.bold(activity.title)}
" + + def show_custom_signup(): settings = frappe.get_single("LMS Settings") if settings.custom_signup_content or settings.user_category: From 9a8c6e4cd112a0bc78c57e5fd6f0c5be6590f0f1 Mon Sep 17 00:00:00 2001 From: dannnn512 Date: Wed, 1 Apr 2026 09:27:02 +0700 Subject: [PATCH 2/3] fix: define module word hunt --- frontend/components.d.ts | 2 + frontend/src/components/DragDrop.vue | 208 ++++++++++++++++++--------- 2 files changed, 146 insertions(+), 64 deletions(-) diff --git a/frontend/components.d.ts b/frontend/components.d.ts index fe8b2bd45..727e4ac56 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -119,6 +119,8 @@ declare module 'vue' { UserDropdown: typeof import('./src/components/Sidebar/UserDropdown.vue')['default'] VideoBlock: typeof import('./src/components/VideoBlock.vue')['default'] VideoStatistics: typeof import('./src/components/Modals/VideoStatistics.vue')['default'] + WordHunt: typeof import('./src/components/WordHunt.vue')['default'] + WordHuntBlock: typeof import('./src/components/WordHuntBlock.vue')['default'] ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default'] ZoomSettings: typeof import('./src/components/Settings/ZoomSettings.vue')['default'] } diff --git a/frontend/src/components/DragDrop.vue b/frontend/src/components/DragDrop.vue index 287bfab74..a508ee1c0 100644 --- a/frontend/src/components/DragDrop.vue +++ b/frontend/src/components/DragDrop.vue @@ -73,9 +73,81 @@ {{ __('Clear Selection') }}
-
+
+
+
+ + +
+ +
+