feat: assignments in batches
This commit is contained in:
@@ -8,7 +8,29 @@
|
|||||||
:columns="getAssessmentColumns()"
|
:columns="getAssessmentColumns()"
|
||||||
:rows="assessments.data"
|
:rows="assessments.data"
|
||||||
row-key="name"
|
row-key="name"
|
||||||
:options="{ selectable: false, showTooltip: false }"
|
:options="{
|
||||||
|
selectable: false,
|
||||||
|
showTooltip: false,
|
||||||
|
getRowRoute: (row) => {
|
||||||
|
if (row.submission) {
|
||||||
|
return {
|
||||||
|
name: 'AssignmentSubmission',
|
||||||
|
params: {
|
||||||
|
assignmentName: row.assessment_name,
|
||||||
|
submissionName: row.submission.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
name: 'AssignmentSubmission',
|
||||||
|
params: {
|
||||||
|
assignmentName: row.assessment_name,
|
||||||
|
submissionName: 'new',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
</ListView>
|
</ListView>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { Dropdown } from 'frappe-ui'
|
import { Dropdown, createResource } from 'frappe-ui'
|
||||||
import { ChevronDown, LogIn, LogOut, User } from 'lucide-vue-next'
|
import { ChevronDown, LogIn, LogOut, User } from 'lucide-vue-next'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { convertToTitleCase } from '../utils'
|
import { convertToTitleCase } from '../utils'
|
||||||
@@ -67,9 +67,17 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const { logout, user, branding } = sessionStore()
|
const branding = createResource({
|
||||||
let { isLoggedIn } = sessionStore()
|
url: 'lms.lms.api.get_branding',
|
||||||
|
cache: true,
|
||||||
|
auto: true,
|
||||||
|
onSuccess(data) {
|
||||||
|
document.querySelector("link[rel='icon']").href = data.favicon
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { logout, user } = sessionStore()
|
||||||
|
let { isLoggedIn } = sessionStore()
|
||||||
const userDropdownOptions = [
|
const userDropdownOptions = [
|
||||||
/* {
|
/* {
|
||||||
icon: User,
|
icon: User,
|
||||||
|
|||||||
328
frontend/src/pages/AssignmentSubmission.vue
Normal file
328
frontend/src/pages/AssignmentSubmission.vue
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
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>
|
||||||
|
</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'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
const submissionFile = ref(null)
|
||||||
|
const answer = ref(null)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
assignmentName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
submissionName: {
|
||||||
|
type: String,
|
||||||
|
default: 'new',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const assignment = createResource({
|
||||||
|
url: 'frappe.client.get',
|
||||||
|
params: {
|
||||||
|
doctype: 'LMS Assignment',
|
||||||
|
name: props.assignmentName,
|
||||||
|
},
|
||||||
|
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: assignment.data?.title,
|
||||||
|
route: {
|
||||||
|
name: 'AssignmentSubmission',
|
||||||
|
params: {
|
||||||
|
assignmentName: assignment.data?.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
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>
|
||||||
@@ -221,7 +221,7 @@ const course = reactive({
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!user.data?.is_moderator || !user.data?.is_instructor) {
|
if (!user.data?.is_moderator || !user.data?.is_instructor) {
|
||||||
window.location.href = '/login'
|
router.push({ name: 'Courses' })
|
||||||
}
|
}
|
||||||
if (props.courseName !== 'new') {
|
if (props.courseName !== 'new') {
|
||||||
courseResource.reload()
|
courseResource.reload()
|
||||||
|
|||||||
@@ -96,6 +96,12 @@ 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,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
let router = createRouter({
|
let router = createRouter({
|
||||||
|
|||||||
@@ -41,20 +41,10 @@ export const sessionStore = defineStore('lms-session', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const branding = createResource({
|
|
||||||
url: 'lms.lms.api.get_branding',
|
|
||||||
auto: true,
|
|
||||||
cache: true,
|
|
||||||
onSuccess(data) {
|
|
||||||
document.querySelector("link[rel='icon']").href = data.favicon
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
branding,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2023-10-06 12:08:46.898950",
|
"modified": "2024-04-05 12:01:36.601160",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Assignment",
|
"name": "LMS Assignment",
|
||||||
@@ -94,6 +94,15 @@
|
|||||||
"role": "Moderator",
|
"role": "Moderator",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "LMS Student",
|
||||||
|
"share": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"show_title_field_in_link": 1,
|
"show_title_field_in_link": 1,
|
||||||
|
|||||||
@@ -156,7 +156,7 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"make_attachments_public": 1,
|
"make_attachments_public": 1,
|
||||||
"modified": "2023-10-06 15:14:55.984714",
|
"modified": "2024-04-05 15:57:22.758563",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Assignment Submission",
|
"name": "LMS Assignment Submission",
|
||||||
@@ -174,6 +174,18 @@
|
|||||||
"role": "System Manager",
|
"role": "System Manager",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"if_owner": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "LMS Student",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
|
|||||||
@@ -11,10 +11,15 @@ from frappe.email.doctype.email_template.email_template import get_email_templat
|
|||||||
class LMSAssignmentSubmission(Document):
|
class LMSAssignmentSubmission(Document):
|
||||||
def validate(self):
|
def validate(self):
|
||||||
self.validate_duplicates()
|
self.validate_duplicates()
|
||||||
|
self.validate_url()
|
||||||
|
|
||||||
def after_insert(self):
|
def after_insert(self):
|
||||||
if not frappe.flags.in_test:
|
if not frappe.flags.in_test:
|
||||||
self.send_mail()
|
outgoing_email_account = frappe.get_cached_value(
|
||||||
|
"Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name"
|
||||||
|
)
|
||||||
|
if outgoing_email_account:
|
||||||
|
self.send_mail()
|
||||||
|
|
||||||
def validate_duplicates(self):
|
def validate_duplicates(self):
|
||||||
if frappe.db.exists(
|
if frappe.db.exists(
|
||||||
@@ -28,6 +33,10 @@ class LMSAssignmentSubmission(Document):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def validate_url(self):
|
||||||
|
if self.type == "URL" and not validate_url(self.answer):
|
||||||
|
frappe.throw(_("Please enter a valid URL."))
|
||||||
|
|
||||||
def send_mail(self):
|
def send_mail(self):
|
||||||
subject = _("New Assignment Submission")
|
subject = _("New Assignment Submission")
|
||||||
template = "assignment_submission"
|
template = "assignment_submission"
|
||||||
|
|||||||
Reference in New Issue
Block a user