adding canvas service

This commit is contained in:
2024-08-21 13:50:50 -06:00
parent 556c7a7372
commit c698e33853
13 changed files with 412 additions and 48 deletions

View File

@@ -1,6 +1,6 @@
namespace CanvasModel.EnrollmentTerms;
public record RedundantEnrollmentTermsResponse
public record coRedundantEnrollmentTermsResponse
(
[property: JsonPropertyName("enrollment_terms")]
IEnumerable<EnrollmentTermModel> EnrollmentTerms

View File

@@ -8,9 +8,11 @@
"name": "canvas-mangement",
"version": "0.1.0",
"dependencies": {
"@tanstack/react-query": "^5.52.0",
"next": "14.2.5",
"react": "^18",
"react-dom": "^18"
"react-dom": "^18",
"react-hot-toast": "^2.4.1"
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",
@@ -1439,6 +1441,30 @@
"tslib": "^2.4.0"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.52.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.52.0.tgz",
"integrity": "sha512-U1DOEgltjUwalN6uWYTewSnA14b+tE7lSylOiASKCAO61ENJeCq9VVD/TXHA6O5u9+6v5+UgGYBSccTKDoyMqw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.52.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.52.0.tgz",
"integrity": "sha512-T8tLZdPEopSD3A1EBZ/sq7WkI76pKLKKiT82F486K8wf26EPgYCdeiSnJfuayssdQjWwLQMQVl/ROUBNmlWgCQ==",
"dependencies": {
"@tanstack/query-core": "5.52.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18.0.0"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
@@ -2474,8 +2500,7 @@
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
@@ -3832,6 +3857,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/goober": {
"version": "2.1.14",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.14.tgz",
"integrity": "sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/gopd": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
@@ -5624,6 +5657,21 @@
"react": "^18.3.1"
}
},
"node_modules/react-hot-toast": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz",
"integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==",
"dependencies": {
"goober": "^2.1.10"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View File

@@ -9,9 +9,11 @@
"lint": "next lint"
},
"dependencies": {
"@tanstack/react-query": "^5.52.0",
"next": "14.2.5",
"react": "^18",
"react-dom": "^18"
"react-dom": "^18",
"react-hot-toast": "^2.4.1"
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",

View File

@@ -1,22 +1,35 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { createQueryClient } from "@/services/utils/queryClient";
import { dehydrate } from "@tanstack/react-query";
import { MyQueryClientProvider } from "@/services/utils/MyQueryClientProvider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Canvas Manager 2.0",
};
export default function RootLayout({
export async function getDehydratedClient() {
const queryClient = createQueryClient();
// await hydrateOpenSections(queryClient);
const dehydratedState = dehydrate(queryClient);
return dehydratedState;
}
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const dehydratedState = await getDehydratedClient();
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<MyQueryClientProvider dehydratedState={dehydratedState}>
<body className={inter.className}>{children}</body>
</MyQueryClientProvider>
</html>
);
}

View File

@@ -1,5 +1,4 @@
import { canvasAssignmentService } from "@/services/canvas/canvasAssignmentService";
import Image from "next/image";
export default async function Home() {
const assignments = await canvasAssignmentService.getAll(960410);

View File

@@ -0,0 +1,11 @@
import { useSuspenseQuery } from "@tanstack/react-query";
export const canvasCourseKeys = {
courseDetails: (canavasId: number) => ["canvas course", canavasId] as const,
};
export const useCanvasCourseQuery =(canvasId: number) => useSuspenseQuery({
queryKey: canvasCourseKeys.courseDetails(canvasId),
queryFn: async () => canvasserv
})

View File

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

View File

@@ -1,5 +1,5 @@
import { CanvasUserDisplayModel } from "../users/userDisplayModel";
import { CanvasGradeModel } from "./canvasG'radeModel";
import { CanvasGradeModel } from "./canvasGradeModel";
export interface CanvasEnrollmentModel {
id: number;

View File

@@ -0,0 +1,124 @@
import { CanvasEnrollmentTermModel } from "@/models/canvas/enrollmentTerms/canvasEnrollmentTermModel";
import { canvasServiceUtils } from "./canvasServiceUtils";
import { webRequestor } from "./webRequestor";
import { CanvasCourseModel } from "@/models/canvas/courses/canvasCourseModel";
import { CanvasModuleItem } from "@/models/canvas/modules/canvasModuleItems";
import { CanvasPage } from "@/models/canvas/pages/canvasPageModel";
import { CanvasEnrollmentModel } from "@/models/canvas/enrollments/canvasEnrollmentModel";
const getTerms = async () => {
const url = `accounts/10/terms`;
const data = await canvasServiceUtils.paginatedRequest<{
enrollment_terms: CanvasEnrollmentTermModel[];
}>({ url });
const terms = data.flatMap((r) => r.enrollment_terms);
return terms;
};
export const canvasService = {
async getCourses(termId: number) {
const url = `courses`;
const coursesResponse =
await canvasServiceUtils.paginatedRequest<CanvasCourseModel>({ url });
return coursesResponse
.flat()
.filter((c) => c.enrollment_term_id === termId);
},
async getCourse(courseId: number): Promise<CanvasCourseModel> {
const url = `courses/${courseId}`;
const { data, response } = await webRequestor.get<CanvasCourseModel>(url);
if (!data) {
console.error((await response?.text()) ?? "");
console.error(response?.url ?? "");
throw new Error("Error getting course from Canvas");
}
return data;
},
async getCurrentTermsFor(queryDate: Date = new Date()) {
const terms = await getTerms();
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 updateModuleItem(
canvasCourseId: number,
canvasModuleId: number,
item: CanvasModuleItem
) {
console.log(`Updating module item ${item.title}`);
const url = `courses/${canvasCourseId}/modules/${canvasModuleId}/items/${item.id}`;
const body = {
module_item: { title: item.title, position: item.position },
};
const { data, response } =
await webRequestor.putWithDeserialize<CanvasModuleItem>(url, body);
if (!data) throw new Error("Something went wrong updating module item");
},
async createModuleItem(
canvasCourseId: number,
canvasModuleId: number,
title: string,
type: string,
contentId: number | string
): Promise<void> {
console.log(`Creating new module item ${title}`);
const url = `courses/${canvasCourseId}/modules/${canvasModuleId}/items`;
const body = { module_item: { title, type, content_id: contentId } };
const response = await webRequestor.post(url, body);
if (!response.ok)
throw new Error("Something went wrong creating module item");
},
async createPageModuleItem(
canvasCourseId: number,
canvasModuleId: number,
title: string,
canvasPage: CanvasPage
): Promise<void> {
console.log(`Creating new module item ${title}`);
const url = `courses/${canvasCourseId}/modules/${canvasModuleId}/items`;
const body = {
module_item: { title, type: "Page", page_url: canvasPage.url },
};
const { data, response } =
await webRequestor.postWithDeserialize<CanvasModuleItem>(url, body);
if (!response.ok)
throw new Error("Something went wrong creating page module item");
},
async getEnrolledStudents(canvasCourseId: number) {
console.log(`Getting students for course ${canvasCourseId}`);
const url = `courses/${canvasCourseId}/enrollments?enrollment_type=student`;
const { data, response } =
await webRequestor.getMany<CanvasEnrollmentModel>(url);
if (!data)
throw new Error(
`Something went wrong getting enrollments for ${canvasCourseId}`
);
return data;
},
};

View File

@@ -34,16 +34,15 @@ const deserialize = async <T>(response: Response): Promise<T | undefined> => {
}
};
const rateLimitAwarePostAsync = async (
const rateLimitAwarePost = async (
url: string,
options: FetchOptions,
body: any,
retryCount = 0
): Promise<Response> => {
const response = await fetch(url, {
...options,
method: "POST",
body: JSON.stringify(body),
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
@@ -55,7 +54,7 @@ const rateLimitAwarePostAsync = async (
`Hit rate limit on post, retry count is ${retryCount} / ${rateLimitRetryCount}, retrying`
);
await sleep(rateLimitSleepInterval);
return await rateLimitAwarePostAsync(url, options, retryCount + 1);
return await rateLimitAwarePost(url, body, retryCount + 1);
}
}
@@ -71,7 +70,7 @@ const rateLimitAwarePostAsync = async (
return response;
};
const recursiveDeleteAsync = async (
const recursiveDelete = async (
url: string,
options: FetchOptions,
retryCount = 0
@@ -89,7 +88,7 @@ const recursiveDeleteAsync = async (
if (await isRateLimited(response)) {
console.info("After delete response in rate limited");
await sleep(rateLimitSleepInterval);
return await recursiveDeleteAsync(url, options, retryCount + 1);
return await recursiveDelete(url, options, retryCount + 1);
}
return response;
@@ -101,7 +100,7 @@ const recursiveDeleteAsync = async (
`Hit rate limit in delete, retry count is ${retryCount} / ${rateLimitRetryCount}, retrying`
);
await sleep(rateLimitSleepInterval);
return await recursiveDeleteAsync(url, options, retryCount + 1);
return await recursiveDelete(url, options, retryCount + 1);
} else {
console.info(
`Hit rate limit in delete, ${rateLimitRetryCount} retries did not fix it`
@@ -112,10 +111,7 @@ const recursiveDeleteAsync = async (
}
};
export const webRequestor = {
getMany: async <T>(
url: string,
options: FetchOptions = {}
): Promise<[T[] | undefined, Response]> => {
getMany: async <T>(url: string, options: FetchOptions = {}) => {
const response = await fetch(url, {
...options,
method: "GET",
@@ -124,13 +120,10 @@ export const webRequestor = {
Authorization: `Bearer ${token}`,
},
});
return [await deserialize<T[]>(response), response];
return { data: await deserialize<T[]>(response), response };
},
get: async <T>(
url: string,
options: FetchOptions = {}
): Promise<[T | undefined, Response]> => {
get: async <T>(url: string, options: FetchOptions = {}) => {
const response = await fetch(url, {
...options,
method: "GET",
@@ -139,27 +132,23 @@ export const webRequestor = {
Authorization: `Bearer ${token}`,
},
});
return [await deserialize<T>(response), response];
return { data: await deserialize<T>(response), response };
},
post: async (url: string, options: FetchOptions = {}): Promise<Response> => {
return await rateLimitAwarePostAsync(url, options);
post: async (url: string, body: any) => {
return await rateLimitAwarePost(url, body);
},
postWithDeserialize: async <T>(
url: string,
options: FetchOptions = {}
): Promise<[T | undefined, Response]> => {
const response = await rateLimitAwarePostAsync(url, options);
return [await deserialize<T>(response), response];
postWithDeserialize: async <T>(url: string, body: any) => {
const response = await rateLimitAwarePost(url, body);
return { data: await deserialize<T[]>(response), response };
},
put: async (url: string, options: FetchOptions = {}): Promise<Response> => {
put: async (url: string, body: any = {}) => {
const response = await fetch(url, {
...options,
method: "PUT",
body: JSON.stringify(body),
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
@@ -167,26 +156,22 @@ export const webRequestor = {
return response;
},
putWithDeserialize: async <T>(
url: string,
options: FetchOptions = {}
): Promise<[T | undefined, Response]> => {
putWithDeserialize: async <T>(url: string, body: any = {}) => {
const response = await fetch(url, {
...options,
body: JSON.stringify(body),
method: "PUT",
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
return [await deserialize<T>(response), response];
return { data: await deserialize<T[]>(response), response };
},
delete: async (
url: string,
options: FetchOptions = {}
): Promise<Response> => {
return await recursiveDeleteAsync(url, options);
return await recursiveDelete(url, options);
},
};

View File

@@ -0,0 +1,23 @@
"use client";
import {
DehydratedState,
hydrate,
QueryClientProvider,
} from "@tanstack/react-query";
import React from "react";
import { FC, ReactNode, useState } from "react";
import { createQueryClient } from "./queryClient";
export const MyQueryClientProvider: FC<{
children: ReactNode;
dehydratedState: DehydratedState;
}> = ({ children, dehydratedState }) => {
const [queryClient] = useState(createQueryClient());
hydrate(queryClient, dehydratedState);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};

View File

@@ -0,0 +1,143 @@
import toast, { ErrorIcon, CheckmarkIcon } from "react-hot-toast";
import { ReactNode } from "react";
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
const addErrorAsToast = async (error: any) => {
console.error("error from toast", error);
const message = getErrorMessage(error);
toast(
(t: any) => (
<div className="row">
<div className="col-auto my-auto">
<ErrorIcon />
</div>
<div className="col my-auto">
<div className="white-space">{message}</div>
<div>
<a
href="https://snow.kualibuild.com/app/651eeebc32976c013a4c4739/run"
target="_blank"
rel="noreferrer"
>
Report Bug
</a>
</div>
</div>
<div className="col-auto my-auto">
<button
onClick={() => toast.dismiss(t.id)}
className="btn btn-outline-secondary btn-sm"
>
<i className="bi bi-x"></i>
</button>
</div>
</div>
),
{
duration: Infinity,
}
);
};
export function getErrorMessage(error: any) {
if (error?.response?.status === 422) {
console.log(error.response.data.detail);
const serializationMessages = error.response.data.detail.map(
(d: any) => `${d.type} - ${d.loc[1]}`
);
return `Deserialization error on request:\n${serializationMessages.join(
"\n"
)}`;
}
if (typeof error === "string") {
return error;
}
if (error.response?.data.detail) {
if (typeof error.response?.data.detail === "string") {
return error.response?.data.detail;
} else return JSON.stringify(error.response?.data.detail);
}
console.log(error);
return "Error With Request";
}
export function createInfoToast(
children: ReactNode,
onClose: () => void = () => {}
) {
const closeHandler = (t: any) => {
toast.dismiss(t.id);
onClose();
};
toast(
(t: any) => (
<div className="row">
<div className="col-auto my-auto">
<i className="bi bi-info-circle-fill"></i>
</div>
<div className="col my-auto">{children}</div>
<div className="col-auto my-auto">
<button
onClick={() => closeHandler(t)}
className="btn btn-outline-secondary py-1"
>
<i className="bi-x-lg" />
</button>
</div>
</div>
),
{
duration: Infinity,
style: {
maxWidth: "75em",
},
}
);
}
export function createSuccessToast(message: string) {
toast(
(t: any) => (
<div className="row">
<div className="col-auto my-auto">
<CheckmarkIcon />
</div>
<div className="col my-auto"> {message}</div>
<div className="col-auto my-auto">
<button
onClick={() => toast.dismiss(t.id)}
className="btn btn-outline-secondary py-1"
>
<i className="bi-x-lg" />
</button>
</div>
</div>
),
{
duration: Infinity,
style: {
maxWidth: "75em",
},
}
);
}
export const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 0,
},
mutations: {
onError: addErrorAsToast,
retry: 0,
},
},
queryCache: new QueryCache({
onError: addErrorAsToast,
}),
mutationCache: new MutationCache({
onError: addErrorAsToast,
}),
});