diff --git a/backend/project/endpoints/authentication/auth.py b/backend/project/endpoints/authentication/auth.py
index ab59ef5b..86bf96dc 100644
--- a/backend/project/endpoints/authentication/auth.py
+++ b/backend/project/endpoints/authentication/auth.py
@@ -45,11 +45,6 @@ def microsoft_authentication():
res = requests.post(f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token",
data=data,
timeout=5)
- if res.status_code != 200:
- abort(make_response((
- {"message":
- "An error occured while trying to authenticate your authorization code"},
- 500)))
token = res.json()["access_token"]
profile_res = requests.get("https://graph.microsoft.com/v1.0/me",
headers={"Authorization":f"Bearer {token}"},
diff --git a/backend/project/endpoints/authentication/me.py b/backend/project/endpoints/authentication/me.py
index c4f19a70..bd43d8aa 100644
--- a/backend/project/endpoints/authentication/me.py
+++ b/backend/project/endpoints/authentication/me.py
@@ -23,7 +23,7 @@ def get(self):
"""
Will return all user data associated with the access token in the request
"""
- uid = get_jwt_identity
+ uid = get_jwt_identity()
return query_by_id_from_model(User,
"uid",
diff --git a/backend/project/endpoints/courses/join_codes/course_join_codes.py b/backend/project/endpoints/courses/join_codes/course_join_codes.py
index a2401783..1eb7e00c 100644
--- a/backend/project/endpoints/courses/join_codes/course_join_codes.py
+++ b/backend/project/endpoints/courses/join_codes/course_join_codes.py
@@ -34,7 +34,7 @@ def get(self, course_id):
return query_selected_from_model(
CourseShareCode,
urljoin(f"{RESPONSE_URL}/", f"{str(course_id)}/", "join_codes"),
- select_values=["join_code", "expiry_time"],
+ select_values=["join_code", "expiry_time", "for_admins"],
filters={"course_id": course_id}
)
diff --git a/backend/project/models/user.py b/backend/project/models/user.py
index 7bc9ed30..4f03874e 100644
--- a/backend/project/models/user.py
+++ b/backend/project/models/user.py
@@ -30,6 +30,6 @@ def to_dict(self):
"""
return {
'uid': self.uid,
- 'role': self.role.name, # Convert the enum to a string
- 'display_name': self.display_name
+ 'display_name': self.display_name,
+ 'role': self.role.name # Convert the enum to a string
}
diff --git a/backend/project/utils/models/user_utils.py b/backend/project/utils/models/user_utils.py
index 37cd263c..21568c6d 100644
--- a/backend/project/utils/models/user_utils.py
+++ b/backend/project/utils/models/user_utils.py
@@ -21,7 +21,7 @@ def get_user(user_id):
db.session.rollback()
abort(make_response(({"message": "An error occurred while fetching the user"}
, 500)))
- if not user:
+ if user is None:
abort(make_response(({"message":f"User with id: {user_id} not found"}, 404)))
return user
diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py
index 96e25045..94ace471 100644
--- a/backend/project/utils/query_agent.py
+++ b/backend/project/utils/query_agent.py
@@ -193,7 +193,7 @@ def query_by_id_from_model(model: DeclarativeMeta,
if not result:
return {"message": "Resource not found", "url": base_url}, 404
return {
- "data": result,
+ "data": result.to_dict() if hasattr(result, "to_dict") else result,
"message": "Resource fetched correctly",
"url": urljoin(f"{base_url}/", str(column_id))}, 200
except SQLAlchemyError:
diff --git a/frontend/.gitignore b/frontend/.gitignore
index a547bf36..d7de12f3 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -12,6 +12,8 @@ dist
dist-ssr
*.local
+.env
+
# Editor directories and files
.vscode/*
!.vscode/extensions.json
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 1674097e..8fc1e44f 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,16 +8,17 @@
"name": "ugent-3",
"version": "0.0.0",
"dependencies": {
- "@emotion/react": "^11.11.3",
- "@emotion/styled": "^11.11.0",
- "@mui/icons-material": "^5.15.10",
+ "@emotion/react": "^11.11.4",
+ "@emotion/styled": "^11.11.5",
+ "@mui/icons-material": "^5.15.15",
"@mui/lab": "^5.0.0-alpha.170",
- "@mui/material": "^5.15.10",
+ "@mui/material": "^5.15.15",
"@mui/styled-engine-sc": "^6.0.0-alpha.16",
"@mui/x-data-grid": "^7.1.1",
"@mui/x-date-pickers": "^7.1.1",
"axios": "^1.6.8",
"dayjs": "^1.11.10",
+ "debounce": "^2.0.0",
"downloadjs": "^1.4.7",
"i18next-browser-languagedetector": "^7.2.0",
"i18next-http-backend": "^2.5.0",
@@ -31,8 +32,8 @@
"styled-components": "^6.1.8"
},
"devDependencies": {
- "@types/history": "^4.7.11",
"@types/downloadjs": "^1.4.6",
+ "@types/history": "^4.7.11",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@types/react-router-dom": "^5.3.3",
@@ -3478,6 +3479,17 @@
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
"integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
},
+ "node_modules/debounce": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.0.0.tgz",
+ "integrity": "sha512-xRetU6gL1VJbs85Mc4FoEGSjQxzpdxRyFhe3lmWFyy2EzydIcD4xzUvRJMD+NPDfMwKNhxa3PvsIOU32luIWeA==",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index f866c3cf..b1903b29 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,16 +12,17 @@
"test": "npm run cypress:test"
},
"dependencies": {
- "@emotion/react": "^11.11.3",
- "@emotion/styled": "^11.11.0",
- "@mui/icons-material": "^5.15.10",
+ "@emotion/react": "^11.11.4",
+ "@emotion/styled": "^11.11.5",
+ "@mui/icons-material": "^5.15.15",
+ "@mui/material": "^5.15.15",
"@mui/lab": "^5.0.0-alpha.170",
- "@mui/material": "^5.15.10",
"@mui/styled-engine-sc": "^6.0.0-alpha.16",
"@mui/x-data-grid": "^7.1.1",
"@mui/x-date-pickers": "^7.1.1",
"axios": "^1.6.8",
"dayjs": "^1.11.10",
+ "debounce": "^2.0.0",
"downloadjs": "^1.4.7",
"i18next-browser-languagedetector": "^7.2.0",
"i18next-http-backend": "^2.5.0",
diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json
index 1a438508..212bd4cc 100644
--- a/frontend/public/locales/en/translation.json
+++ b/frontend/public/locales/en/translation.json
@@ -16,6 +16,43 @@
"welcomeDescription": "Welcome to PeristerĂ³nas, the online submission platform of UGent",
"login": "Login"
},
+ "courseDetailTeacher": {
+ "title": "Course Details",
+ "deleteCourse": "Delete Course",
+ "unauthorizedDelete": "You are unauthorized to delete this course",
+ "noCoursesFound": "No courses found",
+ "noProjects": "No projects",
+ "noStudents": "No students in this course",
+ "joinCodes": "Join Codes",
+ "forAdmins": "For Admins",
+ "forStudents": "For Students",
+ "noExpiryDate": "No expiry date",
+ "expiryDate": "Expiry Date",
+ "newJoinCode": "New Join Code",
+ "deleteSelected": "Delete Selected Students",
+ "projects": "Projects",
+ "newProject": "New Project",
+ "assistantList": "List of co-teachers/assistants",
+ "newTeacher": "New teacher",
+ "studentList": "List of students",
+ "newStudent": "New student(s)",
+ "deadline": "Deadline",
+ "teacher": "Teacher",
+ "view": "View",
+ "admins": "Admins",
+ "students": "Students"
+ },
+ "allCoursesTeacher": {
+ "title": "All Courses",
+ "courseForm": "Course Form",
+ "courseName": "Course Name",
+ "submit": "Submit",
+ "emptyCourseNameError": "Course name should not be empty",
+ "cancel": "Cancel",
+ "create": "Create",
+ "activeCourses": "Active Courses",
+ "archivedCourses":"Archived Courses"
+ },
"courseForm": {
"courseName": "Course Name",
"submit": "Submit",
@@ -42,7 +79,12 @@
"daysAgo": "days ago",
"hoursAgo": "hours ago",
"minutesAgo": "minutes ago",
- "justNow": "just now"
+ "justNow": "just now",
+ "yearsLater": "years later",
+ "monthsLater": "months later",
+ "daysLater": "days later",
+ "hoursLater": "hours later",
+ "minutesLater": "minutes later"
},
"error": {
"pageNotFound": "Page Not Found",
diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json
index cbccc0dc..7addc7e6 100644
--- a/frontend/public/locales/nl/translation.json
+++ b/frontend/public/locales/nl/translation.json
@@ -15,6 +15,43 @@
"welcomeDescription": "Welkom bij PeristerĂ³nas, het online indieningsplatform van UGent",
"login": "Aanmelden"
},
+ "courseDetailTeacher": {
+ "title": "Vak Details",
+ "noCoursesFound": "Geen vakken gevonden",
+ "unauthorizedDelete": "U heeft niet de juiste rechten om dit vak te verwijderen",
+ "noProjects": "Geen projecten",
+ "noStudents": "Geen studenten voor dit vak",
+ "deleteCourse": "Verwijder vak",
+ "joinCodes": "Join Codes",
+ "forAdmins": "Voor Admins",
+ "forStudents": "Voor Studenten",
+ "noExpiryDate": "Geen vervaldatum",
+ "expiryDate": "Vervaldatum",
+ "newJoinCode": "Nieuwe Join Code",
+ "deleteSelected": "Verwijder geselecteerde studenten",
+ "projects": "Projecten",
+ "newProject": "Nieuw Project",
+ "assistantList": "Lijst co-leerkrachten/assistenten",
+ "newTeacher": "Nieuwe leerkracht",
+ "studentList": "Lijst studenten",
+ "newStudent": "Nieuwe student(en)",
+ "deadline": "Deadline",
+ "teacher": "Leerkracht",
+ "view": "Bekijk",
+ "admins": "Admins",
+ "students": "Studenten"
+ },
+ "allCoursesTeacher": {
+ "title": "Alle Vakken",
+ "courseForm": "Vak Form",
+ "courseName": "Vak Naam",
+ "submit": "Opslaan",
+ "emptyCourseNameError": "Vak naam mag niet leeg zijn",
+ "cancel": "Annuleer",
+ "create": "Nieuw Vak",
+ "activeCourses": "Actieve Vakken",
+ "archivedCourses":"Gearchiveerde Vakken"
+ },
"courseForm": {
"courseName": "Vak Naam",
"submit": "Opslaan",
@@ -74,7 +111,12 @@
"daysAgo": "dagen geleden",
"hoursAgo": "uur geleden",
"minutesAgo": "minuten geleden",
- "justNow": "Zonet"
+ "justNow": "Zonet",
+ "yearsLater": "jaren later",
+ "monthsLater": "maanden later",
+ "daysLater": "dagen later",
+ "hoursLater": "uur later",
+ "minutesLater": "minuten later"
},
"projectsOverview": {
"past_deadline": "Verlopen Projecten",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8f4f2e28..efe17395 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,5 +1,8 @@
import { Route, RouterProvider, createBrowserRouter, createRoutesFromElements } from "react-router-dom";
import Layout from "./components/Header/Layout";
+import { AllCoursesTeacher } from "./components/Courses/AllCoursesTeacher";
+import { CourseDetailTeacher } from "./components/Courses/CourseDetailTeacher";
+import { dataLoaderCourseDetail, dataLoaderCourses } from "./components/Courses/CourseUtils";
import LanguagePath from "./components/LanguagePath";
import ProjectView from "./pages/project/projectView/ProjectView";
import { ErrorBoundary } from "./pages/error/ErrorBoundary.tsx";
@@ -20,6 +23,10 @@ const router = createBrowserRouter(
}>
+
+ } loader={dataLoaderCourses}/>
+ } loader={dataLoaderCourseDetail} />
+
} loader={fetchProjectPage}/>
} />
diff --git a/frontend/src/components/Courses/AllCoursesTeacher.tsx b/frontend/src/components/Courses/AllCoursesTeacher.tsx
new file mode 100644
index 00000000..facfa9c2
--- /dev/null
+++ b/frontend/src/components/Courses/AllCoursesTeacher.tsx
@@ -0,0 +1,79 @@
+import { Button, Dialog, DialogActions, DialogTitle, FormControl, FormHelperText, Grid, Input, InputLabel } from "@mui/material";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+import { SideScrollableCourses } from "./CourseUtilComponents";
+import { Course, callToApiToCreateCourse } from "./CourseUtils";
+import { Title } from "../Header/Title";
+import { useLoaderData } from "react-router-dom";
+
+/**
+ * @returns A jsx component representing all courses for a teacher
+ */
+export function AllCoursesTeacher(): JSX.Element {
+ const [open, setOpen] = useState(false);
+ const courses = (useLoaderData() as Course[]);
+
+ const [courseName, setCourseName] = useState('');
+ const [error, setError] = useState('');
+
+ const navigate = useNavigate();
+
+ const { t } = useTranslation('translation', { keyPrefix: 'allCoursesTeacher' });
+
+ const handleClickOpen = () => {
+ setOpen(true);
+ };
+
+ const handleClose = () => {
+ setOpen(false);
+ };
+
+ const handleInputChange = (event: React.ChangeEvent) => {
+ setCourseName(event.target.value);
+ setError(''); // Clearing error message when user starts typing
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault(); // Prevents the default form submission behaviour
+
+ if (!courseName.trim()) {
+ setError(t('emptyCourseNameError'));
+ return;
+ }
+
+ const data = { name: courseName };
+ callToApiToCreateCourse(JSON.stringify(data), navigate);
+ };
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/Courses/CourseDetailTeacher.tsx b/frontend/src/components/Courses/CourseDetailTeacher.tsx
new file mode 100644
index 00000000..e1cf0ff3
--- /dev/null
+++ b/frontend/src/components/Courses/CourseDetailTeacher.tsx
@@ -0,0 +1,411 @@
+import { Box, Button, Card, CardActions, CardContent, CardHeader, Checkbox, FormControlLabel, Grid, IconButton, Input, Menu, MenuItem, Paper, Typography } from "@mui/material";
+import { ChangeEvent, useCallback, useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Course, Project, apiHost, getIdFromLink, getNearestFutureDate, getUserName, appHost } from "./CourseUtils";
+import { Link, useNavigate, NavigateFunction, useLoaderData } from "react-router-dom";
+import { Title } from "../Header/Title";
+import ClearIcon from '@mui/icons-material/Clear';
+import { timeDifference } from "../../utils/date-utils";
+
+interface UserUid{
+ uid: string
+}
+
+/**
+ * Handles the deletion of an admin.
+ * @param navigate - The navigate function from react-router-dom.
+ * @param courseId - The ID of the course.
+ * @param uid - The UID of the admin.
+ */
+function handleDeleteAdmin(navigate: NavigateFunction, courseId: string, uid: string): void {
+ fetch(`${apiHost}/courses/${courseId}/admins`, {
+ method: 'DELETE',
+ credentials: 'include',
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ "admin_uid": uid
+ })
+ })
+ .then(() => {
+ navigate(0);
+ });
+}
+
+/**
+ * Handles the deletion of a student.
+ * @param navigate - The navigate function from react-router-dom.
+ * @param courseId - The ID of the course.
+ * @param uid - The UID of the admin.
+ */
+function handleDeleteStudent(navigate: NavigateFunction, courseId: string, uids: string[]): void {
+ fetch(`${apiHost}/courses/${courseId}/students`, {
+ method: 'DELETE',
+ credentials: 'include',
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ "students": uids
+ })
+ })
+ .then(() => {
+ navigate(0);
+ });
+}
+
+/**
+ * Handles the deletion of a course.
+ * @param navigate - The navigate function from react-router-dom.
+ * @param courseId - The ID of the course.
+ */
+function handleDeleteCourse(navigate: NavigateFunction, courseId: string): void {
+ fetch(`${apiHost}/courses/${courseId}`, {
+ method: 'DELETE',
+ credentials: 'include',
+ }).then((response) => {
+ if(response.ok){
+ navigate(-1);
+ }
+ else if(response.status === 404){
+ navigate(-1);
+ }
+ });
+}
+
+/**
+ *
+ * @returns A jsx component representing the course detail page for a teacher
+ */
+export function CourseDetailTeacher(): JSX.Element {
+ const [selectedStudents, setSelectedStudents] = useState([]);
+ const [anchorEl, setAnchorElStudent] = useState(null);
+ const openCodes = Boolean(anchorEl);
+ const handleClickCodes = (event: React.MouseEvent) => {
+ setAnchorElStudent(event.currentTarget);
+ };
+ const handleCloseCodes = () => {
+ setAnchorElStudent(null);
+ };
+
+ const courseDetail = useLoaderData() as { //TODO CATCH ERROR
+ course: Course ,
+ projects:Project[] ,
+ admins: UserUid[],
+ students: UserUid[]
+ };
+ const { course, projects, admins, students } = courseDetail;
+ const { t } = useTranslation('translation', { keyPrefix: 'courseDetailTeacher' });
+ const { i18n } = useTranslation();
+ const lang = i18n.language;
+ const navigate = useNavigate();
+
+ const handleCheckboxChange = (event: ChangeEvent, uid: string) => {
+ if (event.target.checked) {
+ setSelectedStudents((prevSelected) => [...prevSelected, uid]);
+ } else {
+ setSelectedStudents((prevSelected) =>
+ prevSelected.filter((student) => student !== uid)
+ );
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+ {t('projects')}:
+
+
+
+
+
+
+
+
+ {t('admins')}:
+
+ {admins.map((admin) => (
+
+
+ {getUserName(admin.uid)}
+
+
+
+ ))}
+
+
+
+
+ {t('students')}:
+
+
+
+ handleDeleteStudent(navigate, course.course_id, selectedStudents)}>
+
+ {t('deleteSelected')}
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+/**
+ * @param projects - The array of projects.
+ * @returns Either a place holder for no projects or a grid of cards describing the projects.
+ */
+function EmptyOrNotProjects({projects}: {projects: Project[]}): JSX.Element {
+ const { t } = useTranslation('translation', { keyPrefix: 'courseDetailTeacher' });
+ if(projects === undefined || projects.length === 0){
+ return (
+ {t('noProjects')}
+ );
+ }
+ else{
+ return (
+
+ {projects?.map((project) => (
+
+
+
+
+
+
+ {getNearestFutureDate(project.deadlines) &&
+ (
+
+ {`${t('deadline')}: ${getNearestFutureDate(project.deadlines)?.toLocaleDateString()}`}
+
+ )}
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+ }
+}
+
+/**
+ * @param navigate - The navigate function from react-router-dom.
+ * @param course - The course against which we will check if the uid is of the teacher.
+ * @param admin - The admin in question.
+ * @returns Either nothing, if the admin uid is of teacher or a delete button.
+ */
+function EitherDeleteIconOrNothing({admin, course, navigate} : {admin:UserUid, course:Course, navigate: NavigateFunction}) : JSX.Element{
+ if(course.teacher === getIdFromLink(admin.uid)){
+ return <>>;
+ }
+ else{
+ return (
+
+ handleDeleteAdmin(navigate,course.course_id,getIdFromLink(admin.uid))}>
+
+
+
+ );
+ }
+}
+
+/**
+ * @param students - The array of students.
+ * @param selectedStudents - The array of selected students.
+ * @param handleCheckboxChange - The function to handle the checkbox change.
+ * @returns Either a place holder for no students or a grid of checkboxes for the students.
+ */
+function EmptyOrNotStudents({students, selectedStudents, handleCheckboxChange}: {students: UserUid[], selectedStudents: string[], handleCheckboxChange: (event: React.ChangeEvent, studentId: string) => void}): JSX.Element {
+ if(students.length === 0){
+ return (
+ No students found
+ );
+ }
+ else{
+ return (
+
+ {students.map((student) => (
+
+
+ handleCheckboxChange(event, getIdFromLink(student.uid))}
+ />
+
+
+ {getUserName(student.uid)}
+
+
+ ))}
+
+ );
+ }
+}
+
+interface JoinCode{
+ join_code: string,
+ expiry_time: string,
+ for_admins: boolean
+}
+
+/**
+ * Renders the JoinCodeMenu component.
+ * @param open - Whether the dialog is open or not.
+ * @param handleClose - Function to handle the dialog close event.
+ * @param handleNewCode - Function to handle the creation of a new join code.
+ * @param handleDeleteCode - Function to handle the deletion of a join code.
+ * @param getCodes - Function to get the list of join codes.
+ * @returns The rendered JoinCodeDialog component.
+ */
+function JoinCodeMenu({courseId,open,handleClose, anchorEl}: {courseId:string, open: boolean, handleClose : () => void, anchorEl: HTMLElement | null}) {
+ const { t } = useTranslation('translation', { keyPrefix: 'courseDetailTeacher' });
+
+ const [codes, setCodes] = useState([]);
+ const [expiry_time, setExpiryTime] = useState(null);
+ const [for_admins, setForAdmins] = useState(false);
+
+ const handleInputChange = (event: React.ChangeEvent) => {
+ setExpiryTime(new Date(event.target.value));
+ };
+
+ const handleCopyToClipboard = (join_code: string) => {
+ navigator.clipboard.writeText(`${appHost}/join-course?code=${join_code}`)
+ };
+
+ const getCodes = useCallback(() => {
+ fetch(`${apiHost}/courses/${courseId}/join_codes`, {
+ method: 'GET',
+ credentials: 'include',
+ })
+ .then(response => response.json())
+ .then(data => {
+ const filteredData = data.data.filter((code: JoinCode) => {
+ // Filter out expired codes
+ let expired = false;
+ if(code.expiry_time !== null){
+ const expiryTime = new Date(code.expiry_time);
+ const now = new Date();
+ expired = expiryTime < now;
+ }
+
+ return !expired;
+ });
+ setCodes(filteredData);
+ })
+ }, [courseId]);
+
+ const handleNewCode = () => {
+
+ const bodyContent: { for_admins: boolean, expiry_time?: string } = { "for_admins": for_admins };
+ if (expiry_time !== null) {
+ bodyContent.expiry_time = expiry_time.toISOString();
+ }
+
+ fetch(`${apiHost}/courses/${courseId}/join_codes`, {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(bodyContent)
+ })
+ .then(() => getCodes())
+ }
+
+ const handleDeleteCode = (joinCode: string) => {
+ fetch(`${apiHost}/courses/${courseId}/join_codes/${joinCode}`,
+ {
+ method: 'DELETE',
+ credentials: 'include',
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ "join_code": joinCode
+ })
+ })
+ .then(() => getCodes());
+ }
+
+ useEffect(() => {
+ getCodes();
+ }, [t, getCodes ]);
+
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/Courses/CourseUtilComponents.tsx b/frontend/src/components/Courses/CourseUtilComponents.tsx
new file mode 100644
index 00000000..6a9e4804
--- /dev/null
+++ b/frontend/src/components/Courses/CourseUtilComponents.tsx
@@ -0,0 +1,220 @@
+import { Box, Button, Card, CardActions, CardContent, CardHeader, Grid, Paper, TextField, Typography } from "@mui/material";
+import { Course, Project, apiHost, getIdFromLink, getNearestFutureDate } from "./CourseUtils";
+import { Link, useNavigate, useLocation } from "react-router-dom";
+import { useState, useEffect, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import debounce from 'debounce';
+
+/**
+ * @param text - The text to be displayed
+ * @returns Typography that overflow into ... when text is too long
+ */
+export function EpsilonTypography({text} : {text: string}): JSX.Element {
+ return (
+ {text}
+ );
+}
+
+/**
+ * @param label - The label of the search box
+ * @param searchTerm - The current search term
+ * @param handleSearchChange - The function to handle search term changes
+ * @returns a Grid item containing a TextField, used for searching/filtering
+ */
+export function SearchBox({label,searchTerm,handleSearchChange}: {label: string, searchTerm: string, handleSearchChange: (event: React.ChangeEvent) => void}): JSX.Element {
+ return (
+
+
+
+
+
+ );
+}
+
+/**
+ * We should reuse this in the student course view since it will be mainly the same except the create button.
+ * @param props - The component props requiring the courses that will be displayed in the scroller.
+ * @returns A component to display courses in horizontal scroller where each course is a card containing its name.
+ */
+export function SideScrollableCourses({courses}: {courses: Course[]}): JSX.Element {
+ //const navigate = useNavigate();
+ const location = useLocation();
+ const navigate = useNavigate();
+
+ // Get initial state from URL
+ const urlParams = useMemo(() => new URLSearchParams(location.search), [location.search]); //useMemo so only recompute when location.search changes
+ const initialSearchTerm = urlParams.get('name') || '';
+ const initialUforaIdFilter = urlParams.get('ufora_id') || '';
+ const initialTeacherNameFilter = urlParams.get('teacher') || '';
+
+ const [searchTerm, setSearchTerm] = useState(initialSearchTerm);
+ const [uforaIdFilter, setUforaIdFilter] = useState(initialUforaIdFilter);
+ const [teacherNameFilter, setTeacherNameFilter] = useState(initialTeacherNameFilter);
+ const [projects, setProjects] = useState<{ [courseId: string]: Project[] }>({});
+
+ const debouncedHandleSearchChange = useMemo(() =>
+ debounce((key: string, value: string) => {
+ if (value === '') {
+ urlParams.delete(key);
+ } else {
+ urlParams.set(key, value);
+ }
+ const newUrl = `${location.pathname}?${urlParams.toString()}`;
+ navigate(newUrl, { replace: true });
+ }, 500), [urlParams, navigate, location.pathname]);
+
+ useEffect(() => {
+ debouncedHandleSearchChange('name', searchTerm);
+ }, [searchTerm, debouncedHandleSearchChange]);
+
+ useEffect(() => {
+ debouncedHandleSearchChange('ufora_id', uforaIdFilter);
+ }, [uforaIdFilter, debouncedHandleSearchChange]);
+
+ useEffect(() => {
+ debouncedHandleSearchChange('teacher', teacherNameFilter);
+ }, [teacherNameFilter, debouncedHandleSearchChange]);
+
+ const handleSearchChange = (event: React.ChangeEvent) => {
+ const newSearchTerm = event.target.value;
+ setSearchTerm(newSearchTerm);
+ };
+
+ const handleUforaIdFilterChange = (event: React.ChangeEvent) => {
+ const newUforaIdFilter = event.target.value;
+ setUforaIdFilter(newUforaIdFilter);
+ };
+
+ const handleTeacherNameFilterChange = (event: React.ChangeEvent) => {
+ const newTeacherNameFilter = event.target.value;
+ setTeacherNameFilter(newTeacherNameFilter);
+ };
+
+ useEffect(() => {
+ // Fetch projects for each course
+ const fetchProjects = async () => {
+ const projectPromises = courses.map(course =>
+ fetch(`${apiHost}/projects?course_id=${getIdFromLink(course.course_id)}`,
+ { credentials: 'include' }
+ )
+ .then(response => response.json())
+ );
+
+ const projectResults = await Promise.all(projectPromises);
+ const projectsMap: { [courseId: string]: Project[] } = {};
+
+ projectResults.forEach((result, index) => {
+ projectsMap[getIdFromLink(courses[index].course_id)] = result.data;
+ });
+
+ setProjects(projectsMap);
+ };
+
+ fetchProjects();
+ }, [courses]);
+
+ const filteredCourses = courses.filter(course =>
+ course.name.toLowerCase().includes(searchTerm.toLowerCase()) &&
+ (course.ufora_id ? course.ufora_id.toLowerCase().includes(uforaIdFilter.toLowerCase()) : !uforaIdFilter) &&
+ course.teacher.toLowerCase().includes(teacherNameFilter.toLowerCase())
+ );
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Empty or not.
+ * @returns either a place holder or the actual content.
+ */
+function EmptyOrNotFilteredCourses({filteredCourses, projects}: {filteredCourses: Course[], projects: { [courseId: string]: Project[] }}): JSX.Element {
+ const { t } = useTranslation('translation', { keyPrefix: 'courseDetailTeacher' });
+ if(filteredCourses.length === 0){
+ return (
+ {t('noCoursesFound')}
+ );
+ }
+
+ return (
+
+
+ {filteredCourses.map((course, index) => (
+
+
+ }/>
+
+ {course.ufora_id && (
+ <>
+ Ufora_id: {course.ufora_id}
+ >
+ )}
+ Teacher: {course.teacher}
+ >
+ }/>
+
+ {t('projects')}:
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
+/**
+ * @param projects - The projects to be displayed if not empty
+ * @returns either a place holder with text for no projects or the projects
+ */
+function EmptyOrNotProjects({projects, noProjectsText}: {projects: Project[], noProjectsText:string}): JSX.Element {
+ if(projects === undefined || projects.length === 0){
+ return (
+ {noProjectsText}
+ );
+ }
+ else{
+ const now = new Date();
+ return (
+ <>
+ {projects.slice(0, 3).map((project) => {
+ let timeLeft = '';
+ if (project.deadlines != undefined) {
+ const deadlineDate = getNearestFutureDate(project.deadlines);
+ if(deadlineDate == null){
+ return <>>
+ }
+ const diffTime = Math.abs(deadlineDate.getTime() - now.getTime());
+ const diffHours = Math.ceil(diffTime / (1000 * 60 * 60));
+ const diffDays = Math.ceil(diffHours * 24);
+
+ timeLeft = diffDays > 1 ? `${diffDays} days` : `${diffHours} hours`;
+ }
+ return (
+
+
+
+
+
+ );
+ })}
+ >
+ );
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/components/Courses/CourseUtils.tsx b/frontend/src/components/Courses/CourseUtils.tsx
new file mode 100644
index 00000000..51a462a2
--- /dev/null
+++ b/frontend/src/components/Courses/CourseUtils.tsx
@@ -0,0 +1,146 @@
+import { NavigateFunction, Params } from 'react-router-dom';
+
+export interface Course{
+ course_id: string,
+ name: string,
+ teacher:string,
+ ufora_id:string,
+ url:string
+}
+
+export interface Project{
+ title: string,
+ project_id: string,
+ deadlines: string[][]
+}
+
+export const apiHost = import.meta.env.VITE_APP_API_HOST;
+export const appHost = import.meta.env.VITE_APP_HOST;
+/**
+ * @returns The uid of the acces token of the logged in user
+ */
+export function loggedInToken(){
+ return "teacher1";
+}
+
+/**
+ * Get the username based on the provided uid.
+ * @param uid - The uid of the user.
+ * @returns The username.
+ */
+export function getUserName(uid: string): string {
+ return getIdFromLink(uid);
+}
+
+/**
+ * @returns The Uid of the logged in user
+ */
+export function loggedInUid(){
+ return "Gunnar";
+}
+
+/**
+ * On a succesfull post the function will redirect to the data.url of the response, this should point to the detail page
+ * @param data - course data to send to the api
+ * @param navigate - function that allows the app to redirect
+ */
+export function callToApiToCreateCourse(data: string, navigate: NavigateFunction){
+ fetch(`${apiHost}/courses`, {
+ credentials: 'include', // include, *same-origin, omit
+ headers: {
+ "Content-Type": "application/json"
+ },
+ method: 'POST',
+ body: data,
+ })
+ .then(response => response.json())
+ .then(data => {
+ //But first also make sure that teacher is in the course admins list
+ fetch(`${apiHost}/courses/${getIdFromLink(data.url)}/admins`, {
+ credentials: 'include',
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({admin_uid: loggedInUid()})
+ });
+ navigate(getIdFromLink(data.url)); // navigate to data.url
+ })
+}
+
+/**
+ * @param link - the link to the api endpoint
+ * @returns the Id at the end of the link
+ */
+export function getIdFromLink(link: string): string {
+ const parts = link.split('/');
+ return parts[parts.length - 1];
+}
+
+/**
+ * Function to find the nearest future date from a list of dates
+ * @param dates - Array of dates
+ * @returns The nearest future date
+ */
+export function getNearestFutureDate(dates: string[][]): Date | null {
+ const now = new Date();
+ const futureDates = dates.map(date => new Date(date[1])).filter(date => date > now);
+ if (futureDates.length === 0) return null;
+ return futureDates.reduce((nearest, current) => current < nearest ? current : nearest);
+}
+
+/**
+ * Load courses for courses teacher page, this filters courses on logged in teacher uid
+ * @returns A Promise that resolves to the loaded courses data.
+ */
+
+const fetchData = async (url: string, params?: URLSearchParams) => {
+ let uri = `${apiHost}/${url}`;
+ if(params){
+ uri += `?${params}`
+ }
+ const res = await fetch(uri, {
+ credentials: 'include'
+ });
+ if(res.status !== 200){
+ throw new Response("Failed to fetch data", {status: res.status});
+ }
+ const jsonResult = await res.json();
+
+ return jsonResult.data;
+};
+
+export const dataLoaderCourses = async () => {
+ //const params = new URLSearchParams({ 'teacher': loggedInUid() });
+ return fetchData(`courses`);
+};
+
+const dataLoaderCourse = async (courseId: string) => {
+ return fetchData(`courses/${courseId}`);
+};
+
+const dataLoaderProjects = async (courseId: string) => {
+ const params = new URLSearchParams({ course_id: courseId });
+ return fetchData(`projects`, params);
+};
+
+const dataLoaderAdmins = async (courseId: string) => {
+ return fetchData(`courses/${courseId}/admins`);
+};
+
+const dataLoaderStudents = async (courseId: string) => {
+ return fetchData(`courses/${courseId}/students`);
+};
+
+export const dataLoaderCourseDetail = async ({ params } : { params:Params}) => {
+ const { courseId } = params;
+ if (!courseId) {
+ throw new Error("Course ID is undefined.");
+ }
+ const course = await dataLoaderCourse(courseId);
+ const projects = await dataLoaderProjects(courseId);
+ const admins = await dataLoaderAdmins(courseId);
+ const students = await dataLoaderStudents(courseId);
+
+ return { course, projects, admins, students };
+};
\ No newline at end of file
diff --git a/frontend/src/components/ProjectForm/ProjectForm.tsx b/frontend/src/components/ProjectForm/ProjectForm.tsx
index 3b23815a..e47f5354 100644
--- a/frontend/src/components/ProjectForm/ProjectForm.tsx
+++ b/frontend/src/components/ProjectForm/ProjectForm.tsx
@@ -137,9 +137,7 @@ export default function ProjectForm() {
const fetchCourses = async () => {
const response = await fetch(`${apiUrl}/courses?teacher=${user}`, {
- headers: {
- "Authorization": user
- },
+ credentials: 'include'
})
const jsonData = await response.json();
if (jsonData.data) {
@@ -206,9 +204,7 @@ export default function ProjectForm() {
const response = await fetch(`${apiUrl}/projects`, {
method: "post",
- headers: {
- "Authorization": user
- },
+ credentials: 'include',
body: formData
})
diff --git a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx
index eae8b416..f0937995 100644
--- a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx
+++ b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx
@@ -5,7 +5,6 @@ import ProjectSubmissionsOverviewDatagrid from "./ProjectSubmissionOverviewDatag
import download from 'downloadjs';
import {useTranslation} from "react-i18next";
const apiUrl = import.meta.env.VITE_API_HOST
-const user = "teacher"
/**
* @returns Overview page for submissions
@@ -20,9 +19,7 @@ export default function ProjectSubmissionOverview() {
const fetchProject = async () => {
const response = await fetch(`${apiUrl}/projects/${projectId}`, {
- headers: {
- "Authorization": user
- },
+ credentials: 'include'
})
const jsonData = await response.json();
setProjectTitle(jsonData["data"].title);
@@ -31,9 +28,7 @@ export default function ProjectSubmissionOverview() {
const downloadProjectSubmissions = async () => {
await fetch(`${apiUrl}/projects/${projectId}/submissions-download`, {
- headers: {
- "Authorization": user
- },
+ credentials: 'include',
})
.then(res => {
return res.blob();
diff --git a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx
index a3d02d8f..0c5fb167 100644
--- a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx
+++ b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx
@@ -9,7 +9,6 @@ import DownloadIcon from '@mui/icons-material/Download';
import download from "downloadjs";
const apiUrl = import.meta.env.VITE_API_HOST
-const user = "teacher"
interface Submission {
grading: string;
@@ -30,9 +29,7 @@ function getRowId(row: Submission) {
const fetchSubmissionsFromUser = async (submission_id: string) => {
await fetch(`${apiUrl}/submissions/${submission_id}/download`, {
- headers: {
- "Authorization": user
- },
+ credentials: 'include',
})
.then(res => {
return res.blob();
@@ -87,9 +84,7 @@ export default function ProjectSubmissionsOverviewDatagrid() {
const fetchLastSubmissionsByUser = async () => {
const response = await fetch(`${apiUrl}/projects/${projectId}/latest-per-user`, {
- headers: {
- "Authorization": user
- },
+ credentials: 'include',
})
const jsonData = await response.json();
setSubmissions(jsonData.data);
diff --git a/frontend/src/pages/project/projectView/ProjectView.tsx b/frontend/src/pages/project/projectView/ProjectView.tsx
index 8ce28644..65053f4d 100644
--- a/frontend/src/pages/project/projectView/ProjectView.tsx
+++ b/frontend/src/pages/project/projectView/ProjectView.tsx
@@ -35,14 +35,14 @@ export default function ProjectView() {
useEffect(() => {
fetch(`${API_URL}/projects/${projectId}`, {
- headers: { Authorization: "teacher" },
+ credentials: 'include',
}).then((response) => {
if (response.ok) {
response.json().then((data) => {
const projectData = data["data"];
setProjectData(projectData);
fetch(`${API_URL}/courses/${projectData.course_id}`, {
- headers: { Authorization: "teacher" },
+ credentials: 'include',
}).then((response) => {
if (response.ok) {
response.json().then((data) => {
@@ -55,7 +55,7 @@ export default function ProjectView() {
});
fetch(`${API_URL}/projects/${projectId}/assignment`, {
- headers: { Authorization: "teacher" },
+ credentials: 'include',
}).then((response) => {
if (response.ok) {
response.text().then((data) => setAssignmentRawText(data));
diff --git a/frontend/src/pages/project/projectView/SubmissionCard.tsx b/frontend/src/pages/project/projectView/SubmissionCard.tsx
index c223aa27..84016e54 100644
--- a/frontend/src/pages/project/projectView/SubmissionCard.tsx
+++ b/frontend/src/pages/project/projectView/SubmissionCard.tsx
@@ -48,7 +48,7 @@ export default function SubmissionCard({
useEffect(() => {
fetch(`${submissionUrl}?project_id=${projectId}`, {
- headers: { Authorization: "teacher" }
+ credentials: 'include'
}).then((response) => {
if (response.ok) {
response.json().then((data) => {
diff --git a/frontend/src/pages/project/projectView/SubmissionsGrid.tsx b/frontend/src/pages/project/projectView/SubmissionsGrid.tsx
index 46a56f73..93bf14ed 100644
--- a/frontend/src/pages/project/projectView/SubmissionsGrid.tsx
+++ b/frontend/src/pages/project/projectView/SubmissionsGrid.tsx
@@ -42,7 +42,7 @@ export default function SubmissionsGrid({
headerName: t("submitTime"),
type: "string",
flex: 1,
- valueFormatter: (value) => timeDifference(value),
+ valueFormatter: (value) => timeDifference(value, true),
},
{
field: "submission_status",
diff --git a/frontend/src/utils/date-utils.ts b/frontend/src/utils/date-utils.ts
index 59fe2db9..e1330607 100644
--- a/frontend/src/utils/date-utils.ts
+++ b/frontend/src/utils/date-utils.ts
@@ -5,23 +5,37 @@ import i18next from "i18next";
* @param date - date string to be converted to time difference
* @returns - time difference between the current date and the given date
*/
-export function timeDifference(date: string) {
+export function timeDifference(date: string, past: boolean = false) {
const t = (key: string) => {
return i18next.t(`time.${key}`);
};
const current = new Date();
const previous = new Date(date);
- const diff = current.getTime() - previous.getTime();
+ let diff = 0;
+ if (past) {
+ diff = current.getTime() - previous.getTime();
+ } else {
+ diff = previous.getTime() - current.getTime();
+ }
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const months = Math.floor(days / 30);
const years = Math.floor(months / 12);
- if (years > 0) return `${years} ${t("yearsAgo")}`;
- if (months > 0) return `${months} ${t("monthsAgo")}`;
- if (days > 0) return `${days} ${t("daysAgo")}`;
- if (hours > 0) return `${hours} ${t("hoursAgo")}`;
- if (minutes > 0) return `${minutes} ${t("minutesAgo")}`;
+ if (past) {
+ if (years > 0) return `${years} ${t("yearsAgo")}`;
+ if (months > 0) return `${months} ${t("monthsAgo")}`;
+ if (days > 0) return `${days} ${t("daysAgo")}`;
+ if (hours > 0) return `${hours} ${t("hoursAgo")}`;
+ if (minutes > 0) return `${minutes} ${t("minutesAgo")}`;
+ }
+ else{
+ if (years > 0) return `${years} ${t("yearsLater")}`;
+ if (months > 0) return `${months} ${t("monthsLater")}`;
+ if (days > 0) return `${days} ${t("daysLater")}`;
+ if (hours > 0) return `${hours} ${t("hoursLater")}`;
+ if (minutes > 0) return `${minutes} ${t("minutesLater")}`;
+ }
return t("justNow");
}