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

@@ -0,0 +1,129 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
import {
useAddCanvasModuleMutation,
useCanvasModulesQuery,
} from "./canvasModuleHooks";
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { canvasModuleService } from "../services/canvasModuleService";
import { canvasAssignmentService } from "../services/canvasAssignmentService";
export const canvasAssignmentKeys = {
assignments: (canvasCourseId: number) =>
["canvas", canvasCourseId, "assignments"] as const,
};
export const useCanvasAssignmentsQuery = () => {
const { data: settings } = useLocalCourseSettingsQuery();
return useQuery({
queryKey: canvasAssignmentKeys.assignments(settings.canvasId),
queryFn: async () => canvasAssignmentService.getAll(settings.canvasId),
});
};
export const useAddAssignmentToCanvasMutation = () => {
const { data: settings } = useLocalCourseSettingsQuery();
const { data: canvasModules } = useCanvasModulesQuery();
const addModule = useAddCanvasModuleMutation();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
assignment,
moduleName,
}: {
assignment: LocalAssignment;
moduleName: string;
}) => {
if (!canvasModules) {
// console.log("cannot add assignment until modules loaded");
throw new Error("cannot add assignment until modules loaded");
}
const assignmentGroup = settings.assignmentGroups.find(
(g) => g.name === assignment.localAssignmentGroupName
);
const canvasAssignmentId = await canvasAssignmentService.create(
settings.canvasId,
assignment,
settings,
assignmentGroup?.canvasId
);
const canvasModule = canvasModules.find((c) => c.name === moduleName);
const moduleId = canvasModule
? canvasModule.id
: await addModule.mutateAsync(moduleName);
await canvasModuleService.createModuleItem(
settings.canvasId,
moduleId,
assignment.name,
"Assignment",
canvasAssignmentId
);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: canvasAssignmentKeys.assignments(settings.canvasId),
});
},
});
};
export const useUpdateAssignmentInCanvasMutation = () => {
const { data: settings } = useLocalCourseSettingsQuery();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
assignment,
canvasAssignmentId,
}: {
assignment: LocalAssignment;
canvasAssignmentId: number;
}) => {
const assignmentGroup = settings.assignmentGroups.find(
(g) => g.name === assignment.localAssignmentGroupName
);
await canvasAssignmentService.update(
settings.canvasId,
canvasAssignmentId,
assignment,
settings,
assignmentGroup?.canvasId
);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: canvasAssignmentKeys.assignments(settings.canvasId),
});
},
});
};
export const useDeleteAssignmentFromCanvasMutation = () => {
const { data: settings } = useLocalCourseSettingsQuery();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
canvasAssignmentId,
assignmentName,
}: {
canvasAssignmentId: number;
assignmentName: string;
}) => {
await canvasAssignmentService.delete(
settings.canvasId,
canvasAssignmentId,
assignmentName
);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: canvasAssignmentKeys.assignments(settings.canvasId),
});
},
});
};

View File

