feat: negative marking in quiz

This commit is contained in:
Jannat Patel
2025-06-27 19:58:35 +05:30
parent 02b8e02131
commit cf452c2300
9 changed files with 421 additions and 301 deletions

View File

@@ -2,7 +2,7 @@
<Dialog <Dialog
v-model="show" v-model="show"
:options="{ :options="{
size: '3xl', size: '5xl',
}" }"
> >
<template #body> <template #body>
@@ -21,7 +21,7 @@
class="!p-0" class="!p-0"
/> />
</div> </div>
<div v-if="!chooseFromExisting || editMode" class="space-y-2"> <div v-if="!chooseFromExisting || editMode">
<div> <div>
<label class="block text-xs text-ink-gray-5 mb-1"> <label class="block text-xs text-ink-gray-5 mb-1">
{{ __('Question') }} {{ __('Question') }}
@@ -34,7 +34,7 @@
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]" editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/> />
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-8 mt-4">
<FormControl <FormControl
v-model="question.marks" v-model="question.marks"
:label="__('Marks')" :label="__('Marks')"
@@ -51,7 +51,7 @@
</div> </div>
<div <div
v-if="question.type == 'Choices'" v-if="question.type == 'Choices'"
class="text-base font-semibold text-ink-gray-9 mb-5 mt-5" class="text-base font-semibold text-ink-gray-9 mb-5 mt-10"
> >
{{ __('Options') }} {{ __('Options') }}
</div> </div>
@@ -61,7 +61,10 @@
> >
{{ __('Possibilities') }} {{ __('Possibilities') }}
</div> </div>
<div v-if="question.type == 'Choices'" class="grid grid-cols-2 gap-4"> <div
v-if="question.type == 'Choices'"
class="grid grid-cols-2 gap-x-8 gap-y-4"
>
<div v-for="n in 4" class="space-y-4 py-2"> <div v-for="n in 4" class="space-y-4 py-2">
<FormControl <FormControl
:label="__('Option') + ' ' + n" :label="__('Option') + ' ' + n"
@@ -81,7 +84,7 @@
</div> </div>
<div <div
v-else-if="question.type == 'User Input'" v-else-if="question.type == 'User Input'"
class="grid grid-cols-2 gap-4 py-2" class="grid grid-cols-2 gap-x-8 gap-y-4 py-2"
> >
<div v-for="n in 4"> <div v-for="n in 4">
<FormControl <FormControl
@@ -106,7 +109,7 @@
</div> </div>
<div class="flex items-center justify-end space-x-2 mt-5"> <div class="flex items-center justify-end space-x-2 mt-5">
<Button variant="solid" @click="submitQuestion()"> <Button variant="solid" @click="submitQuestion()">
{{ __('Submit') }} {{ __('Save') }}
</Button> </Button>
</div> </div>
</div> </div>
@@ -217,7 +220,7 @@ const questionRow = createResource({
return { return {
doc: { doc: {
doctype: 'LMS Quiz Question', doctype: 'LMS Quiz Question',
parent: quiz.value.data.name, parent: quiz.value.doc.name,
parentfield: 'questions', parentfield: 'questions',
parenttype: 'LMS Quiz', parenttype: 'LMS Quiz',
...values, ...values,

View File

@@ -1,7 +1,7 @@
<template> <template>
<div v-if="quiz.data"> <div v-if="quiz.data">
<div <div
class="bg-surface-blue-2 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-ink-blue-3 leading-5" class="bg-surface-blue-2 space-y-2 py-2 px-3 mb-4 rounded-md text-sm text-ink-blue-2 leading-5"
> >
<div v-if="inVideo"> <div v-if="inVideo">
{{ __('You will have to complete the quiz to continue the video') }} {{ __('You will have to complete the quiz to continue the video') }}
@@ -41,6 +41,16 @@
) )
}} }}
</div> </div>
<div v-if="quiz.data.enable_negative_marking" class="leading-5">
{{
__(
'If you answer incorrectly, {0} {1} will be deducted from your score for each incorrect answer.'
).format(
quiz.data.marks_to_cut,
quiz.data.marks_to_cut == 1 ? 'mark' : 'marks'
)
}}
</div>
</div> </div>
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4"> <div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">

