Compare commits

...

98 Commits

Author SHA1 Message Date
Frappe PR Bot
015e228304 chore(release): Bumped to Version 2.14.0 2024-11-27 16:55:28 +00:00
Jannat Patel
a9f40d16f0 Merge pull request #1109 from FahidLatheef/develop
feat: Add table component to LMS Lesson
2024-11-27 15:46:37 +05:30
Jannat Patel
b8da14a32e Merge pull request #1154 from frappe/l10n_develop2
chore: sync translations from crowdin
2024-11-27 15:46:01 +05:30
Jannat Patel
34ba2fb361 chore: Persian translations 2024-11-27 00:57:55 +05:30
Jannat Patel
98ccb15796 chore: Swedish translations 2024-11-27 00:57:54 +05:30
Jannat Patel
6c06f7d19b Merge pull request #1152 from frappe/l10n_develop2
chore: sync translations from crowdin
2024-11-26 17:29:54 +05:30
Jannat Patel
86b129a25f chore: Esperanto translations 2024-11-26 00:59:16 +05:30
Jannat Patel
6e8d4cd8e8 chore: Bosnian translations 2024-11-26 00:59:15 +05:30
Jannat Patel
1b4622bdb2 chore: Persian translations 2024-11-26 00:59:13 +05:30
Jannat Patel
58d51579e3 chore: Chinese Simplified translations 2024-11-26 00:59:12 +05:30
Jannat Patel
06706ea41b chore: Turkish translations 2024-11-26 00:59:10 +05:30
Jannat Patel
d634a0f784 chore: Swedish translations 2024-11-26 00:59:09 +05:30
Jannat Patel
a92159b811 chore: Russian translations 2024-11-26 00:59:08 +05:30
Jannat Patel
7e1e37393c chore: Polish translations 2024-11-26 00:59:06 +05:30
Jannat Patel
d2f9a2cea4 chore: Hungarian translations 2024-11-26 00:59:05 +05:30
Jannat Patel
5111d83eee chore: German translations 2024-11-26 00:59:04 +05:30
Jannat Patel
0dc77343c4 chore: Arabic translations 2024-11-26 00:59:02 +05:30
Jannat Patel
cec5913632 chore: Spanish translations 2024-11-26 00:59:01 +05:30
Jannat Patel
75d43a1563 chore: French translations 2024-11-26 00:58:59 +05:30
Frappe PR Bot
1ecdbd9e06 chore(release): Bumped to Version 2.13.0 2024-11-25 09:21:10 +00:00
Jannat Patel
a90e3d611c Merge pull request #1150 from pateljannat/roles-desk-access-issue
fix: desk access and course amount validation issue
2024-11-25 14:49:28 +05:30
Jannat Patel
d49d638253 fix: amount validation for course 2024-11-25 14:36:32 +05:30
Jannat Patel
83338a56c0 fix: disable desk_access for lms roles 2024-11-25 14:26:11 +05:30
Jannat Patel
562020de70 Merge pull request #1149 from frappe/l10n_develop2
chore: sync translations from crowdin
2024-11-25 11:00:48 +05:30
Jannat Patel
044907edeb Merge pull request #1148 from frappe/pot_develop_2024-11-22
chore: update POT file
2024-11-25 11:00:32 +05:30
Jannat Patel
cfa1aa87fc Merge pull request #1115 from yarin-zhang/develop
Add Chinese locale
2024-11-25 11:00:17 +05:30
Jannat Patel
0ac32ee474 chore: Swedish translations 2024-11-25 00:49:11 +05:30
Jannat Patel
de0675f850 chore: Persian translations 2024-11-23 23:46:59 +05:30
frappe-pr-bot
1c529790f2 chore: update POT file 2024-11-22 16:05:29 +00:00
Jannat Patel
40bcc4d572 Merge pull request #1147 from pateljannat/onboarding-steps
feat: onboarding steps
2024-11-22 16:47:12 +05:30
Jannat Patel
58f109e79c feat: onboarding steps 2024-11-22 16:28:28 +05:30
沨沄极客
cb324f6269 Merge branch 'develop' into develop 2024-11-22 15:29:15 +08:00
Jannat Patel
7cafaf5cbc Merge pull request #1145 from pateljannat/learning-paths
feat: learning paths
2024-11-22 11:12:42 +05:30
Jannat Patel
a394952630 Merge pull request #1146 from frappe/l10n_develop2
chore: sync translations from crowdin
2024-11-22 11:07:41 +05:30
Jannat Patel
68e87f20aa feat: added progress column in program members list 2024-11-22 11:07:23 +05:30
Jannat Patel
64ed0b3e94 feat: program restrictions 2024-11-21 17:10:24 +05:30
Jannat Patel
fcaaee958d chore: Persian translations 2024-11-20 23:08:25 +05:30
Jannat Patel
29e356ff86 Merge pull request #1144 from pateljannat/issues-52
fix: changed SCORM input from checkbox to switch with better description
2024-11-20 20:20:41 +05:30
Jannat Patel
460edc7bc7 fix: changed SCORM input from checkbox to switch with better description 2024-11-20 19:52:28 +05:30
Jannat Patel
582c7af12d feat: reorder courses and students view for programs 2024-11-20 19:32:49 +05:30
Frappe PR Bot
af533a7a2c chore(release): Bumped to Version 2.12.0 2024-11-20 06:10:26 +00:00
Jannat Patel
acbede157f Merge pull request #1142 from frappe/l10n_develop2
chore: sync translations from crowdin
2024-11-20 11:39:35 +05:30
Jannat Patel
f63a627ff2 chore: Chinese Simplified translations 2024-11-19 23:01:33 +05:30
Jannat Patel
b1a0556c12 Merge pull request #1137 from iamejaaz/notification-sidebar-ui
fix: show notification count at the top in collapsed
2024-11-19 21:56:14 +05:30
Jannat Patel
0097ede6ed Merge pull request #1135 from iamejaaz/add-keyboard-shortcut
feat: add keyboard shortcut to save lesson
2024-11-19 21:54:37 +05:30
Jannat Patel
b72774e54d Merge pull request #1141 from frappe/l10n_develop2
chore: sync translations from crowdin
2024-11-19 21:52:38 +05:30
Jannat Patel
3027a9e523 chore: Esperanto translations 2024-11-18 23:02:04 +05:30
Jannat Patel
c3995952b3 chore: Bosnian translations 2024-11-18 23:02:02 +05:30
Jannat Patel
ff1642382c chore: Persian translations 2024-11-18 23:02:01 +05:30
Jannat Patel
cfe35e40da chore: Chinese Simplified translations 2024-11-18 23:01:59 +05:30
Jannat Patel
c3238a9f91 chore: Turkish translations 2024-11-18 23:01:58 +05:30
Jannat Patel
58f08bf065 chore: Swedish translations 2024-11-18 23:01:56 +05:30
Jannat Patel
d3ac6ea337 chore: Russian translations 2024-11-18 23:01:55 +05:30
Jannat Patel
6649b7955f chore: Polish translations 2024-11-18 23:01:53 +05:30
Jannat Patel
15a53d33e0 chore: Hungarian translations 2024-11-18 23:01:52 +05:30
Jannat Patel
57f09542a2 chore: German translations 2024-11-18 23:01:50 +05:30
Jannat Patel
fa384b391d chore: Arabic translations 2024-11-18 23:01:49 +05:30
Jannat Patel
12b138c39f chore: Spanish translations 2024-11-18 23:01:47 +05:30
Jannat Patel
420a5f39eb chore: French translations 2024-11-18 23:01:46 +05:30
Jannat Patel
12c2666bd1 Merge pull request #1139 from pateljannat/issues-51
fix: validate amount and currency for paid courses and batches
2024-11-18 16:51:08 +05:30
Jannat Patel
1ecbc2e3f9 fix: validate amount and currency for paid courses and batches 2024-11-18 16:37:09 +05:30
Jannat Patel
e1a78382c3 feat: learning paths 2024-11-18 16:15:27 +05:30
Jannat Patel
dcf5c72cad Merge pull request #1136 from frappe/l10n_develop2
chore: sync translations from crowdin
2024-11-18 11:27:56 +05:30
Jannat Patel
2ebf6be609 Merge pull request #1111 from iamejaaz/same-day-live-class
feat: allow same date live class creation
2024-11-18 11:27:39 +05:30
Jannat Patel
4ce7019ce6 Merge pull request #1134 from frappe/pot_develop_2024-11-15
chore: update POT file
2024-11-18 11:26:43 +05:30
Jannat Patel
3faf814162 Merge pull request #1133 from pateljannat/issues-50
fix: misc issues
2024-11-18 11:23:25 +05:30
Jannat Patel
52bd9825d8 fix: choice questions validations 2024-11-18 11:16:34 +05:30
Ejaaz Khan
b6028e741c fix: show notification count at the top in collapsed 2024-11-17 19:19:31 +05:30
沨沄极客
4ee1693434 Merge branch 'develop' into develop 2024-11-17 17:37:33 +08:00
Jannat Patel
cbc7892b25 chore: Persian translations 2024-11-16 22:45:19 +05:30
Ejaaz Khan
a4fa2ef0b3 feat: add keyboard shortcut to save lesson 2024-11-16 10:36:58 +05:30
frappe-pr-bot
96de90cb5f chore: update POT file 2024-11-15 16:04:37 +00:00
Jannat Patel
dfb22c81c3 Merge pull request #1113 from iamejaaz/search-functionality-in-jobs
feat: search functionality in jobs
2024-11-15 20:39:52 +05:30
Jannat Patel
6a70ed18d8 fix: misc issues 2024-11-15 20:36:15 +05:30
Jannat Patel
629c237349 Merge pull request #1132 from pateljannat/SCORM-2
feat: SCORM
2024-11-15 20:18:59 +05:30
Jannat Patel
cf014bca3c feat: record lesson progress 2024-11-15 19:14:34 +05:30
Jannat Patel
9323d8e17d Merge pull request #1131 from frappe/l10n_develop2
chore: sync translations from crowdin
2024-11-14 14:29:36 +05:30
yarin-zhang
1ba63a2175 Update Chinese locale 2024-11-14 16:58:04 +08:00
沨沄极客
b5551fd8ba Merge branch 'develop' into develop 2024-11-14 16:56:30 +08:00
yarin-zhang
fac0038af8 Update Chinese locale 2024-11-14 16:52:52 +08:00
yarin-zhang
ee6685e324 Update Chinese locale 2024-11-14 16:38:54 +08:00
yarin-zhang
0fb18f995c Update Chinese locale 2024-11-14 16:19:14 +08:00
Ejaaz Khan
61e13aa7cd refactor: add transalation and use camel case 2024-11-13 23:26:13 +05:30
Jannat Patel
acb8c6c500 chore: Turkish translations 2024-11-13 21:24:13 +05:30
Fahid Latheef A
af838121d9 Merge branch 'frappe:develop' into develop 2024-11-13 13:50:58 +05:30
Jannat Patel
f504841a5c Merge pull request #1119 from frappe/l10n_develop2
chore: sync translations from crowdin
2024-11-13 11:45:06 +05:30
Ejaaz Khan
be49ba6d04 refactor: add translate in all error messages 2024-11-13 00:37:15 +05:30
Jannat Patel
24ffed11fb chore: Turkish translations 2024-11-12 21:18:40 +05:30
Jannat Patel
73754bd104 chore: merged conflicts 2024-11-12 12:13:39 +05:30
Jannat Patel
2e1aac4931 feat: SCORM 2024-11-11 18:25:56 +05:30
Fahid Latheef A
93b3eda05c refactor: removed trailing semicolon 2024-11-11 11:46:02 +05:30
Fahid Latheef A
740584d883 Merge branch 'frappe:develop' into develop 2024-11-11 11:45:08 +05:30
yarin-zhang
5e6160149f Update Revision Date 2024-11-11 11:13:26 +08:00
yarin-zhang
be66c563a8 Add Chinese locale 2024-11-11 10:30:34 +08:00
Ejaaz Khan
92c380c74b feat: search functionality in jobs 2024-11-10 21:12:08 +05:30
Ejaaz Khan
e25f161980 feat: allow same date live class creation 2024-11-10 17:48:26 +05:30
Fahid Latheef Alungal
822603128d Merge remote-tracking branch 'origin/develop' into develop 2024-11-10 02:13:21 +05:30
Fahid Latheef Alungal
9dbe8fbb1f feat: tables in lms lessons 2024-11-10 02:09:48 +05:30
76 changed files with 8197 additions and 2648 deletions

