Merge pull request #1686 from pateljannat/program-refactor
refactor: learning path
This commit is contained in:
1
frontend/components.d.ts
vendored
1
frontend/components.d.ts
vendored
@@ -88,6 +88,7 @@ declare module 'vue' {
|
|||||||
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
|
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
|
||||||
PaymentSettings: typeof import('./src/components/Settings/PaymentSettings.vue')['default']
|
PaymentSettings: typeof import('./src/components/Settings/PaymentSettings.vue')['default']
|
||||||
Play: typeof import('./src/components/Icons/Play.vue')['default']
|
Play: typeof import('./src/components/Icons/Play.vue')['default']
|
||||||
|
ProgramForm: typeof import('./src/components/Modals/ProgramForm.vue')['default']
|
||||||
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
|
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
|
||||||
Question: typeof import('./src/components/Modals/Question.vue')['default']
|
Question: typeof import('./src/components/Modals/Question.vue')['default']
|
||||||
Quiz: typeof import('./src/components/Quiz.vue')['default']
|
Quiz: typeof import('./src/components/Quiz.vue')['default']
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ import { usersStore } from '@/stores/user'
|
|||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { useSidebar } from '@/stores/sidebar'
|
import { useSidebar } from '@/stores/sidebar'
|
||||||
import { useSettings } from '@/stores/settings'
|
import { useSettings } from '@/stores/settings'
|
||||||
import { Button, createResource, Tooltip } from 'frappe-ui'
|
import { Button, call, createResource, Tooltip } from 'frappe-ui'
|
||||||
import PageModal from '@/components/Modals/PageModal.vue'
|
import PageModal from '@/components/Modals/PageModal.vue'
|
||||||
import { capture } from '@/telemetry'
|
import { capture } from '@/telemetry'
|
||||||
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
||||||
@@ -214,6 +214,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
BookText,
|
BookText,
|
||||||
Zap,
|
Zap,
|
||||||
|
Check,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
TrialBanner,
|
TrialBanner,
|
||||||
@@ -360,35 +361,26 @@ const addProgrammingExercises = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const addPrograms = () => {
|
const addPrograms = async () => {
|
||||||
let activeFor = ['Programs', 'ProgramForm']
|
let canAddProgram = await checkIfCanAddProgram()
|
||||||
|
if (!canAddProgram) return
|
||||||
|
let activeFor = ['Programs', 'ProgramDetail']
|
||||||
let index = 1
|
let index = 1
|
||||||
let canAddProgram = false
|
|
||||||
|
|
||||||
if (
|
sidebarLinks.value.splice(index, 0, {
|
||||||
!isInstructor.value &&
|
label: 'Programs',
|
||||||
!isModerator.value &&
|
icon: 'Route',
|
||||||
settingsStore.learningPaths.data
|
to: 'Programs',
|
||||||
) {
|
activeFor: activeFor,
|
||||||
sidebarLinks.value = sidebarLinks.value.filter(
|
})
|
||||||
(link) => link.label !== 'Courses'
|
}
|
||||||
)
|
|
||||||
activeFor.push('CourseDetail')
|
|
||||||
activeFor.push('Lesson')
|
|
||||||
index = 0
|
|
||||||
canAddProgram = true
|
|
||||||
} else if (isInstructor.value || isModerator.value) {
|
|
||||||
canAddProgram = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canAddProgram) {
|
const checkIfCanAddProgram = async () => {
|
||||||
sidebarLinks.value.splice(index, 0, {
|
if (isModerator.value || isInstructor.value) {
|
||||||
label: 'Programs',
|
return true
|
||||||
icon: 'Route',
|
|
||||||
to: 'Programs',
|
|
||||||
activeFor: activeFor,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
const programs = await call('lms.lms.utils.get_programs')
|
||||||
|
return programs.enrolled.length > 0 || programs.published.length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const openPageModal = (link) => {
|
const openPageModal = (link) => {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="!readOnlyMode">
|
<div v-if="!readOnlyMode">
|
||||||
<router-link
|
<router-link
|
||||||
v-if="isModerator || isStudent"
|
v-if="canAccessBatch"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'Batch',
|
name: 'Batch',
|
||||||
params: {
|
params: {
|
||||||
@@ -66,11 +66,11 @@
|
|||||||
>
|
>
|
||||||
<Button variant="solid" class="w-full mt-4">
|
<Button variant="solid" class="w-full mt-4">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Settings v-if="isModerator" class="size-4 stroke-1.5" />
|
<LogIn v-if="isStudent" class="size-4 stroke-1.5" />
|
||||||
<LogIn v-else class="size-4 stroke-1.5" />
|
<Settings v-else class="size-4 stroke-1.5" />
|
||||||
</template>
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
|
{{ isStudent ? __('Visit Batch') : __('Manage Batch') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -204,4 +204,12 @@ const isStudent = computed(() => {
|
|||||||
const isModerator = computed(() => {
|
const isModerator = computed(() => {
|
||||||
return user.data?.is_moderator
|
return user.data?.is_moderator
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isEvaluator = computed(() => {
|
||||||
|
return user.data?.is_evaluator
|
||||||
|
})
|
||||||
|
|
||||||
|
const canAccessBatch = computed(() => {
|
||||||
|
return isModerator.value || isStudent.value || isEvaluator.value
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="course.title"
|
v-if="course.title"
|
||||||
class="flex flex-col h-full rounded-md border-2 overflow-auto text-ink-gray-9"
|
class="flex flex-col h-full rounded-md overflow-auto text-ink-gray-9"
|
||||||
style="min-height: 350px"
|
style="min-height: 350px"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
{{ course.title }}
|
{{ course.title }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col flex-auto p-4">
|
<div class="flex flex-col flex-auto p-4 border-x-2 border-b-2 rounded-b-md">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div v-if="course.lessons">
|
<div v-if="course.lessons">
|
||||||
<Tooltip :text="__('Lessons')">
|
<Tooltip :text="__('Lessons')">
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { getSidebarLinks } from '@/utils'
|
import { getSidebarLinks } from '@/utils'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { call } from 'frappe-ui'
|
||||||
import { watch, ref, onMounted } from 'vue'
|
import { watch, ref, onMounted } from 'vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { useSettings } from '@/stores/settings'
|
import { useSettings } from '@/stores/settings'
|
||||||
@@ -71,6 +72,8 @@ const sidebarLinks = ref(getSidebarLinks())
|
|||||||
const otherLinks = ref([])
|
const otherLinks = ref([])
|
||||||
const showMenu = ref(false)
|
const showMenu = ref(false)
|
||||||
const menu = ref(null)
|
const menu = ref(null)
|
||||||
|
const isModerator = ref(false)
|
||||||
|
const isInstructor = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
sidebarSettings.reload(
|
sidebarSettings.reload(
|
||||||
@@ -134,12 +137,15 @@ const addOtherLinks = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(userResource, () => {
|
watch(userResource, () => {
|
||||||
if (
|
if (userResource.data) {
|
||||||
userResource.data &&
|
isModerator.value = userResource.data.is_moderator
|
||||||
(userResource.data.is_moderator || userResource.data.is_instructor)
|
isInstructor.value = userResource.data.is_instructor
|
||||||
) {
|
addPrograms()
|
||||||
addQuizzes()
|
if (isModerator.value || isInstructor.value) {
|
||||||
addAssignments()
|
addProgrammingExercises()
|
||||||
|
addQuizzes()
|
||||||
|
addAssignments()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -159,6 +165,28 @@ const addAssignments = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addPrograms = async () => {
|
||||||
|
let canAddProgram = await checkIfCanAddProgram()
|
||||||
|
if (!canAddProgram) return
|
||||||
|
let activeFor = ['Programs', 'ProgramDetail']
|
||||||
|
let index = 1
|
||||||
|
|
||||||
|
sidebarLinks.value.splice(index, 0, {
|
||||||
|
label: 'Programs',
|
||||||
|
icon: 'Route',
|
||||||
|
to: 'Programs',
|
||||||
|
activeFor: activeFor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkIfCanAddProgram = async () => {
|
||||||
|
if (isModerator.value || isInstructor.value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const programs = await call('lms.lms.utils.get_programs')
|
||||||
|
return programs.enrolled.length > 0 || programs.published.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
let isActive = (tab) => {
|
let isActive = (tab) => {
|
||||||
return tab.activeFor?.includes(router.currentRoute.value.name)
|
return tab.activeFor?.includes(router.currentRoute.value.name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,6 @@
|
|||||||
>
|
>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="flex items-center justify-between space-x-5 mb-4">
|
<div class="flex items-center justify-between space-x-5 mb-4">
|
||||||
<!-- <div class="text-xl font-semibold text-ink-gray-6">
|
|
||||||
{{ __('{0} Members').format(memberCount) }}
|
|
||||||
</div> -->
|
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="searchFilter"
|
v-model="searchFilter"
|
||||||
:placeholder="__('Search by Member')"
|
:placeholder="__('Search by Member')"
|
||||||
@@ -151,7 +148,7 @@ import {
|
|||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { theme } from '@/utils/theme'
|
import { theme } from '@/utils/theme'
|
||||||
|
|
||||||
const show = defineModel<boolean | undefined>()
|
const show = defineModel<boolean>({ default: false })
|
||||||
const searchFilter = ref<string | null>(null)
|
const searchFilter = ref<string | null>(null)
|
||||||
type Filters = {
|
type Filters = {
|
||||||
course: string | undefined
|
course: string | undefined
|
||||||
@@ -225,7 +222,6 @@ const progressColumns = computed(() => {
|
|||||||
{
|
{
|
||||||
label: __('Progress'),
|
label: __('Progress'),
|
||||||
key: 'progress',
|
key: 'progress',
|
||||||
width: '30%',
|
|
||||||
align: 'right',
|
align: 'right',
|
||||||
icon: 'trending-up',
|
icon: 'trending-up',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -117,14 +117,7 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import { Avatar, Button, createResource, Dialog, FormControl } from 'frappe-ui'
|
||||||
Avatar,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
createResource,
|
|
||||||
Dialog,
|
|
||||||
FormControl,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ref, watch, reactive, inject } from 'vue'
|
import { ref, watch, reactive, inject } from 'vue'
|
||||||
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
|
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
|
||||||
@@ -146,7 +139,6 @@ const start = ref(0)
|
|||||||
const memberList = ref<Member[]>([])
|
const memberList = ref<Member[]>([])
|
||||||
const hasNextPage = ref(false)
|
const hasNextPage = ref(false)
|
||||||
const showForm = ref(false)
|
const showForm = ref(false)
|
||||||
const dayjs = inject('$dayjs')
|
|
||||||
const user = inject<User | null>('$user')
|
const user = inject<User | null>('$user')
|
||||||
const { updateOnboardingStep } = useOnboarding('learning')
|
const { updateOnboardingStep } = useOnboarding('learning')
|
||||||
|
|
||||||
|
|||||||
@@ -109,13 +109,6 @@ const tabsStructure = computed(() => {
|
|||||||
'If enabled, users can access the course and batch lists without logging in.',
|
'If enabled, users can access the course and batch lists without logging in.',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Enable Learning Paths',
|
|
||||||
name: 'enable_learning_paths',
|
|
||||||
description:
|
|
||||||
'This will ensure students follow the assigned programs in order.',
|
|
||||||
type: 'checkbox',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Prevent Skipping Videos',
|
label: 'Prevent Skipping Videos',
|
||||||
name: 'prevent_skipping_videos',
|
name: 'prevent_skipping_videos',
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
{{ __(link.label) }}
|
{{ __(link.label) }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="link.count"
|
v-if="link.count && !isCollapsed"
|
||||||
class="!ml-auto block text-xs text-ink-gray-5"
|
class="!ml-auto block text-xs text-ink-gray-5"
|
||||||
:class="
|
:class="
|
||||||
isCollapsed && link.count > 9
|
isCollapsed && link.count > 9
|
||||||
|
|||||||
4
frontend/src/global.d.ts
vendored
4
frontend/src/global.d.ts
vendored
@@ -2,6 +2,10 @@ export {}
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
function __(text: string): string
|
function __(text: string): string
|
||||||
|
|
||||||
|
interface String {
|
||||||
|
format(...args: any[]): string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
|
|||||||
@@ -209,12 +209,13 @@
|
|||||||
v-else
|
v-else
|
||||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
|
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
|
||||||
>
|
>
|
||||||
<LessonContent
|
content
|
||||||
|
<!-- <LessonContent
|
||||||
v-if="lesson.data?.body"
|
v-if="lesson.data?.body"
|
||||||
:content="lesson.data.body"
|
:content="lesson.data.body"
|
||||||
:youtube="lesson.data.youtube"
|
:youtube="lesson.data.youtube"
|
||||||
:quizId="lesson.data.quiz_id"
|
:quizId="lesson.data.quiz_id"
|
||||||
/>
|
/> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,379 +0,0 @@
|
|||||||
<template>
|
|
||||||
<header
|
|
||||||
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
|
||||||
>
|
|
||||||
<Breadcrumbs :items="breadbrumbs" />
|
|
||||||
<Button variant="solid" @click="saveProgram()">
|
|
||||||
{{ __('Save') }}
|
|
||||||
</Button>
|
|
||||||
</header>
|
|
||||||
<div v-if="program.doc" class="pt-5 px-5 w-3/4 mx-auto space-y-10">
|
|
||||||
<FormControl v-model="program.doc.title" :label="__('Title')" />
|
|
||||||
|
|
||||||
<!-- Courses -->
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
|
||||||
{{ __('Program Courses') }}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
currentForm = 'course'
|
|
||||||
showDialog = true
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
</template>
|
|
||||||
{{ __('Add') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ListView
|
|
||||||
:columns="courseColumns"
|
|
||||||
:rows="program.doc.program_courses"
|
|
||||||
row-key="name"
|
|
||||||
:options="{
|
|
||||||
showTooltip: false,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<ListHeader
|
|
||||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
|
||||||
>
|
|
||||||
<ListHeaderItem :item="item" v-for="item in courseColumns" />
|
|
||||||
</ListHeader>
|
|
||||||
<ListRows>
|
|
||||||
<Draggable
|
|
||||||
:list="program.doc.program_courses"
|
|
||||||
item-key="name"
|
|
||||||
group="items"
|
|
||||||
@end="updateOrder"
|
|
||||||
class="cursor-move"
|
|
||||||
>
|
|
||||||
<template #item="{ element: row }">
|
|
||||||
<ListRow :row="row" />
|
|
||||||
</template>
|
|
||||||
</Draggable>
|
|
||||||
</ListRows>
|
|
||||||
<ListSelectBanner>
|
|
||||||
<template #actions="{ unselectAll, selections }">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
@click="remove(selections, unselectAll, 'program_courses')"
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ListSelectBanner>
|
|
||||||
</ListView>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Members -->
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
|
||||||
{{ __('Program Members') }}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
currentForm = 'member'
|
|
||||||
showDialog = true
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
</template>
|
|
||||||
{{ __('Add') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ListView
|
|
||||||
:columns="memberColumns"
|
|
||||||
:rows="program.doc.program_members"
|
|
||||||
row-key="name"
|
|
||||||
:options="{
|
|
||||||
showTooltip: false,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<ListHeader
|
|
||||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
|
||||||
>
|
|
||||||
<ListHeaderItem :item="item" v-for="item in memberColumns" />
|
|
||||||
</ListHeader>
|
|
||||||
<ListRows>
|
|
||||||
<ListRow :row="row" v-for="row in program.doc.program_members" />
|
|
||||||
</ListRows>
|
|
||||||
<ListSelectBanner>
|
|
||||||
<template #actions="{ unselectAll, selections }">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
@click="remove(selections, unselectAll, 'program_members')"
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ListSelectBanner>
|
|
||||||
</ListView>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
v-model="showDialog"
|
|
||||||
:options="{
|
|
||||||
title:
|
|
||||||
currentForm == 'course'
|
|
||||||
? __('New Program Course')
|
|
||||||
: __('New Program Member'),
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: __('Add'),
|
|
||||||
variant: 'solid',
|
|
||||||
onClick: () =>
|
|
||||||
currentForm == 'course'
|
|
||||||
? addProgramCourse(close)
|
|
||||||
: addProgramMember(close),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #body-content>
|
|
||||||
<Link
|
|
||||||
v-if="currentForm == 'course'"
|
|
||||||
v-model="course"
|
|
||||||
doctype="LMS Course"
|
|
||||||
:filters="{
|
|
||||||
disable_self_learning: 1,
|
|
||||||
}"
|
|
||||||
:label="__('Program Course')"
|
|
||||||
:description="
|
|
||||||
__(
|
|
||||||
'Only courses for which self learning is disabled can be added to program.'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
v-if="currentForm == 'member'"
|
|
||||||
v-model="member"
|
|
||||||
doctype="User"
|
|
||||||
:filters="{
|
|
||||||
ignore_user_type: 1,
|
|
||||||
}"
|
|
||||||
:label="__('Program Member')"
|
|
||||||
:onCreate="(value, close) => openSettings('Members', close)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import {
|
|
||||||
Breadcrumbs,
|
|
||||||
Button,
|
|
||||||
call,
|
|
||||||
createDocumentResource,
|
|
||||||
Dialog,
|
|
||||||
FormControl,
|
|
||||||
ListView,
|
|
||||||
ListRows,
|
|
||||||
ListRow,
|
|
||||||
ListHeader,
|
|
||||||
ListHeaderItem,
|
|
||||||
ListSelectBanner,
|
|
||||||
usePageMeta,
|
|
||||||
toast,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { sessionStore } from '@/stores/session'
|
|
||||||
import { openSettings } from '@/utils'
|
|
||||||
import Draggable from 'vuedraggable'
|
|
||||||
import Link from '@/components/Controls/Link.vue'
|
|
||||||
|
|
||||||
const { brand } = sessionStore()
|
|
||||||
const showDialog = ref(false)
|
|
||||||
const currentForm = ref(null)
|
|
||||||
const course = ref(null)
|
|
||||||
const member = ref(null)
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
programName: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const program = createDocumentResource({
|
|
||||||
doctype: 'LMS Program',
|
|
||||||
name: props.programName,
|
|
||||||
auto: true,
|
|
||||||
cache: ['program', props.programName],
|
|
||||||
})
|
|
||||||
|
|
||||||
const addProgramCourse = () => {
|
|
||||||
program.setValue.submit(
|
|
||||||
{
|
|
||||||
program_courses: [
|
|
||||||
...program.doc.program_courses,
|
|
||||||
{ course: course.value },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
showDialog.value = false
|
|
||||||
course.value = null
|
|
||||||
toast.success(__('Course added to program'))
|
|
||||||
program.reload()
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
toast.error(err.messages?.[0] || err)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addProgramMember = () => {
|
|
||||||
program.setValue.submit(
|
|
||||||
{
|
|
||||||
program_members: [
|
|
||||||
...program.doc.program_members,
|
|
||||||
{ member: member.value },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
showDialog.value = false
|
|
||||||
member.value = null
|
|
||||||
toast.success(__('Member added to program'))
|
|
||||||
program.reload()
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
toast.error(err.messages?.[0] || err)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const remove = (selections, unselectAll, doctype) => {
|
|
||||||
selections = Array.from(selections)
|
|
||||||
program.setValue.submit(
|
|
||||||
{
|
|
||||||
[doctype]: program.doc[doctype].filter(
|
|
||||||
(row) => !selections.includes(row.name)
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
unselectAll()
|
|
||||||
toast.success(__('Items removed successfully'))
|
|
||||||
program.reload()
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
toast.error(err.messages?.[0] || err)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateOrder = (e) => {
|
|
||||||
let sourceIdx = e.from.dataset.idx
|
|
||||||
let targetIdx = e.to.dataset.idx
|
|
||||||
let courses = program.doc.program_courses
|
|
||||||
courses.splice(targetIdx, 0, courses.splice(sourceIdx, 1)[0])
|
|
||||||
|
|
||||||
courses.forEach((course, index) => {
|
|
||||||
course.idx = index + 1
|
|
||||||
})
|
|
||||||
|
|
||||||
program.setValue.submit(
|
|
||||||
{
|
|
||||||
program_courses: courses,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
toast.success(__('Course moved successfully'))
|
|
||||||
program.reload()
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
toast.error(err.messages?.[0] || err)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveProgram = () => {
|
|
||||||
call('frappe.model.rename_doc.update_document_title', {
|
|
||||||
doctype: 'LMS Program',
|
|
||||||
docname: program.doc.name,
|
|
||||||
name: program.doc.title,
|
|
||||||
}).then((data) => {
|
|
||||||
router.push({ name: 'ProgramForm', params: { programName: data } })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const courseColumns = computed(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'Title',
|
|
||||||
key: 'course_title',
|
|
||||||
width: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'ID',
|
|
||||||
key: 'course',
|
|
||||||
width: 3,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const memberColumns = computed(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'Member',
|
|
||||||
key: 'member',
|
|
||||||
width: 3,
|
|
||||||
align: 'left',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Full Name',
|
|
||||||
key: 'full_name',
|
|
||||||
width: 3,
|
|
||||||
align: 'left',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Progress (%)',
|
|
||||||
key: 'progress',
|
|
||||||
width: 3,
|
|
||||||
align: 'right',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const breadbrumbs = computed(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'Programs',
|
|
||||||
route: { name: 'Programs' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: props.programName === 'new' ? 'New Program' : props.programName,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
usePageMeta(() => {
|
|
||||||
return {
|
|
||||||
title: program.doc?.title,
|
|
||||||
icon: brand.favicon,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
<template>
|
|
||||||
<header
|
|
||||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
|
||||||
>
|
|
||||||
<Breadcrumbs :items="breadbrumbs" />
|
|
||||||
<Button
|
|
||||||
v-if="canCreateProgram()"
|
|
||||||
@click="showDialog = true"
|
|
||||||
variant="solid"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<Plus class="h-4 w-4 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
{{ __('New') }}
|
|
||||||
</Button>
|
|
||||||
</header>
|
|
||||||
<div v-if="programs.data?.length" class="pt-5 px-5">
|
|
||||||
<div v-for="program in programs.data" class="mb-10">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="text-xl text-ink-gray-9 font-semibold">
|
|
||||||
{{ program.name }}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<Badge
|
|
||||||
v-if="program.members"
|
|
||||||
variant="subtle"
|
|
||||||
theme="green"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{{ program.members }}
|
|
||||||
{{ program.members == 1 ? __('member') : __('members') }}
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
v-if="program.progress"
|
|
||||||
variant="subtle"
|
|
||||||
theme="blue"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{{ program.progress }}{{ __('% completed') }}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<router-link
|
|
||||||
v-if="user.data?.is_moderator || user.data?.is_instructor"
|
|
||||||
:to="{
|
|
||||||
name: 'ProgramForm',
|
|
||||||
params: { programName: program.name },
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button v-if="!readOnlyMode">
|
|
||||||
<template #prefix>
|
|
||||||
<Edit class="h-4 w-4 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
{{ __('Edit') }}
|
|
||||||
</Button>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="program.courses?.length"
|
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 mt-5"
|
|
||||||
>
|
|
||||||
<div v-for="course in program.courses" class="relative group">
|
|
||||||
<CourseCard
|
|
||||||
:course="course"
|
|
||||||
@click="enrollMember(program.name, course.name)"
|
|
||||||
class="cursor-pointer"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="lockCourse(course)"
|
|
||||||
class="absolute inset-0 bg-black-overlay-500 opacity-60 rounded-md"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
v-if="lockCourse(course)"
|
|
||||||
class="absolute inset-0 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<LockKeyhole class="size-10 text-ink-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-sm italic text-ink-gray-5 mt-4">
|
|
||||||
{{ __('No courses in this program') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<EmptyState v-else type="Programs" />
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
v-model="showDialog"
|
|
||||||
:options="{
|
|
||||||
title: __('New Program'),
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: __('Create'),
|
|
||||||
variant: 'solid',
|
|
||||||
onClick: () => createProgram(close),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #body-content>
|
|
||||||
<FormControl :label="__('Title')" v-model="title" />
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Breadcrumbs,
|
|
||||||
Button,
|
|
||||||
call,
|
|
||||||
createResource,
|
|
||||||
Dialog,
|
|
||||||
FormControl,
|
|
||||||
usePageMeta,
|
|
||||||
toast,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { computed, inject, onMounted, ref } from 'vue'
|
|
||||||
import { Edit, Plus, LockKeyhole } from 'lucide-vue-next'
|
|
||||||
import CourseCard from '@/components/CourseCard.vue'
|
|
||||||
import EmptyState from '@/components/EmptyState.vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { sessionStore } from '../stores/session'
|
|
||||||
import { useSettings } from '@/stores/settings'
|
|
||||||
|
|
||||||
const { brand } = sessionStore()
|
|
||||||
const user = inject('$user')
|
|
||||||
const showDialog = ref(false)
|
|
||||||
const router = useRouter()
|
|
||||||
const title = ref('')
|
|
||||||
const settings = useSettings()
|
|
||||||
const readOnlyMode = window.read_only_mode
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (
|
|
||||||
!settings.learningPaths.data &&
|
|
||||||
!user.data?.is_moderator &&
|
|
||||||
!user.data?.is_instructor
|
|
||||||
) {
|
|
||||||
router.push({ name: 'Courses' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const programs = createResource({
|
|
||||||
url: 'lms.lms.utils.get_programs',
|
|
||||||
auto: true,
|
|
||||||
cache: 'programs',
|
|
||||||
})
|
|
||||||
|
|
||||||
const createProgram = (close) => {
|
|
||||||
call('frappe.client.insert', {
|
|
||||||
doc: {
|
|
||||||
doctype: 'LMS Program',
|
|
||||||
title: title.value,
|
|
||||||
},
|
|
||||||
}).then((res) => {
|
|
||||||
router.push({ name: 'ProgramForm', params: { programName: res.name } })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const enrollMember = (program, course) => {
|
|
||||||
call('lms.lms.utils.enroll_in_program_course', {
|
|
||||||
program: program,
|
|
||||||
course: course,
|
|
||||||
})
|
|
||||||
.then((data) => {
|
|
||||||
if (data.current_lesson) {
|
|
||||||
router.push({
|
|
||||||
name: 'Lesson',
|
|
||||||
params: {
|
|
||||||
courseName: course,
|
|
||||||
chapterNumber: data.current_lesson.split('-')[0],
|
|
||||||
lessonNumber: data.current_lesson.split('-')[1],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (data) {
|
|
||||||
router.push({
|
|
||||||
name: 'Lesson',
|
|
||||||
params: {
|
|
||||||
courseName: course,
|
|
||||||
chapterNumber: 1,
|
|
||||||
lessonNumber: 1,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
toast.error(err.messages?.[0] || err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const lockCourse = (course) => {
|
|
||||||
if (user.data?.is_moderator || user.data?.is_instructor) return false
|
|
||||||
if (course.membership) return false
|
|
||||||
if (course.eligible) return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const canCreateProgram = () => {
|
|
||||||
if (readOnlyMode) return false
|
|
||||||
if (user.data?.is_moderator || user.data?.is_instructor) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const breadbrumbs = computed(() => [
|
|
||||||
{
|
|
||||||
label: __('Programs'),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
usePageMeta(() => {
|
|
||||||
return {
|
|
||||||
title: __('Programs'),
|
|
||||||
icon: brand.favicon,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
140
frontend/src/pages/Programs/ProgramDetail.vue
Normal file
140
frontend/src/pages/Programs/ProgramDetail.vue
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||||
|
>
|
||||||
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
|
</header>
|
||||||
|
<div v-if="program.data" class="pt-5 px-5 pb-10 mx-auto">
|
||||||
|
<div class="flex items-center space-x-2 mb-5">
|
||||||
|
<div class="text-lg font-semibold text-ink-gray-9">
|
||||||
|
{{ program.data.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Badge :theme="program.data.progress < 100 ? 'orange' : 'green'">
|
||||||
|
{{ program.data.progress }}% {{ __('completed') }}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
v-if="program.data.enforce_course_order"
|
||||||
|
placement="right"
|
||||||
|
:text="
|
||||||
|
__(
|
||||||
|
'Courses must be completed in order. You can only start the next course after completing the previous one.'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Info class="size-3 cursor-pointer" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 mb-5">
|
||||||
|
<div
|
||||||
|
v-for="course in program.data.courses"
|
||||||
|
:key="course.name"
|
||||||
|
class="relative group"
|
||||||
|
:class="
|
||||||
|
(course.eligible && program.data.enforce_course_order) ||
|
||||||
|
!program.data.enforce_course_order
|
||||||
|
? 'cursor-pointer'
|
||||||
|
: 'cursor-default'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<CourseCard
|
||||||
|
:course="course"
|
||||||
|
@click="openCourse(course, program.data.enforce_course_order)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="!course.eligible && program.data.enforce_course_order"
|
||||||
|
class="absolute inset-0 flex flex-col items-center justify-center space-y-2 text-ink-white rounded-md invisible group-hover:visible"
|
||||||
|
:style="{
|
||||||
|
background: 'radial-gradient(circle, darkgray 0%, lightgray 100%)',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<LockKeyhole class="size-5" />
|
||||||
|
<span class="font-medium text-center leading-5 px-10">
|
||||||
|
{{ __('Please complete the previous course to unlock this one.') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject, onMounted } from 'vue'
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Breadcrumbs,
|
||||||
|
call,
|
||||||
|
createResource,
|
||||||
|
Tooltip,
|
||||||
|
usePageMeta,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { LockKeyhole, Info } from 'lucide-vue-next'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import CourseCard from '@/components/CourseCard.vue'
|
||||||
|
|
||||||
|
const { brand } = sessionStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const user = inject<any>('$user')
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
programName: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkIfEnrolled()
|
||||||
|
})
|
||||||
|
|
||||||
|
const checkIfEnrolled = () => {
|
||||||
|
call('frappe.client.get_value', {
|
||||||
|
doctype: 'LMS Program Member',
|
||||||
|
filters: {
|
||||||
|
member: user.data.name,
|
||||||
|
parent: props.programName,
|
||||||
|
},
|
||||||
|
parent: 'LMS Program',
|
||||||
|
fieldname: 'name',
|
||||||
|
}).then((data: { name: string }) => {
|
||||||
|
if (data.name) {
|
||||||
|
program.reload()
|
||||||
|
} else {
|
||||||
|
router.push({ name: 'Programs' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const program = createResource({
|
||||||
|
url: 'lms.lms.utils.get_program_details',
|
||||||
|
params: {
|
||||||
|
program_name: props.programName,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const openCourse = (course: any, enforceCourseOrder: boolean) => {
|
||||||
|
if (!course.eligible && enforceCourseOrder) return
|
||||||
|
router.push({
|
||||||
|
name: 'CourseDetail',
|
||||||
|
params: { courseName: course.name },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
return [
|
||||||
|
{ label: __('Programs'), route: { name: 'Programs' } },
|
||||||
|
{
|
||||||
|
label: props.programName,
|
||||||
|
route: {
|
||||||
|
name: 'ProgramDetail',
|
||||||
|
params: { programName: props.programName },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
usePageMeta(() => {
|
||||||
|
return {
|
||||||
|
title: props.programName,
|
||||||
|
icon: brand.favicon,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
157
frontend/src/pages/Programs/ProgramEnrollment.vue
Normal file
157
frontend/src/pages/Programs/ProgramEnrollment.vue
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
size: '2xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-title>
|
||||||
|
<div v-if="program.data" class="text-xl font-semibold text-ink-gray-9">
|
||||||
|
{{ __('Enrollment for Program {0}').format(program.data?.name) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #body-content>
|
||||||
|
<div v-if="program.data" class="text-base">
|
||||||
|
<div class="bg-surface-blue-2 text-ink-blue-3 p-2 rounded-md leading-5">
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
__('This program consists of {0} courses').format(
|
||||||
|
program.data.courses.length
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span v-if="program.data.enforce_course_order">
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
' designed as a structured learning path to guide your progress. Courses in this program must be taken in order, and each course will unlock as you complete the previous one. '
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
' designed as a learning path to guide your progress. You may take the courses in any order that suits you. '
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{ __('Are you sure you want to enroll?') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<div class="text-sm font-semibold text-ink-gray-5">
|
||||||
|
{{ __('Courses in this Program') }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
|
||||||
|
<div
|
||||||
|
v-for="course in program.data.courses"
|
||||||
|
class="flex flex-col border p-2 rounded-md h-full"
|
||||||
|
>
|
||||||
|
<div class="font-semibold leading-5 mb-2">
|
||||||
|
{{ course.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <div class="text-sm text-ink-gray-7 mb-8">
|
||||||
|
{{ course.short_introduction }}
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center space-x-5 text-sm text-ink-gray-5 mb-8"
|
||||||
|
>
|
||||||
|
<Tooltip :text="__('Lessons')">
|
||||||
|
<span class="flex items-center space-x-1">
|
||||||
|
<BookOpen class="size-3 stroke-1.5" />
|
||||||
|
<span> {{ course.lessons }} {{ __('lessons') }} </span>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip :text="__('Enrolled Students')">
|
||||||
|
<span class="flex items-center space-x-1">
|
||||||
|
<User class="size-3 stroke-1.5" />
|
||||||
|
<span> {{ course.enrollments }} {{ __('students') }} </span>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<!-- <Tooltip v-if="course.rating" :text="__('Average Rating')">
|
||||||
|
<span class="flex items-center space-x-1">
|
||||||
|
<Star class="size-3 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ course.rating }} {{ __("rating") }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Tooltip> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-1 mt-auto">
|
||||||
|
<UserAvatar :user="course.instructors[0]" />
|
||||||
|
<span>
|
||||||
|
{{ course.instructors[0].full_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ close }">
|
||||||
|
<div class="flex justify-end space-x-2 group">
|
||||||
|
<Button variant="solid" @click="enrollInProgram(close)">
|
||||||
|
{{ __('Confirm Enrollment') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Button, call, createResource, Dialog, toast, Tooltip } from 'frappe-ui'
|
||||||
|
import { inject, watch } from 'vue'
|
||||||
|
import { BookOpen, Star, User } from 'lucide-vue-next'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const user = inject<any>('$user')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
programName: any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const program = createResource({
|
||||||
|
url: 'lms.lms.utils.get_program_details',
|
||||||
|
makeParams(values: any) {
|
||||||
|
return {
|
||||||
|
program_name: props.programName,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.programName,
|
||||||
|
() => {
|
||||||
|
if (props.programName) {
|
||||||
|
program.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const enrollInProgram = (close: () => void) => {
|
||||||
|
call('lms.lms.utils.enroll_in_program', {
|
||||||
|
program: props.programName,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(__('Successfully enrolled in program'))
|
||||||
|
router.push({
|
||||||
|
name: 'ProgramDetail',
|
||||||
|
params: { programName: props.programName },
|
||||||
|
})
|
||||||
|
close()
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
toast.error(__('Failed to enroll in program: {0}').format(error.message))
|
||||||
|
console.error('Enrollment Error:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
586
frontend/src/pages/Programs/ProgramForm.vue
Normal file
586
frontend/src/pages/Programs/ProgramForm.vue
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
size: '2xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-title>
|
||||||
|
<div class="flex items-center justify-between space-x-2 text-base w-full">
|
||||||
|
<div class="text-xl font-semibold text-ink-gray-9">
|
||||||
|
{{
|
||||||
|
programName === 'new' ? __('Create Program') : __('Edit Program')
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<Badge theme="orange" v-if="dirty">
|
||||||
|
{{ __('Not Saved') }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="text-base">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 pb-5">
|
||||||
|
<FormControl
|
||||||
|
v-model="program.name"
|
||||||
|
:label="__('Title')"
|
||||||
|
type="text"
|
||||||
|
:required="true"
|
||||||
|
@change="dirty = true"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col space-y-3">
|
||||||
|
<FormControl
|
||||||
|
v-model="program.published"
|
||||||
|
:label="__('Published')"
|
||||||
|
type="checkbox"
|
||||||
|
@change="dirty = true"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="program.enforce_course_order"
|
||||||
|
:label="__('Enforce Course Order')"
|
||||||
|
type="checkbox"
|
||||||
|
@change="dirty = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pb-5">
|
||||||
|
<div class="flex items-center justify-between mt-5 mb-4">
|
||||||
|
<div class="text-lg font-semibold">
|
||||||
|
{{ __('Courses') }}
|
||||||
|
</div>
|
||||||
|
<Button @click="openForm('course')">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
<span>
|
||||||
|
{{ __('Add') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
v-if="programCourses.data.length > 0"
|
||||||
|
:columns="courseColumns"
|
||||||
|
:rows="programCourses.data"
|
||||||
|
:options="{
|
||||||
|
selectable: true,
|
||||||
|
resizeColumn: true,
|
||||||
|
showTooltip: false,
|
||||||
|
}"
|
||||||
|
rowKey="name"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem :item="item" v-for="item in courseColumns" />
|
||||||
|
</ListHeader>
|
||||||
|
<ListRows>
|
||||||
|
<Draggable
|
||||||
|
:list="programCourses.data"
|
||||||
|
item-key="name"
|
||||||
|
group="items"
|
||||||
|
@end="updateOrder"
|
||||||
|
class="cursor-move"
|
||||||
|
>
|
||||||
|
<template #item="{ element: row }">
|
||||||
|
<ListRow :row="row" />
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
</ListRows>
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="remove(selections, unselectAll, 'courses')"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
<div v-else class="text-ink-gray-7">
|
||||||
|
{{ __('No courses added yet.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mt-5 mb-4">
|
||||||
|
<div class="text-lg font-semibold">
|
||||||
|
{{ __('Members') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-x-2">
|
||||||
|
<Button
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
showProgressDialog = true
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<TrendingUp class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Progress Summary') }}
|
||||||
|
</Button>
|
||||||
|
<Button @click="openForm('member')">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Add') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
v-if="programMembers.data.length > 0"
|
||||||
|
:columns="memberColumns"
|
||||||
|
:rows="programMembers.data"
|
||||||
|
:options="{
|
||||||
|
selectable: true,
|
||||||
|
resizeColumn: true,
|
||||||
|
}"
|
||||||
|
rowKey="name"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem :item="item" v-for="item in memberColumns" />
|
||||||
|
</ListHeader>
|
||||||
|
<ListRows>
|
||||||
|
<ListRow :row="row" v-for="row in programMembers.data" />
|
||||||
|
</ListRows>
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="remove(selections, unselectAll, 'members')"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
<div v-else class="text-ink-gray-7">
|
||||||
|
{{ __('No members added yet.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dialog
|
||||||
|
v-model="showFormDialog"
|
||||||
|
:options="{
|
||||||
|
title:
|
||||||
|
currentForm == 'course'
|
||||||
|
? __('Add Course to Program')
|
||||||
|
: __('Enroll Member to Program'),
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Add'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: ({ close }: { close: () => void }) =>
|
||||||
|
currentForm == 'course'
|
||||||
|
? addCourse(close)
|
||||||
|
: addMember(close),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div @click.stop>
|
||||||
|
<Link
|
||||||
|
v-if="currentForm == 'course'"
|
||||||
|
v-model="course"
|
||||||
|
doctype="LMS Course"
|
||||||
|
:label="__('Course')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
v-if="currentForm == 'member'"
|
||||||
|
v-model="member"
|
||||||
|
doctype="User"
|
||||||
|
:filters="{
|
||||||
|
ignore_user_type: 1,
|
||||||
|
}"
|
||||||
|
:label="__('Program Member')"
|
||||||
|
:onCreate="(value: string, close: () => void) => openSettings('Members', close)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ProgramProgressSummary
|
||||||
|
v-model="showProgressDialog"
|
||||||
|
:programName="programName"
|
||||||
|
:programMembers="programMembers.data"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ close }">
|
||||||
|
<div class="flex justify-end space-x-2 group">
|
||||||
|
<Button
|
||||||
|
v-if="programName != 'new'"
|
||||||
|
@click="deleteProgram(close)"
|
||||||
|
variant="outline"
|
||||||
|
theme="red"
|
||||||
|
class="invisible group-hover:visible"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Trash2 class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Delete') }}
|
||||||
|
</Button>
|
||||||
|
<Button variant="solid" @click="saveProgram(close)">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
createListResource,
|
||||||
|
Dialog,
|
||||||
|
FormControl,
|
||||||
|
ListSelectBanner,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { Plus, Trash2, TrendingUp } from 'lucide-vue-next'
|
||||||
|
import { Programs, Program } from '@/types/programs'
|
||||||
|
import { openSettings } from '@/utils'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
import Draggable from 'vuedraggable'
|
||||||
|
import ProgramProgressSummary from '@/pages/Programs/ProgramProgressSummary.vue'
|
||||||
|
|
||||||
|
const show = defineModel<boolean>()
|
||||||
|
const programs = defineModel<Programs>('programs')
|
||||||
|
const showFormDialog = ref(false)
|
||||||
|
const currentForm = ref<'course' | 'member'>('course')
|
||||||
|
const course = ref<string>('')
|
||||||
|
const member = ref<string>('')
|
||||||
|
const showProgressDialog = ref(false)
|
||||||
|
const dirty = ref(false)
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
programName: string | null
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
programName: 'new',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const program = ref<Program>({
|
||||||
|
name: '',
|
||||||
|
title: '',
|
||||||
|
published: false,
|
||||||
|
enforce_course_order: false,
|
||||||
|
program_courses: [],
|
||||||
|
program_members: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.programName,
|
||||||
|
() => {
|
||||||
|
setProgramData()
|
||||||
|
fetchCourses()
|
||||||
|
fetchMembers()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const setProgramData = () => {
|
||||||
|
let isNew = true
|
||||||
|
programs.value?.data.forEach((p: Program) => {
|
||||||
|
if (p.name === props.programName) {
|
||||||
|
isNew = false
|
||||||
|
program.value = { ...p }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
program.value = {
|
||||||
|
name: '',
|
||||||
|
title: '',
|
||||||
|
published: false,
|
||||||
|
enforce_course_order: false,
|
||||||
|
program_courses: [],
|
||||||
|
program_members: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dirty.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const programCourses = createListResource({
|
||||||
|
doctype: 'LMS Program Course',
|
||||||
|
fields: ['course', 'course_title', 'name', 'idx'],
|
||||||
|
cache: ['programCourses', props.programName],
|
||||||
|
parent: 'LMS Program',
|
||||||
|
orderBy: 'idx',
|
||||||
|
onSuccess(data: ProgramCourse[]) {
|
||||||
|
program.value.program_courses = data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const programMembers = createListResource({
|
||||||
|
doctype: 'LMS Program Member',
|
||||||
|
fields: ['member', 'full_name', 'progress', 'name'],
|
||||||
|
cache: ['programMembers', props.programName],
|
||||||
|
parent: 'LMS Program',
|
||||||
|
orderBy: 'creation desc',
|
||||||
|
onSuccess(data: ProgramMember[]) {
|
||||||
|
program.value.program_members = data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchCourses = () => {
|
||||||
|
programCourses.update({
|
||||||
|
filters: {
|
||||||
|
parent: props.programName,
|
||||||
|
parenttype: 'LMS Program',
|
||||||
|
parentfield: 'program_courses',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
programCourses.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMembers = () => {
|
||||||
|
programMembers.update({
|
||||||
|
filters: {
|
||||||
|
parent: props.programName,
|
||||||
|
parenttype: 'LMS Program',
|
||||||
|
parentfield: 'program_members',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
programMembers.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveProgram = (close: () => void) => {
|
||||||
|
if (props.programName === 'new') createNewProgram(close)
|
||||||
|
else updateProgram(close)
|
||||||
|
dirty.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNewProgram = (close: () => void) => {
|
||||||
|
programs.value.insert.submit(
|
||||||
|
{
|
||||||
|
...program.value,
|
||||||
|
title: program.value.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
close()
|
||||||
|
programs.value.reload()
|
||||||
|
toast.success(__('Program created successfully'))
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateProgram = (close: () => void) => {
|
||||||
|
programs.value.setValue.submit(
|
||||||
|
{
|
||||||
|
name: props.programName,
|
||||||
|
...program.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
close()
|
||||||
|
programs.value.reload()
|
||||||
|
toast.success(__('Program updated successfully'))
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openForm = (formType: 'course' | 'member') => {
|
||||||
|
currentForm.value = formType
|
||||||
|
showFormDialog.value = true
|
||||||
|
if (formType === 'course') {
|
||||||
|
course.value = ''
|
||||||
|
} else {
|
||||||
|
member.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCourse = (close: () => void) => {
|
||||||
|
if (!course.value) {
|
||||||
|
toast.warning(__('Please select a course'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
programCourses.insert.submit(
|
||||||
|
{
|
||||||
|
parent: props.programName,
|
||||||
|
parenttype: 'LMS Program',
|
||||||
|
parentfield: 'program_courses',
|
||||||
|
course: course.value,
|
||||||
|
idx: programCourses.data.length + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
updateCounts('course', 'add')
|
||||||
|
close()
|
||||||
|
toast.success(__('Course added to program successfully'))
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addMember = (close: () => void) => {
|
||||||
|
if (!member.value) {
|
||||||
|
toast.warning(__('Please select a member'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
programMembers.insert.submit(
|
||||||
|
{
|
||||||
|
parent: props.programName,
|
||||||
|
parenttype: 'LMS Program',
|
||||||
|
parentfield: 'program_members',
|
||||||
|
member: member.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
updateCounts('member', 'add')
|
||||||
|
close()
|
||||||
|
toast.success(__('Member added to program successfully'))
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCounts = async (
|
||||||
|
type: 'member' | 'course',
|
||||||
|
action: 'add' | 'remove'
|
||||||
|
) => {
|
||||||
|
if (!props.programName) return
|
||||||
|
|
||||||
|
let memberCount = programMembers.data?.length || 0
|
||||||
|
let courseCount = programCourses.data?.length || 0
|
||||||
|
|
||||||
|
if (type === 'member') {
|
||||||
|
memberCount += action === 'add' ? 1 : -1
|
||||||
|
} else {
|
||||||
|
courseCount += action === 'add' ? 1 : -1
|
||||||
|
}
|
||||||
|
|
||||||
|
await programs.value.setValue.submit(
|
||||||
|
{
|
||||||
|
name: props.programName,
|
||||||
|
member_count: memberCount,
|
||||||
|
course_count: courseCount,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
setProgramData()
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateOrder = async (e: DragEvent) => {
|
||||||
|
let sourceIdx = e.from.dataset.idx
|
||||||
|
let targetIdx = e.to.dataset.idx
|
||||||
|
let courses = programCourses.data
|
||||||
|
courses.splice(targetIdx, 0, courses.splice(sourceIdx, 1)[0])
|
||||||
|
|
||||||
|
for (const [index, course] of courses.entries()) {
|
||||||
|
programCourses.setValue.submit(
|
||||||
|
{
|
||||||
|
name: course.name,
|
||||||
|
idx: index + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError(err: any) {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await wait(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wait = (ms: number) => new Promise((res) => setTimeout(res, ms))
|
||||||
|
|
||||||
|
const remove = async (
|
||||||
|
selections: string[],
|
||||||
|
unselectAll: () => void,
|
||||||
|
type: string
|
||||||
|
) => {
|
||||||
|
selections = Array.from(selections)
|
||||||
|
for (const selection of selections) {
|
||||||
|
if (type == 'courses') {
|
||||||
|
await programCourses.delete.submit(selection)
|
||||||
|
await updateCounts('course', 'remove')
|
||||||
|
} else {
|
||||||
|
await programMembers.delete.submit(selection)
|
||||||
|
await updateCounts('member', 'remove')
|
||||||
|
}
|
||||||
|
await programs.value.reload()
|
||||||
|
await wait(100)
|
||||||
|
}
|
||||||
|
unselectAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteProgram = (close: () => void) => {
|
||||||
|
if (props.programName == 'new') return
|
||||||
|
programs.value?.delete.submit(props.programName, {
|
||||||
|
onSuccess() {
|
||||||
|
toast.success(__('Program deleted successfully'))
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const courseColumns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Title',
|
||||||
|
key: 'course_title',
|
||||||
|
width: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const memberColumns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Member',
|
||||||
|
key: 'member',
|
||||||
|
width: 3,
|
||||||
|
align: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Full Name',
|
||||||
|
key: 'full_name',
|
||||||
|
width: 3,
|
||||||
|
align: 'left',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
137
frontend/src/pages/Programs/ProgramProgressSummary.vue
Normal file
137
frontend/src/pages/Programs/ProgramProgressSummary.vue
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Progress Summary for {0}').format(programName),
|
||||||
|
size: '2xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="text-base">
|
||||||
|
<div class="flex items-center justify-between space-x-4 mb-4">
|
||||||
|
<NumberChart
|
||||||
|
class="border rounded-md w-full"
|
||||||
|
:config="{
|
||||||
|
title: __('Enrollments'),
|
||||||
|
value: programMembers.length || 0,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<NumberChart
|
||||||
|
class="border rounded-md w-full"
|
||||||
|
:config="{
|
||||||
|
title: __('Average Progress %'),
|
||||||
|
value: averageProgress || 0,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DonutChart
|
||||||
|
:config="{
|
||||||
|
data: progressDistribution || [],
|
||||||
|
title: __('Progress Distribution'),
|
||||||
|
categoryColumn: 'category',
|
||||||
|
valueColumn: 'count',
|
||||||
|
colors: [
|
||||||
|
theme.colors.red['400'],
|
||||||
|
theme.colors.amber['400'],
|
||||||
|
theme.colors.pink['400'],
|
||||||
|
theme.colors.blue['400'],
|
||||||
|
theme.colors.green['400'],
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mt-10">
|
||||||
|
<FormControl
|
||||||
|
v-model="searchFilter"
|
||||||
|
:placeholder="__('Search by Member')"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<ListView
|
||||||
|
v-if="progressList.length"
|
||||||
|
:columns="progressColumns"
|
||||||
|
:rows="progressList"
|
||||||
|
rowKey="name"
|
||||||
|
:options="{
|
||||||
|
selectable: false,
|
||||||
|
showTooltip: false,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<div v-else class="text-center text-gray-500">
|
||||||
|
{{ __('No members found.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DonutChart,
|
||||||
|
FormControl,
|
||||||
|
ListView,
|
||||||
|
NumberChart,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import type { ProgramMember } from '@/types'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { theme } from '@/utils/theme'
|
||||||
|
|
||||||
|
const show = defineModel<boolean>({ default: false })
|
||||||
|
const searchFilter = ref<string | null>(null)
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
programName: string
|
||||||
|
programMembers: ProgramMember[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const progressList = ref<ProgramMember[]>(props.programMembers || [])
|
||||||
|
|
||||||
|
const progressDistribution = computed(() => {
|
||||||
|
const categories = ['0-20%', '20-40%', '40-60%', '60-80%', '80-100%']
|
||||||
|
const distribution = categories.map((category) => {
|
||||||
|
const [min, max] = category.slice(0, -1).split('-').map(Number)
|
||||||
|
return {
|
||||||
|
category,
|
||||||
|
count: props.programMembers.filter((member) => {
|
||||||
|
const progress = member.progress || 0
|
||||||
|
return progress >= min && progress < max
|
||||||
|
}).length,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return distribution
|
||||||
|
})
|
||||||
|
|
||||||
|
const averageProgress = computed(() => {
|
||||||
|
if (props.programMembers.length === 0) return 0
|
||||||
|
const totalProgress = props.programMembers.reduce(
|
||||||
|
(sum, member) => sum + (member.progress || 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
return totalProgress / props.programMembers.length
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(searchFilter, () => {
|
||||||
|
if (searchFilter.value) {
|
||||||
|
progressList.value = props.programMembers.filter((member) =>
|
||||||
|
member.full_name.toLowerCase().includes(searchFilter.value?.toLowerCase())
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
progressList.value = props.programMembers
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const progressColumns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Member'),
|
||||||
|
key: 'full_name',
|
||||||
|
width: '50%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Progress (%)'),
|
||||||
|
key: 'progress',
|
||||||
|
align: 'right',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
118
frontend/src/pages/Programs/Programs.vue
Normal file
118
frontend/src/pages/Programs/Programs.vue
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||||
|
>
|
||||||
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
|
<Button v-if="canCreateProgram()" @click="openForm('new')" variant="solid">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('New') }}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<div v-if="programs.data?.length && !isStudent" class="py-10 w-3/4 mx-auto">
|
||||||
|
<div class="text-lg font-semibold text-ink-gray-9 mb-5">
|
||||||
|
{{ __('{0} Programs').format(programs.data.length) }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||||
|
<div
|
||||||
|
v-for="program in programs.data"
|
||||||
|
@click="openForm(program.name)"
|
||||||
|
class="border rounded-md p-3 hover:border-outline-gray-3 cursor-pointer space-y-2"
|
||||||
|
>
|
||||||
|
<div class="text-lg font-semibold">
|
||||||
|
{{ program.name }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-1">
|
||||||
|
<BookOpen class="h-4 w-4 stroke-1.5 mr-1" />
|
||||||
|
<span>
|
||||||
|
{{ program.course_count }}
|
||||||
|
{{ program.course_count == 1 ? __('Course') : __('Courses') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-1">
|
||||||
|
<User class="h-4 w-4 stroke-1.5 mr-1" />
|
||||||
|
<span>
|
||||||
|
{{ program.member_count || 0 }}
|
||||||
|
{{ program.member_count == 1 ? __('member') : __('members') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StudentPrograms v-else-if="isStudent" />
|
||||||
|
<EmptyState v-else type="Programs" />
|
||||||
|
<ProgramForm
|
||||||
|
v-model="showForm"
|
||||||
|
:programName="currentProgram"
|
||||||
|
v-model:programs="programs"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Breadcrumbs, Button, usePageMeta, createListResource } from 'frappe-ui'
|
||||||
|
import { computed, inject, onMounted, ref } from 'vue'
|
||||||
|
import { BookOpen, Plus, User } from 'lucide-vue-next'
|
||||||
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import ProgramForm from '@/pages/Programs/ProgramForm.vue'
|
||||||
|
import EmptyState from '@/components/EmptyState.vue'
|
||||||
|
import StudentPrograms from '@/pages/Programs/StudentPrograms.vue'
|
||||||
|
|
||||||
|
const { brand } = sessionStore()
|
||||||
|
const user = inject('$user')
|
||||||
|
const showForm = ref(false)
|
||||||
|
const currentProgram = ref(null)
|
||||||
|
const readOnlyMode = window.read_only_mode
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!user.data) {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
if (user.data?.is_moderator || user.data?.is_instructor) {
|
||||||
|
programs.reload()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const programs = createListResource({
|
||||||
|
doctype: 'LMS Program',
|
||||||
|
cache: ['program'],
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'title',
|
||||||
|
'member_count',
|
||||||
|
'course_count',
|
||||||
|
'published',
|
||||||
|
'enforce_course_order',
|
||||||
|
],
|
||||||
|
auto: false,
|
||||||
|
orderBy: 'creation desc',
|
||||||
|
})
|
||||||
|
|
||||||
|
const canCreateProgram = () => {
|
||||||
|
if (readOnlyMode) return false
|
||||||
|
if (user.data?.is_moderator || user.data?.is_instructor) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const openForm = (programName) => {
|
||||||
|
if (!canCreateProgram()) return
|
||||||
|
currentProgram.value = programName
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStudent = computed(() => {
|
||||||
|
return user.data?.is_student || false
|
||||||
|
})
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => [
|
||||||
|
{
|
||||||
|
label: __('Programs'),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
usePageMeta(() => {
|
||||||
|
return {
|
||||||
|
title: __('Programs'),
|
||||||
|
icon: brand.favicon,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
105
frontend/src/pages/Programs/StudentPrograms.vue
Normal file
105
frontend/src/pages/Programs/StudentPrograms.vue
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<template>
|
||||||
|
<div class="py-5 px-5 w-full lg:w-3/4 lg:px-0 mx-auto">
|
||||||
|
<div class="flex items-center justify-between mb-5">
|
||||||
|
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||||
|
{{ __('All Programs') }}
|
||||||
|
</div>
|
||||||
|
<TabButtons v-model="currentTab" :buttons="tabs" class="w-fit" />
|
||||||
|
</div>
|
||||||
|
<div v-for="(data, category) in programs.data">
|
||||||
|
<div v-if="category == currentTab">
|
||||||
|
<div
|
||||||
|
v-if="data.length > 0"
|
||||||
|
class="grid grid-cols-1 lg:grid-cols-3 gap-5"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="program in data"
|
||||||
|
@click="openDetails(program.name, category)"
|
||||||
|
class="border rounded-md p-3 hover:border-outline-gray-3 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div class="text-lg font-semibold mb-2">
|
||||||
|
{{ program.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-5 text-sm text-ink-gray-7">
|
||||||
|
<div class="flex items-center space-x-1">
|
||||||
|
<BookOpen class="size-3 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ program.course_count }}
|
||||||
|
{{ program.course_count == 1 ? __('course') : __('courses') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-1">
|
||||||
|
<User class="size-4 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ program.member_count || 0 }}
|
||||||
|
{{ program.member_count == 1 ? __('member') : __('members') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="Object.keys(program).includes('progress')" class="mt-5">
|
||||||
|
<ProgressBar :progress="program.progress" />
|
||||||
|
<div class="text-sm mt-1">
|
||||||
|
{{ Math.ceil(program.progress) }}% {{ __('completed') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EmptyState v-else :type="convertToTitleCase(category) + ' Programs'" />
|
||||||
|
<!-- <div v-else class="col-span-3 text-center text-ink-gray-5">
|
||||||
|
{{ __('No programs found in this category.') }}
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ProgramEnrollment
|
||||||
|
v-model="showEnrollmentConfirmation"
|
||||||
|
:programName="enrollmentProgram"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { createResource, TabButtons } from 'frappe-ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { BookOpen, User } from 'lucide-vue-next'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { convertToTitleCase } from '@/utils'
|
||||||
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
|
import ProgramEnrollment from '@/pages/Programs/ProgramEnrollment.vue'
|
||||||
|
import EmptyState from '@/components/EmptyState.vue'
|
||||||
|
|
||||||
|
const currentTab = ref('enrolled')
|
||||||
|
const router = useRouter()
|
||||||
|
const showEnrollmentConfirmation = ref(false)
|
||||||
|
const enrollmentProgram = ref(null)
|
||||||
|
|
||||||
|
const programs = createResource({
|
||||||
|
url: 'lms.lms.utils.get_programs',
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const openDetails = (programName: any, category: string) => {
|
||||||
|
if (category === 'enrolled') {
|
||||||
|
router.push({
|
||||||
|
name: 'ProgramDetail',
|
||||||
|
params: { programName: programName },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
showEnrollmentConfirmation.value = true
|
||||||
|
enrollmentProgram.value = programName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Enrolled'),
|
||||||
|
value: 'enrolled',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Published'),
|
||||||
|
value: 'published',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
50
frontend/src/pages/Programs/types.ts
Normal file
50
frontend/src/pages/Programs/types.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
interface Program {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
published: boolean;
|
||||||
|
enforce_course_order: boolean;
|
||||||
|
program_courses: ProgramCourse[];
|
||||||
|
program_batches: ProgramMember[];
|
||||||
|
course_count: number;
|
||||||
|
member_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProgramCourse {
|
||||||
|
course: string;
|
||||||
|
course_title: string;
|
||||||
|
idx: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProgramMember {
|
||||||
|
member: string;
|
||||||
|
full_name: string;
|
||||||
|
progress: number;
|
||||||
|
idx: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Programs {
|
||||||
|
data: Program[];
|
||||||
|
reload: () => void;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
next: () => void;
|
||||||
|
setValue: {
|
||||||
|
submit: (
|
||||||
|
data: Program,
|
||||||
|
options?: { onSuccess?: () => void }
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
insert: {
|
||||||
|
submit: (
|
||||||
|
data: Program,
|
||||||
|
options?: { onSuccess?: () => void }
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
delete: {
|
||||||
|
submit: (
|
||||||
|
name: string,
|
||||||
|
options?: { onSuccess?: () => void }
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -183,16 +183,16 @@ const routes = [
|
|||||||
component: () => import('@/pages/QuizSubmission.vue'),
|
component: () => import('@/pages/QuizSubmission.vue'),
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/programs/:programName',
|
|
||||||
name: 'ProgramForm',
|
|
||||||
component: () => import('@/pages/ProgramForm.vue'),
|
|
||||||
props: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/programs',
|
path: '/programs',
|
||||||
name: 'Programs',
|
name: 'Programs',
|
||||||
component: () => import('@/pages/Programs.vue'),
|
component: () => import('@/pages/Programs/Programs.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/programs/:programName',
|
||||||
|
name: 'ProgramDetail',
|
||||||
|
component: () => import('@/pages/Programs/ProgramDetail.vue'),
|
||||||
|
props: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/assignments',
|
path: '/assignments',
|
||||||
|
|||||||
@@ -8,13 +8,6 @@ export const useSettings = defineStore('settings', () => {
|
|||||||
const isSettingsOpen = ref(false)
|
const isSettingsOpen = ref(false)
|
||||||
const activeTab = ref(null)
|
const activeTab = ref(null)
|
||||||
|
|
||||||
const learningPaths = createResource({
|
|
||||||
url: 'lms.lms.api.get_lms_setting',
|
|
||||||
params: { field: 'enable_learning_paths' },
|
|
||||||
auto: true,
|
|
||||||
cache: ['learningPath'],
|
|
||||||
})
|
|
||||||
|
|
||||||
const allowGuestAccess = createResource({
|
const allowGuestAccess = createResource({
|
||||||
url: 'lms.lms.api.get_lms_setting',
|
url: 'lms.lms.api.get_lms_setting',
|
||||||
params: { field: 'allow_guest_access' },
|
params: { field: 'allow_guest_access' },
|
||||||
@@ -38,7 +31,6 @@ export const useSettings = defineStore('settings', () => {
|
|||||||
return {
|
return {
|
||||||
isSettingsOpen,
|
isSettingsOpen,
|
||||||
activeTab,
|
activeTab,
|
||||||
learningPaths,
|
|
||||||
allowGuestAccess,
|
allowGuestAccess,
|
||||||
preventSkippingVideos,
|
preventSkippingVideos,
|
||||||
sidebarSettings,
|
sidebarSettings,
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { createResource } from 'frappe-ui'
|
import { createResource } from 'frappe-ui'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
export const usersStore = defineStore('lms-users', () => {
|
export const usersStore = defineStore('lms-users', () => {
|
||||||
let userResource = createResource({
|
let userResource = createResource({
|
||||||
url: 'lms.lms.api.get_user_info',
|
url: 'lms.lms.api.get_user_info',
|
||||||
onError(error) {
|
onError(error) {
|
||||||
if (error && error.exc_type === 'AuthenticationError') {
|
if (error && error.exc_type === 'AuthenticationError') {
|
||||||
router.push('/login')
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
from lms.lms.doctype.lms_enrollment.lms_enrollment import update_program_progress
|
||||||
from lms.lms.utils import get_course_progress
|
from lms.lms.utils import get_course_progress
|
||||||
|
|
||||||
|
|
||||||
@@ -19,3 +20,4 @@ class LMSCourseProgress(Document):
|
|||||||
"name",
|
"name",
|
||||||
)
|
)
|
||||||
frappe.db.set_value("LMS Enrollment", membership, "progress", progress)
|
frappe.db.set_value("LMS Enrollment", membership, "progress", progress)
|
||||||
|
update_program_progress(self.member)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class LMSEnrollment(Document):
|
|||||||
self.validate_membership_in_different_batch_same_course()
|
self.validate_membership_in_different_batch_same_course()
|
||||||
|
|
||||||
def on_update(self):
|
def on_update(self):
|
||||||
self.update_program_progress()
|
update_program_progress(self.member)
|
||||||
|
|
||||||
def validate_membership_in_same_batch(self):
|
def validate_membership_in_same_batch(self):
|
||||||
filters = {"member": self.member, "course": self.course, "name": ["!=", self.name]}
|
filters = {"member": self.member, "course": self.course, "name": ["!=", self.name]}
|
||||||
@@ -59,21 +59,20 @@ class LMSEnrollment(Document):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_program_progress(self):
|
|
||||||
programs = frappe.get_all("LMS Program Member", {"member": self.member}, ["parent", "name"])
|
|
||||||
|
|
||||||
for program in programs:
|
def update_program_progress(member):
|
||||||
total_progress = 0
|
programs = frappe.get_all("LMS Program Member", {"member": member}, ["parent", "name"])
|
||||||
courses = frappe.get_all("LMS Program Course", {"parent": program.parent}, pluck="course")
|
|
||||||
for course in courses:
|
|
||||||
progress = frappe.db.get_value(
|
|
||||||
"LMS Enrollment", {"course": course, "member": self.member}, "progress"
|
|
||||||
)
|
|
||||||
progress = progress or 0
|
|
||||||
total_progress += progress
|
|
||||||
|
|
||||||
average_progress = ceil(total_progress / len(courses))
|
for program in programs:
|
||||||
frappe.db.set_value("LMS Program Member", program.name, "progress", average_progress)
|
total_progress = 0
|
||||||
|
courses = frappe.get_all("LMS Program Course", {"parent": program.parent}, pluck="course")
|
||||||
|
for course in courses:
|
||||||
|
progress = frappe.db.get_value("LMS Enrollment", {"course": course, "member": member}, "progress")
|
||||||
|
progress = progress or 0
|
||||||
|
total_progress += progress
|
||||||
|
|
||||||
|
average_progress = ceil(total_progress / len(courses))
|
||||||
|
frappe.db.set_value("LMS Program Member", program.name, "progress", average_progress)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
|
|||||||
@@ -161,8 +161,8 @@
|
|||||||
"link_fieldname": "payment"
|
"link_fieldname": "payment"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2025-03-13 15:31:38.019002",
|
"modified": "2025-08-19 10:33:15.457678",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Payment",
|
"name": "LMS Payment",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
@@ -180,9 +180,11 @@
|
|||||||
"write": 1
|
"write": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"show_title_field_in_link": 1,
|
"show_title_field_in_link": 1,
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": [],
|
"states": [],
|
||||||
"title_field": "billing_name"
|
"title_field": "billing_name",
|
||||||
}
|
"track_changes": 1
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,8 +7,17 @@
|
|||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
"title",
|
"title",
|
||||||
|
"published",
|
||||||
|
"column_break_cwjx",
|
||||||
|
"enforce_course_order",
|
||||||
|
"column_break_mikl",
|
||||||
|
"section_break_vhhu",
|
||||||
"program_courses",
|
"program_courses",
|
||||||
"program_members"
|
"program_members",
|
||||||
|
"section_break_pppe",
|
||||||
|
"course_count",
|
||||||
|
"column_break_qwhf",
|
||||||
|
"member_count"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@@ -30,12 +39,61 @@
|
|||||||
"label": "Title",
|
"label": "Title",
|
||||||
"reqd": 1,
|
"reqd": 1,
|
||||||
"unique": 1
|
"unique": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"fieldname": "published",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"in_standard_filter": 1,
|
||||||
|
"label": "Published"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"fieldname": "enforce_course_order",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"in_standard_filter": 1,
|
||||||
|
"label": "Enforce Course Order"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_vhhu",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_cwjx",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_pppe",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "course_count",
|
||||||
|
"fieldtype": "Int",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Course Count"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_qwhf",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "member_count",
|
||||||
|
"fieldtype": "Int",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Member Count"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_mikl",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-11-28 22:06:16.742867",
|
"modified": "2025-08-20 12:28:57.238902",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Program",
|
"name": "LMS Program",
|
||||||
"naming_rule": "By fieldname",
|
"naming_rule": "By fieldname",
|
||||||
@@ -76,10 +134,21 @@
|
|||||||
"role": "Course Creator",
|
"role": "Course Creator",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "LMS Student",
|
||||||
|
"share": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "creation",
|
"sort_field": "creation",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": [],
|
"states": [],
|
||||||
|
"title_field": "title",
|
||||||
"track_changes": 1
|
"track_changes": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class LMSProgram(Document):
|
|||||||
def validate(self):
|
def validate(self):
|
||||||
self.validate_program_courses()
|
self.validate_program_courses()
|
||||||
self.validate_program_members()
|
self.validate_program_members()
|
||||||
|
self.update_count()
|
||||||
|
|
||||||
def validate_program_courses(self):
|
def validate_program_courses(self):
|
||||||
courses = [row.course for row in self.program_courses]
|
courses = [row.course for row in self.program_courses]
|
||||||
@@ -30,3 +31,13 @@ class LMSProgram(Document):
|
|||||||
frappe.bold(next(iter(duplicates)))
|
frappe.bold(next(iter(duplicates)))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def update_count(self):
|
||||||
|
course_count = len(self.program_courses)
|
||||||
|
member_count = len(self.program_members)
|
||||||
|
|
||||||
|
if self.course_count != course_count:
|
||||||
|
self.course_count = course_count
|
||||||
|
|
||||||
|
if self.member_count != member_count:
|
||||||
|
self.member_count = member_count
|
||||||
|
|||||||
@@ -27,16 +27,18 @@
|
|||||||
"read_only": 1
|
"read_only": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-11-18 12:43:46.800199",
|
"modified": "2025-08-13 17:32:43.554055",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Program Course",
|
"name": "LMS Program Course",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "creation",
|
"sort_field": "creation",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": []
|
"states": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,16 +35,18 @@
|
|||||||
"label": "Progress"
|
"label": "Progress"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-11-21 12:51:31.882576",
|
"modified": "2025-08-13 17:33:00.265037",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Program Member",
|
"name": "LMS Program Member",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "creation",
|
"sort_field": "creation",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": []
|
"states": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
"persona_captured",
|
"persona_captured",
|
||||||
"column_break_zdel",
|
"column_break_zdel",
|
||||||
"allow_guest_access",
|
"allow_guest_access",
|
||||||
"enable_learning_paths",
|
|
||||||
"prevent_skipping_videos",
|
"prevent_skipping_videos",
|
||||||
"column_break_bjis",
|
"column_break_bjis",
|
||||||
"unsplash_access_key",
|
"unsplash_access_key",
|
||||||
@@ -339,12 +338,6 @@
|
|||||||
"fieldtype": "HTML",
|
"fieldtype": "HTML",
|
||||||
"label": "Payments app is not installed"
|
"label": "Payments app is not installed"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"default": "0",
|
|
||||||
"fieldname": "enable_learning_paths",
|
|
||||||
"fieldtype": "Check",
|
|
||||||
"label": "Enable Learning Paths"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"fieldname": "general_tab",
|
"fieldname": "general_tab",
|
||||||
"fieldtype": "Tab Break",
|
"fieldtype": "Tab Break",
|
||||||
@@ -429,7 +422,7 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-07-01 17:01:58.466698",
|
"modified": "2025-08-12 16:47:49.983018",
|
||||||
"modified_by": "sayali@frappe.io",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Settings",
|
"name": "LMS Settings",
|
||||||
|
|||||||
139
lms/lms/utils.py
139
lms/lms/utils.py
@@ -1118,6 +1118,7 @@ def get_course_details(course):
|
|||||||
"lessons",
|
"lessons",
|
||||||
"enrollments",
|
"enrollments",
|
||||||
"rating",
|
"rating",
|
||||||
|
"card_gradient",
|
||||||
],
|
],
|
||||||
as_dict=1,
|
as_dict=1,
|
||||||
)
|
)
|
||||||
@@ -1270,7 +1271,6 @@ def get_lesson(course, chapter, lesson):
|
|||||||
progress = get_progress(course, lesson_details.name)
|
progress = get_progress(course, lesson_details.name)
|
||||||
|
|
||||||
lesson_details.chapter_title = frappe.db.get_value("Course Chapter", chapter_name, "title")
|
lesson_details.chapter_title = frappe.db.get_value("Course Chapter", chapter_name, "title")
|
||||||
lesson_details.rendered_content = render_html(lesson_details)
|
|
||||||
neighbours = get_neighbour_lesson(course, chapter, lesson)
|
neighbours = get_neighbour_lesson(course, chapter, lesson)
|
||||||
lesson_details.next = neighbours["next"]
|
lesson_details.next = neighbours["next"]
|
||||||
lesson_details.progress = progress
|
lesson_details.progress = progress
|
||||||
@@ -1919,80 +1919,93 @@ def update_certificate_purchase(course, payment_name):
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_programs():
|
def get_programs():
|
||||||
if has_course_moderator_role() or has_course_instructor_role() or has_course_evaluator_role():
|
enrolled_programs = frappe.get_all(
|
||||||
programs = frappe.get_all("LMS Program", fields=["name"])
|
"LMS Program Member", {"member": frappe.session.user}, ["parent as name", "progress"]
|
||||||
else:
|
)
|
||||||
programs = frappe.get_all(
|
for program in enrolled_programs:
|
||||||
"LMS Program Member", {"member": frappe.session.user}, ["parent as name", "progress"]
|
program.update(
|
||||||
|
frappe.db.get_value(
|
||||||
|
"LMS Program", program.name, ["name", "course_count", "member_count"], as_dict=True
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
for program in programs:
|
published_programs = frappe.get_all(
|
||||||
program_courses = frappe.get_all(
|
"LMS Program",
|
||||||
"LMS Program Course", {"parent": program.name}, ["course"], order_by="idx"
|
{
|
||||||
)
|
"published": 1,
|
||||||
program.courses = []
|
},
|
||||||
previous_progress = 0
|
["name", "course_count", "member_count"],
|
||||||
for i, course in enumerate(program_courses):
|
)
|
||||||
details = get_course_details(course.course)
|
|
||||||
if i == 0:
|
|
||||||
details.eligible = True
|
|
||||||
elif previous_progress == 100:
|
|
||||||
details.eligible = True
|
|
||||||
else:
|
|
||||||
details.eligible = False
|
|
||||||
|
|
||||||
previous_progress = details.membership.progress if details.membership else 0
|
programs_to_remove = []
|
||||||
program.courses.append(details)
|
for program in published_programs:
|
||||||
|
if program.name in [p.name for p in enrolled_programs]:
|
||||||
|
programs_to_remove.append(program)
|
||||||
|
published_programs = [program for program in published_programs if program not in programs_to_remove]
|
||||||
|
|
||||||
program.members = frappe.db.count("LMS Program Member", {"parent": program.name})
|
return {
|
||||||
|
"enrolled": enrolled_programs,
|
||||||
return programs
|
"published": published_programs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def enroll_in_program_course(program, course):
|
def get_program_details(program_name):
|
||||||
enrollment = frappe.db.exists("LMS Enrollment", {"member": frappe.session.user, "course": course})
|
program = frappe.db.get_value(
|
||||||
|
"LMS Program",
|
||||||
if enrollment:
|
program_name,
|
||||||
enrollment = frappe.db.get_value("LMS Enrollment", enrollment, ["name", "current_lesson"], as_dict=1)
|
[
|
||||||
enrollment.current_lesson = get_lesson_index(enrollment.current_lesson)
|
"name",
|
||||||
return enrollment
|
"member_count",
|
||||||
|
"course_count",
|
||||||
|
"published",
|
||||||
|
"allow_self_enrollment",
|
||||||
|
"enforce_course_order",
|
||||||
|
],
|
||||||
|
as_dict=1,
|
||||||
|
)
|
||||||
program_courses = frappe.get_all(
|
program_courses = frappe.get_all(
|
||||||
"LMS Program Course", {"parent": program}, ["course", "idx"], order_by="idx"
|
"LMS Program Course", {"parent": program_name}, ["course"], order_by="idx"
|
||||||
)
|
)
|
||||||
current_course_idx = [
|
|
||||||
program_course.idx for program_course in program_courses if program_course.course == course
|
|
||||||
][0]
|
|
||||||
|
|
||||||
for program_course in program_courses:
|
program.courses = []
|
||||||
if program_course.idx < current_course_idx:
|
previous_progress = 0
|
||||||
enrollment = frappe.db.get_value(
|
for i, course in enumerate(program_courses):
|
||||||
"LMS Enrollment",
|
details = get_course_details(course.course)
|
||||||
{"member": frappe.session.user, "course": program_course.course},
|
if i == 0:
|
||||||
["name", "progress"],
|
details.eligible = True
|
||||||
as_dict=1,
|
elif previous_progress == 100:
|
||||||
|
details.eligible = True
|
||||||
|
else:
|
||||||
|
details.eligible = False
|
||||||
|
|
||||||
|
previous_progress = details.membership.progress if details.membership else 0
|
||||||
|
program.courses.append(details)
|
||||||
|
if frappe.session.user != "Guest":
|
||||||
|
program.progress = frappe.db.get_value(
|
||||||
|
"LMS Program Member",
|
||||||
|
{"parent": program_name, "member": frappe.session.user},
|
||||||
|
"progress",
|
||||||
)
|
)
|
||||||
if enrollment and enrollment.progress != 100:
|
|
||||||
frappe.throw(
|
|
||||||
_("Please complete the previous courses in the program to enroll in this course.")
|
|
||||||
)
|
|
||||||
elif not enrollment:
|
|
||||||
frappe.throw(
|
|
||||||
_("Please complete the previous courses in the program to enroll in this course.")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
enrollment = frappe.new_doc("LMS Enrollment")
|
return program
|
||||||
enrollment.update(
|
|
||||||
{
|
|
||||||
"member": frappe.session.user,
|
@frappe.whitelist()
|
||||||
"course": course,
|
def enroll_in_program(program):
|
||||||
}
|
if frappe.session.user == "Guest":
|
||||||
)
|
frappe.throw(_("Please login to enroll in the program."))
|
||||||
enrollment.save()
|
if not frappe.db.exists("LMS Program Member", {"parent": program, "member": frappe.session.user}):
|
||||||
return enrollment
|
program_member = frappe.new_doc("LMS Program Member")
|
||||||
|
program_member.update(
|
||||||
|
{
|
||||||
|
"parent": program,
|
||||||
|
"parenttype": "LMS Program",
|
||||||
|
"parentfield": "members",
|
||||||
|
"member": frappe.session.user,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
program_member.save(ignore_permissions=True)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
|
|||||||
@@ -109,4 +109,5 @@ lms.patches.v2_0.link_zoom_account_to_live_class
|
|||||||
lms.patches.v2_0.link_zoom_account_to_batch
|
lms.patches.v2_0.link_zoom_account_to_batch
|
||||||
lms.patches.v2_0.sidebar_for_certified_members
|
lms.patches.v2_0.sidebar_for_certified_members
|
||||||
lms.patches.v2_0.move_batch_instructors_to_evaluators
|
lms.patches.v2_0.move_batch_instructors_to_evaluators
|
||||||
lms.patches.v2_0.enable_programming_exercises_in_sidebar
|
lms.patches.v2_0.enable_programming_exercises_in_sidebar
|
||||||
|
lms.patches.v2_0.count_in_program
|
||||||
18
lms/patches/v2_0/count_in_program.py
Normal file
18
lms/patches/v2_0/count_in_program.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import frappe
|
||||||
|
|
||||||
|
|
||||||
|
def execute():
|
||||||
|
programs = frappe.get_all("LMS Program", pluck="name")
|
||||||
|
|
||||||
|
for program in programs:
|
||||||
|
course_count = frappe.db.count(
|
||||||
|
"LMS Program Course",
|
||||||
|
{"parent": program, "parenttype": "LMS Program", "parentfield": "program_courses"},
|
||||||
|
)
|
||||||
|
frappe.db.set_value("LMS Program", program, "course_count", course_count)
|
||||||
|
|
||||||
|
member_count = frappe.db.count(
|
||||||
|
"LMS Program Member",
|
||||||
|
{"parent": program, "parenttype": "LMS Program", "parentfield": "program_members"},
|
||||||
|
)
|
||||||
|
frappe.db.set_value("LMS Program", program, "member_count", member_count)
|
||||||
Reference in New Issue
Block a user