Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b3a71e424 | ||
|
|
5499e7294d | ||
|
|
fe1f78f8aa | ||
|
|
1709c6b658 | ||
|
|
d3583a2cfb | ||
|
|
634035fbc0 | ||
|
|
3c5b18411b | ||
|
|
82bb45a9ef | ||
|
|
373f3df196 | ||
|
|
6021f15bac | ||
|
|
8f6f35d7c1 | ||
|
|
7aa5f4d20b | ||
|
|
64b54b05a6 | ||
|
|
22b1f22df4 | ||
|
|
ae4e5539d7 | ||
|
|
dbd96329b5 | ||
|
|
c118ec7c4a | ||
|
|
7aab449502 | ||
|
|
cf166b3a57 | ||
|
|
da5910d40d | ||
|
|
8640ecf9be | ||
|
|
c4faceff30 | ||
|
|
01bd017bda | ||
|
|
d76357981b | ||
|
|
19b759e9fb | ||
|
|
df3bca6405 | ||
|
|
5cde79b5eb | ||
|
|
9b35cdbddc | ||
|
|
70ec22004a | ||
|
|
95ed77421a | ||
|
|
d64ec9817c | ||
|
|
ce01b7634f | ||
|
|
e0819f83bc | ||
|
|
f87d28c2f5 | ||
|
|
544b59744b | ||
|
|
467dfb831d | ||
|
|
4c4b4eaf55 | ||
|
|
227e5d00e5 | ||
|
|
73e9e384c8 | ||
|
|
5bebdcba68 | ||
|
|
1c2e52ae4b | ||
|
|
9377e89561 | ||
|
|
4cae05ecbe | ||
|
|
909dcfd51e | ||
|
|
2bd96a1f2a | ||
|
|
aca41080ee | ||
|
|
1c351696a9 | ||
|
|
51a8958aa6 | ||
|
|
777b8aed02 | ||
|
|
3672b90075 | ||
|
|
92c7e613db | ||
|
|
5c58b85a00 | ||
|
|
8af82daa37 | ||
|
|
224bb18d3e | ||
|
|
aab7bdcc20 | ||
|
|
c5ca428d98 | ||
|
|
af0cc7126b | ||
|
|
a085050d27 | ||
|
|
2442f35f56 | ||
|
|
ed79ea536b | ||
|
|
b3d0aecd14 | ||
|
|
5f43e67c0b | ||
|
|
49a765a9a6 | ||
|
|
4d82bc86e8 | ||
|
|
8fe02b83b8 | ||
|
|
9c9075606b | ||
|
|
53285a0d19 | ||
|
|
9cdeaebb47 | ||
|
|
a9cb52c68b | ||
|
|
f33e950e83 | ||
|
|
9c9b5963fe | ||
|
|
1597054cc9 | ||
|
|
deba6aa845 | ||
|
|
2d8ba3b84e | ||
|
|
e56b28abad | ||
|
|
eb350c5a20 | ||
|
|
961d5ec77b | ||
|
|
fa566514aa | ||
|
|
6e97449bf7 | ||
|
|
016dafb3c3 | ||
|
|
675bcc8956 | ||
|
|
aba4c034fc | ||
|
|
c76d8c582f | ||
|
|
f1cb0e6f3c | ||
|
|
d296687456 | ||
|
|
5b68001c94 | ||
|
|
8b1d9bb5a9 |
@@ -13,6 +13,6 @@ module.exports = defineConfig({
|
||||
openMode: 0,
|
||||
},
|
||||
e2e: {
|
||||
baseUrl: "http://test_site_ui:8000",
|
||||
baseUrl: "http://test:8000",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ describe("Course Creation", () => {
|
||||
cy.visit("/lms/courses");
|
||||
|
||||
// Create a course
|
||||
cy.get("a").contains("New").click();
|
||||
cy.get("header").children().last().children().last().click();
|
||||
cy.wait(1000);
|
||||
cy.url().should("include", "/courses/new/edit");
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("Course Creation", () => {
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.get("label").contains("Title").type("Test Chapter");
|
||||
cy.button("Add Chapter").click();
|
||||
cy.button("Create").click();
|
||||
});
|
||||
|
||||
// Add Lesson
|
||||
|
||||
@@ -18,11 +18,12 @@
|
||||
"@editorjs/nested-list": "^1.4.2",
|
||||
"@editorjs/paragraph": "^2.11.3",
|
||||
"@editorjs/simple-image": "^1.6.0",
|
||||
"ace-builds": "^1.36.2",
|
||||
"chart.js": "^4.4.1",
|
||||
"codemirror-editor-vue3": "^2.8.0",
|
||||
"dayjs": "^1.11.6",
|
||||
"feather-icons": "^4.28.0",
|
||||
"frappe-ui": "^0.1.69",
|
||||
"frappe-ui": "^0.1.72",
|
||||
"lucide-vue-next": "^0.383.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"pinia": "^2.0.33",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createListResource, Avatar } from 'frappe-ui'
|
||||
import { createResource, Avatar } from 'frappe-ui'
|
||||
import { timeAgo } from '@/utils'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -35,24 +35,15 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const communications = createListResource({
|
||||
doctype: 'Communication',
|
||||
fields: [
|
||||
'subject',
|
||||
'content',
|
||||
'recipients',
|
||||
'cc',
|
||||
'communication_date',
|
||||
'sender',
|
||||
'sender_full_name',
|
||||
],
|
||||
filters: {
|
||||
reference_doctype: 'LMS Batch',
|
||||
reference_name: props.batch,
|
||||
const communications = createResource({
|
||||
url: 'lms.lms.api.get_announcements',
|
||||
makeParams(value) {
|
||||
return {
|
||||
batch: props.batch,
|
||||
}
|
||||
},
|
||||
orderBy: 'communication_date desc',
|
||||
auto: true,
|
||||
cache: ['batch', props.batch],
|
||||
cache: ['announcement', props.batch],
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
|
||||
@@ -56,7 +56,6 @@ const props = defineProps({
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
audio.value = document.querySelector('audio')
|
||||
console.log(audio.value)
|
||||
audio.value.onloadedmetadata = () => {
|
||||
duration.value = audio.value.duration
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
variant="solid"
|
||||
class="w-full mt-2"
|
||||
v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left"
|
||||
@click="enrollInBatch()"
|
||||
>
|
||||
{{ __('Enroll Now') }}
|
||||
</Button>
|
||||
@@ -97,11 +98,13 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { inject, computed } from 'vue'
|
||||
import { Badge, Button } from 'frappe-ui'
|
||||
import { Badge, Button, createResource } from 'frappe-ui'
|
||||
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
|
||||
import { formatNumberIntoCurrency, formatTime } from '@/utils'
|
||||
import { formatNumberIntoCurrency, formatTime, showToast } from '@/utils'
|
||||
import DateRange from '@/components/Common/DateRange.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
@@ -111,6 +114,39 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const enroll = createResource({
|
||||
url: 'lms.lms.utils.enroll_in_batch',
|
||||
makeParams(values) {
|
||||
return {
|
||||
batch: props.batch.data.name,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const enrollInBatch = () => {
|
||||
if (!user.data) {
|
||||
window.location.href = `/login?redirect-to=/batches/details/${props.batch.data.name}`
|
||||
}
|
||||
enroll.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
showToast(
|
||||
__('Success'),
|
||||
__('You have been enrolled in this batch'),
|
||||
'check'
|
||||
)
|
||||
router.push({
|
||||
name: 'Batch',
|
||||
params: {
|
||||
batchName: props.batch.data.name,
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const seats_left = computed(() => {
|
||||
if (props.batch.data?.seat_count) {
|
||||
return props.batch.data?.seat_count - props.batch.data?.students?.length
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-col justify-between h-full">
|
||||
<div class="flex flex-col justify-between min-h-0">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold mb-1">
|
||||
@@ -16,11 +16,13 @@
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<SettingFields :fields="fields" :data="data.data" />
|
||||
<div class="flex flex-row-reverse mt-auto">
|
||||
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
<div class="overflow-y-auto">
|
||||
<SettingFields :fields="fields" :data="data.data" />
|
||||
<div class="flex flex-row-reverse mt-auto">
|
||||
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -70,9 +72,16 @@ const update = () => {
|
||||
fieldsToSave[f.name] = f.value
|
||||
}
|
||||
})
|
||||
saveSettings.submit({
|
||||
fields: fieldsToSave,
|
||||
})
|
||||
saveSettings.submit(
|
||||
{
|
||||
fields: fieldsToSave,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
isDirty.value = false
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
watch(props.data, (newData) => {
|
||||
|
||||
204
frontend/src/components/Controls/CodeEditor.vue
Normal file
204
frontend/src/components/Controls/CodeEditor.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div
|
||||
class="editor flex flex-col gap-1"
|
||||
:style="{
|
||||
height: height,
|
||||
}"
|
||||
>
|
||||
<span class="text-xs" v-if="label">
|
||||
{{ label }}
|
||||
</span>
|
||||
<div
|
||||
ref="editor"
|
||||
class="h-auto flex-1 overflow-hidden overscroll-none !rounded border border-outline-gray-2 bg-surface-gray-2 dark:bg-gray-900"
|
||||
/>
|
||||
<span
|
||||
class="mt-1 text-xs text-gray-600"
|
||||
v-show="description"
|
||||
v-html="description"
|
||||
></span>
|
||||
<Button
|
||||
v-if="showSaveButton"
|
||||
@click="emit('save', aceEditor?.getValue())"
|
||||
class="mt-3"
|
||||
>
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useDark } from '@vueuse/core'
|
||||
import ace from 'ace-builds'
|
||||
import 'ace-builds/src-min-noconflict/ext-searchbox'
|
||||
import 'ace-builds/src-min-noconflict/theme-chrome'
|
||||
import 'ace-builds/src-min-noconflict/theme-twilight'
|
||||
import { PropType, onMounted, ref, watch } from 'vue'
|
||||
import { Button } from 'frappe-ui'
|
||||
|
||||
const isDark = useDark({
|
||||
attribute: 'data-theme',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [Object, String, Array],
|
||||
},
|
||||
type: {
|
||||
type: String as PropType<'JSON' | 'HTML' | 'Python' | 'JavaScript' | 'CSS'>,
|
||||
default: 'JSON',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '250px',
|
||||
},
|
||||
showLineNumbers: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showSaveButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save', 'update:modelValue'])
|
||||
const editor = ref<HTMLElement | null>(null)
|
||||
let aceEditor = null as ace.Ace.Editor | null
|
||||
|
||||
onMounted(() => {
|
||||
setupEditor()
|
||||
})
|
||||
|
||||
const setupEditor = () => {
|
||||
aceEditor = ace.edit(editor.value as HTMLElement)
|
||||
resetEditor(props.modelValue as string, true)
|
||||
aceEditor.setReadOnly(props.readonly)
|
||||
aceEditor.setOptions({
|
||||
fontSize: '12px',
|
||||
useWorker: false,
|
||||
showGutter: props.showLineNumbers,
|
||||
wrap: props.showLineNumbers,
|
||||
})
|
||||
if (props.type === 'CSS') {
|
||||
import('ace-builds/src-noconflict/mode-css').then(() => {
|
||||
aceEditor?.session.setMode('ace/mode/css')
|
||||
})
|
||||
} else if (props.type === 'JavaScript') {
|
||||
import('ace-builds/src-noconflict/mode-javascript').then(() => {
|
||||
aceEditor?.session.setMode('ace/mode/javascript')
|
||||
})
|
||||
} else if (props.type === 'Python') {
|
||||
import('ace-builds/src-noconflict/mode-python').then(() => {
|
||||
aceEditor?.session.setMode('ace/mode/python')
|
||||
})
|
||||
} else if (props.type === 'JSON') {
|
||||
import('ace-builds/src-noconflict/mode-json').then(() => {
|
||||
aceEditor?.session.setMode('ace/mode/json')
|
||||
})
|
||||
} else {
|
||||
import('ace-builds/src-noconflict/mode-html').then(() => {
|
||||
aceEditor?.session.setMode('ace/mode/html')
|
||||
})
|
||||
}
|
||||
aceEditor.on('blur', () => {
|
||||
try {
|
||||
let value = aceEditor?.getValue() || ''
|
||||
if (props.type === 'JSON') {
|
||||
value = JSON.parse(value)
|
||||
}
|
||||
if (value === props.modelValue) return
|
||||
if (!props.showSaveButton && !props.readonly) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getModelValue = () => {
|
||||
let value = props.modelValue || ''
|
||||
try {
|
||||
if (props.type === 'JSON' || typeof value === 'object') {
|
||||
value = JSON.stringify(value, null, 2)
|
||||
}
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
return value as string
|
||||
}
|
||||
|
||||
function resetEditor(value: string, resetHistory = false) {
|
||||
value = getModelValue()
|
||||
aceEditor?.setValue(value)
|
||||
aceEditor?.clearSelection()
|
||||
aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome')
|
||||
props.autofocus && aceEditor?.focus()
|
||||
if (resetHistory) {
|
||||
aceEditor?.session.getUndoManager().reset()
|
||||
}
|
||||
}
|
||||
|
||||
watch(isDark, () => {
|
||||
aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome')
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.type,
|
||||
() => {
|
||||
setupEditor()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
resetEditor(props.modelValue as string)
|
||||
}
|
||||
)
|
||||
|
||||
defineExpose({ resetEditor })
|
||||
</script>
|
||||
<style scoped>
|
||||
.editor .ace_editor {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 5px;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
.editor :deep(.ace_scrollbar-h) {
|
||||
display: none;
|
||||
}
|
||||
.editor :deep(.ace_search) {
|
||||
@apply dark:bg-gray-800 dark:text-gray-200;
|
||||
@apply dark:border-gray-800;
|
||||
}
|
||||
.editor :deep(.ace_searchbtn) {
|
||||
@apply dark:bg-gray-800 dark:text-gray-200;
|
||||
@apply dark:border-gray-800;
|
||||
}
|
||||
.editor :deep(.ace_button) {
|
||||
@apply dark:bg-gray-800 dark:text-gray-200;
|
||||
}
|
||||
|
||||
.editor :deep(.ace_search_field) {
|
||||
@apply dark:bg-gray-900 dark:text-gray-200;
|
||||
@apply dark:border-gray-800;
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
<div>
|
||||
<label class="block mb-1" :class="labelClasses" v-if="label">
|
||||
{{ label }}
|
||||
<span class="text-red-500" v-if="required">*</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
<Button
|
||||
@@ -115,6 +116,9 @@ const props = defineProps({
|
||||
type: Function,
|
||||
default: (value) => `${value} is an Invalid value`,
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
},
|
||||
})
|
||||
|
||||
const values = defineModel()
|
||||
@@ -152,24 +156,11 @@ const filterOptions = createResource({
|
||||
url: 'frappe.desk.search.search_link',
|
||||
method: 'POST',
|
||||
cache: [text.value, props.doctype],
|
||||
auto: true,
|
||||
params: {
|
||||
txt: text.value,
|
||||
doctype: props.doctype,
|
||||
},
|
||||
/* transform: (data) => {
|
||||
let allData = data
|
||||
.filter((c) => {
|
||||
return c.description.split(', ')[1]
|
||||
})
|
||||
.map((option) => {
|
||||
let email = option.description.split(', ')[1]
|
||||
return {
|
||||
label: option.label || email,
|
||||
value: email,
|
||||
}
|
||||
})
|
||||
return allData
|
||||
}, */
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<script setup>
|
||||
import { Star } from 'lucide-vue-next'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
|
||||
@@ -30,29 +30,29 @@
|
||||
</div>
|
||||
<div class="flex flex-col flex-auto p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div v-if="course.lesson_count">
|
||||
<div v-if="course.lessons">
|
||||
<Tooltip :text="__('Lessons')">
|
||||
<span class="flex items-center">
|
||||
<BookOpen class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||
{{ course.lesson_count }}
|
||||
{{ course.lessons }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="course.enrollment_count">
|
||||
<div v-if="course.enrollments">
|
||||
<Tooltip :text="__('Enrolled Students')">
|
||||
<span class="flex items-center">
|
||||
<Users class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||
{{ course.enrollment_count }}
|
||||
{{ course.enrollments }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="course.avg_rating">
|
||||
<div v-if="course.rating">
|
||||
<Tooltip :text="__('Average Rating')">
|
||||
<span class="flex items-center">
|
||||
<Star class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||
{{ course.avg_rating }}
|
||||
{{ course.rating }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -93,21 +93,19 @@
|
||||
<div class="flex items-center mb-3">
|
||||
<BookOpen class="h-5 w-5 stroke-1.5 text-gray-600" />
|
||||
<span class="ml-2">
|
||||
{{ course.data.lesson_count }} {{ __('Lessons') }}
|
||||
{{ course.data.lessons }} {{ __('Lessons') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center mb-3">
|
||||
<Users class="h-5 w-5 stroke-1.5 text-gray-600" />
|
||||
<span class="ml-2">
|
||||
{{ course.data.enrollment_count_formatted }}
|
||||
{{ formatAmount(course.data.enrollments) }}
|
||||
{{ __('Enrolled Students') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Star class="h-5 w-5 stroke-1.5 fill-orange-500 text-gray-50" />
|
||||
<span class="ml-2">
|
||||
{{ course.data.avg_rating }} {{ __('Rating') }}
|
||||
</span>
|
||||
<span class="ml-2"> {{ course.data.rating }} {{ __('Rating') }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,7 +114,7 @@
|
||||
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
||||
import { computed, inject } from 'vue'
|
||||
import { Button, createResource } from 'frappe-ui'
|
||||
import { createToast } from '@/utils/'
|
||||
import { showToast, formatAmount } from '@/utils/'
|
||||
import { capture } from '@/telemetry'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -139,11 +137,11 @@ const video_link = computed(() => {
|
||||
|
||||
function enrollStudent() {
|
||||
if (!user.data) {
|
||||
createToast({
|
||||
title: 'Please Login',
|
||||
icon: 'alert-circle',
|
||||
iconClasses: 'text-yellow-600 bg-yellow-100',
|
||||
})
|
||||
showToast(
|
||||
__('Please Login'),
|
||||
__('You need to login first to enroll for this course'),
|
||||
'circle-warn'
|
||||
)
|
||||
setTimeout(() => {
|
||||
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
||||
}, 2000)
|
||||
@@ -159,11 +157,11 @@ function enrollStudent() {
|
||||
capture('enrolled_in_course', {
|
||||
course: props.course.data.name,
|
||||
})
|
||||
createToast({
|
||||
title: 'Enrolled Successfully',
|
||||
icon: 'check',
|
||||
iconClasses: 'text-green-600 bg-green-100',
|
||||
})
|
||||
showToast(
|
||||
__('Success'),
|
||||
__('You have been enrolled in this course'),
|
||||
'check'
|
||||
)
|
||||
setTimeout(() => {
|
||||
router.push({
|
||||
name: 'Lesson',
|
||||
@@ -173,7 +171,7 @@ function enrollStudent() {
|
||||
lessonNumber: 1,
|
||||
},
|
||||
})
|
||||
}, 3000)
|
||||
}, 2000)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -206,7 +204,6 @@ const certificate = createResource({
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
console.log(data)
|
||||
window.open(
|
||||
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
|
||||
data.name
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
<Trash2
|
||||
v-if="allowEdit"
|
||||
@click.prevent="trashLesson(lesson.name, chapter.name)"
|
||||
class="h-4 w-4 stroke-1.5 text-gray-700 ml-auto invisible group-hover:visible"
|
||||
class="h-4 w-4 text-red-500 ml-auto invisible group-hover:visible"
|
||||
/>
|
||||
<Check
|
||||
v-if="lesson.is_complete"
|
||||
@@ -119,7 +119,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, createResource } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import { ref, getCurrentInstance } from 'vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||
import {
|
||||
@@ -138,6 +138,8 @@ const route = useRoute()
|
||||
const expandAll = ref(true)
|
||||
const showChapterModal = ref(false)
|
||||
const currentChapter = ref(null)
|
||||
const app = getCurrentInstance()
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
@@ -202,9 +204,23 @@ const updateLessonIndex = createResource({
|
||||
})
|
||||
|
||||
const trashLesson = (lessonName, chapterName) => {
|
||||
deleteLesson.submit({
|
||||
lesson: lessonName,
|
||||
chapter: chapterName,
|
||||
$dialog({
|
||||
title: __('Delete Lesson'),
|
||||
message: __('Are you sure you want to delete this lesson?'),
|
||||
actions: [
|
||||
{
|
||||
label: __('Delete'),
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick(close) {
|
||||
deleteLesson.submit({
|
||||
lesson: lessonName,
|
||||
chapter: chapterName,
|
||||
})
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
avg_rating: {
|
||||
type: Number,
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
membership: {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
||||
class="flex text-sm font-medium space-x-2 cursor-pointer"
|
||||
@click="openHelpDialog('upload')"
|
||||
>
|
||||
<span class="leading-5">
|
||||
@@ -56,6 +56,21 @@
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center text-sm font-medium space-x-2">
|
||||
<span>
|
||||
{{ __('What does include in preview mean?') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 mb-1 leading-5">
|
||||
{{
|
||||
__(
|
||||
'If Include in Preview is enabled for a lesson then the lesson will also be accessible to non logged in users.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ExplanationVideos v-model="showExplanation" :type="type" />
|
||||
</template>
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 text-gray-900 mt-auto">
|
||||
<a
|
||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||
:href="cls.start_url"
|
||||
target="_blank"
|
||||
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
||||
@@ -47,7 +48,7 @@
|
||||
<a
|
||||
:href="cls.join_url"
|
||||
target="_blank"
|
||||
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
||||
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
||||
>
|
||||
<Video class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Join') }}
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<script setup>
|
||||
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
|
||||
import { reactive } from 'vue'
|
||||
import { createToast } from '@/utils/'
|
||||
import { showToast } from '@/utils/'
|
||||
|
||||
const show = defineModel()
|
||||
|
||||
@@ -94,22 +94,14 @@ const makeAnnouncement = (close) => {
|
||||
},
|
||||
onSuccess() {
|
||||
close()
|
||||
createToast({
|
||||
title: 'Success',
|
||||
text: 'Announcement has been sent successfully',
|
||||
icon: 'Check',
|
||||
iconClasses: 'bg-green-600 text-white rounded-md p-px',
|
||||
})
|
||||
showToast(
|
||||
__('Success'),
|
||||
__('Announcement has been sent successfully'),
|
||||
'check'
|
||||
)
|
||||
},
|
||||
onError(err) {
|
||||
createToast({
|
||||
title: 'Error',
|
||||
text: err.messages?.[0] || err,
|
||||
icon: 'x',
|
||||
iconClasses: 'bg-red-600 text-white rounded-md p-px',
|
||||
position: 'top-center',
|
||||
timeout: 10,
|
||||
})
|
||||
showToast(__('Error'), __(err.messages?.[0] || err), 'check')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add Chapter'),
|
||||
title: chapterDetail ? __('Edit Chapter') : __('Add Chapter'),
|
||||
size: 'lg',
|
||||
actions: [
|
||||
{
|
||||
label: chapterDetail ? __('Edit Chapter') : __('Add Chapter'),
|
||||
label: chapterDetail ? __('Edit') : __('Create'),
|
||||
variant: 'solid',
|
||||
onClick: (close) =>
|
||||
chapterDetail ? editChapter(close) : addChapter(close),
|
||||
|
||||
@@ -179,26 +179,6 @@ const tabsStructure = computed(() => {
|
||||
name: 'app_name',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
label: 'Copyright',
|
||||
name: 'copyright',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
label: 'Address',
|
||||
name: 'address',
|
||||
type: 'textarea',
|
||||
rows: 4,
|
||||
},
|
||||
{
|
||||
label: 'Footer "Powered By"',
|
||||
name: 'footer_powered',
|
||||
type: 'textarea',
|
||||
rows: 4,
|
||||
},
|
||||
{
|
||||
type: 'Column Break',
|
||||
},
|
||||
{
|
||||
label: 'Logo',
|
||||
name: 'banner_image',
|
||||
@@ -214,6 +194,23 @@ const tabsStructure = computed(() => {
|
||||
name: 'footer_logo',
|
||||
type: 'Upload',
|
||||
},
|
||||
{
|
||||
label: 'Address',
|
||||
name: 'address',
|
||||
type: 'textarea',
|
||||
rows: 2,
|
||||
},
|
||||
{
|
||||
label: 'Footer "Powered By"',
|
||||
name: 'footer_powered',
|
||||
type: 'textarea',
|
||||
rows: 4,
|
||||
},
|
||||
{
|
||||
label: 'Copyright',
|
||||
name: 'copyright',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -292,9 +289,11 @@ const tabsStructure = computed(() => {
|
||||
rows: 10,
|
||||
},
|
||||
{
|
||||
label: 'Ask user category',
|
||||
label: 'Ask for Occupation',
|
||||
name: 'user_category',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'Enable this option to ask users to select their occupation during the signup process.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
<template>
|
||||
<div v-if="quiz.data">
|
||||
<div class="bg-blue-100 py-2 px-2 mb-4 rounded-md text-sm text-blue-800">
|
||||
<div class="leading-relaxed">
|
||||
<div
|
||||
class="bg-blue-100 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-blue-800"
|
||||
>
|
||||
<div class="leading-5">
|
||||
{{
|
||||
__('This quiz consists of {0} questions.').format(questions.length)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data?.duration" class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'Please ensure that you complete all the questions in {0} minutes.'
|
||||
).format(quiz.data.duration)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data?.duration" class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'If you fail to do so, the quiz will be automatically submitted when the timer ends.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data.passing_percentage" class="leading-relaxed">
|
||||
{{
|
||||
__(
|
||||
@@ -22,14 +38,16 @@
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data.time" class="leading-relaxed">
|
||||
{{
|
||||
__(
|
||||
'The quiz has a time limit. For each question you will be given {0} seconds.'
|
||||
).format(quiz.data.time)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="quiz.data.duration" class="flex items-center space-x-2 my-4">
|
||||
<span class="text-gray-600 text-xs"> {{ __('Time') }}: </span>
|
||||
<ProgressBar :progress="timerProgress" />
|
||||
<span class="font-semibold">
|
||||
{{ formatTimer(timer) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="activeQuestion == 0">
|
||||
<div class="border text-center p-20 rounded-md">
|
||||
<div class="font-semibold text-lg">
|
||||
@@ -63,7 +81,7 @@
|
||||
class="border rounded-md p-5"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<div class="text-sm">
|
||||
<div class="text-sm text-gray-600">
|
||||
<span class="mr-2">
|
||||
{{ __('Question {0}').format(activeQuestion) }}:
|
||||
</span>
|
||||
@@ -162,8 +180,8 @@
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-5">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="text-sm text-gray-600">
|
||||
{{
|
||||
__('Question {0} of {1}').format(
|
||||
activeQuestion,
|
||||
@@ -250,20 +268,29 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Badge, Button, createResource, ListView, TextEditor } from 'frappe-ui'
|
||||
import { ref, watch, reactive, inject } from 'vue'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
createResource,
|
||||
ListView,
|
||||
TextEditor,
|
||||
FormControl,
|
||||
} from 'frappe-ui'
|
||||
import { ref, watch, reactive, inject, computed } from 'vue'
|
||||
import { createToast } from '@/utils/'
|
||||
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
|
||||
import { timeAgo } from '@/utils'
|
||||
import FormControl from 'frappe-ui/src/components/FormControl.vue'
|
||||
const user = inject('$user')
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const activeQuestion = ref(0)
|
||||
const currentQuestion = ref('')
|
||||
const selectedOptions = reactive([0, 0, 0, 0])
|
||||
const showAnswers = reactive([])
|
||||
let questions = reactive([])
|
||||
const possibleAnswer = ref(null)
|
||||
const timer = ref(0)
|
||||
let timerInterval = null
|
||||
|
||||
const props = defineProps({
|
||||
quizName: {
|
||||
@@ -284,6 +311,7 @@ const quiz = createResource({
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
populateQuestions()
|
||||
setupTimer()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -299,6 +327,37 @@ const populateQuestions = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const setupTimer = () => {
|
||||
if (quiz.data.duration) {
|
||||
timer.value = quiz.data.duration * 60
|
||||
}
|
||||
}
|
||||
|
||||
const startTimer = () => {
|
||||
timerInterval = setInterval(() => {
|
||||
timer.value--
|
||||
if (timer.value == 0) {
|
||||
clearInterval(timerInterval)
|
||||
submitQuiz()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const formatTimer = (seconds) => {
|
||||
const hrs = Math.floor(seconds / 3600)
|
||||
.toString()
|
||||
.padStart(2, '0')
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
.toString()
|
||||
.padStart(2, '0')
|
||||
const secs = (seconds % 60).toString().padStart(2, '0')
|
||||
return hrs != '00' ? `${hrs}:${mins}:${secs}` : `${mins}:${secs}`
|
||||
}
|
||||
|
||||
const timerProgress = computed(() => {
|
||||
return (timer.value / (quiz.data.duration * 60)) * 100
|
||||
})
|
||||
|
||||
const shuffleArray = (array) => {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
@@ -383,6 +442,7 @@ watch(
|
||||
const startQuiz = () => {
|
||||
activeQuestion.value = 1
|
||||
localStorage.removeItem(quiz.data.title)
|
||||
if (quiz.data.duration) startTimer()
|
||||
}
|
||||
|
||||
const markAnswer = (index) => {
|
||||
@@ -493,9 +553,15 @@ const submitQuiz = () => {
|
||||
}
|
||||
|
||||
const createSubmission = () => {
|
||||
quizSubmission.reload().then(() => {
|
||||
if (quiz.data && quiz.data.max_attempts) attempts.reload()
|
||||
})
|
||||
quizSubmission.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
if (quiz.data && quiz.data.max_attempts) attempts.reload()
|
||||
if (quiz.data.duration) clearInterval(timerInterval)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const resetQuiz = () => {
|
||||
@@ -504,6 +570,7 @@ const resetQuiz = () => {
|
||||
showAnswers.length = 0
|
||||
quizSubmission.reset()
|
||||
populateQuestions()
|
||||
setupTimer()
|
||||
}
|
||||
|
||||
const getInstructions = (question) => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<div class="flex itemsc-center justify-between">
|
||||
<div class="font-semibold mb-1">
|
||||
<div class="text-xl font-semibold leading-none mb-1">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<Badge
|
||||
|
||||
@@ -17,17 +17,16 @@
|
||||
/>
|
||||
|
||||
<div v-else-if="field.type == 'Code'">
|
||||
<div>
|
||||
{{ __(field.label) }}
|
||||
</div>
|
||||
<Codemirror
|
||||
v-model:value="data[field.name]"
|
||||
:height="200"
|
||||
:options="{
|
||||
mode: field.mode,
|
||||
theme: 'seti',
|
||||
}"
|
||||
/>
|
||||
<CodeEditor
|
||||
:label="__(field.label)"
|
||||
type="HTML"
|
||||
description="The HTML you add here will be shown on your sign up page."
|
||||
v-model="data[field.name]"
|
||||
height="250px"
|
||||
class="shrink-0"
|
||||
:showLineNumbers="true"
|
||||
>
|
||||
</CodeEditor>
|
||||
</div>
|
||||
|
||||
<div v-else-if="field.type == 'Upload'">
|
||||
@@ -53,9 +52,11 @@
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else>
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||
<div class="flex items-center text-sm space-x-2">
|
||||
<div
|
||||
class="flex items-center justify-center rounded border border-outline-gray-1 w-[15rem] py-5"
|
||||
>
|
||||
<img :src="data[field.name]?.file_url" class="h-6 rounded" />
|
||||
</div>
|
||||
<div class="flex flex-col flex-wrap">
|
||||
<span class="break-all">
|
||||
@@ -73,6 +74,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
v-else-if="field.type == 'checkbox'"
|
||||
size="sm"
|
||||
:label="__(field.label)"
|
||||
:description="__(field.description)"
|
||||
v-model="data[field.name]"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-else
|
||||
:key="field.name"
|
||||
@@ -88,14 +97,12 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { FormControl, FileUploader, Button } from 'frappe-ui'
|
||||
import { FormControl, FileUploader, Button, Switch } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
import { getFileSize, validateFile } from '@/utils'
|
||||
import { X, FileText } from 'lucide-vue-next'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import Codemirror from 'codemirror-editor-vue3'
|
||||
import 'codemirror/theme/seti.css'
|
||||
import 'codemirror/mode/htmlmixed/htmlmixed.js'
|
||||
import CodeEditor from '@/components/Controls/CodeEditor.vue'
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
<video
|
||||
@timeupdate="updateTime"
|
||||
@ended="videoEnded"
|
||||
class="rounded-lg border border-gray-100"
|
||||
@click="togglePlay"
|
||||
class="rounded-lg border border-gray-100 group cursor-pointer"
|
||||
ref="videoRef"
|
||||
>
|
||||
<source :src="fileURL" :type="type" />
|
||||
</video>
|
||||
<div
|
||||
class="flex items-center space-x-2 bg-gray-200 rounded-md p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto"
|
||||
class="flex items-center space-x-2 bg-gray-200 rounded-md p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto invisible group-hover:visible"
|
||||
>
|
||||
<Button variant="ghost">
|
||||
<template #icon>
|
||||
@@ -106,6 +107,14 @@ const pauseVideo = () => {
|
||||
playing.value = false
|
||||
}
|
||||
|
||||
const togglePlay = () => {
|
||||
if (playing.value) {
|
||||
pauseVideo()
|
||||
} else {
|
||||
playVideo()
|
||||
}
|
||||
}
|
||||
|
||||
const videoEnded = () => {
|
||||
playing.value = false
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import router from './router'
|
||||
import App from './App.vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import dayjs from '@/utils/dayjs'
|
||||
import { createDialog } from '@/utils/dialogs'
|
||||
import translationPlugin from './translation'
|
||||
import { usersStore } from './stores/user'
|
||||
import { sessionStore } from './stores/session'
|
||||
@@ -36,3 +37,4 @@ let { isLoggedIn } = sessionStore()
|
||||
app.provide('$user', userResource)
|
||||
app.provide('$allUsers', allUsers)
|
||||
app.config.globalProperties.$user = userResource
|
||||
app.config.globalProperties.$dialog = createDialog
|
||||
|
||||
@@ -236,7 +236,7 @@ const breadcrumbs = computed(() => {
|
||||
const isStudent = computed(() => {
|
||||
return (
|
||||
user?.data &&
|
||||
batch.data?.students.length &&
|
||||
batch.data?.students?.length &&
|
||||
batch.data?.students.includes(user.data.name)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -32,57 +32,65 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div>
|
||||
<FileUploader
|
||||
v-if="!batch.image"
|
||||
class="mt-4"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="mb-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{ uploading ? `Uploading ${progress}%` : 'Upload an image' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="text-xs text-gray-600 mb-1">
|
||||
{{ __('Meta Image') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 mb-2">
|
||||
{{ __('Meta Image') }}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!batch.image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||
<div class="border rounded-md w-fit py-5 px-20">
|
||||
<Image class="size-5 stroke-1 text-gray-700" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span>
|
||||
{{ batch.image.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500 mt-1">
|
||||
{{ getFileSize(batch.image.file_size) }}
|
||||
</span>
|
||||
<div class="ml-4">
|
||||
<Button @click="openFileSelector">
|
||||
{{ __('Upload') }}
|
||||
</Button>
|
||||
<div class="mt-2 text-gray-600 text-sm">
|
||||
{{
|
||||
__(
|
||||
'Appears when the batch URL is shared on any online platform'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="flex items-center">
|
||||
<img :src="batch.image.file_url" class="border rounded-md w-40" />
|
||||
<div class="ml-4">
|
||||
<Button @click="removeImage()">
|
||||
{{ __('Remove') }}
|
||||
</Button>
|
||||
<div class="mt-2 text-gray-600 text-sm">
|
||||
{{
|
||||
__(
|
||||
'Appears when the batch URL is shared on any online platform'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<X
|
||||
@click="removeImage()"
|
||||
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MultiSelect
|
||||
v-model="instructors"
|
||||
doctype="User"
|
||||
:label="__('Instructors')"
|
||||
/>
|
||||
</div>
|
||||
<MultiSelect
|
||||
v-model="instructors"
|
||||
doctype="User"
|
||||
:label="__('Instructors')"
|
||||
/>
|
||||
<div class="mb-4">
|
||||
<FormControl
|
||||
v-model="batch.description"
|
||||
:label="__('Description')"
|
||||
type="textarea"
|
||||
class="my-4"
|
||||
:placeholder="__('Short description of the batch')"
|
||||
/>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 mb-1">
|
||||
@@ -133,6 +141,7 @@
|
||||
v-model="batch.timezone"
|
||||
:label="__('Timezone')"
|
||||
type="text"
|
||||
:placeholder="__('Example: IST (+5:30)')"
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
@@ -149,6 +158,7 @@
|
||||
:label="__('Seat Count')"
|
||||
type="number"
|
||||
class="mb-4"
|
||||
:placeholder="__('Number of seats available')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.evaluation_end_date"
|
||||
@@ -228,11 +238,11 @@ import {
|
||||
createResource,
|
||||
} from 'frappe-ui'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getFileSize, showToast } from '../utils'
|
||||
import { X, FileText } from 'lucide-vue-next'
|
||||
import { showToast } from '../utils'
|
||||
import { Image } from 'lucide-vue-next'
|
||||
import { capture } from '@/telemetry'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
{{ __('Loading Batches...') }}
|
||||
</div>
|
||||
<Tabs
|
||||
v-if="hasBatches"
|
||||
v-model="tabIndex"
|
||||
:tabs="makeTabs"
|
||||
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
||||
@@ -79,24 +80,63 @@
|
||||
<BatchCard :batch="batch" />
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="grid flex-1 place-items-center text-xl font-medium text-gray-500"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center mt-4">
|
||||
<div>
|
||||
{{ __('No {0} batches found').format(tab.label.toLowerCase()) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-5 italic text-gray-500">
|
||||
{{ __('No {0} batches').format(tab.label.toLowerCase()) }}
|
||||
</div>
|
||||
</template>
|
||||
</Tabs>
|
||||
<div
|
||||
v-else-if="
|
||||
!batches.loading &&
|
||||
!hasBatches &&
|
||||
(user.data?.is_instructor || user.data?.is_moderator)
|
||||
"
|
||||
class="grid grid-cols-3 p-5"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'BatchForm',
|
||||
params: {
|
||||
batchName: 'new',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div class="bg-gray-50 py-32 px-5 rounded-md">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<Plus
|
||||
class="size-10 stroke-1 text-gray-800 p-1 rounded-full border bg-white"
|
||||
/>
|
||||
<div class="font-medium">
|
||||
{{ __('Create a Batch') }}
|
||||
</div>
|
||||
<span class="text-gray-700 text-sm leading-4">
|
||||
{{ __('You can link courses and assessments to it.') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!batches.loading && !hasBatches"
|
||||
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
|
||||
>
|
||||
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
|
||||
<div class="text-xl font-medium">
|
||||
{{ __('No batches found') }}
|
||||
</div>
|
||||
<div>
|
||||
{{
|
||||
__(
|
||||
'There are no batches available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
createListResource,
|
||||
createResource,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
@@ -104,13 +144,14 @@ import {
|
||||
Badge,
|
||||
Select,
|
||||
} from 'frappe-ui'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { BookOpen, Plus } from 'lucide-vue-next'
|
||||
import BatchCard from '@/components/BatchCard.vue'
|
||||
import { inject, ref, computed, onMounted, watch } from 'vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
|
||||
const user = inject('$user')
|
||||
const currentCategory = ref(null)
|
||||
const hasBatches = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
@@ -119,10 +160,10 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const batches = createListResource({
|
||||
const batches = createResource({
|
||||
doctype: 'LMS Batch',
|
||||
url: 'lms.lms.utils.get_batches',
|
||||
cache: ['batches', user?.data?.email],
|
||||
cache: ['batches', user.data?.email],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
@@ -183,6 +224,14 @@ const addToTabs = (label) => {
|
||||
})
|
||||
}
|
||||
|
||||
watch(batches, () => {
|
||||
Object.keys(batches.data).forEach((key) => {
|
||||
if (batches.data[key].length) {
|
||||
hasBatches.value = true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => currentCategory.value,
|
||||
() => {
|
||||
|
||||
@@ -16,16 +16,16 @@
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Tooltip
|
||||
v-if="course.data.avg_rating"
|
||||
v-if="course.data.rating"
|
||||
:text="__('Average Rating')"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Star class="h-5 w-5 text-gray-100 fill-orange-500" />
|
||||
<span class="ml-1">
|
||||
{{ course.data.avg_rating }}
|
||||
{{ course.data.rating }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span v-if="course.data.avg_rating" class="mx-3">·</span>
|
||||
<span v-if="course.data.rating" class="mx-3">·</span>
|
||||
<Tooltip
|
||||
v-if="course.data.enrollment_count"
|
||||
:text="__('Enrolled Students')"
|
||||
@@ -74,7 +74,7 @@
|
||||
</div>
|
||||
<CourseReviews
|
||||
:courseName="course.data.name"
|
||||
:avg_rating="course.data.avg_rating"
|
||||
:avg_rating="course.data.rating"
|
||||
:membership="course.data.membership"
|
||||
/>
|
||||
</div>
|
||||
@@ -116,7 +116,7 @@ const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
label: course?.data?.title,
|
||||
route: { name: 'CourseDetail', params: { course: course?.data?.name } },
|
||||
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
@@ -23,15 +23,23 @@
|
||||
v-model="course.title"
|
||||
:label="__('Title')"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="course.short_introduction"
|
||||
:label="__('Short Introduction')"
|
||||
:placeholder="
|
||||
__(
|
||||
'A one line introduction to the course that appears on the course card'
|
||||
)
|
||||
"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Course Description') }}
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="course.description"
|
||||
@@ -41,49 +49,62 @@
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!course.course_image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading ? `Uploading ${progress}%` : 'Upload an image'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="text-xs text-gray-600 mb-1">
|
||||
<div class="mb-4">
|
||||
<div class="text-xs text-gray-600 mb-2">
|
||||
{{ __('Course Image') }}
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||
<FileUploader
|
||||
v-if="!course.course_image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md w-fit py-5 px-20">
|
||||
<Image class="size-5 stroke-1 text-gray-700" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<Button @click="openFileSelector">
|
||||
{{ __('Upload') }}
|
||||
</Button>
|
||||
<div class="mt-2 text-gray-600 text-sm">
|
||||
{{
|
||||
__('Appears on the course card in the course list')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
:src="course.course_image.file_url"
|
||||
class="border rounded-md w-40"
|
||||
/>
|
||||
<div class="ml-4">
|
||||
<Button @click="removeImage()">
|
||||
{{ __('Remove') }}
|
||||
</Button>
|
||||
<div class="mt-2 text-gray-600 text-sm">
|
||||
{{ __('Appears on the course card in the course list') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span>
|
||||
{{ course.course_image.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500 mt-1">
|
||||
{{ getFileSize(course.course_image.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="removeImage()"
|
||||
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="course.video_link"
|
||||
:label="__('Preview Video')"
|
||||
:placeholder="
|
||||
__(
|
||||
'Paste the youtube link of a short video introducing the course'
|
||||
)
|
||||
"
|
||||
class="mb-4"
|
||||
/>
|
||||
<div class="mb-4">
|
||||
@@ -104,6 +125,8 @@
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="newTag"
|
||||
:placeholder="__('Keywords for the course')"
|
||||
class="w-52"
|
||||
@keyup.enter="updateTags()"
|
||||
id="tags"
|
||||
/>
|
||||
@@ -121,6 +144,7 @@
|
||||
v-model="instructors"
|
||||
doctype="User"
|
||||
:label="__('Instructors')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="container border-t">
|
||||
@@ -130,7 +154,7 @@
|
||||
<div class="grid grid-cols-3 gap-10 mb-4">
|
||||
<div
|
||||
v-if="user.data?.is_moderator"
|
||||
class="flex flex-col space-y-3"
|
||||
class="flex flex-col space-y-4"
|
||||
>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
@@ -224,14 +248,9 @@ import {
|
||||
reactive,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import {
|
||||
convertToTitleCase,
|
||||
showToast,
|
||||
getFileSize,
|
||||
updateDocumentTitle,
|
||||
} from '@/utils'
|
||||
import { convertToTitleCase, showToast, updateDocumentTitle } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { FileText, Image, X } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
|
||||
/>
|
||||
<div class="flex space-x-2 justify-end">
|
||||
<div class="w-46 md:w-44">
|
||||
<div class="w-40 md:w-44">
|
||||
<FormControl
|
||||
v-if="categories.data?.length"
|
||||
type="select"
|
||||
@@ -48,6 +48,7 @@
|
||||
</header>
|
||||
<div class="">
|
||||
<Tabs
|
||||
v-if="hasCourses"
|
||||
v-model="tabIndex"
|
||||
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
||||
:tabs="makeTabs"
|
||||
@@ -101,18 +102,57 @@
|
||||
<CourseCard :course="course" />
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="grid flex-1 place-items-center text-xl font-medium text-gray-500"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center mt-4">
|
||||
<div>
|
||||
{{ __('No {0} courses found').format(tab.label.toLowerCase()) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-5 italic text-gray-500">
|
||||
{{ __('No {0} courses').format(tab.label.toLowerCase()) }}
|
||||
</div>
|
||||
</template>
|
||||
</Tabs>
|
||||
<div
|
||||
v-else-if="
|
||||
!courses.loading &&
|
||||
(user.data?.is_moderator || user.data?.is_instructor)
|
||||
"
|
||||
class="grid grid-cols-3 p-5"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'CourseForm',
|
||||
params: {
|
||||
courseName: 'new',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div class="bg-gray-50 py-32 px-5 rounded-md">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<Plus
|
||||
class="size-10 stroke-1 text-gray-800 p-1 rounded-full border bg-white"
|
||||
/>
|
||||
<div class="font-medium">
|
||||
{{ __('Create a Course') }}
|
||||
</div>
|
||||
<span class="text-gray-700 text-sm leading-4">
|
||||
{{ __('You can add chapters and lessons to it.') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!courses.loading && !hasCourses"
|
||||
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
|
||||
>
|
||||
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
|
||||
<div class="text-xl font-medium">
|
||||
{{ __('No courses found') }}
|
||||
</div>
|
||||
<div>
|
||||
{{
|
||||
__(
|
||||
'There are no courses available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -127,13 +167,14 @@ import {
|
||||
createResource,
|
||||
} from 'frappe-ui'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import { Plus, Search } from 'lucide-vue-next'
|
||||
import { BookOpen, Plus, Search } from 'lucide-vue-next'
|
||||
import { ref, computed, inject, onMounted, watch } from 'vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
|
||||
const user = inject('$user')
|
||||
const searchQuery = ref('')
|
||||
const currentCategory = ref(null)
|
||||
const hasCourses = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
@@ -223,6 +264,16 @@ const categories = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
watch(courses, () => {
|
||||
if (courses.data) {
|
||||
Object.keys(courses.data).forEach((section) => {
|
||||
if (courses.data[section].length) {
|
||||
hasCourses.value = true
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => currentCategory.value,
|
||||
() => {
|
||||
|
||||
@@ -149,7 +149,7 @@ const newJob = createResource({
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Job Opportunity',
|
||||
company_logo: job.image.file_url,
|
||||
company_logo: job.image?.file_url,
|
||||
...job,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -52,46 +52,88 @@
|
||||
</header>
|
||||
<div v-if="job.data" class="max-w-3xl mx-auto">
|
||||
<div class="p-4">
|
||||
<div class="flex mb-10">
|
||||
<img
|
||||
:src="job.data.company_logo"
|
||||
class="w-16 h-16 rounded-lg object-contain mr-4"
|
||||
:alt="job.data.company_name"
|
||||
/>
|
||||
<div>
|
||||
<div class="space-y-5 mb-10">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
:src="job.data.company_logo"
|
||||
class="w-16 h-16 rounded-lg object-contain mr-4"
|
||||
:alt="job.data.company_name"
|
||||
/>
|
||||
<div class="text-2xl font-semibold mb-4">
|
||||
{{ job.data.job_title }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-10 gap-y-2 md:gap-y-4"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-10 gap-y-5 md:gap-y-5"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Building2 class="h-4 w-4 stroke-1.5" />
|
||||
<span>{{ job.data.company_name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<MapPin class="h-4 w-4 stroke-1.5" />
|
||||
<span>{{ job.data.location }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<ClipboardType class="h-4 w-4 stroke-1.5" />
|
||||
<span>{{ job.data.type }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<CalendarDays class="h-4 w-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(job.data.creation).format('DD MMM YYYY') }}
|
||||
<span class="p-4 bg-green-50 rounded-full">
|
||||
<Building2 class="h-4 w-4 text-green-500" />
|
||||
</span>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="text-xs text-gray-600 font-medium uppercase">
|
||||
{{ __('Organisation') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold">
|
||||
{{ job.data.company_name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="p-4 bg-red-50 rounded-full">
|
||||
<MapPin class="h-4 w-4 text-red-500" />
|
||||
</span>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="text-xs text-gray-600 font-medium uppercase">
|
||||
{{ __('Location') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold">
|
||||
{{ job.data.location }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="p-4 bg-yellow-50 rounded-full">
|
||||
<ClipboardType class="h-4 w-4 text-yellow-500" />
|
||||
</span>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="text-xs font-medium text-gray-600 uppercase">
|
||||
{{ __('Category') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold">
|
||||
{{ job.data.type }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="p-4 bg-blue-50 rounded-full">
|
||||
<CalendarDays class="h-4 w-4 text-blue-500" />
|
||||
</span>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="text-xs text-gray-600 font-medium uppercase">
|
||||
{{ __('Posted on') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold">
|
||||
{{ dayjs(job.data.creation).format('DD MMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="applicationCount.data"
|
||||
class="flex items-center space-x-2"
|
||||
>
|
||||
<SquareUserRound class="h-4 w-4 stroke-1.5" />
|
||||
<span
|
||||
>{{ applicationCount.data }}
|
||||
{{ __('applications received') }}</span
|
||||
>
|
||||
<span class="p-4 bg-purple-50 rounded-full">
|
||||
<SquareUserRound class="h-4 w-4 text-purple-500" />
|
||||
</span>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="text-xs text-gray-600 font-medium uppercase">
|
||||
{{ __('Applications Received') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold">
|
||||
{{ applicationCount.data }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,14 +17,9 @@
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<router-link
|
||||
v-if="user.data"
|
||||
:to="{ name: 'CourseDetail', params: { courseName: courseName } }"
|
||||
>
|
||||
<Button variant="solid">
|
||||
{{ __('Start Learning') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button v-if="user.data" @click="enrollStudent()" variant="solid">
|
||||
{{ __('Start Learning') }}
|
||||
</Button>
|
||||
<Button v-else @click="redirectToLogin()">
|
||||
{{ __('Login') }}
|
||||
</Button>
|
||||
@@ -120,6 +115,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
lesson.data.instructor_content &&
|
||||
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
|
||||
allowInstructorContent()
|
||||
"
|
||||
@@ -193,7 +189,7 @@ import { createResource, Breadcrumbs, Button } from 'frappe-ui'
|
||||
import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import Discussions from '@/components/Discussions.vue'
|
||||
import { getEditorTools, updateDocumentTitle } from '../utils'
|
||||
@@ -203,6 +199,7 @@ import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const allowDiscussions = ref(false)
|
||||
const editor = ref(null)
|
||||
@@ -278,7 +275,7 @@ const renderEditor = (holder, content) => {
|
||||
}
|
||||
|
||||
const markProgress = () => {
|
||||
if (user.data && !lesson.data?.progress) {
|
||||
if (user.data && lesson.data && !lesson.data.progress) {
|
||||
progress.submit()
|
||||
}
|
||||
}
|
||||
@@ -300,14 +297,14 @@ const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
label: lesson?.data?.course_title,
|
||||
route: { name: 'CourseDetail', params: { course: props.courseName } },
|
||||
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
|
||||
})
|
||||
items.push({
|
||||
label: lesson?.data?.title,
|
||||
route: {
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
course: props.courseName,
|
||||
courseName: props.courseName,
|
||||
chapterNumber: props.chapterNumber,
|
||||
lessonNumber: props.lessonNumber,
|
||||
},
|
||||
@@ -378,6 +375,30 @@ const allowInstructorContent = () => {
|
||||
return false
|
||||
}
|
||||
|
||||
const enrollment = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams() {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Enrollment',
|
||||
course: props.courseName,
|
||||
member: user.data?.name,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const enrollStudent = () => {
|
||||
enrollment.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
window.location.reload()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, FormControl, createResource, Button } from 'frappe-ui'
|
||||
import { Breadcrumbs, Button, createResource, FormControl } from 'frappe-ui'
|
||||
import {
|
||||
computed,
|
||||
reactive,
|
||||
|
||||
@@ -4,6 +4,19 @@
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<div class="space-x-2">
|
||||
<router-link
|
||||
v-if="quizDetails.data?.name"
|
||||
:to="{
|
||||
name: 'QuizPage',
|
||||
params: {
|
||||
quizID: quizDetails.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Open') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="quizDetails.data?.name"
|
||||
:to="{
|
||||
@@ -25,7 +38,7 @@
|
||||
<div class="w-3/4 mx-auto py-5">
|
||||
<!-- Details -->
|
||||
<div class="mb-8">
|
||||
<div class="text-sm font-semibold mb-4">
|
||||
<div class="font-semibold mb-4">
|
||||
{{ __('Details') }}
|
||||
</div>
|
||||
<FormControl
|
||||
@@ -37,11 +50,17 @@
|
||||
"
|
||||
/>
|
||||
<div v-if="quizDetails.data?.name">
|
||||
<div class="grid grid-cols-3 gap-5 mt-4 mb-8">
|
||||
<div class="grid grid-cols-2 gap-5 mt-4 mb-8">
|
||||
<FormControl
|
||||
type="number"
|
||||
v-model="quiz.max_attempts"
|
||||
:label="__('Maximun Attempts')"
|
||||
/>
|
||||
<FormControl
|
||||
type="number"
|
||||
v-model="quiz.duration"
|
||||
:label="__('Duration (in minutes)')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="quiz.total_marks"
|
||||
:label="__('Total Marks')"
|
||||
@@ -55,7 +74,7 @@
|
||||
|
||||
<!-- Settings -->
|
||||
<div class="mb-8">
|
||||
<div class="text-sm font-semibold mb-4">
|
||||
<div class="font-semibold mb-4">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 my-4">
|
||||
@@ -73,7 +92,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<div class="text-sm font-semibold mb-4">
|
||||
<div class="font-semibold mb-4">
|
||||
{{ __('Shuffle Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3">
|
||||
@@ -93,7 +112,7 @@
|
||||
<!-- Questions -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-sm font-semibold">
|
||||
<div class="font-semibold">
|
||||
{{ __('Questions') }}
|
||||
</div>
|
||||
<Button @click="openQuestionModal()">
|
||||
@@ -213,6 +232,7 @@ const quiz = reactive({
|
||||
total_marks: 0,
|
||||
passing_percentage: 0,
|
||||
max_attempts: 0,
|
||||
duration: 0,
|
||||
limit_questions_to: 0,
|
||||
show_answers: true,
|
||||
show_submission_history: false,
|
||||
|
||||
@@ -57,6 +57,15 @@ export function formatNumberIntoCurrency(number, currency) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// create a function that formats numbers in thousands to k
|
||||
|
||||
export function formatAmount(amount) {
|
||||
if (amount > 999) {
|
||||
return (amount / 1000).toFixed(1) + 'k'
|
||||
}
|
||||
return amount
|
||||
}
|
||||
|
||||
export function convertToTitleCase(str) {
|
||||
if (!str) {
|
||||
return ''
|
||||
@@ -82,10 +91,13 @@ export function getFileSize(file_size) {
|
||||
|
||||
export function showToast(title, text, icon, iconClasses = null) {
|
||||
if (!iconClasses) {
|
||||
iconClasses =
|
||||
icon == 'check'
|
||||
? 'bg-green-600 text-white rounded-md p-px'
|
||||
: 'bg-red-600 text-white rounded-md p-px'
|
||||
if (icon == 'check') {
|
||||
iconClasses = 'bg-green-600 text-white rounded-md p-px'
|
||||
} else if (icon == 'circle-warn') {
|
||||
iconClasses = 'bg-yellow-600 text-white rounded-md p-px'
|
||||
} else {
|
||||
iconClasses = 'bg-red-600 text-white rounded-md p-px'
|
||||
}
|
||||
}
|
||||
createToast({
|
||||
title: title,
|
||||
|
||||
@@ -51,7 +51,7 @@ export class Quiz {
|
||||
app.mount(this.wrapper)
|
||||
return
|
||||
}
|
||||
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center mb-2'>
|
||||
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center bg-gray-50 mb-2'>
|
||||
<span class="font-medium">
|
||||
Quiz: ${quiz}
|
||||
</span>
|
||||
|
||||
@@ -852,6 +852,11 @@
|
||||
dependencies:
|
||||
vue-demi ">=0.14.8"
|
||||
|
||||
ace-builds@^1.36.2:
|
||||
version "1.36.2"
|
||||
resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.36.2.tgz#9499bd59e839a335ac4850e74549ca8d849dc554"
|
||||
integrity sha512-eqqfbGwx/GKjM/EnFu4QtQ+d2NNBu84MGgxoG8R5iyFpcVeQ4p9YlTL+ZzdEJqhdkASqoqOxCSNNGyB6lvMm+A==
|
||||
|
||||
ansi-regex@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
|
||||
@@ -1219,10 +1224,10 @@ fraction.js@^4.3.7:
|
||||
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
|
||||
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
|
||||
|
||||
frappe-ui@^0.1.69:
|
||||
version "0.1.69"
|
||||
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.69.tgz#bfc6d19dff97d2666c36da63f5de62f819539406"
|
||||
integrity sha512-MKHYTcRvmccZwTYlIcmf4OCbJQH5eqKXsq3Cj2lbnmoWuuTh9m7T3AoRKEwOIlZ0mSGCH9yzaF2BINBXGpIJdQ==
|
||||
frappe-ui@^0.1.72:
|
||||
version "0.1.72"
|
||||
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.72.tgz#f5550056ddee7ad4341f2c1825d046404d221820"
|
||||
integrity sha512-XWYKmCjw3ViD+/+tZMUiYqwHFlMGMsVuazOYiN5bKlE+aiheJsnHlOOUyQswYX1Y7jNxuC7gGpSLNg2ZpXA7hA==
|
||||
dependencies:
|
||||
"@headlessui/vue" "^1.7.14"
|
||||
"@popperjs/core" "^2.11.2"
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.8.0"
|
||||
__version__ = "2.10.0"
|
||||
|
||||
@@ -110,7 +110,8 @@ doc_events = {
|
||||
# ---------------
|
||||
scheduler_events = {
|
||||
"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",
|
||||
],
|
||||
"daily": ["lms.job.doctype.job_opportunity.job_opportunity.update_job_openings"],
|
||||
}
|
||||
@@ -185,6 +186,7 @@ jinja = {
|
||||
"lms.lms.utils.get_lesson_url",
|
||||
"lms.page_renderers.get_profile_url",
|
||||
"lms.overrides.user.get_palette",
|
||||
"lms.lms.utils.is_instructor",
|
||||
],
|
||||
"filters": [],
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSJobApplication(FrappeTestCase):
|
||||
class TestLMSJobApplication(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -6,8 +6,9 @@ from frappe.translate import get_all_translations
|
||||
from frappe import _
|
||||
from frappe.query_builder import DocType
|
||||
from frappe.query_builder.functions import Count
|
||||
from frappe.utils import time_diff, now_datetime, get_datetime
|
||||
from frappe.utils import time_diff, now_datetime, get_datetime, flt
|
||||
from typing import Optional
|
||||
from lms.lms.utils import get_average_rating, get_lesson_count
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -293,7 +294,10 @@ def get_branding():
|
||||
image_fields = ["banner_image", "footer_logo", "favicon"]
|
||||
|
||||
for field in image_fields:
|
||||
website_settings.update({field: get_file_info(website_settings.get(field))})
|
||||
if website_settings.get(field):
|
||||
website_settings.update({field: get_file_info(website_settings.get(field))})
|
||||
else:
|
||||
website_settings.update({field: None})
|
||||
|
||||
return website_settings
|
||||
|
||||
@@ -322,7 +326,7 @@ def get_evaluator_details(evaluator):
|
||||
)
|
||||
|
||||
if frappe.db.exists("Course Evaluator", {"evaluator": evaluator}):
|
||||
doc = frappe.get_doc("Course Evaluator", evaluator, as_dict=1)
|
||||
doc = frappe.get_doc("Course Evaluator", evaluator)
|
||||
else:
|
||||
doc = frappe.new_doc("Course Evaluator")
|
||||
doc.evaluator = evaluator
|
||||
@@ -576,14 +580,17 @@ def get_members(start=0, search=""):
|
||||
"""
|
||||
|
||||
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
|
||||
or_filters = {}
|
||||
|
||||
if search:
|
||||
filters["full_name"] = ["like", f"%{search}%"]
|
||||
or_filters["full_name"] = ["like", f"%{search}%"]
|
||||
or_filters["email"] = ["like", f"%{search}%"]
|
||||
|
||||
members = frappe.get_all(
|
||||
"User",
|
||||
filters=filters,
|
||||
fields=["name", "full_name", "user_image", "username", "last_active"],
|
||||
or_filters=or_filters,
|
||||
page_length=20,
|
||||
start=start,
|
||||
)
|
||||
@@ -754,3 +761,44 @@ def get_payment_gateway_details(payment_gateway):
|
||||
"doctype": doctype,
|
||||
"docname": docname,
|
||||
}
|
||||
|
||||
|
||||
def update_course_statistics():
|
||||
courses = frappe.get_all("LMS Course", fields=["name"])
|
||||
|
||||
for course in courses:
|
||||
lessons = get_lesson_count(course.name)
|
||||
|
||||
enrollments = frappe.db.count(
|
||||
"LMS Enrollment", {"course": course.name, "member_type": "Student"}
|
||||
)
|
||||
|
||||
avg_rating = get_average_rating(course.name) or 0
|
||||
avg_rating = flt(avg_rating, frappe.get_system_settings("float_precision") or 3)
|
||||
|
||||
frappe.db.set_value(
|
||||
"LMS Course",
|
||||
course.name,
|
||||
{"lessons": lessons, "enrollments": enrollments, "rating": avg_rating},
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_announcements(batch):
|
||||
return frappe.get_all(
|
||||
"Communication",
|
||||
filters={
|
||||
"reference_doctype": "LMS Batch",
|
||||
"reference_name": batch,
|
||||
},
|
||||
fields=[
|
||||
"subject",
|
||||
"content",
|
||||
"recipients",
|
||||
"cc",
|
||||
"communication_date",
|
||||
"sender",
|
||||
"sender_full_name",
|
||||
],
|
||||
order_by="communication_date desc",
|
||||
)
|
||||
|
||||
@@ -7,17 +7,3 @@ from frappe.model.document import Document
|
||||
|
||||
class BatchStudent(Document):
|
||||
pass
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def enroll_batch(batch_name):
|
||||
if frappe.db.exists(
|
||||
"Batch Student", {"student": frappe.session.user, "parent": batch_name}
|
||||
):
|
||||
frappe.throw("You are already enrolled in this batch")
|
||||
enrollment = frappe.new_doc("Batch Student")
|
||||
enrollment.student = frappe.session.user
|
||||
enrollment.parent = batch_name
|
||||
enrollment.parentfield = "students"
|
||||
enrollment.parenttype = "LMS Batch"
|
||||
enrollment.save(ignore_permissions=True)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestBatchStudent(FrappeTestCase):
|
||||
class TestBatchStudent(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -9,9 +9,8 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"course",
|
||||
"title",
|
||||
"column_break_3",
|
||||
"description",
|
||||
"title",
|
||||
"section_break_5",
|
||||
"lessons"
|
||||
],
|
||||
@@ -35,11 +34,6 @@
|
||||
"fieldname": "column_break_3",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Description"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_5",
|
||||
"fieldtype": "Section Break"
|
||||
@@ -59,7 +53,7 @@
|
||||
"link_fieldname": "chapter"
|
||||
}
|
||||
],
|
||||
"modified": "2023-09-29 17:03:58.013819",
|
||||
"modified": "2024-10-29 16:54:20.904683",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "Course Chapter",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestCourseEvaluator(FrappeTestCase):
|
||||
class TestCourseEvaluator(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -15,20 +15,22 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Assessment Type",
|
||||
"options": "DocType"
|
||||
"options": "DocType",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "assessment_name",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Assessment Name",
|
||||
"options": "assessment_type"
|
||||
"options": "assessment_type",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2023-05-29 14:56:36.602399",
|
||||
"modified": "2024-10-11 19:16:01.630524",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Assessment",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSAssignment(FrappeTestCase):
|
||||
class TestLMSAssignment(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSBadge(FrappeTestCase):
|
||||
class TestLMSBadge(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSBadgeAssignment(FrappeTestCase):
|
||||
class TestLMSBadgeAssignment(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -33,6 +33,7 @@ class LMSBatch(Document):
|
||||
self.validate_timetable()
|
||||
self.send_confirmation_mail()
|
||||
self.validate_evaluation_end_date()
|
||||
self.add_students_to_live_class()
|
||||
|
||||
def validate_batch_end_date(self):
|
||||
if self.end_date < self.start_date:
|
||||
@@ -139,6 +140,27 @@ class LMSBatch(Document):
|
||||
if cint(self.seat_count) < len(self.students):
|
||||
frappe.throw(_("There are no seats available in this batch."))
|
||||
|
||||
def add_students_to_live_class(self):
|
||||
for student in self.students:
|
||||
if student.is_new():
|
||||
live_classes = frappe.get_all(
|
||||
"LMS Live Class", {"batch_name": self.name}, ["name", "event"]
|
||||
)
|
||||
|
||||
for live_class in live_classes:
|
||||
if live_class.event:
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Event Participants",
|
||||
"reference_doctype": "User",
|
||||
"reference_docname": student.student,
|
||||
"email": student.student,
|
||||
"parent": live_class.event,
|
||||
"parenttype": "Event",
|
||||
"parentfield": "event_participants",
|
||||
}
|
||||
).save()
|
||||
|
||||
def validate_timetable(self):
|
||||
for schedule in self.timetable:
|
||||
if schedule.start_time and schedule.end_time:
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSClass(FrappeTestCase):
|
||||
class TestLMSBatch(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
import unittest
|
||||
|
||||
|
||||
class TestLMSBatchTimetable(FrappeTestCase):
|
||||
class TestLMSBatchTimetable(unittest.TestCase):
|
||||
pass
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSCategory(FrappeTestCase):
|
||||
class TestLMSCategory(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSCertificateEvaluation(FrappeTestCase):
|
||||
class TestLMSCertificateEvaluation(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSCertificateRequest(FrappeTestCase):
|
||||
class TestLMSCertificateRequest(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -48,7 +48,12 @@
|
||||
"certification_section",
|
||||
"enable_certification",
|
||||
"column_break_rxww",
|
||||
"expiry"
|
||||
"expiry",
|
||||
"tab_4_tab",
|
||||
"statistics_section",
|
||||
"enrollments",
|
||||
"lessons",
|
||||
"rating"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -249,6 +254,36 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Category",
|
||||
"options": "LMS Category"
|
||||
},
|
||||
{
|
||||
"fieldname": "tab_4_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Statistics"
|
||||
},
|
||||
{
|
||||
"fieldname": "statistics_section",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enrollments",
|
||||
"fieldtype": "Data",
|
||||
"label": "Enrollments",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "lessons",
|
||||
"fieldtype": "Data",
|
||||
"label": "Lessons",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "rating",
|
||||
"fieldtype": "Data",
|
||||
"label": "Rating",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"is_published_field": "published",
|
||||
@@ -275,7 +310,7 @@
|
||||
}
|
||||
],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2024-09-21 10:23:58.633912",
|
||||
"modified": "2024-10-30 23:08:31.842860",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Course",
|
||||
|
||||
@@ -187,192 +187,3 @@ def reindex_exercises(doc):
|
||||
course = frappe.get_doc("LMS Course", course_data["name"])
|
||||
course.reindex_exercises()
|
||||
frappe.msgprint("All exercises in this course have been re-indexed.")
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def search_course(text):
|
||||
courses = frappe.get_all(
|
||||
"LMS Course",
|
||||
filters={"published": True},
|
||||
or_filters={
|
||||
"title": ["like", f"%{text}%"],
|
||||
"tags": ["like", f"%{text}%"],
|
||||
"short_introduction": ["like", f"%{text}%"],
|
||||
"description": ["like", f"%{text}%"],
|
||||
},
|
||||
fields=["name", "title"],
|
||||
)
|
||||
return courses
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def submit_for_review(course):
|
||||
chapters = frappe.get_all("Chapter Reference", {"parent": course})
|
||||
if not len(chapters):
|
||||
return "No Chp"
|
||||
frappe.db.set_value("LMS Course", course, "status", "Under Review")
|
||||
return "OK"
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_course(
|
||||
tags,
|
||||
title,
|
||||
short_introduction,
|
||||
video_link,
|
||||
description,
|
||||
course,
|
||||
published,
|
||||
upcoming,
|
||||
image=None,
|
||||
paid_course=False,
|
||||
course_price=None,
|
||||
currency=None,
|
||||
):
|
||||
if not can_create_courses(course):
|
||||
return
|
||||
|
||||
if course:
|
||||
doc = frappe.get_doc("LMS Course", course)
|
||||
else:
|
||||
doc = frappe.get_doc({"doctype": "LMS Course"})
|
||||
|
||||
doc.update(
|
||||
{
|
||||
"title": title,
|
||||
"short_introduction": short_introduction,
|
||||
"video_link": video_link,
|
||||
"image": image,
|
||||
"description": description,
|
||||
"tags": tags,
|
||||
"published": cint(published),
|
||||
"upcoming": cint(upcoming),
|
||||
"paid_course": cint(paid_course),
|
||||
"course_price": course_price,
|
||||
"currency": currency,
|
||||
}
|
||||
)
|
||||
doc.save(ignore_permissions=True)
|
||||
return doc.name
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_chapter(course, title, chapter_description, idx, chapter):
|
||||
if chapter:
|
||||
doc = frappe.get_doc("Course Chapter", chapter)
|
||||
else:
|
||||
doc = frappe.get_doc({"doctype": "Course Chapter"})
|
||||
|
||||
doc.update({"course": course, "title": title, "description": chapter_description})
|
||||
doc.save(ignore_permissions=True)
|
||||
|
||||
if chapter:
|
||||
chapter_reference = frappe.get_doc("Chapter Reference", {"chapter": chapter})
|
||||
else:
|
||||
chapter_reference = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Chapter Reference",
|
||||
"parent": course,
|
||||
"parenttype": "LMS Course",
|
||||
"parentfield": "chapters",
|
||||
"idx": idx,
|
||||
}
|
||||
)
|
||||
|
||||
chapter_reference.update({"chapter": doc.name})
|
||||
chapter_reference.save(ignore_permissions=True)
|
||||
|
||||
return doc.name
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_lesson(
|
||||
title,
|
||||
body,
|
||||
chapter,
|
||||
preview,
|
||||
idx,
|
||||
lesson,
|
||||
instructor_notes=None,
|
||||
youtube=None,
|
||||
quiz_id=None,
|
||||
question=None,
|
||||
file_type=None,
|
||||
):
|
||||
if lesson:
|
||||
doc = frappe.get_doc("Course Lesson", lesson)
|
||||
else:
|
||||
doc = frappe.get_doc({"doctype": "Course Lesson"})
|
||||
|
||||
doc.update(
|
||||
{
|
||||
"chapter": chapter,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"instructor_notes": instructor_notes,
|
||||
"include_in_preview": preview,
|
||||
"youtube": youtube,
|
||||
"quiz_id": quiz_id,
|
||||
"question": question,
|
||||
"file_type": file_type,
|
||||
}
|
||||
)
|
||||
doc.save(ignore_permissions=True)
|
||||
|
||||
if lesson:
|
||||
lesson_reference = frappe.get_doc("Lesson Reference", {"lesson": lesson})
|
||||
else:
|
||||
lesson_reference = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Lesson Reference",
|
||||
"parent": chapter,
|
||||
"parenttype": "Course Chapter",
|
||||
"parentfield": "lessons",
|
||||
"idx": idx,
|
||||
}
|
||||
)
|
||||
|
||||
lesson_reference.update({"lesson": doc.name})
|
||||
lesson_reference.save(ignore_permissions=True)
|
||||
|
||||
return doc.name
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def reorder_lesson(old_chapter, old_lesson_array, new_chapter, new_lesson_array):
|
||||
if old_chapter == new_chapter:
|
||||
sort_lessons(new_chapter, new_lesson_array)
|
||||
else:
|
||||
sort_lessons(old_chapter, old_lesson_array)
|
||||
sort_lessons(new_chapter, new_lesson_array)
|
||||
|
||||
|
||||
def sort_lessons(chapter, lesson_array):
|
||||
lesson_array = json.loads(lesson_array)
|
||||
for les in lesson_array:
|
||||
ref = frappe.get_all("Lesson Reference", {"lesson": les}, ["name", "idx"])
|
||||
if ref:
|
||||
frappe.db.set_value(
|
||||
"Lesson Reference",
|
||||
ref[0].name,
|
||||
{
|
||||
"parent": chapter,
|
||||
"idx": lesson_array.index(les) + 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def reorder_chapter(chapter_array):
|
||||
chapter_array = json.loads(chapter_array)
|
||||
|
||||
for chap in chapter_array:
|
||||
ref = frappe.get_all("Chapter Reference", {"chapter": chap}, ["name", "idx"])
|
||||
if ref:
|
||||
frappe.db.set_value(
|
||||
"Chapter Reference",
|
||||
ref[0].name,
|
||||
{
|
||||
"idx": chapter_array.index(chap) + 1,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -75,7 +75,8 @@
|
||||
"in_standard_filter": 1,
|
||||
"label": "Course",
|
||||
"options": "LMS Course",
|
||||
"reqd": 1
|
||||
"reqd": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "current_lesson",
|
||||
@@ -126,7 +127,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-05-14 14:50:08.405033",
|
||||
"modified": "2024-10-30 12:44:16.103598",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Enrollment",
|
||||
|
||||
@@ -10,19 +10,20 @@
|
||||
"title",
|
||||
"host",
|
||||
"batch_name",
|
||||
"event",
|
||||
"column_break_astv",
|
||||
"date",
|
||||
"time",
|
||||
"duration",
|
||||
"section_break_glxh",
|
||||
"description",
|
||||
"section_break_glxh",
|
||||
"date",
|
||||
"duration",
|
||||
"column_break_spvt",
|
||||
"time",
|
||||
"timezone",
|
||||
"password",
|
||||
"auto_recording",
|
||||
"section_break_yrpq",
|
||||
"password",
|
||||
"start_url",
|
||||
"column_break_yokr",
|
||||
"auto_recording",
|
||||
"join_url"
|
||||
],
|
||||
"fields": [
|
||||
@@ -122,11 +123,18 @@
|
||||
"fieldtype": "Select",
|
||||
"label": "Auto Recording",
|
||||
"options": "No Recording\nLocal\nCloud"
|
||||
},
|
||||
{
|
||||
"fieldname": "event",
|
||||
"fieldtype": "Link",
|
||||
"label": "Event",
|
||||
"options": "Event",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-01-09 11:22:33.272341",
|
||||
"modified": "2024-10-31 15:41:35.540856",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Live Class",
|
||||
|
||||
@@ -16,6 +16,7 @@ class LMSLiveClass(Document):
|
||||
if calendar:
|
||||
event = self.create_event()
|
||||
self.add_event_participants(event, calendar)
|
||||
frappe.db.set_value(self.doctype, self.name, "event", event.name)
|
||||
|
||||
def create_event(self):
|
||||
start = f"{self.date} {self.time}"
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSLiveClass(FrappeTestCase):
|
||||
class TestLMSLiveClass(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"default": "0",
|
||||
"fieldname": "payment_received",
|
||||
"fieldtype": "Check",
|
||||
"in_standard_filter": 1,
|
||||
"label": "Payment Received"
|
||||
},
|
||||
{
|
||||
@@ -140,7 +141,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2023-10-26 16:54:12.408274",
|
||||
"modified": "2024-10-31 15:33:39.420366",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Payment",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSPayment(FrappeTestCase):
|
||||
class TestLMSPayment(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSQuestion(FrappeTestCase):
|
||||
class TestLMSQuestion(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
"title",
|
||||
"max_attempts",
|
||||
"show_answers",
|
||||
"show_submission_history",
|
||||
"column_break_gaac",
|
||||
"total_marks",
|
||||
"passing_percentage",
|
||||
"show_submission_history",
|
||||
"duration",
|
||||
"section_break_tzbu",
|
||||
"shuffle_questions",
|
||||
"column_break_clsh",
|
||||
@@ -128,11 +129,16 @@
|
||||
{
|
||||
"fieldname": "column_break_clsh",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "duration",
|
||||
"fieldtype": "Duration",
|
||||
"label": "Duration"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-08-09 12:21:36.256522",
|
||||
"modified": "2024-10-11 22:39:40.381183",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Quiz",
|
||||
|
||||
@@ -100,6 +100,16 @@ def quiz_summary(quiz, results):
|
||||
score = 0
|
||||
results = results and json.loads(results)
|
||||
is_open_ended = False
|
||||
percentage = 0
|
||||
|
||||
quiz_details = frappe.db.get_value(
|
||||
"LMS Quiz",
|
||||
quiz,
|
||||
["total_marks", "passing_percentage", "lesson", "course"],
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
score_out_of = quiz_details.total_marks
|
||||
|
||||
for result in results:
|
||||
question_details = frappe.db.get_value(
|
||||
@@ -113,17 +123,6 @@ def quiz_summary(quiz, results):
|
||||
result["question"] = question_details.question_detail
|
||||
result["marks_out_of"] = question_details.marks
|
||||
|
||||
quiz_details = frappe.get_doc(
|
||||
"LMS Quiz",
|
||||
quiz,
|
||||
["total_marks", "passing_percentage", "lesson", "course"],
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
score = 0
|
||||
percentage = 0
|
||||
score_out_of = quiz_details.total_marks
|
||||
|
||||
if question_details.type != "Open Ended":
|
||||
correct = result["is_correct"][0]
|
||||
for point in result["is_correct"]:
|
||||
@@ -135,24 +134,26 @@ def quiz_summary(quiz, results):
|
||||
score += marks
|
||||
|
||||
del result["question_name"]
|
||||
percentage = (score / score_out_of) * 100
|
||||
else:
|
||||
result["is_correct"] = 0
|
||||
is_open_ended = True
|
||||
|
||||
percentage = (score / score_out_of) * 100
|
||||
result["answer"] = re.sub(
|
||||
r'<img[^>]*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, result["answer"]
|
||||
)
|
||||
|
||||
submission = frappe.get_doc(
|
||||
submission = frappe.new_doc("LMS Quiz Submission")
|
||||
# Score and percentage are calculated by the controller function
|
||||
submission.update(
|
||||
{
|
||||
"doctype": "LMS Quiz Submission",
|
||||
"quiz": quiz,
|
||||
"result": results,
|
||||
"score": score,
|
||||
"score": 0,
|
||||
"score_out_of": score_out_of,
|
||||
"member": frappe.session.user,
|
||||
"percentage": percentage,
|
||||
"percentage": 0,
|
||||
"passing_percentage": quiz_details.passing_percentage,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSSidebarItem(FrappeTestCase):
|
||||
class TestLMSSidebarItem(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSSource(FrappeTestCase):
|
||||
class TestLMSSource(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSTimetableLegend(FrappeTestCase):
|
||||
class TestLMSTimetableLegend(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSTimetableTemplate(FrappeTestCase):
|
||||
class TestLMSTimetableTemplate(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestPaymentCountry(FrappeTestCase):
|
||||
class TestPaymentCountry(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestZoomSettings(FrappeTestCase):
|
||||
class TestZoomSettings(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -42,6 +42,10 @@ def get_payment_link(doctype, docname, title, amount, total_amount, currency, ad
|
||||
"redirect_to": redirect_to,
|
||||
"payment": payment.name,
|
||||
}
|
||||
if payment_gateway == "Razorpay":
|
||||
order = controller.create_order(**payment_details)
|
||||
payment_details.update({"order_id": order.get("id")})
|
||||
|
||||
url = controller.get_payment_url(**payment_details)
|
||||
|
||||
return url
|
||||
|
||||
@@ -86,32 +86,32 @@ def get_charts(data):
|
||||
|
||||
completed = 0
|
||||
less_than_hundred = 0
|
||||
less_than_seventy = 0
|
||||
less_than_forty = 0
|
||||
less_than_ten = 0
|
||||
less_than_seventy_one = 0
|
||||
less_than_forty_one = 0
|
||||
less_than_eleven = 0
|
||||
|
||||
for row in data:
|
||||
if row.progress == 100:
|
||||
completed += 1
|
||||
elif row.progress < 100 and row.progress > 70:
|
||||
less_than_hundred += 1
|
||||
elif row.progress < 70 and row.progress > 40:
|
||||
less_than_seventy += 1
|
||||
elif row.progress < 40 and row.progress > 10:
|
||||
less_than_forty += 1
|
||||
elif row.progress < 10:
|
||||
less_than_ten += 1
|
||||
elif row.progress < 71 and row.progress > 40:
|
||||
less_than_seventy_one += 1
|
||||
elif row.progress < 41 and row.progress > 10:
|
||||
less_than_forty_one += 1
|
||||
elif row.progress < 11:
|
||||
less_than_eleven += 1
|
||||
|
||||
charts = {
|
||||
"data": {
|
||||
"labels": ["0-10", "10-40", "40-70", "70-99", "100"],
|
||||
"labels": ["0-10", "11-40", "41-70", "71-99", "100"],
|
||||
"datasets": [
|
||||
{
|
||||
"name": "Progress (%)",
|
||||
"values": [
|
||||
less_than_ten,
|
||||
less_than_forty,
|
||||
less_than_seventy,
|
||||
less_than_eleven,
|
||||
less_than_forty_one,
|
||||
less_than_seventy_one,
|
||||
less_than_hundred,
|
||||
completed,
|
||||
],
|
||||
|
||||
@@ -17,6 +17,7 @@ from frappe.utils import (
|
||||
add_months,
|
||||
cint,
|
||||
cstr,
|
||||
ceil,
|
||||
flt,
|
||||
fmt_money,
|
||||
format_date,
|
||||
@@ -108,7 +109,7 @@ def get_chapters(course):
|
||||
chapter_details = frappe.db.get_value(
|
||||
"Course Chapter",
|
||||
{"name": chapter.chapter},
|
||||
["name", "title", "description"],
|
||||
["name", "title"],
|
||||
as_dict=True,
|
||||
)
|
||||
chapter.update(chapter_details)
|
||||
@@ -156,11 +157,12 @@ def get_lesson_details(chapter, progress=False):
|
||||
"file_type",
|
||||
"instructor_notes",
|
||||
"course",
|
||||
"content",
|
||||
],
|
||||
as_dict=True,
|
||||
)
|
||||
lesson_details.number = f"{chapter.idx}.{row.idx}"
|
||||
lesson_details.icon = get_lesson_icon(lesson_details.body)
|
||||
lesson_details.icon = get_lesson_icon(lesson_details.body, lesson_details.content)
|
||||
|
||||
if progress:
|
||||
lesson_details.is_complete = get_progress(lesson_details.course, lesson_details.name)
|
||||
@@ -169,20 +171,38 @@ def get_lesson_details(chapter, progress=False):
|
||||
return lessons
|
||||
|
||||
|
||||
def get_lesson_icon(content):
|
||||
icon = None
|
||||
macros = find_macros(content)
|
||||
def get_lesson_icon(body, content):
|
||||
if content:
|
||||
content = json.loads(content)
|
||||
|
||||
for block in content.get("blocks"):
|
||||
if block.get("type") == "upload" and block.get("data").get("file_type").lower() in [
|
||||
"mp4",
|
||||
"webm",
|
||||
"ogg",
|
||||
"mov",
|
||||
]:
|
||||
return "icon-youtube"
|
||||
|
||||
if block.get("type") == "embed" and block.get("data").get("service") in [
|
||||
"youtube",
|
||||
"vimeo",
|
||||
]:
|
||||
return "icon-youtube"
|
||||
|
||||
if block.get("type") == "quiz":
|
||||
return "icon-quiz"
|
||||
|
||||
return "icon-list"
|
||||
|
||||
macros = find_macros(body)
|
||||
for macro in macros:
|
||||
if macro[0] == "YouTubeVideo" or macro[0] == "Video":
|
||||
icon = "icon-youtube"
|
||||
return "icon-youtube"
|
||||
elif macro[0] == "Quiz":
|
||||
icon = "icon-quiz"
|
||||
return "icon-quiz"
|
||||
|
||||
if not icon:
|
||||
icon = "icon-list"
|
||||
|
||||
return icon
|
||||
return "icon-list"
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
@@ -948,7 +968,7 @@ def check_multicurrency(amount, currency, country=None, amount_usd=None):
|
||||
if apply_rounding and amount % 100 != 0:
|
||||
amount = amount + 100 - amount % 100
|
||||
|
||||
return amount, currency
|
||||
return ceil(amount), currency
|
||||
|
||||
|
||||
def apply_gst(amount, country=None):
|
||||
@@ -1026,23 +1046,13 @@ def get_course_details(course):
|
||||
"currency",
|
||||
"amount_usd",
|
||||
"enable_certification",
|
||||
"lessons",
|
||||
"enrollments",
|
||||
"rating",
|
||||
],
|
||||
as_dict=1,
|
||||
)
|
||||
course_details.tags = course_details.tags.split(",") if course_details.tags else []
|
||||
course_details.lesson_count = get_lesson_count(course_details.name)
|
||||
|
||||
course_details.enrollment_count = frappe.db.count(
|
||||
"LMS Enrollment", {"course": course_details.name, "member_type": "Student"}
|
||||
)
|
||||
course_details.enrollment_count_formatted = format_number(
|
||||
course_details.enrollment_count
|
||||
)
|
||||
|
||||
avg_rating = get_average_rating(course_details.name) or 0
|
||||
course_details.avg_rating = flt(
|
||||
avg_rating, frappe.get_system_settings("float_precision") or 3
|
||||
)
|
||||
|
||||
course_details.instructors = get_instructors(course_details.name)
|
||||
if course_details.paid_course:
|
||||
@@ -1091,14 +1101,14 @@ def get_categorized_courses(courses):
|
||||
):
|
||||
new.append(course)
|
||||
|
||||
if course.membership and course.published:
|
||||
if course.membership:
|
||||
enrolled.append(course)
|
||||
elif course.is_instructor:
|
||||
created.append(course)
|
||||
|
||||
categories = [live, enrolled, created]
|
||||
for category in categories:
|
||||
category.sort(key=lambda x: x.enrollment_count, reverse=True)
|
||||
category.sort(key=lambda x: x.enrollments, reverse=True)
|
||||
|
||||
live.sort(key=lambda x: x.featured, reverse=True)
|
||||
|
||||
@@ -1123,7 +1133,7 @@ def get_course_outline(course, progress=False):
|
||||
chapter_details = frappe.db.get_value(
|
||||
"Course Chapter",
|
||||
chapter.chapter,
|
||||
["name", "title", "description"],
|
||||
["name", "title"],
|
||||
as_dict=True,
|
||||
)
|
||||
chapter_details["idx"] = chapter.idx
|
||||
@@ -1677,7 +1687,7 @@ def update_payment_record(doctype, docname):
|
||||
if doctype == "LMS Course":
|
||||
enroll_in_course(data.payment, docname)
|
||||
else:
|
||||
enroll_in_batch(data.payment, docname)
|
||||
enroll_in_batch(docname, data.payment)
|
||||
except Exception as e:
|
||||
frappe.log_error(frappe.get_traceback(), _("Enrollment Failed"))
|
||||
|
||||
@@ -1701,25 +1711,33 @@ def enroll_in_course(payment_name, course):
|
||||
enrollment.save(ignore_permissions=True)
|
||||
|
||||
|
||||
def enroll_in_batch(payment_name, batch):
|
||||
@frappe.whitelist()
|
||||
def enroll_in_batch(batch, payment_name=None):
|
||||
if not frappe.db.exists(
|
||||
"Batch Student", {"parent": batch, "student": frappe.session.user}
|
||||
):
|
||||
student = frappe.new_doc("Batch Student")
|
||||
current_count = frappe.db.count("Batch Student", {"parent": batch})
|
||||
payment = frappe.db.get_value(
|
||||
"LMS Payment", payment_name, ["name", "source"], as_dict=True
|
||||
)
|
||||
|
||||
student.update(
|
||||
{
|
||||
"student": frappe.session.user,
|
||||
"payment": payment.name,
|
||||
"source": payment.source,
|
||||
"parent": batch,
|
||||
"parenttype": "LMS Batch",
|
||||
"parentfield": "students",
|
||||
"idx": current_count + 1,
|
||||
}
|
||||
)
|
||||
|
||||
if payment_name:
|
||||
payment = frappe.db.get_value(
|
||||
"LMS Payment", payment_name, ["name", "source"], as_dict=True
|
||||
)
|
||||
student.update(
|
||||
{
|
||||
"payment": payment.name,
|
||||
"source": payment.source,
|
||||
}
|
||||
)
|
||||
|
||||
student.save(ignore_permissions=True)
|
||||
|
||||
5361
lms/locale/ar.po
Normal file
5361
lms/locale/ar.po
Normal file
File diff suppressed because it is too large
Load Diff
5361
lms/locale/bs.po
Normal file
5361
lms/locale/bs.po
Normal file
File diff suppressed because it is too large
Load Diff
5361
lms/locale/de.po
Normal file
5361
lms/locale/de.po
Normal file
File diff suppressed because it is too large
Load Diff
5361
lms/locale/eo.po
Normal file
5361
lms/locale/eo.po
Normal file
File diff suppressed because it is too large
Load Diff
7713
lms/locale/es.po
7713
lms/locale/es.po
File diff suppressed because it is too large
Load Diff
5361
lms/locale/fa.po
Normal file
5361
lms/locale/fa.po
Normal file
File diff suppressed because it is too large
Load Diff
5361
lms/locale/fr.po
Normal file
5361
lms/locale/fr.po
Normal file
File diff suppressed because it is too large
Load Diff
5361
lms/locale/hu.po
Normal file
5361
lms/locale/hu.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
5361
lms/locale/pl.po
Normal file
5361
lms/locale/pl.po
Normal file
File diff suppressed because it is too large
Load Diff
5361
lms/locale/ru.po
Normal file
5361
lms/locale/ru.po
Normal file
File diff suppressed because it is too large
Load Diff
5361
lms/locale/sv.po
Normal file
5361
lms/locale/sv.po
Normal file
File diff suppressed because it is too large
Load Diff
5361
lms/locale/tr.po
Normal file
5361
lms/locale/tr.po
Normal file
File diff suppressed because it is too large
Load Diff
5361
lms/locale/zh.po
Normal file
5361
lms/locale/zh.po
Normal file
File diff suppressed because it is too large
Load Diff
@@ -90,4 +90,5 @@ lms.patches.v1_0.set_published_on
|
||||
lms.patches.v2_0.fix_progress_percentage
|
||||
lms.patches.v2_0.add_discussion_topic_titles
|
||||
lms.patches.v2_0.sidebar_settings
|
||||
lms.patches.v2_0.delete_certificate_request_notification #18-09-2024
|
||||
lms.patches.v2_0.delete_certificate_request_notification #18-09-2024
|
||||
lms.patches.v2_0.add_course_statistics #21-10-2024
|
||||
6
lms/patches/v2_0/add_course_statistics.py
Normal file
6
lms/patches/v2_0/add_course_statistics.py
Normal file
@@ -0,0 +1,6 @@
|
||||
import frappe
|
||||
from lms.lms.api import update_course_statistics
|
||||
|
||||
|
||||
def execute():
|
||||
update_course_statistics()
|
||||
Reference in New Issue
Block a user