UNPKG

caccl-api

Version:

A class that defines a set of smart Canvas endpoints that actually behave how you'd expect them to.

1,236 lines (1,177 loc) 57.2 kB
/** * Functions for interacting with assignments within courses * @namespace api.course.assignment */ // Import caccl import CACCLError from 'caccl-error'; // Import shared classes import EndpointCategory from '../../shared/EndpointCategory'; // Import shared types import APIConfig from '../../shared/types/APIConfig'; import CanvasAssignment from '../../types/CanvasAssignment'; import CanvasUser from '../../types/CanvasUser'; import CanvasSubmission from '../../types/CanvasSubmission'; import CanvasProgress from '../../types/CanvasProgress'; import ErrorCode from '../../shared/types/ErrorCode'; import CanvasAssignmentOverride from '../../types/CanvasAssignmentOverride'; // Import shared helpers import utils from '../../shared/helpers/utils'; import waitForCompletion from '../../shared/helpers/waitForCompletion'; import parallelLimit from '../../shared/helpers/parallelLimit'; // Import shared constants import API_PREFIX from '../../shared/constants/API_PREFIX'; // Endpoint category class ECatAssignment extends EndpointCategory { /*------------------------------------------------------------------------*/ /* Table of Contents: */ /* - Assignments */ /* - Grading */ /* - Overrides */ /* - Submissions */ /*------------------------------------------------------------------------*/ /*------------------------------------------------------------------------*/ /* Assignment Endpoints */ /*------------------------------------------------------------------------*/ /** * Lists the assignments in a course * @author Gabe Abrams * @method list * @memberof api.course.assignment * @instance * @async * @param {object} [opts] object containing all arguments * @param {number} [opts.courseId=default course id] Canvas course Id to * query * @param {boolean} [opts.ignoreOverridesForDates] if true, assignment * dates are taken from the default dates instead of from the ones in * overrides * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasAssignment[]>} list of Canvas Assignments {@link https://canvas.instructure.com/doc/api/assignments.html#Assignment} */ public async list( opts: { courseId?: number, ignoreOverridesForDates?: boolean, } = {}, config?: APIConfig, ): Promise<CanvasAssignment[]> { return this.visitEndpoint({ config, action: 'get the list of assignments in a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/assignments`, method: 'GET', params: { override_assignment_dates: !opts.ignoreOverridesForDates, }, }); } /** * Get info on a specific assignment in a course * @author Gabe Abrams * @method get * @memberof api.course.assignment * @instance * @async * @param {object} opts object containing all arguments * @param {number} opts.assignmentId Canvas assignment Id * @param {number} [opts.courseId=default course id] Canvas course Id to query * @param {boolean} [opts.ignoreOverridesForDates] if true, assignment * dates are taken from the default dates instead of from the ones in * overrides * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasAssignment>} Canvas Assignment {@link https://canvas.instructure.com/doc/api/assignments.html#Assignment} */ public async get( opts: { assignmentId: number, ignoreOverridesForDates?: boolean, courseId?: number, }, config?: APIConfig, ): Promise<CanvasAssignment> { return this.visitEndpoint({ config, action: 'get info on a specific assignment in a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/assignments/${opts.assignmentId}`, method: 'GET', params: { override_assignment_dates: !opts.ignoreOverridesForDates, }, }); } /** * Updates a Canvas assignment * @author Gabe Abrams * @method update * @memberof api.course.assignment * @instance * @async * @param {object} opts - object containing all arguments * @param {number} opts.assignmentId Canvas assignment Id to update * @param {number} [opts.courseId=default course id] Canvas course Id to query * @param {string} [opts.name=current value] The name of the assignment * @param {number} [opts.pointsPossible=current value] Points possible * @param {date} [opts.dueAt=current value] Due at datetime * @param {date} [opts.lockAt=current value] Due at datetime * @param {date} [opts.unlockAt=current value] Due at datetime * @param {string} [opts.description=current value] html description of * the assignment * @param {string[]} [opts.submissionTypes=current value] Submission type(s) * @param {string} [opts.allowedExtensions=current value] List of allowed * file extensions (exclude period). Online upload must be enabled * @param {string} [opts.gradingType=current value] Grading type * @param {number} [opts.position=current value] Position in assignment * list * @param {boolean} [opts.published=current value] If true, publish page * upon creation. Must be a boolean * @param {boolean} [opts.muted=current value] If true, assignment is * muted. Must be a boolean * @param {number} [opts.groupSetId=current value] Student group set Id * @param {number} [opts.assignmentGroupId=current value] Assignment group * Id * @param {boolean} [opts.peerReviewsEnabled=current value] If true, users * asked to submit peer reviews. Must be a boolean * @param {boolean} [opts.automaticPeerReviewsEnabled=current value] If * true, Canvas will automatically assign peer reviews. Must be a boolean * @param {boolean} [opts.omitFromFinalGrade=current value] If true, * assignment is omitted from the final grade. Must be a boolean * @param {boolean} [opts.gradeGroupStudentsIndividually=current value] If * true, students in groups can be given separate grades and when one student * in a group gets a grade, other students do not get graded. Must be a * boolean * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasAssignment>} Canvas Assignment {@link https://canvas.instructure.com/doc/api/assignments.html#Assignment} */ public async update( opts: { assignmentId: number, courseId?: number, name?: string, pointsPossible?: number, dueAt?: (Date | string), lockAt?: (Date | string), unlockAt?: (Date | string), description?: string, submissionTypes?: ( 'online_quiz' | 'none' | 'on_paper' | 'discussion_topic' | 'external_tool' | 'online_upload' | 'online_text_entry' | 'online_url' | 'media_recording' | 'student_annotation' )[], allowedExtensions?: string[], gradingType?: ( 'pass_fail' | 'percent' | 'letter_grade' | 'gpa_scale' | 'points' | 'not_graded' ), position?: number, published?: boolean, muted?: boolean, groupSetId?: number, assignmentGroupId?: number, peerReviewsEnabled?: boolean, automaticPeerReviewsEnabled?: boolean, omitFromFinalGrade?: boolean, gradeGroupStudentsIndividually?: boolean, }, config?: APIConfig, ): Promise<CanvasAssignment> { return this.visitEndpoint({ config, action: 'update an assignment in a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/assignments/${opts.assignmentId}`, method: 'PUT', params: { 'assignment[name]': utils.includeIfTruthy(opts.name), 'assignment[submission_types]': utils.includeIfTruthy(opts.submissionTypes), 'assignment[grading_type]': utils.includeIfTruthy(opts.gradingType), position: utils.includeIfTruthy(opts.position), 'assignment[peer_reviews]': utils.includeIfBoolean(opts.peerReviewsEnabled), 'assignment[automatic_peer_reviews]': utils.includeIfBoolean(opts.automaticPeerReviewsEnabled), 'assignment[grade_group_students_individually]': utils.includeIfBoolean( opts.gradeGroupStudentsIndividually, ), 'assignment[description]': utils.includeIfTruthy(opts.description), 'assignment[allowed_extensions]': utils.includeIfTruthy(opts.allowedExtensions), 'assignment[group_category_id]': utils.includeIfTruthy(opts.groupSetId), 'assignment[points_possible]': utils.includeIfNumber(opts.pointsPossible), 'assignment[due_at]': utils.includeIfDate(opts.dueAt), 'assignment[lock_at]': utils.includeIfDate(opts.lockAt), 'assignment[unlock_at]': utils.includeIfDate(opts.unlockAt), 'assignment[published]': utils.includeIfBoolean(opts.published), 'assignment[assignment_group_id]': utils.includeIfNumber(opts.assignmentGroupId), 'assignment[omit_from_final_grade]': utils.includeIfBoolean(opts.omitFromFinalGrade), 'assignment[muted]': utils.includeIfBoolean(opts.muted), }, }); } /** * Creates a Canvas assignment * @author Gabe Abrams * @method create * @memberof api.course.assignment * @instance * @async * @param {object} [opts] object containing all arguments * @param {number} [opts.courseId=default course id] Canvas course Id to * create an assignment in * @param {string} [opts.name=Unnamed Assignment] The name of the * assignment * @param {number} [opts.pointsPossible=null] Points possible * @param {date} [opts.dueAt=null] Due at datetime * @param {date} [opts.lockAt=null] Due at datetime * @param {date} [opts.unlockAt=null] Due at datetime * @param {string} [opts.description=null] html description of * the assignment * @param {string} [opts.submissionTypes=null] Submission type(s) * @param {string} [opts.allowedExtensions=any] List of allowed file * extensions (exclude period). Online upload must be enabled * @param {string} [opts.gradingType=points] Grading type * @param {number} [opts.position=last] Position in assignment list * @param {boolean} [opts.published] If true, publish page upon * creation * @param {boolean} [opts.muted] If true, assignment is muted * @param {number} [opts.groupSetId=null] Student group set Id * @param {number} [opts.assignmentGroupId=top assignment group] Assignment * group Id * @param {boolean} [opts.peerReviewsEnabled] If true, users asked to * submit peer reviews * @param {boolean} [opts.automaticPeerReviewsEnabled] If true, * Canvas will automatically assign peer reviews * @param {boolean} [opts.omitFromFinalGrade] If true, assignment is * omitted from the final grade * @param {boolean} [opts.gradeGroupStudentsIndividually] If true, * students in groups can be given separate grades and when one student in a * group gets a grade, other students do not get graded * @param {string} [opts.assignmentAppId=null] If defined, the external * tool that matches this id will be used for submissions. Also, the * submission types will be overwritten with ['external_tool'] and the * student will be redirected via LTI to the assignmentAppURL when they * launch the assignment * @param {string} [opts.assignmentAppURL=tool launch url] The launch URL * of the external tool. If not included and assignmentAppId is defined, we * will first request info on the external tool to get its launchURL and * will use that value here. Only relevant if assignmentAppId is defined. * @param {boolean} [opts.assignmentAppNewTab] Only relevant if * assignmentAppId is defined. If true, when a student clicks the * assignment, their LTI session with the external tool will be opened in a * new tab * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasAssignment>} Canvas Assignment {@link https://canvas.instructure.com/doc/api/assignments.html#Assignment} */ public async create( opts: { courseId?: number, name?: string, pointsPossible?: number, dueAt?: (Date | string), lockAt?: (Date | string), unlockAt?: (Date | string), description?: string, submissionTypes?: ( 'online_quiz' | 'none' | 'on_paper' | 'discussion_topic' | 'external_tool' | 'online_upload' | 'online_text_entry' | 'online_url' | 'media_recording' | 'student_annotation' )[], allowedExtensions?: string[], gradingType?: ( 'pass_fail' | 'percent' | 'letter_grade' | 'gpa_scale' | 'points' | 'not_graded' ), position?: number, published?: boolean, muted?: boolean, groupSetId?: number, assignmentGroupId?: number, peerReviewsEnabled?: boolean, automaticPeerReviewsEnabled?: boolean, omitFromFinalGrade?: boolean, gradeGroupStudentsIndividually?: boolean, assignmentAppId?: number, assignmentAppURL?: string, assignmentAppNewTab?: boolean, } = {}, config?: APIConfig, ): Promise<CanvasAssignment> { // Create params const params: { [k: string]: any } = { 'assignment[name]': (opts.name || 'Unnamed Assignment'), 'assignment[grading_type]': (opts.gradingType || 'points'), position: utils.includeIfTruthy(opts.position), 'assignment[peer_reviews]': ( utils.isTruthy(opts.peerReviewsEnabled) ), 'assignment[automatic_peer_reviews]': utils.isTruthy(opts.automaticPeerReviewsEnabled), 'assignment[grade_group_students_individually]': utils.isTruthy(opts.gradeGroupStudentsIndividually), 'assignment[description]': ( utils.includeIfTruthy(opts.description) ), 'assignment[allowed_extensions]': ( utils.includeIfTruthy(opts.allowedExtensions) ), 'assignment[group_category_id]': ( utils.includeIfTruthy(opts.groupSetId) ), 'assignment[points_possible]': ( utils.includeIfNumber(opts.pointsPossible) ), 'assignment[due_at]': utils.includeIfDate(opts.dueAt), 'assignment[lock_at]': utils.includeIfDate(opts.lockAt), 'assignment[unlock_at]': utils.includeIfDate(opts.unlockAt), 'assignment[published]': ( utils.isTruthy(opts.published) ), 'assignment[assignment_group_id]': ( utils.includeIfNumber(opts.assignmentGroupId) ), 'assignment[omit_from_final_grade]': ( utils.isTruthy(opts.omitFromFinalGrade) ), 'assignment[muted]': utils.isTruthy(opts.muted), }; // Prep for external tool if (opts.assignmentAppId) { // Using an external tool params['assignment[external_tool_tag_attributes][new_tab]'] = ( !!opts.assignmentAppNewTab ); params['assignment[external_tool_tag_attributes][content_type]'] = ( 'context_external_tool' ); params['assignment[external_tool_tag_attributes][content_id]'] = ( opts.assignmentAppId ); params['assignment[submission_types]'] = ['external_tool']; if (opts.assignmentAppURL) { // No need to fetch the launchURL params['assignment[external_tool_tag_attributes][url]'] = ( opts.assignmentAppURL ); } else { // Need to fetch the launchURL const app = await this.api.course.app.get( { courseId: (opts.courseId ?? this.defaultCourseId), appId: opts.assignmentAppId, }, config, ); params['assignment[external_tool_tag_attributes][url]'] = app.url; } } else { params['assignment[submission_types]'] = ( opts.submissionTypes || ['none'] ); } return this.visitEndpoint({ config, action: 'create a new assignment in a course', params, path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/assignments`, method: 'POST', }); } /** * Delete an assignment * @author Gabe Abrams * @method delete * @memberof api.course.assignment * @instance * @async * @param {object} opts object containing all arguments * @param {number} opts.assignmentId Canvas assignment Id * @param {number} [opts.courseId=default course id] Canvas course Id * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasAssignment>} Canvas Assignment {@link https://canvas.instructure.com/doc/api/assignments.html#Assignment} */ public async delete( opts: { assignmentId: number, courseId?: number, }, config?: APIConfig, ): Promise<CanvasAssignment> { return this.visitEndpoint({ config, action: 'delete an assignment from a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/assignments/${opts.assignmentId}`, method: 'DELETE', }); } /*------------------------------------------------------------------------*/ /* Grading Endpoints */ /*------------------------------------------------------------------------*/ /** * List gradeable students for a specific assignment * @author Gabe Abrams * @method listGradeableStudents * @memberof api.course.assignment * @instance * @async * @param {object} opts object containing all arguments * @param {number} opts.assignmentId Canvas assignment Id to query * @param {number} [opts.courseId=default course id] Canvas course Id to * query * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasUser[]>} list of Canvas users {@link https://canvas.instructure.com/doc/api/users.html#User} */ public async listGradeableStudents( opts: { assignmentId: number, courseId?: number, }, config?: APIConfig, ): Promise<CanvasUser[]> { const students: CanvasUser[] = await this.visitEndpoint({ config, action: 'get the list of students who are gradeable in a specific assignment in a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/assignments/${opts.assignmentId}/gradeable_students`, method: 'GET', }); return students.filter((s) => { return !(s as any).fake_student; }); } /** * Adds a comment to a submission * @author Gabe Abrams * @method createSubmissionComment * @memberof api.course.assignment * @instance * @async * @param {object} opts object containing all arguments * @param {number} opts.assignmentId Canvas course Id * @param {number} opts.studentId Canvas student Id of the sub to comment * on * @param {string} opts.comment The text of the comment * @param {number} [opts.courseId=default course id] Canvas course Id * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasSubmission>} Canvas submission {@link https://canvas.instructure.com/doc/api/submissions.html#Submission} */ public async createSubmissionComment( opts: { assignmentId: number, studentId: number, comment: string, courseId?: number, }, config?: APIConfig, ): Promise<CanvasSubmission> { return this.visitEndpoint({ config, action: 'create a new comment on a submission', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/assignments/${opts.assignmentId}/submissions/${opts.studentId}`, method: 'PUT', params: { 'comment[text_comment]': opts.comment, }, }); } /** * Updates a student's grade and/or comment * @author Gabe Abrams * @method updateGrade * @memberof api.course.assignment * @instance * @async * @param {object} opts object containing all arguments * @param {number} opts.assignmentId Canvas assignment id * @param {number} opts.studentId Canvas student id * @param {number} [opts.courseId=default course id] Canvas course id * @param {number} [opts.points] the overall points to assign to the * student * @param {string} [opts.comment] the grader comment to leave on the * submission * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasSubmission>} Canvas submission {@link https://canvas.instructure.com/doc/api/submissions.html#Submission} */ public async updateGrade( opts: { assignmentId: number, studentId: number, courseId?: number, points?: number, comment?: string, }, config?: APIConfig, ): Promise<CanvasSubmission> { return this.visitEndpoint({ config, action: 'update student grade and/or comments for a specific assignment in a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/assignments/${opts.assignmentId}/submissions/${opts.studentId}`, method: 'PUT', params: { 'comment[text_comment]': utils.includeIfTruthy(opts.comment), 'submission[posted_grade]': utils.includeIfNumber(opts.points), }, }); } /** * Batch updates grades and/or comments. Also supports updating rubric items * @author Gabe Abrams * @method updateGrades * @memberof api.course.assignment * @instance * @async * @param {object} opts object containing all arguments * @param {number} opts.assignmentId Canvas assignment Id * @param {Array} opts.gradeItems List of grade items to upload to Canvas: * [{ * studentId: <student id>, * points: <optional, points to overwrite with>, * comment: <optional, comment to append (or overwrite if rubric comment)>, * rubricId: <optional, rubric item (overall grade/comment if excluded)> * },...] * @param {number} [opts.courseId=default course id] Canvas course Id * @param {boolean} [opts.waitForCompletion] If true, promise won't * resolve until Canvas has finished updating the grades, instead of resolving * once the grade changes have been queued * @param {number} [opts.waitForCompletionTimeout=2] The number of minutes * to wait before timing out the grade update job * @param {boolean} [opts.dontMergeRubricItemUpdates] When uploading * grades to a rubric item, we intelligently merge rubric item updates with * previous rubric assessments. For instance, if the assignment's rubric is: * { grammar, argument, formatting } * And the student of interest has the following rubric assessment so far: * { grammar: 10/10, argument: 8/10, formatting: ungraded } * When we upload a new gradeItem (9/10 points) to the student's * formatting rubric item, the result is: * { grammar: 10/10, argument: 8/10, formatting: 9/10 } * However, if dontMergeRubricItemUpdates=true, the result is: * { grammar: ungraded, argument: ungraded, formatting: 9/10 } * Note: merging is an added feature. By default, the Canvas API does not * merge rubric assessments. * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasProgress>} Canvas Progress object {@link https://canvas.instructure.com/doc/api/progress.html#Progress} */ public async updateGrades( opts: { assignmentId: number, gradeItems: ( { studentId: number, points?: number, comment?: string, rubricId?: string, } )[], courseId?: number, waitForCompletion?: boolean, waitForCompletionTimeout?: number, dontMergeRubricItemUpdates?: boolean, }, config?: APIConfig, ): Promise<CanvasProgress> { /* --- 1. Check if we need to merge --- */ // Check if we need to merge rubric item updates const studentsToMerge: number[] = []; // Check if merge is necessary // > not necessary if no rubric item updates let performRubricItemMerge = false; if (!opts.dontMergeRubricItemUpdates) { performRubricItemMerge = opts.gradeItems.some((item) => { return item.rubricId; }); } // Pull assignment so we can get rubric information if (performRubricItemMerge) { const assignment = await this.api.course.assignment.get( { courseId: (opts.courseId ?? this.defaultCourseId), assignmentId: opts.assignmentId, }, config, ); // Make sure the assignment has a rubric if (!assignment.rubric) { // This assignment doesn't have a rubric throw new CACCLError({ message: 'We could not upload grades because the rubric we were trying to upload to didn\'t exist.', code: ErrorCode.NoRubricOnBatchGradeUpload, }); } // Only merge students who don't have all the rubric items defined // (if all the rubric items are being uploaded, no merge needed) // > Get data on rubric const realRubricItemIds = new Set<string>(); const numRubricItems = assignment.rubric.length; assignment.rubric.forEach((rubricItem) => { realRubricItemIds.add(rubricItem.id); }); // > Figure out which students have which rubric items const studentToRubricItemsOverwritten = ( new Map<number, Set<string>>() ); const allStudentsWithRubricItems = new Set<number>(); // ^ {studentId => { Set of rubric ids being uploaded }} opts.gradeItems.forEach((gradeItem) => { const { rubricId, studentId } = gradeItem; allStudentsWithRubricItems.add(studentId); // Skip if this item isn't a (real) rubric item if (!rubricId || realRubricItemIds.has(rubricId)) { return; } // Only mark this rubric item as being overwritten if both points and // comments are being overwritten if ( gradeItem.points === undefined || gradeItem.points === null || !gradeItem.comment ) { // Not completely overwriting return; } // Keep track of rubric items that are found if (!studentToRubricItemsOverwritten.has(studentId)) { // Initialize student map studentToRubricItemsOverwritten.set(studentId, new Set<string>()); } studentToRubricItemsOverwritten.get(studentId).add(rubricId); }); // > Find students that need to be merged (has some rubric items but not // completely overwriting all of them) allStudentsWithRubricItems.forEach((studentId) => { const numOverwrittenItems = ( ( studentToRubricItemsOverwritten.get(studentId) || { size: 0 } ).size ); if (numOverwrittenItems < numRubricItems) { // Need to merge this student studentsToMerge.push(studentId); } }); } /* ----------- Fetch subs ----------- */ const subs: CanvasSubmission[] = await parallelLimit( studentsToMerge.map((studentId) => { return async () => { return this.api.course.assignment.getSubmission( { studentId, courseId: (opts.courseId ?? this.defaultCourseId), assignmentId: opts.assignmentId, includeRubricAssessment: true, excludeUser: true, // Save request space }, config, ); }; }), 10, ); /* ---------- Perform Merge --------- */ // Prep for merge (if applicable) const params: { [k: string]: any } = {}; if (subs.length > 0) { // Keep track of which items are being overwritten const overwritingMap: { [k: string]: { [k: string]: { points: boolean, comment: boolean, } } } = {}; // ^ {studentId => rubricId => { // points: true/false, is being overwritten, // comment: true/false, is being overwritten // }} opts.gradeItems.forEach((gradeItem) => { if (!gradeItem.rubricId) { // No need to keep track of non-rubric item updates // (these are not being merged) return; } const sid = gradeItem.studentId; const rid = gradeItem.rubricId; // Initialize map if needed if (!overwritingMap[sid]) { overwritingMap[sid] = {}; } if (!overwritingMap[sid][rid]) { overwritingMap[sid][rid] = { points: false, comment: false }; } // Save points and comments if (gradeItem.points !== undefined) { overwritingMap[sid][rid].points = true; } if (gradeItem.comment !== undefined) { overwritingMap[sid][rid].comment = true; } }); // Perform actual merge subs.forEach((sub) => { if (!sub.rubric_assessment) { // No need to merge: submission has no rubric content yet return; } const sid = sub.user_id; // Loop through rubric items and merge Object.keys(sub.rubric_assessment).forEach((rubricId) => { // Get previous values const oldPoints = sub.rubric_assessment[rubricId].points; const oldComment = sub.rubric_assessment[rubricId].comments; // Check if we're overwriting these values let overwritePoints; let overwriteComment; if (overwritingMap[sid] && overwritingMap[sid][rubricId]) { overwritePoints = overwritingMap[sid][rubricId].points; overwriteComment = overwritingMap[sid][rubricId].comment; } // Add old value if ( oldPoints !== undefined && oldPoints !== null && !overwritePoints ) { // We have an old points val and we're not overwriting it // (include the old points value) params[`grade_data[${sid}][rubric_assessment][${rubricId}][points]`] = oldPoints; } if (oldComment && !overwriteComment) { // We have an old comment and we're not overwriting it // (include the old comment) params[`grade_data[${sid}][rubric_assessment][${rubricId}][comments]`] = oldComment; } }); }); } // Add rest of grade item updates to params opts.gradeItems.forEach((gradeItem) => { if (gradeItem.rubricId) { if (gradeItem.points !== undefined) { params[`grade_data[${gradeItem.studentId}][rubric_assessment][${gradeItem.rubricId}][points]`] = gradeItem.points; } if (gradeItem.comment) { params[`grade_data[${gradeItem.studentId}][rubric_assessment][${gradeItem.rubricId}][comments]`] = gradeItem.comment; } } else { if (gradeItem.points !== undefined) { params[`grade_data[${gradeItem.studentId}][posted_grade]`] = gradeItem.points; } if (gradeItem.comment) { params[`grade_data[${gradeItem.studentId}][text_comment]`] = gradeItem.comment; } } }); // Send request const progress = await this.visitEndpoint({ params, config, action: 'update student grades, comments, and/or rubric assessments for a specific assignment in a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/assignments/${opts.assignmentId}/submissions/update_grades`, method: 'POST', }); /* --- Wait for completion (if applicable) --- */ if (opts.waitForCompletion) { const finishedProgress = await waitForCompletion({ progress, visitEndpoint: this.visitEndpoint, timeoutMin: opts.waitForCompletionTimeout, }); return finishedProgress; } return progress; } /*------------------------------------------------------------------------*/ /* Assignment Override Endpoints */ /*------------------------------------------------------------------------*/ /** * Gets the list of overrides for an assignment * @author Gabe Abrams * @method listOverrides * @memberof api.course.assignment * @instance * @async * @param {object} opts object containing all arguments * @param {number} opts.assignmentId Canvas assignment id to look up * @param {number} [opts.courseId=default course id] Canvas course id to query * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasAssignmentOverride[]>} list of Canvas AssignmentOverrides {@link https://canvas.instructure.com/doc/api/assignments.html#AssignmentOverride} */ public async listOverrides( opts: { assignmentId: number, courseId?: number, }, config?: APIConfig, ): Promise<CanvasAssignmentOverride[]> { return this.visitEndpoint({ config, action: 'get a list of assignment overrides for a specific assignment in a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/assignments/${opts.assignmentId}/overrides`, method: 'GET', }); } /** * Get a specific override on an assignment in a course * @author Gabe Abrams * @method getOverride * @memberof api.course.assignment * @instance * @async * @param {object} opts object containing all arguments * @param {number} opts.assignmentId Canvas assignment id to query * @param {number} opts.overrideId Canvas override id to look up * @param {number} [opts.courseId=default course id] Canvas course id to query * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasAssignmentOverride>} Canvas AssignmentOverride {@link https://canvas.instructure.com/doc/api/assignments.html#AssignmentOverride} */ public async getOverride( opts: { assignmentId: number, overrideId: number, courseId?: number, }, config?: APIConfig, ): Promise<CanvasAssignmentOverride> { return this.visitEndpoint({ config, action: 'get a list of assignment overrides for a specific assignment in a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/assignments/${opts.assignmentId}/overrides/${opts.overrideId}`, method: 'GET', }); } /** * Create assignment override. Note that if any dates (dueAt, unlockAt, or * lockAt) are left out, they will be set to "none" for the target(s) of this * override. If dueAt is omitted, the target(s) will have no deadline. If * unlockAt is omitted, the target(s) will immediately be able to see the * assignment (even if everyone else has to wait until the unlockAt date). If * lockAt is omitted, the target(s) will be able to submit at any * time in the future (even if everyone else can't submit because their lock * date has passed). In short, it is not recommended to omit dates that are * defined in the assignment. * @author Gabe Abrams * @method createOverride * @memberof api.course.assignment * @instance * @async * @param {object} opts object containing all arguments * @param {number} opts.assignmentId Canvas assignment id * @param {number} [opts.courseId=default course id] Canvas course id * @param {number[]} [opts.studentIds] List of Canvas student IDs to override * (Note: either studentIds, groupId, or sectionId must be included) * @param {number} [opts.groupId] Group to override, must be a group * assignment (Note: either studentIds, groupId, or sectionId must be * included) * @param {number} [opts.sectionId] Section to override (Note: either * studentIds, groupId, or sectionId must be included) * @param {string} [opts.title=Override for X students] Title of the * override * @param {date} [opts.dueAt=no due date] New due date. If excluded, the * target(s) of this override have no due date (they can submit whenever they * want without being marked as late) * @param {date} [opts.unlockAt=no unlock date] New unlock date. If * excluded, the target(s) of this override can immediately see the assignment * (their unlock date is the beginning of time) * @param {date} [opts.lockAt=no lock date] New lock date. If excluded, * the target(s) of this override can see and submit the assignment at * any point in the future (their lock date is the end of time) * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasAssignmentOverride>} Canvas AssignmentOverride {@link https://canvas.instructure.com/doc/api/assignments.html#AssignmentOverride} */ public async createOverride( opts: { assignmentId: number, courseId?: number, studentIds?: number[], groupId?: number, sectionId?: number, title?: string, dueAt?: (Date | string), unlockAt?: (Date | string), lockAt?: (Date | string), }, config?: APIConfig, ): Promise<CanvasAssignmentOverride> { let { title } = opts; if (!title) { title = `Override for ${opts.studentIds.length} student${utils.sIfPlural(opts.studentIds.length)}`; } // Pre-process dates const dueAt = utils.includeIfDate(opts.dueAt) || null; const unlockAt = utils.includeIfDate(opts.unlockAt) || null; const lockAt = utils.includeIfDate(opts.lockAt) || null; return this.visitEndpoint({ config, action: 'create a new override for a specific assignment in a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/assignments/${opts.assignmentId}/overrides`, method: 'POST', params: { 'assignment_override[title]': utils.includeIfTruthy(title), 'assignment_override[student_ids]': utils.includeIfTruthy(opts.studentIds), 'assignment_override[group_id]': utils.includeIfTruthy(opts.groupId), 'assignment_override[course_section_id]': utils.includeIfTruthy(opts.sectionId), 'assignment_override[due_at]': dueAt, 'assignment_override[unlock_at]': unlockAt, 'assignment_override[lock_at]': lockAt, }, }); } /** * Update an assignment override. Note: target can only be updated if the * override is a student override (if this is a group or section override, * the target remains unchanged). * Also, note that if any dates (dueAt, unlockAt, or lockAt) are omitted, * their previous override values will be changed to "none." For instance, * if the previous override has a dueAt and the update does not, the updated * override will have no dueAt date (the target(s) of the override will have * no deadline). * @author Gabe Abrams * @method updateOverride * @memberof api.course.assignment * @instance * @async * @param {object} opts object containing all arguments * @param {number} opts.assignmentId Canvas assignment id * @param {number} opts.overrideId the override id to update * @param {number[]} opts.studentIds List of Canvas student IDs being * overridden * @param {number} [opts.courseId=default course id] Canvas course id * @param {string} [opts.title=current value] New title of the * override * @param {date} [opts.dueAt=no due date] New due date. If excluded, the * target(s) of this override have no due date (they can submit whenever they * want without being marked as late) * @param {date} [opts.unlockAt=no unlock date] New unlock date. If * excluded, the target(s) of this override can immediately see the assignment * (their unlock date is the beginning of time) * @param {date} [opts.lockAt=no lock date] New lock date. If excluded, * the target(s) of this override can see and submit the assignment at * any point in the future (their lock date is the end of time) * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasAssignmentOverride>} Canvas AssignmentOverride {@link https://canvas.instructure.com/doc/api/assignments.html#AssignmentOverride} */ public async updateOverride( opts: { assignmentId: number, overrideId: number, studentIds: number[], courseId?: number, title?: string, dueAt?: (Date | string), unlockAt?: (Date | string), lockAt?: (Date | string), }, config?: APIConfig, ): Promise<CanvasAssignmentOverride> { // Pre-process dates const dueAt = utils.includeIfDate(opts.dueAt) || null; const unlockAt = utils.includeIfDate(opts.unlockAt) || null; const lockAt = utils.includeIfDate(opts.lockAt) || null; return this.visitEndpoint({ config, action: 'update an override for a specific assignment in a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/assignments/${opts.assignmentId}/overrides/${opts.overrideId}`, method: 'PUT', params: { 'assignment_override[title]': utils.includeIfTruthy(opts.title), 'assignment_override[student_ids]': utils.includeIfTruthy(opts.studentIds), 'assignment_override[due_at]': dueAt, 'assignment_override[unlock_at]': unlockAt, 'assignment_override[lock_at]': lockAt, }, }); } /** * Deletes an assignment override * @author Gabe Abrams * @method deleteOverride * @memberof api.course.assignment * @instance * @async * @param {object} opts object containing all arguments * @param {number} opts.assignmentId Canvas assignment id to query * @param {number} opts.overrideId Canvas override id to look up * @param {number} [opts.courseId=default course id] Canvas course id to query * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasAssignmentOverride>} Canvas AssignmentOverride {@link https://canvas.instructure.com/doc/api/assignments.html#AssignmentOverride} */ public async deleteOverride( opts: { assignmentId: number, overrideId: number, courseId?: number, }, config?: APIConfig, ): Promise<CanvasAssignmentOverride> { return this.visitEndpoint({ config, action: 'delete an override for a specific assignment in a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/assignments/${opts.assignmentId}/overrides/${opts.overrideId}`, method: 'DELETE', }); } /*------------------------------------------------------------------------*/ /* Assignment Submission Endpoints */ /*------------------------------------------------------------------------*/ /** * Lists the submissions to a specific assignment in a course. If the assignment * has anonymous grading turned on, to exclude the test user, we will also * pull the list of students in the course. If including the user object for * an anonymously graded assignment, fake user objects will be created where * each submissions[i].user object contains a isAnonymousUser boolean that is * true * @author Gabe Abrams * @method listSubmissions * @memberof api.course.assignment * @instance * @async * @param {object} opts object containing all arguments * @param {number} opts.assignmentId The Canvas assignment Id to query * @param {number} [opts.courseId=default course id] Canvas course Id * @param {boolean} [opts.includeComments] If truthy, includes all * comments on submissions * @param {boolean} [opts.includeRubricAssessment] If truthy, * includes rubric assessments: breakdown of score for each rubric item * @param {boolean} [opts.excludeUser] If truthy, excludes * submission[i].user value with the submission's user information * @param {boolean} [opts.includeTestStudent] If truthy, includes * dummy submission by test student (student view) if there is one. Note: * if anonymous grading is enabled for this assignment, includeTestStudent * will be true because we don't know which student is the test student * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults that were included when api was initialized) * @returns {Promise<CanvasSubmission[]>} list of Canvas submissions {@link https://canvas.instructure.com/doc/api/submissions.html#Submission} */ public async listSubmissions( opts: { assignmentId: number, courseId?: number, includeComments?: boolean, includeRubricAssessment?: boolean, excludeUser?: boolean, includeTestStudent?: boolean, }, config?: APIConfig, ): Promise<CanvasSubmission[]> { // Fetch the user info if we're not excluding user info OR if we're // filtering out the test student (we need user info to filter) const fetchUser = ( !opts.includeTestStudent || !opts.excludeUser ); const subs: CanvasSubmission[] = await this.visitEndpoint({ config, action: 'list the submissions to a specific assignment in a course', path: `${API_PREFIX}/courses/${opts.courseId ?? this.defaultCourseId}/assignments/${opts.assignmentId}/submissions`, method: 'GET', params: { include: utils.genIncludesList({ submission_comments: opts.includeComments, rubric_assessment: opts.includeRubricAssessment, user: fetchUser, }), }, }); // Filter test student if applicable if (!opts.includeTestStudent) { // Handle empty list case if (!subs || subs.length === 0) { return []; } // Handle normal case where we have user objects const realSubs = subs.filter((sub) => { return ( !sub.user || sub.user.name !== 'Test Student' ); }); // Finish return realSubs; } // Not filtering out test student. Just return subs return subs; } /** * Lists the submissions for a batch of assignment/students in a course * @author Gabe Abrams * @method listAllSubmissions * @memberof api.course.assignment * @instance * @async * @param {object} [opts] object containing all arguments * @param {number} [opts.courseId=default course id] Canvas course Id * @param {number[]} [opts.studentIds=all students] a list of * specific students to pull submissions for * @param {number[]} [opts.assignmentIds=all assignments] a list of * assignments to get submissions for * @param {Date} [opts.submittedSince=beginning of time] Exclude * submissions that were not submitted or were submitted before this date * @param {Date} [opts.gradedSince=beginning of time] Exclude * submissions that were not graded or were graded before this date * @param {string} [opts.workflowState=all workflows] a workflow state * to filter by. Allowed values: 'submitted', 'unsubmitted', 'graded', or * 'pending_review' * @param {string} [opts.enrollmentState=all states except deleted] an * enrollment state to filter by. Allowed values: 'active' or 'concluded' * @param {boolean} [opts.includeSubmissionHistory] if true, submission * history is included * @param {boolean} [opts.includeComments] if true, includes all comments * on submissions * @param {boolean} [opts.includeRubricAssessment] if true, * rubric assessment is included * @param {boolean} [opts.includeAssignment] if true, the assignment is * included for each submission * @param {boolean} [opts.includeTotalScores] if true, include the total * scores * @param {boolean} [opts.includeVisibility] if true, include visibility * @param {boolean} [opts.includeUser] if true, include the user info * with each submission * @param {APIConfig} [config] custom configuration for this specific endpoint * call (overwrites defaults tha