Compare commits

...

47 Commits

Author SHA1 Message Date
Jannat Patel
224bb18d3e Merge pull request #1077 from pateljannat/issues-45
fix: show live class start button only to moderators and evaluators
2024-10-23 12:53:42 +05:30
Jannat Patel
aab7bdcc20 fix: show live class start button only to moderators and evaluators 2024-10-23 11:02:16 +05:30
Jannat Patel
c5ca428d98 Merge pull request #1076 from pateljannat/issues-44
fix: misc issues
2024-10-23 10:55:42 +05:30
Frappe PR Bot
af0cc7126b chore(release): Bumped to Version 2.9.0 2024-10-23 05:09:14 +00:00
Jannat Patel
a085050d27 build: removed frappe-ui package 2024-10-23 10:36:26 +05:30
Jannat Patel
2442f35f56 fix: added is_instructor to jinja 2024-10-23 10:35:26 +05:30
Jannat Patel
ed79ea536b Merge pull request #1072 from frappe/pot_develop_2024-10-18
chore: update POT file
2024-10-18 23:06:49 +05:30
frappe-pr-bot
b3d0aecd14 chore: update POT file 2024-10-18 16:04:26 +00:00
Jannat Patel
5f43e67c0b Merge pull request #1068 from pateljannat/payment-issues
fix: batch enrollment after payment completion
2024-10-17 10:39:46 +05:30
Jannat Patel
49a765a9a6 style: fix spacing 2024-10-17 10:31:56 +05:30
Jannat Patel
4d82bc86e8 style: fix spacing 2024-10-17 10:30:06 +05:30
Jannat Patel
8fe02b83b8 fix: batch enrollment after payment completion 2024-10-17 09:27:24 +05:30
Jannat Patel
9c9075606b Merge pull request #1059 from frappe/pot_develop_2024-10-11
chore: update POT file
2024-10-15 19:38:24 +05:30
Jannat Patel
53285a0d19 fix: misc issues 2024-10-14 19:17:32 +05:30
Jannat Patel
9cdeaebb47 Merge pull request #1062 from pateljannat/quiz-timer
feat: timer in quiz
2024-10-14 16:11:55 +05:30
Jannat Patel
a9cb52c68b fix: hide timer instructions if duration is not set 2024-10-14 15:49:27 +05:30
Jannat Patel
f33e950e83 feat: timer in quiz 2024-10-14 14:31:26 +05:30
Jannat Patel
9c9b5963fe Merge pull request #1060 from pateljannat/issues-43
fix: redirect to login before enrollment
2024-10-11 22:33:52 +05:30
Jannat Patel
1597054cc9 fix: redirect to login before enrollment 2024-10-11 22:18:18 +05:30
frappe-pr-bot
deba6aa845 chore: update POT file 2024-10-11 16:04:13 +00:00
Jannat Patel
2d8ba3b84e Merge pull request #1058 from pateljannat/issues-42
fix: batch self enrollment
2024-10-11 19:22:50 +05:30
Jannat Patel
e56b28abad chore: removed unnecessary lines 2024-10-11 19:17:56 +05:30
Jannat Patel
eb350c5a20 fix: batch self enrollment 2024-10-11 19:16:40 +05:30
Jannat Patel
961d5ec77b Merge pull request #1057 from pateljannat/settings-minor-changes
fix: misc ux issues
2024-10-11 16:18:19 +05:30
Jannat Patel
fa566514aa fix: image fetch for settings 2024-10-11 15:32:41 +05:30
Jannat Patel
6e97449bf7 fix: misc ux issues 2024-10-11 13:39:30 +05:30
Jannat Patel
016dafb3c3 Merge pull request #1056 from pateljannat/issues-41
fix: misc issues
2024-10-10 16:43:59 +05:30
Jannat Patel
675bcc8956 test: replaced FrappeTestCase with UnitTestCase 2024-10-10 16:20:53 +05:30
Jannat Patel
aba4c034fc fix: misc issues 2024-10-10 14:48:59 +05:30
Jannat Patel
c76d8c582f Merge pull request #1052 from pateljannat/issues-40
fix: misc quiz issues
2024-10-09 19:17:01 +05:30
Jannat Patel
f1cb0e6f3c fix: usd conversion 2024-10-09 19:07:25 +05:30
Jannat Patel
d296687456 fix: misc quiz issues 2024-10-09 16:03:56 +05:30
Jannat Patel
5b68001c94 Merge pull request #1049 from pateljannat/issues-39
fix: create order for razorpay
2024-10-09 11:59:57 +05:30
Frappe PR Bot
736d79b8c9 chore(release): Bumped to Version 2.8.0 2024-10-09 06:04:56 +00:00
Jannat Patel
98c0bd5f3e Merge pull request #1042 from frappe/pot_develop_2024-10-04
chore: update POT file
2024-10-09 11:34:01 +05:30
Jannat Patel
8b1d9bb5a9 fix: create order for razorpay 2024-10-09 11:31:31 +05:30
Jannat Patel
289a0f9122 Merge pull request #1046 from pateljannat/issues-38
fix: quiz columns
2024-10-08 16:27:04 +05:30
Jannat Patel
3cd08c80c8 fix: reduced with of marks column 2024-10-08 16:09:21 +05:30
Jannat Patel
3d82c36250 fix: quiz columns 2024-10-08 16:03:37 +05:30
Jannat Patel
9b9af0215a Merge pull request #1045 from pateljannat/issues-37
fix: using google docs viewer to render pdf
2024-10-08 12:37:19 +05:30
Jannat Patel
2e4cf02737 fix: using google docs viewer to render pdf 2024-10-08 12:00:51 +05:30
Jannat Patel
438e9e1c47 Merge pull request #1044 from pateljannat/open-ended-questions
feat: open ended questions
2024-10-08 10:37:44 +05:30
Jannat Patel
36ded70eef fix: only allow instructor and moderator on submission page 2024-10-08 10:21:45 +05:30
Jannat Patel
ba78a15a1f fix: ui test button label 2024-10-08 10:14:04 +05:30
Jannat Patel
93061194bb fix: error toast when saving marks 2024-10-08 10:13:07 +05:30
Jannat Patel
6d41e4e552 feat: open ended questions 2024-10-07 21:18:42 +05:30
frappe-pr-bot
3b06968d0a chore: update POT file 2024-10-04 16:04:32 +00:00
75 changed files with 1507 additions and 669 deletions

View File