@@ -0,0 +1,106 @@
import { CanvasAssignmentGroup } from "@/features/canvas/models/assignments/canvasAssignmentGroup";
import { CanvasCourseModel } from "@/features/canvas/models/courses/canvasCourseModel";
import { LocalAssignmentGroup } from "@/features/local/assignments/models/localAssignmentGroup";
import { useMutation, useQuery } from "@tanstack/react-query";
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
import { useUpdateLocalCourseSettingsMutation } from "@/features/local/course/localCoursesHooks";
import { canvasAssignmentGroupService } from "../services/canvasAssignmentGroupService";
import { canvasService } from "../services/canvasService";
export const canvasCourseKeys = {
courseDetails: (canavasId: number) =>
["canvas", canavasId, "course details"] as const,
assignmentGroups: (canavasId: number) =>
["canvas", canavasId, "assignment groups"] as const,
courseListInTerm: (canvasTermId: number | undefined) =>
["canvas courses in term", canvasTermId] as const,
students: (canvasId: number) =>
["students in canvas course", canvasId] as const,
};
export const useCourseListInTermQuery = (canvasTermId: number | undefined) =>
useQuery({
queryKey: canvasCourseKeys.courseListInTerm(canvasTermId),
queryFn: async (): Promise<CanvasCourseModel[]> =>
canvasTermId ? await canvasService.getCourses(canvasTermId) : [],
enabled: !!canvasTermId,
});
export const useSetAssignmentGroupsMutation = (canvasId: number) => {
const updateSettingsMutation = useUpdateLocalCourseSettingsMutation();
const { data: canvasAssignmentGroups } = useAssignmentGroupsQuery(canvasId);
return useMutation({
mutationFn: async (settings: LocalCourseSettings) => {
if (typeof canvasAssignmentGroups === "undefined") {
console.log("cannot apply groups if no groups loaded");
return;
}
const localAssignmentGroups = settings.assignmentGroups;
const localNames = localAssignmentGroups.map((g) => g.name);
const groupsToDelete = canvasAssignmentGroups.filter(
(c: CanvasAssignmentGroup) => !localNames.includes(c.name)
);
await Promise.all(
groupsToDelete.map(
async (g: CanvasAssignmentGroup) =>
await canvasAssignmentGroupService.delete(canvasId, g.id, g.name)
)
);
const updatedGroups = await Promise.all(
localAssignmentGroups.map(
async (group): Promise<LocalAssignmentGroup> => {
const canvasGroup = canvasAssignmentGroups.find(
(c: CanvasAssignmentGroup) => c.name === group.name
);
if (!canvasGroup) {
const newGroup = await canvasAssignmentGroupService.create(
canvasId,
group
);
return {
...group,
canvasId: newGroup.canvasId,
};
} else {
const groupWithCanvasId = {
...group,
canvasId: canvasGroup.id,
};
if (canvasGroup.group_weight !== group.weight) {
await canvasAssignmentGroupService.update(
canvasId,
groupWithCanvasId
);
}
return groupWithCanvasId;
}
}
)
);
const updatedSettings: LocalCourseSettings = {
...settings,
assignmentGroups: updatedGroups,
};
await updateSettingsMutation.mutateAsync({ settings: updatedSettings });
return updatedSettings;
},
});
};
export const useAssignmentGroupsQuery = (canvasId: number) => {
return useQuery({
queryKey: canvasCourseKeys.assignmentGroups(canvasId),
queryFn: async (): Promise<CanvasAssignmentGroup[]> =>
await canvasAssignmentGroupService.getAll(canvasId),
});
};
export const useCourseStudentsQuery = (canvasId: number) =>
useQuery({
queryKey: canvasCourseKeys.students(canvasId),
queryFn: async () => await canvasService.getEnrolledStudents(canvasId),
});

View File

@@ -0,0 +1,38 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { canvasService } from "../services/canvasService";
export const canvasKeys = {
allTerms: ["all canvas terms"] as const,
allAroundDate: (date: Date) => ["all canvas terms", date] as const,
};
export const useAllCanvasTermsQuery = () =>
useSuspenseQuery({
queryKey: canvasKeys.allTerms,
queryFn: canvasService.getAllTerms,
});
export const useCanvasTermsQuery = (queryDate: Date) => {
const { data: terms } = useAllCanvasTermsQuery();
return useSuspenseQuery({
queryKey: canvasKeys.allAroundDate(queryDate),
queryFn: () => {
const finiteTerms = terms.filter((t) => {
if (!t.end_at) return false;
const endDate = new Date(t.end_at);
return endDate > queryDate;
});
console.log("finite terms", finiteTerms, terms);
const currentTerms = finiteTerms
.sort(
(a, b) =>
new Date(a.start_at ?? "").getTime() -
new Date(b.start_at ?? "").getTime()
)
.slice(0, 3);
return currentTerms;
},
});
};

View File

@@ -0,0 +1,30 @@
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { canvasModuleService } from "../services/canvasModuleService";
export const canvasCourseModuleKeys = {
modules: (canvasId: number) => ["canvas", canvasId, "module list"] as const,
};
export const useCanvasModulesQuery = () => {
const { data: settings } = useLocalCourseSettingsQuery();
return useQuery({
queryKey: canvasCourseModuleKeys.modules(settings.canvasId),
queryFn: async () =>
await canvasModuleService.getCourseModules(settings.canvasId),
});
};
export const useAddCanvasModuleMutation = () => {
const { data: settings } = useLocalCourseSettingsQuery();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (moduleName: string) =>
await canvasModuleService.createModule(settings.canvasId, moduleName),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: canvasCourseModuleKeys.modules(settings.canvasId),
});
},
});
};

View File

@@ -0,0 +1,42 @@
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { canvasNavigationService } from "../services/canvasNavigationService";
export const canvasCourseTabKeys = {
tabs: (canvasId: number) => ["canvas", canvasId, "tabs list"] as const,
};
export const useCanvasTabsQuery = () => {
const { data: settings } = useLocalCourseSettingsQuery();
return useQuery({
queryKey: canvasCourseTabKeys.tabs(settings.canvasId),
queryFn: async () =>
await canvasNavigationService.getCourseTabs(settings.canvasId),
});
};
export const useUpdateCanvasTabMutation = () => {
const { data: settings } = useLocalCourseSettingsQuery();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
tabId,
hidden,
position,
}: {
tabId: string;
hidden?: boolean;
position?: number;
}) =>
await canvasNavigationService.updateCourseTab(settings.canvasId, tabId, {
hidden,
position,
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: canvasCourseTabKeys.tabs(settings.canvasId),
refetchType: "all",
});
},
});
};

