feat: assignments list and form

This commit is contained in:
Jannat Patel
2024-12-24 21:48:45 +05:30
parent f331c48e1d
commit a44f59c362
20 changed files with 808 additions and 141 deletions

View File

@@ -185,6 +185,17 @@ const addQuizzes = () => {
} }
} }
const addAssignments = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({
label: 'Assignments',
icon: 'Pencil',
to: 'Assignments',
activeFor: ['Assignments', 'AssignmentForm'],
})
}
}
const addPrograms = () => { const addPrograms = () => {
let activeFor = ['Programs', 'ProgramForm'] let activeFor = ['Programs', 'ProgramForm']
let index = 1 let index = 1
@@ -247,8 +258,9 @@ 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 isInstructor.value = userResource.data.is_instructor
addQuizzes()
addPrograms() addPrograms()
addQuizzes()
addAssignments()
} }
}) })

View File

@@ -0,0 +1,75 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
}"
>
<template #body>
<div class="p-5 space-y-4">
<div v-if="type == 'quiz'" class="text-lg font-semibold">
{{ __('Add a quiz to your lesson') }}
</div>
<div v-else class="text-lg font-semibold">
{{ __('Add an assignment to your lesson') }}
</div>
<div>
<Link
v-if="type == 'quiz'"
v-model="quiz"
doctype="LMS Quiz"
:label="__('Select a quiz')"
:onCreate="(value, close) => redirectToForm()"
/>
<Link
v-else
v-model="assignment"
doctype="LMS Assignment"
:label="__('Select an assignment')"
:onCreate="(value, close) => redirectToForm()"
/>
</div>
<div class="flex justify-end space-x-2">
<Button variant="solid" @click="addAssessment()">
{{ __('Save') }}
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, Button } from 'frappe-ui'
import { onMounted, ref, nextTick } from 'vue'
import Link from '@/components/Controls/Link.vue'
const show = ref(false)
const quiz = ref(null)
const assignment = ref(null)
const props = defineProps({
type: {
type: String,
required: true,
},
onAddition: {
type: Function,
required: true,
},
})
onMounted(async () => {
await nextTick()
show.value = true
})
const addAssessment = () => {
props.onAddition(props.type == 'quiz' ? quiz.value : assignment.value)
show.value = false
}
const redirectToForm = () => {
if (props.type == 'quiz') window.open('/lms/quizzes/new', '_blank')
else window.open('/lms/assignments/new', '_blank')
}
</script>

View File

