caccl-api
Advanced tools
Comparing version 2.0.14 to 2.0.15
{ | ||
"name": "caccl-api", | ||
"version": "2.0.14", | ||
"version": "2.0.15", | ||
"description": "A class that defines a set of smart Canvas endpoints that actually behave how you'd expect them to.", | ||
@@ -34,10 +34,13 @@ "main": "./lib/index.js", | ||
"@types/url-parse": "^1.4.8", | ||
"@typescript-eslint/eslint-plugin": "^5.30.5", | ||
"@typescript-eslint/parser": "^5.30.5", | ||
"copyfiles": "^2.4.1", | ||
"docdash": "^1.2.0", | ||
"doctrine": "^2.1.0", | ||
"eslint": "^8.8.0", | ||
"eslint": "^8.19.0", | ||
"eslint-config-airbnb": "^19.0.4", | ||
"eslint-plugin-import": "^2.22.1", | ||
"eslint-plugin-jsx-a11y": "^6.4.1", | ||
"eslint-plugin-react": "^7.22.0", | ||
"eslint-config-airbnb-typescript": "^17.0.0", | ||
"eslint-plugin-import": "^2.26.0", | ||
"eslint-plugin-jest": "^26.5.3", | ||
"eslint-plugin-jsx-a11y": "^6.6.0", | ||
"jsdoc": "^3.6.6", | ||
@@ -48,6 +51,6 @@ "rimraf": "^3.0.2", | ||
"dependencies": { | ||
"caccl-error": "^2.0.14", | ||
"caccl-send-request": "^2.0.14", | ||
"caccl-error": "^2.0.15", | ||
"caccl-send-request": "^2.0.15", | ||
"fast-clone": "^1.5.13" | ||
} | ||
} |
@@ -14,1 +14,5 @@ # caccl-api | ||
**L**ibrary | ||
## Special Thanks | ||
Special thanks to [Yuen Ler Chow](https://github.com/yuenler) for his great work on various endpoints, specifically the course content migration endpoint. |
@@ -100,2 +100,8 @@ /** | ||
* @param {number} [opts.weight=current value] New weight | ||
* @param {number} [opts.dropLowest=0] number of lowest assignment scores to | ||
* drop | ||
* @param {number} [opts.dropHighest=0] number of highest assignment scores to | ||
* drop | ||
* @param {number[]} [opts.neverDrop] list of assignment ids to not drop in | ||
* the drop lowest/highest rule | ||
* @param {APIConfig} [config] custom configuration for this specific endpoint | ||
@@ -111,5 +117,32 @@ * call (overwrites defaults that were included when api was initialized) | ||
weight?: number, | ||
dropLowest?: number, | ||
dropHighest?: number, | ||
neverDrop?: number[], | ||
}, | ||
config?: APIConfig, | ||
): Promise<CanvasAssignmentGroup> { | ||
// Create rules | ||
const { | ||
dropLowest, | ||
dropHighest, | ||
neverDrop, | ||
} = opts; | ||
let rules: { [k: string]: (number | number[]) }; | ||
if ( | ||
(dropLowest && dropLowest > 0) | ||
|| (dropHighest && dropHighest > 0) | ||
) { | ||
rules = {}; | ||
if (dropLowest) { | ||
rules.drop_lowest = dropLowest; | ||
} | ||
if (dropHighest) { | ||
rules.drop_highest = dropHighest; | ||
} | ||
if (neverDrop && neverDrop.length > 0) { | ||
rules.never_drop = neverDrop; | ||
} | ||
} | ||
// Create assignment group | ||
return this.visitEndpoint({ | ||
@@ -123,2 +156,3 @@ config, | ||
group_weight: utils.includeIfNumber(opts.weight), | ||
rules: utils.includeIfTruthy(rules), | ||
}, | ||
@@ -151,2 +185,3 @@ }); | ||
): Promise<CanvasAssignmentGroup> { | ||
// Create the assignment group | ||
return this.visitEndpoint({ | ||
@@ -153,0 +188,0 @@ config, |
@@ -7,2 +7,4 @@ /** | ||
// Import shared classes | ||
import CACCLError from 'caccl-error'; | ||
import ErrorCode from '../../shared/types/ErrorCode'; | ||
import EndpointCategory from '../../shared/EndpointCategory'; | ||
@@ -15,2 +17,3 @@ | ||
import CanvasEnrollment from '../../types/CanvasEnrollment'; | ||
import { DateHandlingType, dayOfWeekToNumber, DateShiftOptions } from './types/DateHandling'; | ||
@@ -30,5 +33,8 @@ // Import shared helpers | ||
import ECatDiscussionTopic from './ECatDiscussionTopic'; | ||
import ECatFile from './ECatFile'; | ||
import ECatFolder from './ECatFolder'; | ||
import ECatGradebookColumn from './ECatGradebookColumn'; | ||
import ECatGroup from './ECatGroup'; | ||
import ECatGroupSet from './ECatGroupSet'; | ||
import ECatModule from './ECatModule'; | ||
import ECatNavMenuItem from './ECatNavMenuItem'; | ||
@@ -40,2 +46,12 @@ import ECatPage from './ECatPage'; | ||
/*------------------------------------------------------------------------*/ | ||
/* Constants */ | ||
/*------------------------------------------------------------------------*/ | ||
const assignmentTagPrefix = '#CurrentlyBeingMigrated#'; | ||
/*------------------------------------------------------------------------*/ | ||
/* Endpoint Category */ | ||
/*------------------------------------------------------------------------*/ | ||
// Endpoint category | ||
@@ -50,5 +66,8 @@ class ECatCourse extends EndpointCategory { | ||
public discussionTopic: ECatDiscussionTopic; | ||
public file: ECatFile; | ||
public folder: ECatFolder; | ||
public gradebookColumn: ECatGradebookColumn; | ||
public group: ECatGroup; | ||
public groupSet: ECatGroupSet; | ||
public module: ECatModule; | ||
public navMenuItem: ECatNavMenuItem; | ||
@@ -74,5 +93,8 @@ public page: ECatPage; | ||
this.discussionTopic = new ECatDiscussionTopic(initPack); | ||
this.file = new ECatFile(initPack); | ||
this.folder = new ECatFolder(initPack); | ||
this.gradebookColumn = new ECatGradebookColumn(initPack); | ||
this.group = new ECatGroup(initPack); | ||
this.groupSet = new ECatGroupSet(initPack); | ||
this.module = new ECatModule(initPack); | ||
this.navMenuItem = new ECatNavMenuItem(initPack); | ||
@@ -753,2 +775,393 @@ this.page = new ECatPage(initPack); | ||
} | ||
/*------------------------------------------------------------------------*/ | ||
/* Migrations */ | ||
/*------------------------------------------------------------------------*/ | ||
/** | ||
* Perform a course content migration | ||
* @author Yuen Ler Chow | ||
* @method migrateContent | ||
* @memberof api.course | ||
* @instance | ||
* @async | ||
* @param {object} opts object containing all arguments | ||
* @param {number} [opts.sourceCourseId=default course id] Canvas course Id of | ||
* the source course | ||
* @param {number} opts.destinationCourseId Canvas course Id of the | ||
* destination course | ||
* @param {object} opts.include object containing all items and their ids to | ||
* include | ||
* @param {number[]} [opts.include.fileIds = []] list of file ids to include | ||
* @param {number[]} [opts.include.quizIds = []] list of quiz ids to include | ||
* @param {number[]} [opts.include.assignmentIds = []] list of assignment ids | ||
* to include | ||
* @param {number[]} [opts.include.announcementIds = []] list of announcement | ||
* ids to include | ||
* @param {number[]} [opts.include.discussionIds = []] list of discussion ids | ||
* to include | ||
* @param {number[]} [opts.include.moduleIds = []] list of module ids to | ||
* include | ||
* @param {number[]} [opts.include.pageIds = []] list of page ids to include | ||
* @param {number[]} [opts.include.rubricIds = []] list of rubric ids to | ||
* include | ||
* @param {DateShiftOptions} opts.dateShiftOptions options for shifting dates | ||
* @param {number} [opts.timeoutMs = 5 minutes] maximum time in milliseconds | ||
* to wait for course migration to finish | ||
* @param {APIConfig} [config] custom configuration for this specific endpoint | ||
*/ | ||
public async migrateContent( | ||
opts: { | ||
sourceCourseId: number, | ||
destinationCourseId: number, | ||
include: { | ||
fileIds?: number[], | ||
quizIds?: number[], | ||
assignmentIds?: number[], | ||
announcementIds?: number[], | ||
discussionTopicsIds?: number[], | ||
moduleIds?: number[], | ||
pageIds?: number[], | ||
rubricIds?: number[], | ||
}, | ||
dateShiftOptions: DateShiftOptions, | ||
timeoutMs?: number, | ||
}, | ||
) { | ||
const { | ||
sourceCourseId, | ||
destinationCourseId, | ||
include, | ||
dateShiftOptions, | ||
timeoutMs = 300000, // 5 minutes | ||
} = opts; | ||
// If the user didn't specify the ids for an item, | ||
// just make it an empty array | ||
const { | ||
fileIds = [], | ||
quizIds = [], | ||
assignmentIds = [], | ||
announcementIds = [], | ||
discussionTopicsIds = [], | ||
moduleIds = [], | ||
pageIds = [], | ||
rubricIds = [], | ||
} = include; | ||
// Create a params object that we'll dynamically fill | ||
// with params depending on the request | ||
const params: { [k: string]: any } = { | ||
migration_type: 'course_copy_importer', | ||
settings: { | ||
source_course_id: sourceCourseId, | ||
overwrite_quizzes: true, | ||
}, | ||
}; | ||
// Add selected ids to the request | ||
params.select = { | ||
files: fileIds, | ||
quizzes: quizIds, | ||
assignments: assignmentIds, | ||
announcements: announcementIds, | ||
discussion_topics: discussionTopicsIds, | ||
modules: moduleIds, | ||
pages: pageIds, | ||
rubrics: rubricIds, | ||
}; | ||
// if we remove dates we don't need to provide start and end dates, | ||
// but if we shift dates, we do | ||
if (dateShiftOptions.dateHandling === DateHandlingType.RemoveDates) { | ||
params.date_shift_options = { | ||
remove_dates: true, | ||
}; | ||
} else if (dateShiftOptions.dateHandling === DateHandlingType.ShiftDates) { | ||
const { | ||
oldStart, | ||
oldEnd, | ||
newStart, | ||
newEnd, | ||
daySubstitutionMap = {}, | ||
} = dateShiftOptions; | ||
// Translate input (day week map) to number-based params that Canvas uses | ||
const dayNumberSubstitutionMap: { [k: number]: number } = {}; | ||
Object.keys(daySubstitutionMap).forEach((k) => { | ||
const key = k as keyof typeof daySubstitutionMap; | ||
dayNumberSubstitutionMap[dayOfWeekToNumber[key]] = ( | ||
dayOfWeekToNumber[daySubstitutionMap[key]] | ||
); | ||
}); | ||
// Add date shift info to the request | ||
params.date_shift_options = { | ||
shift_dates: true, | ||
old_start_date: oldStart, | ||
old_end_date: oldEnd, | ||
new_start_date: newStart, | ||
new_end_date: newEnd, | ||
day_substitutions: dayNumberSubstitutionMap, | ||
}; | ||
} | ||
// Iterate through each assignment and change the name to be | ||
// current name + [id] | ||
for (let i = 0; i < assignmentIds.length; i++) { | ||
const id = assignmentIds[i]; | ||
const { name } = await this.api.course.assignment.get({ | ||
assignmentId: id, | ||
courseId: sourceCourseId, | ||
}); | ||
this.api.course.assignment.update({ | ||
assignmentId: id, | ||
courseId: sourceCourseId, | ||
name: `${name}${assignmentTagPrefix}${id}`, | ||
}); | ||
} | ||
// Create the migration | ||
try { | ||
const contentMigration = await this.visitEndpoint({ | ||
path: `${API_PREFIX}/courses/${destinationCourseId}/content_migrations`, | ||
action: 'perform a course content migration', | ||
method: 'POST', | ||
params, | ||
}); | ||
// Initialize status variables that are updated on each check | ||
let workflowState = 'running'; | ||
let migrationIssuesCount = 0; | ||
const CHECK_INTERVAL_MS = 500; | ||
// Calculate num iterations | ||
const numIterations = Math.ceil(timeoutMs / CHECK_INTERVAL_MS); | ||
// Continuously check every CHECK_INTERVAL_MS if the migration is | ||
// finished, failed, or timed out | ||
for (let i = 0; i < numIterations; i++) { | ||
// Wait for CHECK_INTERVAL_MS | ||
await new Promise((resolve) => { | ||
setTimeout(resolve, CHECK_INTERVAL_MS); | ||
}); | ||
// Go to the api endpoint to get the status of the content migration | ||
const status = await this.visitEndpoint({ | ||
path: `${API_PREFIX}/courses/${destinationCourseId}/content_migrations/${contentMigration.id}`, | ||
action: 'check the status of a content migration', | ||
method: 'GET', | ||
}); | ||
workflowState = status.workflow_state; | ||
migrationIssuesCount = status.migration_issues_count; | ||
// If the workflow is no longer running, end the loop | ||
if (workflowState === 'completed' || workflowState === 'failed') { | ||
break; | ||
} | ||
} | ||
// Detect a timeout (if the workflow never left the pending state) | ||
if (workflowState !== 'completed' && workflowState !== 'failed') { | ||
throw new CACCLError({ | ||
message: 'Migration timed out', | ||
code: ErrorCode.MigrationTimeout, | ||
}); | ||
} | ||
if (migrationIssuesCount > 0) { | ||
// Go to the api endpoint to get a list of migration issues | ||
const migrationIssues = await this.visitEndpoint({ | ||
path: `${API_PREFIX}/courses/${destinationCourseId}/content_migrations/${contentMigration.id}/migration_issues`, | ||
action: 'get migration issues', | ||
method: 'GET', | ||
}); | ||
let errorsAsText: string; | ||
// If there is only 1 issue, we simply print the issue. | ||
// If there is more than 1, we need to concatenate these | ||
// issues with commas + ands | ||
if (migrationIssuesCount === 1) { | ||
errorsAsText = migrationIssues[0].description; | ||
} else if (migrationIssuesCount === 2) { | ||
errorsAsText = `${migrationIssues[0].description} and ${migrationIssues[1].description}`; | ||
} else { | ||
errorsAsText = ( | ||
migrationIssues | ||
// Extract only the descriptions and add "and" to last item | ||
.map((migrationIssue: any, i: number) => { | ||
if (i === migrationIssues.length - 1) { | ||
return `and ${migrationIssue.description}`; | ||
} | ||
return migrationIssue.description; | ||
}) | ||
// Put together | ||
.join(', ') | ||
); | ||
} | ||
const errorMessage = `We ran into an error while migrating your course content: ${errorsAsText}.`; | ||
throw new CACCLError({ | ||
message: errorMessage, | ||
code: ErrorCode.MigrationIssue, | ||
}); | ||
} | ||
} catch (err) { | ||
if (err instanceof CACCLError) { | ||
// Rethrow the error (it's already in the right format) | ||
throw err; | ||
} | ||
// An unknown error occurred. Throw a new error | ||
throw new CACCLError({ | ||
message: err, | ||
code: ErrorCode.MigrationIssue, | ||
}); | ||
} | ||
let sourceAssignments = await this.api.course.assignment.list({ | ||
courseId: sourceCourseId, | ||
}); | ||
// filter sourceAssignments to only those that were migrated | ||
sourceAssignments = sourceAssignments.filter((assignment) => { | ||
return assignmentIds.includes(assignment.id); | ||
}); | ||
const destinationAssignments = await this.api.course.assignment.list({ | ||
courseId: destinationCourseId, | ||
}); | ||
// mapping source group id to destination group id | ||
const assignmentGroupMap: { [k: number]: number } = {}; | ||
// mapping source assignment id to destination assignment id | ||
const assignmentMap: { [k: number]: number } = {}; | ||
// iterate through each source assignment to determine the mapping | ||
sourceAssignments.forEach((sourceAssignment) => { | ||
const destinationAssignment = destinationAssignments.find((assignment) => { | ||
return assignment.name === sourceAssignment.name; | ||
}); | ||
if (destinationAssignment) { | ||
assignmentMap[sourceAssignment.id] = destinationAssignment.id; | ||
} else { | ||
throw new CACCLError({ | ||
message: 'Could not find a migrated assignment in the destination course.', | ||
code: ErrorCode.CouldNotFindDestinationAssignment, | ||
}); | ||
} | ||
}); | ||
// iterate through each assignment group in the source course and | ||
// create the same assignment group in the destination course | ||
const sourceAssignmentGroups = await this.api.course.assignmentGroup.list({ | ||
courseId: sourceCourseId, | ||
}); | ||
// check if apply_assignment_group_weights is true in the source course | ||
const sourceCourse = await this.api.course.get({ | ||
courseId: sourceCourseId, | ||
}); | ||
const applyAssignmentGroupWeights = sourceCourse.apply_assignment_group_weights; | ||
for (let i = 0; i < sourceAssignmentGroups.length; i++) { | ||
const sourceId = sourceAssignmentGroups[i].id; | ||
const sourceAssignmentGroup = await this.api.course.assignmentGroup.get({ | ||
assignmentGroupId: sourceId, | ||
courseId: sourceCourseId, | ||
}); | ||
// TODO: check if the assignment group name already exists in the destination course, | ||
// in which we case we do not create a new assignment group | ||
// instead, get the id of this matching assignment group and update weights if needed | ||
// and also add this assignment group to the map | ||
const destinationAssignmentGroup = await this.api.course.assignmentGroup.create({ | ||
courseId: destinationCourseId, | ||
name: sourceAssignmentGroup.name, | ||
weight: ( | ||
applyAssignmentGroupWeights | ||
? sourceAssignmentGroup.group_weight | ||
: undefined | ||
), | ||
}); | ||
// set apply_assignment_group_weights to true/false in the destination course | ||
await this.visitEndpoint({ | ||
path: `${API_PREFIX}/courses/${destinationCourseId}`, | ||
action: 'set apply_assignment_group_weights to true', | ||
method: 'PUT', | ||
params: { | ||
course: { | ||
apply_assignment_group_weights: applyAssignmentGroupWeights, | ||
}, | ||
}, | ||
}); | ||
// add assignment group mapping | ||
assignmentGroupMap[sourceId] = destinationAssignmentGroup.id; | ||
} | ||
// iterate through each source assignment | ||
for (let i = 0; i < sourceAssignments.length; i++) { | ||
const sourceAssignment = sourceAssignments[i]; | ||
// Get the assignment group id of the assignment | ||
const assignmentGroupId = sourceAssignment.assignment_group_id; | ||
const destinationAssignmentGroupId = assignmentGroupMap[assignmentGroupId]; | ||
// throw an error if the assignment group id is not in the map | ||
if (!destinationAssignmentGroupId) { | ||
throw new CACCLError({ | ||
message: 'Could not find assignment group id in map', | ||
code: ErrorCode.CouldNotFindDestinationAssignmentGroup, | ||
}); | ||
} | ||
// determine the id of the assignment in the new course by using the map | ||
const destinationAssignmentId = assignmentMap[sourceAssignment.id]; | ||
// throw an error if the assignment id is not in the map | ||
if (!destinationAssignmentId) { | ||
throw new CACCLError({ | ||
message: 'Could not find assignment id in map', | ||
code: ErrorCode.CouldNotFindDestinationAssignment, | ||
}); | ||
} | ||
// Remove tag from assignment names | ||
const parts = sourceAssignment.name.split('#'); | ||
const tag = parts[parts.length - 1]; | ||
const originalAssignmentName = sourceAssignment.name.substring( | ||
// Start at beginning of name | ||
0, | ||
// Cut off the tag from the end | ||
sourceAssignment.name.length - (`${assignmentTagPrefix}${tag}`).length, | ||
); | ||
// Update the assignment group id of the assignment and remove the tag from the name in the destination course | ||
await this.api.course.assignment.update({ | ||
courseId: destinationCourseId, | ||
assignmentId: destinationAssignmentId, | ||
assignmentGroupId: destinationAssignmentGroupId, | ||
name: originalAssignmentName, | ||
}); | ||
// remove tag from name in original course | ||
await this.api.course.assignment.update({ | ||
courseId: sourceCourseId, | ||
assignmentId: sourceAssignment.id, | ||
name: originalAssignmentName, | ||
}); | ||
} | ||
// iterate through the source assignment groups and update the drop rules in the destination groups | ||
for (let i = 0; i < sourceAssignmentGroups.length; i++) { | ||
const sourceAssignmentGroup = sourceAssignmentGroups[i]; | ||
// use map to find destination assignment group id | ||
const destinationAssignmentGroupId = assignmentGroupMap[sourceAssignmentGroup.id]; | ||
// use the assignment map to map ids in sourceAssignmentGroup.rules.never_drop | ||
let destinationNeverDrop: number[] = []; | ||
if (sourceAssignmentGroup.rules.never_drop) { | ||
destinationNeverDrop = sourceAssignmentGroup.rules.never_drop.map( | ||
(id: number) => { return assignmentMap[id]; }, | ||
); | ||
} | ||
// update destination assignment group | ||
await this.api.course.assignmentGroup.update({ | ||
courseId: destinationCourseId, | ||
assignmentGroupId: destinationAssignmentGroupId, | ||
dropLowest: sourceAssignmentGroup.rules.drop_lowest, | ||
dropHighest: sourceAssignmentGroup.rules.drop_highest, | ||
neverDrop: destinationNeverDrop, | ||
}); | ||
} | ||
} | ||
} | ||
@@ -755,0 +1168,0 @@ |
@@ -7,3 +7,3 @@ /** | ||
// Highest errors = | ||
// > CAPI27 | ||
// > CAPI30 | ||
// > CANV18 (exclude 404, 500) | ||
@@ -84,4 +84,9 @@ | ||
NavItemNotFound = 'CAPI25', | ||
}; | ||
MigrationTimeout = 'CAPI26', | ||
MigrationIssue = 'CAPI28', | ||
CouldNotFindDestinationAssignment = 'CAPI29', | ||
CouldNotFindDestinationAssignmentGroup = 'CAPI30', | ||
} | ||
export default ErrorCode; |
@@ -6,4 +6,4 @@ interface CanvasTerm { | ||
end_at?: string | null, | ||
}; | ||
} | ||
export default CanvasTerm; |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
5393952
459
25932
18
19
Updatedcaccl-error@^2.0.15
Updatedcaccl-send-request@^2.0.15