From af273a9a1ca32c8920b850d759f8cf340f347dc0 Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Tue, 17 Feb 2026 19:38:19 +0530 Subject: [PATCH] refactor: batch student progress --- .../src/components/Controls/Autocomplete.vue | 331 +++++++++++------- frontend/src/components/Controls/Link.vue | 3 +- .../src/components/Modals/AssessmentModal.vue | 6 +- .../Modals/BatchStudentProgress.vue | 146 -------- frontend/src/pages/Batches/BatchDetail.vue | 52 ++- .../components/AdminBatchDashboard.vue | 24 +- .../Batches/components/Announcements.vue | 2 +- .../Batches/components/BatchDashboard.vue | 4 +- .../components/BatchStudentProgress.vue | 222 ++++++++++++ frontend/yarn.lock | 2 +- lms/lms/utils.py | 119 +++---- 11 files changed, 533 insertions(+), 378 deletions(-) delete mode 100644 frontend/src/components/Modals/BatchStudentProgress.vue create mode 100644 frontend/src/pages/Batches/components/BatchStudentProgress.vue diff --git a/frontend/src/components/Controls/Autocomplete.vue b/frontend/src/components/Controls/Autocomplete.vue index 79fbd5b7..2d3cea03 100644 --- a/frontend/src/components/Controls/Autocomplete.vue +++ b/frontend/src/components/Controls/Autocomplete.vue @@ -1,92 +1,142 @@ @@ -97,16 +147,15 @@ import { ComboboxInput, ComboboxOptions, ComboboxOption, - ComboboxButton, } from '@headlessui/vue' -import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue' +import { Popover } from 'frappe-ui' import { ChevronDown, X } from 'lucide-vue-next' -import { watchDebounced } from '@vueuse/core' +import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue' const props = defineProps({ modelValue: { - type: [String, Object], - default: null, + type: String, + default: '', }, options: { type: Array, @@ -137,93 +186,107 @@ const props = defineProps({ default: true, }, }) - const emit = defineEmits(['update:modelValue', 'update:query', 'change']) -const trigger = ref(null) + +const query = ref('') +const showOptions = ref(false) const search = ref(null) + const attrs = useAttrs() const slots = useSlots() -const selectedValue = ref(props.modelValue) -const query = ref('') + const valuePropPassed = computed(() => 'value' in attrs) -watch(selectedValue, (val) => { - query.value = '' - emit(valuePropPassed.value ? 'change' : 'update:modelValue', val) +const selectedValue = computed({ + get() { + return valuePropPassed.value ? attrs.value : props.modelValue + }, + set(val) { + query.value = '' + if (val) { + showOptions.value = false + } + emit(valuePropPassed.value ? 'change' : 'update:modelValue', val) + }, }) -function clearValue() { - emit('update:modelValue', null) +function close() { + showOptions.value = false } const groups = computed(() => { - if (!props.options?.length) return [] + if (!props.options || props.options.length == 0) return [] - const normalized = props.options[0]?.group + let groups = props.options[0]?.group ? props.options : [{ group: '', items: props.options }] - return normalized - .map((group, i) => ({ - key: i, - group: group.group, - hideLabel: group.hideLabel || false, - items: props.filterable ? filterOptions(group.items) : group.items, - })) + + return groups + .map((group, i) => { + return { + key: i, + group: group.group, + hideLabel: group.hideLabel || false, + items: props.filterable ? filterOptions(group.items) : group.items, + } + }) .filter((group) => group.items.length > 0) }) function filterOptions(options) { - if (!query.value) return options - const q = query.value.toLowerCase() - return options.filter((option) => - [option.label, option.value] - .filter(Boolean) - .some((text) => text.toString().toLowerCase().includes(q)) - ) -} - -watchDebounced( - query, - (val) => { - emit('update:query', val) - }, - { debounce: 300 } -) - -const onFocus = () => { - trigger.value?.$el.click() - nextTick(() => { - search.value?.focus() + if (!query.value) { + return options + } + return options.filter((option) => { + let searchTexts = [option.label, option.value] + return searchTexts.some((text) => + (text || '').toString().toLowerCase().includes(query.value.toLowerCase()) + ) }) } -const close = () => { - selectedValue.value = null - trigger.value?.$el.click() +function displayValue(option) { + if (typeof option === 'string') { + let allOptions = groups.value.flatMap((group) => group.items) + let selectedOption = allOptions.find((o) => o.value === option) + return selectedOption?.label || option + } + return option?.label } -const textColor = computed(() => - props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8' -) +watch(query, (q) => { + emit('update:query', q) +}) + +watch(showOptions, (val) => { + if (val) { + nextTick(() => { + search.value.el.focus() + }) + } +}) + +const textColor = computed(() => { + return props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8' +}) const inputClasses = computed(() => { - const sizeClasses = { + let sizeClasses = { sm: 'text-base rounded h-7', md: 'text-base rounded h-8', lg: 'text-lg rounded-md h-10', xl: 'text-xl rounded-md h-10', }[props.size] - const paddingClasses = { + let paddingClasses = { sm: 'py-1.5 px-2', md: 'py-1.5 px-2.5', lg: 'py-1.5 px-3', xl: 'py-1.5 px-3', }[props.size] - const variant = props.disabled ? 'disabled' : props.variant - - const variantClasses = { + let variant = props.disabled ? 'disabled' : props.variant + let variantClasses = { subtle: 'border border-gray-100 bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3', outline: @@ -244,4 +307,6 @@ const inputClasses = computed(() => { 'transition-colors w-full', ] }) + +defineExpose({ query }) diff --git a/frontend/src/components/Controls/Link.vue b/frontend/src/components/Controls/Link.vue index a84cea4f..6d73dbaf 100644 --- a/frontend/src/components/Controls/Link.vue +++ b/frontend/src/components/Controls/Link.vue @@ -95,7 +95,8 @@ const value = computed({ get: () => (valuePropPassed.value ? attrs.value : props.modelValue), set: (val) => { return ( - val && emit(valuePropPassed.value ? 'change' : 'update:modelValue', val) + val?.value && + emit(valuePropPassed.value ? 'change' : 'update:modelValue', val.value) ) }, }) diff --git a/frontend/src/components/Modals/AssessmentModal.vue b/frontend/src/components/Modals/AssessmentModal.vue index 46779a47..e75131d1 100644 --- a/frontend/src/components/Modals/AssessmentModal.vue +++ b/frontend/src/components/Modals/AssessmentModal.vue @@ -20,11 +20,15 @@ :options="assessmentTypes" v-model="assessmentType" :label="__('Type')" + placeholder=" " + @update:modelValue="() => (assessment = null)" /> - - - - diff --git a/frontend/src/pages/Batches/BatchDetail.vue b/frontend/src/pages/Batches/BatchDetail.vue index 41cf027d..58d4787e 100644 --- a/frontend/src/pages/Batches/BatchDetail.vue +++ b/frontend/src/pages/Batches/BatchDetail.vue @@ -17,22 +17,20 @@ {{ __('Save') }} -
- - -
+ + +
@@ -76,6 +74,7 @@ diff --git a/frontend/yarn.lock b/frontend/yarn.lock index e3abd376..8cf99798 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -4324,7 +4324,7 @@ regjsparser@^0.13.0: dependencies: jsesc "~3.1.0" -reka-ui@^2.5.0: +reka-ui@^2.5.0, reka-ui@^2.8.0: version "2.8.0" resolved "https://registry.yarnpkg.com/reka-ui/-/reka-ui-2.8.0.tgz#612023ad40c5c10999aef304f2b828cdd08da6a8" integrity sha512-N4JOyIrmDE7w2i06WytqcV2QICubtS2PsK5Uo8FIMAgmO13KhUAgAByP26cXjjm2oF/w7rTyRs8YaqtvaBT+SA== diff --git a/lms/lms/utils.py b/lms/lms/utils.py index 174fea24..84e387cd 100644 --- a/lms/lms/utils.py +++ b/lms/lms/utils.py @@ -1347,35 +1347,13 @@ def get_exercise_details(assessment: dict, member: str) -> dict: @frappe.whitelist() -def get_batch_students( - filters: dict, offset: int = 0, limit_start: int = 0, limit_page_length: int = None, limit: int = None -): - # limit_start and limit_page_length are used for backward compatibility - start = limit_start or offset - page_length = limit_page_length or limit - batch = filters.get("batch") - if not batch: - return [] - +def get_batch_student_progress(member: str, batch: str) -> dict: if not can_modify_batch(batch): frappe.throw(_("You are not authorized to view the students of this batch.")) - students = [] - students_list = frappe.get_all( - "LMS Batch Enrollment", - filters={"batch": batch}, - fields=["member", "name"], - offset=start, - limit=page_length, - order_by="creation desc", - ) - - for student in students_list: - details = get_batch_student_details(student) - calculate_student_progress(batch, details) - students.append(details) - - return students + details = get_batch_student_details(member) + calculate_student_progress(batch, details) + return details def get_course_completion_stats(batch: str) -> list: @@ -1469,16 +1447,14 @@ def get_batch_chart_data(batch: str) -> list: return get_course_completion_stats(batch) + get_assignment_pass_stats(batch) + get_quiz_pass_stats(batch) -def get_batch_student_details(student: dict) -> dict: +def get_batch_student_details(student: str) -> dict: details = frappe.db.get_value( "User", - student.member, - ["full_name", "email", "username", "last_active", "user_image"], + student, + ["full_name", "email", "username", "last_active", "user_image", "name"], as_dict=True, ) details.last_active = format_datetime(details.last_active, "dd MMM YY") - details.name = student.name - details.assessments = frappe._dict() return details @@ -1508,8 +1484,7 @@ def calculate_student_progress(batch: str, details: dict): def calculate_course_progress(batch_courses: list, details: dict): course_progress = [] - details.courses = frappe._dict() - + details.courses = [] for course in batch_courses: progress = ( frappe.db.get_value( @@ -1517,7 +1492,7 @@ def calculate_course_progress(batch_courses: list, details: dict): ) or 0 ) - details.courses[course.title] = progress + details.courses.append({"course": course.course, "title": course.title, "progress": progress}) course_progress.append(progress) details.average_course_progress = ( @@ -1527,14 +1502,15 @@ def calculate_course_progress(batch_courses: list, details: dict): def calculate_assessment_progress(assessments: list, details: dict): assessments_completed = 0 - details.assessments = frappe._dict() + details.assessments = [] for assessment in assessments: title = frappe.db.get_value(assessment.assessment_type, assessment.assessment_name, "title") assessment_info = has_submitted_assessment( assessment.assessment_name, assessment.assessment_type, details.email ) - details.assessments[title] = assessment_info + assessment_info.title = title + details.assessments.append(assessment_info) if assessment_info.result == "Pass": assessments_completed += 1 @@ -1548,6 +1524,24 @@ def has_submitted_assessment(assessment: str, assessment_type: str, member: str if not member: member = frappe.session.user + doctype, docfield, fields, not_attempted = get_assessment_meta(assessment_type) + filters = {} + filters[docfield] = assessment + filters["member"] = member + + attempt = frappe.db.exists(doctype, filters) + if attempt: + return get_assessment_attempt_details(doctype, filters, fields, assessment_type, assessment) + else: + return frappe._dict( + { + "status": not_attempted, + "result": "Failed", + } + ) + + +def get_assessment_meta(assessment_type: str): if assessment_type == "LMS Assignment": doctype = "LMS Assignment Submission" docfield = "assignment" @@ -1564,39 +1558,30 @@ def has_submitted_assessment(assessment: str, assessment_type: str, member: str fields = ["status"] not_attempted = "Not Attempted" - filters = {} - filters[docfield] = assessment - filters["member"] = member + return doctype, docfield, fields, not_attempted - attempt = frappe.db.exists(doctype, filters) - if attempt: - fields.append("name") - attempt_details = frappe.db.get_value(doctype, filters, fields, as_dict=1) - if assessment_type == "LMS Quiz": - result = "Failed" - passing_percentage = frappe.db.get_value("LMS Quiz", 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 == "LMS Quiz" - else attempt_details.status, - "result": result, - "assessment": assessment, - "type": assessment_type, - "submission": attempt_details.name, - } - ) + +def get_assessment_attempt_details( + doctype: str, filters: dict, fields: list, assessment_type: str, assessment: str +): + fields.append("name") + attempt_details = frappe.db.get_value(doctype, filters, fields, as_dict=1) + if assessment_type == "LMS Quiz": + result = "Failed" + passing_percentage = frappe.db.get_value("LMS Quiz", assessment, "passing_percentage") + if attempt_details.percentage >= passing_percentage: + result = "Pass" else: - return frappe._dict( - { - "status": not_attempted, - "result": "Failed", - } - ) + result = attempt_details.status + return frappe._dict( + { + "status": attempt_details.percentage if assessment_type == "LMS Quiz" else attempt_details.status, + "result": result, + "assessment": assessment, + "type": assessment_type, + "submission": attempt_details.name, + } + ) def can_access_topic(doctype: str, docname: str) -> bool: