+
diff --git a/nextjs/src/hooks/canvas/canvasQuizHooks.ts b/nextjs/src/hooks/canvas/canvasQuizHooks.ts
new file mode 100644
index 0000000..61abdf1
--- /dev/null
+++ b/nextjs/src/hooks/canvas/canvasQuizHooks.ts
@@ -0,0 +1,8 @@
+
+
+export const canvasQuizKeys = {
+ quizzes: (canvasCourseId: number )=> ["canvas", canvasCourseId, "quizzes"],
+}
+
+
+export const useCanvasQuizQuery
\ No newline at end of file
diff --git a/nextjs/src/models/canvas/assignments/canvasRubricCreationResponse.ts b/nextjs/src/models/canvas/assignments/canvasRubricCreationResponse.ts
new file mode 100644
index 0000000..7578141
--- /dev/null
+++ b/nextjs/src/models/canvas/assignments/canvasRubricCreationResponse.ts
@@ -0,0 +1,7 @@
+import { CanvasRubric } from "./canvasRubric";
+import { CanvasRubricAssociation } from "./canvasRubricAssociation";
+
+export interface CanvasRubricCreationResponse {
+ rubric: CanvasRubric;
+ rubric_association: CanvasRubricAssociation;
+}
diff --git a/nextjs/src/models/local/assignment/utils/assignmentPointsUtils.ts b/nextjs/src/models/local/assignment/utils/assignmentPointsUtils.ts
new file mode 100644
index 0000000..0e62446
--- /dev/null
+++ b/nextjs/src/models/local/assignment/utils/assignmentPointsUtils.ts
@@ -0,0 +1,10 @@
+import { LocalAssignment } from "../localAssignment";
+
+export const assignmentPoints = (assignment: LocalAssignment) => {
+ const basePoints = assignment.rubric
+ .map((r) =>
+ r.label.toLowerCase().includes("(extra credit)") ? 0 : r.points
+ )
+ .reduce((acc, current) => acc + current, 0);
+ return basePoints;
+};
diff --git a/nextjs/src/services/canvas/canvasAssignmentService.ts b/nextjs/src/services/canvas/canvasAssignmentService.ts
index 73e6b23..9503cf7 100644
--- a/nextjs/src/services/canvas/canvasAssignmentService.ts
+++ b/nextjs/src/services/canvas/canvasAssignmentService.ts
@@ -1,5 +1,10 @@
import { CanvasAssignment } from "@/models/canvas/assignments/canvasAssignment";
-import { canvasServiceUtils } from "./canvasServiceUtils";
+import { baseCanvasUrl, canvasServiceUtils } from "./canvasServiceUtils";
+import { LocalAssignment } from "@/models/local/assignment/localAssignment";
+import { axiosClient } from "../axiosUtils";
+import { markdownToHTMLSafe } from "../htmlMarkdownUtils";
+import { CanvasRubricCreationResponse } from "@/models/canvas/assignments/canvasRubricCreationResponse";
+import { assignmentPoints } from "@/models/local/assignment/utils/assignmentPointsUtils";
export const canvasAssignmentService = {
async getAll(courseId: number): Promise
{
@@ -15,4 +20,136 @@ export const canvasAssignmentService = {
}))
);
},
+
+ async create(
+ canvasCourseId: number,
+ localAssignment: LocalAssignment,
+ canvasAssignmentGroupId?: number
+ ): Promise {
+ console.log(`Creating assignment: ${localAssignment.name}`);
+ const url = `${baseCanvasUrl}/courses/${canvasCourseId}/assignments`;
+ 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),
+ due_at: localAssignment.dueAt,
+ lock_at: localAssignment.lockAt,
+ points_possible: assignmentPoints(localAssignment),
+ assignment_group_id: canvasAssignmentGroupId,
+ },
+ };
+
+ const response = await axiosClient.post(url, body);
+ const canvasAssignment = response.data;
+
+ if (!canvasAssignment) throw new Error("Created Canvas assignment is null");
+
+ await this.createRubric(
+ canvasCourseId,
+ canvasAssignment.id,
+ localAssignment
+ );
+
+ return canvasAssignment.id;
+ },
+
+ async update(
+ courseId: number,
+ canvasAssignmentId: number,
+ localAssignment: LocalAssignment,
+ canvasAssignmentGroupId?: number
+ ): Promise {
+ console.log(`Updating assignment: ${localAssignment.name}`);
+ const url = `${baseCanvasUrl}/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),
+ due_at: localAssignment.dueAt,
+ lock_at: localAssignment.lockAt,
+ points_possible: assignmentPoints(localAssignment),
+ assignment_group_id: canvasAssignmentGroupId,
+ },
+ };
+
+ await axiosClient.put(url, body);
+ await this.createRubric(courseId, canvasAssignmentId, localAssignment);
+ },
+
+ async delete(
+ courseId: number,
+ assignmentCanvasId: number,
+ assignmentName: string
+ ): Promise {
+ console.log(`Deleting assignment from Canvas: ${assignmentName}`);
+ const url = `${baseCanvasUrl}/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");
+ }
+ },
+
+ async createRubric(
+ courseId: number,
+ assignmentCanvasId: number,
+ localAssignment: LocalAssignment
+ ): Promise {
+ const criterion = localAssignment.rubric.map((rubricItem, i) => ({
+ description: rubricItem.label,
+ points: rubricItem.points,
+ ratings: [
+ { description: "Full Marks", points: rubricItem.points },
+ { description: "No Marks", points: 0 },
+ ],
+ }));
+
+ 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 = `${baseCanvasUrl}/courses/${courseId}/rubrics`;
+ const rubricResponse = await axiosClient.post(
+ rubricUrl,
+ rubricBody
+ );
+
+ if (!rubricResponse.data) throw new Error("Failed to create rubric");
+
+ const assignmentPointAdjustmentUrl = `${baseCanvasUrl}/courses/${courseId}/assignments/${assignmentCanvasId}`;
+ const assignmentPointAdjustmentBody = {
+ assignment: { points_possible: assignmentPoints(localAssignment) },
+ };
+
+ await axiosClient.put(
+ assignmentPointAdjustmentUrl,
+ assignmentPointAdjustmentBody
+ );
+ },
};
diff --git a/nextjs/src/services/canvas/canvasQuizService.ts b/nextjs/src/services/canvas/canvasQuizService.ts
new file mode 100644
index 0000000..0ca3ad6
--- /dev/null
+++ b/nextjs/src/services/canvas/canvasQuizService.ts
@@ -0,0 +1,154 @@
+import { CanvasQuiz } from "@/models/canvas/quizzes/canvasQuizModel";
+import { axiosClient } from "../axiosUtils";
+import { baseCanvasUrl } from "./canvasServiceUtils";
+import { LocalQuiz } from "@/models/local/quiz/localQuiz";
+import { markdownToHTMLSafe } from "../htmlMarkdownUtils";
+import { getDateFromStringOrThrow } from "@/models/local/timeUtils";
+import { canvasAssignmentService } from "./canvasAssignmentService";
+import { LocalQuizQuestion } from "@/models/local/quiz/localQuizQuestion";
+
+const getAnswers = (question: LocalQuizQuestion) => {
+ return question.answers.map((answer: any) => ({
+ answer_html: answer.htmlText,
+ answer_weight: answer.correct ? 100 : 0,
+ }));
+};
+const createQuestionOnly = async (
+ canvasCourseId: number,
+ canvasQuizId: number,
+ question: LocalQuizQuestion,
+ position: number
+): Promise<{ question: any; position: number }> => {
+ console.log("Creating individual question", question);
+
+ const url = `${baseCanvasUrl}/courses/${canvasCourseId}/quizzes/${canvasQuizId}/questions`;
+ const body = {
+ question: {
+ question_text: markdownToHTMLSafe(question.text),
+ question_type: `${question.questionType}_question`,
+ points_possible: question.points,
+ position,
+ answers: getAnswers(question),
+ },
+ };
+
+ const response = await axiosClient.post(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,
+ 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 = `${baseCanvasUrl}/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")
+ );
+
+ const deletionTasks = assignmentsToDelete.map((assignment) =>
+ canvasAssignmentService.delete(
+ canvasCourseId,
+ assignment.id,
+ assignment.name
+ )
+ );
+
+ await Promise.all(deletionTasks);
+ console.log(`Deleted ${assignmentsToDelete.length} redundant assignments`);
+};
+
+export const canvasQuizService = {
+ async getAll(canvasCourseId: number): Promise {
+ const url = `${baseCanvasUrl}/courses/${canvasCourseId}/quizzes`;
+ const response = await axiosClient.get(url);
+ return response.data.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,
+ }));
+ },
+
+ async create(
+ canvasCourseId: number,
+ localQuiz: LocalQuiz,
+ canvasAssignmentGroupId?: number
+ ): Promise {
+ console.log("Creating quiz", localQuiz);
+
+ const url = `${baseCanvasUrl}/courses/${canvasCourseId}/quizzes`;
+ const body = {
+ quiz: {
+ title: localQuiz.name,
+ description: markdownToHTMLSafe(localQuiz.description),
+ 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 response = await axiosClient.post(url, body);
+ const canvasQuiz: CanvasQuiz = response.data;
+
+ if (!canvasQuiz) throw new Error("Created quiz is null");
+
+ await this.createQuizQuestions(canvasCourseId, canvasQuiz.id, localQuiz);
+ return canvasQuiz.id;
+ },
+
+ async createQuizQuestions(
+ canvasCourseId: number,
+ canvasQuizId: number,
+ localQuiz: LocalQuiz
+ ) {
+ console.log("Creating quiz questions", localQuiz);
+
+ const tasks = localQuiz.questions.map((question, index) =>
+ createQuestionOnly(canvasCourseId, canvasQuizId, question, index)
+ );
+ const questionAndPositions = await Promise.all(tasks);
+ await hackFixQuestionOrdering(
+ canvasCourseId,
+ canvasQuizId,
+ questionAndPositions
+ );
+ await hackFixRedundantAssignments(canvasCourseId);
+ },
+};