View File

@@ -0,0 +1,104 @@
import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
useCanvasModulesQuery,
useAddCanvasModuleMutation,
} from "./canvasModuleHooks";
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { canvasModuleService } from "../services/canvasModuleService";
import { canvasPageService } from "../services/canvasPageService";
export const canvasPageKeys = {
pagesInCourse: (courseCanvasId: number) => [
"canvas",
courseCanvasId,
"pages",
],
};
export const useCanvasPagesQuery = () => {
const { data: settings } = useLocalCourseSettingsQuery();
return useQuery({
queryKey: canvasPageKeys.pagesInCourse(settings.canvasId),
queryFn: async () => await canvasPageService.getAll(settings.canvasId),
});
};
export const useCreateCanvasPageMutation = () => {
const { data: settings } = useLocalCourseSettingsQuery();
const queryClient = useQueryClient();
const { data: canvasModules } = useCanvasModulesQuery();
const addModule = useAddCanvasModuleMutation();
return useMutation({
mutationFn: async ({
page,
moduleName,
}: {
page: LocalCoursePage;
moduleName: string;
}) => {
if (!canvasModules) {
console.log("cannot add page until modules loaded");
return;
}
const canvasPage = await canvasPageService.create(
settings.canvasId,
page,
settings
);
const canvasModule = canvasModules.find((c) => c.name === moduleName);
const moduleId = canvasModule
? canvasModule.id
: await addModule.mutateAsync(moduleName);
await canvasModuleService.createPageModuleItem(
settings.canvasId,
moduleId,
page.name,
canvasPage
);
return canvasPage;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: canvasPageKeys.pagesInCourse(settings.canvasId),
});
},
});
};
export const useUpdateCanvasPageMutation = () => {
const { data: settings } = useLocalCourseSettingsQuery();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
page,
canvasPageId,
}: {
page: LocalCoursePage;
canvasPageId: number;
}) =>
canvasPageService.update(settings.canvasId, canvasPageId, page, settings),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: canvasPageKeys.pagesInCourse(settings.canvasId),
});
},
});
};
export const useDeleteCanvasPageMutation = () => {
const { data: settings } = useLocalCourseSettingsQuery();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (canvasPageId: number) =>
canvasPageService.delete(settings.canvasId, canvasPageId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: canvasPageKeys.pagesInCourse(settings.canvasId),
});
},
});
};

View File

@@ -0,0 +1,87 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
useAddCanvasModuleMutation,
useCanvasModulesQuery,
} from "./canvasModuleHooks";
import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { canvasModuleService } from "../services/canvasModuleService";
import { canvasQuizService } from "../services/canvasQuizService";
export const canvasQuizKeys = {
quizzes: (canvasCourseId: number) =>
["canvas", canvasCourseId, "quizzes"] as const,
};
export const useCanvasQuizzesQuery = () => {
const { data: settings } = useLocalCourseSettingsQuery();
return useQuery({
queryKey: canvasQuizKeys.quizzes(settings.canvasId),
queryFn: async () => canvasQuizService.getAll(settings.canvasId),
});
};
export const useAddQuizToCanvasMutation = () => {
const { data: settings } = useLocalCourseSettingsQuery();
const queryClient = useQueryClient();
const { data: canvasModules } = useCanvasModulesQuery();
const addModule = useAddCanvasModuleMutation();
return useMutation({
mutationFn: async ({
quiz,
moduleName,
}: {
quiz: LocalQuiz;
moduleName: string;
}) => {
if (!canvasModules) {
console.log("cannot add quiz until modules loaded");
return;
}
const assignmentGroup = settings.assignmentGroups.find(
(g) => g.name === quiz.localAssignmentGroupName
);
const canvasQuizId = await canvasQuizService.create(
settings.canvasId,
quiz,
settings,
assignmentGroup?.canvasId
);
const canvasModule = canvasModules.find((c) => c.name === moduleName);
const moduleId = canvasModule
? canvasModule.id
: await addModule.mutateAsync(moduleName);
await canvasModuleService.createModuleItem(
settings.canvasId,
moduleId,
quiz.name,
"Quiz",
canvasQuizId
);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: canvasQuizKeys.quizzes(settings.canvasId),
});
},
});
};
export const useDeleteQuizFromCanvasMutation = () => {
const { data: settings } = useLocalCourseSettingsQuery();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (canvasQuizId: number) => {
await canvasQuizService.delete(settings.canvasId, canvasQuizId);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: canvasQuizKeys.quizzes(settings.canvasId),
});
},
});
};

View File

