From a37c5d53fdde2707aa8ad87e9151550b68bd7c01 Mon Sep 17 00:00:00 2001 From: liv Date: Fri, 24 May 2024 17:32:28 +1000 Subject: [PATCH 01/12] added backend api for getting user roles --- backend/src/routes/routes.ts | 5 ++++- backend/src/routes/users.ts | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/backend/src/routes/routes.ts b/backend/src/routes/routes.ts index 24c8c5a..c43e972 100644 --- a/backend/src/routes/routes.ts +++ b/backend/src/routes/routes.ts @@ -17,7 +17,7 @@ import { getExamInfo, getExamQuestions, postExam } from './exams'; import { editQuestion, getQuestion, getQuestionComments, postQuestion } from './questions'; -import { postUser } from './users'; +import { getUserRole, postUser } from './users'; import { EVAN, healthCheck } from './health'; @@ -105,6 +105,9 @@ router.post('/courses', postCourse); * */ +// Gets a user's role +router.get('/users/:userId/role', getUserRole); + // Gets comment by comment id router.get('/comments/:commentId', getComment); diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 53136e4..0a344ec 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -29,3 +29,24 @@ export async function postUser(req: Request, res: Response) { res.status(201).json('User Added'); } + +export async function getUserRole(req: Request, res: Response) { + const { userId } = req.params; + + if (!userId) { + res.status(400).json('Missing userId'); + return; + } + + const userRepository = getConnection().getRepository(UserDb); + + // Check for user + const user = await userRepository.findOne({ where: { userId } }); + + if (!user) { + res.status(404).json('User not found'); + return; + } + + res.status(200).json(user.role); +} \ No newline at end of file From f2094536d8ae43ff67fefda70a31976ae4d8fa3f Mon Sep 17 00:00:00 2001 From: liv Date: Fri, 24 May 2024 17:33:00 +1000 Subject: [PATCH 02/12] added requireRole wrapper for components --- frontend/src/api/useUser.ts | 33 ++++++++++++++++++++++++++++++++ frontend/src/app/requireRole.tsx | 19 ++++++++++++++++++ frontend/src/types.ts | 5 +++++ 3 files changed, 57 insertions(+) create mode 100644 frontend/src/api/useUser.ts create mode 100644 frontend/src/app/requireRole.tsx diff --git a/frontend/src/api/useUser.ts b/frontend/src/api/useUser.ts new file mode 100644 index 0000000..ebd2ad8 --- /dev/null +++ b/frontend/src/api/useUser.ts @@ -0,0 +1,33 @@ +'use client'; +import useSWR, { Fetcher } from 'swr'; +import { UserRole } from '@/types'; +import { useEffect } from 'react'; +import toast from 'react-hot-toast'; + +const ENDPOINT = `${process.env.API_URL}/api/users/`; + +const fetcher: Fetcher = async (...args) => { + const res = await fetch(...args); + + if (!res.ok) { + throw new Error(await res.json()); + } + + return res.json(); +}; + +export default function useUserRole(userId: string) { + const { data, error, isLoading } = useSWR(ENDPOINT + userId +'/role', fetcher); + + useEffect(() => { + if (error) { + toast.error('Error loading user role', { id: 'userRoleError' }); + } + }, [error]); + + return { + userRole: data, + isLoading, + isError: error, + }; +} \ No newline at end of file diff --git a/frontend/src/app/requireRole.tsx b/frontend/src/app/requireRole.tsx new file mode 100644 index 0000000..3ccd541 --- /dev/null +++ b/frontend/src/app/requireRole.tsx @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import useUserRole from '@/api/useUser'; +import { UserRole } from '@/types'; +import NotFound from '@/app/not-found'; +import { useUser } from '@auth0/nextjs-auth0/client'; + +export default function requireRole(WrappedComponent: React.ComponentType, role: UserRole) { + const ComponentWithRole = (props: any) => { + const { user } = useUser(); + const { userRole } = useUserRole(user?.sub || ''); + + return userRole === role ? : ; + }; + + ComponentWithRole.displayName = `requireRole(${WrappedComponent.displayName || WrappedComponent.name || 'Component'}, ${role})`; + + return ComponentWithRole; +} \ No newline at end of file diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a6718c9..aa826ca 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -52,4 +52,9 @@ export type DisplayCourse = { export type RecentChange = { courseCode: Course['courseCode'] exams: Array +} + +export enum UserRole { + USER = 0, + ADMIN = 1, } \ No newline at end of file From cde97f6421f46774c0246daa984f37e95801a633 Mon Sep 17 00:00:00 2001 From: liv Date: Fri, 24 May 2024 17:33:25 +1000 Subject: [PATCH 03/12] frontend skeleton pages for adding courses and exams --- .../courses/[courseCode]/exams/add/page.tsx | 10 ++++++++ frontend/src/app/courses/add/page.tsx | 10 ++++++++ frontend/src/app/courses/page.tsx | 23 +++++++++++++++++-- 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 frontend/src/app/courses/[courseCode]/exams/add/page.tsx create mode 100644 frontend/src/app/courses/add/page.tsx diff --git a/frontend/src/app/courses/[courseCode]/exams/add/page.tsx b/frontend/src/app/courses/[courseCode]/exams/add/page.tsx new file mode 100644 index 0000000..0937054 --- /dev/null +++ b/frontend/src/app/courses/[courseCode]/exams/add/page.tsx @@ -0,0 +1,10 @@ +'use client' +import requireRole from '@/app/requireRole'; +import requireAuth from '@/app/requireAuth'; +import { UserRole } from '@/types'; + +function AddExam(){ + return
Add an exam here
+} + +export default requireAuth(requireRole(AddExam, UserRole.ADMIN)); \ No newline at end of file diff --git a/frontend/src/app/courses/add/page.tsx b/frontend/src/app/courses/add/page.tsx new file mode 100644 index 0000000..3cc3c26 --- /dev/null +++ b/frontend/src/app/courses/add/page.tsx @@ -0,0 +1,10 @@ +'use client' +import requireRole from '@/app/requireRole'; +import requireAuth from '@/app/requireAuth'; +import { UserRole } from '@/types'; + +function AddCourse(){ + return
Add a course here
+} + +export default requireAuth(requireRole(AddCourse, UserRole.ADMIN)); \ No newline at end of file diff --git a/frontend/src/app/courses/page.tsx b/frontend/src/app/courses/page.tsx index 1b4067a..135771e 100644 --- a/frontend/src/app/courses/page.tsx +++ b/frontend/src/app/courses/page.tsx @@ -1,13 +1,16 @@ 'use client'; import useCourses from '@/api/useCourses'; import Card from '@/components/Card'; -import { IconCirclePlus } from '@tabler/icons-react'; import CourseCard from '@/components/CourseCard'; import { backendCourseToFrontend } from '@/lib/courseUtils'; import requireAuth from '../requireAuth'; import { usePinned } from '@/api/usePins'; import { useMemo } from 'react'; import Title from '@/components/Title'; +import requireRole from '@/app/requireRole'; +import { UserRole } from '@/types'; +import { IconCirclePlus } from '@tabler/icons-react'; +import Link from 'next/link'; function Courses() { const { courses, isError, isLoading } = useCourses(); @@ -29,13 +32,28 @@ function Courses() { }), [courses, pinned]); + const AddCourse = requireRole( +() => ( + + +
+

+ Add Course +

+
+
+ + ), + UserRole.ADMIN, + ); + return (
{isLoading && ( <div role="status" className="absolute left-[50%] top-[50%]"> - <svg aria-hidden="true" + <svg aria-hidden="true" className="inline w-16 h-16 text-gray-200 animate-spin dark:text-gray-600 fill-emerald-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg"> <path @@ -58,6 +76,7 @@ function Courses() { key={course.code} /> ))} + <AddCourse /> </div> ) : ( <p className="text-xl text-zinc-900 dark:text-white">No courses available</p> From a6256e84ef845fa974f2cb41ac77ec0e41298a26 Mon Sep 17 00:00:00 2001 From: liv <o.ronda@uqconnect.edu.au> Date: Fri, 24 May 2024 17:36:40 +1000 Subject: [PATCH 04/12] added react-hook-form --- frontend/package-lock.json | 16 ++++++++++++++++ frontend/package.json | 1 + 2 files changed, 17 insertions(+) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b5c71c8..c5535a1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -38,6 +38,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-highlight-words": "^0.20.0", + "react-hook-form": "^7.51.5", "react-hot-toast": "^2.4.1", "remark": "^15.0.1", "remark-gfm": "^4.0.0", @@ -6684,6 +6685,21 @@ "react": "^0.14.0 || ^15.0.0 || ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0" } }, + "node_modules/react-hook-form": { + "version": "7.51.5", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.5.tgz", + "integrity": "sha512-J2ILT5gWx1XUIJRETiA7M19iXHlG74+6O3KApzvqB/w8S5NQR7AbU8HVZrMALdmDgWpRPYiZJl0zx8Z4L2mP6Q==", + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/react-hot-toast": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0086486..70789f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,6 +40,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-highlight-words": "^0.20.0", + "react-hook-form": "^7.51.5", "react-hot-toast": "^2.4.1", "remark": "^15.0.1", "remark-gfm": "^4.0.0", From 5b6bcf812e55ae625e678e0937ab45ba078ef205 Mon Sep 17 00:00:00 2001 From: liv <o.ronda@uqconnect.edu.au> Date: Fri, 24 May 2024 19:24:53 +1000 Subject: [PATCH 05/12] added basic forms --- .../courses/[courseCode]/exams/add/page.tsx | 13 ++++- frontend/src/app/courses/add/page.tsx | 13 ++++- frontend/src/app/requireRole.tsx | 5 +- .../src/components/Courses/AddCourseForm.tsx | 42 ++++++++++++++++ frontend/src/components/Exams/AddExamForm.tsx | 49 +++++++++++++++++++ frontend/src/components/Input.tsx | 29 +++++++++++ frontend/src/components/Select.tsx | 42 ++++++++++++++++ frontend/src/components/TextArea.tsx | 29 +++++++++++ 8 files changed, 214 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/Courses/AddCourseForm.tsx create mode 100644 frontend/src/components/Exams/AddExamForm.tsx create mode 100644 frontend/src/components/Input.tsx create mode 100644 frontend/src/components/Select.tsx create mode 100644 frontend/src/components/TextArea.tsx diff --git a/frontend/src/app/courses/[courseCode]/exams/add/page.tsx b/frontend/src/app/courses/[courseCode]/exams/add/page.tsx index 0937054..f3d3c7f 100644 --- a/frontend/src/app/courses/[courseCode]/exams/add/page.tsx +++ b/frontend/src/app/courses/[courseCode]/exams/add/page.tsx @@ -1,10 +1,19 @@ -'use client' +'use client'; import requireRole from '@/app/requireRole'; import requireAuth from '@/app/requireAuth'; import { UserRole } from '@/types'; +import Title from '@/components/Title'; +import AddExamForm from '@/components/Exams/AddExamForm'; function AddExam(){ - return <main>Add an exam here</main> + return ( + <main> + <div className="max-w-4xl w-full mx-auto flex flex-col items-center justify-center"> + <Title title="Add Exam"/> + <AddExamForm onSubmit={(data) => console.log(data)}/> + </div> + </main> + ); } export default requireAuth(requireRole(AddExam, UserRole.ADMIN)); \ No newline at end of file diff --git a/frontend/src/app/courses/add/page.tsx b/frontend/src/app/courses/add/page.tsx index 3cc3c26..18d07b7 100644 --- a/frontend/src/app/courses/add/page.tsx +++ b/frontend/src/app/courses/add/page.tsx @@ -1,10 +1,19 @@ -'use client' +'use client'; import requireRole from '@/app/requireRole'; import requireAuth from '@/app/requireAuth'; import { UserRole } from '@/types'; +import AddCourseForm from '@/components/Courses/AddCourseForm'; +import Title from '@/components/Title'; function AddCourse(){ - return <main>Add a course here</main> + return ( + <main> + <div className="max-w-4xl w-full mx-auto flex flex-col items-center justify-center"> + <Title title="Add Course"/> + <AddCourseForm onSubmit={(data) => console.log(data)}/> + </div> + </main> + ); } export default requireAuth(requireRole(AddCourse, UserRole.ADMIN)); \ No newline at end of file diff --git a/frontend/src/app/requireRole.tsx b/frontend/src/app/requireRole.tsx index 3ccd541..1c4501f 100644 --- a/frontend/src/app/requireRole.tsx +++ b/frontend/src/app/requireRole.tsx @@ -1,8 +1,5 @@ -import { useEffect } from 'react'; -import { v4 as uuidv4 } from 'uuid'; import useUserRole from '@/api/useUser'; import { UserRole } from '@/types'; -import NotFound from '@/app/not-found'; import { useUser } from '@auth0/nextjs-auth0/client'; export default function requireRole(WrappedComponent: React.ComponentType<any>, role: UserRole) { @@ -10,7 +7,7 @@ export default function requireRole(WrappedComponent: React.ComponentType<any>, const { user } = useUser(); const { userRole } = useUserRole(user?.sub || ''); - return userRole === role ? <WrappedComponent {...props} /> : <NotFound/>; + return userRole === role ? <WrappedComponent {...props} /> : null; }; ComponentWithRole.displayName = `requireRole(${WrappedComponent.displayName || WrappedComponent.name || 'Component'}, ${role})`; diff --git a/frontend/src/components/Courses/AddCourseForm.tsx b/frontend/src/components/Courses/AddCourseForm.tsx new file mode 100644 index 0000000..3d40615 --- /dev/null +++ b/frontend/src/components/Courses/AddCourseForm.tsx @@ -0,0 +1,42 @@ +import { useForm } from 'react-hook-form'; +import Input from '@/components/Input'; +import Select from '@/components/Select'; +import TextArea from '@/components/TextArea'; +import { Button } from '@/components/Button'; + +const universityOptions = [ + { label: 'University of Queensland', value: 'UQ' }, +]; + +type AddCourseFormFields = { + courseCode: string + courseName: string + courseDescription: string + university: string +} + +export default function AddCourseForm({ onSubmit }: { onSubmit: (data: AddCourseFormFields) => void }) { + const { register, handleSubmit } = useForm<AddCourseFormFields>(); + + return ( + <form + className="w-full" + onSubmit={handleSubmit(onSubmit)} + > + <Input label="Course Code" {...register('courseCode')} /> + <Input label="Course Name" {...register('courseName')} /> + <TextArea label="Course Description" {...register('courseDescription')} /> + <Select + label="University" + options={universityOptions} {...register('university')} + /> + <Button + type="submit" + className="w-full" + > + Submit + </Button> + </form> + ); +} + diff --git a/frontend/src/components/Exams/AddExamForm.tsx b/frontend/src/components/Exams/AddExamForm.tsx new file mode 100644 index 0000000..80f1f9e --- /dev/null +++ b/frontend/src/components/Exams/AddExamForm.tsx @@ -0,0 +1,49 @@ +import { useForm } from 'react-hook-form'; +import Input from '@/components/Input'; +import Select from '@/components/Select'; +import { Button } from '@/components/Button'; + +const examTypeOptions = [ + { label: 'Final', value: 'final' }, + { label: 'Midsem', value: 'midsem' }, + { label: 'Quiz', value: 'quiz' }, +]; +const examSemesterOptions = [ + { label: 'Semester 1', value: 1 }, + { label: 'Semester 2', value: 2 }, + { label: 'Summer Semester', value: 3 }, +]; + +type AddExamFormFields = { + examYear: number + examSemester: number + examType: string +} + +export default function AddExamForm({ onSubmit }: { onSubmit: (data: AddExamFormFields) => void }) { + const { register, handleSubmit } = useForm<AddExamFormFields>(); + + return ( + <form + className="w-full" + onSubmit={handleSubmit(onSubmit)} + > + <Input label="Year" {...register('examYear')} /> + <Select + label="Semester" + options={examSemesterOptions} {...register('examSemester')} + /> + <Select + label="Type" + options={examTypeOptions} {...register('examType')} + /> + <Button + type="submit" + className="w-full" + > + Submit + </Button> + </form> + ); +} + diff --git a/frontend/src/components/Input.tsx b/frontend/src/components/Input.tsx new file mode 100644 index 0000000..75382d5 --- /dev/null +++ b/frontend/src/components/Input.tsx @@ -0,0 +1,29 @@ +import { forwardRef, InputHTMLAttributes } from 'react'; + + +const Input = forwardRef<HTMLInputElement, { label: string } & InputHTMLAttributes<HTMLInputElement>>(({ + label, + ...inputProps +}, ref) => { + return ( + <div className="mb-5"> + {!!label && ( + <label + htmlFor={inputProps.id} + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + {label} + </label> + )} + <input + {...inputProps} + className="bg-transparent border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-emerald-500 focus:border-emerald-500 block w-full p-2.5 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-emerald-500 dark:focus:border-emerald-500" + ref={ref} + /> + </div> + ); +}); + +Input.displayName = 'Input'; + +export default Input; \ No newline at end of file diff --git a/frontend/src/components/Select.tsx b/frontend/src/components/Select.tsx new file mode 100644 index 0000000..8e1e34f --- /dev/null +++ b/frontend/src/components/Select.tsx @@ -0,0 +1,42 @@ +import { forwardRef, SelectHTMLAttributes } from 'react'; + +const Select = forwardRef<HTMLSelectElement, { + label: string, + options: { label: string, value: any }[] +} & SelectHTMLAttributes<HTMLSelectElement>>(({ + label, + options, + ...inputProps +}, ref) => { + return ( + <div className="mb-5"> + {!!label && ( + <label + htmlFor={inputProps.id} + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + {label} + </label> + )} + <select + {...inputProps} + ref={ref} + className="bg-transparent border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-emerald-500 focus:border-emerald-500 block w-full p-2.5 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-emerald-500 dark:focus:border-emerald-500" + > + {options.map((opt) => ( + <option + key={opt.value} + value={opt.value} + className="bg-white dark:bg-gray-800 dark:text-white" + > + {opt.label} + </option> + ))} + </select> + </div> + ); +}); + +Select.displayName = 'Select'; + +export default Select; \ No newline at end of file diff --git a/frontend/src/components/TextArea.tsx b/frontend/src/components/TextArea.tsx new file mode 100644 index 0000000..dc73376 --- /dev/null +++ b/frontend/src/components/TextArea.tsx @@ -0,0 +1,29 @@ +import { forwardRef, TextareaHTMLAttributes } from 'react'; + + +const TextArea = forwardRef<HTMLTextAreaElement, { label: string } & TextareaHTMLAttributes<HTMLTextAreaElement>>(({ + label, + ...inputProps +}, ref) => { + return ( + <div className="mb-5"> + {!!label && ( + <label + htmlFor={inputProps.id} + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + {label} + </label> + )} + <textarea + {...inputProps} + className="block p-2.5 w-full text-sm text-gray-900 bg-transparent rounded-lg border border-gray-300 focus:ring-emerald-500 focus:border-emerald-500 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-emerald-500 dark:focus:border-emerald-500" + ref={ref} + /> + </div> + ); +}); + +TextArea.displayName = 'TextArea'; + +export default TextArea; \ No newline at end of file From 24f3c21e4a6ed09f8ef3fa18d6e8af9ebed3404e Mon Sep 17 00:00:00 2001 From: liv <o.ronda@uqconnect.edu.au> Date: Fri, 24 May 2024 19:32:22 +1000 Subject: [PATCH 06/12] check user role for adding courses and exams --- backend/src/routes/courses.ts | 16 +++++++++++++++- backend/src/routes/exams.ts | 21 +++++++++++++++++---- backend/src/types.ts | 10 +++++++++- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/backend/src/routes/courses.ts b/backend/src/routes/courses.ts index 6c1a4a1..d017fb9 100644 --- a/backend/src/routes/courses.ts +++ b/backend/src/routes/courses.ts @@ -1,6 +1,6 @@ // Imports import { Request, Response } from 'express'; // Import Request and Response types -import { CourseBodyParams, CourseQueryParams, CourseRouteParams, RateObject } from '../types'; +import { CourseBodyParams, CourseQueryParams, CourseRouteParams, RateObject, UserRole } from '../types'; import { getConnection } from '../db/index'; import { User as UserDb } from '../db/User'; @@ -64,6 +64,7 @@ export async function postCourse(req: Request<any, any, CourseBodyParams>, res: courseName, courseDescription, university, + userId, } = req.body; if (!courseCode || !courseName || !courseDescription || !university) { @@ -71,6 +72,19 @@ export async function postCourse(req: Request<any, any, CourseBodyParams>, res: return; } + if (!userId) { + res.status(400).json('Missing userId'); + return; + } + + const userRepository = getConnection().getRepository(UserDb); + const user = await userRepository.findOne({ where: { userId } }); + + if (!user || user.role !== UserRole.ADMIN) { + res.status(403).json('Unauthorized user'); + return; + } + const courseRepository = getConnection().getRepository(CourseDb); const course = await courseRepository.findOne({ where: { courseCode } }); if (course) { diff --git a/backend/src/routes/exams.ts b/backend/src/routes/exams.ts index 6aedee9..ee3aaa9 100644 --- a/backend/src/routes/exams.ts +++ b/backend/src/routes/exams.ts @@ -1,14 +1,13 @@ // Imports import { Request, Response, Router } from 'express'; // Import Request and Response types -import { ExamBodyParams, ExamRouteParams } from '../types'; +import { ExamBodyParams, ExamRouteParams, UserRole } from '../types'; -import { getConnection } from '../db/index'; +import { getConnection } from '../db'; import { Course as CourseDb } from '../db/Course'; import { Exam as ExamDb } from '../db/Exam'; import { Question as QuestionDb } from '../db/Questions'; +import { User as UserDb } from '../db/User'; -// Export Routers -export const router = Router(); export async function postExam(req: Request<any, any, ExamBodyParams>, res: Response) { const { @@ -16,6 +15,7 @@ export async function postExam(req: Request<any, any, ExamBodyParams>, res: Resp examSemester, examType, courseCode, + userId, } = req.body; // Check key @@ -24,6 +24,19 @@ export async function postExam(req: Request<any, any, ExamBodyParams>, res: Resp return; } + if (!userId) { + res.status(400).json('Missing userId'); + return; + } + + const userRepository = getConnection().getRepository(UserDb); + const user = await userRepository.findOne({ where: { userId } }); + + if (!user || user.role !== UserRole.ADMIN) { + res.status(403).json('Unauthorized user'); + return; + } + // Check course code const courseRepository = getConnection().getRepository(CourseDb); const course = await courseRepository.findOne({ where: { courseCode } }); diff --git a/backend/src/types.ts b/backend/src/types.ts index fcd86c4..f7ac330 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -61,13 +61,16 @@ export type QuestionQueryParams = { export type ExamBodyParams = Partial<Omit<Exam, 'examId'>> & { courseCode?: string + userId?: string } export type ExamRouteParams = { examId: number } -export type CourseBodyParams = Course +export type CourseBodyParams = Course & { + userId?: string +} export type CourseRouteParams = { courseCode: string @@ -82,3 +85,8 @@ export type RateObject = { courseCode: string stars: number } + +export enum UserRole { + USER = 0, + ADMIN = 1, +} \ No newline at end of file From 75ba674501e03cc849071b746f3339cadb36e9da Mon Sep 17 00:00:00 2001 From: liv <o.ronda@uqconnect.edu.au> Date: Fri, 24 May 2024 21:11:05 +1000 Subject: [PATCH 07/12] added frontend api for adding courses & exams --- frontend/src/api/usePostCourse.ts | 41 +++++++++++++++++++ frontend/src/api/usePostExam.ts | 39 ++++++++++++++++++ .../courses/[courseCode]/exams/add/page.tsx | 18 +++++++- .../src/app/courses/[courseCode]/page.tsx | 23 +++++++++-- frontend/src/app/courses/add/page.tsx | 16 +++++++- frontend/src/app/courses/page.tsx | 2 +- .../src/components/Courses/AddCourseForm.tsx | 8 +--- frontend/src/components/Exams/AddExamForm.tsx | 7 +--- frontend/src/types.ts | 16 +++++++- 9 files changed, 149 insertions(+), 21 deletions(-) create mode 100644 frontend/src/api/usePostCourse.ts create mode 100644 frontend/src/api/usePostExam.ts diff --git a/frontend/src/api/usePostCourse.ts b/frontend/src/api/usePostCourse.ts new file mode 100644 index 0000000..d92d034 --- /dev/null +++ b/frontend/src/api/usePostCourse.ts @@ -0,0 +1,41 @@ +'use client'; +import { useSWRConfig } from 'swr'; +import toast from 'react-hot-toast'; +import { AddCourseFormFields } from '@/types'; + +const ENDPOINT = `${process.env.API_URL}/api`; + +export default function usePostCourse(onSuccess?: () => void) { + // use the global mutate + const { mutate } = useSWRConfig(); + + const postCourse = async (userId: string, course: AddCourseFormFields) => { + // send the course data + const res = await fetch(`${ENDPOINT}/courses`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + userId, + courseCode: course.courseCode, + courseName: course.courseName, + courseDescription: course.courseDescription, + university: course.university, + }), + }); + + if (res.ok) { + toast.success('Added course', { id: 'coursePost' }); + // course successfully created, invalidate the courses cache so it refetches updated data + await mutate(ENDPOINT + '/courses'); + onSuccess?.(); + } else { + toast.error('Error adding course', { id: 'coursePostError' }); + } + }; + + return { + postCourse, + }; +} \ No newline at end of file diff --git a/frontend/src/api/usePostExam.ts b/frontend/src/api/usePostExam.ts new file mode 100644 index 0000000..29fcc88 --- /dev/null +++ b/frontend/src/api/usePostExam.ts @@ -0,0 +1,39 @@ +'use client'; +import { useSWRConfig } from 'swr'; +import toast from 'react-hot-toast'; +import { AddExamFormFields } from '@/types'; + +const ENDPOINT = `${process.env.API_URL}/api`; + +export default function usePostExam(courseCode: string, onSuccess?: () => void) { + // use the global mutate + const { mutate } = useSWRConfig(); + + const postExam = async (userId: string, exam: AddExamFormFields) => { + // send the exam data + const res = await fetch(`${ENDPOINT}/exams`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + userId, + courseCode, + ...exam, + }), + }); + + if (res.ok) { + toast.success('Added exam', { id: 'examPost' }); + // exam successfully created, invalidate the course's exam cache so it refetches updated data + await mutate(ENDPOINT + '/courses/' + courseCode + '/exams'); + onSuccess?.(); + } else { + toast.error('Error adding exam', { id: 'examPostError' }); + } + }; + + return { + postExam, + }; +} \ No newline at end of file diff --git a/frontend/src/app/courses/[courseCode]/exams/add/page.tsx b/frontend/src/app/courses/[courseCode]/exams/add/page.tsx index f3d3c7f..de033dd 100644 --- a/frontend/src/app/courses/[courseCode]/exams/add/page.tsx +++ b/frontend/src/app/courses/[courseCode]/exams/add/page.tsx @@ -4,13 +4,27 @@ import requireAuth from '@/app/requireAuth'; import { UserRole } from '@/types'; import Title from '@/components/Title'; import AddExamForm from '@/components/Exams/AddExamForm'; +import { useUser } from '@auth0/nextjs-auth0/client'; +import { useRouter } from 'next/navigation'; +import usePostExam from '@/api/usePostExam'; + +function AddExam({ params }: { params: { courseCode: string } }) { + const { user } = useUser(); + const router = useRouter(); + const { postExam } = usePostExam(params.courseCode, () => { + router.push(`/courses/${params.courseCode}`); + }); -function AddExam(){ return ( <main> <div className="max-w-4xl w-full mx-auto flex flex-col items-center justify-center"> <Title title="Add Exam"/> - <AddExamForm onSubmit={(data) => console.log(data)}/> + <AddExamForm + onSubmit={async (data) => { + console.log(data); + await postExam(user?.sub || '', data); + }} + /> </div> </main> ); diff --git a/frontend/src/app/courses/[courseCode]/page.tsx b/frontend/src/app/courses/[courseCode]/page.tsx index a0c34b7..bbc4f20 100644 --- a/frontend/src/app/courses/[courseCode]/page.tsx +++ b/frontend/src/app/courses/[courseCode]/page.tsx @@ -1,15 +1,16 @@ 'use client'; import useCourse from '@/api/useCourse'; import useExams from '@/api/useExams'; -import { Course, Exam } from '@/types'; +import { Course, Exam, UserRole } from '@/types'; import requireAuth from '@/app/requireAuth'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { IconStarFilled } from '@tabler/icons-react'; +import { IconCirclePlus, IconStarFilled } from '@tabler/icons-react'; import Card from '@/components/Card'; import { useUser } from '@auth0/nextjs-auth0/client'; import useUpdateCourse from '@/api/useUpdateCourse'; import Link from 'next/link'; import Title from '@/components/Title'; +import requireRole from '@/app/requireRole'; function CourseDescription({ course }: { course: Course }) { @@ -42,6 +43,21 @@ function CourseExams({ course }: { course: Course }) { const groupedExams = useMemo(() => exams ? mapExamsBySemester(exams) : {}, [exams]); + const AddExam = requireRole( + () => ( + <Link href={`/courses/${course.courseCode}/exams/add`}> + <Card> + <div className="flex flex-col items-center justify-center"> + <p className="flex items-center justify-center gap-1 text-md text-gray-500 dark:text-gray-400 font-medium"> + Add Exam <IconCirclePlus/> + </p> + </div> + </Card> + </Link> + ), + UserRole.ADMIN, + ); + return ( <div> {isLoading && ( @@ -95,9 +111,10 @@ function CourseExams({ course }: { course: Course }) { </div> </div> ))} + <AddExam/> </div> ) : ( - <p className="text-xl text-zinc-900 dark:text-white">No exams available</p> + <AddExam/> ) )} </div> diff --git a/frontend/src/app/courses/add/page.tsx b/frontend/src/app/courses/add/page.tsx index 18d07b7..4e26f3c 100644 --- a/frontend/src/app/courses/add/page.tsx +++ b/frontend/src/app/courses/add/page.tsx @@ -4,13 +4,27 @@ import requireAuth from '@/app/requireAuth'; import { UserRole } from '@/types'; import AddCourseForm from '@/components/Courses/AddCourseForm'; import Title from '@/components/Title'; +import usePostCourse from '@/api/usePostCourse'; +import { useRouter } from 'next/navigation'; +import { useUser } from '@auth0/nextjs-auth0/client'; function AddCourse(){ + const { user } = useUser(); + const router = useRouter(); + const { postCourse } = usePostCourse(() => { + router.push('/courses'); + }); + return ( <main> <div className="max-w-4xl w-full mx-auto flex flex-col items-center justify-center"> <Title title="Add Course"/> - <AddCourseForm onSubmit={(data) => console.log(data)}/> + <AddCourseForm + onSubmit={async (data) => { + console.log(data); + await postCourse(user?.sub || '', data); + }} + /> </div> </main> ); diff --git a/frontend/src/app/courses/page.tsx b/frontend/src/app/courses/page.tsx index 135771e..ce440ae 100644 --- a/frontend/src/app/courses/page.tsx +++ b/frontend/src/app/courses/page.tsx @@ -79,7 +79,7 @@ function Courses() { <AddCourse /> </div> ) : ( - <p className="text-xl text-zinc-900 dark:text-white">No courses available</p> + <AddCourse/> )} </div> </main> diff --git a/frontend/src/components/Courses/AddCourseForm.tsx b/frontend/src/components/Courses/AddCourseForm.tsx index 3d40615..2da2511 100644 --- a/frontend/src/components/Courses/AddCourseForm.tsx +++ b/frontend/src/components/Courses/AddCourseForm.tsx @@ -3,18 +3,12 @@ import Input from '@/components/Input'; import Select from '@/components/Select'; import TextArea from '@/components/TextArea'; import { Button } from '@/components/Button'; +import { AddCourseFormFields } from '@/types'; const universityOptions = [ { label: 'University of Queensland', value: 'UQ' }, ]; -type AddCourseFormFields = { - courseCode: string - courseName: string - courseDescription: string - university: string -} - export default function AddCourseForm({ onSubmit }: { onSubmit: (data: AddCourseFormFields) => void }) { const { register, handleSubmit } = useForm<AddCourseFormFields>(); diff --git a/frontend/src/components/Exams/AddExamForm.tsx b/frontend/src/components/Exams/AddExamForm.tsx index 80f1f9e..715b92a 100644 --- a/frontend/src/components/Exams/AddExamForm.tsx +++ b/frontend/src/components/Exams/AddExamForm.tsx @@ -2,6 +2,7 @@ import { useForm } from 'react-hook-form'; import Input from '@/components/Input'; import Select from '@/components/Select'; import { Button } from '@/components/Button'; +import { AddExamFormFields } from '@/types'; const examTypeOptions = [ { label: 'Final', value: 'final' }, @@ -14,12 +15,6 @@ const examSemesterOptions = [ { label: 'Summer Semester', value: 3 }, ]; -type AddExamFormFields = { - examYear: number - examSemester: number - examType: string -} - export default function AddExamForm({ onSubmit }: { onSubmit: (data: AddExamFormFields) => void }) { const { register, handleSubmit } = useForm<AddExamFormFields>(); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index aa826ca..3154bc5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -57,4 +57,18 @@ export type RecentChange = { export enum UserRole { USER = 0, ADMIN = 1, -} \ No newline at end of file +} + +export type AddCourseFormFields = { + courseCode: string + courseName: string + courseDescription: string + university: string +} + + +export type AddExamFormFields = { + examYear: number + examSemester: number + examType: string +} From 993ad8a5e660a89dde3328bcedda0772c874dbd1 Mon Sep 17 00:00:00 2001 From: liv <o.ronda@uqconnect.edu.au> Date: Fri, 24 May 2024 21:42:15 +1000 Subject: [PATCH 08/12] updated some tests maybe --- integration_tests/backend/test_courses.py | 6 +++++- integration_tests/backend/test_exams.py | 12 ++++++++++++ integration_tests/backend/test_full_suite.py | 15 +++++++++++++-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/integration_tests/backend/test_courses.py b/integration_tests/backend/test_courses.py index 28b4e7d..1d04e1f 100644 --- a/integration_tests/backend/test_courses.py +++ b/integration_tests/backend/test_courses.py @@ -76,7 +76,8 @@ def setUp(self): # Creates a new user userData = { - "userId": self.USER_ID + "userId": self.USER_ID, + "role": 1 # admin role } newUser = self.User(**userData) @@ -118,6 +119,7 @@ def test_course_post(self): Checks for the correct response message """ courseData = { + "userId": self.USER_ID, "courseCode": "COMP3506", "courseName": "Data Structures and Algorithms", "courseDescription": "Doing some DSA", @@ -148,6 +150,7 @@ def test_course_post_null_coursecode(self): Checks for the correct response message """ courseData = { + "userId": self.USER_ID, "courseCode": "", "courseName": "Software Architecture", "courseDescription": "Doing some software architecture stuff with Richard and Evan (my bestie)", @@ -173,6 +176,7 @@ def test_course_post_duplicate_coursecode(self): Checks for the correct response message """ courseData = { + "userId": self.USER_ID, "courseCode": "CSSE6400", "courseName": "Software Architecture", "courseDescription": "Doing some software architecture stuff with Richard and Evan (my bestie)", diff --git a/integration_tests/backend/test_exams.py b/integration_tests/backend/test_exams.py index 280ac8b..f0267d7 100644 --- a/integration_tests/backend/test_exams.py +++ b/integration_tests/backend/test_exams.py @@ -30,6 +30,15 @@ def setUp(self): self.COMMENT_PNG = None self.PARENT_COMMENT_ID = None + # Creates a new user + userData = { + "userId": self.USER_ID, + "role": 1 # admin role + } + + newUser = self.User(**userData) + self.session.add(newUser) + # Create course courseData = { "courseCode": self.COURSE_CODE, @@ -94,6 +103,7 @@ def test_exam_post(self): Checks for the correct response message """ body = { + "userId": self.USER_ID, "examYear": self.EXAM_YEAR, "examSemester": self.EXAM_SEMESTER, "examType": self.EXAM_TYPE, @@ -118,6 +128,7 @@ def test_exam_post_missing_courseCode(self): Checks for the correct response message """ body = { + "userId": self.USER_ID, "examYear": self.EXAM_YEAR, "examSemester": self.EXAM_SEMESTER, "examType": self.EXAM_TYPE @@ -140,6 +151,7 @@ def test_exam_post_course_not_found(self): Checks for the correct response message """ body = { + "userId": self.USER_ID, "examYear": self.EXAM_YEAR, "examSemester": self.EXAM_SEMESTER, "examType": self.EXAM_TYPE, diff --git a/integration_tests/backend/test_full_suite.py b/integration_tests/backend/test_full_suite.py index f470838..1762962 100644 --- a/integration_tests/backend/test_full_suite.py +++ b/integration_tests/backend/test_full_suite.py @@ -78,8 +78,19 @@ def test_full_1(self): response = requests.post(self.host() + '/users', json=user) self.assertEqual(201, response.status_code) + # Give them admin perms (has to be done manually for ~security~ reasons) + created_user = self.session.query(self.User).filter_by( + userId=str(self.USER_ID)).first() + created_user.role = 1 + self.session.commit() + + # Check they have the correct role + response = requests.get(self.host() + '/users/' + str(self.USER_ID) + '/role') + self.assertEqual(200, response.status_code) + self.assertEqual(1, response.json()) + # Create a course - response = requests.post(self.host() + '/courses', json=course) + response = requests.post(self.host() + '/courses', json={**course, "userId": self.USER_ID}) self.assertEqual(201, response.status_code) # Check the course params set correctly @@ -106,7 +117,7 @@ def test_full_1(self): } # Create the exam - response = requests.post(self.host() + '/exams', json=exam) + response = requests.post(self.host() + '/exams', json={**exam, "userId": self.USER_ID}) self.assertEqual(201, response.status_code) # Testing examId returned and is a valid int examId = response.json()['examId'] From 754ed28e992c866e20b837ccc802a9077ac6c4a3 Mon Sep 17 00:00:00 2001 From: liv <o.ronda@uqconnect.edu.au> Date: Fri, 24 May 2024 22:09:03 +1000 Subject: [PATCH 09/12] test signing in for playwright they work locally just need some more thinking on how to deal with auth0 in docker + github actions --- integration_tests/frontend/test_full_e2e.py | 67 +++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 integration_tests/frontend/test_full_e2e.py diff --git a/integration_tests/frontend/test_full_e2e.py b/integration_tests/frontend/test_full_e2e.py new file mode 100644 index 0000000..9754fb5 --- /dev/null +++ b/integration_tests/frontend/test_full_e2e.py @@ -0,0 +1,67 @@ +# import re +# from ..base import BaseCase +# from playwright.sync_api import sync_playwright, expect +# +# +# class FullE2E(BaseCase): +# def setUp(self): +# self.p = sync_playwright().start() +# self.browser = self.p.chromium.launch() +# self.context = self.browser.new_context() +# self.page = self.context.new_page() +# self.page.goto("http://frontend:3000") +# self.session = self.get_db_session() +# self.User = self.Base.classes['user'] +# self.session.commit() +# +# def tearDown(self): +# self.page.close() +# self.context.close() +# self.browser.close() +# self.p.stop() +# +# +# def test_e2e(self): +# # Sign in as admin user +# self.page.get_by_role("link", name="Sign in").click() +# self.page.get_by_label("Email address*").click() +# self.page.get_by_label("Email address*").fill("admin@unibasement.local") +# self.page.get_by_label("Password*").click() +# self.page.get_by_label("Password*").fill("Admin_Password") +# self.page.get_by_role("button", name="Continue", exact=True).click() +# self.page.wait_for_url("http://frontend:3000/") +# +# # Check that the user is redirected to the home page which shows a greeting +# # <greeting>, admin +# expect(self.page.locator("body")).to_contain_text(", admin") +# +# print(self.session.query(self.User).all()) +# +# created_user = self.session.query(self.User).filter_by( +# userId="auth0|6650788478a66f328f8351b4").first() +# created_user.role = 1 +# self.session.commit() +# +# # Go to courses +# self.page.get_by_role("link", name="Courses").click() +# self.page.wait_for_url("http://frontend:3000/courses") +# +# # Expect to see the add courses button as admin +# expect(self.page.get_by_role("link", name="Add Course")).to_be_visible() +# +# # Add a course +# self.page.get_by_role("link", name="Add Course").click() +# self.page.wait_for_url("http://frontend:3000/courses/add") +# expect(self.page.get_by_text("Add Course")).to_be_visible() +# +# # Fill in form +# self.page.locator("input[name=\"courseCode\"]").click() +# self.page.locator("input[name=\"courseCode\"]").fill("CSSE6400") +# self.page.locator("input[name=\"courseName\"]").click() +# self.page.locator("input[name=\"courseName\"]").fill("Software Architectures") +# self.page.locator("textarea[name=\"courseDescription\"]").click() +# self.page.locator("textarea[name=\"courseDescription\"]").fill("My favourite course :)") +# self.page.get_by_role("button", name="Submit").click() +# +# # Expect to be redirected to the courses page on submit +# self.page.wait_for_url("http://frontend:3000/courses") From f548a3fa26f5ccd03d662456ea344b3a8407ddc2 Mon Sep 17 00:00:00 2001 From: liv <o.ronda@uqconnect.edu.au> Date: Sat, 25 May 2024 22:00:23 +1000 Subject: [PATCH 10/12] fix trying to fetch recent changes for no pinned courses --- frontend/src/api/useRecentChanges.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/frontend/src/api/useRecentChanges.ts b/frontend/src/api/useRecentChanges.ts index 7bae3c7..fef9846 100644 --- a/frontend/src/api/useRecentChanges.ts +++ b/frontend/src/api/useRecentChanges.ts @@ -2,25 +2,32 @@ import useSWR, { Fetcher } from 'swr'; import { RecentChange } from '@/types'; import { usePinned } from '@/api/usePins'; -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import toast from 'react-hot-toast'; const ENDPOINT = `${process.env.API_URL}/api/recent_changes`; -const fetcher: Fetcher<RecentChange[], string> = async (...args) => { - const res = await fetch(...args); - if (!res.ok) { - throw new Error(await res.json()); - } - - return res.json(); -}; export default function useRecentChanges() { const lastVisited: string | null = localStorage.getItem('lastVisited'); const { pinned } = usePinned(); + const fetcher: Fetcher<RecentChange[], string> = useCallback(async (...args) => { + if (!pinned.length) { + return []; + } + + const res = await fetch(...args); + + if (!res.ok) { + throw new Error(await res.json()); + } + + return res.json(); + }, [pinned]); + + let url = ENDPOINT + `?lastVisited=${lastVisited}`; pinned.forEach((p) => { url += `&courseCodes=${p.code}`; From 3bc5f81f44d045225f5461a12d212dbddea84dc2 Mon Sep 17 00:00:00 2001 From: liv <o.ronda@uqconnect.edu.au> Date: Sat, 25 May 2024 22:03:51 +1000 Subject: [PATCH 11/12] finished e2e test and excluded it from being run in actions --- integration_tests/Dockerfile | 2 +- integration_tests/frontend/test_full_e2e.py | 267 +++++++++++++++----- 2 files changed, 201 insertions(+), 68 deletions(-) diff --git a/integration_tests/Dockerfile b/integration_tests/Dockerfile index 08285a0..8fb5566 100644 --- a/integration_tests/Dockerfile +++ b/integration_tests/Dockerfile @@ -17,4 +17,4 @@ RUN playwright install chromium COPY . tests # Run the unit tests -CMD bash -c "sleep 10 && python -m pytest -v --report-log report.log tests" \ No newline at end of file +CMD bash -c "sleep 10 && python -m pytest -k "not test_e2e" -v --report-log report.log tests" \ No newline at end of file diff --git a/integration_tests/frontend/test_full_e2e.py b/integration_tests/frontend/test_full_e2e.py index 9754fb5..211dcef 100644 --- a/integration_tests/frontend/test_full_e2e.py +++ b/integration_tests/frontend/test_full_e2e.py @@ -1,67 +1,200 @@ -# import re -# from ..base import BaseCase -# from playwright.sync_api import sync_playwright, expect -# -# -# class FullE2E(BaseCase): -# def setUp(self): -# self.p = sync_playwright().start() -# self.browser = self.p.chromium.launch() -# self.context = self.browser.new_context() -# self.page = self.context.new_page() -# self.page.goto("http://frontend:3000") -# self.session = self.get_db_session() -# self.User = self.Base.classes['user'] -# self.session.commit() -# -# def tearDown(self): -# self.page.close() -# self.context.close() -# self.browser.close() -# self.p.stop() -# -# -# def test_e2e(self): -# # Sign in as admin user -# self.page.get_by_role("link", name="Sign in").click() -# self.page.get_by_label("Email address*").click() -# self.page.get_by_label("Email address*").fill("admin@unibasement.local") -# self.page.get_by_label("Password*").click() -# self.page.get_by_label("Password*").fill("Admin_Password") -# self.page.get_by_role("button", name="Continue", exact=True).click() -# self.page.wait_for_url("http://frontend:3000/") -# -# # Check that the user is redirected to the home page which shows a greeting -# # <greeting>, admin -# expect(self.page.locator("body")).to_contain_text(", admin") -# -# print(self.session.query(self.User).all()) -# -# created_user = self.session.query(self.User).filter_by( -# userId="auth0|6650788478a66f328f8351b4").first() -# created_user.role = 1 -# self.session.commit() -# -# # Go to courses -# self.page.get_by_role("link", name="Courses").click() -# self.page.wait_for_url("http://frontend:3000/courses") -# -# # Expect to see the add courses button as admin -# expect(self.page.get_by_role("link", name="Add Course")).to_be_visible() -# -# # Add a course -# self.page.get_by_role("link", name="Add Course").click() -# self.page.wait_for_url("http://frontend:3000/courses/add") -# expect(self.page.get_by_text("Add Course")).to_be_visible() -# -# # Fill in form -# self.page.locator("input[name=\"courseCode\"]").click() -# self.page.locator("input[name=\"courseCode\"]").fill("CSSE6400") -# self.page.locator("input[name=\"courseName\"]").click() -# self.page.locator("input[name=\"courseName\"]").fill("Software Architectures") -# self.page.locator("textarea[name=\"courseDescription\"]").click() -# self.page.locator("textarea[name=\"courseDescription\"]").fill("My favourite course :)") -# self.page.get_by_role("button", name="Submit").click() -# -# # Expect to be redirected to the courses page on submit -# self.page.wait_for_url("http://frontend:3000/courses") +import re + +from playwright.sync_api import sync_playwright, expect + +from ..base import BaseCase + +""" +This test can only be run locally since it depends on the frontend +being run using npm and having auth0 credentials setup +""" + + +class FullE2E(BaseCase): + def setUp(self): + self.p = sync_playwright().start() + self.browser = self.p.chromium.launch() + self.context = self.browser.new_context(color_scheme="dark") + self.page = self.context.new_page() + self.page.goto("http://localhost:3000") + self.session = self.get_db_session() + self.User = self.Base.classes['user'] + + # Creates the admin user in db + admin_user_data = { + "userId": "auth0|6650788478a66f328f8351b4", + "role": 1 # admin role + } + + admin_user = self.User(**admin_user_data) + self.session.add(admin_user) + + # Creates the regular user in db + regular_user_data = { + "userId": "auth0|6650784678a66f328f835197", + "role": 0 # regular user role + } + + regular_user = self.User(**regular_user_data) + self.session.add(regular_user) + + self.session.commit() + + def tearDown(self): + self.page.close() + self.context.close() + self.browser.close() + self.p.stop() + + def test_e2e(self): + # Sign in as admin user + self.page.get_by_role("link", name="Sign in").click() + self.page.get_by_label("Email address*").click() + self.page.get_by_label("Email address*").fill("admin@unibasement.local") + self.page.get_by_label("Password*").click() + self.page.get_by_label("Password*").fill("Admin_Password") + self.page.get_by_role("button", name="Continue", exact=True).click() + self.page.wait_for_url("http://localhost:3000/") + + # Check that the user is redirected to the home page which shows a greeting + # <greeting>, admin + expect(self.page.locator("body")).to_contain_text(", admin") + + # Go to courses + self.page.get_by_role("link", name="Courses").click() + self.page.wait_for_url("http://localhost:3000/courses") + + # Expect to see the add courses button as admin + expect(self.page.get_by_role("link", name="Add Course")).to_be_visible() + + # Add a course + self.page.get_by_role("link", name="Add Course").click() + self.page.wait_for_url("http://localhost:3000/courses/add") + expect(self.page.get_by_text("Add Course")).to_be_visible() + + # Fill in form + self.page.locator("input[name=\"courseCode\"]").click() + self.page.locator("input[name=\"courseCode\"]").fill("CSSE6400") + self.page.locator("input[name=\"courseName\"]").click() + self.page.locator("input[name=\"courseName\"]").fill("Software Architecture") + self.page.locator("textarea[name=\"courseDescription\"]").click() + self.page.locator("textarea[name=\"courseDescription\"]").fill("My favourite course :)") + self.page.get_by_role("button", name="Submit").click() + + # Expect to be redirected to the courses page on submit + self.page.wait_for_url("http://localhost:3000/courses") + + # Expect to see the course on the courses page + expect(self.page.get_by_role("link", name="CSSE6400 Software Architecture")).to_be_visible() + self.page.get_by_role("link", name="CSSE6400 Software Architecture").click() + + # Expect to be redirected to the CSSE6400 page + self.page.wait_for_url("http://localhost:3000/courses/CSSE6400") + + # Expect to see the course description + expect(self.page.get_by_text("My favourite course :)")).to_be_visible() + + # Go to past exams + self.page.get_by_role("button", name="Past Exams").click() + + # Expect to see the add exam button as admin + expect(self.page.get_by_role("link", name="Add Exam")).to_be_visible() + + # Add an exam + self.page.get_by_role("link", name="Add Exam").click() + self.page.wait_for_url("http://localhost:3000/courses/CSSE6400/exams/add") + expect(self.page.get_by_text("Add Exam")).to_be_visible() + + # Fill in form + self.page.get_by_role("textbox").click() + self.page.get_by_role("textbox").fill("2023") + self.page.locator("select[name=\"examSemester\"]").select_option("1") + self.page.locator("select[name=\"examType\"]").select_option("final") + self.page.get_by_role("button", name="Submit").click() + + # Expect to be redirected to the CSSE6400 page on submit + self.page.wait_for_url("http://localhost:3000/courses/CSSE6400") + self.page.get_by_role("button", name="Past Exams").click() + + # Expect to see the exam on the past exams page + expect(self.page.get_by_role("heading", name="2023")).to_be_visible() + expect(self.page.get_by_role("heading", name="Semester")).to_be_visible() + expect(self.page.get_by_role("link", name="final")).to_be_visible() + + # Go to the exam page + self.page.get_by_role("link", name="final").click() + self.page.wait_for_url("http://localhost:3000/courses/CSSE6400/exams/1") + + # Expect to be able to add a question + expect(self.page.get_by_role("button", name="Add Question")).to_be_visible() + + # Add a question + self.page.get_by_role("button", name="Add Question").click() + self.page.get_by_placeholder("Add an answer...").fill("This is a test question.") + self.page.get_by_role("button", name="Post").click() + + # Add an answer + self.page.get_by_role("button", name="Answers").click() + self.page.get_by_role("button", name="Add Answer").click() + self.page.get_by_placeholder("Add an answer...").fill("An example answer") + self.page.get_by_role("button", name="Post").click() + + # Logout as admin + self.page.get_by_text("admin").click() + self.page.get_by_role("link", name="Logout").click() + self.page.wait_for_url("http://localhost:3000/") + + # Sign in as regular user + self.page.get_by_role("link", name="Sign in").click() + self.page.get_by_label("Email address*").click() + self.page.get_by_label("Email address*").fill("user@unibasement.local") + self.page.get_by_label("Password*").click() + self.page.get_by_label("Password*").fill("User_Password") + self.page.get_by_role("button", name="Continue", exact=True).click() + self.page.wait_for_url("http://localhost:3000/") + + # Check that the user is redirected to the home page which shows a greeting + # <greeting>, user + expect(self.page.locator("body")).to_contain_text(", user") + + # Go to courses & find the CSSE6400 course + self.page.get_by_role("link", name="Courses").click() + self.page.wait_for_url("http://localhost:3000/courses") + expect(self.page.get_by_text("Add Course")).not_to_be_visible() + expect(self.page.get_by_role("link", name="CSSE6400 Software Architecture")).to_be_visible() + self.page.get_by_role("link", name="CSSE6400 Software Architecture").click() + self.page.wait_for_url("http://localhost:3000/courses/CSSE6400") + + # Check rating the course + self.page.get_by_role("button", name="Rate The Course").click() + expect(self.page.locator("body")).to_contain_text("0 rating") + self.page.locator("div").filter( + has_text=re.compile(r"^DescriptionPast ExamsRate The CourseLeave your rating:$")).locator("path").nth( + 4).click() + expect(self.page.locator("body")).to_contain_text("1 rating") + + # Go to exams + self.page.get_by_role("button", name="Past Exams").click() + expect(self.page.get_by_text("Add Exam")).not_to_be_visible() + expect(self.page.get_by_role("link", name="final")).to_be_visible() + + # Go to the exam page + self.page.get_by_role("link", name="final").click() + self.page.wait_for_url("http://localhost:3000/courses/CSSE6400/exams/1") + + # Expect to see the question + expect(self.page.get_by_text("This is a test question.")).to_be_visible() + + # Expect to see the answer + self.page.get_by_role("button", name="Answers").click() + expect(self.page.get_by_text("An example answer")).to_be_visible() + + # Check upvoting the answer + expect(self.page.get_by_text("1", exact=True)).not_to_be_visible() + self.page.locator("#accordion-collapse-body").get_by_role("button").nth(1).click() + expect(self.page.get_by_text("1", exact=True)).to_be_visible() + + # Logout as regular user + self.page.get_by_text("user").click() + self.page.get_by_role("link", name="Logout").click() + self.page.wait_for_url("http://localhost:3000/") From 9d127368348715dfc7cb0b34229e08b5c3bff326 Mon Sep 17 00:00:00 2001 From: liv <o.ronda@uqconnect.edu.au> Date: Sat, 25 May 2024 22:08:48 +1000 Subject: [PATCH 12/12] escape quotes in run cmd --- integration_tests/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration_tests/Dockerfile b/integration_tests/Dockerfile index 8fb5566..0d7e105 100644 --- a/integration_tests/Dockerfile +++ b/integration_tests/Dockerfile @@ -17,4 +17,4 @@ RUN playwright install chromium COPY . tests # Run the unit tests -CMD bash -c "sleep 10 && python -m pytest -k "not test_e2e" -v --report-log report.log tests" \ No newline at end of file +CMD bash -c "sleep 10 && python -m pytest -k \"not test_e2e\" -v --report-log report.log tests" \ No newline at end of file