Merge pull request #1602 from pateljannat/negative-marking-in-quiz

feat: negative marking in quiz
This commit is contained in:
Jannat Patel
2025-06-30 11:41:53 +05:30
committed by GitHub
9 changed files with 422 additions and 301 deletions

View File

@@ -2,7 +2,7 @@
<Dialog
v-model="show"
:options="{
size: '3xl',
size: '5xl',
}"
>
<template #body>
@@ -21,7 +21,7 @@
class="!p-0"
/>
</div>
<div v-if="!chooseFromExisting || editMode" class="space-y-2">
<div v-if="!chooseFromExisting || editMode">
<div>
<label class="block text-xs text-ink-gray-5 mb-1">
{{ __('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]"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-2 gap-8 mt-4">
<FormControl
v-model="question.marks"
:label="__('Marks')"
@@ -51,7 +51,7 @@
</div>
<div
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') }}
</div>
@@ -61,7 +61,10 @@
>
{{ __('Possibilities') }}
</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">
<FormControl
:label="__('Option') + ' ' + n"
@@ -81,7 +84,7 @@
</div>
<div
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">
<FormControl
@@ -106,7 +109,7 @@
</div>
<div class="flex items-center justify-end space-x-2 mt-5">
<Button variant="solid" @click="submitQuestion()">
{{ __('Submit') }}
{{ __('Save') }}
</Button>
</div>
</div>
@@ -217,7 +220,7 @@ const questionRow = createResource({
return {
doc: {
doctype: 'LMS Quiz Question',
parent: quiz.value.data.name,
parent: quiz.value.doc.name,
parentfield: 'questions',
parenttype: 'LMS Quiz',
...values,

View File

@@ -1,7 +1,7 @@
<template>
<div v-if="quiz.data">
<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">
{{ __('You will have to complete the quiz to continue the video') }}
@@ -41,6 +41,16 @@
)
}}
</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 v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">

View File

@@ -57,22 +57,24 @@
</Button>
</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
v-model="code"
:language="exercise.doc?.language.toLowerCase()"
height="400px"
maxHeight="1000px"
/>
<span v-if="error" class="text-xs text-ink-gray-5 px-2">
{{ __('Compiler Message') }}:
</span>
<textarea
v-if="error"
v-model="errorMessage"
class="bg-surface-gray-1 border-none text-sm h-32 leading-6"
readonly
/>
<div class="flex flex-col space-y-1">
<span v-if="error" class="text-xs text-ink-gray-5 px-1">
{{ __('Compiler Message') }}:
</span>
<textarea
v-if="error"
v-model="errorMessage"
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 /> -->
</div>

View File

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

View File

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

View File

@@ -17,8 +17,10 @@
"duration",
"section_break_tzbu",
"shuffle_questions",
"column_break_clsh",
"limit_questions_to",
"column_break_clsh",
"enable_negative_marking",
"marks_to_cut",
"section_break_sbjx",
"questions",
"section_break_3",
@@ -134,6 +136,19 @@
"fieldname": "duration",
"fieldtype": "Data",
"label": "Duration (in minutes)"
},
{
"default": "0",
"fieldname": "enable_negative_marking",
"fieldtype": "Check",
"label": "Enable Negative Marking"
},
{
"default": "1",
"depends_on": "enable_negative_marking",
"fieldname": "marks_to_cut",
"fieldtype": "Int",
"label": "Marks To Cut"
}
],
"grid_page_length": 50,
@@ -144,8 +159,8 @@
"link_fieldname": "quiz"
}
],
"modified": "2025-04-07 15:03:48.525458",
"modified_by": "Administrator",
"modified": "2025-06-27 20:00:15.660323",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Quiz",
"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."))
def calculate_total_marks(self):
if len(self.questions) == 0:
self.total_marks = 0
self.passing_percentage = 100
return
if self.limit_questions_to:
self.total_marks = sum(
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(
"LMS Quiz",
quiz,
["total_marks", "passing_percentage", "lesson", "course"],
[
"name",
"total_marks",
"passing_percentage",
"lesson",
"course",
"enable_negative_marking",
"marks_to_cut",
],
as_dict=1,
)
data = process_results(results, quiz)
data = process_results(results, quiz_details)
results = data["results"]
score = data["score"]
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
is_open_ended = False
for result in results:
question_details = frappe.db.get_value(
"LMS Quiz Question",
{"parent": quiz, "question": result["question_name"]},
{"parent": quiz_details.name, "question": result["question_name"]},
["question", "marks", "question_detail", "type"],
as_dict=1,
)
@@ -154,7 +167,11 @@ def process_results(results, quiz):
else:
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
score += marks