@@ -148,7 +148,7 @@ const getRowRoute = (row) => {
return { return {
name: 'AssignmentSubmission', name: 'AssignmentSubmission',
params: { params: {
assignmentName: row.assessment_name, assignmentID: row.assessment_name,
submissionName: row.submission.name, submissionName: row.submission.name,
}, },
} }
@@ -156,7 +156,7 @@ const getRowRoute = (row) => {
return { return {
name: 'AssignmentSubmission', name: 'AssignmentSubmission',
params: { params: {
assignmentName: row.assessment_name, assignmentID: row.assessment_name,
submissionName: 'new', submissionName: 'new',
}, },
} }

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class=""> <div class="">
<div class="w-full flex items-center justify-between border-b pb-4"> <div class="w-full flex items-center justify-between pb-4">
<div class="font-medium text-gray-600"> <div class="font-medium text-gray-600">
{{ __('Statistics') }} {{ __('Statistics') }}
</div> </div>
@@ -98,9 +98,6 @@
row-key="name" row-key="name"
:options="{ :options="{
showTooltip: false, showTooltip: false,
onRowClick: (row) => {
openStudentProgressModal(row)
},
}" }"
> >
<ListHeader <ListHeader
@@ -121,7 +118,12 @@
</ListHeaderItem> </ListHeaderItem>
</ListHeader> </ListHeader>
<ListRows> <ListRows>
<ListRow :row="row" v-for="row in students.data"> <ListRow
:row="row"
v-for="row in students.data"
class="group cursor-pointer"
@click="openStudentProgressModal(row)"
>
<template #default="{ column, item }"> <template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align"> <ListRowItem :item="row[column.key]" :align="column.align">
<template #prefix> <template #prefix>
@@ -140,6 +142,16 @@
> >
<ProgressBar :progress="row[column.key]" size="sm" /> <ProgressBar :progress="row[column.key]" size="sm" />
</div> </div>
<div
v-else-if="column.key == 'copy'"
class="invisible group-hover:visible"
>
<Button variant="ghost" @click="copyEmail(row)">
<template #icon>
<Clipboard class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
<div v-else> <div v-else>
{{ row[column.key] }} {{ row[column.key] }}
</div> </div>
@@ -190,7 +202,14 @@ import {
ListView, ListView,
ListRowItem, ListRowItem,
} from 'frappe-ui' } from 'frappe-ui'
import { BookOpen, Plus, ShieldCheck, Trash2, User } from 'lucide-vue-next' import {
BookOpen,
Clipboard,
Plus,
ShieldCheck,
Trash2,
User,
} from 'lucide-vue-next'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import StudentModal from '@/components/Modals/StudentModal.vue' import StudentModal from '@/components/Modals/StudentModal.vue'
import { showToast } from '@/utils' import { showToast } from '@/utils'
@@ -247,6 +266,10 @@ const getStudentColumns = () => {
align: 'center', align: 'center',
icon: 'clock', icon: 'clock',
}, },
{
label: '',
key: 'copy',
},
] ]
return columns return columns
@@ -331,7 +354,10 @@ const getChartData = () => {
const getChartOptions = (categories) => { const getChartOptions = (categories) => {
const courseColor = '#0289F7' const courseColor = '#0289F7'
const assessmentColor = '#E03636' const assessmentColor = '#E03636'
const maxY = Math.ceil(students.data?.length / 10) * 10 const maxY =
students.data?.length % 5
? students.data?.length + (5 - (students.data?.length % 5))
: students.data?.length
return { return {
chart: { chart: {
@@ -346,7 +372,7 @@ const getChartOptions = (categories) => {
distributed: true, distributed: true,
borderRadius: 0, borderRadius: 0,
horizontal: false, horizontal: false,
columnWidth: '30%', columnWidth: '5%',
}, },
}, },
colors: Object.values(categories).map((item) => colors: Object.values(categories).map((item) =>
@@ -362,20 +388,22 @@ const getChartOptions = (categories) => {
fontSize: '10px', fontSize: '10px',
}, },
rotate: 0, rotate: 0,
formatter: function (value) {
return value.length > 22 ? `${value.substring(0, 22)}...` : value // Trim long labels
},
}, },
}, },
yaxis: { yaxis: {
max: maxY, max: maxY,
min: 0, min: 0,
stepSize: 10, stepSize: 10,
tickAmount: maxY / 10, tickAmount: maxY / 5,
}, },
} }
} }
const copyEmail = (row) => {
navigator.clipboard.writeText(row.email)
showToast(__('Success'), __('Email copied to clipboard'), 'check')
}
watch(students, () => { watch(students, () => {
if (students.data?.length) { if (students.data?.length) {
assessmentCount.value = Object.keys(students.data?.[0].assessments).length assessmentCount.value = Object.keys(students.data?.[0].assessments).length

View File

@@ -1,5 +1,20 @@
<template> <template>
<div class="space-y-5"> <div class="space-y-5">
<div class="space-y-2">
<div class="flex items-center text-sm font-medium space-x-2">
<span>
{{ __('What does include in preview mean?') }}
</span>
</div>
<div class="text-xs text-gray-600 mb-1 leading-5">
{{
__(
'If Include in Preview is enabled for a lesson then the lesson will also be accessible to non logged in users.'
)
}}
</div>
</div>
<div class="space-y-2"> <div class="space-y-2">
<div <div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer" class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
@@ -56,21 +71,6 @@
}} }}
</div> </div>
</div> </div>
<div class="space-y-2">
<div class="flex items-center text-sm font-medium space-x-2">
<span>
{{ __('What does include in preview mean?') }}
</span>
</div>
<div class="text-xs text-gray-600 mb-1 leading-5">
{{
__(
'If Include in Preview is enabled for a lesson then the lesson will also be accessible to non logged in users.'
)
}}
</div>
</div>
</div> </div>
<ExplanationVideos v-model="showExplanation" :title="title" :type="type" /> <ExplanationVideos v-model="showExplanation" :title="title" :type="type" />
</template> </template>

View File

@@ -1,7 +1,7 @@
<template> <template>
<Dialog v-model="show" :options="{}"> <Dialog v-model="show" :options="{}">
<template #body> <template #body>
<div class="p-5 space-y-8"> <div class="p-5 space-y-8 text-base">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Avatar :image="student.user_image" size="3xl" /> <Avatar :image="student.user_image" size="3xl" />
<div class="space-y-1"> <div class="space-y-1">

View File

@@ -1,58 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
}"
>
<template #body>
<div class="p-5 space-y-4">
<div class="text-lg font-semibold">
{{ __('Add a quiz to your lesson') }}
</div>
<div>
<Link
v-model="quiz"
doctype="LMS Quiz"
:label="__('Select a quiz')"
:onCreate="(value, close) => redirectToQuizForm()"
/>
</div>
<div class="flex justify-end space-x-2">
<Button variant="solid" @click="addQuiz()">
{{ __('Save') }}
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, Button } from 'frappe-ui'
import { onMounted, ref, nextTick } from 'vue'
import Link from '@/components/Controls/Link.vue'
const show = ref(false)
const quiz = ref(null)
const props = defineProps({
onQuizAddition: {
type: Function,
required: true,
},
})
onMounted(async () => {
await nextTick()
show.value = true
})
const addQuiz = () => {
props.onQuizAddition(quiz.value)
show.value = false
}
const redirectToQuizForm = () => {
window.open('/lms/quizzes/new', '_blank')
}
</script>