View File

@@ -13,6 +13,6 @@ module.exports = defineConfig({
openMode: 0, openMode: 0,
}, },
e2e: { e2e: {
baseUrl: "http://test:8000", baseUrl: "http://lms1:8000",
}, },
}); });

View File

@@ -18,6 +18,7 @@
"@editorjs/nested-list": "^1.4.2", "@editorjs/nested-list": "^1.4.2",
"@editorjs/paragraph": "^2.11.3", "@editorjs/paragraph": "^2.11.3",
"@editorjs/simple-image": "^1.6.0", "@editorjs/simple-image": "^1.6.0",
"@editorjs/table": "^2.4.2",
"ace-builds": "^1.36.2", "ace-builds": "^1.36.2",
"chart.js": "^4.4.1", "chart.js": "^4.4.1",
"codemirror-editor-vue3": "^2.8.0", "codemirror-editor-vue3": "^2.8.0",

View File

@@ -1,18 +1,18 @@
<template> <template>
<div <div
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out bg-gray-50" class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out bg-gray-50"
:class="isSidebarCollapsed ? 'w-14' : 'w-56'" :class="sidebarStore.isSidebarCollapsed ? 'w-14' : 'w-56'"
> >
<div <div
class="flex flex-col overflow-hidden" class="flex flex-col overflow-hidden"
:class="isSidebarCollapsed ? 'items-center' : ''" :class="sidebarStore.isSidebarCollapsed ? 'items-center' : ''"
> >
<UserDropdown :isCollapsed="isSidebarCollapsed" /> <UserDropdown :isCollapsed="sidebarStore.isSidebarCollapsed" />
<div class="flex flex-col" v-if="sidebarSettings.data"> <div class="flex flex-col" v-if="sidebarSettings.data">
<SidebarLink <SidebarLink
v-for="link in sidebarLinks" v-for="link in sidebarLinks"
:link="link" :link="link"
:isCollapsed="isSidebarCollapsed" :isCollapsed="sidebarStore.isSidebarCollapsed"
class="mx-2 my-0.5" class="mx-2 my-0.5"
/> />
</div> </div>
@@ -22,11 +22,11 @@
> >
<div <div
class="flex items-center justify-between pr-2 cursor-pointer" class="flex items-center justify-between pr-2 cursor-pointer"
:class="isSidebarCollapsed ? 'pl-3' : 'pl-4'" :class="sidebarStore.isSidebarCollapsed ? 'pl-3' : 'pl-4'"
@click="showWebPages = !showWebPages" @click="showWebPages = !showWebPages"
> >
<div <div
v-if="!isSidebarCollapsed" v-if="!sidebarStore.isSidebarCollapsed"
class="flex items-center text-sm text-gray-600 my-1" class="flex items-center text-sm text-gray-600 my-1"
> >
<span class="grid h-5 w-6 flex-shrink-0 place-items-center"> <span class="grid h-5 w-6 flex-shrink-0 place-items-center">
@@ -53,7 +53,7 @@
<SidebarLink <SidebarLink
v-for="link in sidebarSettings.data.web_pages" v-for="link in sidebarSettings.data.web_pages"
:link="link" :link="link"
:isCollapsed="isSidebarCollapsed" :isCollapsed="sidebarStore.isSidebarCollapsed"
class="mx-2 my-0.5" class="mx-2 my-0.5"
:showControls="isModerator ? true : false" :showControls="isModerator ? true : false"
@openModal="openPageModal" @openModal="openPageModal"
@@ -64,17 +64,19 @@
</div> </div>
<SidebarLink <SidebarLink
:link="{ :link="{
label: isSidebarCollapsed ? 'Expand' : 'Collapse', label: sidebarStore.isSidebarCollapsed ? 'Expand' : 'Collapse',
}" }"
:isCollapsed="isSidebarCollapsed" :isCollapsed="sidebarStore.isSidebarCollapsed"
@click="isSidebarCollapsed = !isSidebarCollapsed" @click="toggleSidebar()"
class="m-2" class="m-2"
> >
<template #icon> <template #icon>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center"> <span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<CollapseSidebar <CollapseSidebar
class="h-4.5 w-4.5 text-gray-700 duration-300 ease-in-out" class="h-4.5 w-4.5 text-gray-700 duration-300 ease-in-out"
:class="{ '[transform:rotateY(180deg)]': isSidebarCollapsed }" :class="{
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
}"
/> />
</span> </span>
</template> </template>
@@ -96,12 +98,15 @@ import { ref, onMounted, inject, watch } from 'vue'
import { getSidebarLinks } from '../utils' import { getSidebarLinks } from '../utils'
import { usersStore } from '@/stores/user' import { usersStore } from '@/stores/user'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { useSidebar } from '@/stores/sidebar'
import { useSettings } from '@/stores/settings'
import { ChevronRight, Plus } from 'lucide-vue-next' import { ChevronRight, Plus } from 'lucide-vue-next'
import { createResource, Button } from 'frappe-ui' import { createResource, Button } from 'frappe-ui'
import PageModal from '@/components/Modals/PageModal.vue' import PageModal from '@/components/Modals/PageModal.vue'
const { user, sidebarSettings } = sessionStore() const { user, sidebarSettings } = sessionStore()
const { userResource } = usersStore() const { userResource } = usersStore()
let sidebarStore = useSidebar()
const socket = inject('$socket') const socket = inject('$socket')
const unreadCount = ref(0) const unreadCount = ref(0)
const sidebarLinks = ref(getSidebarLinks()) const sidebarLinks = ref(getSidebarLinks())
@@ -110,6 +115,7 @@ const isModerator = ref(false)
const isInstructor = ref(false) const isInstructor = ref(false)
const pageToEdit = ref(null) const pageToEdit = ref(null)
const showWebPages = ref(false) const showWebPages = ref(false)
const settingsStore = useSettings()
onMounted(() => { onMounted(() => {
socket.on('publish_lms_notifications', (data) => { socket.on('publish_lms_notifications', (data) => {
@@ -179,6 +185,28 @@ const addQuizzes = () => {
} }
} }
const addPrograms = () => {
if (settingsStore.learningPaths.data) {
let activeFor = ['Programs', 'ProgramForm']
let index = 1
if (!isInstructor.value && !isModerator.value) {
sidebarLinks.value = sidebarLinks.value.filter(
(link) => link.label !== 'Courses'
)
activeFor.push('CourseDetail')
activeFor.push('Lesson')
index = 0
}
sidebarLinks.value.splice(index, 0, {
label: 'Programs',
icon: 'Route',
to: 'Programs',
activeFor: activeFor,
})
}
}
const openPageModal = (link) => { const openPageModal = (link) => {
showPageModal.value = true showPageModal.value = true
pageToEdit.value = link pageToEdit.value = link
@@ -211,8 +239,11 @@ watch(userResource, () => {
isModerator.value = userResource.data.is_moderator isModerator.value = userResource.data.is_moderator
isInstructor.value = userResource.data.is_instructor isInstructor.value = userResource.data.is_instructor
addQuizzes() addQuizzes()
addPrograms()
} }
}) })
let isSidebarCollapsed = ref(getSidebarFromStorage()) const toggleSidebar = () => {
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
}
</script> </script>

View File

@@ -92,7 +92,7 @@
{{ option.label }} {{ option.label }}
</div> </div>
<div <div
v-if="option.label != option.description" v-if="option.description"
class="text-xs text-gray-700" class="text-xs text-gray-700"
v-html="option.description" v-html="option.description"
></div> ></div>

View File

@@ -44,6 +44,7 @@
</div> </div>
</template> </template>
</Autocomplete> </Autocomplete>
<p v-if="description" class="text-sm text-gray-600">{{ description }}</p>
</div> </div>
</template> </template>
@@ -67,6 +68,10 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
description: {
type: String,
default: '',
},
}) })
const emit = defineEmits(['update:modelValue', 'change']) const emit = defineEmits(['update:modelValue', 'change'])
@@ -118,7 +123,7 @@ const options = createResource({
transform: (data) => { transform: (data) => {
return data.map((option) => { return data.map((option) => {
return { return {
label: option.value, label: option.label || option.value,
value: option.value, value: option.value,
description: option.description, description: option.description,
} }

View File

@@ -140,7 +140,7 @@ function enrollStudent() {
showToast( showToast(
__('Please Login'), __('Please Login'),
__('You need to login first to enroll for this course'), __('You need to login first to enroll for this course'),
'circle-warn' 'alert-circle'
) )
setTimeout(() => { setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}` window.location.href = `/login?redirect-to=${window.location.pathname}`

View File

@@ -1,5 +1,5 @@
<template> <template>
<span v-if="instructors.length == 1"> <span v-if="instructors?.length == 1">
<router-link <router-link
:to="{ :to="{
name: 'Profile', name: 'Profile',
@@ -9,7 +9,7 @@
{{ instructors[0].full_name }} {{ instructors[0].full_name }}
</router-link> </router-link>
</span> </span>
<span v-if="instructors.length == 2"> <span v-if="instructors?.length == 2">
<router-link <router-link
:to="{ :to="{
name: 'Profile', name: 'Profile',
@@ -28,7 +28,7 @@
{{ instructors[1].first_name }} {{ instructors[1].first_name }}
</router-link> </router-link>
</span> </span>
<span v-if="instructors.length > 2"> <span v-if="instructors?.length > 2">
<router-link <router-link
:to="{ :to="{
name: 'Profile', name: 'Profile',
@@ -37,7 +37,7 @@
> >
{{ instructors[0].first_name }} {{ instructors[0].first_name }}
</router-link> </router-link>
and {{ instructors.length - 1 }} others and {{ instructors?.length - 1 }} others
</span> </span>
</template> </template>
<script setup> <script setup>

View File

@@ -16,7 +16,7 @@
</div> </div>
<div <div
:class="{ :class="{
'shadow rounded-md pt-2 px-2': showOutline && outline.data?.length, 'shadow rounded-md py-2 px-2': showOutline && outline.data?.length,
}" }"
> >
<Disclosure <Disclosure
@@ -25,21 +25,42 @@
:key="chapter.name" :key="chapter.name"
:defaultOpen="openChapterDetail(chapter.idx)" :defaultOpen="openChapterDetail(chapter.idx)"
> >
<DisclosureButton ref="" class="flex w-full p-2"> <DisclosureButton ref="" class="flex items-center w-full p-2 group">
<ChevronRight <ChevronRight
:class="{ :class="{
'rotate-90 transform duration-200': open, 'rotate-90 transform duration-200': open,
'duration-200': !open, 'duration-200': !open,
hidden: chapter.is_scorm_package,
open: index == 1, open: index == 1,
}" }"
class="h-4 w-4 text-gray-900 stroke-1 mr-2" class="h-4 w-4 text-gray-900 stroke-1"
/> />
<div class="text-base text-left font-medium leading-5"> <div
class="text-base text-left font-medium leading-5 ml-2"
@click="redirectToChapter(chapter)"
>
{{ chapter.title }} {{ chapter.title }}
</div> </div>
<div class="flex ml-auto space-x-4">
<Tooltip :text="__('Edit Chapter')" placement="bottom">
<FilePenLine
v-if="allowEdit"
@click.prevent="openChapterModal(chapter)"
class="h-4 w-4 text-gray-900 invisible group-hover:visible"
/>
</Tooltip>
<Tooltip :text="__('Delete Chapter')" placement="bottom">
<Trash2
v-if="allowEdit"
@click.prevent="trashChapter(chapter.name)"
class="h-4 w-4 text-red-500 invisible group-hover:visible"
/>
</Tooltip>
</div>
</DisclosureButton> </DisclosureButton>
<DisclosurePanel> <DisclosurePanel v-if="!chapter.is_scorm_package">
<Draggable <Draggable
v-if="!chapter.is_scorm_package"
:list="chapter.lessons" :list="chapter.lessons"
:disabled="!allowEdit" :disabled="!allowEdit"
item-key="name" item-key="name"
@@ -89,6 +110,7 @@
</Draggable> </Draggable>
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8"> <div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
<router-link <router-link
v-if="!chapter.is_scorm_package"
:to="{ :to="{
name: 'LessonForm', name: 'LessonForm',
params: { params: {
@@ -102,9 +124,6 @@
{{ __('Add Lesson') }} {{ __('Add Lesson') }}
</Button> </Button>
</router-link> </router-link>
<Button class="ml-2" @click="openChapterModal(chapter)">
{{ __('Edit Chapter') }}
</Button>
</div> </div>
</DisclosurePanel> </DisclosurePanel>
</Disclosure> </Disclosure>
@@ -118,24 +137,26 @@
/> />
</template> </template>
<script setup> <script setup>
import { Button, createResource } from 'frappe-ui' import { Button, createResource, Tooltip } from 'frappe-ui'
import { ref, getCurrentInstance } from 'vue' import { getCurrentInstance, inject, ref } from 'vue'
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue' import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import { import {
ChevronRight,
MonitorPlay,
HelpCircle,
FileText,
Check, Check,
ChevronRight,
FileText,
FilePenLine,
HelpCircle,
MonitorPlay,
Trash2, Trash2,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import ChapterModal from '@/components/Modals/ChapterModal.vue' import ChapterModal from '@/components/Modals/ChapterModal.vue'
import { showToast } from '@/utils' import { showToast } from '@/utils'
const route = useRoute() const route = useRoute()
const expandAll = ref(true) const router = useRouter()
const user = inject('$user')
const showChapterModal = ref(false) const showChapterModal = ref(false)
const currentChapter = ref(null) const currentChapter = ref(null)
const app = getCurrentInstance() const app = getCurrentInstance()
@@ -205,8 +226,10 @@ const updateLessonIndex = createResource({
const trashLesson = (lessonName, chapterName) => { const trashLesson = (lessonName, chapterName) => {
$dialog({ $dialog({
title: __('Delete Lesson'), title: __('Delete this lesson?'),
message: __('Are you sure you want to delete this lesson?'), message: __(
'Deleting this lesson will permanently remove it from the course. This action cannot be undone. Are you sure you want to continue?'
),
actions: [ actions: [
{ {
label: __('Delete'), label: __('Delete'),
@@ -245,6 +268,61 @@ const updateOutline = (e) => {
idx: e.newIndex, idx: e.newIndex,
}) })
} }
const deleteChapter = createResource({
url: 'lms.lms.api.delete_chapter',
makeParams(values) {
return {
chapter: values.chapter,
}
},
onSuccess() {
outline.reload()
showToast('Success', 'Chapter deleted successfully', 'check')
},
})
const trashChapter = (chapterName) => {
$dialog({
title: __('Delete this chapter?'),
message: __(
'Deleting this chapter will also delete all its lessons and permanently remove it from the course. This action cannot be undone. Are you sure you want to continue?'
),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
onClick(close) {
deleteChapter.submit({ chapter: chapterName })
close()
},
},
],
})
}
const redirectToChapter = (chapter) => {
if (!chapter.is_scorm_package) return
event.preventDefault()
if (props.allowEdit) return
if (!user.data) {
showToast(
__('You are not enrolled'),
__('Please enroll for this course to view this lesson'),
'alert-circle'
)
return
}
router.push({
name: 'SCORMChapter',
params: {
courseName: props.courseName,
chapterName: chapter.name,
},
})
}
</script> </script>
<style> <style>
.outline-lesson:has(.router-link-active) { .outline-lesson:has(.router-link-active) {

View File

@@ -8,6 +8,7 @@
<AppSidebar /> <AppSidebar />
</div> </div>
<div class="w-full overflow-auto" id="scrollContainer"> <div class="w-full overflow-auto" id="scrollContainer">
<OnboardingBanner />
<slot /> <slot />
</div> </div>
</div> </div>
@@ -16,4 +17,5 @@
</template> </template>
<script setup> <script setup>
import AppSidebar from './AppSidebar.vue' import AppSidebar from './AppSidebar.vue'
import OnboardingBanner from '@/components/OnboardingBanner.vue'
</script> </script>

View File

@@ -9,7 +9,7 @@
allowfullscreen allowfullscreen
></iframe> ></iframe>
</div> </div>
<div v-for="block in content.split('\n\n')"> <div v-for="block in content?.split('\n\n')">
<div v-if="block.includes('{{ YouTubeVideo')"> <div v-if="block.includes('{{ YouTubeVideo')">
<iframe <iframe
class="youtube-video" class="youtube-video"

View File

@@ -15,25 +15,77 @@
}" }"
> >
<template #body-content> <template #body-content>
<FormControl <div class="space-y-4 text-base">
ref="chapterInput" <FormControl label="Title" v-model="chapter.title" :required="true" />
label="Title" <Switch
v-model="chapter.title" size="sm"
class="mb-4" :label="__('SCORM Package')"
:required="true" :description="
__(
'Enable this only if you want to upload a SCORM package as a chapter.'
)
"
v-model="chapter.is_scorm_package"
/> />
<div v-if="chapter.is_scorm_package">
<FileUploader
v-if="!chapter.scorm_package"
:fileTypes="['.zip']"
:validateFile="validateFile"
@success="(file) => (chapter.scorm_package = file)"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading ? `Uploading ${progress}%` : 'Upload an zip file'
}}
</Button>
</div>
</template>
</FileUploader>
<div v-else class="">
<div class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
</div>
<div class="flex flex-col">
<span>
{{ chapter.scorm_package.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(chapter.scorm_package.file_size) }}
</span>
</div>
<X
@click="() => (chapter.scorm_package = null)"
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
</div>
</div>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, FormControl, createResource } from 'frappe-ui' import {
import { defineModel, reactive, watch, ref } from 'vue' Button,
import { createToast } from '@/utils/' createResource,
Dialog,
FileUploader,
FormControl,
Switch,
} from 'frappe-ui'
import { defineModel, reactive, watch } from 'vue'
import { showToast, getFileSize } from '@/utils/'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { FileText, X } from 'lucide-vue-next'
import { useSettings } from '@/stores/settings'
const show = defineModel() const show = defineModel()
const outline = defineModel('outline') const outline = defineModel('outline')
const chapterInput = ref(null) const settingsStore = useSettings()
const props = defineProps({ const props = defineProps({
course: { course: {
@@ -47,30 +99,19 @@ const props = defineProps({
const chapter = reactive({ const chapter = reactive({
title: '', title: '',
is_scorm_package: 0,
scorm_package: null,
}) })
const chapterResource = createResource({ const chapterResource = createResource({
url: 'frappe.client.insert', url: 'lms.lms.api.upsert_chapter',
makeParams(values) { makeParams(values) {
return { return {
doc: {
doctype: 'Course Chapter',
title: chapter.title, title: chapter.title,
description: chapter.description,
course: props.course, course: props.course,
}, is_scorm_package: chapter.is_scorm_package,
} scorm_package: chapter.scorm_package,
},
})
const chapterEditResource = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'Course Chapter',
name: props.chapterDetail?.name, name: props.chapterDetail?.name,
fieldname: 'title',
value: chapter.title,
} }
}, },
}) })
@@ -90,14 +131,12 @@ const chapterReference = createResource({
}, },
}) })
const addChapter = (close) => { const addChapter = async (close) => {
chapterResource.submit( chapterResource.submit(
{}, {},
{ {
validate() { validate() {
if (!chapter.title) { return validateChapter()
return 'Title is required'
}
}, },
onSuccess: (data) => { onSuccess: (data) => {
capture('chapter_created') capture('chapter_created')
@@ -105,30 +144,48 @@ const addChapter = (close) => {
{ name: data.name }, { name: data.name },
{ {
onSuccess(data) { onSuccess(data) {
chapter.title = '' cleanChapter()
if (!settingsStore.onboardingDetails.data?.is_onboarded) {
settingsStore.onboardingDetails.reload()
}
outline.value.reload() outline.value.reload()
createToast({ showToast(
text: 'Chapter added successfully', __('Success'),
icon: 'check', __('Chapter added successfully'),
iconClasses: 'bg-green-600 text-white rounded-md p-px', 'check'
}) )
}, },
onError(err) { onError(err) {
showError(err) showToast(__('Error'), err.messages?.[0] || err, 'x')
}, },
} }
) )
close() close()
}, },
onError(err) { onError(err) {
showError(err) showToast(__('Error'), err.messages?.[0] || err, 'x')
}, },
} }
) )
} }
const validateChapter = () => {
if (!chapter.title) {
return __('Title is required')
}
if (chapter.is_scorm_package && !chapter.scorm_package) {
return __('Please upload a SCORM package')
}
}
const cleanChapter = () => {
chapter.title = ''
chapter.is_scorm_package = 0
chapter.scorm_package = null
}
const editChapter = (close) => { const editChapter = (close) => {
chapterEditResource.submit( chapterResource.submit(
{}, {},
{ {
validate() { validate() {
@@ -138,43 +195,29 @@ const editChapter = (close) => {
}, },
onSuccess() { onSuccess() {
outline.value.reload() outline.value.reload()
createToast({ showToast(__('Success'), __('Chapter updated successfully'), 'check')
text: 'Chapter updated successfully',
icon: 'check',
iconClasses: 'bg-green-600 text-white rounded-md p-px',
})
close() close()
}, },
onError(err) { onError(err) {
showError(err) showToast(__('Error'), err.messages?.[0] || err, 'x')
}, },
} }
) )
} }
const showError = (err) => {
createToast({
title: 'Error',
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'bg-red-600 text-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
}
watch( watch(
() => props.chapterDetail, () => props.chapterDetail,
(newChapter) => { (newChapter) => {
chapter.title = newChapter?.title chapter.title = newChapter?.title
chapter.is_scorm_package = newChapter?.is_scorm_package
chapter.scorm_package = newChapter?.scorm_package
} }
) )
watch(show, () => { const validateFile = (file) => {
if (show.value) { let extension = file.name.split('.').pop().toLowerCase()
setTimeout(() => { if (extension !== 'zip') {
chapterInput.value.$el.querySelector('input').focus() return __('Only zip files are allowed')
}, 100) }
} }
})
</script> </script>

View File

@@ -161,25 +161,34 @@ const submitLiveClass = (close) => {
return createLiveClass.submit(liveClass, { return createLiveClass.submit(liveClass, {
validate() { validate() {
if (!liveClass.title) { if (!liveClass.title) {
return 'Please enter a title.' return __('Please enter a title.')
} }
if (!liveClass.date) { if (!liveClass.date) {
return 'Please select a date.' return __('Please select a date.')
}
if (dayjs(liveClass.date).isSameOrBefore(dayjs(), 'day')) {
return 'Please select a future date.'
} }
if (!liveClass.time) { if (!liveClass.time) {
return 'Please select a time.' return __('Please select a time.')
}
if (!valideTime()) {
return 'Please enter a valid time in the format HH:mm.'
}
if (!liveClass.duration) {
return 'Please select a duration.'
} }
if (!liveClass.timezone) { if (!liveClass.timezone) {
return 'Please select a timezone.' return __('Please select a timezone.')
}
if (!valideTime()) {
return __('Please enter a valid time in the format HH:mm.')
}
const liveClassDateTime = dayjs(`${liveClass.date}T${liveClass.time}`).tz(
liveClass.timezone,
true
)
if (
liveClassDateTime.isSameOrBefore(
dayjs().tz(liveClass.timezone, false),
'minute'
)
) {
return __('Please select a future date and time.')
}
if (!liveClass.duration) {
return __('Please select a duration.')
} }
}, },
onSuccess() { onSuccess() {

View File

@@ -12,9 +12,9 @@
id="existing" id="existing"
value="existing" value="existing"
v-model="questionType" v-model="questionType"
class="w-3 h-3 accent-gray-900" class="w-3 h-3 cursor-pointer"
/> />
<label for="existing"> <label for="existing" class="cursor-pointer">
{{ __('Add an existing question') }} {{ __('Add an existing question') }}
</label> </label>
</div> </div>
@@ -25,9 +25,9 @@
id="new" id="new"
value="new" value="new"
v-model="questionType" v-model="questionType"
class="w-3 h-3" class="w-3 h-3 cursor-pointer"
/> />
<label for="new"> <label for="new" class="cursor-pointer">
{{ __('Create a new question') }} {{ __('Create a new question') }}
</label> </label>
</div> </div>
@@ -127,7 +127,7 @@ const populateFields = () => {
let counter = 1 let counter = 1
fields.forEach((field) => { fields.forEach((field) => {
while (counter <= 4) { while (counter <= 4) {
question[`${field}_${counter}`] = field === 'is_correct' ? false : '' question[`${field}_${counter}`] = field === 'is_correct' ? false : null
counter++ counter++
} }
}) })

View File

@@ -108,9 +108,31 @@ const tabsStructure = computed(() => {
hideLabel: true, hideLabel: true,
items: [ items: [
{ {
label: 'Members', label: 'General',
description: 'Manage the members of your learning system', icon: 'Wrench',
icon: 'UserRoundPlus', fields: [
{
label: 'Enable Learning Paths',
name: 'enable_learning_paths',
description:
'This will enforce students to go through programs assigned to them in the correct order.',
type: 'checkbox',
},
{
label: 'Send calendar invite for evaluations',
name: 'send_calendar_invite_for_evaluations',
description:
'If enabled, it sends google calendar invite to the student for evaluations.',
type: 'checkbox',
},
{
label: 'Unsplash Access Key',
name: 'unsplash_access_key',
description:
'Optional. If this is set, students can pick a cover image from the unsplash library for their profile page. https://unsplash.com/documentation#getting-started.',
type: 'text',
},
],
}, },
], ],
}, },
@@ -156,9 +178,14 @@ const tabsStructure = computed(() => {
], ],
}, },
{ {
label: 'Settings', label: 'Lists',
hideLabel: true, hideLabel: false,
items: [ items: [
{
label: 'Members',
description: 'Manage the members of your learning system',
icon: 'UserRoundPlus',
},
{ {
label: 'Categories', label: 'Categories',
description: 'Manage the members of your learning system', description: 'Manage the members of your learning system',

View File

@@ -0,0 +1,151 @@
<template>
<div v-if="showOnboardingBanner && onboardingDetails.data">
<Tooltip :text="__('Skip Onboarding')" placement="left">
<X
class="w-4 h-4 stroke-1 absolute top-2 right-2 cursor-pointer mr-1"
@click="skipOnboarding.reload()"
/>
</Tooltip>
<div class="flex items-center justify-evenly bg-gray-100 p-10">
<div
@click="redirectToCourseForm()"
class="flex items-center space-x-2"
:class="{
'cursor-pointer': !onboardingDetails.data.course_created.length,
}"
>
<span
v-if="onboardingDetails.data.course_created.length"
class="py-1 px-1 bg-white rounded-full"
>
<Check class="h-4 w-4 stroke-2 text-green-600" />
</span>
<span v-else class="font-semibold bg-white px-2 py-1 rounded-full">
1
</span>
<span class="text-lg font-semibold">
{{ __('Create a course') }}
</span>
</div>
<div
@click="redirectToChapterForm()"
class="flex items-center space-x-2"
:class="{
'cursor-pointer':
onboardingDetails.data.course_created.length &&
!onboardingDetails.data.chapter_created.length,
'text-gray-400': !onboardingDetails.data.course_created.length,
}"
>
<span
v-if="onboardingDetails.data.chapter_created.length"
class="py-1 px-1 bg-white rounded-full"
>
<Check class="h-4 w-4 stroke-2 text-green-600" />
</span>
<span v-else class="font-semibold bg-white px-2 py-1 rounded-full">
2
</span>
<span class="text-lg font-semibold">
{{ __('Add a chapter') }}
</span>
</div>
<div
@click="redirectToLessonForm()"
class="flex items-center space-x-2"
:class="{
'cursor-pointer':
onboardingDetails.data.course_created.length &&
onboardingDetails.data.chapter_created.length,
'text-gray-400':
!onboardingDetails.data.course_created.length ||
!onboardingDetails.data.chapter_created.length,
}"
>
<span
v-if="onboardingDetails.data.lesson_created.length"
class="py-1 px-1 bg-white rounded-full"
>
<Check class="h-4 w-4 stroke-2 text-green-600" />
</span>
<span class="font-semibold bg-white px-2 py-1 rounded-full"> 3 </span>
<span class="text-lg font-semibold">
{{ __('Add a lesson') }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Check, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { useSettings } from '@/stores/settings'
import { createResource, Tooltip } from 'frappe-ui'
const showOnboardingBanner = ref(false)
const settings = useSettings()
const onboardingDetails = settings.onboardingDetails
const router = useRouter()
watch(onboardingDetails, () => {
if (!onboardingDetails.data?.is_onboarded) {
showOnboardingBanner.value = true
} else {
showOnboardingBanner.value = false
}
})
const redirectToCourseForm = () => {
if (onboardingDetails.data?.course_created.length) {
return
} else {
router.push({ name: 'CourseForm', params: { courseName: 'new' } })
}
}
const redirectToChapterForm = () => {
if (!onboardingDetails.data?.course_created.length) {
return
} else {
router.push({
name: 'CourseForm',
params: {
courseName: onboardingDetails.data?.first_course,
},
})
}
}
const redirectToLessonForm = () => {
if (!onboardingDetails.data?.course_created.length) {
return
} else if (!onboardingDetails.data?.chapter_created.length) {
return
} else {
router.push({
name: 'LessonForm',
params: {
courseName: onboardingDetails.data?.first_course,
chapterNumber: 1,
lessonNumber: 1,
},
})
}
}
const skipOnboarding = createResource({
url: 'frappe.client.set_value',
makeParams() {
return {
doctype: 'LMS Settings',
name: 'LMS Settings',
fieldname: 'is_onboarding_complete',
value: 1,
}
},
onSuccess(data) {
onboardingDetails.reload()
},
})
</script>

View File

@@ -397,6 +397,9 @@ const attempts = createResource({
watch( watch(
() => quiz.data, () => quiz.data,
() => { () => {
if (quiz.data) {
populateQuestions()
}
if (quiz.data && quiz.data.max_attempts) { if (quiz.data && quiz.data.max_attempts) {
attempts.reload() attempts.reload()
resetQuiz() resetQuiz()

View File

@@ -29,6 +29,7 @@
<script setup> <script setup>
import { Button, Badge } from 'frappe-ui' import { Button, Badge } from 'frappe-ui'
import SettingFields from '@/components/SettingFields.vue' import SettingFields from '@/components/SettingFields.vue'
import { showToast } from '@/utils'
const props = defineProps({ const props = defineProps({
fields: { fields: {
@@ -54,7 +55,14 @@ const update = () => {
props.data.doc[f.name] = f.value props.data.doc[f.name] = f.value
} }
}) })
props.data.save.submit() props.data.save.submit(
{},
{
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
},
}
)
} }
</script> </script>

View File

@@ -90,6 +90,7 @@
:type="field.type" :type="field.type"
:rows="field.rows" :rows="field.rows"
:options="field.options" :options="field.options"
:description="field.description"
/> />
</div> </div>
</div> </div>
@@ -100,7 +101,7 @@
import { FormControl, FileUploader, Button, Switch } from 'frappe-ui' import { FormControl, FileUploader, Button, Switch } from 'frappe-ui'
import { computed } from 'vue' import { computed } from 'vue'
import { getFileSize, validateFile } from '@/utils' import { getFileSize, validateFile } from '@/utils'
import { X, FileText } from 'lucide-vue-next' import { X } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import CodeEditor from '@/components/Controls/CodeEditor.vue' import CodeEditor from '@/components/Controls/CodeEditor.vue'

View File

@@ -7,7 +7,7 @@
> >
<div <div
class="flex items-center w-full duration-300 ease-in-out group" class="flex items-center w-full duration-300 ease-in-out group"
:class="isCollapsed ? 'p-1' : 'px-2 py-1'" :class="isCollapsed ? 'p-1 relative' : 'px-2 py-1'"
> >
<Tooltip :text="link.label" placement="right"> <Tooltip :text="link.label" placement="right">
<slot name="icon"> <slot name="icon">
@@ -29,7 +29,15 @@
> >
{{ __(link.label) }} {{ __(link.label) }}
</span> </span>
<span v-if="link.count" class="!ml-auto block text-xs text-gray-600"> <span
v-if="link.count"
class="!ml-auto block text-xs text-gray-600"
:class="
isCollapsed && link.count > 9
? 'absolute top-[2px] right-0 bg-white'
: ''
"
>
{{ link.count }} {{ link.count }}
</span> </span>
<div <div

View File

@@ -70,7 +70,11 @@
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal" class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal"
></div> ></div>
<div class="mt-10"> <div class="mt-10">
<CourseOutline :courseName="course.data.name" :showOutline="true" /> <CourseOutline
:title="__('Course Outline')"
:courseName="course.data.name"
:showOutline="true"
/>
</div> </div>
<CourseReviews <CourseReviews
:courseName="course.data.name" :courseName="course.data.name"

View File

@@ -434,6 +434,9 @@ const submitCourse = () => {
onSuccess(data) { onSuccess(data) {
capture('course_created') capture('course_created')
showToast('Success', 'Course created successfully', 'check') showToast('Success', 'Course created successfully', 'check')
if (!settingsStore.onboardingDetails.data?.is_onboarded) {
settingsStore.onboardingDetails.reload()
}
router.push({ router.push({
name: 'CourseForm', name: 'CourseForm',
params: { courseName: data.name }, params: { courseName: data.name },

View File

@@ -30,6 +30,7 @@
</FormControl> </FormControl>
</div> </div>
<router-link <router-link
v-if="user.data?.is_moderator || user.data?.is_instructor"
:to="{ :to="{
name: 'CourseForm', name: 'CourseForm',
params: { params: {
@@ -37,7 +38,7 @@
}, },
}" }"
> >
<Button v-if="user.data?.is_moderator" variant="solid"> <Button variant="solid">
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
@@ -159,30 +160,45 @@
<script setup> <script setup>
import { import {
Breadcrumbs,
Tabs,
Badge, Badge,
Breadcrumbs,
Button, Button,
FormControl, call,
createResource, createResource,
FormControl,
Tabs,
} from 'frappe-ui' } from 'frappe-ui'
import CourseCard from '@/components/CourseCard.vue' import CourseCard from '@/components/CourseCard.vue'
import { BookOpen, Plus, Search } from 'lucide-vue-next' import { BookOpen, Plus, Search } from 'lucide-vue-next'
import { ref, computed, inject, onMounted, watch } from 'vue' import { ref, computed, inject, onMounted, watch } from 'vue'
import { updateDocumentTitle } from '@/utils' import { updateDocumentTitle } from '@/utils'
import { useRouter } from 'vue-router'
import { useSettings } from '@/stores/settings'
const user = inject('$user') const user = inject('$user')
const searchQuery = ref('') const searchQuery = ref('')
const currentCategory = ref(null) const currentCategory = ref(null)
const hasCourses = ref(false) const hasCourses = ref(false)
const router = useRouter()
const settings = useSettings()
onMounted(() => { onMounted(() => {
checkLearningPath()
let queries = new URLSearchParams(location.search) let queries = new URLSearchParams(location.search)
if (queries.has('category')) { if (queries.has('category')) {
currentCategory.value = queries.get('category') currentCategory.value = queries.get('category')
} }
}) })
const checkLearningPath = () => {
if (
settings.learningPaths.data &&
(!user.data?.is_moderator || !user.data?.is_instructor)
) {
router.push({ name: 'Programs' })
}
}
const courses = createResource({ const courses = createResource({
url: 'lms.lms.utils.get_courses', url: 'lms.lms.utils.get_courses',
cache: ['courses', user.data?.email], cache: ['courses', user.data?.email],

View File

@@ -7,7 +7,22 @@
class="h-7" class="h-7"
:items="[{ label: __('Jobs'), route: { name: 'Jobs' } }]" :items="[{ label: __('Jobs'), route: { name: 'Jobs' } }]"
/> />
<div class="flex"> <div class="flex space-x-2">
<div class="w-40 md:w-44">
<FormControl
v-model="jobType"
type="select"
:options="jobTypes"
:placeholder="__('Type')"
/>
</div>
<div class="w-28 md:w-36">
<FormControl type="text" placeholder="Search" v-model="searchQuery">
<template #prefix>
<Search class="w-4 h-4 stroke-1.5 text-gray-600" name="search" />
</template>
</FormControl>
</div>
<router-link <router-link
v-if="user.data?.name" v-if="user.data?.name"
:to="{ :to="{
@@ -26,9 +41,9 @@
</router-link> </router-link>
</div> </div>
</header> </header>
<div v-if="jobs.data?.length"> <div v-if="jobsList?.length">
<div class="divide-y lg:w-3/4 mx-auto p-5"> <div class="divide-y lg:w-3/4 mx-auto p-5">
<div v-for="job in jobs.data"> <div v-for="job in jobsList">
<router-link <router-link
:to="{ :to="{
name: 'JobDetail', name: 'JobDetail',
@@ -47,13 +62,22 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { Button, Breadcrumbs, createResource } from 'frappe-ui' import { Button, Breadcrumbs, createResource, FormControl } from 'frappe-ui'
import { Plus } from 'lucide-vue-next' import { Plus, Search } from 'lucide-vue-next'
import { inject, computed } from 'vue' import { inject, computed, ref, onMounted } from 'vue'
import JobCard from '@/components/JobCard.vue' import JobCard from '@/components/JobCard.vue'
import { updateDocumentTitle } from '@/utils' import { updateDocumentTitle } from '@/utils'
const user = inject('$user') const user = inject('$user')
const jobType = ref(null)
const searchQuery = ref('')
onMounted(() => {
let queries = new URLSearchParams(location.search)
if (queries.has('type')) {
jobType.value = queries.get('type')
}
})
const jobs = createResource({ const jobs = createResource({
url: 'lms.lms.api.get_job_opportunities', url: 'lms.lms.api.get_job_opportunities',
@@ -68,5 +92,32 @@ const pageMeta = computed(() => {
} }
}) })
const jobsList = computed(() => {
let jobData = jobs.data
if (jobType.value && jobType.value != '') {
jobData = jobData.filter((job) => job.type == jobType.value)
}
if (searchQuery.value) {
let query = searchQuery.value.toLowerCase()
jobData = jobData.filter(
(job) =>
job.job_title.toLowerCase().includes(query) ||
job.company_name.toLowerCase().includes(query) ||
job.location.toLowerCase().includes(query)
)
}
return jobData
})
const jobTypes = computed(() => {
return [
'',
{ label: __('Full Time'), value: 'Full Time' },
{ label: __('Part Time'), value: 'Part Time' },
{ label: __('Contract'), value: 'Contract' },
{ label: __('Freelance'), value: 'Freelance' },
]
})
updateDocumentTitle(pageMeta) updateDocumentTitle(pageMeta)
</script> </script>

View File

@@ -103,7 +103,7 @@
<span <span
class="h-6 mr-1" class="h-6 mr-1"
:class="{ :class="{
'avatar-group overlap': lesson.data.instructors.length > 1, 'avatar-group overlap': lesson.data.instructors?.length > 1,
}" }"
> >
<UserAvatar <UserAvatar
@@ -111,7 +111,10 @@
:user="instructor" :user="instructor"
/> />
</span> </span>
<CourseInstructors :instructors="lesson.data.instructors" /> <CourseInstructors
v-if="lesson.data?.instructors"
:instructors="lesson.data.instructors"
/>
</div> </div>
<div <div
v-if=" v-if="
@@ -146,6 +149,7 @@
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal mt-5" class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal mt-5"
> >
<LessonContent <LessonContent
v-if="lesson.data?.body"
:content="lesson.data.body" :content="lesson.data.body"
:youtube="lesson.data.youtube" :youtube="lesson.data.youtube"
:quizId="lesson.data.quiz_id" :quizId="lesson.data.quiz_id"
@@ -240,7 +244,10 @@ const lesson = createResource({
auto: true, auto: true,
onSuccess(data) { onSuccess(data) {
if (Object.keys(data).length === 0) { if (Object.keys(data).length === 0) {
router.push({ name: 'Courses' }) router.push({
name: 'CourseDetail',
params: { courseName: props.courseName },
})
return return
} }
lessonProgress.value = data.membership?.progress lessonProgress.value = data.membership?.progress
@@ -369,13 +376,13 @@ const checkIfDiscussionsAllowed = () => {
const allowEdit = () => { const allowEdit = () => {
if (user.data?.is_moderator) return true if (user.data?.is_moderator) return true
if (lesson.data?.instructors.includes(user.data?.name)) return true if (lesson.data?.instructors?.includes(user.data?.name)) return true
return false return false
} }
const allowInstructorContent = () => { const allowInstructorContent = () => {
if (user.data?.is_moderator) return true if (user.data?.is_moderator) return true
if (lesson.data?.instructors.includes(user.data?.name)) return true if (lesson.data?.instructors?.includes(user.data?.name)) return true
return false return false
} }

View File

@@ -6,7 +6,11 @@
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b overflow-hidden bg-white px-3 py-2.5 sm:px-5" class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b overflow-hidden bg-white px-3 py-2.5 sm:px-5"
> >
<Breadcrumbs class="text-ellipsis" :items="breadcrumbs" /> <Breadcrumbs class="text-ellipsis" :items="breadcrumbs" />
<Button variant="solid" @click="saveLesson()" class="mt-3 md:mt-0"> <Button
variant="solid"
@click="saveLesson({ showSuccessMessage: true })"
class="mt-3 md:mt-0"
>
{{ __('Save') }} {{ __('Save') }}
</Button> </Button>
</header> </header>
@@ -88,12 +92,15 @@ import LessonHelp from '@/components/LessonHelp.vue'
import { ChevronRight } from 'lucide-vue-next' import { ChevronRight } from 'lucide-vue-next'
import { updateDocumentTitle, createToast, getEditorTools } from '@/utils' import { updateDocumentTitle, createToast, getEditorTools } from '@/utils'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { useSettings } from '@/stores/settings'
const editor = ref(null) const editor = ref(null)
const instructorEditor = ref(null) const instructorEditor = ref(null)
const user = inject('$user') const user = inject('$user')
const openInstructorEditor = ref(false) const openInstructorEditor = ref(false)
const settingsStore = useSettings()
let autoSaveInterval let autoSaveInterval
let showSuccessMessage = false
const props = defineProps({ const props = defineProps({
courseName: { courseName: {
@@ -117,6 +124,7 @@ onMounted(() => {
capture('lesson_form_opened') capture('lesson_form_opened')
editor.value = renderEditor('content') editor.value = renderEditor('content')
instructorEditor.value = renderEditor('instructor-notes') instructorEditor.value = renderEditor('instructor-notes')
window.addEventListener('keydown', keyboardShortcut)
}) })
const renderEditor = (holder) => { const renderEditor = (holder) => {
@@ -186,12 +194,24 @@ const addInstructorNotes = (data) => {
const enableAutoSave = () => { const enableAutoSave = () => {
autoSaveInterval = setInterval(() => { autoSaveInterval = setInterval(() => {
saveLesson() saveLesson({ showSuccessMessage: false })
}, 10000) }, 10000)
} }
const keyboardShortcut = (e) => {
if (
e.key === 's' &&
(e.ctrlKey || e.metaKey) &&
!e.target.classList.contains('ProseMirror')
) {
saveLesson({ showSuccessMessage: true })
e.preventDefault()
}
}
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearInterval(autoSaveInterval) clearInterval(autoSaveInterval)
window.removeEventListener('keydown', keyboardShortcut)
}) })
const newLessonResource = createResource({ const newLessonResource = createResource({
@@ -343,7 +363,11 @@ const convertToJSON = (lessonData) => {
return blocks return blocks
} }
const saveLesson = () => { const saveLesson = (e) => {
showSuccessMessage = false
if (typeof e != 'undefined' && e.showSuccessMessage) {
showSuccessMessage = true
}
editor.value.save().then((outputData) => { editor.value.save().then((outputData) => {
lesson.content = JSON.stringify(outputData) lesson.content = JSON.stringify(outputData)
instructorEditor.value.save().then((outputData) => { instructorEditor.value.save().then((outputData) => {
@@ -371,6 +395,9 @@ const createNewLesson = () => {
onSuccess() { onSuccess() {
capture('lesson_created') capture('lesson_created')
showToast('Success', 'Lesson created successfully', 'check') showToast('Success', 'Lesson created successfully', 'check')
if (!settingsStore.onboardingDetails.data?.is_onboarded) {
settingsStore.onboardingDetails.reload()
}
lessonDetails.reload() lessonDetails.reload()
}, },
} }
@@ -392,6 +419,11 @@ const editCurrentLesson = () => {
validate() { validate() {
return validateLesson() return validateLesson()
}, },
onSuccess() {
showSuccessMessage
? showToast('Success', 'Lesson updated successfully', 'check')
: ''
},
onError(err) { onError(err) {
showToast('Error', err.message, 'x') showToast('Error', err.message, 'x')
}, },

View File

@@ -0,0 +1,354 @@
<template>
<header
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadbrumbs" />
<Button variant="solid">
{{ __('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 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-gray-100 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"
>
<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 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-gray-100 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')"
/>
</template>
</Dialog>
</template>
<script setup>
import {
Breadcrumbs,
Button,
call,
createDocumentResource,
Dialog,
FormControl,
ListView,
ListRows,
ListRow,
ListHeader,
ListHeaderItem,
ListSelectBanner,
} from 'frappe-ui'
import { computed, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils/'
import Draggable from 'vuedraggable'
const showDialog = ref(false)
const currentForm = ref(null)
const course = ref(null)
const member = ref(null)
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
showToast(__('Success'), __('Course added to program'), 'check')
program.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
},
}
)
}
const addProgramMember = () => {
program.setValue.submit(
{
program_members: [
...program.doc.program_members,
{ member: member.value },
],
},
{
onSuccess(data) {
showDialog.value = false
member.value = null
showToast(__('Success'), __('Member added to program'), 'check')
program.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
},
}
)
}
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()
showToast(__('Success'), __('Items removed successfully'), 'check')
program.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
},
}
)
}
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) {
showToast(__('Success'), __('Course moved successfully'), 'check')
program.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
},
}
)
}
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: 'left',
},
]
})
const breadbrumbs = computed(() => {
return [
{
label: 'Programs',
route: { name: 'Programs' },
},
{
label: props.programName === 'new' ? 'New Program' : props.programName,
},
]
})
</script>

View File

@@ -0,0 +1,185 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadbrumbs" />
<Button
v-if="user.data?.is_moderator || user.data?.is_instructor"
@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-20">
<div class="flex items-center justify-between">
<div class="text-xl 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 ? __(singularize('members')) : __('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>
<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"
>
<CourseCard
v-for="course in program.courses"
:course="course"
@click="enrollMember(program.name, course.name)"
class="cursor-pointer"
/>
</div>
<div v-else class="text-sm italic text-gray-600 mt-4">
{{ __('No courses in this program') }}
</div>
</div>
</div>
<div
v-else
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
<div class="text-xl font-medium">
{{ __('No programs found') }}
</div>
<div class="leading-5">
{{
__(
'There are no programs available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div>
</div>
<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,
} from 'frappe-ui'
import { computed, inject, ref } from 'vue'
import { BookOpen, Edit, Plus } from 'lucide-vue-next'
import CourseCard from '@/components/CourseCard.vue'
import { useRouter } from 'vue-router'
import { showToast, singularize } from '@/utils'
const user = inject('$user')
const showDialog = ref(false)
const router = useRouter()
const title = ref('')
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) => {
showToast('Error', err.messages?.[0] || err, 'x')
})
}
const breadbrumbs = computed(() => [
{
label: 'Programs',
},
])
</script>

View File

@@ -141,6 +141,7 @@
v-slot="{ idx, column, item }" v-slot="{ idx, column, item }"
v-for="row in quiz.questions" v-for="row in quiz.questions"
@click="openQuestionModal(row)" @click="openQuestionModal(row)"
class="cursor-pointer"
> >
<ListRowItem :item="item"> <ListRowItem :item="item">
<div <div

View File

@@ -0,0 +1,204 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs class="h-7" :items="breadcrumbs" />
</header>
<div
v-if="
readyToRender &&
(enrollment.data?.length ||
user.data?.is_moderator ||
user.data?.is_instructor)
"
>
<iframe :src="chapter.doc.launch_file" class="w-full h-screen" />
</div>
<div v-else-if="!enrollment.data?.length">
<div class="text-center pt-10 px-5 md:px-0 pb-10">
<div class="text-center">
<div class="mb-4">
{{
__(
'You are not enrolled in this course. Please enroll to access this lesson.'
)
}}
</div>
<Button variant="solid" @click="enrollStudent()">
{{ __('Start Learning') }}
</Button>
</div>
</div>
</div>
</template>
<script setup>
import {
Breadcrumbs,
Button,
call,
createDocumentResource,
createListResource,
createResource,
} from 'frappe-ui'
import { computed, inject, onBeforeMount, ref } from 'vue'
import { useSidebar } from '@/stores/sidebar'
import { updateDocumentTitle } from '@/utils'
const sidebarStore = useSidebar()
const user = inject('$user')
const readyToRender = ref(false)
const props = defineProps({
courseName: {
type: String,
required: true,
},
chapterName: {
type: String,
required: true,
},
})
onBeforeMount(() => {
sidebarStore.isSidebarCollapsed = true
window.API_1484_11 = {
Initialize: () => 'true',
Terminate: () => 'true',
GetValue: (key) => {
console.log(`GET: ${key}`)
return getDataFromLMS(key)
},
SetValue: (key, value) => {
console.log(`SET: ${key} to value: ${value}`)
saveDataToLMS(key, value)
return 'true'
},
Commit: () => 'true',
GetLastError: () => '0',
GetErrorString: () => '',
GetDiagnostic: () => '',
}
window.API = {
LMSInitialize: () => 'true',
LMSFinish: () => 'true',
LMSGetValue: (key) => {
console.log(`GET: ${key}`)
return getDataFromLMS(key)
},
LMSSetValue: (key, value) => {
console.log(`SET: ${key} to value: ${value}`)
saveDataToLMS(key, value)
return 'true'
},
LMSCommit: () => 'true',
LMSGetLastError: () => '0',
LMSGetErrorString: () => '',
LMSGetDiagnostic: () => '',
}
})
const getDataFromLMS = (key) => {
if (key == 'cmi.core.lesson_status') {
if (progress.data?.status == 'Complete') {
return 'passed'
}
return 'incomplete'
}
return ''
}
const saveDataToLMS = (key, value) => {
if (key == 'cmi.core.lesson_status' && value == 'passed') {
saveProgress()
}
}
const enrollment = createListResource({
doctype: 'LMS Enrollment',
fields: ['member', 'course'],
filters: {
course: props.courseName,
member: user.data?.name,
},
auto: true,
cache: ['enrollments', props.courseName, user.data?.name],
})
const chapter = createDocumentResource({
doctype: 'Course Chapter',
name: props.chapterName,
auto: true,
cache: ['chapter', props.chapterName],
onSuccess(data) {
progress.submit()
},
})
const saveProgress = () => {
call('lms.lms.doctype.course_lesson.course_lesson.save_progress', {
lesson: chapter.doc.lessons[0].lesson,
course: props.courseName,
})
}
const progress = createResource({
url: 'frappe.client.get_value',
makeParams(values) {
return {
doctype: 'LMS Course Progress',
fieldname: 'status',
filters: {
member: user.data?.name,
lesson: chapter.doc.lessons[0].lesson,
chapter: chapter.doc.name,
course: chapter.doc?.course,
},
}
},
onSuccess(data) {
readyToRender.value = true
},
})
const enrollStudent = () => {
enrollment.insert.submit(
{
course: props.courseName,
member: user.data?.name,
},
{
onSuccess(data) {
window.location.reload()
},
}
)
}
const breadcrumbs = computed(() => {
return [
{
label: 'Courses',
route: { name: 'Courses' },
},
{
label: chapter.doc?.course_title,
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
},
{
label: chapter.doc?.title,
},
]
})
const pageMeta = computed(() => {
return {
title: chapter?.doc?.title,
description: __('This is a chapter in the course {0}').format(
chapter?.doc?.course_title
),
}
})
updateDocumentTitle(pageMeta)
</script>

View File

@@ -27,6 +27,12 @@ const routes = [
component: () => import('@/pages/Lesson.vue'), component: () => import('@/pages/Lesson.vue'),
props: true, props: true,
}, },
{
path: '/courses/:courseName/learn/:chapterName',
name: 'SCORMChapter',
component: () => import('@/pages/SCORMChapter.vue'),
props: true,
},
{ {
path: '/batches', path: '/batches',
name: 'Batches', name: 'Batches',
@@ -176,6 +182,17 @@ const routes = [
component: () => import('@/pages/QuizSubmission.vue'), component: () => import('@/pages/QuizSubmission.vue'),
props: true, props: true,
}, },
{
path: '/programs/:programName',
name: 'ProgramForm',
component: () => import('@/pages/ProgramForm.vue'),
props: true,
},
{
path: '/programs',
name: 'Programs',
component: () => import('@/pages/Programs.vue'),
},
] ]
let router = createRouter({ let router = createRouter({

View File

@@ -1,12 +1,32 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { createResource } from 'frappe-ui'
export const useSettings = defineStore('settings', () => { export const useSettings = defineStore('settings', () => {
const isSettingsOpen = ref(false) const isSettingsOpen = ref(false)
const activeTab = ref(null) const activeTab = ref(null)
const learningPaths = createResource({
url: 'frappe.client.get_single_value',
makeParams(values) {
return {
doctype: 'LMS Settings',
field: 'enable_learning_paths',
}
},
auto: true,
cache: ['learningPaths'],
})
const onboardingDetails = createResource({
url: 'lms.lms.utils.is_onboarding_complete',
auto: true,
cache: ['onboardingDetails'],
})
return { return {
isSettingsOpen, isSettingsOpen,
activeTab, activeTab,
learningPaths,
onboardingDetails,
} }
}) })

View File

@@ -0,0 +1,10 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useSidebar = defineStore('sidebar', () => {
const isSidebarCollapsed = ref(false)
return {
isSidebarCollapsed,
}
})

View File

@@ -5,6 +5,8 @@ import updateLocale from 'dayjs/esm/plugin/updateLocale'
import isToday from 'dayjs/esm/plugin/isToday' import isToday from 'dayjs/esm/plugin/isToday'
import isSameOrBefore from 'dayjs/esm/plugin/isSameOrBefore' import isSameOrBefore from 'dayjs/esm/plugin/isSameOrBefore'
import isSameOrAfter from 'dayjs/esm/plugin/isSameOrAfter' import isSameOrAfter from 'dayjs/esm/plugin/isSameOrAfter'
import utc from 'dayjs/esm/plugin/utc'
import timezone from 'dayjs/esm/plugin/timezone'
dayjs.extend(updateLocale) dayjs.extend(updateLocale)
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
@@ -12,5 +14,7 @@ dayjs.extend(localizedFormat)
dayjs.extend(isToday) dayjs.extend(isToday)
dayjs.extend(isSameOrBefore) dayjs.extend(isSameOrBefore)
dayjs.extend(isSameOrAfter) dayjs.extend(isSameOrAfter)
dayjs.extend(utc)
dayjs.extend(timezone)
export default dayjs export default dayjs

View File

@@ -11,6 +11,7 @@ import { watch } from 'vue'
import dayjs from '@/utils/dayjs' import dayjs from '@/utils/dayjs'
import Embed from '@editorjs/embed' import Embed from '@editorjs/embed'
import SimpleImage from '@editorjs/simple-image' import SimpleImage from '@editorjs/simple-image'
import Table from '@editorjs/table'
export function createToast(options) { export function createToast(options) {
toast({ toast({
@@ -93,7 +94,7 @@ export function showToast(title, text, icon, iconClasses = null) {
if (!iconClasses) { if (!iconClasses) {
if (icon == 'check') { if (icon == 'check') {
iconClasses = 'bg-green-600 text-white rounded-md p-px' iconClasses = 'bg-green-600 text-white rounded-md p-px'
} else if (icon == 'circle-warn') { } else if (icon == 'alert-circle') {
iconClasses = 'bg-yellow-600 text-white rounded-md p-px' iconClasses = 'bg-yellow-600 text-white rounded-md p-px'
} else { } else {
iconClasses = 'bg-red-600 text-white rounded-md p-px' iconClasses = 'bg-red-600 text-white rounded-md p-px'
@@ -150,6 +151,7 @@ export function getEditorTools() {
quiz: Quiz, quiz: Quiz,
upload: Upload, upload: Upload,
image: SimpleImage, image: SimpleImage,
table: Table,
paragraph: { paragraph: {
class: Paragraph, class: Paragraph,
inlineToolbar: true, inlineToolbar: true,

View File

@@ -120,6 +120,13 @@
dependencies: dependencies:
"@codexteam/icons" "^0.0.6" "@codexteam/icons" "^0.0.6"
"@editorjs/table@^2.4.2":
version "2.4.2"
resolved "https://registry.yarnpkg.com/@editorjs/table/-/table-2.4.2.tgz#99a2b3f9ea8f39c9ca4df80b8e63bff6e21d0193"
integrity sha512-zGmwLCarsaTgOfccxR3Lc6oC3QTX0JdoK0O3+8TE/VCR/xnW92VO7rAcu4cqTwtbFMQErYl8id9a5hM23vyFng==
dependencies:
"@codexteam/icons" "^0.0.6"
"@esbuild/aix-ppc64@0.21.5": "@esbuild/aix-ppc64@0.21.5":
version "0.21.5" version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f"

View File

@@ -1 +1 @@
__version__ = "2.11.0" __version__ = "2.14.0"

View File

@@ -66,7 +66,9 @@ def delete_lms_roles():
def create_course_creator_role(): def create_course_creator_role():
if not frappe.db.exists("Role", "Course Creator"): if frappe.db.exists("Role", "Course Creator"):
frappe.db.set_value("Role", "Course Creator", "desk_access", 0)
else:
role = frappe.get_doc( role = frappe.get_doc(
{ {
"doctype": "Role", "doctype": "Role",
@@ -79,7 +81,9 @@ def create_course_creator_role():
def create_moderator_role(): def create_moderator_role():
if not frappe.db.exists("Role", "Moderator"): if frappe.db.exists("Role", "Moderator"):
frappe.db.set_value("Role", "Moderator", "desk_access", 0)
else:
role = frappe.get_doc( role = frappe.get_doc(
{ {
"doctype": "Role", "doctype": "Role",
@@ -92,7 +96,9 @@ def create_moderator_role():
def create_evaluator_role(): def create_evaluator_role():
if not frappe.db.exists("Role", "Batch Evaluator"): if frappe.db.exists("Role", "Batch Evaluator"):
frappe.db.set_value("Role", "Batch Evaluator", "desk_access", 0)
else:
role = frappe.new_doc("Role") role = frappe.new_doc("Role")
role.update( role.update(
{ {
@@ -105,7 +111,9 @@ def create_evaluator_role():
def create_lms_student_role(): def create_lms_student_role():
if not frappe.db.exists("Role", "LMS Student"): if frappe.db.exists("Role", "LMS Student"):
frappe.db.set_value("Role", "LMS Student", "desk_access", 0)
else:
role = frappe.new_doc("Role") role = frappe.new_doc("Role")
role.update( role.update(
{ {

View File

@@ -3,6 +3,10 @@
import json import json
import frappe import frappe
import zipfile
import os
import shutil
import xml.etree.ElementTree as ET
from frappe.translate import get_all_translations from frappe.translate import get_all_translations
from frappe import _ from frappe import _
from frappe.query_builder import DocType from frappe.query_builder import DocType
@@ -10,6 +14,7 @@ from frappe.query_builder.functions import Count
from frappe.utils import time_diff, now_datetime, get_datetime, flt from frappe.utils import time_diff, now_datetime, get_datetime, flt
from typing import Optional from typing import Optional
from lms.lms.utils import get_average_rating, get_lesson_count from lms.lms.utils import get_average_rating, get_lesson_count
from xml.dom.minidom import parseString
@frappe.whitelist() @frappe.whitelist()
@@ -876,3 +881,124 @@ def give_dicussions_permission():
"delete": 1, "delete": 1,
} }
).save(ignore_permissions=True) ).save(ignore_permissions=True)
@frappe.whitelist()
def upsert_chapter(title, course, is_scorm_package, scorm_package, name=None):
values = frappe._dict(
{"title": title, "course": course, "is_scorm_package": is_scorm_package}
)
if is_scorm_package:
scorm_package = frappe._dict(scorm_package)
extract_path = extract_package(course, title, scorm_package)
values.update(
{
"scorm_package": scorm_package.name,
"scorm_package_path": extract_path.split("public")[1],
"manifest_file": get_manifest_file(extract_path).split("public")[1],
"launch_file": get_launch_file(extract_path).split("public")[1],
}
)
if name:
chapter = frappe.get_doc("Course Chapter", name)
else:
chapter = frappe.new_doc("Course Chapter")
chapter.update(values)
chapter.save()
if is_scorm_package and not len(chapter.lessons):
add_lesson(title, chapter.name, course)
return chapter
def extract_package(course, title, scorm_package):
package = frappe.get_doc("File", scorm_package.name)
zip_path = package.get_full_path()
extract_path = frappe.get_site_path("public", "files", "scorm", course, title)
zipfile.ZipFile(zip_path).extractall(extract_path)
return extract_path
def get_manifest_file(extract_path):
manifest_file = None
for root, dirs, files in os.walk(extract_path):
for file in files:
if file == "imsmanifest.xml":
manifest_file = os.path.join(root, file)
break
if manifest_file:
break
return manifest_file
def get_launch_file(extract_path):
launch_file = None
manifest_file = get_manifest_file(extract_path)
if manifest_file:
with open(manifest_file) as file:
data = file.read()
dom = parseString(data)
resource = dom.getElementsByTagName("resource")
for res in resource:
if (
res.getAttribute("adlcp:scormtype") == "sco"
or res.getAttribute("adlcp:scormType") == "sco"
):
launch_file = res.getAttribute("href")
break
if launch_file:
launch_file = os.path.join(os.path.dirname(manifest_file), launch_file)
return launch_file
def add_lesson(title, chapter, course):
lesson = frappe.new_doc("Course Lesson")
lesson.update(
{
"title": title,
"chapter": chapter,
"course": course,
}
)
lesson.insert()
lesson_reference = frappe.new_doc("Lesson Reference")
lesson_reference.update(
{
"lesson": lesson.name,
"parent": chapter,
"parenttype": "Course Chapter",
"parentfield": "lessons",
}
)
lesson_reference.insert()
@frappe.whitelist()
def delete_chapter(chapter):
chapterInfo = frappe.db.get_value(
"Course Chapter", chapter, ["is_scorm_package", "scorm_package_path"], as_dict=True
)
if chapterInfo.is_scorm_package:
delete_scorm_package(chapterInfo.scorm_package_path)
frappe.db.delete("Chapter Reference", {"chapter": chapter})
frappe.db.delete("Lesson Reference", {"parent": chapter})
frappe.db.delete("Course Lesson", {"chapter": chapter})
frappe.db.delete("Course Chapter", chapter)
def delete_scorm_package(scorm_package_path):
scorm_package_path = frappe.get_site_path("public", scorm_package_path)
if os.path.exists(scorm_package_path):
shutil.rmtree(scorm_package_path)

View File

@@ -8,9 +8,17 @@
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"course",
"column_break_3",
"title", "title",
"column_break_3",
"course",
"course_title",
"scorm_section",
"is_scorm_package",
"scorm_package",
"scorm_package_path",
"column_break_dlnw",
"manifest_file",
"launch_file",
"section_break_5", "section_break_5",
"lessons" "lessons"
], ],
@@ -43,6 +51,56 @@
"fieldtype": "Table", "fieldtype": "Table",
"label": "Lessons", "label": "Lessons",
"options": "Lesson Reference" "options": "Lesson Reference"
},
{
"default": "0",
"fieldname": "is_scorm_package",
"fieldtype": "Check",
"label": "Is SCORM Package"
},
{
"depends_on": "is_scorm_package",
"fieldname": "manifest_file",
"fieldtype": "Code",
"label": "Manifest File",
"read_only": 1
},
{
"depends_on": "is_scorm_package",
"fieldname": "launch_file",
"fieldtype": "Code",
"label": "Launch File",
"read_only": 1
},
{
"fieldname": "scorm_section",
"fieldtype": "Section Break",
"label": "SCORM"
},
{
"fieldname": "scorm_package",
"fieldtype": "Link",
"label": "SCORM Package",
"options": "File",
"read_only": 1
},
{
"fieldname": "column_break_dlnw",
"fieldtype": "Column Break"
},
{
"depends_on": "is_scorm_package",
"fieldname": "scorm_package_path",
"fieldtype": "Code",
"label": "SCORM Package Path",
"read_only": 1
},
{
"fetch_from": "course.title",
"fieldname": "course_title",
"fieldtype": "Data",
"label": "Course Title",
"read_only": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
@@ -53,7 +111,7 @@
"link_fieldname": "chapter" "link_fieldname": "chapter"
} }
], ],
"modified": "2024-10-29 16:54:20.904683", "modified": "2024-11-15 12:03:31.370943",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "Course Chapter", "name": "Course Chapter",
@@ -73,17 +131,14 @@
"write": 1 "write": 1
}, },
{ {
"create": 1,
"email": 1, "email": 1,
"export": 1, "export": 1,
"if_owner": 1,
"print": 1, "print": 1,
"read": 1, "read": 1,
"report": 1, "report": 1,
"role": "LMS Student", "role": "LMS Student",
"select": 1, "select": 1,
"share": 1, "share": 1
"write": 1
} }
], ],
"search_fields": "title", "search_fields": "title",

View File

@@ -8,12 +8,18 @@
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"chapter",
"course",
"column_break_4",
"title", "title",
"include_in_preview", "include_in_preview",
"index_label", "column_break_4",
"chapter",
"is_scorm_package",
"course",
"section_break_11",
"content",
"body",
"column_break_cjmf",
"instructor_content",
"instructor_notes",
"section_break_6", "section_break_6",
"youtube", "youtube",
"column_break_9", "column_break_9",
@@ -22,13 +28,7 @@
"question", "question",
"column_break_15", "column_break_15",
"file_type", "file_type",
"section_break_11", "column_break_syza",
"content",
"body",
"column_break_cjmf",
"instructor_content",
"instructor_notes",
"help_section",
"help" "help"
], ],
"fields": [ "fields": [
@@ -59,12 +59,6 @@
"label": "Title", "label": "Title",
"reqd": 1 "reqd": 1
}, },
{
"fieldname": "index_label",
"fieldtype": "Data",
"label": "Index Label",
"read_only": 1
},
{ {
"fieldname": "section_break_6", "fieldname": "section_break_6",
"fieldtype": "Section Break", "fieldtype": "Section Break",
@@ -74,14 +68,7 @@
"fieldname": "body", "fieldname": "body",
"fieldtype": "Markdown Editor", "fieldtype": "Markdown Editor",
"ignore_xss_filter": 1, "ignore_xss_filter": 1,
"label": "Body", "label": "Body"
"reqd": 1
},
{
"fieldname": "help_section",
"fieldtype": "Section Break",
"hidden": 1,
"label": "Help"
}, },
{ {
"fieldname": "help", "fieldname": "help",
@@ -158,11 +145,23 @@
"fieldname": "instructor_content", "fieldname": "instructor_content",
"fieldtype": "Text", "fieldtype": "Text",
"label": "Instructor Content" "label": "Instructor Content"
},
{
"fieldname": "column_break_syza",
"fieldtype": "Column Break"
},
{
"default": "0",
"fetch_from": "chapter.is_scorm_package",
"fieldname": "is_scorm_package",
"fieldtype": "Check",
"label": "Is SCORM Package",
"read_only": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-10-08 11:04:54.748773", "modified": "2024-11-14 13:46:56.838659",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "Course Lesson", "name": "Course Lesson",

View File

@@ -52,7 +52,6 @@ class CourseLesson(Document):
ex.lesson = None ex.lesson = None
ex.course = None ex.course = None
ex.index_ = 0 ex.index_ = 0
ex.index_label = ""
ex.save(ignore_permissions=True) ex.save(ignore_permissions=True)
def check_and_create_folder(self): def check_and_create_folder(self):
@@ -94,15 +93,15 @@ def save_progress(lesson, course):
frappe.db.set_value("LMS Enrollment", membership, "current_lesson", lesson) frappe.db.set_value("LMS Enrollment", membership, "current_lesson", lesson)
quiz_completed = get_quiz_progress(lesson)
if not quiz_completed:
return 0
if frappe.db.exists( if frappe.db.exists(
"LMS Course Progress", {"lesson": lesson, "member": frappe.session.user} "LMS Course Progress", {"lesson": lesson, "member": frappe.session.user}
): ):
return return
quiz_completed = get_quiz_progress(lesson)
if not quiz_completed:
return 0
frappe.get_doc( frappe.get_doc(
{ {
"doctype": "LMS Course Progress", "doctype": "LMS Course Progress",

View File

@@ -193,13 +193,15 @@
"depends_on": "paid_batch", "depends_on": "paid_batch",
"fieldname": "amount", "fieldname": "amount",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Amount" "label": "Amount",
"mandatory_depends_on": "paid_batch"
}, },
{ {
"depends_on": "paid_batch", "depends_on": "paid_batch",
"fieldname": "currency", "fieldname": "currency",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Currency", "label": "Currency",
"mandatory_depends_on": "paid_batch",
"options": "Currency" "options": "Currency"
}, },
{ {
@@ -328,7 +330,7 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-07-18 18:06:37.229885", "modified": "2024-11-18 16:28:41.336928",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Batch", "name": "LMS Batch",

View File

@@ -28,6 +28,7 @@ class LMSBatch(Document):
self.validate_duplicate_courses() self.validate_duplicate_courses()
self.validate_duplicate_students() self.validate_duplicate_students()
self.validate_payments_app() self.validate_payments_app()
self.validate_amount_and_currency()
self.validate_duplicate_assessments() self.validate_duplicate_assessments()
self.validate_membership() self.validate_membership()
self.validate_timetable() self.validate_timetable()
@@ -64,6 +65,10 @@ class LMSBatch(Document):
if "payments" not in installed_apps: if "payments" not in installed_apps:
frappe.throw(_("Please install the Payments app to create a paid batches.")) frappe.throw(_("Please install the Payments app to create a paid batches."))
def validate_amount_and_currency(self):
if self.paid_batch and (not self.amount or not self.currency):
frappe.throw(_("Amount and currency are required for paid batches."))
def validate_duplicate_assessments(self): def validate_duplicate_assessments(self):
assessments = [row.assessment_name for row in self.assessment] assessments = [row.assessment_name for row in self.assessment]
for assessment in self.assessment: for assessment in self.assessment:

View File

@@ -19,6 +19,7 @@ class LMSCourse(Document):
self.validate_video_link() self.validate_video_link()
self.validate_status() self.validate_status()
self.validate_payments_app() self.validate_payments_app()
self.validate_amount_and_currency()
self.image = validate_image(self.image) self.image = validate_image(self.image)
def validate_published(self): def validate_published(self):
@@ -51,6 +52,10 @@ class LMSCourse(Document):
if "payments" not in installed_apps: if "payments" not in installed_apps:
frappe.throw(_("Please install the Payments app to create a paid courses.")) frappe.throw(_("Please install the Payments app to create a paid courses."))
def validate_amount_and_currency(self):
if self.paid_course and (not self.course_price and not self.currency):
frappe.throw(_("Amount and currency are required for paid courses."))
def on_update(self): def on_update(self):
if not self.upcoming and self.has_value_changed("upcoming"): if not self.upcoming and self.has_value_changed("upcoming"):
self.send_email_to_interested_users() self.send_email_to_interested_users()

View File

@@ -4,6 +4,7 @@
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import ceil
class LMSEnrollment(Document): class LMSEnrollment(Document):
@@ -11,6 +12,9 @@ class LMSEnrollment(Document):
self.validate_membership_in_same_batch() self.validate_membership_in_same_batch()
self.validate_membership_in_different_batch_same_course() self.validate_membership_in_different_batch_same_course()
def on_update(self):
self.update_program_progress()
def validate_membership_in_same_batch(self): def validate_membership_in_same_batch(self):
filters = {"member": self.member, "course": self.course, "name": ["!=", self.name]} filters = {"member": self.member, "course": self.course, "name": ["!=", self.name]}
if self.batch_old: if self.batch_old:
@@ -55,6 +59,26 @@ 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
average_progress = ceil(total_progress / len(courses))
frappe.db.set_value("LMS Program Member", program.name, "progress", average_progress)
@frappe.whitelist() @frappe.whitelist()
def create_membership( def create_membership(

View File

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2024, Frappe and contributors
// For license information, please see license.txt
// frappe.ui.form.on("LMS Program", {
// refresh(frm) {
// },
// });

View File

@@ -0,0 +1,84 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:title",
"creation": "2024-11-18 12:27:13.283169",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"title",
"program_courses",
"program_members"
],
"fields": [
{
"fieldname": "program_courses",
"fieldtype": "Table",
"label": "Program Courses",
"options": "LMS Program Course"
},
{
"fieldname": "program_members",
"fieldtype": "Table",
"label": "Program Members",
"options": "LMS Program Member"
},
{
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Title",
"reqd": 1,
"unique": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-11-20 12:26:02.214628",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Program",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Course Creator",
"share": 1,
"write": 1
}
],
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,32 @@
# Copyright (c) 2024, Frappe and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.document import Document
class LMSProgram(Document):
def validate(self):
self.validate_program_courses()
self.validate_program_members()
def validate_program_courses(self):
courses = [row.course for row in self.program_courses]
duplicates = {course for course in courses if courses.count(course) > 1}
if len(duplicates):
frappe.throw(
_("Course {0} has already been added to this batch.").format(
frappe.bold(next(iter(duplicates)))
)
)
def validate_program_members(self):
members = [row.member for row in self.program_members]
duplicates = {member for member in members if members.count(member) > 1}
if len(duplicates):
frappe.throw(
_("Member {0} has already been added to this batch.").format(
frappe.bold(next(iter(duplicates)))
)
)

View File

@@ -0,0 +1,21 @@
# Copyright (c) 2024, Frappe and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record depdendencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class TestLMSProgram(UnitTestCase):
"""
Unit tests for LMSProgram.
Use this class for testing individual functions and methods.
"""
pass

View File

@@ -0,0 +1,42 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2024-11-18 12:27:37.030302",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"course",
"course_title"
],
"fields": [
{
"fieldname": "course",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Course",
"options": "LMS Course",
"reqd": 1
},
{
"fetch_from": "course.title",
"fieldname": "course_title",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Course Title",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-11-18 12:43:46.800199",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Program Course",
"owner": "Administrator",
"permissions": [],
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,9 @@
# Copyright (c) 2024, Frappe and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class LMSProgramCourse(Document):
pass

View File

@@ -0,0 +1,50 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2024-11-18 12:29:13.615014",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"member",
"full_name",
"progress"
],
"fields": [
{
"fieldname": "member",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Member",
"options": "User",
"reqd": 1
},
{
"fetch_from": "member.full_name",
"fieldname": "full_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Full Name",
"read_only": 1
},
{
"default": "0",
"fieldname": "progress",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Progress"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-11-21 12:51:31.882576",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Program Member",
"owner": "Administrator",
"permissions": [],
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,9 @@
# Copyright (c) 2024, Frappe and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class LMSProgramMember(Document):
pass

View File

@@ -16,6 +16,7 @@ class LMSQuestion(Document):
def validate_correct_answers(question): def validate_correct_answers(question):
if question.type == "Choices": if question.type == "Choices":
validate_duplicate_options(question) validate_duplicate_options(question)
validate_minimum_options(question)
validate_correct_options(question) validate_correct_options(question)
elif question.type == "User Input": elif question.type == "User Input":
validate_possible_answer(question) validate_possible_answer(question)
@@ -42,6 +43,11 @@ def validate_correct_options(question):
frappe.throw(_("At least one option must be correct for this question.")) frappe.throw(_("At least one option must be correct for this question."))
def validate_minimum_options(question):
if question.type == "Choices" and (not question.option_1 or not question.option_2):
frappe.throw(_("Minimum two options are required for multiple choice questions."))
def validate_possible_answer(question): def validate_possible_answer(question):
possible_answers = [] possible_answers = []
possible_answers_fields = [ possible_answers_fields = [

View File

@@ -5,13 +5,15 @@
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"general_tab",
"default_home", "default_home",
"send_calendar_invite_for_evaluations",
"is_onboarding_complete", "is_onboarding_complete",
"column_break_zdel", "column_break_zdel",
"enable_learning_paths",
"unsplash_access_key", "unsplash_access_key",
"livecode_url", "livecode_url",
"section_break_szgq", "section_break_szgq",
"send_calendar_invite_for_evaluations",
"show_day_view", "show_day_view",
"column_break_2", "column_break_2",
"show_dashboard", "show_dashboard",
@@ -80,6 +82,7 @@
{ {
"fieldname": "mentor_request_section", "fieldname": "mentor_request_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"hidden": 1,
"label": "Mentor Request" "label": "Mentor Request"
}, },
{ {
@@ -127,6 +130,7 @@
{ {
"fieldname": "section_break_szgq", "fieldname": "section_break_szgq",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"hidden": 1,
"label": "Batch Settings" "label": "Batch Settings"
}, },
{ {
@@ -336,12 +340,23 @@
"fieldname": "payments_app_is_not_installed", "fieldname": "payments_app_is_not_installed",
"fieldtype": "HTML", "fieldtype": "HTML",
"label": "Payments app is not installed" "label": "Payments app is not installed"
},
{
"default": "0",
"fieldname": "enable_learning_paths",
"fieldtype": "Check",
"label": "Enable Learning Paths"
},
{
"fieldname": "general_tab",
"fieldtype": "Tab Break",
"label": "General"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2024-10-01 12:15:49.800242", "modified": "2024-11-20 11:55:05.358421",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Settings", "name": "LMS Settings",
@@ -356,6 +371,13 @@
"role": "System Manager", "role": "System Manager",
"share": 1, "share": 1,
"write": 1 "write": 1
},
{
"email": 1,
"print": 1,
"read": 1,
"role": "LMS Student",
"share": 1
} }
], ],
"sort_field": "modified", "sort_field": "modified",

View File

@@ -855,7 +855,10 @@ def get_telemetry_boot_info():
} }
@frappe.whitelist()
def is_onboarding_complete(): def is_onboarding_complete():
if not has_course_moderator_role():
return {"is_onboarded": False}
course_created = frappe.db.a_row_exists("LMS Course") course_created = frappe.db.a_row_exists("LMS Course")
chapter_created = frappe.db.a_row_exists("Course Chapter") chapter_created = frappe.db.a_row_exists("Course Chapter")
lesson_created = frappe.db.a_row_exists("Course Lesson") lesson_created = frappe.db.a_row_exists("Course Lesson")
@@ -1128,11 +1131,20 @@ def get_course_outline(course, progress=False):
chapter_details = frappe.db.get_value( chapter_details = frappe.db.get_value(
"Course Chapter", "Course Chapter",
chapter.chapter, chapter.chapter,
["name", "title"], ["name", "title", "is_scorm_package", "launch_file", "scorm_package"],
as_dict=True, as_dict=True,
) )
chapter_details["idx"] = chapter.idx chapter_details["idx"] = chapter.idx
chapter_details.lessons = get_lessons(course, chapter_details, progress=progress) chapter_details.lessons = get_lessons(course, chapter_details, progress=progress)
if chapter_details.is_scorm_package:
chapter_details.scorm_package = frappe.db.get_value(
"File",
chapter_details.scorm_package,
["file_name", "file_size", "file_url"],
as_dict=1,
)
outline.append(chapter_details) outline.append(chapter_details)
return outline return outline
@@ -1146,9 +1158,12 @@ def get_lesson(course, chapter, lesson):
"Lesson Reference", {"parent": chapter_name, "idx": lesson}, "lesson" "Lesson Reference", {"parent": chapter_name, "idx": lesson}, "lesson"
) )
lesson_details = frappe.db.get_value( lesson_details = frappe.db.get_value(
"Course Lesson", lesson_name, ["include_in_preview", "title"], as_dict=1 "Course Lesson",
lesson_name,
["include_in_preview", "title", "is_scorm_package"],
as_dict=1,
) )
if not lesson_details: if not lesson_details or lesson_details.is_scorm_package:
return {} return {}
membership = get_membership(course) membership = get_membership(course)
@@ -1739,3 +1754,81 @@ def enroll_in_batch(batch, payment_name=None):
) )
student.save(ignore_permissions=True) student.save(ignore_permissions=True)
@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"]
)
for program in programs:
program_courses = frappe.get_all(
"LMS Program Course", {"parent": program.name}, ["course"], order_by="idx"
)
program.courses = []
for course in program_courses:
program.courses.append(get_course_details(course.course))
program.members = frappe.db.count("LMS Program Member", {"parent": program.name})
return 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
program_courses = frappe.get_all(
"LMS Program Course", {"parent": program}, ["course", "idx"], 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,
)
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

View File

@@ -1,4 +1,5 @@
{ {
"app": "lms",
"charts": [ "charts": [
{ {
"chart_name": "New Signups", "chart_name": "New Signups",
@@ -145,7 +146,7 @@
"type": "Link" "type": "Link"
} }
], ],
"modified": "2024-08-09 13:19:06.273056", "modified": "2024-11-21 12:16:25.886431",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS", "name": "LMS",
@@ -212,5 +213,6 @@
"type": "DocType" "type": "DocType"
} }
], ],
"title": "LMS" "title": "LMS",
"type": "Workspace"
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -94,3 +94,4 @@ lms.patches.v2_0.delete_certificate_request_notification #18-09-2024
lms.patches.v2_0.add_course_statistics #21-10-2024 lms.patches.v2_0.add_course_statistics #21-10-2024
lms.patches.v2_0.give_discussions_permissions lms.patches.v2_0.give_discussions_permissions
lms.patches.v2_0.delete_web_forms lms.patches.v2_0.delete_web_forms
lms.patches.v2_0.update_desk_access_for_lms_roles

View File

@@ -0,0 +1,9 @@
import frappe
def execute():
roles = ["Course Creator", "Moderator", "Batch Evaluator", "LMS Student"]
for role in roles:
if frappe.db.exists("Role", role):
frappe.db.set_value("Role", role, "desk_access", 0)