View File

@@ -57,22 +57,24 @@
</Button> </Button>
</div> </div>
</div> </div>
<div class="flex flex-col space-y-4 py-5 border-b"> <div class="flex flex-col space-y-4 pt-5 border-b">
<Code <Code
v-model="code" v-model="code"
:language="exercise.doc?.language.toLowerCase()" :language="exercise.doc?.language.toLowerCase()"
height="400px" height="400px"
maxHeight="1000px" maxHeight="1000px"
/> />
<span v-if="error" class="text-xs text-ink-gray-5 px-2"> <div class="flex flex-col space-y-1">
{{ __('Compiler Message') }}: <span v-if="error" class="text-xs text-ink-gray-5 px-1">
</span> {{ __('Compiler Message') }}:
<textarea </span>
v-if="error" <textarea
v-model="errorMessage" v-if="error"
class="bg-surface-gray-1 border-none text-sm h-32 leading-6" v-model="errorMessage"
readonly class="font-mono text-ink-red-3 bg-surface-gray-1 border-none text-sm h-32 leading-6"
/> readonly
/>
</div>
<!-- <textarea v-else v-model="output" class="bg-surface-gray-1 border-none text-sm h-28 leading-6" readonly /> --> <!-- <textarea v-else v-model="output" class="bg-surface-gray-1 border-none text-sm h-28 leading-6" readonly /> -->
</div> </div>

View File

