chore: resolved conflicts
This commit is contained in:
2
frontend/components.d.ts
vendored
2
frontend/components.d.ts
vendored
@@ -42,6 +42,8 @@ declare module 'vue' {
|
|||||||
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
|
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
|
||||||
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
|
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
|
||||||
ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default']
|
ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default']
|
||||||
|
CommandPalette: typeof import('./src/components/CommandPalette/CommandPalette.vue')['default']
|
||||||
|
CommandPaletteGroup: typeof import('./src/components/CommandPalette/CommandPaletteGroup.vue')['default']
|
||||||
Configuration: typeof import('./src/components/Sidebar/Configuration.vue')['default']
|
Configuration: typeof import('./src/components/Sidebar/Configuration.vue')['default']
|
||||||
ContactUsEmail: typeof import('./src/components/ContactUsEmail.vue')['default']
|
ContactUsEmail: typeof import('./src/components/ContactUsEmail.vue')['default']
|
||||||
CouponDetails: typeof import('./src/components/Settings/Coupons/CouponDetails.vue')['default']
|
CouponDetails: typeof import('./src/components/Settings/Coupons/CouponDetails.vue')['default']
|
||||||
|
|||||||
272
frontend/src/components/CommandPalette/CommandPalette.vue
Normal file
272
frontend/src/components/CommandPalette/CommandPalette.vue
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog v-model="show" :options="{ size: '2xl' }">
|
||||||
|
<template #body>
|
||||||
|
<div class="text-base">
|
||||||
|
<div class="flex items-center space-x-2 pl-4.5 border-b">
|
||||||
|
<Search class="size-4 text-ink-gray-4" />
|
||||||
|
<input
|
||||||
|
ref="inputRef"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search"
|
||||||
|
class="w-full border-none bg-transparent py-3 !pl-2 pr-4.5 text-base text-ink-gray-7 placeholder-ink-gray-4 focus:ring-0"
|
||||||
|
@input="onInput"
|
||||||
|
v-model="query"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-96 overflow-auto mb-2">
|
||||||
|
<div v-if="query.length" class="mt-5 space-y-5">
|
||||||
|
<CommandPaletteGroup
|
||||||
|
:list="searchResults"
|
||||||
|
@navigateTo="navigateTo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mt-5 space-y-5">
|
||||||
|
<CommandPaletteGroup
|
||||||
|
:list="jumpToOptions"
|
||||||
|
@navigateTo="navigateTo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center space-x-5 w-full border-t py-2 text-sm text-ink-gray-7 px-4.5"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<MoveUp
|
||||||
|
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
|
||||||
|
/>
|
||||||
|
<MoveDown
|
||||||
|
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{{ __('to navigate') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<CornerDownLeft
|
||||||
|
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{{ __('to select') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="bg-surface-gray-2 p-1 rounded-sm"> esc </span>
|
||||||
|
<span>
|
||||||
|
{{ __('to close') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { createResource, debounce, Dialog } from 'frappe-ui'
|
||||||
|
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
Briefcase,
|
||||||
|
CornerDownLeft,
|
||||||
|
FileSearch,
|
||||||
|
MoveUp,
|
||||||
|
MoveDown,
|
||||||
|
Search,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import CommandPaletteGroup from './CommandPaletteGroup.vue'
|
||||||
|
|
||||||
|
const show = defineModel<boolean>({ required: true, default: false })
|
||||||
|
const router = useRouter()
|
||||||
|
const query = ref<string>('')
|
||||||
|
const searchResults = ref<Array<any>>([])
|
||||||
|
|
||||||
|
const search = createResource({
|
||||||
|
url: 'lms.command_palette.search_sqlite',
|
||||||
|
makeParams: () => ({
|
||||||
|
query: query.value,
|
||||||
|
}),
|
||||||
|
onSuccess() {
|
||||||
|
generateSearchResults()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const debouncedSearch = debounce(() => {
|
||||||
|
if (query.value.length > 2) {
|
||||||
|
search.reload()
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
const onInput = () => {
|
||||||
|
debouncedSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateSearchResults = () => {
|
||||||
|
search.data?.forEach((type: any) => {
|
||||||
|
let result: { title: string; items: any[] } = { title: '', items: [] }
|
||||||
|
result.title = type.title
|
||||||
|
type.items.forEach((item: any) => {
|
||||||
|
let paramName = item.doctype === 'LMS Course' ? 'courseName' : 'batchName'
|
||||||
|
item.route = {
|
||||||
|
name: item.doctype === 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
|
||||||
|
params: {
|
||||||
|
[paramName]: item.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
item.isActive = false
|
||||||
|
})
|
||||||
|
result.items = type.items
|
||||||
|
searchResults.value.push(result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendSearchPage = () => {
|
||||||
|
let searchPage: { title: string; items: Array<any> } = {
|
||||||
|
title: '',
|
||||||
|
items: [],
|
||||||
|
}
|
||||||
|
searchPage.title = __('Jump to')
|
||||||
|
searchPage.items = [
|
||||||
|
{
|
||||||
|
title: __('Search for ') + `"${query.value}"`,
|
||||||
|
route: {
|
||||||
|
name: 'Search',
|
||||||
|
query: {
|
||||||
|
q: query.value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
icon: FileSearch,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
searchResults.value = [searchPage]
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
query,
|
||||||
|
() => {
|
||||||
|
appendSearchPage()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(show, () => {
|
||||||
|
if (!show.value) {
|
||||||
|
query.value = ''
|
||||||
|
searchResults.value = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
addKeyboardShortcuts()
|
||||||
|
})
|
||||||
|
|
||||||
|
const addKeyboardShortcuts = () => {
|
||||||
|
window.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowUp' && show.value) {
|
||||||
|
e.preventDefault()
|
||||||
|
shortcutForArrowKey(-1)
|
||||||
|
} else if (e.key === 'ArrowDown' && show.value) {
|
||||||
|
shortcutForArrowKey(1)
|
||||||
|
} else if (e.key === 'Enter' && show.value) {
|
||||||
|
shortcutForEnter()
|
||||||
|
} else if (e.key === 'Escape' && show.value) {
|
||||||
|
show.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortcutForArrowKey = (direction: number) => {
|
||||||
|
let currentList = query.value.length
|
||||||
|
? searchResults.value
|
||||||
|
: jumpToOptions.value
|
||||||
|
let allItems = currentList.flatMap((result: any) => result.items)
|
||||||
|
let indexOfActive = allItems.findIndex((option: any) => option.isActive)
|
||||||
|
let newIndex = indexOfActive + direction
|
||||||
|
if (newIndex < 0) newIndex = allItems.length - 1
|
||||||
|
if (newIndex >= allItems.length) newIndex = 0
|
||||||
|
allItems[indexOfActive].isActive = false
|
||||||
|
allItems[newIndex].isActive = true
|
||||||
|
nextTick(scrollActiveItemIntoView)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollActiveItemIntoView = () => {
|
||||||
|
const activeItem = document.querySelector(
|
||||||
|
'.hover\\:bg-surface-gray-2.bg-surface-gray-2'
|
||||||
|
) as HTMLElement
|
||||||
|
if (activeItem) {
|
||||||
|
activeItem.scrollIntoView({ block: 'nearest' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortcutForEnter = () => {
|
||||||
|
let currentList = query.value.length
|
||||||
|
? searchResults.value
|
||||||
|
: jumpToOptions.value
|
||||||
|
let allItems = currentList.flatMap((result: any) => result.items)
|
||||||
|
let activeOption = allItems.find((option) => option.isActive)
|
||||||
|
if (activeOption) {
|
||||||
|
navigateTo(activeOption.route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateTo = (route: {
|
||||||
|
name: string
|
||||||
|
params?: Record<string, any>
|
||||||
|
query?: Record<string, any>
|
||||||
|
}) => {
|
||||||
|
show.value = false
|
||||||
|
query.value = ''
|
||||||
|
router.replace({ name: route.name, params: route.params, query: route.query })
|
||||||
|
}
|
||||||
|
|
||||||
|
const jumpToOptions = ref([
|
||||||
|
{
|
||||||
|
title: __('Jump to'),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: 'Advanced Search',
|
||||||
|
icon: Search,
|
||||||
|
route: {
|
||||||
|
name: 'Search',
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Courses',
|
||||||
|
icon: BookOpen,
|
||||||
|
route: {
|
||||||
|
name: 'Courses',
|
||||||
|
},
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Batches',
|
||||||
|
icon: Users,
|
||||||
|
route: {
|
||||||
|
name: 'Batches',
|
||||||
|
},
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Jobs',
|
||||||
|
icon: Briefcase,
|
||||||
|
route: {
|
||||||
|
name: 'Jobs',
|
||||||
|
},
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
mark {
|
||||||
|
background-color: theme('colors.amber.100');
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<div v-for="result in list" class="px-2.5 space-y-2">
|
||||||
|
<div class="text-ink-gray-5 px-2">
|
||||||
|
{{ result.title }}
|
||||||
|
</div>
|
||||||
|
<div class="">
|
||||||
|
<div
|
||||||
|
v-for="item in result.items"
|
||||||
|
class="flex items-center justify-between p-2 rounded hover:bg-surface-gray-2 cursor-pointer"
|
||||||
|
:class="{ 'bg-surface-gray-2': item.isActive }"
|
||||||
|
@click="emit('navigateTo', item.route)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<component
|
||||||
|
v-if="item.icon"
|
||||||
|
:is="item.icon"
|
||||||
|
class="size-4 stroke-1.5 text-ink-gray-6"
|
||||||
|
/>
|
||||||
|
<div v-html="item.title"></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="item.modified" class="text-ink-gray-5">
|
||||||
|
{{ dayjs.unix(item.modified).fromNow(true) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { inject } from 'vue'
|
||||||
|
|
||||||
|
const dayjs = inject<any>('$dayjs')
|
||||||
|
const emit = defineEmits(['navigateTo'])
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
list: Array<{
|
||||||
|
title: string
|
||||||
|
items: Array<{
|
||||||
|
title: string
|
||||||
|
icon?: any
|
||||||
|
isActive?: boolean
|
||||||
|
modified?: string
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -156,17 +156,7 @@ const getGradientColor = () => {
|
|||||||
localStorage.getItem('theme') == 'light' ? 'lightMode' : 'darkMode'
|
localStorage.getItem('theme') == 'light' ? 'lightMode' : 'darkMode'
|
||||||
let color = props.course.card_gradient?.toLowerCase() || 'blue'
|
let color = props.course.card_gradient?.toLowerCase() || 'blue'
|
||||||
let colorMap = colors[theme][color]
|
let colorMap = colors[theme][color]
|
||||||
return `linear-gradient(to top right, black, ${colorMap[400]})`
|
return `linear-gradient(to top right, black, ${colorMap[200]})`
|
||||||
/* return `bg-gradient-to-br from-${color}-100 via-${color}-200 to-${color}-400` */
|
|
||||||
/* return `linear-gradient(to bottom right, ${colorMap[100]}, ${colorMap[400]})` */
|
|
||||||
/* return `radial-gradient(ellipse at 80% 20%, black 20%, ${colorMap[500]} 100%)` */
|
|
||||||
/* return `radial-gradient(ellipse at 30% 70%, black 50%, ${colorMap[500]} 100%)` */
|
|
||||||
/* return `radial-gradient(ellipse at 80% 20%, ${colorMap[100]} 0%, ${colorMap[300]} 50%, ${colorMap[500]} 100%)` */
|
|
||||||
/* return `conic-gradient(from 180deg at 50% 50%, ${colorMap[100]} 0%, ${colorMap[200]} 50%, ${colorMap[400]} 100%)` */
|
|
||||||
/* return `linear-gradient(135deg, ${colorMap[100]}, ${colorMap[300]}), linear-gradient(120deg, rgba(255,255,255,0.4) 0%, transparent 60%) ` */
|
|
||||||
/* return `radial-gradient(circle at 20% 30%, ${colorMap[100]} 0%, transparent 40%),
|
|
||||||
radial-gradient(circle at 80% 40%, ${colorMap[200]} 0%, transparent 50%),
|
|
||||||
linear-gradient(135deg, ${colorMap[300]} 0%, ${colorMap[400]} 100%);` */
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -95,8 +95,8 @@
|
|||||||
name: allowEdit ? 'LessonForm' : 'Lesson',
|
name: allowEdit ? 'LessonForm' : 'Lesson',
|
||||||
params: {
|
params: {
|
||||||
courseName: courseName,
|
courseName: courseName,
|
||||||
chapterNumber: lesson.number.split('.')[0],
|
chapterNumber: lesson.number.split('-')[0],
|
||||||
lessonNumber: lesson.number.split('.')[1],
|
lessonNumber: lesson.number.split('-')[1],
|
||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
@@ -389,8 +389,8 @@ const redirectToChapter = (chapter) => {
|
|||||||
|
|
||||||
const isActiveLesson = (lessonNumber) => {
|
const isActiveLesson = (lessonNumber) => {
|
||||||
return (
|
return (
|
||||||
route.params.chapterNumber == lessonNumber.split('.')[0] &&
|
route.params.chapterNumber == lessonNumber.split('-')[0] &&
|
||||||
route.params.lessonNumber == lessonNumber.split('.')[1]
|
route.params.lessonNumber == lessonNumber.split('-')[1]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4 text-base">
|
||||||
<p class="text-ink-gray-9">
|
<p class="text-ink-gray-9">
|
||||||
{{
|
{{
|
||||||
__(
|
__(
|
||||||
@@ -39,6 +39,9 @@
|
|||||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||||
<div class="">
|
<div class="">
|
||||||
<Button @click="openFileSelector" :loading="uploading">
|
<Button @click="openFileSelector" :loading="uploading">
|
||||||
|
<template #prefix>
|
||||||
|
<Upload class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
{{
|
{{
|
||||||
uploading ? `Uploading ${progress}%` : 'Upload your resume'
|
uploading ? `Uploading ${progress}%` : 'Upload your resume'
|
||||||
}}
|
}}
|
||||||
@@ -66,7 +69,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, FileUploader, Button, createResource, toast } from 'frappe-ui'
|
import { Dialog, FileUploader, Button, createResource, toast } from 'frappe-ui'
|
||||||
import { FileText } from 'lucide-vue-next'
|
import { FileText, Upload } from 'lucide-vue-next'
|
||||||
import { ref, inject } from 'vue'
|
import { ref, inject } from 'vue'
|
||||||
import { getFileSize } from '@/utils/'
|
import { getFileSize } from '@/utils/'
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,21 @@
|
|||||||
>
|
>
|
||||||
<UserDropdown :isCollapsed="sidebarStore.isSidebarCollapsed" />
|
<UserDropdown :isCollapsed="sidebarStore.isSidebarCollapsed" />
|
||||||
<div class="flex flex-col" v-if="sidebarSettings.data">
|
<div class="flex flex-col" v-if="sidebarSettings.data">
|
||||||
<div v-for="link in sidebarLinks" class="mx-2 my-0.5">
|
<div v-for="link in sidebarLinks" class="mx-2 my-2.5">
|
||||||
<SidebarLink
|
<div
|
||||||
:link="link"
|
v-if="!link.hideLabel"
|
||||||
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base font-medium text-ink-gray-5 transition-all duration-300 ease-in-out"
|
||||||
/>
|
>
|
||||||
|
<span>{{ __(link.label) }}</span>
|
||||||
|
</div>
|
||||||
|
<nav class="space-y-1">
|
||||||
|
<div v-for="item in link.items">
|
||||||
|
<SidebarLink
|
||||||
|
:link="item"
|
||||||
|
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -173,6 +183,7 @@
|
|||||||
:currentStep="currentStep"
|
:currentStep="currentStep"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<CommandPalette v-model="settingsStore.isCommandPaletteOpen" />
|
||||||
<PageModal
|
<PageModal
|
||||||
v-model="showPageModal"
|
v-model="showPageModal"
|
||||||
v-model:reloadSidebar="sidebarSettings"
|
v-model:reloadSidebar="sidebarSettings"
|
||||||
@@ -181,9 +192,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import UserDropdown from '@/components/Sidebar/UserDropdown.vue'
|
|
||||||
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
|
||||||
import SidebarLink from '@/components/Sidebar/SidebarLink.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'
|
||||||
@@ -194,7 +202,6 @@ import PageModal from '@/components/Modals/PageModal.vue'
|
|||||||
import { capture } from '@/telemetry'
|
import { capture } from '@/telemetry'
|
||||||
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import InviteIcon from '@/components/Icons/InviteIcon.vue'
|
|
||||||
import {
|
import {
|
||||||
ref,
|
ref,
|
||||||
onMounted,
|
onMounted,
|
||||||
@@ -217,7 +224,6 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
BookText,
|
BookText,
|
||||||
Zap,
|
Zap,
|
||||||
Check,
|
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
TrialBanner,
|
TrialBanner,
|
||||||
@@ -228,18 +234,24 @@ import {
|
|||||||
minimize,
|
minimize,
|
||||||
IntermediateStepModal,
|
IntermediateStepModal,
|
||||||
} from 'frappe-ui/frappe'
|
} from 'frappe-ui/frappe'
|
||||||
|
import InviteIcon from '@/components/Icons/InviteIcon.vue'
|
||||||
|
import UserDropdown from '@/components/Sidebar/UserDropdown.vue'
|
||||||
|
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||||
|
import SidebarLink from '@/components/Sidebar/SidebarLink.vue'
|
||||||
|
import CommandPalette from '@/components/CommandPalette/CommandPalette.vue'
|
||||||
|
|
||||||
const { user } = sessionStore()
|
const { user } = sessionStore()
|
||||||
const { userResource } = usersStore()
|
const { userResource } = usersStore()
|
||||||
let sidebarStore = useSidebar()
|
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(null)
|
||||||
const showPageModal = ref(false)
|
const showPageModal = ref(false)
|
||||||
const isModerator = ref(false)
|
const isModerator = ref(false)
|
||||||
const isInstructor = ref(false)
|
const isInstructor = ref(false)
|
||||||
const pageToEdit = ref(null)
|
const pageToEdit = ref(null)
|
||||||
const { settings, sidebarSettings, activeTab, isSettingsOpen } = useSettings()
|
const { sidebarSettings, activeTab, isSettingsOpen, programs } = useSettings()
|
||||||
|
const settingsStore = useSettings()
|
||||||
const showOnboarding = ref(false)
|
const showOnboarding = ref(false)
|
||||||
const showIntermediateModal = ref(false)
|
const showIntermediateModal = ref(false)
|
||||||
const currentStep = ref({})
|
const currentStep = ref({})
|
||||||
@@ -254,9 +266,8 @@ const iconProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
addNotifications()
|
|
||||||
setSidebarLinks()
|
|
||||||
setUpOnboarding()
|
setUpOnboarding()
|
||||||
|
addKeyboardShortcut()
|
||||||
socket.on('publish_lms_notifications', (data) => {
|
socket.on('publish_lms_notifications', (data) => {
|
||||||
unreadNotifications.reload()
|
unreadNotifications.reload()
|
||||||
})
|
})
|
||||||
@@ -269,9 +280,11 @@ const setSidebarLinks = () => {
|
|||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
Object.keys(data).forEach((key) => {
|
Object.keys(data).forEach((key) => {
|
||||||
if (!parseInt(data[key])) {
|
if (!parseInt(data[key])) {
|
||||||
sidebarLinks.value = sidebarLinks.value.filter(
|
sidebarLinks.value.forEach((link) => {
|
||||||
(link) => link.label.toLowerCase().split(' ').join('_') !== key
|
link.items = link.items.filter(
|
||||||
)
|
(item) => item.label.toLowerCase().split(' ').join('_') !== key
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -279,6 +292,23 @@ const setSidebarLinks = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addKeyboardShortcut = () => {
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (
|
||||||
|
e.key === 'k' &&
|
||||||
|
(e.ctrlKey || e.metaKey) &&
|
||||||
|
!e.target.classList.contains('ProseMirror')
|
||||||
|
) {
|
||||||
|
toggleCommandPalette()
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCommandPalette = () => {
|
||||||
|
settingsStore.isCommandPaletteOpen = !settingsStore.isCommandPaletteOpen
|
||||||
|
}
|
||||||
|
|
||||||
const unreadNotifications = createResource({
|
const unreadNotifications = createResource({
|
||||||
cache: 'Unread Notifications Count',
|
cache: 'Unread Notifications Count',
|
||||||
url: 'frappe.client.get_count',
|
url: 'frappe.client.get_count',
|
||||||
@@ -293,140 +323,18 @@ const unreadNotifications = createResource({
|
|||||||
},
|
},
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
unreadCount.value = data
|
unreadCount.value = data
|
||||||
sidebarLinks.value = sidebarLinks.value.map((link) => {
|
updateUnreadCount()
|
||||||
if (link.label === 'Notifications') {
|
|
||||||
link.count = data
|
|
||||||
}
|
|
||||||
return link
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
auto: user ? true : false,
|
auto: user ? true : false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const addNotifications = () => {
|
const updateUnreadCount = () => {
|
||||||
if (user) {
|
sidebarLinks.value?.forEach((link) => {
|
||||||
sidebarLinks.value.push({
|
link.items.forEach((item) => {
|
||||||
label: 'Notifications',
|
if (item.label === 'Notifications') {
|
||||||
icon: 'Bell',
|
item.count = unreadCount.value || 0
|
||||||
to: 'Notifications',
|
}
|
||||||
activeFor: ['Notifications'],
|
|
||||||
count: unreadCount.value,
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addQuizzes = () => {
|
|
||||||
if (!isInstructor.value && !isModerator.value) return
|
|
||||||
|
|
||||||
const quizzesLinkExists = sidebarLinks.value.some(
|
|
||||||
(link) => link.label === 'Quizzes'
|
|
||||||
)
|
|
||||||
if (quizzesLinkExists) return
|
|
||||||
|
|
||||||
sidebarLinks.value.splice(4, 0, {
|
|
||||||
label: 'Quizzes',
|
|
||||||
icon: 'CircleHelp',
|
|
||||||
to: 'Quizzes',
|
|
||||||
activeFor: ['Quizzes', 'QuizForm', 'QuizSubmissionList', 'QuizSubmission'],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const addAssignments = () => {
|
|
||||||
if (!isInstructor.value && !isModerator.value) return
|
|
||||||
|
|
||||||
const assignmentsLinkExists = sidebarLinks.value.some(
|
|
||||||
(link) => link.label === 'Assignments'
|
|
||||||
)
|
|
||||||
if (assignmentsLinkExists) return
|
|
||||||
|
|
||||||
sidebarLinks.value.splice(5, 0, {
|
|
||||||
label: 'Assignments',
|
|
||||||
icon: 'Pencil',
|
|
||||||
to: 'Assignments',
|
|
||||||
activeFor: [
|
|
||||||
'Assignments',
|
|
||||||
'AssignmentForm',
|
|
||||||
'AssignmentSubmissionList',
|
|
||||||
'AssignmentSubmission',
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const addProgrammingExercises = () => {
|
|
||||||
if (!isInstructor.value && !isModerator.value) return
|
|
||||||
const programmingExercisesLinkExists = sidebarLinks.value.some(
|
|
||||||
(link) => link.label === 'Programming Exercises'
|
|
||||||
)
|
|
||||||
if (programmingExercisesLinkExists) return
|
|
||||||
|
|
||||||
sidebarLinks.value.splice(3, 0, {
|
|
||||||
label: 'Programming Exercises',
|
|
||||||
icon: 'Code',
|
|
||||||
to: 'ProgrammingExercises',
|
|
||||||
activeFor: [
|
|
||||||
'ProgrammingExercises',
|
|
||||||
'ProgrammingExerciseForm',
|
|
||||||
'ProgrammingExerciseSubmissions',
|
|
||||||
'ProgrammingExerciseSubmission',
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const addPrograms = async () => {
|
|
||||||
const programsLinkExists = sidebarLinks.value.some(
|
|
||||||
(link) => link.label === 'Programs'
|
|
||||||
)
|
|
||||||
if (programsLinkExists) return
|
|
||||||
|
|
||||||
let canAddProgram = await checkIfCanAddProgram()
|
|
||||||
if (!canAddProgram) return
|
|
||||||
let activeFor = ['Programs', 'ProgramDetail']
|
|
||||||
let index = 2
|
|
||||||
|
|
||||||
sidebarLinks.value.splice(index, 0, {
|
|
||||||
label: 'Programs',
|
|
||||||
icon: 'Route',
|
|
||||||
to: 'Programs',
|
|
||||||
activeFor: activeFor,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const addContactUsDetails = () => {
|
|
||||||
if (!settings?.data?.contact_us_email && !settings?.data?.contact_us_url)
|
|
||||||
return
|
|
||||||
|
|
||||||
const contactUsLinkExists = sidebarLinks.value.some(
|
|
||||||
(link) => link.label === 'Contact Us'
|
|
||||||
)
|
|
||||||
if (contactUsLinkExists) return
|
|
||||||
|
|
||||||
sidebarLinks.value.push({
|
|
||||||
label: 'Contact Us',
|
|
||||||
icon: settings.data?.contact_us_url ? 'Headset' : 'Mail',
|
|
||||||
to: settings.data?.contact_us_url
|
|
||||||
? settings.data?.contact_us_url
|
|
||||||
: settings.data?.contact_us_email,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkIfCanAddProgram = async () => {
|
|
||||||
if (isModerator.value || isInstructor.value) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
const programs = await call('lms.lms.utils.get_programs')
|
|
||||||
return programs.enrolled.length > 0 || programs.published.length > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const addHome = () => {
|
|
||||||
const homeLinkExists = sidebarLinks.value.some(
|
|
||||||
(link) => link.label === 'Home'
|
|
||||||
)
|
|
||||||
if (homeLinkExists) return
|
|
||||||
sidebarLinks.value.unshift({
|
|
||||||
label: 'Home',
|
|
||||||
icon: 'Home',
|
|
||||||
to: 'Home',
|
|
||||||
activeFor: ['Home'],
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,18 +582,16 @@ const setUpOnboarding = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(userResource, () => {
|
watch(userResource, async () => {
|
||||||
addContactUsDetails()
|
await userResource.promise
|
||||||
if (userResource.data) {
|
if (userResource.data) {
|
||||||
isModerator.value = userResource.data.is_moderator
|
isModerator.value = userResource.data.is_moderator
|
||||||
isInstructor.value = userResource.data.is_instructor
|
isInstructor.value = userResource.data.is_instructor
|
||||||
addHome()
|
await programs.reload()
|
||||||
addPrograms()
|
|
||||||
addProgrammingExercises()
|
|
||||||
addQuizzes()
|
|
||||||
addAssignments()
|
|
||||||
setUpOnboarding()
|
setUpOnboarding()
|
||||||
}
|
}
|
||||||
|
sidebarLinks.value = getSidebarLinks()
|
||||||
|
setSidebarLinks()
|
||||||
})
|
})
|
||||||
|
|
||||||
const redirectToWebsite = () => {
|
const redirectToWebsite = () => {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const apps = createResource({
|
|||||||
name: 'frappe',
|
name: 'frappe',
|
||||||
logo: '/assets/lms/images/desk.png',
|
logo: '/assets/lms/images/desk.png',
|
||||||
title: __('Desk'),
|
title: __('Desk'),
|
||||||
route: '/app',
|
route: '/desk/lms',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
data.map((app) => {
|
data.map((app) => {
|
||||||
|
|||||||
@@ -222,12 +222,12 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const createdCourses = createResource({
|
const createdCourses = createResource({
|
||||||
url: 'lms.lms.utils.get_created_courses',
|
url: 'lms.lms.api.get_created_courses',
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const createdBatches = createResource({
|
const createdBatches = createResource({
|
||||||
url: 'lms.lms.utils.get_created_batches',
|
url: 'lms.lms.api.get_created_batches',
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -79,17 +79,17 @@ const myLiveClasses = createResource({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const adminLiveClasses = createResource({
|
const adminLiveClasses = createResource({
|
||||||
url: 'lms.lms.utils.get_admin_live_classes',
|
url: 'lms.lms.api.get_admin_live_classes',
|
||||||
auto: isAdmin.value ? true : false,
|
auto: isAdmin.value ? true : false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const adminEvals = createResource({
|
const adminEvals = createResource({
|
||||||
url: 'lms.lms.utils.get_admin_evals',
|
url: 'lms.lms.api.get_admin_evals',
|
||||||
auto: isAdmin.value ? true : false,
|
auto: isAdmin.value ? true : false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const streakInfo = createResource({
|
const streakInfo = createResource({
|
||||||
url: 'lms.lms.utils.get_streak_info',
|
url: 'lms.lms.api.get_streak_info',
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -161,12 +161,12 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const myCourses = createResource({
|
const myCourses = createResource({
|
||||||
url: 'lms.lms.utils.get_my_courses',
|
url: 'lms.lms.api.get_my_courses',
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const myBatches = createResource({
|
const myBatches = createResource({
|
||||||
url: 'lms.lms.utils.get_my_batches',
|
url: 'lms.lms.api.get_my_batches',
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
route: { name: 'Jobs' },
|
route: { name: 'Jobs' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: job.data?.job_title,
|
label: job.doc?.job_title,
|
||||||
route: { name: 'JobDetail', params: { job: job.data?.name } },
|
route: { name: 'JobDetail', params: { job: job.doc?.name } },
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
v-if="canManageJob && applicationCount.data > 0"
|
v-if="canManageJob && applicationCount.data > 0"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'JobApplications',
|
name: 'JobApplications',
|
||||||
params: { job: job.data?.name },
|
params: { job: job.doc?.name },
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button variant="subtle">
|
<Button variant="subtle">
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
v-if="canManageJob"
|
v-if="canManageJob"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'JobForm',
|
name: 'JobForm',
|
||||||
params: { jobName: job.data?.name },
|
params: { jobName: job.doc?.name },
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button>
|
<Button>
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
{{ __('Edit') }}
|
{{ __('Edit') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<Button @click="redirectToWebsite(job.data?.company_website)">
|
<Button @click="redirectToWebsite(job.doc?.company_website)">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<SquareArrowOutUpRight class="h-4 w-4 stroke-1.5" />
|
<SquareArrowOutUpRight class="h-4 w-4 stroke-1.5" />
|
||||||
</template>
|
</template>
|
||||||
@@ -69,30 +69,30 @@
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!readOnlyMode">
|
<div v-else-if="!readOnlyMode">
|
||||||
<Button @click="redirectToLogin(job.data?.name)">
|
<Button @click="redirectToLogin(job.doc?.name)">
|
||||||
<span>
|
<span>
|
||||||
{{ __('Login to apply') }}
|
{{ __('Login to apply') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div v-if="job.data" class="max-w-3xl mx-auto pt-5">
|
<div v-if="job.doc" class="max-w-3xl mx-auto pt-5">
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<div class="space-y-5 mb-12">
|
<div class="space-y-5 mb-12">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<img
|
<img
|
||||||
:src="job.data.company_logo"
|
:src="job.doc.company_logo"
|
||||||
class="size-10 rounded-lg object-contain cursor-pointer mr-4"
|
class="size-10 rounded-lg object-contain cursor-pointer mr-4"
|
||||||
:alt="job.data.company_name"
|
:alt="job.doc.company_name"
|
||||||
@click="redirectToWebsite(job.data.company_website)"
|
@click="redirectToWebsite(job.doc.company_website)"
|
||||||
/>
|
/>
|
||||||
<div class="">
|
<div class="">
|
||||||
<div class="text-2xl text-ink-gray-9 font-semibold mb-1">
|
<div class="text-2xl text-ink-gray-9 font-semibold mb-1">
|
||||||
{{ job.data.job_title }}
|
{{ job.doc.job_title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-ink-gray-5 font-semibold">
|
<div class="text-sm text-ink-gray-5 font-semibold">
|
||||||
{{ job.data.company_name }} - {{ job.data.location }},
|
{{ job.doc.company_name }} - {{ job.doc.location }},
|
||||||
{{ job.data.country }}
|
{{ job.doc.country }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,19 +102,19 @@
|
|||||||
<template #prefix>
|
<template #prefix>
|
||||||
<CalendarDays class="size-3 stroke-2 text-ink-gray-7" />
|
<CalendarDays class="size-3 stroke-2 text-ink-gray-7" />
|
||||||
</template>
|
</template>
|
||||||
{{ dayjs(job.data.creation).fromNow() }}
|
{{ dayjs(job.doc.creation).fromNow() }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge size="lg">
|
<Badge size="lg">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<ClipboardType class="size-3 stroke-2 text-ink-gray-7" />
|
<ClipboardType class="size-3 stroke-2 text-ink-gray-7" />
|
||||||
</template>
|
</template>
|
||||||
{{ job.data.type }}
|
{{ job.doc.type }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge v-if="job.data?.work_mode" size="lg">
|
<Badge v-if="job.doc?.work_mode" size="lg">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<BriefcaseBusiness class="size-3 stroke-2 text-ink-gray-7" />
|
<BriefcaseBusiness class="size-3 stroke-2 text-ink-gray-7" />
|
||||||
</template>
|
</template>
|
||||||
{{ job.data.work_mode }}
|
{{ job.doc.work_mode }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge v-if="applicationCount.data" size="lg">
|
<Badge v-if="applicationCount.data" size="lg">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
@@ -137,14 +137,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-html="job.data.description"
|
v-html="job.doc.description"
|
||||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-12"
|
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-12"
|
||||||
></p>
|
></p>
|
||||||
</div>
|
</div>
|
||||||
<JobApplicationModal
|
<JobApplicationModal
|
||||||
v-model="showApplicationModal"
|
v-model="showApplicationModal"
|
||||||
v-model:application="jobApplication"
|
v-model:application="jobApplication"
|
||||||
:job="job.data.name"
|
:job="job.doc.name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,6 +155,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
createResource,
|
createResource,
|
||||||
|
createDocumentResource,
|
||||||
usePageMeta,
|
usePageMeta,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { inject, ref, computed } from 'vue'
|
import { inject, ref, computed } from 'vue'
|
||||||
@@ -186,13 +187,11 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const job = createResource({
|
const job = createDocumentResource({
|
||||||
url: 'lms.lms.api.get_job_details',
|
doctype: 'Job Opportunity',
|
||||||
params: {
|
name: props.job,
|
||||||
job: props.job,
|
|
||||||
},
|
|
||||||
cache: ['job', props.job],
|
|
||||||
auto: true,
|
auto: true,
|
||||||
|
cache: ['job', props.job],
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (user.data?.name) {
|
if (user.data?.name) {
|
||||||
jobApplication.submit()
|
jobApplication.submit()
|
||||||
@@ -207,7 +206,7 @@ const jobApplication = createResource({
|
|||||||
return {
|
return {
|
||||||
doctype: 'LMS Job Application',
|
doctype: 'LMS Job Application',
|
||||||
filters: {
|
filters: {
|
||||||
job: job.data?.name,
|
job: job.doc?.name,
|
||||||
user: user.data?.name,
|
user: user.data?.name,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -220,7 +219,7 @@ const applicationCount = createResource({
|
|||||||
return {
|
return {
|
||||||
doctype: 'LMS Job Application',
|
doctype: 'LMS Job Application',
|
||||||
filters: {
|
filters: {
|
||||||
job: job.data?.name,
|
job: job.doc?.name,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -239,13 +238,13 @@ const redirectToWebsite = (url) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canManageJob = computed(() => {
|
const canManageJob = computed(() => {
|
||||||
if (!user.data?.name || !job.data) return false
|
if (!user.data?.name || !job.doc) return false
|
||||||
return user.data.name === job.data.owner || user.data?.is_moderator
|
return user.data.name === job.doc.owner || user.data?.is_moderator
|
||||||
})
|
})
|
||||||
|
|
||||||
usePageMeta(() => {
|
usePageMeta(() => {
|
||||||
return {
|
return {
|
||||||
title: job.data?.job_title,
|
title: job.doc?.job_title,
|
||||||
icon: brand.favicon,
|
icon: brand.favicon,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
232
frontend/src/pages/Search/Search.vue
Normal file
232
frontend/src/pages/Search/Search.vue
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||||
|
>
|
||||||
|
<Breadcrumbs :items="[{ label: __('Search') }]" />
|
||||||
|
</header>
|
||||||
|
<div class="w-4/6 mx-auto py-5">
|
||||||
|
<div class="px-2.5">
|
||||||
|
<TextInput
|
||||||
|
ref="searchInput"
|
||||||
|
class="flex-1"
|
||||||
|
placeholder="Search for a keyword or phrase and press enter"
|
||||||
|
autocomplete="off"
|
||||||
|
:model-value="query"
|
||||||
|
@update:model-value="updateQuery"
|
||||||
|
@keydown.enter="() => submit()"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Search class="w-4 text-ink-gray-5" />
|
||||||
|
</template>
|
||||||
|
<template #suffix>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<button
|
||||||
|
v-if="query"
|
||||||
|
@click="clearSearch"
|
||||||
|
class="p-1 size-6 grid place-content-center focus:outline-none focus:ring focus:ring-outline-gray-3 rounded"
|
||||||
|
>
|
||||||
|
<X class="w-4 text-ink-gray-7" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</TextInput>
|
||||||
|
<div
|
||||||
|
v-if="query && searchResults.length"
|
||||||
|
class="text-sm text-ink-gray-5 mt-2"
|
||||||
|
>
|
||||||
|
{{ searchResults.length }}
|
||||||
|
{{ searchResults.length === 1 ? __('match') : __('matches') }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="queryChanged" class="text-sm text-ink-gray-5 mt-2">
|
||||||
|
{{ __('Press enter to search') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="query && !searchResults.length"
|
||||||
|
class="text-sm text-ink-gray-5 mt-2"
|
||||||
|
>
|
||||||
|
{{ __('No results found') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<div v-if="searchResults.length" class="">
|
||||||
|
<div
|
||||||
|
v-for="(result, index) in searchResults"
|
||||||
|
@click="navigate(result)"
|
||||||
|
class="rounded-md cursor-pointer hover:bg-surface-gray-2 px-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex space-x-2 py-3"
|
||||||
|
:class="{
|
||||||
|
'border-b': index !== searchResults.length - 1,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Tooltip :text="result.author_info.full_name">
|
||||||
|
<Avatar
|
||||||
|
:label="result.author_info.full_name"
|
||||||
|
:image="result.author_info.user_image"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<div class="space-y-1 w-full">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="font-medium" v-html="result.title"></div>
|
||||||
|
<div class="text-sm text-ink-gray-5 ml-2">
|
||||||
|
{{ getDocTypeTitle(result.doctype) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
result.published_on || result.start_date || result.creation
|
||||||
|
"
|
||||||
|
class="ml-auto text-sm text-ink-gray-5"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
dayjs(
|
||||||
|
result.published_on ||
|
||||||
|
result.start_date ||
|
||||||
|
result.creation
|
||||||
|
).format('DD MMM YYYY')
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="leading-5" v-html="result.content"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Breadcrumbs,
|
||||||
|
createResource,
|
||||||
|
debounce,
|
||||||
|
TextInput,
|
||||||
|
Tooltip,
|
||||||
|
usePageMeta,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { inject, onMounted, ref, watch } from 'vue'
|
||||||
|
import { Search, X } from 'lucide-vue-next'
|
||||||
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const query = ref('')
|
||||||
|
const searchInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const searchResults = ref<Array<any>>([])
|
||||||
|
const { brand } = sessionStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const queryChanged = ref(false)
|
||||||
|
const dayjs = inject<any>('$dayjs')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (router.currentRoute.value.query.q) {
|
||||||
|
query.value = router.currentRoute.value.query.q as string
|
||||||
|
submit()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateQuery = (value: string) => {
|
||||||
|
query.value = value
|
||||||
|
router.replace({ query: value ? { q: value } : {} })
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = debounce(() => {
|
||||||
|
if (query.value.length > 2) {
|
||||||
|
search.reload()
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
const search = createResource({
|
||||||
|
url: 'lms.command_palette.search_sqlite',
|
||||||
|
makeParams: () => ({
|
||||||
|
query: query.value,
|
||||||
|
}),
|
||||||
|
onSuccess() {
|
||||||
|
generateSearchResults()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const generateSearchResults = () => {
|
||||||
|
searchResults.value = []
|
||||||
|
if (search.data) {
|
||||||
|
queryChanged.value = false
|
||||||
|
search.data.forEach((group: any) => {
|
||||||
|
group.items.forEach((item: any) => {
|
||||||
|
searchResults.value.push(item)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
searchResults.value.sort((a, b) => b.score - a.score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigate = (result: any) => {
|
||||||
|
if (result.doctype == 'LMS Course') {
|
||||||
|
router.push({
|
||||||
|
name: 'CourseDetail',
|
||||||
|
params: {
|
||||||
|
courseName: result.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else if (result.doctype == 'LMS Batch') {
|
||||||
|
router.push({
|
||||||
|
name: 'BatchDetail',
|
||||||
|
params: {
|
||||||
|
batchName: result.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else if (result.doctype == 'Job Opportunity') {
|
||||||
|
router.push({
|
||||||
|
name: 'JobDetail',
|
||||||
|
params: {
|
||||||
|
job: result.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(query, () => {
|
||||||
|
if (query.value && query.value != search.params?.query) {
|
||||||
|
queryChanged.value = true
|
||||||
|
} else if (!query.value) {
|
||||||
|
queryChanged.value = false
|
||||||
|
searchResults.value = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.q,
|
||||||
|
(newQ) => {
|
||||||
|
if (newQ && newQ !== query.value) {
|
||||||
|
query.value = newQ as string
|
||||||
|
submit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const getDocTypeTitle = (doctype: string) => {
|
||||||
|
if (doctype === 'LMS Course') {
|
||||||
|
return __('Course')
|
||||||
|
} else if (doctype === 'LMS Batch') {
|
||||||
|
return __('Batch')
|
||||||
|
} else if (doctype === 'Job Opportunity') {
|
||||||
|
return __('Job')
|
||||||
|
} else {
|
||||||
|
return doctype
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
query.value = ''
|
||||||
|
updateQuery('')
|
||||||
|
}
|
||||||
|
|
||||||
|
usePageMeta(() => {
|
||||||
|
return {
|
||||||
|
title: __('Search'),
|
||||||
|
icon: brand.favicon,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -243,6 +243,11 @@ const routes = [
|
|||||||
),
|
),
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/search',
|
||||||
|
name: 'Search',
|
||||||
|
component: () => import('@/pages/Search/Search.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/data-import',
|
path: '/data-import',
|
||||||
name: 'DataImportList',
|
name: 'DataImportList',
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { createResource } from 'frappe-ui'
|
import { createResource } from 'frappe-ui'
|
||||||
import { sessionStore } from './session'
|
|
||||||
|
|
||||||
export const useSettings = defineStore('settings', () => {
|
export const useSettings = defineStore('settings', () => {
|
||||||
const isSettingsOpen = ref(false)
|
const isSettingsOpen = ref(false)
|
||||||
|
const isCommandPaletteOpen = ref(false)
|
||||||
const activeTab = ref(null)
|
const activeTab = ref(null)
|
||||||
|
|
||||||
const settings = createResource({
|
const settings = createResource({
|
||||||
@@ -19,9 +19,16 @@ export const useSettings = defineStore('settings', () => {
|
|||||||
auto: false,
|
auto: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const programs = createResource({
|
||||||
|
url: 'lms.lms.utils.get_programs',
|
||||||
|
auto: false,
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isSettingsOpen,
|
|
||||||
activeTab,
|
activeTab,
|
||||||
|
isSettingsOpen,
|
||||||
|
isCommandPaletteOpen,
|
||||||
|
programs,
|
||||||
settings,
|
settings,
|
||||||
sidebarSettings,
|
sidebarSettings,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -403,46 +403,176 @@ export function getUserTimezone() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getSidebarLinks() {
|
export function getSidebarLinks() {
|
||||||
|
let links = getSidebarItems()
|
||||||
|
|
||||||
|
links.forEach((link) => {
|
||||||
|
link.items = link.items.filter((item) => {
|
||||||
|
return item.condition ? item.condition() : true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
links = links.filter((link) => {
|
||||||
|
return link.items.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return links
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSidebarItems = () => {
|
||||||
|
const { userResource } = usersStore()
|
||||||
|
const { settings } = useSettings()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: 'Courses',
|
label: 'General',
|
||||||
icon: 'BookOpen',
|
hideLabel: true,
|
||||||
to: 'Courses',
|
items: [
|
||||||
activeFor: [
|
{
|
||||||
'Courses',
|
label: 'Home',
|
||||||
'CourseDetail',
|
icon: 'Home',
|
||||||
'Lesson',
|
to: 'Home',
|
||||||
'CourseForm',
|
condition: () => {
|
||||||
'LessonForm',
|
return userResource?.data
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Search',
|
||||||
|
icon: 'Search',
|
||||||
|
to: 'Search',
|
||||||
|
condition: () => {
|
||||||
|
return userResource?.data
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Notifications',
|
||||||
|
icon: 'Bell',
|
||||||
|
to: 'Notifications',
|
||||||
|
condition: () => {
|
||||||
|
return userResource?.data
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Batches',
|
label: 'Learning',
|
||||||
icon: 'Users',
|
hideLabel: true,
|
||||||
to: 'Batches',
|
items: [
|
||||||
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'],
|
{
|
||||||
|
label: 'Courses',
|
||||||
|
icon: 'BookOpen',
|
||||||
|
to: 'Courses',
|
||||||
|
activeFor: [
|
||||||
|
'Courses',
|
||||||
|
'CourseDetail',
|
||||||
|
'Lesson',
|
||||||
|
'CourseForm',
|
||||||
|
'LessonForm',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Programs',
|
||||||
|
icon: 'Route',
|
||||||
|
to: 'Programs',
|
||||||
|
activeFor: ['Programs', 'ProgramDetail'],
|
||||||
|
await: true,
|
||||||
|
condition: () => {
|
||||||
|
return checkIfCanAddProgram()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Batches',
|
||||||
|
icon: 'Users',
|
||||||
|
to: 'Batches',
|
||||||
|
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Certifications',
|
||||||
|
icon: 'GraduationCap',
|
||||||
|
to: 'CertifiedParticipants',
|
||||||
|
activeFor: ['CertifiedParticipants'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Jobs',
|
||||||
|
icon: 'Briefcase',
|
||||||
|
to: 'Jobs',
|
||||||
|
activeFor: ['Jobs', 'JobDetail'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Statistics',
|
||||||
|
icon: 'TrendingUp',
|
||||||
|
to: 'Statistics',
|
||||||
|
activeFor: ['Statistics'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Contact Us',
|
||||||
|
icon: settings.data?.contact_us_url ? 'Headset' : 'Mail',
|
||||||
|
to: settings.data?.contact_us_url
|
||||||
|
? settings.data?.contact_us_url
|
||||||
|
: settings.data?.contact_us_email,
|
||||||
|
condition: () => {
|
||||||
|
return (
|
||||||
|
settings?.data?.contact_us_email ||
|
||||||
|
settings?.data?.contact_us_url
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Certifications',
|
label: 'Assessments',
|
||||||
icon: 'GraduationCap',
|
hideLabel: true,
|
||||||
to: 'CertifiedParticipants',
|
items: [
|
||||||
activeFor: ['CertifiedParticipants'],
|
{
|
||||||
},
|
label: 'Quizzes',
|
||||||
{
|
icon: 'CircleHelp',
|
||||||
label: 'Jobs',
|
to: 'Quizzes',
|
||||||
icon: 'Briefcase',
|
condition: () => {
|
||||||
to: 'Jobs',
|
return isAdmin()
|
||||||
activeFor: ['Jobs', 'JobDetail'],
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Statistics',
|
label: 'Assignments',
|
||||||
icon: 'TrendingUp',
|
icon: 'Pencil',
|
||||||
to: 'Statistics',
|
to: 'Assignments',
|
||||||
activeFor: ['Statistics'],
|
condition: () => {
|
||||||
|
return isAdmin()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Programming Exercises',
|
||||||
|
icon: 'Code',
|
||||||
|
to: 'ProgrammingExercises',
|
||||||
|
condition: () => {
|
||||||
|
return isAdmin()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAdmin = () => {
|
||||||
|
const { userResource } = usersStore()
|
||||||
|
return (
|
||||||
|
userResource?.data?.is_instructor ||
|
||||||
|
userResource?.data?.is_moderator ||
|
||||||
|
userResource.data?.is_evaluator
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkIfCanAddProgram = () => {
|
||||||
|
const { userResource } = usersStore()
|
||||||
|
const { programs } = useSettings()
|
||||||
|
if (!userResource.data) return false
|
||||||
|
if (userResource?.data?.is_moderator || userResource?.data?.is_instructor) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
programs.data?.enrolled.length > 0 ||
|
||||||
|
programs.data?.published.length > 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function getFormattedDateRange(
|
export function getFormattedDateRange(
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
|
|||||||
@@ -65,6 +65,6 @@ export default defineConfig(({ mode }) => ({
|
|||||||
'highlight.js',
|
'highlight.js',
|
||||||
'plyr',
|
'plyr',
|
||||||
],
|
],
|
||||||
exclude: mode === 'production' ? [] : ['frappe-ui'],
|
//exclude: mode === 'production' ? [] : ['frappe-ui'],
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
86
lms/command_palette.py
Normal file
86
lms/command_palette.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import frappe
|
||||||
|
from frappe.utils import nowdate
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def search_sqlite(query: str):
|
||||||
|
from lms.sqlite import LearningSearch, LearningSearchIndexMissingError
|
||||||
|
|
||||||
|
search = LearningSearch()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = search.search(query)
|
||||||
|
except LearningSearchIndexMissingError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return prepare_search_results(result)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_search_results(result):
|
||||||
|
roles = frappe.get_roles()
|
||||||
|
groups = {}
|
||||||
|
|
||||||
|
for r in result["results"]:
|
||||||
|
doctype = r["doctype"]
|
||||||
|
if doctype == "LMS Course" and can_access_course(r, roles):
|
||||||
|
r["author_info"] = get_instructor_info(doctype, r)
|
||||||
|
groups.setdefault("Courses", []).append(r)
|
||||||
|
elif doctype == "LMS Batch" and can_access_batch(r, roles):
|
||||||
|
r["author_info"] = get_instructor_info(doctype, r)
|
||||||
|
groups.setdefault("Batches", []).append(r)
|
||||||
|
elif doctype == "Job Opportunity" and can_access_job(r, roles):
|
||||||
|
r["author_info"] = get_instructor_info(doctype, r)
|
||||||
|
groups.setdefault("Job Opportunities", []).append(r)
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for key in groups:
|
||||||
|
out.append({"title": key, "items": groups[key]})
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def can_access_course(course, roles):
|
||||||
|
if can_create_course(roles):
|
||||||
|
return True
|
||||||
|
elif course.get("published"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def can_access_batch(batch, roles):
|
||||||
|
if can_create_batch(roles):
|
||||||
|
return True
|
||||||
|
elif batch.get("published") and batch.get("start_date") >= nowdate():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def can_access_job(job, roles):
|
||||||
|
if "Moderator" in roles:
|
||||||
|
return True
|
||||||
|
return job.get("status") == "Open"
|
||||||
|
|
||||||
|
|
||||||
|
def can_create_course(roles):
|
||||||
|
return "Course Creator" in roles or "Moderator" in roles
|
||||||
|
|
||||||
|
|
||||||
|
def can_create_batch(roles):
|
||||||
|
return "Batch Evaluator" in roles or "Moderator" in roles
|
||||||
|
|
||||||
|
|
||||||
|
def get_instructor_info(doctype, record):
|
||||||
|
instructors = frappe.get_all(
|
||||||
|
"Course Instructor", filters={"parenttype": doctype, "parent": record.get("name")}, pluck="instructor"
|
||||||
|
)
|
||||||
|
|
||||||
|
instructor = record.get("author")
|
||||||
|
if len(instructors):
|
||||||
|
instructor = instructors[0]
|
||||||
|
|
||||||
|
return frappe.db.get_value(
|
||||||
|
"User",
|
||||||
|
instructor,
|
||||||
|
["full_name", "email", "user_image", "username"],
|
||||||
|
as_dict=True,
|
||||||
|
)
|
||||||
19
lms/desktop_icon/frappe_lms.json
Normal file
19
lms/desktop_icon/frappe_lms.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"app": "lms",
|
||||||
|
"creation": "2025-12-15 14:31:50.704854",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Desktop Icon",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon_type": "App",
|
||||||
|
"idx": 0,
|
||||||
|
"label": "Frappe LMS",
|
||||||
|
"link": "/lms",
|
||||||
|
"link_type": "External",
|
||||||
|
"logo_url": "/assets/lms/frontend/learning.svg",
|
||||||
|
"modified": "2025-12-15 14:31:50.704854",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"name": "Frappe LMS",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"roles": [],
|
||||||
|
"standard": 1
|
||||||
|
}
|
||||||
@@ -64,6 +64,9 @@ after_install = "lms.install.after_install"
|
|||||||
after_sync = "lms.install.after_sync"
|
after_sync = "lms.install.after_sync"
|
||||||
before_uninstall = "lms.install.before_uninstall"
|
before_uninstall = "lms.install.before_uninstall"
|
||||||
setup_wizard_requires = "assets/lms/js/setup_wizard.js"
|
setup_wizard_requires = "assets/lms/js/setup_wizard.js"
|
||||||
|
after_migrate = [
|
||||||
|
"lms.sqlite.build_index_in_background",
|
||||||
|
]
|
||||||
|
|
||||||
# Desk Notifications
|
# Desk Notifications
|
||||||
# ------------------
|
# ------------------
|
||||||
@@ -115,6 +118,9 @@ doc_events = {
|
|||||||
# Scheduled Tasks
|
# Scheduled Tasks
|
||||||
# ---------------
|
# ---------------
|
||||||
scheduler_events = {
|
scheduler_events = {
|
||||||
|
"all": [
|
||||||
|
"lms.sqlite.build_index_in_background",
|
||||||
|
],
|
||||||
"hourly": [
|
"hourly": [
|
||||||
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals",
|
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals",
|
||||||
"lms.lms.api.update_course_statistics",
|
"lms.lms.api.update_course_statistics",
|
||||||
@@ -191,7 +197,6 @@ update_website_context = [
|
|||||||
|
|
||||||
jinja = {
|
jinja = {
|
||||||
"methods": [
|
"methods": [
|
||||||
"lms.lms.utils.get_signup_optin_checks",
|
|
||||||
"lms.lms.utils.get_tags",
|
"lms.lms.utils.get_tags",
|
||||||
"lms.lms.utils.get_lesson_count",
|
"lms.lms.utils.get_lesson_count",
|
||||||
"lms.lms.utils.get_instructors",
|
"lms.lms.utils.get_instructors",
|
||||||
@@ -254,3 +259,5 @@ add_to_apps_screen = [
|
|||||||
"has_permission": "lms.lms.api.check_app_permission",
|
"has_permission": "lms.lms.api.check_app_permission",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
sqlite_search = ["lms.sqlite.LearningSearch"]
|
||||||
|
|||||||
443
lms/lms/api.py
443
lms/lms/api.py
@@ -6,6 +6,7 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from datetime import timedelta
|
||||||
from xml.dom.minidom import parseString
|
from xml.dom.minidom import parseString
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
@@ -23,47 +24,13 @@ from frappe.utils import (
|
|||||||
flt,
|
flt,
|
||||||
format_date,
|
format_date,
|
||||||
get_datetime,
|
get_datetime,
|
||||||
|
getdate,
|
||||||
now,
|
now,
|
||||||
)
|
)
|
||||||
from frappe.utils.response import Response
|
from frappe.utils.response import Response
|
||||||
|
|
||||||
from lms.lms.doctype.course_lesson.course_lesson import save_progress
|
from lms.lms.doctype.course_lesson.course_lesson import save_progress
|
||||||
from lms.lms.utils import get_average_rating, get_lesson_count
|
from lms.lms.utils import get_average_rating, get_batch_details, get_course_details, get_lesson_count
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def autosave_section(section, code):
|
|
||||||
"""Saves the code edited in one of the sections."""
|
|
||||||
doc = frappe.get_doc(doctype="Code Revision", section=section, code=code, author=frappe.session.user)
|
|
||||||
doc.insert()
|
|
||||||
return {"name": doc.name}
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def submit_solution(exercise, code):
|
|
||||||
"""Submits a solution.
|
|
||||||
|
|
||||||
@exerecise: name of the exercise to submit
|
|
||||||
@code: solution to the exercise
|
|
||||||
"""
|
|
||||||
ex = frappe.get_doc("LMS Exercise", exercise)
|
|
||||||
if not ex:
|
|
||||||
return
|
|
||||||
doc = ex.submit(code)
|
|
||||||
return {"name": doc.name, "creation": doc.creation}
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def save_current_lesson(course_name, lesson_name):
|
|
||||||
"""Saves the current lesson for a student/mentor."""
|
|
||||||
name = frappe.get_value(
|
|
||||||
doctype="LMS Enrollment",
|
|
||||||
filters={"course": course_name, "member": frappe.session.user},
|
|
||||||
fieldname="name",
|
|
||||||
)
|
|
||||||
if not name:
|
|
||||||
return
|
|
||||||
frappe.db.set_value("LMS Enrollment", name, "current_lesson", lesson_name)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
@@ -102,9 +69,32 @@ def get_translations():
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def validate_billing_access(billing_type, name):
|
def validate_billing_access(billing_type, name):
|
||||||
|
doctype = "LMS Batch" if billing_type == "batch" else "LMS Course"
|
||||||
|
access, message = verify_billing_access(doctype, name, billing_type)
|
||||||
|
|
||||||
|
address = frappe.db.get_value(
|
||||||
|
"Address",
|
||||||
|
{"email_id": frappe.session.user},
|
||||||
|
[
|
||||||
|
"name",
|
||||||
|
"address_title as billing_name",
|
||||||
|
"address_line1",
|
||||||
|
"address_line2",
|
||||||
|
"city",
|
||||||
|
"state",
|
||||||
|
"country",
|
||||||
|
"pincode",
|
||||||
|
"phone",
|
||||||
|
],
|
||||||
|
as_dict=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"access": access, "message": message, "address": address}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_billing_access(doctype, name, billing_type):
|
||||||
access = True
|
access = True
|
||||||
message = ""
|
message = ""
|
||||||
doctype = "LMS Batch" if billing_type == "batch" else "LMS Course"
|
|
||||||
|
|
||||||
if frappe.session.user == "Guest":
|
if frappe.session.user == "Guest":
|
||||||
access = False
|
access = False
|
||||||
@@ -154,47 +144,7 @@ def validate_billing_access(billing_type, name):
|
|||||||
access = False
|
access = False
|
||||||
message = _("You have already purchased the certificate for this course.")
|
message = _("You have already purchased the certificate for this course.")
|
||||||
|
|
||||||
address = frappe.db.get_value(
|
return access, message
|
||||||
"Address",
|
|
||||||
{"email_id": frappe.session.user},
|
|
||||||
[
|
|
||||||
"name",
|
|
||||||
"address_title as billing_name",
|
|
||||||
"address_line1",
|
|
||||||
"address_line2",
|
|
||||||
"city",
|
|
||||||
"state",
|
|
||||||
"country",
|
|
||||||
"pincode",
|
|
||||||
"phone",
|
|
||||||
],
|
|
||||||
as_dict=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"access": access, "message": message, "address": address}
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
|
||||||
def get_job_details(job):
|
|
||||||
return frappe.db.get_value(
|
|
||||||
"Job Opportunity",
|
|
||||||
job,
|
|
||||||
[
|
|
||||||
"job_title",
|
|
||||||
"location",
|
|
||||||
"country",
|
|
||||||
"type",
|
|
||||||
"work_mode",
|
|
||||||
"company_name",
|
|
||||||
"company_logo",
|
|
||||||
"company_website",
|
|
||||||
"name",
|
|
||||||
"creation",
|
|
||||||
"description",
|
|
||||||
"owner",
|
|
||||||
],
|
|
||||||
as_dict=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
@@ -1665,3 +1615,340 @@ def get_profile_details(username):
|
|||||||
|
|
||||||
details.roles = frappe.get_roles(details.name)
|
details.roles = frappe.get_roles(details.name)
|
||||||
return details
|
return details
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_streak_info():
|
||||||
|
if frappe.session.user == "Guest":
|
||||||
|
return {}
|
||||||
|
|
||||||
|
all_dates = fetch_activity_dates(frappe.session.user)
|
||||||
|
streak, longest_streak = calculate_streaks(all_dates)
|
||||||
|
current_streak = calculate_current_streak(all_dates, streak)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"current_streak": current_streak,
|
||||||
|
"longest_streak": longest_streak,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_activity_dates(user):
|
||||||
|
doctypes = [
|
||||||
|
"LMS Course Progress",
|
||||||
|
"LMS Quiz Submission",
|
||||||
|
"LMS Assignment Submission",
|
||||||
|
"LMS Programming Exercise Submission",
|
||||||
|
]
|
||||||
|
|
||||||
|
all_dates = []
|
||||||
|
for dt in doctypes:
|
||||||
|
all_dates.extend(frappe.get_all(dt, {"member": user}, pluck="creation"))
|
||||||
|
|
||||||
|
return sorted({d.date() if hasattr(d, "date") else d for d in all_dates})
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_streaks(all_dates):
|
||||||
|
streak = 0
|
||||||
|
longest_streak = 0
|
||||||
|
prev_day = None
|
||||||
|
|
||||||
|
for d in all_dates:
|
||||||
|
if d.weekday() in (5, 6):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if prev_day:
|
||||||
|
expected = prev_day + timedelta(days=1)
|
||||||
|
while expected.weekday() in (5, 6):
|
||||||
|
expected += timedelta(days=1)
|
||||||
|
|
||||||
|
streak = streak + 1 if d == expected else 1
|
||||||
|
else:
|
||||||
|
streak = 1
|
||||||
|
|
||||||
|
longest_streak = max(longest_streak, streak)
|
||||||
|
prev_day = d
|
||||||
|
|
||||||
|
return streak, longest_streak
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_current_streak(all_dates, streak):
|
||||||
|
if not all_dates:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
last_date = all_dates[-1]
|
||||||
|
today = getdate()
|
||||||
|
|
||||||
|
ref_day = today
|
||||||
|
while ref_day.weekday() in (5, 6):
|
||||||
|
ref_day -= timedelta(days=1)
|
||||||
|
|
||||||
|
if last_date == ref_day or last_date == ref_day - timedelta(days=1):
|
||||||
|
return streak
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_my_live_classes():
|
||||||
|
my_live_classes = []
|
||||||
|
if frappe.session.user == "Guest":
|
||||||
|
return my_live_classes
|
||||||
|
|
||||||
|
batches = frappe.get_all(
|
||||||
|
"LMS Batch Enrollment",
|
||||||
|
{
|
||||||
|
"member": frappe.session.user,
|
||||||
|
},
|
||||||
|
order_by="creation desc",
|
||||||
|
pluck="batch",
|
||||||
|
)
|
||||||
|
|
||||||
|
live_class_details = frappe.get_all(
|
||||||
|
"LMS Live Class",
|
||||||
|
filters={
|
||||||
|
"date": [">=", getdate()],
|
||||||
|
"batch_name": ["in", batches],
|
||||||
|
},
|
||||||
|
fields=[
|
||||||
|
"name",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"time",
|
||||||
|
"date",
|
||||||
|
"duration",
|
||||||
|
"attendees",
|
||||||
|
"start_url",
|
||||||
|
"join_url",
|
||||||
|
"owner",
|
||||||
|
],
|
||||||
|
limit=2,
|
||||||
|
order_by="date",
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(live_class_details):
|
||||||
|
for live_class in live_class_details:
|
||||||
|
live_class.course_title = frappe.db.get_value("LMS Course", live_class.course, "title")
|
||||||
|
|
||||||
|
my_live_classes.append(live_class)
|
||||||
|
|
||||||
|
return my_live_classes
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_created_courses():
|
||||||
|
created_courses = []
|
||||||
|
if frappe.session.user == "Guest":
|
||||||
|
return created_courses
|
||||||
|
|
||||||
|
CourseInstructor = frappe.qb.DocType("Course Instructor")
|
||||||
|
Course = frappe.qb.DocType("LMS Course")
|
||||||
|
|
||||||
|
query = (
|
||||||
|
frappe.qb.from_(CourseInstructor)
|
||||||
|
.join(Course)
|
||||||
|
.on(CourseInstructor.parent == Course.name)
|
||||||
|
.select(Course.name)
|
||||||
|
.where(CourseInstructor.instructor == frappe.session.user)
|
||||||
|
.orderby(Course.published_on, order=frappe.qb.desc)
|
||||||
|
.limit(3)
|
||||||
|
)
|
||||||
|
|
||||||
|
results = query.run(as_dict=True)
|
||||||
|
courses = [row["name"] for row in results]
|
||||||
|
|
||||||
|
for course in courses:
|
||||||
|
course_details = get_course_details(course)
|
||||||
|
created_courses.append(course_details)
|
||||||
|
|
||||||
|
return created_courses
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_created_batches():
|
||||||
|
created_batches = []
|
||||||
|
if frappe.session.user == "Guest":
|
||||||
|
return created_batches
|
||||||
|
|
||||||
|
CourseInstructor = frappe.qb.DocType("Course Instructor")
|
||||||
|
Batch = frappe.qb.DocType("LMS Batch")
|
||||||
|
|
||||||
|
query = (
|
||||||
|
frappe.qb.from_(CourseInstructor)
|
||||||
|
.join(Batch)
|
||||||
|
.on(CourseInstructor.parent == Batch.name)
|
||||||
|
.select(Batch.name)
|
||||||
|
.where(CourseInstructor.instructor == frappe.session.user)
|
||||||
|
.where(Batch.start_date >= getdate())
|
||||||
|
.orderby(Batch.start_date, order=frappe.qb.asc)
|
||||||
|
.limit(4)
|
||||||
|
)
|
||||||
|
|
||||||
|
results = query.run(as_dict=True)
|
||||||
|
batches = [row["name"] for row in results]
|
||||||
|
|
||||||
|
for batch in batches:
|
||||||
|
batch_details = get_batch_details(batch)
|
||||||
|
created_batches.append(batch_details)
|
||||||
|
|
||||||
|
return created_batches
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_admin_live_classes():
|
||||||
|
if frappe.session.user == "Guest":
|
||||||
|
return []
|
||||||
|
|
||||||
|
CourseInstructor = frappe.qb.DocType("Course Instructor")
|
||||||
|
LMSLiveClass = frappe.qb.DocType("LMS Live Class")
|
||||||
|
|
||||||
|
query = (
|
||||||
|
frappe.qb.from_(CourseInstructor)
|
||||||
|
.join(LMSLiveClass)
|
||||||
|
.on(CourseInstructor.parent == LMSLiveClass.batch_name)
|
||||||
|
.select(
|
||||||
|
LMSLiveClass.name,
|
||||||
|
LMSLiveClass.title,
|
||||||
|
LMSLiveClass.description,
|
||||||
|
LMSLiveClass.time,
|
||||||
|
LMSLiveClass.date,
|
||||||
|
LMSLiveClass.duration,
|
||||||
|
LMSLiveClass.attendees,
|
||||||
|
LMSLiveClass.start_url,
|
||||||
|
LMSLiveClass.join_url,
|
||||||
|
LMSLiveClass.owner,
|
||||||
|
)
|
||||||
|
.where(CourseInstructor.instructor == frappe.session.user)
|
||||||
|
.where(LMSLiveClass.date >= getdate())
|
||||||
|
.orderby(LMSLiveClass.date, order=frappe.qb.asc)
|
||||||
|
.limit(4)
|
||||||
|
)
|
||||||
|
results = query.run(as_dict=True)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_admin_evals():
|
||||||
|
if frappe.session.user == "Guest":
|
||||||
|
return []
|
||||||
|
|
||||||
|
evals = frappe.get_all(
|
||||||
|
"LMS Certificate Request",
|
||||||
|
{
|
||||||
|
"evaluator": frappe.session.user,
|
||||||
|
"date": [">=", getdate()],
|
||||||
|
},
|
||||||
|
[
|
||||||
|
"name",
|
||||||
|
"date",
|
||||||
|
"start_time",
|
||||||
|
"course",
|
||||||
|
"evaluator",
|
||||||
|
"google_meet_link",
|
||||||
|
"member",
|
||||||
|
"member_name",
|
||||||
|
],
|
||||||
|
limit=4,
|
||||||
|
order_by="date asc",
|
||||||
|
)
|
||||||
|
|
||||||
|
for evaluation in evals:
|
||||||
|
evaluation.course_title = frappe.db.get_value("LMS Course", evaluation.course, "title")
|
||||||
|
|
||||||
|
return evals
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_my_courses():
|
||||||
|
my_courses = []
|
||||||
|
if frappe.session.user == "Guest":
|
||||||
|
return my_courses
|
||||||
|
|
||||||
|
courses = get_my_latest_courses()
|
||||||
|
|
||||||
|
if not len(courses):
|
||||||
|
courses = get_featured_home_courses()
|
||||||
|
|
||||||
|
if not len(courses):
|
||||||
|
courses = get_popular_courses()
|
||||||
|
|
||||||
|
for course in courses:
|
||||||
|
my_courses.append(get_course_details(course))
|
||||||
|
|
||||||
|
return my_courses
|
||||||
|
|
||||||
|
|
||||||
|
def get_my_latest_courses():
|
||||||
|
return frappe.get_all(
|
||||||
|
"LMS Enrollment",
|
||||||
|
{
|
||||||
|
"member": frappe.session.user,
|
||||||
|
},
|
||||||
|
order_by="modified desc",
|
||||||
|
limit=3,
|
||||||
|
pluck="course",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_featured_home_courses():
|
||||||
|
return frappe.get_all(
|
||||||
|
"LMS Course",
|
||||||
|
{"published": 1, "featured": 1},
|
||||||
|
order_by="published_on desc",
|
||||||
|
limit=3,
|
||||||
|
pluck="name",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_popular_courses():
|
||||||
|
return frappe.get_all(
|
||||||
|
"LMS Course",
|
||||||
|
{
|
||||||
|
"published": 1,
|
||||||
|
},
|
||||||
|
order_by="enrollments desc",
|
||||||
|
limit=3,
|
||||||
|
pluck="name",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_my_batches():
|
||||||
|
my_batches = []
|
||||||
|
if frappe.session.user == "Guest":
|
||||||
|
return my_batches
|
||||||
|
|
||||||
|
batches = get_my_latest_batches()
|
||||||
|
|
||||||
|
if not len(batches):
|
||||||
|
batches = get_upcoming_batches()
|
||||||
|
|
||||||
|
for batch in batches:
|
||||||
|
batch_details = get_batch_details(batch)
|
||||||
|
if batch_details:
|
||||||
|
my_batches.append(batch_details)
|
||||||
|
|
||||||
|
return my_batches
|
||||||
|
|
||||||
|
|
||||||
|
def get_my_latest_batches():
|
||||||
|
return frappe.get_all(
|
||||||
|
"LMS Batch Enrollment",
|
||||||
|
{
|
||||||
|
"member": frappe.session.user,
|
||||||
|
},
|
||||||
|
order_by="creation desc",
|
||||||
|
limit=4,
|
||||||
|
pluck="batch",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_upcoming_batches():
|
||||||
|
return frappe.get_all(
|
||||||
|
"LMS Batch",
|
||||||
|
{
|
||||||
|
"published": 1,
|
||||||
|
"start_date": [">=", getdate()],
|
||||||
|
},
|
||||||
|
order_by="start_date asc",
|
||||||
|
limit=4,
|
||||||
|
pluck="name",
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
// Copyright (c) 2021, Frappe and contributors
|
|
||||||
// For license information, please see license.txt
|
|
||||||
|
|
||||||
frappe.ui.form.on("Exercise Latest Submission", {
|
|
||||||
// refresh: function(frm) {
|
|
||||||
// }
|
|
||||||
});
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
{
|
|
||||||
"actions": [],
|
|
||||||
"creation": "2021-12-08 17:56:26.049675",
|
|
||||||
"doctype": "DocType",
|
|
||||||
"editable_grid": 1,
|
|
||||||
"engine": "InnoDB",
|
|
||||||
"field_order": [
|
|
||||||
"exercise",
|
|
||||||
"status",
|
|
||||||
"batch_old",
|
|
||||||
"column_break_4",
|
|
||||||
"exercise_title",
|
|
||||||
"course",
|
|
||||||
"lesson",
|
|
||||||
"section_break_8",
|
|
||||||
"solution",
|
|
||||||
"image",
|
|
||||||
"test_results",
|
|
||||||
"comments",
|
|
||||||
"latest_submission",
|
|
||||||
"member",
|
|
||||||
"member_email"
|
|
||||||
],
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldname": "exercise",
|
|
||||||
"fieldtype": "Link",
|
|
||||||
"in_list_view": 1,
|
|
||||||
"in_standard_filter": 1,
|
|
||||||
"label": "Exercise",
|
|
||||||
"options": "LMS Exercise",
|
|
||||||
"search_index": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "status",
|
|
||||||
"fieldtype": "Select",
|
|
||||||
"label": "Status",
|
|
||||||
"options": "Correct\nIncorrect"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "batch_old",
|
|
||||||
"fieldtype": "Link",
|
|
||||||
"label": "Batch Old",
|
|
||||||
"options": "LMS Batch Old"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "column_break_4",
|
|
||||||
"fieldtype": "Column Break"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fetch_from": "exercise.title",
|
|
||||||
"fieldname": "exercise_title",
|
|
||||||
"fieldtype": "Data",
|
|
||||||
"label": "Exercise Title",
|
|
||||||
"read_only": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fetch_from": "exercise.course",
|
|
||||||
"fieldname": "course",
|
|
||||||
"fieldtype": "Link",
|
|
||||||
"in_standard_filter": 1,
|
|
||||||
"label": "Course",
|
|
||||||
"options": "LMS Course",
|
|
||||||
"read_only": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fetch_from": "exercise.lesson",
|
|
||||||
"fieldname": "lesson",
|
|
||||||
"fieldtype": "Link",
|
|
||||||
"label": "Lesson",
|
|
||||||
"options": "Course Lesson"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "section_break_8",
|
|
||||||
"fieldtype": "Section Break"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fetch_from": "latest_submission.solution",
|
|
||||||
"fieldname": "solution",
|
|
||||||
"fieldtype": "Code",
|
|
||||||
"label": "Solution"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fetch_from": "latest_submission.image",
|
|
||||||
"fieldname": "image",
|
|
||||||
"fieldtype": "Code",
|
|
||||||
"label": "Image",
|
|
||||||
"read_only": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fetch_from": "latest_submission.test_results",
|
|
||||||
"fieldname": "test_results",
|
|
||||||
"fieldtype": "Small Text",
|
|
||||||
"label": "Test Results"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "comments",
|
|
||||||
"fieldtype": "Small Text",
|
|
||||||
"label": "Comments"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "latest_submission",
|
|
||||||
"fieldtype": "Link",
|
|
||||||
"label": "Latest Submission",
|
|
||||||
"options": "Exercise Submission"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "member",
|
|
||||||
"fieldtype": "Link",
|
|
||||||
"label": "Member",
|
|
||||||
"options": "LMS Enrollment"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fetch_from": "member.member",
|
|
||||||
"fieldname": "member_email",
|
|
||||||
"fieldtype": "Link",
|
|
||||||
"in_list_view": 1,
|
|
||||||
"in_standard_filter": 1,
|
|
||||||
"label": "Member Email",
|
|
||||||
"options": "User",
|
|
||||||
"search_index": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"index_web_pages_for_search": 1,
|
|
||||||
"links": [],
|
|
||||||
"modified": "2025-12-14 20:56:52.370697",
|
|
||||||
"modified_by": "Administrator",
|
|
||||||
"module": "LMS",
|
|
||||||
"name": "Exercise Latest Submission",
|
|
||||||
"owner": "Administrator",
|
|
||||||
"permissions": [
|
|
||||||
{
|
|
||||||
"create": 1,
|
|
||||||
"delete": 1,
|
|
||||||
"email": 1,
|
|
||||||
"export": 1,
|
|
||||||
"print": 1,
|
|
||||||
"read": 1,
|
|
||||||
"report": 1,
|
|
||||||
"role": "System Manager",
|
|
||||||
"share": 1,
|
|
||||||
"write": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"row_format": "Dynamic",
|
|
||||||
"sort_field": "modified",
|
|
||||||
"sort_order": "DESC",
|
|
||||||
"states": [],
|
|
||||||
"track_changes": 1
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# Copyright (c) 2021, Frappe and contributors
|
|
||||||
# For license information, please see license.txt
|
|
||||||
|
|
||||||
# import frappe
|
|
||||||
from frappe.model.document import Document
|
|
||||||
|
|
||||||
|
|
||||||
class ExerciseLatestSubmission(Document):
|
|
||||||
pass
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# Copyright (c) 2021, Frappe and Contributors
|
|
||||||
# See license.txt
|
|
||||||
|
|
||||||
# import frappe
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
|
|
||||||
class TestExerciseLatestSubmission(unittest.TestCase):
|
|
||||||
pass
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
// Copyright (c) 2021, FOSS United and contributors
|
|
||||||
// For license information, please see license.txt
|
|
||||||
|
|
||||||
frappe.ui.form.on("Exercise Submission", {
|
|
||||||
// refresh: function(frm) {
|
|
||||||
// }
|
|
||||||
});
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
{
|
|
||||||
"actions": [],
|
|
||||||
"creation": "2021-05-19 11:41:18.108316",
|
|
||||||
"doctype": "DocType",
|
|
||||||
"editable_grid": 1,
|
|
||||||
"engine": "InnoDB",
|
|
||||||
"field_order": [
|
|
||||||
"exercise",
|
|
||||||
"status",
|
|
||||||
"batch_old",
|
|
||||||
"column_break_4",
|
|
||||||
"exercise_title",
|
|
||||||
"course",
|
|
||||||
"lesson",
|
|
||||||
"section_break_8",
|
|
||||||
"solution",
|
|
||||||
"image",
|
|
||||||
"test_results",
|
|
||||||
"comments",
|
|
||||||
"member"
|
|
||||||
],
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldname": "exercise",
|
|
||||||
"fieldtype": "Link",
|
|
||||||
"in_list_view": 1,
|
|
||||||
"label": "Exercise",
|
|
||||||
"options": "LMS Exercise"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fetch_from": "exercise.title",
|
|
||||||
"fieldname": "exercise_title",
|
|
||||||
"fieldtype": "Data",
|
|
||||||
"in_list_view": 1,
|
|
||||||
"label": "Exercise Title",
|
|
||||||
"read_only": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fetch_from": "exercise.course",
|
|
||||||
"fieldname": "course",
|
|
||||||
"fieldtype": "Link",
|
|
||||||
"label": "Course",
|
|
||||||
"options": "LMS Course",
|
|
||||||
"read_only": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "batch_old",
|
|
||||||
"fieldtype": "Link",
|
|
||||||
"label": "Batch Old",
|
|
||||||
"options": "LMS Batch Old"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fetch_from": "exercise.lesson",
|
|
||||||
"fieldname": "lesson",
|
|
||||||
"fieldtype": "Link",
|
|
||||||
"label": "Lesson",
|
|
||||||
"options": "Course Lesson"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "image",
|
|
||||||
"fieldtype": "Code",
|
|
||||||
"label": "Image",
|
|
||||||
"read_only": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "status",
|
|
||||||
"fieldtype": "Select",
|
|
||||||
"label": "Status",
|
|
||||||
"options": "Correct\nIncorrect"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "test_results",
|
|
||||||
"fieldtype": "Small Text",
|
|
||||||
"label": "Test Results"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "comments",
|
|
||||||
"fieldtype": "Small Text",
|
|
||||||
"label": "Comments"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "solution",
|
|
||||||
"fieldtype": "Code",
|
|
||||||
"in_list_view": 1,
|
|
||||||
"label": "Solution"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "column_break_4",
|
|
||||||
"fieldtype": "Column Break"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "section_break_8",
|
|
||||||
"fieldtype": "Section Break"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "member",
|
|
||||||
"fieldtype": "Link",
|
|
||||||
"label": "Member",
|
|
||||||
"options": "LMS Enrollment"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"index_web_pages_for_search": 1,
|
|
||||||
"links": [],
|
|
||||||
"modified": "2021-12-08 22:25:05.809377",
|
|
||||||
"modified_by": "Administrator",
|
|
||||||
"module": "LMS",
|
|
||||||
"name": "Exercise Submission",
|
|
||||||
"owner": "Administrator",
|
|
||||||
"permissions": [
|
|
||||||
{
|
|
||||||
"create": 1,
|
|
||||||
"delete": 1,
|
|
||||||
"email": 1,
|
|
||||||
"export": 1,
|
|
||||||
"print": 1,
|
|
||||||
"read": 1,
|
|
||||||
"report": 1,
|
|
||||||
"role": "System Manager",
|
|
||||||
"share": 1,
|
|
||||||
"write": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"sort_field": "modified",
|
|
||||||
"sort_order": "DESC",
|
|
||||||
"track_changes": 1
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# Copyright (c) 2021, FOSS United and contributors
|
|
||||||
# For license information, please see license.txt
|
|
||||||
|
|
||||||
import frappe
|
|
||||||
from frappe.model.document import Document
|
|
||||||
|
|
||||||
|
|
||||||
class ExerciseSubmission(Document):
|
|
||||||
def on_update(self):
|
|
||||||
self.update_latest_submission()
|
|
||||||
|
|
||||||
def update_latest_submission(self):
|
|
||||||
names = frappe.get_all(
|
|
||||||
"Exercise Latest Submission", {"exercise": self.exercise, "member": self.member}
|
|
||||||
)
|
|
||||||
if names:
|
|
||||||
doc = frappe.get_doc("Exercise Latest Submission", names[0])
|
|
||||||
doc.latest_submission = self.name
|
|
||||||
doc.save(ignore_permissions=True)
|
|
||||||
else:
|
|
||||||
doc = frappe.get_doc(
|
|
||||||
{
|
|
||||||
"doctype": "Exercise Latest Submission",
|
|
||||||
"exercise": self.exercise,
|
|
||||||
"member": self.member,
|
|
||||||
"latest_submission": self.name,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
doc.insert(ignore_permissions=True)
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# Copyright (c) 2021, FOSS United and Contributors
|
|
||||||
# See license.txt
|
|
||||||
|
|
||||||
# import frappe
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
|
|
||||||
class TestExerciseSubmission(unittest.TestCase):
|
|
||||||
pass
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
// Copyright (c) 2021, FOSS United and contributors
|
|
||||||
// For license information, please see license.txt
|
|
||||||
|
|
||||||
frappe.ui.form.on("LMS Batch Old", {
|
|
||||||
// refresh: function(frm) {
|
|
||||||
// }
|
|
||||||
});
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
{
|
|
||||||
"actions": [],
|
|
||||||
"autoname": "format: BATCH-{#####}",
|
|
||||||
"creation": "2021-03-18 19:37:34.614796",
|
|
||||||
"doctype": "DocType",
|
|
||||||
"editable_grid": 1,
|
|
||||||
"engine": "InnoDB",
|
|
||||||
"field_order": [
|
|
||||||
"course",
|
|
||||||
"start_date",
|
|
||||||
"start_time",
|
|
||||||
"column_break_3",
|
|
||||||
"title",
|
|
||||||
"sessions_on",
|
|
||||||
"end_time",
|
|
||||||
"section_break_5",
|
|
||||||
"description",
|
|
||||||
"section_break_7",
|
|
||||||
"visibility",
|
|
||||||
"membership",
|
|
||||||
"column_break_9",
|
|
||||||
"status",
|
|
||||||
"stage"
|
|
||||||
],
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldname": "course",
|
|
||||||
"fieldtype": "Link",
|
|
||||||
"in_list_view": 1,
|
|
||||||
"in_standard_filter": 1,
|
|
||||||
"label": "Course",
|
|
||||||
"options": "LMS Course",
|
|
||||||
"reqd": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "title",
|
|
||||||
"fieldtype": "Data",
|
|
||||||
"label": "Title",
|
|
||||||
"reqd": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "description",
|
|
||||||
"fieldtype": "Markdown Editor",
|
|
||||||
"label": "Description"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": "Public",
|
|
||||||
"fieldname": "visibility",
|
|
||||||
"fieldtype": "Select",
|
|
||||||
"in_list_view": 1,
|
|
||||||
"label": "Visibility",
|
|
||||||
"options": "Public\nUnlisted\nPrivate"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "membership",
|
|
||||||
"fieldtype": "Select",
|
|
||||||
"label": "Membership",
|
|
||||||
"options": "\nOpen\nRestricted\nInvite Only\nClosed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": "Active",
|
|
||||||
"fieldname": "status",
|
|
||||||
"fieldtype": "Select",
|
|
||||||
"in_list_view": 1,
|
|
||||||
"label": "Status",
|
|
||||||
"options": "Active\nInactive"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": "Ready",
|
|
||||||
"fieldname": "stage",
|
|
||||||
"fieldtype": "Select",
|
|
||||||
"label": "Stage",
|
|
||||||
"options": "Ready\nIn Progress\nCompleted\nCancelled"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "column_break_3",
|
|
||||||
"fieldtype": "Column Break"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "section_break_5",
|
|
||||||
"fieldtype": "Section Break",
|
|
||||||
"label": "Batch Description"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "column_break_9",
|
|
||||||
"fieldtype": "Column Break"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "section_break_7",
|
|
||||||
"fieldtype": "Section Break",
|
|
||||||
"label": "Batch Settings"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "start_date",
|
|
||||||
"fieldtype": "Date",
|
|
||||||
"in_list_view": 1,
|
|
||||||
"label": "Start Date"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "start_time",
|
|
||||||
"fieldtype": "Time",
|
|
||||||
"in_list_view": 1,
|
|
||||||
"label": "Start Time"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "sessions_on",
|
|
||||||
"fieldtype": "Data",
|
|
||||||
"label": "Sessions On Days"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "end_time",
|
|
||||||
"fieldtype": "Time",
|
|
||||||
"in_list_view": 1,
|
|
||||||
"label": "End Time"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"index_web_pages_for_search": 1,
|
|
||||||
"links": [
|
|
||||||
{
|
|
||||||
"group": "Members",
|
|
||||||
"link_doctype": "LMS Enrollment",
|
|
||||||
"link_fieldname": "batch_old"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"modified": "2022-09-28 18:43:22.955907",
|
|
||||||
"modified_by": "Administrator",
|
|
||||||
"module": "LMS",
|
|
||||||
"name": "LMS Batch Old",
|
|
||||||
"naming_rule": "Expression",
|
|
||||||
"owner": "Administrator",
|
|
||||||
"permissions": [
|
|
||||||
{
|
|
||||||
"create": 1,
|
|
||||||
"delete": 1,
|
|
||||||
"email": 1,
|
|
||||||
"export": 1,
|
|
||||||
"print": 1,
|
|
||||||
"read": 1,
|
|
||||||
"report": 1,
|
|
||||||
"role": "System Manager",
|
|
||||||
"share": 1,
|
|
||||||
"write": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"quick_entry": 1,
|
|
||||||
"sort_field": "modified",
|
|
||||||
"sort_order": "DESC",
|
|
||||||
"states": [],
|
|
||||||
"track_changes": 1
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# Copyright (c) 2021, FOSS United and contributors
|
|
||||||
# For license information, please see license.txt
|
|
||||||
|
|
||||||
import frappe
|
|
||||||
from frappe import _
|
|
||||||
from frappe.model.document import Document
|
|
||||||
|
|
||||||
from lms.lms.doctype.lms_enrollment.lms_enrollment import create_membership
|
|
||||||
from lms.lms.utils import is_mentor
|
|
||||||
|
|
||||||
|
|
||||||
class LMSBatchOld(Document):
|
|
||||||
def validate(self):
|
|
||||||
pass
|
|
||||||
# self.validate_if_mentor()
|
|
||||||
|
|
||||||
def validate_if_mentor(self):
|
|
||||||
if not is_mentor(self.course, frappe.session.user):
|
|
||||||
course_title = frappe.db.get_value("LMS Course", self.course, "title")
|
|
||||||
frappe.throw(_("You are not a mentor of the course {0}").format(course_title))
|
|
||||||
|
|
||||||
def after_insert(self):
|
|
||||||
create_membership(batch=self.name, course=self.course, member_type="Mentor")
|
|
||||||
|
|
||||||
def is_member(self, email, member_type=None):
|
|
||||||
"""Checks if a person is part of a batch.
|
|
||||||
|
|
||||||
If member_type is specified, checks if the person is a Student/Mentor.
|
|
||||||
"""
|
|
||||||
|
|
||||||
filters = {"batch_old": self.name, "member": email}
|
|
||||||
if member_type:
|
|
||||||
filters["member_type"] = member_type
|
|
||||||
return frappe.db.exists("LMS Enrollment", filters)
|
|
||||||
|
|
||||||
def get_membership(self, email):
|
|
||||||
"""Returns the membership document of given user."""
|
|
||||||
name = frappe.get_value(
|
|
||||||
doctype="LMS Enrollment",
|
|
||||||
filters={"batch_old": self.name, "member": email},
|
|
||||||
fieldname="name",
|
|
||||||
)
|
|
||||||
return frappe.get_doc("LMS Enrollment", name)
|
|
||||||
|
|
||||||
def get_current_lesson(self, user):
|
|
||||||
"""Returns the name of the current lesson for the given user."""
|
|
||||||
membership = self.get_membership(user)
|
|
||||||
return membership and membership.current_lesson
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def save_message(message, batch):
|
|
||||||
doc = frappe.get_doc(
|
|
||||||
{
|
|
||||||
"doctype": "LMS Message",
|
|
||||||
"batch_old": batch,
|
|
||||||
"author": frappe.session.user,
|
|
||||||
"message": message,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
doc.save(ignore_permissions=True)
|
|
||||||
|
|
||||||
|
|
||||||
def switch_batch(course_name, email, batch_name):
|
|
||||||
"""Switches the user from the current batch of the course to a new batch."""
|
|
||||||
membership = frappe.get_last_doc("LMS Enrollment", filters={"course": course_name, "member": email})
|
|
||||||
|
|
||||||
batch = frappe.get_doc("LMS Batch Old", batch_name)
|
|
||||||
if not batch:
|
|
||||||
raise ValueError(f"Invalid Batch: {batch_name}")
|
|
||||||
|
|
||||||
if batch.course != course_name:
|
|
||||||
raise ValueError("Can not switch batches across courses")
|
|
||||||
|
|
||||||
if batch.is_member(email):
|
|
||||||
print(f"{email} is already a member of {batch.title}")
|
|
||||||
return
|
|
||||||
|
|
||||||
old_batch = frappe.get_doc("LMS Batch Old", membership.batch_old)
|
|
||||||
|
|
||||||
membership.batch_old = batch_name
|
|
||||||
membership.save()
|
|
||||||
|
|
||||||
# update exercise submissions
|
|
||||||
filters = {"owner": email, "batch_old": old_batch.name}
|
|
||||||
for name in frappe.db.get_all("Exercise Submission", filters=filters, pluck="name"):
|
|
||||||
doc = frappe.get_doc("Exercise Submission", name)
|
|
||||||
print("updating exercise submission", name)
|
|
||||||
doc.batch_old = batch_name
|
|
||||||
doc.save()
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# Copyright (c) 2021, FOSS United and Contributors
|
|
||||||
# See license.txt
|
|
||||||
|
|
||||||
# import frappe
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
|
|
||||||
class TestLMSBatchOld(unittest.TestCase):
|
|
||||||
pass
|
|
||||||
@@ -6,9 +6,7 @@ from frappe import _
|
|||||||
from frappe.email.doctype.email_template.email_template import get_email_template
|
from frappe.email.doctype.email_template.email_template import get_email_template
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.model.naming import make_autoname
|
from frappe.model.naming import make_autoname
|
||||||
from frappe.utils import add_years, nowdate
|
from frappe.utils import nowdate
|
||||||
|
|
||||||
from lms.lms.utils import is_certified
|
|
||||||
|
|
||||||
|
|
||||||
class LMSCertificate(Document):
|
class LMSCertificate(Document):
|
||||||
@@ -112,6 +110,13 @@ def has_website_permission(doc, ptype, user, verbose=False):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_certified(course):
|
||||||
|
certificate = frappe.get_all("LMS Certificate", {"member": frappe.session.user, "course": course})
|
||||||
|
if len(certificate):
|
||||||
|
return certificate[0].name
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def create_certificate(course):
|
def create_certificate(course):
|
||||||
if is_certified(course):
|
if is_certified(course):
|
||||||
|
|||||||
@@ -3,35 +3,6 @@
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import frappe
|
|
||||||
from frappe.utils import cint, nowdate
|
|
||||||
|
|
||||||
from lms.lms.doctype.lms_certificate.lms_certificate import create_certificate
|
|
||||||
from lms.lms.doctype.lms_course.test_lms_course import new_course
|
|
||||||
|
|
||||||
|
|
||||||
class TestLMSCertificate(unittest.TestCase):
|
class TestLMSCertificate(unittest.TestCase):
|
||||||
def test_certificate_creation(self):
|
pass
|
||||||
course = new_course(
|
|
||||||
"Test Certificate",
|
|
||||||
{
|
|
||||||
"enable_certification": 1,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
create_enrollment(course.name)
|
|
||||||
certificate = create_certificate(course.name)
|
|
||||||
|
|
||||||
self.assertEqual(certificate.member, "Administrator")
|
|
||||||
self.assertEqual(certificate.course, course.name)
|
|
||||||
self.assertEqual(certificate.issue_date, nowdate())
|
|
||||||
|
|
||||||
frappe.db.delete("LMS Certificate", certificate.name)
|
|
||||||
frappe.db.delete("LMS Course", course.name)
|
|
||||||
|
|
||||||
|
|
||||||
def create_enrollment(course):
|
|
||||||
enrollment = frappe.new_doc("LMS Enrollment")
|
|
||||||
enrollment.course = course
|
|
||||||
enrollment.member = frappe.session.user
|
|
||||||
enrollment.progress = cint(100)
|
|
||||||
enrollment.save()
|
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
{
|
{
|
||||||
"actions": [
|
"actions": [],
|
||||||
{
|
|
||||||
"action": "lms.lms.doctype.lms_course.lms_course.reindex_exercises",
|
|
||||||
"action_type": "Server Action",
|
|
||||||
"group": "Reindex",
|
|
||||||
"label": "Reindex Exercises"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"allow_import": 1,
|
"allow_import": 1,
|
||||||
"allow_rename": 1,
|
"allow_rename": 1,
|
||||||
"creation": "2022-02-22 15:28:26.091549",
|
"creation": "2022-02-22 15:28:26.091549",
|
||||||
@@ -76,6 +69,8 @@
|
|||||||
"default": "0",
|
"default": "0",
|
||||||
"fieldname": "published",
|
"fieldname": "published",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"in_standard_filter": 1,
|
||||||
"label": "Published"
|
"label": "Published"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -152,8 +147,6 @@
|
|||||||
"fieldname": "status",
|
"fieldname": "status",
|
||||||
"fieldtype": "Select",
|
"fieldtype": "Select",
|
||||||
"hidden": 1,
|
"hidden": 1,
|
||||||
"in_list_view": 1,
|
|
||||||
"in_standard_filter": 1,
|
|
||||||
"label": "Status",
|
"label": "Status",
|
||||||
"options": "In Progress\nUnder Review\nApproved",
|
"options": "In Progress\nUnder Review\nApproved",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
@@ -313,7 +306,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"make_attachments_public": 1,
|
"make_attachments_public": 1,
|
||||||
"modified": "2025-10-13 15:08:11.734204",
|
"modified": "2025-12-15 15:15:42.226098",
|
||||||
"modified_by": "sayali@frappe.io",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Course",
|
"name": "LMS Course",
|
||||||
@@ -336,6 +329,7 @@
|
|||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
@@ -348,6 +342,7 @@
|
|||||||
"delete": 1,
|
"delete": 1,
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
|
"import": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ from frappe import _
|
|||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.utils import cint, today
|
from frappe.utils import cint, today
|
||||||
|
|
||||||
from lms.lms.utils import get_chapters
|
|
||||||
|
|
||||||
from ...utils import generate_slug, update_payment_record, validate_image
|
from ...utils import generate_slug, update_payment_record, validate_image
|
||||||
|
|
||||||
|
|
||||||
@@ -133,77 +131,3 @@ class LMSCourse(Document):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Course#{self.name}>"
|
return f"<Course#{self.name}>"
|
||||||
|
|
||||||
def has_mentor(self, email):
|
|
||||||
"""Checks if this course has a mentor with given email."""
|
|
||||||
if not email or email == "Guest":
|
|
||||||
return False
|
|
||||||
|
|
||||||
mapping = frappe.get_all("LMS Course Mentor Mapping", {"course": self.name, "mentor": email})
|
|
||||||
return mapping != []
|
|
||||||
|
|
||||||
def add_mentor(self, email):
|
|
||||||
"""Adds a new mentor to the course."""
|
|
||||||
if not email:
|
|
||||||
raise ValueError("Invalid email")
|
|
||||||
if email == "Guest":
|
|
||||||
raise ValueError("Guest user can not be added as a mentor")
|
|
||||||
|
|
||||||
# given user is already a mentor
|
|
||||||
if self.has_mentor(email):
|
|
||||||
return
|
|
||||||
|
|
||||||
doc = frappe.get_doc({"doctype": "LMS Course Mentor Mapping", "course": self.name, "mentor": email})
|
|
||||||
doc.insert()
|
|
||||||
|
|
||||||
def get_student_batch(self, email):
|
|
||||||
"""Returns the batch the given student is part of.
|
|
||||||
|
|
||||||
Returns None if the student is not part of any batch.
|
|
||||||
"""
|
|
||||||
if not email:
|
|
||||||
return
|
|
||||||
|
|
||||||
batch_name = frappe.get_value(
|
|
||||||
doctype="LMS Enrollment",
|
|
||||||
filters={"course": self.name, "member_type": "Student", "member": email},
|
|
||||||
fieldname="batch_old",
|
|
||||||
)
|
|
||||||
return batch_name and frappe.get_doc("LMS Batch Old", batch_name)
|
|
||||||
|
|
||||||
def get_batches(self, mentor=None):
|
|
||||||
batches = frappe.get_all("LMS Batch Old", {"course": self.name})
|
|
||||||
if mentor:
|
|
||||||
# TODO: optimize this
|
|
||||||
memberships = frappe.db.get_all("LMS Enrollment", {"member": mentor}, ["batch_old"])
|
|
||||||
batch_names = {m.batch_old for m in memberships}
|
|
||||||
return [b for b in batches if b.name in batch_names]
|
|
||||||
|
|
||||||
def reindex_exercises(self):
|
|
||||||
for i, c in enumerate(get_chapters(self.name), start=1):
|
|
||||||
self._reindex_exercises_in_chapter(c, i)
|
|
||||||
|
|
||||||
def _reindex_exercises_in_chapter(self, c, index):
|
|
||||||
i = 1
|
|
||||||
for lesson in self.get_lessons(c):
|
|
||||||
for exercise in lesson.get_exercises():
|
|
||||||
exercise.index_ = i
|
|
||||||
exercise.index_label = f"{index}.{i}"
|
|
||||||
exercise.save()
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
def get_all_memberships(self, member):
|
|
||||||
all_memberships = frappe.get_all(
|
|
||||||
"LMS Enrollment", {"member": member, "course": self.name}, ["batch_old"]
|
|
||||||
)
|
|
||||||
for membership in all_memberships:
|
|
||||||
membership.batch_title = frappe.db.get_value("LMS Batch Old", membership.batch_old, "title")
|
|
||||||
return all_memberships
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def reindex_exercises(doc):
|
|
||||||
course_data = json.loads(doc)
|
|
||||||
course = frappe.get_doc("LMS Course", course_data["name"])
|
|
||||||
course.reindex_exercises()
|
|
||||||
frappe.msgprint("All exercises in this course have been re-indexed.")
|
|
||||||
|
|||||||
@@ -13,31 +13,15 @@ class TestLMSCourse(unittest.TestCase):
|
|||||||
course = new_course("Test Course")
|
course = new_course("Test Course")
|
||||||
assert course.title == "Test Course"
|
assert course.title == "Test Course"
|
||||||
|
|
||||||
# disabled this test as it is failing
|
|
||||||
def _test_add_mentors(self):
|
|
||||||
course = new_course("Test Course")
|
|
||||||
assert course.get_mentors() == []
|
|
||||||
|
|
||||||
new_user("Tester", "tester@example.com")
|
|
||||||
course.add_mentor("tester@example.com")
|
|
||||||
|
|
||||||
mentors = course.get_mentors()
|
|
||||||
mentors_data = [dict(email=mentor.email, batch_count=mentor.batch_count) for mentor in mentors]
|
|
||||||
assert mentors_data == [{"email": "tester@example.com", "batch_count": 0}]
|
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
if frappe.db.exists("User", "tester@example.com"):
|
if frappe.db.exists("User", "tester@example.com"):
|
||||||
frappe.delete_doc("User", "tester@example.com")
|
frappe.delete_doc("User", "tester@example.com")
|
||||||
|
|
||||||
if frappe.db.exists("LMS Course", "test-course"):
|
if frappe.db.exists("LMS Course", "test-course"):
|
||||||
frappe.db.delete("Batch Course", {"course": "test-course"})
|
frappe.db.delete("Batch Course", {"course": "test-course"})
|
||||||
frappe.db.delete("Exercise Submission", {"course": "test-course"})
|
|
||||||
frappe.db.delete("Exercise Latest Submission", {"course": "test-course"})
|
|
||||||
frappe.db.delete("LMS Exercise", {"course": "test-course"})
|
|
||||||
frappe.db.delete("LMS Enrollment", {"course": "test-course"})
|
frappe.db.delete("LMS Enrollment", {"course": "test-course"})
|
||||||
frappe.db.delete("Course Lesson", {"course": "test-course"})
|
frappe.db.delete("Course Lesson", {"course": "test-course"})
|
||||||
frappe.db.delete("Course Chapter", {"course": "test-course"})
|
frappe.db.delete("Course Chapter", {"course": "test-course"})
|
||||||
frappe.db.delete("LMS Batch Old", {"course": "test-course"})
|
|
||||||
frappe.db.delete("LMS Course Mentor Mapping", {"course": "test-course"})
|
frappe.db.delete("LMS Course Mentor Mapping", {"course": "test-course"})
|
||||||
frappe.db.delete("Course Instructor", {"parent": "test-course"})
|
frappe.db.delete("Course Instructor", {"parent": "test-course"})
|
||||||
frappe.db.sql("delete from `tabCourse Instructor`")
|
frappe.db.sql("delete from `tabCourse Instructor`")
|
||||||
@@ -80,6 +64,7 @@ def new_course(title, additional_filters=None):
|
|||||||
"video_link": "https://youtu.be/pEbIhUySqbk",
|
"video_link": "https://youtu.be/pEbIhUySqbk",
|
||||||
"image": "/assets/lms/images/course-home.png",
|
"image": "/assets/lms/images/course-home.png",
|
||||||
"instructors": [{"instructor": user}],
|
"instructors": [{"instructor": user}],
|
||||||
|
"published": 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
if additional_filters:
|
if additional_filters:
|
||||||
|
|||||||
@@ -2,17 +2,24 @@
|
|||||||
# For license information, please see license.txt
|
# For license information, please see license.txt
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
|
from frappe import _
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.utils import cint
|
from frappe.utils import cint
|
||||||
|
|
||||||
|
|
||||||
class LMSCourseReview(Document):
|
class LMSCourseReview(Document):
|
||||||
def validate(self):
|
def validate(self):
|
||||||
|
self.validate_enrollment()
|
||||||
self.validate_if_already_reviewed()
|
self.validate_if_already_reviewed()
|
||||||
|
|
||||||
|
def validate_enrollment(self):
|
||||||
|
enrollment = frappe.db.exists("LMS Enrollment", {"course": self.course, "member": self.owner})
|
||||||
|
if not enrollment:
|
||||||
|
frappe.throw(_("You must be enrolled in the course to submit a review"))
|
||||||
|
|
||||||
def validate_if_already_reviewed(self):
|
def validate_if_already_reviewed(self):
|
||||||
if frappe.db.exists("LMS Course Review", {"course": self.course, "owner": self.owner}):
|
if frappe.db.exists("LMS Course Review", {"course": self.course, "owner": self.owner}):
|
||||||
frappe.throw(frappe._("You have already reviewed this course"))
|
frappe.throw(_("You have already reviewed this course"))
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
|
|||||||
@@ -19,18 +19,11 @@
|
|||||||
"purchased_certificate",
|
"purchased_certificate",
|
||||||
"certificate",
|
"certificate",
|
||||||
"section_break_8",
|
"section_break_8",
|
||||||
"batch_old",
|
|
||||||
"column_break_12",
|
"column_break_12",
|
||||||
"member_type",
|
"member_type",
|
||||||
"role"
|
"role"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
|
||||||
"fieldname": "batch_old",
|
|
||||||
"fieldtype": "Link",
|
|
||||||
"label": "Batch Old",
|
|
||||||
"options": "LMS Batch Old"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"fieldname": "member",
|
"fieldname": "member",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
@@ -141,7 +134,7 @@
|
|||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-12-14 20:59:37.166118",
|
"modified": "2025-12-15 21:27:30.733483",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Enrollment",
|
"name": "LMS Enrollment",
|
||||||
|
|||||||
@@ -9,56 +9,43 @@ from frappe.utils import ceil
|
|||||||
|
|
||||||
class LMSEnrollment(Document):
|
class LMSEnrollment(Document):
|
||||||
def validate(self):
|
def validate(self):
|
||||||
self.validate_membership_in_same_batch()
|
self.validate_course_enrollment_eligibility()
|
||||||
self.validate_membership_in_different_batch_same_course()
|
|
||||||
|
|
||||||
def on_update(self):
|
def on_update(self):
|
||||||
update_program_progress(self.member)
|
update_program_progress(self.member)
|
||||||
|
|
||||||
def validate_membership_in_same_batch(self):
|
def validate_course_enrollment_eligibility(self):
|
||||||
filters = {"member": self.member, "course": self.course, "name": ["!=", self.name]}
|
course_details = frappe.db.get_value(
|
||||||
if self.batch_old:
|
"LMS Course",
|
||||||
filters["batch_old"] = self.batch_old
|
self.course,
|
||||||
previous_membership = frappe.db.get_value(
|
["published", "disable_self_learning", "paid_course", "paid_certificate"],
|
||||||
"LMS Enrollment", filters, fieldname=["member_type", "member"], as_dict=1
|
as_dict=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if previous_membership:
|
if course_details.disable_self_learning:
|
||||||
member_name = frappe.db.get_value("User", self.member, "full_name")
|
|
||||||
course_title = frappe.db.get_value("LMS Course", self.course, "title")
|
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
_("{0} is already a {1} of the course {2}").format(
|
_(
|
||||||
member_name, previous_membership.member_type, course_title
|
"You cannot enroll in this course as self-learning is disabled. Please contact the Administrator."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def validate_membership_in_different_batch_same_course(self):
|
if not course_details.published:
|
||||||
"""Ensures that a studnet is only part of one batch."""
|
frappe.throw(_("You cannot enroll in an unpublished course."))
|
||||||
# nothing to worry if the member is not a student
|
|
||||||
if self.member_type != "Student":
|
|
||||||
return
|
|
||||||
|
|
||||||
course = frappe.db.get_value("LMS Batch Old", self.batch_old, "course")
|
if course_details.paid_course:
|
||||||
memberships = frappe.get_all(
|
payment = frappe.db.exists(
|
||||||
"LMS Enrollment",
|
"LMS Payment",
|
||||||
filters={
|
{
|
||||||
"member": self.member,
|
"reference_doctype": "LMS Course",
|
||||||
"name": ["!=", self.name],
|
"reference_docname": course,
|
||||||
"member_type": "Student",
|
"member": member,
|
||||||
"course": self.course,
|
"payment_receipt": True,
|
||||||
},
|
},
|
||||||
fields=["batch_old", "member_type", "name"],
|
|
||||||
)
|
|
||||||
|
|
||||||
if memberships:
|
|
||||||
membership = memberships[0]
|
|
||||||
member_name = frappe.db.get_value("User", self.member, "full_name")
|
|
||||||
frappe.throw(
|
|
||||||
_("{0} is already a Student of {1} course through {2} batch").format(
|
|
||||||
member_name, course, membership.batch_old
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not payment:
|
||||||
|
frappe.throw(_("You need to complete the payment for this course before enrolling."))
|
||||||
|
|
||||||
|
|
||||||
def update_program_progress(member):
|
def update_program_progress(member):
|
||||||
programs = frappe.get_all("LMS Program Member", {"member": member}, ["parent", "name"])
|
programs = frappe.get_all("LMS Program Member", {"member": member}, ["parent", "name"])
|
||||||
@@ -77,8 +64,6 @@ def update_program_progress(member):
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def create_membership(course, batch=None, member=None, member_type="Student", role="Member"):
|
def create_membership(course, batch=None, member=None, member_type="Student", role="Member"):
|
||||||
validate_course_enrollment_eligibility(course, member)
|
|
||||||
|
|
||||||
enrollment = frappe.new_doc("LMS Enrollment")
|
enrollment = frappe.new_doc("LMS Enrollment")
|
||||||
enrollment.update(
|
enrollment.update(
|
||||||
{
|
{
|
||||||
@@ -94,42 +79,6 @@ def create_membership(course, batch=None, member=None, member_type="Student", ro
|
|||||||
return enrollment
|
return enrollment
|
||||||
|
|
||||||
|
|
||||||
def validate_course_enrollment_eligibility(course, member):
|
|
||||||
if not member:
|
|
||||||
member = frappe.session.user
|
|
||||||
|
|
||||||
course_details = frappe.db.get_value(
|
|
||||||
"LMS Course",
|
|
||||||
course,
|
|
||||||
["published", "disable_self_learning", "paid_course", "paid_certificate"],
|
|
||||||
as_dict=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if course_details.disable_self_learning:
|
|
||||||
frappe.throw(
|
|
||||||
_(
|
|
||||||
"You cannot enroll in this course as self-learning is disabled. Please contact the Administrator."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not course_details.published:
|
|
||||||
frappe.throw(_("You cannot enroll in an unpublished course."))
|
|
||||||
|
|
||||||
if course_details.paid_course:
|
|
||||||
payment = frappe.db.exists(
|
|
||||||
"LMS Payment",
|
|
||||||
{
|
|
||||||
"reference_doctype": "LMS Course",
|
|
||||||
"reference_docname": course,
|
|
||||||
"member": member,
|
|
||||||
"payment_receipt": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if not payment:
|
|
||||||
frappe.throw(_("You need to complete the payment for this course before enrolling."))
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def update_current_membership(batch, course, member):
|
def update_current_membership(batch, course, member):
|
||||||
all_memberships = frappe.get_all("LMS Enrollment", {"member": member, "course": course})
|
all_memberships = frappe.get_all("LMS Enrollment", {"member": member, "course": course})
|
||||||
|
|||||||
@@ -5,19 +5,6 @@ import unittest
|
|||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
|
|
||||||
from lms.lms.doctype.lms_course.test_lms_course import new_course, new_user
|
|
||||||
|
|
||||||
|
|
||||||
class TestLMSEnrollment(unittest.TestCase):
|
class TestLMSEnrollment(unittest.TestCase):
|
||||||
def test_membership(self):
|
pass
|
||||||
course = new_course("Test Enrollment")
|
|
||||||
enrollment = frappe.new_doc("LMS Enrollment")
|
|
||||||
enrollment.course = course.name
|
|
||||||
enrollment.member = frappe.session.user
|
|
||||||
|
|
||||||
enrollment.save()
|
|
||||||
|
|
||||||
self.assertEqual(enrollment.course, course.name)
|
|
||||||
self.assertEqual(enrollment.member, "Administrator")
|
|
||||||
frappe.db.delete("LMS Enrollment", enrollment.name)
|
|
||||||
frappe.db.delete("LMS Course", course.name)
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
// Copyright (c) 2021, FOSS United and contributors
|
|
||||||
// For license information, please see license.txt
|
|
||||||
|
|
||||||
frappe.ui.form.on("LMS Exercise", {
|
|
||||||
// refresh: function(frm) {
|
|
||||||
// }
|
|
||||||
});
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
{
|
|
||||||
"actions": [],
|
|
||||||
"allow_rename": 1,
|
|
||||||
"creation": "2021-05-19 17:43:39.923430",
|
|
||||||
"doctype": "DocType",
|
|
||||||
"editable_grid": 1,
|
|
||||||
"engine": "InnoDB",
|
|
||||||
"field_order": [
|
|
||||||
"title",
|
|
||||||
"description",
|
|
||||||
"code",
|
|
||||||
"answer",
|
|
||||||
"column_break_4",
|
|
||||||
"course",
|
|
||||||
"hints",
|
|
||||||
"tests",
|
|
||||||
"image",
|
|
||||||
"lesson",
|
|
||||||
"index_",
|
|
||||||
"index_label"
|
|
||||||
],
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldname": "title",
|
|
||||||
"fieldtype": "Data",
|
|
||||||
"in_list_view": 1,
|
|
||||||
"label": "Title"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "course",
|
|
||||||
"fieldtype": "Link",
|
|
||||||
"in_list_view": 1,
|
|
||||||
"label": "Course",
|
|
||||||
"options": "LMS Course"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": 4,
|
|
||||||
"fieldname": "description",
|
|
||||||
"fieldtype": "Small Text",
|
|
||||||
"in_list_view": 1,
|
|
||||||
"label": "Description"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": 4,
|
|
||||||
"fieldname": "answer",
|
|
||||||
"fieldtype": "Code",
|
|
||||||
"label": "Answer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "tests",
|
|
||||||
"fieldtype": "Code",
|
|
||||||
"label": "Tests"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "column_break_4",
|
|
||||||
"fieldtype": "Column Break"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": 4,
|
|
||||||
"fieldname": "hints",
|
|
||||||
"fieldtype": "Small Text",
|
|
||||||
"label": "Hints"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": 4,
|
|
||||||
"fieldname": "code",
|
|
||||||
"fieldtype": "Code",
|
|
||||||
"label": "Code"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "image",
|
|
||||||
"fieldtype": "Code",
|
|
||||||
"label": "Image",
|
|
||||||
"read_only": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "lesson",
|
|
||||||
"fieldtype": "Link",
|
|
||||||
"in_list_view": 1,
|
|
||||||
"label": "Lesson",
|
|
||||||
"options": "Course Lesson"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "index_",
|
|
||||||
"fieldtype": "Int",
|
|
||||||
"label": "Index",
|
|
||||||
"read_only": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "index_label",
|
|
||||||
"fieldtype": "Data",
|
|
||||||
"in_list_view": 1,
|
|
||||||
"label": "Index Label",
|
|
||||||
"read_only": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"index_web_pages_for_search": 1,
|
|
||||||
"links": [],
|
|
||||||
"modified": "2021-09-29 15:27:55.585874",
|
|
||||||
"modified_by": "Administrator",
|
|
||||||
"module": "LMS",
|
|
||||||
"name": "LMS Exercise",
|
|
||||||
"owner": "Administrator",
|
|
||||||
"permissions": [
|
|
||||||
{
|
|
||||||
"create": 1,
|
|
||||||
"delete": 1,
|
|
||||||
"email": 1,
|
|
||||||
"export": 1,
|
|
||||||
"print": 1,
|
|
||||||
"read": 1,
|
|
||||||
"report": 1,
|
|
||||||
"role": "System Manager",
|
|
||||||
"share": 1,
|
|
||||||
"write": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"search_fields": "title",
|
|
||||||
"sort_field": "index_label",
|
|
||||||
"sort_order": "ASC",
|
|
||||||
"title_field": "title",
|
|
||||||
"track_changes": 1
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# Copyright (c) 2021, FOSS United and contributors
|
|
||||||
# For license information, please see license.txt
|
|
||||||
|
|
||||||
import frappe
|
|
||||||
from frappe.model.document import Document
|
|
||||||
|
|
||||||
from lms.lms.utils import get_membership
|
|
||||||
|
|
||||||
|
|
||||||
class LMSExercise(Document):
|
|
||||||
def get_user_submission(self):
|
|
||||||
"""Returns the latest submission for this user."""
|
|
||||||
user = frappe.session.user
|
|
||||||
if not user or user == "Guest":
|
|
||||||
return
|
|
||||||
|
|
||||||
result = frappe.get_all(
|
|
||||||
"Exercise Submission",
|
|
||||||
fields="*",
|
|
||||||
filters={"owner": user, "exercise": self.name},
|
|
||||||
order_by="creation desc",
|
|
||||||
page_length=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result:
|
|
||||||
return result[0]
|
|
||||||
|
|
||||||
def submit(self, code):
|
|
||||||
"""Submits the given code as solution to exercise."""
|
|
||||||
user = frappe.session.user
|
|
||||||
if not user or user == "Guest":
|
|
||||||
return
|
|
||||||
|
|
||||||
old_submission = self.get_user_submission()
|
|
||||||
if old_submission and old_submission.solution == code:
|
|
||||||
return old_submission
|
|
||||||
|
|
||||||
member = get_membership(self.course, frappe.session.user)
|
|
||||||
|
|
||||||
doc = frappe.get_doc(
|
|
||||||
doctype="Exercise Submission",
|
|
||||||
exercise=self.name,
|
|
||||||
exercise_title=self.title,
|
|
||||||
course=self.course,
|
|
||||||
lesson=self.lesson,
|
|
||||||
batch=member.batch_old,
|
|
||||||
solution=code,
|
|
||||||
member=member.name,
|
|
||||||
)
|
|
||||||
doc.insert(ignore_permissions=True)
|
|
||||||
|
|
||||||
return doc
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# Copyright (c) 2021, FOSS United and Contributors
|
|
||||||
# See license.txt
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
# import frappe
|
|
||||||
|
|
||||||
|
|
||||||
class TestLMSExercise(unittest.TestCase):
|
|
||||||
pass
|
|
||||||
@@ -2,16 +2,246 @@ import unittest
|
|||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
|
|
||||||
from .utils import slugify
|
from lms.lms.doctype.lms_certificate.lms_certificate import get_default_certificate_template, is_certified
|
||||||
|
|
||||||
|
from .utils import (
|
||||||
|
get_average_rating,
|
||||||
|
get_chapters,
|
||||||
|
get_instructors,
|
||||||
|
get_lesson_index,
|
||||||
|
get_lesson_url,
|
||||||
|
get_lessons,
|
||||||
|
get_membership,
|
||||||
|
get_reviews,
|
||||||
|
get_tags,
|
||||||
|
has_course_instructor_role,
|
||||||
|
has_evaluator_role,
|
||||||
|
has_moderator_role,
|
||||||
|
has_student_role,
|
||||||
|
is_instructor,
|
||||||
|
slugify,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestUtils(unittest.TestCase):
|
class TestUtils(unittest.TestCase):
|
||||||
def test_simple(self):
|
def setUp(self):
|
||||||
|
self.student1 = self.create_user("student1@example.com", "Ashley", "Smith", ["LMS Student"])
|
||||||
|
self.student2 = self.create_user("student2@example.com", "John", "Doe", ["LMS Student"])
|
||||||
|
self.admin = self.create_user(
|
||||||
|
"frappe@example.com", "Frappe", "Admin", ["Moderator", "Course Creator", "Batch Evaluator"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.create_a_course()
|
||||||
|
self.add_chapters()
|
||||||
|
self.add_lessons()
|
||||||
|
|
||||||
|
self.add_enrollment(self.course.name, self.student1.email)
|
||||||
|
self.add_enrollment(self.course.name, self.student2.email)
|
||||||
|
|
||||||
|
self.add_rating(self.course.name, self.student1.email, 0.8, "Good course")
|
||||||
|
self.add_rating(self.course.name, self.student2.email, 1, "Excellent course")
|
||||||
|
|
||||||
|
self.create_certificate(self.course.name, self.student1.email)
|
||||||
|
|
||||||
|
def create_a_course(self):
|
||||||
|
course = frappe.new_doc("LMS Course")
|
||||||
|
course.title = "Utility Course"
|
||||||
|
course.short_introduction = "A course to test utilities of Frappe Learning"
|
||||||
|
course.description = "This is a detailed description of the Utility Course."
|
||||||
|
course.tags = "Frappe,Learning,Utility"
|
||||||
|
course.published = 1
|
||||||
|
course.append("instructors", {"instructor": "frappe@example.com"})
|
||||||
|
course.save()
|
||||||
|
self.course = course
|
||||||
|
|
||||||
|
def add_chapters(self):
|
||||||
|
chapters = []
|
||||||
|
for i in range(1, 4):
|
||||||
|
chapter = frappe.new_doc("Course Chapter")
|
||||||
|
chapter.course = self.course.name
|
||||||
|
chapter.title = f"Chapter {i}"
|
||||||
|
chapter.save()
|
||||||
|
chapters.append(chapter)
|
||||||
|
|
||||||
|
self.course.reload()
|
||||||
|
for chapter in chapters:
|
||||||
|
self.course.append("chapters", {"chapter": chapter.name})
|
||||||
|
|
||||||
|
self.course.save()
|
||||||
|
|
||||||
|
def add_lessons(self):
|
||||||
|
for chapter in self.course.chapters:
|
||||||
|
chapterDoc = frappe.get_doc("Course Chapter", chapter.chapter)
|
||||||
|
lessons = []
|
||||||
|
for j in range(1, 3):
|
||||||
|
lesson = frappe.new_doc("Course Lesson")
|
||||||
|
lesson.course = self.course.name
|
||||||
|
lesson.chapter = chapter.chapter
|
||||||
|
lesson.title = f"Lesson {j} of {chapter.chapter}"
|
||||||
|
content = '{"time":1765194986690,"blocks":[{"id":"dkLzbW14ds","type":"markdown","data":{"text":"This is a simple content for the current lesson."}},{"id":"KBwuWPc8rV","type":"markdown","data":{"text":""}}],"version":"2.29.0"}'
|
||||||
|
lesson.content = content
|
||||||
|
lesson.save()
|
||||||
|
lessons.append(lesson)
|
||||||
|
|
||||||
|
for lesson in lessons:
|
||||||
|
chapterDoc.append("lessons", {"lesson": lesson.name})
|
||||||
|
chapterDoc.save()
|
||||||
|
|
||||||
|
def create_user(self, email, first_name, last_name, roles):
|
||||||
|
if not frappe.db.exists("User", email):
|
||||||
|
user = frappe.new_doc("User")
|
||||||
|
user.email = email
|
||||||
|
user.first_name = first_name
|
||||||
|
user.last_name = last_name
|
||||||
|
user.user_type = "Website User"
|
||||||
|
for role in roles:
|
||||||
|
user.append("roles", {"role": role})
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
else:
|
||||||
|
return frappe.get_doc("User", email)
|
||||||
|
|
||||||
|
def create_certificate(self, course_name, member):
|
||||||
|
certificate = frappe.new_doc("LMS Certificate")
|
||||||
|
certificate.course = course_name
|
||||||
|
certificate.member = member
|
||||||
|
certificate.issue_date = frappe.utils.nowdate()
|
||||||
|
certificate.template = get_default_certificate_template()
|
||||||
|
certificate.save()
|
||||||
|
return certificate
|
||||||
|
|
||||||
|
def test_simple_slugs(self):
|
||||||
self.assertEqual(slugify("hello-world"), "hello-world")
|
self.assertEqual(slugify("hello-world"), "hello-world")
|
||||||
self.assertEqual(slugify("Hello World"), "hello-world")
|
self.assertEqual(slugify("Hello World"), "hello-world")
|
||||||
self.assertEqual(slugify("Hello, World!"), "hello-world")
|
self.assertEqual(slugify("Hello, World!"), "hello-world")
|
||||||
|
|
||||||
def test_duplicates(self):
|
def test_duplicates_slugs(self):
|
||||||
self.assertEqual(slugify("Hello World", ["hello-world"]), "hello-world-2")
|
self.assertEqual(slugify("Hello World", ["hello-world"]), "hello-world-2")
|
||||||
|
|
||||||
self.assertEqual(slugify("Hello World", ["hello-world", "hello-world-2"]), "hello-world-3")
|
self.assertEqual(slugify("Hello World", ["hello-world", "hello-world-2"]), "hello-world-3")
|
||||||
|
|
||||||
|
def add_enrollment(self, course, member):
|
||||||
|
enrollment = frappe.new_doc("LMS Enrollment")
|
||||||
|
enrollment.course = course
|
||||||
|
enrollment.member = member
|
||||||
|
enrollment.save()
|
||||||
|
|
||||||
|
def test_get_membership(self):
|
||||||
|
membership = get_membership(self.course.name, self.student1.email)
|
||||||
|
self.assertIsNotNone(membership)
|
||||||
|
self.assertEqual(membership.course, self.course.name)
|
||||||
|
self.assertEqual(membership.member, self.student1.email)
|
||||||
|
|
||||||
|
def test_get_chapters(self):
|
||||||
|
chapters = get_chapters(self.course.name)
|
||||||
|
self.assertEqual(len(chapters), len(self.course.chapters))
|
||||||
|
|
||||||
|
for i, chapter in enumerate(chapters, start=1):
|
||||||
|
self.assertEqual(chapter.title, f"Chapter {i}")
|
||||||
|
|
||||||
|
def test_get_lessons(self):
|
||||||
|
lessons = get_lessons(self.course.name)
|
||||||
|
all_lessons = frappe.db.count("Course Lesson", {"course": self.course.name})
|
||||||
|
self.assertEqual(len(lessons), all_lessons)
|
||||||
|
|
||||||
|
for chapter in self.course.chapters:
|
||||||
|
chapter_lessons = [lesson for lesson in lessons if lesson.chapter == chapter.chapter]
|
||||||
|
self.assertEqual(len(chapter_lessons), 2)
|
||||||
|
for j, lesson in enumerate(chapter_lessons, start=1):
|
||||||
|
self.assertEqual(lesson.title, f"Lesson {j} of {chapter.chapter}")
|
||||||
|
self.assertEqual(lesson.number, f"{chapter.idx}-{j}")
|
||||||
|
|
||||||
|
def test_get_tags(self):
|
||||||
|
tags = get_tags(self.course.name)
|
||||||
|
expected_tags = ["Frappe", "Learning", "Utility"]
|
||||||
|
self.assertEqual(set(tags), set(expected_tags))
|
||||||
|
|
||||||
|
def test_get_instructors(self):
|
||||||
|
instructors = get_instructors("LMS Course", self.course.name)
|
||||||
|
self.assertEqual(len(instructors), len(self.course.instructors))
|
||||||
|
self.assertEqual(instructors[0].name, "frappe@example.com")
|
||||||
|
|
||||||
|
def test_get_average_rating(self):
|
||||||
|
average_rating = get_average_rating(self.course.name)
|
||||||
|
self.assertEqual(average_rating, 4.5)
|
||||||
|
|
||||||
|
def add_rating(self, course_name, member, rating, review):
|
||||||
|
frappe.session.user = member
|
||||||
|
review = frappe.new_doc("LMS Course Review")
|
||||||
|
review.course = course_name
|
||||||
|
review.rating = rating
|
||||||
|
review.review = review
|
||||||
|
review.save()
|
||||||
|
frappe.session.user = "Administrator"
|
||||||
|
|
||||||
|
def test_get_reviews(self):
|
||||||
|
reviews = get_reviews(self.course.name)
|
||||||
|
self.assertEqual(len(reviews), 2)
|
||||||
|
|
||||||
|
for review in reviews:
|
||||||
|
if review.rating == 0.8:
|
||||||
|
self.assertEqual(review.member, self.student1.email)
|
||||||
|
self.assertEqual(review.review, "Good course")
|
||||||
|
elif review.rating == 1:
|
||||||
|
self.assertEqual(review.member, self.student2.email)
|
||||||
|
self.assertEqual(review.review, "Excellent course")
|
||||||
|
|
||||||
|
def test_get_lesson_index(self):
|
||||||
|
lessons = get_lessons(self.course.name)
|
||||||
|
for lesson in lessons:
|
||||||
|
self.assertEqual(get_lesson_index(lesson.name), lesson.number)
|
||||||
|
|
||||||
|
def test_get_lesson_url(self):
|
||||||
|
lessons = get_lessons(self.course.name)
|
||||||
|
for lesson in lessons:
|
||||||
|
expected_url = f"/lms/courses/{self.course.name}/learn/{lesson.number}"
|
||||||
|
self.assertEqual(get_lesson_url(self.course.name, lesson.number), expected_url)
|
||||||
|
|
||||||
|
def test_is_instructor(self):
|
||||||
|
frappe.session.user = "frappe@example.com"
|
||||||
|
self.assertTrue(is_instructor(self.course.name))
|
||||||
|
frappe.session.user = "Administrator"
|
||||||
|
self.assertFalse(is_instructor(self.course.name))
|
||||||
|
|
||||||
|
def test_has_course_instructor_role(self):
|
||||||
|
self.assertIsNotNone(has_course_instructor_role("frappe@example.com"))
|
||||||
|
self.assertIsNone(has_course_instructor_role("student1@example.com"))
|
||||||
|
|
||||||
|
def test_has_moderator_role(self):
|
||||||
|
self.assertIsNotNone(has_moderator_role("frappe@example.com"))
|
||||||
|
self.assertIsNone(has_moderator_role("student2@example.com"))
|
||||||
|
|
||||||
|
def test_has_evaluator_role(self):
|
||||||
|
self.assertIsNotNone(has_evaluator_role("frappe@example.com"))
|
||||||
|
self.assertIsNone(has_evaluator_role("student2@example.com"))
|
||||||
|
|
||||||
|
def test_has_student_role(self):
|
||||||
|
self.assertIsNotNone(has_student_role("student1@example.com"))
|
||||||
|
self.assertIsNotNone(has_student_role("student2@example.com"))
|
||||||
|
|
||||||
|
def test_is_certified(self):
|
||||||
|
frappe.session.user = self.student1.email
|
||||||
|
self.assertIsNotNone(is_certified(self.course.name))
|
||||||
|
frappe.session.user = self.student2.email
|
||||||
|
self.assertIsNone(is_certified(self.course.name))
|
||||||
|
frappe.session.user = "Administrator"
|
||||||
|
|
||||||
|
def test_rating_validation(self):
|
||||||
|
student3 = self.create_user("student3@example.com", "Emily", "Cooper", ["LMS Student"])
|
||||||
|
with self.assertRaises(frappe.exceptions.ValidationError):
|
||||||
|
self.add_rating(self.course.name, student3.email, -0.5, "Bad course")
|
||||||
|
frappe.session.user = "Administrator"
|
||||||
|
frappe.delete_doc("User", student3.email)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if frappe.db.exists("LMS Course", self.course.name):
|
||||||
|
frappe.db.delete("LMS Certificate", {"course": self.course.name})
|
||||||
|
frappe.db.delete("LMS Enrollment", {"course": self.course.name})
|
||||||
|
frappe.db.delete("LMS Course Review", {"course": self.course.name})
|
||||||
|
frappe.db.delete("Course Lesson", {"course": self.course.name})
|
||||||
|
frappe.db.delete("Course Chapter", {"course": self.course.name})
|
||||||
|
frappe.db.delete("Course Instructor", {"parent": self.course.name})
|
||||||
|
frappe.delete_doc("LMS Course", self.course.name)
|
||||||
|
|
||||||
|
frappe.delete_doc("User", "student1@example.com")
|
||||||
|
frappe.delete_doc("User", "student2@example.com")
|
||||||
|
frappe.delete_doc("User", "frappe@example.com")
|
||||||
|
|||||||
637
lms/lms/utils.py
637
lms/lms/utils.py
@@ -1,8 +1,6 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import string
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
import requests
|
import requests
|
||||||
@@ -14,7 +12,6 @@ from frappe.rate_limiter import rate_limit
|
|||||||
from frappe.utils import (
|
from frappe.utils import (
|
||||||
add_months,
|
add_months,
|
||||||
cint,
|
cint,
|
||||||
cstr,
|
|
||||||
flt,
|
flt,
|
||||||
fmt_money,
|
fmt_money,
|
||||||
format_datetime,
|
format_datetime,
|
||||||
@@ -27,7 +24,7 @@ from frappe.utils import (
|
|||||||
rounded,
|
rounded,
|
||||||
)
|
)
|
||||||
|
|
||||||
from lms.lms.md import find_macros, markdown_to_html
|
from lms.lms.md import find_macros
|
||||||
|
|
||||||
RE_SLUG_NOTALLOWED = re.compile("[^a-z0-9]+")
|
RE_SLUG_NOTALLOWED = re.compile("[^a-z0-9]+")
|
||||||
|
|
||||||
@@ -83,6 +80,7 @@ def get_membership(course, member=None):
|
|||||||
"current_lesson",
|
"current_lesson",
|
||||||
"progress",
|
"progress",
|
||||||
"member",
|
"member",
|
||||||
|
"course",
|
||||||
"purchased_certificate",
|
"purchased_certificate",
|
||||||
"certificate",
|
"certificate",
|
||||||
],
|
],
|
||||||
@@ -150,11 +148,12 @@ def get_lesson_details(chapter, progress=False):
|
|||||||
"file_type",
|
"file_type",
|
||||||
"instructor_notes",
|
"instructor_notes",
|
||||||
"course",
|
"course",
|
||||||
|
"chapter",
|
||||||
"content",
|
"content",
|
||||||
],
|
],
|
||||||
as_dict=True,
|
as_dict=True,
|
||||||
)
|
)
|
||||||
lesson_details.number = f"{chapter.idx}.{row.idx}"
|
lesson_details.number = f"{chapter.idx}-{row.idx}"
|
||||||
lesson_details.icon = get_lesson_icon(lesson_details.body, lesson_details.content)
|
lesson_details.icon = get_lesson_icon(lesson_details.body, lesson_details.content)
|
||||||
|
|
||||||
if progress:
|
if progress:
|
||||||
@@ -228,16 +227,6 @@ def get_instructors(doctype, docname):
|
|||||||
return instructor_details
|
return instructor_details
|
||||||
|
|
||||||
|
|
||||||
def get_students(course, batch=None):
|
|
||||||
"""Returns (email, full_name, username) of all the students of this batch as a list of dict."""
|
|
||||||
filters = {"course": course, "member_type": "Student"}
|
|
||||||
|
|
||||||
if batch:
|
|
||||||
filters["batch_old"] = batch
|
|
||||||
|
|
||||||
return frappe.get_all("LMS Enrollment", filters, ["member"])
|
|
||||||
|
|
||||||
|
|
||||||
def get_average_rating(course):
|
def get_average_rating(course):
|
||||||
ratings = [review.rating for review in get_reviews(course)]
|
ratings = [review.rating for review in get_reviews(course)]
|
||||||
if not len(ratings):
|
if not len(ratings):
|
||||||
@@ -269,29 +258,6 @@ def get_reviews(course):
|
|||||||
return reviews
|
return reviews
|
||||||
|
|
||||||
|
|
||||||
def get_sorted_reviews(course):
|
|
||||||
rating_count = rating_percent = frappe._dict()
|
|
||||||
keys = ["5.0", "4.0", "3.0", "2.0", "1.0"]
|
|
||||||
for key in keys:
|
|
||||||
rating_count[key] = 0
|
|
||||||
|
|
||||||
reviews = get_reviews(course)
|
|
||||||
for review in reviews:
|
|
||||||
rating_count[cstr(review.rating)] += 1
|
|
||||||
|
|
||||||
for key in keys:
|
|
||||||
rating_percent[key] = rating_count[key] / len(reviews) * 100
|
|
||||||
|
|
||||||
return rating_percent
|
|
||||||
|
|
||||||
|
|
||||||
def is_certified(course):
|
|
||||||
certificate = frappe.get_all("LMS Certificate", {"member": frappe.session.user, "course": course})
|
|
||||||
if len(certificate):
|
|
||||||
return certificate[0].name
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def get_lesson_index(lesson_name):
|
def get_lesson_index(lesson_name):
|
||||||
"""Returns the {chapter_index}.{lesson_index} for the lesson."""
|
"""Returns the {chapter_index}.{lesson_index} for the lesson."""
|
||||||
lesson = frappe.db.get_value("Lesson Reference", {"lesson": lesson_name}, ["idx", "parent"], as_dict=True)
|
lesson = frappe.db.get_value("Lesson Reference", {"lesson": lesson_name}, ["idx", "parent"], as_dict=True)
|
||||||
@@ -311,14 +277,6 @@ def get_lesson_url(course, lesson_number):
|
|||||||
return f"/lms/courses/{course}/learn/{lesson_number}"
|
return f"/lms/courses/{course}/learn/{lesson_number}"
|
||||||
|
|
||||||
|
|
||||||
def get_batch(course, batch_name):
|
|
||||||
return frappe.get_all("LMS Batch Old", {"name": batch_name, "course": course})
|
|
||||||
|
|
||||||
|
|
||||||
def get_slugified_chapter_title(chapter):
|
|
||||||
return slugify(chapter)
|
|
||||||
|
|
||||||
|
|
||||||
def get_progress(course, lesson, member=None):
|
def get_progress(course, lesson, member=None):
|
||||||
if not member:
|
if not member:
|
||||||
member = frappe.session.user
|
member = frappe.session.user
|
||||||
@@ -330,52 +288,6 @@ def get_progress(course, lesson, member=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def render_html(lesson):
|
|
||||||
youtube = lesson.youtube
|
|
||||||
quiz_id = lesson.quiz_id
|
|
||||||
body = lesson.body
|
|
||||||
|
|
||||||
if youtube and "/" in youtube:
|
|
||||||
youtube = youtube.split("/")[-1]
|
|
||||||
|
|
||||||
quiz_id = "{{ Quiz('" + quiz_id + "') }}" if quiz_id else ""
|
|
||||||
youtube = "{{ YouTubeVideo('" + youtube + "') }}" if youtube else ""
|
|
||||||
text = youtube + body + quiz_id
|
|
||||||
|
|
||||||
if lesson.question:
|
|
||||||
assignment = "{{ Assignment('" + lesson.question + "-" + lesson.file_type + "') }}"
|
|
||||||
text = text + assignment
|
|
||||||
|
|
||||||
return markdown_to_html(text)
|
|
||||||
|
|
||||||
|
|
||||||
def is_mentor(course, email):
|
|
||||||
"""Checks if given user is a mentor for this course."""
|
|
||||||
if not email:
|
|
||||||
return False
|
|
||||||
return frappe.db.count("LMS Course Mentor Mapping", {"course": course, "mentor": email})
|
|
||||||
|
|
||||||
|
|
||||||
def get_mentors(course):
|
|
||||||
"""Returns the list of all mentors for this course."""
|
|
||||||
course_mentors = []
|
|
||||||
mentors = frappe.get_all("LMS Course Mentor Mapping", {"course": course}, ["mentor"])
|
|
||||||
for mentor in mentors:
|
|
||||||
member = frappe.db.get_value("User", mentor.mentor, ["name", "username", "full_name", "user_image"])
|
|
||||||
member.batch_count = frappe.db.count(
|
|
||||||
"LMS Enrollment", {"member": member.name, "member_type": "Mentor"}
|
|
||||||
)
|
|
||||||
course_mentors.append(member)
|
|
||||||
return course_mentors
|
|
||||||
|
|
||||||
|
|
||||||
def is_eligible_to_review(course):
|
|
||||||
"""Checks if user is eligible to review the course"""
|
|
||||||
if frappe.db.count("LMS Course Review", {"course": course, "owner": frappe.session.user}):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def get_course_progress(course, member=None):
|
def get_course_progress(course, member=None):
|
||||||
"""Returns the course progress of the session user"""
|
"""Returns the course progress of the session user"""
|
||||||
lesson_count = get_lessons(course, get_details=False)
|
lesson_count = get_lessons(course, get_details=False)
|
||||||
@@ -389,20 +301,6 @@ def get_course_progress(course, member=None):
|
|||||||
return flt(((completed_lessons / lesson_count) * 100), precision)
|
return flt(((completed_lessons / lesson_count) * 100), precision)
|
||||||
|
|
||||||
|
|
||||||
def get_initial_members(course):
|
|
||||||
members = frappe.get_all("LMS Enrollment", {"course": course}, ["member"], limit=3)
|
|
||||||
|
|
||||||
member_details = []
|
|
||||||
for member in members:
|
|
||||||
member_details.append(
|
|
||||||
frappe.db.get_value(
|
|
||||||
"User", member.member, ["name", "username", "full_name", "user_image"], as_dict=True
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return member_details
|
|
||||||
|
|
||||||
|
|
||||||
def is_instructor(course):
|
def is_instructor(course):
|
||||||
instructors = get_instructors("LMS Course", course)
|
instructors = get_instructors("LMS Course", course)
|
||||||
for instructor in instructors:
|
for instructor in instructors:
|
||||||
@@ -411,57 +309,6 @@ def is_instructor(course):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def convert_number_to_character(number):
|
|
||||||
return string.ascii_uppercase[number]
|
|
||||||
|
|
||||||
|
|
||||||
def get_signup_optin_checks():
|
|
||||||
mapper = frappe._dict(
|
|
||||||
{
|
|
||||||
"terms_of_use": {"page_name": "terms_page", "title": _("Terms of Use")},
|
|
||||||
"privacy_policy": {"page_name": "privacy_policy_page", "title": _("Privacy Policy")},
|
|
||||||
"cookie_policy": {"page_name": "cookie_policy_page", "title": _("Cookie Policy")},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
checks = ["terms_of_use", "privacy_policy", "cookie_policy"]
|
|
||||||
links = []
|
|
||||||
|
|
||||||
for check in checks:
|
|
||||||
if frappe.db.get_single_value("LMS Settings", check):
|
|
||||||
page = frappe.db.get_single_value("LMS Settings", mapper[check].get("page_name"))
|
|
||||||
route = frappe.db.get_value("Web Page", page, "route")
|
|
||||||
links.append("<a href='/" + route + "'>" + mapper[check].get("title") + "</a>")
|
|
||||||
|
|
||||||
return (", ").join(links)
|
|
||||||
|
|
||||||
|
|
||||||
def format_amount(amount, currency):
|
|
||||||
amount_reduced = amount / 1000
|
|
||||||
if amount_reduced < 1:
|
|
||||||
return fmt_money(amount, 0, currency)
|
|
||||||
precision = 0 if amount % 1000 == 0 else 1
|
|
||||||
return _("{0}k").format(fmt_money(amount_reduced, precision, currency))
|
|
||||||
|
|
||||||
|
|
||||||
def format_number(number):
|
|
||||||
number_reduced = number / 1000
|
|
||||||
if number_reduced < 1:
|
|
||||||
return number
|
|
||||||
return f"{frappe.utils.flt(number_reduced, 1)}k"
|
|
||||||
|
|
||||||
|
|
||||||
def first_lesson_exists(course):
|
|
||||||
first_chapter = frappe.db.get_value("Chapter Reference", {"parent": course, "idx": 1}, "name")
|
|
||||||
if not first_chapter:
|
|
||||||
return False
|
|
||||||
|
|
||||||
first_lesson = frappe.db.get_value("Lesson Reference", {"parent": first_chapter, "idx": 1}, "name")
|
|
||||||
if not first_lesson:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def has_course_instructor_role(member=None):
|
def has_course_instructor_role(member=None):
|
||||||
return frappe.db.get_value(
|
return frappe.db.get_value(
|
||||||
"Has Role",
|
"Has Role",
|
||||||
@@ -470,33 +317,6 @@ def has_course_instructor_role(member=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def can_create_courses(course, member=None):
|
|
||||||
if not member:
|
|
||||||
member = frappe.session.user
|
|
||||||
|
|
||||||
instructors = frappe.get_all(
|
|
||||||
"Course Instructor",
|
|
||||||
{
|
|
||||||
"parent": course,
|
|
||||||
},
|
|
||||||
pluck="instructor",
|
|
||||||
)
|
|
||||||
|
|
||||||
if frappe.session.user == "Guest":
|
|
||||||
return False
|
|
||||||
|
|
||||||
if has_moderator_role(member):
|
|
||||||
return True
|
|
||||||
|
|
||||||
if has_course_instructor_role(member) and member in instructors:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if not course and has_course_instructor_role(member):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def can_create_batches(member=None):
|
def can_create_batches(member=None):
|
||||||
if not member:
|
if not member:
|
||||||
member = frappe.session.user
|
member = frappe.session.user
|
||||||
@@ -702,44 +522,6 @@ def get_lesson_count(course):
|
|||||||
return lesson_count
|
return lesson_count
|
||||||
|
|
||||||
|
|
||||||
def get_all_memberships(member):
|
|
||||||
return frappe.get_all(
|
|
||||||
"LMS Enrollment",
|
|
||||||
{"member": member},
|
|
||||||
["name", "course", "batch_old", "current_lesson", "member_type", "progress"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_filtered_membership(course, memberships):
|
|
||||||
current_membership = list(filter(lambda x: x.course == course, memberships))
|
|
||||||
return current_membership[0] if len(current_membership) else None
|
|
||||||
|
|
||||||
|
|
||||||
def show_start_learing_cta(course, membership):
|
|
||||||
if course.disable_self_learning or course.upcoming:
|
|
||||||
return False
|
|
||||||
if is_instructor(course.name):
|
|
||||||
return False
|
|
||||||
if course.status != "Approved":
|
|
||||||
return False
|
|
||||||
if not has_lessons(course):
|
|
||||||
return False
|
|
||||||
if not membership:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def has_lessons(course):
|
|
||||||
lesson_exists = False
|
|
||||||
chapter_exists = frappe.db.get_value(
|
|
||||||
"Chapter Reference", {"parent": course.name}, ["name", "chapter"], as_dict=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if chapter_exists:
|
|
||||||
lesson_exists = frappe.db.exists("Lesson Reference", {"parent": chapter_exists.chapter})
|
|
||||||
|
|
||||||
return lesson_exists
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
@rate_limit(limit=500, seconds=60 * 60)
|
@rate_limit(limit=500, seconds=60 * 60)
|
||||||
def get_chart_data(
|
def get_chart_data(
|
||||||
@@ -763,7 +545,6 @@ def get_chart_data(
|
|||||||
value_field = chart.value_based_on or "1"
|
value_field = chart.value_based_on or "1"
|
||||||
|
|
||||||
filters = [([chart.document_type, "docstatus", "<", 2])]
|
filters = [([chart.document_type, "docstatus", "<", 2])]
|
||||||
print(chart.filters_json)
|
|
||||||
filters = filters + json.loads(chart.filters_json)
|
filters = filters + json.loads(chart.filters_json)
|
||||||
filters.append([doctype, datefield, ">=", from_date])
|
filters.append([doctype, datefield, ">=", from_date])
|
||||||
filters.append([doctype, datefield, "<=", to_date])
|
filters.append([doctype, datefield, "<=", to_date])
|
||||||
@@ -801,43 +582,6 @@ def get_course_completion_data():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_telemetry_boot_info():
|
|
||||||
POSTHOG_PROJECT_FIELD = "posthog_project_id"
|
|
||||||
POSTHOG_HOST_FIELD = "posthog_host"
|
|
||||||
|
|
||||||
if not frappe.conf.get(POSTHOG_HOST_FIELD) or not frappe.conf.get(POSTHOG_PROJECT_FIELD):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"posthog_host": frappe.conf.get(POSTHOG_HOST_FIELD),
|
|
||||||
"posthog_project_id": frappe.conf.get(POSTHOG_PROJECT_FIELD),
|
|
||||||
"enable_telemetry": 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def is_onboarding_complete():
|
|
||||||
if not has_moderator_role():
|
|
||||||
return {"is_onboarded": True}
|
|
||||||
|
|
||||||
course_created = frappe.db.a_row_exists("LMS Course")
|
|
||||||
chapter_created = frappe.db.a_row_exists("Course Chapter")
|
|
||||||
lesson_created = frappe.db.a_row_exists("Course Lesson")
|
|
||||||
|
|
||||||
if course_created and chapter_created and lesson_created:
|
|
||||||
frappe.db.set_single_value("LMS Settings", "is_onboarding_complete", 1)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"is_onboarded": frappe.db.get_single_value("LMS Settings", "is_onboarding_complete"),
|
|
||||||
"course_created": course_created,
|
|
||||||
"chapter_created": chapter_created,
|
|
||||||
"lesson_created": lesson_created,
|
|
||||||
"first_course": frappe.get_all("LMS Course", limit=1, order_by=None, pluck="name")[0]
|
|
||||||
if course_created
|
|
||||||
else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_evaluator(course, batch=None):
|
def get_evaluator(course, batch=None):
|
||||||
evaluator = None
|
evaluator = None
|
||||||
if batch:
|
if batch:
|
||||||
@@ -958,13 +702,6 @@ def get_current_exchange_rate(source, target="USD"):
|
|||||||
return details["rates"][target]
|
return details["rates"][target]
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def change_currency(amount, currency, country=None):
|
|
||||||
amount = cint(amount)
|
|
||||||
amount, currency = check_multicurrency(amount, currency, country)
|
|
||||||
return fmt_money(amount, 0, currency)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
@rate_limit(limit=500, seconds=60 * 60)
|
@rate_limit(limit=500, seconds=60 * 60)
|
||||||
def get_courses(filters=None, start=0):
|
def get_courses(filters=None, start=0):
|
||||||
@@ -1085,6 +822,7 @@ def get_course_fields():
|
|||||||
"title",
|
"title",
|
||||||
"tags",
|
"tags",
|
||||||
"image",
|
"image",
|
||||||
|
"video_link",
|
||||||
"card_gradient",
|
"card_gradient",
|
||||||
"short_introduction",
|
"short_introduction",
|
||||||
"published",
|
"published",
|
||||||
@@ -1109,35 +847,11 @@ def get_course_fields():
|
|||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
@rate_limit(limit=500, seconds=60 * 60)
|
@rate_limit(limit=500, seconds=60 * 60)
|
||||||
def get_course_details(course):
|
def get_course_details(course):
|
||||||
|
fields = get_course_fields()
|
||||||
course_details = frappe.db.get_value(
|
course_details = frappe.db.get_value(
|
||||||
"LMS Course",
|
"LMS Course",
|
||||||
course,
|
course,
|
||||||
[
|
fields,
|
||||||
"name",
|
|
||||||
"title",
|
|
||||||
"tags",
|
|
||||||
"description",
|
|
||||||
"image",
|
|
||||||
"video_link",
|
|
||||||
"short_introduction",
|
|
||||||
"published",
|
|
||||||
"upcoming",
|
|
||||||
"featured",
|
|
||||||
"disable_self_learning",
|
|
||||||
"published_on",
|
|
||||||
"category",
|
|
||||||
"status",
|
|
||||||
"paid_course",
|
|
||||||
"paid_certificate",
|
|
||||||
"course_price",
|
|
||||||
"currency",
|
|
||||||
"amount_usd",
|
|
||||||
"enable_certification",
|
|
||||||
"lessons",
|
|
||||||
"enrollments",
|
|
||||||
"rating",
|
|
||||||
"card_gradient",
|
|
||||||
],
|
|
||||||
as_dict=1,
|
as_dict=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2351,343 +2065,6 @@ def persona_captured():
|
|||||||
frappe.db.set_single_value("LMS Settings", "persona_captured", 1)
|
frappe.db.set_single_value("LMS Settings", "persona_captured", 1)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_my_courses():
|
|
||||||
my_courses = []
|
|
||||||
if frappe.session.user == "Guest":
|
|
||||||
return my_courses
|
|
||||||
|
|
||||||
courses = get_my_latest_courses()
|
|
||||||
|
|
||||||
if not len(courses):
|
|
||||||
courses = get_featured_home_courses()
|
|
||||||
|
|
||||||
if not len(courses):
|
|
||||||
courses = get_popular_courses()
|
|
||||||
|
|
||||||
for course in courses:
|
|
||||||
my_courses.append(get_course_details(course))
|
|
||||||
|
|
||||||
return my_courses
|
|
||||||
|
|
||||||
|
|
||||||
def get_my_latest_courses():
|
|
||||||
return frappe.get_all(
|
|
||||||
"LMS Enrollment",
|
|
||||||
{
|
|
||||||
"member": frappe.session.user,
|
|
||||||
},
|
|
||||||
order_by="modified desc",
|
|
||||||
limit=3,
|
|
||||||
pluck="course",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_featured_home_courses():
|
|
||||||
return frappe.get_all(
|
|
||||||
"LMS Course",
|
|
||||||
{"published": 1, "featured": 1},
|
|
||||||
order_by="published_on desc",
|
|
||||||
limit=3,
|
|
||||||
pluck="name",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_popular_courses():
|
|
||||||
return frappe.get_all(
|
|
||||||
"LMS Course",
|
|
||||||
{
|
|
||||||
"published": 1,
|
|
||||||
},
|
|
||||||
order_by="enrollments desc",
|
|
||||||
limit=3,
|
|
||||||
pluck="name",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_my_batches():
|
|
||||||
my_batches = []
|
|
||||||
if frappe.session.user == "Guest":
|
|
||||||
return my_batches
|
|
||||||
|
|
||||||
batches = get_my_latest_batches()
|
|
||||||
|
|
||||||
if not len(batches):
|
|
||||||
batches = get_upcoming_batches()
|
|
||||||
|
|
||||||
for batch in batches:
|
|
||||||
batch_details = get_batch_details(batch)
|
|
||||||
if batch_details:
|
|
||||||
my_batches.append(batch_details)
|
|
||||||
|
|
||||||
return my_batches
|
|
||||||
|
|
||||||
|
|
||||||
def get_my_latest_batches():
|
|
||||||
return frappe.get_all(
|
|
||||||
"LMS Batch Enrollment",
|
|
||||||
{
|
|
||||||
"member": frappe.session.user,
|
|
||||||
},
|
|
||||||
order_by="creation desc",
|
|
||||||
limit=4,
|
|
||||||
pluck="batch",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_upcoming_batches():
|
|
||||||
return frappe.get_all(
|
|
||||||
"LMS Batch",
|
|
||||||
{
|
|
||||||
"published": 1,
|
|
||||||
"start_date": [">=", getdate()],
|
|
||||||
},
|
|
||||||
order_by="start_date asc",
|
|
||||||
limit=4,
|
|
||||||
pluck="name",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_my_live_classes():
|
|
||||||
my_live_classes = []
|
|
||||||
if frappe.session.user == "Guest":
|
|
||||||
return my_live_classes
|
|
||||||
|
|
||||||
batches = frappe.get_all(
|
|
||||||
"LMS Batch Enrollment",
|
|
||||||
{
|
|
||||||
"member": frappe.session.user,
|
|
||||||
},
|
|
||||||
order_by="creation desc",
|
|
||||||
pluck="batch",
|
|
||||||
)
|
|
||||||
|
|
||||||
live_class_details = frappe.get_all(
|
|
||||||
"LMS Live Class",
|
|
||||||
filters={
|
|
||||||
"date": [">=", getdate()],
|
|
||||||
"batch_name": ["in", batches],
|
|
||||||
},
|
|
||||||
fields=[
|
|
||||||
"name",
|
|
||||||
"title",
|
|
||||||
"description",
|
|
||||||
"time",
|
|
||||||
"date",
|
|
||||||
"duration",
|
|
||||||
"attendees",
|
|
||||||
"start_url",
|
|
||||||
"join_url",
|
|
||||||
"owner",
|
|
||||||
],
|
|
||||||
limit=2,
|
|
||||||
order_by="date",
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(live_class_details):
|
|
||||||
for live_class in live_class_details:
|
|
||||||
live_class.course_title = frappe.db.get_value("LMS Course", live_class.course, "title")
|
|
||||||
|
|
||||||
my_live_classes.append(live_class)
|
|
||||||
|
|
||||||
return my_live_classes
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_created_courses():
|
|
||||||
created_courses = []
|
|
||||||
if frappe.session.user == "Guest":
|
|
||||||
return created_courses
|
|
||||||
|
|
||||||
CourseInstructor = frappe.qb.DocType("Course Instructor")
|
|
||||||
Course = frappe.qb.DocType("LMS Course")
|
|
||||||
|
|
||||||
query = (
|
|
||||||
frappe.qb.from_(CourseInstructor)
|
|
||||||
.join(Course)
|
|
||||||
.on(CourseInstructor.parent == Course.name)
|
|
||||||
.select(Course.name)
|
|
||||||
.where(CourseInstructor.instructor == frappe.session.user)
|
|
||||||
.orderby(Course.published_on, order=frappe.qb.desc)
|
|
||||||
.limit(3)
|
|
||||||
)
|
|
||||||
|
|
||||||
results = query.run(as_dict=True)
|
|
||||||
courses = [row["name"] for row in results]
|
|
||||||
|
|
||||||
for course in courses:
|
|
||||||
course_details = get_course_details(course)
|
|
||||||
created_courses.append(course_details)
|
|
||||||
|
|
||||||
return created_courses
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_created_batches():
|
|
||||||
created_batches = []
|
|
||||||
if frappe.session.user == "Guest":
|
|
||||||
return created_batches
|
|
||||||
|
|
||||||
CourseInstructor = frappe.qb.DocType("Course Instructor")
|
|
||||||
Batch = frappe.qb.DocType("LMS Batch")
|
|
||||||
|
|
||||||
query = (
|
|
||||||
frappe.qb.from_(CourseInstructor)
|
|
||||||
.join(Batch)
|
|
||||||
.on(CourseInstructor.parent == Batch.name)
|
|
||||||
.select(Batch.name)
|
|
||||||
.where(CourseInstructor.instructor == frappe.session.user)
|
|
||||||
.where(Batch.start_date >= getdate())
|
|
||||||
.orderby(Batch.start_date, order=frappe.qb.asc)
|
|
||||||
.limit(4)
|
|
||||||
)
|
|
||||||
|
|
||||||
results = query.run(as_dict=True)
|
|
||||||
batches = [row["name"] for row in results]
|
|
||||||
|
|
||||||
for batch in batches:
|
|
||||||
batch_details = get_batch_details(batch)
|
|
||||||
created_batches.append(batch_details)
|
|
||||||
|
|
||||||
return created_batches
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_admin_live_classes():
|
|
||||||
if frappe.session.user == "Guest":
|
|
||||||
return []
|
|
||||||
|
|
||||||
CourseInstructor = frappe.qb.DocType("Course Instructor")
|
|
||||||
LMSLiveClass = frappe.qb.DocType("LMS Live Class")
|
|
||||||
|
|
||||||
query = (
|
|
||||||
frappe.qb.from_(CourseInstructor)
|
|
||||||
.join(LMSLiveClass)
|
|
||||||
.on(CourseInstructor.parent == LMSLiveClass.batch_name)
|
|
||||||
.select(
|
|
||||||
LMSLiveClass.name,
|
|
||||||
LMSLiveClass.title,
|
|
||||||
LMSLiveClass.description,
|
|
||||||
LMSLiveClass.time,
|
|
||||||
LMSLiveClass.date,
|
|
||||||
LMSLiveClass.duration,
|
|
||||||
LMSLiveClass.attendees,
|
|
||||||
LMSLiveClass.start_url,
|
|
||||||
LMSLiveClass.join_url,
|
|
||||||
LMSLiveClass.owner,
|
|
||||||
)
|
|
||||||
.where(CourseInstructor.instructor == frappe.session.user)
|
|
||||||
.where(LMSLiveClass.date >= getdate())
|
|
||||||
.orderby(LMSLiveClass.date, order=frappe.qb.asc)
|
|
||||||
.limit(4)
|
|
||||||
)
|
|
||||||
results = query.run(as_dict=True)
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_admin_evals():
|
|
||||||
if frappe.session.user == "Guest":
|
|
||||||
return []
|
|
||||||
|
|
||||||
evals = frappe.get_all(
|
|
||||||
"LMS Certificate Request",
|
|
||||||
{
|
|
||||||
"evaluator": frappe.session.user,
|
|
||||||
"date": [">=", getdate()],
|
|
||||||
},
|
|
||||||
[
|
|
||||||
"name",
|
|
||||||
"date",
|
|
||||||
"start_time",
|
|
||||||
"course",
|
|
||||||
"evaluator",
|
|
||||||
"google_meet_link",
|
|
||||||
"member",
|
|
||||||
"member_name",
|
|
||||||
],
|
|
||||||
limit=4,
|
|
||||||
order_by="date asc",
|
|
||||||
)
|
|
||||||
|
|
||||||
for evaluation in evals:
|
|
||||||
evaluation.course_title = frappe.db.get_value("LMS Course", evaluation.course, "title")
|
|
||||||
|
|
||||||
return evals
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_activity_dates(user):
|
|
||||||
doctypes = [
|
|
||||||
"LMS Course Progress",
|
|
||||||
"LMS Quiz Submission",
|
|
||||||
"LMS Assignment Submission",
|
|
||||||
"LMS Programming Exercise Submission",
|
|
||||||
]
|
|
||||||
|
|
||||||
all_dates = []
|
|
||||||
for dt in doctypes:
|
|
||||||
all_dates.extend(frappe.get_all(dt, {"member": user}, pluck="creation"))
|
|
||||||
|
|
||||||
return sorted({d.date() if hasattr(d, "date") else d for d in all_dates})
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_streaks(all_dates):
|
|
||||||
streak = 0
|
|
||||||
longest_streak = 0
|
|
||||||
prev_day = None
|
|
||||||
|
|
||||||
for d in all_dates:
|
|
||||||
if d.weekday() in (5, 6):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if prev_day:
|
|
||||||
expected = prev_day + timedelta(days=1)
|
|
||||||
while expected.weekday() in (5, 6):
|
|
||||||
expected += timedelta(days=1)
|
|
||||||
|
|
||||||
streak = streak + 1 if d == expected else 1
|
|
||||||
else:
|
|
||||||
streak = 1
|
|
||||||
|
|
||||||
longest_streak = max(longest_streak, streak)
|
|
||||||
prev_day = d
|
|
||||||
|
|
||||||
return streak, longest_streak
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_current_streak(all_dates, streak):
|
|
||||||
if not all_dates:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
last_date = all_dates[-1]
|
|
||||||
today = getdate()
|
|
||||||
|
|
||||||
ref_day = today
|
|
||||||
while ref_day.weekday() in (5, 6):
|
|
||||||
ref_day -= timedelta(days=1)
|
|
||||||
|
|
||||||
if last_date == ref_day or last_date == ref_day - timedelta(days=1):
|
|
||||||
return streak
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_streak_info():
|
|
||||||
if frappe.session.user == "Guest":
|
|
||||||
return {}
|
|
||||||
|
|
||||||
all_dates = fetch_activity_dates(frappe.session.user)
|
|
||||||
streak, longest_streak = calculate_streaks(all_dates)
|
|
||||||
current_streak = calculate_current_streak(all_dates, streak)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"current_streak": current_streak,
|
|
||||||
"longest_streak": longest_streak,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def validate_discussion_reply(doc, method):
|
def validate_discussion_reply(doc, method):
|
||||||
topic = frappe.db.get_value(
|
topic = frappe.db.get_value(
|
||||||
"Discussion Topic", doc.topic, ["reference_doctype", "reference_docname"], as_dict=True
|
"Discussion Topic", doc.topic, ["reference_doctype", "reference_docname"], as_dict=True
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ msgstr ""
|
|||||||
"Project-Id-Version: frappe\n"
|
"Project-Id-Version: frappe\n"
|
||||||
"Report-Msgid-Bugs-To: jannat@frappe.io\n"
|
"Report-Msgid-Bugs-To: jannat@frappe.io\n"
|
||||||
"POT-Creation-Date: 2025-12-05 16:04+0000\n"
|
"POT-Creation-Date: 2025-12-05 16:04+0000\n"
|
||||||
"PO-Revision-Date: 2025-12-09 18:55\n"
|
"PO-Revision-Date: 2025-12-13 20:35\n"
|
||||||
"Last-Translator: jannat@frappe.io\n"
|
"Last-Translator: jannat@frappe.io\n"
|
||||||
"Language-Team: Hungarian\n"
|
"Language-Team: Hungarian\n"
|
||||||
"MIME-Version: 1.0\n"
|
"MIME-Version: 1.0\n"
|
||||||
@@ -1514,7 +1514,7 @@ msgstr ""
|
|||||||
#. Label of the confirmation_email_template (Link) field in DocType 'LMS Batch'
|
#. Label of the confirmation_email_template (Link) field in DocType 'LMS Batch'
|
||||||
#: lms/lms/doctype/lms_batch/lms_batch.json
|
#: lms/lms/doctype/lms_batch/lms_batch.json
|
||||||
msgid "Confirmation Email Template"
|
msgid "Confirmation Email Template"
|
||||||
msgstr ""
|
msgstr "Megerősítő E-mail Sablon"
|
||||||
|
|
||||||
#: lms/lms/doctype/lms_certificate/lms_certificate.py:30
|
#: lms/lms/doctype/lms_certificate/lms_certificate.py:30
|
||||||
msgid "Congratulations on getting certified!"
|
msgid "Congratulations on getting certified!"
|
||||||
@@ -4476,7 +4476,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: frontend/src/pages/BatchForm.vue:292 frontend/src/pages/CourseForm.vue:298
|
#: frontend/src/pages/BatchForm.vue:292 frontend/src/pages/CourseForm.vue:298
|
||||||
msgid "Meta Tags"
|
msgid "Meta Tags"
|
||||||
msgstr ""
|
msgstr "Meta Címkék"
|
||||||
|
|
||||||
#: lms/lms/api.py:1510
|
#: lms/lms/api.py:1510
|
||||||
msgid "Meta tags should be a list."
|
msgid "Meta tags should be a list."
|
||||||
@@ -4994,7 +4994,7 @@ msgstr ""
|
|||||||
#. Label of the output (Data) field in DocType 'LMS Test Case Submission'
|
#. Label of the output (Data) field in DocType 'LMS Test Case Submission'
|
||||||
#: lms/lms/doctype/lms_test_case_submission/lms_test_case_submission.json
|
#: lms/lms/doctype/lms_test_case_submission/lms_test_case_submission.json
|
||||||
msgid "Output"
|
msgid "Output"
|
||||||
msgstr ""
|
msgstr "Kimenet"
|
||||||
|
|
||||||
#: frontend/src/components/Settings/BadgeForm.vue:216
|
#: frontend/src/components/Settings/BadgeForm.vue:216
|
||||||
#: lms/lms/doctype/lms_badge/lms_badge.js:37
|
#: lms/lms/doctype/lms_badge/lms_badge.js:37
|
||||||
@@ -5213,7 +5213,7 @@ msgstr ""
|
|||||||
#: lms/lms/doctype/lms_coupon/lms_coupon.json
|
#: lms/lms/doctype/lms_coupon/lms_coupon.json
|
||||||
#: lms/lms/doctype/lms_quiz_submission/lms_quiz_submission.json
|
#: lms/lms/doctype/lms_quiz_submission/lms_quiz_submission.json
|
||||||
msgid "Percentage"
|
msgid "Percentage"
|
||||||
msgstr ""
|
msgstr "Százalék"
|
||||||
|
|
||||||
#. Option for the 'Grade Type' (Select) field in DocType 'Education Detail'
|
#. Option for the 'Grade Type' (Select) field in DocType 'Education Detail'
|
||||||
#: lms/lms/doctype/education_detail/education_detail.json
|
#: lms/lms/doctype/education_detail/education_detail.json
|
||||||
@@ -5857,7 +5857,7 @@ msgstr ""
|
|||||||
#: lms/lms/doctype/lms_course/lms_course.json
|
#: lms/lms/doctype/lms_course/lms_course.json
|
||||||
#: lms/lms/doctype/lms_lesson_note/lms_lesson_note.json
|
#: lms/lms/doctype/lms_lesson_note/lms_lesson_note.json
|
||||||
msgid "Red"
|
msgid "Red"
|
||||||
msgstr ""
|
msgstr "Piros"
|
||||||
|
|
||||||
#. Label of the redemption_count (Int) field in DocType 'LMS Coupon'
|
#. Label of the redemption_count (Int) field in DocType 'LMS Coupon'
|
||||||
#: frontend/src/components/Settings/Coupons/CouponList.vue:189
|
#: frontend/src/components/Settings/Coupons/CouponList.vue:189
|
||||||
@@ -5873,7 +5873,7 @@ msgstr ""
|
|||||||
#. Timetable'
|
#. Timetable'
|
||||||
#: lms/lms/doctype/lms_batch_timetable/lms_batch_timetable.json
|
#: lms/lms/doctype/lms_batch_timetable/lms_batch_timetable.json
|
||||||
msgid "Reference DocName"
|
msgid "Reference DocName"
|
||||||
msgstr ""
|
msgstr "Hivatkozás DocNév"
|
||||||
|
|
||||||
#. Label of the reference_doctype (Link) field in DocType 'LMS Batch Timetable'
|
#. Label of the reference_doctype (Link) field in DocType 'LMS Batch Timetable'
|
||||||
#. Label of the reference_doctype (Select) field in DocType 'LMS Coupon Item'
|
#. Label of the reference_doctype (Select) field in DocType 'LMS Coupon Item'
|
||||||
@@ -6532,7 +6532,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: frontend/src/pages/Billing.vue:118
|
#: frontend/src/pages/Billing.vue:118
|
||||||
msgid "State/Province"
|
msgid "State/Province"
|
||||||
msgstr ""
|
msgstr "Állam / Megye"
|
||||||
|
|
||||||
#. Label of the tab_4_tab (Tab Break) field in DocType 'LMS Course'
|
#. Label of the tab_4_tab (Tab Break) field in DocType 'LMS Course'
|
||||||
#. Label of the statistics (Check) field in DocType 'LMS Settings'
|
#. Label of the statistics (Check) field in DocType 'LMS Settings'
|
||||||
@@ -6853,7 +6853,7 @@ msgstr ""
|
|||||||
#: lms/lms/doctype/lms_assignment/lms_assignment.json
|
#: lms/lms/doctype/lms_assignment/lms_assignment.json
|
||||||
#: lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.json
|
#: lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.json
|
||||||
msgid "Text"
|
msgid "Text"
|
||||||
msgstr ""
|
msgstr "Szöveg"
|
||||||
|
|
||||||
#: frontend/src/components/BatchFeedback.vue:6
|
#: frontend/src/components/BatchFeedback.vue:6
|
||||||
msgid "Thank you for providing your feedback."
|
msgid "Thank you for providing your feedback."
|
||||||
@@ -7396,12 +7396,12 @@ msgstr ""
|
|||||||
#. Label of the user_field (Select) field in DocType 'LMS Badge'
|
#. Label of the user_field (Select) field in DocType 'LMS Badge'
|
||||||
#: lms/lms/doctype/lms_badge/lms_badge.json
|
#: lms/lms/doctype/lms_badge/lms_badge.json
|
||||||
msgid "User Field"
|
msgid "User Field"
|
||||||
msgstr ""
|
msgstr "Felhasználói Mező"
|
||||||
|
|
||||||
#. Label of the user_image (Attach Image) field in DocType 'Course Evaluator'
|
#. Label of the user_image (Attach Image) field in DocType 'Course Evaluator'
|
||||||
#: lms/lms/doctype/course_evaluator/course_evaluator.json
|
#: lms/lms/doctype/course_evaluator/course_evaluator.json
|
||||||
msgid "User Image"
|
msgid "User Image"
|
||||||
msgstr ""
|
msgstr "Profilkép"
|
||||||
|
|
||||||
#. Option for the 'Type' (Select) field in DocType 'LMS Question'
|
#. Option for the 'Type' (Select) field in DocType 'LMS Question'
|
||||||
#. Option for the 'Type' (Select) field in DocType 'LMS Quiz Question'
|
#. Option for the 'Type' (Select) field in DocType 'LMS Quiz Question'
|
||||||
@@ -7459,7 +7459,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: frontend/src/pages/Notifications.vue:39
|
#: frontend/src/pages/Notifications.vue:39
|
||||||
msgid "View"
|
msgid "View"
|
||||||
msgstr ""
|
msgstr "Nézet"
|
||||||
|
|
||||||
#: frontend/src/pages/JobDetail.vue:31
|
#: frontend/src/pages/JobDetail.vue:31
|
||||||
msgid "View Applications"
|
msgid "View Applications"
|
||||||
@@ -7958,7 +7958,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: frontend/src/pages/CertifiedParticipants.vue:79
|
#: frontend/src/pages/CertifiedParticipants.vue:79
|
||||||
msgid "certificate"
|
msgid "certificate"
|
||||||
msgstr ""
|
msgstr "tanúsítvány"
|
||||||
|
|
||||||
#: frontend/src/pages/CertifiedParticipants.vue:78
|
#: frontend/src/pages/CertifiedParticipants.vue:78
|
||||||
msgid "certificates"
|
msgid "certificates"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
137
lms/sqlite.py
Normal file
137
lms/sqlite.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe.search.sqlite_search import SQLiteSearch, SQLiteSearchIndexMissingError
|
||||||
|
from frappe.utils import nowdate
|
||||||
|
|
||||||
|
|
||||||
|
class LearningSearch(SQLiteSearch):
|
||||||
|
INDEX_NAME = "learning.db"
|
||||||
|
|
||||||
|
INDEX_SCHEMA = {
|
||||||
|
"metadata_fields": [
|
||||||
|
"owner",
|
||||||
|
"published",
|
||||||
|
"published_on",
|
||||||
|
"start_date",
|
||||||
|
"status",
|
||||||
|
"company_name",
|
||||||
|
"creation",
|
||||||
|
],
|
||||||
|
"tokenizer": "unicode61 remove_diacritics 2 tokenchars '-_'",
|
||||||
|
}
|
||||||
|
|
||||||
|
INDEXABLE_DOCTYPES = {
|
||||||
|
"LMS Course": {
|
||||||
|
"fields": [
|
||||||
|
"name",
|
||||||
|
"title",
|
||||||
|
{"content": "description"},
|
||||||
|
"short_introduction",
|
||||||
|
"published",
|
||||||
|
"category",
|
||||||
|
"owner",
|
||||||
|
{"modified": "published_on"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"LMS Batch": {
|
||||||
|
"fields": [
|
||||||
|
"name",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
{"content": "batch_details"},
|
||||||
|
"published",
|
||||||
|
"category",
|
||||||
|
"owner",
|
||||||
|
{"modified": "start_date"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"Job Opportunity": {
|
||||||
|
"fields": [
|
||||||
|
"name",
|
||||||
|
{"title": "job_title"},
|
||||||
|
{"content": "description"},
|
||||||
|
"owner",
|
||||||
|
"location",
|
||||||
|
"country",
|
||||||
|
"company_name",
|
||||||
|
"status",
|
||||||
|
"creation",
|
||||||
|
{"modified": "creation"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
DOCTYPE_FIELDS = {
|
||||||
|
"LMS Course": [
|
||||||
|
"name",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"short_introduction",
|
||||||
|
"category",
|
||||||
|
"creation",
|
||||||
|
"modified",
|
||||||
|
"owner",
|
||||||
|
],
|
||||||
|
"LMS Batch": [
|
||||||
|
"name",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"batch_details",
|
||||||
|
"category",
|
||||||
|
"creation",
|
||||||
|
"modified",
|
||||||
|
"owner",
|
||||||
|
],
|
||||||
|
"Job Opportunity": [
|
||||||
|
"name",
|
||||||
|
"job_title",
|
||||||
|
"company_name",
|
||||||
|
"description",
|
||||||
|
"creation",
|
||||||
|
"modified",
|
||||||
|
"owner",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def build_index(self):
|
||||||
|
try:
|
||||||
|
super().build_index()
|
||||||
|
except Exception as e:
|
||||||
|
frappe.throw(e)
|
||||||
|
|
||||||
|
def get_search_filters(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@SQLiteSearch.scoring_function
|
||||||
|
def get_doctype_boost(self, row, query, query_words):
|
||||||
|
doctype = row["doctype"]
|
||||||
|
if doctype == "LMS Course":
|
||||||
|
if row["published"]:
|
||||||
|
return 1.3
|
||||||
|
elif doctype == "LMS Batch":
|
||||||
|
if row["published"] and row["start_date"] >= nowdate():
|
||||||
|
return 1.3
|
||||||
|
elif row["published"]:
|
||||||
|
return 1.2
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class LearningSearchIndexMissingError(SQLiteSearchIndexMissingError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def build_index():
|
||||||
|
search = LearningSearch()
|
||||||
|
search.build_index()
|
||||||
|
|
||||||
|
|
||||||
|
def build_index_in_background():
|
||||||
|
if not frappe.cache().get_value("learning_search_indexing_in_progress"):
|
||||||
|
frappe.enqueue(build_index, queue="long")
|
||||||
|
|
||||||
|
|
||||||
|
def build_index_if_not_exists():
|
||||||
|
search = LearningSearch()
|
||||||
|
if not search.index_exists():
|
||||||
|
build_index()
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
<script type="text/javascript" src="/assets/frappe/node_modules/moment/min/moment-with-locales.min.js"></script>
|
|
||||||
<script type="text/javascript" src="/assets/frappe/node_modules/moment-timezone/builds/moment-timezone-with-data.min.js"></script>
|
|
||||||
<script type="text/javascript" src="/assets/frappe/js/frappe/utils/datetime.js"></script>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
// comment_when is failing because of this
|
|
||||||
if (!frappe.sys_defaults) {
|
|
||||||
frappe.sys_defaults = {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script type="text/javascript" src="{{ livecode_url }}/static/livecode.js"></script>
|
|
||||||
|
|
||||||
<script type="text/javascript" src="/assets/mon_school/js/livecode-files.js"></script>
|
|
||||||
|
|
||||||
<template id="livecode-template">
|
|
||||||
<div class="livecode-editor livecode-editor-inline">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-8 col-md-6">
|
|
||||||
<div class="controls">
|
|
||||||
<button class="run">{{ _("Run") }}</button>
|
|
||||||
|
|
||||||
<div class="exercise-controls pull-right">
|
|
||||||
<span style="padding-right: 10px;"><span class="last-submitted human-time" data-timestamp=""></span></span>
|
|
||||||
<button class="submit btn-primary">{{ _("Submit") }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="code-editor">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-8 col-md-6">
|
|
||||||
<div class="code-wrapper">
|
|
||||||
<textarea class="code"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-4 col-md-6 canvas-wrapper">
|
|
||||||
<div class="svg-image" width="300" height="300"></div>
|
|
||||||
<pre class="output"></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
function getLiveCodeOptions() {
|
|
||||||
return {
|
|
||||||
base_url: "{{ livecode_url }}",
|
|
||||||
runtime: "python",
|
|
||||||
files: LIVECODE_FILES, // loaded from livecode-files.js
|
|
||||||
command: ["python", "start.py"],
|
|
||||||
codemirror: true,
|
|
||||||
onMessage: {
|
|
||||||
image: function(editor, msg) {
|
|
||||||
const element = editor.parent.querySelector(".svg-image");
|
|
||||||
element.innerHTML = msg.image;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$(function() {
|
|
||||||
var editorLookup = {};
|
|
||||||
|
|
||||||
$("pre.example, pre.exercise").each((i, e) => {
|
|
||||||
var code = $(e).text();
|
|
||||||
var template = document.querySelector('#livecode-template');
|
|
||||||
var clone = template.content.cloneNode(true);
|
|
||||||
|
|
||||||
$(e)
|
|
||||||
.wrap('<div></div>')
|
|
||||||
.hide()
|
|
||||||
.parent()
|
|
||||||
.append(clone)
|
|
||||||
.find("textarea.code")
|
|
||||||
.val(code);
|
|
||||||
|
|
||||||
if ($(e).hasClass("exercise")) {
|
|
||||||
var last_submitted = $(e).data("last-submitted");
|
|
||||||
if (last_submitted) {
|
|
||||||
$(e).parent().find(".last-submitted")
|
|
||||||
.data("timestamp", last_submitted)
|
|
||||||
.html(__("Submitted {0}", [comment_when(last_submitted)]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$(e).parent().find(".exercise-controls").remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
var editor = new LiveCodeEditor(e.parentElement, {
|
|
||||||
...getLiveCodeOptions(),
|
|
||||||
codemirror: true,
|
|
||||||
onMessage: {
|
|
||||||
image: function(editor, msg) {
|
|
||||||
const canvasElement = editor.parent.querySelector("div.svg-image");
|
|
||||||
canvasElement.innerHTML = msg.image;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$(e).parent().find(".submit").on('click', function() {
|
|
||||||
var name = $(e).data("name");
|
|
||||||
let code = editor.codemirror.doc.getValue();
|
|
||||||
|
|
||||||
frappe.call("lms.lms.api.submit_solution", {
|
|
||||||
"exercise": name,
|
|
||||||
"code": code
|
|
||||||
}).then(r => {
|
|
||||||
if (r.message.name) {
|
|
||||||
frappe.msgprint("Submitted successfully!");
|
|
||||||
|
|
||||||
let d = r.message.creation;
|
|
||||||
$(e).parent().find(".human-time").html(__("Submitted {0}", [comment_when(d)]));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$(".exercise-image").each((i, e) => {
|
|
||||||
var svg = JSON.parse($(e).data("image"));
|
|
||||||
$(e).html(svg);
|
|
||||||
});
|
|
||||||
|
|
||||||
$("pre.exercise").each((i, e) => {
|
|
||||||
var svg = JSON.parse($(e).data("image"));
|
|
||||||
$(e).parent().find(".svg-image").html(svg);
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style type="text/css">
|
|
||||||
.svg-image {
|
|
||||||
border: 5px solid #ddd;
|
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
width: 310px;
|
|
||||||
height: 310px;
|
|
||||||
}
|
|
||||||
.livecode-editor {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.livecode-editor-small .svg-image {
|
|
||||||
border: 5px solid #ddd;
|
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
width: 210px;
|
|
||||||
height: 210px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* work-in-progress styles for showing admonition */
|
|
||||||
.admonition {
|
|
||||||
border: 1px solid #aaa;
|
|
||||||
border-left: .5rem solid #888;
|
|
||||||
border-radius: .3em;
|
|
||||||
font-size: 0.9em;
|
|
||||||
margin: 1.5em 0;
|
|
||||||
padding: 0 0.5em;
|
|
||||||
}
|
|
||||||
.admonition-title {
|
|
||||||
padding: 0.5em 0px;
|
|
||||||
font-weight: bold;
|
|
||||||
padding-top:
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<link rel="stylesheet" href="{{ livecode_url }}/static/codemirror/lib/codemirror.css">
|
|
||||||
|
|
||||||
<script src="{{ livecode_url }}/static/codemirror/lib/codemirror.js"></script>
|
|
||||||
<script src="{{ livecode_url }}/static/codemirror/mode/python/python.js"></script>
|
|
||||||
<script src="{{ livecode_url }}/static/codemirror/keymap/sublime.js"></script>
|
|
||||||
|
|
||||||
<script src="{{ livecode_url }}/static/codemirror/addon/edit/matchbrackets.js"></script>
|
|
||||||
<script src="{{ livecode_url }}/static/codemirror/addon/comment/comment.js"></script>
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
{% if not course.upcoming %}
|
|
||||||
<div class="reviews-parent">
|
|
||||||
{% set reviews = get_reviews(course.name) %}
|
|
||||||
<div class="page-title mb-5"> {{ _("Reviews") }} </div>
|
|
||||||
|
|
||||||
|
|
||||||
{% if avg_rating %}
|
|
||||||
<div class="reviews-header">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="avg-rating">
|
|
||||||
{{ frappe.utils.flt(avg_rating, frappe.get_system_settings("float_precision") or 3) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="avg-rating-stars">
|
|
||||||
<div class="rating">
|
|
||||||
{% for i in [1, 2, 3, 4, 5] %}
|
|
||||||
<svg class="icon icon-lg {% if i <= frappe.utils.ceil(avg_rating) %} star-click {% endif %}" data-rating="{{ i }}">
|
|
||||||
<use href="#icon-star"></use>
|
|
||||||
</svg>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="course-meta"> {{ reviews | length }} {{ _("ratings") }} </div>
|
|
||||||
<!--
|
|
||||||
|
|
||||||
-->
|
|
||||||
|
|
||||||
<div class="mt-5">
|
|
||||||
{% include "lms/templates/reviews_cta.html" %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="vertical-divider"></div>
|
|
||||||
{% set sorted_reviews = get_sorted_reviews(course.name) %}
|
|
||||||
<div>
|
|
||||||
{% for review in sorted_reviews %}
|
|
||||||
<div class="d-flex align-items-center mb-3">
|
|
||||||
<div class="course-meta mr-2">
|
|
||||||
{{ frappe.utils.cint(review) }} {{ _("stars") }}
|
|
||||||
</div>
|
|
||||||
<div class="progress">
|
|
||||||
<div class="progress-bar" role="progressbar" aria-valuenow="{{ sorted_reviews[review] }}"
|
|
||||||
aria-valuemin="0" aria-valuemax="100" style="width:{{ sorted_reviews[review] }}%">
|
|
||||||
<span class="sr-only"> {{ sorted_reviews[review] }} {{ _("Complete") }} </span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="course-meta ml-3"> {{ frappe.utils.cint(sorted_reviews[review]) }}% </div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
|
|
||||||
{% if reviews | length %}
|
|
||||||
<div class="mt-12">
|
|
||||||
{% for review in reviews %}
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="mr-4">
|
|
||||||
{{ widgets.Avatar(member=review.owner_details, avatar_class="avatar-medium") }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<a class="button-links mr-4" href="/lms/users/{{ review.owner_details.username }}">
|
|
||||||
<span class="bold-heading">
|
|
||||||
{{ review.owner_details.full_name }}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<div class="frappe-timestamp course-meta" data-timestamp="{{ review.creation }}">
|
|
||||||
{{ review.creation }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="rating">
|
|
||||||
{% for i in [1, 2, 3, 4, 5] %}
|
|
||||||
<svg class="icon icon-md {% if i <= review.rating %} star-click {% endif %}" data-rating="{{ i }}">
|
|
||||||
<use href="#icon-star"></use>
|
|
||||||
</svg>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="review-content"> {{ review.review }} </div>
|
|
||||||
</div>
|
|
||||||
{% if loop.index != reviews | length %}
|
|
||||||
<div class="card-divider"></div>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<div class="empty-state">
|
|
||||||
<img class="icon icon-xl" src="/assets/lms/icons/comment.svg">
|
|
||||||
<div class="empty-state-text">
|
|
||||||
<div class="empty-state-heading">{{ _("Review the course") }}</div>
|
|
||||||
<div class="course-meta">{{ _("Help us improve our course material.") }}</div>
|
|
||||||
<div class="mt-2">
|
|
||||||
{% include "lms/templates/reviews_cta.html" %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="modal fade review-modal" id="review-modal" tabindex="-1" role="dialog"
|
|
||||||
aria-labelledby="exampleModalLabel" aria-hidden="true">
|
|
||||||
<div class="modal-dialog" role="document">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<div class="modal-title">{{ _("Write a Review") }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="modal-body">
|
|
||||||
<form class="review-form" id="review-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="clearfix">
|
|
||||||
<label class="control-label reqd" style="padding-right: 0px;">{{ _("Rating") }}</label>
|
|
||||||
</div>
|
|
||||||
<div class="control-input-wrapper">
|
|
||||||
<div class="control-input">
|
|
||||||
<div class="rating rating-field" id="rating">
|
|
||||||
{% for i in [1, 2, 3, 4, 5] %}
|
|
||||||
<svg class="icon icon-md icon-rating" data-rating="{{ i }}">
|
|
||||||
<use href="#icon-star"></use>
|
|
||||||
</svg>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="clearfix">
|
|
||||||
<label class="control-label reqd" style="padding-right: 0px;">{{ _("Review") }}</label>
|
|
||||||
</div>
|
|
||||||
<div class="control-input-wrapper">
|
|
||||||
<div class="control-input">
|
|
||||||
<textarea type="text" autocomplete="off" class="input-with-feedback form-control review-field"
|
|
||||||
data-fieldtype="Text" data-fieldname="feedback_comments" spellcheck="false"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="error-field muted-text"></p>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-secondary btn-sm mr-2" data-dismiss="modal" aria-label="Close">
|
|
||||||
{{ _("Discard") }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="btn btn-primary btn-sm" data-course="{{ course.name | urlencode}}" id="submit-review">
|
|
||||||
{{ _("Submit") }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{% if membership and is_eligible_to_review(course.name) %}
|
|
||||||
<span class="btn btn-secondary btn-sm review-link">
|
|
||||||
{{ _("Write a review") }}
|
|
||||||
</span>
|
|
||||||
{% elif not is_instructor and frappe.session.user == "Guest" %}
|
|
||||||
<a class="btn btn-secondary btn-sm" href="/login?redirect-to=/courses/{{ course.name }}">
|
|
||||||
{{ _("Write a review") }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
Reference in New Issue
Block a user