@@ -0,0 +1,82 @@
import { CanvasDiscussionTopicModel } from "../discussions/canvasDiscussionModelTopic";
import { CanvasSubmissionModel } from "../submissions/canvasSubmissionModel";
import { CanvasAssignmentDate } from "./canvasAssignmentDate";
import { CanvasAssignmentOverride } from "./canvasAssignmentOverride";
import { CanvasExternalToolTagAttributes } from "./canvasExternalToolTagAttributes";
import { CanvasLockInfo } from "./canvasLockInfo";
import { CanvasRubricCriteria } from "./canvasRubricCriteria";
import { CanvasTurnitinSettings } from "./canvasTurnitinSettings";
export interface CanvasAssignment {
id: number;
name: string;
description: string;
created_at: string; // ISO 8601 date string
has_overrides: boolean;
course_id: number;
html_url: string;
submissions_download_url: string;
assignment_group_id: number;
due_date_required: boolean;
max_name_length: number;
peer_reviews: boolean;
automatic_peer_reviews: boolean;
position: number;
grading_type: string;
published: boolean;
unpublishable: boolean;
only_visible_to_overrides: boolean;
locked_for_user: boolean;
moderated_grading: boolean;
grader_count: number;
allowed_attempts: number;
is_quiz_assignment: boolean;
submission_types: string[];
updated_at?: string; // ISO 8601 date string
due_at?: string; // ISO 8601 date string
lock_at?: string; // ISO 8601 date string
unlock_at?: string; // ISO 8601 date string
all_dates?: CanvasAssignmentDate[];
allowed_extensions?: string[];
turnitin_enabled?: boolean;
vericite_enabled?: boolean;
turnitin_settings?: CanvasTurnitinSettings;
grade_group_students_individually?: boolean;
external_tool_tag_attributes?: CanvasExternalToolTagAttributes;
peer_review_count?: number;
peer_reviews_assign_at?: string; // ISO 8601 date string
intra_group_peer_reviews?: boolean;
group_category_id?: number;
needs_grading_count?: number;
needs_grading_count_by_section?: {
section_id: string;
needs_grading_count: number;
}[];
post_to_sis?: boolean;
integration_id?: string;
integration_data?: unknown;
muted?: boolean;
points_possible?: number;
has_submitted_submissions?: boolean;
grading_standard_id?: number;
lock_info?: CanvasLockInfo;
lock_explanation?: string;
quiz_id?: number;
anonymous_submissions?: boolean;
discussion_topic?: CanvasDiscussionTopicModel;
freeze_on_copy?: boolean;
frozen?: boolean;
frozen_attributes?: string[];
submission?: CanvasSubmissionModel;
use_rubric_for_grading?: boolean;
rubric_settings?: unknown;
rubric?: CanvasRubricCriteria[];
assignment_visibility?: number[];
overrides?: CanvasAssignmentOverride[];
omit_from_final_grade?: boolean;
final_grader_id?: number;
grader_comments_visible_to_graders?: boolean;
graders_anonymous_to_graders?: boolean;
grader_names_visible_to_final_grader?: boolean;
anonymous_grading?: boolean;
}

View File

@@ -0,0 +1,8 @@
export interface CanvasAssignmentDate {
title: string;
id?: number;
base?: boolean;
due_at?: string; // ISO 8601 date string
unlock_at?: string; // ISO 8601 date string
lock_at?: string; // ISO 8601 date string
}

View File

@@ -0,0 +1,10 @@
export interface CanvasAssignmentGroup {
id: number; // TypeScript doesn't have `ulong`, so using `number` for large integers.
name: string;
position: number;
group_weight: number;
// sis_source_id?: string; // Uncomment if needed.
// integration_data?: Record<string, string>; // Uncomment if needed.
// assignments?: CanvasAssignment[]; // Uncomment if needed, assuming CanvasAssignment is defined.
// rules?: any; // Assuming 'rules' is of unknown type, so using 'any' here.
}

View File

@@ -0,0 +1,13 @@
export interface CanvasAssignmentOverride {
id: number;
assignment_id: number;
course_section_id: number;
title: string;
student_ids?: number[];
group_id?: number;
due_at?: string; // ISO 8601 date string
all_day?: boolean;
all_day_date?: string; // ISO 8601 date string
unlock_at?: string; // ISO 8601 date string
lock_at?: string; // ISO 8601 date string
}

View File

@@ -0,0 +1,5 @@
export interface CanvasExternalToolTagAttributes {
url: string;
resource_link_id: string;
new_tab?: boolean;
}

View File

@@ -0,0 +1,7 @@
export interface CanvasLockInfo {
asset_string: string;
unlock_at?: string; // ISO 8601 date string
lock_at?: string; // ISO 8601 date string
context_module?: unknown;
manually_locked?: boolean;
}

View File

@@ -0,0 +1,13 @@
export interface CanvasRubric {
id?: number;
title: string;
context_id: number;
context_type: string;
points_possible: number;
reusable: boolean;
read_only: boolean;
hide_score_total?: boolean;
// Uncomment and define if needed
// data: CanvasRubricCriteria[];
// free_form_criterion_comments?: boolean;
}

