Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af0cc7126b | ||
|
|
ed79ea536b | ||
|
|
b3d0aecd14 | ||
|
|
5f43e67c0b | ||
|
|
49a765a9a6 | ||
|
|
4d82bc86e8 | ||
|
|
8fe02b83b8 | ||
|
|
9c9075606b | ||
|
|
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 |
@@ -5,7 +5,7 @@ describe("Course Creation", () => {
|
||||
cy.visit("/lms/courses");
|
||||
|
||||
// Create a course
|
||||
cy.get("a").contains("New Course").click();
|
||||
cy.get("a").contains("New").click();
|
||||
cy.wait(1000);
|
||||
cy.url().should("include", "/courses/new/edit");
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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: {
|
||||
|
||||
@@ -116,7 +116,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 } from '@/utils/'
|
||||
import { capture } from '@/telemetry'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -139,11 +139,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 +159,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 +173,7 @@ function enrollStudent() {
|
||||
lessonNumber: 1,
|
||||
},
|
||||
})
|
||||
}, 3000)
|
||||
}, 2000)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -206,7 +206,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()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<iframe
|
||||
:src="getPDFSource(block)"
|
||||
width="100%"
|
||||
height="400"
|
||||
height="700px"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New Batch') }}
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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-46 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,7 +41,7 @@
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
{{ __('New Course') }}
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
lesson.data.instructor_content &&
|
||||
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
|
||||
allowInstructorContent()
|
||||
"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -82,10 +82,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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.7.0"
|
||||
__version__ = "2.9.0"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -293,7 +293,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 +325,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 +579,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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,7 @@ from frappe.utils import (
|
||||
add_months,
|
||||
cint,
|
||||
cstr,
|
||||
ceil,
|
||||
flt,
|
||||
fmt_money,
|
||||
format_date,
|
||||
@@ -948,7 +949,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):
|
||||
@@ -1677,7 +1678,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 +1702,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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user