refactoring canvas files

This commit is contained in:
2025-07-23 11:40:18 -06:00
parent 815f929c2d
commit 99f491f16e
67 changed files with 94 additions and 108 deletions

View File

@@ -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/";

View File

@@ -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);
},
};

View File

@@ -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
);
};

View File

@@ -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;
}),
});

View File

@@ -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;
},
};

View File

@@ -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;
},
};

View File

@@ -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);
},
};

View File

@@ -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);
},
};

View File

@@ -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;
};

View File

@@ -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;
},
};

View File

@@ -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;
}

View File

@@ -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;
}
};

View File

@@ -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;
}
};

View File

@@ -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);
});
});

View File

@@ -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,