View File

@@ -0,0 +1,183 @@
<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" />
<div class="space-x-2">
<router-link
v-if="assignment.doc?.name"
:to="{
name: 'AssignmentSubmissionList',
params: {
assignmentID: assignment.doc.name,
},
}"
>
<Button>
{{ __('Submission List') }}
</Button>
</router-link>
<Button variant="solid" @click="saveAssignment()">
{{ __('Save') }}
</Button>
</div>
</header>
<div class="w-3/4 mx-auto py-5">
<div class="font-semibold mb-4">
{{ __('Details') }}
</div>
<div class="grid grid-cols-2 gap-5 mt-4 mb-8">
<FormControl
v-model="model.title"
:label="__('Title')"
:required="true"
/>
<FormControl
v-model="model.type"
type="select"
:options="assignmentOptions"
:label="__('Type')"
:required="true"
/>
</div>
<div>
<div class="text-xs text-gray-600 mb-2">
{{ __('Question') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="model.question"
@change="(val) => (model.question = 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>
</template>
<script setup>
import {
Breadcrumbs,
Button,
createDocumentResource,
createResource,
FormControl,
TextEditor,
} from 'frappe-ui'
import { computed, inject, onMounted, onBeforeUnmount, reactive } from 'vue'
import { showToast } from '@/utils'
import { useRouter } from 'vue-router'
const user = inject('$user')
const router = useRouter()
const props = defineProps({
assignmentID: {
type: String,
required: true,
},
})
const model = reactive({
title: '',
type: 'PDF',
question: '',
})
onMounted(() => {
if (
props.assignmentID == 'new' &&
!user.data?.is_moderator &&
!user.data?.is_instructor
) {
router.push({ name: 'Courses' })
}
if (props.assignmentID !== 'new') {
assignment.reload()
}
window.addEventListener('keydown', keyboardShortcut)
})
const keyboardShortcut = (e) => {
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
saveAssignment()
e.preventDefault()
}
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
const assignment = createDocumentResource({
doctype: 'LMS Assignment',
name: props.assignmentID,
auto: false,
onSuccess(data) {
Object.keys(data).forEach((key) => {
model[key] = data[key]
})
},
})
const newAssignment = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Assignment',
...values,
},
}
},
onSuccess(data) {
router.push({ name: 'AssignmentForm', params: { assignmentID: data.name } })
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
})
const saveAssignment = () => {
if (props.assignmentID == 'new') {
newAssignment.submit({
...model,
})
} else {
assignment.setValue.submit(
{
...model,
},
{
onSuccess(data) {
showToast(__('Success'), __('Assignment saved successfully'), 'check')
assignment.reload()
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
}
)
}
}
const breadcrumbs = computed(() => [
{
label: __('Assignments'),
route: { name: 'Assignments' },
},
{
label: assignment.doc ? assignment.doc.title : __('New Assignment'),
},
])
const assignmentOptions = computed(() => {
return [
{ label: 'PDF', value: 'PDF' },
{ label: 'Image', value: 'Image' },
{ label: 'Document', value: 'Document' },
{ label: 'Text', value: 'Text' },
{ label: 'URL', value: 'URL' },
]
})
</script>

View File

@@ -10,7 +10,7 @@
<div class="container py-5"> <div class="container py-5">
<div <div
v-if="submissionResource.data" v-if="submissionResource.data"
class="bg-blue-100 p-2 rounded-md leading-5 text-sm italic" class="bg-blue-100 p-2 rounded-md leading-5 text-sm mb-4"
> >
{{ __("You've successfully submitted the assignment.") }} {{ __("You've successfully submitted the assignment.") }}
{{ {{
@@ -133,7 +133,7 @@ const answer = ref(null)
const router = useRouter() const router = useRouter()
const props = defineProps({ const props = defineProps({
assignmentName: { assignmentID: {
type: String, type: String,
required: true, required: true,
}, },
@@ -147,7 +147,7 @@ const assignment = createResource({
url: 'frappe.client.get', url: 'frappe.client.get',
params: { params: {
doctype: 'LMS Assignment', doctype: 'LMS Assignment',
name: props.assignmentName, name: props.assignmentID,
}, },
auto: true, auto: true,
}) })
@@ -191,7 +191,7 @@ const newSubmission = createResource({
makeParams(values) { makeParams(values) {
let doc = { let doc = {
doctype: 'LMS Assignment Submission', doctype: 'LMS Assignment Submission',
assignment: props.assignmentName, assignment: props.assignmentID,
member: user.data?.name, member: user.data?.name,
} }
if (showUploader()) { if (showUploader()) {
@@ -256,7 +256,7 @@ const addNewSubmission = () => {
router.push({ router.push({
name: 'AssignmentSubmission', name: 'AssignmentSubmission',
params: { params: {
assignmentName: props.assignmentName, assignmentID: props.assignmentID,
submissionName: data.name, submissionName: data.name,
}, },
}) })
@@ -278,7 +278,7 @@ const breadcrumbs = computed(() => {
route: { route: {
name: 'AssignmentSubmission', name: 'AssignmentSubmission',
params: { params: {
assignmentName: assignment.data?.name, assignmentID: assignment.data?.name,
}, },
}, },
}, },

View File

@@ -0,0 +1,202 @@
<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.loading || submissions.data?.length"
class="md:w-3/4 md:mx-auto py-5 mx-5"
>
<div class="grid grid-cols-4 gap-5 mb-5">
<Link
doctype="LMS Assignment"
v-model="assignmentID"
:placeholder="__('Assignment')"
/>
<Link doctype="User" v-model="member" :placeholder="__('Member')" />
</div>
<ListView
:columns="submissionColumns"
:rows="submissions.data"
rowKey="name"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
>
<ListHeaderItem :item="item" v-for="item in submissionColumns" />
</ListHeader>
<ListRows>
<router-link
v-for="row in submissions.data"
:to="{
name: 'AssignmentSubmission',
params: {
assignmentID: row.assignment,
submissionName: row.name,
},
}"
>
<ListRow :row="row">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'status'">
<Badge :theme="getStatusTheme(row[column.key])">
{{ row[column.key] }}
</Badge>
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</router-link>
</ListRows>
</ListView>
</div>
<div
v-else
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<Pencil class="size-8 mx-auto stroke-1 text-gray-500" />
<div class="text-xl font-medium">
{{ __('No submissions') }}
</div>
<!-- <div class="leading-5">
{{
__(
'There are no submissions for the assignment {0}.',
).format(assignmentTitle.data?.title)
}}
</div> -->
</div>
</template>
<script setup>
import {
Badge,
Breadcrumbs,
createListResource,
createResource,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
} from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Pencil } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue'
const user = inject('$user')
const dayjs = inject('$dayjs')
const router = useRouter()
const assignmentID = ref('')
const member = ref('')
onMounted(() => {
if (!user.data?.is_instructor && !user.data?.is_moderator) {
router.push({ name: 'Courses' })
}
assignmentID.value = router.currentRoute.value.params.assignmentID
submissions.reload()
})
const getAssignmentFilters = () => {
let filters = {}
if (assignmentID.value) {
console.log(assignmentID.value)
filters.assignment = assignmentID.value
}
if (member.value) {
console.log(member.value)
filters.member = member.value
}
console.log(filters)
return filters
}
const submissions = createListResource({
doctype: 'LMS Assignment Submission',
filters: getAssignmentFilters(),
fields: [
'name',
'assignment',
'assignment_title',
'member_name',
'creation',
'status',
],
orderBy: 'creation desc',
transform(data) {
return data.map((row) => {
return {
...row,
creation: dayjs(row.creation).fromNow(),
}
})
},
})
watch([assignmentID, member], () => {
console.log('watch called')
submissions.reload()
})
/* const assignmentTitle = createResource({
url: "frappe.client.get_value",
params: {
doctype: "LMS Assignment",
fieldname: "title",
filters: { name: props.assignmentID },
},
}) */
const submissionColumns = computed(() => {
return [
{
label: 'Member',
key: 'member_name',
width: 2,
},
{
label: 'Assignment',
key: 'assignment_title',
width: 2,
},
{
label: 'Submitted',
key: 'creation',
width: 1,
align: 'left',
},
{
label: 'Status',
key: 'status',
width: 1,
align: 'center',
},
]
})
const getStatusTheme = (status) => {
if (status === 'Pass') {
return 'green'
} else if (status === 'Not Graded') {
return 'orange'
} else {
return 'red'
}
}
const breadcrumbs = computed(() => {
return [
{
label: 'Assignment Submissions',
},
]
})
</script>

View File

@@ -0,0 +1,123 @@
<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" />
<router-link
:to="{
name: 'AssignmentForm',
params: {
assignmentID: 'new',
},
}"
>
<Button variant="solid">
<template #prefix>
<Plus class="w-4 h-4" />
</template>
{{ __('New') }}
</Button>
</router-link>
</header>
<div v-if="assignments.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
<ListView
:columns="assignmentColumns"
:rows="assignments.data"
row-key="name"
:options="{
showTooltip: false,
selectable: false,
getRowRoute: (row) => ({
name: 'AssignmentForm',
params: {
assignmentID: row.name,
},
}),
}"
>
</ListView>
<div class="flex justify-center my-5">
<Button v-if="assignments.hasNextPage" @click="assignments.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
<div
v-else
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<Pencil class="size-10 mx-auto stroke-1 text-gray-500" />
<div class="text-xl font-medium">
{{ __('No assignments found') }}
</div>
<div class="leading-5">
{{
__(
'You have not created any assignments yet. To create a new assignment, click on the "New" button above.'
)
}}
</div>
</div>
</template>
<script setup>
import { Breadcrumbs, Button, createListResource, ListView } from 'frappe-ui'
import { computed, inject } from 'vue'
import { Plus, Pencil } from 'lucide-vue-next'
const user = inject('$user')
const dayjs = inject('$dayjs')
const assignmentFilter = computed(() => {
if (user.data?.is_moderator) return {}
return {
owner: user.data?.name,
}
})
const assignments = createListResource({
doctype: 'LMS Assignment',
fields: ['name', 'title', 'type', 'creation'],
filters: assignmentFilter,
orderBy: 'modified desc',
auto: true,
cache: ['assignments'],
transform(data) {
return data.map((row) => {
return {
...row,
creation: dayjs(row.creation).fromNow(),
}
})
},
})
const assignmentColumns = computed(() => {
return [
{
label: __('Title'),
key: 'title',
width: 2,
},
{
label: __('Type'),
key: 'type',
width: 1,
align: 'center',
},
{
label: __('Created'),
key: 'creation',
width: 1,
align: 'center',
},
]
})
const breadcrumbs = computed(() => [
{
label: 'Assignments',
route: { name: 'Assignments' },
},
])
</script>

View File

@@ -42,8 +42,11 @@
</div> </div>
</header> </header>
<div v-if="jobsList?.length"> <div v-if="jobsList?.length">
<div class="divide-y lg:w-3/4 mx-auto p-5"> <div class="lg:w-3/4 mx-auto p-5">
<div v-for="job in jobsList"> <div class="text-xl font-semibold mb-5">
{{ __('Find the perfect job for you') }}
</div>
<div v-for="job in jobsList" class="divide-y">
<router-link <router-link
:to="{ :to="{
name: 'JobDetail', name: 'JobDetail',

View File

@@ -256,11 +256,7 @@ onMounted(() => {
}) })
const keyboardShortcut = (e) => { const keyboardShortcut = (e) => {
if ( if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
e.key === 's' &&
(e.ctrlKey || e.metaKey) &&
!e.target.classList.contains('ProseMirror')
) {
submitQuiz() submitQuiz()
e.preventDefault() e.preventDefault()
} }

View File

@@ -131,12 +131,6 @@ const routes = [
component: () => import('@/pages/JobCreation.vue'), component: () => import('@/pages/JobCreation.vue'),
props: true, props: true,
}, },
{
path: '/assignment-submission/:assignmentName/:submissionName',
name: 'AssignmentSubmission',
component: () => import('@/pages/AssignmentSubmission.vue'),
props: true,
},
{ {
path: '/certified-participants', path: '/certified-participants',
name: 'CertifiedParticipants', name: 'CertifiedParticipants',
@@ -193,6 +187,28 @@ const routes = [
name: 'Programs', name: 'Programs',
component: () => import('@/pages/Programs.vue'), component: () => import('@/pages/Programs.vue'),
}, },
{
path: '/assignments',
name: 'Assignments',
component: () => import('@/pages/Assignments.vue'),
},
{
path: '/assignments/:assignmentID',
name: 'AssignmentForm',
component: () => import('@/pages/AssignmentForm.vue'),
props: true,
},
{
path: '/assignment-submission/:assignmentID/:submissionName',
name: 'AssignmentSubmission',
component: () => import('@/pages/AssignmentSubmission.vue'),
props: true,
},
{
path: '/assignment-submissions',
name: 'AssignmentSubmissionList',
component: () => import('@/pages/AssignmentSubmissionList.vue'),
},
] ]
let router = createRouter({ let router = createRouter({

View File

@@ -0,0 +1,80 @@
import { Pencil } from 'lucide-vue-next'
import { createApp, h } from 'vue'
import AssessmentPlugin from '@/components/AssessmentPlugin.vue'
import translationPlugin from '../translation'
import { usersStore } from '../stores/user'
export class Assignment {
constructor({ data, api, readOnly }) {
this.data = data
this.readOnly = readOnly
}
static get toolbox() {
const app = createApp({
render: () =>
h(Pencil, { size: 18, strokeWidth: 1.5, color: 'black' }),
})
const div = document.createElement('div')
app.mount(div)
return {
title: __('Assignment'),
icon: div.innerHTML,
}
}
static get isReadOnlySupported() {
return true
}
render() {
this.wrapper = document.createElement('div')
if (Object.keys(this.data).length) {
this.renderAssignment(this.data.assignment)
} else {
this.renderAssignmentModal()
}
return this.wrapper
}
renderAssignment(assignment) {
if (this.readOnly) {
const app = createApp(AssignmentBlock, {
assignment: assignment,
})
app.use(translationPlugin)
const { userResource } = usersStore()
app.provide('$user', userResource)
app.mount(this.wrapper)
return
}
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center bg-gray-50 mb-2'>
<span class="font-medium">
Assignment: ${assignment}
</span>
</div>`
return
}
renderAssignmentModal() {
if (this.readOnly) {
return
}
const app = createApp(AssessmentPlugin, {
onAddition: (assignment) => {
this.data.assignment = assignment
this.renderAssignment(assignment)
},
})
app.use(translationPlugin)
app.mount(this.wrapper)
}
save(blockContent) {
return {
assignment: this.data.assignment,
}
}
}

View File

@@ -1,6 +1,7 @@
import { toast } from 'frappe-ui' import { toast } from 'frappe-ui'
import { useTimeAgo } from '@vueuse/core' import { useTimeAgo } from '@vueuse/core'
import { Quiz } from '@/utils/quiz' import { Quiz } from '@/utils/quiz'
import { Assignment } from '@/utils/Assignment'
import { Upload } from '@/utils/upload' import { Upload } from '@/utils/upload'
import { Markdown } from '@/utils/markdownParser' import { Markdown } from '@/utils/markdownParser'
import Header from '@editorjs/header' import Header from '@editorjs/header'
@@ -155,6 +156,7 @@ export function getEditorTools() {
}, },
}, },
quiz: Quiz, quiz: Quiz,
assignment: Assignment,
upload: Upload, upload: Upload,
markdown: Markdown, markdown: Markdown,
image: SimpleImage, image: SimpleImage,

View File

@@ -1,5 +1,5 @@
import QuizBlock from '@/components/QuizBlock.vue' import QuizBlock from '@/components/QuizBlock.vue'
import QuizPlugin from '@/components/QuizPlugin.vue' import AssessmentPlugin from '@/components/AssessmentPlugin.vue'
import { createApp, h } from 'vue' import { createApp, h } from 'vue'
import { usersStore } from '../stores/user' import { usersStore } from '../stores/user'
import translationPlugin from '../translation' import translationPlugin from '../translation'
@@ -63,8 +63,8 @@ export class Quiz {
if (this.readOnly) { if (this.readOnly) {
return return
} }
const app = createApp(QuizPlugin, { const app = createApp(AssessmentPlugin, {
onQuizAddition: (quiz) => { onAddition: (quiz) => {
this.data.quiz = quiz this.data.quiz = quiz
this.renderQuiz(quiz) this.renderQuiz(quiz)
}, },

View File

@@ -9,10 +9,11 @@
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"title", "title",
"grade_assignment",
"question", "question",
"column_break_hmwv", "column_break_hmwv",
"type", "type",
"grade_assignment",
"section_break_sjti",
"show_answer", "show_answer",
"answer" "answer"
], ],
@@ -20,7 +21,8 @@
{ {
"fieldname": "question", "fieldname": "question",
"fieldtype": "Text Editor", "fieldtype": "Text Editor",
"label": "Question" "label": "Question",
"reqd": 1
}, },
{ {
"fieldname": "type", "fieldname": "type",
@@ -28,14 +30,16 @@
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Type", "label": "Type",
"options": "Document\nPDF\nURL\nImage\nText" "options": "Document\nPDF\nURL\nImage\nText",
"reqd": 1
}, },
{ {
"fieldname": "title", "fieldname": "title",
"fieldtype": "Data", "fieldtype": "Data",
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Title" "label": "Title",
"reqd": 1
}, },
{ {
"fieldname": "column_break_hmwv", "fieldname": "column_break_hmwv",
@@ -60,11 +64,15 @@
"fieldname": "grade_assignment", "fieldname": "grade_assignment",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Grade Assignment" "label": "Grade Assignment"
},
{
"fieldname": "section_break_sjti",
"fieldtype": "Section Break"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-04-05 12:01:36.601160", "modified": "2024-12-24 09:36:31.464508",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Assignment", "name": "LMS Assignment",

View File

@@ -14,19 +14,17 @@
"member", "member",
"member_name", "member_name",
"section_break_dlzh", "section_break_dlzh",
"question",
"column_break_zvis",
"assignment_attachment", "assignment_attachment",
"answer", "answer",
"section_break_rqal", "column_break_oqqy",
"status",
"evaluator", "evaluator",
"column_break_esgd", "status",
"comments", "comments",
"section_break_cwaw", "section_break_rqal",
"lesson", "question",
"column_break_esgd",
"course", "course",
"column_break_ygdu" "lesson"
], ],
"fields": [ "fields": [
{ {
@@ -89,8 +87,7 @@
"fieldname": "evaluator", "fieldname": "evaluator",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Evaluator", "label": "Evaluator",
"options": "User", "options": "User"
"read_only": 1
}, },
{ {
"depends_on": "eval:!([\"URL\", \"Text\"]).includes(doc.type);", "depends_on": "eval:!([\"URL\", \"Text\"]).includes(doc.type);",
@@ -128,14 +125,6 @@
"fieldname": "column_break_esgd", "fieldname": "column_break_esgd",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{
"fieldname": "section_break_cwaw",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_ygdu",
"fieldtype": "Column Break"
},
{ {
"depends_on": "eval:([\"URL\", \"Text\"]).includes(doc.type);", "depends_on": "eval:([\"URL\", \"Text\"]).includes(doc.type);",
"fieldname": "answer", "fieldname": "answer",
@@ -148,14 +137,14 @@
"fieldtype": "Section Break" "fieldtype": "Section Break"
}, },
{ {
"fieldname": "column_break_zvis", "fieldname": "column_break_oqqy",
"fieldtype": "Column Break" "fieldtype": "Column Break"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2024-04-05 15:57:22.758563", "modified": "2024-12-24 21:22:35.212732",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Assignment Submission", "name": "LMS Assignment Submission",

View File

@@ -1487,11 +1487,19 @@ def get_batch_students(batch):
detail.courses_completed = courses_completed detail.courses_completed = courses_completed
detail.assessments_completed = assessments_completed detail.assessments_completed = assessments_completed
detail.progress = (
(courses_completed + assessments_completed) if len(batch_courses) or len(assessments):
/ (len(batch_courses) + len(assessments)) detail.progress = flt(
* 100 (
) (courses_completed + assessments_completed)
/ (len(batch_courses) + len(assessments))
* 100
),
2,
)
else:
detail.progress = 0
students.append(detail) students.append(detail)
return students return students