@@ -5,7 +5,7 @@ describe("Course Creation", () => {
cy.visit("/lms/courses"); cy.visit("/lms/courses");
// Create a course // Create a course
cy.get("a").contains("New Course").click(); cy.get("a").contains("New").click();
cy.wait(1000); cy.wait(1000);
cy.url().should("include", "/courses/new/edit"); cy.url().should("include", "/courses/new/edit");

View File

@@ -18,6 +18,7 @@
"@editorjs/nested-list": "^1.4.2", "@editorjs/nested-list": "^1.4.2",
"@editorjs/paragraph": "^2.11.3", "@editorjs/paragraph": "^2.11.3",
"@editorjs/simple-image": "^1.6.0", "@editorjs/simple-image": "^1.6.0",
"ace-builds": "^1.36.2",
"chart.js": "^4.4.1", "chart.js": "^4.4.1",
"codemirror-editor-vue3": "^2.8.0", "codemirror-editor-vue3": "^2.8.0",
"dayjs": "^1.11.6", "dayjs": "^1.11.6",

View File

@@ -107,6 +107,7 @@ const unreadCount = ref(0)
const sidebarLinks = ref(getSidebarLinks()) const sidebarLinks = ref(getSidebarLinks())
const showPageModal = ref(false) const showPageModal = ref(false)
const isModerator = ref(false) const isModerator = ref(false)
const isInstructor = ref(false)
const pageToEdit = ref(null) const pageToEdit = ref(null)
const showWebPages = ref(false) 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) => { const openPageModal = (link) => {
showPageModal.value = true showPageModal.value = true
pageToEdit.value = link pageToEdit.value = link
@@ -197,6 +209,8 @@ const getSidebarFromStorage = () => {
watch(userResource, () => { watch(userResource, () => {
if (userResource.data) { if (userResource.data) {
isModerator.value = userResource.data.is_moderator isModerator.value = userResource.data.is_moderator
isInstructor.value = userResource.data.is_instructor
addQuizzes()
} }
}) })

View File

@@ -160,7 +160,7 @@ const getRowRoute = (row) => {
} }
} else { } else {
return { return {
name: 'Quiz', name: 'QuizPage',
params: { params: {
quizID: row.assessment_name, quizID: row.assessment_name,
}, },

View File

@@ -56,7 +56,6 @@ const props = defineProps({
onMounted(() => { onMounted(() => {
setTimeout(() => { setTimeout(() => {
audio.value = document.querySelector('audio') audio.value = document.querySelector('audio')
console.log(audio.value)
audio.value.onloadedmetadata = () => { audio.value.onloadedmetadata = () => {
duration.value = audio.value.duration duration.value = audio.value.duration
} }

View File

@@ -75,6 +75,7 @@
variant="solid" variant="solid"
class="w-full mt-2" class="w-full mt-2"
v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left" v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left"
@click="enrollInBatch()"
> >
{{ __('Enroll Now') }} {{ __('Enroll Now') }}
</Button> </Button>
@@ -97,11 +98,13 @@
</template> </template>
<script setup> <script setup>
import { inject, computed } from 'vue' 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 { 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 DateRange from '@/components/Common/DateRange.vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const user = inject('$user') const user = inject('$user')
const props = defineProps({ 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(() => { const seats_left = computed(() => {
if (props.batch.data?.seat_count) { if (props.batch.data?.seat_count) {
return props.batch.data?.seat_count - props.batch.data?.students?.length return props.batch.data?.seat_count - props.batch.data?.students?.length

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="flex flex-col justify-between h-full"> <div class="flex flex-col justify-between min-h-0">
<div> <div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="font-semibold mb-1"> <div class="font-semibold mb-1">
@@ -16,6 +16,7 @@
{{ __(description) }} {{ __(description) }}
</div> </div>
</div> </div>
<div class="overflow-y-auto">
<SettingFields :fields="fields" :data="data.data" /> <SettingFields :fields="fields" :data="data.data" />
<div class="flex flex-row-reverse mt-auto"> <div class="flex flex-row-reverse mt-auto">
<Button variant="solid" :loading="saveSettings.loading" @click="update"> <Button variant="solid" :loading="saveSettings.loading" @click="update">
@@ -23,6 +24,7 @@
</Button> </Button>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { createResource, Button, Badge } from 'frappe-ui' import { createResource, Button, Badge } from 'frappe-ui'
@@ -70,9 +72,16 @@ const update = () => {
fieldsToSave[f.name] = f.value fieldsToSave[f.name] = f.value
} }
}) })
saveSettings.submit({ saveSettings.submit(
{
fields: fieldsToSave, fields: fieldsToSave,
}) },
{
onSuccess(data) {
isDirty.value = false
},
}
)
} }
watch(props.data, (newData) => { watch(props.data, (newData) => {

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

View File

@@ -152,24 +152,11 @@ const filterOptions = createResource({
url: 'frappe.desk.search.search_link', url: 'frappe.desk.search.search_link',
method: 'POST', method: 'POST',
cache: [text.value, props.doctype], cache: [text.value, props.doctype],
auto: true,
params: { params: {
txt: text.value, txt: text.value,
doctype: props.doctype, 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(() => { const options = computed(() => {

View File

@@ -21,7 +21,7 @@
<script setup> <script setup>
import { Star } from 'lucide-vue-next' import { Star } from 'lucide-vue-next'
import { computed, ref, watch } from 'vue' import { ref, watch } from 'vue'
const props = defineProps({ const props = defineProps({
id: { id: {

View File

@@ -116,7 +116,7 @@
import { BookOpen, Users, Star } from 'lucide-vue-next' import { BookOpen, Users, Star } from 'lucide-vue-next'
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
import { Button, createResource } from 'frappe-ui' import { Button, createResource } from 'frappe-ui'
import { createToast } from '@/utils/' import { showToast } from '@/utils/'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -139,11 +139,11 @@ const video_link = computed(() => {
function enrollStudent() { function enrollStudent() {
if (!user.data) { if (!user.data) {
createToast({ showToast(
title: 'Please Login', __('Please Login'),
icon: 'alert-circle', __('You need to login first to enroll for this course'),
iconClasses: 'text-yellow-600 bg-yellow-100', 'circle-warn'
}) )
setTimeout(() => { setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}` window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 2000) }, 2000)
@@ -159,11 +159,11 @@ function enrollStudent() {
capture('enrolled_in_course', { capture('enrolled_in_course', {
course: props.course.data.name, course: props.course.data.name,
}) })
createToast({ showToast(
title: 'Enrolled Successfully', __('Success'),
icon: 'check', __('You have been enrolled in this course'),
iconClasses: 'text-green-600 bg-green-100', 'check'
}) )
setTimeout(() => { setTimeout(() => {
router.push({ router.push({
name: 'Lesson', name: 'Lesson',
@@ -173,7 +173,7 @@ function enrollStudent() {
lessonNumber: 1, lessonNumber: 1,
}, },
}) })
}, 3000) }, 2000)
}) })
} }
} }
@@ -206,7 +206,6 @@ const certificate = createResource({
} }
}, },
onSuccess(data) { onSuccess(data) {
console.log(data)
window.open( window.open(
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${ `/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
data.name data.name

View File

@@ -76,7 +76,7 @@
<Trash2 <Trash2
v-if="allowEdit" v-if="allowEdit"
@click.prevent="trashLesson(lesson.name, chapter.name)" @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 <Check
v-if="lesson.is_complete" v-if="lesson.is_complete"
@@ -119,7 +119,7 @@
</template> </template>
<script setup> <script setup>
import { Button, createResource } from 'frappe-ui' import { Button, createResource } from 'frappe-ui'
import { ref } from 'vue' import { ref, getCurrentInstance } from 'vue'
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue' import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import { import {
@@ -138,6 +138,8 @@ const route = useRoute()
const expandAll = ref(true) const expandAll = ref(true)
const showChapterModal = ref(false) const showChapterModal = ref(false)
const currentChapter = ref(null) const currentChapter = ref(null)
const app = getCurrentInstance()
const { $dialog } = app.appContext.config.globalProperties
const props = defineProps({ const props = defineProps({
courseName: { courseName: {
@@ -202,10 +204,24 @@ const updateLessonIndex = createResource({
}) })
const trashLesson = (lessonName, chapterName) => { const trashLesson = (lessonName, 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({ deleteLesson.submit({
lesson: lessonName, lesson: lessonName,
chapter: chapterName, chapter: chapterName,
}) })
close()
},
},
],
})
} }
const openChapterDetail = (index) => { const openChapterDetail = (index) => {

View File

@@ -37,7 +37,7 @@
<iframe <iframe
:src="getPDFSource(block)" :src="getPDFSource(block)"
width="100%" width="100%"
height="400" height="700px"
frameborder="0" frameborder="0"
allowfullscreen allowfullscreen
></iframe> ></iframe>

View File

@@ -37,6 +37,7 @@
</div> </div>
<div class="flex items-center space-x-2 text-gray-900 mt-auto"> <div class="flex items-center space-x-2 text-gray-900 mt-auto">
<a <a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url" :href="cls.start_url"
target="_blank" 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-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"

View File

@@ -5,9 +5,11 @@
</div> </div>
<div <div
v-if="sidebarSettings.data" 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="{ :style="{
gridTemplateColumns: `repeat(${sidebarLinks.length}, minmax(0, 1fr))`, gridTemplateColumns: `repeat(${
sidebarLinks.length + 1
}, minmax(0, 1fr))`,
}" }"
> >
<button <button
@@ -23,15 +25,46 @@
:class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']" :class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']"
/> />
</button> </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>
</div> </div>
</template> </template>
<script setup> <script setup>
import { getSidebarLinks } from '../utils' import { getSidebarLinks } from '../utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { computed, ref, onMounted } from 'vue' import { watch, ref, onMounted } from 'vue'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { usersStore } from '@/stores/user' import { usersStore } from '@/stores/user'
import { Popover } from 'frappe-ui'
import * as icons from 'lucide-vue-next' import * as icons from 'lucide-vue-next'
const { logout, user, sidebarSettings } = sessionStore() const { logout, user, sidebarSettings } = sessionStore()
@@ -39,6 +72,7 @@ let { isLoggedIn } = sessionStore()
const router = useRouter() const router = useRouter()
let { userResource } = usersStore() let { userResource } = usersStore()
const sidebarLinks = ref(getSidebarLinks()) const sidebarLinks = ref(getSidebarLinks())
const otherLinks = ref([])
onMounted(() => { onMounted(() => {
sidebarSettings.reload( sidebarSettings.reload(
@@ -52,37 +86,53 @@ onMounted(() => {
) )
} }
}) })
addAccessLinks()
addOtherLinks()
}, },
} }
) )
}) })
const addAccessLinks = () => { const addOtherLinks = () => {
if (user) { if (user) {
sidebarLinks.value.push({ otherLinks.value.push({
label: 'Notifications',
icon: 'Bell',
to: 'Notifications',
})
otherLinks.value.push({
label: 'Profile', label: 'Profile',
icon: 'UserRound', icon: 'UserRound',
activeFor: [
'Profile',
'ProfileAbout',
'ProfileCertification',
'ProfileEvaluator',
'ProfileRoles',
],
}) })
sidebarLinks.value.push({ otherLinks.value.push({
label: 'Log out', label: 'Log out',
icon: 'LogOut', icon: 'LogOut',
}) })
} else { } else {
sidebarLinks.value.push({ otherLinks.value.push({
label: 'Log in', label: 'Log in',
icon: 'LogIn', 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) => { let isActive = (tab) => {
return tab.activeFor?.includes(router.currentRoute.value.name) return tab.activeFor?.includes(router.currentRoute.value.name)
} }

View File

@@ -54,7 +54,7 @@
:label="__('Type')" :label="__('Type')"
v-model="question.type" v-model="question.type"
type="select" type="select"
:options="['Choices', 'User Input']" :options="['Choices', 'User Input', 'Open Ended']"
class="pb-2" class="pb-2"
/> />
<div v-if="question.type == 'Choices'" class="divide-y border-t"> <div v-if="question.type == 'Choices'" class="divide-y border-t">
@@ -74,7 +74,11 @@
/> />
</div> </div>
</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 <FormControl
:label="__('Possibility') + ' ' + n" :label="__('Possibility') + ' ' + n"
v-model="question[`possibility_${n}`]" v-model="question[`possibility_${n}`]"

View File

@@ -179,26 +179,6 @@ const tabsStructure = computed(() => {
name: 'app_name', name: 'app_name',
type: 'text', 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', label: 'Logo',
name: 'banner_image', name: 'banner_image',
@@ -214,6 +194,23 @@ const tabsStructure = computed(() => {
name: 'footer_logo', name: 'footer_logo',
type: 'Upload', 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, rows: 10,
}, },
{ {
label: 'Ask user category', label: 'Ask for Occupation',
name: 'user_category', name: 'user_category',
type: 'checkbox', type: 'checkbox',
description:
'Enable this option to ask users to select their occupation during the signup process.',
}, },
], ],
}, },

View File

@@ -1,11 +1,27 @@
<template> <template>
<div v-if="quiz.data"> <div v-if="quiz.data">
<div class="bg-blue-100 py-2 px-2 mb-4 rounded-md text-sm text-blue-800"> <div
<div class="leading-relaxed"> 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) __('This quiz consists of {0} questions.').format(questions.length)
}} }}
</div> </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"> <div v-if="quiz.data.passing_percentage" class="leading-relaxed">
{{ {{
__( __(
@@ -22,14 +38,16 @@
) )
}} }}
</div> </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>
<div v-if="activeQuestion == 0"> <div v-if="activeQuestion == 0">
<div class="border text-center p-20 rounded-md"> <div class="border text-center p-20 rounded-md">
<div class="font-semibold text-lg"> <div class="font-semibold text-lg">
@@ -63,19 +81,12 @@
class="border rounded-md p-5" class="border rounded-md p-5"
> >
<div class="flex justify-between"> <div class="flex justify-between">
<div class="text-sm"> <div class="text-sm text-gray-600">
<span class="mr-2"> <span class="mr-2">
{{ __('Question {0}').format(activeQuestion) }}: {{ __('Question {0}').format(activeQuestion) }}:
</span> </span>
<span v-if="questionDetails.data.type == 'User Input'"> <span>
{{ __('Type your answer') }} {{ getInstructions(questionDetails.data) }}
</span>
<span v-else>
{{
questionDetails.data.multiple
? __('Choose all answers that apply')
: __('Choose one answer')
}}
</span> </span>
</div> </div>
<div class="text-gray-900 text-sm font-semibold item-left"> <div class="text-gray-900 text-sm font-semibold item-left">
@@ -139,7 +150,7 @@
{{ questionDetails.data[`explanation_${index}`] }} {{ questionDetails.data[`explanation_${index}`] }}
</div> </div>
</div> </div>
<div v-else> <div v-else-if="questionDetails.data.type == 'User Input'">
<FormControl <FormControl
v-model="possibleAnswer" v-model="possibleAnswer"
type="textarea" type="textarea"
@@ -159,8 +170,18 @@
</Badge> </Badge>
</div> </div>
</div> </div>
<div class="flex items-center justify-between mt-5"> <div v-else>
<div> <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( __('Question {0} of {1}').format(
activeQuestion, activeQuestion,
@@ -169,7 +190,11 @@
}} }}
</div> </div>
<Button <Button
v-if="quiz.data.show_answers && !showAnswers.length" v-if="
quiz.data.show_answers &&
!showAnswers.length &&
questionDetails.data.type != 'Open Ended'
"
@click="checkAnswer()" @click="checkAnswer()"
> >
<span> <span>
@@ -193,11 +218,18 @@
</div> </div>
</div> </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"> <div class="text-lg font-semibold">
{{ __('Quiz Summary') }} {{ __('Quiz Summary') }}
</div> </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}' 'You got {0}% correct answers with a score of {1} out of {2}'
@@ -236,20 +268,29 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { Badge, Button, createResource, ListView } from 'frappe-ui' import {
import { ref, watch, reactive, inject } from 'vue' Badge,
Button,
createResource,
ListView,
TextEditor,
FormControl,
} from 'frappe-ui'
import { ref, watch, reactive, inject, computed } from 'vue'
import { createToast } from '@/utils/' import { createToast } from '@/utils/'
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next' import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
import { timeAgo } from '@/utils' import { timeAgo } from '@/utils'
import FormControl from 'frappe-ui/src/components/FormControl.vue' import ProgressBar from '@/components/ProgressBar.vue'
const user = inject('$user')
const user = inject('$user')
const activeQuestion = ref(0) const activeQuestion = ref(0)
const currentQuestion = ref('') const currentQuestion = ref('')
const selectedOptions = reactive([0, 0, 0, 0]) const selectedOptions = reactive([0, 0, 0, 0])
const showAnswers = reactive([]) const showAnswers = reactive([])
let questions = reactive([]) let questions = reactive([])
const possibleAnswer = ref(null) const possibleAnswer = ref(null)
const timer = ref(0)
let timerInterval = null
const props = defineProps({ const props = defineProps({
quizName: { quizName: {
@@ -270,6 +311,7 @@ const quiz = createResource({
auto: true, auto: true,
onSuccess(data) { onSuccess(data) {
populateQuestions() 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) => { const shuffleArray = (array) => {
for (let i = array.length - 1; i > 0; i--) { for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)) const j = Math.floor(Math.random() * (i + 1))
@@ -369,6 +442,7 @@ watch(
const startQuiz = () => { const startQuiz = () => {
activeQuestion.value = 1 activeQuestion.value = 1
localStorage.removeItem(quiz.data.title) localStorage.removeItem(quiz.data.title)
if (quiz.data.duration) startTimer()
} }
const markAnswer = (index) => { const markAnswer = (index) => {
@@ -450,9 +524,10 @@ const addToLocalStorage = () => {
} }
const nextQuetion = () => { const nextQuetion = () => {
if (!quiz.data.show_answers) { if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') {
checkAnswer() checkAnswer()
} else { } else {
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
resetQuestion() resetQuestion()
} }
} }
@@ -467,7 +542,8 @@ const resetQuestion = () => {
const submitQuiz = () => { const submitQuiz = () => {
if (!quiz.data.show_answers) { if (!quiz.data.show_answers) {
checkAnswer() if (questionDetails.data.type == 'Open Ended') addToLocalStorage()
else checkAnswer()
setTimeout(() => { setTimeout(() => {
createSubmission() createSubmission()
}, 500) }, 500)
@@ -477,9 +553,15 @@ const submitQuiz = () => {
} }
const createSubmission = () => { const createSubmission = () => {
quizSubmission.reload().then(() => { quizSubmission.submit(
{},
{
onSuccess(data) {
if (quiz.data && quiz.data.max_attempts) attempts.reload() if (quiz.data && quiz.data.max_attempts) attempts.reload()
}) if (quiz.data.duration) clearInterval(timerInterval)
},
}
)
} }
const resetQuiz = () => { const resetQuiz = () => {
@@ -488,6 +570,14 @@ const resetQuiz = () => {
showAnswers.length = 0 showAnswers.length = 0
quizSubmission.reset() quizSubmission.reset()
populateQuestions() 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 = () => { const getSubmissionColumns = () => {

View File

@@ -2,7 +2,7 @@
<div class="flex flex-col justify-between h-full"> <div class="flex flex-col justify-between h-full">
<div> <div>
<div class="flex itemsc-center justify-between"> <div class="flex itemsc-center justify-between">
<div class="font-semibold mb-1"> <div class="text-xl font-semibold leading-none mb-1">
{{ __(label) }} {{ __(label) }}
</div> </div>
<Badge <Badge

View File

@@ -17,17 +17,16 @@
/> />
<div v-else-if="field.type == 'Code'"> <div v-else-if="field.type == 'Code'">
<div> <CodeEditor
{{ __(field.label) }} :label="__(field.label)"
</div> type="HTML"
<Codemirror description="The HTML you add here will be shown on your sign up page."
v-model:value="data[field.name]" v-model="data[field.name]"
:height="200" height="250px"
:options="{ class="shrink-0"
mode: field.mode, :showLineNumbers="true"
theme: 'seti', >
}" </CodeEditor>
/>
</div> </div>
<div v-else-if="field.type == 'Upload'"> <div v-else-if="field.type == 'Upload'">
@@ -53,9 +52,11 @@
</template> </template>
</FileUploader> </FileUploader>
<div v-else> <div v-else>
<div class="flex items-center text-sm"> <div class="flex items-center text-sm space-x-2">
<div class="border rounded-md p-2 mr-2"> <div
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" /> 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>
<div class="flex flex-col flex-wrap"> <div class="flex flex-col flex-wrap">
<span class="break-all"> <span class="break-all">
@@ -73,6 +74,14 @@
</div> </div>
</div> </div>
<Switch
v-else-if="field.type == 'checkbox'"
size="sm"
:label="__(field.label)"
:description="__(field.description)"
v-model="data[field.name]"
/>
<FormControl <FormControl
v-else v-else
:key="field.name" :key="field.name"
@@ -88,14 +97,12 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { FormControl, FileUploader, Button } from 'frappe-ui' import { FormControl, FileUploader, Button, Switch } from 'frappe-ui'
import { computed } from 'vue' import { computed } from 'vue'
import { getFileSize, validateFile } from '@/utils' import { getFileSize, validateFile } from '@/utils'
import { X, FileText } from 'lucide-vue-next' import { X, FileText } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import Codemirror from 'codemirror-editor-vue3' import CodeEditor from '@/components/Controls/CodeEditor.vue'
import 'codemirror/theme/seti.css'
import 'codemirror/mode/htmlmixed/htmlmixed.js'
const props = defineProps({ const props = defineProps({
fields: { fields: {

View File

@@ -27,7 +27,7 @@
: 'ml-2 w-auto opacity-100' : 'ml-2 w-auto opacity-100'
" "
> >
{{ link.label }} {{ __(link.label) }}
</span> </span>
<span v-if="link.count" class="!ml-auto block text-xs text-gray-600"> <span v-if="link.count" class="!ml-auto block text-xs text-gray-600">
{{ link.count }} {{ link.count }}

View File

@@ -3,13 +3,14 @@
<video <video
@timeupdate="updateTime" @timeupdate="updateTime"
@ended="videoEnded" @ended="videoEnded"
class="rounded-lg border border-gray-100" @click="togglePlay"
class="rounded-lg border border-gray-100 group cursor-pointer"
ref="videoRef" ref="videoRef"
> >
<source :src="fileURL" :type="type" /> <source :src="fileURL" :type="type" />
</video> </video>
<div <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"> <Button variant="ghost">
<template #icon> <template #icon>
@@ -106,6 +107,14 @@ const pauseVideo = () => {
playing.value = false playing.value = false
} }
const togglePlay = () => {
if (playing.value) {
pauseVideo()
} else {
playVideo()
}
}
const videoEnded = () => { const videoEnded = () => {
playing.value = false playing.value = false
} }

View File

@@ -5,6 +5,7 @@ import router from './router'
import App from './App.vue' import App from './App.vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import dayjs from '@/utils/dayjs' import dayjs from '@/utils/dayjs'
import { createDialog } from '@/utils/dialogs'
import translationPlugin from './translation' import translationPlugin from './translation'
import { usersStore } from './stores/user' import { usersStore } from './stores/user'
import { sessionStore } from './stores/session' import { sessionStore } from './stores/session'
@@ -36,3 +37,4 @@ let { isLoggedIn } = sessionStore()
app.provide('$user', userResource) app.provide('$user', userResource)
app.provide('$allUsers', allUsers) app.provide('$allUsers', allUsers)
app.config.globalProperties.$user = userResource app.config.globalProperties.$user = userResource
app.config.globalProperties.$dialog = createDialog

View File

@@ -27,7 +27,7 @@
<template #prefix> <template #prefix>
<Plus class="h-4 w-4 stroke-1.5" /> <Plus class="h-4 w-4 stroke-1.5" />
</template> </template>
{{ __('New Batch') }} {{ __('New') }}
</Button> </Button>
</router-link> </router-link>
</div> </div>

View File

@@ -122,12 +122,21 @@
/> />
</div> </div>
</div> </div>
<Button variant="solid" class="mt-8" @click="generatePaymentLink()"> <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') }} {{ __('Proceed to Payment') }}
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
</div>
<div v-else-if="access.data?.message"> <div v-else-if="access.data?.message">
<NotPermitted <NotPermitted
:text="access.data.message" :text="access.data.message"

View File

@@ -8,7 +8,7 @@
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]" :items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
/> />
<div class="flex space-x-2 justify-end"> <div class="flex space-x-2 justify-end">
<div class="w-44"> <div class="w-46 md:w-44">
<FormControl <FormControl
v-if="categories.data?.length" v-if="categories.data?.length"
type="select" type="select"
@@ -17,7 +17,7 @@
:placeholder="__('Category')" :placeholder="__('Category')"
/> />
</div> </div>
<div class="w-36"> <div class="w-28 md:w-36">
<FormControl <FormControl
type="text" type="text"
placeholder="Search" placeholder="Search"
@@ -41,7 +41,7 @@
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
{{ __('New Course') }} {{ __('New') }}
</Button> </Button>
</router-link> </router-link>
</div> </div>

View File

@@ -149,7 +149,7 @@ const newJob = createResource({
return { return {
doc: { doc: {
doctype: 'Job Opportunity', doctype: 'Job Opportunity',
company_logo: job.image.file_url, company_logo: job.image?.file_url,
...job, ...job,
}, },
} }

View File

@@ -52,46 +52,88 @@
</header> </header>
<div v-if="job.data" class="max-w-3xl mx-auto"> <div v-if="job.data" class="max-w-3xl mx-auto">
<div class="p-4"> <div class="p-4">
<div class="flex mb-10"> <div class="space-y-5 mb-10">
<div class="flex items-center">
<img <img
:src="job.data.company_logo" :src="job.data.company_logo"
class="w-16 h-16 rounded-lg object-contain mr-4" class="w-16 h-16 rounded-lg object-contain mr-4"
:alt="job.data.company_name" :alt="job.data.company_name"
/> />
<div>
<div class="text-2xl font-semibold mb-4"> <div class="text-2xl font-semibold mb-4">
{{ job.data.job_title }} {{ job.data.job_title }}
</div> </div>
</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"> <div class="flex items-center space-x-2">
<Building2 class="h-4 w-4 stroke-1.5" /> <span class="p-4 bg-green-50 rounded-full">
<span>{{ job.data.company_name }}</span> <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>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<MapPin class="h-4 w-4 stroke-1.5" /> <span class="p-4 bg-red-50 rounded-full">
<span>{{ job.data.location }}</span> <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>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<ClipboardType class="h-4 w-4 stroke-1.5" /> <span class="p-4 bg-yellow-50 rounded-full">
<span>{{ job.data.type }}</span> <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>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<CalendarDays class="h-4 w-4 stroke-1.5" /> <span class="p-4 bg-blue-50 rounded-full">
<span> <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') }} {{ dayjs(job.data.creation).format('DD MMM YYYY') }}
</span> </span>
</div> </div>
</div>
<div <div
v-if="applicationCount.data" v-if="applicationCount.data"
class="flex items-center space-x-2" class="flex items-center space-x-2"
> >
<SquareUserRound class="h-4 w-4 stroke-1.5" /> <span class="p-4 bg-purple-50 rounded-full">
<span <SquareUserRound class="h-4 w-4 text-purple-500" />
>{{ applicationCount.data }} </span>
{{ __('applications received') }}</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> </div>
</div> </div>

View File

@@ -120,6 +120,7 @@
</div> </div>
<div <div
v-if=" v-if="
lesson.data.instructor_content &&
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 && JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
allowInstructorContent() allowInstructorContent()
" "
@@ -278,7 +279,7 @@ const renderEditor = (holder, content) => {
} }
const markProgress = () => { const markProgress = () => {
if (user.data && !lesson.data?.progress) { if (user.data && lesson.data && !lesson.data.progress) {
progress.submit() progress.submit()
} }
} }

View File

@@ -42,7 +42,7 @@
<img <img
:src="badge.badge_image" :src="badge.badge_image"
:alt="badge.badge" :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="p-5">
<div class="text-2xl font-semibold mb-2"> <div class="text-2xl font-semibold mb-2">

View File

@@ -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" 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 :items="breadcrumbs" />
<div class="space-x-2">
<router-link
v-if="quizDetails.data?.name"
:to="{
name: 'QuizPage',
params: {
quizID: quizDetails.data.name,
},
}"
>
<Button>
{{ __('Open') }}
</Button>
</router-link>
<router-link
v-if="quizDetails.data?.name"
:to="{
name: 'QuizSubmissionList',
params: {
quizID: quizDetails.data.name,
},
}"
>
<Button>
{{ __('Submission List') }}
</Button>
</router-link>
<Button variant="solid" @click="submitQuiz()"> <Button variant="solid" @click="submitQuiz()">
{{ __('Save') }} {{ __('Save') }}
</Button> </Button>
</div>
</header> </header>
<div class="w-3/4 mx-auto py-5"> <div class="w-3/4 mx-auto py-5">
<!-- Details --> <!-- Details -->
<div class="mb-8"> <div class="mb-8">
<div class="text-sm font-semibold mb-4"> <div class="font-semibold mb-4">
{{ __('Details') }} {{ __('Details') }}
</div> </div>
<FormControl <FormControl
@@ -22,11 +50,17 @@
" "
/> />
<div v-if="quizDetails.data?.name"> <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 <FormControl
type="number"
v-model="quiz.max_attempts" v-model="quiz.max_attempts"
:label="__('Maximun Attempts')" :label="__('Maximun Attempts')"
/> />
<FormControl
type="number"
v-model="quiz.duration"
:label="__('Duration (in minutes)')"
/>
<FormControl <FormControl
v-model="quiz.total_marks" v-model="quiz.total_marks"
:label="__('Total Marks')" :label="__('Total Marks')"
@@ -40,7 +74,7 @@
<!-- Settings --> <!-- Settings -->
<div class="mb-8"> <div class="mb-8">
<div class="text-sm font-semibold mb-4"> <div class="font-semibold mb-4">
{{ __('Settings') }} {{ __('Settings') }}
</div> </div>
<div class="grid grid-cols-3 gap-5 my-4"> <div class="grid grid-cols-3 gap-5 my-4">
@@ -58,7 +92,7 @@
</div> </div>
<div class="mb-8"> <div class="mb-8">
<div class="text-sm font-semibold mb-4"> <div class="font-semibold mb-4">
{{ __('Shuffle Settings') }} {{ __('Shuffle Settings') }}
</div> </div>
<div class="grid grid-cols-3"> <div class="grid grid-cols-3">
@@ -78,7 +112,7 @@
<!-- Questions --> <!-- Questions -->
<div> <div>
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div class="text-sm font-semibold"> <div class="font-semibold">
{{ __('Questions') }} {{ __('Questions') }}
</div> </div>
<Button @click="openQuestionModal()"> <Button @click="openQuestionModal()">
@@ -198,6 +232,7 @@ const quiz = reactive({
total_marks: 0, total_marks: 0,
passing_percentage: 0, passing_percentage: 0,
max_attempts: 0, max_attempts: 0,
duration: 0,
limit_questions_to: 0, limit_questions_to: 0,
show_answers: true, show_answers: true,
show_submission_history: false, show_submission_history: false,
@@ -347,17 +382,17 @@ const questionColumns = computed(() => {
{ {
label: __('ID'), label: __('ID'),
key: 'question', key: 'question',
width: '25%', width: '10rem',
}, },
{ {
label: __('Question'), label: __('Question'),
key: __('question_detail'), key: __('question_detail'),
width: '60%', width: '40rem',
}, },
{ {
label: __('Marks'), label: __('Marks'),
key: 'marks', key: 'marks',
width: '10%', width: '5rem',
}, },
] ]
}) })

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

View File

@@ -2,47 +2,121 @@
<header <header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5" 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> </header>
<div class="w-1/2 mx-auto py-10"> <div v-if="submisisonDetails.doc" class="w-1/2 mx-auto py-5 space-y-4">
<Quiz :quizName="quizID" /> <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> </div>
</template> </template>
<script setup> <script setup>
import Quiz from '@/components/Quiz.vue' import {
import { createResource, Breadcrumbs } from 'frappe-ui' createDocumentResource,
import { computed, inject, onMounted } from 'vue' Breadcrumbs,
FormControl,
Button,
Badge,
} from 'frappe-ui'
import { computed, onMounted, inject } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { showToast } from '@/utils'
const user = inject('$user')
const router = useRouter() const router = useRouter()
const user = inject('$user')
onMounted(() => { onMounted(() => {
if (!user.data) { if (!user.data?.is_instructor && !user.data?.is_moderator)
router.push({ name: 'Courses' }) router.push({ name: 'Courses' })
}
}) })
const props = defineProps({ const props = defineProps({
quizID: { submission: {
type: String, type: String,
required: true, required: true,
}, },
}) })
const title = createResource({ const submisisonDetails = createDocumentResource({
url: 'frappe.client.get_value', doctype: 'LMS Quiz Submission',
params: { name: props.submission,
doctype: 'LMS Quiz',
fieldname: 'title',
filters: {
name: props.quizID,
},
},
auto: true, auto: true,
}) })
const breadcrumbs = computed(() => { 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> </script>

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

View File

@@ -19,7 +19,7 @@
</Button> </Button>
</router-link> </router-link>
</header> </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 <ListView
:columns="quizColumns" :columns="quizColumns"
:rows="quizzes.data" :rows="quizzes.data"

View File

@@ -160,7 +160,19 @@ const routes = [
}, },
{ {
path: '/quiz/:quizID', 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'), component: () => import('@/pages/QuizSubmission.vue'),
props: true, props: true,
}, },

View File

@@ -82,10 +82,13 @@ export function getFileSize(file_size) {
export function showToast(title, text, icon, iconClasses = null) { export function showToast(title, text, icon, iconClasses = null) {
if (!iconClasses) { if (!iconClasses) {
iconClasses = if (icon == 'check') {
icon == 'check' iconClasses = 'bg-green-600 text-white rounded-md p-px'
? 'bg-green-600 text-white rounded-md p-px' } else if (icon == 'circle-warn') {
: 'bg-red-600 text-white rounded-md p-px' iconClasses = 'bg-yellow-600 text-white rounded-md p-px'
} else {
iconClasses = 'bg-red-600 text-white rounded-md p-px'
}
} }
createToast({ createToast({
title: title, title: title,

View File

@@ -56,9 +56,11 @@ export class Upload {
app.mount(this.wrapper) app.mount(this.wrapper)
return return
} else if (file.file_type == 'PDF') { } 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 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 return
} else { } else {
this.wrapper.innerHTML = `<img class="mb-4" src=${encodeURI( this.wrapper.innerHTML = `<img class="mb-4" src=${encodeURI(

View File

@@ -852,6 +852,11 @@
dependencies: dependencies:
vue-demi ">=0.14.8" 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: ansi-regex@^5.0.1:
version "5.0.1" version "5.0.1"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"

View File

@@ -1 +1 @@
__version__ = "2.7.0" __version__ = "2.9.0"

View File

@@ -185,6 +185,7 @@ jinja = {
"lms.lms.utils.get_lesson_url", "lms.lms.utils.get_lesson_url",
"lms.page_renderers.get_profile_url", "lms.page_renderers.get_profile_url",
"lms.overrides.user.get_palette", "lms.overrides.user.get_palette",
"lms.lms.utils.is_instructor",
], ],
"filters": [], "filters": [],
} }

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests import UnitTestCase
class TestLMSJobApplication(FrappeTestCase): class TestLMSJobApplication(UnitTestCase):
pass pass

View File

@@ -293,7 +293,10 @@ def get_branding():
image_fields = ["banner_image", "footer_logo", "favicon"] image_fields = ["banner_image", "footer_logo", "favicon"]
for field in image_fields: for field in image_fields:
if website_settings.get(field):
website_settings.update({field: get_file_info(website_settings.get(field))}) website_settings.update({field: get_file_info(website_settings.get(field))})
else:
website_settings.update({field: None})
return website_settings return website_settings
@@ -322,7 +325,7 @@ def get_evaluator_details(evaluator):
) )
if frappe.db.exists("Course Evaluator", {"evaluator": 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: else:
doc = frappe.new_doc("Course Evaluator") doc = frappe.new_doc("Course Evaluator")
doc.evaluator = evaluator doc.evaluator = evaluator
@@ -576,14 +579,17 @@ def get_members(start=0, search=""):
""" """
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]} filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
or_filters = {}
if search: 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( members = frappe.get_all(
"User", "User",
filters=filters, filters=filters,
fields=["name", "full_name", "user_image", "username", "last_active"], fields=["name", "full_name", "user_image", "username", "last_active"],
or_filters=or_filters,
page_length=20, page_length=20,
start=start, start=start,
) )

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests import UnitTestCase
class TestBatchStudent(FrappeTestCase): class TestBatchStudent(UnitTestCase):
pass pass

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests import UnitTestCase
class TestCourseEvaluator(FrappeTestCase): class TestCourseEvaluator(UnitTestCase):
pass pass

View File

@@ -55,6 +55,7 @@
"fieldname": "title", "fieldname": "title",
"fieldtype": "Data", "fieldtype": "Data",
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1,
"label": "Title", "label": "Title",
"reqd": 1 "reqd": 1
}, },
@@ -161,7 +162,7 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-04-03 10:48:17.525859", "modified": "2024-10-08 11:04:54.748773",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "Course Lesson", "name": "Course Lesson",

View File

@@ -15,20 +15,22 @@
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1, "in_list_view": 1,
"label": "Assessment Type", "label": "Assessment Type",
"options": "DocType" "options": "DocType",
"reqd": 1
}, },
{ {
"fieldname": "assessment_name", "fieldname": "assessment_name",
"fieldtype": "Dynamic Link", "fieldtype": "Dynamic Link",
"in_list_view": 1, "in_list_view": 1,
"label": "Assessment Name", "label": "Assessment Name",
"options": "assessment_type" "options": "assessment_type",
"reqd": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-05-29 14:56:36.602399", "modified": "2024-10-11 19:16:01.630524",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Assessment", "name": "LMS Assessment",

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests import UnitTestCase
class TestLMSAssignment(FrappeTestCase): class TestLMSAssignment(UnitTestCase):
pass pass

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests import UnitTestCase
class TestLMSBadge(FrappeTestCase): class TestLMSBadge(UnitTestCase):
pass pass

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests import UnitTestCase
class TestLMSBadgeAssignment(FrappeTestCase): class TestLMSBadgeAssignment(UnitTestCase):
pass pass

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests import UnitTestCase
class TestLMSClass(FrappeTestCase): class TestLMSBatch(UnitTestCase):
pass pass

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase import unittest
class TestLMSBatchTimetable(FrappeTestCase): class TestLMSBatchTimetable(unittest.TestCase):
pass pass

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests import UnitTestCase
class TestLMSCategory(FrappeTestCase): class TestLMSCategory(UnitTestCase):
pass pass

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests import UnitTestCase
class TestLMSCertificateEvaluation(FrappeTestCase): class TestLMSCertificateEvaluation(UnitTestCase):
pass pass

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests import UnitTestCase
class TestLMSCertificateRequest(FrappeTestCase): class TestLMSCertificateRequest(UnitTestCase):
pass pass

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests import UnitTestCase
class TestLMSLiveClass(FrappeTestCase): class TestLMSLiveClass(UnitTestCase):
pass pass

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests import UnitTestCase
class TestLMSPayment(FrappeTestCase): class TestLMSPayment(UnitTestCase):
pass pass

View File

@@ -51,7 +51,7 @@
"fieldtype": "Select", "fieldtype": "Select",
"in_list_view": 1, "in_list_view": 1,
"label": "Type", "label": "Type",
"options": "Choices\nUser Input" "options": "Choices\nUser Input\nOpen Ended"
}, },
{ {
"depends_on": "eval:doc.type == \"Choices\";", "depends_on": "eval:doc.type == \"Choices\";",
@@ -196,7 +196,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2024-08-01 12:53:22.540990", "modified": "2024-10-07 09:41:17.862774",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Question", "name": "LMS Question",

View File

@@ -17,7 +17,7 @@ def validate_correct_answers(question):
if question.type == "Choices": if question.type == "Choices":
validate_duplicate_options(question) validate_duplicate_options(question)
validate_correct_options(question) validate_correct_options(question)
else: elif question.type == "User Input":
validate_possible_answer(question) validate_possible_answer(question)

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests import UnitTestCase
class TestLMSQuestion(FrappeTestCase): class TestLMSQuestion(UnitTestCase):
pass pass

View File

@@ -10,10 +10,11 @@
"title", "title",
"max_attempts", "max_attempts",
"show_answers", "show_answers",
"show_submission_history",
"column_break_gaac", "column_break_gaac",
"total_marks", "total_marks",
"passing_percentage", "passing_percentage",
"show_submission_history", "duration",
"section_break_tzbu", "section_break_tzbu",
"shuffle_questions", "shuffle_questions",
"column_break_clsh", "column_break_clsh",
@@ -128,11 +129,16 @@
{ {
"fieldname": "column_break_clsh", "fieldname": "column_break_clsh",
"fieldtype": "Column Break" "fieldtype": "Column Break"
},
{
"fieldname": "duration",
"fieldtype": "Duration",
"label": "Duration"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-08-09 12:21:36.256522", "modified": "2024-10-11 22:39:40.381183",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Quiz", "name": "LMS Quiz",

View File

@@ -3,7 +3,8 @@
import json import json
import frappe import frappe
from frappe import _ import re
from frappe import _, safe_decode
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import cstr, comma_and, cint from frappe.utils import cstr, comma_and, cint
from fuzzywuzzy import fuzz from fuzzywuzzy import fuzz
@@ -13,6 +14,9 @@ from lms.lms.utils import (
has_course_moderator_role, has_course_moderator_role,
has_course_instructor_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): class LMSQuiz(Document):
@@ -20,6 +24,7 @@ class LMSQuiz(Document):
self.validate_duplicate_questions() self.validate_duplicate_questions()
self.validate_limit() self.validate_limit()
self.calculate_total_marks() self.calculate_total_marks()
self.validate_open_ended_questions()
def validate_duplicate_questions(self): def validate_duplicate_questions(self):
questions = [row.question for row in self.questions] questions = [row.question for row in self.questions]
@@ -48,6 +53,19 @@ class LMSQuiz(Document):
else: else:
self.total_marks = sum(cint(question.marks) for question in self.questions) 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): def autoname(self):
if not self.name: if not self.name:
self.name = generate_slug(self.title, "LMS Quiz") self.name = generate_slug(self.title, "LMS Quiz")
@@ -81,44 +99,61 @@ def set_total_marks(questions):
def quiz_summary(quiz, results): def quiz_summary(quiz, results):
score = 0 score = 0
results = results and json.loads(results) 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: 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( question_details = frappe.db.get_value(
"LMS Quiz Question", "LMS Quiz Question",
{"parent": quiz, "question": result["question_name"]}, {"parent": quiz, "question": result["question_name"]},
["question", "marks", "question_detail"], ["question", "marks", "question_detail", "type"],
as_dict=1, as_dict=1,
) )
result["question_name"] = question_details.question result["question_name"] = question_details.question
result["question"] = question_details.question_detail result["question"] = question_details.question_detail
marks = question_details.marks if correct else 0 result["marks_out_of"] = question_details.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
marks = question_details.marks if correct else 0
result["marks"] = marks result["marks"] = marks
score += marks score += marks
del result["question_name"] del result["question_name"]
else:
result["is_correct"] = 0
is_open_ended = True
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 percentage = (score / score_out_of) * 100
result["answer"] = re.sub(
r'<img[^>]*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, result["answer"]
)
submission = frappe.get_doc( submission = frappe.new_doc("LMS Quiz Submission")
# Score and percentage are calculated by the controller function
submission.update(
{ {
"doctype": "LMS Quiz Submission", "doctype": "LMS Quiz Submission",
"quiz": quiz, "quiz": quiz,
"result": results, "result": results,
"score": score, "score": 0,
"score_out_of": score_out_of, "score_out_of": score_out_of,
"member": frappe.session.user, "member": frappe.session.user,
"percentage": percentage, "percentage": 0,
"passing_percentage": quiz_details.passing_percentage, "passing_percentage": quiz_details.passing_percentage,
} }
) )
@@ -139,128 +174,51 @@ def quiz_summary(quiz, results):
"submission": submission.name, "submission": submission.name,
"pass": percentage == quiz_details.passing_percentage, "pass": percentage == quiz_details.passing_percentage,
"percentage": percentage, "percentage": percentage,
"is_open_ended": is_open_ended,
} }
@frappe.whitelist() def _save_file(match):
def save_quiz( data = match.group(1).split("data:")[1]
quiz_title, headers, content = data.split(",")
passing_percentage, mtype = headers.split(";", 1)[0]
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
values = { if isinstance(content, str):
"title": quiz_title, content = content.encode("utf-8")
"passing_percentage": passing_percentage, if b"," in content:
"max_attempts": max_attempts, content = content.split(b",")[1]
"show_answers": show_answers,
"show_submission_history": show_submission_history, 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: else:
doc = frappe.new_doc("LMS Quiz") filename = get_random_filename(content_type=mtype)
doc.update(values)
doc.save()
update_questions(doc.name, questions)
return doc.name
_file = frappe.get_doc(
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",
{ {
"parent": quiz, "doctype": "File",
}, "file_name": filename,
pluck="name", "content": content,
) "decode": False,
"is_private": False,
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,
} }
) )
_file.save(ignore_permissions=True)
file_url = _file.unique_url
frappe.flags.has_dataurl = True
doc.update({"question": question.question, "marks": question.marks}) return f'<img src="{file_url}"'
doc.save()
@frappe.whitelist() def get_corrupted_image_msg():
def save_question(quiz, values, index): return _("Image: Corrupted Data Stream")
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"],
}
)
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}"],
}
)
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
@frappe.whitelist() @frappe.whitelist()
@@ -318,9 +276,3 @@ def check_input_answers(question, answer):
return 1 return 1
return 0 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"])

View File

@@ -9,7 +9,8 @@
"column_break_qcpo", "column_break_qcpo",
"marks", "marks",
"section_break_huup", "section_break_huup",
"question_detail" "question_detail",
"type"
], ],
"fields": [ "fields": [
{ {
@@ -44,12 +45,21 @@
{ {
"fieldname": "section_break_huup", "fieldname": "section_break_huup",
"fieldtype": "Section Break" "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, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2024-07-29 15:10:09.662715", "modified": "2024-10-07 15:01:38.800906",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Quiz Question", "name": "LMS Quiz Question",

View File

@@ -11,6 +11,7 @@
"answer", "answer",
"column_break_flus", "column_break_flus",
"marks", "marks",
"marks_out_of",
"is_correct" "is_correct"
], ],
"fields": [ "fields": [
@@ -33,8 +34,7 @@
"fieldname": "is_correct", "fieldname": "is_correct",
"fieldtype": "Check", "fieldtype": "Check",
"in_list_view": 1, "in_list_view": 1,
"label": "Is Correct", "label": "Is Correct"
"read_only": 1
}, },
{ {
"fieldname": "section_break_fztv", "fieldname": "section_break_fztv",
@@ -54,14 +54,20 @@
"fieldname": "marks", "fieldname": "marks",
"fieldtype": "Int", "fieldtype": "Int",
"in_list_view": 1, "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 "read_only": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2024-05-17 17:38:51.760653", "modified": "2024-10-07 17:28:38.597472",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Quiz Result", "name": "LMS Quiz Result",

View File

@@ -6,6 +6,7 @@
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"quiz", "quiz",
"quiz_title",
"course", "course",
"column_break_3", "column_break_3",
"member", "member",
@@ -39,7 +40,6 @@
"fieldtype": "Int", "fieldtype": "Int",
"in_list_view": 1, "in_list_view": 1,
"label": "Score", "label": "Score",
"read_only": 1,
"reqd": 1 "reqd": 1
}, },
{ {
@@ -95,7 +95,6 @@
"fieldtype": "Int", "fieldtype": "Int",
"label": "Percentage", "label": "Percentage",
"non_negative": 1, "non_negative": 1,
"read_only": 1,
"reqd": 1 "reqd": 1
}, },
{ {
@@ -105,12 +104,19 @@
"non_negative": 1, "non_negative": 1,
"read_only": 1, "read_only": 1,
"reqd": 1 "reqd": 1
},
{
"fetch_from": "quiz.title",
"fieldname": "quiz_title",
"fieldtype": "Data",
"label": "Quiz Title",
"read_only": 1
} }
], ],
"in_create": 1, "in_create": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-02-27 13:01:53.611726", "modified": "2024-10-07 16:52:04.162521",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Quiz Submission", "name": "LMS Quiz Submission",

View File

@@ -1,15 +1,28 @@
# Copyright (c) 2021, FOSS United and contributors # Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt # For license information, please see license.txt
# import frappe import frappe
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import cint
from frappe import _
class LMSQuizSubmission(Document): class LMSQuizSubmission(Document):
def before_insert(self): def validate(self):
if not self.percentage: self.validate_marks()
self.set_percentage() 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): def set_percentage(self):
if self.score and self.score_out_of: if self.score and self.score_out_of:
self.percentage = (self.score / self.score_out_of) * 100 self.percentage = (self.score / self.score_out_of) * 100

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests import UnitTestCase
class TestLMSSidebarItem(FrappeTestCase): class TestLMSSidebarItem(UnitTestCase):
pass pass

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests import UnitTestCase
class TestLMSSource(FrappeTestCase): class TestLMSSource(UnitTestCase):
pass pass

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests import UnitTestCase
class TestLMSTimetableLegend(FrappeTestCase): class TestLMSTimetableLegend(UnitTestCase):
pass pass

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests import UnitTestCase
class TestLMSTimetableTemplate(FrappeTestCase): class TestLMSTimetableTemplate(UnitTestCase):
pass pass

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests import UnitTestCase
class TestPaymentCountry(FrappeTestCase): class TestPaymentCountry(UnitTestCase):
pass pass

View File

@@ -2,8 +2,8 @@
# See license.txt # See license.txt
# import frappe # import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests import UnitTestCase
class TestZoomSettings(FrappeTestCase): class TestZoomSettings(UnitTestCase):
pass pass

View File

@@ -42,6 +42,10 @@ def get_payment_link(doctype, docname, title, amount, total_amount, currency, ad
"redirect_to": redirect_to, "redirect_to": redirect_to,
"payment": payment.name, "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) url = controller.get_payment_url(**payment_details)
return url return url

View File

@@ -17,6 +17,7 @@ from frappe.utils import (
add_months, add_months,
cint, cint,
cstr, cstr,
ceil,
flt, flt,
fmt_money, fmt_money,
format_date, format_date,
@@ -948,7 +949,7 @@ def check_multicurrency(amount, currency, country=None, amount_usd=None):
if apply_rounding and amount % 100 != 0: if apply_rounding and amount % 100 != 0:
amount = amount + 100 - amount % 100 amount = amount + 100 - amount % 100
return amount, currency return ceil(amount), currency
def apply_gst(amount, country=None): def apply_gst(amount, country=None):
@@ -1677,7 +1678,7 @@ def update_payment_record(doctype, docname):
if doctype == "LMS Course": if doctype == "LMS Course":
enroll_in_course(data.payment, docname) enroll_in_course(data.payment, docname)
else: else:
enroll_in_batch(data.payment, docname) enroll_in_batch(docname, data.payment)
except Exception as e: except Exception as e:
frappe.log_error(frappe.get_traceback(), _("Enrollment Failed")) frappe.log_error(frappe.get_traceback(), _("Enrollment Failed"))
@@ -1701,25 +1702,33 @@ def enroll_in_course(payment_name, course):
enrollment.save(ignore_permissions=True) 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( if not frappe.db.exists(
"Batch Student", {"parent": batch, "student": frappe.session.user} "Batch Student", {"parent": batch, "student": frappe.session.user}
): ):
student = frappe.new_doc("Batch Student") student = frappe.new_doc("Batch Student")
current_count = frappe.db.count("Batch Student", {"parent": batch}) 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.update(
{ {
"student": frappe.session.user, "student": frappe.session.user,
"payment": payment.name,
"source": payment.source,
"parent": batch, "parent": batch,
"parenttype": "LMS Batch", "parenttype": "LMS Batch",
"parentfield": "students", "parentfield": "students",
"idx": current_count + 1, "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) student.save(ignore_permissions=True)

File diff suppressed because it is too large Load Diff