mirror of
https://github.com/alexmickelson/canvasManagement.git
synced 2026-03-26 07:38:33 -06:00
refactoring canvas files
This commit is contained in:
129
src/features/canvas/hooks/canvasAssignmentHooks.ts
Normal file
129
src/features/canvas/hooks/canvasAssignmentHooks.ts
Normal 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),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
106
src/features/canvas/hooks/canvasCourseHooks.ts
Normal file
106
src/features/canvas/hooks/canvasCourseHooks.ts
Normal 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),
|
||||
});
|
||||
38
src/features/canvas/hooks/canvasHooks.ts
Normal file
38
src/features/canvas/hooks/canvasHooks.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
};
|
||||
30
src/features/canvas/hooks/canvasModuleHooks.ts
Normal file
30
src/features/canvas/hooks/canvasModuleHooks.ts
Normal 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),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
42
src/features/canvas/hooks/canvasNavigationHooks.tsx
Normal file
42
src/features/canvas/hooks/canvasNavigationHooks.tsx
Normal 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",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
104
src/features/canvas/hooks/canvasPageHooks.ts
Normal file
104
src/features/canvas/hooks/canvasPageHooks.ts
Normal 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),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
87
src/features/canvas/hooks/canvasQuizHooks.ts
Normal file
87
src/features/canvas/hooks/canvasQuizHooks.ts
Normal 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),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
82
src/features/canvas/models/assignments/canvasAssignment.ts
Normal file
82
src/features/canvas/models/assignments/canvasAssignment.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface CanvasExternalToolTagAttributes {
|
||||
url: string;
|
||||
resource_link_id: string;
|
||||
new_tab?: boolean;
|
||||
}
|
||||
7
src/features/canvas/models/assignments/canvasLockInfo.ts
Normal file
7
src/features/canvas/models/assignments/canvasLockInfo.ts
Normal 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;
|
||||
}
|
||||
13
src/features/canvas/models/assignments/canvasRubric.ts
Normal file
13
src/features/canvas/models/assignments/canvasRubric.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { CanvasRubric } from "./canvasRubric";
|
||||
import { CanvasRubricAssociation } from "./canvasRubricAssociation";
|
||||
|
||||
export interface CanvasRubricCreationResponse {
|
||||
rubric: CanvasRubric;
|
||||
rubric_association: CanvasRubricAssociation;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface CanvasCalendarLinkModel {
|
||||
ics: string;
|
||||
}
|
||||
56
src/features/canvas/models/courses/canvasCourseModel.ts
Normal file
56
src/features/canvas/models/courses/canvasCourseModel.ts
Normal 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 };
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
6
src/features/canvas/models/courses/canvasTermModel.ts
Normal file
6
src/features/canvas/models/courses/canvasTermModel.ts
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface CanvasFileAttachmentModel {
|
||||
content_type: string;
|
||||
url: string;
|
||||
filename: string;
|
||||
display_name: string;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
11
src/features/canvas/models/enrollments/canvasGradeModel.ts
Normal file
11
src/features/canvas/models/enrollments/canvasGradeModel.ts
Normal 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;
|
||||
}
|
||||
18
src/features/canvas/models/modules/canvasModule.ts
Normal file
18
src/features/canvas/models/modules/canvasModule.ts
Normal 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;
|
||||
}
|
||||
26
src/features/canvas/models/modules/canvasModuleItems.ts
Normal file
26
src/features/canvas/models/modules/canvasModuleItems.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
16
src/features/canvas/models/pages/canvasPageModel.ts
Normal file
16
src/features/canvas/models/pages/canvasPageModel.ts
Normal 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;
|
||||
}
|
||||
19
src/features/canvas/models/quizzes/canvasQuizAnswerModel.ts
Normal file
19
src/features/canvas/models/quizzes/canvasQuizAnswerModel.ts
Normal 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;
|
||||
}
|
||||
44
src/features/canvas/models/quizzes/canvasQuizModel.ts
Normal file
44
src/features/canvas/models/quizzes/canvasQuizModel.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface CanvasQuizPermissions {
|
||||
read: boolean;
|
||||
submit: boolean;
|
||||
create: boolean;
|
||||
manage: boolean;
|
||||
read_statistics: boolean;
|
||||
review_grades: boolean;
|
||||
update: boolean;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
21
src/features/canvas/models/users/canvasUserModel.ts
Normal file
21
src/features/canvas/models/users/canvasUserModel.ts
Normal 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
|
||||
}
|
||||
9
src/features/canvas/models/users/userDisplayModel.ts
Normal file
9
src/features/canvas/models/users/userDisplayModel.ts
Normal 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;
|
||||
}
|
||||
65
src/features/canvas/services/canvasAssignmentGroupService.ts
Normal file
65
src/features/canvas/services/canvasAssignmentGroupService.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
158
src/features/canvas/services/canvasAssignmentService.ts
Normal file
158
src/features/canvas/services/canvasAssignmentService.ts
Normal 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
|
||||
);
|
||||
};
|
||||
39
src/features/canvas/services/canvasFileRouter.ts
Normal file
39
src/features/canvas/services/canvasFileRouter.ts
Normal 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;
|
||||
}),
|
||||
});
|
||||
66
src/features/canvas/services/canvasModuleService.ts
Normal file
66
src/features/canvas/services/canvasModuleService.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
34
src/features/canvas/services/canvasNavigationService.ts
Normal file
34
src/features/canvas/services/canvasNavigationService.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
73
src/features/canvas/services/canvasPageService.ts
Normal file
73
src/features/canvas/services/canvasPageService.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
214
src/features/canvas/services/canvasQuizService.ts
Normal file
214
src/features/canvas/services/canvasQuizService.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
21
src/features/canvas/services/canvasRubricUtils.ts
Normal file
21
src/features/canvas/services/canvasRubricUtils.ts
Normal 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;
|
||||
};
|
||||
67
src/features/canvas/services/canvasService.ts
Normal file
67
src/features/canvas/services/canvasService.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
66
src/features/canvas/services/canvasServiceUtils.ts
Normal file
66
src/features/canvas/services/canvasServiceUtils.ts
Normal 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;
|
||||
}
|
||||
64
src/features/canvas/services/canvasWebRequestor.ts
Normal file
64
src/features/canvas/services/canvasWebRequestor.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
102
src/features/canvas/services/files/canvasFileService.ts
Normal file
102
src/features/canvas/services/files/canvasFileService.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
91
src/features/canvas/services/rubric.test.ts
Normal file
91
src/features/canvas/services/rubric.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user