View File

@@ -0,0 +1,12 @@
export interface CanvasRubricAssociation {
id: number;
rubric_id: number;
association_id: number;
association_type: string;
use_for_grading: boolean;
summary_data?: string;
purpose: string;
hide_score_total?: boolean;
hide_points: boolean;
hide_outcome_results: boolean;
}

View File

@@ -0,0 +1,7 @@
import { CanvasRubric } from "./canvasRubric";
import { CanvasRubricAssociation } from "./canvasRubricAssociation";
export interface CanvasRubricCreationResponse {
rubric: CanvasRubric;
rubric_association: CanvasRubricAssociation;
}

View File

@@ -0,0 +1,16 @@
export interface CanvasRubricCriteria {
id: string;
description: string;
long_description: string;
points?: number;
learning_outcome_id?: string;
vendor_guid?: string;
criterion_use_range?: boolean;
ratings?: {
points: number;
id: string;
description: string;
long_description: string;
}[];
ignore_for_scoring?: boolean;
}

View File

@@ -0,0 +1,10 @@
export interface CanvasTurnitinSettings {
originality_report_visibility: string;
s_paper_check: boolean;
internet_check: boolean;
journal_check: boolean;
exclude_biblio: boolean;
exclude_quoted: boolean;
exclude_small_matches_type?: boolean;
exclude_small_matches_value?: number;
}

View File

@@ -0,0 +1,3 @@
export interface CanvasCalendarLinkModel {
ics: string;
}

View File

@@ -0,0 +1,56 @@
import { CanvasEnrollmentModel } from "../enrollments/canvasEnrollmentModel";
import { CanvasCalendarLinkModel } from "./canvasCalendarLinkModel";
import { CanvasCourseProgressModel } from "./canvasCourseProgressModel";
import { CanvasTermModel } from "./canvasTermModel";
export interface CanvasCourseModel {
id: number;
sis_course_id: string;
uuid: string;
integration_id: string;
name: string;
course_code: string;
workflow_state: string;
account_id: number;
root_account_id: number;
enrollment_term_id: number;
created_at: string; // ISO 8601 date string
locale: string;
calendar: CanvasCalendarLinkModel;
default_view: string;
syllabus_body: string;
permissions: { [key: string]: boolean };
storage_quota_mb: number;
storage_quota_used_mb: number;
license: string;
course_format: string;
time_zone: string;
sis_import_id?: number;
grading_standard_id?: number;
start_at?: string; // ISO 8601 date string
end_at?: string; // ISO 8601 date string
enrollments?: CanvasEnrollmentModel[];
total_students?: number;
needs_grading_count?: number;
term?: CanvasTermModel;
course_progress?: CanvasCourseProgressModel;
apply_assignment_group_weights?: boolean;
is_public?: boolean;
is_public_to_auth_users?: boolean;
public_syllabus?: boolean;
public_syllabus_to_auth?: boolean;
public_description?: string;
hide_final_grades?: boolean;
allow_student_assignment_edits?: boolean;
allow_wiki_comments?: boolean;
allow_student_forum_attachments?: boolean;
open_enrollment?: boolean;
self_enrollment?: boolean;
restrict_enrollments_to_course_dates?: boolean;
access_restricted_by_date?: boolean;
blueprint?: boolean;
blueprint_restrictions?: { [key: string]: boolean };
blueprint_restrictions_by_object_type?: {
[key: string]: { [key: string]: boolean };
};
}

View File

@@ -0,0 +1,6 @@
export interface CanvasCourseProgressModel {
requirement_count?: number;
requirement_completed_count?: number;
next_requirement_url?: string;
completed_at?: string; // ISO 8601 date string
}

View File

@@ -0,0 +1,16 @@
export interface CanvasCourseSettingsModel {
allow_final_grade_override: boolean;
allow_student_discussion_topics: boolean;
allow_student_forum_attachments: boolean;
allow_student_discussion_editing: boolean;
grading_standard_enabled: boolean;
allow_student_organized_groups: boolean;
hide_final_grades: boolean;
hide_distribution_graphs: boolean;
lock_all_announcements: boolean;
restrict_student_past_view: boolean;
restrict_student_future_view: boolean;
show_announcements_on_home_page: boolean;
home_page_announcement_limit: number;
grading_standard_id?: number;
}

View File

@@ -0,0 +1,12 @@
export interface CanvasCourseStudentModel {
id: number;
name: string;
created_at: string; // ISO 8601 date string
sortable_name: string;
short_name: string;
sis_user_id: string;
integration_id?: string;
root_account: string;
login_id: string;
email: string;
}

View File

