chore: resolved conflicts

This commit is contained in:
Jannat Patel
2025-12-15 15:18:08 +05:30
59 changed files with 1850 additions and 2615 deletions

View File

@@ -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']

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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/'

View File

@@ -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 = () => {

View File

@@ -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) => {

View File

@@ -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,
}) })

View File

@@ -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,
}) })

View File

@@ -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,
}) })

View File

@@ -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,
} }
}) })

View 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>

View File

@@ -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',

View File

@@ -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,
} }

View File

@@ -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,

View File

@@ -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
View 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,
)

View 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
}

View File

@@ -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"]

View File

@@ -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",
)

View File

@@ -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) {
// }
});

View File

@@ -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
}

View File

@@ -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

View File

@@ -1,9 +0,0 @@
# Copyright (c) 2021, Frappe and Contributors
# See license.txt
# import frappe
import unittest
class TestExerciseLatestSubmission(unittest.TestCase):
pass

View File

@@ -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) {
// }
});

View File

@@ -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
}

View File

@@ -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)

View File

@@ -1,9 +0,0 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
# import frappe
import unittest
class TestExerciseSubmission(unittest.TestCase):
pass

View File

@@ -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) {
// }
});

View File

@@ -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
}

View File

@@ -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()

View File

@@ -1,9 +0,0 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
# import frappe
import unittest
class TestLMSBatchOld(unittest.TestCase):
pass

View File

@@ -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):

View File

@@ -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()

View File

@@ -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,

View File

@@ -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.")

View File

@@ -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:

View File

@@ -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()

View File

@@ -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",

View File

@@ -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})

View File

@@ -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)

View File

@@ -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) {
// }
});

View File

@@ -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
}

View File

@@ -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

View File

@@ -1,10 +0,0 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
import unittest
# import frappe
class TestLMSExercise(unittest.TestCase):
pass

View File

@@ -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")

View File

@@ -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

View File

@@ -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
View 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()

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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 %}