Merge pull request #2054 from pateljannat/issues-178
feat: student progress in course dashboard
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
ignore:
|
||||||
|
- "**/test_helper.py"
|
||||||
Submodule
+1
Submodule frappe-semgrep-rules added at 239029b7eb
@@ -99,18 +99,17 @@
|
|||||||
name="item-label"
|
name="item-label"
|
||||||
v-bind="{ active, selected, option }"
|
v-bind="{ active, selected, option }"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col space-y-1 text-ink-gray-8">
|
<div class="flex flex-col gap-1 p-1">
|
||||||
<div>
|
<div class="text-base font-medium text-ink-gray-8">
|
||||||
{{ option.label }}
|
{{
|
||||||
|
option.value == option.label
|
||||||
|
? option.description
|
||||||
|
: option.label
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-ink-gray-5">
|
||||||
|
{{ option.value }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
option.description &&
|
|
||||||
option.description != option.label
|
|
||||||
"
|
|
||||||
class="text-xs text-ink-gray-7"
|
|
||||||
v-html="option.description"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
</slot>
|
</slot>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -112,6 +112,14 @@
|
|||||||
v-else-if="lesson.icon === 'icon-quiz'"
|
v-else-if="lesson.icon === 'icon-quiz'"
|
||||||
class="h-4 w-4 stroke-1 mr-2"
|
class="h-4 w-4 stroke-1 mr-2"
|
||||||
/>
|
/>
|
||||||
|
<NotebookPen
|
||||||
|
v-else-if="lesson.icon === 'icon-assignment'"
|
||||||
|
class="h-4 w-4 stroke-1 mr-2"
|
||||||
|
/>
|
||||||
|
<SquareCode
|
||||||
|
v-else-if="lesson.icon === 'icon-code'"
|
||||||
|
class="h-4 w-4 stroke-1 mr-2"
|
||||||
|
/>
|
||||||
<FileText
|
<FileText
|
||||||
v-else-if="lesson.icon === 'icon-list'"
|
v-else-if="lesson.icon === 'icon-list'"
|
||||||
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
|
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
|
||||||
@@ -177,8 +185,11 @@ import {
|
|||||||
FilePenLine,
|
FilePenLine,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
MonitorPlay,
|
MonitorPlay,
|
||||||
|
NotebookPen,
|
||||||
Plus,
|
Plus,
|
||||||
|
SquareCode,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Notebook,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<Dialog
|
<Dialog
|
||||||
v-model="show"
|
v-model="show"
|
||||||
:options="{
|
:options="{
|
||||||
title: __('Add a Student'),
|
title: __('Enroll a Student'),
|
||||||
size: 'sm',
|
size: 'sm',
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
@@ -19,9 +19,24 @@
|
|||||||
doctype="User"
|
doctype="User"
|
||||||
v-model="student"
|
v-model="student"
|
||||||
:filters="{ ignore_user_type: 1 }"
|
:filters="{ ignore_user_type: 1 }"
|
||||||
|
placeholder=" "
|
||||||
|
:label="__('Student')"
|
||||||
:onCreate="
|
:onCreate="
|
||||||
(value, close) => {
|
() => {
|
||||||
openSettings('Members', close)
|
openSettings('Members')
|
||||||
|
show = false
|
||||||
|
}
|
||||||
|
"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
doctype="LMS Payment"
|
||||||
|
v-model="payment"
|
||||||
|
placeholder=" "
|
||||||
|
:label="__('Payment')"
|
||||||
|
:onCreate="
|
||||||
|
() => {
|
||||||
|
openSettings('Transactions')
|
||||||
show = false
|
show = false
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
@@ -31,15 +46,16 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, createResource, toast } from 'frappe-ui'
|
import { call, Dialog, toast } from 'frappe-ui'
|
||||||
import { ref, inject } from 'vue'
|
import { ref, inject } from 'vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
import { openSettings } from '@/utils'
|
import { openSettings } from '@/utils'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
const students = defineModel('reloadStudents')
|
const students = defineModel('reloadStudents')
|
||||||
const batchModal = defineModel('batchModal')
|
const batchModal = defineModel('batchModal')
|
||||||
const student = ref()
|
const student = ref(null)
|
||||||
|
const payment = ref(null)
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const { updateOnboardingStep } = useOnboarding('learning')
|
const { updateOnboardingStep } = useOnboarding('learning')
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
@@ -51,36 +67,28 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const studentResource = createResource({
|
|
||||||
url: 'frappe.client.insert',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doc: {
|
|
||||||
doctype: 'LMS Batch Enrollment',
|
|
||||||
batch: props.batch,
|
|
||||||
member: student.value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const addStudent = (close) => {
|
const addStudent = (close) => {
|
||||||
studentResource.submit(
|
call('frappe.client.insert', {
|
||||||
{},
|
doc: {
|
||||||
{
|
doctype: 'LMS Batch Enrollment',
|
||||||
onSuccess() {
|
batch: props.batch,
|
||||||
if (user.data?.is_system_manager)
|
member: student.value,
|
||||||
updateOnboardingStep('add_batch_student')
|
payment: payment.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (user.data?.is_system_manager)
|
||||||
|
updateOnboardingStep('add_batch_student')
|
||||||
|
|
||||||
students.value.reload()
|
students.value.reload()
|
||||||
batchModal.value.reload()
|
batchModal.value.reload()
|
||||||
student.value = null
|
student.value = null
|
||||||
close()
|
payment.value = null
|
||||||
},
|
close()
|
||||||
onError(err) {
|
})
|
||||||
toast.error(err.messages?.[0] || err)
|
.catch((err) => {
|
||||||
},
|
toast.error(err.messages?.[0] || err)
|
||||||
}
|
console.error(err)
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<slot name="prefix" />
|
<slot name="prefix" />
|
||||||
<div class="font-semibold text-2xl">
|
<div class="font-semibold text-ink-gray-9 text-2xl">
|
||||||
{{ value }}
|
{{ value }}
|
||||||
</div>
|
</div>
|
||||||
<slot name="suffix" />
|
<slot name="suffix" />
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="flex items-center text-sm space-x-2">
|
<div class="flex items-center text-sm space-x-2">
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2"
|
class="flex items-center justify-center rounded border border-outline-gray-modals bg-surface-gray-2"
|
||||||
:class="field.size == 'lg' ? 'px-5 py-5' : 'px-20 py-8'"
|
:class="field.size == 'lg' ? 'px-5 py-5' : 'px-20 py-8'"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<X
|
<X
|
||||||
@click="data[field.name] = null"
|
@click="data[field.name] = null"
|
||||||
class="border text-ink-gray-7 border-outline-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
class="border text-ink-gray-7 border-outline-gray-modals rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<div class="grid grid-cols-[2fr_1fr] gap-5 items-start">
|
<div class="grid grid-cols-[2fr_1fr] gap-5 items-start">
|
||||||
<div v-if="course.data?.enrollments" class="border rounded-lg py-3 px-4">
|
<div v-if="course.data?.enrollments" class="border rounded-lg py-3 px-4">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<div class="text-lg font-semibold">
|
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||||
{{ __('Students') }}
|
{{ __('Students') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
@@ -63,50 +63,52 @@
|
|||||||
</ListHeaderItem>
|
</ListHeaderItem>
|
||||||
</ListHeader>
|
</ListHeader>
|
||||||
<ListRows v-for="row in progressList.data" class="max-h-[500px]">
|
<ListRows v-for="row in progressList.data" class="max-h-[500px]">
|
||||||
<router-link
|
<ListRow
|
||||||
:to="{
|
:row="row"
|
||||||
name: 'Profile',
|
@click="
|
||||||
params: { username: row.member_username },
|
() => {
|
||||||
}"
|
showProgressModal = true
|
||||||
|
currentStudent = row
|
||||||
|
}
|
||||||
|
"
|
||||||
|
class="cursor-pointer"
|
||||||
>
|
>
|
||||||
<ListRow :row="row">
|
<template #default="{ column, item }">
|
||||||
<template #default="{ column, item }">
|
<ListRowItem
|
||||||
<ListRowItem
|
:item="row[column.key]"
|
||||||
:item="row[column.key]"
|
:align="column.align"
|
||||||
:align="column.align"
|
class="w-full"
|
||||||
class="w-full"
|
>
|
||||||
>
|
<template #prefix>
|
||||||
<template #prefix>
|
<div v-if="column.key == 'member_name'">
|
||||||
<div v-if="column.key == 'member_name'">
|
<Avatar
|
||||||
<Avatar
|
class="flex items-center"
|
||||||
class="flex items-center"
|
:image="row['member_image']"
|
||||||
:image="row['member_image']"
|
:label="item"
|
||||||
:label="item"
|
size="sm"
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ProgressBar
|
|
||||||
v-else-if="column.key == 'progress'"
|
|
||||||
:progress="Math.ceil(row[column.key])"
|
|
||||||
class="!mx-0 !mr-4"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
|
||||||
<div v-if="column.key == 'creation'">
|
|
||||||
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<ProgressBar
|
||||||
v-else-if="column.key == 'progress'"
|
v-else-if="column.key == 'progress'"
|
||||||
class="text-xs !mx-0 w-5"
|
:progress="Math.ceil(row[column.key])"
|
||||||
>
|
class="!mx-0 !mr-4"
|
||||||
{{ Math.ceil(row[column.key]) }}%
|
/>
|
||||||
</div>
|
</template>
|
||||||
<div v-else>
|
<div v-if="column.key == 'creation'">
|
||||||
{{ row[column.key].toString() }}
|
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
|
||||||
</div>
|
</div>
|
||||||
</ListRowItem>
|
<div
|
||||||
</template>
|
v-else-if="column.key == 'progress'"
|
||||||
</ListRow>
|
class="text-xs !mx-0 w-5"
|
||||||
</router-link>
|
>
|
||||||
|
{{ Math.ceil(row[column.key]) }}%
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
{{ row[column.key].toString() }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
</ListRows>
|
</ListRows>
|
||||||
</ListView>
|
</ListView>
|
||||||
<div
|
<div
|
||||||
@@ -130,7 +132,7 @@
|
|||||||
<div class="grid grid-cols-[2fr_1fr] items-center justify-between">
|
<div class="grid grid-cols-[2fr_1fr] items-center justify-between">
|
||||||
<div class="flex flex-col space-y-4 flex-1 text-sm">
|
<div class="flex flex-col space-y-4 flex-1 text-sm">
|
||||||
<div
|
<div
|
||||||
class="flex items-center"
|
class="flex items-center text-ink-gray-7"
|
||||||
v-for="row in chartDetails.data?.progress_distribution"
|
v-for="row in chartDetails.data?.progress_distribution"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -142,6 +144,8 @@
|
|||||||
? 'red'
|
? 'red'
|
||||||
: row.name.startsWith('In')
|
: row.name.startsWith('In')
|
||||||
? 'amber'
|
? 'amber'
|
||||||
|
: row.name.startsWith('Adv')
|
||||||
|
? 'blue'
|
||||||
: 'green'
|
: 'green'
|
||||||
][400],
|
][400],
|
||||||
}"
|
}"
|
||||||
@@ -151,11 +155,13 @@
|
|||||||
{{ row.name.split('(')[0] }}
|
{{ row.name.split('(')[0] }}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<div class="ml-auto">
|
<Tooltip :text="row.value">
|
||||||
{{
|
<div class="ml-auto">
|
||||||
Math.round((row.value / course.data?.enrollments) * 100)
|
{{
|
||||||
}}%
|
Math.round((row.value / course.data?.enrollments) * 100)
|
||||||
</div>
|
}}%
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ECharts
|
<ECharts
|
||||||
@@ -205,7 +211,7 @@
|
|||||||
class="!w-32"
|
class="!w-32"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y max-h-[43vh] overflow-y-auto">
|
<div class="divide-y max-h-[43vh] text-ink-gray-7 overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
v-for="progress in lessonProgress.data"
|
v-for="progress in lessonProgress.data"
|
||||||
class="flex justify-between text-sm py-2 my-1"
|
class="flex justify-between text-sm py-2 my-1"
|
||||||
@@ -239,6 +245,13 @@
|
|||||||
v-model="showEnrollmentModal"
|
v-model="showEnrollmentModal"
|
||||||
:course="course"
|
:course="course"
|
||||||
/>
|
/>
|
||||||
|
<StudentCourseProgress
|
||||||
|
v-if="showProgressModal"
|
||||||
|
v-model="showProgressModal"
|
||||||
|
:course="course"
|
||||||
|
:student="currentStudent"
|
||||||
|
:lessons="lessonProgress"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
@@ -260,12 +273,13 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ChevronDown, Plus, Star } from 'lucide-vue-next'
|
import { Plus, Star } from 'lucide-vue-next'
|
||||||
import { formatAmount } from '@/utils'
|
import { formatAmount } from '@/utils'
|
||||||
import colors from '@/utils/frappe-ui-colors.json'
|
import colors from '@/utils/frappe-ui-colors.json'
|
||||||
import CourseEnrollmentModal from '@/pages/Courses/CourseEnrollmentModal.vue'
|
import CourseEnrollmentModal from '@/pages/Courses/CourseEnrollmentModal.vue'
|
||||||
import NumberChartGraph from '@/components/NumberChartGraph.vue'
|
import NumberChartGraph from '@/components/NumberChartGraph.vue'
|
||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
|
import StudentCourseProgress from '@/pages/Courses/StudentCourseProgress.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
course: any
|
course: any
|
||||||
@@ -273,6 +287,8 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const showEnrollmentModal = ref(false)
|
const showEnrollmentModal = ref(false)
|
||||||
const searchFilter = ref<string | null>(null)
|
const searchFilter = ref<string | null>(null)
|
||||||
|
const showProgressModal = ref(false)
|
||||||
|
const currentStudent = ref<any>(null)
|
||||||
const theme = ref<'darkMode' | 'lightMode'>(
|
const theme = ref<'darkMode' | 'lightMode'>(
|
||||||
localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
|
localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
|
||||||
)
|
)
|
||||||
@@ -307,6 +323,7 @@ const progressList = createListResource({
|
|||||||
],
|
],
|
||||||
pageLength: 100,
|
pageLength: 100,
|
||||||
auto: true,
|
auto: true,
|
||||||
|
cache: ['courseProgress', props.course.data?.name],
|
||||||
})
|
})
|
||||||
|
|
||||||
const lessonProgress = createResource({
|
const lessonProgress = createResource({
|
||||||
@@ -357,6 +374,7 @@ const progressColors = computed(() => {
|
|||||||
let colorList = []
|
let colorList = []
|
||||||
colorList.push(colors[theme.value]['red'][400])
|
colorList.push(colors[theme.value]['red'][400])
|
||||||
colorList.push(colors[theme.value]['amber'][400])
|
colorList.push(colors[theme.value]['amber'][400])
|
||||||
|
colorList.push(colors[theme.value]['blue'][400])
|
||||||
colorList.push(colors[theme.value]['green'][400])
|
colorList.push(colors[theme.value]['green'][400])
|
||||||
return colorList
|
return colorList
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,8 +19,7 @@
|
|||||||
placeholder=" "
|
placeholder=" "
|
||||||
v-model="student"
|
v-model="student"
|
||||||
:required="true"
|
:required="true"
|
||||||
:allowCreate="true"
|
:onCreate="
|
||||||
@create="
|
|
||||||
() => {
|
() => {
|
||||||
openSettings('Members')
|
openSettings('Members')
|
||||||
show = false
|
show = false
|
||||||
@@ -33,8 +32,7 @@
|
|||||||
:label="__('Payment')"
|
:label="__('Payment')"
|
||||||
placeholder=" "
|
placeholder=" "
|
||||||
v-model="payment"
|
v-model="payment"
|
||||||
:allowCreate="true"
|
:onCreate="
|
||||||
@create="
|
|
||||||
() => {
|
() => {
|
||||||
openSettings('Transactions')
|
openSettings('Transactions')
|
||||||
show = false
|
show = false
|
||||||
@@ -54,9 +52,9 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
|
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
|
||||||
import { Link } from 'frappe-ui/frappe'
|
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { openSettings } from '@/utils'
|
import { openSettings } from '@/utils'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
const show = defineModel<boolean>({ required: true, default: false })
|
const show = defineModel<boolean>({ required: true, default: false })
|
||||||
const student = ref<string | null>(null)
|
const student = ref<string | null>(null)
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Student Progress'),
|
||||||
|
size: hasAssessmentData ? '3xl' : 'xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="text-base text-ink-gray-9 max-h-[70vh] overflow-y-auto">
|
||||||
|
<div class="flex justify-between mb-5 px-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Avatar
|
||||||
|
:image="student?.member_image"
|
||||||
|
:label="student?.member_name"
|
||||||
|
size="xl"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ student?.member_name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-ink-gray-5">
|
||||||
|
{{ student.member }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-25 space-y-2">
|
||||||
|
<div class="text-ink-gray-5 text-sm">
|
||||||
|
{{ Math.round(student.progress) }}% {{ __('completed') }}
|
||||||
|
</div>
|
||||||
|
<ProgressBar
|
||||||
|
:label="__('Course Progress')"
|
||||||
|
:progress="student.progress"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5" :class="hasAssessmentData ? 'grid-cols-2' : ''">
|
||||||
|
<div
|
||||||
|
v-if="lessons.data"
|
||||||
|
class="border border-outline-gray-modals rounded-lg px-3 pt-3 max-h-[60vh] overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="text-ink-gray-5 mb-5">
|
||||||
|
{{ __('Lesson Progress') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="progress in lessons.data"
|
||||||
|
class="flex justify-between text-sm py-2 my-1"
|
||||||
|
>
|
||||||
|
<div class="">
|
||||||
|
<span class="mr-3 text-xs">
|
||||||
|
{{ progress.chapter_idx }}.{{ progress.idx }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{ progress.title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Tooltip
|
||||||
|
v-if="getLessonStatus(progress) == 'Complete'"
|
||||||
|
:text="__('Complete')"
|
||||||
|
>
|
||||||
|
<Check class="text-ink-green-3 size-4" />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip v-else :text="__('Pending')">
|
||||||
|
<Minus class="text-ink-amber-2 size-4" />
|
||||||
|
</Tooltip>
|
||||||
|
<!-- <Badge :theme="getLessonStatusTheme(progress)">
|
||||||
|
{{ getLessonStatus(progress) }}
|
||||||
|
</Badge> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-if="assessmentProgress.data?.quizzes?.length"
|
||||||
|
class="border border-outline-gray-modals rounded-lg px-3 pt-3 h-fit"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="text-ink-gray-5 mb-5">
|
||||||
|
{{ __('Quiz Progress') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="quiz in assessmentProgress.data.quizzes"
|
||||||
|
class="flex justify-between text-sm py-2 my-1"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{{ quiz.quiz_title }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ quiz.score }}
|
||||||
|
</div>
|
||||||
|
<div>{{ quiz.percentage }}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="assessmentProgress.data?.assignments?.length"
|
||||||
|
class="border border-outline-gray-modals rounded-lg px-3 pt-3 h-fit"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="text-ink-gray-5 mb-5">
|
||||||
|
{{ __('Assignment Progress') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="assignment in assessmentProgress.data.assignments"
|
||||||
|
class="flex justify-between text-sm py-2 my-1"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{{ assignment.assignment_title }}
|
||||||
|
</div>
|
||||||
|
<Badge :theme="getAssessmentStatusTheme(assignment.status)">
|
||||||
|
{{ assignment.status }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="assessmentProgress.data?.exercises?.length"
|
||||||
|
class="border border-outline-gray-modals rounded-lg px-3 pt-3 h-fit"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="text-ink-gray-5 mb-5">
|
||||||
|
{{ __('Programming Exercise Progress') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="exercise in assessmentProgress.data.exercises"
|
||||||
|
class="flex justify-between text-sm py-2 my-1"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{{ exercise.exercise_title }}
|
||||||
|
</div>
|
||||||
|
<Badge :theme="getAssessmentStatusTheme(exercise.status)">
|
||||||
|
{{ exercise.status }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
createListResource,
|
||||||
|
createResource,
|
||||||
|
Dialog,
|
||||||
|
Tooltip,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Check, Minus } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const show = defineModel<boolean>({ required: true, default: false })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
course: any
|
||||||
|
student: any
|
||||||
|
lessons: any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const lessonProgress = createListResource({
|
||||||
|
doctype: 'LMS Course Progress',
|
||||||
|
filters: {
|
||||||
|
course: ['=', props.course.data?.name],
|
||||||
|
member: ['=', props.student?.member],
|
||||||
|
},
|
||||||
|
fields: ['name', 'lesson', 'status'],
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const assessmentProgress = createResource({
|
||||||
|
url: 'lms.lms.api.get_course_assessment_progress',
|
||||||
|
params: {
|
||||||
|
course: props.course.data?.name,
|
||||||
|
member: props.student?.member,
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const getLessonStatus = (lesson: any) => {
|
||||||
|
return (
|
||||||
|
lessonProgress.data?.find((lp: any) => lp.lesson === lesson.lesson)
|
||||||
|
?.status || __('Pending')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLessonStatusTheme = (lesson: any) => {
|
||||||
|
const status = getLessonStatus(lesson)
|
||||||
|
if (status === 'Complete') {
|
||||||
|
return 'green'
|
||||||
|
} else {
|
||||||
|
return 'orange'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAssessmentStatusTheme = (status: string) => {
|
||||||
|
if (status.includes('Pass')) return 'green'
|
||||||
|
else if (status.includes('Fail')) return 'red'
|
||||||
|
else return 'orange'
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAssessmentData = computed(() => {
|
||||||
|
return (
|
||||||
|
(assessmentProgress.data?.quizzes &&
|
||||||
|
assessmentProgress.data.quizzes.length > 0) ||
|
||||||
|
(assessmentProgress.data?.assignments &&
|
||||||
|
assessmentProgress.data.assignments.length > 0) ||
|
||||||
|
(assessmentProgress.data?.exercises &&
|
||||||
|
assessmentProgress.data.exercises.length > 0)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="myCourses.data?.length" class="mt-10">
|
<div v-if="myCourses.data?.length">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<span class="font-semibold text-lg text-ink-gray-9">
|
<span class="font-semibold text-lg text-ink-gray-9">
|
||||||
{{
|
{{
|
||||||
|
|||||||
@@ -513,7 +513,8 @@ const getSidebarItems = () => {
|
|||||||
: settings.data?.contact_us_email,
|
: settings.data?.contact_us_email,
|
||||||
condition: () => {
|
condition: () => {
|
||||||
return (
|
return (
|
||||||
settings?.data?.contact_us_email ||
|
(settings?.data?.contact_us_email &&
|
||||||
|
userResource?.data) ||
|
||||||
settings?.data?.contact_us_url
|
settings?.data?.contact_us_url
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ import frappe
|
|||||||
from . import __version__ as app_version
|
from . import __version__ as app_version
|
||||||
|
|
||||||
app_name = "frappe_lms"
|
app_name = "frappe_lms"
|
||||||
app_title = "Frappe LMS"
|
app_title = "Learning"
|
||||||
app_publisher = "Frappe"
|
app_publisher = "Frappe"
|
||||||
app_description = "Frappe LMS App"
|
app_description = "Frappe LMS App"
|
||||||
app_icon_url = "/assets/lms/images/lms-logo.png"
|
app_icon_url = "/assets/lms/images/lms-logo.png"
|
||||||
|
|||||||
+139
-9
@@ -1654,8 +1654,12 @@ def get_progress_distribution(progressList):
|
|||||||
"value": len([p for p in progressList if 30 <= p < 60]),
|
"value": len([p for p in progressList if 30 <= p < 60]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Advanced (60-100%)",
|
"name": "Advanced (60-99%)",
|
||||||
"value": len([p for p in progressList if 60 <= p <= 100]),
|
"value": len([p for p in progressList if 60 <= p < 100]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Completed (100%)",
|
||||||
|
"value": len([p for p in progressList if p == 100]),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2037,7 +2041,7 @@ def delete_programming_exercise(exercise):
|
|||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_lesson_completion_stats(course):
|
def get_lesson_completion_stats(course: str):
|
||||||
roles = frappe.get_roles()
|
roles = frappe.get_roles()
|
||||||
if "Course Creator" not in roles and "Moderator" not in roles:
|
if "Course Creator" not in roles and "Moderator" not in roles:
|
||||||
frappe.throw(_("You do not have permission to access lesson completion stats."))
|
frappe.throw(_("You do not have permission to access lesson completion stats."))
|
||||||
@@ -2048,13 +2052,17 @@ def get_lesson_completion_stats(course):
|
|||||||
Lesson = frappe.qb.DocType("Course Lesson")
|
Lesson = frappe.qb.DocType("Course Lesson")
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
frappe.qb.from_(CourseProgress)
|
frappe.qb.from_(LessonReference)
|
||||||
.join(LessonReference)
|
|
||||||
.on(CourseProgress.lesson == LessonReference.lesson)
|
|
||||||
.join(ChapterReference)
|
.join(ChapterReference)
|
||||||
.on(LessonReference.parent == ChapterReference.chapter)
|
.on(LessonReference.parent == ChapterReference.chapter)
|
||||||
.join(Lesson)
|
.join(Lesson)
|
||||||
.on(CourseProgress.lesson == Lesson.name)
|
.on(LessonReference.lesson == Lesson.name)
|
||||||
|
.left_join(CourseProgress)
|
||||||
|
.on(
|
||||||
|
(CourseProgress.lesson == LessonReference.lesson)
|
||||||
|
& (CourseProgress.course == course)
|
||||||
|
& (CourseProgress.status == "Complete")
|
||||||
|
)
|
||||||
.select(
|
.select(
|
||||||
LessonReference.idx,
|
LessonReference.idx,
|
||||||
ChapterReference.idx.as_("chapter_idx"),
|
ChapterReference.idx.as_("chapter_idx"),
|
||||||
@@ -2063,10 +2071,132 @@ def get_lesson_completion_stats(course):
|
|||||||
Lesson.name.as_("lesson_name"),
|
Lesson.name.as_("lesson_name"),
|
||||||
fn.Count(CourseProgress.name).as_("completion_count"),
|
fn.Count(CourseProgress.name).as_("completion_count"),
|
||||||
)
|
)
|
||||||
.where((CourseProgress.course == course) & (CourseProgress.status == "Complete"))
|
.where(ChapterReference.parent == course)
|
||||||
.groupby(CourseProgress.lesson)
|
.groupby(LessonReference.lesson)
|
||||||
.orderby(ChapterReference.idx, LessonReference.idx)
|
.orderby(ChapterReference.idx, LessonReference.idx)
|
||||||
.run(as_dict=True)
|
.run(as_dict=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_course_assessment_progress(course: str, member: str):
|
||||||
|
if not can_modify_course(course):
|
||||||
|
frappe.throw(
|
||||||
|
_("You do not have permission to access this course's assessment data."), frappe.PermissionError
|
||||||
|
)
|
||||||
|
|
||||||
|
quizzes = get_course_quiz_progress(course, member)
|
||||||
|
assignments = get_course_assignment_progress(course, member)
|
||||||
|
programming_exercises = get_course_programming_exercise_progress(course, member)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"quizzes": quizzes,
|
||||||
|
"assignments": assignments,
|
||||||
|
"exercises": programming_exercises,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_course_quiz_progress(course: str, member: str):
|
||||||
|
quizzes = get_assessment_from_lesson(course, "quiz")
|
||||||
|
attempts = []
|
||||||
|
|
||||||
|
for quiz in quizzes:
|
||||||
|
submissions = frappe.get_all(
|
||||||
|
"LMS Quiz Submission",
|
||||||
|
{
|
||||||
|
"quiz": quiz,
|
||||||
|
"member": member,
|
||||||
|
},
|
||||||
|
["name", "score", "percentage", "quiz", "quiz_title"],
|
||||||
|
order_by="creation desc",
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
if len(submissions):
|
||||||
|
attempts.append(submissions[0])
|
||||||
|
else:
|
||||||
|
attempts.append(
|
||||||
|
{
|
||||||
|
"quiz": quiz,
|
||||||
|
"quiz_title": frappe.db.get_value("LMS Quiz", quiz, "title"),
|
||||||
|
"score": 0,
|
||||||
|
"percentage": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return attempts
|
||||||
|
|
||||||
|
|
||||||
|
def get_course_assignment_progress(course: str, member: str):
|
||||||
|
assignments = get_assessment_from_lesson(course, "assignment")
|
||||||
|
submissions = []
|
||||||
|
|
||||||
|
for assignment in assignments:
|
||||||
|
assignment_subs = frappe.get_all(
|
||||||
|
"LMS Assignment Submission",
|
||||||
|
{
|
||||||
|
"assignment": assignment,
|
||||||
|
"member": member,
|
||||||
|
},
|
||||||
|
["name", "status", "assignment", "assignment_title"],
|
||||||
|
order_by="creation desc",
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
if len(assignment_subs):
|
||||||
|
submissions.append(assignment_subs[0])
|
||||||
|
else:
|
||||||
|
submissions.append(
|
||||||
|
{
|
||||||
|
"assignment": assignment,
|
||||||
|
"assignment_title": frappe.db.get_value("LMS Assignment", assignment, "title"),
|
||||||
|
"status": "Not Submitted",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return submissions
|
||||||
|
|
||||||
|
|
||||||
|
def get_course_programming_exercise_progress(course: str, member: str):
|
||||||
|
exercises = get_assessment_from_lesson(course, "program")
|
||||||
|
submissions = []
|
||||||
|
|
||||||
|
for exercise in exercises:
|
||||||
|
exercise_subs = frappe.get_all(
|
||||||
|
"LMS Programming Exercise Submission",
|
||||||
|
{
|
||||||
|
"exercise": exercise,
|
||||||
|
"member": member,
|
||||||
|
},
|
||||||
|
["name", "status", "exercise", "exercise_title"],
|
||||||
|
order_by="creation desc",
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
if len(exercise_subs):
|
||||||
|
submissions.append(exercise_subs[0])
|
||||||
|
else:
|
||||||
|
submissions.append(
|
||||||
|
{
|
||||||
|
"exercise": exercise,
|
||||||
|
"exercise_title": frappe.db.get_value("LMS Programming Exercise", exercise, "title"),
|
||||||
|
"status": "Not Attempted",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return submissions
|
||||||
|
|
||||||
|
|
||||||
|
def get_assessment_from_lesson(course: str, assessmentType: str):
|
||||||
|
assessments = []
|
||||||
|
lessons = frappe.get_all("Course Lesson", {"course": course}, ["name", "title", "content"])
|
||||||
|
|
||||||
|
for lesson in lessons:
|
||||||
|
if lesson.content:
|
||||||
|
content = json.loads(lesson.content)
|
||||||
|
for block in content.get("blocks", []):
|
||||||
|
if block.get("type") == assessmentType:
|
||||||
|
data_field = "exercise" if assessmentType == "program" else assessmentType
|
||||||
|
quiz_name = block.get("data", {}).get(data_field)
|
||||||
|
assessments.append(quiz_name)
|
||||||
|
|
||||||
|
return assessments
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_import": 1,
|
||||||
"allow_rename": 1,
|
"allow_rename": 1,
|
||||||
"autoname": "format: ASG-{#####}",
|
"autoname": "format: ASG-{#####}",
|
||||||
"creation": "2023-05-26 19:41:26.025081",
|
"creation": "2023-05-26 19:41:26.025081",
|
||||||
@@ -79,8 +80,13 @@
|
|||||||
],
|
],
|
||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [
|
||||||
"modified": "2025-12-19 16:30:58.531722",
|
{
|
||||||
|
"link_doctype": "LMS Assignment Submission",
|
||||||
|
"link_fieldname": "assignment"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"modified": "2026-02-05 11:37:36.492016",
|
||||||
"modified_by": "sayali@frappe.io",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Assignment",
|
"name": "LMS Assignment",
|
||||||
@@ -104,6 +110,7 @@
|
|||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
@@ -124,6 +131,7 @@
|
|||||||
"create": 1,
|
"create": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
@@ -135,6 +143,7 @@
|
|||||||
"create": 1,
|
"create": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
|
|||||||
@@ -42,7 +42,8 @@
|
|||||||
"fieldname": "assignment",
|
"fieldname": "assignment",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Assignment",
|
"label": "Assignment",
|
||||||
"options": "LMS Assignment"
|
"options": "LMS Assignment",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "member",
|
"fieldname": "member",
|
||||||
@@ -150,7 +151,7 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"make_attachments_public": 1,
|
"make_attachments_public": 1,
|
||||||
"modified": "2025-12-17 14:47:22.944223",
|
"modified": "2026-02-05 11:38:03.792865",
|
||||||
"modified_by": "sayali@frappe.io",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Assignment Submission",
|
"name": "LMS Assignment Submission",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_import": 1,
|
||||||
"allow_rename": 1,
|
"allow_rename": 1,
|
||||||
"autoname": "field:title",
|
"autoname": "field:title",
|
||||||
"creation": "2024-04-30 11:29:53.548647",
|
"creation": "2024-04-30 11:29:53.548647",
|
||||||
@@ -99,8 +100,8 @@
|
|||||||
"link_fieldname": "badge"
|
"link_fieldname": "badge"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2025-07-04 13:02:19.048994",
|
"modified": "2026-02-03 10:52:37.122370",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Badge",
|
"name": "LMS Badge",
|
||||||
"naming_rule": "By fieldname",
|
"naming_rule": "By fieldname",
|
||||||
@@ -118,13 +119,26 @@
|
|||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Moderator",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
"role": "All",
|
"role": "LMS Student",
|
||||||
"share": 1
|
"share": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_import": 1,
|
||||||
"allow_rename": 1,
|
"allow_rename": 1,
|
||||||
"creation": "2025-02-10 11:17:12.462368",
|
"creation": "2025-02-10 11:17:12.462368",
|
||||||
"doctype": "DocType",
|
"doctype": "DocType",
|
||||||
@@ -73,7 +74,7 @@
|
|||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-01-14 08:53:16.672825",
|
"modified": "2026-02-03 10:51:28.475356",
|
||||||
"modified_by": "sayali@frappe.io",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Batch Enrollment",
|
"name": "LMS Batch Enrollment",
|
||||||
@@ -96,6 +97,7 @@
|
|||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
@@ -114,6 +116,7 @@
|
|||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_import": 1,
|
||||||
"allow_rename": 1,
|
"allow_rename": 1,
|
||||||
"autoname": "hash",
|
"autoname": "hash",
|
||||||
"creation": "2025-10-11 21:39:11.456420",
|
"creation": "2025-10-11 21:39:11.456420",
|
||||||
@@ -113,7 +114,7 @@
|
|||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-10-27 19:52:11.835042",
|
"modified": "2026-02-03 10:50:23.387175",
|
||||||
"modified_by": "sayali@frappe.io",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Coupon",
|
"name": "LMS Coupon",
|
||||||
@@ -149,6 +150,7 @@
|
|||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
@@ -161,6 +163,7 @@
|
|||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
@@ -173,6 +176,7 @@
|
|||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_import": 1,
|
||||||
"allow_rename": 1,
|
"allow_rename": 1,
|
||||||
"creation": "2023-03-02 10:59:01.741349",
|
"creation": "2023-03-02 10:59:01.741349",
|
||||||
"default_view": "List",
|
"default_view": "List",
|
||||||
@@ -177,7 +178,7 @@
|
|||||||
"link_fieldname": "live_class"
|
"link_fieldname": "live_class"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2026-01-14 08:54:07.684781",
|
"modified": "2026-02-03 10:54:39.198916",
|
||||||
"modified_by": "sayali@frappe.io",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Live Class",
|
"name": "LMS Live Class",
|
||||||
@@ -200,6 +201,7 @@
|
|||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
@@ -221,6 +223,7 @@
|
|||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_import": 1,
|
||||||
"allow_rename": 1,
|
"allow_rename": 1,
|
||||||
"creation": "2023-08-24 17:46:52.065763",
|
"creation": "2023-08-24 17:46:52.065763",
|
||||||
"default_view": "List",
|
"default_view": "List",
|
||||||
@@ -201,7 +202,7 @@
|
|||||||
"link_fieldname": "payment"
|
"link_fieldname": "payment"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2025-12-19 17:55:25.968384",
|
"modified": "2026-02-03 10:54:12.361407",
|
||||||
"modified_by": "sayali@frappe.io",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Payment",
|
"name": "LMS Payment",
|
||||||
@@ -218,6 +219,19 @@
|
|||||||
"role": "System Manager",
|
"role": "System Manager",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Moderator",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"row_format": "Dynamic",
|
"row_format": "Dynamic",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_import": 1,
|
||||||
"allow_rename": 1,
|
"allow_rename": 1,
|
||||||
"autoname": "field:title",
|
"autoname": "field:title",
|
||||||
"creation": "2024-11-18 12:27:13.283169",
|
"creation": "2024-11-18 12:27:13.283169",
|
||||||
@@ -92,7 +93,7 @@
|
|||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-12-04 12:56:14.249363",
|
"modified": "2026-02-03 10:51:50.616781",
|
||||||
"modified_by": "sayali@frappe.io",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Program",
|
"name": "LMS Program",
|
||||||
@@ -116,6 +117,7 @@
|
|||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
@@ -128,6 +130,7 @@
|
|||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_import": 1,
|
||||||
"allow_rename": 1,
|
"allow_rename": 1,
|
||||||
"creation": "2025-06-18 15:02:36.198855",
|
"creation": "2025-06-18 15:02:36.198855",
|
||||||
"doctype": "DocType",
|
"doctype": "DocType",
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
"fieldname": "language",
|
"fieldname": "language",
|
||||||
"fieldtype": "Select",
|
"fieldtype": "Select",
|
||||||
"label": "Language",
|
"label": "Language",
|
||||||
"options": "Python\nJavaScript",
|
"options": "Python\nJavaScript\nRust\nGo",
|
||||||
"reqd": 1
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -63,7 +64,7 @@
|
|||||||
"link_fieldname": "exercise"
|
"link_fieldname": "exercise"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2025-06-24 14:42:27.463492",
|
"modified": "2026-02-03 10:45:23.687185",
|
||||||
"modified_by": "sayali@frappe.io",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Programming Exercise",
|
"name": "LMS Programming Exercise",
|
||||||
@@ -74,6 +75,7 @@
|
|||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
@@ -86,6 +88,7 @@
|
|||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
@@ -98,6 +101,7 @@
|
|||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
@@ -110,6 +114,7 @@
|
|||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_import": 1,
|
||||||
"allow_rename": 1,
|
"allow_rename": 1,
|
||||||
"autoname": "field:source",
|
"autoname": "field:source",
|
||||||
"creation": "2023-10-26 16:28:53.932278",
|
"creation": "2023-10-26 16:28:53.932278",
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-11-10 11:39:57.251861",
|
"modified": "2026-02-03 10:53:42.654881",
|
||||||
"modified_by": "sayali@frappe.io",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Source",
|
"name": "LMS Source",
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_import": 1,
|
||||||
"allow_rename": 1,
|
"allow_rename": 1,
|
||||||
"autoname": "field:account_name",
|
"autoname": "field:account_name",
|
||||||
"creation": "2025-05-26 13:04:18.285735",
|
"creation": "2025-05-26 13:04:18.285735",
|
||||||
@@ -83,7 +84,7 @@
|
|||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-11-10 11:39:13.146961",
|
"modified": "2026-02-03 10:50:59.906919",
|
||||||
"modified_by": "sayali@frappe.io",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Zoom Settings",
|
"name": "LMS Zoom Settings",
|
||||||
@@ -107,6 +108,7 @@
|
|||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
@@ -120,6 +122,7 @@
|
|||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
"if_owner": 1,
|
"if_owner": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import frappe
|
||||||
|
|
||||||
|
from lms.lms.api import get_certified_participants, get_course_assessment_progress
|
||||||
|
from lms.lms.test_helpers import BaseTestUtils
|
||||||
|
|
||||||
|
|
||||||
|
class TestLMSAPI(BaseTestUtils):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self._setup_course_flow()
|
||||||
|
|
||||||
|
def test_certified_participants_with_category(self):
|
||||||
|
filters = {"category": "Utility Course"}
|
||||||
|
certified_participants = get_certified_participants(filters=filters)
|
||||||
|
self.assertEqual(len(certified_participants), 1)
|
||||||
|
self.assertEqual(certified_participants[0].member, self.student1.email)
|
||||||
|
|
||||||
|
filters = {"category": "Nonexistent Category"}
|
||||||
|
certified_participants_no_match = get_certified_participants(filters=filters)
|
||||||
|
self.assertEqual(len(certified_participants_no_match), 0)
|
||||||
|
|
||||||
|
def test_certified_participants_with_open_to_work(self):
|
||||||
|
filters = {"open_to_work": 1}
|
||||||
|
certified_participants_open_to_work = get_certified_participants(filters=filters)
|
||||||
|
self.assertEqual(len(certified_participants_open_to_work), 0)
|
||||||
|
|
||||||
|
frappe.db.set_value("User", self.student1.email, "open_to", "Work")
|
||||||
|
certified_participants_open_to_work = get_certified_participants(filters=filters)
|
||||||
|
self.assertEqual(len(certified_participants_open_to_work), 1)
|
||||||
|
frappe.db.set_value("User", self.student1.email, "open_to", "")
|
||||||
|
|
||||||
|
def test_certified_participants_with_open_to_hiring(self):
|
||||||
|
filters = {"hiring": 1}
|
||||||
|
certified_participants_hiring = get_certified_participants(filters=filters)
|
||||||
|
self.assertEqual(len(certified_participants_hiring), 0)
|
||||||
|
|
||||||
|
frappe.db.set_value("User", self.student1.email, "open_to", "Hiring")
|
||||||
|
certified_participants_hiring = get_certified_participants(filters=filters)
|
||||||
|
self.assertEqual(len(certified_participants_hiring), 1)
|
||||||
|
frappe.db.set_value("User", self.student1.email, "open_to", "")
|
||||||
|
|
||||||
|
def test_course_assessment_progress(self):
|
||||||
|
progress = get_course_assessment_progress(self.course.name, self.student1.name)
|
||||||
|
progress = frappe._dict(progress)
|
||||||
|
|
||||||
|
self.assertEqual(len(progress.quizzes), 1)
|
||||||
|
for quiz in progress.quizzes:
|
||||||
|
self.assertEqual(quiz.quiz, self.quiz.name)
|
||||||
|
self.assertEqual(quiz.quiz_title, self.quiz.title)
|
||||||
|
self.assertEqual(quiz.score, 12)
|
||||||
|
self.assertEqual(quiz.percentage, 80)
|
||||||
|
|
||||||
|
self.assertEqual(len(progress.assignments), 1)
|
||||||
|
for assignment in progress.assignments:
|
||||||
|
self.assertEqual(assignment.assignment, self.assignment.name)
|
||||||
|
self.assertEqual(assignment.assignment_title, self.assignment.title)
|
||||||
|
self.assertEqual(assignment.status, "Pass")
|
||||||
|
|
||||||
|
self.assertEqual(len(progress.exercises), 1)
|
||||||
|
for exercise in progress.exercises:
|
||||||
|
self.assertEqual(exercise.exercise, self.programming_exercise.name)
|
||||||
|
self.assertEqual(exercise.exercise_title, self.programming_exercise.title)
|
||||||
|
self.assertEqual(exercise.status, "Passed")
|
||||||
+293
-4
@@ -19,8 +19,8 @@ class BaseTestUtils(UnitTestCase):
|
|||||||
if frappe.db.exists(item_type, item_name):
|
if frappe.db.exists(item_type, item_name):
|
||||||
try:
|
try:
|
||||||
frappe.delete_doc(item_type, item_name, force=True)
|
frappe.delete_doc(item_type, item_name, force=True)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
print(f"Error deleting {item_type} {item_name}: {e}")
|
||||||
|
|
||||||
def _create_user(self, email, first_name, last_name, roles, user_type="Website User"):
|
def _create_user(self, email, first_name, last_name, roles, user_type="Website User"):
|
||||||
if frappe.db.exists("User", email):
|
if frappe.db.exists("User", email):
|
||||||
@@ -82,18 +82,21 @@ class BaseTestUtils(UnitTestCase):
|
|||||||
self.cleanup_items.append(("Course Chapter", chapter.name))
|
self.cleanup_items.append(("Course Chapter", chapter.name))
|
||||||
return chapter
|
return chapter
|
||||||
|
|
||||||
def _create_lesson(self, title, chapter, course):
|
def _create_lesson(self, title, chapter, course, content=None):
|
||||||
existing = frappe.db.exists("Course Lesson", {"course": course, "title": title})
|
existing = frappe.db.exists("Course Lesson", {"course": course, "title": title})
|
||||||
if existing:
|
if existing:
|
||||||
return frappe.get_doc("Course Lesson", existing)
|
return frappe.get_doc("Course Lesson", existing)
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
content = '{"time":1765194986690,"blocks":[{"id":"dkLzbW14ds","type":"markdown","data":{"text":"This is a simple content for the current lesson."}},{"id":"KBwuWPc8rV","type":"markdown","data":{"text":""}}],"version":"2.29.0"}'
|
||||||
|
|
||||||
lesson = frappe.new_doc("Course Lesson")
|
lesson = frappe.new_doc("Course Lesson")
|
||||||
lesson.update(
|
lesson.update(
|
||||||
{
|
{
|
||||||
"course": course,
|
"course": course,
|
||||||
"chapter": chapter,
|
"chapter": chapter,
|
||||||
"title": title,
|
"title": title,
|
||||||
"content": '{"time":1765194986690,"blocks":[{"id":"dkLzbW14ds","type":"markdown","data":{"text":"This is a simple content for the current lesson."}},{"id":"KBwuWPc8rV","type":"markdown","data":{"text":""}}],"version":"2.29.0"}',
|
"content": content,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
lesson.save()
|
lesson.save()
|
||||||
@@ -248,3 +251,289 @@ class BaseTestUtils(UnitTestCase):
|
|||||||
certificate.save()
|
certificate.save()
|
||||||
self.cleanup_items.append(("LMS Certificate", certificate.name))
|
self.cleanup_items.append(("LMS Certificate", certificate.name))
|
||||||
return certificate
|
return certificate
|
||||||
|
|
||||||
|
def _create_quiz_questions(self):
|
||||||
|
questions = []
|
||||||
|
for index in range(1, 4):
|
||||||
|
question = frappe.new_doc("LMS Question")
|
||||||
|
question.update(
|
||||||
|
{
|
||||||
|
"question": f"Utility Question {index}?",
|
||||||
|
"type": "Choices",
|
||||||
|
"option_1": "Option 1",
|
||||||
|
"is_correct_1": 1,
|
||||||
|
"option_2": "Option 2",
|
||||||
|
"is_correct_2": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
question.save()
|
||||||
|
self.cleanup_items.append(("LMS Quiz Question", question.name))
|
||||||
|
questions.append(question)
|
||||||
|
return questions
|
||||||
|
|
||||||
|
def _create_quiz(self, title="Utility Quiz"):
|
||||||
|
existing = frappe.db.exists("LMS Quiz", {"title": title})
|
||||||
|
if existing:
|
||||||
|
return frappe.get_doc("LMS Quiz", existing)
|
||||||
|
|
||||||
|
quiz = frappe.new_doc("LMS Quiz")
|
||||||
|
quiz.update(
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"passing_percentage": 70,
|
||||||
|
"total_marks": 15,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for question in self.questions:
|
||||||
|
quiz.append(
|
||||||
|
"questions",
|
||||||
|
{
|
||||||
|
"question": question.name,
|
||||||
|
"marks": 5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
quiz.save()
|
||||||
|
self.cleanup_items.append(("LMS Quiz", quiz.name))
|
||||||
|
return quiz
|
||||||
|
|
||||||
|
def _create_assignment(self, title="Utility Assignment"):
|
||||||
|
existing = frappe.db.exists("LMS Assignment", {"title": title})
|
||||||
|
if existing:
|
||||||
|
return frappe.get_doc("LMS Assignment", existing)
|
||||||
|
|
||||||
|
assignment = frappe.new_doc("LMS Assignment")
|
||||||
|
assignment.update(
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"question": "This is a utility assignment to test the assignment creation helper method.",
|
||||||
|
"type": "Text",
|
||||||
|
"grade_assignment": 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assignment.save()
|
||||||
|
self.cleanup_items.append(("LMS Assignment", assignment.name))
|
||||||
|
return assignment
|
||||||
|
|
||||||
|
def _setup_course_flow(self):
|
||||||
|
self.student1 = self._create_user("student1@example.com", "Ashley", "Smith", ["LMS Student"])
|
||||||
|
self.student2 = self._create_user("student2@example.com", "John", "Doe", ["LMS Student"])
|
||||||
|
self.admin = self._create_user(
|
||||||
|
"frappe@example.com", "Frappe", "Admin", ["Moderator", "Course Creator", "Batch Evaluator"]
|
||||||
|
)
|
||||||
|
self.course = self._create_course()
|
||||||
|
self._setup_quiz()
|
||||||
|
self._setup_assignment()
|
||||||
|
self._setup_programming_exercise()
|
||||||
|
self._setup_chapters()
|
||||||
|
|
||||||
|
self._create_enrollment(self.student1.email, self.course.name)
|
||||||
|
self._add_student_progress(self.student1.email, self.course.name)
|
||||||
|
self._create_enrollment(self.student2.email, self.course.name)
|
||||||
|
self._add_student_progress(self.student2.email, self.course.name)
|
||||||
|
|
||||||
|
self._add_rating(self.course.name, self.student1.email, 0.8, "Good course")
|
||||||
|
self._add_rating(self.course.name, self.student2.email, 1, "Excellent course")
|
||||||
|
|
||||||
|
self._create_certificate(self.course.name, self.student1.email)
|
||||||
|
|
||||||
|
def _setup_quiz(self):
|
||||||
|
self.questions = self._create_quiz_questions()
|
||||||
|
self.quiz = self._create_quiz()
|
||||||
|
|
||||||
|
def _setup_assignment(self):
|
||||||
|
self.assignment = self._create_assignment()
|
||||||
|
|
||||||
|
def _setup_programming_exercise(self):
|
||||||
|
self.programming_exercise = self._create_programming_exercise()
|
||||||
|
|
||||||
|
def _setup_chapters(self):
|
||||||
|
chapters = []
|
||||||
|
for i in range(1, 4):
|
||||||
|
chapter = self._create_chapter(f"Chapter {i}", self.course.name)
|
||||||
|
chapters.append(chapter)
|
||||||
|
self.course.reload()
|
||||||
|
for chapter in chapters:
|
||||||
|
if not any(c.chapter == chapter.name for c in self.course.chapters):
|
||||||
|
self.course.append("chapters", {"chapter": chapter.name})
|
||||||
|
self.course.save()
|
||||||
|
self._setup_lessons()
|
||||||
|
|
||||||
|
def _setup_lessons(self):
|
||||||
|
for index, chapter_ref in enumerate(self.course.chapters):
|
||||||
|
chapter_doc = frappe.get_doc("Course Chapter", chapter_ref.chapter)
|
||||||
|
for j in range(1, 5):
|
||||||
|
content = None
|
||||||
|
if j == 2 and index == 2:
|
||||||
|
content = self._get_quiz_lesson_content()
|
||||||
|
if j == 3 and index == 2:
|
||||||
|
content = self._get_assignment_lesson_content()
|
||||||
|
if j == 4 and index == 2:
|
||||||
|
content = self._get_exercise_lesson_content()
|
||||||
|
lesson_title = f"Lesson {j} of {chapter_ref.chapter}"
|
||||||
|
lesson = self._create_lesson(lesson_title, chapter_ref.chapter, self.course.name, content)
|
||||||
|
|
||||||
|
if not any(l.lesson == lesson.name for l in chapter_doc.lessons):
|
||||||
|
chapter_doc.append("lessons", {"lesson": lesson.name})
|
||||||
|
|
||||||
|
chapter_doc.save()
|
||||||
|
|
||||||
|
def _get_quiz_lesson_content(self):
|
||||||
|
return f"""{{
|
||||||
|
"time": 1765194986690,
|
||||||
|
"blocks": [
|
||||||
|
{{
|
||||||
|
"id": "dkLzbW14ds",
|
||||||
|
"type": "quiz",
|
||||||
|
"data": {{ "quiz": "{self.quiz.name}" }}
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
"version": "2.29.0"
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
def _get_assignment_lesson_content(self):
|
||||||
|
return f"""{{
|
||||||
|
"time": 1765194986690,
|
||||||
|
"blocks": [
|
||||||
|
{{
|
||||||
|
"id": "dkLzbW14ds",
|
||||||
|
"type": "assignment",
|
||||||
|
"data": {{ "assignment": "{self.assignment.name}" }}
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
"version": "2.29.0"
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
def _get_exercise_lesson_content(self):
|
||||||
|
return f"""{{
|
||||||
|
"time": 1765194986690,
|
||||||
|
"blocks": [
|
||||||
|
{{
|
||||||
|
"id": "dkLzbW14ds",
|
||||||
|
"type": "program",
|
||||||
|
"data": {{ "exercise": "{self.programming_exercise.name}" }}
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
"version": "2.29.0"
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
def _setup_batch_flow(self):
|
||||||
|
self.evaluator = self._create_evaluator()
|
||||||
|
self.batch = self._create_batch(self.course.name)
|
||||||
|
self._create_batch_enrollment(self.student1.email, self.batch.name)
|
||||||
|
self._create_batch_enrollment(self.student2.email, self.batch.name)
|
||||||
|
|
||||||
|
def _add_student_progress(self, member, course):
|
||||||
|
self._create_quiz_submission(member)
|
||||||
|
self._create_assignment_submission(member)
|
||||||
|
self._create_programming_exercise_submission(member)
|
||||||
|
lessons = frappe.db.get_all(
|
||||||
|
"Course Lesson", {"course": course}, pluck="name", limit=2, order_by="creation desc"
|
||||||
|
)
|
||||||
|
for lesson in lessons:
|
||||||
|
self._create_lesson_progress(member, course, lesson)
|
||||||
|
|
||||||
|
def _create_lesson_progress(self, member, course, lesson):
|
||||||
|
existing = frappe.db.exists(
|
||||||
|
"LMS Course Progress", {"member": member, "course": course, "lesson": lesson}
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return frappe.get_doc("LMS Course Progress", existing)
|
||||||
|
|
||||||
|
progress = frappe.new_doc("LMS Course Progress")
|
||||||
|
progress.update({"member": member, "course": course, "lesson": lesson, "status": "Complete"})
|
||||||
|
progress.insert()
|
||||||
|
self.cleanup_items.append(("LMS Course Progress", progress.name))
|
||||||
|
return progress
|
||||||
|
|
||||||
|
def _create_quiz_submission(self, member):
|
||||||
|
existing = frappe.db.exists("LMS Quiz Submission", {"quiz": self.quiz.name, "member": member})
|
||||||
|
if existing:
|
||||||
|
return frappe.get_doc("LMS Quiz Submission", existing)
|
||||||
|
submission = frappe.new_doc("LMS Quiz Submission")
|
||||||
|
submission.update(
|
||||||
|
{
|
||||||
|
"quiz": self.quiz.name,
|
||||||
|
"member": member,
|
||||||
|
"score_out_of": self.quiz.total_marks,
|
||||||
|
"passing_percentage": self.quiz.passing_percentage,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for question in self.questions:
|
||||||
|
submission.append(
|
||||||
|
"result",
|
||||||
|
{
|
||||||
|
"question": question.name,
|
||||||
|
"marks": 4,
|
||||||
|
"marks_out_of": 5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
submission.insert()
|
||||||
|
self.cleanup_items.append(("LMS Quiz Submission", submission.name))
|
||||||
|
return submission
|
||||||
|
|
||||||
|
def _create_assignment_submission(self, member):
|
||||||
|
existing = frappe.db.exists(
|
||||||
|
"LMS Assignment Submission", {"assignment": self.assignment.name, "member": member}
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return frappe.get_doc("LMS Assignment Submission", existing)
|
||||||
|
|
||||||
|
submission = frappe.new_doc("LMS Assignment Submission")
|
||||||
|
submission.update(
|
||||||
|
{
|
||||||
|
"assignment": self.assignment.name,
|
||||||
|
"member": member,
|
||||||
|
"answer": "This is the submission content for the utility assignment.",
|
||||||
|
"status": "Pass",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
submission.insert()
|
||||||
|
self.cleanup_items.append(("LMS Assignment Submission", submission.name))
|
||||||
|
return submission
|
||||||
|
|
||||||
|
def _create_programming_exercise(self, title="Utility Programming Exercise"):
|
||||||
|
existing = frappe.db.exists("LMS Programming Exercise", {"title": title})
|
||||||
|
if existing:
|
||||||
|
return frappe.get_doc("LMS Programming Exercise", existing)
|
||||||
|
|
||||||
|
programming_exercise = frappe.new_doc("LMS Programming Exercise")
|
||||||
|
programming_exercise.update(
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"language": "Python",
|
||||||
|
"problem_statement": "Write a function to return the sum of two numbers.",
|
||||||
|
"test_cases": [
|
||||||
|
{"input": "2", "expected_output": "3"},
|
||||||
|
{"input": "11", "expected_output": "12"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
programming_exercise.save()
|
||||||
|
self.cleanup_items.append(("LMS Programming Exercise", programming_exercise.name))
|
||||||
|
return programming_exercise
|
||||||
|
|
||||||
|
def _create_programming_exercise_submission(self, member):
|
||||||
|
existing = frappe.db.exists(
|
||||||
|
"LMS Programming Exercise Submission",
|
||||||
|
{"exercise": self.programming_exercise.name, "member": member},
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return frappe.get_doc("LMS Programming Exercise Submission", existing)
|
||||||
|
|
||||||
|
submission = frappe.new_doc("LMS Programming Exercise Submission")
|
||||||
|
submission.update(
|
||||||
|
{
|
||||||
|
"exercise": self.programming_exercise.name,
|
||||||
|
"member": member,
|
||||||
|
"code": "print(inputs[0] + 1)",
|
||||||
|
"status": "Passed",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
submission.insert()
|
||||||
|
self.cleanup_items.append(("LMS Programming Exercise Submission", submission.name))
|
||||||
|
return submission
|
||||||
|
|||||||
+3
-74
@@ -2,9 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe.utils import get_time, getdate, to_timedelta
|
from frappe.utils import getdate, to_timedelta
|
||||||
|
|
||||||
from lms.lms.api import get_certified_participants
|
|
||||||
from lms.lms.doctype.lms_certificate.lms_certificate import is_certified
|
from lms.lms.doctype.lms_certificate.lms_certificate import is_certified
|
||||||
from lms.lms.test_helpers import BaseTestUtils
|
from lms.lms.test_helpers import BaseTestUtils
|
||||||
from lms.lms.utils import (
|
from lms.lms.utils import (
|
||||||
@@ -33,48 +32,8 @@ class TestLMSUtils(BaseTestUtils):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.student1 = self._create_user("student1@example.com", "Ashley", "Smith", ["LMS Student"])
|
self._setup_course_flow()
|
||||||
self.student2 = self._create_user("student2@example.com", "John", "Doe", ["LMS Student"])
|
self._setup_batch_flow()
|
||||||
self.admin = self._create_user(
|
|
||||||
"frappe@example.com", "Frappe", "Admin", ["Moderator", "Course Creator", "Batch Evaluator"]
|
|
||||||
)
|
|
||||||
self.course = self._create_course()
|
|
||||||
self._setup_chapters_and_lessons()
|
|
||||||
|
|
||||||
self._create_enrollment(self.student1.email, self.course.name)
|
|
||||||
self._create_enrollment(self.student2.email, self.course.name)
|
|
||||||
|
|
||||||
self._add_rating(self.course.name, self.student1.email, 0.8, "Good course")
|
|
||||||
self._add_rating(self.course.name, self.student2.email, 1, "Excellent course")
|
|
||||||
|
|
||||||
self._create_certificate(self.course.name, self.student1.email)
|
|
||||||
|
|
||||||
self.evaluator = self._create_evaluator()
|
|
||||||
self.batch = self._create_batch(self.course.name)
|
|
||||||
self._create_batch_enrollment(self.student1.email, self.batch.name)
|
|
||||||
self._create_batch_enrollment(self.student2.email, self.batch.name)
|
|
||||||
|
|
||||||
def _setup_chapters_and_lessons(self):
|
|
||||||
chapters = []
|
|
||||||
for i in range(1, 4):
|
|
||||||
chapter = self._create_chapter(f"Chapter {i}", self.course.name)
|
|
||||||
chapters.append(chapter)
|
|
||||||
|
|
||||||
self.course.reload()
|
|
||||||
for chapter in chapters:
|
|
||||||
if not any(c.chapter == chapter.name for c in self.course.chapters):
|
|
||||||
self.course.append("chapters", {"chapter": chapter.name})
|
|
||||||
self.course.save()
|
|
||||||
|
|
||||||
for chapter_ref in self.course.chapters:
|
|
||||||
chapter_doc = frappe.get_doc("Course Chapter", chapter_ref.chapter)
|
|
||||||
for j in range(1, 3):
|
|
||||||
lesson_title = f"Lesson {j} of {chapter_ref.chapter}"
|
|
||||||
lesson = self._create_lesson(lesson_title, chapter_ref.chapter, self.course.name)
|
|
||||||
|
|
||||||
if not any(l.lesson == lesson.name for l in chapter_doc.lessons):
|
|
||||||
chapter_doc.append("lessons", {"lesson": lesson.name})
|
|
||||||
chapter_doc.save()
|
|
||||||
|
|
||||||
def test_simple_slugs(self):
|
def test_simple_slugs(self):
|
||||||
self.assertEqual(slugify("hello-world"), "hello-world")
|
self.assertEqual(slugify("hello-world"), "hello-world")
|
||||||
@@ -156,36 +115,6 @@ class TestLMSUtils(BaseTestUtils):
|
|||||||
self.assertIsNone(is_certified(self.course.name))
|
self.assertIsNone(is_certified(self.course.name))
|
||||||
frappe.session.user = "Administrator"
|
frappe.session.user = "Administrator"
|
||||||
|
|
||||||
def test_certified_participants_with_category(self):
|
|
||||||
filters = {"category": "Utility Course"}
|
|
||||||
certified_participants = get_certified_participants(filters=filters)
|
|
||||||
self.assertEqual(len(certified_participants), 1)
|
|
||||||
self.assertEqual(certified_participants[0].member, self.student1.email)
|
|
||||||
|
|
||||||
filters = {"category": "Nonexistent Category"}
|
|
||||||
certified_participants_no_match = get_certified_participants(filters=filters)
|
|
||||||
self.assertEqual(len(certified_participants_no_match), 0)
|
|
||||||
|
|
||||||
def test_certified_participants_with_open_to_work(self):
|
|
||||||
filters = {"open_to_work": 1}
|
|
||||||
certified_participants_open_to_work = get_certified_participants(filters=filters)
|
|
||||||
self.assertEqual(len(certified_participants_open_to_work), 0)
|
|
||||||
|
|
||||||
frappe.db.set_value("User", self.student1.email, "open_to", "Work")
|
|
||||||
certified_participants_open_to_work = get_certified_participants(filters=filters)
|
|
||||||
self.assertEqual(len(certified_participants_open_to_work), 1)
|
|
||||||
frappe.db.set_value("User", self.student1.email, "open_to", "")
|
|
||||||
|
|
||||||
def test_certified_participants_with_open_to_hiring(self):
|
|
||||||
filters = {"hiring": 1}
|
|
||||||
certified_participants_hiring = get_certified_participants(filters=filters)
|
|
||||||
self.assertEqual(len(certified_participants_hiring), 0)
|
|
||||||
|
|
||||||
frappe.db.set_value("User", self.student1.email, "open_to", "Hiring")
|
|
||||||
certified_participants_hiring = get_certified_participants(filters=filters)
|
|
||||||
self.assertEqual(len(certified_participants_hiring), 1)
|
|
||||||
frappe.db.set_value("User", self.student1.email, "open_to", "")
|
|
||||||
|
|
||||||
def test_rating_validation(self):
|
def test_rating_validation(self):
|
||||||
student3 = self._create_user("student3@example.com", "Emily", "Cooper", ["LMS Student"])
|
student3 = self._create_user("student3@example.com", "Emily", "Cooper", ["LMS Student"])
|
||||||
with self.assertRaises(frappe.exceptions.ValidationError):
|
with self.assertRaises(frappe.exceptions.ValidationError):
|
||||||
|
|||||||
@@ -205,6 +205,10 @@ def get_lesson_icon(body, content):
|
|||||||
|
|
||||||
if block.get("type") == "quiz":
|
if block.get("type") == "quiz":
|
||||||
return "icon-quiz"
|
return "icon-quiz"
|
||||||
|
if block.get("type") == "assignment":
|
||||||
|
return "icon-assignment"
|
||||||
|
if block.get("type") == "program":
|
||||||
|
return "icon-code"
|
||||||
|
|
||||||
return "icon-list"
|
return "icon-list"
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
"""Utilities for making custom routing."""
|
|
||||||
|
|
||||||
from werkzeug.datastructures import ImmutableDict
|
|
||||||
from werkzeug.routing import BaseConverter, Map
|
|
||||||
|
|
||||||
|
|
||||||
class RegexConverter(BaseConverter):
|
|
||||||
"""werkzeug converter that supports custom regular expression.
|
|
||||||
|
|
||||||
The `install_regex_converter` function must be called before using
|
|
||||||
regex converter in rules.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, map, regex):
|
|
||||||
super().__init__(map)
|
|
||||||
self.regex = regex
|
|
||||||
|
|
||||||
|
|
||||||
def install_regex_converter():
|
|
||||||
"""Installs the RegexConvetor to the default converters supported by werkzeug.
|
|
||||||
|
|
||||||
This allows specifing rules using regex. For example:
|
|
||||||
|
|
||||||
/profiles/<regex("[a-z0-9]{5,}"):username>
|
|
||||||
"""
|
|
||||||
default_converters = dict(Map.default_converters, regex=RegexConverter)
|
|
||||||
Map.default_converters = ImmutableDict(default_converters)
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
from setuptools import find_packages, setup
|
|
||||||
|
|
||||||
with open("requirements.txt") as f:
|
|
||||||
install_requires = f.read().strip().split("\n")
|
|
||||||
|
|
||||||
# get version from __version__ variable in lms/__init__.py
|
|
||||||
from lms import __version__ as version
|
|
||||||
|
|
||||||
setup(
|
|
||||||
name="lms",
|
|
||||||
version=version,
|
|
||||||
description="Learning Management System",
|
|
||||||
author="Jannat",
|
|
||||||
author_email="jannat@frappe.io",
|
|
||||||
packages=find_packages(),
|
|
||||||
zip_safe=False,
|
|
||||||
include_package_data=True,
|
|
||||||
install_requires=install_requires,
|
|
||||||
)
|
|
||||||
Reference in New Issue
Block a user