@@ -0,0 +1,6 @@
export interface CanvasTermModel {
id: number;
name: string;
start_at?: string; // ISO 8601 date string
end_at?: string; // ISO 8601 date string
}

View File

@@ -0,0 +1,40 @@
import { CanvasUserDisplayModel } from "../users/userDisplayModel";
import { CanvasFileAttachmentModel } from "./canvasFileAttachmentModel";
export interface CanvasDiscussionTopicModel {
id: number;
title: string;
message: string;
html_url: string;
read_state: string;
subscription_hold: string;
assignment_id: number;
lock_explanation: string;
user_name: string;
topic_children: number[];
podcast_url: string;
discussion_type: string;
attachments: CanvasFileAttachmentModel[];
permissions: { [key: string]: boolean };
author: CanvasUserDisplayModel;
unread_count?: number;
subscribed?: boolean;
posted_at?: string; // ISO 8601 date string
last_reply_at?: string; // ISO 8601 date string
require_initial_post?: boolean;
user_can_see_posts?: boolean;
discussion_subentry_count?: number;
delayed_post_at?: string; // ISO 8601 date string
published?: boolean;
lock_at?: string; // ISO 8601 date string
locked?: boolean;
pinned?: boolean;
locked_for_user?: boolean;
lock_info?: unknown;
group_topic_children?: unknown;
root_topic_id?: number;
group_category_id?: number;
allow_rating?: boolean;
only_graders_can_rate?: boolean;
sort_by_rating?: boolean;
}

View File

@@ -0,0 +1,6 @@
export interface CanvasFileAttachmentModel {
content_type: string;
url: string;
filename: string;
display_name: string;
}

View File

@@ -0,0 +1,16 @@
export interface CanvasEnrollmentTermModel {
id: number;
name: string;
sis_term_id?: string;
sis_import_id?: number;
start_at?: string; // ISO 8601 date string
end_at?: string; // ISO 8601 date string
grading_period_group_id?: number;
workflow_state?: string;
overrides?: {
[key: string]: {
start_at?: string; // ISO 8601 date string
end_at?: string; // ISO 8601 date string
};
};
}

View File

@@ -0,0 +1,48 @@
import { CanvasUserDisplayModel } from "../users/userDisplayModel";
import { CanvasGradeModel } from "./canvasGradeModel";
export interface CanvasEnrollmentModel {
id: number;
course_id: number;
enrollment_state: string;
type: string;
user_id: number;
role: string;
role_id: number;
html_url: string;
grades: CanvasGradeModel;
user: CanvasUserDisplayModel;
override_grade: string;
sis_course_id?: string;
course_integration_id?: string;
course_section_id?: number;
section_integration_id?: string;
sis_account_id?: string;
sis_section_id?: string;
sis_user_id?: string;
limit_privileges_to_course_section?: boolean;
sis_import_id?: number;
root_account_id?: number;
associated_user_id?: number;
created_at?: string; // ISO 8601 date string
updated_at?: string; // ISO 8601 date string
start_at?: string; // ISO 8601 date string
end_at?: string; // ISO 8601 date string
last_activity_at?: string; // ISO 8601 date string
last_attended_at?: string; // ISO 8601 date string
total_activity_time?: number;
override_score?: number;
unposted_current_grade?: string;
unposted_final_grade?: string;
unposted_current_score?: string;
unposted_final_score?: string;
has_grading_periods?: boolean;
totals_for_all_grading_periods_option?: boolean;
current_grading_period_title?: string;
current_grading_period_id?: number;
current_period_override_grade?: string;
current_period_override_score?: number;
current_period_unposted_final_score?: number;
current_period_unposted_current_grade?: string;
current_period_unposted_final_grade?: string;
}

View File

@@ -0,0 +1,11 @@
export interface CanvasGradeModel {
html_url?: string;
current_grade?: number;
final_grade?: number;
current_score?: number;
final_score?: number;
unposted_current_grade?: number;
unposted_final_grade?: number;
unposted_current_score?: number;
unposted_final_score?: number;
}

View File

@@ -0,0 +1,18 @@
import { CanvasModuleItem } from "./canvasModuleItems";
export interface CanvasModule {
id: number;
workflow_state: string;
position: number;
name: string;
unlock_at?: string; // ISO 8601 date string
require_sequential_progress?: boolean;
prerequisite_module_ids?: number[];
items_count: number;
items_url: string;
items?: CanvasModuleItem[];
state?: string;
completed_at?: string; // ISO 8601 date string
publish_final_grade?: boolean;
published?: boolean;
}

View File

@@ -0,0 +1,26 @@
export interface CanvasModuleItem {
id: number;
module_id: number;
position: number;
title: string;
indent?: number;
type: string;
content_id?: number;
html_url: string;
url?: string;
page_url?: string;
external_url?: string;
new_tab: boolean;
completion_requirement?: {
type: string;
min_score?: number;
completed?: boolean;
};
published?: boolean;
content_details?: {
due_at?: string; // ISO 8601 date string
lock_at?: string; // ISO 8601 date string
points_possible: number;
locked_for_user: boolean;
};
}

