Merge pull request #1699 from frappe/develop

chore: merge 'develop' into 'main'
This commit is contained in:
Jannat Patel
2025-08-29 17:42:38 +05:30
committed by GitHub
82 changed files with 28302 additions and 10429 deletions
+1 -1
View File
@@ -98,7 +98,7 @@ describe("Course Creation", () => {
// View Course
cy.wait(1000);
cy.visit("/lms");
cy.visit("/lms/courses");
cy.closeOnboardingModal();
cy.url().should("include", "/lms/courses");
+1
View File
@@ -88,6 +88,7 @@ declare module 'vue' {
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
PaymentSettings: typeof import('./src/components/Settings/PaymentSettings.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']
Question: typeof import('./src/components/Modals/Question.vue')['default']
Quiz: typeof import('./src/components/Quiz.vue')['default']
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+29 -27
View File
@@ -196,7 +196,7 @@ import { usersStore } from '@/stores/user'
import { sessionStore } from '@/stores/session'
import { useSidebar } from '@/stores/sidebar'
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 { capture } from '@/telemetry'
import LMSLogo from '@/components/Icons/LMSLogo.vue'
@@ -214,6 +214,7 @@ import {
Users,
BookText,
Zap,
Check,
} from 'lucide-vue-next'
import {
TrialBanner,
@@ -360,35 +361,35 @@ const addProgrammingExercises = () => {
}
}
const addPrograms = () => {
let activeFor = ['Programs', 'ProgramForm']
let index = 1
let canAddProgram = false
const addPrograms = async () => {
let canAddProgram = await checkIfCanAddProgram()
if (!canAddProgram) return
let activeFor = ['Programs', 'ProgramDetail']
let index = 2
if (
!isInstructor.value &&
!isModerator.value &&
settingsStore.learningPaths.data
) {
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
}
sidebarLinks.value.splice(index, 0, {
label: 'Programs',
icon: 'Route',
to: 'Programs',
activeFor: activeFor,
})
}
if (canAddProgram) {
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
}
const addHome = () => {
sidebarLinks.value.unshift({
label: 'Home',
icon: 'Home',
to: 'Home',
activeFor: ['Home'],
})
}
const openPageModal = (link) => {
@@ -642,6 +643,7 @@ watch(userResource, () => {
if (userResource.data) {
isModerator.value = userResource.data.is_moderator
isInstructor.value = userResource.data.is_instructor
addHome()
addPrograms()
addProgrammingExercises()
addQuizzes()
+12 -4
View File
@@ -56,7 +56,7 @@
</div>
<div v-if="!readOnlyMode">
<router-link
v-if="isModerator || isStudent"
v-if="canAccessBatch"
:to="{
name: 'Batch',
params: {
@@ -66,11 +66,11 @@
>
<Button variant="solid" class="w-full mt-4">
<template #prefix>
<Settings v-if="isModerator" class="size-4 stroke-1.5" />
<LogIn v-else class="size-4 stroke-1.5" />
<LogIn v-if="isStudent" class="size-4 stroke-1.5" />
<Settings v-else class="size-4 stroke-1.5" />
</template>
<span>
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
{{ isStudent ? __('Visit Batch') : __('Manage Batch') }}
</span>
</Button>
</router-link>
@@ -204,4 +204,12 @@ const isStudent = computed(() => {
const isModerator = computed(() => {
return user.data?.is_moderator
})
const isEvaluator = computed(() => {
return user.data?.is_evaluator
})
const canAccessBatch = computed(() => {
return isModerator.value || isStudent.value || isEvaluator.value
})
</script>
+2 -2
View File
@@ -1,7 +1,7 @@
<template>
<div
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"
>
<div
@@ -47,7 +47,7 @@
{{ course.title }}
</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 v-if="course.lessons">
<Tooltip :text="__('Lessons')">
+11
View File
@@ -208,6 +208,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
lessonProgress: {
type: Number,
default: 0,
},
})
const outline = createResource({
@@ -229,6 +233,13 @@ watch(
}
)
watch(
() => props.lessonProgress,
() => {
outline.reload()
}
)
const deleteLesson = createResource({
url: 'lms.lms.api.delete_lesson',
makeParams(values) {
+1 -1
View File
@@ -52,7 +52,7 @@
<Clock class="w-4 h-4 stroke-1.5" />
<span>
{{ formatTime(cls.time) }} -
{{ dayjs(getClassEnd(cls)).format('HH:mm') }}
{{ dayjs(getClassEnd(cls)).format('HH:mm A') }}
</span>
</div>
<div
+34 -6
View File
@@ -56,6 +56,7 @@
<script setup>
import { getSidebarLinks } from '@/utils'
import { useRouter } from 'vue-router'
import { call } from 'frappe-ui'
import { watch, ref, onMounted } from 'vue'
import { sessionStore } from '@/stores/session'
import { useSettings } from '@/stores/settings'
@@ -71,6 +72,8 @@ const sidebarLinks = ref(getSidebarLinks())
const otherLinks = ref([])
const showMenu = ref(false)
const menu = ref(null)
const isModerator = ref(false)
const isInstructor = ref(false)
onMounted(() => {
sidebarSettings.reload(
@@ -134,12 +137,15 @@ const addOtherLinks = () => {
}
watch(userResource, () => {
if (
userResource.data &&
(userResource.data.is_moderator || userResource.data.is_instructor)
) {
addQuizzes()
addAssignments()
if (userResource.data) {
isModerator.value = userResource.data.is_moderator
isInstructor.value = userResource.data.is_instructor
addPrograms()
if (isModerator.value || isInstructor.value) {
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) => {
return tab.activeFor?.includes(router.currentRoute.value.name)
}
@@ -12,9 +12,6 @@
>
<div class="w-full">
<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
v-model="searchFilter"
:placeholder="__('Search by Member')"
@@ -151,7 +148,7 @@ import {
import { computed, ref, watch } from 'vue'
import { theme } from '@/utils/theme'
const show = defineModel<boolean | undefined>()
const show = defineModel<boolean>({ default: false })
const searchFilter = ref<string | null>(null)
type Filters = {
course: string | undefined
@@ -225,7 +222,6 @@ const progressColumns = computed(() => {
{
label: __('Progress'),
key: 'progress',
width: '30%',
align: 'right',
icon: 'trending-up',
},
+2 -9
View File
@@ -117,14 +117,7 @@
</Dialog>
</template>
<script setup lang="ts">
import {
Avatar,
Badge,
Button,
createResource,
Dialog,
FormControl,
} from 'frappe-ui'
import { Avatar, Button, createResource, Dialog, FormControl } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { ref, watch, reactive, inject } from 'vue'
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
@@ -146,7 +139,6 @@ const start = ref(0)
const memberList = ref<Member[]>([])
const hasNextPage = ref(false)
const showForm = ref(false)
const dayjs = inject('$dayjs')
const user = inject<User | null>('$user')
const { updateOnboardingStep } = useOnboarding('learning')
@@ -193,6 +185,7 @@ const openProfile = (username: string) => {
username: username,
},
})
console.log(show.value)
}
const newMember = createResource({
@@ -37,6 +37,7 @@
<component
v-if="activeTab.template"
:is="activeTab.template"
v-model:show="show"
v-bind="{
label: activeTab.label,
description: activeTab.description,
@@ -109,13 +110,6 @@ const tabsStructure = computed(() => {
'If enabled, users can access the course and batch lists without logging in.',
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',
name: 'prevent_skipping_videos',
+2 -2
View File
@@ -11,7 +11,7 @@
class="flex items-center w-full duration-300 ease-in-out group"
:class="isCollapsed ? 'p-1 relative' : 'px-2 py-1'"
>
<Tooltip :text="link.label" placement="right">
<Tooltip :text="__(link.label)" placement="right">
<slot name="icon">
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<component
@@ -32,7 +32,7 @@
{{ __(link.label) }}
</span>
<span
v-if="link.count"
v-if="link.count && !isCollapsed"
class="!ml-auto block text-xs text-ink-gray-5"
:class="
isCollapsed && link.count > 9
@@ -1,22 +1,24 @@
<template>
<div>
<div v-if="!forHome || (forHome && upcoming_evals.data?.length)">
<div class="flex items-center justify-between mb-4">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Upcoming Evaluations') }}
</div>
<Button
v-if="upcoming_evals.data?.length != evaluationCourses.length"
v-if="
upcoming_evals.data?.length != evaluationCourses.length && !forHome
"
@click="openEvalModal"
>
{{ __('Schedule Evaluation') }}
</Button>
</div>
<div v-if="upcoming_evals.data?.length">
<div class="grid grid-cols-3 gap-4">
<div class="grid gap-4" :class="forHome ? 'grid-cols-2' : 'grid-cols-3'">
<div v-for="evl in upcoming_evals.data">
<div class="border text-ink-gray-7 rounded-md p-3">
<div class="flex justify-between mb-3">
<span class="font-semibold text-ink-gray-9 leading-5">
<span class="text-lg font-semibold text-ink-gray-9 leading-5">
{{ evl.course_title }}
</span>
<Menu
@@ -94,8 +96,8 @@
</div>
</div>
</div>
<div v-else class="text-sm italic text-ink-gray-5">
{{ __('Please schedule an evaluation to get certified.') }}
<div v-else class="text-ink-gray-5">
{{ __('Schedule an evaluation to get certified.') }}
</div>
</div>
<EvaluationModal
@@ -122,7 +124,6 @@ import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
const dayjs = inject('$dayjs')
const user = inject('$user')
const showEvalModal = ref(false)
const app = getCurrentInstance()
const { $dialog } = app.appContext.config.globalProperties
@@ -140,12 +141,15 @@ const props = defineProps({
type: String,
default: null,
},
forHome: {
type: Boolean,
default: false,
},
})
const upcoming_evals = createResource({
url: 'lms.lms.utils.get_upcoming_evals',
params: {
student: user.data.name,
courses: props.courses.map((course) => course.course),
batch: props.batch,
},
+4
View File
@@ -2,6 +2,10 @@ export {}
declare global {
function __(text: string): string
interface String {
format(...args: any[]): string
}
}
declare module 'vue' {
+4 -4
View File
@@ -79,14 +79,14 @@
<div class="space-y-5">
<FormControl
v-model="batch.start_date"
:label="__('Start Date')"
:label="__('Batch Start Date')"
type="date"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batch.end_date"
:label="__('End Date')"
:label="__('Batch End Date')"
type="date"
class="mb-4"
:required="true"
@@ -95,14 +95,14 @@
<div class="space-y-5">
<FormControl
v-model="batch.start_time"
:label="__('Start Time')"
:label="__('Session Start Time')"
type="time"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batch.end_time"
:label="__('End Time')"
:label="__('Session End Time')"
type="time"
class="mb-4"
:required="true"
+4 -1
View File
@@ -142,7 +142,10 @@
{{ __('Settings') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="flex flex-col space-y-5">
<div
v-if="user.data?.is_moderator"
class="flex flex-col space-y-5"
>
<FormControl
type="checkbox"
v-model="course.published"
+263
View File
@@ -0,0 +1,263 @@
<template>
<div>
<div v-if="createdCourses.data?.length" class="mt-10">
<div class="flex items-center justify-between mb-3">
<span class="font-semibold text-lg">
{{ __('Courses Created') }}
</span>
<router-link
:to="{
name: 'Courses',
}"
>
<span class="flex items-center space-x-1 text-ink-gray-5 text-xs">
<span>
{{ __('See all') }}
</span>
<MoveRight class="size-3 stroke-1.5" />
</span>
</router-link>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
<router-link
v-for="course in createdCourses.data"
:to="{ name: 'CourseDetail', params: { courseName: course.name } }"
>
<CourseCard :course="course" />
</router-link>
</div>
</div>
<div v-if="createdBatches.data?.length" class="mt-10">
<div class="flex items-center justify-between mb-3">
<span class="font-semibold text-lg">
{{ __('Upcoming Batches') }}
</span>
<router-link
:to="{
name: 'Batches',
}"
>
<span class="flex items-center space-x-1 text-ink-gray-5 text-xs">
<span>
{{ __('See all') }}
</span>
<MoveRight class="size-3 stroke-1.5" />
</span>
</router-link>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<router-link
v-for="batch in createdBatches.data"
:to="{ name: 'BatchDetail', params: { batchName: batch.name } }"
>
<BatchCard :batch="batch" />
</router-link>
</div>
</div>
<div
v-if="!createdCourses.data?.length && !createdBatches.data?.length"
class="flex flex-col items-center justify-center mt-60"
>
<GraduationCap class="size-10 mx-auto stroke-1 text-ink-gray-5" />
<div class="text-lg font-semibold text-ink-gray-7 mb-1.5">
{{ __('No courses created') }}
</div>
<div
class="leading-5 text-base w-full md:w-2/5 text-base text-center text-ink-gray-7"
>
{{
__(
'There are no courses currently. Create your first course to get started!'
)
}}
</div>
<router-link
:to="{ name: 'CourseForm', params: { courseName: 'new' } }"
class="mt-4"
>
<Button>
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('Create Course') }}
</Button>
</router-link>
</div>
<div class="grid grid-cols-2 gap-5 mt-10">
<div v-if="evals?.data?.length">
<div class="font-semibold text-lg mb-3">
{{ __('Upcoming Evaluations') }}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
<div
v-for="evaluation in evals?.data"
class="border rounded-md p-3 flex flex-col h-full cursor-pointer"
@click="redirectToProfile()"
>
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
{{ evaluation.course_title }}
</div>
<div class="text-ink-gray-7 text-sm">
<div class="flex items-center mb-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ dayjs(evaluation.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center mb-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ formatTime(evaluation.start_time) }}
</span>
</div>
<div class="flex items-center">
<GraduationCap class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ evaluation.member_name }}
</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="liveClasses?.data?.length">
<div class="font-semibold text-lg mb-3">
{{ __('Upcoming Live Classes') }}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
<div v-for="cls in liveClasses?.data" class="border rounded-md p-3">
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
{{ cls.title }}
</div>
<div class="text-ink-gray-7 text-sm leading-5 mb-4">
{{ cls.description }}
</div>
<div class="mt-auto space-y-3 text-ink-gray-7 text-sm">
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span>
{{ formatTime(cls.time) }} -
{{ dayjs(getClassEnd(cls)).format('HH:mm A') }}
</span>
</div>
<div
v-if="canAccessClass(cls)"
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
>
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
target="_blank"
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
:class="cls.join_url ? 'w-full' : 'w-1/2'"
>
<Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }}
</a>
<a
:href="cls.join_url"
target="_blank"
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
</div>
<Tooltip
v-else-if="hasClassEnded(cls)"
:text="__('This class has ended')"
placement="right"
>
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
<Info class="w-4 h-4 stroke-1.5" />
<span>
{{ __('Ended') }}
</span>
</div>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Button, createResource, Tooltip } from 'frappe-ui'
import { inject } from 'vue'
import { useRouter } from 'vue-router'
import {
Calendar,
Clock,
GraduationCap,
Info,
Monitor,
MoveRight,
Plus,
Video,
} from 'lucide-vue-next'
import { formatTime } from '@/utils'
import CourseCard from '@/components/CourseCard.vue'
import BatchCard from '@/components/BatchCard.vue'
const user = inject<any>('$user')
const dayjs = inject<any>('$dayjs')
const router = useRouter()
const props = defineProps<{
liveClasses?: { data?: any[] }
evals?: { data?: any[] }
}>()
const createdCourses = createResource({
url: 'lms.lms.utils.get_created_courses',
auto: true,
})
const createdBatches = createResource({
url: 'lms.lms.utils.get_created_batches',
auto: true,
})
const getClassEnd = (cls: { date: string; time: string; duration: number }) => {
const classStart = new Date(`${cls.date}T${cls.time}`)
return new Date(classStart.getTime() + cls.duration * 60000)
}
const canAccessClass = (cls: {
date: string
time: string
duration: number
}) => {
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
if (hasClassEnded(cls)) return false
return true
}
const hasClassEnded = (cls: {
date: string
time: string
duration: number
}) => {
const classEnd = getClassEnd(cls)
const now = new Date()
return now > classEnd
}
const redirectToProfile = () => {
router.push({
name: 'ProfileEvaluationSchedule',
params: { username: user.data?.username },
})
}
</script>
+138
View File
@@ -0,0 +1,138 @@
<template>
<!-- <header
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="[{ label: __('Home'), route: { name: 'Home' } }]" />
</header> -->
<div class="w-full px-5 pt-10 pb-10">
<div class="flex items-center justify-between">
<div class="space-y-2">
<div class="text-xl font-bold">
{{ __('Hey') }}, {{ user.data?.full_name }} 👋
</div>
<div class="text-lg text-ink-gray-6">
{{ subtitle }}
</div>
</div>
<div>
<TabButtons v-if="isAdmin" v-model="currentTab" :buttons="tabs" />
<div
v-else
@click="showStreakModal = true"
class="bg-surface-amber-2 px-2 py-1 rounded-md cursor-pointer"
>
<span> 🔥 </span>
<span>
{{ streakInfo.data?.current_streak }}
</span>
</div>
</div>
</div>
<AdminHome
v-if="isAdmin && currentTab === 'instructor'"
:liveClasses="adminLiveClasses"
:evals="adminEvals"
/>
<StudentHome v-else :myLiveClasses="myLiveClasses" />
</div>
<Streak v-model="showStreakModal" :streakInfo="streakInfo" />
</template>
<script setup lang="ts">
import { computed, inject, onMounted, ref } from 'vue'
import {
Breadcrumbs,
call,
createResource,
TabButtons,
usePageMeta,
} from 'frappe-ui'
import { sessionStore } from '@/stores/session'
import StudentHome from '@/pages/Home/StudentHome.vue'
import AdminHome from '@/pages/Home/AdminHome.vue'
import Streak from '@/pages/Home/Streak.vue'
const user = inject<any>('$user')
const { brand } = sessionStore()
const evalCount = ref(0)
const currentTab = ref<'student' | 'instructor'>('instructor')
const showStreakModal = ref(false)
onMounted(() => {
call('lms.lms.utils.get_upcoming_evals').then((data: any) => {
evalCount.value = data.length
})
})
const isAdmin = computed(() => {
return (
user.data?.is_moderator ||
user.data?.is_instructor ||
user.data?.is_evaluator
)
})
const myLiveClasses = createResource({
url: 'lms.lms.utils.get_my_live_classes',
auto: !isAdmin.value ? true : false,
})
const adminLiveClasses = createResource({
url: 'lms.lms.utils.get_admin_live_classes',
auto: isAdmin.value ? true : false,
})
const adminEvals = createResource({
url: 'lms.lms.utils.get_admin_evals',
auto: isAdmin.value ? true : false,
})
const streakInfo = createResource({
url: 'lms.lms.utils.get_streak_info',
auto: true,
})
const subtitle = computed(() => {
if (isAdmin.value) {
if (adminLiveClasses.data?.length > 0 && adminEvals.data?.length > 0) {
return __(
'You have {0} upcoming live classes and {1} evaluations scheduled.'
).format(adminLiveClasses.data.length, adminEvals.data.length)
} else if (adminLiveClasses.data?.length > 0) {
return __('You have {0} upcoming live classes.').format(
adminLiveClasses.data.length
)
} else if (adminEvals.data?.length > 0) {
return __('You have {0} evaluations scheduled.').format(
adminEvals.data.length
)
}
return __('Manage your courses and batches at a glance')
} else {
if (myLiveClasses.data?.length > 0 && evalCount.value > 0) {
return __(
'You have {0} upcoming live classes and {1} evaluations scheduled.'
).format(myLiveClasses.data.length, evalCount.value)
} else if (myLiveClasses.data?.length > 0) {
return __('You have {0} upcoming live classes.').format(
myLiveClasses.data.length
)
} else if (evalCount.value > 0) {
return __('You have {0} evaluations scheduled.').format(evalCount.value)
}
return __('Resume where you left off')
}
})
const tabs = [
{ label: __('Student'), value: 'student' },
{ label: __('Instructor'), value: 'instructor' },
]
usePageMeta(() => {
return {
title: __('Home'),
icon: brand.favicon,
}
})
</script>
+78
View File
@@ -0,0 +1,78 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Learning Consistency'),
}"
>
<template #body-content>
<div class="text-base">
<div class="text-center">
<div class="text-[30px]">🔥</div>
<div class="mt-3">
<div class="text-ink-gray-5 mb-1">
{{
streakInfo.data?.current_streak < 1
? __('You can do better,')
: streakInfo.data?.current_streak < 10
? __('Keep going,')
: __('You rock,')
}}
{{ __(' you are on a') }}
</div>
<div class="font-semibold text-xl">
{{ streakInfo.data?.current_streak }} {{ __('day streak') }}
</div>
</div>
</div>
<div
class="grid grid-cols-2 bg-surface-gray-1 px-2.5 py-2 rounded-md mt-8"
>
<div class="space-y-1 border-r border-outline-gray-2 mr-4">
<div class="text-ink-gray-6">
{{ __('Current Streak') }}
</div>
<div class="font-semibold text-lg">
{{ streakInfo.data?.current_streak }} {{ __('days') }}
</div>
</div>
<div class="space-y-1">
<div class="text-ink-gray-6">
{{ __('Longest Streak') }}
</div>
<div class="font-semibold text-lg">
{{ streakInfo.data?.longest_streak }} {{ __('days') }}
</div>
</div>
</div>
<div
class="text-ink-gray-7 border border-outline-gray-1 px-2.5 py-2 rounded-md text-xs leading-5 mt-5"
>
{{
__(
'Your learning streak counts the number of days in a row youve kept up your learning, whether its a lesson, quiz, or assignment. Dont worry, weekends dont break your streak.'
)
}}
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Dialog } from 'frappe-ui'
const show = defineModel<boolean>({
default: false,
})
const props = defineProps<{
streakInfo: {
data: {
current_streak: number
longest_streak: number
}
}
}>()
</script>
+195
View File
@@ -0,0 +1,195 @@
<template>
<div>
<div v-if="myCourses.data?.length" class="mt-10">
<div class="flex items-center justify-between mb-3">
<span class="font-semibold text-lg">
{{
myCourses.data[0].membership
? __('My Courses')
: __('Our Popular Courses')
}}
</span>
<router-link
:to="{
name: 'Courses',
}"
>
<span class="flex items-center space-x-1 text-ink-gray-5 text-xs">
<span>
{{ __('See all') }}
</span>
<MoveRight class="size-3 stroke-1.5" />
</span>
</router-link>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
<router-link
v-for="course in myCourses.data"
:to="{ name: 'CourseDetail', params: { courseName: course.name } }"
>
<CourseCard :course="course" />
</router-link>
</div>
</div>
<div v-if="myBatches.data?.length" class="mt-10">
<div class="flex items-center justify-between mb-3">
<span class="font-semibold text-lg">
{{
myBatches.data?.[0].students.includes(user.data?.name)
? __('My Batches')
: __('Our Upcoming Batches')
}}
</span>
<router-link
:to="{
name: 'Batches',
}"
>
<span class="flex items-center space-x- 1 text-ink-gray-5 text-xs">
<span>
{{ __('See all') }}
</span>
<MoveRight class="size-3 stroke-1.5" />
</span>
</router-link>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<router-link
v-for="batch in myBatches.data"
:to="{ name: 'BatchDetail', params: { batchName: batch.name } }"
>
<BatchCard :batch="batch" />
</router-link>
</div>
</div>
<div class="grid grid-cols-2 gap-5 mt-10">
<UpcomingEvaluations :forHome="true" />
<div v-if="myLiveClasses.data?.length">
<div class="font-semibold text-lg mb-3">
{{ __('Upcoming Live Classes') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div v-for="cls in myLiveClasses.data" class="border rounded-md p-2">
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
{{ cls.title }}
</div>
<div class="text-ink-gray-7 text-sm leading-5 mb-4">
{{ cls.description }}
</div>
<div class="mt-auto space-y-3 text-ink-gray-7 text-sm">
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span>
{{ formatTime(cls.time) }} -
{{ dayjs(getClassEnd(cls)).format('HH:mm A') }}
</span>
</div>
<div
v-if="canAccessClass(cls)"
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
>
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
target="_blank"
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
:class="cls.join_url ? 'w-full' : 'w-1/2'"
>
<Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }}
</a>
<a
:href="cls.join_url"
target="_blank"
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
</div>
<Tooltip
v-else-if="hasClassEnded(cls)"
:text="__('This class has ended')"
placement="right"
>
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
<Info class="w-4 h-4 stroke-1.5" />
<span>
{{ __('Ended') }}
</span>
</div>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { createResource, Tooltip } from 'frappe-ui'
import { formatTime } from '@/utils'
import {
Calendar,
Clock,
Info,
Monitor,
MoveRight,
Video,
} from 'lucide-vue-next'
import CourseCard from '@/components/CourseCard.vue'
import BatchCard from '@/components/BatchCard.vue'
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
const dayjs = inject<any>('$dayjs')
const user = inject<any>('$user')
const props = defineProps<{
myLiveClasses: any
}>()
const myCourses = createResource({
url: 'lms.lms.utils.get_my_courses',
auto: true,
})
const myBatches = createResource({
url: 'lms.lms.utils.get_my_batches',
auto: true,
})
const getClassEnd = (cls: { date: string; time: string; duration: number }) => {
const classStart = new Date(`${cls.date}T${cls.time}`)
return new Date(classStart.getTime() + cls.duration * 60000)
}
const canAccessClass = (cls: {
date: string
time: string
duration: number
}) => {
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
if (hasClassEnded(cls)) return false
return true
}
const hasClassEnded = (cls: {
date: string
time: string
duration: number
}) => {
const classEnd = getClassEnd(cls)
const now = new Date()
return now > classEnd
}
</script>
+4 -2
View File
@@ -209,12 +209,13 @@
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"
>
<LessonContent
content
<!-- <LessonContent
v-if="lesson.data?.body"
:content="lesson.data.body"
:youtube="lesson.data.youtube"
:quizId="lesson.data.quiz_id"
/>
/> -->
</div>
</div>
<div
@@ -267,6 +268,7 @@
:courseName="courseName"
:key="chapterNumber"
:getProgress="lesson.data.membership ? true : false"
:lessonProgress="lessonProgress"
/>
</div>
</div>
-379
View File
@@ -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>
-216
View File
@@ -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>
@@ -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>
@@ -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
View 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>
@@ -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>
+123
View File
@@ -0,0 +1,123 @@
<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} {1}').format(
programs.data.length,
programs.data.length == 1 ? __('Program') : __('Programs')
)
}}
</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>
@@ -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
View 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;
};
}
+3 -3
View File
@@ -54,7 +54,7 @@
:config="{
data: signupsChart.data,
title: 'Signups',
subtitle: 'Signups per month',
subtitle: 'Signups per day',
xAxis: {
key: 'date',
type: 'time',
@@ -74,7 +74,7 @@
:config="{
data: enrollmentChart.data,
title: 'Enrollments',
subtitle: 'Enrollments per month',
subtitle: 'Enrollments per day',
xAxis: {
key: 'date',
type: 'time',
@@ -96,7 +96,7 @@
:config="{
data: certification.data,
title: 'Certifications',
subtitle: 'Certifications per month',
subtitle: 'Certifications per day',
xAxis: {
key: 'date',
type: 'time',
+11 -11
View File
@@ -3,13 +3,11 @@ import { usersStore } from './stores/user'
import { sessionStore } from './stores/session'
import { useSettings } from './stores/settings'
let defaultRoute = '/courses'
const routes = [
{
path: '/',
redirect: {
name: 'Courses',
},
name: 'Home',
component: () => import('@/pages/Home/Home.vue'),
},
{
path: '/courses',
@@ -183,16 +181,16 @@ const routes = [
component: () => import('@/pages/QuizSubmission.vue'),
props: true,
},
{
path: '/programs/:programName',
name: 'ProgramForm',
component: () => import('@/pages/ProgramForm.vue'),
props: true,
},
{
path: '/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',
@@ -260,6 +258,8 @@ router.beforeEach(async (to, from, next) => {
}
if (!isLoggedIn) {
if (to.name == 'Home') router.push({ name: 'Courses' })
await allowGuestAccess.promise
if (!allowGuestAccess.data) {
window.location.href = '/login'
+1 -1
View File
@@ -61,7 +61,7 @@ export const sessionStore = defineStore('lms-session', () => {
field: 'livecode_url',
},
cache: 'livecodeURL',
auto: true,
auto: user.value ? true : false,
})
return {
-9
View File
@@ -4,17 +4,9 @@ import { createResource } from 'frappe-ui'
import { sessionStore } from './session'
export const useSettings = defineStore('settings', () => {
const { isLoggedIn } = sessionStore()
const isSettingsOpen = ref(false)
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({
url: 'lms.lms.api.get_lms_setting',
params: { field: 'allow_guest_access' },
@@ -38,7 +30,6 @@ export const useSettings = defineStore('settings', () => {
return {
isSettingsOpen,
activeTab,
learningPaths,
allowGuestAccess,
preventSkippingVideos,
sidebarSettings,
+1 -4
View File
@@ -1,15 +1,12 @@
import { defineStore } from 'pinia'
import { createResource } from 'frappe-ui'
import { useRouter } from 'vue-router'
const router = useRouter()
export const usersStore = defineStore('lms-users', () => {
let userResource = createResource({
url: 'lms.lms.api.get_user_info',
onError(error) {
if (error && error.exc_type === 'AuthenticationError') {
router.push('/login')
window.location.href = '/login'
}
},
auto: true,
+3 -1
View File
@@ -58,7 +58,9 @@ export class Assignment {
}
call('frappe.client.get_value', {
doctype: 'LMS Assignment',
name: assignment,
filters: {
name: assignment,
},
fieldname: ['title'],
}).then((data) => {
this.wrapper.innerHTML = `<div class='border rounded-md p-4 text-center bg-surface-menu-bar mb-4'>
+3 -1
View File
@@ -79,7 +79,9 @@ export class Program {
}
call("frappe.client.get_value", {
doctype: 'LMS Programming Exercise',
name: exercise,
filters: {
name: exercise
},
fieldname: "title"
}).then((data: { title: string }) => {
this.wrapper.innerHTML = `<div class='border rounded-md p-4 text-center bg-surface-menu-bar mb-4'>
+1 -1
View File
@@ -61,7 +61,7 @@ export default defineConfig({
],
server: {
host: '0.0.0.0', // Accept connections from any network interface
allowedHosts: ['ps', 'fs'], // Explicitly allow this host
allowedHosts: ['ps', 'fs', 'home'], // Explicitly allow this host
},
resolve: {
alias: {
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.33.0"
__version__ = "2.34.0"
@@ -4,6 +4,7 @@
import frappe
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
@@ -19,3 +20,4 @@ class LMSCourseProgress(Document):
"name",
)
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()
def on_update(self):
self.update_program_progress()
update_program_progress(self.member)
def validate_membership_in_same_batch(self):
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:
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": self.member}, "progress"
)
progress = progress or 0
total_progress += progress
def update_program_progress(member):
programs = frappe.get_all("LMS Program Member", {"member": member}, ["parent", "name"])
average_progress = ceil(total_progress / len(courses))
frappe.db.set_value("LMS Program Member", program.name, "progress", average_progress)
for program in programs:
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()
+6 -4
View File
@@ -161,8 +161,8 @@
"link_fieldname": "payment"
}
],
"modified": "2025-03-13 15:31:38.019002",
"modified_by": "Administrator",
"modified": "2025-08-19 10:33:15.457678",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Payment",
"owner": "Administrator",
@@ -180,9 +180,11 @@
"write": 1
}
],
"row_format": "Dynamic",
"show_title_field_in_link": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "billing_name"
}
"title_field": "billing_name",
"track_changes": 1
}
+73 -4
View File
@@ -7,8 +7,17 @@
"engine": "InnoDB",
"field_order": [
"title",
"published",
"column_break_cwjx",
"enforce_course_order",
"column_break_mikl",
"section_break_vhhu",
"program_courses",
"program_members"
"program_members",
"section_break_pppe",
"course_count",
"column_break_qwhf",
"member_count"
],
"fields": [
{
@@ -30,12 +39,61 @@
"label": "Title",
"reqd": 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,
"links": [],
"modified": "2024-11-28 22:06:16.742867",
"modified_by": "Administrator",
"modified": "2025-08-20 12:28:57.238902",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Program",
"naming_rule": "By fieldname",
@@ -76,10 +134,21 @@
"role": "Course Creator",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "title",
"track_changes": 1
}
}
@@ -10,6 +10,7 @@ class LMSProgram(Document):
def validate(self):
self.validate_program_courses()
self.validate_program_members()
self.update_count()
def validate_program_courses(self):
courses = [row.course for row in self.program_courses]
@@ -30,3 +31,13 @@ class LMSProgram(Document):
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
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-11-18 12:43:46.800199",
"modified_by": "Administrator",
"modified": "2025-08-13 17:32:43.554055",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Program Course",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}
@@ -35,16 +35,18 @@
"label": "Progress"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-11-21 12:51:31.882576",
"modified_by": "Administrator",
"modified": "2025-08-13 17:33:00.265037",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Program Member",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}
@@ -11,7 +11,6 @@
"persona_captured",
"column_break_zdel",
"allow_guest_access",
"enable_learning_paths",
"prevent_skipping_videos",
"column_break_bjis",
"unsplash_access_key",
@@ -339,12 +338,6 @@
"fieldtype": "HTML",
"label": "Payments app is not installed"
},
{
"default": "0",
"fieldname": "enable_learning_paths",
"fieldtype": "Check",
"label": "Enable Learning Paths"
},
{
"fieldname": "general_tab",
"fieldtype": "Tab Break",
@@ -429,7 +422,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-07-01 17:01:58.466698",
"modified": "2025-08-12 16:47:49.983018",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Settings",
+432 -73
View File
@@ -2,6 +2,7 @@ import hashlib
import json
import re
import string
from datetime import datetime, timedelta
import frappe
import razorpay
@@ -39,12 +40,12 @@ def slugify(title, used_slugs=None):
If a list of used slugs is specified, it will make sure the generated slug
is not one of them.
>>> slugify("Hello World!")
'hello-world'
>>> slugify("Hello World!", ["hello-world"])
'hello-world-2'
>>> slugify("Hello World!", ["hello-world", "hello-world-2"])
'hello-world-3'
>>> slugify("Hello World!")
'hello-world'
>>> slugify("Hello World!", ["hello-world"])
'hello-world-2'
>>> slugify("Hello World!", ["hello-world", "hello-world-2"])
'hello-world-3'
"""
if not used_slugs:
used_slugs = []
@@ -844,14 +845,22 @@ def get_evaluator(course, batch=None):
@frappe.whitelist()
def get_upcoming_evals(student, courses, batch=None):
def get_upcoming_evals(courses=None, batch=None):
if frappe.session.user == "Guest":
return []
if not courses:
courses = []
filters = {
"member": student,
"course": ["in", courses],
"member": frappe.session.user,
"date": [">=", frappe.utils.nowdate()],
"status": "Upcoming",
}
if len(courses) > 0:
filters["course"] = ["in", courses]
if batch:
filters["batch_name"] = batch
@@ -1118,6 +1127,7 @@ def get_course_details(course):
"lessons",
"enrollments",
"rating",
"card_gradient",
],
as_dict=1,
)
@@ -1126,7 +1136,7 @@ def get_course_details(course):
# course_details.is_instructor = is_instructor(course_details.name)
if course_details.paid_course or course_details.paid_certificate:
"""course_details.course_price, course_details.currency = check_multicurrency(
course_details.course_price, course_details.currency, None, course_details.amount_usd
course_details.course_price, course_details.currency, None, course_details.amount_usd
)"""
course_details.price = fmt_money(course_details.course_price, 0, course_details.currency)
@@ -1270,7 +1280,6 @@ def get_lesson(course, chapter, lesson):
progress = get_progress(course, lesson_details.name)
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)
lesson_details.next = neighbours["next"]
lesson_details.progress = progress
@@ -1919,80 +1928,93 @@ def update_certificate_purchase(course, payment_name):
@frappe.whitelist()
def get_programs():
if has_course_moderator_role() or has_course_instructor_role() or has_course_evaluator_role():
programs = frappe.get_all("LMS Program", fields=["name"])
else:
programs = frappe.get_all(
"LMS Program Member", {"member": frappe.session.user}, ["parent as name", "progress"]
enrolled_programs = frappe.get_all(
"LMS Program Member", {"member": frappe.session.user}, ["parent as name", "progress"]
)
for program in enrolled_programs:
program.update(
frappe.db.get_value(
"LMS Program", program.name, ["name", "course_count", "member_count"], as_dict=True
)
)
for program in programs:
program_courses = frappe.get_all(
"LMS Program Course", {"parent": program.name}, ["course"], order_by="idx"
)
program.courses = []
previous_progress = 0
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
published_programs = frappe.get_all(
"LMS Program",
{
"published": 1,
},
["name", "course_count", "member_count"],
)
previous_progress = details.membership.progress if details.membership else 0
program.courses.append(details)
programs_to_remove = []
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 programs
return {
"enrolled": enrolled_programs,
"published": published_programs,
}
@frappe.whitelist()
def enroll_in_program_course(program, course):
enrollment = frappe.db.exists("LMS Enrollment", {"member": frappe.session.user, "course": course})
if enrollment:
enrollment = frappe.db.get_value("LMS Enrollment", enrollment, ["name", "current_lesson"], as_dict=1)
enrollment.current_lesson = get_lesson_index(enrollment.current_lesson)
return enrollment
def get_program_details(program_name):
program = frappe.db.get_value(
"LMS Program",
program_name,
[
"name",
"member_count",
"course_count",
"published",
"allow_self_enrollment",
"enforce_course_order",
],
as_dict=1,
)
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:
if program_course.idx < current_course_idx:
enrollment = frappe.db.get_value(
"LMS Enrollment",
{"member": frappe.session.user, "course": program_course.course},
["name", "progress"],
as_dict=1,
program.courses = []
previous_progress = 0
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
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")
enrollment.update(
{
"member": frappe.session.user,
"course": course,
}
)
enrollment.save()
return enrollment
return program
@frappe.whitelist()
def enroll_in_program(program):
if frappe.session.user == "Guest":
frappe.throw(_("Please login to enroll in the program."))
if not frappe.db.exists("LMS Program Member", {"parent": program, "member": frappe.session.user}):
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)
@@ -2120,3 +2142,340 @@ def get_related_courses(course):
def persona_captured():
frappe.db.set_single_value("LMS Settings", "persona_captured", 1)
@frappe.whitelist()
def get_my_courses():
my_courses = []
if frappe.session.user == "Guest":
return my_courses
courses = get_my_latest_courses()
if not len(courses):
courses = get_featured_home_courses()
if not len(courses):
courses = get_popular_courses()
for course in courses:
my_courses.append(get_course_details(course))
return my_courses
def get_my_latest_courses():
return frappe.get_all(
"LMS Enrollment",
{
"member": frappe.session.user,
},
order_by="creation desc",
limit=3,
pluck="course",
)
def get_featured_home_courses():
return frappe.get_all(
"LMS Course",
{"published": 1, "featured": 1},
order_by="published_on desc",
limit=3,
pluck="name",
)
def get_popular_courses():
return frappe.get_all(
"LMS Course",
{
"published": 1,
},
order_by="enrollments desc",
limit=3,
pluck="name",
)
@frappe.whitelist()
def get_my_batches():
my_batches = []
if frappe.session.user == "Guest":
return my_batches
batches = get_my_latest_batches()
if not len(batches):
batches = get_upcoming_batches()
for batch in batches:
batch_details = get_batch_details(batch)
if batch_details:
my_batches.append(batch_details)
return my_batches
def get_my_latest_batches():
return frappe.get_all(
"LMS Batch Enrollment",
{
"member": frappe.session.user,
},
order_by="creation desc",
limit=4,
pluck="batch",
)
def get_upcoming_batches():
return frappe.get_all(
"LMS Batch",
{
"published": 1,
"start_date": [">=", getdate()],
},
order_by="start_date asc",
limit=4,
pluck="name",
)
@frappe.whitelist()
def get_my_live_classes():
my_live_classes = []
if frappe.session.user == "Guest":
return my_live_classes
batches = frappe.get_all(
"LMS Batch Enrollment",
{
"member": frappe.session.user,
},
order_by="creation desc",
pluck="batch",
)
live_class_details = frappe.get_all(
"LMS Live Class",
filters={
"date": [">=", getdate()],
"batch_name": ["in", batches],
},
fields=[
"name",
"title",
"description",
"time",
"date",
"duration",
"attendees",
"start_url",
"join_url",
"owner",
],
limit=2,
order_by="date",
)
if len(live_class_details):
for live_class in live_class_details:
live_class.course_title = frappe.db.get_value("LMS Course", live_class.course, "title")
my_live_classes.append(live_class)
return my_live_classes
@frappe.whitelist()
def get_created_courses():
created_courses = []
if frappe.session.user == "Guest":
return created_courses
CourseInstructor = frappe.qb.DocType("Course Instructor")
Course = frappe.qb.DocType("LMS Course")
query = (
frappe.qb.from_(CourseInstructor)
.join(Course)
.on(CourseInstructor.parent == Course.name)
.select(Course.name)
.where(CourseInstructor.instructor == frappe.session.user)
.orderby(Course.published_on, order=frappe.qb.desc)
.limit(3)
)
results = query.run(as_dict=True)
courses = [row["name"] for row in results]
for course in courses:
course_details = get_course_details(course)
created_courses.append(course_details)
return created_courses
@frappe.whitelist()
def get_created_batches():
created_batches = []
if frappe.session.user == "Guest":
return created_batches
CourseInstructor = frappe.qb.DocType("Course Instructor")
Batch = frappe.qb.DocType("LMS Batch")
query = (
frappe.qb.from_(CourseInstructor)
.join(Batch)
.on(CourseInstructor.parent == Batch.name)
.select(Batch.name)
.where(CourseInstructor.instructor == frappe.session.user)
.where(Batch.start_date >= getdate())
.orderby(Batch.start_date, order=frappe.qb.asc)
.limit(4)
)
results = query.run(as_dict=True)
batches = [row["name"] for row in results]
for batch in batches:
batch_details = get_batch_details(batch)
created_batches.append(batch_details)
return created_batches
@frappe.whitelist()
def get_admin_live_classes():
if frappe.session.user == "Guest":
return []
CourseInstructor = frappe.qb.DocType("Course Instructor")
LMSLiveClass = frappe.qb.DocType("LMS Live Class")
query = (
frappe.qb.from_(CourseInstructor)
.join(LMSLiveClass)
.on(CourseInstructor.parent == LMSLiveClass.batch_name)
.select(
LMSLiveClass.name,
LMSLiveClass.title,
LMSLiveClass.description,
LMSLiveClass.time,
LMSLiveClass.date,
LMSLiveClass.duration,
LMSLiveClass.attendees,
LMSLiveClass.start_url,
LMSLiveClass.join_url,
LMSLiveClass.owner,
)
.where(CourseInstructor.instructor == frappe.session.user)
.where(LMSLiveClass.date >= getdate())
.orderby(LMSLiveClass.date, order=frappe.qb.asc)
.limit(4)
)
results = query.run(as_dict=True)
return results
@frappe.whitelist()
def get_admin_evals():
if frappe.session.user == "Guest":
return []
evals = frappe.get_all(
"LMS Certificate Request",
{
"evaluator": frappe.session.user,
"date": [">=", getdate()],
},
[
"name",
"date",
"start_time",
"course",
"evaluator",
"google_meet_link",
"member",
"member_name",
],
limit=4,
order_by="date asc",
)
for evaluation in evals:
evaluation.course_title = frappe.db.get_value("LMS Course", evaluation.course, "title")
return evals
def fetch_activity_dates(user):
doctypes = [
"LMS Course Progress",
"LMS Quiz Submission",
"LMS Assignment Submission",
"LMS Programming Exercise Submission",
]
all_dates = []
for dt in doctypes:
all_dates.extend(frappe.get_all(dt, {"member": user}, pluck="creation"))
return sorted({d.date() if hasattr(d, "date") else d for d in all_dates})
def calculate_streaks(all_dates):
streak = 0
longest_streak = 0
prev_day = None
for d in all_dates:
if d.weekday() in (5, 6):
continue
if prev_day:
expected = prev_day + timedelta(days=1)
while expected.weekday() in (5, 6):
expected += timedelta(days=1)
streak = streak + 1 if d == expected else 1
else:
streak = 1
longest_streak = max(longest_streak, streak)
prev_day = d
return streak, longest_streak
def calculate_current_streak(all_dates, streak):
if not all_dates:
return 0
last_date = all_dates[-1]
today = getdate()
ref_day = today
while ref_day.weekday() in (5, 6):
ref_day -= timedelta(days=1)
if last_date == ref_day or last_date == ref_day - timedelta(days=1):
return streak
return 0
@frappe.whitelist()
def get_streak_info():
if frappe.session.user == "Guest":
return {}
all_dates = fetch_activity_dates(frappe.session.user)
streak, longest_streak = calculate_streaks(all_dates)
current_streak = calculate_current_streak(all_dates, streak)
return {
"current_streak": current_streak,
"longest_streak": longest_streak,
}
+425 -376
View File
File diff suppressed because it is too large Load Diff
+431 -382
View File
File diff suppressed because it is too large Load Diff
+425 -376
View File
File diff suppressed because it is too large Load Diff
+7441
View File
File diff suppressed because it is too large Load Diff
+427 -378
View File
File diff suppressed because it is too large Load Diff
+431 -382
View File
File diff suppressed because it is too large Load Diff
+431 -382
View File
File diff suppressed because it is too large Load Diff
+427 -378
View File
File diff suppressed because it is too large Load Diff
+427 -378
View File
File diff suppressed because it is too large Load Diff
+431 -382
View File
File diff suppressed because it is too large Load Diff
+425 -376
View File
File diff suppressed because it is too large Load Diff
+426 -377
View File
File diff suppressed because it is too large Load Diff
+439 -390
View File
File diff suppressed because it is too large Load Diff
+424 -375
View File
File diff suppressed because it is too large Load Diff
+7441
View File
File diff suppressed because it is too large Load Diff
+424 -375
View File
File diff suppressed because it is too large Load Diff
+428 -379
View File
File diff suppressed because it is too large Load Diff
+425 -376
View File
File diff suppressed because it is too large Load Diff
+428 -379
View File
File diff suppressed because it is too large Load Diff
+428 -379
View File
File diff suppressed because it is too large Load Diff
+439 -390
View File
File diff suppressed because it is too large Load Diff
+439 -390
View File
File diff suppressed because it is too large Load Diff
+431 -382
View File
File diff suppressed because it is too large Load Diff
+432 -380
View File
File diff suppressed because it is too large Load Diff
+433 -384
View File
File diff suppressed because it is too large Load Diff
+424 -375
View File
File diff suppressed because it is too large Load Diff
+431 -382
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -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.sidebar_for_certified_members
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
View 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)
-105
View File
@@ -1,105 +0,0 @@
function getLiveCodeOptions() {
var START = `
import sketch
code = open("main.py").read()
env = dict(sketch.__dict__)
exec(code, env)
`;
var SKETCH = `
import json
def sendmsg(msgtype, function, args):
"""Sends a message to the frontend.
The frontend will receive the specified message whenever
this function is called. The frontend can decide to some
action on each of these messages.
"""
msg = dict(msgtype=msgtype, function=function, args=args)
print("--MSG--", json.dumps(msg))
def _draw(func, **kwargs):
sendmsg(msgtype="draw", function=func, args=kwargs)
def circle(x, y, d):
"""Draws a circle of diameter d with center (x, y).
"""
_draw("circle", x=x, y=y, d=d)
def line(x1, y1, x2, y2):
"""Draws a line from point (x1, y1) to point (x2, y2).
"""
_draw("line", x1=x1, y1=y1, x2=x2, y2=y2)
def rect(x, y, w, h):
"""Draws a rectangle on the canvas.
Parameters
----------
x: x coordinate of the top-left corner of the rectangle
y: y coordinate of the top-left corner of the rectangle
w: width of the rectangle
h: height of the rectangle
"""
_draw("rect", x=x, y=y, w=w, h=h)
def clear():
_draw("clear")
# clear the canvas on start
clear()
`;
const CANVAS_FUNCTIONS = {
circle: function (ctx, args) {
ctx.beginPath();
ctx.arc(args.x, args.y, args.d / 2, 0, 2 * Math.PI);
ctx.stroke();
},
line: function (ctx, args) {
ctx.beginPath();
ctx.moveTo(args.x1, args.y1);
ctx.lineTo(args.x2, args.y2);
ctx.stroke();
},
rect: function (ctx, args) {
ctx.beginPath();
ctx.rect(args.x, args.y, args.w, args.h);
ctx.stroke();
},
clear: function (ctx, args) {
var width = 300;
var height = 300;
ctx.clearRect(0, 0, width, height);
},
};
function drawOnCanvas(canvasElement, funcName, args) {
var ctx = canvasElement.getContext("2d");
var func = CANVAS_FUNCTIONS[funcName];
var scalex = canvasElement.width / 300;
var scaley = canvasElement.height / 300;
ctx.save();
ctx.scale(scalex, scaley);
func(ctx, args);
ctx.restore();
}
return {
runtime: "python",
files: [
{ filename: "start.py", contents: START },
{ filename: "sketch.py", contents: SKETCH },
],
command: ["python", "start.py"],
codemirror: true,
onMessage: {
draw: function (editor, msg) {
const canvasElement = editor.parent.querySelector("canvas");
drawOnCanvas(canvasElement, msg.function, msg.args);
},
},
};
}
-5
View File
@@ -1,5 +0,0 @@
frappe.provide("lms.setup");
// redirect to desk page 'lms' after setup wizard is complete
// 'lms' desk page redirects to '/courses'
//frappe.setup.welcome_page = "/app/lms-home";