@@ -93,6 +93,12 @@
{{ row[column.key] }} {{ row[column.key] }}
</Badge> </Badge>
</div> </div>
<div
v-else-if="column.key == 'modified'"
class="text-sm text-ink-gray-5"
>
{{ row[column.key] }}
</div>
<div v-else> <div v-else>
{{ row[column.key] }} {{ row[column.key] }}
</div> </div>
@@ -261,7 +267,7 @@ const submissionColumns = computed(() => {
{ {
label: __('Member'), label: __('Member'),
key: 'member_name', key: 'member_name',
width: '20%', width: '30%',
icon: 'user', icon: 'user',
}, },
{ {
@@ -279,7 +285,7 @@ const submissionColumns = computed(() => {
{ {
label: __('Modified'), label: __('Modified'),
key: 'modified', key: 'modified',
width: '20%', width: '15%',
icon: 'clock', icon: 'clock',
align: 'right', align: 'right',
}, },

View File

@@ -4,30 +4,39 @@
> >
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<div v-if="!readOnlyMode" class="space-x-2"> <div v-if="!readOnlyMode" class="space-x-2">
<Badge v-if="quizDetails.isDirty" theme="orange">
{{ __('Not Saved') }}
</Badge>
<router-link <router-link
v-if="quizDetails.data?.name" v-if="quizDetails.doc?.name"
:to="{ :to="{
name: 'QuizPage', name: 'QuizPage',
params: { params: {
quizID: quizDetails.data.name, quizID: quizDetails.doc.name,
}, },
}" }"
> >
<Button> <Button>
{{ __('Open') }} <template #prefix>
<ListChecks class="size-4 stroke-1.5" />
</template>
{{ __('Test Quiz') }}
</Button> </Button>
</router-link> </router-link>
<router-link <router-link
v-if="quizDetails.data?.name" v-if="quizDetails.doc?.name"
:to="{ :to="{
name: 'QuizSubmissionList', name: 'QuizSubmissionList',
params: { params: {
quizID: quizDetails.data.name, quizID: quizDetails.doc.name,
}, },
}" }"
> >
<Button> <Button>
{{ __('Submission List') }} <template #prefix>
<ClipboardList class="size-4 stroke-1.5" />
</template>
{{ __('Check Submissions') }}
</Button> </Button>
</router-link> </router-link>
<Button variant="solid" @click="submitQuiz()"> <Button variant="solid" @click="submitQuiz()">
@@ -35,144 +44,152 @@
</Button> </Button>
</div> </div>
</header> </header>
<div class="w-3/4 mx-auto py-5"> <div v-if="quizDetails.doc" class="py-5">
<!-- Details --> <div class="px-20 pb-5 space-y-5 border-b mb-5">
<div class="mb-8"> <div class="text-lg text-ink-gray-9 font-semibold mb-4">
<div class="font-semibold text-ink-gray-9 mb-4">
{{ __('Details') }} {{ __('Details') }}
</div> </div>
<FormControl <div class="grid grid-cols-2 gap-5">
v-model="quiz.title" <div class="space-y-5">
:label=" <FormControl
quizDetails.data?.name v-model="quizDetails.doc.title"
? __('Title') :label="__('Title')"
: __('Enter a title and save the quiz to proceed') :required="true"
" />
:required="true"
/>
<div v-if="quizDetails.data?.name">
<div class="grid grid-cols-2 gap-5 mt-4 mb-8">
<FormControl <FormControl
type="number" type="number"
v-model="quiz.max_attempts" v-model="quizDetails.doc.max_attempts"
:label="__('Maximum Attempts')" :label="__('Maximum Attempts')"
/> />
<FormControl <FormControl
type="number" type="number"
v-model="quiz.duration" v-model="quizDetails.doc.duration"
:label="__('Duration (in minutes)')" :label="__('Duration (in minutes)')"
/> />
</div>
<div class="space-y-5">
<FormControl <FormControl
v-model="quiz.total_marks" v-model="quizDetails.doc.total_marks"
:label="__('Total Marks')" :label="__('Total Marks')"
disabled disabled
/> />
<FormControl <FormControl
v-model="quiz.passing_percentage" v-model="quizDetails.doc.passing_percentage"
:label="__('Passing Percentage')" :label="__('Passing Percentage')"
:required="true"
/> />
</div> </div>
</div>
<!-- Settings --> </div>
<div class="mb-8"> <div class="px-20 pb-5 space-y-5 border-b mb-5">
<div class="font-semibold text-ink-gray-9 mb-4"> <div class="text-lg text-ink-gray-9 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">
<FormControl <div class="flex flex-col space-y-10">
v-model="quiz.show_answers" <FormControl
type="checkbox" v-model="quizDetails.doc.show_answers"
:label="__('Show Answers')" type="checkbox"
/> :label="__('Show Answers')"
<FormControl />
v-model="quiz.show_submission_history" <FormControl
type="checkbox" v-model="quizDetails.doc.show_submission_history"
:label="__('Show Submission History')" type="checkbox"
/> :label="__('Show Submission History')"
</div> />
</div> </div>
<div class="flex flex-col space-y-5">
<div class="mb-8"> <FormControl
<div class="font-semibold text-ink-gray-9 mb-4"> v-model="quizDetails.doc.shuffle_questions"
{{ __('Shuffle Settings') }} type="checkbox"
</div> :label="__('Shuffle Questions')"
<div class="grid grid-cols-3"> />
<FormControl <FormControl
v-model="quiz.shuffle_questions" v-if="quizDetails.doc.shuffle_questions"
type="checkbox" v-model="quizDetails.doc.limit_questions_to"
:label="__('Shuffle Questions')" :label="__('Limit Questions To')"
/> />
<FormControl </div>
v-if="quiz.shuffle_questions" <div class="flex flex-col space-y-5">
v-model="quiz.limit_questions_to" <FormControl
:label="__('Limit Questions To')" v-model="quizDetails.doc.enable_negative_marking"
/> type="checkbox"
</div> :label="__('Enable Negative Marking')"
</div> />
<FormControl
<!-- Questions --> v-if="quizDetails.doc.enable_negative_marking"
<div> v-model="quizDetails.doc.marks_to_cut"
<div class="flex items-center justify-between mb-4"> :label="__('Marks to Cut')"
<div class="font-semibold text-ink-gray-9"> />
{{ __('Questions') }} </div>
</div> </div>
<Button v-if="!readOnlyMode" @click="openQuestionModal()"> </div>
<template #prefix>
<Plus class="w-4 h-4" /> <div class="px-20 pb-5 space-y-5 mb-5">
</template> <div class="flex items-center justify-between mb-4">
{{ __('New Question') }} <div class="text-lg font-semibold text-ink-gray-9">
</Button> {{ __('Questions') }}
</div> </div>
<ListView <Button v-if="!readOnlyMode" @click="openQuestionModal()">
:columns="questionColumns" <template #prefix>
:rows="quiz.questions" <Plus class="w-4 h-4" />
row-key="name" </template>
:options="{ {{ __('New Question') }}
showTooltip: false, </Button>
}" </div>
> <ListView
<ListHeader v-if="questions.length"
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2" :columns="questionColumns"
> :rows="questions"
<ListHeaderItem :item="item" v-for="item in questionColumns" /> row-key="name"
</ListHeader> :options="{
<ListRows> showTooltip: false,
<ListRow }"
:row="row" >
v-slot="{ idx, column, item }" <ListHeader
v-for="row in quiz.questions" class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
@click="openQuestionModal(row)" >
class="cursor-pointer" <ListHeaderItem :item="item" v-for="item in questionColumns" />
> </ListHeader>
<ListRowItem :item="item"> <ListRows>
<div <ListRow
v-if="column.key == 'question_detail'" :row="row"
class="text-xs truncate h-4" v-slot="{ idx, column, item }"
v-html="item" v-for="row in questions"
></div> @click="openQuestionModal(row)"
<div v-else class="text-xs"> class="cursor-pointer"
{{ item }} >
</div> <ListRowItem :item="item">
</ListRowItem> <div
</ListRow> v-if="column.key == 'question_detail'"
</ListRows> class="text-xs truncate h-4"
<ListSelectBanner> v-html="item"
<template #actions="{ unselectAll, selections }"> ></div>
<div class="flex gap-2"> <div v-else class="text-xs">
<Button {{ item }}
variant="ghost" </div>
@click="deleteQuestions(selections, unselectAll)" </ListRowItem>
> </ListRow>
<Trash2 class="h-4 w-4 stroke-1.5" /> </ListRows>
</Button> <ListSelectBanner>
</div> <template #actions="{ unselectAll, selections }">
</template> <div class="flex gap-2">
</ListSelectBanner> <Button
</ListView> variant="ghost"
</div> @click="deleteQuestions(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
<div v-else class="text-ink-gray-6 text-sm">
{{ __('No questions added yet') }}
</div> </div>
</div> </div>
</div> </div>
<Question <Question
v-model="showQuestionModal" v-model="showQuestionModal"
:questionDetail="currentQuestion" :questionDetail="currentQuestion"
@@ -199,6 +216,8 @@ import {
Button, Button,
usePageMeta, usePageMeta,
toast, toast,
createDocumentResource,
Badge,
} from 'frappe-ui' } from 'frappe-ui'
import { import {
computed, computed,
@@ -210,8 +229,7 @@ import {
watch, watch,
} from 'vue' } from 'vue'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import { Plus, Trash2 } from 'lucide-vue-next' import { ClipboardList, ListChecks, Plus, Trash2 } from 'lucide-vue-next'
import { updateDocumentTitle } from '@/utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import Question from '@/components/Modals/Question.vue' import Question from '@/components/Modals/Question.vue'
@@ -233,18 +251,7 @@ const props = defineProps({
}, },
}) })
const quiz = reactive({ const questions = ref([])
title: '',
total_marks: 0,
passing_percentage: 0,
max_attempts: 0,
duration: 0,
limit_questions_to: 0,
show_answers: true,
show_submission_history: false,
shuffle_questions: false,
questions: [],
})
onMounted(() => { onMounted(() => {
if ( if (
@@ -280,86 +287,26 @@ watch(
} }
) )
const quizDetails = createResource({ const quizDetails = createDocumentResource({
url: 'frappe.client.get', doctype: 'LMS Quiz',
makeParams(values) { name: props.quizID,
return { doctype: 'LMS Quiz', name: props.quizID }
},
auto: false, auto: false,
onSuccess(data) { onSuccess(doc) {
Object.keys(data).forEach((key) => { if (doc.questions && doc.questions.length > 0) {
if (Object.hasOwn(quiz, key)) quiz[key] = data[key] questions.value = doc.questions.map((question) => question)
})
let checkboxes = [
'show_answers',
'show_submission_history',
'shuffle_questions',
]
for (let idx in checkboxes) {
let key = checkboxes[idx]
quiz[key] = quiz[key] ? true : false
}
},
})
const quizCreate = createResource({
url: 'frappe.client.insert',
auto: false,
makeParams(values) {
return {
doc: {
doctype: 'LMS Quiz',
...quiz,
},
}
},
})
const quizUpdate = createResource({
url: 'frappe.client.set_value',
auto: false,
makeParams(values) {
return {
doctype: 'LMS Quiz',
name: values.quizID,
fieldname: {
total_marks: calculateTotalMarks(),
...quiz,
},
} }
}, },
}) })
const submitQuiz = () => { const submitQuiz = () => {
if (quizDetails.data?.name) updateQuiz() quizDetails.setValue.submit(
else createQuiz() {
} ...quizDetails.doc,
total_marks: calculateTotalMarks(),
const createQuiz = () => { },
quizCreate.submit(
{},
{ {
onSuccess(data) { onSuccess(data) {
toast.success(__('Quiz created successfully')) quizDetails.doc.total_marks = data.total_marks
router.push({
name: 'QuizForm',
params: { quizID: data.name },
})
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
}
const updateQuiz = () => {
quizUpdate.submit(
{ quizID: quizDetails.data?.name },
{
onSuccess(data) {
quiz.total_marks = data.total_marks
toast.success(__('Quiz updated successfully')) toast.success(__('Quiz updated successfully'))
}, },
onError(err) { onError(err) {
@@ -371,9 +318,15 @@ const updateQuiz = () => {
const calculateTotalMarks = () => { const calculateTotalMarks = () => {
let totalMarks = 0 let totalMarks = 0
if (quiz.limit_questions_to && quiz.questions.length > 0) if (
return quiz.questions[0].marks * quiz.limit_questions_to quizDetails.doc?.limit_questions_to &&
quiz.questions.forEach((question) => { quizDetails.doc?.questions.length > 0
)
return (
quizDetails.doc.questions[0].marks * quizDetails.doc.limit_questions_to
)
quizDetails.doc?.questions.forEach((question) => {
totalMarks += question.marks totalMarks += question.marks
}) })
return totalMarks return totalMarks
@@ -448,7 +401,7 @@ const breadcrumbs = computed(() => {
] ]
crumbs.push({ crumbs.push({
label: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title, label: props.quizID == 'new' ? __('New Quiz') : quizDetails.doc?.title,
route: { name: 'QuizForm', params: { quizID: props.quizID } }, route: { name: 'QuizForm', params: { quizID: props.quizID } },
}) })
return crumbs return crumbs
@@ -456,7 +409,7 @@ const breadcrumbs = computed(() => {
usePageMeta(() => { usePageMeta(() => {
return { return {
title: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title, title: props.quizID == 'new' ? __('New Quiz') : quizDetails.doc?.title,
icon: brand.favicon, icon: brand.favicon,
} }
}) })

View File

@@ -3,37 +3,42 @@
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5" class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
> >
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<router-link <Button v-if="!readOnlyMode" variant="solid" @click="showForm = true">
v-if="!readOnlyMode" <template #prefix>
:to="{ <Plus class="w-4 h-4" />
name: 'QuizForm', </template>
params: { {{ __('New') }}
quizID: 'new', </Button>
},
}"
>
<Button variant="solid">
<template #prefix>
<Plus class="w-4 h-4" />
</template>
{{ __('New Quiz') }}
</Button>
</router-link>
</header> </header>
<div v-if="quizzes.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5"> <div class="py-5 mx-5">
<div v-if="quizCount" class="text-xl font-semibold text-ink-gray-7 mb-4"> <div class="flex items-center justify-between mb-5">
{{ __('{0} Quizzes').format(quizCount) }} <div class="text-xl font-semibold text-ink-gray-7">
{{
quizzes.data?.length
? __('{0} Quizzes').format(quizzes.data.length)
: __('No Quizzes')
}}
</div>
<FormControl v-model="search" type="text" placeholder="Search">
<template #prefix>
<FeatherIcon name="search" class="size-4 text-ink-gray-5" />
</template>
</FormControl>
</div> </div>
<ListView <ListView
v-if="quizzes.data?.length"
:columns="quizColumns" :columns="quizColumns"
:rows="quizzes.data" :rows="quizzes.data"
row-key="name" row-key="name"
:options="{ showTooltip: false, selectable: false }" :options="{ showTooltip: false, selectable: true }"
> >
<ListHeader <ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2" class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
> >
<ListHeaderItem :item="item" v-for="item in quizColumns"> <ListHeaderItem :item="item" v-for="item in quizColumns">
<template #prefix="{ item }">
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
</template>
</ListHeaderItem> </ListHeaderItem>
</ListHeader> </ListHeader>
<ListRows> <ListRows>
@@ -46,72 +51,176 @@
}, },
}" }"
> >
<ListRow :row="row" /> <ListRow :row="row">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'show_answers'">
<FormControl
type="checkbox"
v-model="row[column.key]"
:disabled="true"
/>
</div>
<div
v-else-if="column.key == 'modified'"
class="text-xs text-ink-gray-5"
>
{{ row[column.key] }}
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</router-link> </router-link>
</ListRows> </ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="deleteQuiz(selections, unselectAll)"
>
<FeatherIcon name="trash-2" class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView> </ListView>
<div class="flex justify-center my-5"> <EmptyState v-else type="Quizzes" />
<Button v-if="quizzes.hasNextPage" @click="quizzes.next()"> <div v-if="quizzes.hasNextPage" class="flex justify-center my-5">
<Button @click="quizzes.next()">
{{ __('Load More') }} {{ __('Load More') }}
</Button> </Button>
</div> </div>
</div> </div>
<EmptyState v-else type="Quizzes" /> <Dialog
v-model="showForm"
:options="{
title: __('Create a Quiz'),
size: 'sm',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick({ close }) {
insertQuiz(close)
},
},
],
}"
>
<template #body-content>
<FormControl v-model="title" :label="__('Title')" type="text" />
</template>
</Dialog>
</template> </template>
<script setup> <script setup>
import { import {
Breadcrumbs, Breadcrumbs,
Button, Button,
call,
createListResource, createListResource,
Dialog,
FeatherIcon,
FormControl,
ListView, ListView,
ListRows, ListRows,
ListRow, ListRow,
ListRowItem,
ListHeader, ListHeader,
ListHeaderItem, ListHeaderItem,
ListSelectBanner,
toast,
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { computed, inject, onMounted, ref } from 'vue' import { computed, inject, onMounted, ref, watch } from 'vue'
import { Plus } from 'lucide-vue-next' import { Plus } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import EmptyState from '@/components/EmptyState.vue' import EmptyState from '@/components/EmptyState.vue'
const { brand } = sessionStore() const { brand } = sessionStore()
const user = inject('$user') const user = inject('$user')
const dayjs = inject('$dayjs')
const router = useRouter() const router = useRouter()
const quizCount = ref(0) const search = ref('')
const readOnlyMode = window.read_only_mode const readOnlyMode = window.read_only_mode
const quizFilters = ref({})
const showForm = ref(false)
const title = ref('')
onMounted(() => { onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) { if (!user.data?.is_moderator && !user.data?.is_instructor) {
router.push({ name: 'Courses' }) router.push({ name: 'Courses' })
} else if (!user.data?.is_moderator) {
quizFilters.value['owner'] = user.data?.name
} }
getQuizCount()
}) })
const quizFilter = computed(() => { watch(search, () => {
if (user.data?.is_moderator) return {} quizFilters.value['title'] = ['like', `%${search.value}%`]
return { quizzes.update({
owner: user.data?.name, filters: quizFilters.value,
} })
quizzes.reload()
}) })
const quizzes = createListResource({ const quizzes = createListResource({
doctype: 'LMS Quiz', doctype: 'LMS Quiz',
filters: quizFilter, filters: quizFilters,
fields: ['name', 'title', 'passing_percentage', 'total_marks'], fields: [
'name',
'title',
'passing_percentage',
'total_marks',
'show_answers',
'max_attempts',
'modified',
],
auto: true, auto: true,
cache: ['quizzes', user.data?.name], cache: ['quizzes', user.data?.name],
orderBy: 'modified desc', orderBy: 'modified desc',
transform(data) {
return data.map((quiz) => {
return {
...quiz,
modified: dayjs(quiz.modified).fromNow(),
}
})
},
}) })
const getQuizCount = () => { const insertQuiz = (close) => {
call('frappe.client.get_count', { quizzes.insert.submit(
doctype: 'LMS Quiz', {
}).then((data) => { title: title.value,
quizCount.value = data },
{
onSuccess(data) {
toast.success(__('Quiz created successfully'))
close()
title.value = ''
router.push({
name: 'QuizForm',
params: {
quizID: data.name,
},
})
},
onError(error) {
toast.error(__('Error creating quiz: {0}', error.message))
},
}
)
}
const deleteQuiz = (selections, unselectAll) => {
Array.from(selections).forEach(async (quizName) => {
await quizzes.delete.submit(quizName)
}) })
unselectAll()
toast.success(__('Quizzes deleted successfully'))
} }
const quizColumns = computed(() => { const quizColumns = computed(() => {
@@ -120,18 +229,42 @@ const quizColumns = computed(() => {
label: __('Title'), label: __('Title'),
key: 'title', key: 'title',
width: 2, width: 2,
icon: 'file-text',
}, },
{ {
label: __('Total Marks'), label: __('Total Marks'),
key: 'total_marks', key: 'total_marks',
width: 1, width: 1,
align: 'center', align: 'center',
icon: 'hash',
}, },
{ {
label: __('Passing Percentage'), label: __('Passing Percentage'),
key: 'passing_percentage', key: 'passing_percentage',
width: 1, width: 1,
align: 'center', align: 'center',
icon: 'percent',
},
{
label: __('Max Attempts'),
key: 'max_attempts',
width: 1,
align: 'center',
icon: 'repeat',
},
{
label: __('Show Answers'),
key: 'show_answers',
width: 1,
align: 'center',
icon: 'eye',
},
{
label: __('Modified'),
key: 'modified',
width: 1,
align: 'right',
icon: 'clock',
}, },
] ]
}) })

View File

@@ -104,24 +104,6 @@ export function getImgDimensions(imgSrc) {
}) })
} }
export function updateDocumentTitle(meta) {
watch(
() => meta,
(meta) => {
if (!meta.value.title) return
if (meta.value.title && meta.value.subtitle) {
document.title = `${meta.value.title} | ${meta.value.subtitle}`
return
}
if (meta.value.title) {
document.title = `${meta.value.title}`
return
}
},
{ immediate: true, deep: true }
)
}
export function htmlToText(html) { export function htmlToText(html) {
const div = document.createElement('div') const div = document.createElement('div')
div.innerHTML = html div.innerHTML = html

View File

@@ -17,8 +17,10 @@
"duration", "duration",
"section_break_tzbu", "section_break_tzbu",
"shuffle_questions", "shuffle_questions",
"column_break_clsh",
"limit_questions_to", "limit_questions_to",
"column_break_clsh",
"enable_negative_marking",
"marks_to_cut",
"section_break_sbjx", "section_break_sbjx",
"questions", "questions",
"section_break_3", "section_break_3",
@@ -134,6 +136,18 @@
"fieldname": "duration", "fieldname": "duration",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Duration (in minutes)" "label": "Duration (in minutes)"
},
{
"default": "0",
"fieldname": "enable_negative_marking",
"fieldtype": "Check",
"label": "Enable Negative Marking"
},
{
"depends_on": "enable_negative_marking",
"fieldname": "marks_to_cut",
"fieldtype": "Int",
"label": "Marks To Cut"
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
@@ -144,8 +158,8 @@
"link_fieldname": "quiz" "link_fieldname": "quiz"
} }
], ],
"modified": "2025-04-07 15:03:48.525458", "modified": "2025-06-27 18:33:13.714465",
"modified_by": "Administrator", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Quiz", "name": "LMS Quiz",
"owner": "Administrator", "owner": "Administrator",

View File

@@ -44,6 +44,11 @@ class LMSQuiz(Document):
frappe.throw(_("All questions should have the same marks if the limit is set.")) frappe.throw(_("All questions should have the same marks if the limit is set."))
def calculate_total_marks(self): def calculate_total_marks(self):
if len(self.questions) == 0:
self.total_marks = 0
self.passing_percentage = 100
return
if self.limit_questions_to: if self.limit_questions_to:
self.total_marks = sum( self.total_marks = sum(
question.marks for question in self.questions[: cint(self.limit_questions_to)] question.marks for question in self.questions[: cint(self.limit_questions_to)]
@@ -102,11 +107,19 @@ def quiz_summary(quiz, results):
quiz_details = frappe.db.get_value( quiz_details = frappe.db.get_value(
"LMS Quiz", "LMS Quiz",
quiz, quiz,
["total_marks", "passing_percentage", "lesson", "course"], [
"name",
"total_marks",
"passing_percentage",
"lesson",
"course",
"enable_negative_marking",
"marks_to_cut",
],
as_dict=1, as_dict=1,
) )
data = process_results(results, quiz) data = process_results(results, quiz_details)
results = data["results"] results = data["results"]
score = data["score"] score = data["score"]
is_open_ended = data["is_open_ended"] is_open_ended = data["is_open_ended"]
@@ -129,14 +142,14 @@ def quiz_summary(quiz, results):
} }
def process_results(results, quiz): def process_results(results, quiz_details):
score = 0 score = 0
is_open_ended = False is_open_ended = False
for result in results: for result in results:
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_details.name, "question": result["question_name"]},
["question", "marks", "question_detail", "type"], ["question", "marks", "question_detail", "type"],
as_dict=1, as_dict=1,
) )
@@ -154,7 +167,11 @@ def process_results(results, quiz):
else: else:
result["is_correct"] = 0 result["is_correct"] = 0
marks = question_details.marks if correct else 0 if correct:
marks = question_details.marks
else:
marks = -quiz_details.marks_to_cut if quiz_details.enable_negative_marking else 0
result["marks"] = marks result["marks"] = marks
score += marks score += marks