View File

@@ -0,0 +1,16 @@
export interface CanvasPage {
page_id: number;
url: string;
title: string;
published: boolean;
front_page: boolean;
body?: string;
// Uncomment and define if needed
// created_at: string; // ISO 8601 date string
// updated_at: string; // ISO 8601 date string
// editing_roles: string;
// last_edited_by: UserDisplayModel;
// locked_for_user: boolean;
// lock_info?: LockInfoModel;
// lock_explanation?: string;
}

View File

@@ -0,0 +1,19 @@
export interface CanvasQuizAnswer {
id: number;
text: string;
html?: string;
weight: number;
// comments?: string;
// text_after_answers?: string;
// answer_match_left?: string;
// answer_match_right?: string;
// matching_answer_incorrect_matches?: string;
// numerical_answer_type?: string;
// exact?: number;
// margin?: number;
// approximate?: number;
// precision?: number;
// start?: number;
// end?: number;
// blank_id?: number;
}

View File

@@ -0,0 +1,44 @@
import { CanvasLockInfo } from "../assignments/canvasLockInfo";
import { CanvasQuizPermissions } from "./canvasQuizPermission";
export interface CanvasQuiz {
id: number;
title: string;
html_url: string;
mobile_url: string;
preview_url?: string;
description: string;
quiz_type: string;
assignment_group_id?: number;
time_limit?: number;
shuffle_answers?: boolean;
hide_results?: string;
show_correct_answers?: boolean;
show_correct_answers_last_attempt?: boolean;
show_correct_answers_at?: string; // ISO 8601 date string
hide_correct_answers_at?: string; // ISO 8601 date string
one_time_results?: boolean;
scoring_policy?: string;
allowed_attempts: number;
one_question_at_a_time?: boolean;
question_count?: number;
points_possible?: number;
cant_go_back?: boolean;
access_code?: string;
ip_filter?: string;
due_at?: string; // ISO 8601 date string
lock_at?: string; // ISO 8601 date string
unlock_at?: string; // ISO 8601 date string
published?: boolean;
unpublishable?: boolean;
locked_for_user?: boolean;
lock_info?: CanvasLockInfo;
lock_explanation?: string;
speedgrader_url?: string;
quiz_extensions_url?: string;
permissions: CanvasQuizPermissions;
all_dates?: unknown; // Depending on the structure of the dates, this could be further specified
version_number?: number;
question_types?: string[];
anonymous_submissions?: boolean;
}

View File

@@ -0,0 +1,9 @@
export interface CanvasQuizPermissions {
read: boolean;
submit: boolean;
create: boolean;
manage: boolean;
read_statistics: boolean;
review_grades: boolean;
update: boolean;
}

View File

@@ -0,0 +1,14 @@
import { CanvasQuizAnswer } from "./canvasQuizAnswerModel";
export interface CanvasQuizQuestion {
id: number;
quiz_id: number;
position?: number;
question_name: string;
question_type: string;
question_text: string;
correct_comments: string;
incorrect_comments: string;
neutral_comments: string;
answers?: CanvasQuizAnswer[];
}

View File

@@ -0,0 +1,50 @@
import { CanvasAssignment } from "../assignments/canvasAssignment";
import { CanvasCourseModel } from "../courses/canvasCourseModel";
import { CanvasUserModel } from "../users/canvasUserModel";
import { CanvasUserDisplayModel } from "../users/userDisplayModel";
export interface CanvasSubmissionModel {
assignment_id: number;
grade: string;
html_url: string;
preview_url: string;
submission_type: string;
user_id: number;
user: CanvasUserModel;
workflow_state: string;
late_policy_status: string;
assignment?: CanvasAssignment;
course?: CanvasCourseModel;
attempt?: number;
body?: string;
grade_matches_current_submission?: boolean;
score?: number;
submission_comments?: {
id: number;
author_id: number;
author_name: string;
author: CanvasUserDisplayModel;
comment: string;
created_at: string; // ISO 8601 date string
edited_at?: string; // ISO 8601 date string
media_comment?: {
content_type: string;
display_name: string;
media_id: string;
media_type: string;
url: string;
};
}[];
submitted_at?: string; // ISO 8601 date string
url?: string;
grader_id?: number;
graded_at?: string; // ISO 8601 date string
late?: boolean;
assignment_visible?: boolean;
excused?: boolean;
missing?: boolean;
points_deducted?: number;
seconds_late?: number;
extra_attempts?: number;
anonymous_id?: string;
}

View File

