mirror of
https://github.com/alexmickelson/canvasManagement.git
synced 2026-03-26 07:38:33 -06:00
refactoring canvas files
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { baseCanvasUrl } from "@/features/canvas/services/canvasServiceUtils";
|
||||
import { isServer } from "@tanstack/react-query";
|
||||
import axios, { AxiosInstance, AxiosError } from "axios";
|
||||
import toast from "react-hot-toast";
|
||||
import { baseCanvasUrl } from "./canvas/canvasServiceUtils";
|
||||
|
||||
const canvasBaseUrl = "https://snow.instructure.com/api/v1/";
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
|
||||
import { axiosClient } from "../axiosUtils";
|
||||
import { CanvasAssignmentGroup } from "@/models/canvas/assignments/canvasAssignmentGroup";
|
||||
import { LocalAssignmentGroup } from "@/features/local/assignments/models/localAssignmentGroup";
|
||||
import { rateLimitAwareDelete } from "./canvasWebRequestor";
|
||||
|
||||
export const canvasAssignmentGroupService = {
|
||||
async getAll(courseId: number): Promise<CanvasAssignmentGroup[]> {
|
||||
console.log("Requesting assignment groups");
|
||||
const url = `${canvasApi}/courses/${courseId}/assignment_groups`;
|
||||
const assignmentGroups = await paginatedRequest<CanvasAssignmentGroup[]>({
|
||||
url,
|
||||
});
|
||||
return assignmentGroups.flatMap((groupList) => groupList);
|
||||
},
|
||||
|
||||
async create(
|
||||
canvasCourseId: number,
|
||||
localAssignmentGroup: LocalAssignmentGroup
|
||||
): Promise<LocalAssignmentGroup> {
|
||||
console.log(`Creating assignment group: ${localAssignmentGroup.name}`);
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/assignment_groups`;
|
||||
const body = {
|
||||
name: localAssignmentGroup.name,
|
||||
group_weight: localAssignmentGroup.weight,
|
||||
};
|
||||
|
||||
const { data: canvasAssignmentGroup } =
|
||||
await axiosClient.post<CanvasAssignmentGroup>(url, body);
|
||||
|
||||
return {
|
||||
...localAssignmentGroup,
|
||||
canvasId: canvasAssignmentGroup.id,
|
||||
};
|
||||
},
|
||||
|
||||
async update(
|
||||
canvasCourseId: number,
|
||||
localAssignmentGroup: LocalAssignmentGroup
|
||||
): Promise<void> {
|
||||
console.log(
|
||||
`Updating assignment group: ${localAssignmentGroup.name}, ${localAssignmentGroup.canvasId}`
|
||||
);
|
||||
if (!localAssignmentGroup.canvasId) {
|
||||
throw new Error("Cannot update assignment group if canvas ID is null");
|
||||
}
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/assignment_groups/${localAssignmentGroup.canvasId}`;
|
||||
const body = {
|
||||
name: localAssignmentGroup.name,
|
||||
group_weight: localAssignmentGroup.weight,
|
||||
};
|
||||
|
||||
await axiosClient.put(url, body);
|
||||
},
|
||||
|
||||
async delete(
|
||||
canvasCourseId: number,
|
||||
canvasAssignmentGroupId: number,
|
||||
assignmentGroupName: string
|
||||
): Promise<void> {
|
||||
console.log(`Deleting assignment group: ${assignmentGroupName}`);
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/assignment_groups/${canvasAssignmentGroupId}`;
|
||||
await rateLimitAwareDelete(url);
|
||||
},
|
||||
};
|
||||
@@ -1,158 +0,0 @@
|
||||
import { CanvasAssignment } from "@/models/canvas/assignments/canvasAssignment";
|
||||
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
|
||||
import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
|
||||
import { axiosClient } from "../axiosUtils";
|
||||
import { markdownToHTMLSafe } from "../htmlMarkdownUtils";
|
||||
import { CanvasRubricCreationResponse } from "@/models/canvas/assignments/canvasRubricCreationResponse";
|
||||
import { assignmentPoints } from "@/features/local/assignments/models/utils/assignmentPointsUtils";
|
||||
import { getDateFromString } from "@/features/local/utils/timeUtils";
|
||||
import { getRubricCriterion } from "./canvasRubricUtils";
|
||||
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
|
||||
|
||||
export const canvasAssignmentService = {
|
||||
async getAll(courseId: number): Promise<CanvasAssignment[]> {
|
||||
console.log("getting canvas assignments");
|
||||
const url = `${canvasApi}/courses/${courseId}/assignments`; //per_page=100
|
||||
const assignments = await paginatedRequest<CanvasAssignment[]>({ url });
|
||||
return assignments.map((a) => ({
|
||||
...a,
|
||||
due_at: a.due_at ? new Date(a.due_at).toLocaleString() : undefined, // timezones?
|
||||
lock_at: a.lock_at ? new Date(a.lock_at).toLocaleString() : undefined, // timezones?
|
||||
}));
|
||||
},
|
||||
|
||||
async create(
|
||||
canvasCourseId: number,
|
||||
localAssignment: LocalAssignment,
|
||||
settings: LocalCourseSettings,
|
||||
canvasAssignmentGroupId?: number
|
||||
) {
|
||||
console.log(`Creating assignment: ${localAssignment.name}`);
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/assignments`;
|
||||
const content = markdownToHTMLSafe(localAssignment.description, settings);
|
||||
|
||||
const contentWithClassroomLinks =
|
||||
localAssignment.githubClassroomAssignmentShareLink
|
||||
? content.replaceAll(
|
||||
"insert_github_classroom_url",
|
||||
localAssignment.githubClassroomAssignmentShareLink
|
||||
)
|
||||
: content;
|
||||
|
||||
const body = {
|
||||
assignment: {
|
||||
name: localAssignment.name,
|
||||
submission_types: localAssignment.submissionTypes.map((t) =>
|
||||
t.toString()
|
||||
),
|
||||
allowed_extensions: localAssignment.allowedFileUploadExtensions.map(
|
||||
(e) => e.toString()
|
||||
),
|
||||
description: contentWithClassroomLinks,
|
||||
due_at: getDateFromString(localAssignment.dueAt)?.toISOString(),
|
||||
lock_at:
|
||||
localAssignment.lockAt &&
|
||||
getDateFromString(localAssignment.lockAt)?.toISOString(),
|
||||
points_possible: assignmentPoints(localAssignment.rubric),
|
||||
assignment_group_id: canvasAssignmentGroupId,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await axiosClient.post<CanvasAssignment>(url, body);
|
||||
const canvasAssignment = response.data;
|
||||
|
||||
await createRubric(canvasCourseId, canvasAssignment.id, localAssignment);
|
||||
|
||||
return canvasAssignment.id;
|
||||
},
|
||||
|
||||
async update(
|
||||
courseId: number,
|
||||
canvasAssignmentId: number,
|
||||
localAssignment: LocalAssignment,
|
||||
settings: LocalCourseSettings,
|
||||
canvasAssignmentGroupId?: number
|
||||
) {
|
||||
console.log(`Updating assignment: ${localAssignment.name}`);
|
||||
const url = `${canvasApi}/courses/${courseId}/assignments/${canvasAssignmentId}`;
|
||||
const body = {
|
||||
assignment: {
|
||||
name: localAssignment.name,
|
||||
submission_types: localAssignment.submissionTypes.map((t) =>
|
||||
t.toString()
|
||||
),
|
||||
allowed_extensions: localAssignment.allowedFileUploadExtensions.map(
|
||||
(e) => e.toString()
|
||||
),
|
||||
description: markdownToHTMLSafe(localAssignment.description, settings),
|
||||
due_at: getDateFromString(localAssignment.dueAt)?.toISOString(),
|
||||
lock_at:
|
||||
localAssignment.lockAt &&
|
||||
getDateFromString(localAssignment.lockAt)?.toISOString(),
|
||||
points_possible: assignmentPoints(localAssignment.rubric),
|
||||
assignment_group_id: canvasAssignmentGroupId,
|
||||
},
|
||||
};
|
||||
|
||||
await axiosClient.put(url, body);
|
||||
await createRubric(courseId, canvasAssignmentId, localAssignment);
|
||||
},
|
||||
|
||||
async delete(
|
||||
courseId: number,
|
||||
assignmentCanvasId: number,
|
||||
assignmentName: string
|
||||
) {
|
||||
console.log(`Deleting assignment from Canvas: ${assignmentName}`);
|
||||
const url = `${canvasApi}/courses/${courseId}/assignments/${assignmentCanvasId}`;
|
||||
const response = await axiosClient.delete(url);
|
||||
|
||||
if (!response.status.toString().startsWith("2")) {
|
||||
console.error(`Failed to delete assignment: ${assignmentName}`);
|
||||
throw new Error("Failed to delete assignment");
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const createRubric = async (
|
||||
courseId: number,
|
||||
assignmentCanvasId: number,
|
||||
localAssignment: LocalAssignment
|
||||
) => {
|
||||
const criterion = getRubricCriterion(localAssignment.rubric);
|
||||
|
||||
const rubricBody = {
|
||||
rubric_association_id: assignmentCanvasId,
|
||||
rubric: {
|
||||
title: `Rubric for Assignment: ${localAssignment.name}`,
|
||||
association_id: assignmentCanvasId,
|
||||
association_type: "Assignment",
|
||||
use_for_grading: true,
|
||||
criteria: criterion,
|
||||
},
|
||||
rubric_association: {
|
||||
association_id: assignmentCanvasId,
|
||||
association_type: "Assignment",
|
||||
purpose: "grading",
|
||||
use_for_grading: true,
|
||||
},
|
||||
};
|
||||
|
||||
const rubricUrl = `${canvasApi}/courses/${courseId}/rubrics`;
|
||||
const rubricResponse = await axiosClient.post<CanvasRubricCreationResponse>(
|
||||
rubricUrl,
|
||||
rubricBody
|
||||
);
|
||||
|
||||
if (!rubricResponse.data) throw new Error("Failed to create rubric");
|
||||
|
||||
const assignmentPointAdjustmentUrl = `${canvasApi}/courses/${courseId}/assignments/${assignmentCanvasId}`;
|
||||
const assignmentPointAdjustmentBody = {
|
||||
assignment: { points_possible: assignmentPoints(localAssignment.rubric) },
|
||||
};
|
||||
|
||||
await axiosClient.put(
|
||||
assignmentPointAdjustmentUrl,
|
||||
assignmentPointAdjustmentBody
|
||||
);
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
downloadUrlToTempDirectory,
|
||||
uploadToCanvasPart1,
|
||||
uploadToCanvasPart2,
|
||||
} from "@/services/canvas/files/canvasFileService";
|
||||
import { router } from "../serverFunctions/trpcSetup";
|
||||
import publicProcedure from "../serverFunctions/publicProcedure";
|
||||
|
||||
const fileStorageLocation = process.env.FILE_STORAGE_LOCATION ?? "/app/public";
|
||||
|
||||
export const canvasFileRouter = router({
|
||||
getCanvasFileUrl: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sourceUrl: z.string(),
|
||||
canvasCourseId: z.number(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input: { sourceUrl, canvasCourseId } }) => {
|
||||
const { fileName: localFile, success } = sourceUrl.startsWith("/")
|
||||
? { fileName: fileStorageLocation + sourceUrl, success: true }
|
||||
: await downloadUrlToTempDirectory(sourceUrl);
|
||||
|
||||
if (!success) {
|
||||
console.log("could not download file, returning sourceUrl", sourceUrl);
|
||||
// make a toast or some other way of notifying the user
|
||||
return sourceUrl;
|
||||
}
|
||||
console.log("local temp file", localFile);
|
||||
const { upload_url, upload_params } = await uploadToCanvasPart1(
|
||||
localFile,
|
||||
canvasCourseId
|
||||
);
|
||||
console.log("part 1 done", upload_url, upload_params);
|
||||
const canvasUrl = await uploadToCanvasPart2({
|
||||
pathToUpload: localFile,
|
||||
upload_url,
|
||||
upload_params,
|
||||
});
|
||||
console.log("canvas url done", canvasUrl);
|
||||
return canvasUrl;
|
||||
}),
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { CanvasModuleItem } from "@/models/canvas/modules/canvasModuleItems";
|
||||
import { CanvasPage } from "@/models/canvas/pages/canvasPageModel";
|
||||
import { axiosClient } from "../axiosUtils";
|
||||
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
|
||||
import { CanvasModule } from "@/models/canvas/modules/canvasModule";
|
||||
|
||||
export const canvasModuleService = {
|
||||
async updateModuleItem(
|
||||
canvasCourseId: number,
|
||||
canvasModuleId: number,
|
||||
item: CanvasModuleItem
|
||||
) {
|
||||
console.log(`Updating module item ${item.title}`);
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/modules/${canvasModuleId}/items/${item.id}`;
|
||||
const body = {
|
||||
module_item: { title: item.title, position: item.position },
|
||||
};
|
||||
const { data } = await axiosClient.put<CanvasModuleItem>(url, body);
|
||||
|
||||
if (!data) throw new Error("Something went wrong updating module item");
|
||||
},
|
||||
|
||||
async createModuleItem(
|
||||
canvasCourseId: number,
|
||||
canvasModuleId: number,
|
||||
title: string,
|
||||
type: "Assignment" | "Quiz",
|
||||
contentId: number | string
|
||||
) {
|
||||
console.log(`Creating new module item ${title}`);
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/modules/${canvasModuleId}/items`;
|
||||
const body = { module_item: { title, type, content_id: contentId } };
|
||||
await axiosClient.post(url, body);
|
||||
},
|
||||
|
||||
async createPageModuleItem(
|
||||
canvasCourseId: number,
|
||||
canvasModuleId: number,
|
||||
title: string,
|
||||
canvasPage: CanvasPage
|
||||
) {
|
||||
console.log(`Creating new module item ${title}`);
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/modules/${canvasModuleId}/items`;
|
||||
const body = {
|
||||
module_item: { title, type: "Page", page_url: canvasPage.url },
|
||||
};
|
||||
await axiosClient.post<CanvasModuleItem>(url, body);
|
||||
},
|
||||
|
||||
async getCourseModules(canvasCourseId: number) {
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/modules`;
|
||||
const response = await paginatedRequest<CanvasModule[]>({ url });
|
||||
return response;
|
||||
},
|
||||
|
||||
async createModule(canvasCourseId: number, moduleName: string) {
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/modules`;
|
||||
const body = {
|
||||
module: {
|
||||
name: moduleName,
|
||||
},
|
||||
};
|
||||
const response = await axiosClient.post<CanvasModule>(url, body);
|
||||
return response.data.id;
|
||||
},
|
||||
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import { axiosClient } from "../axiosUtils";
|
||||
import { canvasApi } from "./canvasServiceUtils";
|
||||
|
||||
export interface CanvasCourseTab {
|
||||
id: string;
|
||||
html_url: string;
|
||||
full_url: string;
|
||||
position: number;
|
||||
visibility: "public" | "members" | "admins" | "none";
|
||||
label: string;
|
||||
type: "internal" | "external";
|
||||
hidden?: boolean;
|
||||
unused?: boolean;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export const canvasNavigationService = {
|
||||
async getCourseTabs(canvasCourseId: number) {
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/tabs`;
|
||||
const { data } = await axiosClient.get<CanvasCourseTab[]>(url);
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateCourseTab(
|
||||
canvasCourseId: number,
|
||||
tabId: string,
|
||||
params: { hidden?: boolean; position?: number }
|
||||
) {
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/tabs/${tabId}`;
|
||||
const body = { ...params };
|
||||
const { data } = await axiosClient.put(url, body);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
import { CanvasPage } from "@/models/canvas/pages/canvasPageModel";
|
||||
import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
|
||||
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
|
||||
import { markdownToHTMLSafe } from "../htmlMarkdownUtils";
|
||||
import { axiosClient } from "../axiosUtils";
|
||||
import { rateLimitAwareDelete } from "./canvasWebRequestor";
|
||||
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
|
||||
|
||||
export const canvasPageService = {
|
||||
async getAll(courseId: number): Promise<CanvasPage[]> {
|
||||
console.log("requesting pages");
|
||||
try {
|
||||
const url = `${canvasApi}/courses/${courseId}/pages`;
|
||||
const pages = await paginatedRequest<CanvasPage[]>({
|
||||
url,
|
||||
});
|
||||
return pages.flatMap((pageList) => pageList);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 403) {
|
||||
console.log(
|
||||
"Canvas API error: 403 Forbidden for pages. Returning empty array."
|
||||
);
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async create(
|
||||
canvasCourseId: number,
|
||||
page: LocalCoursePage,
|
||||
settings: LocalCourseSettings
|
||||
): Promise<CanvasPage> {
|
||||
console.log(`Creating course page: ${page.name}`);
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/pages`;
|
||||
const body = {
|
||||
wiki_page: {
|
||||
title: page.name,
|
||||
body: markdownToHTMLSafe(page.text, settings),
|
||||
},
|
||||
};
|
||||
|
||||
const { data: canvasPage } = await axiosClient.post<CanvasPage>(url, body);
|
||||
if (!canvasPage) {
|
||||
throw new Error("Created canvas course page was null");
|
||||
}
|
||||
return canvasPage;
|
||||
},
|
||||
|
||||
async update(
|
||||
courseId: number,
|
||||
canvasPageId: number,
|
||||
page: LocalCoursePage,
|
||||
settings: LocalCourseSettings
|
||||
): Promise<void> {
|
||||
console.log(`Updating course page: ${page.name}`);
|
||||
const url = `${canvasApi}/courses/${courseId}/pages/${canvasPageId}`;
|
||||
const body = {
|
||||
wiki_page: {
|
||||
title: page.name,
|
||||
body: markdownToHTMLSafe(page.text, settings),
|
||||
},
|
||||
};
|
||||
await axiosClient.put(url, body);
|
||||
},
|
||||
|
||||
async delete(courseId: number, canvasPageId: number): Promise<void> {
|
||||
console.log(`Deleting page from canvas ${canvasPageId}`);
|
||||
const url = `${canvasApi}/courses/${courseId}/pages/${canvasPageId}`;
|
||||
await rateLimitAwareDelete(url);
|
||||
},
|
||||
};
|
||||
@@ -1,214 +0,0 @@
|
||||
import { CanvasQuiz } from "@/models/canvas/quizzes/canvasQuizModel";
|
||||
import { axiosClient } from "../axiosUtils";
|
||||
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
|
||||
import { markdownToHTMLSafe } from "../htmlMarkdownUtils";
|
||||
import { getDateFromStringOrThrow } from "@/features/local/utils/timeUtils";
|
||||
import { canvasAssignmentService } from "./canvasAssignmentService";
|
||||
import { CanvasQuizQuestion } from "@/models/canvas/quizzes/canvasQuizQuestionModel";
|
||||
import { escapeMatchingText } from "../utils/questionHtmlUtils";
|
||||
import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
|
||||
import {
|
||||
LocalQuizQuestion,
|
||||
QuestionType,
|
||||
} from "@/features/local/quizzes/models/localQuizQuestion";
|
||||
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
|
||||
|
||||
export const getAnswers = (
|
||||
question: LocalQuizQuestion,
|
||||
settings: LocalCourseSettings
|
||||
) => {
|
||||
if (question.questionType === QuestionType.MATCHING)
|
||||
return question.answers.map((a) => {
|
||||
const text =
|
||||
question.questionType === QuestionType.MATCHING
|
||||
? escapeMatchingText(a.text)
|
||||
: a.text;
|
||||
return {
|
||||
answer_match_left: text,
|
||||
answer_match_right: a.matchedText,
|
||||
};
|
||||
});
|
||||
|
||||
return question.answers.map((answer) => ({
|
||||
answer_html: markdownToHTMLSafe(answer.text, settings),
|
||||
answer_weight: answer.correct ? 100 : 0,
|
||||
answer_text: answer.text,
|
||||
}));
|
||||
};
|
||||
|
||||
export const getQuestionType = (question: LocalQuizQuestion) => {
|
||||
return `${question.questionType.replace("=", "")}_question`;
|
||||
};
|
||||
|
||||
const createQuestionOnly = async (
|
||||
canvasCourseId: number,
|
||||
canvasQuizId: number,
|
||||
question: LocalQuizQuestion,
|
||||
position: number,
|
||||
settings: LocalCourseSettings
|
||||
) => {
|
||||
console.log("Creating individual question"); //, question);
|
||||
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}/questions`;
|
||||
|
||||
const body = {
|
||||
question: {
|
||||
question_text: markdownToHTMLSafe(question.text, settings),
|
||||
question_type: getQuestionType(question),
|
||||
points_possible: question.points,
|
||||
position,
|
||||
answers: getAnswers(question, settings),
|
||||
},
|
||||
};
|
||||
|
||||
const response = await axiosClient.post<CanvasQuizQuestion>(url, body);
|
||||
const newQuestion = response.data;
|
||||
|
||||
if (!newQuestion) throw new Error("Created question is null");
|
||||
|
||||
return { question: newQuestion, position };
|
||||
};
|
||||
|
||||
const hackFixQuestionOrdering = async (
|
||||
canvasCourseId: number,
|
||||
canvasQuizId: number,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
questionAndPositions: Array<{ question: any; position: number }>
|
||||
) => {
|
||||
console.log("Fixing question order");
|
||||
|
||||
const order = questionAndPositions.map((qp) => ({
|
||||
type: "question",
|
||||
id: qp.question.id.toString(),
|
||||
}));
|
||||
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}/reorder`;
|
||||
await axiosClient.post(url, { order });
|
||||
};
|
||||
|
||||
const hackFixRedundantAssignments = async (canvasCourseId: number) => {
|
||||
console.log("hack fixing redundant quiz assignments that are auto-created");
|
||||
const assignments = await canvasAssignmentService.getAll(canvasCourseId);
|
||||
const assignmentsToDelete = assignments.filter(
|
||||
(assignment) =>
|
||||
!assignment.is_quiz_assignment &&
|
||||
assignment.submission_types.includes("online_quiz")
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
assignmentsToDelete.map(
|
||||
async (assignment) =>
|
||||
await canvasAssignmentService.delete(
|
||||
canvasCourseId,
|
||||
assignment.id,
|
||||
assignment.name
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
console.log(`Deleted ${assignmentsToDelete.length} redundant assignments`);
|
||||
};
|
||||
|
||||
const createQuizQuestions = async (
|
||||
canvasCourseId: number,
|
||||
canvasQuizId: number,
|
||||
localQuiz: LocalQuiz,
|
||||
settings: LocalCourseSettings
|
||||
) => {
|
||||
console.log("Creating quiz questions"); //, localQuiz);
|
||||
|
||||
const tasks = localQuiz.questions.map(
|
||||
async (question, index) =>
|
||||
await createQuestionOnly(
|
||||
canvasCourseId,
|
||||
canvasQuizId,
|
||||
question,
|
||||
index,
|
||||
settings
|
||||
)
|
||||
);
|
||||
const questionAndPositions = await Promise.all(tasks);
|
||||
await hackFixQuestionOrdering(
|
||||
canvasCourseId,
|
||||
canvasQuizId,
|
||||
questionAndPositions
|
||||
);
|
||||
await hackFixRedundantAssignments(canvasCourseId);
|
||||
};
|
||||
|
||||
export const canvasQuizService = {
|
||||
async getAll(canvasCourseId: number): Promise<CanvasQuiz[]> {
|
||||
try {
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes`;
|
||||
const quizzes = await paginatedRequest<CanvasQuiz[]>({ url });
|
||||
return quizzes.map((quiz) => ({
|
||||
...quiz,
|
||||
due_at: quiz.due_at
|
||||
? new Date(quiz.due_at).toLocaleString()
|
||||
: undefined,
|
||||
lock_at: quiz.lock_at
|
||||
? new Date(quiz.lock_at).toLocaleString()
|
||||
: undefined,
|
||||
}));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 403) {
|
||||
console.log(
|
||||
"Canvas API error: 403 Forbidden for quizzes. Returning empty array."
|
||||
);
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async create(
|
||||
canvasCourseId: number,
|
||||
localQuiz: LocalQuiz,
|
||||
settings: LocalCourseSettings,
|
||||
canvasAssignmentGroupId?: number
|
||||
) {
|
||||
console.log("Creating quiz", localQuiz);
|
||||
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes`;
|
||||
|
||||
const body = {
|
||||
quiz: {
|
||||
title: localQuiz.name,
|
||||
description: markdownToHTMLSafe(localQuiz.description, settings),
|
||||
shuffle_answers: localQuiz.shuffleAnswers,
|
||||
access_code: localQuiz.password,
|
||||
show_correct_answers: localQuiz.showCorrectAnswers,
|
||||
allowed_attempts: localQuiz.allowedAttempts,
|
||||
one_question_at_a_time: localQuiz.oneQuestionAtATime,
|
||||
cant_go_back: false,
|
||||
due_at: localQuiz.dueAt
|
||||
? getDateFromStringOrThrow(
|
||||
localQuiz.dueAt,
|
||||
"creating quiz"
|
||||
).toISOString()
|
||||
: undefined,
|
||||
lock_at: localQuiz.lockAt
|
||||
? getDateFromStringOrThrow(
|
||||
localQuiz.lockAt,
|
||||
"creating quiz"
|
||||
).toISOString()
|
||||
: undefined,
|
||||
assignment_group_id: canvasAssignmentGroupId,
|
||||
},
|
||||
};
|
||||
|
||||
const { data: canvasQuiz } = await axiosClient.post<CanvasQuiz>(url, body);
|
||||
await createQuizQuestions(
|
||||
canvasCourseId,
|
||||
canvasQuiz.id,
|
||||
localQuiz,
|
||||
settings
|
||||
);
|
||||
return canvasQuiz.id;
|
||||
},
|
||||
async delete(canvasCourseId: number, canvasQuizId: number) {
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}`;
|
||||
await axiosClient.delete(url);
|
||||
},
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import { RubricItem } from "@/features/local/assignments/models/rubricItem";
|
||||
|
||||
export const getRubricCriterion = (rubric: RubricItem[]) => {
|
||||
const criterion = rubric
|
||||
.map((rubricItem) => ({
|
||||
description: rubricItem.label,
|
||||
points: rubricItem.points,
|
||||
ratings: {
|
||||
0: { description: "Full Marks", points: rubricItem.points },
|
||||
1: { description: "No Marks", points: 0 },
|
||||
},
|
||||
}))
|
||||
.reduce((acc, item, index) => {
|
||||
return {
|
||||
...acc,
|
||||
[index]: item,
|
||||
};
|
||||
}, {} as { [key: number]: { description: string; points: number; ratings: { [key: number]: { description: string; points: number } } } });
|
||||
|
||||
return criterion;
|
||||
};
|
||||
@@ -1,67 +0,0 @@
|
||||
import { CanvasEnrollmentTermModel } from "@/models/canvas/enrollmentTerms/canvasEnrollmentTermModel";
|
||||
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
|
||||
import { CanvasCourseModel } from "@/models/canvas/courses/canvasCourseModel";
|
||||
import { axiosClient } from "../axiosUtils";
|
||||
import { CanvasCourseStudentModel } from "@/models/canvas/courses/canvasCourseStudentModel";
|
||||
|
||||
const getAllTerms = async () => {
|
||||
const url = `${canvasApi}/accounts/10/terms?per_page=100`;
|
||||
const data = await paginatedRequest<
|
||||
{
|
||||
enrollment_terms: CanvasEnrollmentTermModel[];
|
||||
}[]
|
||||
>({ url });
|
||||
const terms = data.flatMap((t) => t.enrollment_terms);
|
||||
return terms;
|
||||
};
|
||||
|
||||
export const canvasService = {
|
||||
getAllTerms,
|
||||
async getCourses(termId: number) {
|
||||
const url = `${canvasApi}/courses?per_page=100`;
|
||||
const allCourses = await paginatedRequest<CanvasCourseModel[]>({ url });
|
||||
const coursesInTerm = allCourses
|
||||
.flatMap((l) => l)
|
||||
.filter((c) => c.enrollment_term_id === termId);
|
||||
return coursesInTerm;
|
||||
},
|
||||
|
||||
async getCourse(courseId: number): Promise<CanvasCourseModel> {
|
||||
const url = `${canvasApi}/courses/${courseId}`;
|
||||
const { data } = await axiosClient.get<CanvasCourseModel>(url);
|
||||
return data;
|
||||
},
|
||||
|
||||
async getCurrentTermsFor(queryDate: Date = new Date()) {
|
||||
const terms = await getAllTerms();
|
||||
const currentTerms = terms
|
||||
.filter(
|
||||
(t) =>
|
||||
t.end_at &&
|
||||
new Date(t.end_at) > queryDate &&
|
||||
new Date(t.end_at) <
|
||||
new Date(queryDate.setFullYear(queryDate.getFullYear() + 1))
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.start_at ?? "").getTime() -
|
||||
new Date(b.start_at ?? "").getTime()
|
||||
)
|
||||
.slice(0, 3);
|
||||
|
||||
return currentTerms;
|
||||
},
|
||||
|
||||
async getEnrolledStudents(canvasCourseId: number) {
|
||||
console.log(`Getting students for course ${canvasCourseId}`);
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/users?enrollment_type=student`;
|
||||
const data = await paginatedRequest<CanvasCourseStudentModel[]>({url});
|
||||
|
||||
if (!data)
|
||||
throw new Error(
|
||||
`Something went wrong getting enrollments for ${canvasCourseId}`
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
// services/canvasServiceUtils.ts
|
||||
|
||||
import { AxiosResponseHeaders, RawAxiosResponseHeaders } from "axios";
|
||||
import { axiosClient } from "../axiosUtils";
|
||||
|
||||
export const baseCanvasUrl = "https://snow.instructure.com";
|
||||
export const canvasApi = baseCanvasUrl + "/api/v1";
|
||||
|
||||
const getNextUrl = (
|
||||
headers: AxiosResponseHeaders | RawAxiosResponseHeaders
|
||||
): string | undefined => {
|
||||
const linkHeader: string | undefined =
|
||||
typeof headers.get === "function"
|
||||
? (headers.get("link") as string)
|
||||
: ((headers as RawAxiosResponseHeaders)["link"] as string);
|
||||
|
||||
if (!linkHeader) {
|
||||
console.log("could not find link header in the response");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const links = linkHeader.split(",").map((link) => link.trim());
|
||||
const nextLink = links.find((link) => link.includes('rel="next"'));
|
||||
|
||||
if (!nextLink) {
|
||||
// console.log(
|
||||
// "could not find next url in link header, reached end of pagination"
|
||||
// );
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nextUrl = nextLink.split(";")[0].trim().slice(1, -1);
|
||||
return nextUrl;
|
||||
};
|
||||
|
||||
export async function paginatedRequest<T extends unknown[]>(request: {
|
||||
url: string;
|
||||
}): Promise<T> {
|
||||
let requestCount = 1;
|
||||
const url = new URL(request.url);
|
||||
url.searchParams.set("per_page", "100");
|
||||
|
||||
const { data: firstData, headers: firstHeaders } = await axiosClient.get<T>(
|
||||
url.toString()
|
||||
);
|
||||
|
||||
let returnData = Array.isArray(firstData) ? [...firstData] : [firstData]; // terms come across as nested objects {enrolmentTerms: terms[]}
|
||||
let nextUrl = getNextUrl(firstHeaders);
|
||||
|
||||
while (nextUrl) {
|
||||
requestCount += 1;
|
||||
const { data, headers } = await axiosClient.get<T>(nextUrl);
|
||||
if (data) {
|
||||
returnData = returnData.concat(Array.isArray(data) ? [...data] : [data]);
|
||||
}
|
||||
nextUrl = getNextUrl(headers);
|
||||
}
|
||||
|
||||
if (requestCount > 1) {
|
||||
console.log(
|
||||
`Requesting ${typeof returnData} took ${requestCount} requests`
|
||||
);
|
||||
}
|
||||
|
||||
return returnData as T;
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { AxiosResponse } from "axios";
|
||||
import { axiosClient } from "../axiosUtils";
|
||||
|
||||
const rateLimitRetryCount = 6;
|
||||
const rateLimitSleepInterval = 1000;
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export const isRateLimited = async (
|
||||
response: AxiosResponse
|
||||
): Promise<boolean> => {
|
||||
const content = await response.data;
|
||||
return (
|
||||
response.status === 403 &&
|
||||
content.includes("403 Forbidden (Rate Limit Exceeded)")
|
||||
);
|
||||
};
|
||||
|
||||
// const rateLimitAwarePost = async (url: string, body: unknown, retryCount = 0) => {
|
||||
// const response = await axiosClient.post(url, body);
|
||||
|
||||
// if (await isRateLimited(response)) {
|
||||
// if (retryCount < rateLimitRetryCount) {
|
||||
// console.info(
|
||||
// `Hit rate limit on post, retry count is ${retryCount} / ${rateLimitRetryCount}, retrying`
|
||||
// );
|
||||
// await sleep(rateLimitSleepInterval);
|
||||
// return await rateLimitAwarePost(url, body, retryCount + 1);
|
||||
// }
|
||||
// }
|
||||
|
||||
// return response;
|
||||
// };
|
||||
|
||||
export const rateLimitAwareDelete = async (
|
||||
url: string,
|
||||
retryCount = 0
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const response = await axiosClient.delete(url);
|
||||
|
||||
if (await isRateLimited(response)) {
|
||||
console.info("After delete response in rate limited");
|
||||
await sleep(rateLimitSleepInterval);
|
||||
return await rateLimitAwareDelete(url, retryCount + 1);
|
||||
}
|
||||
} catch (e) {
|
||||
const error = e as Error & { response?: Response };
|
||||
if (error.response?.status === 403) {
|
||||
if (retryCount < rateLimitRetryCount) {
|
||||
console.info(
|
||||
`Hit rate limit in delete, retry count is ${retryCount} / ${rateLimitRetryCount}, retrying`
|
||||
);
|
||||
await sleep(rateLimitSleepInterval);
|
||||
return await rateLimitAwareDelete(url, retryCount + 1);
|
||||
} else {
|
||||
console.info(
|
||||
`Hit rate limit in delete, ${rateLimitRetryCount} retries did not fix it`
|
||||
);
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
@@ -1,102 +0,0 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import axios from "axios";
|
||||
import { canvasApi } from "../canvasServiceUtils";
|
||||
import { axiosClient } from "@/services/axiosUtils";
|
||||
import FormData from "form-data";
|
||||
|
||||
export const downloadUrlToTempDirectory = async (
|
||||
sourceUrl: string
|
||||
): Promise<{fileName: string, success: boolean}> => {
|
||||
try {
|
||||
const fileName =
|
||||
path.basename(new URL(sourceUrl).pathname) || `tempfile-${Date.now()}`;
|
||||
const tempFilePath = path.join("/tmp", fileName);
|
||||
const response = await axios.get(sourceUrl, {
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
await fs.writeFile(tempFilePath, response.data);
|
||||
return {fileName: tempFilePath, success: true};
|
||||
} catch (error) {
|
||||
console.log("Error downloading or saving the file:", sourceUrl, error);
|
||||
return {fileName: sourceUrl, success: false};
|
||||
}
|
||||
};
|
||||
|
||||
const getFileSize = async (pathToFile: string): Promise<number> => {
|
||||
try {
|
||||
const stats = await fs.stat(pathToFile);
|
||||
return stats.size;
|
||||
} catch (error) {
|
||||
console.error("Error reading file size:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadToCanvasPart1 = async (
|
||||
pathToUpload: string,
|
||||
canvasCourseId: number
|
||||
) => {
|
||||
try {
|
||||
const url = `${canvasApi}/courses/${canvasCourseId}/files`;
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append("name", path.basename(pathToUpload));
|
||||
formData.append("size", (await getFileSize(pathToUpload)).toString());
|
||||
|
||||
const response = await axiosClient.post(url, formData);
|
||||
|
||||
const upload_url = response.data.upload_url;
|
||||
const upload_params = response.data.upload_params;
|
||||
|
||||
return { upload_url, upload_params };
|
||||
} catch (error) {
|
||||
console.error("Error uploading file to Canvas part 1:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadToCanvasPart2 = async ({
|
||||
pathToUpload,
|
||||
upload_url,
|
||||
upload_params,
|
||||
}: {
|
||||
pathToUpload: string;
|
||||
upload_url: string;
|
||||
upload_params: { [key: string]: string };
|
||||
}) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
Object.keys(upload_params).forEach((key) => {
|
||||
formData.append(key, upload_params[key]);
|
||||
});
|
||||
|
||||
const fileBuffer = await fs.readFile(pathToUpload);
|
||||
const fileName = path.basename(pathToUpload);
|
||||
formData.append("file", fileBuffer, fileName);
|
||||
|
||||
const response = await axiosClient.post(upload_url, formData, {
|
||||
headers: formData.getHeaders(),
|
||||
validateStatus: (status) => status < 500,
|
||||
});
|
||||
|
||||
if (response.status === 301) {
|
||||
const redirectUrl = response.headers.location;
|
||||
if (!redirectUrl) {
|
||||
throw new Error(
|
||||
"Redirect URL not provided in the Location header on redirect from second part of canvas file upload"
|
||||
);
|
||||
}
|
||||
|
||||
const redirectResponse = await axiosClient.get(redirectUrl);
|
||||
console.log("redirect response", redirectResponse.data);
|
||||
}
|
||||
// console.log("returning from part 2", JSON.stringify(response.data));
|
||||
return response.data.url;
|
||||
} catch (error) {
|
||||
console.error("Error uploading file to Canvas part 2:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
import { RubricItem } from "@/features/local/assignments/models/rubricItem";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getRubricCriterion } from "./canvasRubricUtils";
|
||||
import { assignmentPoints } from "@/features/local/assignments/models/utils/assignmentPointsUtils";
|
||||
|
||||
describe("can prepare rubric for canvas", () => {
|
||||
it("can parse normal rubric into criterion", () => {
|
||||
const rubric: RubricItem[] = [
|
||||
{
|
||||
label: "first",
|
||||
points: 1,
|
||||
},
|
||||
{
|
||||
label: "second",
|
||||
points: 2,
|
||||
},
|
||||
];
|
||||
const criterion = getRubricCriterion(rubric);
|
||||
|
||||
expect(criterion).toStrictEqual({
|
||||
0: {
|
||||
description: "first",
|
||||
points: 1,
|
||||
ratings: {
|
||||
0: { description: "Full Marks", points: 1 },
|
||||
1: { description: "No Marks", points: 0 },
|
||||
},
|
||||
},
|
||||
1: {
|
||||
description: "second",
|
||||
points: 2,
|
||||
ratings: {
|
||||
0: { description: "Full Marks", points: 2 },
|
||||
1: { description: "No Marks", points: 0 },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("can parse negative rubric into criterion", () => {
|
||||
const rubric: RubricItem[] = [
|
||||
{
|
||||
label: "first",
|
||||
points: 1,
|
||||
},
|
||||
{
|
||||
label: "second",
|
||||
points: -2,
|
||||
},
|
||||
];
|
||||
const criterion = getRubricCriterion(rubric);
|
||||
|
||||
expect(criterion).toStrictEqual({
|
||||
0: {
|
||||
description: "first",
|
||||
points: 1,
|
||||
ratings: {
|
||||
0: { description: "Full Marks", points: 1 },
|
||||
1: { description: "No Marks", points: 0 },
|
||||
},
|
||||
},
|
||||
1: {
|
||||
description: "second",
|
||||
points: -2,
|
||||
ratings: {
|
||||
0: { description: "Full Marks", points: -2 },
|
||||
1: { description: "No Marks", points: 0 },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("negative rubric items do not contribute to the total", () => {
|
||||
const rubric: RubricItem[] = [
|
||||
{
|
||||
label: "first",
|
||||
points: 1,
|
||||
},
|
||||
{
|
||||
label: "second",
|
||||
points: -2,
|
||||
},
|
||||
{
|
||||
label: "second",
|
||||
points: 3,
|
||||
},
|
||||
];
|
||||
const points = assignmentPoints(rubric);
|
||||
expect(points).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { pageRouter } from "../../features/local/pages/pageRouter";
|
||||
import { quizRouter } from "../../features/local/quizzes/quizRouter";
|
||||
import { settingsRouter } from "../../features/local/course/settingsRouter";
|
||||
import { moduleRouter } from "@/features/local/modules/moduleRouter";
|
||||
import { canvasFileRouter } from "../canvas/canvasFileRouter";
|
||||
import { canvasFileRouter } from "@/features/canvas/services/canvasFileRouter";
|
||||
|
||||
export const trpcAppRouter = router({
|
||||
assignment: assignmentRouter,
|
||||
|
||||
Reference in New Issue
Block a user