Merge pull request #1223 from pateljannat/assignments-in-courses
feat: assignments in courses
This commit is contained in:
@@ -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 = () => {
|
||||
let activeFor = ['Programs', 'ProgramForm']
|
||||
let index = 1
|
||||
@@ -247,8 +258,9 @@ watch(userResource, () => {
|
||||
if (userResource.data) {
|
||||
isModerator.value = userResource.data.is_moderator
|
||||
isInstructor.value = userResource.data.is_instructor
|
||||
addQuizzes()
|
||||
addPrograms()
|
||||
addQuizzes()
|
||||
addAssignments()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
75
frontend/src/components/AssessmentPlugin.vue
Normal file
75
frontend/src/components/AssessmentPlugin.vue
Normal 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>
|
||||
@@ -19,6 +19,7 @@
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
getRowRoute: (row) => getRowRoute(row),
|
||||
selectable: user.data?.is_student ? false : true,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
@@ -41,6 +42,14 @@
|
||||
<div v-if="column.key == 'assessment_type'">
|
||||
{{ row[column.key] == 'LMS Quiz' ? 'Quiz' : 'Assignment' }}
|
||||
</div>
|
||||
<div v-else-if="column.key == 'title'">
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
<div v-else-if="isNaN(row[column.key])">
|
||||
<Badge :theme="getStatusTheme(row[column.key])">
|
||||
{{ row[column.key] }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
@@ -83,6 +92,7 @@ import {
|
||||
ListSelectBanner,
|
||||
createResource,
|
||||
Button,
|
||||
Badge,
|
||||
} from 'frappe-ui'
|
||||
import { inject, ref } from 'vue'
|
||||
import AssessmentModal from '@/components/Modals/AssessmentModal.vue'
|
||||
@@ -148,7 +158,7 @@ const getRowRoute = (row) => {
|
||||
return {
|
||||
name: 'AssignmentSubmission',
|
||||
params: {
|
||||
assignmentName: row.assessment_name,
|
||||
assignmentID: row.assessment_name,
|
||||
submissionName: row.submission.name,
|
||||
},
|
||||
}
|
||||
@@ -156,7 +166,7 @@ const getRowRoute = (row) => {
|
||||
return {
|
||||
name: 'AssignmentSubmission',
|
||||
params: {
|
||||
assignmentName: row.assessment_name,
|
||||
assignmentID: row.assessment_name,
|
||||
submissionName: 'new',
|
||||
},
|
||||
}
|
||||
@@ -180,23 +190,33 @@ const getAssessmentColumns = () => {
|
||||
{
|
||||
label: 'Assessment',
|
||||
key: 'title',
|
||||
width: '30rem',
|
||||
width: '25rem',
|
||||
},
|
||||
{
|
||||
label: 'Type',
|
||||
key: 'assessment_type',
|
||||
width: '10rem',
|
||||
width: '15rem',
|
||||
},
|
||||
]
|
||||
|
||||
if (!user.data?.is_moderator) {
|
||||
columns.push({
|
||||
label: 'Status/Score',
|
||||
label: 'Status/Percentage',
|
||||
key: 'status',
|
||||
align: 'center',
|
||||
align: 'left',
|
||||
width: '10rem',
|
||||
})
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
const getStatusTheme = (status) => {
|
||||
if (status === 'Pass') {
|
||||
return 'green'
|
||||
} else if (status === 'Not Graded') {
|
||||
return 'orange'
|
||||
} else {
|
||||
return 'red'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
448
frontend/src/components/Assignment.vue
Normal file
448
frontend/src/components/Assignment.vue
Normal file
@@ -0,0 +1,448 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="assignment.data"
|
||||
class="grid grid-cols-[68%,32%] h-full"
|
||||
:class="{ 'border rounded-lg': !showTitle }"
|
||||
>
|
||||
<div class="border-r p-5 overflow-y-auto h-[calc(100vh-3.2rem)]">
|
||||
<div v-if="showTitle" class="text-lg font-semibold mb-5">
|
||||
<div v-if="submissionName === 'new'">
|
||||
{{ __('Submission by') }} {{ user.data?.full_name }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ __('Submission by') }} {{ submissionResource.doc?.member_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 font-medium mb-2">
|
||||
{{ __('Question') }}:
|
||||
</div>
|
||||
<div
|
||||
v-html="assignment.data.question"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="font-semibold">
|
||||
{{ __('Submission') }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Badge v-if="isDirty" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else-if="submissionResource.doc?.status"
|
||||
:theme="statusTheme"
|
||||
size="lg"
|
||||
>
|
||||
{{ submissionResource.doc?.status }}
|
||||
</Badge>
|
||||
<Button variant="solid" @click="submitAssignment()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
submissionName != 'new' &&
|
||||
!['Pass', 'Fail'].includes(submissionResource.doc?.status) &&
|
||||
submissionResource.doc?.owner == user.data?.name
|
||||
"
|
||||
class="bg-blue-100 p-3 rounded-md leading-5 text-sm mb-4"
|
||||
>
|
||||
{{ __("You've successfully submitted the assignment.") }}
|
||||
{{
|
||||
__(
|
||||
"Once the moderator grades your submission, you'll find the details here."
|
||||
)
|
||||
}}
|
||||
{{ __('Feel free to make edits to your submission if needed.') }}
|
||||
</div>
|
||||
<div v-if="showUploader()">
|
||||
<div class="text-xs text-gray-600 mt-1 mb-2">
|
||||
{{ __('Add your assignment as {0}').format(assignment.data.type) }}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!submissionFile"
|
||||
:fileTypes="getType()"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveSubmission(file)"
|
||||
>
|
||||
<template #default="{ uploading, progress, openFileSelector }">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading
|
||||
? __('Uploading {0}%').format(progress)
|
||||
: __('Upload File')
|
||||
}}
|
||||
</Button>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||
</div>
|
||||
<a
|
||||
:href="submissionFile.file_url"
|
||||
target="_blank"
|
||||
class="flex flex-col cursor-pointer !no-underline"
|
||||
>
|
||||
<span>
|
||||
{{ submissionFile.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500 mt-1">
|
||||
{{ getFileSize(submissionFile.file_size) }}
|
||||
</span>
|
||||
</a>
|
||||
<X
|
||||
v-if="canModifyAssignment"
|
||||
@click="removeSubmission()"
|
||||
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="assignment.data.type == 'URL'">
|
||||
<div class="text-xs text-gray-600 mb-1">
|
||||
{{ __('Enter a URL') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="answer"
|
||||
type="text"
|
||||
:readonly="!canModifyAssignment"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="text-sm mb-4">
|
||||
{{ __('Write your answer here') }}
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="answer"
|
||||
@change="(val) => (answer = 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
|
||||
v-if="
|
||||
user.data?.name == submissionResource.doc?.owner &&
|
||||
submissionResource.doc?.comments
|
||||
"
|
||||
class="mt-8 p-3 bg-blue-100 rounded-md"
|
||||
>
|
||||
<div class="text-sm text-gray-600 font-medium mb-2">
|
||||
{{ __('Comments by Evaluator') }}:
|
||||
</div>
|
||||
<div class="leading-5">
|
||||
{{ submissionResource.doc.comments }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grading -->
|
||||
<div v-if="canGradeSubmission" class="mt-8 space-y-4">
|
||||
<div class="font-semibold mb-2">
|
||||
{{ __('Grading') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-if="submissionResource.doc"
|
||||
v-model="submissionResource.doc.status"
|
||||
:label="__('Grade')"
|
||||
type="select"
|
||||
:options="submissionStatusOptions"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="submissionResource.doc"
|
||||
v-model="submissionResource.doc.comments"
|
||||
:label="__('Comments')"
|
||||
type="textarea"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
call,
|
||||
createResource,
|
||||
createDocumentResource,
|
||||
FileUploader,
|
||||
FormControl,
|
||||
TextEditor,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { showToast, getFileSize } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const submissionFile = ref(null)
|
||||
const answer = ref(null)
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
const showTitle = router.currentRoute.value.name == 'AssignmentSubmission'
|
||||
const isDirty = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
assignmentID: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
submissionName: {
|
||||
type: String,
|
||||
default: 'new',
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const keyboardShortcut = (e) => {
|
||||
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
||||
submitAssignment()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const assignment = createResource({
|
||||
url: 'frappe.client.get',
|
||||
params: {
|
||||
doctype: 'LMS Assignment',
|
||||
name: props.assignmentID,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
if (props.submissionName != 'new') {
|
||||
submissionResource.reload()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const newSubmission = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
let doc = {
|
||||
doctype: 'LMS Assignment Submission',
|
||||
assignment: props.assignmentID,
|
||||
member: user.data?.name,
|
||||
}
|
||||
if (showUploader()) {
|
||||
doc.assignment_attachment = submissionFile.value.file_url
|
||||
} else {
|
||||
doc.answer = answer.value
|
||||
}
|
||||
return {
|
||||
doc: doc,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const imageResource = createResource({
|
||||
url: 'lms.lms.api.get_file_info',
|
||||
makeParams(values) {
|
||||
return {
|
||||
file_url: values.image,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
submissionFile.value = data
|
||||
},
|
||||
})
|
||||
|
||||
const submissionResource = createDocumentResource({
|
||||
doctype: 'LMS Assignment Submission',
|
||||
name: props.submissionName,
|
||||
onError(err) {
|
||||
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||
},
|
||||
auto: false,
|
||||
cache: [user.data?.name, props.assignmentID],
|
||||
})
|
||||
|
||||
watch(submissionResource, () => {
|
||||
if (submissionResource.doc) {
|
||||
if (submissionResource.doc.assignment_attachment) {
|
||||
imageResource.reload({
|
||||
image: submissionResource.doc.assignment_attachment,
|
||||
})
|
||||
}
|
||||
if (submissionResource.doc.answer) {
|
||||
answer.value = submissionResource.doc.answer
|
||||
}
|
||||
|
||||
if (submissionResource.isDirty) {
|
||||
isDirty.value = true
|
||||
} else if (showUploader() && !submissionFile.value) {
|
||||
isDirty.value = true
|
||||
} else if (!showUploader() && !answer.value) {
|
||||
isDirty.value = true
|
||||
} else {
|
||||
isDirty.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(submissionFile, () => {
|
||||
if (props.submissionName == 'new' && submissionFile.value) {
|
||||
isDirty.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const submitAssignment = () => {
|
||||
if (props.submissionName != 'new') {
|
||||
let evaluator =
|
||||
submissionResource.doc && submissionResource.doc.owner != user.data?.name
|
||||
? user.data?.name
|
||||
: null
|
||||
submissionResource.setValue.submit(
|
||||
{
|
||||
...submissionResource.doc,
|
||||
evaluator: evaluator,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
showToast(__('Success'), __('Changes saved successfully'), 'check')
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
addNewSubmission()
|
||||
}
|
||||
}
|
||||
|
||||
const addNewSubmission = () => {
|
||||
newSubmission.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
showToast('Success', 'Assignment submitted successfully.', 'check')
|
||||
if (router.currentRoute.value.name == 'AssignmentSubmission') {
|
||||
router.push({
|
||||
name: 'AssignmentSubmission',
|
||||
params: {
|
||||
assignmentID: props.assignmentID,
|
||||
submissionName: data.name,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
markLessonProgress()
|
||||
router.go()
|
||||
}
|
||||
submissionResource.name = data.name
|
||||
submissionResource.reload()
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const saveSubmission = (file) => {
|
||||
submissionFile.value = file
|
||||
}
|
||||
|
||||
const markLessonProgress = () => {
|
||||
if (router.currentRoute.value.name == 'Lesson') {
|
||||
let courseName = router.currentRoute.value.params.courseName
|
||||
let chapterNumber = router.currentRoute.value.params.chapterNumber
|
||||
let lessonNumber = router.currentRoute.value.params.lessonNumber
|
||||
|
||||
call('lms.lms.api.mark_lesson_progress', {
|
||||
course: courseName,
|
||||
chapter_number: chapterNumber,
|
||||
lesson_number: lessonNumber,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const getType = () => {
|
||||
const type = assignment.data?.type
|
||||
if (type == 'Image') {
|
||||
return ['image/*']
|
||||
} else if (type == 'Document') {
|
||||
return [
|
||||
'.doc',
|
||||
'.docx',
|
||||
'.xml',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
]
|
||||
} else if (type == 'PDF') {
|
||||
return ['.pdf']
|
||||
}
|
||||
}
|
||||
|
||||
const validateFile = (file) => {
|
||||
let type = assignment.data?.type
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (type == 'Image' && !['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||
return 'Only image file is allowed.'
|
||||
} else if (
|
||||
type == 'Document' &&
|
||||
!['doc', 'docx', 'xml'].includes(extension)
|
||||
) {
|
||||
return 'Only document file is allowed.'
|
||||
} else if (type == 'PDF' && !['pdf'].includes(extension)) {
|
||||
return 'Only PDF file is allowed.'
|
||||
}
|
||||
}
|
||||
|
||||
const removeSubmission = () => {
|
||||
submissionFile.value = null
|
||||
}
|
||||
|
||||
const canGradeSubmission = computed(() => {
|
||||
return (
|
||||
(user.data?.is_moderator ||
|
||||
user.data?.is_evaluator ||
|
||||
user.data?.is_instructor) &&
|
||||
props.submissionName != 'new' &&
|
||||
router.currentRoute.value.name == 'AssignmentSubmission'
|
||||
)
|
||||
})
|
||||
|
||||
const canModifyAssignment = computed(() => {
|
||||
return (
|
||||
!submissionResource.doc ||
|
||||
(submissionResource.doc?.owner == user.data?.name &&
|
||||
submissionResource.doc?.status == 'Not Graded')
|
||||
)
|
||||
})
|
||||
|
||||
const submissionStatusOptions = computed(() => {
|
||||
return [
|
||||
{ label: 'Not Graded', value: 'Not Graded' },
|
||||
{ label: 'Pass', value: 'Pass' },
|
||||
{ label: 'Fail', value: 'Fail' },
|
||||
]
|
||||
})
|
||||
|
||||
const statusTheme = computed(() => {
|
||||
if (!submissionResource.doc) {
|
||||
return 'orange'
|
||||
} else if (submissionResource.doc.status == 'Pass') {
|
||||
return 'green'
|
||||
} else if (submissionResource.doc.status == 'Not Graded') {
|
||||
return 'blue'
|
||||
} else {
|
||||
return 'red'
|
||||
}
|
||||
})
|
||||
|
||||
const showUploader = () => {
|
||||
return ['PDF', 'Image', 'Document'].includes(assignment.data?.type)
|
||||
}
|
||||
</script>
|
||||
46
frontend/src/components/AssignmentBlock.vue
Normal file
46
frontend/src/components/AssignmentBlock.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<Assignment
|
||||
v-if="user.data && submission.data"
|
||||
:assignmentID="assignmentID"
|
||||
:submissionName="submission.data?.name || 'new'"
|
||||
/>
|
||||
<div v-else class="border rounded-md text-center py-20">
|
||||
<div>
|
||||
{{ __('Please login to access the assignment.') }}
|
||||
</div>
|
||||
<Button @click="redirectToLogin()" class="mt-2">
|
||||
<span>
|
||||
{{ __('Login') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { inject, watch } from 'vue'
|
||||
import { Button, createResource } from 'frappe-ui'
|
||||
import Assignment from '@/components/Assignment.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
assignmentID: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const submission = createResource({
|
||||
url: 'frappe.client.get_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Assignment Submission',
|
||||
fieldname: 'name',
|
||||
filters: {
|
||||
assignment: props.assignmentID,
|
||||
member: user.data?.name,
|
||||
},
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
</script>
|
||||
@@ -98,9 +98,6 @@
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
onRowClick: (row) => {
|
||||
openStudentProgressModal(row)
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
@@ -121,7 +118,12 @@
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<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 }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<template #prefix>
|
||||
@@ -140,6 +142,16 @@
|
||||
>
|
||||
<ProgressBar :progress="row[column.key]" size="sm" />
|
||||
</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>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
@@ -190,7 +202,14 @@ import {
|
||||
ListView,
|
||||
ListRowItem,
|
||||
} 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 StudentModal from '@/components/Modals/StudentModal.vue'
|
||||
import { showToast } from '@/utils'
|
||||
@@ -247,6 +266,10 @@ const getStudentColumns = () => {
|
||||
align: 'center',
|
||||
icon: 'clock',
|
||||
},
|
||||
{
|
||||
label: '',
|
||||
key: 'copy',
|
||||
},
|
||||
]
|
||||
|
||||
return columns
|
||||
@@ -331,7 +354,10 @@ const getChartData = () => {
|
||||
const getChartOptions = (categories) => {
|
||||
const courseColor = '#0F736B'
|
||||
const assessmentColor = '#0070CC'
|
||||
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 {
|
||||
chart: {
|
||||
@@ -368,11 +394,16 @@ const getChartOptions = (categories) => {
|
||||
max: maxY,
|
||||
min: 0,
|
||||
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, () => {
|
||||
if (students.data?.length) {
|
||||
assessmentCount.value = Object.keys(students.data?.[0].assessments).length
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
<slot name="item-label" v-bind="{ active, selected, option }" />
|
||||
</template>
|
||||
|
||||
<template v-if="attrs.onCreate" #footer="{ value, close }">
|
||||
<div>
|
||||
<template #footer="{ value, close }">
|
||||
<div v-if="attrs.onCreate">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
@@ -42,6 +42,18 @@
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Clear')"
|
||||
@click="() => clearValue(close)"
|
||||
>
|
||||
<template #prefix>
|
||||
<X class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Autocomplete>
|
||||
<p v-if="description" class="text-sm text-gray-600">{{ description }}</p>
|
||||
@@ -52,7 +64,7 @@
|
||||
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { useAttrs, computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -75,9 +87,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const valuePropPassed = computed(() => 'value' in attrs)
|
||||
|
||||
const value = computed({
|
||||
@@ -131,7 +141,7 @@ const options = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
function reload(val) {
|
||||
const reload = (val) => {
|
||||
options.update({
|
||||
params: {
|
||||
txt: val,
|
||||
@@ -142,6 +152,11 @@ function reload(val) {
|
||||
options.reload()
|
||||
}
|
||||
|
||||
const clearValue = (close) => {
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', '')
|
||||
close()
|
||||
}
|
||||
|
||||
const labelClasses = computed(() => {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
<template>
|
||||
<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="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
||||
@@ -56,21 +71,6 @@
|
||||
}}
|
||||
</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>
|
||||
<ExplanationVideos v-model="showExplanation" :title="title" :type="type" />
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{}">
|
||||
<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">
|
||||
<Avatar :image="student.user_image" size="3xl" />
|
||||
<div class="space-y-1">
|
||||
|
||||
@@ -271,6 +271,7 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
call,
|
||||
createResource,
|
||||
ListView,
|
||||
TextEditor,
|
||||
@@ -280,6 +281,7 @@ import { ref, watch, reactive, inject, computed } from 'vue'
|
||||
import { createToast } from '@/utils/'
|
||||
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
|
||||
import { timeAgo } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
@@ -291,6 +293,7 @@ let questions = reactive([])
|
||||
const possibleAnswer = ref(null)
|
||||
const timer = ref(0)
|
||||
let timerInterval = null
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
quizName: {
|
||||
@@ -560,6 +563,7 @@ const createSubmission = () => {
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
markLessonProgress()
|
||||
if (quiz.data && quiz.data.max_attempts) attempts.reload()
|
||||
if (quiz.data.duration) clearInterval(timerInterval)
|
||||
},
|
||||
@@ -583,6 +587,16 @@ const getInstructions = (question) => {
|
||||
else return __('Type your answer')
|
||||
}
|
||||
|
||||
const markLessonProgress = () => {
|
||||
if (router.currentRoute.value.name == 'Lesson') {
|
||||
call('lms.lms.api.mark_lesson_progress', {
|
||||
course: router.currentRoute.value.params.courseName,
|
||||
chapter_number: router.currentRoute.value.params.chapterNumber,
|
||||
lesson_number: router.currentRoute.value.params.lessonNumber,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const getSubmissionColumns = () => {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
191
frontend/src/pages/AssignmentForm.vue
Normal file
191
frontend/src/pages/AssignmentForm.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<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',
|
||||
query: {
|
||||
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,
|
||||
watch,
|
||||
} 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,
|
||||
})
|
||||
|
||||
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')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
watch(assignment, () => {
|
||||
Object.keys(assignment.doc).forEach((key) => {
|
||||
model[key] = assignment.doc[key]
|
||||
})
|
||||
})
|
||||
|
||||
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>
|
||||
@@ -3,137 +3,20 @@
|
||||
class="flex justify-between sticky top-0 z-10 border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<Button variant="solid" @click="submitAssignment()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</header>
|
||||
<div class="container py-5">
|
||||
<div
|
||||
v-if="submissionResource.data"
|
||||
class="bg-blue-100 p-2 rounded-md leading-5 text-sm italic"
|
||||
>
|
||||
{{ __("You've successfully submitted the assignment.") }}
|
||||
{{
|
||||
__(
|
||||
"Once the moderator grades your submission, you'll find the details here."
|
||||
)
|
||||
}}
|
||||
{{ __('Feel free to make edits to your submission if needed.') }}
|
||||
</div>
|
||||
<div v-if="assignment.data">
|
||||
<div>
|
||||
<div class="text-xl font-semibold hidden">
|
||||
{{ __('Question') }}
|
||||
</div>
|
||||
<div class="text-sm mt-1 hidden">
|
||||
{{
|
||||
__('Read the question carefully before attempting the assignment.')
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-html="assignment.data.question"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal"
|
||||
></div>
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="text-xl font-semibold mt-10">
|
||||
{{ __('Submission') }}
|
||||
</div>
|
||||
<div v-if="showUploader()">
|
||||
<div class="text-sm mt-1 mb-4">
|
||||
{{ __('Add your assignment as {0}').format(assignment.data.type) }}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!submissionFile"
|
||||
:fileTypes="getType()"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveSubmission(file)"
|
||||
>
|
||||
<template
|
||||
#default="{
|
||||
file,
|
||||
uploading,
|
||||
progress,
|
||||
uploaded,
|
||||
message,
|
||||
error,
|
||||
total,
|
||||
success,
|
||||
openFileSelector,
|
||||
}"
|
||||
>
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading
|
||||
? __('Uploading {0}%').format(progress)
|
||||
: __('Upload File')
|
||||
}}
|
||||
</Button>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span>
|
||||
{{ submissionFile.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500 mt-1">
|
||||
{{ getFileSize(submissionFile.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="removeSubmission()"
|
||||
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="assignment.data.type == 'URL'">
|
||||
<div class="text-sm mb-4">
|
||||
{{ __('Enter a URL') }}
|
||||
</div>
|
||||
<FormControl v-model="answer" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="text-sm mb-4">
|
||||
{{ __('Write your answer here') }}
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="answer"
|
||||
@change="(val) => (answer = 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>
|
||||
</div>
|
||||
<div class="overflow-hidden h-[calc(100vh-3.2rem)]">
|
||||
<Assignment :assignmentID="assignmentID" :submissionName="submissionName" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
createResource,
|
||||
FileUploader,
|
||||
Button,
|
||||
FormControl,
|
||||
TextEditor,
|
||||
} from 'frappe-ui'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { showToast, getFileSize } from '../utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Breadcrumbs, createResource } from 'frappe-ui'
|
||||
import { computed, inject, onMounted } from 'vue'
|
||||
import Assignment from '@/components/Assignment.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const submissionFile = ref(null)
|
||||
const answer = ref(null)
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
assignmentName: {
|
||||
assignmentID: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
@@ -143,186 +26,40 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const assignment = createResource({
|
||||
url: 'frappe.client.get',
|
||||
const title = createResource({
|
||||
url: 'frappe.client.get_value',
|
||||
params: {
|
||||
doctype: 'LMS Assignment',
|
||||
name: props.assignmentName,
|
||||
fieldname: 'title',
|
||||
filters: {
|
||||
name: props.assignmentID,
|
||||
},
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const showUploader = () => {
|
||||
return ['PDF', 'Image', 'Document'].includes(assignment.data?.type)
|
||||
}
|
||||
|
||||
const updateSubmission = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
let fieldname = {}
|
||||
if (showUploader()) {
|
||||
fieldname.assignment_attachment = submissionFile.value.file_url
|
||||
} else {
|
||||
fieldname.answer = answer.value
|
||||
}
|
||||
return {
|
||||
doctype: 'LMS Assignment Submission',
|
||||
name: props.submissionName,
|
||||
fieldname: fieldname,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const imageResource = createResource({
|
||||
url: 'lms.lms.api.get_file_info',
|
||||
makeParams(values) {
|
||||
return {
|
||||
file_url: values.image,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
submissionFile.value = data
|
||||
},
|
||||
})
|
||||
|
||||
const newSubmission = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
let doc = {
|
||||
doctype: 'LMS Assignment Submission',
|
||||
assignment: props.assignmentName,
|
||||
member: user.data?.name,
|
||||
}
|
||||
if (showUploader()) {
|
||||
doc.assignment_attachment = submissionFile.value.file_url
|
||||
} else {
|
||||
doc.answer = answer.value
|
||||
}
|
||||
return {
|
||||
doc: doc,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const submissionResource = createResource({
|
||||
url: 'frappe.client.get_value',
|
||||
params: {
|
||||
doctype: 'LMS Assignment Submission',
|
||||
fieldname: showUploader() ? 'assignment_attachment' : 'answer',
|
||||
filters: {
|
||||
name: props.submissionName,
|
||||
},
|
||||
},
|
||||
onSuccess(data) {
|
||||
if (data.assignment_attachment)
|
||||
imageResource.reload({ image: data.assignment_attachment })
|
||||
if (data.answer) answer.value = data.answer
|
||||
},
|
||||
})
|
||||
onMounted(() => {
|
||||
if (!user.data) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
if (props.submissionName != 'new') {
|
||||
submissionResource.reload()
|
||||
}
|
||||
})
|
||||
|
||||
const submitAssignment = () => {
|
||||
if (props.submissionName != 'new') {
|
||||
updateSubmission.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
showToast('Success', 'Submission updated successfully.', 'check')
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
addNewSubmission()
|
||||
}
|
||||
}
|
||||
|
||||
const addNewSubmission = () => {
|
||||
newSubmission.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
showToast('Success', 'Assignment submitted successfully.', 'check')
|
||||
router.push({
|
||||
name: 'AssignmentSubmission',
|
||||
params: {
|
||||
assignmentName: props.assignmentName,
|
||||
submissionName: data.name,
|
||||
},
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
label: 'Assignment',
|
||||
label: 'Submissions',
|
||||
route: { name: 'AssignmentSubmissionList' },
|
||||
},
|
||||
{
|
||||
label: assignment.data?.title,
|
||||
label: title.data?.title,
|
||||
route: {
|
||||
name: 'AssignmentSubmission',
|
||||
params: {
|
||||
assignmentName: assignment.data?.name,
|
||||
assignmentID: props.assignmentID,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
return crumbs
|
||||
})
|
||||
|
||||
const saveSubmission = (file) => {
|
||||
submissionFile.value = file
|
||||
}
|
||||
|
||||
const getType = () => {
|
||||
const type = assignment.data?.type
|
||||
if (type == 'Image') {
|
||||
return ['image/*']
|
||||
} else if (type == 'Document') {
|
||||
return [
|
||||
'.doc',
|
||||
'.docx',
|
||||
'.xml',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
]
|
||||
} else if (type == 'PDF') {
|
||||
return ['.pdf']
|
||||
}
|
||||
}
|
||||
|
||||
const validateFile = (file) => {
|
||||
let type = assignment.data?.type
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (type == 'Image' && !['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||
return 'Only image file is allowed.'
|
||||
} else if (
|
||||
type == 'Document' &&
|
||||
!['doc', 'docx', 'xml'].includes(extension)
|
||||
) {
|
||||
return 'Only document file is allowed.'
|
||||
} else if (type == 'PDF' && !['pdf'].includes(extension)) {
|
||||
return 'Only PDF file is allowed.'
|
||||
}
|
||||
}
|
||||
|
||||
const removeSubmission = () => {
|
||||
submissionFile.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
217
frontend/src/pages/AssignmentSubmissionList.vue
Normal file
217
frontend/src/pages/AssignmentSubmissionList.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<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-3/4 md:mx-auto py-5 mx-5">
|
||||
<div class="grid grid-cols-3 gap-5 mb-5">
|
||||
<Link
|
||||
doctype="LMS Assignment"
|
||||
v-model="assignmentID"
|
||||
:placeholder="__('Assignment')"
|
||||
/>
|
||||
<Link doctype="User" v-model="member" :placeholder="__('Member')" />
|
||||
<FormControl
|
||||
v-model="status"
|
||||
type="select"
|
||||
:options="statusOptions"
|
||||
:placeholder="__('Status')"
|
||||
/>
|
||||
</div>
|
||||
<ListView
|
||||
v-if="submissions.loading || submissions.data?.length"
|
||||
: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
|
||||
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 this assignment.') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
createListResource,
|
||||
FormControl,
|
||||
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('')
|
||||
const status = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data?.is_instructor && !user.data?.is_moderator) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
assignmentID.value = router.currentRoute.value.query.assignmentID
|
||||
member.value = router.currentRoute.value.query.member
|
||||
status.value = router.currentRoute.value.query.status
|
||||
reloadSubmissions()
|
||||
})
|
||||
|
||||
const getAssignmentFilters = () => {
|
||||
let filters = {}
|
||||
if (assignmentID.value) {
|
||||
filters.assignment = assignmentID.value
|
||||
}
|
||||
if (member.value) {
|
||||
filters.member = member.value
|
||||
}
|
||||
if (status.value) {
|
||||
filters.status = status.value
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
const submissions = createListResource({
|
||||
doctype: 'LMS Assignment Submission',
|
||||
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 changes in assignmentID, member, and status and if changes in any then reload submissions. Also update the url query params for the same
|
||||
watch([assignmentID, member, status], () => {
|
||||
router.push({
|
||||
query: {
|
||||
assignmentID: assignmentID.value,
|
||||
member: member.value,
|
||||
status: status.value,
|
||||
},
|
||||
})
|
||||
reloadSubmissions()
|
||||
})
|
||||
|
||||
const reloadSubmissions = () => {
|
||||
submissions.update({
|
||||
filters: getAssignmentFilters(),
|
||||
})
|
||||
submissions.reload()
|
||||
}
|
||||
|
||||
const submissionColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Member',
|
||||
key: 'member_name',
|
||||
width: 1,
|
||||
},
|
||||
{
|
||||
label: 'Assignment',
|
||||
key: 'assignment_title',
|
||||
width: 2,
|
||||
},
|
||||
{
|
||||
label: 'Submitted',
|
||||
key: 'creation',
|
||||
width: 1,
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
label: 'Status',
|
||||
key: 'status',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const statusOptions = computed(() => {
|
||||
return [
|
||||
{ label: '', value: '' },
|
||||
{ label: 'Pass', value: 'Pass' },
|
||||
{ label: 'Fail', value: 'Fail' },
|
||||
{ label: 'Not Graded', value: 'Not Graded' },
|
||||
]
|
||||
})
|
||||
|
||||
const getStatusTheme = (status) => {
|
||||
if (status === 'Pass') {
|
||||
return 'green'
|
||||
} else if (status === 'Not Graded') {
|
||||
return 'blue'
|
||||
} else {
|
||||
return 'red'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Assignment Submissions',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
187
frontend/src/pages/Assignments.vue
Normal file
187
frontend/src/pages/Assignments.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<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 class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||
<div class="grid grid-cols-3 gap-5 mb-5">
|
||||
<FormControl v-model="titleFilter" :placeholder="__('Search by title')" />
|
||||
<FormControl
|
||||
v-model="typeFilter"
|
||||
type="select"
|
||||
:options="assignmentTypes"
|
||||
:placeholder="__('Type')"
|
||||
/>
|
||||
</div>
|
||||
<ListView
|
||||
v-if="assignments.data?.length"
|
||||
:columns="assignmentColumns"
|
||||
:rows="assignments.data"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
selectable: false,
|
||||
getRowRoute: (row) => ({
|
||||
name: 'AssignmentForm',
|
||||
params: {
|
||||
assignmentID: row.name,
|
||||
},
|
||||
}),
|
||||
}"
|
||||
>
|
||||
</ListView>
|
||||
<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>
|
||||
<div
|
||||
v-if="assignments.data && assignments.hasNextPage"
|
||||
class="flex justify-center my-5"
|
||||
>
|
||||
<Button @click="assignments.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
createListResource,
|
||||
FormControl,
|
||||
ListView,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { Plus, Pencil } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const titleFilter = ref('')
|
||||
const typeFilter = ref('')
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
|
||||
titleFilter.value = router.currentRoute.value.query.title
|
||||
typeFilter.value = router.currentRoute.value.query.type
|
||||
})
|
||||
|
||||
watch([titleFilter, typeFilter], () => {
|
||||
router.push({
|
||||
query: {
|
||||
title: titleFilter.value,
|
||||
type: typeFilter.value,
|
||||
},
|
||||
})
|
||||
reloadAssignments()
|
||||
})
|
||||
|
||||
const reloadAssignments = () => {
|
||||
assignments.update({
|
||||
filters: assignmentFilter.value,
|
||||
})
|
||||
assignments.reload()
|
||||
}
|
||||
|
||||
const assignmentFilter = computed(() => {
|
||||
let filters = {}
|
||||
if (titleFilter.value) {
|
||||
filters.title = ['like', `%${titleFilter.value}%`]
|
||||
}
|
||||
if (typeFilter.value) {
|
||||
filters.type = typeFilter.value
|
||||
}
|
||||
if (!user.data?.is_moderator) {
|
||||
filters.owner = user.data?.email
|
||||
}
|
||||
return filters
|
||||
})
|
||||
|
||||
const assignments = createListResource({
|
||||
doctype: 'LMS Assignment',
|
||||
fields: ['name', 'title', 'type', 'creation'],
|
||||
orderBy: 'modified desc',
|
||||
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: 'left',
|
||||
},
|
||||
{
|
||||
label: __('Created'),
|
||||
key: 'creation',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const assignmentTypes = computed(() => {
|
||||
let types = ['', 'Document', 'Image', 'PDF', 'URL', 'Text']
|
||||
return types.map((type) => {
|
||||
return {
|
||||
label: __(type),
|
||||
value: type,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{
|
||||
label: 'Assignments',
|
||||
route: { name: 'Assignments' },
|
||||
},
|
||||
])
|
||||
</script>
|
||||
@@ -89,7 +89,7 @@
|
||||
</Tabs>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="text-2xl font-semibold mb-2">
|
||||
<div class="text-xl font-semibold mb-2">
|
||||
{{ batch.data.title }}
|
||||
</div>
|
||||
<div v-html="batch.data.description" class="leading-5 mb-2"></div>
|
||||
@@ -195,6 +195,7 @@ import {
|
||||
SendIcon,
|
||||
MessageCircle,
|
||||
Globe,
|
||||
ShieldCheck,
|
||||
} from 'lucide-vue-next'
|
||||
import { formatTime, updateDocumentTitle } from '@/utils'
|
||||
import BatchDashboard from '@/components/BatchDashboard.vue'
|
||||
@@ -229,7 +230,7 @@ const batch = createResource({
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [{ label: 'All Batches', route: { name: 'Batches' } }]
|
||||
let crumbs = [{ label: 'Batches', route: { name: 'Batches' } }]
|
||||
if (!isStudent.value) {
|
||||
crumbs.push({
|
||||
label: 'Details',
|
||||
|
||||
@@ -137,7 +137,7 @@ const courses = createResource({
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'All Batches', route: { name: 'Batches' } }]
|
||||
let items = [{ label: 'Batches', route: { name: 'Batches' } }]
|
||||
items.push({
|
||||
label: batch?.data?.title,
|
||||
route: { name: 'BatchDetail', params: { batchName: batch?.data?.name } },
|
||||
|
||||
@@ -119,7 +119,7 @@ const course = createResource({
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
|
||||
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
label: course?.data?.title,
|
||||
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
|
||||
|
||||
@@ -42,8 +42,11 @@
|
||||
</div>
|
||||
</header>
|
||||
<div v-if="jobsList?.length">
|
||||
<div class="divide-y lg:w-3/4 mx-auto p-5">
|
||||
<div v-for="job in jobsList">
|
||||
<div class="lg:w-3/4 mx-auto p-5">
|
||||
<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
|
||||
:to="{
|
||||
name: 'JobDetail',
|
||||
|
||||
@@ -305,7 +305,7 @@ const progress = createResource({
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
|
||||
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
label: lesson?.data?.course_title,
|
||||
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
|
||||
|
||||
@@ -256,11 +256,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const keyboardShortcut = (e) => {
|
||||
if (
|
||||
e.key === 's' &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
!e.target.classList.contains('ProseMirror')
|
||||
) {
|
||||
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
||||
submitQuiz()
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
@@ -131,12 +131,6 @@ const routes = [
|
||||
component: () => import('@/pages/JobCreation.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/assignment-submission/:assignmentName/:submissionName',
|
||||
name: 'AssignmentSubmission',
|
||||
component: () => import('@/pages/AssignmentSubmission.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/certified-participants',
|
||||
name: 'CertifiedParticipants',
|
||||
@@ -193,6 +187,28 @@ const routes = [
|
||||
name: 'Programs',
|
||||
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({
|
||||
|
||||
83
frontend/src/utils/assignment.js
Normal file
83
frontend/src/utils/assignment.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Pencil } from 'lucide-vue-next'
|
||||
import { createApp, h } from 'vue'
|
||||
import AssessmentPlugin from '@/components/AssessmentPlugin.vue'
|
||||
import AssignmentBlock from '@/components/AssignmentBlock.vue'
|
||||
import translationPlugin from '../translation'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import router from '../router'
|
||||
|
||||
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, {
|
||||
assignmentID: assignment,
|
||||
})
|
||||
app.use(translationPlugin)
|
||||
app.use(router)
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { toast } from 'frappe-ui'
|
||||
import { useTimeAgo } from '@vueuse/core'
|
||||
import { Quiz } from '@/utils/quiz'
|
||||
import { Assignment } from '@/utils/assignment'
|
||||
import { Upload } from '@/utils/upload'
|
||||
import { Markdown } from '@/utils/markdownParser'
|
||||
import Header from '@editorjs/header'
|
||||
@@ -155,6 +156,7 @@ export function getEditorTools() {
|
||||
},
|
||||
},
|
||||
quiz: Quiz,
|
||||
assignment: Assignment,
|
||||
upload: Upload,
|
||||
markdown: Markdown,
|
||||
image: SimpleImage,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import QuizBlock from '@/components/QuizBlock.vue'
|
||||
import QuizPlugin from '@/components/QuizPlugin.vue'
|
||||
import AssessmentPlugin from '@/components/AssessmentPlugin.vue'
|
||||
import { createApp, h } from 'vue'
|
||||
import { usersStore } from '../stores/user'
|
||||
import translationPlugin from '../translation'
|
||||
@@ -63,8 +63,8 @@ export class Quiz {
|
||||
if (this.readOnly) {
|
||||
return
|
||||
}
|
||||
const app = createApp(QuizPlugin, {
|
||||
onQuizAddition: (quiz) => {
|
||||
const app = createApp(AssessmentPlugin, {
|
||||
onAddition: (quiz) => {
|
||||
this.data.quiz = quiz
|
||||
this.renderQuiz(quiz)
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ from frappe.utils import time_diff, now_datetime, get_datetime, flt
|
||||
from typing import Optional
|
||||
from lms.lms.utils import get_average_rating, get_lesson_count
|
||||
from xml.dom.minidom import parseString
|
||||
from lms.lms.doctype.course_lesson.course_lesson import save_progress
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -168,6 +169,7 @@ def get_user_info():
|
||||
user.is_instructor = "Course Creator" in user.roles
|
||||
user.is_moderator = "Moderator" in user.roles
|
||||
user.is_evaluator = "Batch Evaluator" in user.roles
|
||||
user.is_student = "LMS Student" in user.roles
|
||||
return user
|
||||
|
||||
|
||||
@@ -1030,3 +1032,14 @@ def delete_scorm_package(scorm_package_path):
|
||||
scorm_package_path = frappe.get_site_path("public", scorm_package_path[1:])
|
||||
if os.path.exists(scorm_package_path):
|
||||
shutil.rmtree(scorm_package_path)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def mark_lesson_progress(course, chapter_number, lesson_number):
|
||||
chapter_name = frappe.get_value(
|
||||
"Chapter Reference", {"parent": course, "idx": chapter_number}, "chapter"
|
||||
)
|
||||
lesson_name = frappe.get_value(
|
||||
"Lesson Reference", {"parent": chapter_name, "idx": lesson_number}, "lesson"
|
||||
)
|
||||
save_progress(lesson_name, course)
|
||||
|
||||
@@ -89,27 +89,25 @@ def save_progress(lesson, course):
|
||||
"LMS Enrollment", {"course": course, "member": frappe.session.user}
|
||||
)
|
||||
if not membership:
|
||||
return
|
||||
|
||||
frappe.db.set_value("LMS Enrollment", membership, "current_lesson", lesson)
|
||||
|
||||
if frappe.db.exists(
|
||||
"LMS Course Progress", {"lesson": lesson, "member": frappe.session.user}
|
||||
):
|
||||
return
|
||||
|
||||
quiz_completed = get_quiz_progress(lesson)
|
||||
if not quiz_completed:
|
||||
return 0
|
||||
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "LMS Course Progress",
|
||||
"lesson": lesson,
|
||||
"status": "Complete",
|
||||
"member": frappe.session.user,
|
||||
}
|
||||
).save(ignore_permissions=True)
|
||||
frappe.db.set_value("LMS Enrollment", membership, "current_lesson", lesson)
|
||||
already_completed = frappe.db.exists(
|
||||
"LMS Course Progress", {"lesson": lesson, "member": frappe.session.user}
|
||||
)
|
||||
|
||||
quiz_completed = get_quiz_progress(lesson)
|
||||
assignment_completed = get_assignment_progress(lesson)
|
||||
|
||||
if not already_completed and quiz_completed and assignment_completed:
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "LMS Course Progress",
|
||||
"lesson": lesson,
|
||||
"status": "Complete",
|
||||
"member": frappe.session.user,
|
||||
}
|
||||
).save(ignore_permissions=True)
|
||||
|
||||
progress = get_course_progress(course)
|
||||
capture_progress_for_analytics(progress, course)
|
||||
@@ -159,6 +157,32 @@ def get_quiz_progress(lesson):
|
||||
return True
|
||||
|
||||
|
||||
def get_assignment_progress(lesson):
|
||||
lesson_details = frappe.db.get_value(
|
||||
"Course Lesson", lesson, ["body", "content"], as_dict=1
|
||||
)
|
||||
assignments = []
|
||||
|
||||
if lesson_details.content:
|
||||
content = json.loads(lesson_details.content)
|
||||
|
||||
for block in content.get("blocks"):
|
||||
if block.get("type") == "assignment":
|
||||
assignments.append(block.get("data").get("assignment"))
|
||||
|
||||
elif lesson_details.body:
|
||||
macros = find_macros(lesson_details.body)
|
||||
assignments = [value for name, value in macros if name == "Assignment"]
|
||||
|
||||
for assignment in assignments:
|
||||
if not frappe.db.exists(
|
||||
"LMS Assignment Submission",
|
||||
{"assignment": assignment, "member": frappe.session.user},
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_lesson_info(chapter):
|
||||
return frappe.db.get_value("Course Chapter", chapter, "course")
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"title",
|
||||
"grade_assignment",
|
||||
"question",
|
||||
"column_break_hmwv",
|
||||
"type",
|
||||
"grade_assignment",
|
||||
"section_break_sjti",
|
||||
"show_answer",
|
||||
"answer"
|
||||
],
|
||||
@@ -20,7 +21,8 @@
|
||||
{
|
||||
"fieldname": "question",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Question"
|
||||
"label": "Question",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "type",
|
||||
@@ -28,14 +30,16 @@
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Type",
|
||||
"options": "Document\nPDF\nURL\nImage\nText"
|
||||
"options": "Document\nPDF\nURL\nImage\nText",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "title",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Title"
|
||||
"label": "Title",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_hmwv",
|
||||
@@ -60,11 +64,15 @@
|
||||
"fieldname": "grade_assignment",
|
||||
"fieldtype": "Check",
|
||||
"label": "Grade Assignment"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_sjti",
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-04-05 12:01:36.601160",
|
||||
"modified": "2024-12-24 09:36:31.464508",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Assignment",
|
||||
|
||||
@@ -14,19 +14,17 @@
|
||||
"member",
|
||||
"member_name",
|
||||
"section_break_dlzh",
|
||||
"question",
|
||||
"column_break_zvis",
|
||||
"assignment_attachment",
|
||||
"answer",
|
||||
"section_break_rqal",
|
||||
"status",
|
||||
"column_break_oqqy",
|
||||
"evaluator",
|
||||
"column_break_esgd",
|
||||
"status",
|
||||
"comments",
|
||||
"section_break_cwaw",
|
||||
"lesson",
|
||||
"section_break_rqal",
|
||||
"question",
|
||||
"column_break_esgd",
|
||||
"course",
|
||||
"column_break_ygdu"
|
||||
"lesson"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -89,8 +87,7 @@
|
||||
"fieldname": "evaluator",
|
||||
"fieldtype": "Link",
|
||||
"label": "Evaluator",
|
||||
"options": "User",
|
||||
"read_only": 1
|
||||
"options": "User"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!([\"URL\", \"Text\"]).includes(doc.type);",
|
||||
@@ -128,14 +125,6 @@
|
||||
"fieldname": "column_break_esgd",
|
||||
"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);",
|
||||
"fieldname": "answer",
|
||||
@@ -148,14 +137,14 @@
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_zvis",
|
||||
"fieldname": "column_break_oqqy",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2024-04-05 15:57:22.758563",
|
||||
"modified": "2024-12-24 21:22:35.212732",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Assignment Submission",
|
||||
|
||||
@@ -6,12 +6,14 @@ from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import validate_url, validate_email_address
|
||||
from frappe.email.doctype.email_template.email_template import get_email_template
|
||||
from frappe.desk.doctype.notification_log.notification_log import make_notification_logs
|
||||
|
||||
|
||||
class LMSAssignmentSubmission(Document):
|
||||
def validate(self):
|
||||
self.validate_duplicates()
|
||||
self.validate_url()
|
||||
self.validate_status()
|
||||
|
||||
def after_insert(self):
|
||||
if not frappe.flags.in_test:
|
||||
@@ -69,6 +71,28 @@ class LMSAssignmentSubmission(Document):
|
||||
header=[subject, "green"],
|
||||
)
|
||||
|
||||
def validate_status(self):
|
||||
doc_before_save = self.get_doc_before_save()
|
||||
if doc_before_save.status != self.status or doc_before_save.comments != self.comments:
|
||||
self.trigger_update_notification()
|
||||
|
||||
def trigger_update_notification(self):
|
||||
notification = frappe._dict(
|
||||
{
|
||||
"subject": _(
|
||||
"There has been an update on your submission for assignment {0}"
|
||||
).format(self.assignment_title),
|
||||
"email_content": self.comments,
|
||||
"document_type": self.doctype,
|
||||
"document_name": self.name,
|
||||
"for_user": self.owner,
|
||||
"from_user": self.evaluator,
|
||||
"type": "Alert",
|
||||
"link": f"/assignment-submission/{self.assignment}/{self.name}",
|
||||
}
|
||||
)
|
||||
make_notification_logs(notification, [self.member])
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def upload_assignment(
|
||||
|
||||
@@ -1421,7 +1421,7 @@ def get_quiz_details(assessment, member):
|
||||
if len(existing_submission):
|
||||
assessment.submission = existing_submission[0]
|
||||
assessment.completed = True
|
||||
assessment.status = assessment.submission.score
|
||||
assessment.status = assessment.submission.percentage or assessment.submission.score
|
||||
else:
|
||||
assessment.status = "Not Attempted"
|
||||
assessment.color = "red"
|
||||
|
||||
Reference in New Issue
Block a user