@@ -0,0 +1,21 @@
import { CanvasEnrollmentModel } from "../enrollments/canvasEnrollmentModel";
export interface CanvasUserModel {
id: number;
name: string;
sortable_name: string;
short_name: string;
sis_user_id: string;
integration_id: string;
login_id: string;
avatar_url: string;
enrollments: CanvasEnrollmentModel[];
email: string;
locale: string;
effective_locale: string;
time_zone: string;
bio: string;
permissions: { [key: string]: boolean };
sis_import_id?: number;
last_login?: string; // ISO 8601 date string
}

View File

@@ -0,0 +1,9 @@
export interface CanvasUserDisplayModel {
avatar_image_url: string;
html_url: string;
anonymous_id: string;
id?: number;
short_name?: string;
display_name?: string;
pronouns?: string;
}

View File

@@ -0,0 +1,65 @@
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
import { CanvasAssignmentGroup } from "@/features/canvas/models/assignments/canvasAssignmentGroup";
import { LocalAssignmentGroup } from "@/features/local/assignments/models/localAssignmentGroup";
import { rateLimitAwareDelete } from "./canvasWebRequestor";
import { axiosClient } from "@/services/axiosUtils";
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

@@ -0,0 +1,158 @@
import { CanvasAssignment } from "@/features/canvas/models/assignments/canvasAssignment";
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
import { CanvasRubricCreationResponse } from "@/features/canvas/models/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";
import { axiosClient } from "@/services/axiosUtils";
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
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

@@ -0,0 +1,39 @@
import publicProcedure from "@/services/serverFunctions/publicProcedure";
import { router } from "@/services/serverFunctions/trpcSetup";
import { z } from "zod";
import { downloadUrlToTempDirectory, uploadToCanvasPart1, uploadToCanvasPart2 } from "./files/canvasFileService";
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

@@ -0,0 +1,66 @@
import { CanvasModuleItem } from "@/features/canvas/models/modules/canvasModuleItems";
import { CanvasPage } from "@/features/canvas/models/pages/canvasPageModel";
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
import { CanvasModule } from "@/features/canvas/models/modules/canvasModule";
import { axiosClient } from "@/services/axiosUtils";
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

@@ -0,0 +1,34 @@
import { axiosClient } from "@/services/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

@@ -0,0 +1,73 @@
import { CanvasPage } from "@/features/canvas/models/pages/canvasPageModel";
import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
import { rateLimitAwareDelete } from "./canvasWebRequestor";
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
import { axiosClient } from "@/services/axiosUtils";
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
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

@@ -0,0 +1,214 @@
import { CanvasQuiz } from "@/features/canvas/models/quizzes/canvasQuizModel";
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
import { getDateFromStringOrThrow } from "@/features/local/utils/timeUtils";
import { canvasAssignmentService } from "./canvasAssignmentService";
import { CanvasQuizQuestion } from "@/features/canvas/models/quizzes/canvasQuizQuestionModel";
import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
import {
LocalQuizQuestion,
QuestionType,
} from "@/features/local/quizzes/models/localQuizQuestion";
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
import { axiosClient } from "@/services/axiosUtils";
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
import { escapeMatchingText } from "@/services/utils/questionHtmlUtils";
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

@@ -0,0 +1,21 @@
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

@@ -0,0 +1,67 @@
import { CanvasEnrollmentTermModel } from "@/features/canvas/models/enrollmentTerms/canvasEnrollmentTermModel";
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
import { CanvasCourseModel } from "@/features/canvas/models/courses/canvasCourseModel";
import { CanvasCourseStudentModel } from "@/features/canvas/models/courses/canvasCourseStudentModel";
import { axiosClient } from "@/services/axiosUtils";
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

@@ -0,0 +1,66 @@
// services/canvasServiceUtils.ts
import { axiosClient } from "@/services/axiosUtils";
import { AxiosResponseHeaders, RawAxiosResponseHeaders } from "axios";
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

@@ -0,0 +1,64 @@
import { axiosClient } from "@/services/axiosUtils";
import { AxiosResponse } from "axios";
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

@@ -0,0 +1,102 @@
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

@@ -0,0 +1,91 @@
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

@@ -1,10 +1,11 @@
import { QuestionType, zodQuestionType } from "@/features/local/quizzes/models/localQuizQuestion";
import { getQuestionType, getAnswers } from "@/features/canvas/services/canvasQuizService";
import {
QuestionType,
zodQuestionType,
} from "@/features/local/quizzes/models/localQuizQuestion";
import { quizMarkdownUtils } from "@/features/local/quizzes/models/utils/quizMarkdownUtils";
import { quizQuestionMarkdownUtils } from "@/features/local/quizzes/models/utils/quizQuestionMarkdownUtils";
import {
getAnswers,
getQuestionType,
} from "@/services/canvas/canvasQuizService";
import { describe, it, expect } from "vitest";
describe("TextAnswerTests", () => {