Compare commits
100 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 | ||
|
|
736d79b8c9 | ||
|
|
98c0bd5f3e | ||
|
|
8b1d9bb5a9 | ||
|
|
289a0f9122 | ||
|
|
3cd08c80c8 | ||
|
|
3d82c36250 | ||
|
|
9b9af0215a | ||
|
|
2e4cf02737 | ||
|
|
438e9e1c47 | ||
|
|
36ded70eef | ||
|
|
ba78a15a1f | ||
|
|
93061194bb | ||
|
|
6d41e4e552 | ||
|
|
3b06968d0a |
@@ -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 Course").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>
|
||||
|
||||
@@ -107,6 +107,7 @@ const unreadCount = ref(0)
|
||||
const sidebarLinks = ref(getSidebarLinks())
|
||||
const showPageModal = ref(false)
|
||||
const isModerator = ref(false)
|
||||
const isInstructor = ref(false)
|
||||
const pageToEdit = ref(null)
|
||||
const showWebPages = ref(false)
|
||||
|
||||
@@ -167,6 +168,17 @@ const addNotifications = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const addQuizzes = () => {
|
||||
if (isInstructor.value || isModerator.value) {
|
||||
sidebarLinks.value.push({
|
||||
label: 'Quizzes',
|
||||
icon: 'CircleHelp',
|
||||
to: 'Quizzes',
|
||||
activeFor: ['Quizzes', 'QuizForm'],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const openPageModal = (link) => {
|
||||
showPageModal.value = true
|
||||
pageToEdit.value = link
|
||||
@@ -197,6 +209,8 @@ const getSidebarFromStorage = () => {
|
||||
watch(userResource, () => {
|
||||
if (userResource.data) {
|
||||
isModerator.value = userResource.data.is_moderator
|
||||
isInstructor.value = userResource.data.is_instructor
|
||||
addQuizzes()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ const getRowRoute = (row) => {
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
name: 'Quiz',
|
||||
name: 'QuizPage',
|
||||
params: {
|
||||
quizID: row.assessment_name,
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<iframe
|
||||
:src="getPDFSource(block)"
|
||||
width="100%"
|
||||
height="400"
|
||||
height="700px"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="sidebarSettings.data"
|
||||
class="fixed flex justify-around border-t border-gray-300 bottom-0 z-10 w-full bg-white standalone:pb-4"
|
||||
class="fixed flex items-center justify-around border-t border-gray-300 bottom-0 z-10 w-full bg-white standalone:pb-4"
|
||||
:style="{
|
||||
gridTemplateColumns: `repeat(${sidebarLinks.length}, minmax(0, 1fr))`,
|
||||
gridTemplateColumns: `repeat(${
|
||||
sidebarLinks.length + 1
|
||||
}, minmax(0, 1fr))`,
|
||||
}"
|
||||
>
|
||||
<button
|
||||
@@ -23,15 +25,46 @@
|
||||
:class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']"
|
||||
/>
|
||||
</button>
|
||||
<Popover
|
||||
trigger="hover"
|
||||
popoverClass="bottom-28 mx-2"
|
||||
placement="top-start"
|
||||
>
|
||||
<template #target>
|
||||
<component
|
||||
:is="icons['List']"
|
||||
class="h-6 w-6 stroke-1.5 text-gray-600"
|
||||
/>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div class="text-base p-5 space-y-4">
|
||||
<div
|
||||
v-for="link in otherLinks"
|
||||
:key="link.label"
|
||||
class="flex items-center space-x-2"
|
||||
@click="handleClick(link)"
|
||||
>
|
||||
<component
|
||||
:is="icons[link.icon]"
|
||||
class="h-4 w-4 stroke-1.5 text-gray-600"
|
||||
/>
|
||||
<div>
|
||||
{{ link.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { getSidebarLinks } from '../utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { watch, ref, onMounted } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { Popover } from 'frappe-ui'
|
||||
import * as icons from 'lucide-vue-next'
|
||||
|
||||
const { logout, user, sidebarSettings } = sessionStore()
|
||||
@@ -39,6 +72,7 @@ let { isLoggedIn } = sessionStore()
|
||||
const router = useRouter()
|
||||
let { userResource } = usersStore()
|
||||
const sidebarLinks = ref(getSidebarLinks())
|
||||
const otherLinks = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
sidebarSettings.reload(
|
||||
@@ -52,37 +86,53 @@ onMounted(() => {
|
||||
)
|
||||
}
|
||||
})
|
||||
addAccessLinks()
|
||||
|
||||
addOtherLinks()
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const addAccessLinks = () => {
|
||||
const addOtherLinks = () => {
|
||||
if (user) {
|
||||
sidebarLinks.value.push({
|
||||
otherLinks.value.push({
|
||||
label: 'Notifications',
|
||||
icon: 'Bell',
|
||||
to: 'Notifications',
|
||||
})
|
||||
otherLinks.value.push({
|
||||
label: 'Profile',
|
||||
icon: 'UserRound',
|
||||
activeFor: [
|
||||
'Profile',
|
||||
'ProfileAbout',
|
||||
'ProfileCertification',
|
||||
'ProfileEvaluator',
|
||||
'ProfileRoles',
|
||||
],
|
||||
})
|
||||
sidebarLinks.value.push({
|
||||
otherLinks.value.push({
|
||||
label: 'Log out',
|
||||
icon: 'LogOut',
|
||||
})
|
||||
} else {
|
||||
sidebarLinks.value.push({
|
||||
otherLinks.value.push({
|
||||
label: 'Log in',
|
||||
icon: 'LogIn',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(userResource, () => {
|
||||
if (
|
||||
userResource.data &&
|
||||
(userResource.data.is_moderator || userResource.data.is_instructor)
|
||||
) {
|
||||
addQuizzes()
|
||||
}
|
||||
})
|
||||
|
||||
const addQuizzes = () => {
|
||||
otherLinks.value.push({
|
||||
label: 'Quizzes',
|
||||
icon: 'CircleHelp',
|
||||
to: 'Quizzes',
|
||||
})
|
||||
}
|
||||
|
||||
let isActive = (tab) => {
|
||||
return tab.activeFor?.includes(router.currentRoute.value.name)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
:label="__('Type')"
|
||||
v-model="question.type"
|
||||
type="select"
|
||||
:options="['Choices', 'User Input']"
|
||||
:options="['Choices', 'User Input', 'Open Ended']"
|
||||
class="pb-2"
|
||||
/>
|
||||
<div v-if="question.type == 'Choices'" class="divide-y border-t">
|
||||
@@ -74,7 +74,11 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else v-for="n in 4" class="space-y-2">
|
||||
<div
|
||||
v-else-if="question.type == 'User Input'"
|
||||
v-for="n in 4"
|
||||
class="space-y-2"
|
||||
>
|
||||
<FormControl
|
||||
:label="__('Possibility') + ' ' + n"
|
||||
v-model="question[`possibility_${n}`]"
|
||||
|
||||
@@ -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,19 +81,12 @@
|
||||
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>
|
||||
<span v-if="questionDetails.data.type == 'User Input'">
|
||||
{{ __('Type your answer') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{
|
||||
questionDetails.data.multiple
|
||||
? __('Choose all answers that apply')
|
||||
: __('Choose one answer')
|
||||
}}
|
||||
<span>
|
||||
{{ getInstructions(questionDetails.data) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-gray-900 text-sm font-semibold item-left">
|
||||
@@ -139,7 +150,7 @@
|
||||
{{ questionDetails.data[`explanation_${index}`] }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-else-if="questionDetails.data.type == 'User Input'">
|
||||
<FormControl
|
||||
v-model="possibleAnswer"
|
||||
type="textarea"
|
||||
@@ -159,8 +170,18 @@
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-5">
|
||||
<div>
|
||||
<div v-else>
|
||||
<TextEditor
|
||||
class="mt-4"
|
||||
:content="possibleAnswer"
|
||||
@change="(val) => (possibleAnswer = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
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-4">
|
||||
<div class="text-sm text-gray-600">
|
||||
{{
|
||||
__('Question {0} of {1}').format(
|
||||
activeQuestion,
|
||||
@@ -169,7 +190,11 @@
|
||||
}}
|
||||
</div>
|
||||
<Button
|
||||
v-if="quiz.data.show_answers && !showAnswers.length"
|
||||
v-if="
|
||||
quiz.data.show_answers &&
|
||||
!showAnswers.length &&
|
||||
questionDetails.data.type != 'Open Ended'
|
||||
"
|
||||
@click="checkAnswer()"
|
||||
>
|
||||
<span>
|
||||
@@ -193,11 +218,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="border rounded-md p-20 text-center">
|
||||
<div v-else class="border rounded-md p-20 text-center space-y-4">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Quiz Summary') }}
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="quizSubmission.data.is_open_ended">
|
||||
{{
|
||||
__(
|
||||
"Your submission has been successfully saved. The instructor will review and grade it shortly, and you'll be notified of your final result."
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{
|
||||
__(
|
||||
'You got {0}% correct answers with a score of {1} out of {2}'
|
||||
@@ -236,20 +268,29 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Badge, Button, createResource, ListView } 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: {
|
||||
@@ -270,6 +311,7 @@ const quiz = createResource({
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
populateQuestions()
|
||||
setupTimer()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -285,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))
|
||||
@@ -369,6 +442,7 @@ watch(
|
||||
const startQuiz = () => {
|
||||
activeQuestion.value = 1
|
||||
localStorage.removeItem(quiz.data.title)
|
||||
if (quiz.data.duration) startTimer()
|
||||
}
|
||||
|
||||
const markAnswer = (index) => {
|
||||
@@ -450,9 +524,10 @@ const addToLocalStorage = () => {
|
||||
}
|
||||
|
||||
const nextQuetion = () => {
|
||||
if (!quiz.data.show_answers) {
|
||||
if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') {
|
||||
checkAnswer()
|
||||
} else {
|
||||
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
|
||||
resetQuestion()
|
||||
}
|
||||
}
|
||||
@@ -467,7 +542,8 @@ const resetQuestion = () => {
|
||||
|
||||
const submitQuiz = () => {
|
||||
if (!quiz.data.show_answers) {
|
||||
checkAnswer()
|
||||
if (questionDetails.data.type == 'Open Ended') addToLocalStorage()
|
||||
else checkAnswer()
|
||||
setTimeout(() => {
|
||||
createSubmission()
|
||||
}, 500)
|
||||
@@ -477,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 = () => {
|
||||
@@ -488,6 +570,14 @@ const resetQuiz = () => {
|
||||
showAnswers.length = 0
|
||||
quizSubmission.reset()
|
||||
populateQuestions()
|
||||
setupTimer()
|
||||
}
|
||||
|
||||
const getInstructions = (question) => {
|
||||
if (question.type == 'Choices')
|
||||
if (question.multiple) return __('Choose all answers that apply')
|
||||
else return __('Choose one answer')
|
||||
else return __('Type your answer')
|
||||
}
|
||||
|
||||
const getSubmissionColumns = () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
: 'ml-2 w-auto opacity-100'
|
||||
"
|
||||
>
|
||||
{{ link.label }}
|
||||
{{ __(link.label) }}
|
||||
</span>
|
||||
<span v-if="link.count" class="!ml-auto block text-xs text-gray-600">
|
||||
{{ link.count }}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New Batch') }}
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
@@ -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,
|
||||
() => {
|
||||
|
||||
@@ -122,9 +122,18 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="solid" class="mt-8" @click="generatePaymentLink()">
|
||||
{{ __('Proceed to Payment') }}
|
||||
</Button>
|
||||
<div class="flex items-center justify-between border-t pt-4 mt-8">
|
||||
<p class="text-gray-600">
|
||||
{{
|
||||
__(
|
||||
'Make sure to enter the right billing name as the same will be used in your invoice.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<Button variant="solid" size="md" @click="generatePaymentLink()">
|
||||
{{ __('Proceed to Payment') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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-44">
|
||||
<div class="w-40 md:w-44">
|
||||
<FormControl
|
||||
v-if="categories.data?.length"
|
||||
type="select"
|
||||
@@ -17,7 +17,7 @@
|
||||
:placeholder="__('Category')"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-36">
|
||||
<div class="w-28 md:w-36">
|
||||
<FormControl
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
@@ -41,13 +41,14 @@
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
{{ __('New Course') }}
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</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,
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<img
|
||||
:src="badge.badge_image"
|
||||
:alt="badge.badge"
|
||||
class="bg-gray-100 rounded-t-md"
|
||||
class="bg-gray-100 rounded-t-md h-[200px] mx-auto"
|
||||
/>
|
||||
<div class="p-5">
|
||||
<div class="text-2xl font-semibold mb-2">
|
||||
|
||||
@@ -3,14 +3,42 @@
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<Button variant="solid" @click="submitQuiz()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
<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="{
|
||||
name: 'QuizSubmissionList',
|
||||
params: {
|
||||
quizID: quizDetails.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Submission List') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button variant="solid" @click="submitQuiz()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<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
|
||||
@@ -22,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')"
|
||||
@@ -40,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">
|
||||
@@ -58,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">
|
||||
@@ -78,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()">
|
||||
@@ -198,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,
|
||||
@@ -347,17 +382,17 @@ const questionColumns = computed(() => {
|
||||
{
|
||||
label: __('ID'),
|
||||
key: 'question',
|
||||
width: '25%',
|
||||
width: '10rem',
|
||||
},
|
||||
{
|
||||
label: __('Question'),
|
||||
key: __('question_detail'),
|
||||
width: '60%',
|
||||
width: '40rem',
|
||||
},
|
||||
{
|
||||
label: __('Marks'),
|
||||
key: 'marks',
|
||||
width: '10%',
|
||||
width: '5rem',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
58
frontend/src/pages/QuizPage.vue
Normal file
58
frontend/src/pages/QuizPage.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="md:w-7/12 md:mx-auto mx-4 py-10">
|
||||
<Quiz :quizName="quizID" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import Quiz from '@/components/Quiz.vue'
|
||||
import { createResource, Breadcrumbs } from 'frappe-ui'
|
||||
import { computed, inject, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
quizID: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const title = createResource({
|
||||
url: 'frappe.client.get_value',
|
||||
params: {
|
||||
doctype: 'LMS Quiz',
|
||||
fieldname: 'title',
|
||||
filters: {
|
||||
name: props.quizID,
|
||||
},
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [{ label: __('Quiz Submission') }, { label: title.data?.title }]
|
||||
})
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
return {
|
||||
title: title.data?.title,
|
||||
description: __('Quiz Submission'),
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
@@ -2,47 +2,121 @@
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<Breadcrumbs v-if="submisisonDetails.doc" :items="breadcrumbs" />
|
||||
<div class="space-x-2">
|
||||
<Badge
|
||||
v-if="submisisonDetails.isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
<Button variant="solid" @click="saveSubmission()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="w-1/2 mx-auto py-10">
|
||||
<Quiz :quizName="quizID" />
|
||||
<div v-if="submisisonDetails.doc" class="w-1/2 mx-auto py-5 space-y-4">
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<FormControl
|
||||
v-model="submisisonDetails.doc.quiz_title"
|
||||
:label="__('Quiz')"
|
||||
:disabled="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="submisisonDetails.doc.member_name"
|
||||
:label="__('Member')"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<FormControl
|
||||
v-model="submisisonDetails.doc.score"
|
||||
:label="__('Score')"
|
||||
:disabled="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="submisisonDetails.doc.percentage"
|
||||
:label="__('Percentage')"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="row in submisisonDetails.doc.result"
|
||||
class="border p-5 rounded-md space-y-4"
|
||||
>
|
||||
<div class="font-semibold">{{ row.idx }}. {{ row.question }}</div>
|
||||
<div v-html="row.answer" class="leading-5"></div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<FormControl v-model="row.marks" :label="__('Marks')" />
|
||||
<FormControl
|
||||
v-model="row.marks_out_of"
|
||||
:label="__('Marks out of')"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import Quiz from '@/components/Quiz.vue'
|
||||
import { createResource, Breadcrumbs } from 'frappe-ui'
|
||||
import { computed, inject, onMounted } from 'vue'
|
||||
import {
|
||||
createDocumentResource,
|
||||
Breadcrumbs,
|
||||
FormControl,
|
||||
Button,
|
||||
Badge,
|
||||
} from 'frappe-ui'
|
||||
import { computed, onMounted, inject } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from '@/utils'
|
||||
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data) {
|
||||
if (!user.data?.is_instructor && !user.data?.is_moderator)
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
quizID: {
|
||||
submission: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const title = createResource({
|
||||
url: 'frappe.client.get_value',
|
||||
params: {
|
||||
doctype: 'LMS Quiz',
|
||||
fieldname: 'title',
|
||||
filters: {
|
||||
name: props.quizID,
|
||||
},
|
||||
},
|
||||
const submisisonDetails = createDocumentResource({
|
||||
doctype: 'LMS Quiz Submission',
|
||||
name: props.submission,
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [{ label: __('Quiz Submission') }, { label: title.data?.title }]
|
||||
return [
|
||||
{
|
||||
label: __('Quiz Submissions'),
|
||||
route: {
|
||||
name: 'QuizSubmissionList',
|
||||
params: {
|
||||
quizID: submisisonDetails.doc.quiz,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: submisisonDetails.doc.quiz_title,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const saveSubmission = () => {
|
||||
submisisonDetails.save.submit(
|
||||
{},
|
||||
{
|
||||
onError(err) {
|
||||
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
104
frontend/src/pages/QuizSubmissionList.vue
Normal file
104
frontend/src/pages/QuizSubmissionList.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div v-if="submissions.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||
<ListView
|
||||
:columns="quizColumns"
|
||||
:rows="submissions.data"
|
||||
row-key="name"
|
||||
:options="{ showTooltip: false, selectable: false }"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in quizColumns">
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<router-link
|
||||
v-for="row in submissions.data"
|
||||
:to="{
|
||||
name: 'QuizSubmission',
|
||||
params: {
|
||||
submission: row.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListRow :row="row" />
|
||||
</router-link>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
createListResource,
|
||||
Breadcrumbs,
|
||||
ListView,
|
||||
ListRow,
|
||||
ListRows,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
} from 'frappe-ui'
|
||||
import { computed, onMounted, inject } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data?.is_instructor && !user.data?.is_moderator)
|
||||
router.push({ name: 'Courses' })
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
quizID: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const submissions = createListResource({
|
||||
doctype: 'LMS Quiz Submission',
|
||||
filters: {
|
||||
quiz: props.quizID,
|
||||
},
|
||||
fields: ['name', 'member_name', 'score', 'percentage', 'quiz_title'],
|
||||
orderBy: 'creation desc',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const quizColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Member'),
|
||||
key: 'member_name',
|
||||
width: 2,
|
||||
},
|
||||
{
|
||||
label: __('Quiz'),
|
||||
key: 'quiz_title',
|
||||
width: 2,
|
||||
},
|
||||
{
|
||||
label: __('Score'),
|
||||
key: 'score',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
label: __('Percentage'),
|
||||
key: 'percentage',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [{ label: __('Quiz Submissions') }]
|
||||
})
|
||||
</script>
|
||||
@@ -19,7 +19,7 @@
|
||||
</Button>
|
||||
</router-link>
|
||||
</header>
|
||||
<div v-if="quizzes.data?.length" class="w-3/4 mx-auto py-5">
|
||||
<div v-if="quizzes.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||
<ListView
|
||||
:columns="quizColumns"
|
||||
:rows="quizzes.data"
|
||||
|
||||
@@ -160,7 +160,19 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: '/quiz/:quizID',
|
||||
name: 'Quiz',
|
||||
name: 'QuizPage',
|
||||
component: () => import('@/pages/QuizPage.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/quiz-submissions/:quizID',
|
||||
name: 'QuizSubmissionList',
|
||||
component: () => import('@/pages/QuizSubmissionList.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/quiz-submission/:submission',
|
||||
name: 'QuizSubmission',
|
||||
component: () => import('@/pages/QuizSubmission.vue'),
|
||||
props: true,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -56,9 +56,11 @@ export class Upload {
|
||||
app.mount(this.wrapper)
|
||||
return
|
||||
} else if (file.file_type == 'PDF') {
|
||||
this.wrapper.innerHTML = `<iframe src="${encodeURI(
|
||||
this.wrapper.innerHTML = `<iframe src="https://docs.google.com/viewer?url=${
|
||||
window.location.origin
|
||||
}${encodeURI(
|
||||
file.file_url
|
||||
)}#toolbar=0" width='100%' height='700px' class="mb-4"></iframe>`
|
||||
)}&embedded=true" width='100%' height='700px' class="mb-4" type="application/pdf"></iframe>`
|
||||
return
|
||||
} else {
|
||||
this.wrapper.innerHTML = `<img class="mb-4" src=${encodeURI(
|
||||
|
||||
@@ -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.7.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
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"fieldname": "title",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Title",
|
||||
"reqd": 1
|
||||
},
|
||||
@@ -161,7 +162,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-04-03 10:48:17.525859",
|
||||
"modified": "2024-10-08 11:04:54.748773",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "Course Lesson",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Type",
|
||||
"options": "Choices\nUser Input"
|
||||
"options": "Choices\nUser Input\nOpen Ended"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.type == \"Choices\";",
|
||||
@@ -196,7 +196,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2024-08-01 12:53:22.540990",
|
||||
"modified": "2024-10-07 09:41:17.862774",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Question",
|
||||
|
||||
@@ -17,7 +17,7 @@ def validate_correct_answers(question):
|
||||
if question.type == "Choices":
|
||||
validate_duplicate_options(question)
|
||||
validate_correct_options(question)
|
||||
else:
|
||||
elif question.type == "User Input":
|
||||
validate_possible_answer(question)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
|
||||
import json
|
||||
import frappe
|
||||
from frappe import _
|
||||
import re
|
||||
from frappe import _, safe_decode
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cstr, comma_and, cint
|
||||
from fuzzywuzzy import fuzz
|
||||
@@ -13,6 +14,9 @@ from lms.lms.utils import (
|
||||
has_course_moderator_role,
|
||||
has_course_instructor_role,
|
||||
)
|
||||
from binascii import Error as BinasciiError
|
||||
from frappe.utils.file_manager import safe_b64decode
|
||||
from frappe.core.doctype.file.utils import get_random_filename
|
||||
|
||||
|
||||
class LMSQuiz(Document):
|
||||
@@ -20,6 +24,7 @@ class LMSQuiz(Document):
|
||||
self.validate_duplicate_questions()
|
||||
self.validate_limit()
|
||||
self.calculate_total_marks()
|
||||
self.validate_open_ended_questions()
|
||||
|
||||
def validate_duplicate_questions(self):
|
||||
questions = [row.question for row in self.questions]
|
||||
@@ -48,6 +53,19 @@ class LMSQuiz(Document):
|
||||
else:
|
||||
self.total_marks = sum(cint(question.marks) for question in self.questions)
|
||||
|
||||
def validate_open_ended_questions(self):
|
||||
types = [question.type for question in self.questions]
|
||||
types = set(types)
|
||||
|
||||
if "Open Ended" in types and len(types) > 1:
|
||||
frappe.throw(
|
||||
_(
|
||||
"If you want open ended questions then make sure each question in the quiz is of open ended type."
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.show_answers = 0
|
||||
|
||||
def autoname(self):
|
||||
if not self.name:
|
||||
self.name = generate_slug(self.title, "LMS Quiz")
|
||||
@@ -81,44 +99,61 @@ def set_total_marks(questions):
|
||||
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:
|
||||
correct = result["is_correct"][0]
|
||||
for point in result["is_correct"]:
|
||||
correct = correct and point
|
||||
result["is_correct"] = correct
|
||||
|
||||
question_details = frappe.db.get_value(
|
||||
"LMS Quiz Question",
|
||||
{"parent": quiz, "question": result["question_name"]},
|
||||
["question", "marks", "question_detail"],
|
||||
["question", "marks", "question_detail", "type"],
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
result["question_name"] = question_details.question
|
||||
result["question"] = question_details.question_detail
|
||||
marks = question_details.marks if correct else 0
|
||||
result["marks_out_of"] = question_details.marks
|
||||
|
||||
result["marks"] = marks
|
||||
score += marks
|
||||
if question_details.type != "Open Ended":
|
||||
correct = result["is_correct"][0]
|
||||
for point in result["is_correct"]:
|
||||
correct = correct and point
|
||||
result["is_correct"] = correct
|
||||
|
||||
del result["question_name"]
|
||||
marks = question_details.marks if correct else 0
|
||||
result["marks"] = marks
|
||||
score += marks
|
||||
|
||||
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
|
||||
percentage = (score / score_out_of) * 100
|
||||
del result["question_name"]
|
||||
else:
|
||||
result["is_correct"] = 0
|
||||
is_open_ended = True
|
||||
|
||||
submission = frappe.get_doc(
|
||||
percentage = (score / score_out_of) * 100
|
||||
result["answer"] = re.sub(
|
||||
r'<img[^>]*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, result["answer"]
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
@@ -139,128 +174,51 @@ def quiz_summary(quiz, results):
|
||||
"submission": submission.name,
|
||||
"pass": percentage == quiz_details.passing_percentage,
|
||||
"percentage": percentage,
|
||||
"is_open_ended": is_open_ended,
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_quiz(
|
||||
quiz_title,
|
||||
passing_percentage,
|
||||
questions,
|
||||
max_attempts=0,
|
||||
quiz=None,
|
||||
show_answers=1,
|
||||
show_submission_history=0,
|
||||
):
|
||||
if not has_course_moderator_role() or not has_course_instructor_role():
|
||||
return
|
||||
def _save_file(match):
|
||||
data = match.group(1).split("data:")[1]
|
||||
headers, content = data.split(",")
|
||||
mtype = headers.split(";", 1)[0]
|
||||
|
||||
values = {
|
||||
"title": quiz_title,
|
||||
"passing_percentage": passing_percentage,
|
||||
"max_attempts": max_attempts,
|
||||
"show_answers": show_answers,
|
||||
"show_submission_history": show_submission_history,
|
||||
}
|
||||
if isinstance(content, str):
|
||||
content = content.encode("utf-8")
|
||||
if b"," in content:
|
||||
content = content.split(b",")[1]
|
||||
|
||||
try:
|
||||
content = safe_b64decode(content)
|
||||
except BinasciiError:
|
||||
frappe.flags.has_dataurl = True
|
||||
return f'<img src="#broken-image" alt="{get_corrupted_image_msg()}"'
|
||||
|
||||
if "filename=" in headers:
|
||||
filename = headers.split("filename=")[-1]
|
||||
filename = safe_decode(filename).split(";", 1)[0]
|
||||
|
||||
if quiz:
|
||||
frappe.db.set_value("LMS Quiz", quiz, values)
|
||||
update_questions(quiz, questions)
|
||||
return quiz
|
||||
else:
|
||||
doc = frappe.new_doc("LMS Quiz")
|
||||
doc.update(values)
|
||||
doc.save()
|
||||
update_questions(doc.name, questions)
|
||||
return doc.name
|
||||
filename = get_random_filename(content_type=mtype)
|
||||
|
||||
|
||||
def update_questions(quiz, questions):
|
||||
questions = json.loads(questions)
|
||||
|
||||
delete_questions(quiz, questions)
|
||||
add_questions(quiz, questions)
|
||||
frappe.db.set_value("LMS Quiz", quiz, "total_marks", set_total_marks(quiz, questions))
|
||||
|
||||
|
||||
def delete_questions(quiz, questions):
|
||||
existing_questions = frappe.get_all(
|
||||
"LMS Quiz Question",
|
||||
_file = frappe.get_doc(
|
||||
{
|
||||
"parent": quiz,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
current_questions = [question.get("question_name") for question in questions]
|
||||
|
||||
for question in existing_questions:
|
||||
if question not in current_questions:
|
||||
frappe.db.delete("LMS Quiz Question", question)
|
||||
|
||||
|
||||
def add_questions(quiz, questions):
|
||||
for index, question in enumerate(questions):
|
||||
question = frappe._dict(question)
|
||||
if question.question_name:
|
||||
doc = frappe.get_doc("LMS Quiz Question", question.question_name)
|
||||
else:
|
||||
doc = frappe.new_doc("LMS Quiz Question")
|
||||
doc.update(
|
||||
{
|
||||
"parent": quiz,
|
||||
"parenttype": "LMS Quiz",
|
||||
"parentfield": "questions",
|
||||
"idx": index + 1,
|
||||
}
|
||||
)
|
||||
|
||||
doc.update({"question": question.question, "marks": question.marks})
|
||||
|
||||
doc.save()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_question(quiz, values, index):
|
||||
values = frappe._dict(json.loads(values))
|
||||
|
||||
if values.get("name"):
|
||||
doc = frappe.get_doc("LMS Question", values.get("name"))
|
||||
else:
|
||||
doc = frappe.new_doc("LMS Question")
|
||||
|
||||
doc.update(
|
||||
{
|
||||
"question": values.question,
|
||||
"type": values["type"],
|
||||
"doctype": "File",
|
||||
"file_name": filename,
|
||||
"content": content,
|
||||
"decode": False,
|
||||
"is_private": False,
|
||||
}
|
||||
)
|
||||
_file.save(ignore_permissions=True)
|
||||
file_url = _file.unique_url
|
||||
frappe.flags.has_dataurl = True
|
||||
|
||||
for num in range(1, 5):
|
||||
if values.get(f"option_{num}"):
|
||||
doc.update(
|
||||
{
|
||||
f"option_{num}": values[f"option_{num}"],
|
||||
f"is_correct_{num}": values[f"is_correct_{num}"],
|
||||
}
|
||||
)
|
||||
return f'<img src="{file_url}"'
|
||||
|
||||
if values.get(f"explanation_{num}"):
|
||||
doc.update(
|
||||
{
|
||||
f"explanation_{num}": values[f"explanation_{num}"],
|
||||
}
|
||||
)
|
||||
|
||||
if values.get(f"possibility_{num}"):
|
||||
doc.update(
|
||||
{
|
||||
f"possibility_{num}": values[f"possibility_{num}"],
|
||||
}
|
||||
)
|
||||
|
||||
doc.save()
|
||||
return doc.name
|
||||
def get_corrupted_image_msg():
|
||||
return _("Image: Corrupted Data Stream")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -318,9 +276,3 @@ def check_input_answers(question, answer):
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_user_quizzes():
|
||||
filters = {} if has_course_moderator_role() else {"owner": frappe.session.user}
|
||||
return frappe.get_all("LMS Quiz", filters=filters, fields=["name", "title"])
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"column_break_qcpo",
|
||||
"marks",
|
||||
"section_break_huup",
|
||||
"question_detail"
|
||||
"question_detail",
|
||||
"type"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -44,12 +45,21 @@
|
||||
{
|
||||
"fieldname": "section_break_huup",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fetch_from": "question.type",
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Type",
|
||||
"options": "Choices\nUser Input\nOpen Ended",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-07-29 15:10:09.662715",
|
||||
"modified": "2024-10-07 15:01:38.800906",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Quiz Question",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"answer",
|
||||
"column_break_flus",
|
||||
"marks",
|
||||
"marks_out_of",
|
||||
"is_correct"
|
||||
],
|
||||
"fields": [
|
||||
@@ -33,8 +34,7 @@
|
||||
"fieldname": "is_correct",
|
||||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"label": "Is Correct",
|
||||
"read_only": 1
|
||||
"label": "Is Correct"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_fztv",
|
||||
@@ -54,14 +54,20 @@
|
||||
"fieldname": "marks",
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Marks",
|
||||
"label": "Marks"
|
||||
},
|
||||
{
|
||||
"fieldname": "marks_out_of",
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Marks out of",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-05-17 17:38:51.760653",
|
||||
"modified": "2024-10-07 17:28:38.597472",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Quiz Result",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"quiz",
|
||||
"quiz_title",
|
||||
"course",
|
||||
"column_break_3",
|
||||
"member",
|
||||
@@ -39,7 +40,6 @@
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Score",
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
@@ -95,7 +95,6 @@
|
||||
"fieldtype": "Int",
|
||||
"label": "Percentage",
|
||||
"non_negative": 1,
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
@@ -105,12 +104,19 @@
|
||||
"non_negative": 1,
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fetch_from": "quiz.title",
|
||||
"fieldname": "quiz_title",
|
||||
"fieldtype": "Data",
|
||||
"label": "Quiz Title",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-02-27 13:01:53.611726",
|
||||
"modified": "2024-10-07 16:52:04.162521",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Quiz Submission",
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
# Copyright (c) 2021, FOSS United and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint
|
||||
from frappe import _
|
||||
|
||||
|
||||
class LMSQuizSubmission(Document):
|
||||
def before_insert(self):
|
||||
if not self.percentage:
|
||||
self.set_percentage()
|
||||
def validate(self):
|
||||
self.validate_marks()
|
||||
self.set_percentage()
|
||||
|
||||
def validate_marks(self):
|
||||
for row in self.result:
|
||||
if cint(row.marks) > cint(row.marks_out_of):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Marks for question number {0} cannot be greater than the marks allotted for that question."
|
||||
).format(row.idx)
|
||||
)
|
||||
else:
|
||||
self.score += cint(row.marks)
|
||||
|
||||
def set_percentage(self):
|
||||
if self.score and self.score_out_of:
|
||||
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user