Merge pull request #1044 from pateljannat/open-ended-questions

feat: open ended questions
This commit is contained in:
Jannat Patel
2024-10-08 10:37:44 +05:30
committed by GitHub
23 changed files with 564 additions and 215 deletions

View File

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

View File

@@ -107,6 +107,7 @@ const unreadCount = ref(0)
const sidebarLinks = ref(getSidebarLinks())
const showPageModal = ref(false)
const isModerator = ref(false)
const isInstructor = ref(false)
const pageToEdit = ref(null)
const showWebPages = ref(false)
@@ -167,6 +168,17 @@ const addNotifications = () => {
}
}
const addQuizzes = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({
label: 'Quizzes',
icon: 'CircleHelp',
to: 'Quizzes',
activeFor: ['Quizzes', 'QuizForm'],
})
}
}
const openPageModal = (link) => {
showPageModal.value = true
pageToEdit.value = link
@@ -197,6 +209,8 @@ const getSidebarFromStorage = () => {
watch(userResource, () => {
if (userResource.data) {
isModerator.value = userResource.data.is_moderator
isInstructor.value = userResource.data.is_instructor
addQuizzes()
}
})

View File

@@ -5,9 +5,11 @@
</div>
<div
v-if="sidebarSettings.data"
class="fixed flex justify-around border-t border-gray-300 bottom-0 z-10 w-full bg-white standalone:pb-4"
class="fixed flex items-center justify-around border-t border-gray-300 bottom-0 z-10 w-full bg-white standalone:pb-4"
:style="{
gridTemplateColumns: `repeat(${sidebarLinks.length}, minmax(0, 1fr))`,
gridTemplateColumns: `repeat(${
sidebarLinks.length + 1
}, minmax(0, 1fr))`,
}"
>
<button
@@ -23,15 +25,46 @@
:class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']"
/>
</button>
<Popover
trigger="hover"
popoverClass="bottom-28 mx-2"
placement="top-start"
>
<template #target>
<component
:is="icons['List']"
class="h-6 w-6 stroke-1.5 text-gray-600"
/>
</template>
<template #body-main>
<div class="text-base p-5 space-y-4">
<div
v-for="link in otherLinks"
:key="link.label"
class="flex items-center space-x-2"
@click="handleClick(link)"
>
<component
:is="icons[link.icon]"
class="h-4 w-4 stroke-1.5 text-gray-600"
/>
<div>
{{ link.label }}
</div>
</div>
</div>
</template>
</Popover>
</div>
</div>
</template>
<script setup>
import { getSidebarLinks } from '../utils'
import { useRouter } from 'vue-router'
import { computed, ref, onMounted } from 'vue'
import { watch, ref, onMounted } from 'vue'
import { sessionStore } from '@/stores/session'
import { usersStore } from '@/stores/user'
import { Popover } from 'frappe-ui'
import * as icons from 'lucide-vue-next'
const { logout, user, sidebarSettings } = sessionStore()
@@ -39,6 +72,7 @@ let { isLoggedIn } = sessionStore()
const router = useRouter()
let { userResource } = usersStore()
const sidebarLinks = ref(getSidebarLinks())
const otherLinks = ref([])
onMounted(() => {
sidebarSettings.reload(
@@ -52,37 +86,53 @@ onMounted(() => {
)
}
})
addAccessLinks()
addOtherLinks()
},
}
)
})
const addAccessLinks = () => {
const addOtherLinks = () => {
if (user) {
sidebarLinks.value.push({
otherLinks.value.push({
label: 'Notifications',
icon: 'Bell',
to: 'Notifications',
})
otherLinks.value.push({
label: 'Profile',
icon: 'UserRound',
activeFor: [
'Profile',
'ProfileAbout',
'ProfileCertification',
'ProfileEvaluator',
'ProfileRoles',
],
})
sidebarLinks.value.push({
otherLinks.value.push({
label: 'Log out',
icon: 'LogOut',
})
} else {
sidebarLinks.value.push({
otherLinks.value.push({
label: 'Log in',
icon: 'LogIn',
})
}
}
watch(userResource, () => {
if (
userResource.data &&
(userResource.data.is_moderator || userResource.data.is_instructor)
) {
addQuizzes()
}
})
const addQuizzes = () => {
otherLinks.value.push({
label: 'Quizzes',
icon: 'CircleHelp',
to: 'Quizzes',
})
}
let isActive = (tab) => {
return tab.activeFor?.includes(router.currentRoute.value.name)
}

View File

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

View File

@@ -67,15 +67,8 @@
<span class="mr-2">
{{ __('Question {0}').format(activeQuestion) }}:
</span>
<span v-if="questionDetails.data.type == 'User Input'">
{{ __('Type your answer') }}
</span>
<span v-else>
{{
questionDetails.data.multiple
? __('Choose all answers that apply')
: __('Choose one answer')
}}
<span>
{{ getInstructions(questionDetails.data) }}
</span>
</div>
<div class="text-gray-900 text-sm font-semibold item-left">
@@ -139,7 +132,7 @@
{{ questionDetails.data[`explanation_${index}`] }}
</div>
</div>
<div v-else>
<div v-else-if="questionDetails.data.type == 'User Input'">
<FormControl
v-model="possibleAnswer"
type="textarea"
@@ -159,6 +152,16 @@
</Badge>
</div>
</div>
<div v-else>
<TextEditor
class="mt-4"
:content="possibleAnswer"
@change="(val) => (possibleAnswer = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
<div class="flex items-center justify-between mt-5">
<div>
{{
@@ -169,7 +172,11 @@
}}
</div>
<Button
v-if="quiz.data.show_answers && !showAnswers.length"
v-if="
quiz.data.show_answers &&
!showAnswers.length &&
questionDetails.data.type != 'Open Ended'
"
@click="checkAnswer()"
>
<span>
@@ -193,11 +200,18 @@
</div>
</div>
</div>
<div v-else class="border rounded-md p-20 text-center">
<div v-else class="border rounded-md p-20 text-center space-y-4">
<div class="text-lg font-semibold">
{{ __('Quiz Summary') }}
</div>
<div>
<div v-if="quizSubmission.data.is_open_ended">
{{
__(
"Your submission has been successfully saved. The instructor will review and grade it shortly, and you'll be notified of your final result."
)
}}
</div>
<div v-else>
{{
__(
'You got {0}% correct answers with a score of {1} out of {2}'
@@ -236,7 +250,7 @@
</div>
</template>
<script setup>
import { Badge, Button, createResource, ListView } from 'frappe-ui'
import { Badge, Button, createResource, ListView, TextEditor } from 'frappe-ui'
import { ref, watch, reactive, inject } from 'vue'
import { createToast } from '@/utils/'
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
@@ -450,9 +464,10 @@ const addToLocalStorage = () => {
}
const nextQuetion = () => {
if (!quiz.data.show_answers) {
if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') {
checkAnswer()
} else {
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
resetQuestion()
}
}
@@ -467,7 +482,8 @@ const resetQuestion = () => {
const submitQuiz = () => {
if (!quiz.data.show_answers) {
checkAnswer()
if (questionDetails.data.type == 'Open Ended') addToLocalStorage()
else checkAnswer()
setTimeout(() => {
createSubmission()
}, 500)
@@ -490,6 +506,13 @@ const resetQuiz = () => {
populateQuestions()
}
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 = () => {
return [
{

View File

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

View File

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

View File

@@ -122,12 +122,21 @@
/>
</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') }}
</Button>
</div>
</div>
</div>
</div>
<div v-else-if="access.data?.message">
<NotPermitted
:text="access.data.message"

View File

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

View File

@@ -42,7 +42,7 @@
<img
:src="badge.badge_image"
:alt="badge.badge"
class="bg-gray-100 rounded-t-md"
class="bg-gray-100 rounded-t-md h-[200px] mx-auto"
/>
<div class="p-5">
<div class="text-2xl font-semibold mb-2">

View File

@@ -3,9 +3,24 @@
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" />
<div class="space-x-2">
<router-link
v-if="quizDetails.data?.name"
:to="{
name: 'QuizSubmissionList',
params: {
quizID: quizDetails.data.name,
},
}"
>
<Button>
{{ __('Submission List') }}
</Button>
</router-link>
<Button variant="solid" @click="submitQuiz()">
{{ __('Save') }}
</Button>
</div>
</header>
<div class="w-3/4 mx-auto py-5">
<!-- Details -->

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
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<Breadcrumbs v-if="submisisonDetails.doc" :items="breadcrumbs" />
<div class="space-x-2">
<Badge
v-if="submisisonDetails.isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
<Button variant="solid" @click="saveSubmission()">
{{ __('Save') }}
</Button>
</div>
</header>
<div class="w-1/2 mx-auto py-10">
<Quiz :quizName="quizID" />
<div v-if="submisisonDetails.doc" class="w-1/2 mx-auto py-5 space-y-4">
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="submisisonDetails.doc.quiz_title"
:label="__('Quiz')"
:disabled="true"
/>
<FormControl
v-model="submisisonDetails.doc.member_name"
:label="__('Member')"
:disabled="true"
/>
</div>
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="submisisonDetails.doc.score"
:label="__('Score')"
:disabled="true"
/>
<FormControl
v-model="submisisonDetails.doc.percentage"
:label="__('Percentage')"
:disabled="true"
/>
</div>
<div
v-for="row in submisisonDetails.doc.result"
class="border p-5 rounded-md space-y-4"
>
<div class="font-semibold">{{ row.idx }}. {{ row.question }}</div>
<div v-html="row.answer" class="leading-5"></div>
<div class="grid grid-cols-2 gap-5">
<FormControl v-model="row.marks" :label="__('Marks')" />
<FormControl
v-model="row.marks_out_of"
:label="__('Marks out of')"
:disabled="true"
/>
</div>
</div>
</div>
</template>
<script setup>
import Quiz from '@/components/Quiz.vue'
import { createResource, Breadcrumbs } from 'frappe-ui'
import { computed, inject, onMounted } from 'vue'
import {
createDocumentResource,
Breadcrumbs,
FormControl,
Button,
Badge,
} from 'frappe-ui'
import { computed, onMounted, inject } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from '@/utils'
const user = inject('$user')
const router = useRouter()
const user = inject('$user')
onMounted(() => {
if (!user.data) {
if (!user.data?.is_instructor && !user.data?.is_moderator)
router.push({ name: 'Courses' })
}
})
const props = defineProps({
quizID: {
submission: {
type: String,
required: true,
},
})
const title = createResource({
url: 'frappe.client.get_value',
params: {
doctype: 'LMS Quiz',
fieldname: 'title',
filters: {
name: props.quizID,
},
},
const submisisonDetails = createDocumentResource({
doctype: 'LMS Quiz Submission',
name: props.submission,
auto: true,
})
const breadcrumbs = computed(() => {
return [{ label: __('Quiz Submission') }, { label: title.data?.title }]
return [
{
label: __('Quiz Submissions'),
route: {
name: 'QuizSubmissionList',
params: {
quizID: submisisonDetails.doc.quiz,
},
},
},
{
label: submisisonDetails.doc.quiz_title,
},
]
})
const saveSubmission = () => {
submisisonDetails.save.submit(
{},
{
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
}
)
}
</script>

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>
</router-link>
</header>
<div v-if="quizzes.data?.length" class="w-3/4 mx-auto py-5">
<div v-if="quizzes.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
<ListView
:columns="quizColumns"
:rows="quizzes.data"

View File

@@ -160,7 +160,19 @@ const routes = [
},
{
path: '/quiz/:quizID',
name: 'Quiz',
name: 'QuizPage',
component: () => import('@/pages/QuizPage.vue'),
props: true,
},
{
path: '/quiz-submissions/:quizID',
name: 'QuizSubmissionList',
component: () => import('@/pages/QuizSubmissionList.vue'),
props: true,
},
{
path: '/quiz-submission/:submission',
name: 'QuizSubmission',
component: () => import('@/pages/QuizSubmission.vue'),
props: true,
},

View File

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

View File

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

View File

@@ -3,7 +3,8 @@
import json
import frappe
from frappe import _
import re
from frappe import _, safe_decode
from frappe.model.document import Document
from frappe.utils import cstr, comma_and, cint
from fuzzywuzzy import fuzz
@@ -13,6 +14,9 @@ from lms.lms.utils import (
has_course_moderator_role,
has_course_instructor_role,
)
from binascii import Error as BinasciiError
from frappe.utils.file_manager import safe_b64decode
from frappe.core.doctype.file.utils import get_random_filename
class LMSQuiz(Document):
@@ -20,6 +24,7 @@ class LMSQuiz(Document):
self.validate_duplicate_questions()
self.validate_limit()
self.calculate_total_marks()
self.validate_open_ended_questions()
def validate_duplicate_questions(self):
questions = [row.question for row in self.questions]
@@ -48,6 +53,19 @@ class LMSQuiz(Document):
else:
self.total_marks = sum(cint(question.marks) for question in self.questions)
def validate_open_ended_questions(self):
types = [question.type for question in self.questions]
types = set(types)
if "Open Ended" in types and len(types) > 1:
frappe.throw(
_(
"If you want open ended questions then make sure each question in the quiz is of open ended type."
)
)
else:
self.show_answers = 0
def autoname(self):
if not self.name:
self.name = generate_slug(self.title, "LMS Quiz")
@@ -81,34 +99,50 @@ def set_total_marks(questions):
def quiz_summary(quiz, results):
score = 0
results = results and json.loads(results)
is_open_ended = False
for result in results:
correct = result["is_correct"][0]
for point in result["is_correct"]:
correct = correct and point
result["is_correct"] = correct
question_details = frappe.db.get_value(
"LMS Quiz Question",
{"parent": quiz, "question": result["question_name"]},
["question", "marks", "question_detail"],
["question", "marks", "question_detail", "type"],
as_dict=1,
)
result["question_name"] = question_details.question
result["question"] = question_details.question_detail
marks = question_details.marks if correct else 0
result["marks_out_of"] = question_details.marks
quiz_details = frappe.get_doc(
"LMS Quiz",
quiz,
["total_marks", "passing_percentage", "lesson", "course"],
as_dict=1,
)
score = 0
percentage = 0
score_out_of = quiz_details.total_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
score += marks
del result["question_name"]
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
else:
result["is_correct"] = 0
is_open_ended = True
result["answer"] = re.sub(
r'<img[^>]*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, result["answer"]
)
submission = frappe.get_doc(
{
@@ -139,128 +173,51 @@ def quiz_summary(quiz, results):
"submission": submission.name,
"pass": percentage == quiz_details.passing_percentage,
"percentage": percentage,
"is_open_ended": is_open_ended,
}
@frappe.whitelist()
def save_quiz(
quiz_title,
passing_percentage,
questions,
max_attempts=0,
quiz=None,
show_answers=1,
show_submission_history=0,
):
if not has_course_moderator_role() or not has_course_instructor_role():
return
def _save_file(match):
data = match.group(1).split("data:")[1]
headers, content = data.split(",")
mtype = headers.split(";", 1)[0]
values = {
"title": quiz_title,
"passing_percentage": passing_percentage,
"max_attempts": max_attempts,
"show_answers": show_answers,
"show_submission_history": show_submission_history,
}
if isinstance(content, str):
content = content.encode("utf-8")
if b"," in content:
content = content.split(b",")[1]
try:
content = safe_b64decode(content)
except BinasciiError:
frappe.flags.has_dataurl = True
return f'<img src="#broken-image" alt="{get_corrupted_image_msg()}"'
if "filename=" in headers:
filename = headers.split("filename=")[-1]
filename = safe_decode(filename).split(";", 1)[0]
if quiz:
frappe.db.set_value("LMS Quiz", quiz, values)
update_questions(quiz, questions)
return quiz
else:
doc = frappe.new_doc("LMS Quiz")
doc.update(values)
doc.save()
update_questions(doc.name, questions)
return doc.name
filename = get_random_filename(content_type=mtype)
def update_questions(quiz, questions):
questions = json.loads(questions)
delete_questions(quiz, questions)
add_questions(quiz, questions)
frappe.db.set_value("LMS Quiz", quiz, "total_marks", set_total_marks(quiz, questions))
def delete_questions(quiz, questions):
existing_questions = frappe.get_all(
"LMS Quiz Question",
_file = frappe.get_doc(
{
"parent": quiz,
},
pluck="name",
)
current_questions = [question.get("question_name") for question in questions]
for question in existing_questions:
if question not in current_questions:
frappe.db.delete("LMS Quiz Question", question)
def add_questions(quiz, questions):
for index, question in enumerate(questions):
question = frappe._dict(question)
if question.question_name:
doc = frappe.get_doc("LMS Quiz Question", question.question_name)
else:
doc = frappe.new_doc("LMS Quiz Question")
doc.update(
{
"parent": quiz,
"parenttype": "LMS Quiz",
"parentfield": "questions",
"idx": index + 1,
"doctype": "File",
"file_name": filename,
"content": content,
"decode": False,
"is_private": False,
}
)
_file.save(ignore_permissions=True)
file_url = _file.unique_url
frappe.flags.has_dataurl = True
doc.update({"question": question.question, "marks": question.marks})
doc.save()
return f'<img src="{file_url}"'
@frappe.whitelist()
def save_question(quiz, values, index):
values = frappe._dict(json.loads(values))
if values.get("name"):
doc = frappe.get_doc("LMS Question", values.get("name"))
else:
doc = frappe.new_doc("LMS Question")
doc.update(
{
"question": values.question,
"type": values["type"],
}
)
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
def get_corrupted_image_msg():
return _("Image: Corrupted Data Stream")
@frappe.whitelist()
@@ -318,9 +275,3 @@ def check_input_answers(question, answer):
return 1
return 0
@frappe.whitelist()
def get_user_quizzes():
filters = {} if has_course_moderator_role() else {"owner": frappe.session.user}
return frappe.get_all("LMS Quiz", filters=filters, fields=["name", "title"])

View File

@@ -9,7 +9,8 @@
"column_break_qcpo",
"marks",
"section_break_huup",
"question_detail"
"question_detail",
"type"
],
"fields": [
{
@@ -44,12 +45,21 @@
{
"fieldname": "section_break_huup",
"fieldtype": "Section Break"
},
{
"fetch_from": "question.type",
"fieldname": "type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Type",
"options": "Choices\nUser Input\nOpen Ended",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-07-29 15:10:09.662715",
"modified": "2024-10-07 15:01:38.800906",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Quiz Question",

View File

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

View File

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

View File

@@ -1,15 +1,28 @@
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
# import frappe
import frappe
from frappe.model.document import Document
from frappe.utils import cint
from frappe import _
class LMSQuizSubmission(Document):
def before_insert(self):
if not self.percentage:
def validate(self):
self.validate_marks()
self.set_percentage()
def validate_marks(self):
for row in self.result:
if cint(row.marks) > cint(row.marks_out_of):
frappe.throw(
_(
"Marks for question number {0} cannot be greater than the marks allotted for that question."
).format(row.idx)
)
else:
self.score += cint(row.marks)
def set_percentage(self):
if self.score and self.score_out_of:
self.percentage = (self.score / self.score_out_of) * 100