diff --git a/nextjs/src/app/course/[courseName]/calendar/CourseCalendar.tsx b/nextjs/src/app/course/[courseName]/calendar/CourseCalendar.tsx index c11581d..26bd53f 100644 --- a/nextjs/src/app/course/[courseName]/calendar/CourseCalendar.tsx +++ b/nextjs/src/app/course/[courseName]/calendar/CourseCalendar.tsx @@ -27,7 +27,7 @@ export default function CourseCalendar() { className=" h-full overflow-y-scroll - border-4 + border-4 border-slate-600 rounded-xl bg-slate-950 diff --git a/nextjs/src/app/course/[courseName]/page.tsx b/nextjs/src/app/course/[courseName]/page.tsx index c7c300e..6542da6 100644 --- a/nextjs/src/app/course/[courseName]/page.tsx +++ b/nextjs/src/app/course/[courseName]/page.tsx @@ -13,7 +13,7 @@ export default async function CoursePage({}: {}) {
-
+
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); + }, +};