@@ -110,6 +115,7 @@
import {
Avatar,
Button,
+ createListResource,
createResource,
FeatherIcon,
ListHeader,
@@ -139,39 +145,47 @@ const props = defineProps({
},
})
-const students = createResource({
- url: 'lms.lms.utils.get_batch_students',
+const studentCount = createResource({
+ url: 'lms.lms.utils.get_batch_student_count',
+ cache: ['batch_student_count', props.batch?.data?.name],
params: {
batch: props.batch?.data?.name,
},
auto: true,
})
-const getStudentColumns = () => {
- let columns = [
- {
- label: 'Full Name',
- key: 'full_name',
- width: '20rem',
- icon: 'user',
- },
- {
- label: 'Progress',
- key: 'progress',
- width: '15rem',
- icon: 'activity',
- },
- {
- label: 'Last Active',
- key: 'last_active',
- width: '10rem',
- align: 'center',
- icon: 'clock',
- },
- ]
+const students = createListResource({
+ doctype: 'LMS Batch Enrollment',
+ url: 'lms.lms.utils.get_batch_students',
+ cache: ['batch_students', props.batch?.data?.name],
+ pageLength: 50,
+ filters: {
+ batch: props.batch?.data?.name,
+ },
+ auto: true,
+})
- return columns
-}
+const studentColumns = [
+ {
+ label: 'Full Name',
+ key: 'full_name',
+ width: '20rem',
+ icon: 'user',
+ },
+ {
+ label: 'Progress',
+ key: 'progress',
+ width: '15rem',
+ icon: 'activity',
+ },
+ {
+ label: 'Last Active',
+ key: 'last_active',
+ width: '10rem',
+ align: 'center',
+ icon: 'clock',
+ },
+]
const openStudentModal = () => {
showStudentModal.value = true
@@ -200,6 +214,7 @@ const removeStudents = (selections, unselectAll) => {
{
onSuccess(data) {
students.reload()
+ studentCount.reload()
props.batch.reload()
toast.success(__('Students deleted successfully'))
unselectAll()
diff --git a/lms/lms/utils.py b/lms/lms/utils.py
index 9bad9eee..998e79c0 100644
--- a/lms/lms/utils.py
+++ b/lms/lms/utils.py
@@ -1354,17 +1354,30 @@ def get_exercise_details(assessment, member):
@frappe.whitelist()
-def get_batch_students(batch):
+def get_batch_students(filters, offset=0, limit_start=0, limit_page_length=None, limit=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 []
+
students = []
students_list = frappe.get_all(
- "LMS Batch Enrollment", filters={"batch": batch}, fields=["member", "name"]
+ "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)
- students = sorted(students, key=lambda x: x.progress, reverse=True)
+
return students
From 0aeada45496ca0ade0d69c531dbc3961548743e8 Mon Sep 17 00:00:00 2001
From: Saqib Ansari
Date: Wed, 14 Jan 2026 14:48:05 +0530
Subject: [PATCH 02/21] refactor: telemetry
* only works with frappe v15.96+
---
frontend/package.json | 2 +-
frontend/src/App.vue | 6 --
frontend/src/components/CourseCardOverlay.vue | 6 +-
.../src/components/Modals/ChapterModal.vue | 4 +-
.../components/Modals/ZoomAccountModal.vue | 3 +
.../Settings/PaymentGatewayDetails.vue | 3 +
.../src/components/Sidebar/AppSidebar.vue | 3 +-
frontend/src/main.js | 2 +
frontend/src/pages/BatchForm.vue | 4 +-
frontend/src/pages/Billing.vue | 3 +
frontend/src/pages/CourseForm.vue | 10 ++-
frontend/src/pages/LessonForm.vue | 6 +-
frontend/src/pages/QuizForm.vue | 5 ++
frontend/src/telemetry.ts | 90 -------------------
frontend/yarn.lock | 8 +-
15 files changed, 40 insertions(+), 115 deletions(-)
delete mode 100644 frontend/src/telemetry.ts
diff --git a/frontend/package.json b/frontend/package.json
index 77e7d608..bead43a6 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -34,7 +34,7 @@
"dayjs": "1.11.10",
"dompurify": "3.2.6",
"feather-icons": "4.28.0",
- "frappe-ui": "^0.1.254",
+ "frappe-ui": "^0.1.256",
"highlight.js": "11.11.1",
"lucide-vue-next": "0.383.0",
"markdown-it": "14.0.0",
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 76464f03..3e352bbb 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -15,7 +15,6 @@ import { useScreenSize } from './utils/composables'
import { usersStore } from '@/stores/user'
import { useSettings } from '@/stores/settings'
import { useRouter } from 'vue-router'
-import { posthogSettings } from '@/telemetry'
import DesktopLayout from './components/DesktopLayout.vue'
import MobileLayout from './components/MobileLayout.vue'
import NoSidebarLayout from './components/NoSidebarLayout.vue'
@@ -50,9 +49,4 @@ onUnmounted(() => {
noSidebar.value = false
})
-watch(userResource, () => {
- if (userResource.data) {
- posthogSettings.reload()
- }
-})
diff --git a/frontend/src/components/CourseCardOverlay.vue b/frontend/src/components/CourseCardOverlay.vue
index d401836b..b01e5fc2 100644
--- a/frontend/src/components/CourseCardOverlay.vue
+++ b/frontend/src/components/CourseCardOverlay.vue
@@ -189,15 +189,16 @@ import {
import { computed, inject, ref } from 'vue'
import { Badge, Button, call, createResource, toast } from 'frappe-ui'
import { formatAmount } from '@/utils/'
-import { capture } from '@/telemetry'
import { useRouter } from 'vue-router'
import CertificationLinks from '@/components/CertificationLinks.vue'
import CourseProgressSummary from '@/components/Modals/CourseProgressSummary.vue'
+import { useTelemetry } from 'frappe-ui/frappe'
const router = useRouter()
const user = inject('$user')
const showProgressModal = ref(false)
const readOnlyMode = window.read_only_mode
+const { capture } = useTelemetry()
const props = defineProps({
course: {
@@ -284,6 +285,9 @@ const certificate = createResource({
}&format=${encodeURIComponent(data.template)}`,
'_blank'
)
+ capture('certificate_issued', {
+ course: props.course.data.name,
+ })
},
})
diff --git a/frontend/src/components/Modals/ChapterModal.vue b/frontend/src/components/Modals/ChapterModal.vue
index 6bc70b2a..409fb200 100644
--- a/frontend/src/components/Modals/ChapterModal.vue
+++ b/frontend/src/components/Modals/ChapterModal.vue
@@ -80,13 +80,13 @@ import {
} from 'frappe-ui'
import { reactive, watch, inject } from 'vue'
import { getFileSize } from '@/utils/'
-import { capture } from '@/telemetry'
import { FileText, X } from 'lucide-vue-next'
-import { useOnboarding } from 'frappe-ui/frappe'
+import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
const show = defineModel()
const outline = defineModel('outline')
const user = inject('$user')
+const { capture } = useTelemetry()
const { updateOnboardingStep } = useOnboarding('learning')
const props = defineProps({
diff --git a/frontend/src/components/Modals/ZoomAccountModal.vue b/frontend/src/components/Modals/ZoomAccountModal.vue
index 5f0bb999..48f986a4 100644
--- a/frontend/src/components/Modals/ZoomAccountModal.vue
+++ b/frontend/src/components/Modals/ZoomAccountModal.vue
@@ -66,6 +66,7 @@ import { inject, reactive, watch } from 'vue'
import { User } from '@/components/Settings/types'
import { openSettings, cleanError } from '@/utils'
import Link from '@/components/Controls/Link.vue'
+import { useTelemetry } from 'frappe-ui/frappe'
interface ZoomAccount {
name: string
@@ -97,6 +98,7 @@ interface ZoomAccounts {
const show = defineModel('show')
const user = inject('$user')
const zoomAccounts = defineModel('zoomAccounts')
+const { capture } = useTelemetry()
const account = reactive({
name: '',
@@ -154,6 +156,7 @@ const createAccount = (close: () => void) => {
},
{
onSuccess() {
+ capture('zoom_account_linked')
zoomAccounts.value?.reload()
close()
toast.success(__('Zoom Account created successfully'))
diff --git a/frontend/src/components/Settings/PaymentGatewayDetails.vue b/frontend/src/components/Settings/PaymentGatewayDetails.vue
index c6392140..2af7d6d4 100644
--- a/frontend/src/components/Settings/PaymentGatewayDetails.vue
+++ b/frontend/src/components/Settings/PaymentGatewayDetails.vue
@@ -52,12 +52,14 @@ import {
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import SettingFields from '@/components/Settings/SettingFields.vue'
+import { useTelemetry } from 'frappe-ui/frappe'
const show = defineModel({ required: true, default: false })
const paymentGateways = defineModel('paymentGateways')
const newGateway = ref(null)
const newGatewayFields = ref([])
const newGatewayData = ref>({})
+const { capture } = useTelemetry()
const props = defineProps<{
gatewayID: string | null
@@ -158,6 +160,7 @@ const saveNewGateway = (close: () => void) => {
...newGatewayData.value,
},
}).then((data: any) => {
+ capture('payment_gateway_configured')
paymentGateways.value.reload()
close()
})
diff --git a/frontend/src/components/Sidebar/AppSidebar.vue b/frontend/src/components/Sidebar/AppSidebar.vue
index fbb95344..f28c0a0f 100644
--- a/frontend/src/components/Sidebar/AppSidebar.vue
+++ b/frontend/src/components/Sidebar/AppSidebar.vue
@@ -199,7 +199,6 @@ import { useSidebar } from '@/stores/sidebar'
import { useSettings } from '@/stores/settings'
import { Button, call, createResource, Tooltip, toast } from 'frappe-ui'
import PageModal from '@/components/Modals/PageModal.vue'
-import { capture } from '@/telemetry'
import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { useRouter } from 'vue-router'
import {
@@ -233,6 +232,7 @@ import {
showHelpModal,
minimize,
IntermediateStepModal,
+ useTelemetry,
} from 'frappe-ui/frappe'
import InviteIcon from '@/components/Icons/InviteIcon.vue'
import UserDropdown from '@/components/Sidebar/UserDropdown.vue'
@@ -246,6 +246,7 @@ let sidebarStore = useSidebar()
const socket = inject('$socket')
const unreadCount = ref(0)
const sidebarLinks = ref(null)
+const { capture } = useTelemetry()
const showPageModal = ref(false)
const isModerator = ref(false)
const isInstructor = ref(false)
diff --git a/frontend/src/main.js b/frontend/src/main.js
index c2968afa..00873dc2 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -9,6 +9,7 @@ import translationPlugin from './translation'
import { usersStore } from './stores/user'
import { initSocket } from './socket'
import { FrappeUI, setConfig, frappeRequest, pageMetaPlugin } from 'frappe-ui'
+import { telemetryPlugin } from 'frappe-ui/frappe'
let pinia = createPinia()
let app = createApp(App)
@@ -18,6 +19,7 @@ app.use(FrappeUI)
app.use(pinia)
app.use(router)
app.use(translationPlugin)
+app.use(telemetryPlugin, { app_name: 'lms' })
app.use(pageMetaPlugin)
app.provide('$dayjs', dayjs)
app.provide('$socket', initSocket())
diff --git a/frontend/src/pages/BatchForm.vue b/frontend/src/pages/BatchForm.vue
index e88b1aa6..8338cb45 100644
--- a/frontend/src/pages/BatchForm.vue
+++ b/frontend/src/pages/BatchForm.vue
@@ -292,8 +292,7 @@ import {
} from 'frappe-ui'
import { useRouter } from 'vue-router'
import { Image, Trash2 } from 'lucide-vue-next'
-import { capture } from '@/telemetry'
-import { useOnboarding } from 'frappe-ui/frappe'
+import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { sessionStore } from '../stores/session'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Link from '@/components/Controls/Link.vue'
@@ -312,6 +311,7 @@ const { brand } = sessionStore()
const { updateOnboardingStep } = useOnboarding('learning')
const instructors = ref([])
const app = getCurrentInstance()
+const { capture } = useTelemetry()
const { $dialog } = app.appContext.config.globalProperties
const props = defineProps({
diff --git a/frontend/src/pages/Billing.vue b/frontend/src/pages/Billing.vue
index 81ed9a5e..00a98a94 100644
--- a/frontend/src/pages/Billing.vue
+++ b/frontend/src/pages/Billing.vue
@@ -234,10 +234,12 @@ import { sessionStore } from '../stores/session'
import Link from '@/components/Controls/Link.vue'
import NotPermitted from '@/components/NotPermitted.vue'
import { X } from 'lucide-vue-next'
+import { useTelemetry } from 'frappe-ui/frappe'
const user = inject('$user')
const { brand } = sessionStore()
const showConsentWarning = ref(false)
+const { capture } = useTelemetry()
onMounted(() => {
const script = document.createElement('script')
@@ -339,6 +341,7 @@ const generatePaymentLink = () => {
return validateAddress()
},
onSuccess(data) {
+ capture('checkout_initiated', { type: props.type })
window.location.href = data
},
onError(err) {
diff --git a/frontend/src/pages/CourseForm.vue b/frontend/src/pages/CourseForm.vue
index 5a412e1c..9a62a2de 100644
--- a/frontend/src/pages/CourseForm.vue
+++ b/frontend/src/pages/CourseForm.vue
@@ -349,8 +349,7 @@ import {
} from 'vue'
import { Image, Trash2, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
-import { capture, startRecording, stopRecording } from '@/telemetry'
-import { useOnboarding } from 'frappe-ui/frappe'
+import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { sessionStore } from '../stores/session'
import {
escapeHTML,
@@ -372,6 +371,7 @@ const router = useRouter()
const instructors = ref([])
const related_courses = ref([])
const app = getCurrentInstance()
+const { capture } = useTelemetry()
const { updateOnboardingStep } = useOnboarding('learning')
const { $dialog } = app.appContext.config.globalProperties
@@ -418,7 +418,6 @@ onMounted(() => {
fetchCourseInfo()
} else {
capture('course_form_opened')
- startRecording()
}
window.addEventListener('keydown', keyboardShortcut)
})
@@ -441,7 +440,6 @@ const keyboardShortcut = (e) => {
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
- stopRecording()
})
const courseCreationResource = createResource({
@@ -582,12 +580,16 @@ const createCourse = () => {
}
const editCourse = () => {
+ let was_published = courseResource.data.published
courseEditResource.submit(
{
course: courseResource.data.name,
},
{
onSuccess() {
+ if (!was_published && course.published) {
+ capture('publish_course')
+ }
updateMetaInfo('courses', props.courseName, meta)
toast.success(__('Course updated successfully'))
},
diff --git a/frontend/src/pages/LessonForm.vue b/frontend/src/pages/LessonForm.vue
index b3a3f8a8..1a79db57 100644
--- a/frontend/src/pages/LessonForm.vue
+++ b/frontend/src/pages/LessonForm.vue
@@ -99,14 +99,14 @@ import EditorJS from '@editorjs/editorjs'
import LessonHelp from '@/components/LessonHelp.vue'
import { ChevronRight } from 'lucide-vue-next'
import { getEditorTools, enablePlyr } from '@/utils'
-import { capture, startRecording, stopRecording } from '@/telemetry'
-import { useOnboarding } from 'frappe-ui/frappe'
+import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
const { brand } = sessionStore()
const editor = ref(null)
const instructorEditor = ref(null)
const user = inject('$user')
const openInstructorEditor = ref(false)
+const { capture } = useTelemetry()
const { updateOnboardingStep } = useOnboarding('learning')
let autoSaveInterval
let showSuccessMessage = false
@@ -131,7 +131,6 @@ onMounted(() => {
window.location.href = '/login'
}
capture('lesson_form_opened')
- startRecording()
editor.value = renderEditor('content')
instructorEditor.value = renderEditor('instructor-notes')
window.addEventListener('keydown', keyboardShortcut)
@@ -226,7 +225,6 @@ const keyboardShortcut = (e) => {
onBeforeUnmount(() => {
clearInterval(autoSaveInterval)
window.removeEventListener('keydown', keyboardShortcut)
- stopRecording()
})
const newLessonResource = createResource({
diff --git a/frontend/src/pages/QuizForm.vue b/frontend/src/pages/QuizForm.vue
index d3e8cc7a..e43e06f8 100644
--- a/frontend/src/pages/QuizForm.vue
+++ b/frontend/src/pages/QuizForm.vue
@@ -233,6 +233,7 @@ import { ClipboardList, ListChecks, Plus, Trash2 } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { escapeHTML } from '@/utils'
import Question from '@/components/Modals/Question.vue'
+import { useTelemetry } from 'frappe-ui/frappe'
const { brand } = sessionStore()
const showQuestionModal = ref(false)
@@ -241,6 +242,7 @@ const currentQuestion = reactive({
marks: 0,
name: '',
})
+const { capture } = useTelemetry()
const user = inject('$user')
const router = useRouter()
const readOnlyMode = window.read_only_mode
@@ -308,6 +310,9 @@ const submitQuiz = () => {
},
{
onSuccess(data) {
+ if (props.quizID === 'new') {
+ capture('quiz_created')
+ }
quizDetails.doc.total_marks = data.total_marks
toast.success(__('Quiz updated successfully'))
},
diff --git a/frontend/src/telemetry.ts b/frontend/src/telemetry.ts
deleted file mode 100644
index eb17613c..00000000
--- a/frontend/src/telemetry.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import '../../../frappe/frappe/public/js/lib/posthog.js'
-import { createResource } from 'frappe-ui'
-
-declare global {
- interface Window {
- posthog: any
- }
-}
-
-type PosthogSettings = {
- posthog_project_id: string
- posthog_host: string
- enable_telemetry: boolean
- telemetry_site_age: number
-}
-
-interface CaptureOptions {
- data: {
- user: string
- [key: string]: string | number | boolean | object
- }
-}
-
-let posthog: typeof window.posthog = window.posthog
-
-// Posthog Settings
-let posthogSettings = createResource({
- url: 'lms.lms.telemetry.get_posthog_settings',
- cache: 'posthog_settings',
- onSuccess: (ps: PosthogSettings) => initPosthog(ps),
-})
-
-let isTelemetryEnabled = () => {
- if (!posthogSettings.data) return false
-
- return (
- posthogSettings.data.enable_telemetry &&
- posthogSettings.data.posthog_project_id &&
- posthogSettings.data.posthog_host
- )
-}
-
-// Posthog Initialization
-function initPosthog(ps: PosthogSettings) {
- if (!isTelemetryEnabled()) return
-
- posthog.init(ps.posthog_project_id, {
- api_host: ps.posthog_host,
- person_profiles: 'identified_only',
- autocapture: false,
- capture_pageview: true,
- capture_pageleave: true,
- enable_heatmaps: false,
- disable_session_recording: false,
- loaded: (ph: typeof posthog) => {
- window.posthog = ph
- ph.identify(window.location.hostname)
- },
- })
-}
-
-// Posthog Functions
-function capture(
- event: string,
- options: CaptureOptions = { data: { user: '' } },
-) {
- if (!isTelemetryEnabled()) return
- window.posthog.capture(`lms_${event}`, options)
-}
-
-function startRecording() {
-}
-
-function stopRecording() {
-}
-
-// Posthog Plugin
-function posthogPlugin(app: any) {
- app.config.globalProperties.posthog = posthog
- if (!window.posthog?.length) posthogSettings.fetch()
-}
-
-export {
- posthog,
- posthogSettings,
- posthogPlugin,
- capture,
- startRecording,
- stopRecording,
-}
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 95621ac6..c3ca947a 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -2977,10 +2977,10 @@ fraction.js@^4.1.2:
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
-frappe-ui@^0.1.254:
- version "0.1.254"
- resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.254.tgz#a0edd00a8a87e1f15e01c4526089c82e32e8630d"
- integrity sha512-qdf7h8S6mwwajQMC6p+M11qKB0gVtNKnPTphK2nzrG9M8vxND6t0/8D6LVMX/Tqv/gpQJg7FI7V2hi+p8bT0OQ==
+frappe-ui@^0.1.256:
+ version "0.1.256"
+ resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.256.tgz#c14756eda75ca01ada034559e8bd2f91bcfe6dff"
+ integrity sha512-zj8n6KXpMv/0h1NcaCsjFLP8QBnofDEBJgQa+xECU0/jbq4gSqNhFOkcx788qNL+vmBo9frywTeXwDpl7hUCZA==
dependencies:
"@floating-ui/vue" "^1.1.6"
"@headlessui/vue" "^1.7.14"
From a0ede1dd2ac2d2565f68b0322e5c49bfed3d78ee Mon Sep 17 00:00:00 2001
From: Saqib Ansari
Date: Wed, 14 Jan 2026 14:55:28 +0530
Subject: [PATCH 03/21] fix: run pre-commit
---
frontend/src/App.vue | 1 -
1 file changed, 1 deletion(-)
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 3e352bbb..8edca776 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -48,5 +48,4 @@ const Layout = computed(() => {
onUnmounted(() => {
noSidebar.value = false
})
-
From c59be28a265558d12fd9611e233c0e30a28d08bd Mon Sep 17 00:00:00 2001
From: raizasafeel <89463672+raizasafeel@users.noreply.github.com>
Date: Wed, 14 Jan 2026 14:08:04 +0530
Subject: [PATCH 04/21] perf(batch): optimise dashboard with query builder
---
.../src/components/AdminBatchDashboard.vue | 104 +++++----------
lms/lms/utils.py | 119 +++++++++++++++++-
2 files changed, 146 insertions(+), 77 deletions(-)
diff --git a/frontend/src/components/AdminBatchDashboard.vue b/frontend/src/components/AdminBatchDashboard.vue
index 934ab995..947180c5 100644
--- a/frontend/src/components/AdminBatchDashboard.vue
+++ b/frontend/src/components/AdminBatchDashboard.vue
@@ -8,7 +8,7 @@
@@ -37,7 +37,7 @@
v-if="showProgressChart"
class="border"
:config="{
- data: chartData || [],
+ data: filteredChartData,
title: __('Batch Summary'),
subtitle: __('Progress of students in courses and assessments'),
xAxis: {
@@ -64,96 +64,50 @@
diff --git a/lms/lms/utils.py b/lms/lms/utils.py
index 998e79c0..0acb2565 100644
--- a/lms/lms/utils.py
+++ b/lms/lms/utils.py
@@ -24,6 +24,8 @@ from frappe.utils import (
pretty_date,
rounded,
)
+from pypika import Case
+from pypika import functions as fn
from lms.lms.md import find_macros
@@ -1381,6 +1383,116 @@ def get_batch_students(filters, offset=0, limit_start=0, limit_page_length=None,
return students
+@frappe.whitelist()
+def get_batch_student_count(batch):
+ if not frappe.db.exists("LMS Batch", batch):
+ frappe.throw(_("The specified batch does not exist."))
+ return frappe.db.count("LMS Batch Enrollment", filters={"batch": batch})
+
+
+@frappe.whitelist()
+def get_batch_certificate_count(batch):
+ if not frappe.db.exists("LMS Batch", batch):
+ frappe.throw(_("The specified batch does not exist."))
+ return frappe.db.count("LMS Certificate", filters={"batch_name": batch})
+
+
+@frappe.whitelist()
+def get_batch_assessment_count(batch):
+ if not frappe.db.exists("LMS Batch", batch):
+ frappe.throw(_("The specified batch does not exist."))
+ return frappe.db.count("LMS Assessment", filters={"parent": batch})
+
+
+def get_course_completion_stats(batch):
+ """Get completion counts per course in batch"""
+ BatchCourse = frappe.qb.DocType("Batch Course")
+ BatchEnrollment = frappe.qb.DocType("LMS Batch Enrollment")
+ Enrollment = frappe.qb.DocType("LMS Enrollment")
+
+ rows = (
+ frappe.qb.from_(BatchCourse)
+ .left_join(BatchEnrollment)
+ .on(BatchEnrollment.batch == BatchCourse.parent)
+ .left_join(Enrollment)
+ .on((Enrollment.course == BatchCourse.course) & (Enrollment.member == BatchEnrollment.member))
+ .where(BatchCourse.parent == batch)
+ .groupby(BatchCourse.course, BatchCourse.title)
+ .select(
+ BatchCourse.title,
+ fn.Count(Case().when(Enrollment.progress == 100, Enrollment.member)).distinct().as_("completed"),
+ )
+ ).run(as_dict=True)
+
+ return [{"task": row.title, "value": row.completed or 0} for row in rows]
+
+
+def get_assignment_pass_stats(batch):
+ """Get pass counts per assignment in batch"""
+ Assessment = frappe.qb.DocType("LMS Assessment")
+ Assignment = frappe.qb.DocType("LMS Assignment")
+ BatchEnrollment = frappe.qb.DocType("LMS Batch Enrollment")
+ Submission = frappe.qb.DocType("LMS Assignment Submission")
+
+ rows = (
+ frappe.qb.from_(Assessment)
+ .join(Assignment)
+ .on(Assignment.name == Assessment.assessment_name)
+ .left_join(BatchEnrollment)
+ .on(BatchEnrollment.batch == Assessment.parent)
+ .left_join(Submission)
+ .on(
+ (Submission.assignment == Assessment.assessment_name)
+ & (Submission.member == BatchEnrollment.member)
+ )
+ .where((Assessment.parent == batch) & (Assessment.assessment_type == "LMS Assignment"))
+ .groupby(Assessment.assessment_name, Assignment.title)
+ .select(
+ Assignment.title,
+ fn.Count(Case().when(Submission.status == "Pass", Submission.member)).distinct().as_("passed"),
+ )
+ ).run(as_dict=True)
+
+ return [{"task": row.title, "value": row.passed or 0} for row in rows]
+
+
+def get_quiz_pass_stats(batch):
+ """Get pass counts per quiz in batch"""
+ Assessment = frappe.qb.DocType("LMS Assessment")
+ Quiz = frappe.qb.DocType("LMS Quiz")
+ BatchEnrollment = frappe.qb.DocType("LMS Batch Enrollment")
+ Submission = frappe.qb.DocType("LMS Quiz Submission")
+
+ rows = (
+ frappe.qb.from_(Assessment)
+ .join(Quiz)
+ .on(Quiz.name == Assessment.assessment_name)
+ .left_join(BatchEnrollment)
+ .on(BatchEnrollment.batch == Assessment.parent)
+ .left_join(Submission)
+ .on((Submission.quiz == Assessment.assessment_name) & (Submission.member == BatchEnrollment.member))
+ .where((Assessment.parent == batch) & (Assessment.assessment_type == "LMS Quiz"))
+ .groupby(Assessment.assessment_name, Quiz.title)
+ .select(
+ Quiz.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):
+ """Get completion counts per course and assessment"""
+ if not frappe.db.exists("LMS Batch", batch):
+ frappe.throw(_("The specified batch does not exist."))
+
+ return get_course_completion_stats(batch) + get_assignment_pass_stats(batch) + get_quiz_pass_stats(batch)
+
+
def get_batch_student_details(student):
details = frappe.db.get_value(
"User",
@@ -1423,8 +1535,11 @@ def calculate_course_progress(batch_courses, details):
details.courses = frappe._dict()
for course in batch_courses:
- progress = frappe.db.get_value(
- "LMS Enrollment", {"course": course.course, "member": details.email}, "progress"
+ progress = (
+ frappe.db.get_value(
+ "LMS Enrollment", {"course": course.course, "member": details.email}, "progress"
+ )
+ or 0
)
details.courses[course.title] = progress
course_progress.append(progress)
From e7ccf0a711d0e0ab5e6b28b7a1e4e0510b6b9543 Mon Sep 17 00:00:00 2001
From: Jannat Patel
Date: Wed, 14 Jan 2026 17:54:23 +0530
Subject: [PATCH 05/21] fix: sanitize image filename before saving for course
and jobs
---
frontend/src/components/Controls/Uploader.vue | 2 +-
frontend/src/pages/BatchForm.vue | 17 ++--
frontend/src/pages/CourseForm.vue | 98 +++----------------
frontend/src/pages/JobForm.vue | 79 ++-------------
4 files changed, 33 insertions(+), 163 deletions(-)
diff --git a/frontend/src/components/Controls/Uploader.vue b/frontend/src/components/Controls/Uploader.vue
index 73643892..8c83ff4c 100644
--- a/frontend/src/components/Controls/Uploader.vue
+++ b/frontend/src/components/Controls/Uploader.vue
@@ -70,7 +70,7 @@ const props = withDefaults(
modelValue: string
label?: string
description?: string
- type: 'image' | 'video'
+ type?: 'image' | 'video'
required?: boolean
}>(),
{
diff --git a/frontend/src/pages/BatchForm.vue b/frontend/src/pages/BatchForm.vue
index e88b1aa6..1a694f3a 100644
--- a/frontend/src/pages/BatchForm.vue
+++ b/frontend/src/pages/BatchForm.vue
@@ -281,22 +281,13 @@ import {
import {
Breadcrumbs,
FormControl,
- FileUploader,
Button,
TextEditor,
createResource,
usePageMeta,
toast,
call,
- Toast,
} from 'frappe-ui'
-import { useRouter } from 'vue-router'
-import { Image, Trash2 } from 'lucide-vue-next'
-import { capture } from '@/telemetry'
-import { useOnboarding } from 'frappe-ui/frappe'
-import { sessionStore } from '../stores/session'
-import MultiSelect from '@/components/Controls/MultiSelect.vue'
-import Link from '@/components/Controls/Link.vue'
import {
escapeHTML,
getMetaInfo,
@@ -304,7 +295,14 @@ import {
sanitizeHTML,
updateMetaInfo,
} from '@/utils'
+import { useRouter } from 'vue-router'
+import { Trash2 } from 'lucide-vue-next'
+import { capture } from '@/telemetry'
+import { useOnboarding } from 'frappe-ui/frappe'
+import { sessionStore } from '../stores/session'
import Uploader from '@/components/Controls/Uploader.vue'
+import MultiSelect from '@/components/Controls/MultiSelect.vue'
+import Link from '@/components/Controls/Link.vue'
const router = useRouter()
const user = inject('$user')
@@ -466,6 +464,7 @@ const validateFields = () => {
!['description', 'batch_details'].includes(key) &&
typeof batch[key] === 'string'
) {
+ console.log(key)
batch[key] = escapeHTML(batch[key])
}
})
diff --git a/frontend/src/pages/CourseForm.vue b/frontend/src/pages/CourseForm.vue
index 5a412e1c..339d35d3 100644
--- a/frontend/src/pages/CourseForm.vue
+++ b/frontend/src/pages/CourseForm.vue
@@ -75,58 +75,11 @@